Contents

Error Handing in Go

现状

错误处理,是编程中绕不过的话题。

一个思考,最先出现的错误都应该尽快进行错误处理。

if err != nil {
    ...
}

相信写过 Go 的,对上面这个都不陌生。

如果说 if-else 会出现 if-else hell,那么在 Go 里面还会再出现一个 Error check hell,

/images/tech/FvQfIHgaYAA7tAe.jpg

即便在一些有不少 star 的开源项目中,你依然可以看到这种代码。

图中项目直接 Fatalf 的操作,值得思考,可以适当增加一些自我补救的措施(如:提供默认值,符合大多数用户的简单设置策略等)

如果在保证错误得到正确处理的情况下,

  1. 进行优雅的错误处理,

  2. 对错误能保留上下文(堆栈)信息,

这是我想要探讨的问题。

现今 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 变量。在上面的例子最后,我们将得到一个最先出现的错误。

首先是一个小问题,就是上面注释的步骤 123 中,如果在 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 原理)。

目前看来,我们的两个问题都可以得出答案了:

  1. 对单一业务对象可以进行优雅的错误处理,还没有出现可以一举消灭 Error check hell 的方法;

  2. 对错误能保留上下文(堆栈)信息,可以使用 github.com/pkg/errors 做到很好地错误包裹和保证原始错误检查。

未来

Go2 草案中的蛛丝马迹

说完现状,展望一下未来吧。由于 github.com/pkg/errors#roadmap 中提到了 Go2 error proposals,于是我去窥探了一下未来 👁️ 。

由于还是在草案阶段,我重点关注如何消灭 Error check hellStack frame preservation 这两方面,以及由此拓展出来的部分内容。

overviewdraft 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: