当前位置: 首页 > Go语言, 代码艺术, 工程能力 > 正文

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)

这里着重讲讲以下几点,并结合项目经验谈谈其使用规范:

  1. Wrap 封装底层error,增加更多信息,提供调用栈信息,这是标准库error所缺少的
  2. WithMessage 封装底层error,增加更多自定义信息,但不提供调用栈信息
  3. 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源码可以看到,WrapMessage的基础上封装的。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

WrapMessage 的基础上,进行封装如下:

// 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

 



这篇博文由 s0nnet 于2020年03月25日发表在 Go语言, 代码艺术, 工程能力 分类下, 通告目前不可用,你可以至底部留下评论。
如无特别说明,独木の白帆发表的文章均为原创,欢迎大家转载,转载请注明: Go语言错误处理在业务中的应用实践 | 独木の白帆
关键字: , ,

Go语言错误处理在业务中的应用实践:等您坐沙发呢!

发表评论

快捷键:Ctrl+Enter