This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/BACKEND/GIN/6-error-handling.md
T

259 lines
7.9 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。理解这套机制可以写出结构化、一致的错误响应。
思考题:如果你的业务函数返回 `(result, err)`,是否需要手动调用 `c.Error(err)`?还是可以直接写 JSON 响应?(详见下文第 2 节)
## 正文
### 1. `c.Error` → `c.Errors` 链式错误收集
Gin 允许在请求生命周期内累积多个错误——这在批量操作或多步验证场景中非常有用:
```go
func batchUpdate(c *gin.Context) {
var items []Item
c.ShouldBindJSON(&items)
var errors 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()` | 转为字符串切片 |
> **关键理解:** `c.Error(err)` 不终止请求、不回写响应——它只是往 `c.errors` 切片里追加。你需要显式检查 `len(c.Errors)` 并返回错误响应。
思考题:`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",
})
}
}))
```
> **提问:** `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 | 服务端内部错误 |
思考题:如果你的 API 同时面向前端和第三方开发者,你觉得业务错误码是放在 JSON body 里好,还是也应该加一个自定义响应头(如 `X-Biz-Code`)?为什么?
### 5. 统一错误响应中间件
把错误处理抽成中间件,确保所有路由的响应格式一致:
```go
func errorMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// 检查是否有积压的错误
if len(c.Errors) == 0 {
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",
})
}
}
}
```
> **注意:** 如果 handler 已经通过 `c.JSON()` 写入了响应体,中间件再写会覆盖或报错。因此需要在 handler 返回前拦截错误,而不是在 `c.Next()` 之后处理。更常见的做法是封装统一的 `c.Success()` / `c.Error()` 辅助方法。
## 关联笔记
- [[GIN/3-middleware]] — 中间件执行顺序与全局中间件注册
- [[GIN/5-binding-validation]] — 参数绑定的错误属于 ErrorTypeBind
- [[GIN/observability]] — 错误上报 Prometheus 与链路追踪