Go 错误处理最佳实践

2021-08-30 Golang Go 进阶

# 一、Error vs Exception

# 1.1 Error 的实现

Go error 就是普通的一个接口,在 errors 包中实现了该接口,具体代码如下:

// file: src/builtin/builtin.go

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
	Error() string
}

// file: src/errors/errors.go

package errors

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
    return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}
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

我们经常使用 errors.New() 来返回一个 error 对象,那么为什么在 New 一个 error 的时候会返回一个结构体的指针呢?先来看一个例子,我们同样创建了 errorString 的结构体,和标准库中的唯一不同是,自建的这个返回的是值,而不是指针。

type errorString struct {
	s string
}

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

// New 创建一个自定义错误
func New(s string) error {
	return errorString{text}
}

var errorString1 = New("test a")
var err1 = errors.New("test b")

func main() {
	if errorString1 == New("test a") {
		fmt.Println("err string a")  // 会输出
	}

	if err1 == errors.New("test b") {
		fmt.Println("err 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

在运行时我们会发现,自定义的 errorString 在对比的时候只要对应的字符串相同就会返回 true,但是标准库的包不会。

这是因为,在对比两个 struct 是否相同的时候,会去对比,这两个 struct 里面的各个字段是否是相同的,如果相同就返回 true,但是对比指针的时候会去判断两个指针的地址是否一致。

如果字符串相等就返回 true 会导致什么问题呢?

如果我有两个包,定义了两个错误,但他们其实是两个相同的字符串,在其他库调用对比的时候,可能会由于不同的书写顺序,走进不同的分支导致一些奇奇怪怪的错误。

# 1.2 各个语言的演进历史

  • C:单返回值,一般通过传递指针作为入参,返回值为 int 表示成功还是失败。
  • C++:引入了 exception,但是无法知道被调用方会抛出什么异常。
  • Java:引入了 checked exception,方法的所有者必须申明,调用者必须处理。
    • 在启动时抛出大量的异常是司空见惯的事情,并在它们的调用堆栈中尽职地记录下来。
    • Java 异常不再是异常,而是变得司空见惯了,它们从良性到灾难性都有使用,异常的严重性由函数的调用者来区分。

Go 的处理异常逻辑是不引入 exception,支持多参数返回,所以很容易的在函数签名中带上实现了 error interface 的对象(通常为最后一个返回值),交由调用者来判定。

注意

  • 如果一个函数返回了 (value, error),你不能对这个 value 做任何假设,必须先判定 error;
  • 唯一可以忽略 error 的情况是,你连 value 也不关心。

Go 中有 panic 的机制,如果你认为和其他语言的 exception 一样,那你就错了,Go panic 意味着 fatal error(就是挂了),不能假设调用者来解决,panic 意味着代码不能继续运行。

使用多个返回值和一个简单的约定,Go 解决了让程序员知道什么时候出了问题,并为真正的异常情况保留了 panic。

# 1.3 panic or error?

在 Go 中 panic 会导致程序直接退出,是一个致命的错误,如果使用 panic recover 进行处理的话,会存在很多问题:

  • 性能问题,频繁 panic recover 性能不好;
  • 容易导致程序异常退出,只要有一个地方没有处理到就会导致程序进程整个退出;
  • 不可控,一旦 panic 就将处理逻辑移交给了外部,我们并不能预设外部包一定会进行处理。

那么什么时候使用 panic 呢?

对于真正意外的情况,那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出,我们才使用 panic。

使用 error 处理有哪些好处?

  • 简单;
  • 考虑失败,而不是成功(Plan for failure, not success);
  • 没有隐藏的控制流;
  • 完全交给你来控制 error;
  • Error are values。

# 二、Error Type

# 2.1 Sentinel Errors

哨兵错误,就是定义一些包级别的错误变量,然后在调用的时候外部包可以直接对比变量进行判定,在标准库当中大量的使用了这种方式,例如下方 io 库中定义的错误:

// EOF is the error returned by Read when no more input is available.
// Functions should return EOF only to signal a graceful end of input.
// If the EOF occurs unexpectedly in a structured data stream,
// the appropriate error is either ErrUnexpectedEOF or some other error
// giving more detail.
var EOF = errors.New("EOF")

// ErrUnexpectedEOF means that EOF was encountered in the
// middle of reading a fixed-size block or data structure.
var ErrUnexpectedEOF = errors.New("unexpected EOF")

// ErrNoProgress is returned by some clients of an io.Reader when
// many calls to Read have failed to return any data or error,
// usually the sign of a broken io.Reader implementation.
var ErrNoProgress = errors.New("multiple Read calls return no data or error")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

我们在外部判定的时候一般使用等值判定或者使用 errors.Is 进行判断:

if err == io.EOF {
	//...
}

if errors.Is(err, io.EOF){
	//...
}
1
2
3
4
5
6
7

这种错误处理方式有一个问题是,将 error 当做包的 API 暴露给了第三方,这样会导致在做重构或者升级的时候很麻烦,并且这种方式包含的错误信息会十分的有限。

# 2.2 Error types

Error types 是实现了 error 接口的自定义类型,例如 MyError 类型记录了文件和行号以展示发生了什么。

type MyError struct {
	Msg  string
	File string
	Line int
}

func (e *MyError) Error() string {
	return fmt.Sprintf("%s:%d:%s", e.File, e.Line, e.Msg)
}

func test() error {
	return &MyError{"Something happened", "server.go", 42}
}
1
2
3
4
5
6
7
8
9
10
11
12
13

因为 MyError 是一个 type,调用者可以使用断言转换成这个类型,来获取更多的上下文信息。

func main() {
	err := test()
	switch err := err.(type) {
	case nil:
		// call succeeded, nothing to do
	case *MyError:
		fmt.Println("error occurred on line:", err.Line)
	default:
		// unknown error
	}
}
1
2
3
4
5
6
7
8
9
10
11

与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。但是同样也将错误的类型暴露给了外部,例如标准库中的 os.PathError,这种模型会导致和调用者产生强耦合,从而导致 API 变得脆弱。

# 2.3 Opaque errors

不透明错误,也叫黑盒错误,因为你能知道错误发生了,但是不能看到它内部到底是什么。如下这段代码:

func fn() error {
	x, err := bar.Foo()
	if err != nil {
		return err
	}
	// use x
	return nil
}
1
2
3
4
5
6
7
8

作为调用者,调用完 Foo 函数后,只用知道 Foo 是正常工作还是出了问题。也就是说你只需要判断 err 是否为空,如果不为空,就直接返回错误。否则,继续后面的正常流程,不需要知道 err 到底是什么。这就是处理 Opaque errors 这种类型错误的策略。

当然,在某些情况下,这样做并不够用。例如,在一个网络请求中,需要调用者判断返回的错误类型,以此来决定是否重试。这种情况下,作者给出了一种方法:

In this case rather than asserting the error is a specific type or value, we can assert that the error implements a particular behaviour.

就是说,不去判断错误的类型到底是什么,而是去判断错误是否具有某种行为,或者说实现了某个接口。示例如下:

type temporary interface {
	Temporary() bool
}

func IsTemporary(err error) bool {
	te, ok := err.(temporary)
	return ok && te.Temporary()
}
1
2
3
4
5
6
7
8

拿到网络请求返回的 error 后,调用 IsTemporary 函数,如果返回 true,那就重试。这么做的好处是在进行网络请求的包里,不需要 import 引用定义错误的包。

# 三、Handling Error

# 3.1 Indented flow is for errors

无错误的正常流程代码,将成为一条直线,而不是缩进的代码。

// 写法一
f, err := os.Open(path)
if err != nil {
	// handle error
}
// do stuff

// 写法二
f, err := os.Open(path)
if err == nil {
	// do stuff
}
// handle error
1
2
3
4
5
6
7
8
9
10
11
12
13

因此,推荐上面写法一中的代码。

# 3.2 handle not just check errors

Don't just check errors, handle them gracefully.

func AuthenticateRequest(r *Request) error {
	err := authenticate(r.User)
	if err != nil {
		return err
	}
	return nil
}
1
2
3
4
5
6
7

上面这个例子中的代码是有问题的,直接优化成一句就可以了:

func AuthenticateRequest(r *Request) error {
    return authenticate(r.User)
}
1
2
3

# 3.3 Eliminate error handling by eliminating errors

type Header struct {
	Key, Value string
}

type Status struct {
	Code   int
	Reason string
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
	_, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
	if err != nil {
		return err
	}

	for _, h := range headers {
		_, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
		if err != nil {
			return err
		}
	}

	if _, err := fmt.Fprint(w, "\r\n"); err != nil {
		return err
	}

	_, err = io.Copy(w, body)
	return err
}
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

上面这段代码中,做了多次字符串写入的操作,而每次写入都需要进行错误判断,导致代码冗余繁琐,因此,我们可以包装一个新的 struct,在其中维护一个 error 状态,只在最后返回时拿到 error 即可,具体代码如下:

type errWriter struct {
	io.Writer
	err error
}

func (e *errWriter) Write(buf []byte) (int, error) {
	if e.err != nil {
		return 0, e.err
	}

	var n int
	n, e.err = e.Writer.Write(buf)
	return n, nil
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
	ew := &errWriter{Writer: w}
	fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)

	for _, h := range headers {
		fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
	}

	fmt.Fprint(ew, "\r\n")
	io.Copy(ew, body)

	return ew.err
}
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

# 3.4 Wrap erros

下面再来看一下 3.2 handle not just check errors 中的代码,如果 authenticate 返回错误,则 AuthenticateRequest 会将错误返回给调用方,调用者可能也会这样做,依此类推。在程序的顶部,程序的主体将把错误打印到屏幕或日志文件中,打印出来的可能只是:No such file or directory

这个错误反馈的信息太少了,不知道文件名、路径、行号等等。尝试改进一下,增加一点上下文:

func AuthenticateRequest(r *Request) error {
	err := authenticate(r.User)
	if err != nil {
		return fmt.Errorf("authenticate failed: %v", err)
	}
	return nil
}
1
2
3
4
5
6
7

这种做法实际上是先错误转换成字符串,再拼接另一个字符串,最后,再通过 fmt.Errorf 转换成错误。但这样做 破坏了相等性检测,即我们无法判断错误是否是一种预先定义好的错误了。

一个更好的错误处理方法是使用第三方库:github.com/pkg/errors,它提供了友好的接口:

// Wrap annotates cause with a message.
func Wrap(cause error, message string) error

// Cause unwraps an annotated error.
func Cause(err error) error
1
2
3
4
5
  • 通过 Wrap 可以将一个错误,加上一个字符串,“包装”成一个新的错误;
  • 通过 Cause 则可以进行相反的操作,将里层的错误还原。

下面的代码中,包含两个读文件的函数,每次打开或读取文件都会 Wrap 一个 error,最终再 main 函数中打印结果。

package main

import (
	"fmt"
	"github.com/pkg/errors"
	"io/ioutil"
	"os"
	"path/filepath"
)

func ReadFile(path string) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, errors.Wrap(err, "open failed")
	}
	defer f.Close()

	buf, err := ioutil.ReadAll(f)
	if err != nil {
		return nil, errors.Wrap(err, "read failed")
	}
	return buf, nil
}

func ReadConfig() ([]byte, error) {
	home := os.Getenv("HOME")
	config, err := ReadFile(filepath.Join(home, ".settings.xml"))
	return config, errors.WithMessage(err, "could not read config")
}

func main() {
	_, err := ReadConfig()
	if err != nil {
		fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))
		fmt.Printf("stack trace:\n%+v\n", err)
		os.Exit(1)
	}
}
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

运行结果如下:

可以看到,我们既拿到了原始的错误信息,也得到了堆栈信息。

Wrap errors 源码解析

// Wrap returns an error annotating err with a stack trace
// at the point Wrap is called, and the supplied message.
// If err is nil, Wrap returns nil.
func Wrap(err error, message string) error {
	if err == nil {
		return nil
	}
	err = &withMessage{
		cause: err,
		msg:   message,
	}
	return &withStack{
		err,
		callers(),
	}
}

type withMessage struct {
	cause error
	msg   string
}

type withStack struct {
	error
	*stack
}
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

可以看到,Wrap 方法的原理如下:

  1. 根因 err 保存在一个 withMessage 结构体的 cause 字段中;
  2. 而这个 withMessage 结构体又保存在一个 withStack 结构体中;
  3. 最后返回的 withStack 结构体还增加了堆栈信息。

多次调用 Wrap 方法会将 err 层层包装,形成一个链式的结构。

Wrap erros 使用规范

  • 在你的应用代码(非基础库)中,使用 errors.New 或者 errros.Errorf 返回错误;
    • 它们都会保存堆栈信息。
  • 如果调用其他包内的函数,通常简单的直接返回,透传 error;
    • 防止多次 Wrap 重复返回堆栈信息。
  • 如果和其他库(github 上的第三方库、公司内部基础库、标准基础库)进行协作,考虑使用 errors.Wrap 或者 errors.Wrapf 保存堆栈信息;
    • 也就是只有最底层的函数需要 Wrap,底层函数一般是和数据库、Rpc 等直接交互。
  • 直接返回错误,而不是每个错误产生的地方到处打日志;
  • 在程序的顶部或者是工作的 goroutine 顶部(请求入口),使用 %+v 把堆栈详情记录;
  • 最终可以使用 errors.Cause 获取 root error,再进行和 Sentinel error 判定。

注意

这里的 errors.xxx 指的都是 github.com/pkg/errors 里面的方法。

总结

Packages that are reusable across many projects only return root error values.

Wrap error 是只有 applications 可以选择应用的策略;具有最高可重用性的包只能返回根错误值;此机制与 Go 标准库中使用的相同(kit 库的 sql.ErrNoRows)。

If the error is not going to be handled, wrap and return up the call stack.

这是关于函数/方法调用返回的每个错误的基本问题。如果函数/方法不打算处理错误,那么用足够的上下文 wrap errors 并将其返回到调用堆栈中。例如,额外的上下文可以是使用的输入参数或失败的查询语句。确定您记录的上下文是否恰当一个好方法是检查日志并验证它们可以更好的帮助调试。

Once an error is handled, it is not allowed to be passed up the call stack any longer.

一旦确定函数/方法将处理错误,错误就不再是错误。如果函数/方法仍然需要发出返回,则它不能返回错误值。它应该只返回 nil(比如降级处理中,你返回了降级数据,然后需要 return nil)。

# 四、Go 1.13 errors 的改进

Go 1.13 为 errors 和 fmt 标准库包引入了新特性,以简化处理包含其他错误的错误。

# 4.1 errors.Unwrap

  • 功能:将嵌套的 error 解析出来,多层嵌套需要调用 Unwrap 函数多次,才能获取最里层的 error。
  • 原理:对 err 进行断言,看它是否实现了 Unwrap 方法,如果是,调用它的 Unwrap 方法;否则,返回 nil。
// Unwrap returns the result of calling the Unwrap method on err, if err's
// type contains an Unwrap method returning error.
// Otherwise, Unwrap returns nil.
func Unwrap(err error) error {
	// 判断是否实现了 Unwrap 方法
	u, ok := err.(interface {
		Unwrap() error
	})
	if !ok {
		return nil
	}
	// 调用 Unwrap 方法返回被嵌套的 error
	return u.Unwrap()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 4.2 errors.Is

  • 功能:判断 err 是否和 target 是同一类型,或者 err 嵌套的 error 有没有和 target 是同一类型的,如果是,则返回 true。
  • 原理:通过一个无限循环,使用 Unwrap 不断地将 err 里层嵌套的 error 解开,再看被解开的 error 是否实现了 Is 方法,并且调用它的 Is 方法,当两者都返回 true 的时候,整个函数返回 true。
func Is(err, target error) bool {
	if target == nil {
		return err == target
	}

	isComparable := reflectlite.TypeOf(target).Comparable()

	// 无限循环,比较 err 以及嵌套的 error
	for {
		if isComparable && err == target {
			return true
		}
		// 调用 error 的 Is 方法,这里可以自定义实现
		if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
			return true
		}
		// TODO: consider supporting target.Is(err). This would allow
		// user-definable predicates, but also may allow for coping with sloppy
		// APIs, thereby making it easier to get away with them.
		if err = Unwrap(err); err == nil {
			return false
		}
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 4.3 errors.As

  • 功能:判断 err 或者 err 嵌套的 error 有没有能转换成 target 类型的,如果有,则转换后返回 true。
  • 原理:通过遍历 err 嵌套链,从里面找到类型符合的 error,然后把这个 error 赋予 target,这里有值得赋予,所以 target 必须是一个指针。
func As(err error, target interface{}) bool {
	if target == nil {
		panic("errors: target cannot be nil")
	}
	val := reflectlite.ValueOf(target)
	typ := val.Type()
	if typ.Kind() != reflectlite.Ptr || val.IsNil() {
		panic("errors: target must be a non-nil pointer")
	}
	if e := typ.Elem(); e.Kind() != reflectlite.Interface && !e.Implements(errorType) {
		panic("errors: *target must be interface or implement error")
	}
	targetType := typ.Elem()
	for err != nil {
		// 使用反射判断是否可被赋值,如果可以就赋值并且返回 true
		if reflectlite.TypeOf(err).AssignableTo(targetType) {
			val.Elem().Set(reflectlite.ValueOf(err))
			return true
		}
		// 调用 error 自定义的 As 方法,实现自己的类型断言代码
		if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
			return true
		}
		// 不断地 Unwrap,一层层的获取嵌套的 error
		err = Unwrap(err)
	}
	return false
}
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

# 4.4 Wrapping errors with %w

Go 1.13 中为 fmt.Errorf 增加了 %w 格式符来生成一个嵌套的 error,它并没有像 pkg/errors 那样使用一个 Wrap 函数来嵌套 error,非常简洁。

func Errorf(format string, a ...interface{}) error {
	p := newPrinter()
	p.wrapErrs = true
	p.doPrintf(format, a)
	s := string(p.buf)
	var err error
	if p.wrappedErr == nil {
		err = errors.New(s)
	} else {
		err = &wrapError{s, p.wrappedErr}
	}
	p.free()
	return err
}

type wrapError struct {
	msg string
	err error
}

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

func (e *wrapError) Unwrap() error {
	return e.err
}
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

这里的关键核心代码就是 p.wrappedErr 的判断,这个值是否存在,决定是否要生成一个 wrapping error。这个值是怎么来的呢?就是根据我们设置的 %w 解析出来的。

有了这个值之后,就生成了一个 &wrapError{s, p.wrappedErr} 返回了,通过源码可以看到,wrapError 实现了 Error() 方法,也就是说它是一个 error,其中包含了被嵌套的 error 和 msg 信息。

Last Updated: 2023-01-28 4:31:25