《Go 语言原本》

4.5 错误处理的未来

TODO: 讨论社区里的一些优秀的方案、以及未来可能的设计

4.5.1 来自社区的方案

在错误处理这件事情上,其实社区提供了许多非常优秀的方案, 其中一个非常出色的工作来自 Dave Cheney 和他的错误原语。

错误原语

pkg/errors 与标准库中 errors 包不同,它首先提供了 Wrap

 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
func Wrap(err error, message string) error {
	if err == nil {
		return nil
	}
	
	// 首先将错误产生的上下文进行保存
	err = &withMessage{
		cause: err,
		msg:   message,
	}
	
	// 再将 withMessage 错误的调用堆栈保存为 withStack 错误
	return &withStack{
		err,
		callers(),
	}
}

type withMessage struct {
	cause error
	msg   string
}

func (w *withMessage) Error() string { return w.msg + ": " + w.cause.Error() }
func (w *withMessage) Cause() error  { return w.cause }

type withStack struct {
	error
	*stack // 携带 stack 的信息
}

func (w *withStack) Cause() error { return w.error }

func callers() *stack {
	const depth = 32
	var pcs [depth]uintptr
	n := runtime.Callers(3, pcs[:])
	var st stack = pcs[0:n]
	return &st
}

这是一种依赖运行时接口的解决方案,通过 runtime.Caller 来获取错误出现时的堆栈信息。通过 Wrap() 产生的错误类型 withMessage 还实现了 causer 接口:

1
2
3
type causer interface {
	Cause() error
}

当我们需要对一个错误进行检查时,则可以通过 errors.Cause(err error) 来返回一个错误产生的原因,进而获得了错误产生的上下文信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func Cause(err error) error {
	type causer interface {
		Cause() error
	}

	for err != nil {
		cause, ok := err.(causer)
		if !ok { break }
		err = cause.Cause()
	}
	return err
}

进而可以做到:

1
2
3
4
switch err := errors.Cause(err).(type) {
case *CustomError:
	// ...
}

得益于 fmt.Formatter 接口,pkg/errors 还实现了 Fomat(fmt.State, rune) 方法, 进而在使用 %+v 进行错误打印时,能携带堆栈信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (w *withStack) Format(s fmt.State, verb rune) {
	switch verb {
	case 'v':
		if s.Flag('+') {			// %+v 支持携带堆栈信息的输出
			fmt.Fprintf(s, "%+v", w.Cause())
			w.stack.Format(s, verb) // 将 runtime.Caller 获得的信息进行打印
			return
		}
		fallthrough
	case 's':
		io.WriteString(s, w.Error())
	case 'q':
		fmt.Fprintf(s, "%q", w.Error())
	}
}

得到形如下面格式的错误输出:

1
2
3
4
5
6
7
current message: causer message
main.causer
	/path/to/caller/main.go:5
main.caller
	/path/to/caller/main.go:12
main.main
	/path/to/caller/main.go:27

基于错误链的高层抽象

我们再来看另一种错误处理的哲学,现在我们来考虑下面这个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
conn, err := net.Dial("tcp", "localhost:1234")
if err != nil {
	panic(err)
}
_, err := conn.Write(command1)
if err != nil {
	panic(err)
}

r := bufio.NewReader(conn)
status, err := r.ReadString('\n')
if err != nil {
	panic(err)
}
if status == "ok" {
	_, err := conn.Write(command2)
	if err != nil {
		panic(err)
	}
}

我们很明确的能够观察到错误处理带来的问题:为清晰的阅读代码的整体逻辑带来了障碍。我们希望上面的代码能够清晰的展现最重要的代码逻辑:

1
2
3
4
5
6
7
conn := net.Dial("tcp", "localhost:1234")
conn.Write(command1)
r := bufio.NewReader(conn)
status := r.ReadString('\n')
if status == "ok" {
	conn.Write(command2)
}

如果我们进一步观察这个问题的现象,可以将整段代码抽象为图 1 所示的逻辑结构。

图 1: 产生分支的错误处理手段

如果我们尝试将这段充满分支的逻辑进行高层抽象,将其转化为一个单一链条,则能够得到 图 2 所示的隐式错误链条。

图 2: 消除分支的链式错误处理手段

则能够得到下面的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type SafeConn struct {
	conn   net.Conn
	r      *bufio.Reader
	status string
	err    error
}
func safeDial(n, addr string) SafeConn {
	conn, err := net.Dial(n, addr)
	r := bufio.NewReader(conn)
	return SafeConn{conn, r, "ok", err}
}

func (c *SafeConn) write(b []byte) {
	if c.err != nil && status == "ok" { return }
	_, c.err = c.conn.Write(b)
}
func (c *SafeConn) read() {
	if err != nil { return }
	c.status, c.err = c.r.ReadString('\n')
}

则当建立连接时候:

1
2
3
4
5
6
7
8
9
c := safeDial("tcp", "localhost:1234") // 如果此条指令出错
c.write(command1) // 不会发生任何事情
c.read()          // 不会发生任何事情
c.write(command2) // 不会发生任何事情

// 最后对进行整个流程的错误处理
if c.err != nil || c.status != "ok" {
	panic("bad connection")
}

这种将错误进行高层抽象的方法通常包含以下四个一般性的步骤:

  1. 建立一种新的类型
  2. 将原始值进行封装
  3. 将原始行为进行封装
  4. 将分支条件进行封装

4.5.2 其他可能的设计

TODO:

Generics + Error handling?

Either Coproduct

https://www.ituring.com.cn/article/508191 https://www.bookstack.cn/read/mostly-adequate-guide-chinese/ch8.4

4.5.3 历史性评述

TODO: