翻译自: https://earthly.dev/blog/golang-errors/

Go语言的错误处理与其他语言(Java、JavaScript、Python)主流编程语言有些不一样。Go内置errors并不包含

堆栈信息,也不支持常规的try/catch处理方式。相反,Go语言中的errors可以被函数返回,也能如其他数据类型一样进行处理—-这就导致了error是一种轻量、简单的设计。

在这篇文章中,我会演示Go语言erros基本处理方式和一些在你代码中可能会用到的、用于增强程序健壮性和调试性的策略。

Error类型

Go语言中error被定义为接口:

1
2
3
type error interface {
  Error() string
}

所以任何实现了Error()方法的类型都是error,该方法返回一个字符串信息。

构造Errors

可以通过使用Go内置的errors或者fmt包构造Errors。如下就是使用errors包构造一个静态错误消息:

1
2
3
4
5
6
7
package main

import "errors"

func DoSomething() error {
    return errors.New("something didn't work")
}

类似的,fmt包可以用来添加一些动态信息(如int、string或者其他error)。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

import "fmt"

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, fmt.Errorf("can't divide '%d' by zero", a)
    }
    return a / b, nil
}

值得注意的是,当使用%w包装另一个error时,fmt.Errorf会非常有用—-我将在后文中进一步讨论这个问题。

在上面的实例中有几个注意的事项:

  • Errors可以是nil,其实也是默认值。因此检查if err !=nil是非常重要的经典用法(而不是像你所熟悉的其他语言使用try/catch方式)
  • Errors通常作为最后一个参数返回。所以在我们的例子中,返回的顺序是interror
  • 当函数返回一个有效的error时,其他返回的值通常是对应类型的默认零值。函数的调用者会认为当返回一个非nil的error时,其他返回参数是无效的。
  • 最后,error信息通常都是小写的,且结尾没有标点符号。但也有例外,比如包含专有名词,函数名字第一个大写等。

定义期望的Errors

在Go中另一个比较重要的技巧就是定义期望的Errors,以便在其他的代码块中对其做显示检查。这在你期望当遇到某种错误后执行另一个分支逻辑时就很有用了。

定义哨兵Errors

在上面的Divide函数中,我们可以通过预先定一个哨兵error来改善错误处理。调用者就可以使用errors.Is对此错误进行显示检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
    "errors"
    "fmt"
)

var ErrDivideByZero = errors.New("divide by zero")

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, ErrDivideByZero
    }
    return a / b, nil
}

func main() {
    a, b := 10, 0
    result, err := Divide(a, b)
    if err != nil {
        switch {
        case errors.Is(err, ErrDivideByZero):
            fmt.Println("divide by zero error")
        default:
            fmt.Printf("unexpected division error: %s\n", err)
        }
        return
    }

    fmt.Printf("%d / %d = %d\n", a, b, result)
}

定义自定义错误类型

上述的策略可以处理一些错误场景,但是,你可能需要更多的功能。比如你可能想给error添加一些数据,或者在该error打印时输出一些动态数据信息。

你可以通过自定义错误类型来实现。

下面是上述实例的一个简单重写版本。定义了一个新类型DivisionError,实现了Error接口。我们可以使用errors.As检查该错误,或者将一个标准error转换为定制的DivisionError

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
    "errors"
    "fmt"
)

type DivisionError struct {
    IntA int
    IntB int
    Msg  string
}

func (e *DivisionError) Error() string { 
    return e.Msg
}

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, &DivisionError{
            Msg: fmt.Sprintf("cannot divide '%d' by zero", a),
            IntA: a, IntB: b,
        }
    }
    return a / b, nil
}

func main() {
    a, b := 10, 0
    result, err := Divide(a, b)
    if err != nil {
        var divErr *DivisionError
        switch {
        case errors.As(err, &divErr):
            fmt.Printf("%d / %d is not mathematically valid: %s\n",
              divErr.IntA, divErr.IntB, divErr.Error())
        default:
            fmt.Printf("unexpected division error: %s\n", err)
        }
        return
    }

    fmt.Printf("%d / %d = %d\n", a, b, result)
}

注意:如果有需要的话,你可以自定义errros.Iserrors.As行为。参考this Go.dev blog

注意2:errors.Is在Go1.13中才加入,在此之前都是使用err ==...

Wrapping Errors

目前为止,示例中的errors都是在单个函数调用中创建、处理的。换句话说,涉及到的函数都只有一层深度。

实际中,一般会遇到多层函数调用—-从error产生到被处理,会有很多函数涉及。

GO1.13引入了几个error相关的API,例如errors.Wraperrors.Unwrap,这两个函数在给一个error添加额外信息往上层抛出,同时检查是否是某一错误类型时(不管其错误被wrap了多少次)非常有用。

一丁点历史:2019年Go1.13发布之前,标准库中处理基础的errors.Newfmt.Errorf之外并没有包含处理errors的API。因此,你可能会遇到传统Go程序没有实现新的errorAPI。一些老的代码可能使用三方error库如pkg/errors。最终,2018年一分正式的提案引入了我们在今天Go1.13中所见到的特性。

老的处理方式(在GO1.13之前)

通过观察在老的错误处理示例就能看到在GO1.13引入的API是多么有用。

假设我们正在处理一个用户数据库,在这个程序中,我们有些函数会涉及数据库错误的生命周期管理。

为了简化,我们使用一个假想的数据库example.com/fake/users/db来替代真实的数据库。

我们同时假设这个假想数据库包含了能查询和更新用户记录的函数,并且这些用户数据结构定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package db

type User struct {
  ID       string
  Username string
  Age      int
}

func FindUser(username string) (*User, error) { /* ... */ }
func SetUserAge(user *User, age int) error { /* ... */ }

下面是我们的示例程序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
    "errors"
    "fmt"

    "example.com/fake/users/db"
)

func FindUser(username string) (*db.User, error) {
    return db.Find(username)
}

func SetUserAge(u *db.User, age int) error {
    return db.SetAge(u, age)
}

func FindAndSetUserAge(username string, age int) error {
  var user *User
  var err error

  user, err = FindUser(username)
  if err != nil {
      return err
  }

  if err = SetUserAge(user, age); err != nil {
      return err
  }

  return nil
}

func main() {
    if err := FindAndSetUserAge("bob@example.com", 21); err != nil {
        fmt.Println("failed finding or updating user: %s", err)
        return
    }

    fmt.Println("successfully updated user's age")
}

当一些格式不正确的请求导致我们某个数据库操作失败,会发生什么呢?

main函数中的error 打印如下:

1
failed finding or updating user: malformed request

但是具体是哪一个数据库操作导致的这个错误呢?不幸的是我们没有足够的日志信息去分别具体是哪一个。

Go1.13提供了一些简单的方式去填写这些信息

Errors Are Better Wrapped

下面的代码使用带有%w格式的fmt.Errorf函数重构过,通过wrap错误,返回给调用者。这样就携带了需要的上下文去辨别具体是哪个数据库操作导致的失败了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
    "errors"
    "fmt"

    "example.com/fake/users/db"
)

func FindUser(username string) (*db.User, error) {
    u, err := db.Find(username)
    if err != nil {
        return nil, fmt.Errorf("FindUser: failed executing db query: %w", err)
    }
    return u, nil
}

func SetUserAge(u *db.User, age int) error {
    if err := db.SetAge(u, age); err != nil {
      return fmt.Errorf("SetUserAge: failed executing db update: %w", err)
    }
}

func FindAndSetUserAge(username string, age int) error {
  var user *User
  var err error

  user, err = FindUser(username)
  if err != nil {
      return fmt.Errorf("FindAndSetUserAge: %w", err)
  }

  if err = SetUserAge(user, age); err != nil {
      return fmt.Errorf("FindAndSetUserAge: %w", err)
  }

  return nil
}

func main() {
    if err := FindAndSetUserAge("bob@example.com", 21); err != nil {
        fmt.Println("failed finding or updating user: %s", err)
        return
    }

    fmt.Println("successfully updated user's age")
}

我们重新运行程序,错误输出如下:

1
failed finding or updating user: FindAndSetUserAge: SetUserAge: failed executing db update: malformed request

现在我们就能发现问题来源于db.SetUserAge函数,这确实节省了我们很多debug时间。

使用得当的话,error wrapping 就类似于传统的stack-trace一样提供了额外的上下文信息。

不管对一个error wrap 了多少次 errors.Iserror.As都能正常运作。同时我们可以调用errors.Unwrap得到里层的error

想知道error wrapping是如何工作的,可以参见fmt.Errorfthe %w verbthe errors API的实现

什么时候Wrap

通常,当需要将error往上层抛出时,都需要wrap函数名。

但是也有一些例外的情况,此时不适合wrap error

因为wrapping error会保留原始的错误信息,有时这也意味着暴露了底层细节、不安全等信息。这种情况下,将error进行处理并且返回一个新的错误是更合适的。当你在写一个开源库或者REST API,同时并不想暴露底层error信息给第三方使用者时,尤其如此。

总结

简而言之,以下是文本的要点:

  • Go语言中的Error是一个实现了Error 接口的轻量级数据结构
  • 当我们需要处理某种错误时,预定义哨兵error更有用
  • 通过wrap errors 向error添加额外的调用链路信息(类似于一个stack strace)

我希望这篇关于高效处理error指南会对你有用。如果您想了解更多,我将呈现更多文章,这些文章都是我在Go中做错误处理时总结发现的。

参考