LJ的Blog

学海无涯苦做舟

0%

在 Go 1.13 中处理错误(翻译)

Working with Errors in Go 1.13

Damien Neil and Jonathan Amsterdam
17 October 2019

介绍

Go 将 error 作为 values 已经很好的为我们服务了数十年。尽管标准库对 errors 的支持很少——只有errors.Newfmt.Errorf函数,会产生只包含消息的 errors——内置的 error interface 允许 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() }

像这样的 Error 类型无处不在,而且他们存储的信息千差万别,从时间戳到文件名到服务器地址。通常,该信息包括另一个较低级的 error 以提供额外的上下文。

这种一个错误包含另一个的模式在 Go 代码中如此普遍,经过广泛讨论,Go 1.13 添加了对他的明确支持。本文描述了标准库中提供支持的新增内容:errors包中的三个新函数和新增的格式化动词fmt.Errorf

在描述这些变化的细节之前,让我们再看一下在以前的版本如何检查 errors 和构造 error 。

Go 1.13 之前的错误

检查错误

Go 错误是值。程序以集中方式根据这些值做出决策。最常用的是比较errornil去查看操作是否失败了。

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

有时我们将error和已知的预定值比较,去查看是否发生了特定的error

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

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

error值也许是满足语言定义的error接口的任何类型。程序可以用类型断言或者类型切换将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
}

添加信息

通常,函数在向其添加信息时将error传递到调用堆栈上,就像当error发生时发生了什么的简短描述。一种简单的方式实现是构造一个新的error包括了前一个的文字。

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

使用fmt.Errorf创建一个新的error丢弃了所有来自原始error的东西除了文字。就像我们在上面看到的QueryError,也许我们有时想要定义一个新的error类型包含底层错误,保留它以供代码检查。再看一下QueryError

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

程序可以查看*QueryError内部的值去根据底层错误做决策。你有时会看到这作为unwrapping(解包)错误:

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

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

在 Go 1.13 中的错误

展开方法(The Unwrap method)

Go 1.13 介绍了简化处理错误包含其他错误的errorsfmt标准库包的新特性。其中最重要的是约定而不是变更:一个错误包含其他的要实现Unwrap方法返回底层错误。如果e1.Unwrap()返回了e2,那么我们说e1 wraps e2,然后你可以unwrap e1 去获得 e2。

按照这个预定,我们可以给出上面的QueryError类型一个Unwrap方法返回他包含的错误:

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

解包error的结果也许他自身也有一个Unwrap方法,我们按顺序调用由重复展开错误链产生的错误。( we call the sequence of errors produced by repeated unwrapping the error chain.)

使用 Is 和 As 检查错误

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

errors.Is函数将error和值比较:

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

As函数测试error是否是特定类型:

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

在这个最简单的栗子中,errors.Is函数表现的像是和 sentinel error 比较,errors.As函数表现的像是类型断言。当在处理包装错误(wrapped errors)时,然而,这些函数会考虑链中的所有错误。让我们再看一下上面解包一个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函数,返回调用errorUnwrap方法的结果,或者nilerror没有Unwrap方法时。通常使用errors.Is或者errors.As方法更好,但是,这些函数会在单次调用中检查整个链。

注意:尽管将指针指向指针可能会感觉怪怪的,在这种情况下是对的。把他想象成一个指向错误类型的值,在这种情况下事情就是这样,他返回的error是一个指针类型

使用 %w 包装 errors

像以前提到的,用fmt.Errorf函数向error中添加信息是很普遍的。

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

在 Go 1.13 中,fmt.Errorf函数支持新的%w。当这个动词出现时,由fmt.Errorf返回的error将有一个Unwrap方法返回%w参数,这肯定是一个error。在其他方面,%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包装error使其可用于errors.Iserrors.As

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

是否包装

当向error添加上下文时,无论是使用fmt.Errorf还是通过自定义实现,你都需要决定新error是否应该包装原始的。这个问题没有唯一的答案,他取决于被创建的error的上下文。包装error将其暴露给调用者。当这么做会暴露实现细节时不要包装error

作为一个栗子,想象一下一个Parse函数从io.Reader读取复杂的数据结构。如果error发生了,我们希望报告在哪一行哪一列发生了。如果从io.Reader读取时发生错误,我们想要包装error以便允许检查底层问题。既然调用者为函数提供了io.Reader,那么将由他产生的error暴露是有意义的。

相比之下,一个对数据库发起多次调用的函数也许不应该返回error,因为解包的结果是这些调用中的一个。如果函数使用的数据库是实现细节,那么暴露这些错误违反了抽象(原则,从具体问题中,提取出具有共性的模式,再使用通用的解决方法加以处理)。例如,pkg包中的LookupUser函数使用Godatabase/sql包,那么他也许会碰到sql.ErrNoRows错误。如果你使用fmt.Errorf("accessing DB: %v", err)返回一个error,那么调用者无法查看内部发现sql.ErrNoRows。但是如果函数使用fmt.Errorf("accessing DB: %w", err),那么调用者可以合理的这么写:

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

此时,如果你不想中断客户端,该函数必须始终返回sql.ErrNoRows,即使你切换到一个不同的数据库包。换句话说,包装error使得那个error变成你的 API 的一部分。如果你不想承诺在未来将那个error作为你 API 的一部分,那么你不应该包装error

很重要的一点是无论你是否包装,error文字要是一样的。试图理解error的人无论用哪种方式都将获得相同的信息。包装的选择是关于给程序额外的信息让他们可以做出更明智的决定,或者保留该信息以保留抽象层。

使用 Is 和 As 方法自定义 error

errors.Is方法为了匹配目标值检查链中的每一个error。在默认情况下,如果两者相等那么error匹配目标。此外,链中的error也许会通过实现Is声明他匹配目标。

举个例子,这个error灵感来自Upspin error package,他将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方法。

错误和包 APIs

一个返回错误的包(大多数都应该这么做)应该描述程序员可能依赖的那些错误属性。一个设计良好的包也会避免返回不应该依赖的错误的属性。

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

如果我们希望函数返回可识别的错误条件,类似“未发现项目,” 我们可能会返回包装预定值的error

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

还有一些其他存在的模式,提供可供调用者从语义上检查的错误,类似直接返回一个预定值,一个指定的类型,或者可以被判断函数(predicate function)检查的值。

在所有情况下,都要注意不要将内部细节暴露给用户。正如我们在上面“是否要包装”中提到的那样,当你返回一个来自其他包的error,你应该将error转化为一个不会暴露底层error的格式,除非你愿意承诺在未来返回这个特定错误。

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

如果函数被定义成返回一个包装了一些预定值或类型的error,不要直接返回底层的error

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() error {
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 程序中错误的处理方式有很大的帮助。我们期望提供额外上下文的包装将变得普遍,帮助程序做出更好的决策和帮助程序员更快的找到 bug。

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