5月27日 15:19

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 并保证服务继续运行。

go
r := gin.Default() // Default 内部已经挂了 Recovery

但默认的 Recovery 只做最基础的事情——打印日志、返回 500。生产环境通常需要自定义,比如把 panic 堆栈发到 Sentry、返回统一格式的 JSON:

go
func 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() 收集错误没有意义,关键是在中间件里统一消费这些错误,生成一致的响应格式。

go
func 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),实际项目中远远不够。自定义错误类型可以让每个错误携带业务语义:

go
type 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 中使用:

go
func 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 和未知错误:

go
if 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": "服务内部错误", }) }

错误响应格式统一

无论是参数校验失败、权限不足还是内部错误,前端拿到的应该是同一种结构:

go
type ErrorResponse struct { Code int `json:"code"` // 业务错误码 Message string `json:"message"` // 用户可读信息 Details string `json:"details,omitempty"` // 仅开发环境返回 }

生产环境不要把原始错误信息(数据库报错、堆栈)暴露给用户,只在开发环境或日志中保留:

go
func 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:

go
func 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:

go
go 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。

标签:Gin