10. 错误

未匹配的标注

10.1 接口 error 是什么

C 语言中常常返回整数错误码(errno)来表示函数处理出错,通常用 -1 表示错误,用 0 表示正确。在 Go 中,使用 error 类型表示错误,不过它不再是一个整数类型,而是一个接口类型。

type error interface {
    Error() string
}

它表示哪些能用一个字符串就能说清的错误。最常用的就是 errors.New() 函数,非常简单:

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

使用 New 函数创建出来的 error 类型实际上是 errors 包里未导出的 errorString 类型,它包含唯一的一个字段 s ,并且实现了唯一的方法:Error()string

通常这就够了,他能反映出当时“出错了”,但是有些时候需要更加具体的信息,例如:

func Sqrt(f float64)(float64, error) {
    if f < 0 {
        return 0, errors.New("math:square root of negative number")
    }
    // implementation
}

当调用者发现出现错误的时候,只知道传入了一个负数进来,并不清楚到底传的是什么值。在 Go 里:

It is the error implemetation's responsibility to summarize the context.

即要求返回这个错误的函数要给出具体的“上下文”信息。也就是说,在 Sqrt 函数里,要给出这个负数到底是什么。

所以,如果发现 f<0 ,应该这样返回错误:

if f <0 {
    return 0, fmt.Errorf("math: square root of negative number %g", f)
}

这就用到了 fmt.Errorf 函数,它先将字符串格式化,再调用 errors.New 函数来创建错误。当想知道错误类型,并且打印错误信息的时候,直接打印 error :

fmt.Println(err)

或者:

fmt.Println(err.Error)

fmt 包会自动调用 err.Error() 函数来打印字符串。

通常,将 error 放到函数返回值的最后一个,这是约定俗成的做法; 另外,构造 error 的时候, 要求传入的字符串首字母小写,结尾不带标点符号,这是因为人们经常会这样使用返回的 error :

... err := errors.New("error example")
fmt.Printf("The returned error is %s.\n", err)

10.2 接口 error 有什么问题

Go 中,错误处理是非常重要的。它从语言层面要求人们需要明确地处理遇到的错误。而不是像其他语言,例如 Java ,使用 try-catch-finally 这种“把戏”。

但坏处也是显而易见的,Go 代码里“error”满天飞,显得非常冗长拖沓,并且容易掩盖正常的逻辑。

而为了代码健壮性考虑,对于函数返回的每一个错误,都不能忽略。因为出错的同时,很可能会返回一个 nil 类型的对象。如果不对错误进行判断,那之后对 nil 对象的操作立马会引发 panic

于是,Go 语言中诟病最多的就是它的错误处理方式似乎回到了上古 C 语言时代:

err := doStuff1() 
if err != nil {
    // handle error... 
}

err = doStuff2() 
if err != nil {
    // handle error... 
}
err = doStuff3() 
if err != nil {
    // handle error... }

Go authors 之一的 Russ Cox 对于这种观点进行过驳斥:当初选择返回值这种错误处理机制而不是 try-catch,主要是考虑前者适用于大型软件,后者更适合小程序。

Go 语言官网的 FAQ 里也提到,try-catch 会让代码变得非常混乱,程序员会倾向将一些常见的错误,例如: failing to open a file,也抛到异常里,这会让错误处理更加冗长烦琐且易出错。

Go 语言的多返回值使得返回错误异常简单。一般的错误使用 error,对于真正的异常,Go 提供 panic-recover 机制,这样的处理比较 Java 那种将错误异常“一锅端”的做法更有优势,也使得代码看起来更简洁。

当然 Russ Cox 也承认 Go 的错误处理机制对于开发人员的确有一定的心智负担。
Go 社区曾经出现过用“check & handle”关键字和“try 内置函数”改进错误处理流程的提案,目前这两种提案已经被官方拒绝,目前还没有更好的后续改进方案。

10.3 如何理解关于 error 的三句谚语

Go 语言有很多“箴言”,说得很顺口,但理解起来并不是太容易,因为它们大部分都是有故事的。例如下面这些,如图所示。
谚语
本节讲三条关于 error 的“箴言”,重要的是要理解这些“箴言”背后的道理。

10.3.1 视错误为值

第一句箴言,视错误为值(Errors are just values),实际意思是只要实现了 Error 接口的类型都可以认为是 Error。处理 error 的方式分为三种:

  1. Sentinel errors.
  2. Error Types.
  3. Opaque errors.

首先 Sentinel errorsSentinel 来自计算机中常用的词汇,中文意思是“哨兵”。Sentinel errors 实际想说的是这里有一个错误,处理流程不能再进行下去了,必须要在这里停下。而这些错误,往往是提前约定好的。

例如,io 包里的 io.EOF,表示“文件结束”错误:

func main() {
    r := bytes.NewReader([]byte("0123456789"))

    _, err := r.Read(make([]byte, 10)) 
    if err == io.EOF {
        log.Fatal("read failed:", err) 
    }
}

但是这种方式处理起来,不太灵活;必须要判断 err 是否和约定好的错误 io.EOF 相等。 再来一个例子,如果想返回 err 并且加上一些上下文信息时,就麻烦了:

func main() {
    err := readfile(.bashrc”)
    if strings.Contains(error.Error(), "not found") {
        // handle error 
    }
}

func readfile(path string) error { 
    err := openfile(path) 
    if err != nil {
        return fmt.Errorf("cannot open file %s: %v", path, err) 
    }
    //...... 
}

如果在 readfile 函数里判断 err 不为空,就用 fmt.Errorferr 前加上具体的 file 信息,返回给调用者,返回的 err 其实还是一个字符串。

造成的后果是,调用者不得不用字符串匹配的方式判断底层函数 readfile 是否出现了某种错误。然而,当必须要这样才能判断某种错误时,代码的“坏味道”就出现了。

其实,err.Error() 方法是给程序员而非代码设计的,也就是说当调用 Error 方法时,结果要写到文件或是打印出来,是给程序员看的。在代码里,不要根据 err.Error() 来做一些判断,就像上面的 main 函数里所做的那样,这是代码的“坏味道”。

Sentinel errors 最大的问题在于它在定义 error 和使用 error 的包之间建立了依赖关系。比如要想判断 err == io.EOF 就得引入 io 包,当然这是标准库的包,还能接受。但如果很多用户自定义的 包都定义了错误,那就要引入很多包,来判断各种错误,容易引起循环引用的问题。

因此,应该尽量避免 Sentinel errors,尽管标准库中有一些包这样用,但建议还是别模仿。

第二种就是 Error Types,它指的是实现了 error 接口的那些类型。它的一个重要的好处是,类型中除了 error 外,还可以附带其他字段,从而提供额外的信息,例如出错的行数等。

标准库有一个非常好的例子:

// src/os/error.go

// PathError records an error and the operation and file path that caused it. 
type PathError struct {
    Op string 
    Path string 
    Err error
}

PathError 额外记录了出错时的文件路径和操作类型。通常,使用这样的 error 类型,外层调用者需要使用类型断言来判断错误:

// underlyingError returns the underlying error for known os error types.
func underlyingError(err error) error { 
    switch err := err.(type) {
    case *PathError:
        return err.Err 
    case *LinkError:
        return err.Err 
    case *SyscallError:
        return err.Err 
    }
    return err 
}

但是这又不可避免地在定义错误和使用错误的包之间形成依赖关系,就又回到了前面的问题。

即使 Error typesSentinel errors 好一些,因为它能承载更多的上下文信息,但它仍然存在引入包依赖的问题。因此,也是不推荐的。至少,不要把 Error types 作为一个导出类型。

最后一种,Opaque errors,也就是“黑盒 errors”:能知道错误发生了,但是无法看到它内部到底是什么,不知道它的具体类型是什么。

例如下面这段伪代码:

func fn() error {
    x, err := bar.Foo()
    if err != nil { 
        return err
    }
    //usex
    return nil 
}

作为调用者,调用完 Foo 函数后,只用知道 Foo 是正常工作还是出了问题。也就是说只需要判断 err 是否为空,如果不为空,就直接返回错误;否则,继续后面的正常流程,不需要知道 err 到底是什么。

这就是处理 Opaque errors 这种类型错误的策略:一旦出错,直接返回错误;否则,继续后面的流程。

当然,在某些情况下,这样做并不够用。例如,在一个网络请求中,需要调用者判断返回的错误类型,以此来决定是否重试。这种情况下不要去判断错误的类型到底是什么,而是去判断错误是否具有某种行为,或者说实现了某个接口。

来看个例子:

type temporary interface { 
    Temporary() bool
}

func IsTemporary(err error) bool { 
    te, ok := err.(temporary) 
    return ok && te.Temporary()
}

拿到网络请求返回的 error 后,调用 IsTemporary 函数,如果返回 true,那就重试。

这么做的好处是在进行网络请求的包里,不需要 import 引用定义错误的包,并且不需要知道 error 的具体类型,只需要判断它的行为。这也类似设计模式中的一个原则:面向接口编程,而不是面向对象编程。

10.3.2 检查并优雅地处理错误

第二句箴言:检查并优雅的处理错误(Don’t just check errors, handle them gracefully),即不要仅检查错误,更要优雅地处理它们。

func AuthenticateRequest(r *Request) error { 
    err := authenticate(r.User) 
    if err != nil {
        return err 
    }
    return nil 
}

上面这个例子中的代码有很多冗余,不够简洁,直接优化成一行就可以了:

func AuthenticateRequest(r *Request) error { 
    return authenticate(r.User)
}

还有其他的问题,在函数调用链的最顶层,得到的错误可能是: No such file or directory。 这个错误反馈的信息太少了,不知道文件名、路径、行号等。尝试改进一下,增加一些上下文:

func AuthenticateRequest(r *Request) error { 
    err := authenticate(r.User) 
    if err != nil {
        return fmt.Errorf("authenticate failed: %v", err)
    }
    return nil 
}

这种做法实际上是先将错误转换成字符串,再拼接另一个字符串,最后,再通过 fmt.Errorf 转换成错误。这样做破坏了相等性检测,即人们无法判断错误是否是一种预先定义好的错误了。

Go 1.13 之前的应对方案是使用第三方库: github.com/pkg/errorsGo 1.13 后自带了 error 相关的高级函数,如 Wrap 等。不过要想输出错误堆栈,还是要使用前者。这里先看 pkg/errors,它提供了友好的接口:

// Wrap annotates cause with a message.
func Wrap(cause error, message string) error
// Cause unwraps an annotated error.
func Cause(err error) error

通过 Wrap 可以将一个错误,加上一个字符串,“包装”成一个新的错误; 通过 Cause 则可以进行相反的操作,将里层的错误还原。

有了这两个函数,就方便很多:

func ReadFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, errors.Wrap(err, "open failed") 
    }
    defer f.Close()
    buf, err := ioutil.ReadAll(f) 
    if err != nil {
        return nil, errors.Wrap(err, "read failed") 
    }
    return buf, nil 
}

这是一个读文件的函数,先尝试打开文件,如果出错,则返回一个附加上了“open failed”的错误信息; 之后,尝试读文件,如果出错,则返回一个附加上了“read failed”的错误。

当在外层调用 ReadFile 函数时:

func main() {
    _, err := ReadConfig() 
    if err != nil {
        fmt.Println(err)
        os.Exit(1) 
    }
}

func ReadConfig() ([]byte, error) {
    home := os.Getenv("HOME")
    config, err := ReadFile(filepath.Join(home, ".settings.xml")) 
    return config, errors.Wrap(err, "could not read config")
}

这样在 main 函数里就能输出这样一个错误信息:

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

它是有层次的,非常清晰。而如果用 fmt.Printf%+v 格式来输出:

func main() {
    _, err := ReadConfig() 
    if err !=nil {
        fmt.Printf("%+v", err)
        os.Exit(1) 
    }
}

能得到更有层次、更详细的错误堆栈:

open /Users/qcrao/.settings.xml: no such file or directory 
open failed
main.ReadFile 
    /Users/qcrao/go/src/hello/main.go:14
main.ReadConfig 
    /Users/qcrao/go/src/hello/main.go:27
main.main 
    /Users/qcrao/go/src/hello/main.go:32
runtime.main 
    /usr/local/go/src/runtime/proc.go:203
runtime.goexit 
    /usr/local/go/src/runtime/asm_amd64.s:1357
could not read config 
main.ReadConfig
    /Users/qcrao/go/src/hello/main.go:28 
main.main
    /Users/qcrao/go/src/hello/main.go:32 
runtime.main
    /usr/local/go/src/runtime/proc.go:203 
runtime.goexit
    /usr/local/go/src/runtime/asm_amd64.s:1357

上面讲的是 Wrap 函数,接下来看一下“Cause”函数,以前面提到的 temporary 接口为例:

type temporary interface { 
    Temporary() bool
}

// IsTemporary returns true if err is temporary. 
func IsTemporary(err error) bool {
    te, ok := errors.Cause(err).(temporary)
    return ok && te.Temporary() 
}

判断之前先使用 Cause 取出错误,做断言,最后递归地调用 Temporary 函数。如果错误没实现 temporary 接口,就会断言失败,返回 false

10.3.3 只处理错误一次

第三句箴言:只处理错误一次(Only handle error once)。什么叫“处理”错误:

Handling an error means inspecting the error value, and making a decision.

这句话的意思是检查了一下错误,并且做出一个决定。例如,如果不做任何决定,相当于忽略了错误:

func Write(w io.Writer, buf []byte) {
    w.Write(buf)
}

w.Write(buf) 会返回两个结果,一个表示写成功的字节数,一个是 error,上面的例子中没有对这两个返回值做任何处理。

下面这个例子却又处理了两次错误:

func Write(w io.Writer, buf []byte) error { 
    _, err := w.Write(buf)
    i ferr != nil {
        // annotated error goes to log file 
        log.Println("unable to write:", err)

        // unannotated error returned to caller 
        return err
    }
    return nil 
}

第一次处理是将错误写进了日志,第二次处理则是将错误返回给上层调用者。而调用者也可能将错误写进日志或是继续返回给上层。

这样一来,日志文件中会有很多重复的错误描述,并且在最上层调用者(如 main 函数)看来,它拿到的错误却还是最底层函数返回的 error,没有任何上下文信息。

10.4 错误处理的改进

Go 语言有一些失败的尝试,比如 Go 1.5 引入 vendorinternal 来管理包,最后被滥用而引发了很多问题; Go 1.13 直接抛弃了 GOPATHvendor 特性,改用 module 来管理包; Go 语言 之父之一 Robert Griesemer 提交的通过 try 内置函数来简化错误处理也被否决了。

2019 年 9 月 3 号,Go 发布 1.13 版本,除了将 module 特性转正之外,比较重要的还有将 defer 性能提升 30%,将更多的对象从堆上移动到栈上以提升性能等。

Go 1.13 还支持了 error 包裹(wrapping):

An error e can wrap another error w by providing an Unwrap method that returns w. Both e and w are available to programs,allowing e to provide additional context to w or to reinterpret it while still allowing programs to make decisions based on w.

为了支持 wrappingfmt.Errorf 增加了 %w 的格式,并且在 error 包增加了三个函数: errors.Unwraperrors.Iserrors.As

fmt.Errorf 使用 %w 格式符来生成一个嵌套的 error,它并没有像 pkg/errors 那样使用一个 Wrap 函数来嵌套 error,非常简洁。

Unwrap 将嵌套的 error 解析出来,多层嵌套需要调用 Unwrap 函数多次,才能获取最里层的 error:

func Unwrap(err error) error {
    // 判断是否实现了 Unwrap 方法 
    u, ok := err.(interface {
        Unwrap() error 
    })
    // 如果不是,返回 nil 
    if !ok {
        return nil 
    }
    // 调用 Unwrap 方法返回被嵌套的 error
    return u.Unwrap() 
}

err 进行断言,看它是否实现了 Unwrap 方法,如果是,调用它的 Unwrap 方法; 否则,返回 nil

Is 判断 err 是否和 target 是同一类型,或者 err 嵌套的 error 有没有和 target 是同一类型的,如果是,则返回 true:

func Is(err, target error) bool { 
    if target == nil {
        return err == target 
    }
    isComparable := reflectlite.TypeOf(target).Comparable()
    // 无限循环,比较 err 以及嵌套的 error 
    for {
        if isComparable && err == target { 
            return true
        }
        // 调用 error 的 Is 方法,这里可以自定义实现
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true 
        }
        // 返回被嵌套的下一层的 error 
        if err = Unwrap(err); err == nil {
            return false 
        }
    } 
}

通过一个无限循环,使用 Unwrap 不断地将 err 里层嵌套的 error 解开,再看被解开的 error 是否实现了 Is 方法,并且调用它的 Is 方法,当两者都返回 true 的时候,整个函数返回 true

Aserr 错误链里找到第一个和 target 相等的值并且设置 target 所指向的变量为 err

func As(err error, target interface{}) bool { 
    // target 不能为 nil
    if target == nil {
        panic("errors: target cannot be nil") 
    }
    val := reflectlite.ValueOf(target) 
    typ := val.Type()

    // target 必须是一个非空指针
    if typ.Kind() != reflectlite.Ptr || val.IsNil() {
        panic("errors: target must be a non-nil pointer") 
    }

    // 保证 target 是一个接口类型或者实现了 Error 接口
    if e := typ.Elem(); e.Kind() != reflectlite.Interface && !e.Implements(errorType) {
        panic("errors: *target must be interface or implement error") 
    }
    targetType := typ.Elem() 
    for err != nil{
        // 使用反射判断是否可被赋值,如果可以就赋值并且返回 true 
        if reflectlite.TypeOf(err).AssignableTo(targetType) {
            val.Elem().Set(reflectlite.ValueOf(err))
            return true 
        }
        // 调用 error 自定义的 As 方法,实现自己的类型断言代码
        if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
            return true 
        }
        // 不断地 Unwrap,一层层的获取嵌套的 error
        err = Unwrap(err) 
    }
    return false 
}

返回 true 的条件是错误链里的 err 能被赋值到 target 所指向的变量;或者 err 实现的 As(interface{}) bool 方法返回 true

前者,会将 err 赋给 target 所指向的变量;后者,由 As 函数来赋值。

如果 target 不是一个指向实现了 error 接口的类型或者其他接口类型的非空的指针的时候,函数会 panic

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~