Error Handing in Go
现状
错误处理,是编程中绕不过的话题。
一个思考,最先出现的错误都应该尽快进行错误处理。
if err != nil {
...
}
相信写过 Go
的,对上面这个都不陌生。
如果说 if-else
会出现 if-else hell
,那么在 Go
里面还会再出现一个 Error check hell
,

即便在一些有不少 star
的开源项目中,你依然可以看到这种代码。
图中项目直接 Fatalf 的操作,值得思考,可以适当增加一些自我补救的措施(如:提供默认值,符合大多数用户的简单设置策略等)
如果在保证错误得到正确处理的情况下,
-
进行优雅的错误处理,
-
对错误能保留上下文(堆栈)信息,
这是我想要探讨的问题。
现今 Go
的宗旨仍然是 Errors are values
, 我个人是比较认同的,因为很多时候出错往往在你觉得不会出错的地方。你可以忽略错误,但是无论如何你都将得到一个约定俗成的 error
。总之就是显式错误结果和显式错误检查,两种结合起来的错误处理方式。
现有解决方案
如何优雅处理
Golang Error Handling lesson by Rob Pike
OK. 请听题,现有代码如下所示,怎么消除 Error check hell
?
_, err = fd.Write(p0[a:b])
if err != nil {
return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
return err
}
...
Rob Pike 先生给出的示例是:
var err error
write := func(buf []byte) {
if err != nil {
return
}
_, err = w.Write(buf)
}
write(p0[a:b]) // ---------- 1
write(p1[c:d]) // ---------- 2
write(p2[e:f]) // ---------- 3
// and so on
if err != nil {
return err
}
这样的处理方式很好,通过一个闭包,在使用同一个 writer
的情况下,在一个函数内传递 err
变量。在上面的例子最后,我们将得到一个最先出现的错误。
首先是一个小问题,就是上面注释的步骤 1
,2
,3
中,如果在 1
错误已经发送,但是这个时候还不能及时返回,必须等到执行完成 3
后,进行 if err != nil {}
检查的时候才进行返回,也就是执行步骤为 1-2-3
,而原来不使用闭包的写法上,执行步骤为 1
,发生错误后下一步就是马上返回。
接下来才是大问题,并不是所有代码里的错误来自于同一个操作(函数),针对不同的业务对象,它会有不同的调用,这时候又回到熟悉的 Error check hell
了。
虽然上面的问题还没得到解决,那就继续完善现有方案吧。
上面的代码还可以增加一个小技巧,让代码变得更顺畅,像流水一样,就是使用流式处理。
package main
import (
"bytes"
"encoding/binary"
"fmt"
)
// 10 byte, 缺一个 byte
var b = []byte{72, 101, 108, 108, 111, 44, 32, 228, 184, 150}
var r = bytes.NewReader(b)
type Book struct {
Name [5]byte
Author [5]byte
Num uint8
err error
}
func (b *Book) read(data interface{}) {
if b.err == nil {
b.err = binary.Read(r, binary.LittleEndian, data)
}
}
func (b *Book) ReadName() *Book {
b.read(&b.Name)
return b
}
func (b *Book) ReadAuthor() *Book {
b.read(&b.Author)
return b
}
func (b *Book) ReadNum() *Book {
b.read(&b.Num)
return b
}
func (b *Book) Print() *Book {
if b.err == nil {
fmt.Printf("Book: %+v", b)
}
return b
}
func main() {
b := Book{}
b.ReadName().ReadAuthor().ReadNum().Print()
fmt.Println(b.err) // EOF 错误
}
将err error
作为一个结构体的一个字段,在调用链中这个 err
会在方法调用中起屏障的作用。以此达到最先出现的错误,将导致后续逻辑停止被调用,并在可预期的未来得到处理。
题外话, 在 gorm 中,你会看到这是一种标准行为,DB
结构体的定义为:
type DB struct {
*Config
Error error // <--- 注意这里
RowsAffected int64
Statement *Statement
clone int
}
然后你就会在源代码的很多地方发现诸如下面的,在执行逻辑前进行错误检查的代码:
func Query(db *gorm.DB) {
if db.Error == nil {
...
}
...
return
}
保留上下文信息
在接收到下层的返回错误时候,我们可能得到的不是一层调用错误得到的错误,这时候,保留调用链的,带有堆栈(上下文)信息的错误,对于调试和排查都显得十分重要。
一般来说可以直接这样:
type SomeError struct{
Msg string
err error
}
func (s *SomeError) Error() string {
return fmt.Sprintf("do %s get error: %v", s.Msg, s.err)
}
将原始错误包裹在上一层自定义的错误中,添加一些需要的自定义信息。
但是这样以来,原始的错误属性就会丢失在这一层包裹中。
我们需要根据原始错误进行错误处理的时候就会遇到麻烦,诸如:
switch err.(type) {
case os.ErrInvalid:
...
case os.ErrPermission:
...
case os.ErrExist:
default:
...
}
这时候 github.com/pkg/errors 就可以派上用场了。
Show you my code:
package main
import (
"fmt"
"log"
"github.com/pkg/errors"
)
func main() {
_, err := sendErr()
if err != nil {
err = errors.Wrap(err, "warp error")
}
log.Println(err)
switch err := errors.Cause(err).(type) {
case *DivZero:
// handle specifically
log.Println("caught! original error:", err)
default:
// unknown error
log.Println("unknown! ", err)
}
}
type operationError struct {
operation string
err error // original error
}
type DivZero struct{}
func (myerr *DivZero) Error() string {
return "Cannot divide by 0!"
}
func sendErr() (int, error) {
a := operationError{err: new(DivZero)}
return -1, a.err
}
输出:
2009/11/10 23:00:00 warp error: Cannot divide by 0!
2009/11/10 23:00:00 caught! original error: Cannot divide by 0!
这里的奥妙是使用 errors.Wrap
进行包装的 error
,可以使用 errors.Cause(err)
通过递归检索,暴露出原始错误。
原理可见 pkg/errors - Retrieving the cause of an error 或者源码 errors.go (非常简洁,充分利用 interface
原理)。
目前看来,我们的两个问题都可以得出答案了:
-
对单一业务对象可以进行优雅的错误处理,还没有出现可以一举消灭
Error check hell
的方法; -
对错误能保留上下文(堆栈)信息,可以使用
github.com/pkg/errors
做到很好地错误包裹和保证原始错误检查。
未来
Go2 草案中的蛛丝马迹
说完现状,展望一下未来吧。由于 github.com/pkg/errors#roadmap 中提到了 Go2 error proposals,于是我去窥探了一下未来 👁️ 。
由于还是在草案阶段,我重点关注如何消灭 Error check hell
和 Stack frame preservation 这两方面,以及由此拓展出来的部分内容。
overview 和 draft design 这两部分,我的结论是 TL;DR
。
我的意思是:Too long, but deserve to read. 哈哈哈哈
通过一个例子,总结一下:
func process(user string, files chan string) (n int, err error) {
handle err { return 0, fmt.Errorf("process: %v", err) } // handler A
for i := 0; i < 3; i++ {
handle err { err = fmt.Errorf("attempt %d: %v", i, err) } // handler B
handle err { err = moreWrapping(err) } // handler C
check do(something()) // check 1: handler chain C, B, A
}
val := check do(somethingElse()) // check 2: handler chain A
return val, nil
}
使用 check/handle
关键字,对返回值最后一个中的 error
进行 check
,并在作用域内由最上一层的 handle
进行处理。类似 defer
,但与它不同的是 handle
和变量一样,有自己的作用域。
这样的处理,更符合 DRY
原则,每一次的 check
之后只需要接受不包含错误的其他返回值,handle
也是定义每一层自己的错误处理。
它似乎很完美地解决了之前困扰我们的不同业务对象无法消灭 Error check hell
的困境。
草案中 Stack frame preservation 部分中并没有要针对保留错误的上下文信息能力进行改进的意思。总的来说,继续使用 github.com/pkg/errors
也是不错的选择。
reference:
- errors 库 https://github.com/pkg/errors
- 随手写的 demo 代码 https://go.dev/play/p/X9qVjyPrdW-
- GO 编程模式:错误处理 – CoolShell https://coolshell.cn/articles/21140.html
- Golang Error Handling lesson by Rob Pike – Jxck https://jxck.hatenablog.com/entry/golang-error-handling-lesson-by-rob-pike
- Errors are values – Rob Pike https://go.dev/blog/errors-are-values
- Go2 草案有关于错误处理的部分