Go 博客
Go 1.13 中的错误处理
引言
Go 将错误视为值已服务我们十年之久。虽然标准库对错误的支援非常有限——只有 errors.New
和 fmt.Errorf
函数,它们生成的错误只包含一条消息——但内置的 error
接口允许 Go 程序员添加任何他们想要的额外信息。这一切只需要一个实现了 Error
方法的类型。
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 1.13 之前的错误处理
检查错误
Go 中的错误是值。程序通过几种方式根据这些值做出决策。最常见的方式是将错误与 nil
进行比较,以判断操作是否失败。
if err != nil {
// something went wrong
}
有时我们会将一个错误与已知的*哨兵*值进行比较,以判断是否发生了特定的错误。
var ErrNotFound = errors.New("not found")
if err == ErrNotFound {
// something wasn't found
}
一个错误值可以是任何满足语言定义的 error
接口的类型。程序可以使用类型断言或类型开关将错误值视为更具体的类型。
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
}
添加信息
函数经常会将一个错误传递给调用堆栈,同时为其添加信息,比如在错误发生时正在进行的操作的简要描述。一种简单的方法是构造一个包含前一个错误文本的新错误。
if err != nil {
return fmt.Errorf("decompress %v: %v", name, err)
}
使用 fmt.Errorf
创建新错误会丢弃原始错误的所有信息,只保留文本。正如我们在上面看到的 QueryError
,有时我们可能希望定义一个包含底层错误的新错误类型,以便代码进行检查。这是再次出现的 QueryError
。
type QueryError struct {
Query string
Err error
}
程序可以查看 *QueryError
值来根据底层错误做出决策。您有时会看到这被称为“解包”错误。
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
,并且您可以通过*解包* e1
来获得 e2
。
遵循此约定,我们可以为上面的 QueryError
类型添加一个 Unwrap
方法,该方法返回其包含的错误。
func (e *QueryError) Unwrap() error { return e.Err }
解包错误的返回值本身可能有一个 Unwrap
方法;我们称之为通过重复解包产生的错误序列为*错误链*。
使用 Is 和 As 检查错误
Go 1.13 的 errors
包包含两个用于检查错误的新函数:Is
和 As
。
errors.Is
函数将一个错误与一个值进行比较。
// Similar to:
// if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
// something wasn't found
}
As
函数测试一个错误是否是特定类型。
// 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
函数的行为类似于与哨兵错误的比较,而 errors.As
函数的行为类似于类型断言。然而,在处理已包装的错误时,这些函数会考虑错误链中的所有错误。让我们再次回顾一下上面解包 QueryError
以检查底层错误的示例。
if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
// query failed because of a permission problem
}
使用 errors.Is
函数,我们可以这样写:
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
函数为错误添加额外的信息。
if err != nil {
return fmt.Errorf("decompress %v: %v", name, err)
}
在 Go 1.13 中,fmt.Errorf
函数支持一个新的 %w
动词。当存在此动词时,fmt.Errorf
返回的错误将具有一个 Unwrap
方法,该方法返回 %w
的参数,该参数必须是一个错误。在其他所有方面,%w
与 %v
相同。
if err != nil {
// Return an error which unwraps to err.
return fmt.Errorf("decompress %v: %w", name, err)
}
使用 %w
包装错误使其可供 errors.Is
和 errors.As
使用。
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)
,那么调用者就可以合理地编写:
err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) …
到那时,如果您不想破坏您的客户,该函数就必须始终返回 sql.ErrNoRows
,即使您切换到另一个数据库包。换句话说,包装错误会使该错误成为您 API 的一部分。如果您不想承诺将来将该错误作为 API 的一部分进行支持,则不应包装该错误。
重要的是要记住,无论您是否包装,错误消息都是相同的。*人*在尝试理解错误时会获得相同的信息;选择包装是为了决定是向*程序*提供额外信息以使其能够做出更明智的决策,还是为了保留抽象层而隐藏该信息。
使用 Is 和 As 方法自定义错误测试
errors.Is
函数会检查链中的每个错误是否与目标值匹配。默认情况下,当两个错误相等时,一个错误就与目标匹配。此外,链中的一个错误可以通过实现 Is
*方法*来声明它与目标匹配。
例如,考虑这个受 Upspin 错误包启发的错误,该错误将一个错误与一个模板进行比较,仅考虑模板中非零的字段。
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 的错误值。在许多情况下,不需要进一步的信息。
如果我们希望函数返回一个可识别的错误条件,例如“项目未找到”,我们可能会返回一个包装了哨兵的错误。
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)
}
// ...
}
还有其他现有的模式可以提供供调用者语义检查的错误,例如直接返回哨兵值、特定类型或可以通过谓词函数检查的值。
在所有情况下,都应注意不要将内部细节暴露给用户。正如我们在上面“是否包装”部分所讨论的,当您从另一个包返回错误时,应该将错误转换为一种不暴露底层错误的形式,除非您愿意承诺将来返回该特定错误。
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)
}
如果一个函数被定义为返回一个包装了某个哨兵或类型的错误,请不要直接返回底层错误。
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 在他 2019 年 GopherCon 主题演讲中的演讲中所说,在 Go 2 的道路上,我们会进行实验、简化和发布。既然我们已经发布了这些更改,我们期待着随之而来的实验。
下一篇文章:Go 模块:v2 及以后
上一篇文章:发布 Go 模块
博客索引