Intro

这篇文章的主要内容都是源自于Link1,这是一篇在2016年发布的关于错误处理的内容,围绕着错误处理的一些理念和方法进行介绍。主要想解决的问题是,在尽量低耦合的情况下,为错误添加更丰富的上下文信息,定位错误的位置

注意:Go 1.13的官方包 errors 中已经对本文的许多内容做出了修补,如果只是想要了解如何进行错误处理,可以直接看Link2,不过本文仍然是一篇高质量的值得学习的文章。不过github.com/pkg/errors的堆栈内容可能出于别的考虑,尚未添加到Go的官方实现中

Go程序中的错误处理一直是个令人困扰的问题,不处理错误不行,处理错误往往会导致整页的 if err != nil 这样的语句(不过在这篇文章中,并没有解决这个问题的办法)。Link1的作者对于错误处理给出的结论是,没有单一的方法来处理错误,并提出了三个错误处理的核心策略。

策略一:哨兵错误 Sentinel errors


介绍

这个名字源于编程中使用特定值来表示无法进一步处理的做法,我们借鉴一下,使用特定值来表示错误。

  1. if err == ErrSomething { ... }

比如 io.EOF ,或者类似 syscall 包中的常量 syscall.ENOENT 这样的低级错误,都是使用哨兵错误策略的例子。

甚至还有用于表示没有发生错误的哨兵错误,比如 go/build.NoGoError ,以及 path/filepath.Walk 中的path/filepath.SkipDir 错误。

但是,哨兵错误是最不灵活的错误处理策略,因为调用者必须使用相等运算符将结果与预先声明的值进行比较。这会导致,当你想提供更多的背景时(比如使用 fmt.Sprintferrors.New 函数加入一点额外信息),会破坏原先对于错误的相等性检查。

即使是使用像 fmt.Errorf 函数为错误添加一点有意义的内容,也会破坏这个检查。相反,调用者将被迫查看错误的 Error 方法的输出,看它是否与一个特定的字符串相匹配。

永远不要检查error.Error的输出

多说一句(as an aside),我认为你永远不应该检查 error.Error 方法的输出。 error 接口的 Error 方法是给人看的,而不是给代码看的。

那个字符串的内容应该属于日志,或者显示在屏幕上,而不应该根据它的内容来影响你的程序的行为。

我知道这有时候并不可能,正如有些人所说,我的这个建议并不适用于编写测试。但是尽管如此,我仍然认为,这种操作是一种代码味道(code smell,大概是指一些不好的代码风格),你应该避免它。

使用哨兵错误作为公共API的一部分

如果你的公共函数/方法返回一个特定值的错误,那么这个值就必须是公开的,当然,也需要写到文档里面。这会增加你的API的暴露部分(表面积)。

如果你的API定义了一个返回特定错误的interface,那么这个interface的所有实现,都将被限制于只返回这个错误,即使他们原本可以提供一个描述更准确的错误。

我们可以在 io.Reader 中看到这一点,像 io.Copy 这样的函数都要求reader的实现必须返回 io.EOF 来向调用者发出没有数据的信号,但这并不是一个错误。

哨兵错误会导致两个包之间的依赖

到目前为止,哨兵错误最大的问题是:它在两个包之间产生了依赖性。举个例子,为了检查一个错误是否等于 io.EOF ,你必须导入 io 包。

这种例子看起来并不算坏,而且还挺常见的。但是想象一下,当你的项目中存在许多导出错误值的包,其他包导入这些错误值来检查特定的错误条件而带来的耦合现象。

我曾在一个大型项目中使用过这种模式,我可以告诉你,不良设计的幽灵——循环引用——一直藏在我们脑袋中。

结论:避免使用哨兵错误策略

所以,我建议避免使用这种策略。在标准库中,有少数情况会使用到它们,但这并不值得模仿。

如果有人要求你从你的包中导出一个错误值,那么你应该机智地采用其他方法,比如我接下来介绍的那些。

策略二:错误类型(error types)


介绍

这是我将讨论的第二种错误处理的方式:

  1. if err,ok := err.(SomeType); ok {...}

错误类型是你创建的实现错误接口的类型。在这个例子中, MyError 类型跟踪文件以及行数等信息,并解释发生了什么:

  1. type MyError struct {
  2. Msg string
  3. File string
  4. Line int
  5. }
  6. func (e *MyError) Error() string {
  7. return fmt.Sprintf("%s:%d: %s”, e.File, e.Line, e.Msg)
  8. }
  9. return &MyError{"Something happened", “server.go", 42}

由于 MyError 是一种类型, error 是一个接口,所以可以通过类型断言来得到额外的信息:

  1. err := something()
  2. switch err := err.(type) { // 通过断言得到nil,也挺有意思的
  3. case nil:
  4. // call succeeded, nothing to do
  5. case *MyError:
  6. fmt.Println(“error occurred on line:”, err.Line)
  7. default:
  8. // unknown error
  9. }

与使用错误值相比,错误类型的一大改进是他们能够包装底层错误以提供更多的上下文背景。

一个很好的例子是 os.PathError ,它用它试图执行的操作和它试图使用的文件来注释基础错误(现在是 fs.PathError

  1. // PathError records an error and the operation
  2. // and file path that caused it.
  3. type PathError struct {
  4. Op string
  5. Path string
  6. Err error // the cause
  7. }
  8. func (e *PathError) Error() string

错误类型的问题

调用者要使用类型断言或者类型转换,你必须将错误类型公开。

如果你的代码实现了一个接口,而这个接口的

这种方法需要对包的类型的深入了解,造成了包与调用者的耦合,使得API变得脆弱。

结论:避免使用错误类型

虽然由于可以捕获更多关于出错的上下文,错误类型比哨兵错误值要好,但它仍然有许多错误值的问题。

因此,我建议是避免使用错误类型,后者至少避免将其作为公共API的一部分。

策略三:不透明错误(Opaque errors)


介绍

现在来看第三种错误处理。在我看来,这是最灵活的错误处理策略,因为它要求的代码耦合度最小。

我把这种风格成为不透明的错误处理,因为虽然知道发生了错误,但你没有能力看到错误的内部。作为调用者,你所能知道的关于操作结果的所有信息就是:成功,或者失败了。

这就是不透明的错误处理的全部内容——只返回错误,而不对其内容做任何的假设。如果你采取了这个观点,那么错误处理作为一种调试辅助手段就会变得非常有用。

  1. import github.com/quux/bar
  2. func fn() error {
  3. x, err := bar.Foo()
  4. if err != nil {
  5. return err
  6. }
  7. // use x
  8. }

举个例子, Foo

为错误的行为断言,而不是类型

在少数的情况下,这种错误处理的二元方法是不够的。

举个例子,当你的程序在和进程之外的世界进行的交互,比如网络活动,需要调用者调查错误的性质,以决定操作是否合理。

在这种情况下,与其断言错误是一个特定的类型或值,我们可以断言错误实现了一个特定的行为。看看下面这个例子

  1. type temporary interface {
  2. Temporary() bool
  3. }
  4. // IsTemporary returns true if err is temporary.
  5. func IsTemporary(err error) bool {
  6. te, ok := err.(temporary)
  7. return ok && te.Temporary()
  8. }

我们可以将任何错误传递给 IsTemporary ,以确定该错误是否可以被重试。

如果错误没有实现 temporary 接口,也就是说它并没有 Temporary 方法,那么错误就不是临时的。
如果错误实现了 Temporary ,那么如果 Temporary 返回true,调用者也许可以重试操作。

这里的关键是,这个逻辑可以在不导入定义错误的包的情况下实现,也不需要知道 err 的底层类型,我们只对其行为感兴趣。

不要只是检查错误,要优雅地处理它——Wrap


这让我想到了我想说的第二句Go的谚语:Don’t just check errors, handle them gracefully。你能就下面这段代码提出问题吗?

  1. func AuthenticateRequest(r *Request) error {
  2. err := authenticate(r.User)
  3. if err != nil {
  4. return err
  5. }
  6. return nil
  7. }

一个很明显的建议是,这5行内容都可以直接换成

  1. return authenticate(r.User)

不过这是每个人都应该在review的时候应该注意到的地方。更为根本的问题是,无法判断原始的错误来自哪里。

如果 authenticate 返回一个错误,那么 AuthenticateRequest 将把这个错误直接返回给调用者,很有可能调用者也会直接返回给它的调用者。最终的结果很可能是,在程序的顶部,打印一个 No such file or directory 错误……

没有产生错误的文件和行的信息,没有导致错误的调用堆栈的堆栈跟踪。这段代码的作者将要花费很长的时间对代码进行剖析来寻找错误。

Donovan和Kernighan的《Go编程语言》建议使用 fmt.Errorf 为错误添加一些上下文:

  1. func AuthenticateRequest(r *Request) error {
  2. err := authenticate(r.User)
  3. if err != nil {
  4. return fmt.Errorf("authenticate failed: %v", err)
  5. }
  6. return nil
  7. }

但正如我们前面看到的,这种模式和前两种策略的使用不兼容,因为这个操作会破坏原始错误的任何上下文(error->string->拼接->error)。

注释错误

我推荐一种为错误添加上下文的方法,推荐一个package:github.com/pkg/errors。这个包有两个主要的函数:

  1. // Wrap annotates cause with a message.
  2. func Wrap(cause error, message string) error

Wrap 函数接收一个错误和一条消息,并返回一个新的错误

  1. // Cause unwraps an annotated error.
  2. func Cause(err error) error

Cause 函数接收一个可能被 Wrap 过的error,并解开他以恢复原始的错误

用这两个方法可以为任何错误进行注释,如果我们需要检查原始错误的话,也可以恢复。考虑一下这个将文件读入内存的例子:

  1. func ReadFile(path string) ([]byte, error) {
  2. f, err := os.Open(path)
  3. if err != nil {
  4. return nil, errors.Wrap(err, "open failed")
  5. }
  6. defer f.Close()
  7. buf, err := ioutil.ReadAll(f)
  8. if err != nil {
  9. return nil, errors.Wrap(err, "read failed")
  10. }
  11. return buf, nil
  12. }

我们用这个函数编写一个读取配置文件的函数,并在 main 中调用

  1. func ReadConfig() ([]byte, error) {
  2. home := os.Getenv("HOME")
  3. config, err := ReadFile(filepath.Join(home, ".settings.xml"))
  4. return config, errors.Wrap(err, "could not read config")
  5. }
  6. func main() {
  7. _, err := ReadConfig()
  8. if err != nil {
  9. fmt.Println(err)
  10. os.Exit(1)
  11. }
  12. }

如果在 ReadConfig 的时候发生错误,我们得到的错误将会是:

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

由于 errors.Wrap 会产生一个错误的栈,我们可以检查这个栈以获取其他调用信息。这是同一个例子,不过我们使用 errors.Print 代替了 fmt.Print

  1. func main() {
  2. _, err := ReadConfig()
  3. if err != nil {
  4. errors.Print(err)
  5. os.Exit(1)
  6. }
  7. }

我们会得到:

  1. readfile.go:27: could not read config
  2. readfile.go:14: open failed
  3. open /Users/dfc/.settings.xml: no such file or directory

第一行来自 ReadcConfig ,第二行来自 ReadFileos.Open ,其他的来自 os 包本身,不携带位置信息。

现在我们已经介绍了包装错误以生成堆栈的概念,接下来讨论相反的问题:将它们解包。这是 errors.Cause 函数的部分。

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

在操作中,需要先使用 errors.Cause 函数恢复原始错误(的类型和内容)。

只处理一次错误

最后,我想说一下,错误只要处理一次。处理错误意味着对错误进行检查,并作出决定。

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

如果你做的决定少于一个,就意味着你忽略了这个错误,就像上面这一段, w.Write 的错误被忽略了。

但如果多余一个,也会带来问题。就像下面这样,你可能会在日志文件中找到两行相同的内容。

  1. func Write(w io.Writer, buf []byte) error {
  2. _, err := w.Write(buf)
  3. if err != nil {
  4. // annotated error goes to log file
  5. log.Println("unable to write:", err)
  6. // unannotated error returned to caller
  7. return err
  8. }
  9. return nil
  10. }

所以,应该这么写:

  1. func Write(w io.Write, buf []byte) error {
  2. _, err := w.Write(buf)
  3. return errors.Wrap(err, "write failed")
  4. }

使用 errors 包,既方便机器(处理),也方便人(阅读)。

结论


总而言之,错误也是你的包的公共API的一部分,也要像对待公共API的其他部分一样谨慎。

为了获得最大的灵活性,我建议你尽量把其他所有的错误都当做不透明的。在你做不到这一点的时候,为行为而不是类型或者值进行断言。

在你的程序中,应该尽量减少哨兵错误值的数量,并在错误发生的时候,用errors.Wrap将其转换为不透明的错误。

最后,如果需要检查的话,使用erros.Cause来恢复原本的错误。

Link