Files
cs-note/hzh/TEST/gin.md
T
2026-05-24 11:42:38 +08:00

855 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags:
- 后端
- Go
- Gin
- 测试
- 自我考察
create time: 2026-04-28
update time: 2026-04-28
status: reviewed
---
# Gin 框架自测题
## 使用说明
本试卷覆盖你笔记中的 **核心机制 → 进阶功能 → 工程实践** 全链路知识点。共四部分:选择题、填空题、代码补全、主观题。
**建议用时:** 45 分钟 | **及格线:** 70 / 100
做完后对照下方「参考答案」自评,错的地方回到对应笔记重新理解。
---
## 一、选择题(每题 3 分,共 30 分)
> 每题只有一个正确答案。
### Q1.【路由匹配优先级】
注册了以下三条路由:
```go
r.GET("/users/list", listAll)
r.GET("/users/:id", getOne)
r.GET("/users/*path", catchAll)
```
当收到 `GET /users/list` 请求时,哪个 handler 会被执行?
A. `catchAll` — 因为通配符匹配范围最广
B. `getOne` — 因为动态参数 :id 能匹配到 "list"
C. `listAll` — 静态路由优先级最高
D. 取决于三者的注册顺序
> [!tip]- Q1 答案
> **C — 静态路由优先级最高**
>
> **解析:**
> Gin 的路由匹配基于 Radix Tree,匹配规则是按**路径片段的类型**决定优先级,而非注册顺序:
>
> 1. **静态片段(literal)** — 如 `list`,精确字符串匹配,优先级最高
> 2. **动态片段(param)** — 如 `:id`,占位符匹配,优先级居中
> 3. **通配片段(catch-all)** — 如 `*path`,贪婪匹配其余所有路径,优先级最低
>
> **为什么错选 A/B/D:**
> - ❌ A:通配符确实能匹配 `/users/list`,但它在最后才尝试
> - ❌ B:`:id` 也能捕获 "list" 作为参数值,但静态优先于动态
> - ❌ D:这是标准库 `ServeMux` 的行为,Gin 的 Radix Tree 是结构化匹配,与顺序无关
>
> 💡 **拓展:** 如果两条完全相同的 path+method 重复注册,Gin 会 panic,因为此时不存在优先级区分的问题——它们是冲突的。
### Q2.【中间件执行顺序】
```go
r := gin.Default()
r.Use(func(c *gin.Context) { fmt.Print("A"); c.Next(); fmt.Print("a") })
r.Use(func(c *gin.Context) { fmt.Print("B"); c.Next(); fmt.Print("b") })
v1 := r.Group("/v1", func(c *gin.Context) { fmt.Print("C"); c.Next(); fmt.Print("c") })
{
v1.GET("/test", func(c *gin.Context) { fmt.Print("H") })
}
```
访问 `GET /v1/test` 的输出顺序是?
A. `ABChaCb`
B. `ABCHabc`
C. `ABCha b`
D. `ChBa aB`
> [!tip]- Q2 答案
> **B — `ABCHabc`**
>
> **解析:**
> 中间件执行遵循**洋葱模型(Onion Model)**,需要区分两个维度:
>
> **前置部分(`c.Next()` 之前)—— 正序执行:**
> ```
> r.Use(A) → A开始
> r.Use(B) → B开始
> v1路由组(C) → C开始
> handler(H) → H执行
> ```
> 所以前置输出顺序是:**A B C H**
>
> **后置部分(`c.Next()` 之后)—— 逆序执行:**
> ```
> handler 结束
> v1路由组(C) 的 c.Next() 之后 → c输出
> r.Use(B) 的 c.Next() 之后 → b输出
> r.Use(A) 的 c.Next() 之后 → a输出
> ```
> 所以后置输出顺序是:**c b a**
>
> 合起来就是 **ABCH + abc = ABCHabc** ✅
>
> 💡 **记忆口诀:** "进来正序排排站,出去逆序往回走"。这是所有 Web 框架中间件的通用模式(Express/Koa/NestJS 同理)。
### Q3.【Context 生命周期】
关于 Gin 使用 `sync.Pool` 复用 Context 的说法,**错误**的是:
A. 每次请求从池中取出 Context,请求结束后归还池中
B. 可以在 handler 的 goroutine 中安全地持有 Context 引用,在请求返回后继续使用
C. `c.Reset()` 会把 Keys、Params、handlers、index 全部清空
D. index 被重置为 -1 表示还未开始执行,第一次 `c.Next()` 走到索引 0
> [!tip]- Q3 答案
> **B — Context 池化不安全跨请求持有**
>
> **解析:**
> Gin 为了减少 GC 压力,使用 `sync.Pool` 复用 `Context` 实例。每次请求结束后,Context 会被 `c.Reset()` 清空并放回池中。**下一个请求可能取出同一个 Context 对象**。
>
> **逐项分析:**
> - ✅ A:正确描述。取出来 → 用 → `c.Reset()` → 放回去,循环复用
> - ❌ B:**错误!** goroutine 异步运行,handler 返回后 Context 已 reset 并被其他请求复用,此时读到的数据可能是下一个请求的(安全漏洞 + 数据错乱)
> - ✅ C:`c.Reset()` 确实清空 Keys(map)、Params、handlers slice、index 等核心字段
> - ✅ D:`index = -1` 是初始状态,`c.Next()` 先自增为 0,再执行 handlers[0]
>
> 💡 **关键陷阱:** 如果你在 handler 里 `go func() { time.Sleep(5s); c.Get("uid") }()`,5 秒后读到的很可能不是你的数据!
### Q4.【绑定与校验】
关于 `binding:"required"` 对不同类型字段的处理,以下说法**正确**的是:
A. 对 `string` 类型,值为空字符串 `""` 时校验失败
B. 对 `*string` 指针类型,指向 nil 时校验失败
C. 对 `int` 类型,值为 0 时校验失败
D. A 和 B 都正确,C 不正确
> [!tip]- Q4 答案
> **D — required 对 string="" 和 *string=nil 均失败,对 int=0 不失败**
>
> **解析:**
> `validator` 包的 `required` 标签本质上是检查值的 **"零值" (zero value)**,但不同类型的零值判断规则不同:
>
> | 类型 | 零值 | `required` 是否失败 |
> |------|------|---------------------|
> | `string` | `""` | ✅ 失败 — 空串意味着前端没传 |
> | `*string` (nil) | `nil` | ✅ 失败 — 未分配表示没传 |
> | `int` / `int64` | `0` | ❌ **不失败** — 0 是一个合法的业务值 |
> | `bool` | `false` | ❌ **不失败** — false 也是合法输入 |
> | `[]string` | `nil` 或 `[]` | ✅ 失败 |
>
> **为什么 C 错?** 如果 0 算"必填",那页面上有个数量选择器默认选 0 就无法提交了。所以 `required` 只对引用类型(指针、slice、map)和 string 生效。
>
> 💡 **变通方案:** 如果你需要"int 不能为 0",应该自定义校验器:`binding:"min=1"` 或用自定义 tag。
### Q5.【c.Copy() 安全性】
在中间件中启动异步 goroutine,下列哪种做法是**安全**的?
A. 直接在中间件中 `go func() { c.JSON(200, ...) }()`
B. 先 `copy := c.Copy()`,然后 `go func() { copy.GetString("userID") }()`
C. 先 `copy := c.Copy()`,然后 `go func() { copy.JSON(200, ...) }()`
D. 用 `c.Request.Context().Done()` 判断后再读 c.Keys
> [!tip]- Q5 答案
> **B — c.Copy 后 goroutine 只读**
>
> **解析:**
> `c.Copy()` 创建一个轻量级副本,共享 Request 和 Writer(所以不能对 copy 写响应),但拥有独立的 Keys map。
>
> **逐项分析:**
> - ❌ A:**绝对不行!** goroutine 运行时 handler 已返回,Context 被 reset 放回 pool,此时读写都是数据竞争
> - ✅ B:`c.Copy()` 的 Keys 是独立副本,GetString 只是读取安全
> - ❌ C:copy 的 Writer 和原始 Context **共享**同一个 `http.ResponseWriter`,goroutine 中调用 JSON() 会和主流程竞争写入,导致响应损坏
> - ❌ D:`ctx.Done()` 只能说明请求被取消,不能保证 Context 还没被复用——即使 context 没 done,pool 里的同一个对象也可能被其他请求取走并修改了 Keys
>
> 💡 **黄金法则:** goroutine 要用的数据,在主流程里先 `取值 → 存局部变量`,而不是依赖 Context。
### Q6.【SecureJSON 原理】
SecureJSON 防 JSON 劫持的原理是:
A. 在 JSON 前添加 `)]}',\n` 前缀,使浏览器无法将其解析为合法的 JavaScript
B. 自动设置 `X-Content-Type-Options: nosniff` 响应头
C. 只允许白名单域名通过 CORS 访问
D. 对 JSON body 进行 HMAC 签名验证
> [!tip]- Q6 答案
> **A — SecureJSON 前缀阻断 JS 解析**
>
> **解析:**
> JSON 劫持(JSON Hijacking)的攻击场景:攻击者构造一个恶意页面,用 `<script src="https://your-api.com/user/data">` 加载你的 JSON 接口。因为浏览器同源策略不阻止 script 标签跨域加载,如果接口返回纯 JSON,恶意页面拿到后可以提取数据。
>
> `SecureJSON(")],{}", data)` 的输出变成:
> ```
> ]},"{"name":"Alice","token":"secret"}
> ```
> 由于以 `]},` 开头,这不再是合法的 JavaScript 表达式,浏览器无法直接执行。
>
> **为什么不是其他选项:**
> - ❌ B:`nosniff` 是另一个安全头,防止 MIME 类型嗅探,但不是 SecureJSON 的作用
> - ❌ C:这是 CORS 机制,解决的是跨域访问控制
> - ❌ D:HMAC 签名用于完整性校验,与防劫持无关
>
> 💡 **注意:** SecureJSON 并非万能防御。现代最佳实践是同时使用 CSRF Token + CORS + Content-Type 验证。
### Q7.【ShouldBindBodyWith】
为什么需要 `ShouldBindBodyWith`?
A. Gin 的 Request Body 是 `io.ReadCloser`,只能读取一次,后续绑定会报错 EOF
B. Gin 默认不会缓存 body,每次绑定时都会新建连接读取
C. `ShouldBindJSON` 不支持 form data,必须用 `ShouldBindBodyWith` 替代
D. `ShouldBindBodyWith` 可以将 body 同时绑定到多个结构体而无需额外调用
> [!tip]- Q7 答案
> **A — io.ReadCloser 只能读一次**
>
> **解析:**
> HTTP 请求的 Body 底层是 `io.ReadCloser`,本质是一个流式读取器。**读完了就没了**,没有 "重新定位到开头" 的操作。
>
> 典型场景:你在中间件中调用了 `c.ShouldBindJSON(&req)` 做了认证检查,进入 handler 后又想 `c.ShouldBindJSON(&req2)` 拿业务数据——第二次会报 `EOF`。
>
> **解决方案:**
> ```go
> // 第一次绑定后会缓存 body
> c.ShouldBindBodyWith(&obj, binding.JSON)
> // 第二次直接从缓存读取,不再碰原始 stream
> c.ShouldBindJSON(&obj2)
> ```
>
> **为什么错选 B:** Gin 确实不自动缓存 body,但这不是"每次都新建连接",而是同一条连接上 body 流读完后就消耗掉了。
>
> 💡 **扩展:** 中间件做通用 body 解析时建议用 `ShouldBindBodyWith`,避免下游 handler 再绑定时报错。
### Q8.【优雅停止】
关于 `server.Shutdown()` 的行为,以下说法**正确**的是:
A. 会立即关闭所有活跃连接,包括 WebSocket 长连接
B. 停止接收新连接,但已有请求继续处理,直到全部完成或 context 超时
C. 会取消所有正在处理的请求的 context
D. 是异步非阻塞调用,不会卡住调用方
> [!tip]- Q8 答案
> **B — Shutdown 停新不断旧**
>
> **解析:**
> `server.Shutdown(ctx)` 的行为分两步:
>
> 1. **停止接受新连接** — `listener.Close()`,外部流量不再进来
> 2. **等待活跃请求处理完毕** — 每个请求的 context 被取消(相当于触发 `ctx.Done()`),但 handler 可以继续处理
> 3. **超时强制退出** — 如果某个请求超时未完成,直接断开
>
> **逐项分析:**
> - ❌ A:WebSocket 连接不是 HTTP handler 级别的,Shutdown 不会主动清理它。如果需要关闭 WebSocket,要在 shutdown 逻辑中单独遍历关闭
> - ✅ B:完全正确。停止新的、处理旧的、等超时就砍
> - ❌ C:context 是被 cancel 了,但请求**不一定终止**——handler 可以自己选择不立即 return
> - ❌ D:`Shutdown()` 是**同步阻塞**调用,它会一直等到所有请求处理完才返回
>
> 💡 **线上经验:** Docker kill signal 默认是 SIGTERM(对应 graceful shutdown),如果用 SIGKILL 则直接杀进程,不走 Shutdown 流程。
### Q9.【Radix Tree】
Gin 的路由匹配使用 Radix Tree,关于它的优势,以下说法**最准确**的是:
A. 时间复杂度 O(1),因为底层用了 hash map
B. 时间复杂度 O(d),d 为 URL 路径深度,相比标准库 ServeMux 的 O(n)(n 为路由总数)大幅减少比较次数
C. 每个 HTTP 方法共享一棵树,减少内存占用
D. 支持正则表达式匹配,灵活性更高
> [!tip]- Q9 答案
> **B — Radix Tree O(d) vs ServeMux O(n)**
>
> **解析:**
>
> | 特性 | Gin (Radix Tree) | 标准库 ServeMux |
> |------|------------------|-----------------|
> | 匹配复杂度 | **O(d)**,d = URL 路径段数(如 `/api/v1/users` → d=4) | **O(n)**,逐个比较路由 |
> | 正则支持 | ❌ 仅 `:param` 和 `*wildcard` | ✅ Go 1.22+ 支持 pattern regex |
> | 内存效率 | ✅ 合并公共前缀,节省空间 | 每条路由一个节点 |
> | HTTP 方法存储 | 每方法一棵树(GET/POST 各一棵) | 按 method+path 组合存储 |
>
> **为什么 C 不对?** Gin 实际上**每 HTTP 方法一棵树**,不是共享。这是因为不同方法可以注册相同 path(`GET /users` 和 `POST /users` 是两个不同的路由)。
>
> 💡 **对比理解:** 假设你有 1000 条路由都在 `/api/*` 下,ServeMux 最坏情况要比较 1000 次;Radix Tree 只需要沿着公共前缀走一遍,约等于 URL 段数。
### Q10.【错误处理链】
在 handler 中连续调用三次 `c.Error(err)` 后,正确的做法是:
A. 每次 `c.Error()` 后直接 `return`,因为 error 会自动返回客户端
B. 检查 `len(c.Errors) > 0`,如果大于 0 则统一返回错误列表
C. 调用 `c.Abort()` 终止请求,Gin 会自动返回 500
D. `c.Error()` 会直接写响应体,不需要额外处理
> [!tip]- Q10 答案
> **B — len(c.Errors) 检查后统一返回**
>
> **解析:**
> `c.Error(err)` 的设计意图是**非阻塞地记录错误到上下文**,它不会自动发送响应给客户端,也不会中断执行链。它会往 `c.Errors`(`gin.TypedErrors` 类型)追加一个错误对象。
>
> ```go
> func batchProcess(c *gin.Context) {
> // 第一步:绑定
> if err := c.ShouldBindJSON(&req); err != nil {
> c.Error(&gin.Error{Err: err, Meta: "bind"})
> }
>
> // 第二步:业务校验
> if err := validate(req); err != nil {
> c.Error(&gin.Error{Err: err, Meta: "validate"})
> }
>
> // 第三步:数据库写入
> if err := db.Save(&req); err != nil {
> c.Error(&gin.Error{Err: err, Meta: "db"})
> }
>
> // 集中处理所有错误
> if len(c.Errors) > 0 {
> c.JSON(400, gin.H{"errors": c.Errors})
> return
> }
>
> c.JSON(200, gin.H{"message": "success"})
> }
> ```
>
> **为什么错选 A/C/D:**
> - ❌ A:`c.Error()` 不会触发 return,后续代码照样执行
> - ❌ C:`c.Abort()` 是终止中间件链的,和错误无关;它不会自动返回 500
> - ❌ D:`c.Error()` 不写响应体,你需要自己决定怎么返回
>
> 💡 **进阶:** Gin 提供了 `c.Errors.Last()` 获取最后一个错误,也可以用 `c.Errors.ByType(gin.ErrorTypePrivate)` 按类型过滤。批量返回时用 `c.Errors` 整体更合适。
---
## 二、填空题(每题 3 分,共 24 分)
> 根据知识填写空缺的代码或概念。
### Q11.【路由分组】
```go
r := gin.Default()
api := r.Group("/api")
{
api.GET("/users", listUsers)
v1 := api.Group("/v1")
{
// 完整路径为 _________
v1.DELETE("/:id", deleteUser)
}
}
```
> [!tip]- Q11 答案
> **`/api/v1/:id`**
>
> **解析:**
> `Group()` 返回的新 RouterGroup 会继承父级的路径前缀。这里是两级分组:
> ```
> r → 无前缀
> └─ api = r.Group("/api") → 前缀: /api
> └─ v1 = api.Group("/v1") → 前缀: /api + /v1 = /api/v1
> └─ DELETE("/:id") → 完整路径: /api/v1/:id
> ```
>
> 💡 **坑点:** Group 的路径不会自动加 `/` 分隔,所以 `r.Group("api")` 和 `api.Group("/v1")` 拼接出来是 `apiv1` 而不是 `/api/v1`。建议始终以 `/` 开头。
### Q12.【中间件链终止】
认证中间件中,token 无效时需要终止后续中间件和执行链,应调用的方法是 _________;如果只想写状态码 401 不写 body,可以用 _________ 。
> [!tip]- Q12 答案
> **`c.Abort()`** ; **`c.AbortWithStatus(http.StatusUnauthorized)`**(或 `c.AbortWithStatusJSON(401, gin.H{...})`)
>
> **解析:**
> Gin 提供三级终止 API:
>
> | 方法 | 行为 | 适用场景 |
> |------|------|---------|
> | `c.Abort()` | 仅标记中断,不写响应 | 很少单独用,通常配合后面两个 |
> | `c.AbortWithStatus(statusCode)` | 中断 + 写入指定状态码 + 空 body | 简单鉴权失败、权限不足 |
> | `c.AbortWithStatusJSON(statusCode, jsonObj)` | 中断 + 写入 JSON 响应 | 需要返回结构化错误信息 |
>
> **关键区别:**
> - `c.Abort()` 后中间件链不再往下走,但**当前中间件的后续代码会继续执行**(后置部分)
> - `c.AbortWithStatus*` 除了终止链还自动写 status code 和 header
>
> 💡 **陷阱题:** `c.Abort()` 和 `return` 的区别 — `c.Abort()` 只是设了一个标志位,如果你写了 `c.Abort(); c.JSON(...)`,后面的 JSON 仍然会执行!正确写法:`c.Abort(); return;`
### Q13.【Context 数据共享】
```go
// 中间件存入数据
c.Set("requestID", uuid.New().String())
// handler 获取数据——方式一(安全):
rid, ok := c.Get("requestID")
// handler 获取数据——方式二(key 不存在会 panic):
rid = c.MustGet("_________").(string)
```
> [!tip]- Q13 答案
> **`requestID`**
>
> **解析:**
> Context 的 key-value 存储提供三种读取方式:
>
> | 方法 | 返回值 | key 不存在时 |
> |------|--------|-------------|
> | `c.Get(key)` | `(any, bool)` | 返回 `(nil, false)` — **安全** |
> | `c.MustGet(key)` | `any` | **panic!** — "key does not exist" |
> | `c.GetString(key)` | `string` (零值 "") | 返回空字符串,无法区分 |
>
> `MustGet` 适合 **"这个 key 必须存在"** 的场景(如中间件强制设置的字段),提前暴露 bug 比静默得到零值更好。
>
> 💡 **工程实践:** requestID 通常在请求入口(CORS/日志中间件)统一生成,这样整个请求链路都有 trace_id,方便排查问题。
### Q14.【绑定方法选择】
将以下 binding 方法与数据来源连线(按序号填字母):
> [!tip]- Q14 答案
> **①-C ②-A ③-D ④-B**
>
> **解析:**
>
> | 编号 | 方法 | 数据来源 | 对应内容 |
> |------|------|---------|---------|
> | ① | `ShouldBindJSON` | **C** JSON body | 解析 `{"key": "value"}` 到 struct |
> | ② | `ShouldBindQuery` | **A** URL Query | 解析 `?name=alice&age=25` |
> | ③ | `ShouldBindUri` | **D** URL 路径参数 | 解析路由 `:param` 中的值(如 `GET /users/:id` 中的 `id`) |
> | ④ | `ShouldBindHeader` | **B** 请求头 | 解析 `Authorization: Bearer xxx` 等 Header 字段 |
>
> 💡 **扩展记忆:** 还有 `ShouldBindBodyWith(&obj, binding.Form)` 用于 form data POST body。Gin 最灵活的地方在于一个 struct 可以同时绑多个来源:
> ```go
> type SearchRequest struct {
> Query string `form:"q"` // 从 query 参数绑
> Type string `uri:"type"` // 从 uri 参数绑
> }
> c.ShouldBindQuery(&req) // 同时绑 Query 和 Type
> ```
### Q15.【错误类型分类】
Gin 的 `gin.ErrorType` 分为四种,请写出三种的名称:
> [!tip]- Q15 答案
> | 常量名 | 含义 | 说明 |
> |--------|------|------|
> | `ErrorTypeBind` (=1) | 绑定错误 | `ShouldBind*` 失败时设置 |
> | `ErrorTypeRelease` (=2) | **资源释放错误** | ResponseWriter 关闭等操作出错 |
> | `ErrorTypePrivate` (=4) | **内部业务错误** | 服务器内部错误,不应暴露给客户端 |
> | `ErrorTypePublic` (=8) | **暴露给客户端的业务错误** | 可安全返回给调用方的错误 |
>
> **按位掩码使用示例:**
> ```go
> if err.Type() == gin.ErrorTypePrivate { /* 服务器内部错误 */ }
> if err.Type()&gin.ErrorTypePublic != 0 { /* 公开错误 */ }
> ```
>
> 💡 **注意:** ErrorType 是 bitmask 设计,用 `1 << iota` 可以组合使用。但实践中基本每个错误只属于一种类型。
### Q16.【PureJSON vs JSON】
`c.JSON()` 默认会将中文等非 ASCII 字符转义为 `\uXXXX`(如 `"你好"` → `"\\u4f60\\u597d"`),要原样输出 UTF-8 字节流,应该使用 _________ 。
> [!tip]- Q16 答案
> **`c.PureJSON()`**
>
> **解析:**
> Go 标准库 `encoding/json` 的 `Marshal` 函数有一个默认行为:对非 ASCII 字符进行 Unicode 转义,因为旧版浏览器可能存在编码兼容性问题。
>
> ```go
> c.JSON(200, gin.H{"msg": "你好世界"})
> // 输出: {"msg":"你好世界"}
>
> c.PureJSON(200, gin.H{"msg": "你好世界"})
> // 输出: {"msg":"你好世界"}
> ```
>
> **底层原理:** PureJSON 使用的是自定义的 JSON encoder(Gin 自己实现的),绕过了标准库的 Unicode 转义逻辑。注意 Gin 的 PureJSON 和标准库的 `json.MarshalIndent` 等无冲突,它只是控制了 `EscapeHTML=false` + `UndefinitelyByteOrder=false` 的行为组合。
>
> 💡 **性能差异:** PureJSON 比 JSON 略快(少了 Unicode 检查步骤),在大量中文场景下推荐使用。
### Q17.【文件上传限制】
Gin 默认的 `MaxMultipartMemory` 阈值为 __ MB(兆字节)。超过此大小的 multipart 请求体,超出部分会写入操作系统的 _________ 目录下的临时文件。
> [!tip]- Q17 答案
> **`8`** ; **`os.TempDir()`**(通常是 `/tmp` 或 `C:\Windows\Temp`)
>
> **解析:**
> MaxMultipartMemory 控制 Gin 处理 multipart/form-data 时的内存策略:
> - **≤ 8MB**:全部内容留在内存中处理,速度快
> - **> 8MB**:先读入内存 8MB,超出部分溢写到磁盘临时文件
>
> **手动调整:**
> ```go
> r := gin.Default()
> r.MaxMultipartMemory = 64 << 20 // 64 MB
> ```
>
> 💡 **安全问题:** 如果不设置合理的 MaxMultipartMemory,攻击者可以上传超大文件耗尽服务器内存。生产环境务必限制文件大小,可用 `c.Request.ContentLength` 或自定义中间件校验。
### Q18.【优雅停止信号】
```go
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SI_____)
<-quit
```
Linux/Unix 下常见的两个终止信号是 SIGINT 和 _________ 。
> [!tip]- Q18 答案
> **`GTERM`**(代码填空补全为 `SIGTERM`);**`SIGTERM`**(文字填空填写完整信号名)
>
> **解析:**
>
> | 信号 | 触发方式 | 用途 |
> |------|---------|------|
> | `SIGINT` | Ctrl+C / 终端中断 | 用户主动中断进程 |
> | `SIGTERM` | `kill <pid>` / Docker stop / Kube delete | 操作系统或服务管理程序请求优雅退出 |
>
> **为什么 buffer size = 1?** 因为只需要捕获一次信号来触发 shutdown 流程。如果设得太大,可能有多个信号堆积来不及处理导致进程异常退出。
>
> 💡 **补充:** Windows 上没有 SIGTERM/SIGINT,Gin 在 Windows 上建议使用 `server.Close()` 直接关闭,或者用 `^C` 触发 SIGBREAK 替代。Kubernetes 的 `preStop` hook 发送的就是 SIGTERM。
---
## 三、代码补全(每题 6 分,共 30 分)
> 补充代码中空缺的部分。有些题目有多个空。
### Q19.【CORS 中间件】
补全一个基础 CORS 中间件,处理 OPTIONS 预检请求并设置相关响应头:
```go
func cors() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", _________)
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,OPTIONS")
c.Header("Access-Control-Allow-Headers", "_________,Authorization")
c.Header("Access-Control-Max-Age", "86400")
}
if c.Request.Method == "_________" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
```
> [!tip]- Q19 答案
> **三个空分别是:`origin` ; `Origin,Content-Type`(或 `Content-Type,Origin,X-Token` 等合理值); `OPTIONS`**
>
> **解析:**
>
> ```go
> func cors() gin.HandlerFunc {
> return func(c *gin.Context) {
> origin := c.Request.Header.Get("Origin")
> if origin != "" {
> // 空1:回显请求方的 Origin,不能硬编码 "*"(否则无法配合 withCredentials)
> c.Header("Access-Control-Allow-Origin", origin)
> c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,OPTIONS")
> // 空2:声明客户端允许发送的自定义 Header
> c.Header("Access-Control-Allow-Headers", "Origin,Content-Type,Authorization")
> c.Header("Access-Control-Max-Age", "86400") // 预检结果缓存 24h
> }
> // 空3:浏览器发 OPTIONS 预检请求时,直接返回 204 不往下走业务逻辑
> if c.Request.Method == "OPTIONS" {
> c.AbortWithStatus(http.StatusNoContent)
> return
> }
> c.Next()
> }
> }
> ```
>
> 💡 **进阶:** 生产环境不建议原样回显 `origin`(有 SSRF 风险),应该维护一个白名单校验后再赋值。另外如果前端设置了 `withCredentials = true`,就不能用 `Access-Control-Allow-Origin: *`,必须指定具体域名。
### Q20.【自定义 Validator】
注册一个手机号格式校验器:
> [!tip]- Q20 答案
> **`matched` ; `required`**
>
> **解析:**
>
> ```go
> v.RegisterValidation("phone", func(fl validator.FieldLevel) bool {
> phone := fl.Field().String()
> matched, _ := regexp.MatchString(`^1[3-9]\d{9}$`, phone)
> return matched // ✅ 空1:正则匹配结果就是校验返回值
> })
>
> type RegisterRequest struct {
> Phone string `json:"phone" binding:"required,phone"` // ✅ 空2:先 required 再 phone
> }
> ```
>
> **关键点:**
> 1. 自定义 validation 函数签名固定为 `func(fl validator.FieldLevel) bool`,返回 `true` 表示通过
> 2. `fl.Field()` 返回 `reflect.Value`,调用 `.String()` 获取实际字符串值
> 3. binding tag 中多个规则用逗号分隔,执行顺序是从左到右——`required` 会先检查非空,再通过 `phone` 校验格式
>
> 💡 **坑点:** 如果写 `binding:"phone|required"`(reverse order),当手机号为空时会跳过了 `phone` 校验直接进入 `required`,导致错误提示不准确。建议总是 `required` 在前。
### Q21.【批量更新中的 c.Error 模式】
```go
func batchUpdate(c *gin.Context) {
var items []Item
if err := c.ShouldBindJSON(&items); err != nil {
c.JSON(400, gin.H{"error": "invalid input"})
return
}
for _, item := range items {
if err := validate(item); err != nil {
c.Error(&gin.Error{
Err: _________,
Meta: item.ID,
})
_________ // 跳过当前 item,继续下一个
}
if err := saveToDB(item); err != nil {
c.Error(&gin.Error{Err: err})
}
}
if len(_________ ) > 0 {
c.JSON(400, gin.H{"errors": c.Errors.Last()})
return
}
c.JSON(200, gin.H{"message": "done"})
}
```
> [!tip]- Q21 答案
> **`err` ; `continue` ; `c.Errors`**
>
> **解析:**
>
> | 空 | 填空 | 作用 |
>|----|------|------|
> | 空1 | `err` | `c.Error()` 接收 `*gin.Error`,其中 `Err` 字段存储真实错误 |
> | 空2 | `continue` | 跳过当前失败的 item,继续处理后续项(半成功语义) |
> | 空3 | `c.Errors` | 最后统一检查是否有任何错误积累 |
>
> **这个模式的精妙之处:**
> - 不是"全部成功 or 全部失败"的原子操作,而是**尽量多做**(fail-fast but continue)
> - 第 3 个 `if err := saveToDB(item)` 虽然没加 continue,意味着只要验证通过就会尝试保存——即使前面已有其他 item 出错
>
> 💡 **改进方向:** 如果想返回完整的错误汇总(而不是只显示最后一个),可以遍历 `c.Errors` 逐个返回:
> ```go
> if len(c.Errors) > 0 {
> errs := make([]map[string]any, len(c.Errors))
> for i, e := range c.Errors {
> errs[i] = gin.H{"type": e.Type(), "msg": e.Err.Error()}
> if e.Meta != nil { errs[i]["meta"] = e.Meta }
> }
> c.JSON(400, gin.H{"errors": errs})
> }
> ```
### Q22.【内容协商多格式响应】
> [!tip]- Q22 答案
> **`c.XML(http.StatusOK, data)`**
>
> **解析:**
> `c.Negotiate()` 根据客户端 `Accept` 头自动选择最合适的响应格式。Gin 内置支持三种 MIME 类型:
>
> | Gin 常量 | MIME Type | 渲染方法 |
>|----------|-----------|---------|
> | `gin.MIMEJSON` | `application/json; charset=utf-8` | `c.JSON()` |
> | `gin.MIMEXML` | `application/xml` / `text/xml` | `c.XML()` |
> | `gin.MIMEYAML` | `application/x-yaml` | `c.YAML()` |
>
> **工作流程:**
> 1. 客户端发 `Accept: application/xml, application/json;q=0.9`
> 2. Gin 从 Offered 列表中按优先级匹配第一个支持的
> 3. 设置 `Content-Type` 响应头
> 4. 调用对应 case 中的 Handler 写入 body
> 5. 如果没有匹配的且没有设 Default → panic (406 Not Acceptable)
>
> 💡 **实战用法:** Negotiate 还有一个 `Default` 字段,建议始终设置 fallback:
> ```go
> Default: gin.MIMEJSON, // 客户端没带 Accept 头或都不支持时用 JSON
> ```
### Q23.【JWT 认证中间件】
> [!tip]- Q23 答案
> **`Authorization` ; `c.Next()`**
>
> **解析:**
>
> | 空 | 填空 | 作用 |
>|----|------|------|
> | 空1 | `Authorization` | HTTP 标准鉴权头,通常格式为 `Bearer <token>` |
> | 空2 | `c.Next()` | 验证通过后放行到下游 handler |
>
> **完整流程:**
> ```
> 请求 → jwtAuth()
> → 取 Authorization header
> → parseJWT(token) 验证签名和过期时间
> → claims.UserID / claims.Role 存入 Context
> → c.Next() 进入业务 handler
> → handler 用 c.GetString("userID") 读取
> ```
>
> 💡 **工程补充:**
> - `c.GetHeader()` 等价于 `c.Request.Header.Get()`,只是少了一层链式调用
> - 如果是 `Authorization: Bearer xxx` 格式,需要先 Strip "Bearer " 前缀:`strings.TrimPrefix(token, "Bearer ")`
> - JWT 中间件中不要做数据库查询来验证用户是否存在——应该在首次登录后缓存 userId,减少 DB 压力
---
## 四、主观题(每题 4 分,共 16 分)
> 简要回答即可,不需要长篇大论。关键是说出你的理解。
### Q24.【路由优先级深入】
假设你先注册了 `GET /users/:id`,再注册 `GET /users/list`。请问这两个路由是否会冲突?Gin 会 panic 吗?最终 `/users/list` 会匹配到哪个 handler?结合 Radix Tree 的工作原理解释原因。
> [!tip]- Q24 参考答案
>
> 不会 panic,也不会冲突。因为 Radix Tree 是按**路径片段类型**来决定优先级的:静态片段(`list`)永远优先于动态片段(`:id`)。Gin 在匹配时,遇到分支点会先尝试走静态边,找不到才走动态边。所以无论注册顺序如何,`/users/list` 始终匹配到第二个 handler(`GET /users/list`),而 `/users/123` 匹配到第一个。
>
> 不过,**注册两条完全相同的 path+method 会 panic**(比如两次注册 `GET /users/:id`),因为只有 path 和方法都相同才会被视为重复。
### Q25.【中间件作用域选择】
你有 `/api/v1/` 和 `/api/v2/` 两个 API 分组,都需要 CORS 支持。你会把 CORS 中间件注册到全局(`r.Use(cors())`),还是分别注册到各自分组(`v1.Group(..., cors())` 和 `v2.Group(..., cors())`)?给出理由。
> [!tip]- Q25 参考答案
>
> 两种都可以,取决于项目的整体需求:
>
> - **注册到全局**:如果你希望整个服务的所有接口(包括 health check、内部接口)都允许跨域,这是最简单的做法。但缺点是 CORS 头会加到所有路由上,包括不需要跨域的接口。
> - **注册到各自分组**:更精细的控制,只有需要跨域的 API 分组带上 CORS。但如果 v1 和 v2 之外还有其他分组也需要 CORS,维护成本会变高。
>
> **最佳实践**:一般把 CORS 注册到全局(`r.Use(cors())`),因为:① 大多数现代前后端分离项目所有 API 都需要跨域;② CORS 头的开销极小;③ 如果需要排除特定路径,可以在中间件内部加 `if path == "/health" { c.Next(); return }` 的判断。
### Q26.【c.Copy 与 goroutine 陷阱】
下面这段代码有什么问题?如何修复?
```go
func notifyHandler() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
go func() {
// 发送通知
notifyService.Send(c.GetString("userID"),
c.Writer.Status(),
time.Since(start))
}()
// handler 返回,Context 被回收放回 pool
}
}
```
> [!tip]- Q26 参考答案
>
> **问题:** goroutine 是在 `c.Next()` 之后启动的,handler 返回后 Context 会被 `reset()` 并放回 `sync.Pool`。此时 goroutine 还在运行并引用 `c`,可能出现:
> 1. `c.GetString("userID")` 读到下一个请求的数据(Context 被复用了)
> 2. `c.Writer.Status()` panic(Writer 已被 reset)
>
> **修复:** 使用 `c.Copy()` 创建独立副本,且只能在 goroutine 中**读取**不能**写入**:
>
> ```go
> func notifyHandler() gin.HandlerFunc {
> return func(c *gin.Context) {
> start := time.Now()
> c.Next()
>
> userID := c.GetString("userID")
> status := c.Writer.Status()
> latency := time.Since(start)
>
> copy := c.Copy()
> go func() {
> notifyService.Send(userID, status, latency)
> }()
> }
> }
> ```
>
> 关键原则:**提前取值、用 copy、goroutine 只读**。
### Q27.【错误处理方案设计】
如果你的团队有 10 人以上,负责一个大型 Gin 微服务项目。你觉得采用「Helpers 方案」(每个 handler 直接调用 `OK/Err`)还是「中间件兜底方案」(handler 只用 `c.Set`,中间件统一组装响应)更好?说明优缺点和你的选型依据。
> [!tip]- Q27 参考答案
>
> 对于 10+ 人的大型团队,推荐**中间件兜底方案(方案 B)**,理由如下:
>
> **Helpers 方案的缺点:**
> - 每个人写 JSON 响应的风格不同,容易格式不一致(有人用 `gin.H`,有人用 struct)
> - 新增字段(如 `trace_id`)需要改每个 helper 调用处,或每次手动加上
> - 新人上手快,但随着人数增长,review 成本急剧上升
>
> **中间件兜底的优点:**
> - handler 完全不接触 HTTP 响应细节,关注点分离清晰
> - 响应格式由中间件强制统一,不可能出现格式不一致
> - 横切逻辑(统一打日志、加 trace_id、指标采集)集中在中间件后置逻辑
> - 适合多人协作,降低 merge conflict
>
> **但代价是:** 心智负担更高——需要理解中间件的时序(`c.Next()` 之前 vs 之后),调试不如 Helpers 直接打断点方便。
>
> **渐进式建议:** 小团队先用 Helpers 跑起来,当出现格式不统一的问题时再升级到中间件兜底。
>
> **补充:** 还可以结合方案 C(`errors.As`)实现跨层结构化错误传递,进一步提升大型项目的错误处理质量。
---
## 评分参考
| 题目类型 | 满分 | 权重 |
|---------|------|------|
| 选择题(Q1-Q10) | 30 分 | 30% |
| 填空题(Q11-Q18) | 24 分 | 24% |
| 代码补全(Q19-Q23) | 30 分 | 30% |
| 主观题(Q24-Q27) | 16 分 | 16% |
| **总计** | **100 分** | **100%** |
**及格线:** ≥70 分
**优秀线:** ≥85 分