译|Don’t just check errors, handle them gracefully

来源:cyningsun.github.io/09-09-2019/…html

本文摘自我最近在日本东京举行的GoCon春季会议上的演讲。git

Don't just check errors, handle them gracefully

Errors are just values

我花了不少时间考虑Go程序中错误处理的最佳方法。我真但愿存在单一的错误处理方式,能够经过死记硬背教给全部Go程序员,就像教数学或英文字母表同样。程序员

可是,我得出结论,不存在单一的错误处理方式。 相反,我认为Go的错误处理能够分为三个核心策略。github

Sentinel errors

第一类错误处理就是我所说的_sentinel errors_。编程

if err == ErrSomething { … }
复制代码

该名称源于计算机编程中使用特定值的实践,表示不可能进一步处理。 所以,对于Go,咱们使用特定值来表示错误。网络

例子包括 io.EOF 类的值,或低层级的错误,如 syscall 包中的常 syscall.ENOENTapp

甚至还有 sentinel errors 表示_没有_发生错误,好比 go/build.NoGoError , 和 path/filepath.Walkpath/filepath.SkipDir函数

使用 sentinel 值是灵活性最小的错误处理策略,由于调用者必须使用等于运算符,将结果与预先声明的值进行比较。 当您想要提供更多上下文时就会出现问题,由于返回一个不一样的错误会破坏相等检查。工具

即便是用心良苦的使用 fmt.Errorf 为错误添加一些上下文,将使调用者的相等测试失败。 调用者转而被迫查看 errorError 方法的输出,以查看它是否与特定字符串匹配。测试

Never inspect the output of error.Error

另外,我认为永远不该该检查 error.Error 方法的输出。error 接口上的 Error 方法是为人类,而不是代码。

该字符串的内容属于日志文件,或显示在屏幕上。 您不该该尝试经过检查它以更改程序的行为。

我知道有时候这是不可能的,正若有人在推特上指出的那样,此建议并不适用于编写测试。 更重要的是,在我看来,比较错误的字符串形式是一种代码气味,你应该尽可能避免它。

Sentinel errors become part of your public API

若是您的 public 函数或方法返回特定值的错误,那么该值必须是 public 的,固然还要有文档记录。 这会增长API的面积。

若是您的API定义了一个返回特定错误的接口,则该接口的全部实现都将被限制为仅返回该错误,即便它们可能提供更具描述性的错误。

经过 io.Reader 看到这一点 。 像 io.Copy 这样的函数,须要一个 reader 实现来_精确_地返回 io.EOF,以便向调用者发出再也不有数据的信号,但这不是错误 。

Sentinel errors create a dependency between two packages

到目前为止,sentinel error values 的最大问题是它们在两个包之间建立源代码依赖性。 例如,要检查错误是否等于 io.EOF,您的代码必 import io 包。

这个具体示例听起来并不那么糟糕,由于它很常见,但想象一下,当项目中的许多包导出 error values,项目中的其余包必须 import 以检查特定的错误条件时存在的耦合。

在一个玩弄这种模式的大型项目中工做过,我能够告诉你,以 import 循环的形式出现的糟糕设计的幽灵从未远离咱们的脑海。

Conclusion: avoid sentinel errors

因此,个人建议是在你编写的代码中避免使用 sentinel error values。 在某些状况下,它们会在标准库中使用,但你不该该模仿这种模式。

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

Error types

Error types 是我想讨论的Go错误处理的第二种形式。

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 error 是一种类型,所以调用者可使用类型断言从错误中提取额外的上下文。

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
}
复制代码

error types 相对于 error values 的重大改进是,它们可以包装底层错误以提供更多上下文。

一个很好的例子是 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 复制代码
Problems with error types

调用者可使用类型断言或类型 switch,error types 必须是 public。

若是您的代码实现了一个接口,其契约须要特定的错误类型,则该接口的全部实现者都须要依赖于定义错误类型的包。

对包类型的深刻了解,会创建与调用者很强耦合,从而造成一个脆弱的API。

Conclusion: avoid error types

虽然 error typessentinel error values 更好,由于它们能够捕获更多关于错误的上下文,错误类型一样拥有许多 error values 的问题。

因此个人建议是避免 error types,或者至少避免使它们成为公共API的一部分。

Opaque errors

如今咱们来看第三类错误处理。 在我看来,这是最灵活的错误处理策略,由于它须要的代码和调用者之间的耦合最小。

我将这种方式称为不透明的错误处理,由于虽然您知道发生了错误,但您没法查看错误内部。 做为调用者,您对操做结果的全部了解都是有效的,或者没有。

这就是不透明的错误处理 - 只返回错误而不假设其内容。 若是采用此方式,则错误处理能够做为调试辅助工具,变得很是有用。

import “github.com/quux/bar”

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

例如,Foo 的契约不保证它将在错误的上下文中返回什么。经过传递错误附带额外的上下文,Foo 的做者如今能够自由地注释错误,而不会违反与调用者的契约。

Assert errors for behaviour, not type

在少数状况下,使用二分法(是否有错误)来进行错误处理是不够的。

例如,与进程外部的服务(例如网络活动)的交互,要求调用者查看错误的性质,以肯定重试操做是否合理。

在这种状况下,咱们能够断言错误实现了特定的行为,而不是断言错误是特定的类型或值。 考虑这个例子:

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,那么若是 true 返回true ,调用者能够重试该操做。

这里的关键是,此逻辑能够在不导入定义错误的包,或者直接知道任何关于 err的基础类型的状况下实现 - 咱们只是对它的行为感兴趣。

Don’t just check errors, handle them gracefully

让我想到了第二句Go谚语,我想谈谈; 不要仅仅检查错误,优雅地处理它们。 你能用如下代码提出一些问题吗?

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

No such file or directory

没有生成错误的文件和行的信息。 没有致使错误的调用堆栈的 stack trace。 该代码的做者将被迫进行一个长的会话,将他们的代码二等分,以发现哪一个代码路径触发了文件未找到错误。

Donovan和Kernighan的_The Go Programming Language_建议您使用 fmt.Errorf 向错误路径添加上下文

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

可是正如咱们以前看到的,这种模式与使用 sentinel error values 或类型断言不兼容,由于将错误值转换为字符串,将其与另外一个字符串合并,而后使用 fmt.Errorf 将其转换回错误,破坏了相等性,同时彻底破坏了原始错误中的上下文。

Annotating errors

我想建议一种方法来为错误添加上下文,为此,我将介绍一个简单的包。 该代码在 github.com/pkg/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 会产生堆栈错误,因此咱们能够检查该堆栈以获取其余调试信息。 这又是一个相同的例子,但此次咱们用 fmt.Println 替换 errors.Print

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,第二行来自 ReadFileos.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 函数恢复原始错误。

Only handle errors once

最后,我想提一下:你应该只处理一次错误。 处理错误意味着检查错误值并作出决定。

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
}
复制代码

In this example if an error occurs during Write, a line will be written to a log file, noting the file and line that the error occurred, and the error is also returned to the caller, who possibly will log it, and return it, all the way back up to the top of the program.

So you get a stack of duplicate lines in your log file, but at the top of the program you get the original error without any context. Java anyone?

在此示例中,若是在 Write 期间发生错误,则会将一行写入日志文件,注意错误发生的文件和行,而且错误也会返回给调用者,调用者可能会将其记录并返回,一路回到程序的顶部。

所以,您在日志文件中得到了重复的行的堆栈,可是在程序的顶部,您将得到没有原始错误的任何上下文。 有人使用Java吗?

func Write(w io.Write, buf []byte) error {
	_, err := w.Write(buf)
	return **errors.Wrap(err, "write failed")**
}
复制代码

使用 errors 包,您能够以人和机器均可检查的方式向错误值添加上下文。

Conclusion

总之,错误是包 public API 的一部分,对待它们就像对待 public API 的其余部分同样当心。

为了得到最大的灵活性,我建议您尝试将全部错误都视为不透明的。在不能这样作的状况下,断言行为错误,而不是类型或值错误。

最小化程序中的 sentinel error values,并在错误发生时当即用 errors.Wrap 将其包装,从而将错误转换为不透明错误。

最后,若是须要检查,请使用 errors.Cause 恢复底层错误。

相关文章
相关标签/搜索