Go 博客教程 · 第 8 课 / 10
第 8 课:GORM 与关联查询
第 3 课你手写过这样的代码:
// internal/repository/post_repo.go —— 第 3 课手写版
for rows.Next() {
var p model.Post
var authorID sql.NullInt64 // NULL 列不能直接扫进 int64
var summary sql.NullString
var pubAt sql.NullTime
if err := rows.Scan(&p.ID, &authorID, &p.Title, &p.Slug, &summary,
&p.Content, &p.Status, &p.ViewCount, &pubAt, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err // ★ 列顺序和 Scan 参数顺序必须严格一一对应
}
if authorID.Valid { p.AuthorID = &authorID.Int64 }
if summary.Valid { p.Summary = &summary.String }
if pubAt.Valid { p.PublishedAt = &pubAt.Time }
ps = append(ps, p)
}
return ps, rows.Err() // ★ 别忘了 rows.Err()
11 个列,11 个 &,一个都不能错位。 加一个字段要改三处(SQL、Scan 参数、NULL 转换)。这一课换成 GORM,然后开着 SQL 日志盯着它,看清它到底替我们干了什么。
① 本课目标
- 判断一个查询该用 GORM 还是该退回原生 SQL。
- 按 GORM 命名约定定义模型,不符合约定时用
TableName()/columntag 覆盖。 - 说清
gorm.DeletedAt加进结构体后,所有查询的语义发生了什么变化。 - 认出 GORM 头号坑:
Updates用 struct 忽略零值——并给出三种修法。 - 用
Preload把 N+1 变成常数条查询,并能在 SQL 日志里数出来变了几条。 - 用闭包事务写「创建文章 + 绑定标签」,并知道
tx漏传会发生什么。 - 把 repository 换成 GORM,而 service 和 handler 一行不改——第 4 课接口设计的兑现时刻。
② 前置检查
cd ~/go-blog && go build ./... && echo "编译通过"
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/v1/posts # 期望 200
mysql -uroot -p123456 blog_dev -e "SHOW TABLES;" # comments post_tags posts tags users
go get gorm.io/gorm gorm.io/driver/mysql
★★ 装之前先看清包名
| 包 | 状态 |
|---|---|
gorm.io/gorm |
✅ v2,当前唯一维护的版本 |
github.com/jinzhu/gorm |
❌ v1,2020 年起停止维护 |
网上 2020 年以前的中文教程基本全是 v1,API 差异很大(Session/WithContext/Preload 语义都变了)。搜到的代码如果 import github.com/jinzhu/gorm,直接跳过,别硬套。
本课实测版本:
| 组件 | 版本 |
|---|---|
| Go / MySQL | go1.25.0 darwin/arm64 / 8.4.5 |
| gorm.io/gorm | v1.31.2 |
| gorm.io/driver/mysql | v1.6.0 |
| github.com/go-sql-driver/mysql | v1.8.1(还是第 3 课那个驱动) |
注意最后一行:GORM 底下跑的还是 database/sql 和你第 3 课用过的 MySQL 驱动。它不是新的数据库栈,是 database/sql 上面的一层 SQL 生成器 + 反射映射器。这个认知能解释后面所有行为。
③ 核心概念
3.1 先问:该不该用?
对照三段式 ①:Scan vs 自动映射
// —— 第 3 课手写版
var p model.Post
var authorID sql.NullInt64
err := row.Scan(&p.ID, &authorID, &p.Title, &p.Slug, /* ...还有 7 个... */)
if authorID.Valid { p.AuthorID = &authorID.Int64 }
// —— GORM 版
var p model.Post
err := r.db.WithContext(ctx).First(&p, id).Error // 就这一行
手写 database/sql |
GORM | 代价 | |
|---|---|---|---|
| 简单 CRUD | 11 个 &,加字段改 3 处 |
一行,加字段改 struct 一处 | — |
| NULL 处理 | 手写 sql.NullXxx 转换 |
*string / *int64 自动处理 |
— |
| 看得见 SQL | ✅ SQL 就在眼前 | ❌ 不开日志你不知道它发了什么 | 必须开 Logger |
| 性能可预测 | ✅ 一条 SQL 一次往返 | ❌ Preload 可能悄悄多发几条 |
得盯日志 |
| 复杂 JOIN / 子查询 / 窗口函数 | ✅ 直接写 SQL | ❌ 链式 API 反而更难写更难读 | 用 db.Raw() 逃生 |
| 隐式行为 | 无 | 软删除、零值忽略、自动时间戳 | ★ 本课一大半篇幅在讲这个 |
| 反射开销 | 无 | 每次查询反射解析 struct(有缓存但不为零) | 极高 QPS 要测 |
建议(诚实版):
- CRUD 用 GORM,报表用
Raw。 这不是妥协,GORM 官方自己也这么推荐。 - 学 GORM 期间
logger.Info必开,看不见 SQL 就是盲飞。 - 不要为了「用了 ORM 就不该写 SQL」而硬凑。五表 JOIN 的统计用链式 API 写出来,你三个月后也看不懂。
3.2 连接与配置
// internal/repository/gorm.go
package repository
import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// OpenGorm 建立 GORM 连接。
// 参数只有 dsn:配置从外面传进来,这个函数不读环境变量(第 4 课的依赖注入原则)。
func OpenGorm(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
// ★★ 学习期必开:每条生成的 SQL 打到 stdout。生产改成 logger.Warn。
Logger: logger.Default.LogMode(logger.Info),
// ★ 把 MySQL 1062 之类的驱动错误翻译成 gorm.ErrDuplicatedKey,
// 否则 errors.Is(err, gorm.ErrDuplicatedKey) 恒为 false(实测,见 4.4)。
TranslateError: true,
// schema.sql 里已手写外键约束,让 GORM 也去建会和迁移脚本打架
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
return nil, fmt.Errorf("gorm.Open: %w", err)
}
// ★★ 连接池 GORM 不管,还得自己设 —— 第 3 课那套参数原封不动搬过来。
// db.DB() 取回的就是标准库的 *sql.DB。
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("db.DB(): %w", err)
}
sqlDB.SetMaxOpenConns(25) // 别超过 MySQL 的 max_connections
sqlDB.SetMaxIdleConns(10) // 太小会频繁重连
sqlDB.SetConnMaxLifetime(30 * time.Minute) // 要小于 MySQL 的 wait_timeout
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
// gorm.Open 是懒连接,不 Ping 的话 DSN 配错要到第一次查询才报错
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("ping: %w", err)
}
return db, nil
}
对照三段式 ②:连接池
| 第 3 课 | GORM | 代价 | |
|---|---|---|---|
| 开连接 | sql.Open("mysql", dsn) |
gorm.Open(mysql.Open(dsn), cfg) |
— |
| 连接池 | db.SetMaxOpenConns(25) … |
完全一样,db.DB() 取回来自己设 |
★ GORM 不会替你设,不设就是驱动默认值(MaxIdleConns=2,高并发下疯狂建连接) |
日志打开后每条查询长这样(实测):
2026/09/06 09:31:39 gorm_test.go:126
[3.545ms] [rows:1] INSERT INTO `posts` (`author_id`,`title`,`slug`,...) VALUES (NULL,'零值坑复现',...)
↑耗时 ↑影响行数 ↑真发出去的 SQL(参数已填进去,可直接复制到 mysql 客户端跑)
单条临时开日志用 db.Debug()。只想看 SQL 不执行用 ToSQL(写复杂查询时很有用):
// scratch/tosql.go
sql := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
return tx.Model(&Post{}).Where("status = ?", 1).Order("id DESC").Limit(10).Find(&[]Post{})
})
// SELECT * FROM `posts` WHERE status = 1 AND `posts`.`deleted_at` IS NULL ORDER BY id DESC LIMIT 10
④ 函数逐个精讲
4.1 模型定义 —— 以及和第 4 课分层的一次正面冲突
先把第 1/3/4 课定稿的领域模型抄过来:
// internal/model/post.go —— 第 1/3/4 课定稿,先原样看一眼
type Post struct {
ID int64
AuthorID *int64
Title string
Slug string
Summary string // ★ 零值折叠:读 NULL → "",写 "" → NULL(第 3 课的 nullString)
Content string
Status Status // ★ 第 1 课的 defined type,底层 int8
ViewCount int
PublishedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
能不能直接给 GORM 用? 我拿这个结构体原样跑了一遍,两个问题当场暴露:
1) Summary "" 写进去之后: summary IS NULL = 0 ← 写成了空串,不是 NULL
实际 SQL:INSERT INTO `posts` (...,`summary`,...) VALUES (...,'',...)
2) db.Delete(&Post{}, id) 发出的 SQL:
DELETE FROM `posts` WHERE `posts`.`id` = 4 ← 真物理删除!
Delete 之后表里还有这行吗? count=0
后续 Find 也【没有】自动加 deleted_at IS NULL
两个都是静默的语义变化,编译器和测试都不会提醒你:
| 冲突 | 第 3 课手写版 | GORM 直接用 model.Post |
后果 |
|---|---|---|---|
Summary string |
nullString(p.Summary),"" → NULL |
写成 '' |
「零值折叠」约定破了;WHERE summary IS NULL 的查询全部失效 |
DeletedAt *time.Time |
repository 自己拼 UPDATE ... SET deleted_at=NOW() |
软删除完全不生效,变成物理删除 | 数据真的没了 |
根因:GORM 的软删除是类型驱动的——它认的是 gorm.DeletedAt 这个具体类型(内部是 sql.NullTime + 一组 GORM 接口),光把字段命名成 DeletedAt 没有任何用。同理,string 类型在 Go 里没有「空」这个状态,GORM 只能老实写 ''。
两条路,都要付代价
方案 A:把 model.Post.DeletedAt 改成 gorm.DeletedAt。
// internal/model/post.go
import "gorm.io/gorm" // ★ model 包从此依赖 ORM
type Post struct {
// ...
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
代价很直接:第 4 课那张依赖图破了。model 是最底层、谁都 import 它的包,第 4 课专门讲过它不该沾任何基础设施。现在它 import 了 gorm.io/gorm——意味着你的领域模型、你的单元测试、甚至将来想抽出去复用的 model 包,全都拖着一个 ORM。而且 Summary 的 NULL 问题它还没解决(得再把 Summary 改成 *string,于是 service 层那些 strings.TrimSpace(req.Summary) 全要改)。
方案 B:在 repository 包里放一个持久化结构体,model 保持纯净。
// internal/repository/post_row.go —— ORM 的类型只存在于这个文件
package repository
// postRow 是 posts 表的【持久化表示】,只服务于 GORM。
// 小写开头 = 包外不可见,它绝不会泄露到 service/handler。
type postRow struct {
ID int64 `gorm:"primaryKey"`
AuthorID *int64
Title string
Slug string
Summary *string // ★ 指针:让 "" ↔ NULL 的折叠成为可能
Content string
Status model.Status
ViewCount int
PublishedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` // ★ ORM 类型只出现在这里
// ★★ TableName() 只改【表名】,不改关联列的推断 ——
// 列名是从【结构体名】postRow 推的,会推出 post_row_id(实测)。
// 所以这里必须用 joinForeignKey 显式钉死,否则 Preload 恒返回 0 个标签。
Tags []Tag `gorm:"many2many:post_tags;joinForeignKey:post_id;joinReferences:tag_id"`
}
// TableName 让 postRow 映射到 posts 表(默认会推成 post_rows)
func (postRow) TableName() string { return "posts" }
// toRow 领域对象 → 持久化对象。零值折叠的【写方向】,和第 3 课 nullString 语义一致。
func toRow(p *model.Post) *postRow {
r := &postRow{
ID: p.ID, AuthorID: p.AuthorID, Title: p.Title, Slug: p.Slug,
Content: p.Content, Status: p.Status, ViewCount: p.ViewCount,
PublishedAt: p.PublishedAt,
}
if strings.TrimSpace(p.Summary) != "" { // "" → nil → SQL NULL
s := p.Summary
r.Summary = &s
}
return r
}
// toDomain 持久化对象 → 领域对象。零值折叠的【读方向】。
func (r *postRow) toDomain() *model.Post {
p := &model.Post{
ID: r.ID, AuthorID: r.AuthorID, Title: r.Title, Slug: r.Slug,
Content: r.Content, Status: r.Status, ViewCount: r.ViewCount,
PublishedAt: r.PublishedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
}
if r.Summary != nil { // NULL → ""
p.Summary = *r.Summary
}
if r.DeletedAt.Valid {
t := r.DeletedAt.Time
p.DeletedAt = &t
}
return p
}
实测这一版全部正确:
1) Summary "" -> summary IS NULL = 1 ✅ 零值折叠保住了
2) db.Delete 之后物理行还在吗? count=1 ✅ 真的是软删除
普通 First: err=record not found ✅ 自动加了 deleted_at IS NULL
3) Unscoped 读回来 Summary="" DeletedAt!=nil ✅ 双向映射对称
4) Preload 到 1 个标签 ✅(前提是写了 joinForeignKey)
对照三段式 ③:谁来承担 ORM 的类型要求
| 第 3 课手写版 | 方案 A(model 用 gorm.DeletedAt) |
方案 B(持久化结构体) | |
|---|---|---|---|
| 映射代码 | 每个字段手写 Scan |
零 | 两个函数约 30 行 |
model 包依赖 |
无 | 依赖 gorm.io/gorm |
无 |
| 软删除 | 自己拼 SQL,每个查询自己加条件 | 自动 | 自动 |
| 换 ORM 的代价 | — | 改遍 model + 所有引用 |
只改 repository 一个包 |
GORM 让你在「领域模型纯净」和「不写映射代码」之间二选一。 说「用了 ORM 就不用写映射代码了」是不完整的——只有当你愿意让 ORM 的类型渗进领域模型时才成立。
本教程选方案 B,理由是它守住了第 4 课花一整课建立的依赖方向,而代价(30 行映射)是一次性的、可测的、局部的。但方案 A 在真实项目里非常常见,也不算错——如果你的 model 包本来就只服务这一个应用、且团队认了 GORM 作为长期选型,A 的性价比更高。关键是这个决定要被显式做出来,而不是因为「教程里就这么写的」而默认发生。
不选 B 的信号:如果你发现 postRow 和 model.Post 字段完全一一对应、映射函数纯属搬运、而且半年都没换过 ORM——那 B 就是在交无谓的税,切回 A。
剩下的 tag 都是常规操作
后面几节为了聚焦 GORM 本身的行为,代码里直接写 Post;你在实际项目里把它读作 postRow 即可,行为完全一样。
// internal/repository/post_row.go —— 常用 tag 一览
type postRow struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
Title string `gorm:"size:200;not null"`
Slug string `gorm:"size:200;not null;uniqueIndex"`
Content string `gorm:"type:mediumtext;not null"`
Status model.Status `gorm:"default:0"` // ★ defined type,GORM 认底层 int8,实测直接可用
TempFlag bool `gorm:"-"` // 完全不入库
Computed int `gorm:"->"` // 只读(数据库生成的列)
Secret string `gorm:"<-:create"` // 只写不读
}
实测确认:model.Status(type Status int8)不需要写 Scanner/Valuer——GORM 按底层类型推断出 DataType=int,Create / First / Updates(map) 全部正常,got.Status == model.StatusPublished 成立。
命名约定(实测于 gorm v1.31.2)
我用 gorm.Statement.Parse() 把推断结果打了出来:
模型 *model.Post -> 表名 posts
字段 ID -> 列 id 字段 AuthorID -> 列 author_id
字段 ViewCount -> 列 view_count 字段 PublishedAt -> 列 published_at
关联 Tags type=many_to_many 关联 Comments type=has_many
模型 *model.Tag -> 表名 tags 模型 *model.Comment -> 表名 comments
| 推断 | 规则 | 例子 |
|---|---|---|
| 表名 | 结构体名 → snake_case → 复数 | Post → posts,PostTag → post_tags |
| 列名 | 字段名 → snake_case | AuthorID → author_id(不是 author_i_d,ID 当一个词) |
| 主键 | 名为 ID 的字段 |
别的名字要写 gorm:"primaryKey" |
| 自动时间戳 | CreatedAt 插入时填、UpdatedAt 插入和更新时填 |
改名就失效,或用 gorm:"autoCreateTime" |
| 外键 | <关联结构体名>ID |
Comment.PostID ↔ Post.ID |
不符合约定时覆盖:
// internal/model/post.go
func (Post) TableName() string { return "blog_posts" } // 覆盖表名
type Post struct {
AuthorID int64 `gorm:"column:writer_id"` // 列名不符约定
TempFlag bool `gorm:"-"` // 完全不入库
Computed int `gorm:"->"` // 只读(数据库生成的列)
Secret string `gorm:"<-:create"` // 只写不读
}
gorm.Model 嵌入 vs 自己写字段
GORM 提供了现成的 gorm.Model(含 ID uint / CreatedAt / UpdatedAt / DeletedAt)。本教程不用它,三条理由:
- 主键类型被钉死成
uint。我们的表是BIGINT UNSIGNED,第 4 课的接口是GetByID(ctx, id int64)——嵌了它你得到处uint(id)/int64(p.ID)来回转。 - 软删除是被偷偷塞进来的。上一节刚讲过软删除会改变全项目查询语义;用
gorm.Model意味着这件大事被藏在一行嵌入里,新人根本看不见。显式写出DeletedAt gorm.DeletedAt才对得起它的影响面。 - JSON 序列化不可控。嵌入结构体的字段会平铺进 JSON,想给
DeletedAt加json:"-"得重新定义。
(如果你选了方案 A,这三条同样适用——在 model.Post 里显式写四个字段,别嵌 gorm.Model。)
4.2 AutoMigrate 的真相与红线
// internal/repository/gorm.go —— 仅限本地开发
if os.Getenv("APP_ENV") == "local" {
if err := db.AutoMigrate(&model.Post{}, &model.Tag{}, &model.Comment{}); err != nil {
return nil, fmt.Errorf("automigrate: %w", err)
}
}
它到底做什么? 我建了临时表实测——第一版有 Name varchar(32) 和 Age,第二版删掉 Age、把 Name 缩到 8:
第二次 AutoMigrate 发出的 DDL:ALTER TABLE `mig_demos` MODIFY COLUMN `name` varchar(8)
之后的实际列:
id bigint len=0
name varchar len=8 ← ★ 长度被改小了!
age bigint len=0 ← ★ 删掉的字段还在!
| 它会做 | 它不会做 |
|---|---|
| ✅ 建表、加列、加索引 | ❌ 删列(结构体删了字段,列还在) |
✅ 改列类型/长度(MODIFY COLUMN) |
❌ 删索引、改列名(改字段名 = 加新列,老列留着) |
| ❌ 数据迁移、回填、回滚 |
★ 红线:生产环境不要依赖 AutoMigrate
危险不在「只加不减」,恰恰在它真的会 MODIFY COLUMN:
- 有人把
size:8提交上去,AutoMigrate会在生产库跑ALTER TABLE ... MODIFY COLUMN name varchar(8)——已有长数据要么被截断、要么整条 ALTER 失败卡住启动。 - 大表
ALTER TABLE会锁表/长时间重建,启动时跑等于自己制造停机。 - 无版本记录,两个人同时改模型谁先跑谁说了算,不可审计、不可回滚。
本教程的做法:migrations/schema.sql 是唯一真相源;AutoMigrate 只在 APP_ENV=local 跑,用来快速验证模型和表对不对得上。上生产用 golang-migrate 或 Atlas 这类带版本号和 up/down 的工具。
想只读地检查「模型和线上表对上了吗」:
// scratch/check_schema.go —— 不执行任何 DDL
db.Migrator().HasColumn(&model.Post{}, "summary") // true/false
cols, _ := db.Migrator().ColumnTypes(&model.Post{}) // 列出实际列
4.3 ★★ 软删除:加一个字段,改变全项目语义
DeletedAt gorm.DeletedAt 是本课最容易让人困惑的隐式行为,必须讲透。
// scratch/soft_delete_test.go
db.Create(p) // id=5
db.Delete(&Post{}, p.ID) // ← 你以为的 DELETE
日志里实际发出去的:
UPDATE `posts` SET `deleted_at`='2026-09-06 09:31:51.104'
WHERE `posts`.`id` = 5 AND `posts`.`deleted_at` IS NULL
↑ 是 UPDATE,不是 DELETE
然后每个查询都被改写了(实测):
db.First(&q, 5)
→ SELECT * FROM `posts` WHERE `posts`.`id` = 5 AND `posts`.`deleted_at` IS NULL ORDER BY id LIMIT 1
→ err = "record not found"
db.Model(&Post{}).Where("id = ?", 5).Count(&n)
→ SELECT count(*) FROM `posts` WHERE id = 5 AND `posts`.`deleted_at` IS NULL → n = 0
db.Model(&Post{ID:5}).Updates(map[string]any{"status":0})
→ UPDATE `posts` SET `status`=0,... WHERE `posts`.`deleted_at` IS NULL AND `id` = 5
↑ 连 UPDATE 都带上了!软删除的行改不动
这意味着:
Find/First/Count/Updates/Delete全部自动追加AND deleted_at IS NULL。- MySQL 客户端里
SELECT * FROM posts WHERE id=5看得见这行,但你的 Go 代码看不见——「数据明明在库里,程序说找不到」是这个坑最典型的现场。 uniqueIndex会和软删除打架:slug是唯一索引,软删一篇hello-go后再创建同 slug 的文章会报 1062——那行还占着索引(练习 5 会让你复现并解决)。
逃生舱 Unscoped():
// internal/repository/gorm_post_repo.go —— 三种越过软删除的写法
db.Unscoped().First(&p, id) // 查已删除的
// → SELECT * FROM `posts` WHERE `posts`.`id` = 5 ORDER BY id LIMIT 1 (没有 deleted_at 条件)
// → p.DeletedAt.Valid == true, p.DeletedAt.Time == 删除时间
db.Unscoped().Delete(&Post{}, id) // 物理删除
// → DELETE FROM `posts` WHERE `posts`.`id` = 5
db.Unscoped().Model(&Post{}).Where("id = ?", id).Update("deleted_at", nil) // 恢复
对照三段式 ④:删除
| 第 3/4 课手写版 | GORM 版 | 代价 | |
|---|---|---|---|
| 软删除 | 自己写 UPDATE ... SET deleted_at=NOW(),每个查询自己加 AND deleted_at IS NULL(漏一个就泄露已删数据) |
加一个字段,全自动 | ★ 太自动了:Delete 不是删;SQL 日志里看到 UPDATE 别以为是 bug |
| 硬删除 | 默认行为 | 要显式 Unscoped() |
语义反转了 |
建议:只在真的需要软删除的表上加 gorm.DeletedAt。schema.sql 里只有 posts 和 comments 有 deleted_at 列——tags 和 post_tags 就不该有。
4.4 CRUD 与 GetWithTags
// internal/repository/gorm_post_repo.go —— API 速查(都实测过)
db.Create(&p) // INSERT,成功后 p.ID 被自动回填
db.First(&p, 1) // WHERE id=1 ORDER BY id LIMIT 1;找不到 → ErrRecordNotFound
db.Take(&p) // LIMIT 1(★ 没有 ORDER BY,顺序不保证)
db.Last(&p) // ORDER BY id DESC LIMIT 1
db.Find(&ps) // 多条;★ 找不到【不报错】,切片为空
db.Where("status = ?", 1).Find(&ps) // ? 占位,走预处理不拼字符串
db.Where(&Post{Status: 1}).Find(&ps) // struct 条件 —— ★ 零值字段被忽略(和 Updates 同一个坑)
db.Where(map[string]any{"status": 0}).Find(&ps) // map 条件 —— 零值不会被忽略
db.Model(&Post{}).Where("id = ?", 1).Update("status", 1) // 单字段
db.Model(&Post{}).Where("id = ?", 1).Updates(map[string]any{...}) // 多字段
db.Save(&p) // 主键有值 → 全字段 UPDATE;主键为零 → INSERT
db.Delete(&Post{}, 1) // 有 DeletedAt → UPDATE;没有 → DELETE
Where(struct) 的零值陷阱实测:
db.Where(&Post{Status: 0}).Limit(2).Find(&ps)
→ SELECT * FROM `posts` WHERE `posts`.`deleted_at` IS NULL LIMIT 2 ← ★ status 条件消失了!
db.Where(map[string]any{"status": 0}).Limit(2).Find(&ps)
→ SELECT * FROM `posts` WHERE `posts`.`status` = 0 AND ... LIMIT 2 ← ✅
「找不到」的三种语义(实测):
db.First(&p, 999999) → err = "record not found" errors.Is(err, gorm.ErrRecordNotFound) == true
db.Find(&ps, 999999) → err = <nil> RowsAffected=0 len(ps)=0
db.Raw(...).Scan(&x) → err = <nil> RowsAffected=0 ★ Raw+Scan 也不报 NotFound
对照三段式 ⑤:找不到怎么表达
| 第 3 课 | GORM | |
|---|---|---|
| 单条 | sql.ErrNoRows |
gorm.ErrRecordNotFound |
| 判断 | errors.Is(err, sql.ErrNoRows) |
errors.Is(err, gorm.ErrRecordNotFound) |
| 多条 | rows.Next() 直接 false |
Find 无错误,RowsAffected == 0 |
代价:换了个哨兵错误名,repository 里所有 sql.ErrNoRows 判断都要改成 gorm.ErrRecordNotFound。但 repository 之外一行不用改——第 4 课已经确立了「repository 负责把技术错误翻译成领域错误」,翻译目标 model.ErrNotFound 没变,上层感知不到底下换了 ORM。
// internal/repository/gorm_post_repo.go
package repository
import (
"context"
"errors"
"fmt"
"gorm.io/gorm"
"blog/internal/model"
)
// GormPostRepo 实现第 4 课在 service 包里定义的 PostRepository 接口
// (外加本课新增的两个标签方法)。
// ★★ 它和第 3 课的 SQLPostRepo 是【平级的两个实现】,service 通过接口拿到谁都能跑。
type GormPostRepo struct{ db *gorm.DB }
func NewGormPostRepo(db *gorm.DB) *GormPostRepo { return &GormPostRepo{db: db} }
// GetWithTags 按 ID 查文章并带上标签。
// 参数 ctx:必须一路传下去,超时/取消才生效。GORM 里靠 WithContext 注入。
// 找不到时返回 model.ErrNotFound,【不把 gorm.ErrRecordNotFound 泄露给上层】
// —— 第 4 课的分工:repository 负责把技术错误翻译成领域错误。
// 领域错误住在 model 包(不是 repository 包),否则 handler 要 errors.Is 就得
// 反向 import repository,依赖箭头就倒了 —— 这是第 4 课专门搬过一次家的原因。
func (r *GormPostRepo) GetWithTags(ctx context.Context, id int64) (*model.Post, error) {
var row postRow // ★ 查进【持久化结构体】,不是 model.Post
err := r.db.
WithContext(ctx). // ★ 把 ctx 注入这条链,底下走的是 QueryContext
Preload("Tags"). // ★ 顺带把关联标签查出来,见 4.6
First(&row, id). // 第二个参数是主键值,等价于 Where("id = ?", id)
Error // ★ 链式调用返回 *gorm.DB,错误挂在 .Error 上
if errors.Is(err, gorm.ErrRecordNotFound) {
// %w 包装:上层 errors.Is(err, model.ErrNotFound) 依然认得出来,
// 同时保留了"是哪个 id 没找到"的上下文
return nil, fmt.Errorf("GetWithTags(%d): %w", id, model.ErrNotFound)
}
if err != nil {
// 包一层上下文,出问题时日志能看出是哪个函数、哪个 id
return nil, fmt.Errorf("GetWithTags(%d): %w", id, err)
}
return row.toDomain(), nil // ★ 出口处转回领域对象,postRow 不越过 repository 边界
}
生成的 SQL(3 条,原因见 4.6):
SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` = 6
SELECT * FROM `tags` WHERE `tags`.`id` IN (1,2)
SELECT * FROM `posts` WHERE `posts`.`id` = 6 AND `posts`.`deleted_at` IS NULL ORDER BY id LIMIT 1
易错点:唯一键冲突拿不到 gorm.ErrDuplicatedKey。实测默认配置:
err = Error 1062 (23000): Duplicate entry 'hello-go' for key 'posts.uk_posts_slug'
类型 = *mysql.MySQLError
errors.Is(err, gorm.ErrDuplicatedKey) = false ← ★ 是 false!
根因:GORM 默认把驱动原始错误原样抛出。修法:gorm.Config 开 TranslateError: true(3.2 已开),之后:
err = duplicated key not allowed errors.Is(err, gorm.ErrDuplicatedKey) = true ✅
但还没完:gorm.ErrDuplicatedKey 依然是 GORM 的错误,不能泄露给 service。按第 4 课的分工再翻译一次:
// internal/repository/gorm_post_repo.go —— 技术错误 → 领域错误,repository 的本职工作
func (r *GormPostRepo) Create(ctx context.Context, p *model.Post) error {
row := toRow(p)
err := r.db.WithContext(ctx).Create(row).Error
switch {
case err == nil:
p.ID = row.ID // 自增 ID 回填到领域对象
return nil
case errors.Is(err, gorm.ErrDuplicatedKey):
// ★ 翻译成领域错误。handler 的 httpStatusFor 认得 model.ErrDuplicate → 409,
// 它【一行都不用改】—— 第 3 课那版翻译的是 MySQL 1062,目标是同一个哨兵。
return fmt.Errorf("Create(slug=%q): %w", p.Slug, model.ErrDuplicate)
default:
return fmt.Errorf("Create: %w", err)
}
}
对照三段式 ⑥:错误翻译
| 第 3 课手写版 | GORM 版 | 代价 | |
|---|---|---|---|
| 识别唯一键冲突 | isDuplicateKey(err):errors.As 取 *mysql.MySQLError 判 Number == 1062 |
errors.Is(err, gorm.ErrDuplicatedKey) |
必须先开 TranslateError,默认不开时这行恒 false |
| 翻译目标 | model.ErrDuplicate |
同一个 | 无 |
| 上层影响 | — | 零 | — |
注意「翻译目标不变」这一列:正因为第 4 课把领域错误定在 model 包,这一课换 ORM 时 service 和 handler 的错误处理一行都没动。
4.5 ★★★ UpdateStatus —— GORM 头号坑:Updates 忽略零值
错误现象:你写了「把状态改成 0(草稿)」,接口返回 200,日志没有任何报错,数据库里的值纹丝不动。
复现(实测 SQL 逐条贴出):
// scratch/zero_trap_test.go
p := &Post{Title: "零值坑复现", Slug: "zero-trap", Content: "x", Status: 1, ViewCount: 99}
db.Create(p) // id=4, status=1
db.Model(&Post{ID: p.ID}).Updates(Post{Status: 0}) // 写法 A:Updates 传 struct
[2.185ms] [rows:0] UPDATE `posts` SET `updated_at`='2026-09-06 09:31:39.41'
WHERE `posts`.`deleted_at` IS NULL AND `id` = 4
↑★ SET 里根本没有 status!只更新了 updated_at
<<< A 之后 status=1 (期望 0)
Updates(Post{Status:0}) RowsAffected=0 err=<nil> ← ★ 无声无息
根因:Updates 收到 struct 时用反射遍历字段,跳过所有零值字段(0、""、false、nil、零 time.Time)。GORM 无法区分「你没打算改这个字段」和「你想把它改成 0」——和第 7 课 binding:"required" 的零值陷阱是同一个根本问题:Go 的零值不携带「有没有被设置过」的信息。
三种修法(全部实测)
// 修法 1:用 map —— key 存在就是"要改",没有零值歧义 ★ 推荐
db.Model(&Post{}).Where("id = ?", id).Updates(map[string]any{"status": 0})
// → UPDATE `posts` SET `status`=0,`updated_at`='...' WHERE ... AND `id` = 4 rows:1 ✅
// 修法 2:Select 显式点名要更新的列,被点名的列即使零值也会写
db.Model(&Post{}).Where("id = ?", id).Select("status").Updates(Post{Status: 0})
// → UPDATE `posts` SET `status`=0,`updated_at`='...' ... rows:1 ✅
// 多个:Select("status", "view_count");"除了这几列全改":Select("*").Omit("created_at")
// 修法 3:单字段直接 Update(单数)—— 不走零值过滤
db.Model(&Post{}).Where("id = ?", id).Update("status", 0)
// → UPDATE `posts` SET `status`=0,`updated_at`='...' ... rows:1 ✅
Save 和 Updates 的差别
Save 更新所有字段,包括零值和 NULL(实测):
UPDATE `posts` SET `author_id`=NULL,`title`='零值坑复现',`slug`='zero-trap',`summary`=NULL,
`content`='x',`status`=2,`view_count`=0,`published_at`=NULL,`created_at`='2026-09-06 09:31:39',
`updated_at`='...',`deleted_at`=NULL WHERE `posts`.`deleted_at` IS NULL AND `id` = 4
↑★ 每一个字段都写了,包括你没碰过的
Save 的危险:如果实体来自一个不完整的查询(如 Select("id, title").First(&got)),没查出来的字段在 struct 里是零值,Save 会把它们全写成零值——静默数据丢失。
Updates(struct) |
Updates(map) |
Save(&p) |
|
|---|---|---|---|
| 零值字段 | 跳过 | 写 | 写 |
| 未提及的字段 | 跳过 | 跳过 | 写成当前 struct 的值 |
| 适用 | 只改非零字段(少见) | ★ 日常首选 | 手里有完整实体、要整体覆盖 |
// internal/repository/gorm_post_repo.go
// UpdateStatus 改文章状态。
// ★ status 可能是 0(草稿),所以【绝对不能】用 Updates(model.Post{Status: status})。
// 用 map 是这个函数存在的全部理由,注释必须写清楚,否则下一个人会"顺手优化"成 struct。
func (r *GormPostRepo) UpdateStatus(ctx context.Context, id int64, status model.Status) error {
tx := r.db.WithContext(ctx).
Model(&model.Post{}). // 指定操作哪张表(不带主键,条件走 Where)
Where("id = ?", id).
Updates(map[string]any{"status": status}) // ★ map:status=0 也会真的写进去
if tx.Error != nil {
return fmt.Errorf("UpdateStatus(%d): %w", id, tx.Error)
}
// ★ RowsAffected == 0 有两种可能:行不存在(含被软删除),或新值和旧值一样。
// 统一按"不存在"处理,让 handler 能返回 404。
// 要严格区分得先 SELECT 一次,多一次往返,这里不值得。
if tx.RowsAffected == 0 {
return fmt.Errorf("UpdateStatus(%d): %w", id, model.ErrNotFound)
}
return nil
}
4.6 ★★ 关联与 Preload:亲眼看到 N+1
关系推断(实测)
Comments 关系类型 = has_many
引用: posts.id -> comments.post_id
Tags 关系类型 = many_to_many
中间表推断名 = post_tags
中间表字段 PostID -> 列 post_id 中间表字段 TagID -> 列 tag_id
引用: 主表列=id -> 中间表列=post_id (OwnPrimaryKey=true)
引用: 主表列=id -> 中间表列=tag_id (OwnPrimaryKey=false)
多对多中间表的列名 = <两端结构体名>_<主键列名> 的 snake_case,即 post_id / tag_id——和 schema.sql 里手写的完全一致,什么都不用配。不一致时:
// internal/model/post.go —— 中间表列名和约定不一致时手动指定
Tags []Tag `gorm:"many2many:post_tags;joinForeignKey:article_id;joinReferences:label_id"`
// ↑本侧在中间表的列 ↑对侧在中间表的列
schema.sql 里的联合主键为什么兼容? PRIMARY KEY (post_id, tag_id) 保证一对组合只出现一次。GORM 写中间表用的是 INSERT INTO post_tags (post_id, tag_id) VALUES (...) ON DUPLICATE KEY UPDATE post_id=post_id——它就指望有唯一约束来做幂等。没有这个联合主键,重复绑定同一标签会插出多行。
N+1 问题:先看病,再吃药
// scratch/n_plus_one_test.go —— ★ 反例:循环里查关联
var posts []Post
db.Where("id IN ?", ids).Find(&posts) // 第 1 条
for i := range posts {
var tags []Tag
db.Model(&posts[i]).Association("Tags").Find(&tags) // 第 2..N+1 条
posts[i].Tags = tags
}
SELECT * FROM `posts` WHERE id IN (6,7,8) AND `posts`.`deleted_at` IS NULL
SELECT `tags`.* FROM `tags` JOIN `post_tags` ON `post_tags`.`tag_id`=`tags`.`id` AND `post_tags`.`post_id` = 6
SELECT `tags`.* FROM `tags` JOIN `post_tags` ON `post_tags`.`tag_id`=`tags`.`id` AND `post_tags`.`post_id` = 7
SELECT `tags`.* FROM `tags` JOIN `post_tags` ON `post_tags`.`tag_id`=`tags`.`id` AND `post_tags`.`post_id` = 8
1 + 3 = 4 条。列表页一页 20 条就是 21 条;100 条就是 101 条。每条一次网络往返,接口从 5ms 变 200ms。
// ✅ 正解
db.Preload("Tags").Where("id IN ?", ids).Find(&posts)
SELECT * FROM `posts` WHERE id IN (6,7,8) AND `posts`.`deleted_at` IS NULL
SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` IN (6,7,8)
SELECT * FROM `tags` WHERE `tags`.`id` IN (1,2,3,4)
3 条,而且和文章数量无关——20 条、100 条还是 3 条。
实测澄清:Preload 一个 many2many 要 3 条(主表 + 中间表 + 目标表);has_many 是 2 条(主表 + 子表);同时 preload Tags + Comments 实测 4 条。数量固定、不随行数增长,这才是重点。
Preload 的各种用法
// internal/repository/gorm_post_repo.go
db.Preload("Tags").Preload("Comments").Find(&ps) // 多个关联,实测 4 条 SQL
db.Preload(clause.Associations).First(&p, id) // 全部一级关联(import gorm.io/gorm/clause)
db.Preload("Tags.Posts").First(&p, id) // 嵌套,★ 实测 5 条 SQL,层层放大,慎用
db.Preload("Comments", func(tx *gorm.DB) *gorm.DB { // 条件 Preload
return tx.Order("created_at DESC")
}).Find(&ps)
db.Preload("Comments", "user_id = ?", uid).Find(&ps) // 条件简写
★ 易错点:条件 Preload 里的 Limit 是全局的,不是「每篇一条」
想「每篇文章带最新 1 条评论」,很多人写成 tx.Order("created_at DESC").Limit(1)。实测生成:
SELECT * FROM `comments` WHERE `comments`.`post_id` IN (6,7,8)
AND `comments`.`deleted_at` IS NULL ORDER BY created_at DESC LIMIT 1
↑★ 整个结果集只取 1 行
结果:post[0] comments=1 post[1] comments=0 post[2] comments=0
根因:Preload 就是一条 WHERE post_id IN (...) 的查询,LIMIT 作用在这条查询上,不是 per-parent。
修法:「每组前 N 条」得用窗口函数(ROW_NUMBER() OVER (PARTITION BY post_id ORDER BY created_at DESC))——这就是该用 db.Raw() 的场景(见 4.8)。
Preload vs Joins
// scratch/joins_test.go —— Joins:用关联表做【过滤】
db.Joins("JOIN post_tags pt ON pt.post_id = posts.id").
Joins("JOIN tags t ON t.id = pt.tag_id").
Where("t.name = ?", "go").Distinct("posts.*").Find(&joined)
SELECT DISTINCT posts.* FROM `posts`
JOIN post_tags pt ON pt.post_id = posts.id JOIN tags t ON t.id = pt.tag_id
WHERE t.name = 'go' AND `posts`.`deleted_at` IS NULL
实测:过滤出 3 篇,第一篇的 Tags 字段长度=0 ← ★ Joins 不填充关联字段!
Preload |
Joins |
|
|---|---|---|
| 目的 | 加载关联数据到结构体字段 | 用关联表做过滤/排序 |
| SQL | 多条独立查询 | 一条 JOIN |
| 填充关联字段 | ✅ | ❌(Tags 依然是空切片) |
| 一对多时行数 | 主表行数不变 | 会因 JOIN 放大,要 Distinct |
| 适用 | 「查文章,顺带带上标签」 | 「查带 go 标签的文章」 |
两者可以叠加:db.Joins("...").Where("t.name = ?", "go").Preload("Tags").Find(&ps)——用 Joins 筛、用 Preload 填。
关联写入
// internal/repository/gorm_post_repo.go
assoc := db.Model(&p).Association("Tags")
assoc.Append(&tag) // 加,不动已有的
assoc.Replace(tags) // ★ 全量替换:多的插、少的删
assoc.Delete(&tag) // 解绑单个(只删中间表行,不删 tag 本身)
assoc.Clear() // 清空所有绑定
Replace 实测生成的 SQL:
INSERT INTO `tags` (...) VALUES ('go',...,1),('tag2',...,4) ON DUPLICATE KEY UPDATE `id`=`id` ← 先确保标签存在
INSERT INTO `post_tags` (`post_id`,`tag_id`) VALUES (8,1),(8,4) ON DUPLICATE KEY UPDATE `post_id`=`post_id` ← 建绑定(幂等)
UPDATE `posts` SET `updated_at`='...' WHERE ... AND `id` = 8 ← 顺手 touch 主表
DELETE FROM `post_tags` WHERE `post_tags`.`post_id` = 8 AND `post_tags`.`tag_id` NOT IN (1,4) ← 删掉不在新列表里的
★ 易错点:FullSaveAssociations 与「关联对象不会被更新」
默认行为:GORM 只 upsert 关联关系,不更新关联对象本身。实测:
var tg Tag
db.Where(Tag{Name: "go"}).First(&tg)
tg.Name = "GO-改过的名字" // 改了内存里的
db.Model(&p).Association("Tags").Append(&tg)
// 再查:数据库里的 name 还是 "go" ← ★ 没变
根因:那条 ON DUPLICATE KEY UPDATE id=id 是个 no-op upsert,主键冲突时什么都不改。这通常正是你想要的(不会因为绑个标签就把标签名改了)。
真想连关联对象一起更新,用 db.Session(&gorm.Session{FullSaveAssociations: true}).Updates(&p)——但这很危险:它会把 p.Tags 里每个 Tag 的所有字段都 UPDATE 一遍,一个陈旧的 p 会把标签名写坏。建议不用它,标签改名单独 db.Model(&Tag{}).Where("id=?", id).Update("name", newName)。
反过来,创建文章时不要顺手创建标签,用 Omit(实测标签确实没被 INSERT):
db.Omit("Tags").Create(&p)
db.Omit(clause.Associations).Create(&p) // 跳过所有关联
4.7 CreateWithTags —— 闭包事务
对照三段式 ⑦:事务
// internal/repository/post_repo.go —— 第 3 课手写版
tx, err := r.db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() // ★ 必须 defer,否则 return 早退时连接泄漏
// (Commit 之后再 Rollback 是 no-op,安全)
res, err := tx.ExecContext(ctx, "INSERT INTO posts ...", ...)
if err != nil { return err } // ← defer 兜底回滚
id, err := res.LastInsertId()
if err != nil { return err }
for _, name := range names { /* ...插 tags、查 id、插 post_tags... */ }
return tx.Commit() // ★ 忘了这行,整个事务白干
// internal/repository/gorm_post_repo.go —— GORM 闭包版
// CreateWithTags 在【一个事务里】创建文章并绑定标签。
// 参数 p 是指针:GORM 会把自增 ID 回填进 p.ID,调用方拿得到。
func (r *GormPostRepo) CreateWithTags(ctx context.Context, p *model.Post, names []string) error {
// Transaction 的语义:
// 闭包 return nil → 自动 COMMIT
// 闭包 return error → 自动 ROLLBACK,并把这个 error 原样返回
// 闭包 panic → 自动 ROLLBACK,然后把 panic 继续往上抛
// ★ 不用写 defer Rollback,也不会忘记 Commit。
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// ★★★ 闭包里【每一次】数据库调用都必须用 tx,不能用 r.db。
// 用 r.db 就是走另一条连接,那条语句根本不在事务里(见下面实测)。
if err := tx.Create(p).Error; err != nil {
return fmt.Errorf("create post: %w", err)
}
if len(names) == 0 {
return nil // 提前返回 nil = 提交,不是取消
}
tags := make([]model.Tag, 0, len(names))
for _, name := range names {
var t model.Tag
// FirstOrCreate:先按条件查,查不到就用条件里的值创建。
// Where 传 struct 时零值字段被忽略(和 Updates 同一套规则),
// 这里只有 Name 一个非零字段,正好是我们要的语义。
if err := tx.Where(model.Tag{Name: name}).FirstOrCreate(&t).Error; err != nil {
return fmt.Errorf("firstOrCreate tag %q: %w", name, err)
}
tags = append(tags, t)
}
if err := tx.Model(p).Association("Tags").Replace(tags); err != nil { // 全量替换,幂等
return fmt.Errorf("bind tags: %w", err)
}
p.Tags = tags // 回填,方便 handler 直接返回
return nil // ← COMMIT
})
}
实测三种结局:
=== 成功提交 === 提交结果 err=<nil> id=9 ✅
=== 返回 error === 回滚结果 err=业务规则不满足;回滚后该 slug 的行数=0 ✅
=== panic === recover 到 panic: boom;panic 后该 slug 的行数=0 ✅
(panic 被重新抛出,你得自己在外层 recover)
★★ 高频坑:闭包里误用 db 而不是 tx
// ❌ 反例
db.Transaction(func(tx *gorm.DB) error {
db.Create(&Post{Title: "漏网文章", Slug: "tx-leak"}) // ← 用了外层的 db!
return fmt.Errorf("回滚")
})
实测:用 db 写的那条 err=回滚,回滚后行数=1 ← ★ 事务回滚了,这行还在。
错误现象:事务「回滚成功」,但部分数据留在库里,数据不一致。而且编译器完全不管——db 和 tx 都是 *gorm.DB,类型一模一样。
根因:tx 绑定在某条具体连接上,db 会从连接池另拿一条。两条连接互不知道对方。
防范:① 闭包参数统一叫 tx,code review 时一眼扫出闭包里的 db.;② 把 repository 方法拆成「收 *gorm.DB 参数」的形式,让事务能一路传下去:
// internal/repository/gorm_post_repo.go
func (r *GormPostRepo) createTx(tx *gorm.DB, p *model.Post) error { /* 只用 tx */ }
func (r *GormPostRepo) Create(ctx context.Context, p *model.Post) error {
return r.createTx(r.db.WithContext(ctx), p) // 非事务:传 db
}
func (r *GormPostRepo) CreateWithTags(ctx context.Context, p *model.Post, names []string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return r.createTx(tx, p) // 事务:传 tx
})
}
4.8 逃生舱:Raw 与 Exec
复杂查询直接写 SQL 是完全正当的选择,不是「没学会 GORM」。
// internal/repository/gorm_post_repo.go
// TagStat 只是这个查询的返回容器,不是模型,不用管表名约定。
type TagStat struct {
TagName string `json:"tag_name"` // 列 tag_name → 字段 TagName,靠 snake_case 反推
Cnt int64 `json:"cnt"`
}
// TopTags 统计标签使用次数。
// 用 Raw 的理由:GROUP BY + JOIN + ORDER BY 聚合值,用链式 API 写出来没人看得懂。
func (r *GormPostRepo) TopTags(ctx context.Context, limit int) ([]TagStat, error) {
var out []TagStat
err := r.db.WithContext(ctx).Raw(`
SELECT t.name AS tag_name, COUNT(*) AS cnt
FROM tags t
JOIN post_tags pt ON pt.tag_id = t.id
JOIN posts p ON p.id = pt.post_id
WHERE p.deleted_at IS NULL -- ★ Raw 不会自动加软删除条件,得自己写
GROUP BY t.name ORDER BY cnt DESC
LIMIT ?`, limit).Scan(&out).Error // ★ Raw 后面用 Scan,不是 Find
if err != nil {
return nil, fmt.Errorf("TopTags: %w", err)
}
return out, nil
}
// IncrView 原子自增,不要"先查再加再写"(有并发丢更新)
func (r *GormPostRepo) IncrView(ctx context.Context, id int64) error {
tx := r.db.WithContext(ctx).Exec(
"UPDATE posts SET view_count = view_count + 1 WHERE id = ? AND deleted_at IS NULL", id)
if tx.Error != nil {
return fmt.Errorf("IncrView(%d): %w", id, tx.Error)
}
if tx.RowsAffected == 0 {
return fmt.Errorf("IncrView(%d): %w", id, model.ErrNotFound)
}
return nil
}
实测:rows=[{TagName:go Cnt:3} {TagName:tag0 Cnt:1} {TagName:tag1 Cnt:1} ...]
用 Raw 必须记住三件事:① 参数一律用 ? 占位,绝不 fmt.Sprintf 拼字符串(SQL 注入);② Raw 不加软删除条件,需要就自己写;③ Raw + Scan 找不到不报 ErrRecordNotFound(实测 err=<nil> RowsAffected=0),靠 RowsAffected 判断。
4.9 ★ 高光时刻:service 和 handler 一行都不用改
// internal/service/post_service.go —— 第 4 课写的,这一课【一个字都没动】
// ★ 接口定义在【消费方】(service 包),不在 repository 包 —— 第 4 课 3.5 节的原则。
// repository 包完全不知道这个接口存在,它只管把方法写对。
type PostRepository interface {
Create(ctx context.Context, p *model.Post) error
GetByID(ctx context.Context, id int64) (*model.Post, error)
List(ctx context.Context, f model.PostFilter) ([]model.Post, error)
Update(ctx context.Context, p *model.Post) error
SoftDelete(ctx context.Context, id int64) error
ExistsBySlug(ctx context.Context, slug string) (bool, error)
}
第 4 课当时明说了「没有 CreateWithTags —— 那是第 8 课标签功能的事」。现在就是那一刻:标签功能上线,service 多了新需求,于是在消费方追加两个方法:
// internal/service/post_service.go —— 本课【新增】的部分
type PostRepository interface {
// ... 上面 6 个原样保留 ...
// 本课新增:标签相关。加在这里而不是新建一个接口,
// 是因为消费方(PostService)确实同时要用这 8 个方法。
GetWithTags(ctx context.Context, id int64) (*model.Post, error)
CreateWithTags(ctx context.Context, p *model.Post, names []string) error
UpdateStatus(ctx context.Context, id int64, status model.Status) error
}
注意这里发生了什么:接口变了,是因为业务需求变了(要标签),不是因为换了 ORM。这两件事在这一课同时发生,但彼此独立——你完全可以先用 database/sql 实现这三个新方法,下周再换 GORM。
// internal/service/post_service.go —— 这一课【一个字都没动】
type PostService struct {
repo PostRepository // ★ 依赖的是接口,不是任何具体实现
}
这一课唯一要改的,是 main.go 里的两行:
// cmd/server/main.go —— 换实现只改装配处
func main() {
// ---- 第 3~7 课:database/sql 实现 ----
// sqlDB, err := repository.OpenMySQL(cfg.DSN)
// repo := repository.NewSQLPostRepo(sqlDB)
// ---- 第 8 课:GORM 实现。★ 下面所有代码原封不动 ----
gdb, err := repository.OpenGorm(cfg.DSN)
if err != nil {
log.Fatal(err)
}
repo := repository.NewGormPostRepo(gdb) // ← 只有这两行变了
svc := &service.PostService{Repo: repo} // service 收接口,给它谁都行
h := &handler.PostHandler{Svc: svc}
auth := &middleware.AuthService{Secret: cfg.JWTSecret}
r := handler.NewRouter(h, auth) // 第 7 课的路由装配,没动
log.Fatal(r.Run(cfg.Addr))
}
这就是第 4 课那堆「为什么要多写一个接口」的回报
handler 不知道有 GORM,service 不知道有 GORM,只有 repository 包 import 了 gorm.io/gorm(前提是你选了 4.1 的方案 B)。
更值得注意的是错误链路也一行没改:
gorm.ErrRecordNotFound ← GORM 的技术错误
↓ repository 用 %w 翻译
model.ErrNotFound ← 第 4 课定的领域错误,没变
↓ service 原样透传
↓ handler 的 httpStatusFor(err) ← 第 4 课的纯函数,没变
404 {"code":"NOT_FOUND","message":"资源不存在"} ← 第 2 课的契约,没变
实测确认这条链是通的:errors.Is(err, model.ErrNotFound) = true,curl 打出来就是 404 + NOT_FOUND。
更实际的好处:可以同时留着两个实现,用配置切换,灰度验证行为一致后再删老的:
var repo service.PostRepository
if cfg.UseGorm {
repo = repository.NewGormPostRepo(gdb)
} else {
repo = repository.NewSQLPostRepo(sqlDB)
}
如果当初把 *sql.DB 直接传进 service,这一课就得改遍整个项目。架构的价值只在换东西的那一刻兑现,平时它看起来就是多余的样板代码。
⑤ 跑起来验证
关键动作:开着 SQL 日志跑,对照日志看 GORM 生成的每一条 SQL。
cd ~/go-blog && go build ./... && go run ./cmd/server 2>&1 | tee /tmp/gorm.log
如果 Logger 忘了设成 logger.Info,回去设,本节没法验证。
1) 创建带标签的文章 —— 看事务
curl -s -w ' <- %{http_code}\n' -X POST localhost:8080/api/v1/posts \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d "{\"title\":\"GORM 关联实战\",\"slug\":\"gorm-assoc-$(date +%s)\",\"content\":\"正文\",\"status\":1,\"tags\":[\"go\",\"gorm\",\"orm\"]}"
{"id":19,"title":"GORM 关联实战","tags":[{"id":1,"name":"go"},{"id":6,"name":"gorm"},{"id":7,"name":"orm"}]} <- 201
日志里应该看到一整个事务:
INSERT INTO `posts` (...) VALUES (NULL,'GORM 关联实战','gorm-assoc-1788712550',...)
SELECT * FROM `tags` WHERE `tags`.`name` = 'go' ORDER BY `tags`.`id` LIMIT 1
SELECT * FROM `tags` WHERE `tags`.`name` = 'gorm' ORDER BY `tags`.`id` LIMIT 1
INSERT INTO `tags` (`name`,`created_at`) VALUES ('gorm',...)
SELECT * FROM `tags` WHERE `tags`.`name` = 'orm' ORDER BY `tags`.`id` LIMIT 1
INSERT INTO `tags` (`name`,`created_at`) VALUES ('orm',...)
INSERT INTO `post_tags` (`post_id`,`tag_id`) VALUES (19,1),(19,6),(19,7) ON DUPLICATE KEY UPDATE ...
DELETE FROM `post_tags` WHERE `post_tags`.`post_id` = 19 AND `post_tags`.`tag_id` NOT IN (1,6,7)
观察:3 个标签产生了 3 次 SELECT——FirstOrCreate 是逐个查的,这也是一种 N+1。练习 3 会让你优化掉。
mysql -uroot -p123456 blog_dev -e \
"SELECT p.id,p.title,p.status,GROUP_CONCAT(t.name) tags FROM posts p
LEFT JOIN post_tags pt ON pt.post_id=p.id LEFT JOIN tags t ON t.id=pt.tag_id
WHERE p.id=19 GROUP BY p.id;"
id title status tags
19 GORM 关联实战 1 go,gorm,orm
2) 查详情 —— 数 Preload 的 SQL 条数
curl -s localhost:8080/api/v1/posts/19 | head -c 120; echo
日志里应该正好 3 条:
SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` = 19
SELECT * FROM `tags` WHERE `tags`.`id` IN (1,6,7)
SELECT * FROM `posts` WHERE `posts`.`id` = 19 AND `posts`.`deleted_at` IS NULL ORDER BY id LIMIT 1
↑★ 软删除条件是 GORM 自动加的
3) 列表 —— 确认 Preload 条数不随行数增长
curl -s 'localhost:8080/api/v1/posts?size=3' > /dev/null
curl -s 'localhost:8080/api/v1/posts?size=20' > /dev/null
两次日志里的 SQL 条数应该都是 3 条。如果 size=20 那次变成 21 条,说明 List 里漏了 Preload,正在循环查关联。
4) ★ PATCH status=0 —— 验证零值坑修对了
curl -s -w ' <- %{http_code}\n' -X PATCH localhost:8080/api/v1/posts/19 \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"status":0}'
mysql -uroot -p123456 blog_dev -e "SELECT id,status FROM posts WHERE id=19;"
{"id":19,"status":0} <- 200
UPDATE `posts` SET `status`=0,`updated_at`='...' WHERE `posts`.`deleted_at` IS NULL AND `id` = 19
↑★ 日志里有这个才对
id status
19 0 ← 真的改成 0 了
故意做错一次:把 UpdateStatus 里的 map 改成 Updates(model.Post{Status: status}) 重跑,接口依然返回 200,但日志变成 UPDATE posts SET updated_at='...' WHERE ...——status 消失了,数据库里还是旧值。亲手看一次这个现象,比记十遍规则管用。
5) 软删除
curl -s -w ' <- %{http_code}\n' -X DELETE localhost:8080/api/v1/posts/19 -H "Authorization: Bearer $TOK"
curl -s -w ' <- %{http_code}\n' localhost:8080/api/v1/posts/19
mysql -uroot -p123456 blog_dev -e "SELECT id,title,deleted_at FROM posts WHERE id=19;"
{"ok":true} <- 200
{"error":"文章不存在"} <- 404 ← 程序看不见了
id title deleted_at
19 GORM 关联实战 2026-09-06 09:31:51 ← 数据库里还在
日志里那条"删除"其实是:
UPDATE `posts` SET `deleted_at`='2026-09-06 09:31:51.104' WHERE `posts`.`id` = 19 AND `posts`.`deleted_at` IS NULL
⑥ TODO 练习
练习 1:把评论 repository 也换成 GORM
// internal/repository/gorm_comment_repo.go
// TODO(练习1): 实现 model.CommentRepository 接口的 GORM 版本:
// - ListByPost(ctx, postID int64, page, size int) ([]Comment, error)
// - Create(ctx, c *Comment) error
// - SoftDelete(ctx, id int64) error
// 要求:a) 一律用 WithContext 传 ctx
// b) 找不到时用 %w 包装 model.ErrNotFound,不泄露 gorm.ErrRecordNotFound
// c) 【不许改】service 和 handler 的任何代码
验收标准:grep -rn "gorm.io" internal/service internal/handler 输出为空;curl localhost:8080/api/v1/posts/1/comments 返回 200 且有数据;日志里 ListByPost 只产生 1 条 SQL。
练习 2:修一个零值 bug
// internal/repository/gorm_post_repo.go
// TODO(练习2): 下面这个函数有 bug —— 把 view_count 重置为 0 时不生效。
// 先【不改代码】跑一次,在 SQL 日志里找到证据(贴出那条 UPDATE),
// 再用本课三种修法里【任选两种】各写一版,并说明为什么选其中一种上线。
func (r *GormPostRepo) ResetViewCount(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Model(&model.Post{}).Where("id = ?", id).
Updates(model.Post{ViewCount: 0}).Error // ← bug 在这里
}
验收标准:交出修之前那条 UPDATE ... SET updated_at=...(没有 view_count)的日志;修之后日志里有 SET view_count=0;SELECT view_count FROM posts WHERE id=1 返回 0;说明里提到「RowsAffected=0 但 err=nil」这个静默失败特征。
练习 3:干掉 FirstOrCreate 的 N+1
// internal/repository/gorm_post_repo.go
// TODO(练习3): 现在 CreateWithTags 里每个标签查一次(N 个标签 = N 次 SELECT + 最多 N 次 INSERT)。
// 改成【最多 2 条 SQL】:
// 1. 用 clause.OnConflict{DoNothing: true} 批量 INSERT 所有标签
// 2. 用 Where("name IN ?", names).Find(&tags) 一次全查回来
// 提示:import "gorm.io/gorm/clause"
// tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&tags)
验收标准:传 5 个标签创建一篇文章,日志里标签相关 SQL 不超过 2 条(不含 post_tags 写入);重复创建同名标签不报 1062;SELECT COUNT(*) FROM tags WHERE name='go' 始终是 1。
练习 4:Preload vs Joins 各写一版
// internal/repository/gorm_post_repo.go
// TODO(练习4): 实现 ListByTag(ctx, tagName string, f model.PostFilter) ([]model.Post, error)
// ——「查出所有带某个标签的文章,并且每篇都要带上它的【全部】标签」。
// 版本 A: 只用 Preload(想想:能做到吗?为什么?)
// 版本 B: Joins 做过滤 + Preload 做填充
// 在注释里贴出两版生成的 SQL,说明为什么 B 是对的。
验收标准:版本 B 的结果里每篇文章的 Tags 包含它的所有标签,不只是被筛选的那一个;注释里说清了「Preload("Tags", "name = ?", tagName) 为什么不是答案」(它筛的是加载哪些标签,不是筛哪些文章);版本 B 用了 Distinct 且 SQL 日志证明主表没有因 JOIN 出现重复行。
练习 5:软删除的唯一索引冲突
// docs/soft-delete-unique.md
// TODO(练习5): 复现并解决:
// 1. 创建 slug 为 "dup-test" 的文章 → 200
// 2. 软删除它 → 200
// 3. 再创建 slug 为 "dup-test" 的文章 → ?
// 把第 3 步的报错原文贴出来,分析根因,给出【至少两种】解决方案并说明各自代价。
// (提示方向:slug 加删除后缀 / deleted_at 参与联合唯一索引 / 硬删除 + 归档表)
验收标准:贴出真实报错——实测是 Error 1062 (23000): Duplicate entry 'dup-test' for key 'posts.uk_posts_slug',开了 TranslateError 则是 duplicated key not allowed;两种方案都写清代价(比如「deleted_at 参与联合唯一索引」在 MySQL 里因为 NULL 不参与唯一性判断,需要用 0 而不是 NULL 表示未删除);选一种实现并跑通。
⑦ 自检清单
- 我
go get的是gorm.io/gorm,不是github.com/jinzhu/gorm。 - 我知道 GORM 底下跑的还是
database/sql+ 第 3 课那个 MySQL 驱动。 - 我开了
logger.Info,并且真的读过它生成的 SQL。 - 我用
db.DB()取回*sql.DB自己设了连接池参数(GORM 不管这个)。 - 我能说出
Post→posts、AuthorID→author_id的推断规则,以及怎么用TableName()/column覆盖。 - 我没有用
gorm.Model,四个字段是自己写的,并且说得出三条理由。 - 我知道
model.Post直接给 GORM 用会有两个静默问题(Summary string写成''而非 NULL、DeletedAt *time.Time让软删除完全失效变成物理删除),并且显式选择了方案 A 或方案 B,说得出自己这个选择的代价。 - 如果我选了方案 B,我知道
TableName()不改 many2many 的关联列推断(会推出post_row_id),已经写了joinForeignKey:post_id。 - 我的领域错误住在
model包,repository 用%w把gorm.ErrRecordNotFound/gorm.ErrDuplicatedKey翻译成model.ErrNotFound/model.ErrDuplicate。 - 我知道
AutoMigrate不删列但会改列类型,生产不依赖它,schema.sql是唯一真相源。 - 我能说清加了
gorm.DeletedAt之后,Find/Count/Updates/Delete各自的 SQL 变成了什么。 - 我知道
Unscoped()是软删除的逃生舱,也知道软删除会和唯一索引打架。 - 我知道
Updates(struct)和Where(struct)都会静默跳过零值字段,能背出三种修法,项目里改状态用的是 map。 - 我知道
Save会写所有字段,包括从不完整查询里来的零值。 - 我用
errors.Is(err, gorm.ErrRecordNotFound)判断单条找不到,并知道Find/Raw+Scan不报这个错。 - 我开了
TranslateError: true,否则errors.Is(err, gorm.ErrDuplicatedKey)恒为 false。 - 我在 SQL 日志里亲眼数过 N+1 的 N 条查询,也数过
Preload之后固定的 3 条。 - 我知道条件
Preload里的Limit是全局的,「每组前 N 条」得用窗口函数 +Raw。 - 我知道
Joins用来过滤、Preload用来填充,Joins不会填充关联字段。 - 我的事务闭包里每一次数据库调用都用了
tx,没有一处db.。 - 我知道复杂报表用
db.Raw()是正当选择,Raw里参数用?占位、软删除条件自己写。 - 这一课改完之后,
internal/service/和internal/handler/一行代码都没动,只改了main.go的两行装配(service 里的接口只是追加了三个标签方法,那是业务需求变了,不是换 ORM 逼的)。 - 我能画出「
gorm.ErrRecordNotFound→model.ErrNotFound→httpStatusFor→ 404NOT_FOUND」这条链,并说清中间哪几段是换框架时零改动的。
下一课预告:第 9 课加 Redis 缓存。你会发现一个新问题——GORM 的软删除、UpdatedAt 自动更新这些「隐式行为」,在加了缓存之后会变成缓存一致性的坑(数据库里悄悄变了,缓存不知道)。我们会用第 3 课学的「显式优于隐式」原则来处理它。