Go 博客

Go 1.13 中的错误处理

Damien Neil 和 Jonathan Amsterdam
2019 年 10 月 17 日

简介

Go 将错误作为值处理的方式在过去十年中为我们提供了良好的服务。尽管标准库对错误的支持一直很有限 - 只有errors.Newfmt.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 为 errorsfmt 标准库包引入了新功能,以简化对包含其他错误的错误的操作。其中最重要的是一种约定而不是更改:包含另一个错误的错误可以实现一个返回底层错误的 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 包包含两个用于检查错误的新函数:IsAs

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.Iserrors.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.Iserrors.As 使用

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),那么调用者可以合理地编写

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 程序中错误的处理方式。我们预计包装以提供额外的上下文将变得司空见惯,帮助程序做出更好的决策,并帮助程序员更快地找到错误。

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

下一篇文章:Go 模块:v2 及更高版本
上一篇文章:发布 Go 模块
博客索引