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

11/1/2021 Golang 语言译文

本译文摘自Dave Cheney于2016年在日本东京举行的GoCon春季 (opens new window)会议上的演讲。

https://www.youtube.com/watch?v=lsBF58Q-DnY

https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully

# 关于 Dave Cheney

Dave Cheney (opens new window)是Go编程语言的开源贡献者和项目成员。Dave Cheney是科技界备受尊重的代言人,他讲的是软件设计,性能和Go编程语言等各种主题。从2009年起,Dave Cheney就开始在世界各地写作,教学和演讲golang。Dave Cheney目前是VMware技术人员。在他的前公司收购之前,他曾在位于西雅图的公司Heptio担任工程师,专注于构建工具,帮助开发人员提高Kubernetes的工作效率。

# Errors are just values

我花了很多时间考虑Go程序中错误处理的最佳方法。我真希望存在单一的错误处理方式,可以通过死记硬背教给所有Go程序员,就像教数学或英文字母表一样。

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

# Sentinel errors

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

if err == ErrSomething {}
1

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

例子包括 io.EOF 类的值,或低层级的错误,如 syscall 包中的常 syscall.ENOENT。甚至还有 sentinel errors 表示_没有_发生错误,比如 go/build.NoGoError , 和 path/filepath.Walkpath/filepath.SkipDir

使用 sentinel 值是灵活性最小的错误处理策略,因为调用者必须使用等于运算符,将结果与预先声明的值进行比较(如下代码)。

buf := make([]byte, 100)
n, err := r.Read(buf)
buf = buf[:n]
if err == io.EOF {
  log.Fatal("read failed:", err)
}
1
2
3
4
5
6

**(错误实践)**当您想要提供更多上下文时就会出现问题,因为返回一个不同的错误会破坏相等检查。即使是用心良苦的使用 fmt.Errorf 为错误添加一些上下文,将使调用者的相等测试失败。 调用者转而被迫查看 errorError 方法的输出,以查看它是否与特定字符串匹配。

func readfile(path string) error {
  err := openfile(path)
  if err != nil {
    return fmt.Errorf("cannot open file: %v", err)
  }
  ...
}

func main() {
  err := readfile(".bashrc")
  if strings.Contains(error.Error(),"not found") {
    // handle error
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# Never inspect the output of error.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 {}
1

错误类型是您创建的实现错误接口的类型。 在此示例中,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}
1
2
3
4
5
6
7
8
9
10
11

由于 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
}
1
2
3
4
5
6
7
8
9

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
1
2
3
4
5
6
7
8
9

# 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
}
1
2
3
4
5
6
7
8
9

例如,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()
}
1
2
3
4
5
6
7
8
9

可以将任何错误传递给 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
}
1
2
3
4
5
6
7

一个明显的建议是,函数的五行可以替换为:

return authenticate(r.User)
1

但这是每个人都应该在代码审查中发现的简单问题。这段代码更根本的问题是无法分辨原始错误来自哪里。

如果 authenticate 返回错误,那么 AuthenticateRequest 会将错误返回给调用者,调用者也可能会这样做,依此类推。 在程序的顶部,程序的主体将错误打印到屏幕或日志文件,所有打印的都会是: 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
}
1
2
3
4
5
6
7

但是正如我们之前看到的,这种模式与使用 sentinel error values 或类型断言不兼容,因为将错误值转换为字符串,将其与另一个字符串合并,然后使用 fmt.Errorf 将其转换回错误,破坏了相等性,同时完全破坏了原始错误中的上下文。

# Annotating errors

传统的错误生成方式如下:

err := errors.New("kerboom")
fmt.Printf("%v\n", err) // 打印出"kerboom"字符串
1
2

我想建议一种方法来为错误添加上下文,为此,我将介绍一个简单的包。 该代码在 github.com/pkg/errors (opens new window) 提供。 错误包有两个主要函数:

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

第一个函数是 Wrap,它接收一个错误和一段消息,并产生一个新的错误。

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

第二个函数是 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
}
1
2
3
4
5
6
7
8
9
10
11
12
13

我们将使用此函数编写一个函数来读取配置文件,然后从 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)
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13

如果 ReadConfig 代码路径失败,因为我们使用了 errors.Wrap,我们在K&D样式中得到一个很好的注释错误。

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

因为 errors.Wrap 会产生堆栈错误,所以我们可以检查该堆栈以获取其他调试信息。 这又是一个相同的例子,但这次我们用 fmt.Println 替换 errors.Print

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

我们会得到如下信息:

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

第一行来自 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()
}
1
2
3
4
5

在操作中,每当您需要检查错误是否与特定值或类型匹配时,您应首先使用 errors.Cause 函数恢复原始错误。

# Only handle errors once

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

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

如果不做决定,则忽略该错误。 正如我们在这里看到的那样,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
}
1
2
3
4
5
6
7
8
9
10
11

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

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

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

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

# Conclusion

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

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

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

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

上次更新于: 3 months ago