vault backup: 2026-04-28 12:34:34
This commit is contained in:
+320
-33
@@ -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{"是否需要<br/>多步验证?"}
|
||||
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
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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>", "<nil>"] ← 客户端收到了无意义的空错误列表
|
||||
}
|
||||
```
|
||||
|
||||
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 分类
|
||||
@@ -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。
|
||||
>
|
||||
> <details>
|
||||
> <summary>点击展开答案</summary>
|
||||
>
|
||||
> 不会成功。Gin 的 fallback 逻辑会将未知类型当作 form binding 处理,用 `application/x-www-form-urlencoded` 的方式去解析 JSON 字符串,必然报解析错误。生产环境建议通过中间件强制要求客户端显式声明 `Content-Type`。
|
||||
> </details>
|
||||
|
||||
### 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"`
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
|
||||
@@ -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:"-"` 吗?
|
||||
>
|
||||
> <details>
|
||||
> <summary>点击展开答案</summary>
|
||||
>
|
||||
> **需要。** `c.Param` 手动提取是一回事,但如果有另一个端点用 `ShouldBindJSON` 接收更新请求,前端依然可能传入 `id` 字段来篡改目标记录(Horizontal / Vertical Privilege Escalation)。最安全的做法是在所有涉及绑定的结构体中标记 `binding:"-"`,让编译器帮你兜底。
|
||||
> </details>
|
||||
|
||||
### 常见误区
|
||||
|
||||
```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]] — 父文档,高级绑定与表单处理
|
||||
+351
-35
@@ -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<br/>Content-Type: multipart/form-data
|
||||
Note over S,S: 解析请求体<br/>≤ 8MB → 内存<br/>> 8MB → 部分落盘 /tmp
|
||||
|
||||
S->>RF: FormFile("file")
|
||||
RF-->>S: (file, header, nil)<br/>header = {Filename, Size, Header}
|
||||
|
||||
S->>S: 业务逻辑处理<br/>(类型校验/大小限制)
|
||||
|
||||
S->>FS: SaveUploadedFile(header, "./uploads/x.jpg")
|
||||
FS-->>S: nil (成功)
|
||||
|
||||
S-->>C: 200 OK<br/>{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<br/>{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<br/>MaxMultipartMemory = 8MB"]
|
||||
S2_2["写入 42MB → /tmp"]
|
||||
end
|
||||
|
||||
subgraph CONTAINER["🐳 Docker 容器环境"]
|
||||
TMP2["/tmp 空间有限<br/>或只读文件系统"]
|
||||
end
|
||||
|
||||
CLIENT2 --> SERVER2
|
||||
S2_2 --> CONTAINER
|
||||
|
||||
COND{"/tmp" 有空间?}
|
||||
CONTAINER --> COND
|
||||
COND -->|"否"| BAD["error: no space left<br/>→ 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["实际内容:<br/><?xml ... PHP shell script ?>"] --> A2["人为改名为<br/>'photo.jpg'"]
|
||||
A2 --> A3["试图骗过后端<br/>通过扩展名判断"]
|
||||
end
|
||||
|
||||
subgraph VERIFY["✅ Gin 服务端: http.DetectContentType"]
|
||||
V1["读取前 512 字节"] --> V2["检查 Magic Bytes<br/>(文件头部的固定标识符)"]
|
||||
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` (`<!DO`) | `text/html; charset=utf-8` |
|
||||
| XML | `3C 3F 78 6D` (`<?xm`) | `text/xml; charset=utf-8` |
|
||||
| PHP | `3C 3F 70 68` (`<?ph`) | `text/xml; charset=utf-8` ⚠️ |
|
||||
|
||||
> **注意**: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["原始文件<br/>100MB"] --> C2["切分成 20 个<br/>5MB 的分片"]
|
||||
C2 --> C3{遍历分片列表<br/>index = 0..19}
|
||||
C3 -->|"for i in range"| C4["POST /upload/chunk?<br/>fileName=video.mp4<br/>&chunkIndex=i<br/>&totalChunks=20"]
|
||||
C4 --> C3
|
||||
C3 --"全部传完?"--> C5["POST /upload/merge\nfileName=video.mp4"]
|
||||
end
|
||||
|
||||
subgraph SERVER["🧠 Gin Server"]
|
||||
S1["uploadChunk handler"]
|
||||
S2["保存分片到<br/>./tmp-chunks/video.mp4/i"]
|
||||
S3{"chunkIndex == total-1?"}
|
||||
S4["启动 goroutine<br/>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<br/>(完整文件已合并)"]
|
||||
AM2["✗ ./tmp-chunks/<br/>(已删除)"]
|
||||
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]] — 表单验证与文件校验可组合使用
|
||||
- [[部署与运维基础]] — 容器环境下的磁盘和内存限制
|
||||
|
||||
@@ -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()` | 防止 `<script src>` 劫持 |
|
||||
| 日志系统 / 老旧中间件 | `c.AsciiJSON()` | 保证输出纯 ASCII |
|
||||
| 公开 API 需多格式 | 内容协商(见第 5 节) | 通过 `Accept` 头按需分发 |
|
||||
| 导出 Excel / CSV | 自定义(见第 8 节) | Gin 未内置 `CSV` |
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 标准 JSON 渲染
|
||||
### 1. 标准 JSON 渲染 — `c.JSON()`
|
||||
|
||||
最常用的渲染方式,底层调用 Go 标准库 `encoding/json.Marshal`,自动将汉字等非 ASCII 字符转为 `\uXXXX` 转义序列。
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
@@ -21,21 +67,25 @@ func handler(c *gin.Context) {
|
||||
"message": "你好",
|
||||
"items": []string{"a", "b", "c"},
|
||||
}
|
||||
|
||||
// 等价于 c.Render(200, render.JSON{Data: data})
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
实际输出(注意中文被转义):
|
||||
```json
|
||||
{"items":["a","b","c"],"message":"你好"}
|
||||
```
|
||||
|
||||
**注意:** 标准 `c.JSON()` 会对非 ASCII 字符做 Unicode 转义(`\uXXXX`),这是 RFC 4627 的要求,但现代浏览器和客户端都无需此限制。
|
||||
> **思考题:** RFC 4627 要求 JSON 文本仅包含 ASCII 字符,但 ES5(2009 年)起 JavaScript 就原生支持 Unicode 了——为什么现代浏览器不再需要这种转义?
|
||||
|
||||
> [!warning]- 历史背景
|
||||
> 转义是为了兼容极老的浏览器(IE6~IE8),这些浏览器无法正确处理非 ASCII 的 JSON。如今 ES5+ 已是底线要求,大多数项目可以直接用 `c.PureJSON()` 省去无意义的转义开销。
|
||||
|
||||
### 2. PureJSON — 保留原始 Unicode
|
||||
|
||||
当不需要 Unicode 转义时,使用 `c.PureJSON()`。它基于 Gin 自研的 [goccy/go-json](https://github.com/goccy/go-json) 库,直接将 UTF-8 字节写入响应体。
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
c.PureJSON(http.StatusOK, gin.H{
|
||||
@@ -44,25 +94,39 @@ func handler(c *gin.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
实际输出(中文字符原样输出):
|
||||
```json
|
||||
{"message":"你好世界"}
|
||||
```
|
||||
|
||||
**`PureJSON` vs `JSON` 对比:**
|
||||
**`c.JSON()` vs `c.PureJSON()` 对比:**
|
||||
|
||||
| 特性 | `c.JSON()` | `c.PureJSON()` |
|
||||
|------|-----------|----------------|
|
||||
| 中文编码 | `你好` | `你好` |
|
||||
| HTML 转义 | `<script>` → `\<script\>` | 不转义 |
|
||||
| 性能 | 略快(stdlib) | 略慢(gjson) |
|
||||
| 安全性 | 更安全(防 XSS) | 需注意 XSS |
|
||||
| 特性 | `c.JSON()` | `c.PureJSON()` |
|
||||
| ----- | ----------------------- | ---------------------- |
|
||||
| 中文字符 | `你好`(转义) | `你好`(直出) |
|
||||
| Emoji | `😀`(Go 1.20+ 前) | `😀`(始终直出) |
|
||||
| 底层库 | `encoding/json`(stdlib) | `goccy/go-json` |
|
||||
| 性能 | 标准速度 | Marshal/Unmarshal 通常更快 |
|
||||
| 适用场景 | 浏览器 SPA(旧规范兼容) | 移动端 / 服务间调用 |
|
||||
|
||||
> **何时选哪个:** 如果是面向浏览器的 SPA 应用,用 `JSON`(防 XSS);如果是移动端 API 或后端服务间调用,用 `PureJSON`(可读性更好、带宽更小)。
|
||||
> [!info]- 何时选哪个?
|
||||
> - **面向浏览器的 SPA** → `c.JSON()`:额外的 HTML/Unicode 转义作为防御层,兼容极老旧浏览器
|
||||
> - **移动端 API / 微服务间调用** → `c.PureJSON()`:可读性好、占用带宽更小、性能更好
|
||||
> - **绝大多数新项目** → `c.PureJSON()` 是更好的默认选择
|
||||
>
|
||||
> **决策流程图:**
|
||||
> ```mermaid
|
||||
> flowchart LR
|
||||
> A["客户端是谁?"] --> B{"浏览器?"}
|
||||
> B -- "是" --> C["用 c.JSON()"]
|
||||
> B -- "否: App/SDK/内部" --> D["用 c.PureJSON()"]
|
||||
> style C fill:#e0ffe0
|
||||
> style D fill:#e0ffe0
|
||||
> ```
|
||||
|
||||
### 3. SecureJSON — 防 JSON 劫持
|
||||
|
||||
针对老式浏览器的安全保护——在输出前加上 `)]}',\n` 前缀,阻止 JSON 被邪恶的 `<script src=...>` 跨域加载:
|
||||
老式浏览器可以通过 `<script src="...">` 标签跨域加载任意域的 JSON 响应。如果该响应包含敏感信息(如用户数据),攻击者可以将其内嵌到自己的页面中窃取。**SecureJSON** 通过在 JSON 内容前添加 `)]}',\n` 前缀来阻断这种攻击:
|
||||
|
||||
```go
|
||||
func secureHandler(c *gin.Context) {
|
||||
@@ -70,58 +134,105 @@ func secureHandler(c *gin.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
实际输出:
|
||||
```
|
||||
)];},{"name":"wonder"}
|
||||
```
|
||||
|
||||
**适用场景:**
|
||||
- API 需要被不受信任的第三方域名引用
|
||||
- 遗留系统无法升级 CSP 头
|
||||
- 安全合规要求严格的场景
|
||||
> [!question]- 原理拆解:SecureJSON 是如何防劫持的?
|
||||
> 浏览器在解析这行内容时,会尝试将其当作 JavaScript 表达式执行:
|
||||
>
|
||||
> 1. `')]}'` — 不是合法的 JS 标识符、关键字或运算符开头
|
||||
> 2. 引擎抛出 **SyntaxError**,脚本终止执行
|
||||
> 3. JSON 数据(`{"name":"wonder"}`)永远不会被读取
|
||||
>
|
||||
> ```text
|
||||
> # 正常 JSON 响应(可直接被劫持)
|
||||
> {"name":"wonder"} ← <script> 加载后直接成为 JS 对象
|
||||
>
|
||||
> # SecureJSON 响应(被阻断)
|
||||
> ]);},{"name":"wonder"} ← 语法错误,无法解析
|
||||
> ```
|
||||
>
|
||||
> **⚠️ 注意:** 原始 JSON 数据仍然暴露在 Network 面板中,它只是增加了攻击门槛而非绝对安全。配合 `CSP` 和 `X-Content-Type-Options: nosniff` 头效果更佳。
|
||||
|
||||
**不适用场景:**
|
||||
- 现代 SPA(前后端分离,不用 script 标签加载 JSON)
|
||||
- 移动端 API
|
||||
- 需要解析该输出的自动化测试
|
||||
**✅ 适用场景:**
|
||||
|
||||
### 4. AsciiJSON — 中文转 Unicode
|
||||
- API 需要被不受信任的第三方域名内嵌引用
|
||||
- 遗留系统无法升级 CSP(Content Security Policy)头策略
|
||||
- 安全合规要求严格的金融 / 政企项目
|
||||
|
||||
与 `PureJSON` 相反,强制将所有非 ASCII 字符转为 Unicode 逃逸序列:
|
||||
**❌ 不适用场景:**
|
||||
|
||||
- 现代 SPA(前后端分离,不用 `<script>` 标签加载 JSON)
|
||||
- 移动端 API(App 不做 JS 脚本解析)
|
||||
- 自动化测试 / 爬虫(需要额外去除前缀才能解析)
|
||||
|
||||
### 4. AsciiJSON — 强制 ASCII 编码
|
||||
|
||||
与 `PureJSON` 相反,将**所有**非 ASCII 字符(包括日文、韩文、表情符号等)都转为 `\uXXXX` 转义序列,保证输出为纯 ASCII。
|
||||
|
||||
```go
|
||||
func asciiHandler(c *gin.Context) {
|
||||
c.AsciiJSON(200, gin.H{
|
||||
"name": "张三",
|
||||
"data": []int{1, 2, 3},
|
||||
"text": "Hello!",
|
||||
"flag": true,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
```json
|
||||
{"data":[1,2,3],"name":"\xe5\xbc\xa0\xe4\xb8\x89"}
|
||||
当数据中包含 Emoji 等特殊字符时,AsciiJSON 的行为与 PureJSON 产生明显分歧:
|
||||
|
||||
```go
|
||||
c.AsciiJSON(200, gin.H{"emoji": "😀"})
|
||||
// AsciiJSON → {"emoji":"\\ud83d\\ude00"} ← 转义为 ASCII
|
||||
// PureJSON → {"emoji":"😀"} ← 保留 UTF-8
|
||||
// c.JSON() → 因 Go 版本而异 ← Go 1.20+ 可能不转义
|
||||
```
|
||||
|
||||
> **注意:** `AsciiJSON` 使用的是 UTF-8 字节的十六进制转义(`\xe5\xbc\xa0...`),不是 `\uXXXX`。这是 Gin 的独特实现——客户端解码时需要特殊处理,一般不太常用。
|
||||
> [!caution]- c.JSON() 对 Emoji 的处理因 Go 版本而异
|
||||
> `c.JSON()` 对 Emoji(以及部分其他 Unicode 字符)的转义行为取决于 Go 版本:
|
||||
>
|
||||
> | Go 版本 | Emoji 处理 | 中文处理 |
|
||||
> |---------|-----------|---------|
|
||||
> | ≤ 1.19 | 自动转义 `😀` | `\uXXXX` |
|
||||
> | ≥ 1.20 | **不**转义 `😀` | `\uXXXX` |
|
||||
>
|
||||
> `c.AsciiJSON()` 则**无视 Go 版本**,始终将所有非 ASCII 转义为纯 ASCII。如果你需要跨版本一致的输出(例如部署在不同 Go 版本的机器上),请使用 `AsciiJSON()`。
|
||||
|
||||
### 5. XML / YAML / ProtoBuf 渲染
|
||||
**适用场景:**
|
||||
|
||||
- 某些老旧中间件或日志系统要求严格 ASCII-only
|
||||
- 管道传输层只能处理 ASCII(如某些 MQ/消息队列的明文协议)
|
||||
- 安全扫描工具对非 ASCII 字符有告警阈值
|
||||
|
||||
### 5. XML / YAML / ProtoBuf — 多格式渲染
|
||||
|
||||
Gin 支持多种序列化格式的响应,可通过 `Accept` 请求头实现**内容协商**(Content Negotiation):
|
||||
|
||||
```go
|
||||
func multiFormat(c *gin.Context) {
|
||||
data := gin.H{"title": "Go Guide", "version": "1.0"}
|
||||
|
||||
switch c.ContentType() {
|
||||
switch c.NegotiationFormat() {
|
||||
case "application/xml":
|
||||
c.XML(200, data)
|
||||
c.XML(http.StatusOK, data)
|
||||
case "application/yaml":
|
||||
c.YAML(200, data)
|
||||
c.YAML(http.StatusOK, data)
|
||||
default:
|
||||
c.JSON(200, data)
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!note]- 关键概念辨析:NegotiationFormat vs ContentType
|
||||
> | 方法 | 读取方向 | 用途 |
|
||||
> |------|---------|------|
|
||||
> | `c.NegotiationFormat()` | **请求头** `Accept` | 内容协商——客户端告知"我想要什么格式" |
|
||||
> | `c.ContentType()` | **响应头** `Content-Type` / 请求头 `Content-Type` | 描述当前写入/读取的数据类型 |
|
||||
>
|
||||
> 做多格式接口时,永远用 `c.NegotiationFormat()` 来决定返回哪种序列化格式。`c.ContentType()` 用于你手动设置 `Content-Type` 响应头的场景。
|
||||
|
||||
**XML 输出示例:**
|
||||
```xml
|
||||
<map><title>Go Guide</title><version>1.0</version></map>
|
||||
@@ -134,28 +245,54 @@ map:
|
||||
version: "1.0"
|
||||
```
|
||||
|
||||
> **提问:** Gin 的 XML 渲染用的是 `gin.H`(map[string]interface{}),输出的 XML 根节点总是 `<map>`。如果需要自定义 XML 标签,应该怎么改结构体?
|
||||
> **思考题:** `gin.H` 本质是 `map[string]interface{}`,所以 XML 根节点总是 `<map>` 且没有属性控制能力。如果需要自定义 XML 标签名和结构,应该怎么改?
|
||||
|
||||
答案:用结构体的 `xml` 标签:
|
||||
答案是用**带标签的结构体**替代 map:
|
||||
|
||||
```go
|
||||
type Article struct {
|
||||
Title string `xml:"title"`
|
||||
Version string `xml:"version,attr"` // attr 表示属性
|
||||
Title string `xml:"title"` // 子元素 <title>Hello</title>
|
||||
Version string `xml:"version,attr"` // 属性 version="1.0"
|
||||
}
|
||||
|
||||
// 用外层结构体定义根节点
|
||||
type Response struct {
|
||||
Article Article `xml:"article"`
|
||||
}
|
||||
|
||||
c.XML(http.StatusOK, Response{Article: Article{Title: "Go Guide", Version: "1.0"}})
|
||||
```
|
||||
|
||||
```xml
|
||||
<response>
|
||||
<article version="1.0">
|
||||
<title>Go Guide</title>
|
||||
</article>
|
||||
</response>
|
||||
```
|
||||
|
||||
### 6. JSONP — JSON with Padding
|
||||
|
||||
通过脚本回调的方式实现跨域 GET 请求。由于涉及 eval 执行,安全风险高,现代项目中已很少使用:
|
||||
通过 `<script>` 标签的回调机制实现跨域 GET 请求。由于服务端需要将用户提供的回调名直接拼接到 JavaScript 代码中,**安全风险极高**,现代项目中已基本淘汰:
|
||||
|
||||
```go
|
||||
func jsonpHandler(c *gin.Context) {
|
||||
c.JSONP(http.StatusOK, gin.H{"callback": "getData"})
|
||||
callback := c.Query("callback")
|
||||
// 基础白名单校验:只允许字母、数字、下划线、美元符号
|
||||
if callback == "" || !regexp.MustCompile(`^[a-zA-Z_$][a-zA-Z0-9_$]*$`).MatchString(callback) {
|
||||
c.JSONP(http.StatusBadRequest, gin.H{"error": "invalid callback"})
|
||||
return
|
||||
}
|
||||
c.JSONP(http.StatusOK, gin.H{"msg": "hello"})
|
||||
}
|
||||
```
|
||||
|
||||
浏览器端:
|
||||
预期输出(当 `?callback=getData` 时):
|
||||
```
|
||||
getData({"msg":"hello"})
|
||||
```
|
||||
|
||||
浏览器端用法:
|
||||
```html
|
||||
<script>
|
||||
function getData(data) { console.log(data); }
|
||||
@@ -163,23 +300,109 @@ function getData(data) { console.log(data); }
|
||||
<script src="https://api.example.com/data?callback=getData"></script>
|
||||
```
|
||||
|
||||
> **安全警告:** JSONP 要求回调函数名白名单校验,否则攻击者可构造 `callback=<script>alert(1)</script>` 注入 XSS。
|
||||
> [!danger]- JSONP 安全风险与 CORS 替代方案
|
||||
> JSONP 的本质是在服务端拼接 JavaScript 代码——如果回调名未做白名单校验,攻击者可以注入恶意脚本:
|
||||
>
|
||||
> ```text
|
||||
> # 恶意请求
|
||||
> ?callback=<script>alert(document.cookie)</script>
|
||||
>
|
||||
> # 被注入的输出
|
||||
> <script>alert(document.cookie)</script>({"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]]
|
||||
|
||||
+141
-237
@@ -28,13 +28,10 @@ B. Logger + Recovery
|
||||
C. Recovery + JWT Auth
|
||||
D. Logger + RateLimit
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。`gin.Default()` = `gin.New()` + `Use(Logger())` + `Use(Recovery())`。
|
||||
|
||||
> 来源:`[[GIN/1-gin-architecture]]` §4
|
||||
|
||||
</details>
|
||||
> [!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`
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。`ServeHTTP(ResponseWriter, *Request)` 签名匹配。
|
||||
|
||||
> 来源:`[[GIN/1-gin-architecture/engine-handler]]` §1
|
||||
|
||||
</details>
|
||||
> [!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(朴素前缀树,未压缩)
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。Radix Tree 通过前缀共享实现 O(d) 匹配,d 为 URL 深度。
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §3;`[[GIN/2-routing-complexity-comparison]]`
|
||||
|
||||
</details>
|
||||
> [!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. 取决于注册顺序
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。优先级:静态 > 动态参数 > 通配符,与注册顺序无关。
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §4
|
||||
|
||||
</details>
|
||||
> [!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` — 只取最后一段
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:C**。`basePath` 逐层累加:`"" → "/api" → "/api/v1" → "/api/v1/users/:id"`。
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §5
|
||||
|
||||
</details>
|
||||
> [!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 步
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。Gin 复杂度 O(d),与路由总数 R 无关;ServeMux O(R×L)。
|
||||
|
||||
> 来源:`[[GIN/2-routing-complexity-comparison]]` §3
|
||||
|
||||
</details>
|
||||
> [!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
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:A**。前置按注册顺序,后置按逆序——栈式行为。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §2
|
||||
|
||||
</details>
|
||||
> [!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`
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:C**。这是装饰器模式,层层嵌套包装。
|
||||
|
||||
> 来源:`[[GIN/gin-vs-std]]` §1
|
||||
|
||||
</details>
|
||||
> [!note]- Q8 答案
|
||||
> **答案:C**。这是装饰器模式,层层嵌套包装。
|
||||
>
|
||||
> > 来源:`[[GIN/gin-vs-std]]` §1
|
||||
|
||||
---
|
||||
|
||||
@@ -178,13 +154,10 @@ B. 后续中间件跳过,handler 执行
|
||||
C. 整个链(包括后续中间件、handler、A 的所有剩余代码)都不再执行
|
||||
D. 只有同一路由的中间件被跳过,其他路由不受影响
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:C**。`c.Abort()` 将 index 设为链长度,`c.Next()` 循环条件立即不满足。Abort 后必须紧跟 return。
|
||||
|
||||
> 来源:`[[GIN/middleware-abort]]`
|
||||
|
||||
</details>
|
||||
> [!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
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。`c.Copy()` 创建独立副本,goroutine 中只能读不能写响应。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §5
|
||||
|
||||
</details>
|
||||
> [!note]- Q10 答案
|
||||
> **答案:B**。`c.Copy()` 创建独立副本,goroutine 中只能读不能写响应。
|
||||
>
|
||||
> > 来源:`[[GIN/3-middleware]]` §5
|
||||
|
||||
---
|
||||
|
||||
@@ -212,13 +182,10 @@ B. 可能读到任意并发请求正在使用的数据,造成数据错乱
|
||||
C. Go 运行时会 panic,因为存在数据竞争
|
||||
D. Context 会自动深拷贝,所以没问题
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。sync.Pool 复用对象,字段被 reset 覆盖,全局引用指向的是被新请求改写后的同一个内存地址。
|
||||
|
||||
> 来源:`[[GIN/context-pool]]` §2
|
||||
|
||||
</details>
|
||||
> [!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` — 空表示未初始化
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。`-1` 表示还未开始,`c.Next()` 先 `index++` 到 0,再执行 `handlers[0]`。
|
||||
|
||||
> 来源:`[[GIN/4-context-lifecycle]]` §3
|
||||
|
||||
</details>
|
||||
> [!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()` 内部已经设置了
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。Gin 本身不内置超时,需在 `http.Server{ReadTimeout: ...}` 配置。
|
||||
|
||||
> 来源:`[[GIN/4-context-lifecycle]]` §7
|
||||
|
||||
</details>
|
||||
> [!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 头直接判定,不按顺序
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:C**。先试 JSON(检查 Content-Type),再试 form,最后试 query。
|
||||
|
||||
> 来源:`[[GIN/5-binding-validation]]` §1
|
||||
|
||||
</details>
|
||||
> [!note]- Q14 答案
|
||||
> **答案:C**。先试 JSON(检查 Content-Type),再试 form,最后试 query。
|
||||
>
|
||||
> > 来源:`[[GIN/5-binding-validation]]` §1
|
||||
|
||||
---
|
||||
|
||||
@@ -280,13 +238,10 @@ B. 静默忽略,值为零值
|
||||
C. 抛出 panic
|
||||
D. 打印警告日志但仍继续
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。底层用 `encoding/json.Unmarshal`,对多余字段静默丢弃。
|
||||
|
||||
> 来源:`[[GIN/unknown-fields]]` §1
|
||||
|
||||
</details>
|
||||
> [!note]- Q15 答案
|
||||
> **答案:B**。底层用 `encoding/json.Unmarshal`,对多余字段静默丢弃。
|
||||
>
|
||||
> > 来源:`[[GIN/unknown-fields]]` §1
|
||||
|
||||
---
|
||||
|
||||
@@ -294,97 +249,73 @@ D. 打印警告日志但仍继续
|
||||
|
||||
### Q16 【路由优先级】Gin 路由匹配的优先级从高到低依次是:________ > ________ > ________。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:静态字符串 > 动态参数 (`:param`) > 通配符 (`*rest`)**
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §4
|
||||
|
||||
</details>
|
||||
> [!note]- Q16 答案
|
||||
> **答案:静态字符串 > 动态参数 (`:param`) > 通配符 (`*rest`)**
|
||||
>
|
||||
> > 来源:`[[GIN/2-routing]]` §4
|
||||
|
||||
---
|
||||
|
||||
### Q17 【Engine 结构】`*gin.Engine` 嵌入了 `RouterGroup`,这意味着 Engine 本身就是一颗最大的 RouterGroup,可以直接调用 `.GET()`、`.Use()` 等方法。Engine 中还包含一个 `trees` 字段,其类型是 `methodTrees`(本质是 `[*tree]`),它的作用是:_________________________。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:每种 HTTP 方法维护一棵独立的 Radix Tree(例如 GET 一棵、POST 一棵)**
|
||||
|
||||
> 来源:`[[GIN/1-gin-architecture]]` §1
|
||||
|
||||
</details>
|
||||
> [!note]- Q17 答案
|
||||
> **答案:每种 HTTP 方法维护一棵独立的 Radix Tree(例如 GET 一棵、POST 一棵)**
|
||||
>
|
||||
> > 来源:`[[GIN/1-gin-architecture]]` §1
|
||||
|
||||
---
|
||||
|
||||
### Q18 【中间件作用域】Gin 中间件的三级作用域分别是:________、________、________。执行顺序为:________ → ________ → ________ → handler。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:全局(Engine 级) / 分组(RouterGroup 级) / 路由(单条路由);全局 → 分组 → 路由 → handler**
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §2
|
||||
|
||||
</details>
|
||||
> [!note]- Q18 答案
|
||||
> **答案:全局(Engine 级) / 分组(RouterGroup 级) / 路由(单条路由);全局 → 分组 → 路由 → handler**
|
||||
>
|
||||
> > 来源:`[[GIN/3-middleware]]` §2
|
||||
|
||||
---
|
||||
|
||||
### Q19 【中间件设计模式】Go 标准库 `net/http` 中间件使用 ________ 模式,而 Gin 中间件使用 ________ 模式。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:装饰器(Decorator)/ 责任链(Chain of Responsibility)**
|
||||
|
||||
> 来源:`[[GIN/gin-vs-std]]` §1
|
||||
|
||||
</details>
|
||||
> [!note]- Q19 答案
|
||||
> **答案:装饰器(Decorator)/ 责任链(Chain of Responsibility)**
|
||||
>
|
||||
> > 来源:`[[GIN/gin-vs-std]]` §1
|
||||
|
||||
---
|
||||
|
||||
### Q20 【c.Abort() 系列方法】Gin 提供了三种 Abort 相关方法:`c.Abort()`、`c.AbortWithStatus(code)`、`_______________`(Abort 同时写入 JSON 响应体)。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`c.AbortWithStatusJSON(code, json)`**
|
||||
|
||||
> 来源:`[[GIN/middleware-abort]]` §4
|
||||
|
||||
</details>
|
||||
> [!note]- Q20 答案
|
||||
> **答案:`c.AbortWithStatusJSON(code, json)`**
|
||||
>
|
||||
> > 来源:`[[GIN/middleware-abort]]` §4
|
||||
|
||||
---
|
||||
|
||||
### Q21 【c.Copy() 限制】在通过 `c.Copy()` 创建的 goroutine 副本中,________(能/不能)调用 `c.JSON()` 写入响应,但可以读取 `c.Request` 和 `c.Keys`。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:不能**。`c.Writer` 无法复制,Copy 出的 goroutine 只能读不能写。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §5
|
||||
|
||||
</details>
|
||||
> [!note]- Q21 答案
|
||||
> **答案:不能**。`c.Writer` 无法复制,Copy 出的 goroutine 只能读不能写。
|
||||
>
|
||||
> > 来源:`[[GIN/3-middleware]]` §5
|
||||
|
||||
---
|
||||
|
||||
### Q22 【绑定方法速记】Gin 提供了多种绑定方法:`c.ShouldBindJSON` 绑定 JSON body,`_______________` 绑定 URL 查询参数,`c.ShouldBindUri` 绑定 URI 路径参数,`c.ShouldBindHeader` 绑定 HTTP 请求头。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`c.ShouldBindQuery`**
|
||||
|
||||
> 来源:`[[GIN/5-binding-validation]]` §1
|
||||
|
||||
</details>
|
||||
> [!note]- Q22 答案
|
||||
> **答案:`c.ShouldBindQuery`**
|
||||
>
|
||||
> > 来源:`[[GIN/5-binding-validation]]` §1
|
||||
|
||||
---
|
||||
|
||||
### Q23 【ShouldBindBodyWith】标准库的 `io.ReadCloser` 类型的 body 只能读取一次。当需要在同一个 handler 中对不同结构体多次解析 body 时,应使用 `_______________` 来缓存 body,避免二次读取报错。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`c.ShouldBindBodyWith(&obj, binding.JSON)`**
|
||||
|
||||
> 来源:`[[GIN/5-binding-validation]]` §9
|
||||
|
||||
</details>
|
||||
> [!note]- Q23 答案
|
||||
> **答案:`c.ShouldBindBodyWith(&obj, binding.JSON)`**
|
||||
>
|
||||
> > 来源:`[[GIN/5-binding-validation]]` §9
|
||||
|
||||
---
|
||||
|
||||
@@ -413,15 +344,12 @@ func cors() gin.HandlerFunc {
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`return`**
|
||||
|
||||
CORS 中间件中 OPTIONS 预检请求处理后必须 `return`,否则会继续执行 `c.Next()` 并可能触发下游 handler。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §4
|
||||
|
||||
</details>
|
||||
> [!note]- Q24 答案
|
||||
> **答案:`return`**
|
||||
>
|
||||
> CORS 中间件中 OPTIONS 预检请求处理后必须 `return`,否则会继续执行 `c.Next()` 并可能触发下游 handler。
|
||||
>
|
||||
> > 来源:`[[GIN/3-middleware]]` §4
|
||||
|
||||
---
|
||||
|
||||
@@ -452,15 +380,12 @@ func jwtAuth() gin.HandlerFunc {
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:两行 `return`,最后一行 `c.Next()`**
|
||||
|
||||
核心规则:`c.Abort()` 之后必须紧跟 `return`;认证成功后调用 `c.Next()` 推进链。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §4
|
||||
|
||||
</details>
|
||||
> [!note]- Q25 答案
|
||||
> **答案:两行 `return`,最后一行 `c.Next()`**
|
||||
>
|
||||
> 核心规则:`c.Abort()` 之后必须紧跟 `return`;认证成功后调用 `c.Next()` 推进链。
|
||||
>
|
||||
> > 来源:`[[GIN/3-middleware]]` §4
|
||||
|
||||
---
|
||||
|
||||
@@ -497,13 +422,10 @@ func safeAsyncProcessor() gin.HandlerFunc {
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>查看要点</summary>
|
||||
|
||||
关键改动:① `c.Copy()` 创建副本;② goroutine 中使用 `copy` 而非 `c`。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §5
|
||||
|
||||
</details>
|
||||
> [!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 = ______________`
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
1. `c.Params[:0]`
|
||||
2. `nil`
|
||||
3. `-1`
|
||||
4. `c.errors[:0]`
|
||||
5. `nil`
|
||||
|
||||
> 来源:`[[GIN/4-context-lifecycle]]` §3
|
||||
|
||||
</details>
|
||||
> [!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() {
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`^[a-zA-Z0-9_]+$`**
|
||||
|
||||
完整正则确保只允许字母、数字和下划线。
|
||||
|
||||
> 来源:`[[GIN/5-binding-validation]]` §6
|
||||
|
||||
</details>
|
||||
> [!note]- Q28 答案
|
||||
> **答案:`^[a-zA-Z0-9_]+$`**
|
||||
>
|
||||
> 完整正则确保只允许字母、数字和下划线。
|
||||
>
|
||||
> > 来源:`[[GIN/5-binding-validation]]` §6
|
||||
|
||||
---
|
||||
|
||||
@@ -592,13 +508,10 @@ srv := &http.Server{
|
||||
_______ // ← 补全第三行的启动调用
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
三个空依次为:`r`、`r`、`srv.ListenAndServe()`
|
||||
|
||||
> 来源:`[[GIN/engine-handler]]` §2
|
||||
|
||||
</details>
|
||||
> [!note]- Q29 答案
|
||||
> **三个空依次为**:`r`、`r`、`srv.ListenAndServe()`
|
||||
>
|
||||
> > 来源:`[[GIN/engine-handler]]` §2
|
||||
|
||||
---
|
||||
|
||||
@@ -623,13 +536,10 @@ func strictJSONHandler(c *gin.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
两个空依次为:`DisallowUnknownFields()`、`return`
|
||||
|
||||
> 来源:`[[GIN/unknown-fields]]` §3
|
||||
|
||||
</details>
|
||||
> [!note]- Q30 答案
|
||||
> **两个空依次为**:`DisallowUnknownFields()`、`return`
|
||||
>
|
||||
> > 来源:`[[GIN/unknown-fields]]` §3
|
||||
|
||||
---
|
||||
|
||||
@@ -652,16 +562,13 @@ func backgroundWorker() {
|
||||
|
||||
请问这段代码可能引发什么问题?应该如何修复?
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**问题:** `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]]`
|
||||
|
||||
</details>
|
||||
> [!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?为什么?注册顺序会影响结果吗?
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
会命中 `listAll`(`/users/list`)。**注册顺序不影响匹配结果**。Gin 的 Radix Tree 按优先级决定匹配:静态路由优先级高于动态参数,无论谁先注册,`/users/list` 都是精确匹配静态字符串,必优于 `/users/:id` 的动态参数匹配。
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §4 — "注意:如果有两条同类型的路由,Gin 注册时会 panic——不允许重复。" 不同类型的优先级由 Radix Tree 结构保证,与注册顺序无关。
|
||||
|
||||
</details>
|
||||
> [!note]- Q32 答案
|
||||
> 会命中 `listAll`(`/users/list`)。**注册顺序不影响匹配结果**。Gin 的 Radix Tree 按优先级决定匹配:静态路由优先级高于动态参数,无论谁先注册,`/users/list` 都是精确匹配静态字符串,必优于 `/users/:id` 的动态参数匹配。
|
||||
>
|
||||
> > 来源:`[[GIN/2-routing]]` §4 — "注意:如果有两条同类型的路由,Gin 注册时会 panic——不允许重复。" 不同类型的优先级由 Radix Tree 结构保证,与注册顺序无关。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ create time: YYYY-MM-DD HH:mm
|
||||
|
||||
## 内容规范【IMPORTANT】
|
||||
|
||||
- **教学者模式**: 假设你是教学者,你需要先规划如何记录这个知识点,让读者能够容易理解。你也可以穿插问题在文档中,启发学生的思考。
|
||||
- **教学者模式**: 假设你是教学者,你需要先规划如何记录这个知识点,让读者能够容易理解。你也可以穿插问题在文档中,启发学生的思考。(建议使用 Obsidian **原生** `> [!type]` 语法)
|
||||
- **代码示例**: 优先 Go (后端) + React/TS (前端),代码示例点到为止,不要过于冗长。可以适当通过注释省略一部分代码增强可读性,体现核心逻辑即可。当你给出代码时,一定要给出对应的文本解释。
|
||||
- **图表**: 遇到关键概念、流程,仅仅靠文字不容易清晰说明,此时应该使用 Mermaid。避免使用纯文本 ASCII 图表。
|
||||
- **风格**: 详略得当,注重实用性。文风严谨但是不失“活人感”,循序渐进,深入浅出。
|
||||
|
||||
Reference in New Issue
Block a user