Go:温故错误处理

179 阅读9分钟

简介

1_tEVEklzXuscybMdgwoH0ow.jpg 早期Go将错误视为值的处理方式良好。尽管标准库对错误的支持很少——只有errors.New和fmt.Errorf函数,这些函数产生的错误只包含一个消息——内置的错误接口允许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 {
    // 出了问题
}

有时我们将一个错误与一个已知的哨兵值比较,看看是否发生了特定的错误。

var ErrNotFound = errors.New("not found")

if err == ErrNotFound {
    // 没找到某物
}

一个错误值可以是任何满足语言定义的错误接口的类型。程序可以使用类型断言或类型切换来将一个错误值视为更具体的类型。

type NotFoundError struct {
    Name string
}

func (e *NotFoundError) Error() string { return e.Name + ": not found" }

if e, ok := err.(*NotFoundError); ok {
    // e.Name没有找到
}

添加信息

通常一个函数在将错误传递上调用栈时会添加信息,比如发生错误时正在发生什么的简短描述。一种简单的方法是构造一个包含前一个错误文本的新错误:

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 {
    // 查询因权限问题失败
}

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

Go 1.13及以后的错误

解包方法

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函数将一个错误与一个值进行比较。

// 类似于:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // 没找到某物
}

As函数测试一个错误是否是特定类型。

// 类似于:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
// 注意:*QueryError是错误的类型。
if errors.As(err, &e) {
    // err是*QueryError,e被设置为错误的值
}

在最简单的情况下,errors.Is函数的行为类似于与一个哨兵错误的比较,而errors.As函数的行为类似于类型断言。然而,在操作包装的错误时,这些函数会考虑链中的所有错误。让我们再次看看上面解包QueryError以检查底层错误的例子:

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // 查询因权限问题失败
}

使用errors.Is函数,我们可以这样写:

if errors.Is(err, ErrPermission) {
    // err,或者它包装的某个错误,是一个权限问题
}

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 {
    // 返回一个解包为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("访问数据库: %v", err),那么调用者无法查看内部以找到sql.ErrNoRows。但如果函数返回fmt.Errorf("访问数据库: %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的User字段是"someuser"。
}

errors.As函数同样在存在As方法时咨询As方法。

错误和包APIs

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

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

如果我们希望一个函数返回一个可识别的错误条件,例如“未找到项目”,我们可能会返回一个包装了一个哨兵的错误。

var ErrNotFound = errors.New("not found")

// FetchItem返回命名的项目。
//
// 如果不存在具有该名称的项目,FetchItem返回一个
// 包装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 {
    // os.Open返回

的*os.PathError是一个内部细节。
    // 为了避免将其暴露给调用者,重新打包它为一个新的
    // 错误,带有相同的文本。我们使用%v格式化动词,因为
    // %w将允许调用者解包原始的*os.PathError。
    return fmt.Errorf("%v", err)
}

如果一个函数定义为返回一个包装了某个哨兵或类型的错误,不要直接返回底层错误。

var ErrPermission = errors.New("permission denied")

// DoSomething如果用户没有执行某事的权限,则返回一个
// 包装ErrPermission的错误。
func DoSomething() error {
    if !userHasPermission() {
        // 如果我们直接返回ErrPermission,调用者可能会
        // 依赖于确切的错误值,编写类似这样的代码:
        //
        //     if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
        //
        // 如果我们想在将来添加额外的上下文到错误中,这将引起问题。为了避免这种情况,我们
        // 返回一个包装了哨兵的错误,以便用户必须始绀解包它:
        //
        //     if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
        return fmt.Errorf("%w", ErrPermission)
    }
    // ...
}

golang-errors_huf8f989fa38db8e60ec76a17983326e71_215060_1110x0_resize_q90_box.jpg

总结

虽然我们讨论的变化只涉及三个函数和一个格式化动词,但它们大大改善了Go程序中的错误处理。我们期待包装以提供额外上下文将变得普遍,帮助程序做出更好的决策,帮助程序员更快地找到错误。