大家好,我是煎鱼。
上一篇我在《如何对错误进行编程?》给大家分享了 Go 创始人对 Go 谚语之一 Errors are values 的诠释。
本篇仍然是错误专场,是 Go 谚语中的 Don't just check errors, handle them gracefully,原文章同名,作者是 @Dave Cheney。以下的 “我” 均指代原作者。
这条谚语和 Errors are value 关联性很强,是互解答的,和煎鱼一起学习吧!
错误只是值
我花了很多时间来思考处理 Go 程序中错误的最佳方法。我真的希望能有一种单一的方法来处理错误,那样我们就可以像教数学或字母表一样,通过背诵来教会所有的 Go 程序员。
最终我的结论是:没有单一的方法来处理错误。相反,我认为 Go 的错误处理可以归为三个核心策略。
哨兵错误
第一种错误处理的形式,我们常称之为哨兵错误(Sentinel errors)。
如下代码:
if err == ErrSomething { … }
这个名字来源于计算机编程中使用特定值来表示无法进一步处理的做法。所以在 Go 中,我们常使用特定的值来表示错误。
例子包括:io.EOF 这样的值,或者像 syscall 包中更底层的错误常量,如 syscall.ENOENT。
甚至还有一些标志着错误没有发生的哨兵错误,例如:
- go/build.NoGoError。
- path/filepath.Walk 中的 path/filepath.SkipDir。
使用哨兵值是最不灵活的错误处理策略,因为调用者必须使用等式运算符将结果与预先声明的值进行比较。当你想提供更多的背景时,这就出现了问题,因为返回不同的错误会破坏等式检查。
即使是像使用 fmt.Errorf 为错误添加一些上下文这样有意义的东西,也会破坏调用者的等式检验。相反,调用者将被迫查看错误的 Error 方法的输出,看它是否与一个特定的字符串相匹配。
不要检查 error.Error 的输出
作为一个旁观者,我认为你永远不应该检查 error.Error 方法的输出。错误接口上的 Error 方法是为人类存在的(意思是为人在阅读时的可读性),而不是为代码存在的。
那个字符串的内容属于日志文件,或者显示在屏幕上。你不应该试图通过检查它来改变你的程序的行为。
我知道有时这是不可能的,正如有人在 twitter 上指出的,这个建议并不适用于编写测试。
尽管如此,在我看来,比较错误的字符串形式是一种代码坏味道,你应该尽量避免它。
哨兵错误成为你的公共 API 的一部分
如果你的公共函数或方法返回一个特定值的错误,那么这个值必须是公开的,当然也需要在 API 文档中有所记录。
如果你的 API 定义了一个返回特定错误的接口,那么该接口的所有实现都应被限制在仅返回该错误,即使它们可以提供一个更具描述性的错误。
我们在 io.Reader 中可以看到这一点。像 io.Copy 这样的函数,需要读取器来实现准确地返回 io.EOF,以便向调用者发出没有数据的信号,但这并不是一个错误。
哨兵错误在两个包间建立了依赖
到目前为止,哨兵错误值最糟糕的问题是它们在两个包之间产生了源代码的依赖性。
举个例子:为了检查一个错误是否等于 io.EOF,你的代码必须导入 io 包。
这个具体的例子听起来并不坏,因为它很常见,但想象一下,当你项目中的许多包导出错误值,而你项目中的其他包必须导入这些错误值以检查特定的错误条件时,就会出现明显的耦合。
我曾在一个大型项目中使用过这种模式,我可以告诉你,不良设计的 “幽灵” -- 导入循环的形式 -- 从未远离过我们的头脑。
注:这个问题在 Go modules 下一个不小心就很明显,因为 grpc、grpc-gateway、etcd 常年就存在各种包版本的兼容性问题。一旦有依赖就会被动升级,然后应用就因为版本少了东西跑不起来了。
结论:避免哨兵错误
建议:在你写的代码中避免使用哨兵错误值。
虽然在标准库中,有少数情况下会使用它们,但这并不是你应该模仿的模式。
如果有人要求你从你的包中导出一个错误值,你应该礼貌地拒绝,并建议采用其他方法,比如我接下来要讨论的那些方式。
错误类型
第二种错误处理的形式,是错误类型(Error types)的方式。
如下代码:
if err, ok := err.(SomeType); ok { … }
错误类型指的是你创建的一个实现错误接口的类型。在这个例子中,MyError 类型的三个字段分别代表:文件、代码行以及信息。
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 错误是一个类型,调用者可以通过类型断言来从错误中提取额外的上下文。
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 类型,它将试图要执行的文件操作和文件路径都记录在类型里。
// 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 的一部分。
不透明的错误
现在我们来看看第三类错误处理。在我看来,本节讲的是最灵活的错误处理策略,因为它要求你的代码和调用者之间的耦合度最小。
我把这种风格称为不透明的错误处理(Opaque errors),因为虽然你知道发生了错误,但你没有能力看到错误的内部。作为调用者,你所知道的关于操作结果的所有信息是:它成功了,或者它没有成功。
这就是不透明的错误处理的全部内容 -- 只是返回错误,而不对其内容做任何假设。如果你采取这种立场,那么错误处理作为一种调试辅助手段就会变得非常有用。
如下代码:
import “github.com/quux/bar”
func fn() error {
x, err := bar.Foo()
if err != nil {
return err
}
// use x
}
例如:Foo 的契约没有保证它在错误的上下文中会返回什么。Foo 的作者现在可以自由地用额外的上下文来注释通过它的错误,而不破坏它与调用者的契约。
为行为而不是类型断言错误
在少数情况下,使用二分法(是否有错误)来进行错误处理是不够的。
例如:与你进程之外的交互,如网络活动,需要调用者查看错误的性质,以确定重试操作是否合理。
在这种情况下,与其断言错误是一个特定的类型或值,我们可以断言错误实现了一个特定的行为。考虑一下这个例子。
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 接口;也就是说,它没有一个 Temporary 方法,那么错误就不是临时的。
如果错误确实实现了 Temporary,那么如果 Temporary 返回 true,调用者也许可以重试操作。
这里的关键是,这个逻辑可以在不导入定义错误的包的情况下实现,也不需要知道 err 的底层类型,我们只是对其行为感兴趣。
不要只是检查错误,要优雅地处理它们
这让我想到了我想说的第二句 Go 谚语;不要只是检查错误,要优雅地处理它们(Don't just check errors, handle them gracefully)。
你能就下面这段代码提出一些问题吗?
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return err
}
return nil
}
一个明显的建议是,函数的五行可以被替换为:
return authenticate(r.User)
但这是每个人都应该在代码审查中抓住的简单东西。更为根本的是,这段代码的问题是我无法判断原始错误来自哪里。
如果 authenticate 返回一个错误,那么 AuthenticateRequest 将把这个错误返回给它的调用者,后者可能也会这样做,以此类推。在程序的顶部,程序的主体将把错误打印到屏幕或日志文件中,而所打印的内容是:No such file or directory。
没有产生错误的文件和行的信息。没有导致错误的调用堆栈的堆栈跟踪。
这段代码的作者将被迫对他们的代码进行长时间的剖析,以发现哪个代码路径引发了文件未找到的错误。
Donovan 和 Kernighan 的《Go 编程语言》建议你使用 fmt.Errorf 为错误路径添加上下文。
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return fmt.Errorf("authenticate failed: %v", err)
}
return nil
}
但正如我们前面所看到的,这种模式与哨兵错误值或类型断言的使用不兼容,因为将错误值转换为一个字符串,与另一个字符串合并,然后用 fmt.Errorf 将其转换为一个错误,会破坏平等性,并破坏原始错误的任何上下文。
注释错误
我想提出一种为错误添加上下文的方法,也就是注释错误(Annotating errors),也就是给错误增加注解。
我将介绍一个简单的包。代码在 github.com/pkg/errors(在 Go1.13 起已经被 Go 官方引入,得到了认可)。
errors 包有两个主要功能。
// Wrap annotates cause with a message.
func Wrap(cause error, message string) error
第一个函数是 Wrap,它接收一个错误和一个信息,并产生一个新的错误。
// Cause unwraps an annotated error.
func Cause(err error) error
第二个函数是 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
}
我们将用这个函数来编写一个读取配置文件的函数,然后从 main 中调用。
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")
}
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
如果 ReadConfig 代码路径失败,因为我们使用了 errors.Wrap,我们会得到一个 K&D 风格的漂亮的错误注释。
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
因为 errors.Wrap 产生一个错误堆栈,我们可以检查该堆栈以获得额外的调试信息。这又是同一个例子,但这次我们用 errors.Print 替换 fmt.Println。
func main() {
_, err := ReadConfig()
if err != nil {
errors.Print(err)
os.Exit(1)
}
}
我们会得到这样的东西:
readfile.go:27: could not read config
readfile.go:14: open failed
open /Users/dfc/.settings.xml: no such file or directory
第一行来自 ReadConfig,第二行来自 ReadFile 的 os.Open 部分,其余部分来自 os 包本身,它没有携带位置信息。
现在我们已经介绍了包裹错误以产生堆栈的概念,我们需要讨论相反的情况,即解包错误。这就是 errors.Cause 函数的领域。
// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
te, ok := errors.Cause(err).(temporary)
return ok && te.Temporary()
}
在操作中,每当你需要检查一个错误与一个特定的值或类型相匹配时,你应该首先使用 errors.Cause 函数恢复原始错误。
只处理一次错误
最后我想说的是,你应该只处理一次错误。处理一个错误意味着检查错误值,并做出决定。
func Write(w io.Writer, buf []byte) {
w.Write(buf)
}
如果你做的决定少于一个,你就会忽略掉这个错误。正如我们在这里看到的,来自 w.Write 的错误被丢弃了。
但是针对一个错误做出多于一个的决定也是有问题的。
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
}
在这个例子中,如果在写的过程中发生了错误,就会有一行写到日志文件中,指出发生错误的文件和行,并且错误也会返回给调用者,调用者可能会记录它,并返回它,一直到程序的顶部。
因此你在日志文件中得到了一堆重复的行,但在程序的顶部你得到了没有任何背景的原始错误。有人使用 Java 吗?
func Write(w io.Write, buf []byte) error {
_, err := w.Write(buf)
return errors.Wrap(err, "write failed")
}
使用 errors 包后,你将有能力为错误值添加上下文,以一种人类和机器都可以检查的方式进行对值编程。
总结
错误是你的包的公共 API 的一部分,对待它们要像对待你的公共 API 的任何其他部分一样谨慎。
为了获得最大的灵活性,我建议你尽量把所有的错误都当作不透明的。在你无法做到的情况下,为行为而不是类型或值断言错误。
在你的程序中尽量减少哨兵错误值的数量,并在错误发生时用 error.Wrap 将其转换为不透明的错误。
如果你需要检查的话,使用 errors.Cause 来恢复底层错误。
希望每一位 Gopher 都学会如何更优雅的处理 Go 的错误!
文章持续更新,可以微信搜【脑子进煎鱼了】阅读,本文 GitHub github.com/eddycjy/blog 已收录,学习 Go 语言可以看 Go 学习地图和路线,欢迎 Star 催更。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。