Files
cs-note/hhs/GORM/09-钩子函数.md
T
2026-05-24 11:42:38 +08:00

11 KiB
Raw Blame History

tags, create time
tags create time
GORM
Go
ORM
钩子函数
Callback
BeforeCreate
AfterUpdate
Lifecycle
2026-04-28 00:00

钩子函数

概述

钩子(Hook)是 GORM 生命周期回调机制,允许你在特定操作前后注入自定义逻辑。它们是「横切关注点」的理想载体——不需要在每个业务代码里重复写加密、日志、审计等通用逻辑。

flowchart TD
    subgraph CREATE["🟢 Create"]
        A[Create 调用] --> BC["BeforeCreate"]
        BC --> CreateSQL["执行 INSERT"]
        CreateSQL --> AC["AfterCreate"]
    end
    
    subgraph UPDATE["🟡 Update"]
        D[Update 调用] --> BU["BeforeUpdate"]
        BU --> UpdateSQL["执行 UPDATE"]
        UpdateSQL --> AU["AfterUpdate"]
    end
    
    subgraph DELETE["🔴 Delete"]
        E[Delete 调用] --> BD["BeforeDelete"]
        BD --> DeleteSQL["执行 DELETE"]
        DeleteSQL --> AD["AfterDelete"]
    end
    
    subgraph FIND["🔵 Find/Query"]
        F[Find/First 调用] --> AF["AfterFind"]
    end
    
    style CREATE fill:#22C55E,color:#fff,stroke:#16A34A
    style UPDATE fill:#EAB308,color:#000,stroke:#CA8A04
    style DELETE fill:#EF4444,color:#fff,stroke:#DC2626
    style FIND fill:#3B82F6,color:#fff,stroke:#2563EB
    style BC fill:#22C55E,color:#fff
    style AC fill:#22C55E,color:#fff
    style BU fill:#EAB308,color:#000
    style AU fill:#EAB308,color:#000
    style BD fill:#EF4444,color:#fff
    style AD fill:#EF4444,color:#fff
    style AF fill:#3B82F6,color:#fff

[!note] GORM 的操作顺序 每个操作的执行流:钩子(前置) → SQL 执行 → 钩子(后置)。前置钩子返回错误可以中断整个操作。

完整钩子列表

创建阶段

func (u *User) BeforeCreate(tx *gorm.DB) error {
    // 在 INSERT 之前执行
    u.CreatedAt = time.Now()
    u.UpdatedAt = time.Now()
    
    // 对密码做哈希处理
    hashed, _ := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
    u.Password = string(hashed)
    
    // 生成唯一 ID
    if u.UUID == "" {
        u.UUID = generateUUID()
    }
    return nil // 返回错误可中断插入
}

func (u *User) AfterCreate(tx *gorm.DB) error {
    // 在 INSERT 之后执行
    // 例如:发送欢迎邮件、记录审计日志
    log.Printf("用户 %s 创建成功,ID=%d", u.Name, u.ID)
    return nil
}

更新阶段

func (u *User) BeforeUpdate(tx *gorm.DB) error {
    // 在 UPDATE 之前执行
    u.UpdatedAt = time.Now()
    
    // 检查乐观锁版本号
    var prev User
    tx.Select("version").First(&prev, u.ID)
    if prev.Version != u.Version {
        return errors.New("数据已被其他人修改,请刷新后重试")
    }
    return nil
}

func (u *User) AfterUpdate(tx *gorm.DB) error {
    // 同步缓存
    redis.Set(context.Background(), "user:"+strconv.Itoa(u.ID), u, time.Hour)
    return nil
}

[!tip] Changed vs Updated

  • tx.Statement.Changed("field"):只要调用过 Updates(map) 并包含该字段,就返回 true(无论新旧值是否相同)
  • tx.Statement.Updated("field"):返回值与 Changed 一致,但额外校验「新值 ≠ 旧值」
  • 如果直接通过结构体赋值再调 Save() / UpdateColumn(),这些方法都不会追踪变化,需用 Updates 才有效

删除阶段

func (u *User) BeforeDelete(tx *gorm.DB) error {
    // 清理关联数据(比如清除 Redis Session)
    sessionIDs, _ := getSessionIDsByUserID(u.ID)
    for _, id := range sessionIDs {
        redis.Del(context.Background(), "session:"+id)
    }
    return nil
}

func (u *User) AfterDelete(tx *gorm.DB) error {
    // 如果用了软删除,这里可以做真正的物理清理(如文件、附件)
    if tx.RowsAffected > 0 {
        deleteUploadedFiles(u)
    }
    return nil
}

查询阶段

func (u *User) AfterFind(tx *gorm.DB) error {
    // 在 SELECT 之后执行——每次 Find/First 都会触发
    // 适合用于敏感字段脱敏或格式化处理
    
    // 示例:邮箱脱敏
    if len(u.Email) > 4 {
        masked := u.Email[:2] + "***" + u.Email[len(u.Email)-2:]
        fmt.Println("已脱敏:", masked)
    }
    return nil
}

[!question] AfterFind 的性能影响 AfterFind 会在每次查询时对所有行都执行——如果一次查出 1000 条记录,钩子会运行 1000 次。不要在 AfterFind 中放重逻辑(如 RPC 调用)。

条件判断:根据操作类型选择行为

有时你希望同一个钩子在特定操作下才生效。可以通过 tx.Statement 判断当前操作类型:

func (u *User) BeforeUpdate(tx *gorm.DB) error {
    // 只在 Password 被修改时才重新哈希
    if tx.Statement.Changed("Password") {
        hashed, _ := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
        u.Password = string(hashed)
    }
    return nil
}

[!tip] Statement 常用方法

方法 作用
Changed(field) 该字段是否在 Updates 中被修改
Select() 获取显式 SELECT 的字段列表
Omit() 获取 OMIT 的字段列表
Deleted() 是否是 Delete 操作
Updated(field) 字段是否被更新且值发生变化

Model 级 vs 全局 Callback

GORM 支持两种注册方式,优先级为 Model 级 > 全局:

// Model 级钩子——实现 gorm.LifecycleHooks 接口(最常用)
type Order struct{}
func (Order) BeforeCreate(tx *gorm.DB) error { ... }

[!note] LifecycleHooks 接口 GORM v2 定义了以下接口,struct 只要方法签名匹配即自动注册为钩子:

  • BeforeCreate(tx *gorm.DB) error
  • AfterCreate(tx *gorm.DB) error
  • BeforeUpdate(tx *gorm.DB) error
  • AfterUpdate(tx *gorm.DB) error
  • BeforeDelete(tx *gorm.DB) error
  • AfterDelete(tx *gorm.DB) error
  • AfterFind(tx *gorm.DB) error

你也可以使用 *gorm.State 替代 *gorm.DB(gint-gorm 兼容模式),但在大多数场景下 *gorm.DB 更通用。

全局 Callback —— 作用于所有模型

// 在 Create SQL 执行之前注册自定义逻辑
db.Callback().Create().Before("gorm:create").Register("set_created_at", func(tx *gorm.DB) {
    if v, ok := tx.Get("created_at_overwrite"); ok {
        if t, ok := v.(time.Time); ok {
            tx.Statement.SetColumn("CreatedAt", t)
        }
    }
})

// 在 Delete 之后追加逻辑(不阻塞后续 callback)
db.Callback().Delete().After("gorm:delete").Register("cleanup_cache", func(tx *gorm.DB) {
    // 清理缓存等后置操作
})

// 替换 GORM 内置回调(谨慎使用!)
db.Callback().Create().Replace("gorm:create", func(tx *gorm.DB) {
    // 完全接管 Create 流程
})

// 移除内置回调
db.Callback().Create().Remove("gorm:create")

[!warning] 全局钩子注意事项

  1. 注册时机必须在 db.Open() 之后、首次操作之前
  2. 会影响所有模型——包括内置的 gorm.Model
  3. 按阶段排序:Before(name) / After(name) / Register(name, fn) / Replace(name, fn) / Remove(name)
  4. 频繁读写数据库的全局逻辑会拖慢所有模型,建议在钩子内用 tx.Get() 做条件过滤

执行时序

sequenceDiagram
    participant App as 应用代码
    participant Hook as GORM 钩子系统
    participant DB as 数据库
    
    App->>Hook: db.Create(&user)
    Hook->>Hook: BeforeCreate
    Hook->>DB: INSERT INTO users...
    DB-->>Hook: 返回影响行数
    Hook->>Hook: AfterCreate
    Hook-->>App: 返回结果

实战:一个完整的业务模型

下面展示一个电商订单模型,综合运用多个钩子处理真实场景:

type Order struct {
    gorm.Model
    UserID      uint      `gorm:"not null;index"`
    Amount      decimal.Decimal
    Status      string    // pending → paid → shipped → completed
    PaidAt      *time.Time // 支付时间,nil = 未支付
    Version     int       // 乐观锁版本号
    CreatedBy   string    // 创建人标识
}

func (o *Order) BeforeCreate(tx *gorm.DB) error {
    // ① 初始化状态和审计字段
    if o.Status == "" {
        o.Status = "pending"
    }
    o.CreatedBy = getCurrentUserID() // 从 context 获取
    o.Version = 1
    return nil
}

func (o *Order) BeforeUpdate(tx *gorm.DB) error {
    // ② 版本号递增 + 状态机校验
    o.Version++
    
    validTransitions := map[string][]string{
        "pending": {"paid"},
        "paid":    {"shipped"},
        "shipped": {"completed"},
    }
    newStatus := tx.Statement.Schema.ValueOfField(tx.Statement.Schema.FieldsByName["Status"])
    allowed, ok := validTransitions[o.Status]
    if !ok || !contains(allowed, newStatus.String()) {
        return fmt.Errorf("非法状态转换: %s → %s", o.Status, newStatus)
    }
    return nil
}

func (o *Order) AfterUpdate(tx *gorm.DB) error {
    // ③ 状态变更时发送通知
    if old, ok := tx.Data.(*Order); ok && old.Status != o.Status {
        sendNotification(o.UserID, "订单状态变更为: "+o.Status)
    }
    return nil
}

func (o *Order) AfterDelete(tx *gorm.DB) error {
    // ④ 软删除后清理缓存
    redis.Del(context.Background(), "order:"+strconv.Itoa(int(o.ID)))
    return nil
}

func contains(slice []string, s string) bool {
    for _, v := range slice {
        if v == s {
            return true
        }
    }
    return false
}

[!question] 为什么 AfterDelete 能拿到被删的数据? 因为 GORM 的软删除是先查再标记 deleted_at,AfterDelete 钩子中仍然可以通过 tx.Statement.ReflectValue 访问到原始对象数据。

常用场景速查

需求 推荐方案 说明
自动填充 CreatedAt / UpdatedAt 用 autoTime tag 更简单 手动实现则放 BeforeCreate / BeforeUpdate
密码哈希 BeforeCreate + BeforeUpdate(仅 Changed) 避免重复哈希
乐观锁 BeforeUpdate(检查 version) 并发安全的经典方案
审计日志 AfterCreate / AfterUpdate / AfterDelete 操作完成后记录
数据脱敏 AfterFind 对外输出前格式化(注意性能)
关联清理 BeforeDelete 删除前解除外部引用
全局默认值 Callback.Register() 作用于所有模型,需条件过滤

最佳实践

[!checklist] 使用钩子时的 Checklist

  1. 永远用 tx 不用 db — 钩子内部不要调用 db.Create() / db.Raw(),必须使用传入的 tx 参数,否则会导致死锁
  2. AfterFind 中不要修改原对象 — 直接改字段会影响所有调用方;需要脱敏时在接口层另行处理
  3. 不要在钩子里发送 HTTP 请求或做耗时 IO — 会阻塞主流程;改用 channel + goroutine 异步处理
  4. Changed/Updated 只在 Updates 时有效 — Save 是全量写入、Select/Omit 不会触发追踪
  5. 批量操作也会逐条触发 — Create(&[]Model{}) 每条都走钩子;如想跳过用 db.Session(&gorm.Session{SkipHooks: true}).Create(...)
  6. 钩子返回 nil != 没返回值 — Go 中空指针 nil 不等于 error 零值,中断操作必须 return errors.New("原因")

关联笔记