Gin 框架错误处理机制怎么设计?从 Context 收集到统一中间件
Context 的错误收集机制
Gin 的 Context 内建了错误收集能力。在请求处理过程中,任何阶段都可以通过 c.Error() 把错误挂到 Context 上,等中间件统一取出处理,而不是在每个 handler 里各自返回响应。
go// 往 Context 追加错误 c.Error(errors.New("database connection failed")) // 取出所有错误 allErrors := c.Errors // 只关心最后一个 lastErr := c.Errors.Last()
这种设计让错误处理从"各管各的"变成"先收集、后统一",方便做格式化和日志。
Recovery 中间件:防止 panic 击穿服务
Go 的 panic 如果没人 recover,整个进程直接崩掉。Gin 自带 gin.Recovery() 中间件,在请求链最外层兜底捕获 panic,返回 500 并保证服务继续运行。
gor := gin.Default() // Default 内部已经挂了 Recovery
但默认的 Recovery 只做最基础的事情——打印日志、返回 500。生产环境通常需要自定义,比如把 panic 堆栈发到 Sentry、返回统一格式的 JSON:
gofunc CustomRecovery() gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { // 发送到监控平台 sentry.CaptureException(fmt.Errorf("%v", err)) c.JSON(500, gin.H{ "code": 500, "message": "服务内部错误", }) c.Abort() } }() c.Next() } }
一个容易踩的坑:Recovery 只能捕获同一个 goroutine 的 panic。如果你在 handler 里启动了新的 goroutine,里面的 panic 是兜不住的,必须单独处理。
统一错误处理中间件
单独用 c.Error() 收集错误没有意义,关键是在中间件里统一消费这些错误,生成一致的响应格式。
gofunc ErrorHandler() gin.HandlerFunc { return func(c *gin.Context) { c.Next() // 先放行,让后续 handler 执行 if len(c.Errors) == 0 { return } err := c.Errors.Last() switch err.Type { case gin.ErrorTypeBind: c.JSON(400, gin.H{ "code": 400, "message": "参数绑定失败", "details": err.Error(), }) case gin.ErrorTypePublic: c.JSON(400, gin.H{ "code": 400, "message": err.Error(), }) default: c.JSON(500, gin.H{ "code": 500, "message": "服务内部错误", }) } } }
中间件的注册顺序很重要:ErrorHandler 要放在所有业务中间件之前,这样 c.Next() 执行完后才能兜住所有错误。
自定义错误类型
Gin 内置的 ErrorType 只有几种(Bind、Public、Private、Any),实际项目中远远不够。自定义错误类型可以让每个错误携带业务语义:
gotype AppError struct { Code int // HTTP 状态码 BizCode int // 业务错误码,比如 10001 表示"用户不存在" Message string // 给用户看的信息 Err error // 原始错误,用于日志和调试 } func (e *AppError) Error() string { return e.Message } func (e *AppError) Unwrap() error { return e.Err }
在 handler 中使用:
gofunc GetUser(c *gin.Context) { user, err := userService.GetByID(c.Param("id")) if err != nil { c.Error(&AppError{ Code: 404, BizCode: 10001, Message: "用户不存在", Err: err, }) return } c.JSON(200, user) }
配合统一错误处理中间件,可以用类型断言区分 AppError 和未知错误:
goif appErr, ok := err.Err.(*AppError); ok { c.JSON(appErr.Code, gin.H{ "code": appErr.BizCode, "message": appErr.Message, }) } else { c.JSON(500, gin.H{ "code": 500, "message": "服务内部错误", }) }
错误响应格式统一
无论是参数校验失败、权限不足还是内部错误,前端拿到的应该是同一种结构:
gotype ErrorResponse struct { Code int `json:"code"` // 业务错误码 Message string `json:"message"` // 用户可读信息 Details string `json:"details,omitempty"` // 仅开发环境返回 }
生产环境不要把原始错误信息(数据库报错、堆栈)暴露给用户,只在开发环境或日志中保留:
gofunc buildErrorResponse(c *gin.Context, statusCode int, err error) { resp := ErrorResponse{ Code: statusCode, Message: http.StatusText(statusCode), } if gin.Mode() == gin.DebugMode { resp.Details = err.Error() } c.JSON(statusCode, resp) }
错误日志与链路追踪
光返回错误响应不够,后台必须有完整的记录。一个实用的做法是在错误处理中间件里同时打日志,带上请求路径和 trace ID:
gofunc ErrorLogger() gin.HandlerFunc { return func(c *gin.Context) { c.Next() for _, e := range c.Errors { traceID := c.GetString("X-Trace-ID") log.Printf("[ERROR] trace=%s method=%s path=%s err=%v", traceID, c.Request.Method, c.Request.URL.Path, e.Err) } } }
如果项目用了 OpenTelemetry 之类的链路追踪,在 span 上标记错误属性会更方便排查。
Abort 与错误传播控制
有些场景下,遇到错误后不希望后续 handler 继续执行。这时要用 c.Abort() 系列方法:
go// 中断并返回状态码 c.AbortWithStatus(401) // 中断并返回 JSON c.AbortWithStatusJSON(403, gin.H{ "code": 403, "message": "无权访问", }) // 中断并记录错误 c.AbortWithError(500, err)
Abort 之后,当前请求的处理链就停了,后续的 handler 不会再执行,但已经注册的 defer 语句仍然会运行。
常见问题
handler 里启动的 goroutine panic 了怎么办?
gin.Recovery() 管不到其他 goroutine。解决方案是在新 goroutine 里自己 recover:
gogo func() { defer func() { if err := recover(); err != nil { log.Printf("goroutine panic: %v", err) } }() // 业务逻辑 }()
c.Error() 和直接 c.JSON() 返回错误有什么区别?
c.Error() 只是把错误挂到 Context 上,不会自动返回响应。好处是中间件可以统一拦截、统一格式化。直接 c.JSON() 返回虽然快,但每个 handler 都要自己处理格式,容易不一致。
ErrorType 怎么选?
- ErrorTypeBind:绑定/校验失败,通常返回 400
- ErrorTypePublic:可以给用户看的错误信息
- ErrorTypePrivate:只在服务端记录,不返回给用户
- ErrorTypeAny:匹配所有类型
选择哪种取决于你想让统一中间件怎么处理这个错误。敏感信息用 Private,用户提示用 Public。