Working with Errors in Go 1.13
Damien Neil and Jonathan Amsterdam
17 October 2019
介绍
Go 将 error 作为 values 已经很好的为我们服务了数十年。尽管标准库对 errors 的支持很少——只有errors.New
和fmt.Errorf
函数,会产生只包含消息的 errors——内置的 error interface 允许 Go 程序猿添加他们想要的任何信息。它所需要的只是实现一个Error
方法:
1 | type QueryError struct { |
像这样的 Error 类型无处不在,而且他们存储的信息千差万别,从时间戳到文件名到服务器地址。通常,该信息包括另一个较低级的 error 以提供额外的上下文。
这种一个错误包含另一个的模式在 Go 代码中如此普遍,经过广泛讨论,Go 1.13 添加了对他的明确支持。本文描述了标准库中提供支持的新增内容:errors
包中的三个新函数和新增的格式化动词fmt.Errorf
。
在描述这些变化的细节之前,让我们再看一下在以前的版本如何检查 errors 和构造 error 。
Go 1.13 之前的错误
检查错误
Go 错误是值。程序以集中方式根据这些值做出决策。最常用的是比较error
和nil
去查看操作是否失败了。
1 | if err != nil { |
有时我们将error
和已知的预定值比较,去查看是否发生了特定的error
。
1 | var ErrNotFound = errors.New("not found") |
error
值也许是满足语言定义的error
接口的任何类型。程序可以用类型断言或者类型切换将error
视为更具体的类型。
1 | type NotFoundError struct { |
添加信息
通常,函数在向其添加信息时将error
传递到调用堆栈上,就像当error
发生时发生了什么的简短描述。一种简单的方式实现是构造一个新的error
包括了前一个的文字。
1 | if err != nil { |
使用fmt.Errorf
创建一个新的error
丢弃了所有来自原始error
的东西除了文字。就像我们在上面看到的QueryError
,也许我们有时想要定义一个新的error
类型包含底层错误,保留它以供代码检查。再看一下QueryError
:
1 | type QueryError struct { |
程序可以查看*QueryError
内部的值去根据底层错误做决策。你有时会看到这作为unwrapping
(解包)错误:
1 | if e, ok := err.(*QueryError); ok && e.Err == ErrPermission { |
标准库中的os.PathError
类型是另一个一个error
包含另一个的例子。
在 Go 1.13 中的错误
展开方法(The Unwrap method)
Go 1.13 介绍了简化处理错误包含其他错误的errors
和fmt
标准库包的新特性。其中最重要的是约定而不是变更:一个错误包含其他的要实现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
包包含了两个检查错误的新函数:Is
和As
。
errors.Is
函数将error
和值比较:
1 | // Similar to: |
As
函数测试error
是否是特定类型:
1 | // Similar to: |
在这个最简单的栗子中,errors.Is
函数表现的像是和 sentinel error 比较,errors.As
函数表现的像是类型断言。当在处理包装错误(wrapped errors)时,然而,这些函数会考虑链中的所有错误。让我们再看一下上面解包一个QueryError
去检查底层错误的代码:
1 | if e, ok := err.(*QueryError); ok && e.Err == ErrPermission { |
使用errors.Is
函数,我们可以像下面这样写:
1 | if errors.Is(err, ErrPermission) { |
errors
包也包括了一个新的Unwrap
函数,返回调用error
的Unwrap
方法的结果,或者nil
当error
没有Unwrap
方法时。通常使用errors.Is
或者errors.As
方法更好,但是,这些函数会在单次调用中检查整个链。
注意:尽管将指针指向指针可能会感觉怪怪的,在这种情况下是对的。把他想象成一个指向错误类型的值,在这种情况下事情就是这样,他返回的error
是一个指针类型
使用 %w 包装 errors
像以前提到的,用fmt.Errorf
函数向error
中添加信息是很普遍的。
1 | if err != nil { |
在 Go 1.13 中,fmt.Errorf
函数支持新的%w
。当这个动词出现时,由fmt.Errorf
返回的error
将有一个Unwrap
方法返回%w
参数,这肯定是一个error
。在其他方面,%w
和%v
相同。
1 | if err != nil { |
使用%w
包装error
使其可用于errors.Is
和errors.As
:
1 | err := fmt.Errorf("access denied: %w", ErrPermission) |
是否包装
当向error
添加上下文时,无论是使用fmt.Errorf
还是通过自定义实现,你都需要决定新error
是否应该包装原始的。这个问题没有唯一的答案,他取决于被创建的error
的上下文。包装error
将其暴露给调用者。当这么做会暴露实现细节时不要包装error
。
作为一个栗子,想象一下一个Parse
函数从io.Reader
读取复杂的数据结构。如果error
发生了,我们希望报告在哪一行哪一列发生了。如果从io.Reader
读取时发生错误,我们想要包装error
以便允许检查底层问题。既然调用者为函数提供了io.Reader
,那么将由他产生的error
暴露是有意义的。
相比之下,一个对数据库发起多次调用的函数也许不应该返回error
,因为解包的结果是这些调用中的一个。如果函数使用的数据库是实现细节,那么暴露这些错误违反了抽象(原则,从具体问题中,提取出具有共性的模式,再使用通用的解决方法加以处理)。例如,pkg
包中的LookupUser
函数使用Go
的database/sql
包,那么他也许会碰到sql.ErrNoRows
错误。如果你使用fmt.Errorf("accessing DB: %v", err)
返回一个error
,那么调用者无法查看内部发现sql.ErrNoRows
。但是如果函数使用fmt.Errorf("accessing DB: %w", err)
,那么调用者可以合理的这么写:
1 | err := pkg.LookupUser(...) |
此时,如果你不想中断客户端,该函数必须始终返回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 | type Error struct { |
errors.As
函数在出现时类似的参考As
方法。
错误和包 APIs
一个返回错误的包(大多数都应该这么做)应该描述程序员可能依赖的那些错误属性。一个设计良好的包也会避免返回不应该依赖的错误的属性。
最简单的规范操作是说操作成功还是失败,个别的返回 nil 或 非 nil 错误值。在大多数情况下,不需要进一步的信息。
如果我们希望函数返回可识别的错误条件,类似“未发现项目,” 我们可能会返回包装预定值的error
:
1 | var ErrNotFound = errors.New("not found") |
还有一些其他存在的模式,提供可供调用者从语义上检查的错误,类似直接返回一个预定值,一个指定的类型,或者可以被判断函数(predicate function)检查的值。
在所有情况下,都要注意不要将内部细节暴露给用户。正如我们在上面“是否要包装”中提到的那样,当你返回一个来自其他包的error
,你应该将error
转化为一个不会暴露底层error
的格式,除非你愿意承诺在未来返回这个特定错误。
1 | f, err := os.Open(filename) |
如果函数被定义成返回一个包装了一些预定值或类型的error
,不要直接返回底层的error
:
1 | var ErrPermission = errors.New("permission denied") |
结论
尽管我们讨论的更改仅包含三个函数和一个格式化动词,我们希望他们对改进 Go 程序中错误的处理方式有很大的帮助。我们期望提供额外上下文的包装将变得普遍,帮助程序做出更好的决策和帮助程序员更快的找到 bug。
正如 Russ Cox 在他的 GopherCon 2019 主题演讲中所说,在 Go 2 的道路上,我们进行实验、简化和发布。现在我们已经发布了这些更改,我们期待接下来的实验�