Files
cs-note/hzh/GIN/6-error-handling.md
T
2026-05-24 11:42:38 +08:00

546 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [后端, Go, Gin, 错误处理, 中间件]
create time: 2026-04-28 00:00
---
# 错误处理机制
## 概述
Gin 提供了一套链式错误收集 + 全局处理的错误管理机制:handler 中调用 `c.Error(err)` 将错误存入上下文,`c.Errors` 收集所有错误,全局恢复中间件兜底 panic。理解这套机制可以写出结构化、一致的错误响应。
> [!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 允许在请求生命周期内累积多个错误——这在批量操作或多步验证场景中非常有用:
```go
func batchUpdate(c *gin.Context) {
var items []Item
c.ShouldBindJSON(&items)
var errList gin.ErrorList
for _, item := range items {
if err := validate(item); err != nil {
// 将错误写入 Context,不会中断后续处理
c.Error(&gin.Error{
Err: err,
Meta: item.ID, // 附加元数据(哪个 item 出错了)
})
continue
}
if err := saveToDB(item); err != nil {
c.Error(&gin.Error{Err: err, Meta: item.ID})
}
}
// 取出所有累积的错误
if len(c.Errors) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"errors": c.Errors.ByType(gin.ErrorTypeBind),
})
return
}
c.JSON(http.StatusOK, gin.H{"message": "batch update done"})
}
```
**`gin.Error` 结构:**
```go
type Error struct {
Err error // 原始错误
Type ErrorType // 错误类型
Meta interface{} // 附加的任意元数据
}
type ErrorType uint8
const (
ErrorTypeBind ErrorType = 1 << iota // 绑定错误
ErrorTypeRelease // 资源释放错误
ErrorTypePrivate // 内部业务错误
ErrorTypePublic // 暴露给客户端的业务错误
)
```
**`c.Errors` 常用方法:**
| 方法 | 作用 |
|------|------|
| `c.Errors.ByType(t)` | 按类型过滤错误 |
| `c.Errors.Last()` | 获取最后一个错误 |
| `c.Errors.First()` | 获取第一个错误 |
| `c.Errors.ToStrings()` | 转为字符串切片 |
> [!note] 关键理解
> `c.Error(err)` **不终止请求、不回写响应**——它只是往 `c.errors` 切片里追加。你需要显式检查 `len(c.Errors)` 并返回错误响应。
> [!question]- 思考
> `c.Error()` 和直接 `c.JSON(500, ...)` 有什么区别?什么时候应该用前者?
### 2. 业务错误的正确处理方式
大多数情况下,handler 中的错误不需要走 `c.Error` 链,而是直接返回错误响应:
```go
func getUser(c *gin.Context) {
id := c.Param("id")
user, err := userRepository.FindByID(id)
if err != nil {
// ✅ 推荐:直接返回统一错误响应
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{
"code": 404,
"message": "user not found",
})
return
}
// 服务端错误
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "internal server error",
})
return
}
c.JSON(http.StatusOK, gin.H{"data": user})
}
```
**核心原则:**
- **单次错误**:直接 `c.JSON(code, ...)` 返回,不经过 `c.Error`
- **批量/多步场景**:用 `c.Error` 收集所有错误,一次性返回
- **panic 异常**:交给 `gin.Recovery()` 兜底
### 3. 全局恢复中间件 — `gin.Recovery()`
默认 `gin.Default()` 已经内置了 `Recovery()` 中间件——它会捕获所有 panic,生成 500 响应,并打印堆栈到日志:
```go
// gin/recovery.go — 简化版
func Recovery() HandlerFunc {
return func(c *Context) {
defer func() {
if err := recover(); err != nil {
// 打印堆栈追踪
debug.PrintStack()
// 写入错误链
c.Error(err.(error))
// 返回 500
c.AbortWithStatus(JSONErrorCode(http.StatusInternalServerError))
}
}()
c.Next()
}
}
```
**自定义 Recovery:**
```go
r := gin.New()
r.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
log.Printf("Panic recovered: %v", recovered)
// 可以在这里做更多事:发送告警、记录指标等
if err, ok := recovered.(error); ok {
c.JSON(500, gin.H{
"code": 500,
"message": "internal server error",
"trace_id": c.GetString("request-id"),
})
} else {
c.JSON(500, gin.H{
"code": 500,
"message": "unexpected error",
})
}
}))
```
> [!question]- 思考
> `gin.Recovery()` 把 panic 也写进了 `c.Errors`,这意味着如果你在 Recovery 之后还有中间件,它们能看到这个错误吗?
>
> 答案:不能。`recover()` 后调用了 `c.AbortWithStatus()`,中间件链已经终止。
### 4. HTTP 状态码与业务码的映射
生产环境通常维护两层错误码体系:
| 层级 | 说明 | 示例 |
|------|------|------|
| **HTTP 状态码** | 网络层语义 | 200 / 400 / 404 / 500 |
| **业务错误码** | 业务语义 | `40001` 参数校验失败 / `40002` 余额不足 |
```go
// 统一错误响应格式
type ErrorResponse struct {
Code int `json:"code"` // 业务错误码
Message string `json:"message"` // 用户可读消息
Trace string `json:"trace,omitempty"` // 调试用 trace ID
}
func handleBizError(c *gin.Context, httpCode, bizCode int, msg string) {
c.JSON(httpCode, ErrorResponse{
Code: bizCode,
Message: msg,
})
}
// 使用
handleBizError(c, http.StatusBadRequest, 40001, "用户名不能为空")
handleBizError(c, http.StatusNotFound, 40401, "订单不存在")
```
**常见业务码分段约定:**
| 段 | HTTP 码 | 含义 |
|----|---------|------|
| 2xxxx | 200 | 成功 |
| 40xxx | 400 | 客户端错误(参数、校验) |
| 401xx | 401 | 未认证 |
| 403xx | 403 | 无权限 |
| 404xx | 404 | 资源不存在 |
| 50xxx | 500 | 服务端内部错误 |
> [!question]- 思考
> 如果你的 API 同时面向前端和第三方开发者,你觉得业务错误码是放在 JSON body 里好,还是也应该加一个自定义响应头(如 `X-Biz-Code`)?为什么?
> → 可以在方案选择时灵活决定——Helpers 方案中直接加 `c.Header("X-Biz-Code", strconv.Itoa(bizCode))`;中间件方案则在中间件后置逻辑中统一添加。两种都能做到集中管理。
### 5. 实践方案一:简洁 Helpers(适合大多数项目 ✅)
最实用的做法——写两个辅助函数,handler 里直接调,简单直白:
```go
// 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()
// ① handler 显式设置了错误 → 优先处理
if bizErr, ok := c.Get("error"); ok {
e := bizErr.(*BizError)
c.JSON(e.HTTPCode, gin.H{
"code": e.Code,
"message": e.Message,
})
return
}
// ② 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,
})
}
}
}
```
中间件注册顺序要放在路由之前:
```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
```
## 关联笔记
- [[GIN/3-middleware]] — 中间件执行顺序与全局中间件注册
- [[GIN/5-binding-validation]] — 参数绑定的错误属于 ErrorTypeBind
- [[GIN/observability]] — 错误上报 Prometheus 与链路追踪