译 | 在 Go 1.13 处理错误

原文:Working with Errors in Go 1.13


介绍

Go 将错误视为值的这种行为在过去十年为我们服务良好。虽然标准库对错误的支持很少(只有 errors.Newfmt.Errorf 函数,它们产生仅包含消息的错误),但是内置的 error 接口允许 Go 程序员添加所需的任何信息。仅需一个实现 Error 方法的类型即可:

1
2
3
4
5
6
type QueryError struct {
Query string
Err error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }

像这样的错误类型无处不在,而它们存储的信息差异很大,从时间戳到文件名再到服务器地址。通常,该信息包含另一个较低层次的错误,这个错误提供额外的上下文。

在 Go 代码中,一个错误包含另一个错误的模式非常普遍,以至于经过广泛讨论,Go 1.13 为其添加了明确的支持。本文描述了标准库提供的支持:errors 包中的三个新函数,以及用于 fmt.Errorf 的一个新格式化动词。

在详细描述更改之前,让我们回顾一下在 Go 的早期版本中是如何检查和构造错误的。

Go 1.13 之前的错误

检查错误

Go 的错误是值。程序通过几种方式,基于这些值来做出决定。最常见的方式是将错误与 nil 进行比较,以查看操作是否失败。

1
2
3
if err != nil {
// something went wrong
}

有时,我们会将错误与一个已知的 _标记_ 值进行比较,以查看是否发生了特定的错误。

1
2
3
4
5
var ErrNotFound = errors.New("not found")

if err == ErrNotFound {
// something wasn't found
}

一个错误值可以是任意类型,只要它满足了语言定义的 error 接口。程序可以使用类型断定或者类型选择,将错误值视为更具体的类型。

1
2
3
4
5
6
7
8
9
type NotFoundError struct {
Name string
}

func (e *NotFoundError) Error() string { return e.Name + ": not found" }

if e, ok := err.(*NotFoundError); ok {
// e.Name wasn't found
}

添加信息

通常,函数沿调用栈传递错误,同时向其添加信息,例如对错误发生时的情况的简要概述。一种简单的实现方式是构造一个包含上一个错误的文本的新错误:

1
2
3
if err != nil {
return fmt.Errorf("decompress %v: %v", name, err)
}

使用 fmt.Errorf 创建新错误会丢弃除了文本外原始错误中的所有所有内容。正如从上面的 QueryError 所看到的,有时,我们可能想要定义一个包含底层错误的新的错误类型,并将其保留以供代码检查。下面又是 QueryError

1
2
3
4
type QueryError struct {
Query string
Err error
}

程序可以查看 *QueryError 的值,然后基于底层错误进行决策。有时,你会看到这被成为“解封”错误。

1
2
3
if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
// query failed because of a permission problem
}

标准库中的 os.PathError 类型是一个错误包含另一个错误的又一个例子。

Go 1.13 中的错误

Unwrap 方法

Go 1.13 为 errorsfmt 标准库包引入了新特性,以简化处理包含另一个错误的错误。其中最重要的是约定而不是更改:包含另一个错误的错误可以实现 Unwrap 方法,返回底层错误。如果 e1.Unwrap() 返回 e2,那么我们说 e1 _封装__ 了 e2,并且你可以 _解封_ e1 以获得 e2

遵循此约定,我们可以为上面的 QueryError 类型提供一个 Unwrap 方法,该方法返回包含的错误:

1
func (e *QueryError) Unwrap() error { return e.Err }

解封错误的结果可能本身包含 Unwrap 方法。我们称由重复解封产生的错误序列为 错误链

使用 Is 和 As 函数检查错误

Go 1.13 的 errors 包包含了两个用来检查错误的新函数:IsAs

errors.Is 函数将一个错误与另一个值进行比较。

1
2
3
4
5
// Similar to:
// if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
// something wasn't found
}

As 函数测试错误是否为特定的类型。

1
2
3
4
5
6
// Similar to:
// if e, ok := err.(*QueryError); ok { … }
var e *QueryError
if errors.As(err, &e) {
// err is a *QueryError, and e is set to the error's value
}

在最简单的情况下,errors.Is 函数的行为类似于与标记值的比较,而 errors.As 函数的行为类似于类型声明。但是,在处理封装错误时,这些函数会考虑错误链中的所有错误。我们再看看上面的例子,解封 QueryError 以检查底层错误:

1
2
3
if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
// query failed because of a permission problem
}

使用 errors.Is 函数,我们可以这样写:

1
2
3
if errors.Is(err, ErrPermission) {
// err, or some error that it wraps, is a permission problem
}

errors 包还包含了一个新的 Unwrap 函数,它返回错误的 Unwrap 方法的调用结果,或者在错误没有 Unwrap 方法时返回 nil。通常,最好使用 errors.Is 或者 errors.As,因为这些函数将在一次调用中检查整个链。

使用 %w 封装错误

如前所述,通常我们会使用 fmt.Errorf 函数来给错误添加额外的信息。

1
2
3
if err != nil {
return fmt.Errorf("decompress %v: %v", name, err)
}

在 Go 1.13 中,fmt.Errorf 函数支持一个新的动词:%w。当存在该动词时,fmt.Errorf 返回的错误将带有一个 Unwrap 方法,这个方法会返回 %w 的参数,而这个参数必须是一个错误。除此之外,%w%v 相同。

1
2
3
4
if err != nil {
// Return an error which unwraps to err.
return fmt.Errorf("decompress %v: %w", name, err)
}

使用 %w 来封装错误也使其可用于 errors.Iserrors.As

1
2
3
err := fmt.Errorf("access denied: %w”, ErrPermission)
...
if errors.Is(err, ErrPermission) ...

是否封装

无论是通过 fmt.Errorf 还是实现自定义类型,当为错误添加额外的上下文时,你都需要确定新的错误是否应该封装原始错误。这个问题并没有唯一的答案。它取决于创建新错误的上下文。封装错误已将其暴露给调用者。当封装会暴露实现细节时,不要封装。

举个例子,假设有一个 Parse 函数,它从 io.Reader 读取复杂结构的数据。如果发生错误,我们希望可能报告错误发生代码行号和列号。如果在从 io.Reader 读取数据的时候发生了错误,那么我们会想要封装这个错误以检查底层错误。由于调用者给函数提供 io.Reader,因此,暴露它所产生的错误是有意义的。

相反,一个对数据库进行多次调用的函数可能不应该返回封装了这些调用某个结果的错误。如果函数使用的数据库是实现细节,那么暴露这些错误则违反了抽象。例如,如果你的包 pkgLookupUser 函数使用了 Go 的 database/sql 包,那么它可能会遇到 sql.ErrNoRows 错误。如果你用 fmt.Errorf("accessing DB: %v", err) 来返回错误,那么调用者无法检查底层信息以查找 sql.ErrNoRows。但如果反过来,函数返回 fmt.Errorf("accessing DB: %w", err),那么调用者可以合理地这样写:

1
2
err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) …

此时,如果你不想影响到客户端的话,该函数必须始终返回 sql.ErrNoRows,即使切换到其他数据库包也必须返回。换句话说,封装一个错误会使得这个错误成为你的 API 的一部分。如果你不想将来把这个错误作为 API 的一部分来支持,则不应该封装这个错误。

重要的是记住,无论封装与否,错误文本都将相同。无论哪种方式,试图理解错误的 _人_ 都将获得相同的信息;对于封装的选择是关于是否要为 _程序_ 提供额外的信息,以便他们可以做出更明智的决定;还是隐瞒该信息以保留抽象层。

利用 Is 和 As 方法自定义错误测试

errors.Is 函数检查链中的每个错误,以查找与目标值匹配的错误。默认情况下,如果错误和目标相等,那么二者匹配。此外,链中的错误可能会通过实现 Is _方法_ 来声明它与目标匹配。

例如,考虑这个受 Upspin 错误包 启发的错误,它将错误与模板进行比较,并且仅考虑模板中那些非零字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Error struct {
Path string
User string
}

func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
if !ok {
return false
}
return (e.Path == t.Path || t.Path == "") &&
(e.User == t.User || t.User == "")
}

if errors.Is(err, &Error{User: "someuser"}) {
// err's User field is "someuser".
}

errors.As 函数在 As 方法存在的情况下行为类似。

错误和包 API

返回错误的包(大部分都会返回)应该描述程序员可能依赖的那些错误的属性。一个经过精心设计的包也要避免返回那些带有不应该依赖的属性的错误。

最简单的规范是说,操作成功或者失败,分别返回 nil 或者非 nil 的错误值。在许多情况下,不需要进一步的信息。

如果我们希望函数返回可识别的错误条件,例如“找不到项目”,那么我们可以返回封装了标记值的错误。

1
2
3
4
5
6
7
8
9
10
11
12
var ErrNotFound = errors.New("not found")

// FetchItem returns the named item.
//
// If no item with the name exists, FetchItem returns an error
// wrapping ErrNotFound.
func FetchItem(name string) (*Item, error) {
if itemNotFound(name) {
return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
}
// ...
}

有其他提供错误的模式:可以由调用方进行语义检查,例如直接返回标记值,特定类型或者可以使用判定函数检查的值。

在所有情况下,都应该注意,不要向用户公开内部细节。正如我们在上面的“是否要封装”中提到的那样,当你从另一个包中返回错误时,你应该将错误转换为不暴露底层错误的形式,除非你愿意将来再返回该特定错误。

1
2
3
4
5
6
7
8
f, err := os.Open(filename)
if err != nil {
// The *os.PathError returned by os.Open is an internal detail.
// To avoid exposing it to the caller, repackage it as a new
// error with the same text. We use the %v formatting verb, since
// %w would permit the caller to unwrap the original *os.PathError.
return fmt.Errorf("%v", err)
}

如果函数被定义为返回一个封装某些标记或者类型的错误,请不要直接返回底层错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var ErrPermission = errors.New("permission denied")

// DoSomething returns an error wrapping ErrPermission if the user
// does not have permission to do something.
func DoSomething() {
if !userHasPermission() {
// If we return ErrPermission directly, callers might come
// to depend on the exact error value, writing code like this:
//
// if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
//
// This will cause problems if we want to add additional
// context to the error in the future. To avoid this, we
// return an error wrapping the sentinel so that users must
// always unwrap it:
//
// if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
return fmt.Errorf("%w", ErrPermission)
}
// ...
}

总结

尽管我们所讨论的更改仅包含三个函数和一个格式化动词,但我们希望它们能对改善 Go 程序中的错误处理的方式有所帮助。我们希望通过封装来提供额外的上下文这种行为变得常见,从而帮助程序做出更好的决策,并帮助程序员更快地发现错误。

正如 Russ Cox 在他的 GopherCon 2019 主题演讲中所说的那样,在通往 Go 2 的路上,我们实验、简化和发布。现在,我们已经发布了这些更改,期待接下来的实验。