卷起来,老程序员也得了解errors包的新变化

Go 1.13 中errors包有了一些变化,这些变化是为了更好地支持Go的错误处理提案。Go 1.20中也增加了一个新方法,这个新方法可以代替第三方的库处理多个error,这篇文章将介绍这些变化。

因为原来的Go的errors中的内容非常的简单,可能会导致大家轻视这个包,对于新的变化不是那么的关注。让我们一一介绍这些新的方法。

Unwrap

如果一个err实现了Unwrap函数,那么errors.Unwrap会返回这个err的unwrap方法的结果,否则返回nil。
一般标准的error都没有实现Unwrap方法,比如io.EOF, 但是也有一小部分的error实现了Unwrap方法,比如os.PathErroros.LinkErroros.SyscallErrornet.OpErrornet.DNSConfigError等等。

比如下面的代码:

1
2
3
fmt.Println(errors.Unwrap(io.EOF)) // nil
_, err := net.Dial("tcp", "invalid.address:80")
fmt.Println(errors.Unwrap(err))

第一行因为io.EOF没有Unwrap方法,所以输出nil。
net.Dial失败返回的err是*net.OpError,它实现了Unwrap方法,返回更底层的*net.DNSError,所以第二行输出为lookup invalid.address: no such host

最常用的,我们使用fmt.Errorf + %w包装一个error,比如下面的代码:

1
2
3
4
5
e1 := fmt.Errorf("e1: %w", io.EOF)
e2 := fmt.Errorf("e2: %w + %w", e1, io.ErrClosedPipe)
e3 := fmt.Errorf("e3: %w", e2)
e4 := fmt.Errorf("e4: %w", e3)
fmt.Println(errors.Unwrap(e4)) // e3: e2: e1: EOF + io: read/write on closed pipe

这段代码逐层进行了包装,最后的e4包含了所有的error,我们可以通过errors.Unwrap逐层进行解包,直到最底层的error。
fmt.Errorf可以1一次包装多个error,比如上面的e2,它包含了e1io.ErrClosedPipe两个error。

我们常常在多层调用的时候,把最底层的error逐层包装传递上去,这个时候我们可以使用fmt.Errorf + %w包装error。
在最高层处理error的时候,再逐层Unwrap解开error,逐层处理。

Is

Is函数检查error的树中是否包含指定的目标error。

啥是error的? 一个error的数包括它本身,以及通过Unwrap方法逐层解开的error。
error的Unwrap方法的返回值,可能是单个error,也可能是是多个error,在返回多个error的时候,会采用深度优先的方式进行遍历检查,寻找目标error。

怎么才算找到目标error呢?一种情况就是此err就是目标error,这没有什么好说的,第二种就是此err实现了Is(err)方法,把目标err扔进Is方法返回true。

所以从功能上看Is函数其实叫做Has函数更贴切些。

下面是一个例子:

1
2
3
4
5
6
7
e1 := fmt.Errorf("e1: %w", io.EOF)
e2 := fmt.Errorf("e2: %w + %w", e1, io.ErrClosedPipe)
e3 := fmt.Errorf("e3: %w", e2)
e4 := fmt.Errorf("e4: %w", e3)
fmt.Println(errors.Is(e4, io.EOF)) // true
fmt.Println(errors.Is(e4, io.ErrClosedPipe)) // true
fmt.Println(errors.Is(e4, io.ErrUnexpectedEOF)) // false

As

Is是遍历error的数,检查是否包含目标error。
As是遍历error的数,检查每一个error,看看是否可以把从error赋值给目标变量,如果是,则返回true,并且目标变量已赋值,否则返回false。

下面这个例子,我们可以看到As的用法:

1
2
3
4
5
6
7
8
if _, err := os.Open("non-existing"); err != nil {
var pathError *fs.PathError
if errors.As(err, &pathError) {
fmt.Println("failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
}

如果os.Open返回的error的树中包含*fs.PathError,那么errors.As会把这个error赋值给pathError变量,并且返回true,否则返回false。
我们这个例子正好制造的就是文件不存在的error,所以它会输出:failed at path: non-existing

经常常犯的一个错误就是我们使用一个error变量作为As的第二个参数。下面这个例子tmp就是error接口类型,所以origin可以直接赋值给tmp,所以errors.As返回true,并且tmp的值就是origin的值。

1
2
3
4
5
6
var origin = fmt.Errorf("error: %w", io.EOF)
var tmp = io.ErrClosedPipe
if errors.As(origin, &tmp) {
fmt.Println(tmp) // error: EOF
}

As使用起来总是那么别别扭扭,每次总得声明一个变量,然后把这个变量传递给As函数,在Go支持泛型之后,As应该可以简化成如下的方式:

1
func As[T error](err error) (T, bool)

但是,Go不会修改这个导致不兼容的API,所以我们只能继续保留As函数,增加一个新的函数是一个可行的方法,无论它叫做IsAAsOf还是AsTarget或者其他。

如果你已经掌握了Go的泛型,你可以自己实现一个As函数,比如下面的代码:

1
2
3
4
5
6
7
8
9
func AsA[T error](err error) (T, bool) {
var isErr T
if errors.As(err, &isErr) {
return isErr, true
}
var zero T
return zero, false
}

写段测试代码,我们可以看到它的效果:

1
2
3
4
5
6
7
8
9
type MyError struct{}
func (*MyError) Error() string { return "MyError" }
func main() {
var err error = fmt.Errorf("error: %w", &MyError{})
m, ok := AsA[*MyError](err) // MyError does not implement error (Error method has pointer receiver)
fmt.Println(m, ok)
}

大家在#51945讨论了一段时间,又是无疾而终了。

Join

在我们的项目中,有时候需要处理多个error,比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (s *Server) Serve() error {
var errs []error
if err := s.init(); err != nil {
errs = append(errs, err)
}
if err := s.start(); err != nil {
errs = append(errs, err)
}
if err := s.stop(); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return fmt.Errorf("server error: %v", errs)
}
return nil
}

这段代码中,我们需要处理三个error,如果有一个error不为nil,那么我们就返回errs。
当然,为了处理多个errors情况,先前,有很多的第三方库可以供我们使用,比如

  • go.uber.org/multierr
  • github.com/hashicorp/go-multierror
  • github.com/cockroachdb/errors

但是现在,你不用再造轮子或者使用第三方库了,因为Go 1.20中增加了errors.Join函数,它可以把多个error合并成一个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
var e1 = io.EOF
var e2 = io.ErrClosedPipe
var e3 = io.ErrNoProgress
var e4 = io.ErrShortBuffer
_, e5 := net.Dial("tcp", "invalid.address:80")
e6 := os.Remove("/path/to/nonexistent/file")
var e = errors.Join(e1, e2)
e = errors.Join(e, e3)
e = errors.Join(e, e4)
e = errors.Join(e, e5)
e = errors.Join(e, e6)
fmt.Println(e.Error())
// 输出如下,每一个err一行
//
// EOF
// io: read/write on closed pipe
// multiple Read calls return no data or error
// short buffer
// dial tcp: lookup invalid.address: no such host
// remove /path/to/nonexistent/file: no such file or directory
fmt.Println(errors.Unwrap(e)) // nil
fmt.Println(errors.Is(e, e6)) //true
fmt.Println(errors.Is(e, e3)) // true
fmt.Println(errors.Is(e, e1)) // true

你可以使用Is判断是否包含某个error,或者使用As提取出目标error。