Go 语言设计者选择了C 语言家族的经典错误机制:错误就是值,而错误处理就是基于值比较后的决策。同时,Go 结合函数/方法的多返回值机制避免了像 C 语言等那样在单一的函数返回值中承载多重信息的问题。比如:C 标准库中的fprintf函数的返回值就承载了两种含义:在正常情况下,其返回值表示输出到 FILE 流中的字符数量;如果出现错误,则返回值为一个负数,代表错误值。
// stdio.hint fprintf(FILE * restrict stream, const char * restrict format, ...);
而 Go 标准库中等同功能的fmt.Fprintf的函数则通过一个独立的表示错误值的返回值变量(如下面代码返回值列表中的 err)避免了上述问题:
// fmt包func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error)
要写出高质量的 Go 代码,我们需要始终想着错误处理。这些年来,Go 核心开发团队与 Go 社区已经形成了几种惯用的Go 错误处理策略,在本小节中,我就和大家一起来了解和学习一下。
1. 构造错误值
错误是值,只是以error接口变量的形式统一呈现(函数或方法按惯例通常将 error 类型返回值放在返回值列表的末尾):
var err errorerr = errors.New("this is a demo error")// $GOROOT/src/encoding/jsonfunc Marshal(v interface{}) ([]byte, error)func Unmarshal(data []byte, v interface{}) error
error 接口是 Go 原生内置的类型,它的定义如下:
// $GOROOT/src/builtin/builtin.gotype interface error {Error() string}
在标准库中,Go 提供了构造错误值的两种基本方法:errors.New和fmt.Errorf:
err := errors.New("your first demo error")errWithCtx = fmt.Errorf("index %d is out of bounds", i)wrapErr = fmt.Errorf("wrap error: %w", err) // 仅Go 1.13及后续版本可用
Go 1.13 版本之前,这两种方法实际上返回的是同一个实现了 error 接口的类型的实例,这个未导出的类型就是errors.errorString:
// $GOROOT/src/errors/errors.gotype errorString struct {s string}func (e *errorString) Error() string {return e.s}
Go 1.13 及后续版本中,当我们在格式化字符串中使用%w时,fmt.Errorf返回的错误值的底层类型为fmt.wrapError:
// $GOROOT/src/fmt/errors.go (go 1.13及后续版本)type wrapError struct {msg stringerr error}func (e *wrapError) Error() string {return e.msg}func (e *wrapError) Unwrap() error {return e.err}
和errorString相比,wrapError还实现了Unwrap方法,这使得被wrapError类型包装的错误值在包装错误链中被检视(inspect)到:
var ErrFoo = errors.New("the underlying error")err := fmt.Errorf("wrap err: %w", ErrFoo)errors.Is(err, ErrFoo) // true (仅适用于Go 1.13及后续版本)
我们看到标准库中提供的构建错误值的方法方便有余,但给错误处理者提供的错误上下文(error context)则仅限于以字符串形式呈现的信息(Error 方法返回的信息)。在一些场景下,错误处理者需要从错误值中提取出更多信息以帮助其选择错误处理路径,我们可以自定义错误类型来满足这一需求。比如:标准库中的 net 包就定义了一种携带额外错误上下文的错误类型:
// $GOROOT/src/net/net.gotype OpError struct {Op stringNet stringSource AddrAddr AddrErr error}
这样错误处理者便可以根据这个类型的错误值提供的额外上下文信息做出错误处理路径的选择,比如下面的代码:
// $GOROOT/src/net/http/server.gofunc isCommonNetReadError(err error) bool {if err == io.EOF {return true}if neterr, ok := err.(net.Error); ok && neterr.Timeout() {return true}if oe, ok := err.(*net.OpError); ok && oe.Op == "read" {return true}return false}
2. 不透明错误处理策略
Go 语言中的错误处理就是根据函数/方法返回的 error 类型变量中携带的错误值信息做决策并选择后续代码执行路径的过程。这样最简单的错误策略莫过于完全不关心返回错误值携带的具体上下文信息,只要发生错误就进入唯一的错误处理执行路径,这也是 Go 语言中最常见的错误处理策略,80%以上的 Go 错误处理情形都可以归类到这种策略下。
err := doSomething()if err != nil {// 不关心err变量底层错误值所携带的具体上下文信息// 执行简单错误处理逻辑并返回... ...return err}
这种策略下由于错误处理方并不关心错误值的上下文,因此,错误值的构造方(如上面的函数doSomething)可以直接使用 Go 标准库提供的两个基本错误值构造方法errors.New和fmt.Errorf构造错误值。这样构造出的错误值对错误处理方是不透明的,因此这种策略被称为 “不透明错误处理策略”。
func doSomething(...) error {... ...return errors.New("some error occurred")}
3. “哨兵”错误处理策略
当错误处理方不能仅根据“不透明的错误值”就做出错误处理路径的选取的情况下,错误处理方会尝试对返回的错误值进行检视,于是就有可能出现下面的反模式:
data, err := b.Peek(1)if err != nil {switch err.Error() {case "bufio: negative count":// ... ...returncase "bufio: buffer full":// ... ...returncase "bufio: invalid use of UnreadByte":// ... ...returndefault:// ... ...return}}
- 错误处理方以不透明错误值所能提供的唯一上下文信息作为错误处理路径选择的依据,这种“反模式”会造成严重的隐式耦合:错误值构造方不经意间的一次错误描述字符串的改动,都会造成错误处理方处理行为的变化,
- 并且这种通过字符串比较的方式对错误值进行检视的性能也很差。
Go 标准库采用了定义导出的(exported)“哨兵”错误值的方式来辅助错误处理方检视(inspect)错误值并做出错误处理分支的决策:
// $GOROOT/src/bufio/bufio.govar (ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")ErrBufferFull = errors.New("bufio: buffer full")ErrNegativeCount = errors.New("bufio: negative count"))// 我们的错误处理代码data, err := b.Peek(1)if err != nil {switch err {case bufio.ErrNegativeCount:// ... ...returncase bufio.ErrBufferFull:// ... ...returncase bufio.ErrInvalidUnreadByte:// ... ...returndefault:// ... ...return}}或者:if err := doSomething(); err == bufio.ErrBufferFull {// 处理缓冲区满的错误情况... ...}
- 一般“哨兵”错误值变量以 ErrXXX 格式命名。
- 和不透明错误策略相比,“哨兵”策略让错误处理方在有检视错误值的需求时候可以“有的放矢”了。
- 不过对于 API 的开发者而言,暴露“哨兵”错误值也意味着这些错误值和包的公共函数/方法一起成为了 API 的一部分。一旦发布出去,开发者就要对其进行很好的维护。
- 而“哨兵”错误值也让使用这些值的错误处理方对其产生了依赖。
从 Go 1.13 版本开始,标准库 errors 包提供了Is方法用于错误处理方对错误值进行检视。Is方法类似于将一个 error 类型变量与“哨兵”错误值的比较:
// 类似 if err == ErrOutOfBounds{ … }if errors.Is(err, ErrOutOfBounds) {// 越界的错误处理}
不同的是如果 error 类型变量的底层错误值是一个包装错误(wrap error),errors.Is方法会沿着该包装错误所在错误链(error chain),与链上所有被包装的错误(wrapped error)进行比较,直至找到一个匹配的错误。下面是Is函数应用的一个例子:
// go-error-handling-strategy-1.gopackage mainimport ("errors""fmt")var ErrSentinel = errors.New("the underlying sentinel error")func main() {err1 := fmt.Errorf("wrap err1: %w", ErrSentinel)err2 := fmt.Errorf("wrap err2: %w", err1)if errors.Is(err2, ErrSentinel) {println("err is ErrSentinel")return}println("err is not ErrSentinel")}
运行上述代码:
$go run go-error-handling-strategy-1.goerr is ErrSentinel
4. 错误值类型检视策略
我们需要通过自定义错误类型的构造错误值的方式来提供更多的“错误上下文”信息,并且由于错误值均通过 error 接口变量统一呈现,要得到底层错误类型携带的错误上下文信息,错误处理方需要使用 Go 提供的类型断言机制(type assertion)或类型选择机制(type switch),这种错误处理我称之为错误值类型检视策略。我们来看一个标准库中的例子:
json 包中自定义了一个UnmarshalTypeError的错误类型:
// $GOROOT/src/encoding/json/decode.gotype UnmarshalTypeError struct {Value string // description of JSON value - "bool", "array", "number -5"Type reflect.Type // type of Go value it could not be assigned toOffset int64 // error occurred after reading Offset bytesStruct string // name of the struct type containing the fieldField string // the full path from root node to the field}
错误处理方可以通过错误类型检视策略获得更多错误值的错误上下文信息:
// $GOROOT/src/encoding/json/decode_test.go// 通过类型断言机制获取func TestUnmarshalTypeError(t *testing.T) {for _, item := range decodeTypeErrorTests {err := Unmarshal([]byte(item.src), item.dest)if _, ok := err.(*UnmarshalTypeError); !ok {t.Errorf("expected type error for Unmarshal(%q, type %T): got %T",item.src, item.dest, err)}}}// $GOROOT/src/encoding/json/decode.go// 通过类型选择机制获取func (d *decodeState) addErrorContext(err error) error {if d.errorContext.Struct != nil || len(d.errorContext.FieldStack) > 0 {switch err := err.(type) {case *UnmarshalTypeError:err.Struct = d.errorContext.Struct.Name()err.Field = strings.Join(d.errorContext.FieldStack, ".")return err}}return err}
从 Go 1.13 版本开始,标准库 errors 包提供了As方法用于错误处理方对错误值进行检视。As方法类似于通过类型断言判断一个 error 类型变量是否为特定的自定义错误类型:
// 类似 if e, ok := err.(*MyError); ok { … }var e *MyErrorif errors.As(err, &e) {// 如果err类型为*MyError,变量e将被设置为对应的错误值}
不同的是如果 error 类型变量的底层错误值是一个包装错误(wrap error),errors.As方法会沿着该包装错误所在错误链(error chain),与链上所有被包装的错误(wrapped error)的类型进行比较,直至找到一个匹配的错误类型。下面是As函数应用的一个例子:
go-error-handling-strategy-2.gopackage mainimport ("errors""fmt")type MyError struct {e string}func (e *MyError) Error() string {return e.e}func main() {var err = &MyError{"my error type"}err1 := fmt.Errorf("wrap err1: %w", err)err2 := fmt.Errorf("wrap err2: %w", err1)var e *MyErrorif errors.As(err2, &e) {println("err is a variable of MyError type ")println(e == err)return}println("err is not a variable of the MyError type ")}
运行上述代码:
$go run go-error-handling-strategy-2.goerr is the MyError typetrue
5. 错误行为特征检视策略
到这里,我们需要思考一个问题:除了“不透明错误处理策略”,我们是否还有手段可以降低错误处理方与错误值构造方的耦合?在 Go 标准库中,我们发现了这样一种错误处理方式:将某个包中的错误类型归类,统一提取出一些公共的错误行为特征(behaviour),并将这些错误行为特征放入一个公开的接口类型中。以标准库中的net包为例,它将包内的所有错误类型的公共行为特征抽象并放入net.Error这个接口中。而错误处理方仅需依赖这个公共接口即可检视具体错误值的错误行为特征信息,并根据这些信息做出后续错误处理分支选择的决策:
// $GOROOT/src/net/net.gotype Error interface {errorTimeout() bool // 是超时类(timeout)错误吗?Temporary() bool // 是临时性(temporary)错误吗?}
下面是 http 包使用错误行为特征检视策略进行错误处理的代码:
// $GOROOT/src/net/http/server.gofunc (srv *Server) Serve(l net.Listener) error {... ...for {rw, e := l.Accept()if e != nil {select {case <-srv.getDoneChan():return ErrServerCloseddefault:}if ne, ok := e.(net.Error); ok && ne.Temporary() {// 注:这里对临时性(temporary)错误进行处理... ...time.Sleep(tempDelay)continue}return e}...}... ...}
如何实现 Listener 优雅关闭?
Accept 方法实际上返回的错误类型为*OpError,它是 net 包中的一个自定义错误类型,它实现了错误公共特征接口net.Error,因此可以被错误处理方通过net.Error接口的方法判断其行为是否满足 Temporary 或 Timeout 特征:
// $GOROOT/src/net/net.gotype OpError struct {... ...// Err is the error that occurred during the operation.Err error}type temporary interface {Temporary() bool}func (e *OpError) Temporary() bool {if ne, ok := e.Err.(*os.SyscallError); ok {t, ok := ne.Err.(temporary)return ok && t.Temporary()}t, ok := e.Err.(temporary)return ok && t.Temporary()}
6. 小结
Go 社区关于如何进行错误处理的讨论有很多,但唯一正确的结论就是没有某种单一的错误处理策略可以适合所有项目或所有场合。综合上述的构造错误值方法以及错误处理策略,请记住如下几点:
- 请尽量使用“不透明错误”处理策略降低错误处理方与错误值构造方之间的耦合;
- 如果可以通过错误值类型的特征进行错误检视,那么请尽量使用“错误行为特征检视策略”;
- 在上述两种策略无法实施的情况下,再“哨兵”策略和“错误值类型检视”策略;
- Go 1.13 及后续版本中,尽量用errors.Is和errors.As方法替换原先的错误检视比较语句。
