Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 16 из 25 ~20 мин +50 XP

Хуки жизненного цикла — BeforeCreate, AfterUpdate, валидация и SkipHooks в GORM

Пятнадцатый урок показал, где у записи начинается и заканчивается транзакция; теперь в этот конвейер можно встать самому. gorm хуки — это методы модели, которые GORM вызывает до и после создания, обновления, удаления и чтения, и их сигнатура говорит сама за себя: func(tx *gorm.DB) error. Тот самый tx — транзакционный инстанс из пятнадцатого урока: всё, что вы сделаете через него, поедет в той же транзакции, что и основная операция, а возвращённая из хука ошибка отправит операцию в откат. Это делает hooks gorm не украшением, а полноценным слоем бизнес-логики: слаг собирается в BeforeCreate до INSERT, валидация ловит пустой заголовок до того, как строка уедет в базу, аудиторская метка ставится в BeforeUpdate тем же UPDATE, а AfterFind досыпает удобные поля после каждой выборки. В этом уроке — полный список хуков и их конвейеры в callbacks/callbacks.go, механика db.AddError, валидационные паттерны, SkipHooks как законный обход и порядок хуков при каскадных ассоциациях. Одна честная оговорка о формате: GORM требует рядом настоящую СУБД, а в браузерной песочнице её не поставить, поэтому runner-ов в уроке нет — вас проверяют quiz, predict, fill, order и карточки, а SQL-следы рассчитаны на локальный Go с PostgreSQL или SQLite и логгер из второго урока. Кто набирает в поиске hooks gorm, ищет ровно это: как вклиниться в жизненный цикл записи и не сломать атомарность.

Содержание урока · 14 разделов

gorm хуки: методы, которым библиотека передаёт руль#

Дока формулирует предмет точно: Hooks are functions that are called before or after creation/querying/updating/deletion — хуки это функции, вызываемые до или после создания, чтения, обновления и удаления. И второе предложение важнее первого: if any callback returns an error, GORM will stop future operations and rollback current transaction — если колбэк вернул ошибку, GORM останавливает дальнейшие операции и откатывает текущую транзакцию. Хук — это не настройка и не параметр вызова, а метод самой модели: вы объявляете func (t TodoItem) BeforeCreate(tx gorm.DB) error, и GORM сам находит его при каждой операции. Технически список сверен с исходниками: девять интерфейсов объявлены в callbacks/interfaces.go — BeforeCreateInterface, AfterCreateInterface, BeforeUpdateInterface, AfterUpdateInterface, BeforeSaveInterface, AfterSaveInterface, BeforeDeleteInterface, AfterDeleteInterface и AfterFindInterface, каждый требует ровно один метод с сигнатурой func(gorm.DB) error — так и записано на gorm.io/docs/hooks.html: The type of hook methods should be func(gorm.DB) error. Перед операцией GORM проверяет модель на соответствие интерфейсу — метод есть, значит будет вызван; нет метода — нет вызова, никаких регистраций в конфиге. Отсюда два следствия. Первое: нужен приёмник-указатель — хук правит поля структуры, и правка должна остаться в том объекте, который уедет в INSERT; мы разберём эту ловушку отдельно. Второе: хук — свойство типа, а не данных: у карты значений нет методов, поэтому валидацию в db.Create(map...) не встроить. Кто ищет по запросу hooks gorm, обычно хочет именно этого: повесить бизнес-правило на саму модель, чтобы оно срабатывало везде, где модель создают или правят.

Канон: модель задачи с тремя хуками

GO
// TodoItem из пятого урока + аудиторская метка из этого
type TodoItem struct {
    ID        uint      `gorm:"primaryKey"`
    Title     string    `gorm:"size:140;not null"`
    Done      bool      `gorm:"not null;default:false"`
    Priority  int       `gorm:"default:0;index"`
    Slug      string    `gorm:"size:64;unique"`
    Status    string    `gorm:"size:20;default:todo"`
    CreatedAt time.Time
    UpdatedAt time.Time
    Audited   bool      `gorm:"not null;default:false"` // флаг аудита
}

// BeforeCreate: правим структуру ДО сборки INSERT
func (t *TodoItem) BeforeCreate(tx *gorm.DB) error {
    if t.Slug == "" { // слаг не задан - соберём из заголовка
        t.Slug = slugify(t.Title)
    }
    return nil // nil = конвейер продолжается
}

// BeforeUpdate: дописываем колонку в тот же UPDATE
func (t *TodoItem) BeforeUpdate(tx *gorm.DB) error {
    tx.Statement.SetColumn("Audited", true) // SetColumn: "Hooks Method"
    return nil
}

// AfterFind: приводим в порядок данные после каждой выборки
func (t *TodoItem) AfterFind(tx *gorm.DB) error {
    if t.Status == "" { // строки до введения default:todo
        t.Status = "todo"
    }
    return nil
}

Три типовых жанра сразу. BeforeCreate мутирует структуру — слаг, собранный из заголовка, попадёт в колонку slug того же INSERT. BeforeUpdate добавляет значение в набор колонок операции через Statement.SetColumn — из statement.go, где в doc-комментарии подписано Hooks Method; флаг аудита уезжает в SET вместе с основными полями, без второго запроса. AfterFind чинит уже прочитанные данные — тут он живёт на правах подстановки значений, которые база не хранит.

Конвейер create: где именно стоят хуки#

Чтобы рассуждать о порядке, откроем callbacks/callbacks.go — в RegisterDefaultCallbacks конвейер создания собран из семи колбэков, и это буквально порядок вызовов: gorm:begin_transaction, gorm:before_create, gorm:save_before_associations, gorm:create, gorm:save_after_associations, gorm:after_create и gorm:commit_or_rollback_transaction. Дока рисует то же самое схемой, записанной сплошным комментарием: begin transaction BeforeSave BeforeCreate save before associations insert into database save after associations AfterCreate AfterSave commit or rollback transaction. Обратите внимание: BeforeSave стоит перед BeforeCreate, и это не случайность — Save-хуки общие для создания и обновления, частные — после них. Сам вызов хука живёт в callbacks/callmethod.go: callMethod берёт отражённое значение модели, для среза перебирает элементы по одному (батч-вставка дёргает хук на каждую запись), и передаёт в него tx := db.Session(&gorm.Session{NewDB: true}) — свежую сессию. Дока называет это Modify current operation: через tx.Statement вы можете править текущую операцию — tx.Statement.Select("Name", "Age"), tx.Statement.AddClause(clause.OnConflict{DoNothing: true}) — а текст поясняет: tx is new session mode with the NewDB option, operations based on it will run inside same transaction but without any current conditions. Переведём в термины пятнадцатого урока: tx внутри хука — это транзакционный инстанс на том же ConnPool, что и основная операция (его подменил gorm:begin_transaction), но Statement у него чистый — условия исходной цепочки не протекают. Поэтому операции из хука едут в ту же транзакцию и разделяют её судьбу: откат основной операции откатит и их.

Проверка: порядок пары Save-хуков

У модели объявлены и BeforeSave, и BeforeCreate. Выполняется db.Create(&task). Какой метод GORM вызовет первым?

Ошибка хука: db.AddError, db.Error и откат#

Пятнадцатый урок обещал связку — вот она, с точностью до строки исходников. Каждый хуковой колбэк начинается с охраны db.Error == nil: если ошибка уже есть, BeforeCreate, AfterCreate, BeforeUpdate и соседи не вызываются вовсе — первое заражение останавливает конвейер. Ошибка из хука попадает в db через AddError (gorm.go): если db.Error был пуст, он становится вашей ошибкой, а если уже был — новая приклеивается через fmt.Errorf("%v; %w"). Дальше два сценария. При одиночной операции в дело вступает последний колбэк конвейера: gorm:commit_or_rollback_transaction, разобранный в пятнадцатом уроке, видит db.Error != nil и делает Rollback — вернув ошибку из BeforeCreate, вы откатили ещё не отправленный INSERT; INSERT даже не поедет в базу, потому что gorm:create проверяет db.Error и выходит сразу. А внутри db.Transaction ошибка операции приедет в err вашего if err := tx.Create(...).Error — и вы вернёте её из fc, откатив весь блок. Дока закрепляет NOTE-ом: Save/Delete operations in GORM are running in transactions by default, so changes made in that transaction are not visible until it is committed, if you return any error in your hooks, the change will be rollbacked — изменения в транзакции не видны, пока она не закоммичена, и ошибка из хука её откатит. Симметричное следствие: AfterCreate стоит после INSERT, но до COMMIT — ошибка в нём откатывает уже выполненный INSERT. Дока даже приводит такой хук с errors.New("rollback invalid user") — строка, отправленная в базу, выжить не успевает.

Валидация в хуке: ValidationError-паттерн

GO
// Ошибка-носитель: какое поле и что с ним не так
type ValidationError struct {
    Field   string
    Problem string
}

func (e *ValidationError) Error() string {
    return "валидация не прошла: " + e.Field + " - " + e.Problem
}

// Sentinel-ошибка: ловится снаружи через errors.Is
var ErrNoSlug = errors.New("slug обязателен, а собрать не из чего")

func (t *TodoItem) BeforeCreate(tx *gorm.DB) error {
    if t.Title == "" {
        return &ValidationError{Field: "title", Problem: "пусто"}
    }
    if t.Slug == "" && slugify(t.Title) == "" {
        return ErrNoSlug
    }
    if t.Slug == "" {
        t.Slug = slugify(t.Title)
    }
    return nil
}

result := db.Create(&TodoItem{Slug: "orphan"}) // заголовка нет
// BEGIN
// ROLLBACK  - INSERT не отправлялся вовсе
fmt.Println(result.Error, errors.Is(result.Error, ErrNoSlug))

След — квинтэссенция связки с транзакциями: BEGIN, ошибка в хуке, ROLLBACK, и никакого INSERT — он не уехал, потому что конвейер остановился до gorm:create. Наружу приедет именно ваша ошибка: структура ValidationError позволяет API ответить именем поля, sentinel ErrNoSlug — обработать конкретный случай через errors.Is, не завязываясь на текст. Валидация в Before-хуке честнее проверки после записи: база не видит мусора, а у вас есть транзакционная гарантия открата.

Проверка: ошибка в AfterCreate

INSERT прошёл успешно, после чего AfterCreate вернул ошибку. Что окажется в базе при обычной одиночной операции (без SkipDefaultTransaction)?

BeforeSave против BeforeCreate: где валидация уместнее#

Пара Save-хуков существует потому, что у создания и обновления правила разные. BeforeSave вызывается в обоих конвейерах записи — в create-колбэке условие db.Statement.Schema.BeforeSave || db.Statement.Schema.BeforeCreate, в update-колбэке BeforeSave || BeforeUpdate; туда просится валидация, обязательная всегда: заголовок не пуст, приоритет в диапазоне, статус из допустимого набора. BeforeCreate — только перед INSERT: генерация уникальных значений вроде u.UUID = uuid.New() из доковского примера, сборка слага, подстановка стартовых значений. Прежде чем ставить проверку, задайте себе вопрос: при обновлении это поле может стать невалидным? Если да — BeforeSave. Доковский пример собирает обе идеи в одном методе: u.UUID = uuid.New() — генерация, if !u.IsValid() { err = errors.New("can't save invalid data") } — валидация, return — и ошибка, как мы выяснили, откатывает операцию. Симметричная пара AfterCreate/AfterSave стоит после записи: там делают дозаполнение структур, Related-операции той же транзакцией, уведомления. Валидацию туда ставить не стоит — мусор к этому моменту уже поехал в INSERT; максимум, что уместно в After, — проверка пост-условий, когда нарушение говорит не о данных, а о сбое логики. И не путайте слои: хук валидирует бизнес-правила, а последнее слово всё равно за ограничениями базы — уникальный Slug защищает UNIQUE-индекс из четвёртого урока, и дубликат вернёт ошибку драйвера, какую бы проверку вы ни написали. Хук ускоряет обратную связь, индекс гарантирует инвариант.

Что напечатает создание без слага

Прочитайте код и выберите, что он выведет:

// BeforeCreate: if t.Slug == "" { t.Slug = slugify(t.Title) }
task := TodoItem{Title: "Полить цветы на балконе"}
result := db.Create(&task)
fmt.Println(task.Slug, result.RowsAffected)

Конвейер update: BeforeUpdate, AfterUpdate и правка операции#

Обновление собирается из восьми колбэков: gorm:begin_transaction, gorm:setup_reflect_value, gorm:before_update, gorm:save_before_associations, gorm:update, gorm:save_after_associations, gorm:after_update, gorm:commit_or_rollback_transaction. gorm:setup_reflect_value — служебный: он восстанавливает отражённое значение модели после цепочек. Затем пара BeforeSave/BeforeUpdate — в этом порядке, как и у создания. Доковский пример BeforeUpdate — защитник неизменяемых записей: if u.readonly() { err = errors.New("read only user") } — попробовали отредактировать read-only пользователя, обновление даже не уехало. А AfterUpdate в доке демонстрирует операцию той же транзакцией: if u.Confirmed { tx.Model(&Address{}).Where("user_id = ?", u.ID).Update("verfied", true) } — подтверждение пользователя тянет за собой правку его адресов, и если она упадёт, AddError положит ошибку в db.Error, а финальный колбэк откатит и основной UPDATE. Отдельного разговора заслуживает правка состава самой операции из хука. Прямой путь — tx.Statement: Select перечисляет колонки, AddClause дописывает клозу. Для подмешивания значения в набор колонок есть Statement.SetColumn из statement.go: с картой-приёмником он добавляет ключ в dest, со структурой — пишет поле; doc-комментарий различает режимы: stmt.SetColumn("Name", "jinzhu") — Hooks Method, а с третьим аргументом true — Callbacks Method. Именно так BeforeUpdate из канона добавляет audited=true в тот же UPDATE: один запрос, ноль лишних кругов в базу, и флаг едет в одной транзакции с основными полями.

Флаг аудита в SET того же UPDATE

GO
// BeforeUpdate: tx.Statement.SetColumn("Audited", true)
db.Model(&task).
    Updates(map[string]interface{}{"status": "done", "priority": 3})
// UPDATE todo_items
// SET audited=true, priority=3, status='done', updated_at='...'
// WHERE id = 2

// то же через структуру: флаг добавится так же
db.Model(&task).Updates(TodoItem{Status: "done"})

След показывает ровно то, что обещал SetColumn: к двум запрошенным колонкам прицепилась audited — её дописал хук до сборки SQL. Колонки в SET идут по алфавиту — сортировка ключей карты из восьмого урока, — а updated_at дописывается после них. RowsAffected здесь отвечает за основную правку; аудит-флаг не создавал второй запрос и не создавал вторую транзакцию — всё уехало одним UPDATE.

UpdateColumn против Update: где хук, а где нет

Прочитайте код и выберите, что он выведет:

// task - задача id=2; в BeforeUpdate стоит SetColumn("Audited", true)
db.Model(&task).UpdateColumn("priority", 1)
db.Model(&task).Update("priority", 2)

var fresh TodoItem
db.First(&fresh, task.ID)
fmt.Println(fresh.Priority, fresh.Audited)

SkipHooks: законный обход хуков#

Флаг, которым UpdateColumn выключает хуки, публичен и управляется тремя способами. Точечный — Session: db.Session(&gorm.Session{SkipHooks: true}).Model(&task).Updates(...) — Statement унаследует SkipHooks, и BeforeSave/BeforeUpdate/AfterUpdate пропустятся. Глобальный — Config при gorm.Open: в gorm.go поле SkipHooks есть прямо в Config, и Open переносит его в Statement каждого соединения. А третий способ вы уже знаете — UpdateColumn и UpdateColumns из finisher_api.go, обе строки tx.Statement.SkipHooks = true мы видели в восьмом уроке, когда говорили про служебные правки без updated_at. Теперь ту механику можно объяснить честно: AutoUpdateTime-логика при обновлении стоит под тем же условием !stmt.SkipHooks в callbacks/update.go — поэтому UpdateColumn не двигает updated_at, и обычный Update в сессии с SkipHooks тоже его не тронет. Нюанс на стороне создания: в ConvertToCreateValues автополя CreatedAt/UpdatedAt живут без оглядки на SkipHooks — это AutoCreateTime, а не хук, и Create в сессии с SkipHooks всё равно проставит время. Есть и скрытый вызов: Save на записи, чья update-ветка затронула ноль строк, дофоллбечивает на Create с Session{SkipHooks: true} и Upsert-клозой UpdateAll — восстановление пропавшей строки в терминах Save хуков не дёргает. Практический вывод: хуки — не охрана от админа, а слой по умолчанию; тот, кто захочет обойти, обойдёт легально, поэтому инварианты дублируются ограничениями базы. И наоборот: если вы сами зовёте модель из хука, воспользуйтесь тем же флагом — об этом в ловушках ниже.

Проверка: как пропустить хуки один раз

Нужно выполнить одно обновление так, чтобы BeforeUpdate-валидация модели не запускалась. Какой вызов решает задачу?

Каскад ассоциаций: чьи хуки и в каком порядке#

Канон живёт связками: задача принадлежит списку, теги — m2m. Что происходит с хуками, когда сохраняется вся пачка одним Create? Доковская схема уже содержит ответ: между BeforeCreate владельца и INSERT стоят save before associations, а после INSERT — save after associations. По исходникам это два колбэка из callbacks/associations.go: SaveBeforeAssociations сохраняет belongs to-связи — список задачи, если он задан структурой с нулевым ключом, — а SaveAfterAssociations — has one, has many и many2many вместе со строками join-таблицы. Важнейшая деталь: дети сохраняются не сырым SQL, а полноценным Create через saveAssociations — tx собирается как Session{NewDB: true} с наследуемыми настройками, включая SkipHooks: db.Statement.SkipHooks. Значит, хуки детей работают: у списка, создаваемого вместе с задачей, вызовутся его BeforeSave/BeforeCreate, у тегов — свои. Флаг наследуется: если владельца сохраняли со SkipHooks, дети тоже пройдут без хуков. Порядок целиком: BEGIN; BeforeSave/BeforeCreate владельца; belongs to-дети с их хуками; INSERT владельца; has many/m2m-дети с их хуками; INSERT строк join-таблицы — здесь это отдельный Create для модели связи из тринадцатого урока; AfterCreate/AfterSave владельца; COMMIT или ROLLBACK. Заметьте асимметрию: принадлежности съезжают ДО владельца — их ключи нужны для внешних ключей, потомки — ПОСЛЕ, когда есть ключ владельца. Ошибка на любом уровне кладёт db.Error и откатывает всё одним ROLLBACK — общая судьба, ради которой авто-транзакция и обёртывает каскад.

След каскадного Create с хуками

GO
db.Create(&TodoItem{
    Title: "Разобрать входящие",
    List:  &TodoList{Name: "Работа"},  // belongs to: ключ нулевой
    Tags:  []Tag{{Name: "urgent"}, {Name: "home"}}, // m2m
})
// BEGIN
// INSERT INTO todo_lists (name, ...) VALUES ('Работа', ...) RETURNING id
//   -> BeforeSave/BeforeCreate списка, если объявлены
// INSERT INTO todo_items (title, slug, todo_list_id, ...)
//   VALUES ('Разобрать входящие', 'razobrat-vhodyaschie', 2, ...)
//   -> slug собрал BeforeCreate задачи
// INSERT INTO tags (name, ...) VALUES ('urgent', ...), ('home', ...)
//   -> хуки тегов, DoNothing-клоза против дублей
// INSERT INTO todo_tags (todo_item_id, tag_id) VALUES (6, 7), (6, 8)
//   -> строки связи: у модели из тринадцатого урока хуков нет
// COMMIT

Четыре INSERT, одна транзакция, три уровня хуков. Список уехал первым — задача не может вставиться без его ключа; слаг в строке задачи — работа BeforeCreate владельца; теги и строки связи — после владельца. Если BeforeCreate тега вернёт ошибку, ROLLBACK откатит и список, и задачу: частичного каскада не бывает. Это и есть радиус атомарности авто-транзакции, о котором пятнадцатый урок говорил на пальцах.

Шпаргалка: девять хуков жизненного цикла#

ХукКогда вызываетсяКолбэк-точкаТипичное применение
BeforeSaveперед INSERT и перед UPDATEgorm:before_create / gorm:before_updateобщая валидация полей
BeforeCreateтолько перед INSERTgorm:before_createслаг, uuid, стартовые значения
AfterCreateпосле успешного INSERT, до COMMITgorm:after_createдефолты зависимых данных, уведомления
AfterSaveпосле INSERT и после UPDATEgorm:after_create / gorm:after_updateсброс кэшей, общие пост-действия
BeforeUpdateтолько перед UPDATEgorm:before_updateзащита read-only, флаг аудита, SetColumn
AfterUpdateпосле успешного UPDATEgorm:after_updateправки соседей той же транзакцией
BeforeDeleteтолько перед DELETEgorm:before_deleteпроверка «можно ли удалять»
AfterDeleteпосле DELETEgorm:after_deleteчистка связанных данных
AfterFindпосле SELECT (дети уже загружены)gorm:after_queryвычисляемые и подстановочные поля

Заполните пропуски: хук генерации и валидации

Соберите четыре пропуска: имя частного create-хука, поле-источник слага, конструктор ошибки и нейтральный возврат.

func (t *TodoItem) (tx *gorm.DB) error { // хук ТОЛЬКО перед INSERT if t.Slug == "" { t.Slug = slugify(t.) // слаг не задан - соберём из заголовка } if len(t.Slug) < 3 { return .New("слишком короткий slug") // операция уедет в rollback } return // счастливый путь: конвейер продолжается }

Соберите конвейер db.Create с ассоциацией

Расставьте шаги от старта до финала — владелец с belongs to-связью и m2m-тегами.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

AfterFind: хук чтения#

Чтение имеет свой хук — AfterFind, и конвейер у него свой: gorm:query, затем gorm:preload из четырнадцатого урока, затем gorm:after_query. Колбэк AfterQuery в callbacks/query.go сначала подчищает клозу FROM — чтобы join-данные не мешали последующим выборкам, — а затем вызывает AfterFind, и у вызова два охранника: db.Error == nil и db.RowsAffected > 0 — на пустой выборке, когда First вернул ошибку «запись не найдена», хук не зовётся вовсе: нечего обрабатывать. Дока рисует порядок чтения так: load data from database / Preloading (eager loading) / AfterFind — жадная загрузка успевает до хука, значит в AfterFind дети уже на месте. Пример из доки подставляет значение по умолчанию: if u.MemberShip == "" { u.MemberShip = "user" } — и наш AfterFind делает ровно то же со статусом строк, переживших смену дефолта. Второй жанр — вычисляемые поля: возраст задачи, человекочитаемый приоритет, собранный из частей адрес — всё, что база не хранит, но API отдаёт. Границы жанра помните: AfterFind выполняется на каждую прочитанную запись — тяжёлая логика умножится на длину выборки; и он не запускается у сырых запросов Raw и Row из десятого урока — там сканирование идёт мимо конвейера модели, у карт нет методов. Выборки в структуры зовут его по-прежнему — схема модели та же.

Чек-лист урока#

  • хуки — методы модели с приёмником-указателем и сигнатурой func(tx *gorm.DB) error; девять интерфейсов в callbacks/interfaces.go, регистрация не нужна
  • create-конвейер: gorm:begin_transaction → BeforeSave → BeforeCreate → save before associations → INSERT → save after associations → AfterCreate → AfterSave → gorm:commit_or_rollback_transaction
  • update: setup_reflect_value → BeforeSave → BeforeUpdate → UPDATE → AfterUpdate → AfterSave; delete: BeforeDelete → gorm:delete_before_associations → DELETE (gorm:delete) → AfterDelete
  • ошибка хука через AddError попадает в db.Error: последующие хуки пропускаются, авто-транзакция откатывается — рычаг атомарности из пятнадцатого урока
  • tx в хуке — Session с NewDB: транзакция общая, Statement чистый; через tx.Statement правят операцию, SetColumn добавляет колонку
  • валидация — в BeforeSave (всегда) или BeforeCreate (только создание); паттерн: sentinel + errors.Is + структура ValidationError
  • SkipHooks: UpdateColumn/UpdateColumns, Session{SkipHooks: true}, Config{SkipHooks: true}; при обновлении не двигается и updated_at; при создании автополя остаются
  • каскад: belongs to сохраняется до владельца, has many/m2m и join-строки — после; хуки детей работают и наследуют SkipHooks; AfterFind зовётся после жадной загрузки и при RowsAffected > 0

Что дальше по курсу#

Хуки встроили модель в конвейеры, которые пятнадцатый урок научил открывать и закрывать: сигнатура func(tx *gorm.DB) error даёт вам транзакционный инстанс, а ошибка — гарантированный откат; механика AutoUpdateTime и служебных правок, теперь понятая через SkipHooks, живёт в восьмом уроке, а весь маршрут BEGIN—COMMIT — в пятнадцатом. Логичный следующий вопрос — чтение больших таблиц: когда AfterFind зовётся на тысячу строк, а выборка отдаётся страницами, встаёт пагинация. Семнадцатый урок «Пагинация и сортировка» покажет Offset/Limit, keyset-пагинацию и Order — как отдавать данные порциями, не выгребая таблицу целиком и не пересчитывая смещения дважды. Карта курса из 25 уроков — на странице курса. Домашка ниже прогоняет хуки по всем четырём конвейерам.

Карточки: хуки жизненного цикла

Термин

Сигнатура хука

нажмите, чтобы перевернуть
Определение

func(tx *gorm.DB) error — метод модели с приёмником-указателем: func (t *TodoItem) BeforeCreate(tx *gorm.DB) error. Девять интерфейсов в callbacks/interfaces.go: Before/After × Create, Update, Save, Delete + AfterFind. GORM сам находит метод по интерфейсу, регистрация не нужна. Value-приёмник компилируется, но правит копию — поля не сохранятся.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • gorm хуки — методы модели с приёмником-указателем и сигнатурой func(tx *gorm.DB) error; девять интерфейсов (Before/After × Create/Update/Save/Delete + AfterFind) объявлены в callbacks/interfaces.go, никакой регистрации не нужно.
  • create-конвейер: gorm:begin_transaction → BeforeSave → BeforeCreate → save before associations → INSERT → save after associations → AfterCreate → AfterSave → gorm:commit_or_rollback_transaction; update и delete собираются так же из своих колбэков.
  • Ошибка из хука через db.AddError попадает в db.Error: последующие хуки пропускаются, SQL не отправляется, а финальный колбэк откатывает авто-транзакцию; ошибка в AfterCreate откатывает даже выполненный INSERT.
  • tx в хуке — свежая сессия NewDB на том же ConnPool: операции едут в общую транзакцию, условий исходной цепочки нет; через tx.Statement и SetColumn можно править состав самой операции.
  • Валидация живёт в BeforeSave (общая для создания и обновления) или BeforeCreate (только создание); паттерн: sentinel-ошибка + errors.Is + структура ValidationError с полем; ограничения базы остаются последней линией.
  • SkipHooks включается точечно (Session{SkipHooks: true}), глобально (Config) и неявно (UpdateColumn/UpdateColumns, Save-фоллбэк); при обновлении он снимает и автоправку updated_at, при создании автополя остаются.
  • Каскад: belongs to-дети сохраняются до владельца со своими хуками, has many/m2m и join-строки — после; SkipHooks наследуется; AfterFind зовётся после жадной загрузки и только при RowsAffected > 0.

Домашнее задание

Разверните канон локально и прогоните хуки по всем четырём конвейерам. Шаг первый: добавьте в TodoItem поле Audited bool с тегом not null;default:false и три хука из урока — BeforeCreate со slugify, BeforeUpdate с SetColumn("Audited", true), AfterFind с подстановкой статуса; AutoMigrate сам добавит колонку. Шаг второй: создайте задачу без слага и по логгеру сверьте след — BEGIN, INSERT со слагом, собранным хуком, COMMIT; затем создайте задачу с пустым заголовком и поймайте ValidationError в result.Error, убедившись, что INSERT в следе не появился, а ROLLBACK — да. Шаг третий: обновите задачу через Updates картой и найдите audited=true в SET между другими колонками; повторите тем же набором через UpdateColumn и сравните: флага в SET нет, updated_at не двигается, хук не звался. Шаг четвёртый: соберите каскад db.Create со списком и двумя тегами и выпишите порядок четырёх INSERT вместе с ROLLBACK-проверкой — верните ошибку из BeforeCreate тега и покажите, что список и задача тоже откатились. Шаг пятый: закройте рекурсию — напишите AfterUpdate, который правит счётчик той же задачи через db.Session(&gorm.Session{SkipHooks: true}).Model(...).Update(...), и объясните в комментарии, что случится без SkipHooks. Шаг шестой: проверьте AfterFind — подставьте статус пустым строкам старой миграции, затем выполните First по несуществующему id и убедитесь, что хук не звался (ошибка вернулась, тело хука не исполнялось). Финал: включите Config{SkipHooks: true} на отдельном соединении и покажите, что обычный Update теперь идёт без флага аудита, — и сформулируйте в комментарии, какие из ваших правил обязаны жить в ограничениях базы.

Личные заметкиПусто
Хранятся только в вашем браузере (GORM · Хуки жизненного цикла — BeforeCreate, AfterUpdate, валидация и SkipHooks в GORM)

Вопросы и ответы по уроку

Чем BeforeSave отличается от BeforeCreate и где ставить валидацию?

BeforeSave вызывается перед обеими операциями записи — и INSERT, и UPDATE; BeforeCreate — только перед INSERT. Общая валидация полей (непустой заголовок, допустимый статус) живёт в BeforeSave, создание-специфичная генерация (слаг, uuid, стартовые значения) — в BeforeCreate. Проверки после записи (AfterCreate/AfterSave) мусор уже не ловят: строка отправлена, и максимум пользы там — пост-условия и дозаполнение, которые при нарушении откатятся вместе с транзакцией.

Что такое tx в хуке и можно ли из хука менять сам запрос?

tx — это *gorm.DB, собранный как Session с опцией NewDB: он работает на том же ConnPool, что и основная операция, то есть внутри её транзакции, но с чистым Statement — условия исходной цепочки не протекают. Дока зовёт это Modify current operation: через tx.Statement.Select и AddClause можно править состав запроса, а Statement.SetColumn("имя", значение) добавляет колонку в тот же INSERT или UPDATE — так флаг аудита из BeforeUpdate уезжает в SET без второго запроса.

Как отключить хуки для одной операции и что при этом происходит с updated_at?

Точечный способ — db.Session(&gorm.Session{SkipHooks: true}) на цепочке; глобальный — Config{SkipHooks: true} при gorm.Open. Внутри UpdateColumn и UpdateColumns стоит тот же Statement.SkipHooks = true, поэтому служебные правки из восьмого урока хуков не дёргают. При обновлении флаг снимает и автоматическую правку updated_at — она стоит под тем же условием в callbacks/update.go; при создании автополя CreatedAt/UpdatedAt заполняются независимо — это AutoCreateTime, а не хук.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

sqlc25 уроков~12 чпрактика25 уроков уже доступны

sqlc: типобезопасный код из SQL-запросов

Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.

  • Настраивать sqlc.yaml для Postgres/MySQL/SQLite
  • Писать аннотированные запросы с типизированными параметрами
  • Работать с NULL, массивами и JSON-колонками
  • Организовывать транзакции через WithTx