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
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
|
||||
Reference in New Issue
Block a user