Go 博客教程 · 第 7 课 / 10
第 7 课:从 net/http 迁移到 Gin
前六课你用标准库把博客后端从零写了出来:ServeMux 路由、database/sql 映射、四层架构、中间件链、JWT 鉴权。这些都能跑,而且跑得很好。
这一课不是来说「前面白写了」。恰恰相反——正因为你手写过一遍,才有资格判断 Gin 省了什么、又拿走了什么。本课每一节都按同一个模板走:
第 X 课我们手写的是什么 → Gin 替你做了什么 → 代价是什么
学完你会发现,Gin 真正不可替代的只有一样:参数绑定与校验。其余多数是「顺手」。
① 本课目标
- 判断一个项目该不该上 Gin,而不是条件反射地
go get。 - 说清
*gin.Context扮演的四个角色和它的逃生舱。 - 用
bindingtag 写完整校验,并把 validator 的英文报错翻译成字段级中文错误。 - 认出三个高频事故:
BindJSON重复写响应、c.Abort()不 return、goroutine 里裸用c。 - 把第 6 课的 JWT 中间件改写成 Gin 版,并用
gin.WrapH让老 handler 继续跑着——渐进迁移。
② 前置检查
cd ~/go-blog
go build ./... && echo "编译通过"
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/api/v1/posts # 期望 401
go get github.com/gin-gonic/gin
本课实测环境:
| 组件 | 版本 |
|---|---|
| Go | go1.25.0 darwin/arm64 |
| github.com/gin-gonic/gin | v1.12.0 |
| github.com/go-playground/validator/v10 | v10.30.1 |
校验的活是 validator 干的,tag 的实际行为以 validator 版本为准。本课所有 tag 行为都在 v10.30.1 上实测过。
③ 核心概念
3.1 先问:该不该用?
| 场景 | net/http 够不够 |
|---|---|
| 3–5 个路由的内部工具 | 够,别上 Gin(Go 1.22+ ServeMux 已支持 {id} 和方法匹配) |
| 只有 GET、参数从路径取 | 够,r.PathValue("id") 一行 |
| 大量 POST/PATCH,请求体字段多 | 不够,手写 if req.Title == "" 会写到吐——Gin 的主战场 |
| 要给前端返回字段级校验错误 | 不够,手写要维护字段↔错误表 |
| 路由前缀多、每组鉴权策略不同 | 不够,r.Group(...).Use(...) 对标准库要自己包 |
| 单文件、想零依赖发布 | 够(Gin 约 +4MB,依赖树多几十个包) |
代价(诚实版):
- 多 30+ 传递依赖,供应链面变大。
c.Set/Get返回any,丢掉context.WithValue+ 私有 key 类型的类型安全。- 洋葱模型从「函数包装」变成「
c.Next()显式推进」,出错方式换了一批新的。 - handler 签名和标准库生态解耦,第三方中间件得找 Gin 版的。
本教程的博客有 CRUD + PATCH + 鉴权 + 多路由组,属于「Gin 划算」那一类。我们上。
3.2 最小可运行例子
// cmd/server/main.go —— 最小 Gin 服务
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default() // 自带 Logger + Recovery 两个中间件
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "pong"}) // gin.H 就是 map[string]any 的别名
})
r.Run(":8080") // 等价于 http.ListenAndServe(":8080", r)
}
*gin.Engine 本身实现了 http.Handler——这是理解 Gin 的关键:它就是一个大号 Handler。所以第 10 课的 &http.Server{Handler: r, ReadHeaderTimeout: ...} 依然照常用。
3.3 gin.Default() vs gin.New()——正好是第 5 课那两个中间件
实测:gin.Default() 中间件数=2, gin.New() 中间件数=0。那两个是 gin.Logger() 和 gin.Recovery()。
对照三段式 ①
| 第 5 课手写版 | Gin 版 | 代价 | |
|---|---|---|---|
| 访问日志 | func Logger(next http.Handler) http.Handler,自己包 ResponseWriter 才拿得到状态码 |
gin.Logger() |
输出格式固定,要改得用 LoggerWithConfig |
| panic 恢复 | func Recover(...) + defer recover() |
gin.Recovery() |
响应体是空的,只有 500;要 JSON 得用 gin.CustomRecovery |
实测 gin.Default() 遇到 panic:
[Recovery] 2026/09/06 - 09:29:37 panic recovered:
模拟业务 panic
...堆栈...
[GIN] 2026/09/06 - 09:29:37 | 500 | 632.959µs | GET "/boom"
客户端拿到:status=500 body="" ← 空 body
生产装配:
// cmd/server/main.go —— 生产:自己挑中间件,别用 Default 的默认口味
gin.SetMode(gin.ReleaseMode) // ★ 生产必设:关掉调试日志和路由注册打印
r := gin.New()
r.Use(
gin.CustomRecovery(func(c *gin.Context, err any) {
// 响应体形状和第 4 课 writeDomainError 走 500 分支时完全一致
c.AbortWithStatusJSON(500, APIError{Code: "INTERNAL_SERVER_ERROR", Message: "服务器内部错误"})
}),
RequestID(),
)
不设 ReleaseMode 启动时会看到 [GIN-debug] [WARNING] Running in "debug" mode...。也可用环境变量 GIN_MODE=release。
3.4 *gin.Context 是什么
一句话:它把标准库里四个分开的东西缝成了一个。
| Gin 里的角色 | 标准库对应 | 拿回原件 |
|---|---|---|
| 写响应 | http.ResponseWriter |
c.Writer(包装过,多了 Status()/Size()/Written()) |
| 读请求 | *http.Request |
c.Request |
| 传值/取消 | context.Context |
c.Request.Context() ← 要传给 service 层的是这个 |
| 请求内数据袋 | context.WithValue |
c.Set / c.Get |
逃生舱——接不了的场景随时退回标准库:
// internal/handler/escape.go
legacyHandler.ServeHTTP(c.Writer, c.Request) // 逃生舱 1:原始对象交给老代码
io.Copy(io.Discard, c.Request.Body) // 逃生舱 2:流式读 body
h.svc.Get(c.Request.Context(), 1) // 逃生舱 3:★ 传 c.Request.Context(),不要传 c
为什么 service 层收 c.Request.Context() 而不是 c? *gin.Context 确实实现了 context.Context,能编译过。但一旦传进 service,service 就 import 了 Gin——第 4 课的分层就破了。而且 c 的生命周期由对象池管理(3.6),传出去比 c.Request.Context() 危险得多。
3.5 c.Set / c.Get:省事,但丢了类型安全
对照三段式 ②
// internal/middleware/auth.go
// —— 第 5/6 课手写版:私有 key 类型,编译期就知道类型
type ctxKey struct{} // 外部包不可能伪造同一个 key
var ctxKeyUser ctxKey
r = r.WithContext(context.WithValue(r.Context(), ctxKeyUser, u)) // u 是 *model.User
u, ok := r.Context().Value(ctxKeyUser).(*model.User) // 第 6 课的 CurrentUser
// —— Gin 版:一行搞定,但 key 是字符串、值是 any
c.Set(ctxKeyUser, u)
v, ok := c.Get(ctxKeyUser) // v 的静态类型是 any
u, ok2 := v.(*model.User) // ★ 必须断言,断言错了只有运行时才知道
实测(用 int64 做的最小验证):{"cast_ok":true,"get_ok":true,"raw_type":"int64","uid":42}——Get 拿回来的确实是 any,动态类型对得上才断言得出来。
代价:key 是字符串(两个中间件用同一个名字会互相覆盖,编译器不管);值是 any(c.MustGet(...).(*model.User) 类型不对就 panic);重构改类型时编译器不会帮你找出所有取值点。
缓解办法——把断言收口到一个函数,对齐第 6 课的 CurrentUser / MustCurrentUser 这对命名:
// internal/middleware/auth.go —— 字符串 key 只出现在这个文件里
const ctxKeyUser = "current_user"
// CurrentUser 取当前用户。返回 ok=false 表示未鉴权(而不是 panic)。
// 签名和第 6 课的 CurrentUser(ctx) 保持一致,只是取值源换成了 gin.Context。
func CurrentUser(c *gin.Context) (*model.User, bool) {
v, exists := c.Get(ctxKeyUser)
if !exists {
return nil, false
}
u, ok := v.(*model.User) // 唯一一处断言,只需维护一份
return u, ok
}
// MustCurrentUser 用于【确定】经过了 RequireAuth 的 handler。
// 取不到就 panic —— 和第 6 课同款理由:说明路由装配漏了 RequireAuth,是程序 bug。
func MustCurrentUser(c *gin.Context) *model.User {
u, ok := CurrentUser(c)
if !ok {
panic("MustCurrentUser: 路由没挂 RequireAuth")
}
return u
}
3.6 ★ c 不能在 goroutine 里裸用
Gin 最高频的生产事故,而且不报错,只串数据。
根因:gin.Engine 用 sync.Pool 复用 *gin.Context。请求处理完,c 被归还池子、接着被下一个请求拿去用。你的 goroutine 还在跑,手里那个 c 已经装着别人的数据了。
300 并发实测(go test -race):
// scratch/race_test.go —— 复现要点
r.GET("/x/:id", func(c *gin.Context) {
want := c.Param("id")
c.Set("uid", want)
go func() { // ← 反例:裸用 c
time.Sleep(5 * time.Millisecond)
got, _ := c.Get("uid")
if fmt.Sprint(got) != want { /* 串数据 */ }
}()
cc := c.Copy() // ← 正解:先 Copy
go func() {
time.Sleep(5 * time.Millisecond)
got, _ := cc.Get("uid")
if fmt.Sprint(got) != want { /* 串数据 */ }
}()
c.JSON(200, gin.H{"id": want})
})
WARNING: DATA RACE
裸用 c : 正确=90 串数据/丢数据=210
c.Copy(): 正确=300 串数据/丢数据=0
300 次里 210 次拿到了别的请求的数据,没有 panic、没有日志。正确写法:
// internal/handler/post_handler.go —— 异步任务必须 c.Copy()
cc := c.Copy() // ★ Copy 出一个只读快照,专供 goroutine 用
go func() {
h.notifier.Push(cc.GetString("request_id"), p.ID)
}()
c.JSON(201, p)
c.Copy() 的 index 被设成 abortIndex,不能再调 c.Next(),也不该再往里写响应——它只是数据快照。
④ 函数逐个精讲
4.1 NewRouter —— 路由组与中间件装配
// internal/handler/router.go
package handler
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
// NewRouter 装配整个 HTTP 面。
// 参数是显式依赖(第 4 课的依赖注入原则不变):要什么就传什么,不在函数里 new。
// auth 收的是第 6 课定义的 AuthService【接口】,不是具体结构体 —— "accept interfaces"。
// 返回 *gin.Engine 而不是 http.Handler,是为了 main.go 还能调 r.Run / r.Routes()。
func NewRouter(h *PostHandler, auth middleware.AuthService) *gin.Engine {
r := gin.New() // 不用 Default,中间件自己挑
r.Use(gin.Recovery(), RequestID()) // 全局中间件,顺序 = 执行顺序
r.GET("/healthz", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
r.GET("/legacy", gin.WrapF(legacyStdlibHandler)) // ★ 渐进迁移,见 4.6
v1 := r.Group("/api/v1") // ---- 路由组:统一前缀 ----
{ // 花括号只是排版习惯,不是语法要求
// 公开读接口。挂 OptionalAuth:匿名能看已发布文章,
// 登录用户还能看到自己的草稿 —— 沿用第 6 课那条业务规则。
pub := v1.Group("")
pub.Use(middleware.OptionalAuthGin(auth))
{
pub.GET("/posts", h.List)
pub.GET("/posts/:id", h.Get)
}
authed := v1.Group("") // ★ 空前缀 = 只加中间件,不改路径
authed.Use(middleware.RequireAuthGin(auth)) // 组级中间件:只对这个组生效
{
authed.POST("/posts", h.Create)
authed.PATCH("/posts/:id", h.Patch)
authed.DELETE("/posts/:id", h.Delete)
}
}
return r
}
func legacyStdlibHandler(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"from":"net/http 老 handler,原样复用"}`)
}
对照三段式 ③
Go 1.22 ServeMux |
Gin | |
|---|---|---|
| 路径参数 | "GET /posts/{id}" + r.PathValue("id") |
"/posts/:id" + c.Param("id") |
| 通配 | "/files/{path...}" |
"/files/*path" |
| 路由组 | 没有,要自己 StripPrefix + 逐个套中间件 |
r.Group("/api/v1") + 组级 Use |
| 代价 | — | 路由语法换了一套;:id 和 *path 在同一层前缀下会冲突(radix 树限制),ServeMux 更宽松 |
易错点:Group("") 的空前缀。想给一组路由额外加中间件但不改路径,前缀写 "" 而不是 "/"——写 "/" 会让路径变成 /api/v1//posts。
参数取值速查(实测于 gin v1.12.0):
// internal/handler/post_handler.go —— 参数取值速查
c.Param("id") // /posts/:id → "7"
c.Param("path") // /files/*path,访问 /files/a/b/c.txt → "/a/b/c.txt" ★ 带前导斜杠
c.Query("size") // ?size=20 → "20";没传 → ""
c.DefaultQuery("page", "1") // 没传 → "1"
c.GetQuery("size") // 返回 (值, 是否存在) —— 区分"传了空串"和"没传"
c.GetHeader("Authorization") // 请求头
GET /api/v1/posts/7?size=20 → {"id":"7","page":"1","size":"20"}
GET /api/v1/files/a/b/c.txt → {"path":"/a/b/c.txt"}
GET /api/v1/files/ → {"path":"/"}
4.2 Create —— 参数绑定与校验(本课重头戏)
第 4 课的手写版长这样:
// internal/handler/post_handler.go —— 第 4 课手写版(节选)
// decodeJSON 是第 2 课写的泛型辅助:第一个参数是 w,因为 http.MaxBytesReader 需要它
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, 400, "BAD_REQUEST", "请求体不是合法 JSON"); return
}
// ↓↓↓ 这一坨就是 Gin 要替你干掉的东西 ↓↓↓
if req.Title == "" {
writeError(w, 400, "VALIDATION_FAILED", "title 不能为空"); return
}
if n := len([]rune(req.Title)); n < 3 || n > 200 {
writeError(w, 400, "VALIDATION_FAILED", "title 长度必须在 3-200 之间"); return
}
if req.Slug == "" {
writeError(w, 400, "VALIDATION_FAILED", "slug 不能为空"); return
}
if req.Status != model.StatusDraft && req.Status != model.StatusPublished &&
req.Status != model.StatusOffline {
writeError(w, 400, "VALIDATION_FAILED", "status 只能是 0/1/2"); return
}
if len(req.Tags) > 5 {
writeError(w, 400, "VALIDATION_FAILED", "标签最多 5 个"); return
}
// ...还有 tags 每一项的长度校验...
注意手写版这里有个缺陷:writeError 只填了 Code 和 Message,APIError.Field 一直是空的——前端没法高亮出错的输入框。Gin 版会顺手把这个补上。
Gin 版:
// internal/handler/post_handler.go
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
)
// 校验规则写在 tag 里,是声明式的
type createPostReq struct {
Title string `json:"title" binding:"required,min=3,max=200"`
Slug string `json:"slug" binding:"required,max=200"`
Content string `json:"content" binding:"required"`
Status model.Status `json:"status" binding:"oneof=0 1 2"`
// ↑ 第 1 课定义的具名类型(底层 int8),常量是 model.StatusDraft/StatusPublished/StatusOffline。
// binding 认的是【底层数值】,所以 tag 里写 0 1 2 而不是常量名。
Tags []string `json:"tags" binding:"omitempty,max=5,dive,min=1,max=64"`
// ↑ dive = "下潜到切片每个元素上"再应用后面的规则
}
// Create 处理 POST /api/v1/posts。
// 它只做三件事:绑参 → 调 service → 翻译成 HTTP 响应。
// ★ service 层和 repository 层这一课一行都没改 —— 第 4 课分层的兑现。
func (h *PostHandler) Create(c *gin.Context) {
var req createPostReq
// ShouldBindJSON:解析 body + 跑 validator。失败时它【只返回 error,不碰响应】
if err := c.ShouldBindJSON(&req); err != nil {
// ★ 响应体依然是第 2 课定稿的 APIError —— 换框架【不改变对外契约】
c.JSON(http.StatusBadRequest, bindError(err)) // 见 4.3
return
}
u, ok := middleware.CurrentUser(c) // 鉴权中间件塞进来的 *model.User,见 4.4
if !ok {
c.JSON(http.StatusUnauthorized, APIError{Code: "UNAUTHORIZED", Message: "未登录"})
return
}
_ = u.ID // 作者 ID,交给 service 层落库
// ★ 传 c.Request.Context() 而不是 c —— 保持 service 层对 Gin 无感知
p, err := h.svc.Create(c.Request.Context(), req.Title, req.Slug, req.Content, req.Status, req.Tags)
if err != nil {
// ★ 一条错误出口,和第 4 课的 writeDomainError 完全同构:
// httpStatusFor(err) 这个纯函数【一行都不用改】,只换了写响应的方式。
writeDomainErrorGin(c, err)
return
}
c.JSON(http.StatusCreated, toResponse(p)) // 第 4 课的 DTO 转换,照旧
}
对照三段式 ④
| 手写版 | Gin 版 | 代价 | |
|---|---|---|---|
| 校验代码量 | 上面那 15 行 if,每加一字段涨 3 行 |
5 行 struct tag | 规则写在字符串里,打错 tag 名不报错,直接静默不校验 |
| 错误信息 | 你写的中文,天然可读 | validator 的英文 | 必须自己写翻译层(4.3) |
| 一次返回几个错 | 遇到第一个就 return | 全部字段一起返回 | — |
★ ShouldBindJSON vs BindJSON——新手头号坑
ShouldBindXxx |
BindXxx / MustBindWith |
|
|---|---|---|
| 失败时 | 只返回 error,不碰响应 | 自动 c.AbortWithError(400, err),400 已经写出去了 |
| 你该怎么写 | if err != nil { c.JSON(...); return } |
if err != nil { return } ← 只能直接 return |
| 推荐 | ✅ 用这个 | ❌ 除非你接受它的默认 400 |
用 BindJSON 后又写自己的响应会怎样?实测(debug 模式):
// internal/handler/post_handler.go —— ❌ 反例
if err := c.BindJSON(&req); err != nil {
c.JSON(422, gin.H{"error": "我以为我在返回 422"})
return
}
[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 422
实际客户端收到:
status=400 ← 不是 422!header 早发出去了
body={"error":"我以为我在返回 422"}
错误现象:状态码和 body 对不上,前端按 422 写的分支永远不触发;release 模式下连那行 WARNING 都看不到,纯静默。
根因:BindJSON 走 MustBindWith,失败时 c.AbortWithError(400, err) 已经 WriteHeaderNow() 发出 400。HTTP header 一旦发出不可撤回,后面只能改 body。
正确写法:统一只用 ShouldBindXxx 系列,写进代码规范。
常用 binding tag 速查表(v10.30.1 实测)
| tag | 含义 | 例子 | 实测 Param() |
|---|---|---|---|
required |
非零值 | binding:"required" |
"" |
omitempty |
零值就跳过后面所有规则 | omitempty,email |
— |
min=N / max=N |
字符串按字符数、数字按大小、切片按长度 | min=3,max=200 |
"3" |
len=N |
精确长度 | len=11 |
"11" |
gte/lte/gt/lt |
数值比较 | gte=0,lte=100 |
"0" |
email / url |
格式 | required,email |
"" |
oneof=a b c |
枚举,空格分隔 | oneof=draft published |
"draft published" |
dive |
下潜到切片/map 元素 | max=5,dive,min=1 |
— |
eqfield=X |
和同结构体另一字段相等 | eqfield=Password |
"Password" |
required_if=X val |
条件必填 | required_if=Type 1 |
"Type 1" |
- |
完全跳过该字段 | binding:"-" |
— |
易错点:oneof 的值用空格分隔不是逗号——写成 oneof=0,1,2 会被解析成一个值 "0" 加两个非法 tag。带空格的值用单引号:oneof='draft mode' published。
★★ required 遇上零值的陷阱(呼应第 4 课的 PATCH)
required 的判定是「不是该类型的零值」:
| 字段类型 | 传 {"status":0} |
传 {} |
|---|---|---|
Status model.Status + required |
❌ 失败 | ❌ 失败 |
Status *model.Status + required |
✅ 通过,*Status == 0 |
❌ 失败 |
POST /z {"status":0,"draft":false} (int / bool 字段)
→ 400 Key: 'zeroReq.Status' Error:Field validation for 'Status' failed on the 'required' tag
Key: 'zeroReq.Draft' Error:Field validation for 'Draft' failed on the 'required' tag
POST /rp {"status":0} (Status 是 *int)→ 200 ← 指针非 nil,required 通过
POST /rp {} → 400 ← 指针是 nil
这正是 PATCH 部分更新需要的语义:「没传」和「传了 0」必须分得开。
// internal/handler/post_handler.go —— PATCH 用指针字段
type patchPostReq struct {
// ★ 指针:nil = 前端没传;非 nil = 明确要改成这个值(哪怕是 0)
Status *model.Status `json:"status" binding:"omitempty,oneof=0 1 2"`
Title *string `json:"title" binding:"omitempty,min=3,max=200"`
}
func (h *PostHandler) Patch(c *gin.Context) {
var uri struct {
ID int64 `uri:"id" binding:"required,min=1"`
}
if err := c.ShouldBindUri(&uri); err != nil {
// INVALID_ID 是第 2 课就定下的 code,别在这里另发明一个
c.JSON(http.StatusBadRequest, APIError{Code: "INVALID_ID", Message: "id 非法", Field: "id"})
return
}
var req patchPostReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, bindError(err))
return
}
if req.Status != nil { // nil 检查 = "前端到底传没传"
if err := h.svc.SetStatus(c.Request.Context(), uri.ID, *req.Status); err != nil {
writeDomainErrorGin(c, err) // model.ErrNotFound → 404,一条出口
return
}
}
if req.Title != nil { /* ... */ }
c.JSON(http.StatusOK, gin.H{"id": uri.ID})
}
好消息(实测):omitempty 对非 nil 指针不生效——*int 指向 0 时,omitempty,oneof=1 2 依然会跑 oneof 并拒绝。所以「指针 + omitempty」语义是干净的:nil 跳过,非 nil 照常校验。
ShouldBindQuery / ShouldBindUri
// internal/handler/post_handler.go
type listQuery struct {
// form tag 的 default= 是 Gin 自己的扩展,不是 validator 的
Page int `form:"page,default=1" json:"page" binding:"min=1"`
Size int `form:"size,default=10" json:"size" binding:"min=1,max=100"`
Status string `form:"status" json:"status" binding:"omitempty,oneof=draft published"`
}
func (h *PostHandler) List(c *gin.Context) {
var q listQuery
if err := c.ShouldBindQuery(&q); err != nil { // ← 只看 URL query,不读 body
c.JSON(http.StatusBadRequest, bindError(err))
return
}
// q.Page 默认 1、q.Size 默认 10,不用自己写 if == 0
ps, err := h.svc.List(c.Request.Context(), q.Page, q.Size)
// ...
}
GET /posts/9 → {"uri":{"ID":9},"query":{"Page":1,"Size":10,"Status":""}}
GET /posts/9?size=500 → 400 Size failed on the 'max' tag
GET /posts/0 → 400 ID failed on the 'required' tag ← 0 是零值
易错点(实测):ShouldBindUri 遇到类型转不过去时返回的不是 validator.ValidationErrors:
GET /posts/abc → {"uri_err":"strconv.ParseInt: parsing \"abc\": invalid syntax"}
所以 bindError() 里必须先 errors.As 判断,不能直接强转。
4.3 bindError —— 把 validator 的英文翻译成 APIError
不做这一步,前端拿到的是 Key: 'createPostReq.Title' Error:Field validation for 'Title' failed on the 'min' tag,没法展示。
关键约束:响应体必须还是第 2 课定稿的 APIError。 换框架不该改变对外契约——前端已经按 {code, message, field} 写好了,Gin 是我们的内部实现细节,凭什么让别人跟着改。
// internal/handler/response.go —— 第 2 课定稿,这一课【一个字都没动】
type APIError struct {
Code string `json:"code"` // 机器读:INVALID_ID / POST_NOT_FOUND
Message string `json:"message"` // 人读
Field string `json:"field,omitempty"` // 校验错误才有,其他时候整个键消失
}
// internal/handler/bind_error.go
package handler
import (
"errors"
"fmt"
"reflect"
"strings"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// tag → 中文模板。含 %s 的用 fe.Param() 填空。
// 只覆盖项目实际用到的 tag,新增 tag 记得回来加一行。
var tagMsg = map[string]string{
"required": "不能为空",
"min": "长度/数值不能小于 %s",
"max": "长度/数值不能大于 %s",
"len": "长度必须等于 %s",
"email": "必须是合法邮箱",
"url": "必须是合法 URL",
"oneof": "只能是这几个值之一:%s",
"gte": "必须 >= %s",
"lte": "必须 <= %s",
"eqfield": "必须和 %s 一致",
}
// VALIDATION_FAILED 是校验失败【专用】的 code。
// ★ 为什么不用第 4 课 errorCodeFor(400) 推出来的 "BAD_REQUEST"?
// 因为前端要区分两种 400:"字段填错了"(能高亮输入框重填) 和
// "请求根本不合法"(只能提示重试)。给校验单独一个 code 是【对外契约的一部分】。
const codeValidationFailed = "VALIDATION_FAILED"
// bindError 把绑定/校验错误转成第 2 课的 APIError。
// 返回值是 APIError 而不是 map:对外契约不能因为换框架而改形状。
func bindError(err error) APIError {
// ★ 第一步:分辨这到底是不是校验错误。
// JSON 语法错误、类型不匹配、uri 参数 ParseInt 失败,都【不是】ValidationErrors。
var ve validator.ValidationErrors
if !errors.As(err, &ve) {
// 这类错误没有"哪个字段"的概念,Field 留空(omitempty 会让这个键消失)
return APIError{Code: "BAD_REQUEST", Message: "请求体格式错误:" + err.Error()}
}
// ★★ 第二步:只取【第一个】错误。
// validator 一次能返回 N 个字段的错误,但 APIError 只有一个 Field 位。
// 这是一个【必须显式做的取舍】,见下面的说明。
fe := ve[0]
tpl, ok := tagMsg[fe.Tag()] // fe.Tag() 是失败的那条规则,如 "min"
if !ok {
// 兜底:没登记的 tag 也别返回空串,否则前端看到空提示更懵
return APIError{Code: codeValidationFailed, Field: fe.Field(),
Message: fmt.Sprintf("不满足规则 %s", fe.Tag())}
}
msg := tpl
if strings.Contains(tpl, "%s") {
msg = fmt.Sprintf(tpl, fe.Param()) // min=3 的 "3"
}
return APIError{Code: codeValidationFailed, Field: fe.Field(), Message: msg}
}
// useJSONFieldName 让 fe.Field() 返回 json tag 里的名字(title)而不是 Go 名(Title)。
// ★★ 必须在【任何一次校验发生之前】调用 —— validator 会缓存结构体解析结果,
// 先校验过一次再注册,那个结构体就永远拿不到新名字了。放 main() 第一行最保险。
func useJSONFieldName() {
v, ok := binding.Validator.Engine().(*validator.Validate)
if !ok {
return
}
v.RegisterTagNameFunc(func(f reflect.StructField) string {
name := strings.SplitN(f.Tag.Get("json"), ",", 2)[0] // 去掉 ",omitempty"
if name == "" || name == "-" {
return f.Name // 没写 json tag 就退回 Go 名
}
return name
})
}
★ 一个必须显式做的取舍:N 个错误 vs 1 个 Field
这是本节最值得停下来想的地方。
| 手写版 | Gin + validator | |
|---|---|---|
| 一次能发现几个错 | 1 个(遇到第一个 if 就 return) |
全部(ve 是个切片) |
APIError 能装几个 |
1 个 | 1 个 |
框架给了你更多信息,但你的对外契约装不下。 三条路,各有代价:
- 只报第一个(本课选这条)。契约零改动,前端不用动。代价:用户填错 3 个字段要来回提交 3 次。
- 扩展契约,加一个
Fields []FieldError。体验最好。代价:这是一次 API 变更,要和前端约定、要考虑老客户端兼容——得走版本流程,不能因为「框架顺手支持」就偷偷改。 - 拼成一条
Message("title 长度不能小于 3;slug 不能为空")。折中。代价:Field只能填第一个,前端仍然只能高亮一个框。
这一节真正的教学点不是「怎么翻译错误」,是「框架能力变强 ≠ 你的接口就该跟着变」。 契约是对外承诺,改它要有理由、要走流程,不能被内部重构推着走。练习 4 会让你实现第 2 条并评估代价。
ValidationFieldError 上还有这些(实测值):Namespace()→createPostReq.Title、Field()→Title(注册 TagNameFunc 后是 title)、StructField()→永远是 Go 名、Tag()→min、Param()→3(oneof 时是 draft published offline)、Kind()→string、Value()→用户实际传的值;切片元素的 Field() 是 Tags[1]。
实测最终效果(真起服务 curl 出来的):
{"code":"VALIDATION_FAILED","message":"长度/数值不能小于 3","field":"title"}
易错点:RegisterTagNameFunc 读的是 json tag。ShouldBindQuery 的结构体如果只有 form tag,Field() 会退回 Go 名(实测拿到 "Size" 而不是 "size"),前端就对不上了。两个 tag 都写:
Size int `form:"size,default=10" json:"size" binding:"min=1,max=100"`
复用第 4 课的错误映射:writeDomainErrorGin
第 4 课的 httpStatusFor 是个纯函数——不碰 http.ResponseWriter,只做「领域错误 → 状态码」。所以换框架时它一行都不用改,只需要换写响应的那一步:
// internal/handler/errors.go —— httpStatusFor / errorCodeFor 原样保留,一个字没动
// internal/handler/errors_gin.go —— 只新增这一个函数
// writeDomainErrorGin 是第 4 课 writeDomainError 的 Gin 版。
// 对比一下就知道:【判断逻辑完全复用,只有最后一行不同】。
func writeDomainErrorGin(c *gin.Context, err error) {
status := httpStatusFor(err) // ← 纯函数,复用,零改动
msg := err.Error()
if status == http.StatusInternalServerError {
// ⭐ 和第 4 课同款理由:500 的细节只进日志不给客户端
log.Printf("未预期错误: %v", err)
msg = "服务器内部错误"
}
// ↓ 唯一的区别:第 4 课是 writeError(w, status, code, msg)
c.JSON(status, APIError{Code: errorCodeFor(status), Message: msg})
}
对照三段式 ⑤
| 第 4 课手写版 | Gin 版 | 代价 | |
|---|---|---|---|
| 错误→状态码 | httpStatusFor(err) |
同一个函数 | 无 —— 纯函数是最容易迁移的代码 |
| 状态码→Code | errorCodeFor(status) |
同一个函数 | 无 |
| 写响应 | writeError(w, ...) |
c.JSON(status, APIError{...}) |
多维护一个 _gin 版本;过渡期两版并存 |
可迁移性和纯度成正比。 你的业务判断越不碰 http.ResponseWriter,换框架时要动的代码就越少——这是第 4 课分层原则在这一课的第二次兑现。
4.4 RequireAuthGin —— 第 6 课中间件的 Gin 版
// internal/middleware/auth.go —— 第 6 课标准库版
func RequireAuth(svc AuthService) Middleware {
return func(next http.Handler) http.Handler { // ← 外层:收下一个 Handler
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r) // 第 6 课的辅助函数
if !ok {
writeError(w, 401, "UNAUTHORIZED", "缺少 Bearer token") // 第 2 课的 writeError
return // ← 不调 next 就等于拦截
}
u, err := svc.UserFromToken(r.Context(), token)
if err != nil {
writeError(w, 401, "UNAUTHORIZED", "token 无效")
return
}
r = r.WithContext(context.WithValue(r.Context(), ctxKeyUser, u)) // ← 塞进 context
next.ServeHTTP(w, r) // ← 显式往下传
})
}
}
// internal/middleware/auth_gin.go —— Gin 版
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const ctxKeyUser = "current_user"
// RequireAuthGin 是第 6 课 RequireAuth 的 Gin 版。
// 名字带 Gin 后缀是为了【迁移过渡期两版共存】,全部迁完可以把后缀去掉。
// 本质差异:它【不接收 next】,靠 c.Next()/c.Abort() 控制流转。
func RequireAuthGin(svc AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if !ok || token == "" {
// ★ AbortWithStatusJSON = 写响应 + 标记 abort。但它【不会让函数返回】!
// ★ 依然是 APIError,和标准库版返回的形状【一模一样】
c.AbortWithStatusJSON(http.StatusUnauthorized,
APIError{Code: "UNAUTHORIZED", Message: "缺少 Bearer token"})
return // ← 这个 return 必须写
}
// ★ 传 c.Request.Context():AuthService 是第 6 课定义的,它不该知道 Gin 存在
u, err := svc.UserFromToken(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized,
APIError{Code: "UNAUTHORIZED", Message: "token 无效: " + err.Error()})
return // ← 这个也必须写
}
c.Set(ctxKeyUser, u) // 对应 context.WithValue,但 key 是 string、值是 any
c.Next() // 放行。写在末尾时可省略,显式写出来更清楚
}
}
// OptionalAuthGin 对应第 6 课的 OptionalAuth:识别得出就存,识别不出【也放行】。
// 注意它【一个 Abort 都没有】—— 这正是它和 RequireAuthGin 的全部区别。
func OptionalAuthGin(svc AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
if token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer "); ok && token != "" {
if u, err := svc.UserFromToken(c.Request.Context(), token); err == nil {
c.Set(ctxKeyUser, u)
}
// err != nil 时【故意什么都不做】:带个过期 token 看公开文章不该被拒
}
c.Next()
}
}
对照三段式 ⑥
| 标准库版 | Gin 版 | 代价 | |
|---|---|---|---|
| 签名 | func(http.Handler) http.Handler |
func(*gin.Context) |
和标准库中间件生态不通用,需 gin.WrapH 转 |
| 放行 | 调 next.ServeHTTP(w, r) |
调 c.Next()(末尾可省略) |
省略时「什么时候算放行」变隐式 |
| 拦截 | return 就够了(不调 next 天然拦截) |
必须 c.Abort() 并且 return |
★ 多一个必须记住的步骤 |
| 传值 | r.WithContext(ctx),类型安全 |
c.Set,any |
见 3.5 |
| 可组合性 | 任意包装,Chain(a,b,c)(h) |
只能塞进 Use() 的切片 |
灵活性降,可读性升 |
4.5 RequestID —— 洋葱模型在 Gin 里长什么样
第 5 课的洋葱是函数包装:next.ServeHTTP 之前是「进」、之后是「出」。Gin 的洋葱是 c.Next() 这一行。
// internal/middleware/request_id.go
package middleware
import (
"log"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// RequestID 生成/透传请求 ID,并在请求结束后打一条访问日志。
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
// ===== c.Next() 之前 = 洋葱的"进" =====
id := c.GetHeader("X-Request-ID")
if id == "" {
id = uuid.NewString() // 上游没给就自己生成
}
c.Set("request_id", id) // 供 handler 取用
c.Writer.Header().Set("X-Request-ID", id) // ★ 必须在写 body 之前设置 header
start := time.Now()
c.Next() // ←←← 分界线:执行链上剩下的所有 handler
// ===== c.Next() 之后 = 洋葱的"出" =====
// 响应已经写完了,所以能拿到最终状态码和耗时
log.Printf("[%s] %s %s -> %d %v errs=%d",
id, c.Request.Method, c.Request.URL.Path,
c.Writer.Status(), // ← 标准库要自己包 ResponseWriter 才拿得到
time.Since(start),
len(c.Errors)) // c.Errors 是 Gin 的错误收集器,handler 用 c.Error(err) 塞
}
}
实测日志:
[req-1] GET /healthz -> 200 22.541µs errs=0
[req-3] POST /api/v1/posts -> 401 105.417µs errs=0
[req-5] POST /api/v1/posts -> 201 16.139333ms errs=0
[req-7] GET /api/v1/posts/999999 -> 404 1.481333ms errs=0
c.Writer.Status() 是 Gin 白送的。第 5 课为了拿状态码,你得写一个 type statusWriter struct{ http.ResponseWriter; code int } 重写 WriteHeader。c.Writer 本来就是这样一个包装。
★ c.Abort() 不会 return——重点讲
Abort() 只做一件事:把 c.index 设成 abortIndex,让后续的 c.Next() 不再推进。它对当前函数的控制流没有任何影响。
// scratch/abort_test.go —— 反例
mw := func(c *gin.Context) {
order = append(order, "mw:before")
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
order = append(order, "mw:AFTER-ABORT-STILL-RUNS") // ← 会执行!
c.Next()
order = append(order, "mw:after-next") // ← 也会执行!
}
响应: status=401 body={"error":"unauthorized"}
执行顺序: [mw:before mw:AFTER-ABORT-STILL-RUNS mw:after-next]
↑ abort 之后的代码照跑,只有 handler 确实没跑
错误现象:轻则做无用功;重则 abort 之后又执行了一次 c.JSON(...),触发 [WARNING] Headers were already written——又是状态码和 body 对不上。
正确写法:Abort 系列后面永远紧跟 return,两行绑死当成一个语句记。
| 方法 | 做什么 |
|---|---|
c.Abort() |
只标记,不写响应(你已自己写过响应时用) |
c.AbortWithStatus(401) |
标记 + 状态码,body 为空 |
c.AbortWithStatusJSON(401, obj) |
标记 + 状态码 + JSON body ✅ 最常用 |
c.AbortWithError(500, err) |
标记 + 状态码 + 把 err 塞进 c.Errors(body 为空) |
c.IsAborted() |
查询是否已 abort |
4.6 gin.WrapH / gin.WrapF —— 渐进迁移
不要一次全改。 大项目一口气换掉几十个 handler,出问题定位不到是迁移错了还是本来就有 bug。
// internal/handler/router.go —— 三种复用老代码的方式,都实测跑通
r.GET("/legacy", gin.WrapH(oldHandler)) // http.Handler → gin.HandlerFunc
r.GET("/legacy2", gin.WrapF(oldHandlerFunc)) // func(w, r) → gin.HandlerFunc
r.GET("/legacy3", func(c *gin.Context) { // 手动逃生舱,前后还能加自己的逻辑
oldHandler.ServeHTTP(c.Writer, c.Request)
})
GET /legacy → {"from":"stdlib","path":"/legacy"}
GET /legacy2 → {"from":"stdlib","path":"/legacy2"}
GET /legacy3 → {"from":"stdlib","path":"/legacy3"}
建议的迁移顺序:
- 把
main.go里的http.ServeMux换成gin.Engine,所有老 handler 用gin.WrapF挂上去——不改任何 handler 代码,先跑通冒烟测试。 - 中间件逐个换成 Gin 版(
RequireAuth优先,因为它要往c里塞值)。- 过渡期注意:老 handler 用第 6 课的
CurrentUser(r.Context())取用户,Gin 中间件用c.Set存——两边对不上。过渡期让中间件两边都写:c.Set(ctxKeyUser, u)加上c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxKeyUserStd, u))。
- 过渡期注意:老 handler 用第 6 课的
- 一次迁一个 handler,优先迁参数最多的 POST/PATCH(收益最大)。
- 全部迁完后删掉
WrapF和过渡期的双写。
⑤ 跑起来验证
cd ~/go-blog && go build ./... && go run ./cmd/server
# 另开终端,拿第 6 课的 token
TOK=$(curl -s -X POST localhost:8080/api/v1/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"secret123"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
下面每条后面跟的是实测输出(我的服务跑在 8099,你按自己的端口来)。注意所有错误响应都是 {code, message, field} 三件套——和第 2~6 课完全一致,这正是本课想证明的事。
curl -s -i localhost:8099/healthz | head -3 # 1) 健康检查 + RequestID 写的响应头
curl -s localhost:8099/legacy # 2) gin.WrapF 复用的老 handler
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Request-Id: req-11 ← 中间件在 c.Next() 之前设的
{"from":"net/http 老 handler,原样复用"}
# 3) 无 token —— 组级中间件拦截
curl -s -w ' <- %{http_code}\n' -X POST localhost:8099/api/v1/posts \
-H 'Content-Type: application/json' -d '{}'
# 4) ★ 故意传一堆错参数 —— 看字段级校验错误
curl -s -w ' <- %{http_code}\n' -X POST localhost:8099/api/v1/posts \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"title":"ab","slug":"","status":9,"tags":["","x"]}'
{"code":"UNAUTHORIZED","message":"缺少 Bearer token"} <- 401
{"code":"VALIDATION_FAILED","message":"长度/数值不能小于 3","field":"title"} <- 400
注意第 4 条:请求里其实有 4 个字段不合法(title 太短、slug 为空、status 越界、tags[0] 为空),validator 全都检出来了,但 APIError 只有一个 Field 位,所以我们只报了第一个。这就是 4.3 说的那个取舍——框架能力变强了,契约没变,于是信息在出口处被丢掉了。想全报,走练习 4。
# 5) 正常创建
curl -s -w ' <- %{http_code}\n' -X POST localhost:8099/api/v1/posts \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d "{\"title\":\"Gin 实战\",\"slug\":\"gin-$(date +%s)\",\"content\":\"正文\",\"status\":1,\"tags\":[\"go\",\"gin\"]}"
# 6) 详情 / 不存在 / id=0
curl -s -w ' <- %{http_code}\n' localhost:8099/api/v1/posts/9 | head -c 70; echo
curl -s -w ' <- %{http_code}\n' localhost:8099/api/v1/posts/999999
curl -s -w ' <- %{http_code}\n' localhost:8099/api/v1/posts/0
{"id":9,"title":"Gin 实战",...,"tags":[{"id":1,"name":"go"},{"id":6,"name":"gin"}]} <- 201
{"id":9,"author_id":null,"title":"Gin 实战","slug":"gin-1788714604", <- 200
{"code":"NOT_FOUND","message":"资源不存在"} <- 404
{"code":"INVALID_ID","message":"id 非法","field":"id"} <- 400
404 那条的 message 是 model.ErrNotFound 的文本、code 是 errorCodeFor(404) 推出来的——整条错误链路一个字都没为 Gin 改过。
# 7) ★ PATCH 传 status=0 —— 指针字段才过得去
curl -s -w ' <- %{http_code}\n' -X PATCH localhost:8099/api/v1/posts/9 \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"status":0}'
# 8) PATCH 什么都不传
curl -s -w ' <- %{http_code}\n' -X PATCH localhost:8099/api/v1/posts/9 \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{}'
# 9) 查询串校验 10) 坏 token
curl -s -w ' <- %{http_code}\n' 'localhost:8099/api/v1/posts?size=500'
curl -s -w ' <- %{http_code}\n' -X POST localhost:8099/api/v1/posts \
-H "Authorization: Bearer abc.def.ghi" -H 'Content-Type: application/json' -d '{}'
{"id":9,"status":0} <- 200 ← 传了 0,改成功
{"code":"VALIDATION_FAILED","message":"不能为空","field":"status"} <- 400 ← 没传,被 required 拦下
{"code":"VALIDATION_FAILED","message":"长度/数值不能大于 100","field":"size"} <- 400
{"code":"UNAUTHORIZED","message":"token 无效: token is malformed: could not JSON decode header: ..."} <- 401
第 9 条的 field 是 "size" 小写——因为查询结构体上 form 和 json 两个 tag 都写了。只写 form 的话这里会变成 "Size"(实测过),前端就对不上字段名了。
把 Status *model.Status 改回 Status model.Status 重跑第 7 条,你会看到它变成 400——这就是零值陷阱的现场。
最后确认数据真的落库:
mysql -uroot -p123456 blog_dev -e "SELECT id,title,status FROM posts WHERE id=9;"
id title status
9 Gin 实战 0
⑥ TODO 练习
练习 1:把剩下的 handler 迁到 Gin
// internal/handler/post_handler.go
// TODO(练习1): 把 List / Delete 从 net/http 签名改成 gin.HandlerFunc。
// - List 用 ShouldBindQuery 绑定分页,page 默认 1、size 默认 20、size 上限 100
// - Delete 用 ShouldBindUri 绑定 id,要求 min=1
// - service 层和 repository 层【一行都不许改】
验收标准:git diff internal/service internal/repository 输出为空;curl 'localhost:8080/api/v1/posts?size=0' 返回 400 且 fields 里有 size;DELETE /api/v1/posts/0 返回 400、/999999 返回 404。
练习 2:自定义校验规则 slug_ok
// internal/handler/validators.go
// TODO(练习2): 注册一个名为 slug_ok 的自定义校验:
// 只允许小写字母、数字和连字符,不能以连字符开头或结尾。正则:^[a-z0-9]+(?:-[a-z0-9]+)*$
// 然后把 createPostReq.Slug 改成 binding:"required,max=200,slug_ok"
// 提示:binding.Validator.Engine().(*validator.Validate).RegisterValidation(...)
// 别忘了在 tagMsg 里加 "slug_ok": "只能包含小写字母、数字和中划线"
验收标准:-d '{"slug":"Hello_World"}' 返回 400 且 fields.slug 是中文提示;"hello-world" 能通过;注册代码在 main() 里、在 useJSONFieldName() 附近(都必须在第一次校验之前)。
练习 3:c.Copy() 的异步埋点
// internal/handler/post_handler.go
// TODO(练习3): 在 Get handler 里加异步浏览量 +1:go func(){ h.svc.IncrView(...) }()
// 要求 goroutine 里能拿到 request_id 用于日志。
// 先【故意】裸用 c 写一版压测看串没串数据,再改成 c.Copy()。
验收标准:用 go run -race ./cmd/server 起服务,hey -n 500 -c 50 localhost:8080/api/v1/posts/1 压测——裸用版必须打出 WARNING: DATA RACE 且日志里 request_id 有对不上的行;c.Copy() 版必须干净。
练习 4:扩展契约 —— 一次返回所有校验错误
4.3 里我们为了不动契约,只报了第一个错误。这个练习让你走一遍正式的契约变更流程。
// internal/handler/response.go
// TODO(练习4): 给校验错误加上"一次返回全部字段"的能力。
// 1) 扩展契约(注意是【新增】可选字段,不是改已有字段 —— 老客户端必须还能用):
// type FieldError struct {
// Field string `json:"field"`
// Message string `json:"message"`
// }
// type APIError struct {
// Code string `json:"code"`
// Message string `json:"message"`
// Field string `json:"field,omitempty"` // 保留!老客户端还在读它
// Fields []FieldError `json:"fields,omitempty"` // 新增
// }
// 2) 改 bindError:遍历整个 ve 填满 Fields,同时【Field 仍填第一个】保证向后兼容。
// 3) 想清楚 Message 填什么 —— 有多个错误时它还有意义吗?
验收标准:
- 用 4.3 那个四错齐发的请求打,
fields数组里有 4 项(title/slug/status/tags[0])。 field顶层字段依然存在且等于fields[0].field—— 只按老契约解析的客户端不受影响。- 只有一个错误时,
fields数组仍然出现(不要为了「省」而在 1 个错误时省略,客户端要能统一处理)。 - 在
docs/里写 5 行变更说明:改了什么 / 为什么这不是破坏性变更 / 老客户端会看到什么。
这一步的重点不是代码,是你能说清楚为什么这次改动是安全的。契约变更的成本从来不在实现。
练习 5:诚实的取舍复盘
// docs/gin-tradeoff.md
// TODO(练习5): 不超过 300 字,回答三个问题:
// 1. 这次迁移删掉了多少行手写校验代码?(git diff --stat 数)
// 2. 新增了多少个传递依赖?(go list -m all | wc -l 迁移前后对比)
// 3. 如果这个项目只有 3 个 GET 接口,你还会上 Gin 吗?为什么?
验收标准:三个问题都有具体数字,第 3 问答案是「不会」并说得出理由。
⑦ 自检清单
- 我能说出至少两个「用
net/http就够、不该上 Gin」的具体场景。 - 我知道
gin.Default()比gin.New()多了哪两个中间件,以及它们对应第 5 课我手写的哪两个。 - 我在生产启动路径上写了
gin.SetMode(gin.ReleaseMode)。 - 我能说清
*gin.Context的四个角色,并且传给 service 层的是c.Request.Context()而不是c。 - 我知道
c.Get返回any,项目里所有类型断言都收口在CurrentUser/MustCurrentUser这一对函数里。 - 我能解释为什么 goroutine 里必须
c.Copy(),并且亲手跑过-race看到过DATA RACE。 - 我的项目里只有
ShouldBindXxx,一个BindJSON都没有。 - 我知道
binding:"required"分不清「没传 0」和「传了 0」,PATCH 请求体全部用了指针字段。 - 我写了
bindError,它返回第 2 课的APIError(换框架没有改变对外契约),并且先用errors.As判断是不是validator.ValidationErrors(uri 类型错误不是)。 - 我知道 validator 一次能返回 N 个错误而
APIError只有 1 个Field位,并且能说出三条处理路线各自的代价。 - 我的
httpStatusFor/errorCodeFor是从第 4 课原样复用的,只新写了writeDomainErrorGin。 - 我调用了
RegisterTagNameFunc,并且它在main()里、在任何一次校验发生之前。 - 我的每一个
c.Abort...(...)后面都紧跟着return。 - 我知道
gin.WrapH/gin.WrapF的存在,明白迁移可以分步做。 - 做完这一课,
internal/service/和internal/repository/一行代码都没改。
下一课预告:第 8 课换掉 repository 层的实现,把 database/sql 手写 Scan 换成 GORM。你会看到——因为第 4 课定义了 PostRepository 接口,换 ORM 时 handler 和 service 依然一行都不用改。同时我们会开着 SQL 日志盯着 GORM,看清它生成了什么、以及它在哪些地方悄悄改了你的语义(软删除、零值更新、N+1)。