小马别过河

V1

2022/10/25阅读:17主题:默认主题

Go[译]:Working with Errors in Go 1.13

介绍

过去, Go 把错误作为值处理的方式,给了我们很大的帮助。虽然标准库对错误的支持很有限:只有 errors.Newfmt.Errorf 方法,用于生成一个包含信息的错误。内置的error接口允许开发者加任何自定义的信息。它只要求对应的类型实现Error方法:

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }

像这样的错误类型无处不在,他们存储的信息千差万别,如时间戳、文件名、服务地址。通常,该消息会包含另一个低级的错误来提供额外的上下文。

一个 error 包含另一个 error 的模式普遍存在,经过广泛的讨论,Go 1.13 增加了对它的支持。本文描述标准库对这个新支持的支持:3 个 errors 包的新方法,和fmt.Errorf的一个新的格式动词(formatting verb)。

Go 1.13 之前的错误

错误检查

Go 的 error 是值。程序基于这些值做出判断。最常见的就是判断 error 是否为 nil以判断操作是否失败。

if err != nil {
    // something went wrong
}

有时候我们拿 error 和已知的"哨兵错误"(sentinel value)对比,以判断发生的错误是否是某个具体的错误。

// 定义一个哨兵错误
var ErrNotFound = errors.New("not found")

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

一个错误值可能是实现了error接口的任何类型。程序可以使用类型断言和类型转换来查看 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
}

附加信息

通常,函数在调用栈中传递 error 的过程中,会给 error 添加一些信息,如触发错误时的简要描述。一个简单的方法就是,构造一个新的 error,新 error 包含着旧 error。

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

fmt.Errorf生成的新 error,保留着原 error 的错误信息,但是会丢失原 error 其他的数据。如上面的 QueryError,我们有时想要定义一个新的错误,包含着底层 error,以便检查。再看下QueryError:

type QueryError struct {
    Query string
    Err   error
}

程序可以查看 QueryError 底层的 error。你有可能看过这种代码:

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标准包引入了新的功能,以简化一个error 包含另一个 error 的场景。最重要的是约定,而不是更改:一个 error 如果包含另一个 error,它低层可能实现一个 Unwrap 方法,返回底层的 error。如果 e1.Unwrap() 返回 e2,我们称 e1 包裹(wrap)e2,你可以解包(unwrap)e1 获得 e2

接着上面的讨论,我们可以给 QueryError 新增一个 Unwrap 方法,它会返回包裹着的错误:

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

解包出来的 error 可能也有Unwrap方法;可以重复解包的 error 序列,我们称之为错误链。

使用 IsAs 检查 error

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

errors.Is函数比较 error 的值:

// 等价于:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // something wasn't found
}

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

// 等价于:
//   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 函数的用于类型断言。如果传入的错误包含另一个错误,这两个函数会检查整个错误链的 error。我们再看下解包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函数,如果传入的 error 参数实现了Unwrap方法,就调用它的Unwrap方法,否则返回 nil。一般来说,更推荐使用errors.Iserros.As,毕竟它们会检查整个错误链。

使用%w包裹error

上文提过,fmt.Errorf函数可以增加新的信息到已有的 error。

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

在 Go1.13,fmt.Errorf函数增加了%w动词,它表示fmt.Errorf返回一个包含Unwrap方法的 error,其他方面%w等价于%v

if err != nil {
    // 返回一个包含 err 的错误
    return fmt.Errorf("decompress %v: %w", name, err)
}

%w包裹的一个 error 可用于errors.Iserrors.As:

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

是否包裹 error

当增加额外的上下文到 error,使用fmt.Errorf或者自定义,你需要决定新的 error 是否需要包裹原 error。答案不是唯一的,它取决于创建的新 error。如果要暴露原 error 给调用者,就包裹起来。如果不想暴露实现细节,就不要包裹。

比如,想象一个从io.Reader读取复杂数据结构的Parse函数。如果发生错误,我们希望报道出错的行数和列数。如果io.Reader读取时出错,我们降期望包裹底层的 error,以便检查。由于调用者提供了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,即使你切换到别的数据库包。换句话说,包裹一个 error 会使这个 error 成为你的 API 的一部分。如果你不想保证永远支持 error 作为你的 API 的一部分,就不应该包裹这个错误。

有一个重点,无论你是否包裹 error,错误的内容都是一样的。是否包裹 error,是给程序附加信息以便调用者做出更多的选择,或者保留该信息以保留抽象。

自定义错误使用IsAs方法

errors.Is函数把目标 error 和错误链中的 error 依次匹配。默认情况下,如果两者相等,则匹配成功。此外,错误链中的错误通过实现 Is方法,来声明它跟目标错误是否匹配。

比如,考虑到这个错误是 Upspin error 包启发的,它将对比错误和模板,只考虑非零的字段:

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函数也类似。

error和包API

一个返回 error 的包应该描述程序员所依赖的 error 的属性。一个设计良好的包,不能返回不应该被依赖的 error。

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

如果我们希望返回一个可识别的错误条件,如“找不到item”,我们可以返回一个包裹哨兵错误的 error。

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

还有其他的模式可以提供给调用者检查的错误,比如直接返回哨兵错误,特定类型,或者使用特定的函数检测。

所有情况下,都应该注意不要将内部细节暴露给用户。正如我们上面聊到的是否包裹错误,当你返回一个其他包的 error ,你应该把它转换成另一个不暴露底层的错误,除非你愿意承诺将来还维护这个特定错误。

f, err := os.Open(filename)
if err != nil {
    // os.Open 返回的 *os.PathError 是内部细节
    // 为了避免暴露给调用者,重新打包成新的 error,error 内容一样。
    // 我们使用 %v 而不是 %w,%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 { … }
        // 如果我们增加额外的信息到 error,会出问题。为了避免这种情况,应该返回一个抱着哨兵错误的 error。调用者必须这样判断:
        // if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
       
        return fmt.Errorf("%w", ErrPermission)
    }
    // ...
}

结论

虽然我们只提及了3个函数和1个格式动词,我们希望他们大大改善 Go 程序中的错误处理方程。我们希望把上下文包裹进 error 变得通用,帮助程序更好的决策和帮助程序员更快地发现 bug。

引用

[1]. 本文链接:https://go.dev/blog/go1.13-errors

分类:

后端

标签:

Golang

作者介绍

小马别过河
V1