造一个 Go 语言错误库的轮子

1
go get code.gopub.tech/errors

Show me the code:

 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
import (
    "testing"

    "code.gopub.tech/errors"
)

func TestPrint(t *testing.T) {
    err1 := errors.New("err1")
    err2 := errors.New("err2")
    err := errors.Errorf("prefix: %w, %w", err1, err2)
    fmt.Printf("%+v\n", err)
}
/* Output 
prefix: err1, err2
(1) attached stack trace
  -- stack trace:
  example_test.TestPrint
  	/path/to/new_errors_test.go:65
  [...repeated from below...]
Next: (2) prefix: err1, err2
Next: (3) err1
  err2
  Wraps: (4) attached stack trace
    -- stack trace:
    example_test.TestPrint
    	/path/to/new_errors_test.go:63
    [...repeated from below...]
  Next: (5) err1
  Wraps: (6) attached stack trace
     -- stack trace:
     example_test.TestPrint
     	/path/to/new_errors_test.go:64
     testing.tRunner
     	/usr/local/go/src/testing/testing.go:1576
     runtime.goexit
     	/usr/local/go/src/runtime/asm_amd64.s:1598
   Next: (7) err2
Error types: (1) *errors.withStack (2) *errors.withNewMessage (3) *errors.joinError (4) *errors.withStack (5) *errors.errorString (6) *errors.withStack (7) *errors.errorString

*/

Why 为什么要新造轮子

pkg/errors

Go 语言内置的错误都不带堆栈,所以社区流行的错误库一般是 pkg/errors.
这个库提供了 NewWrapWrapf 等许多方法,能够创建带堆栈的错误实例。然而它也有一些缺陷:

  • 仅支持 %v%+v%s%q 这些格式化动词,不支持 %x%X, 也不支持 %#v, 带宽度 %10s 这种
  • 在 %+v 详细模式打印时,包装错误和被包装错误如果堆栈重复,会重复打印
  • 这个库现在已经停止更新:This repository has been archived by the owner on Dec 1, 2021. It is now read-only.
 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
package main_test

import (
    "testing"

    "github.com/pkg/errors"
)

func TestErrors(t *testing.T) {
    err := errors.New("pkgErr")
    t.Logf("[%10s]", err.Error()) // [    pkgErr]
    t.Logf("[%20s]", err)         // [pkgErr]
    t.Logf("[% X]", err)          // []
    t.Logf("[%#v]", err)          // [pkgErr]
    t.Logf("%+v", errors.Wrap(err, "prefix"))
    /*
        /path/to/errors_test.go:13: pkgErr
        example_test.TestErrors
            /path/to/errors_test.go:10
        testing.tRunner
            /usr/local/go/src/testing/testing.go:1576
        runtime.goexit
            /usr/local/go/src/runtime/asm_amd64.s:1598
        prefix
        example_test.TestErrors
            /path/to/errors_test.go:15
        testing.tRunner
            /usr/local/go/src/testing/testing.go:1576
        runtime.goexit
            /usr/local/go/src/runtime/asm_amd64.s:1598
    */
}

cockroachdb/errors

This library aims to be used as a drop-in replacement to github.com/pkg/errors and Go's standard errors package. It also provides network portability of error objects, in ways suitable for distributed systems with mixed-version software compatibility.

cockroachdb/errors 这个库旨在替换 pkg/errors 和 Go 内置错误。它解决了 pkg/errors 上述的几个缺陷:

 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
package main_test

import (
    "testing"

    "github.com/cockroachdb/errors"
)

func TestErrors2(t *testing.T) {
    err := errors.New("cockroachdbErr")
    t.Logf("[%20s]", err.Error()) // [      cockroachdbErr]
    t.Logf("[%20s]", err)         // [      cockroachdbErr]
    t.Logf("[% X]", err)          // [63 6F 63 6B 72 6F 61 63 68 64 62 45 72 72]
    t.Logf("[%#v]", err)
    /*
        [&withstack.withStack{
            cause: &errutil.leafError{msg:"cockroachdbErr"},
            stack: &withstack.stack{0x1579dc8, 0x122db17, 0x10c11c1},
        }]
    */
    t.Logf("%+v", errors.Wrap(err, "prefix"))
    /*
        /path/to/cockroach_test.go:21: prefix: cockroachdbErr
        (1) attached stack trace
          -- stack trace:
          | example_test.TestErrors2
          | 	/path/to/cockroach_test.go:21
          | [...repeated from below...]
        Wraps: (2) prefix
        Wraps: (3) attached stack trace
          -- stack trace:
          | example_test.TestErrors2
          | 	/path/to/cockroach_test.go:10
          | testing.tRunner
          | 	/usr/local/go/src/testing/testing.go:1576
          | runtime.goexit
          | 	/usr/local/go/src/runtime/asm_amd64.s:1598
        Wraps: (4) cockroachdbErr
        Error types: (1) *withstack.withStack (2) *errutil.withPrefix (3) *withstack.withStack (4) *errutil.leafError
    */
}

这个库还提供了一些高级功能(我还没用过):支持跨网络传输错误、支持敏感数据打码等。

但它也有一些些小的缺陷:

  • 依赖比较重,直接依赖 13 项,go.sum 文件达 103 行
 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
module github.com/cockroachdb/errors

go 1.19

require (
    github.com/cockroachdb/datadriven v1.0.2
    github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b
    github.com/cockroachdb/redact v1.1.5
    github.com/getsentry/sentry-go v0.18.0
    github.com/gogo/googleapis v1.4.1 // gogoproto 1.2-compatible, for CRDB
    github.com/gogo/protobuf v1.3.2
    github.com/gogo/status v1.1.0
    github.com/hydrogen18/memlistener v1.0.0
    github.com/kr/pretty v0.3.1
    github.com/pkg/errors v0.9.1
    github.com/stretchr/testify v1.8.1
    google.golang.org/grpc v1.53.0
    google.golang.org/protobuf v1.28.1
)

require (
    github.com/davecgh/go-spew v1.1.1 // indirect
    github.com/golang/protobuf v1.5.2 // indirect
    github.com/kr/text v0.2.0 // indirect
    github.com/pmezard/go-difflib v1.0.0 // indirect
    github.com/rogpeppe/go-internal v1.9.0 // indirect
    golang.org/x/net v0.7.0 // indirect
    golang.org/x/sys v0.5.0 // indirect
    golang.org/x/text v0.7.0 // indirect
    google.golang.org/genproto v0.0.0-20230227214838-9b19f0bdc514 // indirect
    gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
    gopkg.in/yaml.v3 v3.0.1 // indirect
)
  • multi-cause 错误格式化效果不好、错误信息含换行符时会丢失内容
 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
package main_test

import (
    "testing"

    "github.com/cockroachdb/errors"
)

func TestMultiCause(t *testing.T) {
    err1 := errors.New("err1\nnew\nline")
    err2 := errors.New("err2\nnew\nline")
    err := errors.Join(err1, err2)
    t.Logf("%+v", errors.Wrap(err, "prefix"))
    /*
        /path/to/cockroach_test.go:47: prefix: err1
        (1) attached stack trace
          -- stack trace:
          | example_test.TestMultiCause
          | 	/path/to/cockroach_test.go:47
          | testing.tRunner
          | 	/usr/local/go/src/testing/testing.go:1576
        Wraps: (2) prefix
        Wraps: (3) attached stack trace
          -- stack trace:
          | example_test.TestMultiCause
          | 	/path/to/cockroach_test.go:46
          | testing.tRunner
          | 	/usr/local/go/src/testing/testing.go:1576
        Wraps: (4) err1
          | new
          | line
          | err2
          | new
          | line
             Wraps: (5) attached stack trace
          -- stack trace:
          | example_test.TestMultiCause
          | 	/path/to/cockroach_test.go:45
          | [...repeated from below...]
               Wraps: (6) err2
          | new
          | line
             Wraps: (7) attached stack trace
          -- stack trace:
          | example_test.TestMultiCause
          | 	/path/to/cockroach_test.go:44
          | testing.tRunner
          | 	/usr/local/go/src/testing/testing.go:1576
          | runtime.goexit
          | 	/usr/local/go/src/runtime/asm_amd64.s:1598
               Wraps: (8) err1
          | new
          | line
        Error types: (1) *withstack.withStack (2) *errutil.withPrefix (3) *withstack.withStack (4) *join.joinError (5) *withstack.withStack (6) *errutil.leafError (7) *withstack.withStack (8) *errutil.leafError
     */
}

What 新造的轮子是什么样

不依赖其他库

不过,内部使用了 github.com/knz/go-fmtfwd 和 github.com/kr/pretty 的代码。

go.mod

1
2
3
module code.gopub.tech/errors

go 1.18

格式化为树形,而不是链表

支持多行错误、multi-cause 错误
以上述 cockroachdb/errors 例子重新用 code.gopub.tech/errors 执行,如下:

 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
package main_test

import (
    "testing"

    "code.gopub.tech/errors"
)

func TestNewErrors(t *testing.T) {
    err1 := errors.New("err1\nnew\nline")
    err2 := errors.New("err2\nnew\nline")
    err := errors.Join(err1, err2)
    t.Logf("%+v", errors.Wrap(err, "prefix"))
    /*
    /path/to/new_errors_test.go:13: prefix: err1
    new
    line
    err2
    new
    line
    (1) attached stack trace
      -- stack trace:
      example_test.TestNewErrors
      	/path/to/new_errors_test.go:13
      [...repeated from below...]
    Next: (2) prefix
    Next: (3) attached stack trace
      -- stack trace:
      example_test.TestNewErrors
      	/path/to/new_errors_test.go:12
      [...repeated from below...]
    Next: (4) err1
      new
      line
      err2
      new
      line
      Wraps: (5) attached stack trace
        -- stack trace:
        example_test.TestNewErrors
        	/path/to/new_errors_test.go:10
        [...repeated from below...]
      Next: (6) err1
         new
        line
      Wraps: (7) attached stack trace
         -- stack trace:
         example_test.TestNewErrors
         	/path/to/new_errors_test.go:11
         testing.tRunner
         	/usr/local/go/src/testing/testing.go:1576
         runtime.goexit
         	/usr/local/go/src/runtime/asm_amd64.s:1598
       Next: (8) err2
          new
         line
    Error types: (1) *errors.withStack (2) *errors.withPrefix (3) *errors.withStack (4) *errors.joinError (5) *errors.withStack (6) *errors.errorString (7) *errors.withStack (8) *errors.errorString
    */
}

How 轮子是如何造的

兼容 pkg/errors 的堆栈

因为这个库使用范围太广了,如果对 pkg/errors 类型的错误进行包装后,无法打印其堆栈那将是不可接受的(点名批评内置的 fmt.Errorf("prefix: %w", pkgErr))。
查看(并 抄袭 借鉴)其源码,堆栈信息是通过 StackTrace() 方法打印的。
cockroachdb/errors 是直接引用了 pkg/errors 的 StackTrace 、Frame 等类型。
不过好在 StackTrace 实际就是 []uintptr,所以为了达成尽量不引入依赖的目的,直接使用了反射执行错误实例上 StackTrace() 方法以获取堆栈信息。

格式化为树形

查看 cockroachdb/errors 的源码,发现了它打印错误的套路,就是将各个错误类型的打印方法(Format(s fmt.State, verb rune)) 都代理给 FormatError() 去实现:

  1. 首先通过 Unwrap 递归地获取内层错误信息
  2. 然后构造整个错误链
  3. 然后输出错误链到一个缓冲区
  4. 最后使用外部指定的格式化动词输出缓冲区内容(这样就支持了 %10s% X 等)

然而,这个库实现挺复杂的(里面也借鉴了x/xerrors ),可能是有历史包袱,go1.20 的 fmt.Errorf 新支持了使用多个 %w 包装多个错误,在这个库中打印的效果并不好(上面的示例可以看出)。

本来打算在它的基础上,支持多错误打印,但是写着写着发现它的逻辑太复杂了,干脆推倒重来:

  1. 首先通过 Unwrap()error 和 Unwrap()[]error 递归地获取最内层错误信息
  2. 然后构造出错误树!(单一 cause 时,就是只有一个子树)
  3. 先根顺序遍历错误树输出到缓冲区(这里调整树的格式费了好久)
  4. 输出缓冲区内容

其他一些改动,感兴趣的话可以参考源码。包括:

  • 简化内部 state 的逻辑,去掉依据换行符检测是简单消息还是详细信息(就是这个逻辑,导致 cockroachdb/errors 对多行错误不友好)
  • 将它从 x/errors继承来的 Formatter 改为 ErrorPrinter,并同步修改 Printer 接口,去除其 Detail 方法,转而新增 PrintDetailPrintDetailf 方法(原先的 Detail() 实现也很费劲,调用到这里时需要使用 swtichOver 函数将 buf 缓冲区转换为 headBuf,一点都不直观。新的实现直接在一开始就区分出 simple 缓冲区和 detai 缓冲区)

Links

Reader Echoes

No comments yet

表情

评论提交后需经审核才会显示。