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:23:33 +08:00

226 lines
7.1 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
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-批量操作]]