Golang 错误处理

Go 语言有很多优秀的特性,比如 Goroutine、非侵入性接口等等。但是大家对 Go 也有很多争议,其中争议最大的可能就是它的错误处理机制了,知乎上也有相关的讨论「Go 语言的错误处理机制是一个优秀的设计吗?」

有人认为冗长重复的错误处理格式像是回到了上世纪七十年代,有人也提出这是 Go 语言非常出色的设计之一,那么到底是怎样的呢?

在 Java 中,使用了 try-catch-finally 的处理方式来统一应对错误和异常。所以对我来说(对大部分主要使用 Java 或 Python 的人来说应该是也如此吧),常常因分不清楚到底哪些是错误、哪些是异常而滥用该机制。
所以在我刚刚接触 Go 语言的时候,错误和异常我就傻傻分不清,导致后来用 Go 写了一些东西,过于冗余的错误处理方式让我甚至想放弃这门语言了。但是 Go 又有很多相较于 Java 更方便更显得现代化的优良特性,割舍不下。就在这相爱相杀的过程中,我痛定思痛,决心好好学习一下 Go 中错误处理。

error 类型

Go 继承了 C,以返回值为错误处理的主要方式。但与 C 不同的是,在 Go 的惯用法中,返回值不是整型等常用返回值类型(errno),而是用了一个 error(interface类型)。

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

这也体现了 Go 哲学中的“正交”理念:error context 与 error 类型的分离。无论 error context 是 int、float 还是 string 或是其他,统统用 error 作为返回值类型即可。

在 Andrew Gerrand 的 Error handling and Go 一文中,这位 Go authors 之一明确了 error context 是由 error 接口实现者 supply 的。在 Go 标准库中,Go 提供了两种创建一个实现了 error interface 的类型的变量实例的方法:errors.New 和 fmt.Errorf

1
2
errors.New("your first error code")
fmt.Errorf("error value is %d\n", errcode)

这两个方法实际上返回的是同一个实现了 error interface 的类型实例,这个 unexported 类型就是 errorString。顾名思义,这个 error type 仅提供了一个 string 的 context!

1
2
3
4
5
6
7
8
9
//$GOROOT/srcerrors/errors.go

type errorString struct {
s string
}

func (e *errorString) Error() string {
return e.s
}

这两个方法也基本满足了大部分日常学习和开发中代码中的错误处理需求。

错误还是异常(error or panic)

在 Java 和 Python 中,try-catch 的模式我用的是不亦乐乎,在没有接触 Go 之前,在我的印象里“错误==异常”,即它俩是同一个概念。
但其实,Go 中错误和异常是两个不同的概念。知乎里的一段回答很好,不仅指出了错误和异常的区别,还提到了如何对它们进行管理。

首先我们要理清:什么是错误、什么是异常、为什么需要管理。然后才是怎样管理。

错误和异常从语言机制上面讲,就是 error 和 panic 的区别,放到别的语言也一样,别的语言没有 error 类型,但是有错误码之类的,没有 panic,但是有 throw 之类的。

在语言层面它们是两种概念,导致的是两种不同的结果。如果程序遇到错误不处理,那么可能进一步的产生业务上的错误,比如给用户多扣钱了,或者进一步产生了异常;如果程序遇到异常不处理,那么结果就是进程异常退出。

在项目里面是不是应该处理所有的错误情况和捕捉所有的异常呢?我只能说,你可以这么做,但是估计效果不会太好。我的理由是:

  1. 如果所有东西都处理和记录,那么重要信息可能被淹没在信息的海洋里。
  2. 不应该处理的错误被处理了,很容易导出 BUG 暴露不出来,直到出现更严重错误的时候才暴露出问题,到时候排查就很困难了,因为已经不是错误的第一现场。

所以错误和异常最好能按一定的规则进行分类和管理,在第一时间能暴露错误和还原现场。

对于错误处理,Erlang 有一个很好的概念叫速错,就是有错误第一时间暴露它。我们的项目从 Erlang 到 Go 一直是沿用这一设计原则。但是应用这个原则的前提是先得区分错误和异常这两个概念。

错误和异常上面已经提到了,从语言机制层面比较容易区分它们,但是语言取决于人为,什么情况下用错误表达,什么情况下用异常表达,就得有一套规则,否则很容易出现全部靠异常来做错误处理的情况,似乎 Java 项目特别容易出现这样的设计。

这里我先假想有这样一个业务:游戏玩家通过购买按钮,用铜钱购买宝石。

在实现这个业务的时候,程序逻辑会进一步分化成客户端逻辑和服务端逻辑,客户端逻辑又进一步因为设计方式的不同分化成两种结构:胖客户端结构、瘦客户端结构。

胖客户端结构,有更多的本地数据和懂得更多的业务逻辑,所以在胖客户端结构的应用中,以上的业务会实现成这样:客户端检查缓存中的铜钱数量,铜钱数量足够的时候购买按钮为可用的亮起状态,用户点击购买按钮后客户端发送购买请求到服务端;服务端收到请求后校验用户的铜钱数量,如果铜钱数量不足就抛出异常,终止请求过程并断开客户端的连接,如果铜钱数量足够就进一步完成宝石购买过程,这里不继续描述正常过程。

因为正常的客户端是有一步数据校验的过程的,所以当服务端收到不合理的请求(铜钱不足以购买宝石)时,抛出异常比返回错误更为合理,因为这个请求只可能来自两种客户端:外挂或者有 BUG 的客户端。如果不通过抛出异常来终止业务过程和断开客户端连接,那么程序的错误就很难被第一时间发现,攻击行为也很难被发现。

我们再回头看瘦客户端结构的设计,瘦客户端不会存有太多状态数据和用户数据也不清楚业务逻辑,所以客户端的设计会是这样:用户点击购买按钮,客户端发送购买请求;服务端收到请求后检查铜钱数量,数量不足就返回数量不足的错误码,数量足够就继续完成业务并返回成功信息;客户端收到服务端的处理结果后,在界面上做出反映。

在这种结构下,铜钱不足就变成了业务逻辑范围内的一种失败情况,但不能提升为异常,否则铜钱不足的用户一点购买按钮都会出错掉线。

所以,异常和错误在不同程序结构下是互相转换的,我们没办法一句话的给所有类型所有结构的程序一个统一的异常和错误分类规则。

但是,异常和错误的分类是有迹可循的。比如上面提到的痩客户端结构,铜钱不足是业务逻辑范围内的一种失败情况,它属于业务错误,再比如程序逻辑上尝试请求某个 URL,最多三次,重试三次的过程中请求失败是错误,重试到第三次,失败就被提升为异常了。

所以我们可以这样来归类异常和错误:不会终止程序逻辑运行的归类为错误,会终止程序逻辑运行的归类为异常。

因为错误不会终止逻辑运行,所以错误是逻辑的一部分,比如上面提到的瘦客户端结构,铜钱不足的错误就是业务逻辑处理过程中需要考虑和处理的一个逻辑分支。而异常就是那些不应该出现在业务逻辑中的东西,比如上面提到的胖客户端结构,铜钱不足已经不是业务逻辑需要考虑的一部分了,所以它应该是一个异常。

错误和异常的分类需要通过一定的思维训练来强化分类能力,就类似于面向对象的设计方式一样的,技术实现就摆在那边,但是要用好需要不断的思维训练不断的归类和总结,以上提到的归类方式希望可以作为一个参考,期待大家能发现更多更有效的归类方式。

接下来我们讲一下速错和 Go 语言里面怎么做到速错。

速错我最早接触是在做 ASP.NET 的时候就体验到的,当然跟 Erlang 的速错不完全一致,那时候也没有那么高大上的一个名字,但是对待异常的理念是一样的。

在 .NET 项目开发的时候,有经验的程序员都应该知道,不能随便 re-throw,就是 catch 错误再抛出,原因是异常的第一现场会被破坏,堆栈跟踪信息会丢失,因为外部最后拿到异常的堆栈跟踪信息,是最后那次 throw 的异常的堆栈跟踪信息;其次,不能随便 try catch,随便 catch 很容易导出异常暴露不出来,升级为更严重的业务漏洞。

到了 Erlang 时期,大家学到了速错概念,简单来讲就是:让它挂。只有挂了你才会第一时间知道错误,但是 Erlang 的挂,只是 Erlang 进程的异常退出,不会导致整个 Erlang 节点退出,所以它挂的影响层面比较低。

在 Go 语言项目中,虽然有类似 Erlang 进程的 Goroutine,但是 Goroutine 如果 panic 了,并且没有 recover,那么整个 Go 进程就会异常退出。所以我们在 Go 语言项目中要应用速错的设计理念,就要对 Goroutine 做一定的管理。

在我们的游戏服务端项目中,我把 Goroutine 按挂掉后的结果分为两类:

  1. 挂掉后不影响其他业务或功能的;
  2. 挂掉后业务就无法正常进行的。

第一类 Goroutine 典型的有:处理各个玩家请求的 Goroutine,因为每个玩家连接各自有一个 Goroutine,所以挂掉了只会影响单个玩家,不会影响整体业务进行。

第二类 Goroutine 典型的有:数据库同步用的 Goroutine,如果它挂了,数据就无法同步到数据库,游戏如果继续运行下去只会导致数据回档,还不如让整个游戏都异常退出。

这样一分类,就可以比较清楚哪些 Goroutine 该做 recover 处理,哪些不该做 recover 处理了。

那么在做 recover 处理时,要怎样才能尽量保留第一现场来帮组开发者排查问题原因呢?我们项目中通常是会在最外层的 recover 中把错误和堆栈跟踪信息记进日志,同时把关键的业务信息,比如:用户 ID、来源 IP、请求数据等也一起记录进去。

归纳总结:

  1. 错误和异常需要分类和管理,不能一概而论
  2. 错误和异常的分类可以以是否终止业务过程作为标准
  3. 错误是业务过程的一部分,异常不是
  4. 不要随便捕获异常,更不要随便捕获再重新抛出异常
  5. Go 语言项目需要把 Goroutine 分为两类,区别处理异常
  6. 在捕获到异常时,需要尽可能的保留第一现场的关键数据

error 的正确处理方法

基本用法

在每个返回 error 的函数中都进行了判断与返回,这样就导致了代码都长下面这样

1
2
3
4
5
d, err := F()
if err != nil {
fmt.Println(err)
return err
}

本来就一行就解决的代码,一下多了 4 行与错误处理相关的代码。即使是采用官方的所谓最小化代码量的错误处理示例来处理,还是显得很冗余:

1
2
3
4
5
6
var d int
if d, err := F(); err != nil {
fmt.Println(err)
return err
}
// do something with d

这就是在 Go 语言中调用函数的正确处理方式,甚至连 Println 的调用都要这样做(不过实际中,包括标准库在内的 Go 代码很少去判断 fmt.Println 或 Printf 系列函数的返回值)。
如果不这么做会怎样呢?Go 语言并没有坚持要采用这种冗长的错误机制。它也允许忽略这些函数调用错误。但是这样做很危险。 在下面的例子中,如果第一个 Get 函数错误,那么程序继续调用第二个函数!这是非常恐怖的事情。

1
2
3
4
func main() {
http.Get("http://www.nuke.gov/seal_presidential_bunker")
http.Get("http://www.nuke.gov/trigger_doomsday_device")
}

破解之道

Go 的错误处理的确冗长,但使用一些 tips,还是可以将代码缩减至可以忍受的范围的,这里列举三种:

checkError

对于一些在 error handle 时可以选择 goroutine exit(注意:如果仅存 main goroutine 一个 goroutine,调用 runtime.Goexit 会导致程序以 crash 形式退出)或 os.Exit 的情形,我们可以选择类似常见的 checkError 方式简化错误处理,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func checkError(err error) {
if err != nil {
fmt.Println("Error is ", err)
os.Exit(-1)
}
}

func foo() {
err := doStuff1()
checkError(err)

err = doStuff2()
checkError(err)

err = doStuff3()
checkError(err)
}

这种方式的应用范围比较有限。

聚合 error

有些时候,我们会遇到这样的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if err := doStuff1(); err != nil {
// handle A
// handle B
// ... ...
}

if err := doStuff2(); err != nil {
// handle A
// handle B
// ... ...
}

if err := doStuff3(); err != nil {
// handle A
// handle B
// ... ...
}

在每个错误处理过程,处理过程相似,都是 handle A、handle B 等,我们可以通过 Go 提供的==defer+闭包==的方式,将 handle A、handle B 等等聚合到一个延后执行的匿名辅助闭包中去:

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
func handleA() {
fmt.Println("handle A")
}
func handleB() {
fmt.Println("handle B")
}

func foo() {
var err error
defer func() {
if err != nil {
handleA()
handleB()
}
}()

if err = doStuff1(); err != nil {
return
}

if err = doStuff2(); err != nil {
return
}

if err = doStuff3(); err != nil {
return
}
}

将 doStuff 和 error 处理绑定

在 Rob Pike 的 Errors are values 一文中,Rob Pike 告诉我们标准库中使用了一种简化错误处理代码的技巧,bufio 中的 Writer 就使用了这个技巧:

1
2
3
4
5
6
7
8
9
b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// and so on
if b.Flush() != nil {
return b.Flush()
}
}

我们看到代码中并没有判断三个 b.Write 的返回错误值,错误处理放在哪里了呢?我们打开一下$GOROOT/src/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
}

我们可以看到,错误处理被绑定在 Write 的内部了,Writer 中定义了一个 err 作为错误状态值,与 Writer 的实例绑定在了一起,并且在每次 Write 入口处都会 err 判断是否为 nil。一旦不为 nil,Write 其实什么都没做就直接 return 了。

让 error 携带更多的线索

参考 GOLANG 错误处理最佳方案

以上三种破解之法,各有各的适用场景,同样你也可以看出各有各的不足,没有普适之法。优化 Go 错误处理之法也不会局限在上述三种情况,肯定会有更多的破解方法,比如代码生成,比如其他还待发掘。

解调用者之惑

前面举的例子对于调用者来讲都是较为简单的情况了。但实际编码中,调用者不仅要面对的是:

1
2
3
if err != nil {
//handle error
}

还要面对:

1
2
3
4
5
6
7
8
if err 是 ErrXXX
//handle errorXXX

if err 是 ErrYYY
//handle errorYYY

if err 是ErrZZZ
//handle errorZZZ

我们分三种情况来说明调用者该如何处理不同类型的 error 实现:

由 errors.New 或 fmt.Errorf 返回的错误变量

如果你调用的函数或方法返回的错误变量是调用 errors.New 或 fmt.Errorf 而创建的,由于 errorString 类型是 unexported 的,因此我们无法通过“相当判定”或 type assertion、type switch 来区分不同错误变量的值或类型,唯一的方法就是判断 err.String()是否与某个错误 context string 相等,示意代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func openFile(name string) error {
if file not exist {
return errors.New("file does not exist")
}

if have no priviledge {
return errors.New("no priviledge")
}
return nil
}

func main() {
err := openFile("example.go")
if err.Error() == "file does not exist" {
// handle "file does not exist" error
return
}

if err.Error() == "no priviledge" {
// handle "no priviledge" error
return
}
}

但这种情况太 low 了,不建议这么做!一旦遇到类似情况,就要考虑通过下面方法对上述情况进行重构。

exported Error 变量

打开$GOROOT/src/os/error.go,你会在文件开始处发现如下代码:

1
2
3
4
5
6
var (
ErrInvalid = errors.New("invalid argument")
ErrPermission = errors.New("permission denied")
ErrExist = errors.New("file already exists")
ErrNotExist = errors.New("file does not exist")
)

这些就是 os 包 export 的错误码变量,由于是 exported 的,我们在调用 os 包函数返回后判断错误码时可以直接使用等于判定,比如:

1
2
3
4
5
err := os.XXX
if err == os.ErrInvalid {
// handle invalid
}
// ... ...

也可以使用 switch case:

1
2
3
4
5
6
7
switch err := os.XXX {
case ErrInvalid:
// handle invalid
case ErrPermission:
// handle no permission
// ... ...
}

至于 error 类型变量与 os.ErrInvalid 的可比较性可参考go specs

一般对于库的设计和实现者而言,在库的设计时就要考虑好 export 出哪些错误变量。

定义自己的 error 接口实现类型

如果要提供额外的 error context,我们可以定义自己的实现 error 接口的类型;如果这些类型还是 exported 的,我们就可以用 type assertion or type switch 来判断返回的错误码类型并予以对应处理。

比如$GOROOT/src/net/net.go:

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
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error
}

func (e *OpError) Error() string {
if e == nil {
return "<nil>"
}
s := e.Op
if e.Net != "" {
s += " " + e.Net
}
if e.Source != nil {
s += " " + e.Source.String()
}
if e.Addr != nil {
if e.Source != nil {
s += "->"
} else {
s += " "
}
s += e.Addr.String()
}
s += ": " + e.Err.Error()
return s
}

net.OpError 提供了丰富的 error Context,不仅如此,它还实现了除 Error 以外的其他 method,比如:Timeout(实现 net.timeout interface) 和 Temporary(实现 net.temporary interface)。这样我们在处理 error 时,可通过 type assertion 或 type switch 将 error 转换为*net.OpError,并调用到 Timeout 或 Temporary 方法来实现一些特殊的判定。

1
2
3
4
5
6
err := net.XXX
if oe, ok := err.(*OpError); ok {
if oe.Timeout() {
// handle timeout...
}
}

错误处理的那些坑

每种编程语言都有自己的专属坑,Go 虽出身名门,但毕竟年轻,坑也不少,在 error 处理这块也可以列出几个。

Go FAQ:Why is my nil error value not equal to nil?

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
type MyError string

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

var ErrBad = MyError("ErrBad")

func bad() bool {
return false
}

func returnsError() error {
var p *MyError = nil
if bad() {
p = &ErrBad
}
return p // Will always return a non-nil error.
}

func main() {
err := returnsError()
if err != nil {
fmt.Println("return non-nil error")
return
}
fmt.Println("return nil")
}

上面的输出结果是”return non-nil error”,也就是说 returnsError 返回后,err != nil。err 是一个 interface 类型变量,其 underlying 有两部分组成:类型和值。只有这两部分都为 nil 时,err 才为 nil。但 returnsError 返回时将一个值为 nil,但类型为*MyError 的变量赋值为 err,这样 err 就不为 nil。解决方法:

1
2
3
4
5
6
7
8
func returnsError() error {
var p *MyError = nil
if bad() {
p = &ErrBad
return p
}
return nil
}

switch err.(type)的匹配次序

试想一下下面代码的输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type MyError string

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

func Foo() error {
return MyError("foo error")
}

func main() {
err := Foo()
switch e := err.(type) {
default:
fmt.Println("default")
case error:
fmt.Println("found an error:", e)
case MyError:
fmt.Println("found MyError:", e)
}
return

}

你可能会以为会输出:”found MyError: foo error”,但实际输出却是:”found an error: foo error”,也就是说 e 先匹配到了 error!如果我们调换一下次序呢:

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
err := Foo()
switch e := err.(type) {
default:
fmt.Println("default")
case MyError:
fmt.Println("found MyError:", e)
case error:
fmt.Println("found an error:", e)
}
return
}

这回输出结果变成了:“found MyError: foo error”。

也许你会认为这不全是错误处理的坑,和 switch case 的匹配顺序有关,但不可否认的是有些人会这么去写代码,一旦这么写,坑就踩到了。因此对于通过 switch case 来判定 error type 的情况,将 error 这个“通用”类型放在后面或去掉。

第三方库

如果觉得 Go 内置的错误机制不能很好的满足你的需求,本着“do not reinvent the wheel”的精神,建议使用一些第三方库来满足,比如:errors

参考

The Go Blog - Errors are values
Russ Cox replied to the article “Why I’m not leaving Python for Go”
GOLANG 错误处理最佳方案
GOLANG 测试必须用带堆栈的 errors
Go 语言的有效错误处理
Golang 错误和异常处理的正确姿势


Golang 错误处理
https://www.haoyizebo.com/posts/f273470e/
作者
一博
发布于
2017年7月30日
许可协议