226 lines
7.1 KiB
Markdown
226 lines
7.1 KiB
Markdown
|
|
---
|
|||
|
|
tags: [GORM, Go, ORM, 钩子函数, Callback, BeforeCreate, AfterUpdate, Lifecycle]
|
|||
|
|
create time: 2026-04-28 00:00
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 钩子函数
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
钩子(Hook)是 GORM 生命周期回调机制,允许你在特定操作前后注入自定义逻辑。它们是「横切关注点」的理想载体——不需要在每个业务代码里重复写加密、日志、审计等通用逻辑。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TD
|
|||
|
|
A[Create 调用] --> BC["BeforeCreate"]
|
|||
|
|
BC --> CreateSQL["执行 INSERT"]
|
|||
|
|
CreateSQL --> AC["AfterCreate"]
|
|||
|
|
|
|||
|
|
D[Update 调用] --> BU["BeforeUpdate"]
|
|||
|
|
BU --> UpdateSQL["执行 UPDATE"]
|
|||
|
|
UpdateSQL --> AU["AfterUpdate"]
|
|||
|
|
|
|||
|
|
E[Delete 调用] --> BD["BeforeDelete"]
|
|||
|
|
BD --> DeleteSQL["执行 DELETE"]
|
|||
|
|
DeleteSQL --> AD["AfterDelete"]
|
|||
|
|
|
|||
|
|
F[Find/First 调用] --> AF["AfterFind"]
|
|||
|
|
|
|||
|
|
style BC fill:#EAB308,color:#fff
|
|||
|
|
style BU fill:#F59E0B,color:#000
|
|||
|
|
style BD 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
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 删除阶段
|
|||
|
|
|
|||
|
|
```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)` | 字段是否被更新且值发生变化 |
|
|||
|
|
|
|||
|
|
## 全局钩子 vs Model 级钩子
|
|||
|
|
|
|||
|
|
GORM 支持两种注册方式,优先级为 **Model 级 > 全局**:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// Model 级钩子——写在 struct 的方法上(最常用)
|
|||
|
|
type Order struct{}
|
|||
|
|
func (Order) BeforeCreate(tx *gorm.DB) error { ... }
|
|||
|
|
|
|||
|
|
// 全局钩子——通过 Callback 注册,作用于所有模型
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 使用场景:给所有模型统一设置默认时间(测试用)
|
|||
|
|
db.Session(&gorm.Session{Context: ctx}).Create(&order)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!warning] 全局钩子注意事项
|
|||
|
|
> 全局钩子的注册时机必须在 `db.Open()` 之后、首次操作之前。而且全局钩子会影响**所有模型**——包括内置的 `gorm.Model`——使用时需格外小心。
|
|||
|
|
|
|||
|
|
## 钩子执行链示意图
|
|||
|
|
|
|||
|
|
```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: 返回结果
|
|||
|
|
|
|||
|
|
Note over Hook: Update: BeforeUpdate → SQL → AfterUpdate
|
|||
|
|
Note over Hook: Delete: BeforeDelete → SQL → AfterDelete
|
|||
|
|
Note over Hook: Find: BeforeQuery → SQL → AfterFind
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 常见坑点速查
|
|||
|
|
|
|||
|
|
| 问题 | 原因 | 解决方案 |
|
|||
|
|
|------|------|---------|
|
|||
|
|
| 钩子里调用了 db(而不是 tx) | 死锁——钩子内再开事务嵌套 | 钩子内部全部使用 `tx` 参数 |
|
|||
|
|
| Changed() 在 Save 时总返回 false | Save 是全量写入,不追踪变化 | 用 `Updates(struct)` 配合 Changed() |
|
|||
|
|
| 钩子返回了 nil 但想中断操作 | 零值 nil 不是错误 | `return errors.New("中断原因")` |
|
|||
|
|
| 批量操作也会触发钩子 | `Create(&[]User{})` 每条都走钩子 | 如需跳过可用 `SkipHooks` session |
|
|||
|
|
| AfterFind 脱敏污染了原始数据 | 直接修改结构体字段影响调用方 | 需要脱敏时在接口层处理,不要改原对象 |
|
|||
|
|
|
|||
|
|
## 钩子应用场景总结
|
|||
|
|
|
|||
|
|
| 场景 | 推荐钩子 | 说明 |
|
|||
|
|
|------|---------|------|
|
|||
|
|
| 自动填充 CreatedAt / UpdatedAt | `autoTime` tag 更简单 | 如果手动实现用 BeforeCreate / BeforeUpdate |
|
|||
|
|
| 密码哈希 | BeforeCreate + BeforeUpdate(仅当 Changed) | 避免重复哈希 |
|
|||
|
|
| 乐观锁 | BeforeUpdate(检查 version) | 并发安全的经典方案 |
|
|||
|
|
| 审计日志 | AfterCreate / AfterUpdate / AfterDelete | 操作完成后异步记录 |
|
|||
|
|
| 数据脱敏 | AfterFind | 对外输出前格式化 |
|
|||
|
|
| 关联清理 | BeforeDelete | 删除前解除外部引用 |
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[02-模型定义]]
|
|||
|
|
- [[03-CRUD 操作]]
|
|||
|
|
- [[08-事务管理]]
|
|||
|
|
- [[11-批量操作]]
|