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
发起讨论 只看当前版本


暂无话题~