855 lines
35 KiB
Markdown
855 lines
35 KiB
Markdown
|
|
---
|
|||
|
|
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 分
|