拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Gin项目错误处理最佳实践:统一结构体、全局捕获与日志分级

1. 先聊清楚为什么Gin项目里错误处理会失控先交代一下背景我本人从Gin还没火起来的时候就在Go后端里折腾Web框架经历过从Beego、Echo到Gin的切换也在生产环境里“擦过”无数次错误处理不当的烂摊子。标题里写的Day09是我在某次内部技术分享系列里规划的第九节内容锁定在Gin错误处理的三个核心统一结构体、全局捕获、日志分级。这三个词看着简单实际落地时要踩的坑不少而且它们是一环扣一环的关系。先说一个最常见的现场。团队刚起步时接口少每个handler里错误都是临时写的有的直接c.JSON(400, gin.H{error: 参数错误})有的返回空对象有的干脆panic(xxx)更离谱的是把后端异常堆栈原样丢给前端。等接口上了几十个前端开始来诉苦“到底什么时候返回的是message什么时候是error字段错误码呢”后端自己也迷糊线上日志搜不到有效信息因为错误信息都散布在业务代码里打印格式五花八门。这时候再回头做统一错误处理已经不是“优化”而是“救火”。这套方案的目标很明确所有接口对外返回的错误响应结构完全一致前端只需要接一种格式未捕获的异常、panic、手动抛出的错误全部由框架兜底不会把内部实现细节泄露到Response里后端日志按错误级别划分排查问题时能一眼定位是参数问题、业务冲突还是系统级bug代码层面业务handler尽量不要出现大段错误堆栈处理错误只往上抛一次统一由中间件收口。适合谁看如果你的Gin项目正处于“错误处理靠自觉”的阶段接口数量超过10个或者你想给团队定一套错误处理规范这篇文章基本可以当作一份参考模板来用。下面所有代码都是我在真实项目里跑过的不是demo级别的玩具代码。2. 系统设计一张图理清错误处理链路规划错误处理架构之前我从不急着写代码先想清楚请求从进入Gin到返回Response的全链路里错误到底是在哪里产生、在哪里捕获、在哪里格式化的。我在白板上大概画了三层模型虽然这里不能贴完整的mermaid图但这三层的思路是本篇所有代码的基础。第一层路由与中间件层。全局中间件负责两件事一是对所有请求设置统一的Recovery兜底二是对响应体做统一包装包括成功响应的包装和错误响应的包装。第二层业务Handler层。这一层只负责两件事正常逻辑算出来的结果直接返回给上层算不出来比如用户不存在、余额不足就构造一个业务错误对象往外抛绝不在controller里写重复的c.JSON错误分支。第三层错误定义与日志层。这里定义所有业务错误码、错误结构体、错误消息模板以及每个错误码对应的日志级别。Handler抛出来的错误在中间件里被拦截后根据错误类型决定怎么写日志、怎么写Response。我见过很多人一上来就写一个Error结构体然后把所有处理逻辑塞进一个文件。这样不是不行但项目一大就会乱。我建议的目录结构是这样internal/ ├── controller/ # handler层只处理参数绑定和调用service ├── service/ # 业务逻辑返回业务错误 ├── middleware/ │ ├── recovery.go # 全局Recovery捕获panic │ └── response.go # 统一响应包装中间件 ├── response/ │ ├── response.go # 成功响应和错误响应的统一结构 │ ├── code.go # 业务错误码定义 │ └── errors.go # 错误类型定义与构造函数 └── logger/ └── logger.go # 日志实例按级别封装这个分层的好处是错误处理逻辑集中在response包业务代码里不会出现只写一次的错误处理分支排查问题时只需要看中间件和错误包就能摸清所有错误去向。我见过很多人一上来就写一个Error结构体然后把所有处理逻辑塞进一个文件。这样不是不行但项目一大就会乱。我建议的目录结构是这样internal/ ├── controller/ # handler层只处理参数绑定和调用service ├── service/ # 业务逻辑返回业务错误 ├── middleware/ │ ├── recovery.go # 全局Recovery捕获panic │ └── response.go # 统一响应包装中间件 ├── response/ │ ├── response.go # 成功响应和错误响应的统一结构 │ ├── code.go # 业务错误码定义 │ └── errors.go # 错误类型定义与构造函数 └── logger/ └── logger.go # 日志实例按级别封装这个分层的好处是错误处理逻辑集中在response包业务代码里不会出现只写一次的错误处理分支排查问题时只需要看中间件和错误包就能摸清所有错误去向。3. 统一错误结构体前后端唯一的沟通协议3.1 设计一个能同时容纳成功和失败的结构统一错误结构体本质上是在定义接口的响应协议。前端每次请求不管是成功还是失败拿到的JSON骨架必须是一样的。我的做法是让成功响应和错误响应复用一个外层结构// response/response.go package response import ( github.com/gin-gonic/gin net/http ) // Response 是所有接口的统一响应结构 type Response struct { Code int json:code // 业务码0表示成功非0表示各类错误 Message string json:message // 给前端看的提示信息可直接展示 Data interface{} json:data,omitempty // 具体业务数据错误时通常为空 TraceID string json:trace_id // 链路追踪ID方便日志关联 }这里有几个设计点值得展开。Code是业务码不是HTTP状态码。我见过很多团队直接把HTTP状态码400、404、500当业务码用结果前端想区分“用户名已存在”和“密码错误”这两个同为400的错误根本无从下手。所以要单独维护一套业务码表。HTTP状态码只表示请求层面的成功与否而业务码承载更细致的业务语义。Message一定要是人话。这个Message是最终要显示在用户屏幕上的不能把“sql: no rows in result set”这种后端错误直接塞进去。用户应该看到的是“您输入的验证码已过期请重新获取”而不是一长串英文堆栈。后端程序员容易犯的错是偷懒把底层error直接透传最后被用户截图吐槽“这个App报错看不懂”。TraceID是排查问题的生命线。没有TraceID的时候前端反馈“我这边报错了code是500”后端只能去日志里大海捞针。加了TraceID前端把ID发过来直接grep就完事。在中间件里生成TraceID很简单后面我会给出代码。3.2 业务错误码定义把错误归类成一本字典业务码的定义要有规律可循不能随便拍脑袋。我见过最乱的项目是错误码从1001、2002、3005这么乱跳看到码根本不知道是哪一类问题。我团队里用的是分段定义法// response/code.go package response // 通用系统级错误码 const ( CodeSuccess 0 // 成功 CodeInternalError 1000 // 系统内部错误 CodeInvalidParams 1001 // 参数校验失败 CodeUnauthorized 1002 // 未认证或token失效 CodeForbidden 1003 // 无权限访问 CodeNotFound 1004 // 资源不存在 CodeMethodNotAllowed 1005 // 请求方法不允许 CodeTooManyRequests 1006 // 请求过于频繁 ) // 业务级错误码段 2000~2999用户相关 const ( CodeUserNotExist 2001 // 用户不存在 CodeUserPasswordErr 2002 // 用户名或密码错误 CodeUserDisabled 2003 // 用户已被禁用 CodeUserPhoneBind 2004 // 手机号已被绑定 ) // 业务级错误码段 3000~3999订单与支付相关 const ( CodeOrderNotExist 3001 // 订单不存在 CodeOrderStatusErr 3002 // 订单状态非法 CodeOrderAmountErr 3003 // 订单金额校验错误 CodeStockNotEnough 3004 // 库存不足 )分段的规则是1000~1999是系统级错误任何模块都可能抛2000~2999用户模块3000~3999订单支付模块。哪个模块报错一眼就能从码段判断出来。后续模块在规划周期内提前分配码段避免冲突。HTTP状态码和业务码怎么对应我的规则是场景HTTP状态码业务码成功2000参数错误4001001或更具体业务码未认证4011002无权限4031003资源不存在4041004触发了业务冲突如余额不足200对应业务码如3003你可能注意到业务冲突我倾向于返回HTTP 200只在业务码层面区分。因为HTTP状态码如果用得太细前端每次都要走error分支而且一些网关和浏览器对非2xx状态有特殊行为。业务冲突本质上是一次“正常完成的请求”是业务决定不允许这笔交易发生不是系统报错。这个口径需要在团队里达成共识否则后端返回了200但code3003有些人会认为很别扭。3.3 错误类型的Go实现用自定义Error携带业务上下文定义好了结构体接下来要把它和Go的error接口对接起来。Go内建的error只带一个字符串不够用。所以我会定义自己的错误类型// response/errors.go package response import ( fmt ) // BizError 业务错误类型实现error接口 type BizError struct { Code int // 业务码 Message string // 对外展示消息 Err error // 保留原始错误用于日志输出 Fields map[string]any // 附加字段便于日志检索 } func (e *BizError) Error() string { if e.Err ! nil { return fmt.Sprintf(code%d message%s detail%v, e.Code, e.Message, e.Err) } return fmt.Sprintf(code%d message%s, e.Code, e.Message) } func (e *BizError) Unwrap() error { return e.Err } // NewBizError 创建一个业务错误 func NewBizError(code int, message string) *BizError { return BizError{Code: code, Message: message} } // WrapBizError 在已有错误上包装业务码和信息 func WrapBizError(code int, message string, err error) *BizError { return BizError{Code: code, Message: message, Err: err} } // WithFields 给错误附加字段便于日志结构化输出 func (e *BizError) WithFields(fields map[string]any) *BizError { e.Fields fields return e }这个错误类型有几个好处实现了error接口可以直接在业务代码里return response.NewBizError(...)也可以被fmt.Errorf包裹Unwrap方法让errors.Is和errors.As能穿透到原始错误这在判断错误类型时非常有用Fields字段是为了日志结构化排查问题时可以按用户ID、订单号等维度快速过滤。实际业务代码里抛出错误是这样// service/user_service.go func (s *UserService) Login(ctx context.Context, username, password string) (UserInfo, error) { user, err : s.userRepo.FindByUsername(ctx, username) if errors.Is(err, ErrUserNotFound) { return UserInfo{}, response.WrapBizError( response.CodeUserNotExist, 用户不存在, err, ) } if user.Password ! encrypt(password) { return UserInfo{}, response.NewBizError( response.CodeUserPasswordErr, 用户名或密码错误, ) } return user.ToInfo(), nil }注意我没有在service里返回HTTP状态码因为service不应该知道外层是Gin还是其他框架。它只需要产出业务错误由中间件统一映射成Response。3.4 统一响应封装函数让中间件只用一次有了错误类型还要有写响应的方法。我的response包里提供两个方法一个写成功一个写失败全局所有handler和中间件都复用这两个方法// response/response.go package response import ( github.com/gin-gonic/gin net/http ) // Success 输出成功响应 func Success(c *gin.Context, data any) { c.JSON(http.StatusOK, Response{ Code: CodeSuccess, Message: ok, Data: data, }) } // Failure 输出错误响应根据BizError自动映射HTTP状态码 func Failure(c *gin.Context, err error) { var bizErr *BizError if !errors.As(err, bizErr) { // 非BizError类型的错误一律视为系统内部错误 bizErr BizError{ Code: CodeInternalError, Message: 系统繁忙请稍后重试, Err: err, } } httpStatus : mapBizCodeToHTTPStatus(bizErr.Code) c.AbortWithStatusJSON(httpStatus, Response{ Code: bizErr.Code, Message: bizErr.Message, TraceID: GetTraceID(c), // 从context里取 }) } // mapBizCodeToHTTPStatus 把业务码映射到HTTP状态码 func mapBizCodeToHTTPStatus(code int) int { switch { case code CodeSuccess: return http.StatusOK case code 1000 code 2000: // 系统级错误细分调整 switch code { case CodeInvalidParams: return http.StatusBadRequest case CodeUnauthorized: return http.StatusUnauthorized case CodeForbidden: return http.StatusForbidden case CodeNotFound: return http.StatusNotFound default: return http.StatusInternalServerError } default: // 业务级错误HTTP状态码统一返回200 return http.StatusOK } }这里有两个细节值得记住。第一Failure函数接收的是error而不是*BizError这样调用方不需要做类型断言理论上任何错误都能被统一包装。第二AbortWithStatusJSON用得很关键它不止写入响应还把后续handler链中止掉避免写两次响应导致Gin报错。4. 全局捕获把panic、未知错误、日志记录全部收口4.1 从架构上看全局中间件的职责划分统一错误结构体解决的是“错误长什么样”的问题。接下来要解决“错误从哪里来、到哪里去”的问题。全局捕获的核心思路是业务handler不直接处理错误响应而是把错误抛给中间件。Gin的中间件模型天然支持这种设计——handler在调用链中间中间件可以在handler执行前后插入逻辑。于是我的中间件设计分两个RequestIDMiddleware给每个请求分配TraceID放进context。RecoveryMiddleware用defer捕获panic转成BizError统一写日志和响应。有人会问中间件里到底该不该处理业务错误我的答案是中间件只处理两类错误——handler抛出的已经包装好的BizError以及未被捕获的panic。至于“数据库查询失败”“第三方接口超时”这类业务执行中的细节错误应该在service层就包装成BizError而不是裸抛到底层。4.2 RequestID中间件给每个请求一个身份证先看RequestID的实现。不依赖第三方库Gin 自带的c.Request.Context()足够用// middleware/request_id.go package middleware import ( github.com/gin-gonic/gin github.com/google/uuid ) const ( HeaderRequestID X-Request-ID ContextKeyTraceID trace_id ) func RequestIDMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // 优先复用客户端传上来的RequestID否则生成新ID requestID : c.GetHeader(HeaderRequestID) if requestID { requestID uuid.NewString() } c.Set(ContextKeyTraceID, requestID) // 响应头里带回TraceID前端方便取 c.Writer.Header().Set(HeaderRequestID, requestID) c.Next() } }中间件里生成的TraceID通过c.Set存在Gin的上下文里。在response包里我会提供一个辅助函数GetTraceID(c)统一从context取值。这样所有响应里带TraceID日志里也带TraceID两面对得上。4.3 自定义Recovery兜住所有的panicGin自带一个gin.Recovery()中间件但它的问题很明显打印的堆栈格式简单不能接入项目的日志组件而且对错误响应的格式不兼容。所以必须自己写一个。// middleware/recovery.go package middleware import ( errors fmt net/http runtime/debug github.com/gin-gonic/gin your-project/internal/logger your-project/internal/response ) func RecoveryMiddleware() gin.HandlerFunc { return func(c *gin.Context) { defer func() { if rec : recover(); rec ! nil { // 把recover的内容转成error var err error switch t : rec.(type) { case error: err t case string: err errors.New(t) default: err fmt.Errorf(unknown panic: %v, rec) } // 打印完整堆栈这是排查panic的关键 logger.Error([Recovery] panic recovered, trace_id, response.GetTraceID(c), path, c.Request.URL.Path, method, c.Request.Method, error, err.Error(), stack, string(debug.Stack()), ) // 统一返回系统错误不把堆栈信息返回给前端 response.Failure(c, response.NewBizError( response.CodeInternalError, 系统开小差了请稍后重试, )) c.Abort() } }() c.Next() } }这里踩过的一个重要坑是panic恢复之后如果还调用了c.JSON可能遇到“headers already written”的警告。原因是在panic发生前可能已经有handler写入了一部分Response。所以response.Failure里必须用AbortWithStatusJSON它会先中止请求链再写响应。如果你的业务里有流式接口或自定义Writer这一步要格外留意必要时在Recovery之前先判断c.Writer.Written()。4.4 注册中间件顺序先Recovery再RequestID再其他中间件的注册顺序有讲究。Gin中间件的执行顺序是从上到下依次进入然后从下到上依次退出c.Next()就是分界线。我的注册顺序是// main.go func main() { r : gin.New() // 注意顺序RequestID最外层Recovery第二层 r.Use(middleware.RequestIDMiddleware()) r.Use(middleware.RecoveryMiddleware()) r.Use(middleware.AccessLogMiddleware()) // 访问日志如果你想加的话 registerRoutes(r) r.Run(:8080) }为什么RequestID要在最外层因为后面的Recovery中间件、日志中间件、业务handler都需要TraceID而TraceID必须在请求进入后第一时间生成。如果顺序反了Recovery捕获panic时可能拿不到TraceID日志关联就断了。4.5 统一处理404和405路由层级的兜底有时候请求打到了不存在的路由上这时候连业务handler都不会执行只有Gin自身的NoRoute逻辑。为了让404响应也符合统一结构需要在路由注册时加上func registerRoutes(r *gin.Engine) { // 兜底处理404 r.NoRoute(func(c *gin.Context) { response.Failure(c, response.NewBizError( response.CodeNotFound, 请求的接口不存在, )) }) // 兜底处理405 r.NoMethod(func(c *gin.Context) { response.Failure(c, response.NewBizError( response.CodeMethodNotAllowed, 请求方法不允许, )) }) api : r.Group(/api/v1) { api.POST(/login, userController.Login) // ... 其他路由 } }这一段看似繁琐其实非常实用。没有NoRoute兜底时404返回的是Gin默认的404 page not found纯文本前端解析JSON会直接报错。统一之后前端只需要判断code ! 0就能进入错误分支。5. 日志分级用Zap把错误信息变成可检索的数据5.1 为什么要分级Debug、Info、Warn、Error各司其职统一错误结构体和全局捕获解决了响应层面的问题。但线上排查不能只靠Response还要靠日志。日志如果全是同一个等级排查问题的时候就像在一堆噪音里找信号。日志分级的价值就在这里。我用的日志库是go.uber.org/zap性能和结构化输出都很好。分级原则我总结成一句话操作成功且无需关注的信息写Info操作成功但有隐患的信息写Warn请求失败但参数可纠正的信息写Warn系统异常、panic、数据库不可用等不可恢复的问题写Error调试过程中的细节写Debug生产环境可以关掉。展开说可能更清楚Debug开发调试用比如SQL语句、请求参数体、redis key的pattern生产环境不开Info业务健康信息比如登录成功、下单成功、定时任务执行结果Warn潜在问题比如参数格式不对被纠正、缓存未命中回源数据库、接口响应时间超过阈值Error系统内部错误比如数据库连接失败、panic恢复、第三方接口非预期返回。注意业务规则拒绝比如登录密码错误、库存不足不一定要记Error。这些是“业务预期内”的失败记Warn甚至Info就够。如果密码错误一次就Error一条你的日志系统很快会被垃圾刷爆真正的问题反而被淹没。这个观念很多新手转不过来。5.2 在错误处理链路中嵌入结构化日志有了分级原则还要落实在代码里。我封装了一层logger包避免业务代码直接用zap的全局变量这样后续扩展logstash、kafka消费者都很方便// logger/logger.go package logger import ( go.uber.org/zap go.uber.org/zap/zapcore ) var L *zap.Logger func Init(level zapcore.Level) { cfg : zap.Config{ Level: zap.NewAtomicLevelAt(level), Encoding: json, // JSON格式方便采集 OutputPaths: []string{stdout}, ErrorOutputPaths: []string{stderr}, EncoderConfig: zapcore.EncoderConfig{ TimeKey: time, LevelKey: level, CallerKey: caller, MessageKey: msg, StacktraceKey: stacktrace, LineEnding: zapcore.DefaultLineEnding, EncodeLevel: zapcore.LowercaseLevelEncoder, EncodeTime: zapcore.ISO8601TimeEncoder, EncodeDuration: zapcore.SecondsDurationEncoder, EncodeCaller: zapcore.ShortCallerEncoder, }, } var err error L, err cfg.Build() if err ! nil { panic(err) } } func Debug(msg string, fields ...zap.Field) { L.Debug(msg, fields...) } func Info(msg string, fields ...zap.Field) { L.Info(msg, fields...) } func Warn(msg string, fields ...zap.Field) { L.Warn(msg, fields...) } func Error(msg string, fields ...zap.Field) { L.Error(msg, fields...) }然后要把日志嵌入到错误处理链路里。核心原则是每一条错误只打一次日志谁负责“收口”谁打日志。这句话很重要它是很多团队重复打日志的根源。具体到实现我处理错误的路径是这样的service层抛BizError时不打日志只负责包装中间件通过errors.As拿到BizError后根据错误码和错误内容决定日志级别中间件只打一次日志然后统一写Response。我封装一个统一的日志记录函数// middleware/error_logger.go package middleware import ( errors your-project/internal/logger your-project/internal/response ) // LogBizError 根据错误类型和错误码自动分级记录日志 func LogBizError(c *gin.Context, err error) { traceID : response.GetTraceID(c) // 先判断是不是BizError var bizErr *response.BizError if !errors.As(err, bizErr) { // 未知错误记Error并带上堆栈是重点 logger.Error(unhandled error, zap.String(trace_id, traceID), zap.String(path, c.Request.URL.Path), zap.String(method, c.Request.Method), zap.Error(err), ) return } // 系统级错误码段1000~1999中500映射的记Error其余按Warn code : bizErr.Code fields : []zap.Field{ zap.String(trace_id, traceID), zap.String(path, c.Request.URL.Path), zap.String(method, c.Request.Method), zap.Int(code, code), zap.String(message, bizErr.Message), } // 附加自定义字段 for k, v : range bizErr.Fields { fields append(fields, zap.Any(k, v)) } switch { case code 1000 code 2000: switch code { case response.CodeInternalError: // 系统内部错误带上原始错误和堆栈 logger.Error(system internal error, append(fields, zap.Error(bizErr.Err))..., ) default: logger.Warn(request rejected by system rule, append(fields, zap.Error(bizErr.Err))..., ) } default: // 业务错误默认记Warn因为属于预期内的拒绝 logger.Warn(business rule rejected, append(fields, zap.Error(bizErr.Err))..., ) } }然后把这个函数放到response.Failure调用之前执行func HandlerError(c *gin.Context, err error) { LogBizError(c, err) response.Failure(c, err) }市面上很多文章会建议“在业务代码里打日志”但我在实际项目里见到最多的重复日志场景恰恰就是这里。业务代码打一条、中间件又打一条排查问题时同一个错误出现三四条记录反而没法定位。所以说这句“只打一次”很关键。5.3 让错误日志真正能干活的三个字段日志不只是打出来给开发者人肉看的它是要给监控、告警、检索用的。结构化日志里三个字段尤其重要。第一个是trace_id。你排查一个请求的所有日志用grep trace_id就能把一次请求从入口到出口的全部日志拉出来。这是日志关联的基础。第二个是error字段。Go的zap.Error()会自动把error的字符串写进去但如果底层错误是BizError它的Error()方法里已经带了code和message所以在Error日志里能看到原始错误的完整信息。第三个是path和method。有了这两个字段你看监控面板时能知道哪个接口错误率最高而不是一锅粥。如果你的服务有多个实例建议把instance_id或者pod_name也打在字段里不然多实例部署时日志分散在不同机器排查问题会绕很多弯子。6. 完整的落地代码骨架和路由示例前面几节是零散的设计这节给一份可以直接往项目里贴的骨架代码然后跑通一个完整示例。6.1 一个包含错误处理的最小可运行项目假设项目模块名是github.com/your/project下面是关键文件的完整内容。// main.go package main import ( github.com/gin-gonic/gin go.uber.org/zap/zapcore github.com/your/project/internal/controller github.com/your/project/internal/logger github.com/your/project/internal/middleware github.com/your/project/internal/response ) func main() { logger.Init(zapcore.InfoLevel) r : gin.New() r.Use(middleware.RequestIDMiddleware()) r.Use(middleware.RecoveryMiddleware()) r.Use(middleware.AccessLogMiddleware()) // 404、405统一处理 r.NoRoute(func(c *gin.Context) { response.Failure(c, response.NewBizError(response.CodeNotFound, 接口不存在)) }) r.NoMethod(func(c *gin.Context) { response.Failure(c, response.NewBizError(response.CodeMethodNotAllowed, 方法不允许)) }) api : r.Group(/api/v1) { api.POST(/login, controller.Login) api.GET(/orders/:id, controller.GetOrder) api.GET(/panic, controller.PanicDemo) // 演示用实际不要有 } r.Run(:8080) }// controller/user_controller.go package controller import ( github.com/gin-gonic/gin github.com/your/project/internal/response github.com/your/project/internal/service ) func Login(c *gin.Context) { var req LoginRequest if err : c.ShouldBindJSON(req); err ! nil { response.Failure(c, response.NewBizError(response.CodeInvalidParams, 请求参数格式不正确)) return } userInfo, err : service.Login(c.Request.Context(), req.Username, req.Password) if err ! nil { // 统一交个中间件处理Controller里不写日志 response.Failure(c, err) return } response.Success(c, userInfo) } func GetOrder(c *gin.Context) { orderID : c.Param(id) order, err : service.GetOrder(orderID) if err ! nil { response.Failure(c, err) return } response.Success(c, order) }6.2 模拟几条不同等级的错误输出实际跑起来之后不同错误产生的日志和响应大概长这样成功响应{code:0,message:ok,data:{id:123,amount:99.9}}业务拒绝登录密码错误日志级别WarnHTTP状态码200{level:warn,time:2025-01-15T10:23:4508:00,msg:business rule rejected,trace_id:a1b2c3,path:/api/v1/login,method:POST,code:2002,message:用户名或密码错误}响应{code:2002,message:用户名或密码错误,trace_id:a1b2c3}参数错误日志级别WarnHTTP状态码400{level:warn,time:2025-01-15T10:23:5008:00,msg:request rejected by system rule,trace_id:a1b2c3,path:/api/v1/login,method:POST,code:1001,message:请求参数格式不正确}panic日志级别ErrorHTTP状态码500{level:error,time:2025-01-15T10:23:5508:00,msg:[Recovery] panic recovered,trace_id:a1b2c3,path:/api/v1/panic,method:GET,error:something terrible happened,stack:goroutine 123 [running]:\n...}响应{code:1000,message:系统开小差了请稍后重试,trace_id:a1b2c3}这是靠自己跑实验观察到的结果说实话看到这个输出的时候我心里挺踏实的。因为线上同事报问题时报的TraceID能串起前后端日志里能看到panic的完整堆栈前端也不用再去猜测错误格式。7. 踩坑自检错误处理链路的隐藏问题清单7.1 这份方案里最容易踩的坑第一个坑Response写了两次。这个坑出现频率极高。业务handler里已经写了c.JSON结果在service里又panic了Recovery中间件再写一次ResponseGin日志里就出现superfluous response.WriteHeader call。正确的做法是业务handler里不要写c.JSON错误分支统一走response.Failure写成功响应也要小心写完就不要再调用c.Next()继续处理。所以所有成功响应都走response.Success错误统一走response.Failure是两个唯一的出口。第二个坑错误码重复。多人开发时每个人给业务码都是随手填比如两个人都写2001但代表不同含义。这个只能靠编码规范和code review硬约束。我在团队里会维护一份code.md文档每次新增错误码必须在文档里登记写明码段、含义、关联模块和加微信/钉钉群的讨论记录。第三个坑日志级别乱用Error日志刷爆告警群。很多人一看到错误就直接logger.Error结果业务上预期内的失败频繁触发告警真正需要关注的系统级错误被淹没。我的原则是预期内的失败用Warn无关紧要的调试用Debug不可恢复的系统异常才用Error。这个原则需要在全组达成一致。第四个坑没有TraceID。如果你的错误日志没有TraceID排查问题等于闭着眼找石子。中间件必须放在最外层保证每个请求都有TraceID且响应头要回传这个ID。第五个坑返回信息泄露内部细节。比如数据库报错直接把SQL片段返回给前端。运维和开发看到的是“sql: relation X does not exist”用户看到的是天书。我处理这类问题的方案是BizError的Message字段永远是人话原始错误只放Err字段用于日志绝不上抛到Response。7.2 上线前自检清单我在每次提交代码前都会过一遍这份清单算是个人习惯分享出来所有的handler错误分支是否都调用了response.Failure有没有裸写c.JSONservice层返回的错误是否都用NewBizError或WrapBizError包装过错误码是否在code.go里定义过有没有随手写数字日志级别是不是选对了业务拒绝是Warn系统异常是Error每一条日志是否都带了trace_id和path有没有在多个层级重复打日志同一个错误必须只打一次。前端展示用的Message里有没有后端堆栈或英文原文错误响应里是否带上了trace_id前端反馈问题时能不能给出来这份清单本身也是我在一次线上故障复盘里列出来的——那次故障比“错误处理不规范”严重得多起因就是一次panic没被中间件兜住导致整个进程直接退出、服务全挂。后来我把Recovery中间件当成所有Gin项目的标配没有例外。8. 回到实战这个方案帮我解决了什么整套方案落地之后最直接的变化是代码可读性上了一个台阶。业务模块里不再有重复的if err ! nil { c.JSON(...) }错误处理路径清晰了新来的同事看代码几行就知道接口在干什么。前端对接时只需要看一页接口文档因为响应格式只有一个。我个人在实际操作中还有个体会这套设计不只是给“错误”用的。它等于给整个项目定了一套语言的边界后端告诉前端“你只管拿code具体语义看code表”后端内部告诉日志系统“你只管按级别收具体问题看trace_id”。这套边界的价值在项目规模小的时候不明显等接口上到一两百个、排查一次线上问题需要翻十几个服务日志的时候你就知道它有多值钱了。最后再分享一个小细节中间件里打印访问日志时把响应耗时和状态码带上比如status404 cost12ms在监控接口的成功率和排查慢请求时非常有用。这算是我实践下来最顺手的一个扩展点。后续如果要做全链路追踪在这个基础上接OpenTelemetry也不难——错误响应里的trace_id本来就是为了以后接链路系统准备的别浪费了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门