跳到正文
知识库 / Go 博客教程 / 第 7 课返回主站 ↗

Go 博客教程 · 第 7 课 / 10

第 7 课:从 net/http 迁移到 Gin

前六课你用标准库把博客后端从零写了出来:ServeMux 路由、database/sql 映射、四层架构、中间件链、JWT 鉴权。这些都能跑,而且跑得很好。

这一课不是来说「前面白写了」。恰恰相反——正因为你手写过一遍,才有资格判断 Gin 省了什么、又拿走了什么。本课每一节都按同一个模板走:

第 X 课我们手写的是什么 → Gin 替你做了什么 → 代价是什么

学完你会发现,Gin 真正不可替代的只有一样:参数绑定与校验。其余多数是「顺手」。

教程日期:实测环境:Go 1.25.0 · MySQL 8.4.5 · Redis← 上一课:第 6 课:用户与 JWT 认证下一课:第 8 课:GORM 与关联 →

① 本课目标

  1. 判断一个项目该不该上 Gin,而不是条件反射地 go get
  2. 说清 *gin.Context 扮演的四个角色和它的逃生舱。
  3. binding tag 写完整校验,并把 validator 的英文报错翻译成字段级中文错误。
  4. 认出三个高频事故:BindJSON 重复写响应、c.Abort() 不 return、goroutine 里裸用 c
  5. 把第 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 是字符串(两个中间件用同一个名字会互相覆盖,编译器不管);值是 anyc.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.Enginesync.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 只填了 CodeMessageAPIError.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 都看不到,纯静默。

根因BindJSONMustBindWith,失败时 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.ValidationErrorsGET /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 个(遇到第一个 ifreturn 全部ve 是个切片)
APIError 能装几个 1 个 1 个

框架给了你更多信息,但你的对外契约装不下。 三条路,各有代价:

  1. 只报第一个(本课选这条)。契约零改动,前端不用动。代价:用户填错 3 个字段要来回提交 3 次。
  2. 扩展契约,加一个 Fields []FieldError。体验最好。代价:这是一次 API 变更,要和前端约定、要考虑老客户端兼容——得走版本流程,不能因为「框架顺手支持」就偷偷改
  3. 拼成一条 Message"title 长度不能小于 3;slug 不能为空")。折中。代价:Field 只能填第一个,前端仍然只能高亮一个框。

这一节真正的教学点不是「怎么翻译错误」,是「框架能力变强 ≠ 你的接口就该跟着变」。 契约是对外承诺,改它要有理由、要走流程,不能被内部重构推着走。练习 4 会让你实现第 2 条并评估代价。

ValidationFieldError 上还有这些(实测值):Namespace()createPostReq.TitleField()Title(注册 TagNameFunc 后是 title)、StructField()→永远是 Go 名、Tag()minParam()3oneof 时是 draft published offline)、Kind()stringValue()→用户实际传的值;切片元素的 Field()Tags[1]

实测最终效果(真起服务 curl 出来的):

{"code":"VALIDATION_FAILED","message":"长度/数值不能小于 3","field":"title"}

易错点RegisterTagNameFunc 读的是 json tagShouldBindQuery 的结构体如果只有 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.Setany 见 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 } 重写 WriteHeaderc.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"}

建议的迁移顺序

  1. main.go 里的 http.ServeMux 换成 gin.Engine所有老 handler 用 gin.WrapF 挂上去——不改任何 handler 代码,先跑通冒烟测试。
  2. 中间件逐个换成 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))
  3. 一次迁一个 handler,优先迁参数最多的 POST/PATCH(收益最大)。
  4. 全部迁完后删掉 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 那条的 messagemodel.ErrNotFound 的文本、codeerrorCodeFor(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" 小写——因为查询结构体上 formjson 两个 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 里有 sizeDELETE /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)。