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

17 KiB
Raw Blame History

tags, create time
tags create time
后端
Go
Gin
错误处理
中间件
2026-04-28 00:00

错误处理机制

概述

Gin 提供了一套链式错误收集 + 全局处理的错误管理机制:handler 中调用 c.Error(err) 将错误存入上下文,c.Errors 收集所有错误,全局恢复中间件兜底 panic。理解这套机制可以写出结构化、一致的错误响应。

[!question]- 前置思考 如果你的业务函数返回 (result, err),是否需要手动调用 c.Error(err)?还是可以直接写 JSON 响应?(详见下文第 2 节)

正文

0. 错误处理架构总览

Gin 项目的错误处理可以概括为三层策略:直接返回、链式收集、兜底恢复。整个流程如下:

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 允许在请求生命周期内累积多个错误——这在批量操作或多步验证场景中非常有用:

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 结构:

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 链,而是直接返回错误响应:

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 响应,并打印堆栈到日志:

// 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:

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 余额不足
// 统一错误响应格式
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 里直接调,简单直白:

// 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})
}

使用方式:

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() 之后统一输出

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,
            })
        }
    }
}

中间件注册顺序要放在路由之前:

r := gin.Default()
r.Use(jsonResponseMiddleware())  // 放在路由注册之前
r.POST("/users", createUser)

Step B — Handler 只声明意图,不写 JSON

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 — 配套的业务错误类型

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 中直接复用:

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 标准库的错误包装:

// 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 层抛出带业务码的错误:

func CreateUser(req CreateUserRequest) (*User, error) {
    if req.Name == "" {
        return nil, BadRequest("用户名不能为空")  // 不是 fmt.Errorf!
    }
    // ...
}

Handler 用 errors.As 解包:

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 则根据项目的错误复杂度决定是否引入。不要一开始就上重型武器。


为了帮助理解各方案的请求生命周期,对比以下两种流程:

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

关联笔记