GORM 数据工程实战:批量导入、请求 DTO 与校验错误翻译
一、批量导入:从数据文件到数据库
CreateInBatches 的真实使用场景是后台批量导入、数据初始化、测试造数——不是日常 CRUD。所以教学也用真实形态:数据放在 seed/books.json,接口读文件、解析、分批插入。
seed/books.json(项目根目录新建):
[
{
"title": "The Go Programming Language",
"author": "Donovan & Kernighan",
"price": 4990
},
{ "title": "Go in Action", "author": "William Kennedy", "price": 5900 },
{
"title": "Concurrency in Go",
"author": "Katherine Cox-Buday",
"price": 4600
},
{ "title": "Cloud Native Go", "author": "Matthew Titmus", "price": 5200 },
{ "title": "100 Go Mistakes", "author": "Teiva Harsanyi", "price": 4800 }
]
handlers/book.go 新增(该文件的 import 补 encoding/json、os、fmt,以及 github.com/go-playground/validator/v10):
// SeedBooksFromFile 从 seed/books.json 批量导入图书(后台/数据初始化场景)
func SeedBooksFromFile(c *gin.Context) {
// 1. 读取数据文件
data, err := os.ReadFile("seed/books.json")
if err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取数据文件失败"})
return
}
// 2. 先解析进 DTO(而不是模型):文件里的键只能命中 DTO 声明过的字段
var inputs []createBookInput
if err := json.Unmarshal(data, &inputs); err != nil {
_ = c.Error(err)
c.JSON(http.StatusBadRequest, gin.H{"error": "数据文件格式错误"})
return
}
if len(inputs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "数据文件为空"})
return
}
// 3. 文件数据也要守同一套规则:json.Unmarshal 不跑 binding 标签,得手动过一遍 validator
v := validator.New()
books := make([]models.Book, 0, len(inputs))
for i, in := range inputs {
if err := v.Struct(in); err != nil {
_ = c.Error(err)
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("第 %d 条数据不合法", i+1)})
return
}
books = append(books, models.Book{Title: in.Title, Author: in.Author, Price: in.Price})
}
// 4. 分批插入(每批 100 条)
if err := db.DB.WithContext(c.Request.Context()).CreateInBatches(books, 100).Error; err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量插入失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("已导入 %d 本书", len(books))})
}
CreateInBatches(slice, 100) 按每批 100 条分批插入——数据再多也不会是一条巨型 INSERT。
json.Unmarshal不跑binding标签。binding是 gin 那一层的校验器,只在ShouldBindJSON里生效;文件数据想守同一套规则,就得像第 3 步那样手动validator.Struct(...)。顺带说明为什么错误里带”第几条”——种子文件动辄上百条,不定位到序号等于没报错。为什么不能直接用
c.ShouldBindJSON? 它只从c.Request.Body读数据,而这里的数据在磁盘文件里(POST /books/bulk的请求体是空的)。把文件塞进c.Request.Body再调用确实能跑,但会破坏后续中间件对 body 的读取,还会把”文件内容错误”误报成”请求体格式错误”。注意:
validator.New()和 gin 用的不是同一个实例。 两者都是go-playground/validator/v10,规则相同;但项目里若注册过自定义规则或字段名(binding.Validator.Engine().(*validator.Validate).RegisterValidation(...)),validator.New()会把这些配置全部丢掉——于是”请求体校验”和”文件校验”两条路径悄悄分叉。要共用就取 gin 的实例:v := binding.Validator.Engine().(*validator.Validate)。一个顺带的启示:数据文件的字段名就是 DTO 的
json标签(title/author/price),所以”加载文件”本质是反序列化到 DTO、再映射成模型——这和第二节「收什么和存什么分开」是同一条规则。金额在文件里直接写分(4990= 49.90 元),与入门篇的金额约定保持一致。注意:
os.ReadFile("seed/books.json")是相对当前工作目录的——服务必须从项目根目录启动(go run main.go),否则找不到文件。生产环境这类路径应改为可配置。
路由注册: main.go 路由区加一行:
r.POST("/books/bulk", handlers.SeedBooksFromFile)
分层重构后这个 handler 叫
Seed(文件读取移进 Service)——见《工程化(一)》。
测试:
curl -X POST http://localhost:8080/books/bulk
# {"message":"已导入 5 本书"}
# 用文件与查询增强篇的分页/搜索确认真实数据进来了
curl "http://localhost:8080/books?q=Go&pageSize=5"
# items 里应出现《The Go Programming Language》《Go in Action》等
⚠️ 批量导入非幂等: 重复 POST 会重复插入同一批书(代码没有去重/清表逻辑)。它定位是”数据初始化/测试造数”工具——重复执行前,先想好要不要清空
books(如TRUNCATE books RESTART IDENTITY)。
二、请求 DTO 的最后一公里:required 与零值、错误翻译
入门篇第五章已经把「校验放 DTO 不放模型」讲透,多表关联篇又用评论的 createCommentInput 复了一遍——这里不再重讲 DTO 本身,只补前文没做的两件事:Price 为什么要变成 *int(required 与零值的关系),以及怎么把 400 翻译成字段级错误(2.1)。更新接口的那一半见本篇 2.2 节。
// 请求 DTO:只声明接口"愿意收"的字段 + 校验规则。
// Price 用 *int:binding:"required" 对 int 的 0 会判"缺失"
// (validator 把零值视为未提供),指针才能区分"没传"和"传了 0"。
type createBookInput struct {
Title string `json:"title" binding:"required"`
Author string `json:"author" binding:"required"`
Price *int `json:"price" binding:"required,gte=0,lte=1000000"` // 单位:分,必填,0–10000 元
}
// CreateBook 改造后的绑定部分:
var input createBookInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
return
}
book := models.Book{Title: input.Title, Author: input.Author, Price: *input.Price}
binding:"required,gte=0,lte=1000000":价格必填、0 <= price <= 1000000(即 0–10000 元,单位:分),越界直接 400。为什么Price用*int?required对 int 的零值0也会判”字段缺失”,{"price":0}会被 400 拦下;改成*int后”没传”是nil(400)、”传了 0” 是&0(通过)——入门篇心智模型 2 里”nil表示没传”的指针语义,在这里第一次真正派上用场;- 行为变更提示: 入门篇第五章用的是
Price int(缺省按 0 创建、201),本篇收紧为*int+required:缺price的旧请求变成 400。这是刻意的语义收紧,不是笔误。
测试:
⚠️ 下面第 2、3 条 curl 断言的翻译消息要到 2.1 小节才会出现——本节的 handler 还只返回通用文案「请发送合法的 JSON」(见上方绑定代码)。先在这里理解指针语义,翻译后的输出 2.1 再兑现。
# 传 0:指针字段放行,201 创建成功
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title":"x","author":"y","price":0}' # 201
# 缺 price:required 命中(指针为 nil),400
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title":"x","author":"y"}' # 400:{"error":"缺少必填字段 Price"}
2.1 校验错误翻译:从「400」到「有意义的 400」
ShouldBindJSON 校验失败时返回的错误不是普通字符串,而是 validator.ValidationErrors(Gin 底层用 go-playground/validator)——每个条目都带 .Field()(字段名)、.Tag()(命中的规则:required / gte / lte)、.Param()(规则参数,如 1000000)。把它拆开,就能按字段/规则返回真正有用的消息:
// 写法一:只报第一个校验错误(一次说清一个)
if err := c.ShouldBindJSON(&input); err != nil {
var ve validator.ValidationErrors
if errors.As(err, &ve) && len(ve) > 0 { // 解包 validator 错误(经典写法,任何版本都成立)
e := ve[0] // 既然只取第一个,就不需要循环
switch e.Tag() {
case "required":
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必填字段 " + e.Field()})
case "gte":
c.JSON(http.StatusBadRequest, gin.H{"error": e.Field() + " 不能小于 " + e.Param()})
case "lte":
c.JSON(http.StatusBadRequest, gin.H{"error": e.Field() + " 不能大于 " + e.Param()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "参数不合法"})
}
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"}) // 非校验错误
return
}
// 写法二:聚合全部校验错误,一次告诉客户端所有字段问题
if err := c.ShouldBindJSON(&input); err != nil {
var ve validator.ValidationErrors
if errors.As(err, &ve) && len(ve) > 0 {
msgs := make([]string, 0, len(ve))
for _, e := range ve {
switch e.Tag() {
case "required":
msgs = append(msgs, "缺少必填字段 "+e.Field())
case "gte":
msgs = append(msgs, e.Field()+" 不能小于 "+e.Param())
case "lte":
msgs = append(msgs, e.Field()+" 不能大于 "+e.Param())
default:
msgs = append(msgs, "参数不合法")
}
}
c.JSON(http.StatusBadRequest, gin.H{"errors": msgs}) // 循环外统一返回
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
return
}
响应形态提示:
{"errors": [...]}(数组)是系列第一次出现的错误响应形态——此前所有接口的错误体都是{"error": string}。主线接口按写法一(单条消息)即可,聚合版留给”一次想告诉客户端所有字段问题”的接口自选;若要暴露多条字段错误,客户端需适配errors数组。import 需增补
github.com/go-playground/validator/v10(gin 的依赖,直接复用;errors已在book.go用到,无需重复引入);.Field()返回的是 Go 字段名(Price),要对应前端约定可再映射成小写。踩坑提示:
return别放在”遍历全部”的循环里——那会让循环只执行第一次就返回,根本”聚”不起来(语义退化成”只报第一个”,和写法一重复)。写法一用ve[0]明示”只要第一个”;写法二把返回移到循环外,循环才真正遍历完。两种语义选一种,别混成”循环里return却以为在聚合”。测试:
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title":"x","author":"y","price":-1}' # 400:{"error":"Price 不能小于 0"}
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title":"x","author":"y","price":1000001}' # 400:{"error":"Price 不能大于 1000000"}
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title":"x","author":"y"}' # 400:{"error":"缺少必填字段 Price"}
2.2 更新 DTO:PUT / PATCH 与零值陷阱
创建 DTO 只决定「收哪些字段」;更新 DTO 还要多回答一个问题:这个接口是整体替换(PUT)还是部分更新(PATCH)。答案不同,零值陷阱的解法也不同。
坑本身(入门篇第七章只点了个题,这里是完整解法):Updates 传入结构体时 GORM 忽略零值字段(0、""、false),所以 {"price":0} 会被静默跳过。根源不在 GORM 而在绑定层——非指针字段收 JSON 时,「没传」和「传了零值」落进同一个 0,信息在绑定时就丢了。三条路按语义选一条,下面每行 SQL 都是 GORM v1.31.2 用 DryRun 实测出来的(改完记得打开 SQL 日志自己复核一次):
写法(传 {"price":0}) |
实际生成的 SQL | 适用 |
|---|---|---|
Updates(models.Book{Price: 0}) |
SET author=...(零值被丢) |
✗ 就是那个坑 |
Select("title","author","price").Updates(models.Book{...}) |
SET title="",author=...,price=0,updated_at=... |
PUT |
Updates(updateBookInput{Price: &0}) |
SET price=0(nil 字段跳过,但 updated_at 不补) |
PATCH |
Updates(updates(&in))(反射助手转 map) |
SET price=0,updated_at=... |
PATCH + 自动时间戳 |
路子一:真 PUT —— Select 白名单(最简)
PUT 的语义本来就是「整体替换」:客户端传全量、服务端整列写回,零值自然是合法值,于是不需要指针,一行 Select 就够:
// 非指针:PUT 要求全量提交,所以 required 在这里是合理的
type updateBookInput struct {
Title string `json:"title" binding:"required"`
Author string `json:"author" binding:"required"`
Price int `json:"price" binding:"gte=0,lte=1000000"`
}
var in updateBookInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON(title、author 必填)"})
return
}
// Select 列出参与更新的列:列出的列零值照写,同时它本身就是白名单
result := db.DB.WithContext(c.Request.Context()).Model(&book).
Select("title", "author", "price").
Updates(models.Book{Title: in.Title, Author: in.Author, Price: in.Price})
- 一行
Select同时收口两件事:零值照写 + 只有白名单里的列能被写(deletedAt/coverUrl这类键进不来,批量赋值被堵住)。 - 代价是 PUT 语义本身要求的:客户端漏传
title等于「清空标题」,不是「不改」——所以required在这里必须加。 Select里的字符串是列名,写错不会报错、只是静默不生效;改完看一次 SQL 日志。
路子二:真 PATCH —— 指针 DTO 直接交给 Updates
PATCH 要表达「没传就不动」,这个信息只能靠指针留住(nil = 没传,&0 = 传了 0,正是本节创建 DTO 用 *int 的同一套指针语义)。不需要手写 map,把结构体直接交给 Updates 即可:
type updateBookInput struct {
Title *string `json:"title"`
Author *string `json:"author"`
Price *int `json:"price" binding:"omitempty,gte=0,lte=1000000"` // 更新时不能用 required
}
var in updateBookInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
return
}
db.DB.WithContext(c.Request.Context()).Model(&book).Updates(in)
实测语义:{"price":0} → SET price=0;{}(全 nil)→ 一条 SQL 都不生成(RowsAffected == 0 且无错误,返回 200 还是 400 由你决定)。
两个必须知道的代价:
updated_at不再自动补:传进去的结构体不是模型类型,GORM 的自动时间戳逻辑不生效(实测 v1.31.2;跨版本请以 SQL 日志为准)。要保留就走路子三。- 字段名要能落到列上:
Title→title命中默认命名策略;将来有CoverURL这类,需要写gorm:"column:cover_url"(json标签不参与 GORM 映射)。
⚠️ 指针 DTO 不能和
Select混用:Select("title","author","price").Updates(in)会把没传的字段写成 NULL(SET title=NULL,author=NULL,price=0),直接把数据洗掉。Select只配非指针 DTO(路子一)。
路子三:PATCH + 自动 updated_at —— 写一次的反射助手
把「指针 DTO → map」这件重复劳动收成一个函数(reflect / strings 两个库),之后每个更新接口调用处都只剩一行:
// updates:把指针 DTO 中「传了的」字段转成 map,键取 json 名(与列名一致时可直接更新)
func updates(v any) map[string]any {
out := map[string]any{}
rv, rt := reflect.ValueOf(v).Elem(), reflect.TypeOf(v).Elem()
for i := 0; i < rt.NumField(); i++ {
if f := rv.Field(i); f.Kind() == reflect.Ptr && !f.IsNil() {
out[strings.Split(rt.Field(i).Tag.Get("json"), ",")[0]] = f.Elem().Interface()
}
}
return out
}
db.DB.WithContext(c.Request.Context()).Model(&book).Updates(updates(&in)) // SET price=0, updated_at=...
走 map 分支后 updated_at 照常自动补,类型也没丢(值来自 f.Elem().Interface(),仍是 int / string)。《工程化(一)》会把这类横切小工具收进公共层。
nil的两种含义要分清: 创建 DTO 里Price *int是为了让binding:"required"能识别「根本没传 price」(nil→ 400);更新 DTO 里指针是为了区分「没传」(跳过)和「传了 0」(写 0)。同一套指针语义,两个不同目的——别在更新 DTO 上写required,那会把部分更新变成全量校验。
测试:
# 路子一:PUT 全量替换,0 真的写进去了
curl -X PUT http://localhost:8080/books/1 -H "Content-Type: application/json" \
-d '{"title":"","author":"新作者","price":0}'
# 路子二/三:PATCH,只传要改的字段;再 GET 应看到 price=0(路子三的 updated_at 也会变)
curl -X PATCH http://localhost:8080/books/1 -H "Content-Type: application/json" \
-d '{"price":0}'
三、路由速查
全系列注册过的路由,按注册顺序列出(最后一列是引入它的篇目):
| 方法 | 路径 | handler | 所属篇 |
|---|---|---|---|
| POST | /books |
CreateBook | 1 |
| GET | /books |
GetBooks(分页+搜索+评论数) | 1 → 3 改造 |
| GET | /books/:id |
GetBook(带最新 5 条 Comments 预览) | 1 → 2 改造 |
| PUT | /books/:id |
UpdateBook | 1 |
| DELETE | /books/:id |
DeleteBook | 1 |
| DELETE | /books/:id/permanent |
DeleteBookPermanently(可选) | 1 |
| POST | /books/:id/comments |
CreateComment | 2 |
| GET | /books/:id/comments |
ListComments | 2 |
| DELETE | /books/:id/comments/:cid |
DeleteComment | 2 |
| POST | /books/:id/cover |
UploadCover | 3 |
| GET | /uploads/* |
r.Static 静态服务 |
3 |
| POST | /books/bulk |
SeedBooksFromFile | 4(本篇) |
| POST | /books/:id/tags |
AddBookTag | 5 |
| DELETE | /books/:id/tags/:tid |
RemoveBookTag | 5 |
| GET | /tags/:name/books |
GetBooksByTag | 5 |
四、工程化条目:已在工程化篇落地
前文各处的预告,已分别在《工程化(一)》(分层、注入、测试、泛型分页)与《工程化(二)》(错误中间件、排序白名单、文件头嗅探、对象存储、连接池)落地;逐条对账表见《工程化(二)》§4.2。

