diff --git a/BACKEND/GIN/6-error-handling.md b/BACKEND/GIN/6-error-handling.md index 250f6c4..a0c7410 100644 --- a/BACKEND/GIN/6-error-handling.md +++ b/BACKEND/GIN/6-error-handling.md @@ -9,10 +9,47 @@ create time: 2026-04-28 00:00 Gin 提供了一套链式错误收集 + 全局处理的错误管理机制:handler 中调用 `c.Error(err)` 将错误存入上下文,`c.Errors` 收集所有错误,全局恢复中间件兜底 panic。理解这套机制可以写出结构化、一致的错误响应。 -思考题:如果你的业务函数返回 `(result, err)`,是否需要手动调用 `c.Error(err)`?还是可以直接写 JSON 响应?(详见下文第 2 节) +> [!question]- 前置思考 +> 如果你的业务函数返回 `(result, err)`,是否需要手动调用 `c.Error(err)`?还是可以直接写 JSON 响应?(详见下文第 2 节) ## 正文 +### 0. 错误处理架构总览 + +Gin 项目的错误处理可以概括为三层策略:**直接返回**、**链式收集**、**兜底恢复**。整个流程如下: + +```mermaid +flowchart TD + A["请求进入 Handler"] --> B{"是否需要
多步验证?"} + B -- "否" --> C["直接 c.JSON 返回错误"] + B -- "是" --> D["c.Error(err) 累积错误"] + D --> E{继续处理其他步骤} + E --> D + E -- "全部完成" --> F{"len(c.Errors) > 0?"} + F -- "有错误" --> G["返回统一错误列表"] + F -- "无错误" --> H["正常响应"] + C --> Z["结束"] + G --> Z + H --> Z + + I["Panic 异常"] --> J["gin.Recovery 中间件拦截"] + J --> K["打印堆栈 + 写入 c.Errors"] + K --> L["c.AbortWithStatus(500)"] + L --> Z +``` + +**三条防线:** + +| 层级 | 触发方式 | 处理方式 | 适用场景 | +|------|---------|---------|---------| +| **① 直接返回** | handler 内 `c.JSON` + `return` | 直接回写响应并退出当前路由 | 单次错误(参数错误、资源不存在) | +| **② 链式收集** | `c.Error(err)` | 全部完成后一次性返回 | 批量操作、多步验证 | +| **③ 兜底恢复** | panic | Recovery 中间件捕获 | 运行时意外崩溃 | + +> [!question]- 思考 +> 当第 ② 层的链式收集遇上第 ③ 层的 Panic 时——如果一个 `c.Error()` 的参数本身就是 `nil`,会导致 panic 吗? +> → 详见 [[GIN/6-error-handling/q-c-error-nil-panic]] + ### 1. `c.Error` → `c.Errors` 链式错误收集 Gin 允许在请求生命周期内累积多个错误——这在批量操作或多步验证场景中非常有用: @@ -22,7 +59,7 @@ func batchUpdate(c *gin.Context) { var items []Item c.ShouldBindJSON(&items) - var errors gin.ErrorList + var errList gin.ErrorList for _, item := range items { if err := validate(item); err != nil { @@ -78,9 +115,11 @@ const ( | `c.Errors.First()` | 获取第一个错误 | | `c.Errors.ToStrings()` | 转为字符串切片 | -> **关键理解:** `c.Error(err)` 不终止请求、不回写响应——它只是往 `c.errors` 切片里追加。你需要显式检查 `len(c.Errors)` 并返回错误响应。 +> [!note] 关键理解 +> `c.Error(err)` **不终止请求、不回写响应**——它只是往 `c.errors` 切片里追加。你需要显式检查 `len(c.Errors)` 并返回错误响应。 -思考题:`c.Error()` 和直接 `c.JSON(500, ...)` 有什么区别?什么时候应该用前者? +> [!question]- 思考 +> `c.Error()` 和直接 `c.JSON(500, ...)` 有什么区别?什么时候应该用前者? ### 2. 业务错误的正确处理方式 @@ -164,9 +203,10 @@ r.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { })) ``` -> **提问:** `gin.Recovery()` 把 panic 也写进了 `c.Errors`,这意味着如果你在 Recovery 之后还有中间件,它们能看到这个错误吗? - -答案:不能。`recover()` 后调用了 `c.AbortWithStatus()`,中间件链已经终止。 +> [!question]- 思考 +> `gin.Recovery()` 把 panic 也写进了 `c.Errors`,这意味着如果你在 Recovery 之后还有中间件,它们能看到这个错误吗? +> +> 答案:不能。`recover()` 后调用了 `c.AbortWithStatus()`,中间件链已经终止。 ### 4. HTTP 状态码与业务码的映射 @@ -208,48 +248,295 @@ handleBizError(c, http.StatusNotFound, 40401, "订单不存在") | 404xx | 404 | 资源不存在 | | 50xxx | 500 | 服务端内部错误 | -思考题:如果你的 API 同时面向前端和第三方开发者,你觉得业务错误码是放在 JSON body 里好,还是也应该加一个自定义响应头(如 `X-Biz-Code`)?为什么? +> [!question]- 思考 +> 如果你的 API 同时面向前端和第三方开发者,你觉得业务错误码是放在 JSON body 里好,还是也应该加一个自定义响应头(如 `X-Biz-Code`)?为什么? +> → 可以在方案选择时灵活决定——Helpers 方案中直接加 `c.Header("X-Biz-Code", strconv.Itoa(bizCode))`;中间件方案则在中间件后置逻辑中统一添加。两种都能做到集中管理。 -### 5. 统一错误响应中间件 +### 5. 实践方案一:简洁 Helpers(适合大多数项目 ✅) -把错误处理抽成中间件,确保所有路由的响应格式一致: +最实用的做法——写两个辅助函数,handler 里直接调,简单直白: ```go -func errorMiddleware() gin.HandlerFunc { +// helpers.go + +type Resp struct { + Code int `json:"code"` // 0 = 成功;其他 = 业务码 + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +func OK(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Resp{Code: 0, Message: "ok", Data: data}) +} + +func Err(c *gin.Context, httpCode, bizCode int, msg string) { + c.JSON(httpCode, Resp{Code: bizCode, Message: msg}) +} +``` + +使用方式: + +```go +func createUser(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + Err(c, 400, 40001, "参数校验失败") + return + } + + user, err := svc.CreateUser(req) + if err != nil { + Err(c, 500, 50000, err.Error()) + return + } + + OK(c, user) +} +``` + +> [!tip] 实践提示 +> 这套方案的精髓是 **"响应结构集中管理"** ——`Resp` 结构体定义了一次的格式,所有接口共享。如果需求变了(比如加一个 `trace_id` 字段),只改 `Resp` 一个地方即可。 + +**优点:** +- 直观易懂,没有隐式中间件魔法 +- 调试时可以直接在 `c.JSON` 行打断点 +- 学习成本为零——任何 Go 开发者都能上手 + +**缺点:** +- 每个分支都要写 `return`(Go 惯例,无法避免) +- 横切逻辑(统一打日志、加 trace_id)需要在 helper 内部做 + +--- + +### 6. 实践方案二:中间件兜底 + `c.Set()`(适合大型/团队项目) + +如果你希望 **handler 完全不接触 HTTP 响应细节**,可以让中间件统一负责"组装响应": + +#### Step A — 中间件在 `c.Next()` 之后统一输出 + +```go +func jsonResponseMiddleware() gin.HandlerFunc { return func(c *gin.Context) { c.Next() - // 检查是否有积压的错误 - if len(c.Errors) == 0 { + // ① handler 显式设置了错误 → 优先处理 + if bizErr, ok := c.Get("error"); ok { + e := bizErr.(*BizError) + c.JSON(e.HTTPCode, gin.H{ + "code": e.Code, + "message": e.Message, + }) return } - // 取最后一个(或最严重)的错误 - lastErr := c.Errors.Last() - switch lastErr.Type { - case gin.ErrorTypePrivate: - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 50000, - "message": "internal server error", - }) - case gin.ErrorTypePublic: - if e, ok := lastErr.Err.(*GinError); ok { - c.JSON(e.HTTPCode, gin.H{ - "code": e.Code, - "message": e.Message, - }) - } - default: - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 50000, - "message": "internal server error", + // ② c.Error() 累积了批量错误 + if len(c.Errors) > 0 { + last := c.Errors.Last() + // ...按 ErrorType 输出 + return + } + + // ③ 正常数据 + if data, ok := c.Get("resp_data"); ok && data != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "ok", + "data": data, }) } } } ``` -> **注意:** 如果 handler 已经通过 `c.JSON()` 写入了响应体,中间件再写会覆盖或报错。因此需要在 handler 返回前拦截错误,而不是在 `c.Next()` 之后处理。更常见的做法是封装统一的 `c.Success()` / `c.Error()` 辅助方法。 +中间件注册顺序要放在路由之前: + +```go +r := gin.Default() +r.Use(jsonResponseMiddleware()) // 放在路由注册之前 +r.POST("/users", createUser) +``` + +#### Step B — Handler 只声明意图,不写 JSON + +```go +func createUser(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.Set("error", &BizError{Code: 40001, Message: "参数校验失败", HTTPCode: 400}) + return + } + + user, err := svc.CreateUser(req) + if err != nil { + c.Set("error", &BizError{Code: 50000, Message: err.Error(), HTTPCode: 500}) + return + } + + c.Set("resp_data", user) // 不是 c.JSON!是 c.Set() +} +``` + +#### Step C — 配套的业务错误类型 + +```go +type BizError struct { + Code int // 业务码 + Message string // 用户可见消息 + HTTPCode int // HTTP 状态码 +} + +func (e *BizError) Error() string { return e.Message } + +// 快捷构造器 +func BadRequest(msg string) *BizError { + return &BizError{Code: 40000, HTTPCode: 400, Message: msg} +} + +func NotFound(msg string) *BizError { + return &BizError{Code: 40400, HTTPCode: 404, Message: msg} +} + +func Internal(msg string) *BizError { + return &BizError{Code: 50000, HTTPCode: 500, Message: msg} +} +``` + +Handler 中直接复用: + +```go +func createUser(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.Set("error", BadRequest("用户名不能为空")) + return + } + + user, err := svc.CreateUser(req) + if err != nil { + c.Set("error", Internal(err.Error())) + return + } + + c.Set("resp_data", user) +} +``` + +> [!note] 关键理解 +> `c.Set()` 和 `c.Get()` 只是往 context 中塞 key-value,**没有任何 HTTP 语义**。真正决定 HTTP 响应的是中间件里 `c.Next()` 之后的 `c.JSON()`。handler 完全不知道响应长什么样,做到了**关注点分离**。 + +> [!question]- 思考 +> 中间件同时检查了 `"error"` 和 `c.Errors`,为什么需要两套机制?它们的关系是什么? +> → `c.Set("error")` 适用于单步场景(每次只有一个错误);`c.Error()` 适用于批量场景(多步验证时全部收集完再返回)。中间件两者都检查,优先级:error > Errors > resp_data。 + +--- + +### 7. 实践方案三:配合 `errors.Is/As`(适合跨层传递结构化错误) + +当 Service 层的错误也需要被 Handler 区分处理时,可以用 Go 标准库的错误包装: + +```go +// bizerror.go +type BizError struct { + Code int `json:"-"` + Message string `json:"-"` + HTTPCode int `json:"-"` +} + +func (e *BizError) Error() string { return e.Message } + +// 支持 errors.Is / errors.As +func (e *BizError) Is(target error) bool { _, ok := target.(*BizError); return ok } +func (e *BizError) As(target interface{}) bool { + if p, ok := target.(**BizError); ok { *p = e; return true } + return false +} + +func BadRequest(msg string) *BizError { + return &BizError{Code: 40000, HTTPCode: 400, Message: msg} +} +``` + +Service 层抛出带业务码的错误: + +```go +func CreateUser(req CreateUserRequest) (*User, error) { + if req.Name == "" { + return nil, BadRequest("用户名不能为空") // 不是 fmt.Errorf! + } + // ... +} +``` + +Handler 用 `errors.As` 解包: + +```go +import "errors" + +func createUser(c *gin.Context) { + var req CreateUserRequest + c.ShouldBindJSON(&req) + + user, err := svc.CreateUser(req) + if err != nil { + if bizErr := (&BizError{}); errors.As(err, &bizErr) { + // 提取到了业务错误,原样透传 + Err(c, bizErr.HTTPCode, bizErr.Code, bizErr.Message) + } else { + // 非业务错误(如 DB 连接断开),兜底 500 + Err(c, 500, 50000, "系统异常") + } + return + } + + OK(c, user) +} +``` + +> [!note] 关键理解 +> `errors.As(err, &target)` 会沿着 `err` 的 `Unwrap()` 链逐层查找,找到第一个匹配的类型就赋值给 `target`。这意味着即使 service → repo 多层 `fmt.Errorf("xxx: %w", err)` 包装,handler 依然能精准提取到最原始的 `BizError`。 + +--- + +### 方案对比与选型建议 + +| 维度 | 方案 A — Helpers | 方案 B — 中间件兜底 | 方案 C — errors.As | +|------|------------------|--------------------|--------------------| +| **handler 写 JSON?** | ✅ helper 代写 | ❌ 不写,只用 `c.Set()` | ✅ helper 代写 | +| **中间件参与响应?** | ❌ 不参与 | ✅ 核心角色 | ⚠️ 可选 | +| **心智负担** | 低 | 中(理解中间件时序) | 中高(理解错误链) | +| **响应格式一致性** | 高(靠规范约束) | 极高(架构强制) | 高 | +| **调试友好度** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | +| **错误跨层透传** | 一般 | 一般 | 优秀 | +| **适用团队规模** | ≤3 | ≥5 | ≥8 | +| **推荐场景** | 默认首选 | 大项目/强规范 | 复杂业务/分层清晰 | + +> [!tip] 渐进式升级 +> 建议从方案 A 起步——先跑起来。**只有当团队真的出现响应格式不统一的问题时**,再升级到方案 B。方案 C 则根据项目的错误复杂度决定是否引入。不要一开始就上重型武器。 + +--- + +为了帮助理解各方案的请求生命周期,对比以下两种流程: + +```mermaid +flowchart LR + subgraph S1["方案 A:Helpers(直连)"] + A1["Client"] -->|"HTTP Request"| H1["Handler"] + H1 -->|"shouldBind / service"| Svc{"业务逻辑"} + Svc -->|"失败"| H1 + H1 -->|"Err(c, ...) / OK(c, ...)"| R1["统一 RESP 结构"] + R1 -->|"c.JSON()"| C1["Client"] + end + + subgraph S2["方案 B:中间件兜底"] + A2["Client"] -->|"HTTP Request"| RM["响应中间件\nc.Next() 之后接管"] + RM -->|"c.Next()"| H2["Handler"] + H2 -->|"shouldBind / service"| Svc2{"业务逻辑"} + Svc2 -->|"失败"| H2 + H2 -->|"c.Set('error', …)"| M2["c.Next() 返回"] + M2 -->|"c.Get('error')\nc.JSON()"| R2["统一 RESP 结构"] + R2 -->|"Client"| C2["Client"] + end +``` ## 关联笔记 diff --git a/BACKEND/GIN/6-error-handling/q-biz-code-header.md b/BACKEND/GIN/6-error-handling/q-biz-code-header.md new file mode 100644 index 0000000..318d7fd --- /dev/null +++ b/BACKEND/GIN/6-error-handling/q-biz-code-header.md @@ -0,0 +1,77 @@ +--- +tags: [后端, Go, Gin, 错误处理, API设计] +create time: 2026-04-28 00:15 +--- + +# 业务错误码:JSON Body vs 响应头 + +## 概述 + +讨论 API 返回业务错误码时,应该放在 JSON body 还是自定义 HTTP 响应头(如 `X-Biz-Code`),以及何时需要两者兼有。 + +## 正文 + +### 方案对比 + +| | JSON Body(`code` 字段) | 响应头(`X-Biz-Code`) | +|---|---|---| +| **优点** | 解析简单,所有语言/框架天然支持 | 中间件/网关不经解析 body 就能识别错误类型 | +| **缺点** | 客户端必须先解析 body 才知道发生了什么 | 不是所有代理层都透传自定义头;某些 SDK 默认不暴露 headers | + +```go +// 方案 A:仅 JSON body +c.JSON(http.StatusBadRequest, gin.H{ + "code": 40001, + "message": "参数校验失败", +}) + +// 方案 B:同时写入 header + body +c.Header("X-Biz-Code", strconv.Itoa(40001)) +c.JSON(http.StatusBadRequest, gin.H{ + "code": 40001, + "message": "参数校验失败", +}) +``` + +### 面向前端(同团队) + +JSON body 就够了。前端代码是你写的,统一走 body 的 `code` 字段即可,加 header 反而增加复杂度。 + +```typescript +// 前端统一拦截器处理 +axios.interceptors.response.use(null, (err) => { + const bizCode = err.response?.data?.code; + if (bizCode === 40001) showToast("请检查输入"); + if (bizCode === 40101) navigateTo("/login"); + return Promise.reject(err); +}); +``` + +### 面向第三方开发者 —— 推荐双通道 + +原因有三: + +1. **网关 / 反向代理决策**——WAF、API Gateway 或熔断器通常只能看 HTTP 状态码和 header,无法解析 JSON body。`X-Biz-Code` 能让网关直接拦截特定错误(比如 `40001` 直接限流,而不打到你的服务)。 + +2. **SDK 封装友好**——第三方集成 SDK 后,可根据 header 快速区分重试策略:瞬时错误自动重试,确定错误直接抛出,无需先解 body。 + +3. **调试效率**——运维通过日志打印 `$http_x_biz_code` 就能一眼看到业务错误分布,不用额外解析请求体。 + +### Gin 中的统一实现 + +```go +func handleBizError(c *gin.Context, httpCode, bizCode int, msg string) { + c.Header("X-Biz-Code", strconv.Itoa(bizCode)) // ← header + c.JSON(httpCode, gin.H{ // ← body + "code": bizCode, + "message": msg, + }) +} +``` + +成本极小——多一行 `c.Header()` 而已,但对两边的使用者(前端 + 第三方)都照顾到了。 + +## 关联笔记 + +- [[GIN/6-error-handling]] — 错误处理机制总览 +- [[GIN/6-error-handling/q-c-error-nil-panic]] — c.Error(nil) 是否会导致 panic diff --git a/BACKEND/GIN/6-error-handling/q-c-error-nil-panic.md b/BACKEND/GIN/6-error-handling/q-c-error-nil-panic.md new file mode 100644 index 0000000..93e9877 --- /dev/null +++ b/BACKEND/GIN/6-error-handling/q-c-error-nil-panic.md @@ -0,0 +1,97 @@ +--- +tags: [后端, Go, Gin, 错误处理, FAQ] +create time: 2026-04-28 00:15 +--- + +# `c.Error(nil)` 会导致 panic 吗? + +## 概述 + +回答一个常见的疑虑:在 Gin 的链式错误收集中,传入 `nil` 作为 `c.Error()` 的参数是否会触发运行时 panic。结论是 **不会 panic**,但会带来逻辑污染问题。 + +## 正文 + +### 1. 为什么不会 panic + +`c.Error(err)` 的底层实现只是往内部切片追加元素,不做 nil 检查: + +```go +// gin/context.go — 简化版 +func (c *Context) Error(err error) { + c.errors = append(c.errors, &Error{ + Err: err, // 直接赋值,nil 没问题 + Type: ErrorTypePrivate, + Meta: nil, + }) +} +``` + +Go 的 `append` 对 nil 元素完全安全——它等价于在切片中插入一个零值指针。所以调用 `c.Error(nil)` 不会触发任何 panic。 + +### 2. 但它的行为值得警惕 + +虽然不 panic,nil 错误会在下游产生两类实际问题: + +#### ① 逻辑污染 —— 静默 Bug + +```go +c.Error(nil) +c.Error(nil) + +// len(c.Errors) == 2,进入了"有错误"分支 +if len(c.Errors) > 0 { + c.JSON(400, gin.H{"errors": c.Errors.ToStrings()}) + // → ["", ""] ← 客户端收到了无意义的空错误列表 +} +``` + +nil 错误会: +- 虚增 `len(c.Errors)`,让条件判断走入错误分支 +- 干扰 `ByType` 过滤结果 +- 让 `Last()` / `First()` 返回无效值 + +#### ② 类型断言场景 —— 通常安全但有边界风险 + +```go +last := c.Errors.Last() +if e, ok := last.Err.(*BizError); ok { + // nil 的类型断言结果是 ok == false → 安全 +} +``` + +nil 的错误类型的强转结果是 `ok == false`,本身不会 panic。但如果上游的 Recovery 中间件将非 error 类型的 panic 对象塞入 `c.Errors`,再经此处强转就会触发 panic——这是 recovery 链中的假设被违反,并非 `c.Error(nil)` 直接导致,但 nil 错误会让这类边界更难排查。 + +### 3. 最佳实践 + +在调用前加一层 nil 守卫即可彻底规避: + +```go +if err != nil { + c.Error(&gin.Error{ + Err: err, + Meta: item.ID, + }) +} +``` + +或者封装统一辅助方法时内置防护: + +```go +func ReportError(c *gin.Context, httpCode, bizCode int, msg string) { + if msg == "" { + return // 空消息不写入错误链 + } + c.Error(&gin.Error{ + Err: &BizError{Code: bizCode, Message: msg, HTTPCode: httpCode}, + Type: gin.ErrorTypePrivate, + Meta: nil, + }) + c.Abort() +} +``` + +## 关联笔记 + +- [[GIN/6-error-handling]] — 错误处理机制总览 +- [[GIN/3-middleware]] — 中间件执行顺序与 Recovery 链 +- [[GIN/5-binding-validation]] — 绑定错误的 ErrorTypeBind 分类 diff --git a/BACKEND/GIN/7-binding-advanced.md b/BACKEND/GIN/7-binding-advanced.md index daf1ab2..b73de85 100644 --- a/BACKEND/GIN/7-binding-advanced.md +++ b/BACKEND/GIN/7-binding-advanced.md @@ -32,17 +32,38 @@ func handle(c *gin.Context) { **自动检测优先级:** +```mermaid +flowchart TD + A["c.ShouldBind(&obj)"] --> B{读取 Content-Type} + B -->|"application/json"| C["ShouldBindJSON"] + B -->|"application/xml"| D["ShouldBindXML"] + B -->|"application/x-www-form-urlencoded"| E["ShouldBindForm"] + B -->|"multipart/form-data"| F["ShouldBindMultipart"] + B -->|无或未知类型| G[fallback: 尝试 form binding] + C --> H[绑定结果 → err?] + D --> H + E --> H + F --> H + G --> H + H -->|"有错误"| I["返回 400 + 错误信息"] + H -->|"成功"| J["字段填入 obj"] +``` + | Content-Type | 绑定方式 | |--------------|----------| | `application/json` | `ShouldBindJSON()` | | `application/xml` | `ShouldBindXML()` | -| `form/urlencoded` | `ShouldBindBodyWith(bind.Form, bind.Encoder{})` | +| `application/x-www-form-urlencoded` | `ShouldBindForm()` | | `multipart/form-data` | `ShouldBindMultipart()` | -| 无或未知 | 尝试 form binding | +| 无或未知 | fallback 到 form binding | > **提问:** 如果客户端发送了 `application/json` 但没有设置 Content-Type 头,`ShouldBind` 会成功吗? - -答案:会,但可能不如预期——Gin 的 fallback 逻辑会尝试用 form binding 解析,对于 JSON 格式的请求体会报解析错误。生产环境建议要求客户端显式声明 Content-Type。 +> +>
+> 点击展开答案 +> +> 不会成功。Gin 的 fallback 逻辑会将未知类型当作 form binding 处理,用 `application/x-www-form-urlencoded` 的方式去解析 JSON 字符串,必然报解析错误。生产环境建议通过中间件强制要求客户端显式声明 `Content-Type`。 +>
### 2. Map 作为绑定参数 @@ -61,7 +82,16 @@ func updateFields(c *gin.Context) { } ``` -跳过绑定的字段:使用 ``binding:"-"`` 标签排除: +> [!CAUTION] Map 绑定的类型丢失陷阱 +> +> JSON 中的数字 `100` 在 Go 中会变成 `float64`,而不是 `int`。如果后续需要用到具体数值类型,必须做显式转换: +> ```go +> age, ok := fields["age"].(float64) // JSON 数字 → float64 +> if !ok { /* 处理类型断言失败 */ } +> ``` +> 所以 **Map 绑定适合写"通用 API"**(如配置更新),但不推荐用于结构化业务数据——用结构体 + 校验标签才是更稳妥的选择。 + +**跳过绑定的字段:使用 `binding:"-"` 标签排除某个结构体字段,使其不参与任何请求数据绑定。** 这是防止前端篡改敏感字段(如权限、服务端生成 ID)的关键手段——详见 [[7-binding-advanced/skip-binding]]。 ```go type User struct { @@ -93,7 +123,7 @@ func mixedBind(c *gin.Context) { // 拼接条件 offset := (page - 1) * pageSize results := db.Offset(offset).Limit(pageSize). - Where("name LIKE ?", "%"+keyword+"%").Find(&users) + Where("name LIKE ?", "%"+payload.Keyword+"%").Find(&users) } ``` @@ -115,12 +145,31 @@ func handler(c *gin.Context) { > **陷阱:** 如果 query 和 body 都有 `page` 字段,先 bind query 再 bind JSON,body 的值会覆盖 query。这通常不是期望的行为。 +```mermaid +flowchart LR + A["请求: ?page=2&keyword=gin"] --> B["bind query page=2 keyword=gin"] + C['Body JSON: page=1, name=test'] --> D["bind JSON page=1 name=test"] + B --> E["最终结果 page=1 被覆盖"] + D --> E +``` + **推荐做法:** 分开两个结构体,或者像方案一那样手动提取。 ### 4. 字段默认值策略 Go 零值机制可以部分替代默认值,但 HTTP 场景下有时需要区分"未提供"和"提供了零值": +```mermaid +flowchart TD + A["字段未提供"] --> B{"是否用指针"} + B -->|是| C["*int = nil 判为未提供"] + B -->|否| D["int = 0 零值无法区分"] + A --> E{"字段提供了零值"} + E -->|是| F["指针方式也拿不到信号"] + F --> G["两种方案都无法区分"] + D --> H["用 map 手动标记"] +``` + ```go // 方法一:使用 pointer 类型判断是否被设置 type CreateReq struct { @@ -129,7 +178,7 @@ type CreateReq struct { Priority *int `json:"priority"` // 默认值为 1 } -func handler(c *gin.Context) { +func pointerDefaults(c *gin.Context) { var req CreateReq c.ShouldBindJSON(&req) @@ -151,7 +200,7 @@ type SearchReq struct { Keyword string `json:"keyword"` } -func handler(c *gin.Context) { +func manualDefaults(c *gin.Context) { var req SearchReq c.ShouldBindJSON(&req) @@ -165,16 +214,16 @@ func handler(c *gin.Context) { // keyword 允许为空字符串,不需要设默认值 } -// 方法三:利用 query binding 的 DefaultQuery / DefaultString -func handler(c *gin.Context) { - page := c.DefaultInt("page", 1) // 查询参数默认为 1 - status := c.DefaultQuery("status", "all") // 查询参数默认为 "all" +// 方法三:利用 query binding 的 DefaultQuery / DefaultInt +func queryDefaults(c *gin.Context) { + page := c.DefaultInt("page", 1) // query 参数默认为 1 + status := c.DefaultQuery("status", "all") // query 参数默认为 "all" } ``` 思考题:为什么 Gin 没有像某些框架那样提供 `default` 结构体标签(如 Spring Boot 的 `@DefaultValue`)?你觉得这种设计的好处是什么? -提示:考虑 Go 的零值语义 vs 其他语言的区别。 +> **提示:** 考虑 Go 的零值语义 vs 其他语言的区别。Go 强调"显式优于隐式",默认值逻辑放在业务层而非框架层,让开发者清楚每个字段的来源。这虽然多了几行代码,但避免了隐藏的控制流——当你在调试时,不需要猜某个值是从哪来的。 ### 5. 按条件绑定不同结构体 @@ -203,20 +252,34 @@ func flexibleHandler(c *gin.Context) { > **核心原理:** 第一次调用 `ShouldBindBodyWith` 时会完整读取并缓存 `request.Body`,后续调用直接复用缓存。所以性能代价是**额外占用内存**存储一份请求体副本——只应在需要解耦的场景使用。 +> [!NOTE] 何时使用 ShouldBindBodyWith? +> - ✅ 路由中间件已读取过 Body(如日志记录),需要通过 `c.Request.Body = io.NopCloser(bytes.NewBuffer(buf))` 恢复后再绑定 +> - ✅ 同一请求需要根据某个字段分发给不同处理逻辑 +> - ❌ 如果只需要读一次 body,直接用 `ShouldBindJSON` 即可,无需多此一举 +> +> Gin 还提供更细粒度的 API:`ShouldBindBodyWith(obj, binding.Binding)` 允许指定绑定策略(`bind.JSON`、`bind.Form` 等),而不是让 Gin 自动猜测。 + ### 6. 常见绑定标签速查 -| 标签 | 说明 | 示例 | -|------|------|------| -| `binding:"required"` | 必填 | `"name" binding:"required"` | -| `binding:"omitempty"` | 可选,有值则验证 | | -| `binding:"email"` | 邮箱格式 | | -| `binding:"url"` | URL 格式 | | -| `binding:"numeric"` | 纯数字 | | -| `binding:"gte=0,lte=100"` | 范围限制 | 分数 0-100 | -| `binding:"len=11"` | 长度限制 | 手机号 | -| `binding:"iscolor"` | 颜色值 | | -| `` binding:"-" `` | 跳过绑定 | | -| `json:"-"` | 跳过序列化 | | +Gin 的校验底层使用 `go-playground/validator`。以下为最常用的标签: + +| 标签 | 说明 | 示例代码 | +|------|------|----------| +| `binding:"required"` | 字段必填,空值则返回 400 | `"name" binding:"required"` | +| `binding:"omitempty"` | 可选,若提供则执行后续验证 | `"email" binding:"omitempty,email"` | +| `binding:"email"` | 邮箱格式校验 | | +| `binding:"uri"` | URI 格式校验 | | +| `binding:"numeric"` | 纯数字(整数或浮点) | `"code" binding:"required,numeric"` | +| `binding:"gte=0,lte=100"` | 数值范围限制 | `"score" binding:"gte=0,lte=100"` | +| `binding:"len=11"` | 固定长度 | `"phone" binding:"required,len=11"` | +| `binding:"-"` | 跳过绑定和验证 | `Admin bool \`binding:"-"\`` | +| `json:"-"` | 不参与 JSON 序列化 | | + +**实战技巧:** 多个标签可以拼接,用空格分隔: +```go +// email 可选,但若提供了就必须是有效邮箱格式 +Email string `json:"email" binding:"omitempty,email"` +``` ## 关联笔记 diff --git a/BACKEND/GIN/7-binding-advanced/skip-binding.md b/BACKEND/GIN/7-binding-advanced/skip-binding.md new file mode 100644 index 0000000..918fbfa --- /dev/null +++ b/BACKEND/GIN/7-binding-advanced/skip-binding.md @@ -0,0 +1,131 @@ +--- +tags: [后端, Go, Gin, 绑定, 安全] +create time: 2026-04-28 00:00 +--- + +# 跳过绑定 `binding:"-"` + +## 概述 + +在 Gin 的自动绑定机制中,结构体标签 `binding:"-"` 用于**完全排除某个字段**,使其不参与任何请求数据的绑定和校验。这是防止前端篡改敏感数据的第一道防线。 + +## 正文 + +### 工作原理 + +Gin 在调用 `ShouldBind` / `ShouldBindJSON` 等方法时,底层会遍历结构体的所有字段,检查其 `binding` 标签: + +```go +type User struct { + ID uint `json:"id" gorm:"primaryKey"` + Name string `json:"name" binding:"required"` // ✅ 正常绑定 + 校验 + Role string `json:"role" binding:"required"` // ✅ 正常绑定 + 校验 + Admin bool `json:"admin" binding:"-"` // ❌ 完全不参与绑定 +} +``` + +当请求携带 `{ "name": "Alice", "role": "admin", "admin": true }` 时: + +| 字段 | 绑定行为 | 最终值 | +|------|---------|--------| +| `Name` | 从 JSON 取值 | `"Alice"` | +| `Role` | 从 JSON 取值 | `"admin"` | +| `Admin` | **直接忽略**,读取不到 | `false`(Go 零值) | + +### 为什么需要跳过绑定? + +最常见且最重要的场景是**权限防护**。 + +> [!CAUTION] 没有 `binding:"-"` 的风险 +> +> 假设接口设计如下: +> ```go +> type CreateUserRequest struct { +> Name string `json:"name" binding:"required"` +> Role string `json:"role" binding:"required"` +> Admin bool `json:"admin"` // 忘记加 "-"! +> } +> ``` +> +> 恶意用户只需在请求中添加 `"admin": true`,就能把自己创建为管理员——因为 Gin 会自动把前端传来的值填入结构体。**数据绑定时机远早于任何业务校验逻辑**,框架不会区分"普通字段"和"特权字段"。 + +### 跳过绑定 vs 不序列化 + +`binding:"-"` 与 `json:"-"` 是两个不同的概念,经常组合使用: + +```go +type User struct { + ServerID uint `json:"-" gorm:"primaryKey"` // 不序列化、不绑定 + Internal string `binding:"-"` // 可序列化但不绑定 + Admin bool `json:"admin" binding:"-"` // 可序列化但不绑定 +} +``` + +| 标签 | 影响范围 | 效果 | +|------|---------|------| +| `binding:"-"` | 只读入方向(请求 → 结构体) | 请求中的值不会被填入该字段 | +| `json:"-"` | 只写方向(结构体 → JSON) | 该字段不会出现在 JSON 响应中 | +| 两个同时存在 | 双向隔离 | 字段完全不可见 | + +```mermaid +flowchart TD + A["客户端请求"] --> B{"是否有 binding 排除标签"} + B -->|"是"| C["值被丢弃"] + B -->|"否"| D["填入结构体字段"] + D --> E["业务逻辑处理"] + E --> F{"是否有 json 排除标签"} + F -->|"是"| G["不出现在响应 JSON"] + F -->|"否"| H["返回给客户端"] + C -.->|"保持 Go 零值"| E +``` + +### 实战清单 + +在以下字段上务必加上 `binding:"-"`: + +- **权限相关**:`Admin`、`IsSuperuser`、`PermissionLevel` +- **服务端生成**:`ID`(通常由数据库自增)、`CreatedAt`、`UpdatedAt` +- **计算派生**:`FullName`(由 `FirstName + LastName` 拼接)、`Balance`(由账户记录汇总) +- **内部状态**:`retryCount`、`lockVersion`、`tenantID`(应从 JWT Token 中提取) + +> **提问:** 如果 `ID` 已经在路由参数中通过 `c.Param("id")` 提取了,还需要加 `binding:"-"` 吗? +> +>
+> 点击展开答案 +> +> **需要。** `c.Param` 手动提取是一回事,但如果有另一个端点用 `ShouldBindJSON` 接收更新请求,前端依然可能传入 `id` 字段来篡改目标记录(Horizontal / Vertical Privilege Escalation)。最安全的做法是在所有涉及绑定的结构体中标记 `binding:"-"`,让编译器帮你兜底。 +>
+ +### 常见误区 + +```go +// ❌ 错误:只依赖手动赋值,没有显式跳过绑定 +type UpdateReq struct { + Name string `json:"name" binding:"required"` + Admin bool // 没有 binding 标签 → 仍会被绑定! +} + +func handler(c *gin.Context) { + var req UpdateReq + c.ShouldBindJSON(&req) + req.Admin = false // 试图覆盖,但已经晚了——恶意数据已被填入 + // ... +} + +// ✅ 正确:在结构体层面就拦截 +type UpdateReq struct { + Name string `json:"name" binding:"required"` + Admin bool `json:"admin" binding:"-"` // 先拦截,再在后端设值 +} + +func handler(c *gin.Context) { + var req UpdateReq + c.ShouldBindJSON(&req) + // req.Admin 必定是 false,从外部注入不了任何值 + req.Admin = isAdmin(c) // 从 Session / JWT 取真实身份 +} +``` + +## 关联笔记 + +- [[GIN/7-binding-advanced]] — 父文档,高级绑定与表单处理 diff --git a/BACKEND/GIN/8-file-upload.md b/BACKEND/GIN/8-file-upload.md index dce58c3..e272d53 100644 --- a/BACKEND/GIN/8-file-upload.md +++ b/BACKEND/GIN/8-file-upload.md @@ -1,6 +1,6 @@ --- tags: [后端, Go, Gin, 文件上传, 表单] -create time: 2026-04-28 00:00 +create time: 2026-04-28 10:52 --- # 文件上传 @@ -11,6 +11,45 @@ create time: 2026-04-28 00:00 思考题:Gin 默认把超过内存阈值的临时文件存在哪里?这在容器化部署中会带来什么问题?(详见第 3 节) +## 总览 — 文件上传的请求生命周期 + +```mermaid +flowchart LR + subgraph FE["前端 / Client"] + F1["用户选择文件\n(浏览器 / App)"] + F2["构造 multipart/form-data\n请求体"] + end + + subgraph GW["网络传输"] + HTTP["HTTPS POST\nContent-Type: multipart/form-data"] + end + + subgraph BE["Gin 服务端"] + B1["http.Server\n读取请求头"] + B2{"请求体大小\n≤ MaxMultipartMemory?"} + B2 -->|"是"| B3["全部保留在\n[]byte 内存中"] + B2 -->|"否"| B4["部分写入内存\n超出部分 → /tmp 临时文件"] + B3 --> B5["c.Request.FormFile()\nc.MultipartForm()"] + B4 --> B5 + B6["业务逻辑处理\n(类型校验 / 大小限制 / 存 OSS)"] + B5 --> B6 + end + + B6 --> OUT1["返回 JSON 结果\n{filename, size}"] + B6 --> OUT2["转发到对象存储\n(S3 / OSS)"] + + F1 --> F2 --> HTTP --> B1 --> B2 + + style B3 fill:#e3f2fd + style B4 fill:#fff3e0 + style B2 fill:#fff9c4,stroke:#f9a825 + style OUT2 fill:#e8f5e9,stroke:#2e7d32 +``` + +> **一句话总结**:前端以 `multipart/form-data` 格式发送二进制数据 → Gin 根据大小决定是否落盘 → 通过 `FormFile()` / `MultipartForm()` 获取文件句柄 → 业务层保存或使用。 + +--- + ## 正文 ### 1. 单文件上传 @@ -30,7 +69,7 @@ func uploadSingle(c *gin.Context) { // 保存到新位置 dst := filepath.Join("./uploads", filename) - if err := c.SaveUploadedFile(file, dst, 8<<20, nil); err != nil { + if err := c.SaveUploadedFile(header, dst); err != nil { c.JSON(500, gin.H{"error": "save failed"}) return } @@ -42,21 +81,60 @@ func uploadSingle(c *gin.Context) { } ``` -**`SaveUploadedFile` 签名:** +**`SaveUploadedFile` 签名(Gin 标准版):** ```go -func (c *Context) SaveUploadedFile(file *multipart.File, dst string, maxBytes int64, matchers ...mimeType.Matcher) error +func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string, max ...int64) error ``` -- `maxBytes`:文件大小限制(0 = 不限制,默认 8MB) -- `matchers`:可选的文件类型校验函数 +- `file`:`*multipart.FileHeader`,来自 `FormFile()` 或 `MultipartForm()` 的第二个返回值 +- `dst`:目标文件路径 +- `max`:可选的大小限制(字节数),超过则拒绝保存 + +**交互时序:** + +```mermaid +sequenceDiagram + participant C as 客户端 (浏览器/App) + participant S as Gin Handler + participant RF as Request.FormFile() + participant FS as File System + + C->>S: POST /upload
Content-Type: multipart/form-data + Note over S,S: 解析请求体
≤ 8MB → 内存
> 8MB → 部分落盘 /tmp + + S->>RF: FormFile("file") + RF-->>S: (file, header, nil)
header = {Filename, Size, Header} + + S->>S: 业务逻辑处理
(类型校验/大小限制) + + S->>FS: SaveUploadedFile(header, "./uploads/x.jpg") + FS-->>S: nil (成功) + + S-->>C: 200 OK
{filename:"x.jpg", size:102400} + note over C,C: 前端展示上传成功提示 + + alt 文件过大 (> max 参数) + S->>FS: SaveUploadedFile(header, dst, max) + FS-->>S: err "file too large" + S-->>C: 400 Bad Request
{error:"file too large"} + end +``` + +> **扩展用法**:Gin v1.7+ 支持在参数末尾追加 `mime.TypeMatcher` 进行类型校验,但第三方库 `minio/mimetype` 更常见。本文第 4 节展示了手动校验方案。 + +**大小限制最佳实践:** 不要依赖默认 8MB 阈值,应根据业务场景显式指定——图片上传设 5MB,视频上传可设 100MB。 ### 2. 多文件上传 ```go func uploadMultiple(c *gin.Context) { - // 获取表单中的多个文件(最多 100 个) - form, _ := c.MultipartForm() + // 获取 multipart form(超过 MaxMultipartMemory 时部分数据写临时文件) + form, err := c.MultipartForm() + if err != nil { + c.JSON(400, gin.H{"error": "form too large or malformed"}) + return + } files := form.File["files"] // []*multipart.FileHeader saved := make([]string, 0, len(files)) @@ -65,7 +143,7 @@ func uploadMultiple(c *gin.Context) { filename := filepath.Base(file.Filename) dst := filepath.Join("./uploads", filename) - if err := c.SaveUploadedFile(file, dst, 8<<20, nil); err != nil { + if err := c.SaveUploadedFile(file, dst, 8<<20); err != nil { c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save %s", filename)}) return } @@ -97,6 +175,29 @@ Content-Type: image/png > **关键区别:** 单文件用 `c.Request.FormFile("key")`;多文件用 `c.MultipartForm()` 拿到完整的 `*multipart.Form`。两者操作的是同一个底层结构,只是访问粒度不同。 +**单文件 vs 多文件 — API 对比图:** + +```mermaid +flowchart TD + subgraph S1["Single File — c.Request.FormFile"] + SF1["HTTP POST\nmultipart/form-data"] --> SF2["解析指定 key:\nFormFile + file 参数"] + SF2 --> SF3["返回单个: *multipart.FileHeader"] + SF3 --> SF4["SaveUploadedFile(header, dst)"] + end + + subgraph S2["Multiple Files — c.MultipartForm"] + SM1["HTTP POST\nmultipart/form-data\n多个 files 字段"] --> SM2["解析全部:\nMultipartForm"] + SM2 --> SM3["返回 *multipart.Form"] + SM3 --> SM4["取 form.File.files\n// []*FileHeader"] + SM4 --> SM5["for 循环: SaveUploadFile per file"] + end + + style S1 fill:#e3f2fd,stroke:#1565c0 + style S2 fill:#e8f5e9,stroke:#2e7d32 +``` + +> **选型建议**:如果不确定用户上传几个文件,始终走 `MultipartForm()` 路径——它天然兼容单文件场景。 + ### 3. `MaxMultipartMemory` — 内存管理 Gin 底层继承自 `http.Server`,控制内存/磁盘切换的阈值是 `MaxMultipartMemory`(默认 8MB): @@ -106,6 +207,37 @@ Gin 底层继承自 `http.Server`,控制内存/磁盘切换的阈值是 `MaxMu 请求体大小 > MaxMultipartMemory → 超出部分写入临时文件(os.TempDir()) ``` +**内存分配示意图:** + +```mermaid +flowchart LR + subgraph SMALL["📦 10MB 上传 (略超阈值)"] + S1["[ 头信息 ~2KB ]"] --> S2["[ 前 8MB → 内存 []byte ]"] + S2 --> S3["[ 后 2MB → /tmp/go**** ]"] + end + + subgraph TINY["💾 5MB 上传 (未超阈值)"] + T1["[ 整包 5MB → 内存 []byte ]"] + end + + subgraph HUGE["🗄️ 200MB 上传 (大幅超阈值)"] + H1["[ 头信息 ~2KB ]"] --> H2["[ 前 8MB → 内存 []byte ]"] + H2 --> H3["[ 后 192MB → /tmp/go***** ]"] + end + + style S3 fill:#fff3e0,stroke:#f57c00 + style H3 fill:#ffebee,stroke:#c62828 + style T1 fill:#e8f5e9,stroke:#2e7d32 + + classDef safe fill:#e8f5e9,stroke:#2e7d32 + classDef caution fill:#fff3e0,stroke:#f57c00 + classDef danger fill:#ffebee,stroke:#c62828 + + T1:::safe + S3:::caution + H3:::danger +``` + ```go r := gin.Default() @@ -122,38 +254,80 @@ srv := &http.Server{ **容器化环境的陷阱:** ```mermaid -flowchart LR - A["上传 50MB 文件"] --> B{"≤ 8MB?"} - B -->|"否"| C["超出部分写入 /tmp"] - C --> D["容器 /tmp 空间有限"] - D --> E["磁盘满 → 服务崩溃"] +flowchart TD + subgraph CLIENT2["🖥️ 客户端"] + C2["上传 50MB 文件\nPOST /upload"] + end - style E fill:#ffebee,stroke:#c62828 + subgraph SERVER2["🧠 Gin Server"] + S2_1["解析 multipart form
MaxMultipartMemory = 8MB"] + S2_2["写入 42MB → /tmp"] + end + + subgraph CONTAINER["🐳 Docker 容器环境"] + TMP2["/tmp 空间有限
或只读文件系统"] + end + + CLIENT2 --> SERVER2 + S2_2 --> CONTAINER + + COND{"/tmp" 有空间?} + CONTAINER --> COND + COND -->|"否"| BAD["error: no space left
→ OOM Killer ❌"] + COND -->|"是"| OK2["✓ 成功返回 200"] + + style TMP2 fill:#ffebee,stroke:#c62828 + style BAD fill:#ffebee,stroke:#c62828 + style OK2 fill:#c8e6c9,stroke:#2e7d32 + style COND fill:#fff9c4,stroke:#f9a825 ``` +**另一个常见陷阱:只读 `/tmp`** + +```mermaid +flowchart LR + R1["docker run --read-only ..."] --> R2["容器内所有文件系统为只读"] + R2 --> R3["Gin 尝试写入 /tmp"] + R3 --> R4["error: read-only file system"] + R4 --> R5["请求失败 ❌"] + + B1["解决方案: -v tmp:/tmp"] -.->|挂载独立 volume| R3 + + style R4 fill:#ffebee,stroke:#c62828 + style R5 fill:#ffebee,stroke:#c62828 + style B1 fill:#e8f5e9,stroke:#2e7d32 +``` **最佳实践:** - 不要过度放大 `MaxMultipartMemory`——用流式处理代替全量加载 - 容器环境中监控 `/tmp` 用量 - 考虑使用对象存储(S3/OSS)直传,服务端只生成预签名 URL -### 4. 文件类型与大小校验 +### 4. 文件类型校验 仅靠大小限制不够,还需要校验实际文件内容类型(MimeType),防止恶意文件伪装: ```go -import "golang.org/x/exp/mime/multipart" // or use net/textproto +import ( + "fmt" + "net/http" +) -func validateFile(file multipart.File) error { - // 读取前 512 字节检测真实 MIME 类型 +func validateFileType(fh *multipart.FileHeader) error { + file, err := fh.Open() + if err != nil { + return err + } + defer file.Close() + + // 读取前 512 字节检测真实 MIME 类型(基于 magic bytes) buf := make([]byte, 512) - _, err := file.Read(buf) + _, err = file.Read(buf) if err != nil { return err } contentType := http.DetectContentType(buf) - // 只允许图片 allowed := map[string]bool{ "image/jpeg": true, "image/png": true, @@ -164,16 +338,58 @@ func validateFile(file multipart.File) error { return fmt.Errorf("unsupported file type: %s", contentType) } - // 重置读取位置以便后续保存 - file = multipart.NopCloser(bytes.NewReader(buf)) - _ = file - return nil } ``` > **安全提醒:** 永远不要用文件扩展名判断类型(如 `.jpg`)。攻击者可以轻松绕过——应该基于文件内容的 magic bytes(魔数)检测。 +**Magic Bytes 检测原理 — 只看文件头部不看后缀名:** + +```mermaid +flowchart TD + subgraph ATTACK["❌ 攻击者上传恶意文件"] + A1["实际内容:
"] --> A2["人为改名为
'photo.jpg'"] + A2 --> A3["试图骗过后端
通过扩展名判断"] + end + + subgraph VERIFY["✅ Gin 服务端: http.DetectContentType"] + V1["读取前 512 字节"] --> V2["检查 Magic Bytes
(文件头部的固定标识符)"] + V2 --> V3{"匹配哪个签名?"} + V3 -->|"FF D8 FF..."| V4["判定: image/jpeg ✅"] + V3 -->|"89 50 4E..."| V5["判定: image/png ✅"] + V3 -->|"7F 45 4C..."| V6["判定: application/octet-stream ❌"] + V3 -->|"3C 3F 78 6D..."| V7["判定: text/xml; charset=utf-8 ❌"] + end + + A1 -.->|"文件名:"photo.jpg""| V1 + + style ATTACK fill:#ffebee,stroke:#c62828 + style VERIFY fill:#e8f5e9,stroke:#2e7d32 + style V6 fill:#ffcdd2,stroke:#c62828 + style V7 fill:#ffcdd2,stroke:#c62828 + style V4 fill:#c8e6c9,stroke:#2e7d32 + style V5 fill:#c8e6c9,stroke:#2e7d32 +``` + +**常见文件格式的 Magic Bytes(十六进制):** + +| 格式 | Magic Bytes (Hex) | `http.DetectContentType` 返回值 | +|------|-------------------|----------------------------------| +| JPEG | `FF D8 FF E0` 或 `FF D8 FF E1` | `image/jpeg` | +| PNG | `89 50 4E 47` | `image/png` | +| GIF | `47 49 46 38` (`GIF8`) | `image/gif` | +| PDF | `25 50 44 46` (`%PDF`) | `application/pdf` | +| ZIP/DOCX | `50 4B 03 04` | `application/zip` / 需 `minio/mimetype` | +| WebP | `52 49 46 46 ?? ?? ?? ?? 57 45 42 50` | `minio/mimetype` 推荐 | +| 纯文本/HTML | `3C 21 44 4F` (` **注意**:PHP 脚本被识别为 `text/xml`,不是 `application/octet-stream`——所以即使是标准库也能识别出它"不正常"。但更安全的做法是白名单机制(只允许 `image/*`),而非黑名单。 + +> **进阶推荐**:标准库 `http.DetectContentType` 仅识别常见类型,如需更全面的检测(如 Office 文档、PDF),推荐使用 [`minio/mimetype`](https://github.com/minio/mimetype)。用法类似,只需将 `http.DetectContentType(buf)` 替换为 `mimetype.FromBytes(buf)`,再通过 `mime.Type()` 获取字符串。 + ### 5. 分片上传思路 超大文件(GB 级别)不适合直接上传,应使用分片上传: @@ -187,28 +403,128 @@ func validateFile(file multipart.File) error { 5. 服务端合并分片为最终文件 ``` +**分片上传完整流程图:** + +```mermaid +flowchart TD + subgraph CLIENT["🖥️ 客户端 (浏览器/App)"] + C1["原始文件
100MB"] --> C2["切分成 20 个
5MB 的分片"] + C2 --> C3{遍历分片列表
index = 0..19} + C3 -->|"for i in range"| C4["POST /upload/chunk?
fileName=video.mp4
&chunkIndex=i
&totalChunks=20"] + C4 --> C3 + C3 --"全部传完?"--> C5["POST /upload/merge\nfileName=video.mp4"] + end + + subgraph SERVER["🧠 Gin Server"] + S1["uploadChunk handler"] + S2["保存分片到
./tmp-chunks/video.mp4/i"] + S3{"chunkIndex == total-1?"} + S4["启动 goroutine
mergeChunks()"] + S5["按序读取所有分片"] + S6["顺序写入 → ./uploads/video.mp4"] + S7["删除 ./tmp-chunks/"] + end + + C4 --> S1 + S1 --> S2 + S2 --> S3 + S3 -->|"是"| S4 + S3 -->|"否"| C3 + S4 -- "后台执行" --> S5 + S5 --> S6 + S6 --> S7 + C5 -.->|"通知合并已完成"| S7 + + style CLIENT fill:#e3f2fd,stroke:#1565c0 + style SERVER fill:#fff3e0,stroke:#f57c00 + style S3 fill:#fff9c4,stroke:#f9a825 + style S4 fill:#e1f5fe,stroke:#0277bd,color:#000 +``` + +**磁盘上的目录结构变化:** + +```mermaid +flowchart LR + subgraph AFTER_UPLOADS["① 分片上传完成后 — ./tmp-chunks/"] + AU1["video.mp4/"] + AU1 --> AU2["0"] + AU1 --> AU3["1"] + AU1 --> AU4["..."] + AU1 --> AU5["19"] + end + + subgraph AFTER_MERGE["② 合并后 — ./uploads/ + 清理完成"] + AM1["✓ video.mp4
(完整文件已合并)"] + AM2["✗ ./tmp-chunks/
(已删除)"] + end + + AU5 -->|"mergeChunks 触发"| AM1 + AU5 -->|"os.Remove() 逐个清理"| AM2 + + style AM1 fill:#c8e6c9,stroke:#2e7d32 + style AM2 fill:#ffcdd2,stroke:#c62828 +``` + +**关键注意事项:** +- **并发安全**:`mergeChunks()` 使用 `goroutine` 异步执行,避免阻塞响应线程 +- **原子性**:合并过程应保持"要么全部成功,要么全都不做"——建议先写入 `.tmp` 后缀文件,合并完成后再 `os.Rename` +- **容错恢复**:如果某个分片上传失败,客户端应支持断点续传(跳过已成功的 index) +- **资源清理**:生产环境需要定时清理残留的临时分片目录(防 `/tmp` 爆满) + ```go func uploadChunk(c *gin.Context) { fileName := c.Query("fileName") - chunkIndex := c.Query("chunkIndex") - totalChunks := c.Query("totalChunks") + chunkIndexStr := c.Query("chunkIndex") + totalChunksStr := c.Query("totalChunks") - file, _ := c.FormFile("chunk") - dst := filepath.Join("./tmp-chunks", fileName, chunkIndex) - os.MkdirAll(filepath.Dir(dst), 0755) - c.SaveUploadedFile(file, dst, 10<<20, nil) + fileHeader, err := c.FormFile("chunk") + if err != nil { + c.JSON(400, gin.H{"error": "missing chunk file"}) + return + } + file, _ := fileHeader.Open() + defer file.Close() - // 检查是否最后一个分片 - if chunkIndex == totalChunks { - mergeChunks(fileName, int64(totalChunks)) + chunkIndex, _ := strconv.Atoi(chunkIndexStr) + totalChunks, _ := strconv.Atoi(totalChunksStr) + + // 创建分片目录(生产环境应先检查错误) + dir := filepath.Join("./tmp-chunks", fileName) + os.MkdirAll(dir, 0755) + dst := filepath.Join(dir, chunkIndexStr) + + if err := c.SaveUploadedFile(fileHeader, dst, 10<<20); err != nil { + c.JSON(500, gin.H{"error": "save chunk failed"}) + return } - c.JSON(200, gin.H{"status": "chunk uploaded"}) + // 所有分片上传完毕,触发合并(使用整数比较而非字符串) + if chunkIndex == totalChunks-1 { + go mergeChunks(fileName, int64(totalChunks)) + } + + c.JSON(200, gin.H{"status": "chunk uploaded", "index": chunkIndex}) +} + +// mergeChunks 在后台合并所有分片为完整文件 +func mergeChunks(fileName string, total int64) { + dst := filepath.Join("./uploads", fileName) + f, _ := os.Create(dst) + defer f.Close() + for i := int64(0); i < total; i++ { + chunk := filepath.Join("./tmp-chunks", fileName, strconv.FormatInt(i, 10)) + part, _ := os.Open(chunk) + io.Copy(f, part) + part.Close() + os.Remove(chunk) // 清理临时分片 + } } ``` ## 关联笔记 +- [[GIN/README]] — Gin 全目录索引 - [[GIN/7-binding-advanced]] — multipart/form-data 是表单绑定的一个特例 - [[GIN/11-static-files]] — 上传后的文件如何通过静态文件服务对外暴露 +- [[GIN/5-binding-validation]] — 表单验证与文件校验可组合使用 - [[部署与运维基础]] — 容器环境下的磁盘和内存限制 diff --git a/BACKEND/GIN/9-response-rendering.md b/BACKEND/GIN/9-response-rendering.md index 68def91..260c1d1 100644 --- a/BACKEND/GIN/9-response-rendering.md +++ b/BACKEND/GIN/9-response-rendering.md @@ -7,13 +7,59 @@ create time: 2026-04-28 00:00 ## 概述 -Gin 提供了丰富的响应渲染方法——不仅是基础的 JSON 返回,还包括防劫持的 SecureJSON、保留中文的 PureJSON、ASCII 转换、XML/YAML/ProtoBuf 输出等。理解各渲染方法的差异和适用场景,能写出更健壮、兼容的 API。 +Gin 提供了丰富的响应渲染方法——不仅限于基础的 JSON 返回,还包括防劫持的 SecureJSON、保留原始 Unicode 的 PureJSON、强制 ASCII 编码的 AsciiJSON,以及 XML / YAML / ProtoBuf / CSV / HTML 等多格式输出。理解各渲染方法的差异和适用场景,能写出更健壮、兼容的 API。 -思考题:为什么 Gin 要同时提供 `JSON` 和 `PureJSON`?它们什么时候输出相同的结果,什么时候不同? +```mermaid +flowchart TD + A[选择响应渲染方式] --> B{客户端/使用方} + B -- "浏览器 SPA" --> C[c.JSON] + B -- "移动端或\n微服务间调用" --> D[c.PureJSON] + B -- "第三方不可信域名引用" --> E[c.SecureJSON] + B -- "严格 ASCII-only" --> F[c.AsciiJSON] + B -- "多格式接口" --> G["Accept\n内容协商"] + B -- "老旧跨域 GET\n(已淘汰)" --> H[c.JSONP] + style H fill:#ffcccc + style C fill:#e0ffe0 + style D fill:#e0ffe0 + C --> I[设置状态码并写入 Body] + D --> I + E --> I + F --> I + G --> I + H --> I +``` + +> **思考题:** 为什么 Gin 要同时提供 `c.JSON()` 和 `c.PureJSON()`?它们在哪些情况下输出相同,哪些情况不同? + +> [!tip]- 一句话区别 +> `c.JSON()` 会对部分非 ASCII 字符做 `\uXXXX` 转义;`c.PureJSON()` 原样输出 UTF-8 字节流。纯英文数据两者完全一致。 +> +> ```text +> # 中文场景 +> c.JSON() → {"message":"\\u4f60\\u597d"} +> c.PureJSON()→ {"message":"你好"} +> +> # 纯英文场景(完全一致) +> c.JSON() → {"message":"hello"} +> c.PureJSON() → {"message":"hello"} +> ``` + +### 选型速查表 + +| 场景 | 推荐方法 | 原因 | +|------|----------|------| +| 面向浏览器的 SPA | `c.JSON()` | 额外防御层(HTML/Unicode 转义) | +| 移动端 API / 微服务 | `c.PureJSON()` | 可读性好、带宽小、性能高 | +| 第三方不可信域名引用 | `c.SecureJSON()` | 防止 ` ``` -> **安全警告:** JSONP 要求回调函数名白名单校验,否则攻击者可构造 `callback=` 注入 XSS。 +> [!danger]- JSONP 安全风险与 CORS 替代方案 +> JSONP 的本质是在服务端拼接 JavaScript 代码——如果回调名未做白名单校验,攻击者可以注入恶意脚本: +> +> ```text +> # 恶意请求 +> ?callback= +> +> # 被注入的输出 +> ({"msg":"hello"}) +> ``` +> +> **现代替代方案:** +> +> | 方案 | 优点 | 缺点 | +> |------|------|------| +> | **CORS** (`Access-Control-Allow-Origin: *`) | 安全、灵活、现代浏览器全支持 | 需要预检(OPTIONS)请求 | +> | **JSONP** | 兼容 IE6+ | 仅支持 GET、安全风险高、已淘汰 | +> +> **生产环境强烈建议改用 CORS**,配合 `Access-Control-Allow-Origin` 头使用。 -### 7. 渲染方法速查 +### 7. 其他常用渲染方法一览 -| 方法 | 内容类型 | 特点 | -|------|----------|------| -| `c.JSON(code, obj)` | `application/json` | 标准 JSON,ASCII 转义 | -| `c.PureJSON(code, obj)` | `application/json` | 保留原始 Unicode | -| `c.SecureJSON(code, obj)` | `application/json` | 加前缀防劫持 | -| `c.AsciiJSON(code, obj)` | `application/json` | 强制 ASCII 编码 | -| `c.XML(code, obj)` | `application/xml` | XML 序列化 | -| `c.YAML(code, obj)` | `application/x-yaml` | YAML 序列化 | -| `c.ProtoBuf(code, obj)` | `application/x-protobuf` | Protobuf 序列化 | -| `c.String(code, format, vals)` | `text/plain` | 字符串格式化 | -| `c.Data(code, data)` | 自定义 | 原始字节流 | -| `c.HTML(code, tmplName, obj)` | `text/html` | HTML 模板渲染 | +| 方法 | Content-Type | 典型用途 | +|------|-------------|---------| +| `c.String(code, fmt, a...)` | `text/plain; charset=utf-8` | 短文本 / 调试 | +| `c.Data(code, ct, bytes)` | 自定义 | 原始字节流(图片、PDF 等) | +| `c.File(filepath)` | 自动推断 | 静态资源下载 | +| `c.FileBinary(filepath)` | `application/octet-stream` | 二进制文件下载 | +| `c.FileAttachment(filepath, name)` | `application/octet-stream; attachment` | 强制下载弹窗 + 自定义文件名 | +| `c.HTML(code, tmpl, obj)` | `text/html` | HTML 模板渲染 | +| `c.ProtoBuf(code, pb)` | `application/x-protobuf` | Protobuf 序列化 | -思考题:如果你的 API 同时需要提供 JSON 和 CSV 两种格式,Gin 本身没有 `c.CSV()`,你应该怎么做? +> [!note]- File / FileBinary / FileAttachment 选型 +> | 方法 | Content-Type | 浏览器行为 | +> |------|-------------|-----------| +> | `c.File(filepath)` | 按扩展名自动推断(`.jpg` → `image/jpeg`) | **可能直接在浏览器预览** | +> | `c.FileBinary(filepath)` | `application/octet-stream` | 触发下载,但无自定义文件名 | +> | `c.FileAttachment(filepath, name)` | `application/octet-stream; attachment` | 触发下载 + 指定弹出文件名 | +> +> 如果希望用户下载文件而非在浏览器中打开,优先用 `c.FileAttachment()`。如果需要指定弹窗时显示的文件名(例如 `report-2026Q2.pdf`),这个方法也最方便。 -提示:`c.Data()` 和 `c.Writer.Write()` 的组合。 +### 8. 自定义 CSV 渲染 + +Gin 没有内置 `c.CSV()`,但组合 `c.Data()` 即可轻松实现: + +```go +import ( + "bytes" + "encoding/csv" + "net/http" + + "github.com/gin-gonic/gin" +) + +func csvExport(c *gin.Context) { + var buf bytes.Buffer + w := csv.NewWriter(&buf) + w.Write([]string{"name", "age", "city"}) + w.Write([]string{"Alice", "30", "Beijing"}) + w.Write([]string{"Bob", "25", "Shanghai"}) + w.Flush() + + c.Header("Content-Disposition", "attachment; filename=data.csv") + c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes()) +} +``` + +> **思考题:** 上面几节我们讨论了如何用 `switch` 根据 `Accept` 头做内容协商。如果支持的格式超过三种,每个分支都要写一次 `switch`,比较繁琐。有没有更优雅的封装? + +Gin 提供了 `c.Negotiate()` 方法,将协商逻辑和响应写入合并到一个调用中: + +```go +func negotiateHandler(c *gin.Context) { + data := gin.H{"title": "Go Guide", "version": "1.0"} + + c.Negotiate(http.StatusOK, gin.Negotiate{ + Offered: []string{ + "application/json", + "application/xml", + "application/yaml", + }, + Handler: func() { + switch c.NegotiationFormat() { + case "application/json": + c.JSON(http.StatusOK, data) + case "application/xml": + c.XML(http.StatusOK, data) + case "application/yaml": + c.YAML(http.StatusOK, data) + default: + c.AbortWithError(http.StatusNotAcceptable, + errors.New("unsupported media type")) + } + }, + }) +} +``` + +`c.Negotiate()` 内部会先检查 `Accept` 头是否在 `Offered` 列表中——如果是则进入 `Handler` 写入对应响应;如果不是则从 `Offered` 中选第一个作为默认格式写入,最后自动设置正确的 `Content-Type` 响应头。**推荐将所有多格式接口统一用此模式编写。** + +## 关联笔记 + +- [[BACKEND/GIN/0-overview]] +- [[BACKEND/GIN/2-context-request]] +- [[BACKEND/GIN/1-middleware]] +- [[BACKEND/GIN/3-error-handling]] diff --git a/TEST/1-gin-review-quiz.md b/TEST/1-gin-review-quiz.md index a02f190..9839e86 100644 --- a/TEST/1-gin-review-quiz.md +++ b/TEST/1-gin-review-quiz.md @@ -28,13 +28,10 @@ B. Logger + Recovery C. Recovery + JWT Auth D. Logger + RateLimit -
点击查看答案 - -**答案:B**。`gin.Default()` = `gin.New()` + `Use(Logger())` + `Use(Recovery())`。 - -> 来源:`[[GIN/1-gin-architecture]]` §4 - -
+> [!note]- Q1 答案 +> **答案:B**。`gin.Default()` = `gin.New()` + `Use(Logger())` + `Use(Recovery())`。 +> +> > 来源:`[[GIN/1-gin-architecture]]` §4 --- @@ -45,13 +42,10 @@ B. `http.Handler` C. `http.ResponseWriter` D. `http.ServeMux` -
点击查看答案 - -**答案:B**。`ServeHTTP(ResponseWriter, *Request)` 签名匹配。 - -> 来源:`[[GIN/1-gin-architecture/engine-handler]]` §1 - -
+> [!note]- Q2 答案 +> **答案:B**。`ServeHTTP(ResponseWriter, *Request)` 签名匹配。 +> +> > 来源:`[[GIN/1-gin-architecture/engine-handler]]` §1 --- @@ -62,13 +56,10 @@ B. Radix Tree(基数树/压缩前缀树) C. AVL Tree(平衡二叉树) D. Trie(朴素前缀树,未压缩) -
点击查看答案 - -**答案:B**。Radix Tree 通过前缀共享实现 O(d) 匹配,d 为 URL 深度。 - -> 来源:`[[GIN/2-routing]]` §3;`[[GIN/2-routing-complexity-comparison]]` - -
+> [!note]- Q3 答案 +> **答案:B**。Radix Tree 通过前缀共享实现 O(d) 匹配,d 为 URL 深度。 +> +> > 来源:`[[GIN/2-routing]]` §3;`[[GIN/2-routing-complexity-comparison]]` --- @@ -79,13 +70,10 @@ B. `/users/:id` — 静态优先于动态 C. `/users/*path` — 通配符是兜底规则 D. 取决于注册顺序 -
点击查看答案 - -**答案:B**。优先级:静态 > 动态参数 > 通配符,与注册顺序无关。 - -> 来源:`[[GIN/2-routing]]` §4 - -
+> [!note]- Q4 答案 +> **答案:B**。优先级:静态 > 动态参数 > 通配符,与注册顺序无关。 +> +> > 来源:`[[GIN/2-routing]]` §4 --- @@ -104,13 +92,10 @@ B. `/api/v1/users/` (`:id` 被忽略,因为放在 Group path 里) C. `/api/v1/users/:id` — 正确 D. `/:id` — 只取最后一段 -
点击查看答案 - -**答案:C**。`basePath` 逐层累加:`"" → "/api" → "/api/v1" → "/api/v1/users/:id"`。 - -> 来源:`[[GIN/2-routing]]` §5 - -
+> [!note]- Q5 答案 +> **答案:C**。`basePath` 逐层累加:`"" → "/api" → "/api/v1" → "/api/v1/users/:id"`。 +> +> > 来源:`[[GIN/2-routing]]` §5 --- @@ -121,13 +106,10 @@ B. Gin: ~4 步,ServeMux: ~10000 次比较 C. Gin: ~10000 步,ServeMux: ~4 步 D. Gin: ~4 步,ServeMux: ~4 步 -
点击查看答案 - -**答案:B**。Gin 复杂度 O(d),与路由总数 R 无关;ServeMux O(R×L)。 - -> 来源:`[[GIN/2-routing-complexity-comparison]]` §3 - -
+> [!note]- Q6 答案 +> **答案:B**。Gin 复杂度 O(d),与路由总数 R 无关;ServeMux O(R×L)。 +> +> > 来源:`[[GIN/2-routing-complexity-comparison]]` §3 --- @@ -144,13 +126,10 @@ B. A-before → B-before → handler → A-after → B-after C. A-before → handler → A-after → B-before → B-after D. handler → A-before → B-before → A-after → B-after -
点击查看答案 - -**答案:A**。前置按注册顺序,后置按逆序——栈式行为。 - -> 来源:`[[GIN/3-middleware]]` §2 - -
+> [!note]- Q7 答案 +> **答案:A**。前置按注册顺序,后置按逆序——栈式行为。 +> +> > 来源:`[[GIN/3-middleware]]` §2 --- @@ -161,13 +140,10 @@ B. `func(http.ResponseWriter, *http.Request)` C. `func(http.Handler) http.Handler` D. `func(*http.Server) http.Handler` -
点击查看答案 - -**答案:C**。这是装饰器模式,层层嵌套包装。 - -> 来源:`[[GIN/gin-vs-std]]` §1 - -
+> [!note]- Q8 答案 +> **答案:C**。这是装饰器模式,层层嵌套包装。 +> +> > 来源:`[[GIN/gin-vs-std]]` §1 --- @@ -178,13 +154,10 @@ B. 后续中间件跳过,handler 执行 C. 整个链(包括后续中间件、handler、A 的所有剩余代码)都不再执行 D. 只有同一路由的中间件被跳过,其他路由不受影响 -
点击查看答案 - -**答案:C**。`c.Abort()` 将 index 设为链长度,`c.Next()` 循环条件立即不满足。Abort 后必须紧跟 return。 - -> 来源:`[[GIN/middleware-abort]]` - -
+> [!note]- Q9 答案 +> **答案:C**。`c.Abort()` 将 index 设为链长度,`c.Next()` 循环条件立即不满足。Abort 后必须紧跟 return。 +> +> > 来源:`[[GIN/middleware-abort]]` --- @@ -195,13 +168,10 @@ B. 先用 `c.Copy()` 创建副本,在 goroutine 中使用副本 C. 把 `c` 保存到全局变量,在 goroutine 中读取 D. 使用 `context.WithCancel` 取消原 context -
点击查看答案 - -**答案:B**。`c.Copy()` 创建独立副本,goroutine 中只能读不能写响应。 - -> 来源:`[[GIN/3-middleware]]` §5 - -
+> [!note]- Q10 答案 +> **答案:B**。`c.Copy()` 创建独立副本,goroutine 中只能读不能写响应。 +> +> > 来源:`[[GIN/3-middleware]]` §5 --- @@ -212,13 +182,10 @@ B. 可能读到任意并发请求正在使用的数据,造成数据错乱 C. Go 运行时会 panic,因为存在数据竞争 D. Context 会自动深拷贝,所以没问题 -
点击查看答案 - -**答案:B**。sync.Pool 复用对象,字段被 reset 覆盖,全局引用指向的是被新请求改写后的同一个内存地址。 - -> 来源:`[[GIN/context-pool]]` §2 - -
+> [!note]- Q11 答案 +> **答案:B**。sync.Pool 复用对象,字段被 reset 覆盖,全局引用指向的是被新请求改写后的同一个内存地址。 +> +> > 来源:`[[GIN/context-pool]]` §2 --- @@ -229,13 +196,10 @@ B. `-1` — 表示还未开始执行,第一次 `c.Next()` 走到 index+1=0 C. `-1` — 表示无效值,需要用 -1 做判断 D. `nil` — 空表示未初始化 -
点击查看答案 - -**答案:B**。`-1` 表示还未开始,`c.Next()` 先 `index++` 到 0,再执行 `handlers[0]`。 - -> 来源:`[[GIN/4-context-lifecycle]]` §3 - -
+> [!note]- Q12 答案 +> **答案:B**。`-1` 表示还未开始,`c.Next()` 先 `index++` 到 0,再执行 `handlers[0]`。 +> +> > 来源:`[[GIN/4-context-lifecycle]]` §3 --- @@ -246,13 +210,10 @@ B. 没有,应在 `http.Server` 层配置 `ReadTimeout`/`WriteTimeout` C. 没有,但可用 `c.WithTimeout()` 设置 D. 有,在 `gin.Default()` 内部已经设置了 -
点击查看答案 - -**答案:B**。Gin 本身不内置超时,需在 `http.Server{ReadTimeout: ...}` 配置。 - -> 来源:`[[GIN/4-context-lifecycle]]` §7 - -
+> [!note]- Q13 答案 +> **答案:B**。Gin 本身不内置超时,需在 `http.Server{ReadTimeout: ...}` 配置。 +> +> > 来源:`[[GIN/4-context-lifecycle]]` §7 --- @@ -263,13 +224,10 @@ B. query string → form data → JSON C. JSON → form data → query string D. 根据 Content-Type 头直接判定,不按顺序 -
点击查看答案 - -**答案:C**。先试 JSON(检查 Content-Type),再试 form,最后试 query。 - -> 来源:`[[GIN/5-binding-validation]]` §1 - -
+> [!note]- Q14 答案 +> **答案:C**。先试 JSON(检查 Content-Type),再试 form,最后试 query。 +> +> > 来源:`[[GIN/5-binding-validation]]` §1 --- @@ -280,13 +238,10 @@ B. 静默忽略,值为零值 C. 抛出 panic D. 打印警告日志但仍继续 -
点击查看答案 - -**答案:B**。底层用 `encoding/json.Unmarshal`,对多余字段静默丢弃。 - -> 来源:`[[GIN/unknown-fields]]` §1 - -
+> [!note]- Q15 答案 +> **答案:B**。底层用 `encoding/json.Unmarshal`,对多余字段静默丢弃。 +> +> > 来源:`[[GIN/unknown-fields]]` §1 --- @@ -294,97 +249,73 @@ D. 打印警告日志但仍继续 ### Q16 【路由优先级】Gin 路由匹配的优先级从高到低依次是:________ > ________ > ________。 -
点击查看答案 - -**答案:静态字符串 > 动态参数 (`:param`) > 通配符 (`*rest`)** - -> 来源:`[[GIN/2-routing]]` §4 - -
+> [!note]- Q16 答案 +> **答案:静态字符串 > 动态参数 (`:param`) > 通配符 (`*rest`)** +> +> > 来源:`[[GIN/2-routing]]` §4 --- ### Q17 【Engine 结构】`*gin.Engine` 嵌入了 `RouterGroup`,这意味着 Engine 本身就是一颗最大的 RouterGroup,可以直接调用 `.GET()`、`.Use()` 等方法。Engine 中还包含一个 `trees` 字段,其类型是 `methodTrees`(本质是 `[*tree]`),它的作用是:_________________________。 -
点击查看答案 - -**答案:每种 HTTP 方法维护一棵独立的 Radix Tree(例如 GET 一棵、POST 一棵)** - -> 来源:`[[GIN/1-gin-architecture]]` §1 - -
+> [!note]- Q17 答案 +> **答案:每种 HTTP 方法维护一棵独立的 Radix Tree(例如 GET 一棵、POST 一棵)** +> +> > 来源:`[[GIN/1-gin-architecture]]` §1 --- ### Q18 【中间件作用域】Gin 中间件的三级作用域分别是:________、________、________。执行顺序为:________ → ________ → ________ → handler。 -
点击查看答案 - -**答案:全局(Engine 级) / 分组(RouterGroup 级) / 路由(单条路由);全局 → 分组 → 路由 → handler** - -> 来源:`[[GIN/3-middleware]]` §2 - -
+> [!note]- Q18 答案 +> **答案:全局(Engine 级) / 分组(RouterGroup 级) / 路由(单条路由);全局 → 分组 → 路由 → handler** +> +> > 来源:`[[GIN/3-middleware]]` §2 --- ### Q19 【中间件设计模式】Go 标准库 `net/http` 中间件使用 ________ 模式,而 Gin 中间件使用 ________ 模式。 -
点击查看答案 - -**答案:装饰器(Decorator)/ 责任链(Chain of Responsibility)** - -> 来源:`[[GIN/gin-vs-std]]` §1 - -
+> [!note]- Q19 答案 +> **答案:装饰器(Decorator)/ 责任链(Chain of Responsibility)** +> +> > 来源:`[[GIN/gin-vs-std]]` §1 --- ### Q20 【c.Abort() 系列方法】Gin 提供了三种 Abort 相关方法:`c.Abort()`、`c.AbortWithStatus(code)`、`_______________`(Abort 同时写入 JSON 响应体)。 -
点击查看答案 - -**答案:`c.AbortWithStatusJSON(code, json)`** - -> 来源:`[[GIN/middleware-abort]]` §4 - -
+> [!note]- Q20 答案 +> **答案:`c.AbortWithStatusJSON(code, json)`** +> +> > 来源:`[[GIN/middleware-abort]]` §4 --- ### Q21 【c.Copy() 限制】在通过 `c.Copy()` 创建的 goroutine 副本中,________(能/不能)调用 `c.JSON()` 写入响应,但可以读取 `c.Request` 和 `c.Keys`。 -
点击查看答案 - -**答案:不能**。`c.Writer` 无法复制,Copy 出的 goroutine 只能读不能写。 - -> 来源:`[[GIN/3-middleware]]` §5 - -
+> [!note]- Q21 答案 +> **答案:不能**。`c.Writer` 无法复制,Copy 出的 goroutine 只能读不能写。 +> +> > 来源:`[[GIN/3-middleware]]` §5 --- ### Q22 【绑定方法速记】Gin 提供了多种绑定方法:`c.ShouldBindJSON` 绑定 JSON body,`_______________` 绑定 URL 查询参数,`c.ShouldBindUri` 绑定 URI 路径参数,`c.ShouldBindHeader` 绑定 HTTP 请求头。 -
点击查看答案 - -**答案:`c.ShouldBindQuery`** - -> 来源:`[[GIN/5-binding-validation]]` §1 - -
+> [!note]- Q22 答案 +> **答案:`c.ShouldBindQuery`** +> +> > 来源:`[[GIN/5-binding-validation]]` §1 --- ### Q23 【ShouldBindBodyWith】标准库的 `io.ReadCloser` 类型的 body 只能读取一次。当需要在同一个 handler 中对不同结构体多次解析 body 时,应使用 `_______________` 来缓存 body,避免二次读取报错。 -
点击查看答案 - -**答案:`c.ShouldBindBodyWith(&obj, binding.JSON)`** - -> 来源:`[[GIN/5-binding-validation]]` §9 - -
+> [!note]- Q23 答案 +> **答案:`c.ShouldBindBodyWith(&obj, binding.JSON)`** +> +> > 来源:`[[GIN/5-binding-validation]]` §9 --- @@ -413,15 +344,12 @@ func cors() gin.HandlerFunc { } ``` -
点击查看答案 - -**答案:`return`** - -CORS 中间件中 OPTIONS 预检请求处理后必须 `return`,否则会继续执行 `c.Next()` 并可能触发下游 handler。 - -> 来源:`[[GIN/3-middleware]]` §4 - -
+> [!note]- Q24 答案 +> **答案:`return`** +> +> CORS 中间件中 OPTIONS 预检请求处理后必须 `return`,否则会继续执行 `c.Next()` 并可能触发下游 handler。 +> +> > 来源:`[[GIN/3-middleware]]` §4 --- @@ -452,15 +380,12 @@ func jwtAuth() gin.HandlerFunc { } ``` -
点击查看答案 - -**答案:两行 `return`,最后一行 `c.Next()`** - -核心规则:`c.Abort()` 之后必须紧跟 `return`;认证成功后调用 `c.Next()` 推进链。 - -> 来源:`[[GIN/3-middleware]]` §4 - -
+> [!note]- Q25 答案 +> **答案:两行 `return`,最后一行 `c.Next()`** +> +> 核心规则:`c.Abort()` 之后必须紧跟 `return`;认证成功后调用 `c.Next()` 推进链。 +> +> > 来源:`[[GIN/3-middleware]]` §4 --- @@ -497,13 +422,10 @@ func safeAsyncProcessor() gin.HandlerFunc { } ``` -
查看要点 - -关键改动:① `c.Copy()` 创建副本;② goroutine 中使用 `copy` 而非 `c`。 - -> 来源:`[[GIN/3-middleware]]` §5 - -
+> [!note]- Q26 答案要点 +> **关键改动**:① `c.Copy()` 创建副本;② goroutine 中使用 `copy` 而非 `c`。 +> +> > 来源:`[[GIN/3-middleware]]` §5 --- @@ -531,17 +453,14 @@ func (c *Context) reset(w http.ResponseWriter) { 4. `c.errors = ______________` 5. `c.Keys = ______________` -
点击查看答案 - -1. `c.Params[:0]` -2. `nil` -3. `-1` -4. `c.errors[:0]` -5. `nil` - -> 来源:`[[GIN/4-context-lifecycle]]` §3 - -
+> [!note]- Q27 答案 +> 1. `c.Params = c.Params[:0]` +> 2. `nil` +> 3. `-1` +> 4. `c.errors = c.errors[:0]` +> 5. `nil` +> +> > 来源:`[[GIN/4-context-lifecycle]]` §3 --- @@ -559,15 +478,12 @@ func init() { } ``` -
点击查看答案 - -**答案:`^[a-zA-Z0-9_]+$`** - -完整正则确保只允许字母、数字和下划线。 - -> 来源:`[[GIN/5-binding-validation]]` §6 - -
+> [!note]- Q28 答案 +> **答案:`^[a-zA-Z0-9_]+$`** +> +> 完整正则确保只允许字母、数字和下划线。 +> +> > 来源:`[[GIN/5-binding-validation]]` §6 --- @@ -592,13 +508,10 @@ srv := &http.Server{ _______ // ← 补全第三行的启动调用 ``` -
点击查看答案 - -三个空依次为:`r`、`r`、`srv.ListenAndServe()` - -> 来源:`[[GIN/engine-handler]]` §2 - -
+> [!note]- Q29 答案 +> **三个空依次为**:`r`、`r`、`srv.ListenAndServe()` +> +> > 来源:`[[GIN/engine-handler]]` §2 --- @@ -623,13 +536,10 @@ func strictJSONHandler(c *gin.Context) { } ``` -
点击查看答案 - -两个空依次为:`DisallowUnknownFields()`、`return` - -> 来源:`[[GIN/unknown-fields]]` §3 - -
+> [!note]- Q30 答案 +> **两个空依次为**:`DisallowUnknownFields()`、`return` +> +> > 来源:`[[GIN/unknown-fields]]` §3 --- @@ -652,16 +562,13 @@ func backgroundWorker() { 请问这段代码可能引发什么问题?应该如何修复? -
点击查看答案 - -**问题:** `savedUserID = c.GetString("user_id")` 虽然是拷贝值,但如果改为 `globalC = c`(保存 Context 引用),则会导致数据错乱——因为 Context 被 sync.Pool 复用,另一个请求的 reset() 会清空该对象的 Keys。即使拷贝值,在全局变量中也会有并发写的竞态。 - -**修复方案:** -1. 不要使用全局变量保存请求相关数据 -2. 如果确实需要异步使用数据,用 `c.Copy()` 创建副本,或在 goroutine 中只传基本类型值 -3. 参考:`[[GIN/context-pool]]` §2 和 `[[GIN/context-pool-safety]]` - -
+> [!note]- Q31 答案 +> **问题:** `savedUserID = c.GetString("user_id")` 虽然是拷贝值,但如果改为 `globalC = c`(保存 Context 引用),则会导致数据错乱——因为 Context 被 sync.Pool 复用,另一个请求的 reset() 会清空该对象的 Keys。即使拷贝值,在全局变量中也会有并发写的竞态。 +> +> **修复方案:** +> 1. 不要使用全局变量保存请求相关数据 +> 2. 如果确实需要异步使用数据,用 `c.Copy()` 创建副本,或在 goroutine 中只传基本类型值 +> 3. 参考:`[[GIN/context-pool]]` §2 和 `[[GIN/context-pool-safety]]` --- @@ -676,13 +583,10 @@ r.GET("/users/list", listAll) // 后注册静态路由 当客户端请求 `GET /users/list` 时,会命中哪个 handler?为什么?注册顺序会影响结果吗? -
点击查看答案 - -会命中 `listAll`(`/users/list`)。**注册顺序不影响匹配结果**。Gin 的 Radix Tree 按优先级决定匹配:静态路由优先级高于动态参数,无论谁先注册,`/users/list` 都是精确匹配静态字符串,必优于 `/users/:id` 的动态参数匹配。 - -> 来源:`[[GIN/2-routing]]` §4 — "注意:如果有两条同类型的路由,Gin 注册时会 panic——不允许重复。" 不同类型的优先级由 Radix Tree 结构保证,与注册顺序无关。 - -
+> [!note]- Q32 答案 +> 会命中 `listAll`(`/users/list`)。**注册顺序不影响匹配结果**。Gin 的 Radix Tree 按优先级决定匹配:静态路由优先级高于动态参数,无论谁先注册,`/users/list` 都是精确匹配静态字符串,必优于 `/users/:id` 的动态参数匹配。 +> +> > 来源:`[[GIN/2-routing]]` §4 — "注意:如果有两条同类型的路由,Gin 注册时会 panic——不允许重复。" 不同类型的优先级由 Radix Tree 结构保证,与注册顺序无关。 --- diff --git a/config/agent/DOCUMENT_OPERATION.md b/config/agent/DOCUMENT_OPERATION.md index b35585c..9400f53 100644 --- a/config/agent/DOCUMENT_OPERATION.md +++ b/config/agent/DOCUMENT_OPERATION.md @@ -36,7 +36,7 @@ create time: YYYY-MM-DD HH:mm ## 内容规范【IMPORTANT】 -- **教学者模式**: 假设你是教学者,你需要先规划如何记录这个知识点,让读者能够容易理解。你也可以穿插问题在文档中,启发学生的思考。 +- **教学者模式**: 假设你是教学者,你需要先规划如何记录这个知识点,让读者能够容易理解。你也可以穿插问题在文档中,启发学生的思考。(建议使用 Obsidian **原生** `> [!type]` 语法) - **代码示例**: 优先 Go (后端) + React/TS (前端),代码示例点到为止,不要过于冗长。可以适当通过注释省略一部分代码增强可读性,体现核心逻辑即可。当你给出代码时,一定要给出对应的文本解释。 - **图表**: 遇到关键概念、流程,仅仅靠文字不容易清晰说明,此时应该使用 Mermaid。避免使用纯文本 ASCII 图表。 - **风格**: 详略得当,注重实用性。文风严谨但是不失“活人感”,循序渐进,深入浅出。