(译) Go 1.13中的错误处理

(译) Go 1.13中的错误处理

在过去的十年中, Go的errors are values的理念在编码实践中运行得也很良好。

尽管标准库对错误处理的的支持很少(只有errors.New和fmt.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版本之前的错误处理

检查错误

错误是值(errors are values)。程序通过几种方式基于这些值来做出决策。最常见的是通过与nil的比较来确定操作是否失败。

1
2
3
if err != nil {
// 出错了!
}

有时我们将错误与已知的哨兵值(sentinel value)进行比较来查看是否发生了特定错误。比如:

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

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

错误值可以是满足语言定义的error 接口的任何类型。程序可以使用类型断言(type assertion)或类型开关(type switch)来判断错误值是否可被视为特定的错误类型。

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在errors和fmt标准库包中引入了新功能以简化处理包含其他错误的错误。其中最重要的不是改变,而是一个约定:包含另一个错误的错误可以实现Unwrap方法来返回所包含的底层错误。如果e1.Unwrap()返回了e2,那么我们说e1包装了e2,您可以Unwrap e1来得到e2。

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

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

Unwrap错误的结果本身(底层错误)可能也具有Unwrap方法。我们将这种通过重复unwrap而得到的错误序列为错误链。

使用Is和As检查错误

Go 1.13的errors包中包括了两个用于检查错误的新函数:Is和As。

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函数的行为类似于上面对哨兵错误(sentinel error))的比较,而errors.As函数的行为类似于类型断言(type assertion)。但是,在处理包装错误(包含其他错误的错误)时,这些函数会考虑错误链中的所有错误。让我们再次看一下通过展开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对应的参数必须是错误(类型)。在所有其他方面,%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.Is和errors.As:

1
2
3
4
5
6
err := fmt.Errorf("access denied: %w”, ErrPermission)
...

if errors.Is(err, ErrPermission){
...
}

是否包装

在使用fmt.Errorf或通过实现自定义类型将其他上下文添加到错误时,您需要确定新错误是否应该包装原始错误。这个问题没有统一答案。它取决于创建新错误的上下文。包装错误将会被公开给调用者。如果要避免暴露实现细节,那么请不要包装错误。

举一个例子,假设一个Parse函数从io.Reader读取复杂的数据结构。如果发生错误,我们希望报告发生错误的行号和列号。如果从io.Reader读取时发生错误,我们将包装该错误以供检查底层问题。由于调用者为函数提供了io.Reader,因此有理由公开它产生的错误。

相反,一个对数据库进行多次调用的函数可能不应该将其中调用之一的结果解开的错误返回。如果该函数使用的数据库是实现细节,那么暴露这些错误就是对抽象的违反。例如,如果你的程序包pkg中的函数LookupUser使用了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 error包的启发,它将错误与模板进行了比较,并且仅考虑模板中非零的字段:

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方法,如果该错误实现了As方法。

错误和包API

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

最简单的规约是用于说明操作成功或失败的属性,分别返回nil或non-nil错误值。在许多情况下,不需要进一步的信息了。

如果我们希望函数返回可识别的错误条件,例如“item not found”,则可能会返回包装哨兵的错误。

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)
}
// ...
}

三、结论

尽管我们讨论的更改仅包含三个函数和一个格式化动词(%w),但我们希望它们能大幅改善Go程序中错误处理的方式。我们希望通过包装来提供其他上下文的方式得到Gopher们地普遍使用,从而帮助程序做出更好的决策,并帮助程序员更快地发现错误。

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

评论

`
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×