This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/09-钩子函数.md
T
2026-04-28 20:56:51 +08:00

342 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [GORM, Go, ORM, 钩子函数, Callback, BeforeCreate, AfterUpdate, Lifecycle]
create time: 2026-04-28 00:00
---
# 钩子函数
## 概述
钩子(Hook)是 GORM 生命周期回调机制,允许你在特定操作前后注入自定义逻辑。它们是「横切关注点」的理想载体——不需要在每个业务代码里重复写加密、日志、审计等通用逻辑。
```mermaid
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 执行 → 钩子(后置)`。前置钩子返回错误可以**中断整个操作**。
## 完整钩子列表
### 创建阶段
```go
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
}
```
### 更新阶段
```go
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` 才有效
### 删除阶段
```go
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
}
```
### 查询阶段
```go
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` 判断当前操作类型:
```go
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 级 > 全局**:
```go
// 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 —— 作用于所有模型
```go
// 在 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()` 做条件过滤
## 执行时序
```mermaid
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: 返回结果
```
## 实战:一个完整的业务模型
下面展示一个电商订单模型,综合运用多个钩子处理真实场景:
```go
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("原因")`
## 关联笔记
- [[02-模型定义]]
- [[03-CRUD 操作]]
- [[08-事务管理]]
- [[11-批量操作]]
- [[10-字段标签]](autoTime / autoCreateTime 等内置标签)