Go语言错误处理在业务中的应用实践
在前文《Go语言开发规范》一文中,笔者最后简单提到了错误处理error,但受篇幅限制,讲的还是过于简单。这篇文章将从error设计细节和业务开发这两个角度深入讲讲Go语言中对错误处理的工程实践。
讲起Go语言的error,想必网上可以搜索到一大堆相关的技术文章。但笔者细节阅读了一些文章发现,有些文章侧重在error底层实现上进行各种源码分析。而另外一些文章则从业务的角度,着重讲解error在业务代码中的定义、封装以及业务层级间的经验的规约之道。笔者作为Go语言的重度用户,想结合这两方面以及自己的开发经验谈谈Go错误处理的业务中的应用实践。
日常开发中,主要有两个方面觉得Error比较麻烦。一个是错误检查和打印(日志)。因为代码的分层设计、模块化设计,导致处处打了log;难以获取错误的堆栈信息,其最原始的错误更是较难捕获。另一个是业务的错误码设计。业务一般需要统一封装,如Code值、Message字符串和Detail等消息。我们经常在代码里面到处都需要是 return error, 这个非常麻烦,每个地方都要处理。从防御式编程的角度来讲,我们应该遵循“不可信”原则进行严格的边界检查,每一个action都需要进行判断处理异常。那Go语言从这个角度来讲是很好的示范。但弊端很明显,太多了,几乎每一行代码或者每一个函数调用结束以后,应该对它负责,所以尽早处理掉函数的错误、异常。
标准库之error
得益于Go语言的函数支持多返回值,在业务开发中,经常在返回接口把业务语义(业务返回值)和控制语义(出错返回值)区分开来。所以常见的返回形式都形如result,err两个值。error是Go源代码的内嵌接口类型。虽然它不是Go关键词,但和关键词相似,是预先声明的标识符。其定义和实现也非常的简单:
// The error built-in interface type is the conventional interface for // representing an error condition, with the nil value representing no error. type error interface { Error() string } // New returns an error that formats as the given text. // Each call to New returns a distinct error value even if the text is identical. func New(text string) error { return &errorString{text} } // errorString is a trivial implementation of error. type errorString struct { s string } func (e *errorString) Error() string { return e.s }
我们一般创建error时只需要调用errors.New("error from somewhere")
即可。其底层就是一个字符串结构体errorString,使用其Error()方法即可返回。
那细心的读者可能会发现,虽然Error()方法只返回一个字符串,但其定义的是一个结构体,errors.New()
返回的其实是个结构体的指针。那为什么标准库中设计的是返回一个指针呢?正常情况下,对比两个struct是否相同的时候,会去对比这两个struct里面的各个字段是否是相同的,那指针的话,肯定会去对比其内存地址是否一致。所以,这也很好理解,当判断两个error是否一致是,肯定得判断这两error是不是同一个内存地址的啊,肯定不能只依靠error返回的string字符串是否一样了。试想一下,我们各种包里面定义的错误,难道Error()方法返回的字符串一样就是一个错误吗?显然不能这样。
error封装之pkg/errors
由于标准库中的error过于简单,这也导致存在一些弊端,如无法wrap更多的信息,比如调用栈信息,在经多层调用后难以排查问题。以及无法很好的处理error类型。基于此,error专门添加扩展标准库:https://github.com/pkg/error 。查看其doc可以发现,它主要有以下接口定义:
func As(err error, target interface{}) bool func Cause(err error) error func Errorf(format string, args ...interface{}) error func Is(err, target error) bool func New(message string) error func Unwrap(err error) error func WithMessage(err error, message string) error func WithMessagef(err error, format string, args ...interface{}) error func WithStack(err error) error func Wrap(err error, message string) error func Wrapf(err error, format string, args ...interface{}) error type Frame func (f Frame) Format(s fmt.State, verb rune) func (f Frame) MarshalText() ([]byte, error) type StackTrace func (st StackTrace) Format(s fmt.State, verb rune)
这里着重讲讲以下几点,并结合项目经验谈谈其使用规范:
- Wrap 封装底层error,增加更多信息,提供调用栈信息,这是标准库error所缺少的
- WithMessage 封装底层error,增加更多自定义信息,但不提供调用栈信息
- Cause 返回最底层的error,剥去层层的 wrap
import "github.com/pkg/errors" //错误包装1 if err != nil { return errors.Wrap(err, "read failed") } //错误包装2 if err != nil { return errors.WithMessage(err, "some other message") } // Cause接口 switch err := errors.Cause(err).(type) { case *MyError: // handle specifically default: // unknown error }
关于WithMessage
通过https://github.com/pkg/error源码可以看到,Wrap
是Message
的基础上封装的。WithMessage
的实现如下:其核心是通过withMessage结构体定义的。
// WithMessage annotates err with a new message. // If err is nil, WithMessage returns nil. func WithMessage(err error, message string) error { if err == nil { return nil } return &withMessage{ cause: err, msg: message, } } type withMessage struct { cause error msg string }
关于Wrap
Wrap
在Message
的基础上,进行封装如下:
// Wrap returns an error annotating err with a stack trace // at the point Wrap is called, and the supplied message. // If err is nil, Wrap returns nil. func Wrap(err error, message string) error { if err == nil { return nil } err = &withMessage{ cause: err, msg: message, } return &withStack{ err, callers(), } } type withStack struct { error *stack }
如果是调用其他库(标准库、企业公共库、开源第三方库等)获取到错误时,请使用 errors.Wrap
添加堆栈信息。但不是每个地方都是用 errors.Wrap
,只需要在错误第一次出现时进行 errors.Wrap
即可。所以,我们底层的基础库或底层的三方库,尽量不要使用errors.Wrap
以免堆栈信息被重复包装 。另外,应该根据业务场景判断是的需要将其他库的原始错误信息重写掉,如grpc返回给客户端的信息中,server层的error不易直接在service层返回给grpc客户端。
关于Cause
Cause
的作用就是处理根因。 它的实现是通过递归调用,如果没有实现 causer
接口,那么就返回这个 err,这样就可以暴露原始错误了。
type withStack struct { error *stack } func (w *withStack) Cause() error { return w.error } func Cause(err error) error { type causer interface { Cause() error } for err != nil { cause, ok := err.(causer) if !ok { break } err = cause.Cause() } return err }
在标准库中有一个叫做Sentinel Error术语定义,即哨兵错误。就是定义一些包级别的(可导出)错误变量,然后在调用的时候外部包可以直接对比变量进行判定,在标准库当中大量的使用了这种方式,如IO.EOF、Syscall.ENOENT之类的。
// EOF is the error returned by Read when no more input is available. // Functions should return EOF only to signal a graceful end of input. // If the EOF occurs unexpectedly in a structured data stream, // the appropriate error is either ErrUnexpectedEOF or some other error // giving more detail. var EOF = errors.New("EOF") // ErrUnexpectedEOF means that EOF was encountered in the // middle of reading a fixed-size block or data structure. var ErrUnexpectedEOF = errors.New("unexpected EOF")
那我们在外部这么判断这个错误呢?可以直接使用==判断或使用errors.Is()
判断即可。但是需要注意的时,使用 errors.Is()
的方式势必要把error当作该包的导出API来使用。这难免会增加开发难度(毕竟要先全部熟悉相关error类型定义),而且在重构或升级的时候还得考虑兼容性。所以,总的来说,在业务层代码中尽量少定义和使用这些包级别的error。
那么现在我们回到文章开头提到的第一个问题,即error在错误检查和打印方面的注意事项。简单总结这方面的错误处理的原则:
- 使用pkg/errors内的New/Errorf优化的返回错误
- 接受到其他库返回错误时,直接穿透(不错日志或错误封装)返回
- 调用标准库或三方库交互时,使用WithStack/Wrap携带错误的上下文
- 把错误直接返回给调用者,而不是到处打日志(最上层调用者统一处理错误/日志打印)
其实上面的几条实践原则也很好理解。因为pkg/errors对错误的封装支持较好,我们可以尽量都采用这个统一的错误处理。如果中间层接受到下层返回的错误时,一般情况下,我们只需要判断err是不是为nil就可以决定是否return了。中间不需要进行日志处理,也不需要对错误进行二次包装。最后一条中,如果调用者是最上层的,那就需要统一进行日志处理以及对错误进行记录。
并行错误处理之ErrGroup
在实际的业务开发过程中,比如一些web类的、网络请求类的服务,往往都是并行调用处理的。那问题就来🥱,对于多个并发的error应该如何处理呢?相比熟悉Golang的同学容易联想到WaitGroup
(可以参考笔者之前的文章《深入学习Go语言标准库sync》)。WaitGroup
的使用方式是,使用goroutine并行调度处理,再用Wait
等待结束返回。那ErrorGroup
也与之类似的。
ErrorGroup
在 golang.org/x/sync/errgroup库中实现,其就是为了解决并发场景下,goroutine 产生错误而设计的。具体定义:
type Group func WithContext(ctx context.Context) (*Group, context.Context) func (g *Group) Go(f func() error) func (g *Group) Wait() error
下面通过一个简单的官方例子来说明WithContext、Go、Wait的用法。整体上和WaitGroup
类似。
g := new(errgroup.Group) var urls = []string{ "http://www.golang.org/", "http://www.google.com/", "http://www.somestupidname.com/", }
for _, url := range urls { url := url // https://golang.org/doc/faq#closures_and_goroutines g.Go(func() error { // Fetch the URL. resp, err := http.Get(url) if err == nil { resp.Body.Close() } return err }) }
// Wait for all HTTP fetches to complete. if err := g.Wait(); err == nil { fmt.Println("Successfully fetched all URLs.") }
有几点需要注意的问题是,g.GO
内部如果出现panic错误时,是需要自行recover的,不然就会导致程序崩溃。通过WithContext
可以带入Context上下文信息,但是,但是这个返回的 Context不能继续在业务代码中传递。不然会导致业务代码在运行时出现Context Cancel问题。
error之业务错误处理
上面基本讲完开头提到的第一个了。那第二个问题就是错误在业务中的处理问题了。大部分公司为了统一规范错误码、错误数据格式以及错误类型封装,会定义一套通用的内部标准。笔者在上家公司工作时,就因为gRPC中的errorCode和errorStatus进行过几次激烈的同类,一部分同学还是沿用着restful API的理念,导致gRPC内部定义的error基本没用起来。
通常情况,研发团队会通过改造error的Error方法实现公司内部统一的错误处理,这种改造叫做ErrorType。B站开源的代码可作为一个很好的参考,其定义了ErrorType和相关的导出方法:
定义了适配业务的Error Type
Code:为业务错误码
Message:返回标准库错误信息
Details:返回业务Hint
Code包级别工具方法
Cause: 适配pkg/errors,用于根因获取后的error检测
Equal: 检测两个Codes接口实现者的Code码是否一致
// from: openbilibili/library/ecode/ecode.go#L38 // Codes ecode error interface which has a code & message. type Codes interface { // sometimes Error return Code in string form // NOTE: don't use Error in monitor report even it also work for now Error() string // Code get error code. Code() int // Message get code message. Message() string //Detail get error detail,it may be nil. Details() []interface{} // Equal for compatible. // Deprecated: please use ecode.EqualError. Equal(error) bool }
第一个是Error接口,第二是获取到底报什么错误Code。第三是Message这个是指面向开发者和程序员的错误,就是请求参数错误之类的。还有就是Details,你有一些业务,比如说我被限流了,限流以后可能要返回,多久以后再重试,重试几次,这样的Data我们叫Detail。
同时在Codes包里提供几个包级别的方法,第一就是Cause,用于还原底层error转换成Codes,方便业务使用。
第二个以前代码里面很多同学会和特定的error进行Equal操作(结构体判断),判断是不是这个错误码,这个非常不安全,因为有可能实现了Cause的结构体可能是两种不同东西,最终判断两个东西是不是相等,是用Code的int(具体错误值)。所以我们高级别提供了Equal的方法,判断两个Error到底错误码是不是相等的,这是业务过程中我们想这些解决问题。
另外,B站还自定义了一套错误码类型:Status(状态)。笔者深入研究了B站的源码后大概了解了。其增加Status主要是兼容HTTP服务,同时对gRPC服务可以直接沿用。
// from:openbilibili/library/ecode/internal/types/status.proto message Status { // The error code see ecode.Code
// 实际上该错误码在google.rpc.Code里面定义 int32 code = 1; // A developer-facing error message, which should be in English. Any
// 面向开发人员可读的英文错误信息 string message = 2; // A list of messages that carry the error details. There is a common set of // message types for APIs to use.
// 额外的错误信息,这些错误可以被客户端代码处理 repeated google.protobuf.Any details = 3; }
// from: openbilibili/library/ecode/status.go // Status statusError is an alias of a status proto // implement ecode.Codes type Status struct { s *types.Status } // Error implement error func (s *Status) Error() string { return s.Message() } // Code return error code func (s *Status) Code() int { return int(s.s.Code) } // Message return error message for developer func (s *Status) Message() string { if s.s.Message == "" { return strconv.Itoa(int(s.s.Code)) } return s.s.Message } // Details return error details func (s *Status) Details() []interface{} { if s == nil || s.s == nil { return nil } details := make([]interface{}, 0, len(s.s.Details)) for _, any := range s.s.Details { detail := &ptypes.DynamicAny{} if err := ptypes.UnmarshalAny(any, detail); err != nil { details = append(details, err) continue } details = append(details, detail.Message) } return details }
error与panic比较
这里先讲讲panic。因为它作为一个关键词,使用方法还是比较明确清楚的。从工程经验的角度来看,panic一般有下面使用注意事项:
1.在程序启动阶段,如果有强依赖的服务出现故障,应该及时panic退出
2.一般在程序启动阶段,发现配置信息错误或端口/IO等不可用错误时,应该及时panic退出
3.在业务代码内部尽量不用出现panic,对于不确定的情况应该使用recover及时恢复,保证主程序健壮
4.当然真正意外的情况,如不可恢复的程序错误,如越界,环境故障,溢出等问题,才使用panic
5.panic会存在性能瓶颈,最好不要频繁的panic/recover,如果代码中存在应该debug排查问题的根源所在并显示的处理该错误
那error呢?有哪些使用上的注意事项呢,除了上面的长篇概论外,这里再啰嗦几点:
1.error应优先处理,再处理其他情况。代码应该是尽量“左对齐”一条直线,避免过多的嵌套
2.处理错误时,对于已经分配了资源的case,可以使用defer 进行清理,如fd、body等等。
参考&学习资料:
Go业务基础库之 Error:https://mp.weixin.qq.com/s/ymMypuq-w_QJijAoKVs4Gg
Go error 处理最佳实践(更新):https://mp.weixin.qq.com/s/XojOIIZfKm_wXul9eSU1tQ
GO编程模式:错误处理:https://coolshell.cn/articles/21140.html
Go错误处理最佳实践:https://lailin.xyz/post/go-training-03.html