优雅的处理错误

本文是对 Dave Cheney 博客的翻译, 这里是原文地址

错误只是值

我花了很多时间思考在 Go 程序中处理错误的最佳方式。我真的希望有一种单一的方式来处理错误,这样我们就可以像教授数学或字母一样,教授所有的 Go 程序员。

然而,我得出结论,没有一种单一的方式来处理错误。相反,我认为 Go 的错误处理可以归类为三种核心策略。

哨兵错误

第一种错误处理方式我称之为哨兵错误

1
if err == ErrSomething { … }

这个名字源自计算机编程中使用特定值来表示不可能进行更多处理的惯例。因此,在 Go 中,我们使用特定的值来表示错误。

例如,值如 io.EOF 或类似 syscall 包中的常量的低级错误,如 syscall.ENOENT

甚至有一些哨兵错误表示没有发生错误,比如 go/build.NoGoErrorpath/filepath.Walk 中的 path/filepath.SkipDir

使用哨兵值是最不灵活的错误处理策略,因为调用者必须使用相等运算符将结果与预声明的值进行比较。当您想提供更多上下文时,这会造成问题,因为返回不同的错误会破坏等式检。

即使像使用 fmt.Errorf 给错误添加一些上下文这样善意的操作也会使调用者的相等性测试失败。相反,调用者将被迫查看错误的 Error 方法的输出,以查看它是否与特定的字符串匹配。

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

顺便提一句,我认为您永远不应检查 error.Error 方法的输出。错误接口上的 Error 方法是为人类而存在的,而不是为代码而存在的。

该字符串的内容应放在日志文件中或显示在屏幕上。您不应尝试通过检查它来更改程序的行为。

我知道有时这是不可能的,正如有人在 Twitter 上指出的那样,这个建议不适用于编写测试。尽管如此,在我看来,比较错误的字符串形式是一种代码异味,您应该尽量避免这样做。

哨兵错误会成为您的公共 API 的一部分。

如果您的公共函数或方法返回特定值的错误,那么该值必须是公共的,并且当然需要进行文档化。这会增加您的 API 表面积。

如果您的 API 定义了一个接口,该接口返回特定错误,则该接口的所有实现都将受到限制,只能返回该错误,即使它们可以提供更详细的错误信息。

我们可以看到这一点在 io.Reader 上。像 io.Copy 这样的函数需要一个 reader 实现返回确切的 io.EOF,以向调用者表示没有更多数据,但这并不是一个错误。

哨兵错误会在两个包之间创建依赖关系。

迄今为止哨兵错误值的最大问题是它们在两个包之间创建了源代码依赖关系。例如,要检查错误是否等于 io.EOF,您的代码必须导入 io 包。

这个具体的例子听起来并不那么糟糕,因为这很常见,但想象一下当您的项目中的许多包导出错误值时,存在的耦合性,其他包必须导入它们来检查特定的错误条件。

我曾经在一个尝试使用这种模式的大型项目中工作过,我可以告诉您,不良设计(以导入循环的形式)的威胁一直存在于我们的思想中。

避免使用哨兵错误值

因此,我的建议是在您编写的代码中避免使用哨兵错误值。虽然标准库中有一些情况使用了它们,但这不是您应该效仿的模式。

如果有人要求您从包中导出错误值,您应该礼貌地拒绝,并建议另一种替代方法,例如我将在接下来讨论的方法。

错误类型

我想讨论的第二种错误处理模式是错误类型

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

错误类型是一个由你创建的实现了 Error 接口的类型。在下面这个例子中,MyError追踪文件和行,以及解释发生了什么的消息。

1
2
3
4
5
6
7
8
9
10
11
type MyError struct {
Msg string
File string
Line int
}

func (e *MyError) Error() string {
return fmt.Sprintf("%s:%d: %s”, e.File, e.Line, e.Msg)
}

return &MyError{"Something happened", “server.go", 42}

因为 MyError 错误是一个类型,调用者可以使用类型断言从错误中提取额外的上下文信息。

1
2
3
4
5
6
7
8
9
err := something()
switch err := err.(type) {
case nil:
// call succeeded, nothing to do
case *MyError:
fmt.Println(“error occurred on line:”, err.Line)
default:
// unknown error
}

相较于错误值, 错误类型的一个巨大提升是可以包装底层错误信息,从而提供更多的上下文。

os.PathError 类型是一个很好的例子,它使用操作和文件来注释底层错误。

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

func (e *PathError) Error() string

错误类型的问题

由于调用者需要使用类型断言,因此错误类型也必须变为公共的。

如果您的代码实现了一个接口,该接口的契约要求特定的错误类型,那么该接口的所有实现者都需要依赖于定义该错误类型的包。

对一个包类型的这种熟悉程度会在调用者和接口实现者之间产生强耦合,从而使 API 易于受损。

避免使用错误类型

虽然错误类型比哨兵错误值更好,因为它们可以捕获更多关于出错原因的上下文信息,但错误类型与错误值共享许多问题。

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

不透明错误

现在我们来到了第三种错误处理方式。在我看来,这是最灵活的错误处理策略,因为它需要您的代码与调用者之间最少的耦合。

我称之为不透明的错误处理方式,因为虽然您知道发生了错误,但您无法查看错误的内部。作为调用者,您只知道操作的结果是成功还是失败。

这就是不透明的错误处理方式的全部内容——只返回错误,而不对其内容做任何假设。如果您采用这种立场,那么错误处理就可以成为一种重要的调试辅助工具。

1
2
3
4
5
6
7
8
9
import “github.com/quux/bar”

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

例如,Foo 的声明不保证在错误的情况下会返回什么内容。现在 Foo 的作者可以在传递错误时添加额外的上下文信息,而不会违反其与调用者的约定。

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

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

例如,与您的进程外部世界的交互,如网络活动,要求调用者调查错误的性质,以决定是否有合理的重试操作。

在这种情况下,我们断言错误实现了某种特定的行为,而不是某种错误类型或者错误值。考虑下面这个例子

1
2
3
4
5
6
7
8
9
type temporary interface {
Temporary() bool
}

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

我们可以传入任何错误给 IsTemporary去判断该错误是否可以重试。

如果一个错误实现了 temporary 接口,或许调用者就可以在 IsTemporary 返回 true 时重试操作了。

关键在于,可以在不导入定义错误的包或实际上不知道 err 的底层类型的情况下实现此逻辑——我们只关心它的行为。

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

这让我想起了第二个我想要谈论的 Go 谚语:“不要仅仅查找错误,要优雅地处理它们”。你能否提出以下代码片段中可能出现的问题?

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

一个明显的建议是把函数的处理逻辑替换为下面的代码

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

但这是每个人都应该在代码审查中捕捉到的简单问题。更根本的问题是我无法确定原始错误来自哪里。

如果 authenticate 返回一个错误,那么 AuthenticateRequest 将把错误返回给调用者,调用者可能会执行相同的操作,以此类推。在程序的顶部,程序的主体将把错误打印到屏幕或日志文件上,而打印的只是:“No such file or directory”。

没有提供生成错误的文件和行的信息。没有呈现引导到错误的调用栈的堆栈跟踪。代码的作者将被迫花费很长时间,对他们的代码进行二分查找,以发现哪个代码路径触发了“找不到文件”错误。

Donovan和Kernighan的《Go程序设计语言》建议您使用fmt.Errorf为错误路径添加上下文信息。

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

这种方法会将原本的错误包转,并增加更多的上下问信息返回给上层调用者。

1
2
3
4
5
6
7
8
9
func IsTemporary(err error) bool {
for _, err := range x.Unwrap() {
te, ok := x.(temporary)
if ok && te.Temporary() {
return true
}
}
return false
}

只处理错误一次

最后,我想提醒一下,您应该只处理一次错误。处理错误意味着检查错误值并做出决策。

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

如果您做出的决策少于一个,那么您正在忽略该错误。正如我们在这里看到的,来自w.Write的错误被丢弃了。

但是,对单个错误做出多个决策也是有问题的。

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

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

在这个例子中,如果在Write期间出现错误,将会写入一行日志文件,记录发生错误的文件和行,并将错误返回给调用者,调用者可能会将其记录并返回,一直返回到程序的顶部。

因此,您会在日志文件中获得一堆重复的行,但在程序的顶部,您会得到原始错误而没有任何上下文。Java呢?

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

总结

总之,错误是您软件包的公共API的一部分,应像对待公共API的其他部分一样小心。

为了获得最大的灵活性,我建议您尝试将所有错误视为不透明的。在无法这样做的情况下,对行为而非类型或值进行错误断言。

在程序中尽量减少哨兵错误值的数量,并在它们发生时使用 fmt.Errorf() 将错误转换为不透明的错误。

最后,如果需要检查它,请使用 errors.Unwrap 恢复底层错误。


优雅的处理错误
https://blog.zhangliangliang.cc/post/handle-error-gracefully.html
作者
Bobby Zhang
发布于
2023年4月3日
许可协议