写点什么

微服务错误码设计实践

用户头像
循环智能
关注
发布于: 22 小时前
微服务错误码设计实践

作者:实时辅助团队 黄岑敏

摘要

错误码在系统里面是一个很小的模块,但又是一个必须的模块。设计一个适合业务和团队高效迭代的错误码方案需要考虑哪些问题?在实践过程中又有哪些坑?

背景

我们之前设计迭代一个微服务工程,工程内服务间调用使用 gRPC 协议,网关提供所有对外接口接入,提供 HTTP 接口。为了方便接口定义和描述,规范接口设计,规范业务异常逻辑设计,我们逐步演进迭代了一套微服务错误码的实现方案。方案帮助日常业务迭代减少对异常逻辑的设计困扰,提高技术设计和编码阶段的效率,增加代码可读性,提高问题排查效率。

接口设计

HTTP 状态码可以表示一些服务状态,但是大部分的 HTTP 接口都会有大量业务错误码,HTTP 状态码无法满足丰富的业务表示需要,一般会弱化为系统监控和错误分类等用途。我们的系统设计成业务服务全部响应 200,好处是开发的时候减少了状态码选择的考虑,也简单区分了业务错误和网络错误。对于需要大量异常表示的业务系统来说,code+message 是非常流行的方式,在接口上一般设计为:


{    "code":10001,    "message":"参数错误",    "data":{}}
复制代码

这里面有最朴素的两个考虑:

  1. code 作为数值类型方便程序代码进行比较

  2. 系统的使用者需要 message 进行字面解读,不管是终端用户还是开发人员,在系统运行不符合预期的时候用于进行简单的异常判断

这种设计方式同时考虑了程序和人的使用便利。

使用枚举

code 表示一个具体的错误,一般在开发阶段就会定义好了,因此很容易让人想到使用枚举来表达。最开始我们在系统使用枚举进行设计,枚举具有可读性强的特点方便我们在编码过程中进行引用,枚举放在一起还方便管理。

不过枚举是固定的,在复杂的系统里我们往往需要对同一个错误码进行不同的可读性描述,例如 INVALID_PARAMS 这种非常聚合的一类错误码对于系统程序拦截异常参数来说足够了,但是对于系统使用者来说往往还需要更具体的信息:哪个参数?如何无效?因此错误消息可变是很现实的需求。

这就需要在枚举之上再包装一个结构,使用这个结构对象序列化后得到最终的异常响应结果。在需要返回错误码的时候我们通过枚举构造这个错误码对象然后返回。

第一版如下示意:

type ErrorCode struct {    Code int    Message string}
const ( OK = 0 ErrInvalidParams = 100001 ErrInternalError = 100003)
// 默认值,这里还可以保证code不会重复!var messages = map[int]string{ ErrInvalidParams:"参数错误", ErrInternalError:"内部错误",}
// 使用默认messagefunc ErrorResp(code int) *ErrorCode { return &ErrorCode{Code: code, Message: messages[code]}}// 使用自定义messagefunc ErrorRespWithMessage(code int, message string) *ErrorCode { return &ErrorCode{Code: code, Message: message}}
复制代码

与编程语言异常处理结合

为了在程序里面方便的使用错误码,在 golang 中为错误码实现 error 接口,使用时就可以作为 error 返回,与 golang 的异常处理逻辑保持一致。在处理正常逻辑时返回 nil 即可。在 Java 中我们可以包装为 Exception,使用的时候直接 throw 出去,其他语言也类似,总之符合编程语言的异常使用习惯,把业务逻辑的异常合并到程序异常去,只要能够区分就行。

可能有人会担心使用程序的异常来处理性能损耗,比如 Java 会在构造异常的时候去生成 stack trace(这里可以重写去掉),但是在编码便利这个巨大的利好面前,一点点的性能损耗非常划算。

在 golang 里面,将错误码对象实现了 error 接口后,就可以在任何方法里将错误码作为 error 返回,而接口的正常逻辑响应可以作为另一个返回参数,这样所有的业务接口方法的实现会简化。我们只需要在 handler 层做一个转换,对异常或正常结果的返回结构进行组装,就能实现开头设计的响应结果。这里我们还对未知的错误进行区分处理,一般如果是业务系统没有定义的错误都记录日志后转为固定的内部错误,防止程序的具体信息泄漏出去。


type response struct{ Code int Message string Data json.RawMessage}
func handler(r *http.Request, w http.ResponseWriter) { // deserialize to req // 简化的ServiceFunc参数很明确,可以清晰看到入参出参,用error接口表示业务异常分支 // service.ServiceFunc(ctx context.Context, req *XxxReq) (*XxxResp, error) resp, err := service.ServiceFunc(r.Context(), req) code, ok := err.(*ErrorCode) if !ok && err != nil { log.Printf("got unknown error, err: %v", err) code = ErrorResp(ErrInternalError) } if code == nil { code = ErrorResp(OK) } res := &response{ Code: code.Code, Message: code.Message, } if code.Code == 0 { var data []byte data, err = json.Marshal(resp) if err != nil { // fallback to error response } res.Data = data } // write to http response}
复制代码

ErrorCode 在接口表现上已经和 error 一致了,所以不可避免的我们会在程序里使用方法调用返回的 err 与 ErrorCode 比较,但是我们枚举的是错误码(int),返回的却是一个全新的 struct,因此无法直接比较。比较简单的修改是将枚举的 int 重新定义一个类型,然后将类型实现 error 接口,而在接口响应时则使用另一个 struct 进行序列化。改为 int 类型后还需要处理 code 防重这个细节。

type errorCode struct {   Code ErrorCode   Message string}
func (c errorCode) Error() string { return fmt.Sprintf("code(%d),message(%s)", c.Code, c.Message)}
type ErrorCode int
func set(code ErrorCode, message string) ErrorCode { // 排除重复的code if _,ok:=messages[code];ok { panic(fmt.Sprintf("duplicated code:%d", code)) } return code}
var ( OK = set(0, "")
// 通过set定义错误码可以在程序启动的时候排除重复code // code 和 message 在同一行代码定义,轻松关联管理 ErrInvalidParams = set(100001, "参数错误") ErrInternalError = set(100003, "内部错误"))
var messages = map[ErrorCode]string{}
func (c ErrorCode) getMessage() string { if m,ok := messages[c]; ok { return m } // 未知的错误 return "unknown"}
// 使用默认的messagefunc (c ErrorCode) Error() string { return fmt.Sprintf("code(%d),message(%s)", c, c.getMessage())}
// 使用自定义的messagefunc (c ErrorCode) With(m string) error { return &errorCode{c, m}}
// 转成序列化对象func (c ErrorCode) toStruct() *errorCode { return &errorCode{c, c.getMessage()}}
// 全局handlertype response struct{ Code ErrorCode Message string Data json.RawMessage}
func handler(r *http.Request, w http.ResponseWriter) { // deserialize to req // service.ServiceFunc(ctx context.Context, req *XxxReq) (*XxxResp, error) resp, err := service.ServiceFunc(r.Context(), req) var codeStc *errorCode = nil if err != nil { switch err.(type) { case *errorCode: codeStc = err case ErrorCode: codeStc = err.toStc() default: // 处理泄露未定义的错误,防止错误信息泄露 log.Printf("got unknown error, err: %v", err) codeStc = ErrInternalError.toStruct() } } else { codeStc = OK.toStruct() }
res := &response{ Code: codeStc.Code, Message: codeStc.Message, } if codeStc.Code == OK { var data []byte data, err = json.Marshal(resp) if err != nil { // fallback to error response } res.Data = data } // write to http response}
复制代码

在 lib 使用错误码

既然错误码实现了 error 接口,那么是不是可以在整个系统里作为 error 来使用呢?我们的错误码都是接口错误码,所以一个提供接口的服务模块是需要使用错误码的,这样可以简化很多异常逻辑分支。如果一个模块可能用于命令行工具,或者可以被第三方系统复用,那么我们认为是不适合使用 HTTP 接口错误码表示的。

总结来说就是,服务的 router、controller、client(RPC、HTTP)、service 层都可以使用错误码作为 error 表达。而像 model、util、config 等与接口关联不大的,预期可以作为 lib/SDK 的方式复用的模块建议使用模块自定义的 error 来表示。


举例一个 model 方法

// var ErrNotFound = gorm.ErrRecordNotFoundvar ErrUserIsBlocked = errors.New("user is blocked")func (d userDao) GetUser(ctx context.Context, db *gorm.DB, uid int) (*User, error) {   var user User   err := db.WithContext(ctx).Model(User{}).Where("uid=?", uid).First(&user).Error   if err == gorm.ErrRecordNotFound {      // 这里如果需要返回error,那么可以定义gorm.ErrRecordNotFound      // 的alias ErrNotFound方便调用方进行err的比较(不用显示引用gorm包)      // return nil, err      return nil, nil   }   if err != nil {      // 这里不用转为ErrorCode,类似工具脚本引用返回ErrorCode会比较奇怪      // 也不用log,在service里转换和log      // 发生异常时,业务逻辑一般会有业务相关的处理,在service处理更合适      return nil, err   }   // 假设我们查询的时候不允许调用方获得被禁用的用户信息,那么封装成包内的   // 预定义err是一个比较好的方式   if user.IsBlocked {      return nil, ErrUserIsBlocked   }      return &user, nil}
复制代码

在服务间(HTTP/RPC)使用错误码

HTTP 服务间调用在发生异常的时候返回 code 和 message,这时候就可以在通用的 client 方法做状态码判断,同时返回反序列化后的 error,方便业务代码进行异常逻辑处理。反序列化时使用 struct errorCode 进行,这样可以保留服务方序列化返回的自定义 message,在打日志或者透传到下游调用方的时候也能保留原始的信息。

func UnmarshalResp(data []byte, resp interface{}) (error, bool) {   var r response   err := json.Unmarshal(data, &r)   if err != nil {      log.Printf("unmarshal response fail: %v", err)      return ErrInternalError, false   }   if r.Code != OK {      // 保留服务方的message      return r.Code.With(r.Message), true   }      // unmarshal biz data   err = json.Unmarshal(r.Data, resp)   if err != nil {      log.Printf("unmarshal response.Data fail: %v", err)      return ErrInternalError, false   }   return nil, true}
复制代码

在 rpc 中也是类似,例如 gRPC 使用 protobuf 结构消息 spb.Status 表示接口异常的结果,gRPC 的 golang 实现也是返回实现了 error 接口的 status 对象,在 client 端直接使用 status.FromError 获得 status 进行 code 判断即可,这时 code 都是整形,可以与我们定义的枚举(全局变量错误码)很方便的进行比较。如果作为服务方响应可以在 gRPC service interceptor 内进行 ErrorCode(或 *errorCode)到 status 结构的转换,只需使用我们定义的 code 和 message。

跨服务管理错误码

一般来说,只要约定了接口的序列化结果是像开头定义的那样,那么任何系统都可以方便的接入。不过松散的约定肯定没有集中管理所有错误码来得高效,像阿里开发手册就要求定义错误码的时候到同一平台去审批并固化,先到先得,如果已存在就用存在的。

我们使用 mono repo 简单解决了这个问题,在大仓库内有一个错误码包负责所有系统的错误定义管理,所有服务依赖这个包,有新增直接在仓库内添加即可。gRPC 可以通过定义所有服务依赖的枚举消息来实现全局管理,但是这里只有 code,message 则需要在公共的 lib 内进行定义,或者定义一个代码生成工具生成目标语言的 message,这种方式比较适合多语言开发的团队。

除了全局管理错误码,我们还需要标识每个服务的错误码,虽然这样做会造成部分错误码意思一样但是错误码不一样,不过好处是方便方便定位错误发生在哪个服务里,而错误码复用则需要去挖日志。这里要取一个折中,大部分的错误码在客户端仅仅是展示 message,不会对错误码做任何逻辑判断,这些错误码就没必要使用同一个,而那些少数需要在客户端进行特殊处理的(比如跳转认证页)错误码就需要同一个码,这类错误码需要定义为公用。一般来说每个服务都可能发生的错误需要定义为公用错误码,而其他各自服务特有的错误码可以带上一个服务标识,或者每个服务划分一个号段。

处理类似 cause by inner error

有些语言会对异常提供封装的能力,比如 C#的 InnerException,帮助开发者对发生的异常补充一些信息,提供更丰富的描述能力。

不过一般建议 err 在发生错误的第一现场就进行相应的处理,或记录日志,或转换为包内定义好的 error 全局变量返回,这样处理的好处是错误的第一现场得到保留,不会导致错误多次传递后难以找到是哪里发生的。

如果我们真的需要对原始的 err 进行信息补充和封装。那么还有一些方案提供类似 InnerException 的解决方案,就是定义一个结构体实现 error 接口,这个结构体持有 inner err,这样可以提供调用链内的所有参与方对错误信息进行补充描述,有点类似 context。可以参考:https://github.com/pkg/errors

总结

本文解决的问题:

  1. 错误码的描述和比较

  2. 规范了异常处理方式

  3. 规范错误码使用边界

  4. 解决跨服务错误码传递和识别

  5. 规范错误码管理和异常管理

未解决(未提到)的问题,供以后迭代或者在本文评论讨论:

  1. HTTP 状态码与错误码的共存和映射管理

  2. 错误详情的服务间传递

  3. 错误码 debug 信息丰富方案

  4. 任何你感兴趣的问题请评论

发布于: 22 小时前阅读数: 68
用户头像

循环智能

关注

还未添加个人签名 2021.05.12 加入

提供丰富的技术干货、程序样例,方便开发者快速成长与发展,欢迎提问、互动,多方位探讨!

评论

发布
暂无评论
微服务错误码设计实践