Go error 源码解读、错误处理的优化与最佳实践

警告
本文最后更新于 2023-03-17,文中内容可能已过时。

Go 语言自从诞生起,它的错误处理机制一直被喷出翔🙂。

没错,Go 语言在诞生初期确实简陋得不行,但在多个版本迭代以及各位前辈的摸索下还是找到了 Go 语言「错误处理」的最佳实践。

下面我们深入了解下 Go 的 error 包,并讨论如何让我们的 Go 项目拥有清爽的错误处理。

Go 的 errors 包

Go 中的 error 是一个简单的内置接口类型。只要实现了这个接口,就可以将其视为一种 error。

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

与此同时,Go 的 errors 包实现了这个接口:调用 errors.New() 就会返回error接口的实现类errorString,通过源码我们看到errorString的底层就是一字符串,可真是"省事"啊🙃。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package errors

func New(text string) error {
	return &errorString{text}
}

type errorString struct {
	s string
}

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

errors.New()函数返回的是errorString的指针类型,这样做的目的是为了防止字符串产生碰撞。

我们可以做个小测试:error1error2的 text 都是"error",但是二者并不相等。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func TestErrString(t *testing.T) {
    var error1 = errors.New("error")
    var error2 = errors.New("error")
    
    if error1 != error2 {
        log.Println("error1 != error2")
    }
}
---------------------代码运行结果--------------------------
=== RUN   TestXXXX
2022/03/25 22:05:40 error1 != error2

这种创建 error 的方式很常见,在 Go 源码以及三方包源码中大量出现。

1
2
3
4
5
var EOF = errors.New("EOF")

var ErrUnexpectedEOF = errors.New("unexpected EOF")

var ErrNoProgress = errors.New("multiple Read calls return no data or error")

然而很可惜的是,Go 的 error 设计并不能满足所有场景。

Go error 的设计缺陷

error 具有二义性

发生error != nil时不再意味着一定发生了错误,比如 io.Reader 返回 io.EOF 来告知调用者数据已经读取完毕,而这并不算是一个错误。

在两个包之间创建了依赖

比如我们使用了 io.EOF 来检查数据是否读取完毕,那么代码里一定会导入 io 包。

错误信息太单薄

只有一个字符串表达错误,过于单薄。

改进 Go error

现在我们知道 error 底层其实就是一字符串,它很简洁,但反过来也意味着"简陋",无法携带更多错误信息。

自定义错误类型

所以程序员们决定自己封装一个 error 结构体,比如 Go 源码中的 os.PathError。

1
2
3
4
5
type PathError struct {
    Op   string
    Path string
    Err  error
}

封装 error 堆栈信息

将 error 封装后确实能表达更多的错误信息,但是它还有一个致命问题:没有堆栈信息。

比如这种日志,鬼知道代码哪一行报了错,Debug 时简直要命。

1
2
SERVICE ERROR 2022-03-25T16:32:10.687+0800!!!
       Error 1406: Data too long for column 'content' at row 1

我们可以使用github.com/pkg/error包解决这个问题,这个包提供了errors.withStack()方法将堆栈信息封装进 error:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func WithStack(err error) error {
	if err == nil {
		return nil
	}
	return &withStack{
		err,
		callers(),
	}
}

type withStack struct {
    error
    *stack
}

防止 error 被覆盖

上层 error 想附带更多日志信息时,往往会使用fmt.Errorf()fmt.Errorf()会创建一个新的 error 覆盖掉原本的 error 类型,我们写一个 demo 测试一下:

 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
var errNoRows = errors.New("no rows")

// 模仿sql库返回一个errNoRows
func sqlExec() error {
    return errNoRows
}

func serviceNoErrWrap() error {
    err := sqlExec()
    if err != nil {
        // fmt.Errorf() 吞掉了原本的 errNoRows 类型错误。
        return fmt.Errorf("sqlExec failed.Err:%v", err)
    }
    
    return nil
}

func TestErrWrap(t *testing.T) {
    // 使用fmt.Errorf创建了一个新的err,丢失了底层err
    err := serviceNoErrWrap()
    if err != errNoRows {
        log.Println("===== errType don't equal errNoRows =====")
    }
}
-------------------------------代码运行结果----------------------------------
=== RUN   TestErrWrap
2022/03/26 17:19:43 ===== errType don't equal errNoRows =====

同样,使用github.com/pkg/error包errors.Wrap()函数可以帮助我们为 error 添加自定义的文本信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func Wrap(err error, message string) error {
    if err == nil {
        return nil
    }
    err = &withMessage{
        cause: err,
        msg:   message,
    }
    return &withStack{
        err,
        callers(),
    }
}
注意
github.com/pkg/error包 内容很多,这里不展开聊了,后面单独讲。

到此为止,我们深入认识了 Go 的 error,现在我们谈谈如何在大型项目中做好错误处理。

error 处理最佳实践

优先处理 error

当一个函数返回 error 时,应该优先处理 error,忽略其他返回值。

只处理 error 一次

在 Go 中,每个 err 只应该被处理一次。 如果一个函数返回了 err,那么我们有两个选择:

  1. 选择一:立即处理 err(包括记日志等行为),然后 return nil(把错误吞掉)。

    注意
    这个行为可以被认为是对 error 做降级处理,所以一定要小心处理函数返回值。

  2. 选择二:直接 return err,把 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
30
// 试想如果writeAll函数出错,会打印两遍日志
// 如果整个项目都这么做,最后会惊奇的发现我们在处处打日志,项目中存在大量没有价值的垃圾日志
// unable to write:io.EOF
// could not write config:io.EOF

type config struct {}

func writeAll(w io.Writer, buf []byte) error {
    _, err := w.Write(buf)
    if err != nil {
        log.Println("unable to write:", err)
        return err
    }
    
    return nil
}

func writeConfig(w io.Writer, conf *config) error {
    buf, err := json.Marshal(conf)
    if err != nil {
        log.Printf("could not marshal config:%v", err)
    }
    
    if err := writeAll(w, buf); err != nil {
        log.Println("count not write config: %v", err)
        return err
    }
    
    return nil
}

不要反复包装 error

我们应该包装 error,但仅包装一次,否则会造成日志重复打印。

上层业务代码建议Wrap error,但是底层基础Kit库则不建议这样做。比如 Go 的sql库会返回sql.ErrNoRows这种预定义错误,然后我们的业务代码将其包装后 return。

不透明的错误处理

在大型项目中,推荐使用不透明的错误处理(Opaque errors):不关心错误类型,只关心 error 是否为 nil。

1.jpg

这种方式代码耦合小,不需要判断特定错误类型,也就不需要导入相关包的依赖。

当然了,在这种情况下,只要我们调用函数,就一定跟着一组if err != nil{},这也是大家经常吐槽 Go 项目if err != nil{}满天飞的原因😂。

注意
目前我们只讨论在调用 Go 内置库和第三方库时产生的 error 的最佳处理实践,业务层面的错误处理是一个单独的话题,以后单独写一篇聊。

优化错误处理流程

Go 因为代码中无数的if err != nil被诟病,现在我教大家一个优化技巧:

我们先看看 bufio.scan() 是如何简化 error 处理的:

 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
// CountLines() 实现了"读取内容的行数"功能
func CountLines(r io.Reader) (int, error) {
    var (
        br    = bufio.NewReader(r)
        lines int
        err   error
    )
    
    for {
        _, err := br.ReadString('\n')
        lines++
        if err != nil {
            break
        }
    }
    
    if err != io.EOF {
        return 0, nilsadwawa 
    }
    
    return lines, nil
}

// 利用 bufio.scan() 简化 error 的处理:
func CountLinesGracefulErr(r io.Reader) (int, error) {
    sc := bufio.NewScanner(r)
    
    lines := 0
    for sc.Scan() {
        lines++
    }
    
    return lines, sc.Err()
}

源码解读:bufio.NewScanner() 返回一个 Scanner 对象,结构体内部包含了 error 类型,调用Err()方法即可返回封装好的 error。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type Scanner struct {
    r            io.Reader // The reader provided by the client.
    split        SplitFunc // The function to split the tokens.
    maxTokenSize int       // Maximum size of a token; modified by tests.
    token        []byte    // Last token returned by split.
    buf          []byte    // Buffer used as argument to split.
    start        int       // First non-processed byte in buf.
    end          int       // End of data in buf.
    err          error     // Sticky error.
    empties      int       // Count of successive empty tokens.
    scanCalled   bool      // Scan has been called; buffer is in use.
    done         bool      // Scan has finished.
}

func (s *Scanner) Err() error {
    if s.err == io.EOF {
        return nil
    }
    return s.err
}

利用上面学到的思路,我们可以自己实现一个errWriter对象,简化对 error 的处理:

 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
type Header struct {
    Key, Value string
}

type Status struct {
    Code   int
    Reason string
}

// WriteResponse()函数实现了"构建HttpResponse"功能
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.Fprintf(w, "\r\n"); err != nil {
        return err
    }
    
    _, err = io.Copy(w, body)
    return err
}

// 优化错误处理
type errWriter struct {
    io.Writer
    err error
}

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

func WriteResponseGracefulErr(w io.Writer, st Status, headers []Header, body io.Reader) error {
    ew := &errWriter{w, nil}
    
    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.Fprintf(w, "\r\n")
    
    io.Copy(ew, body)
    
    return ew.err
}

Go.1.13 版本 error 的新特性

Go 1.13 版本借鉴了github.com/pkg/error包,大幅增强了 Golang 语言判断 error 类型的能力,这些函数平时还是用得到的,我们深入学习下:

errors.UnWrap()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 与errors.Wrap()行为相反
// 获取err链中的底层err
func Unwrap(err error) error {
    u, ok := err.(interface {
        Unwrap() error
    })
    if !ok {
        return nil
    }
    return u.Unwrap()
}

errors.Is()

在 1.13 版本之前,我们可以用err == targetErr判断 err 类型

errors.Is()是其增强版:error 链上的任一err == targetErr,即return true,我们写个 demo 跑一下:

 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
var errNoRows = errors.New("no rows")

// 模仿sql库返回一个errNoRows
func sqlExec() error {
    return errNoRows
}

func service() error {
    err := sqlExec()
    if err != nil {
        return errors.WithStack(err)    // 包装errNoRows
    }
    
    return nil
}

func TestErrIs(t *testing.T) {
    err := service()
    
    // errors.Is递归调用errors.UnWrap,命中err链上的任意err即返回true
    if errors.Is(err, errNoRows) {
        log.Println("===== errors.Is() succeeded =====")
    }
    
    //err经errors.WithStack包装,不能通过 == 判断err类型
    if err == errNoRows {
        log.Println("err == errNoRows")
    }
}
-------------------------------代码运行结果----------------------------------
=== RUN   TestErrIs
2022/03/25 18:35:00 ===== errors.Is() succeeded =====

例子解读:因为使用errors.WithStack包装了sqlErrorsqlError位于error链的底层,上层的error已经不再是sqlError类型,所以使用==无法判断出底层的sqlError

源码解读:

  • 内部调用了err = Unwrap(err)方法来获取 error 链中每一个 error。
  • 兼容自定义 error 类型。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func Is(err, target error) bool {
    if target == nil {
        return err == target
    }
    
    isComparable := reflectlite.TypeOf(target).Comparable()
    for {
        if isComparable && err == target {
            return true
        }
        // 自定义的 error 可以实现`Is接口`自定义 error 类型判断逻辑
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true
        }
        if err = Unwrap(err); err == nil {
            return false
        }
    }
}

下面我们尝试使用erros.Is()识别自定义 error 类型:

 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
41
42
43
44
45
type errNoRows struct {
    Desc string
}

func (e errNoRows) Unwrap() error { return e }

func (e errNoRows) Error() string { return e.Desc }

func (e errNoRows) Is(err error) bool {
    return reflect.TypeOf(err).Name() == reflect.TypeOf(e).Name()
}

// 模仿sql库返回一个errNoRows
func sqlExec() error {
    return &errNoRows{"Kaolengmian NB"}
}

func service() error {
    err := sqlExec()
    if err != nil {
        return errors.WithStack(err)
    }
    
    return nil
}

func serviceNoErrWrap() error {
    err := sqlExec()
    if err != nil {
        return fmt.Errorf("sqlExec failed.Err:%v", err)
    }
    
    return nil
}

func TestErrIs(t *testing.T) {
    err := service()
    
    if errors.Is(err, errNoRows{}) {
        log.Println("===== errors.Is() succeeded =====")
    }
}
-------------------------------代码运行结果----------------------------------
=== RUN   TestErrIs
2022/03/25 18:35:00 ===== errors.Is() succeeded =====

errors.As()

在 1.13 版本之前,我们可以用if _,ok := err.(targetErr)判断 err 类型,现在errors.As()是其增强版:error 链上的任一err与targetErr类型相同,即return true,我们写个 demo 跑一下:

 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
41
42
43
44
45
46
47
48
// errors.WithStack 包装了 sqlError 
// sqlError 位于 error 链的底层,上层的 error 已经不再是 sqlError 类型
// 使用类型断言无法判断出底层的 sqlError,而使用 errors.As() 函数可以判断出底层的 sqlError
type sqlError struct {
    error
}

func (e *sqlError) IsNoRows() bool {
    t, ok := e.error.(ErrNoRows)
    return ok && t.IsNoRows()
}

type ErrNoRows interface {
    IsNoRows() bool
}

// 返回一个sqlError
func sqlExec() error {
    return sqlError{}
}

// errors.WithStack包装sqlError
func service() error {
    err := sqlExec()
    if err != nil {
        return errors.WithStack(err)
    }
    
    return nil
}

func TestErrAs(t *testing.T) {
    err := service()
    
    // 递归使用errors.UnWrap,只要Err链上有一种Err满足类型断言,即返回true
    sr := &sqlError{}
    if errors.As(err, sr) {
        log.Println("===== errors.As() succeeded =====")
    }
    
    // 经errors.WithStack包装后,不能通过类型断言将当前Err转换成底层Err
    if _, ok := err.(sqlError); ok {
        log.Println("===== type assert succeeded =====")
    }
}
----------------------------------代码运行结果--------------------------------------------
=== RUN   TestErrAs
2022/03/25 18:09:02 ===== errors.As() succeeded =====

总结

这篇文章我们认识了 Go 的 error,研究了error包github.com/pkg/error包的源码,也聊了聊针对 Go 项目错误处理的优化与最佳实践,文中有大量 Demo 代码,建议 copy 代码跑上两遍,有助于理解我单薄的文字,有助于快速掌握 Go 的 error 处理。


参考:

  1. 《Effective GO》
  2. Go程序设计语言》
  3. https://dave.cheney.net/practical-go/presentations/qcon-china.html#_error_handling

转载声明:本文允许转载,原文地址:Go error 源码解读、错误处理的优化与最佳实践

Buy me a coffee~
室长 支付宝支付宝
室长 微信微信
0%