1

错误处理是任何编程语言都绕不开的话题。一直以来,编程语言的错误处理机制有两大流派:基于异常的结构化 try-catch-finally 处理机制和基于值的处理机制。前者的成员包括 C++、Java、Python、PHP 等主流编程语言,后者的代表则是 C 语言。Go 的设计追求简单,采用的是后一种处理机制:错误就是值,而错误处理就是基于值比较后的决策。

认识 error

在 Go 语言中,错误是值,不过是一个接口值,也即我们平时常用的 error:

// $GOROOT/src/builtin/builtin.go
type interface error {
    Error() string
}                        

error 接口很简单,只声明了一个 Error() 方法。在标准库中提供了构造错误值的两种基本方法:errors.New() 和 fmt.Errorf(),在 Go 1.13 版本之前,这两种方法实际上返回的是一个未导出类型 errors.errorString:

// $GOROOT/src/errors/errors.go
func New(text string) error {
    return &errorString{text}
}

type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

// $GOROOT/src/fmt/errors.go
// 1.13 版本之前
func Errorf(format string, a ...interface{}) error {
    return errors.New(Sprintf(format, a...))
}                       

fmt.Errorf() 适用于需要格式化输出字符串的场景,如果不需要格式化字符串,则建议使用 errors.New()。因为 fmt.Errof() 在生成格式化字符串时需要遍历所有字符,会有一定的性能损失。

错误处理基本策略

了解了错误值后,我们来看一下 Go 语言错误处理的几种惯用策略。

透明策略

透明处理策略是最简单的策略,它完全不关心返回错误值携带的具体上下文信息,只要发生错误就进入唯一的错误处理执行路径。这也是 Go 语言中最常见的错误处理策略,绝大部分的错误处理情形可以归类到这种策略下。

err := doSomething()
if err != nil {
    // 不关心err变量底层错误值所携带的具体上下文信息
    // 执行简单错误处理逻辑并返回
    ...
    return err
}                        

“哨兵”处理策略

“哨兵”策略通过特定值来表示成功和不同的错误,依靠调用方对错误进行检查来处理错误。如果采用这种处理策略,错误值构造方通常会定义一系列导出的“哨兵”错误值,用来辅助错误处理方检视错误值并做出错误处理分支的决策。

// $GOROOT/src/bufio/bufio.go
var (
    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:
        // ...
        return
    case bufio.ErrBufferFull:
        // ...
        return
    case bufio.ErrInvalidUnreadByte:
        // ...
        return
    default:
        // ...
        return
    }
}            

与透明错误策略相比,“哨兵”策略让错误处理方可以更灵活地处理错误。不过对于包的开发者而言,暴露“哨兵”错误值意味着这些错误值和包的公共函数一起成为包的一部分,会让错误处理方对其产生依赖。

类型检视策略

类型检视策略又被称为自定义错误策略,顾名思义,这种错误处理方式通过自定义的错误类型来表示特定的错误,同样依赖上层代码对错误值进行检查,不同的是需要使用类型断言机制(type assertion)或类型选择机制(type switch)对错误进行检查。

来看一个标准库的例子:

// $GOROOT/src/encoding/json/decode.go
type UnmarshalTypeError struct {
    Value  string       
    Type   reflect.Type 
    Offset int64        
    Struct string      
    Field  string       
}
            
// $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
}                                    

这种错误处理的好处在于可以将错误包装起来,提供更多的上下文信息,但实现方必须向上层公开实现的错误类型,与使用方之间同样需要产生依赖关系。

链式 error

在 1.13 版本之前,使用上述“哨兵”和类型检视策略带来的最大的一个问题是 error 经过函数或方法进行自定义处理后,原始的 error 会被丢弃。

// example 1
func main() {
    err := WriteFile("")
    if err == os.ErrPermission {
        fmt.Println("permission denied")
    }
}

func WriteFile(filename string) error {
    if filename == "" {
        return fmt.Errorf("write file error: %v", os.ErrPermission)
    }
    return nil
}

// example 2
func main() {
    err := WriteFile("")
    if _, ok := err.(*os.PathError); ok {
        fmt.Println("permission denied")
    }
}

func WriteFile(filename string) error {
    if filename == "" {
        return fmt.Errorf("write file error: %v", &os.PathError{})
    }
    return nil
}

通过上面两个示例我们可以看到,原始的 error 经过函数的自定义包装后,它的值或者类型就可能被“淹没”了,使用方不能很容易地获取到它,给错误处理带来了不必要的麻烦。

为了解决这个问题,Go 1.13 版本中引入了一套称为链式 error 的解决方案,error 在函数间传递时信息并不会丢失,而是像链条一样被串连起来。

wrapError

wrapError 是链式 error 的核心数据结构,其他相关优化都是围绕它展开的:

type wrapError struct {
    msg string
    err error
}

func (e *wrapError) Error() string {
    return e.msg
}

func (e *wrapError) Unwrap() error {
    return e.err
}

wrapError 与传统的 errorString 相比,额外实现了 Unwrap 方法,用于返回原始 error。

生成链式 error

在 Go 1.13 版本之后,我们可以使用 fmt.Errorf 函数配合格式动词 %w 来生成链式 error,源码如下:

func Errorf(format string, a ...interface{}) error {
    p := newPrinter()
    p.wrapErrs = true
    // 解析格式,如果发现格式动词 %w 且提供了合法的 error 参数,则把 p.wrappedErr 置为 error 
    p.doPrintf(format, a)
    s := string(p.buf)
    var err error
    if p.wrappedErr == nil {
        // 一般情况下生成 errorString
        err = errors.New(s)
    } else {
        // 存在 %w 动词生成 wrapError
        err = &wrapError{s, p.wrappedErr}
    }
    p.free()
    return err
}

生成 wrapError 有两点需要记住:

  • 每次只能使用一次 %w 动词;
  • %w 动词只能匹配实现 error 接口的参数。

errors.Is

errors 包提供了 Is 方法用于错误处理方对错误值进行比较,Is 支持错误在包装过多层后的等值判断。

func Is(err, target error) bool {
    if target == nil {
        return err == target
    }

    isComparable := reflectlite.TypeOf(target).Comparable()
    for {
        // 如果 target 是可比较的,则直接进行比较
        if isComparable && err == target {
            return true
        }
        // 如果 err 实现了 Is 方法,则调用该方法继续进行判断
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true
        }
        // 否则,对 err 进行 Unwrap(也即返回 wrapError 的 err 字段)
        if err = Unwrap(err); err == nil {
            return false
        }
    }
}

errors.As

As 方法类似于通过类型断言判断一个 error 类型变量是否为特定的自定义错误类型。不同的是,如果 error 类型变量的底层错误值是一个链式 error,那么 As 方法会沿着错误链进行类型比较,直至找到一个匹配的错误类型。

func As(err error, target interface{}) bool {
    if target == nil {
        panic("errors: target cannot be nil")
    }
    // 通过反射获取 target 的值和类型,并进行相关判断
    val := reflectlite.ValueOf(target)
    typ := val.Type()
    if typ.Kind() != reflectlite.Ptr || val.IsNil() {
        panic("errors: target must be a non-nil pointer")
    }
    targetType := typ.Elem()
    if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) {
        panic("errors: *target must be interface or implement error")
    }
    for err != nil {
        // 如果 err 的类型与 target 匹配,直接赋值给 target
        if reflectlite.TypeOf(err).AssignableTo(targetType) {
            val.Elem().Set(reflectlite.ValueOf(err))
            return true
        }
        // 判断 err 是否实现 As 方法,若已实现则调用该方法进一步匹配
        if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
            return true
        }
        // 否则,对 err 进行 Unwrap
        err = Unwrap(err)
    }
    return false
}

错误处理建议

关于错误处理的讨论有很多,但没有哪一种错误处理策略适用于所有项目或场合。综合上述的构造错误值方法及错误处理策略,有以下几点建议:

  • 优先使用透明错误处理策略,降低错误处理方与错误值构造方之间的耦合;
  • 其次尽量使用类型检视策略;
  • 在上述两种策略无法实施的情况下,再用“哨兵”策略;
  • 在 Go 1.13 及后续版本中,尽量用 errors.Is 和 errors.As 方法替换原先的错误处理语句。

优化 if err != nil

因为 Go 语言的错误处理机制,会在代码中产生大量的 if err != nil,十分繁琐且不美观,这也是 Go 语言经常被其他主流语言开发者吐槽的地方。那么有什么办法可以优化?

首先能想到的就是视觉上的优化,将多个判断语句放置在一起,但这种方法也只不过是“表面功夫”,而且有很大的局限性。

第二种就是模仿其他语言用 panic 和 recover 来模拟异常捕获来替换错误值判断,不过这是一种反模式,并不推荐使用。首先,错误是正常的编程逻辑,而异常是意料之外的错误,二者不能画等号,而且如果异常没有得到捕获将会导致整个进程退出,个别情况下后果很严重。还有一点,使用异常代替错误机制会大幅影响程序的运行速度。

在这里提供两种优化思路以供参考。

封装多个 error

这个方法就是将多个 if err != nil 语句封装到一个函数或方法中,这样外部调用的时候只需要额外判断一次就可以了。下面看一个例子:

func openBoth(src, dst string) (*os.File, *os.File, error) {
    var r, w *os.File
    var err error
    if r, err = os.Open(src); err != nil {
        return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    
    if w, err = os.Create(dst); err != nil {
        r.Close()
        return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    return r, w, nil
}

func CopyFile(src, dst string) error {
    var err error
    var r, w *os.File
    if r, w, err = openBoth(src, dst); err != nil {
        return err
    }
    defer func() {
        r.Close()
        w.Close()
        if err != nil {
            os.Remove(dst)
        }
    }()
    
    if _, err = io.Copy(w, r); err != nil {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    return nil
}                        

为了减少 CopyFile 函数中 if err != nil 的重复次数,以上代码引入了一个 openBoth 函数,我们将打开源文件、创建目的文件和相关的错误处理工作转移到了 openBoth 函数中。这种方法的优点是比较简单,缺点是效果有时并不显著。

内置 error

我们先粗略看一下 bufio 包的 Writer 实现:

// $GOROOT/src/bufio/bufio.go
type Writer struct {
    err error
    buf []byte
    n   int
    wr  io.Writer
}

func (b *Writer) WriteByte(c byte) error {
    if b.err != nil {
        return b.err
    }
    if b.Available() <= 0 && b.Flush() != nil {
        return b.err
    }
    b.buf[b.n] = c
    b.n++
    return nil
}                       

可以看到,Writer 定义了一个 err 字段作为内部错误状态值,它与 Writer 的实例绑定在了一起,并且在 WriteByte 方法的入口判断是否为 nil。一旦不为 nil,WriteByte 就直接返回内置的 err。我们来使用这种思路来重构一下前面例子中的代码:

type FileCopier struct {
    w   *os.File
    r   *os.File
    err error
}

func (f *FileCopier) open(path string) (*os.File, error) {
    if f.err != nil {
        return nil, f.err
    }
    
    h, err := os.Open(path)
    if err != nil {
        f.err = err
        return nil, err
    }
    return h, nil
}

func (f *FileCopier) openSrc(path string) {
    if f.err != nil {
        return
    }
    
    f.r, f.err = f.open(path)
    return
}

func (f *FileCopier) createDst(path string) {
    if f.err != nil {
        return
    }
    
    f.w, f.err = os.Create(path)
    return
}

func (f *FileCopier) copy() {
    if f.err != nil {
        return
    }
    
    if _, err := io.Copy(f.w, f.r); err != nil {
        f.err = err
    }
}

func (f *FileCopier) CopyFile(src, dst string) error {
    if f.err != nil {
        return f.err
    }
    
    defer func() {
        if f.r != nil {
            f.r.Close()
        }
        if f.w != nil {
            f.w.Close()
        }
        if f.err != nil {
            if f.w != nil {
                os.Remove(dst)
            }
        }
    }()
    
    f.openSrc(src)
    f.createDst(dst)
    f.copy()
    return f.err
}

func main() {
    var fc FileCopier
    err := fc.CopyFile("foo.txt", "bar.txt")
    if err != nil {
        fmt.Println("copy file error:", err)
        return
    }
    fmt.Println("copy file ok")
}                        

我们将原 CopyFile 函数彻底抛弃,将其逻辑封装到 FileCopier 结构的 CopyFile 方法中。FileCopier 结构内置了一个 err 字段用于保存内部的错误状态,这样在 CopyFile 方法中,我们只需按照正常业务逻辑,顺序执行 openSrc、createDst 和 copy 即可,正常业务逻辑的视觉连续性就这样被很好地实现了。


与昊
222 声望634 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道