golang分布式场景下的error处理

156 阅读10分钟

错误处理在golang日常开发中非常重要,对于一段业务逻辑,需要先判断各种异常情况,妥善处理各种异常情况,只有全部正常的情况下,才能按照预期完成业务逻辑,可以说异常处理伴随开发始终,而golang这种显式处理error的风格,也确实让异常处理变得相对比较啰嗦,当然显式的错误处理确实也有很多优点,比如十分直观、强迫开发者必须处理错误、符合golang整体的代码风格,其实这几年下来现在很适应这种显示处理错误的方式,不过实际开发中还是碰到了很多比较纠结的地方,最近好好整理了这些问题,也仔细研究了一些错误处理库,将相关的思考记录如下:

错误处理中思考的问题:

到底是应该每次返回错误都打印一次错误日志,还是应该只在入口处统一打印一次错误日志?

如果每次都打印,确实太麻烦了,打印出的日志也非常多,还大量重复,如果只在入口处打印,那么错误发生的位置其实不太好精确判断,也拿不到完整的堆栈信息。golang在1.13时将链式error处理纳入了标准库,也就是说官方更加推荐的做法是每一级返回error时,都使用 fmt.Errorf("something failed, %w", err) 的方式为错误附加当前的业务信息,很长一段时间我都在采用这种不断进行wrap,最后在最上层统一打印日志的方式,这种方式的缺点也是无法精确定位错误发生的位置和堆栈,因为每次wrap附加的信息都只是一些标记性的文案,后来我就在错误发生的位置也打印一次日志,但这种方式会让整个代码看起来很不统一,有的地方只进行wrap,有的地方同时wrap并打印日志,很奇怪,开发也需要一直记得这点。基于这些我觉得错误中附加堆栈信息还是更好的做法,因为这样无论何处获取到error都能明确地了解错误发生的堆栈信息。

业务异常状态码是应该单独作为一个返回值,还是也统一作为error进行处理?

业务状态码是比较常见的处理逻辑,例如对于一些平台的开放api,某账户余额不足,那么相关的接口就会返回一个余额不足的状态码,这个状态码能够让用户快速定位到异常的原因,那么异常状态码该如何返回呢,如果作为返回值的一部分,比如某个grpc服务所有返回值都要加上code和message字段,用于表示返回的状态码及提示信息,同时也会返回一个error,这种方式会比较啰嗦,client侧需要先判断error是否为空,再判断code是否为0。实际上grpc的status包表明使用error返回异常状态码是更推荐的做法,即使用 status.Error(codes.FailedPrecondition, "something failed") 这种方式返回error,error中会包含grpc状态码及其他错误细节,但是status包只允许返回特定的grpc状态码,不能返回自行规定的业务状态码,需要考虑一种更为优雅简单的方式通过error返回状态码。

分布式场景下的错误处理如何能和单机错误处理的体验几乎一致?

首先error从一个服务直接返回给另一个服务时,必然涉及到error的编解码,需要先将error编码通过网络传输, 之后解码再还原回原始的错误类型,错误中包含的堆栈信息、提示信息等也需要进行编解码,这需要通用的错误编解码函数,同时errors.Is的判断方式也需要增强,标准库中的errors.Is本质上是判断两个错误变量的地址是否一致,地址一致才视作同一个错误,当错误跨网络进行传输后必然就是一个新的变量,那么如果将错误都定义在一个公共的代码仓库,再进行errors.Is判断错误是否一致肯定就都是不一致的。所以分布式场景下判断错误是否一致可能需要直接比对错误字符串,字符串一致则认为错误就是一致的,也可能需要比对一些其他信息。

针对于这些问题,我仔细看了不少golang的错误处理库,其中github.com/cockroachdb/errorsgithub.com/pkg/errors应该算是golang中最优秀的错误处理库,尤其是 pkg/errors,早期几乎可以说是替代标准库的存在,不过pkg/errors在分布式场景下支持的并不完善,cockroachdb/errors对分布式支持的更好,也提供了各种工具函数,几乎可以解决业务层面的所有错误处理问题,也因为这点cockroachdb/errors相对于pkg/errors看起来会更重一点。cockroachdb本身就是一个非常有名的分布式数据库,必然会涉及到各种分布式场景下的错误处理问题,我们碰到的问题也基本囊括其中。故我们可以基于cockroachdb/errors再封装一个自己的errors包解决上述问题。

errors包的设计原则:

  1. 包名就叫errors,并且完全兼容errors标准库和pkg/errors中的各个函数,保证项目仅引入一个名为errors的包,且可完成所有error处理相关逻辑,避免引入很多errors包,然后再起各种别名,这样很不易用,函数做好兼容,也可以非常方便地从旧的errors包过渡到新的errors包。

  2. error本身仅仅是一个接口,并不包含具体的类型信息,故不要定义一个新的error类型,这会导致这个特定的error类型在整个golang错误处理中特立独行。应当使用一种装饰器模式的做法,增强error接口的能力,如 WithStack(err error) 会为任意error类型增加堆栈信息,WithHint(err error, hint string) 会为任意error类型增加用户提示信息,各装饰器函数都只针对抽象的error接口设计,使error处理的过程更加通用、更加灵活、更加统一。

  3. error对象创建或Wrap时都应当附加堆栈信息,整体看来,错误中包含堆栈信息还是更好的做法,虽然这有悖于golang轻量级错误处理的初衷,但错误对象自身包含堆栈信息,意味着无论在代码的什么位置都能精确地知道错误发生的准确位置和调用情况,也便于统一将error中包含的各类内容上报给监控系统。

  4. 提供WithHint函数,针对任意错误类型附加用户提示信息,提示信息默认不会在日志中打印,仅用于在前端直接展示给用户。

  5. 提供WithCode函数,针对任意错误类型附加业务状态码,业务网关处从error中提取状态码并返回给前端。

  6. 提供通用的错误编解码函数,使错误能够跨服务进行传输,解码时需要能还原回原本的具体错误类型。errors.Is需要增强分布式场景下的处理能力,当error中包含的错误字符串完全相同且具体的error类型也完全一致时则认为错误就是相同的,而无需要求两个错误的地址也保持一致。

  7. 提供配套的错误处理中间件,如grpc server及client侧的拦截器,server侧自动将rpc返回的错误进行编码,client侧自动进行解码,让错误如同在同一个服务内部进行返回一样。

errors包应当包含的函数:

使用 cockroachdb/errors 基本可以妥善地解决上述问题,但是 cockroachdb/errors 包含的内容很多,一开始使用会稍微有一种无从下手的感觉,另外 cockroachdb/errors 并不支持给错误附加业务状态码,只支持附加grpc状态码及http状态码,可以基于 cockroachdb/errors 封装一个新的errors包,并增加WithCode等更加定制化的工具函数,以下是errors包含的函数签名:

// New 创建一个错误,此错误携带堆栈信息
func New(msg string) error

// Newf 使用格式化方式创建一个错误,此错误携带堆栈信息
func Newf(format string, args ...any) error

// Is 判断错误是否为相同错误,当错误为链式错误时,错误链中任意错误和目标错误相等则视作相等,
// 判断相等时不要求错误变量地址一致,错误字符串以及error接口中实际的错误类型均一致则视作错误相等
func Is(err, target error) bool

// As 将错误转换为特定类型错误,当错误为链式错误时,错误链中任意错误可转换为目标错误则完成转换
func As(err error, target any) bool

// Wrap 使用msg作为前缀包装错误,并附加堆栈信息
func Wrap(err error, msg string) error

// Wrapf 使用格式化字符串作为前缀包装错误,并附加堆栈信息
func Wrapf(err error, format string, args ...any) error

// Unwrap 解包一层错误
func Unwrap(err error) error

// Cause 完整解包错误,得到最内层错误
func Cause(err error) error

// Join 将多个错误包装为一个错误
func Join(errs ...error) error

// WithCode 为错误附加业务状态码
func WithCode(err error, code int) error

// GetCode 获取错误中包含的业务状态码
func GetCode(err error) int

// WithGrpcCode 为错误附加grpc状态码
func WithGrpcCode(err error, code codes.Code) error

// GetGrpcCode 获取错误中的grpc状态码
func GetGrpcCode(err error) codes.Code

// WithHttpCode 为错误附加http状态码
func WithHttpCode(err error, code int) error

// GetHttpCode 获取错误中的业务状态码
func GetHttpCode(err error, defaultCode int) int

// WithHint 为错误附加提示信息,提示信息一般用于直接展示给用户
func WithHint(err error, hint string) error

// WithHintf 为错误附加格式化提示信息,提示信息一般用于直接展示给用户
func WithHintf(err error, format string, args ...any) error

// GetAllHints 获取错误中包含的全部提示信息
func GetAllHints(err error) []string

// FlattenHints 将全部提示信息扁平化为一条提示信息返回
func FlattenHints(err error) string

// WithStack 为错误附加堆栈信息
func WithStack(err error) error

// GetStackTrace 获取错误中的堆栈信息
// errbase.StackTrace 实际上是 pkg/errors中的StackTrace,主流日志库均可友好打印堆栈信息
func GetStackTrace(err error) errbase.StackTrace

// GetSource 获取错误最深处的调用位置,即错误发生的实际位置,若未能获取到调用位置,将返回空
func GetSource(err error) string

// EncodeError 将err进行编码,编码后为一个基于protobuf生成的结构体,主要用于跨网络进行错误传输
func EncodeError(ctx context.Context, err error) errors.EncodedError

// DecodeError 解码错误,还原错误类型
func DecodeError(ctx context.Context, enc errors.EncodedError) error

以上函数绝大多数都可以直接复用 cockroachdb/errors 中的同名函数,仅WithCode相关,需要自己实现一个新的错误类型,用于为已有错误增加状态码信息。我将相关代码整理后放入此仓库之中 github.com/haysons/gok… 可进行参考或直接使用。