:::info 日期:2019 年 10 月 17 日
作者:Damien Neil and Jonathan Amsterdam
原文链接:https://go.dev/blog/go1.13-errors :::

介绍

在过去的十年中,Go 将错误视为值的处理方式为我们提供了很好的帮助。 虽然标准库对错误的支持很少——只有 errors.New 和 fmt.Errorf 函数,它们产生只包含一条消息的错误——内置的错误接口允许 Go 程序员添加他们想要的任何信息。 它所需要的只是一个实现 Error 方法的类型:

  1. type QueryError struct {
  2. Query string
  3. Err error
  4. }
  5. func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
  1. 像这样的错误类型无处不在,它们存储的信息千差万别,从时间戳到文件名再到服务器地址。 通常,该信息包含另一个较低级别的错误以提供额外的上下文。

一个错误包含另一个错误的模式在 Go 代码中非常普遍,经过广泛讨论,Go 1.13 添加了对它的明确支持。 这篇文章描述了提供这种支持的标准库的新增内容:errors 包中的三个新函数,以及 fmt.Errorf 的新格式动词。

在详细描述更改之前,让我们回顾一下在该语言的先前版本中如何检查和构建错误。

Go 1.13 以前的错误处理

检查错误

Go 错误是值。 程序以几种方式根据这些值做出决策。 最常见的是将错误与 nil 进行比较以查看操作是否失败。

  1. if err != nil {
  2. // something went wrong
  3. }
  1. 有时我们将错误与已知标记值进行比较,以查看是否发生了特定错误。
  1. var ErrNotFound = errors.New("not found")
  2. if err == ErrNotFound {
  3. // something wasn't found
  4. }
  1. 错误值可以是满足语言定义的错误接口的任何类型。 程序可以使用类型断言或类型切换来将错误值视为更具体的类型。
  1. type NotFoundError struct {
  2. Name string
  3. }
  4. func (e *NotFoundError) Error() string { return e.Name + ": not found" }
  5. if e, ok := err.(*NotFoundError); ok {
  6. // e.Name wasn't found
  7. }

添加信息

通常,函数在向其添加信息的同时将错误向上传递到调用堆栈,例如对发生错误时发生的情况的简要描述。 一种简单的方法是构造一个包含前一个文本的新错误:

  1. if err != nil {
  2. return fmt.Errorf("decompress %v: %v", name, err)
  3. }
  1. 使用 fmt.Errorf 创建新错误会丢弃原始错误中除文本之外的所有内容。 正如我们在上面的 QueryError 中看到的,我们有时可能想要定义一个包含底层错误的新错误类型,并将其保留以供代码检查。 这里又是 QueryError
  1. type QueryError struct {
  2. Query string
  3. Err error
  4. }
  1. 程序可以查看 *QueryError 值内部以根据潜在错误做出决定。 您有时会看到这称为“解包”错误。
  1. if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
  2. // query failed because of a permission problem
  3. }
  1. 标准库中的 os.PathError 类型是一个错误包含另一个错误的另一个例子。

Go 1.13 错误处理

Unwrap 方法

Go 1.13 为错误和 fmt 标准库包引入了新功能,以简化处理包含其他错误的错误。 其中最重要的是约定而不是更改:包含另一个错误的错误可能会实现返回底层错误的 Unwrap 方法。 如果 e1.Unwrap() 返回 e2,那么我们说 e1 包装了 e2,您可以解开 e1 得到 e2。

按照这个约定,我们可以给 QueryError 类型上面的一个 Unwrap 方法,该方法返回其包含的错误:

  1. func (e *QueryError) Unwrap() error { return e.Err }
  1. 解包错误的结果本身可能有一个 Unwrap 方法; 我们称重复展开错误链所产生的错误序列。

使用 Is 和 As 检查错误

Go 1.13 错误包包括两个用于检查错误的新函数:Is 和 As。 errors.Is 函数将错误与值进行比较。

  1. // Similar to:
  2. // if err == ErrNotFound { … }
  3. if errors.Is(err, ErrNotFound) {
  4. // something wasn't found
  5. }

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

  1. // Similar to:
  2. // if e, ok := err.(*QueryError); ok { … }
  3. var e *QueryError
  4. // Note: *QueryError is the type of the error.
  5. if errors.As(err, &e) {
  6. // err is a *QueryError, and e is set to the error's value
  7. }
  1. 在最简单的情况下,errors.Is 函数的行为就像一个哨兵错误的比较,而 errors.As 函数的行为就像一个类型断言。 但是,在处理包装错误时,这些函数会考虑链中的所有错误。

让我们再次看一下上面解包 QueryError 以检查底层错误的示例:

  1. if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
  2. // query failed because of a permission problem
  3. }
  1. 使用 errors.Is 函数,我们可以将其写为:
  1. if errors.Is(err, ErrPermission) {
  2. // err, or some error that it wraps, is a permission problem
  3. }
  1. errors 包还包含一个新的 Unwrap 函数,该函数返回调用错误的 Unwrap 方法的结果,或者当错误没有 Unwrap 方法时返回 nil 但是,通常最好使用 errors.Is errors.As,因为这些函数将在一次调用中检查整个链。

注意:虽然将指针指向指针可能会感觉奇怪,但在这种情况下它是正确的。 把它想象成一个指向错误类型值的指针; 在这种情况下,返回的错误是指针类型。

使用 %w 包裹错误

如前所述,通常使用 fmt.Errorf 函数向错误添加附加信息。

  1. if err != nil {
  2. return fmt.Errorf("decompress %v: %v", name, err)
  3. }
  1. Go 1.13 中, fmt.Errorf 函数支持新的 %w 动词。 当这个动词存在时,fmt.Errorf 返回的错误会有一个 Unwrap 方法返回 %w 的参数,这肯定是一个错误。 在所有其他方面,%w %v 相同。
  1. if err != nil {
  2. // Return an error which unwraps to err.
  3. return fmt.Errorf("decompress %v: %w", name, err)
  4. }
  1. %w 包装错误使其可用于 errors.Is errors.As
  1. err := fmt.Errorf("access denied: %w", ErrPermission)
  2. ...
  3. 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),那么调用者可以合理地写

  1. err := pkg.LookupUser(...)
  2. if errors.Is(err, sql.ErrNoRows)

此时,如果您不想破坏客户端,即使您切换到不同的数据库包,该函数也必须始终返回 sql.ErrNoRows。 换句话说,包装错误会使该错误成为 API 的一部分。 如果您不想承诺在将来支持该错误作为 API 的一部分,则不应包装该错误。

重要的是要记住,无论是否换行,错误文本都是一样的。 试图理解错误的人会以任何一种方式获得相同的信息; 包装的选择是关于是否为程序提供附加信息以便他们可以做出更明智的决定,或者保留该信息以保留抽象层。

使用 Is 和 As 方法自定义错误测试

errors.Is 函数检查链中的每个错误是否与目标值匹配。 默认情况下,如果两者相等,则错误与目标匹配。 此外,链中的错误可能会通过实现 Is 方法声明它与目标匹配。

例如,考虑受 Upspin 错误包启发的这个错误,它将错误与模板进行比较,仅考虑模板中的非零字段:

  1. type Error struct {
  2. Path string
  3. User string
  4. }
  5. func (e *Error) Is(target error) bool {
  6. t, ok := target.(*Error)
  7. if !ok {
  8. return false
  9. }
  10. return (e.Path == t.Path || t.Path == "") &&
  11. (e.User == t.User || t.User == "")
  12. }
  13. if errors.Is(err, &Error{User: "someuser"}) {
  14. // err's User field is "someuser".
  15. }
  1. errors.As 函数在出现时类似地查询 As 方法。

错误和 API

一个返回错误的包(大多数都这样做)应该描述程序员可能依赖的那些错误的属性。

一个设计良好的包还可以避免返回不应该依赖的属性的错误。 最简单的规范是说操作要么成功要么失败,分别返回一个 nil 或非 nil 错误值。 在许多情况下,不需要进一步的信息。

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

  1. var ErrNotFound = errors.New("not found")
  2. // FetchItem returns the named item.
  3. //
  4. // If no item with the name exists, FetchItem returns an error
  5. // wrapping ErrNotFound.
  6. func FetchItem(name string) (*Item, error) {
  7. if itemNotFound(name) {
  8. return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
  9. }
  10. // ...
  11. }
  1. 还有其他现有模式可用于提供可由调用者在语义上检查的错误,例如直接返回标记值、特定类型或可使用谓词函数检查的值。

在所有情况下,都应注意不要将内部细节暴露给用户。 正如我们在上面的“是否要包装”中提到的那样,当您从另一个包返回错误时,您应该将错误转换为不暴露底层错误的形式,除非您愿意承诺在将来返回该特定错误 .

  1. f, err := os.Open(filename)
  2. if err != nil {
  3. // The *os.PathError returned by os.Open is an internal detail.
  4. // To avoid exposing it to the caller, repackage it as a new
  5. // error with the same text. We use the %v formatting verb, since
  6. // %w would permit the caller to unwrap the original *os.PathError.
  7. return fmt.Errorf("%v", err)
  8. }

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

  1. var ErrPermission = errors.New("permission denied")
  2. // DoSomething returns an error wrapping ErrPermission if the user
  3. // does not have permission to do something.
  4. func DoSomething() error {
  5. if !userHasPermission() {
  6. // If we return ErrPermission directly, callers might come
  7. // to depend on the exact error value, writing code like this:
  8. //
  9. // if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
  10. //
  11. // This will cause problems if we want to add additional
  12. // context to the error in the future. To avoid this, we
  13. // return an error wrapping the sentinel so that users must
  14. // always unwrap it:
  15. //
  16. // if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
  17. return fmt.Errorf("%w", ErrPermission)
  18. }
  19. // ...
  20. }

总结

尽管我们讨论的更改仅包含三个函数和一个格式化动词,但我们希望它们对改进 Go 程序中的错误处理方式大有帮助。 我们希望提供额外上下文的包装将变得司空见惯,帮助程序做出更好的决策并帮助程序员更快地找到错误。

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