0%

《Go 语言精进之路》读书笔记(07):错误处理

Go 十分重视错误处理,它有着相对保守的设计和显式处理错误的惯例。这篇文章将聚焦 Go 中的错误处理。

了解错误处理的 4 种策略

1
2
3
世界上有两类编程语言,一类是总被人抱怨和诟病的,而另一类是无人使用的。

-- C++ 之父 Bjarne Stroustrup

Go 自诞生以来,就因其简单看起来有些过时的错误出来机制(error handling)而被大家诟病,直到今天这种声音依旧存在。

Go 语言没有提供像 C++、Java、Python 等主流编程语言那样基于异常(exception)的结构化 try-catch-finally 错误处理机制,Go 的设计者认为将程序异常耦合到程序控制结构中会导致代码混乱,并且在那样的机制下,程序员会将大多常见错误标记为异常,这与 Go 追求简单的价值观背道而驰。

Go 的设计决策者选择了 C 语言家族经典错误处理机制:错误就是值,而错误处理就是基于值比较后的决策。同时 Go 结合函数方法的多返回机制避免了像 C 语言那样在单一函数返回值中承载多重信息的问题。

Go 这种简单的基于错误值比较的错误处理机制使得每个 Go 开发人员必须显式地关注和处理每个错误,经过显式错误处理的代码会更健壮。Go 的错误不是异常,它就是普通值,我们不需要额外的语言处理机制去处理它们,而只需要利用已有的语言机制,像处理其他普通类型值一样去处理错误。没有 try-catch-finally 这样的异常处理机制也让 Go 代码的可读性更佳。

这些年 Go 核心开发团队与 Go 社区已经形成了 4 种惯用的 Go 错误处理策略。

构造错误值

错误是指值,只不过以 error 接口变量的形式统一呈现:按照惯例,函数或者方法通常将 error 类型返回值放在返回值列表的末尾:

1
2
var err error
err = errors.New("something went wrong")

error 接口是 Go 原生内置的类型,它的定义如下:

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

任何实现了 Error() string 方法的类型实例均可作为错误值赋给 error 接口变量。在标准库中,提供了构造错误值的两种基本方法:

  • errors.New()
  • fmt.Errorf()
1
2
3
err := errors.New("your first demo error")
errWithCtx = fmt.Errorf("index %d is out of bounds", i)
wrapErr := fmt.Errorf("wrap error: %w", err)

当在格式化字符串中使用 %w 时,fmt.Errorf 返回的错误值的底层类型为 fmt.wrapError

1
2
3
4
5
6
7
8
9
10
11
12
type wrapError struct {
msg string
err error
}

func (e *wrapError) Error() string {
return e.msg
}

func (e *wrapError) Unwrap() error {
retuen e.err
}

wrapError 多实现了 Unwrap 方法,这使得被 wrapError 类型包装的错误值在包装错误链中被检视到:

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

import (
"errors"
"fmt"
)

func main() {
var ErrFoo = errors.New("the underlaying error")
err := fmt.Errorf("wrap err: %w", ErrFoo)
fmt.Println(err)
println(errors.Is(err, ErrFoo))
}
1
2
3
# ./main
wrap err: the underlaying error
true

虽然标准库提供的构建错误值的方法很方便,但是给错误处理者提供的 错误上下文 则仅限于以字符串形式呈现的信息(Error 方法返回的信息)。如果需要从错误值中提取出更多信息以帮助其选择错误处理路径,这时可以选择自定义错误类型来满足需求。例如标准库的 net 包就定义了一种携带额外错误上下文的类型:

1
2
3
4
5
6
7
8
// $GOROOT/src/net/net.go
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error
}

error 接口是错误提供值与错误值检视之间的契约。error 接口的实现者负责提供错误上下文供负责错误处理的代码使用。这种错误上下文与 error 接口类型的分离体现了 Go 设计哲学中的 正交 概念。

透明错误处理策略

Go 语言中的错误处理就是根据函数/方法返回的 error 类型变量中携带的错误值信息做决策并选择后续代码执行路径的过程。最简单的错误处理策略就是完全不关心返回错误值携带的具体上下文信息,只要发生错误就进入唯一的错误处理执行路径。这也是 Go 中最常见的错误处理策略:

1
2
3
4
err :=  doSomething()
if err != nil {
return err
}

由于错误处理方并不关心错误值的上下文,因此错误值的构造方可以直接使用 Go 标准库提供的两个基本错误值构造方法:errors.New()fmt.Errorf()。这样勾出的错误值对错误处理方是透明的,因此这种策略被称为 透明错误处理策略。这种错误处理策略最大限度地减少了错误处理方与错误值构造方之间的耦合关系。

哨兵错误处理策略

如果不能仅根据错误值就做出错误处理路径的选取决策,错误处理方就会尝试对返回的错误值进行检视。如果以透明错误值所能提供的唯一上下文信息(字符串)作为错误处理路径的依据,这种反模式会造成严重的隐式耦合,而且这种通过字符串比较的方式对错误值进行检视的性能也很差。

Go 标准库采用了定义导出的(exported)哨兵 错误值的方式来辅助错误处理方检视错误值并做出错误处理分支的决策:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var (
ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
ErrBufferFull = errors.New("bufio: buffer full")
)

data, err := b.Peek(1)
if err != nil {
switch err {
case bufio.ErrInvalideUnreadByte:
......
return
case bufio.ErrInvalidUnreadRune:
......
return
case bufio.ErrBufferFull:
....
return
}
}

一般哨兵错误变量以 ErrXXX 格式命名,与透明错误策略相比,哨兵 策略让错误处理方在有检视错误值的需求时有的放矢。

标准库的 errors 包提供了 Is 方法用于错误处理方对错误值进行检视。Is 方法类似于将一个 error 类型变量与一个哨兵错误值进行比较,不同的是:如果 error 类型变量的底层错误是一个包装错误(wrap error),errors.Is 方法会沿着该包装错误所在错误链与链上所有被包装的错误进行计较,直到找到一个匹配的错误

1
2
3
4
// 类似 if err == ErrOutOfBounds{ … }
if errors.Is(err, ErrOutOfBounds) {
// 越界的错误处理
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"errors"
"fmt"
)

var ErrSentinel = errors.New("The underlaying sentinel error")

func main() {
err1 := fmt.Errorf("wrap err1: %w", ErrSentinel)
err2 := fmt.Errorf("wrap err2: %w", err1)

if errors.Is(err2, ErrSentinel) {
println("err is ErrSentinel")
return
}

println("err is not ErrSentinel")
}

所以应该尽量使用 errors.Is 方法来检视某个错误值是不是某个特定的哨兵错误值。

错误值类型检视策略

如果错误处理方需要错误值提供更多的错误上下文,上述透明错误处理策略和哨兵错误处理策略都不能满足要求。

我们可以通过 自定义错误类型 的构造错误值的方式来提供更多的错误上下文信息,由于错误值均通过 error 接口变量统一呈现,因此要得到底层错误类型携带的错误上下文信息,错误处理方需要使用 Go 的类型断言机制(type assertion)或者类型选择机制(type switch)。这种错误处理可以称为 错误值类型检视策略

一般自定义导出的错误类型以 XXXError 的形式命名。标准库 errors 包提供了 As 方法用于错误处理方对错误值进行检视。As 方法通过类似于类型断言判断一个 error 变量是否为特定的、自定义错误类型。不同的是,如果 error 类型变量的底层错误值是一个包装错误,那么 errors.As 方法会沿着该包装错误所在错误链与链上所有被包装的错误类型进行比较,直到找到一个匹配的错误类型:

1
2
3
4
var e *MyError
if errors.As(err, &e) {
......
}

如下是一个示例:

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"
)

type MyError struct {
e string
}

func (e *MyError) Error() string {
return e.e
}

func main() {
var err = &MyError{
e: "my error type",
}

err1 := fmt.Errorf("wrap err1: %w", err)
err2 := fmt.Errorf("wrap err2: %w", err1)
var e *MyError
if errors.As(err2, &e) {
println("MyError is on the chain of err2")
println(e == err)
return
}

println("MyError is not on the chain of err2")
}
1
2
3
# ./main
MyError is on the chain of err2
true

所以应该尽量使用 errors.As 方法去检视某个错误值是不是某个自定义错误类型的实例

错误行为特征检视策略

除了透明错误处理策略外,还有一种方法可以降低错误处理方与错误值构造方之间的耦合:将某个包中的错误进行归类,统一提取出一些公共的错误行为特征,并将这些错误行为特征放入一个公开的接口类型中。

例如标准库的 net 包将包内的所有错误类型的公共行为特征抽象并放入 net.Error 这个接口中。而错误处理方法仅需要依赖这个公共的接口即可检视具体错误值的错误行为特征信息,并根据这些信息做出后续错误处理分支选择的决策。

1
2
3
4
5
6
// $GOROOT/src/net/net.go
type Error interface {
error
Timeout() bool // 是超时类错误吗?
Temporary() bool // 是临时性错误吗?
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// $GOROOT/src/net/http/server.go
func (srv *Server) Serve(l net.Listener) error {
...
for {
rw, e := l.Accept()
if e != nil {
......
if ne, ok := e.(net.Error); ok && ne.Temporary() {
// 这里对临时性错误进行处理
...
time.Sleep(tempDelay)
continue
}
return e
}
...
}
...
}

尽量优化反复出现的 if err != nil

Go 选择的 使用显式错误结果和显示错误检查 策略,可能会导致代码中出现大量的 if err != nil 错误检查语句。

两种观点

一种观点认为:Go 的成功很大程度上要归功于显式地错误处理方式,因为它让 Go 程序员首先考虑失败的情况,这将引导 Go 程序员在编写代码时处理故障,而不是在程序部署并运行在生产环境后再处理。

但另一种观点认为 Go 错误处理方式在有些时候显得冗长和有些啰嗦。

尽量优化

我们可以通过良好的设计减少或消除这类反复出现的错误检查,对反复出现的 if err != nil 尽可能优化。

优化思路

优化反复出现的 if err != nil 语句,其根本目的是让错误检查和处理较少,不要干扰正常的业务代码,让正常业务代码更具有视觉连续性。因此大致有两个优化方向:

  • 改善代码的视觉呈现
  • 降低 if err != nil 重复的次数,如果觉得 if err != nil 重复次数过多,可以降低其出现的次数,这其实是将该问题转换为降低函数/方法的复杂度

具有有如下优化思路:

  • 视觉扁平化:由于 Go 支持将触发错误处理的语句与错误处理代码放在一行,因此从视觉上可以呈现出优化。当然如果错误处理分支代码比较多,这种方法就不太适合了
1
2
3
4
5
func SomeFunc() error {
if err := doStuff1(); err != nil { // 处理错误 }
if err := doStuff2(); err != nil { // 处理错误 }
if err := doStuff3(); err != nil { // 处理错误 }
}
  • 重构,减少 if err != nil 的重复次数:沿着降低复杂度的方向对待优化的代码进行重构,以减少 if err != nil 代码片段的重复次数
  • check/handle 风格化:可以利用 panic 和 recover 封装一套跳转机制,模拟实现一套 check/handle 机制。如下是一个示例:
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
func check(err error) {
if err != nil {
panic(err)
}
}

func CopyFile(src, dst string) (err error) {
var r, w *os.File

defer func() {
if r != nil {
r.Close()
}

if w != nil {
w.Close()
}

if e := recover(); e != nil {
if w != nil {
os.Remove(dst)
}

err = fmt.Errorf("copy %s %s: %v", src, dst, err)
}
}()

r, err = os.Open(src)
check(err)

w, err = os.Open(dst)
check(err)

_, err = io.Copy(w, r)
check(err)

return nil
}

当然这种优化方案也具有一定的约束,例如函数必须使用具名的 error 返回值,使用 panic 和 recover 也有额外的性能开销。

  • 封装:内置 error 状态。bufio 包的 Writer 就是使用这个思路来避免反复出现的 if err != nil 语句。代码如下所示:
1
2
3
4
5
6
7
8
b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])

if b.Flush() != nil {
return b.Flush()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// $GOROOT/src/bufio/bufio.go
type Writer struct {
err error
buf []byte
n int
wr io.Writer
}

func (b *Writer) Write(p []byte) (nn int, err error) {
for len(p) > b.Available() && b.err == nil {
...
}
if b.err != nil {
return nn, b.err
}
......
return nn, nil
}

可以看到错误状态被封装在 bufio.Writer 结构的内部,它使用了一个 err 字段作为内部错误状态值,它与 Writer 的实例绑定在一起,并且在 Write 方法的入口判断是否为 nil。一旦不为 nil,Write 什么也不做就会返回。

以下示例通过这种方法重写了 CopyFile:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import
(
"io"
"os"
)

type FileCopier struct {
w *os.File
r *os.File
err error
}

func (f *FileCopier) open(path string) (*os.File, error) {
if f.err != nil {
return nil, f.err
}

h, err := os.Open(path)
if err != nil {
f.err = err
return nil, err
}

return h, nil
}

func (f *FileCopier) openSrc(path string) {
if f.err != nil {
return
}

f.r, f.err = f.open(path)
return
}

func (f *FileCopier) createDst(path string) {
if f.err != nil {
return
}

f.w, f.err = os.Create(path)
return
}

func (f *FileCopier) copy() {
if f.err != nil {
return
}

if _, err := io.Copy(f.w, f.r); err != nil {
f.err = err
}
}

func (f *FileCopier) CopyFile(src, dst string) error {
if f.err != nil {
return f.err
}

defer func() {
if f.r != nil {
f.r.Close()
}

if f.w != nil {
f.w.Close()
}

if f.err != nil {
if f.w != nil {
os.Remove(dst)
}
}
}()

f.openSrc(src)
f.createDst(dst)
f.copy()
return f.err
}

不要使用 panic 进行正常的错误处理

Go 使用了 panic 专门用于处理异常,而我们建议不要使用 panic 进行正常的错误处理。

Go 的 panic 不是 Java 的 checked exception

Java 的 checked exception 用于一些可预见的、常会发生的错误场景。针对 checked exception 的所谓异常处理就是针对这些场景的错误处预案。如果要与 Go 的某种语法对应,它对应的应该是 Go 的正常错误处理,即基于显式 error 模型的显式错误处理。

而 panic 是一个 Go 的内置函数,用来停止当前常规控制流并启动 panicking 流程。当函数 F 调用 panic 时,F 的执行立即中止。函数 F 中已经进行求值的 defer 函数都将得到正常执行,然后函数 F 将控制权返回给其调用者。之后该 panicking 将继续在栈上进行下去,直到当前 goroutine 中的所有函数都返回为止,此时程序将崩溃退出。panic 可以通过直接调用 panic 函数来引发,也有可能是由运行时错误引起的。

因此 Go 中的 panic 是不得已而为之,引发 panic 的情形很少有预案能够应对,更多的是让程序崩溃掉。

对于上层调用者而言,即 API 调用者根本不会逐一了解 API 是否会引发 panic,也没有义务去处理引发的 panic。一旦 panic 没有被捕获(recover),整个 Go 程序也就终止了。

panic 的典型应用

我们要尽量少用 panic,避免给上层带去它们无法处理的情况。但是少用不代表不用,如何更好地使用 panic,Go 标准库给我们一些启示:

  • 充当断言角色,提示潜在 bug。在标准库中,大多数 panic 都是充当类似断言的作用的
  • 用于简化错误处理控制结构:panic 的语义机制决定了它可以在函数栈间游走,直到被某函数栈上的 defer 函数中的 recover 捕获,因此它一定程度上可以用于简化错误处理的控制结构。上文中的 check/handler 错误处理机制就是利用了 panic 这个特性
  • 使用 recover 捕获 panic,防止 goroutine 意外退出:之前说过,无论在哪个 goroutine 中发生未被捕获的 panic,整个程序都将崩溃退出。但是有时我们必须限制这种危害,保持程序的健壮性。例如标准库 http 包的 serve 函数就是典型代表:在处理某个连接的 goroutine 引发的 panic 时,我们需要保证应用程序本身以及处理其他连接的 goroutine 仍然是正常运行的

理解 panic 的输出信息

对于 panic 导致的程序崩溃,首先需要检查位于栈顶的栈跟踪信息,并定位到直接引发 panic 的那一行代码,多数情况下通过这行代码就可以直接揪出导致问题的元凶。

如果还需要检视整个函数调用栈,那么就需要识别出 panic 后整个栈帧信息:

  • 栈跟踪信息中每个函数/方法后面的 参数数值个数 与函数/方法原型的参数个数是否一致
  • 栈跟踪信息中的每个函数/方法后面的 参数数值 是否按照函数/方法原型列表中从左到右的参数类型的内存布局一一展开的,每个数值占用一个字(word)
  • 如果是方法,则第一个参数是 receiver 自身
  • 函数/方法返回值放在栈跟踪信息的 参数数值 列表的后面,如果有多个返回值,则同样从左到右的顺序,按照返回值类型的内存布局输出

由于编译器的优化,很多简单的函数或方法会被自动内联。函数一旦内联化,就无法在栈跟踪信息中看到栈帧信息了。栈帧信息都变成省略号。要想看到栈跟踪信息中的栈帧数据,我们需要使用 -gcflags="-l" 来告诉编译器不要执行内联优化。