pkg/errors
包在 Go 错误处理生态中可谓大名鼎鼎了,截止目前在 GitHub 上有 8.2k 的 star 量。虽然不是 Go 官方包,但却被很多团队当作事实标准来使用。
本文就来深入到 pkg/errors
包源码中,一窥它的设计与实现。
Go 错误处理诉求
Go 语言的错误处理非常简单,秉承着 Errors are values 的大道至简风格。不过也正是由于简单,也就暴露了 Go 错误处理的些许简陋。
在日常开发中,我们对于错误处理会有两个最普遍的诉求:
- 附加错误信息:在拿到原有的底层代码或第三方库返回的错误后,我们可能希望附加一些业务信息,比如
userID
,这样就知道这条错误是由哪个用户产生的。 - 附加错误堆栈:因为错误堆栈中有出错代码的位置,以及整个调用链路,这会方便我们定位问题。
在错误中附加了这两个信息以后,输出的错误日志就非常有价值了,我们可以根据输出信息快速定位问题。
在 Go 1.13 版本之前,给一个错误附加错误信息可以这样做:
1 | newErr := fmt.Errorf("user %d is err: %s", userID, err) |
不过,这里存在一个较大的问题,就是新的错误 newErr
与原来的 err
是两个完全不同的值,有时候我们想通过 newErr
找到错误的原始根因 err
,就没办法了。
好在 Go 1.13 版本为 fmt.Errorf
提供了 %w
动词,能给解决无法通过 newErr
找到 err
的问题,我们只需要把原来的 %s
换成 %w
即可:
1 | newErr := fmt.Errorf("user %d is err: %w", userID, err) |
这样,我们就可以用 err = errors.Unwrap(newErr)
拿到错误根因 err
了。
但是,Go 依然没有提供获取错误堆栈信息的方法,只能我们自己想办法解决。
幸运的是,以上这两个问题 pkg/errors
包都可以帮我们解决。
pkg/errors
包使用示例如下:
1 | package main |
示例代码中,函数调用链为:main() -> b() -> a()
。
函数 a
直接返回一个错误 errors.New("a error")
。函数 b
在得到错误后,对其进行了包装,附加了新的错误信息 newErr := errors.WithMessage(err, "b error")
,使用 errors.Cause(newErr)
可以从包装后的错误中还原出初始的错误根因。最后在 main
函数中,我们分别以不同形式打印了三次错误信息,%v
动词可以打印错误信息,%+v
可以打印错误信息和错误堆栈。
NOTE:
示例代码中有一行被注释了newErr := errors.Wrap(err, "b error")
,你可以自行尝试下如果使用errors.Wrap
来包装err
,最终程序输出结果有何变化。
执行示例代码,输出结果如下:
1 | $ go run main.go |
输出结果一目了然,使用 errors.Cause(newErr)
拿到的错误根因就是 err
。使用 errors.WithMessage(err, "b error")
对 err
包装后得到的 newErr
使用 %v
输出结果为 b error: a error
,而使用 %+v
输出结果则包含了整个错误调用链的堆栈信息。
这就是 pkg/errors
的“威力”,非常好用。
虽然由于 Go 2 错误提案 的存在,现在 pkg/errors
包 GitHub 仓库已经归档,但等待 Go 2 演进的路上遥遥无期,pkg/errors
包依然是最有竞争力的选择。
所以 pkg/errors
仍然推荐使用,并且源码也值得一读。
pkg/errors 源码解读
pkg/errors
提供了这些公开函数供我们使用:
1 | $ go doc pkg/errors | grep "^func" |
这些函数的用法,我就不一一演示了,先有个印象,讲解完源码以后,你自己就会用了。
接下来我们一起对源码进行详细解读,看看 pkg/errors
底层到底是如何实现的。
pkg/errors
项目目录结构如下:
1 | $ tree github.com/pkg/errors@v0.9.1 |
可以发现,此项目采用平铺式目录结构,除了非 Go 代码文件以及测试文件,我们需要关注的 Go 文件就仅有 3 个:errors.go
、go113.go
以及 stack.go
。
看到这里,你也就可以放宽心了,pkg/errors
包源码其实并没有多少。
errors.go
我们先来看 errors.go
文件。
这个文件中的代码量并不多,即使算上全部的注释,也才不到 300
行。
pkg/errors
提供的第一个表示错误的结构体 fundamental
定义如下:
1 | func New(message string) error { |
fundamental
结构体定义非常简单,仅有两个字段:
msg
用来记录错误信息。- 指针字段
*stack
用来记录出错时的错误堆栈信息。这里我们现在仅需要知道*stack
是什么即可,暂且不去深究它的底层实现。
有两个函数都可以构造 fundamental
类型的错误:
New
函数通过错误信息message
来构造错误对象,并在内部调用callers()
获取错误堆栈。Errorf
函数接收变长参数,内部使用fmt.Sprintf
来格式化错误信息。
New
其实对标的就是 Go 自带的 errors.New()
,这两个函数签名相同。而 Errorf
对标的是 Go 自带的 fmt.Errorf()
,两个函数签名也相同。
pkg/errors
的函数命名还是比较考究的,与内置库同名。这样能够减少迁移成本,并且降低理解的心智负担。
fundamental
错误类还实现了两个方法 Error
和 Format
。
Error
方法没什么好说的,就是用来实现标准的error
接口。这里只返回了msg
信息,没有对错误堆栈信息做任何处理。Format
方法是格式化输出用的,实现了fmt.Formatter
接口,而参数s fmt.State
也是个接口。
这两个接口定义如下:
1 | type Formatter interface { |
当我们使用 fmt.Printf("%v\n", err)
格式化打印错误对象信息时,就会调用 err
对象的 Format
方法。
Format
方法的参数 s fmt.State
又实现了 io.Writer
接口,我们向这个参数中写入的任何内容,都会被替换到 %v
占位符处。
而通过 verb
参数,我们可以拿到 fmt.Printf
的格式化动词,就是 %
后面紧挨着的字符,比如 %s
、%v
中的 s
和 v
。
有了 verb
就可以定制化不同输出了,%v
会被 Format
方法内部的第一个 case
匹配,如果是 %+v
则进入 if s.Flag('+')
逻辑。
根据 Format
方法源码,可以总结 fundamental
类型错误输出格式有如下几种:
verb | 输出 |
---|---|
%s | 错误信息 |
%v | 错误信息 |
%+v | 错误信息 + 堆栈信息 |
%q | 转义后的错误信息 |
NOTE:
如果你对这几个verb
不是很熟悉,可以查看 Go fmt 文档。
我们再来看下 pkg/errors
提供的第二个表示错误的结构体 withStack
:
1 | func WithStack(err error) error { |
withStack
结构体同样包含两个字段,分别是 error
和错误堆栈。顾名思义,这个结构体就是为了给一个错误对象,携带错误堆栈信息用的。
WithStack
构造函数也确实是干这件事的,它接收一个 error
,并返回一个 withStack
类型错误,这时已经附加上了错误堆栈信息。
withStack
结构体的 Cause
方法返回 error
字段,表示错误根因,即 withStack
错误的初始原因。
至于 Unwrap
方法,根据注释可以了解,是为了兼容 Go 1.13 而适配的方法,功能与 Cause
一样。
withStack
结构体同样定义了 Format
方法,这没什么好解释的了。
总结 withStack
类型错误输出格式有如下几种:
verb | 输出 |
---|---|
%s | 错误信息 |
%v | 错误信息 |
%+v | 错误根因 + 堆栈信息 |
%q | 转义后的错误信息 |
pkg/errors
提供的最后一个表示错误的结构体为 withMessage
:
1 | func WithMessage(err error, message string) error { |
withMessage
是给一个错误附加新的错误信息,这里并没有包含错误堆栈信息。
它的方法功能一目了然,无需我多解释什么了。
总结 withMessage
类型错误输出格式有如下几种:
verb | 输出 |
---|---|
%s | 错误信息 |
%v | 错误信息 |
%+v | 错误根因 + 错误信息 |
%q | 错误信息 |
现在我们将 pkg/errors
包提供的三种错误放在一起进行对比:
1 | type fundamental struct { |
可以发现,其实这三个错误就是 error
、msg
以及 stack
三者的两两组合。
error
就是初始错误,msg
是附加的错误信息,stack
则是附加的堆栈信息。
那么你可能会想,pkg/errors
包有没有提供将这三个信息放在一起的方法?
当然有,errors.go
源码中剩下的最后三个函数定义如下:
1 | func Wrap(err error, message string) error { |
Wrap
函数,接收一个 err
,和一个错误信息 message
,如果 err
为 nil
,直接返回,否则,先使用这两个信息构造 withMessage
错误类型,然后再用这个新的 error
对象和通过调用 callers()
得到的错误堆栈 *stack
一起构造一个新的 withStack
错误。
Wrap
函数最终返回的 error
就完整的包含了 error
、msg
以及 stack
三个信息。
Wrapf
函数同理。
Cause
函数则用来递归的拆解嵌套错误,直到取出错误对象的最原始的错误,即错误根因。
以上,就是 errors.go
文件的全部代码了。
go113.go
然后我们再一起看看 go113.go
文件中的代码。
go113.go
文件源码如下:
1 | // +build go1.13 |
根据这个文件的名称,想必你也能猜测出来,这里的代码是与 Go 1.13 版本相关的,构建约束 // +build go1.13
也证实了这一点。
Is
、As
、Unwrap
这三个函数都是 Go 1.13 版本引入的。
Is
函数用来判断两个错误是否为同一个,它用来替代==
运算符。As
用来替代错误断言。Unwrap
用来解包错误,跟Cause
函数作用相同。
这个文件就是用来兼容 Go 1.13 的,并无其他额外功能。
stack.go
接下来我们要阅读源码的文件就只剩下 stack.go
了。
首先我们要看的就是在 errors.go
中见的最多的 callers()
函数调用,callers
函数就出自这个文件,来看看这个函数是怎么定义的:
1 | func callers() *stack { |
callers
函数内部使用 runtime.Callers
获取当前的调用栈信息。这在代码调试、生成错误报告、记录日志等场景非常有用。
runtime.Callers
函数签名如下:
1 | func Callers(skip int, pc []uintptr) int |
skip
参数用于指定要跳过的栈帧数量。0
表示包括 runtime.Callers
自己的调用帧,1
表示从调用 runtime.Callers
的函数开始,依此类推。通常设置 skip
为 1
或更高,以跳过对错误检测或日志记录功能本身的调用。这里设为 3
表示跳过 runtime.Callers
、callers
以及 errors.go
中定义的 New
、Errorf
等函数调用帧。
runtime.Callers
函数会填充传递给它的指针切片 pc []uintptr
当前 goroutine 调用栈的程序计数器(PC)值。这个 pc
可以用于进一步获取关于每个栈帧的详细信息,例如通过 runtime.FuncForPC
函数获取函数名称、行号等,稍后会进行演示讲解。pc
切片的大小决定了可以捕获的栈帧的数量。
runtime.Callers
函数返回值是 int
类型,它记录填充到 pc
切片中的项目数。如果实际的调用栈比提供的 pc
长度短,返回值就小于 pc
切片的长度。
所以,stack
类型的变量 st
的赋值操作,只取了 pcs[0:n]
,n
是返回的切片长度。
既然 pcs
切片变量能赋值给 stack
类型变量 st
,就说明二者类型一致。
stack
类型的底层类型也确实是 []uintptr
,定义如下:
1 | // stack represents a stack of program counters. |
stack
支持的方法定义如下:
1 | func (s *stack) Format(st fmt.State, verb rune) { |
你是否记得 Format
方法,会在 error.Format
方法中被调用,回忆下 errors.go
中的代码:
1 | func (f *fundamental) Format(s fmt.State, verb rune) { |
f.stack.Format(s, verb)
就是调用了 Format
方法,可以发现 stack.Format
方法只有在使用 %+v
时才生效,这也与我们之前总结的错误输出格式所对应。
在遇到 %+v
这个 case
时,程序会遍历 stack
切片中的每一项,即每一个栈帧,并且使用 f := Frame(pc)
将其包装成 Frame
类型,然后对其进行格式化输出 fmt.Fprintf(st, "\n%+v", f)
。
所以我们要再看一下 Frame
是什么:
1 | // Frame represents a program counter inside a stack frame. |
实际上 Frame
底层类型正是 uintptr
类型,所以可以直接通过 Frame(pc)
进行转换。
Frame
支持的方法定义如下:
1 | func (f Frame) pc() uintptr { return uintptr(f) - 1 } |
我们重点关注 Format
方法,在 stack.Format
中执行 fmt.Fprintf(st, "\n%+v", f)
时就会调用 Frame.Format
方法。
结合注释我们很容易能总结出 Frame
类型输出格式有如下几种:
verb | 输出 |
---|---|
%s | 源码所在文件路径 |
%d | 源码行号 |
%n | 函数名称 |
%v | 等价于 %s:%d |
%+s | 函数名称 + \n\t + 源码所在文件路径 |
%n | 等价于 %+s:%d |
另外几个辅助函数我就不逐行解释了,我给你讲个示例,你就都明白了。
1 | package main |
示例代码中,函数调用链为:main() -> Print() -> printStack()
。
printStack
函数参数 skip
用来指定跳过的函数调用栈帧数量。runtime.FuncForPC
函数接收一个程序计数器 pc
值,然后返回 *runtime.Func
类型对象 fn
,通过 fn.FileLine(pc - 1)
可以拿到文件路径和行号,fn.Name()
可以拿到函数名。
执行示例代码,输出结果如下:
1 | $ go run main.go |
可以看到,Print(0)
会从 runtime.Callers
栈帧开始输出,Print(3)
则会跳过 runtime.Callers
、main.printStack
、main.Print
这三个函数,从 main.main
开始输出。
你也许会好奇,runtime.FuncForPC(pc - 1)
和 fn.FileLine(pc - 1)
函数的调用参数为什么是 pc - 1
,而不是 pc
?
这个问题其实在 Frame
定义处的注释中有说明:
1 | // Frame represents a program counter inside a stack frame. |
但看完了注释就更加疑惑了,由于历史原因 runtime.Callers
返回值是程序计数器 + 1,历史原因又是什么呢?
我们可以深入到 runtime.Callers
源码,顺着 runtime.Callers() -> callers() -> tracebackPCs()
调用链路,一路跟踪到 tracebackPCs
函数的定义中:
1 | func tracebackPCs(u *unwinder, skip int, pcBuf []uintptr) int { |
这里也有一段注释:
1 | // Callers expect the pc buffer to contain return addresses |
可以简单理解为,调用方期望拿到 pc
并自行执行 -1
操作,因此 runtime.Callers
函数内部会在调用时为 pc
加 1
来生成返回地址。
不得不吐槽下这个逻辑隐藏的太深了,不深入源码看根本不知道其意图。我猜测可能会在 Go 仓库的某个历史 issues 中有一些说明,如果你感兴趣可以去翻一翻。
NOTE:
其实runtime
包提供了另外一个CallersFrames
方法,也能过获取函数名称、行号等,与runtime.Callers
搭配使用更佳方便,无需考虑pc - 1
问题,你可以去我的 GitHub 仓库查看我实现的示例代码。
扯远了,最后我们再回过头来看看 stack.StackTrace
方法的逻辑:
1 | func (s *stack) StackTrace() StackTrace { |
可以看到,这里就是在组装 []Frame
切片,然后返回。
返回类型 StackTrace
定义如下:
1 | // StackTrace is stack of Frames from innermost (newest) to outermost (oldest). |
它有两个方法:
1 | // Format formats the stack of Frames according to the fmt.Formatter interface. |
经过前面的源码分析,相信这两个方法你自己也能看懂了,就留给你自己分析吧。
至此,pkg/errors
包的源码就全部解读完成了。
现在的你已经掌握了 pkg/errors
包是如何设计的,它的更多使用技巧就等着你自行去探索了,我就不一一举例了。
总结
本文从 Go 错误处理常见的两个诉求说起,Go 自带的错误处理无法很好的处理附加错误信息和附加堆栈信息。而 pkg/errors
包则完全能够满足我们的诉求,并且有很好的兼容性。
我带你分析了 pkg/errors
包的全部源码,pkg/errors
包含三个文件:
errors.go
定义了三种错误类型fundamental
、withStack
、withMessage
供我们使用,借助这三种错误类型,我们可以很方便的组装error
、msg
和stack
这些信息。go113.go
是为了兼容 Go 1.13 版本而引入的文件,分别对Is
、As
、Unwrap
这三个函数进行了代理。stack.go
中的代码用来处理错误堆栈信息。通过此文件我们还学习到了runtime.Callers
和runtime.FuncForPC
的用法。
这也是我写「Go 错误处理指北」系列的第二篇文章,接下来还会有更多的文章继续讲解 Go 中的错误处理,敬请期待。
本文示例源码我都放在了 GitHub 中,欢迎点击查看。
希望此文能对你有所启发。
延伸阅读
- pkg/errors Documentation:https://pkg.go.dev/github.com/pkg/errors
- pkg/errors 源码:https://github.com/pkg/errors/
- Go 1.13 Release Notes:https://tip.golang.org/doc/go1.13#error_wrapping
- Errors are values:https://go.dev/blog/errors-are-values
- Go 错误处理指北:Error vs Exception vs ErrNo:https://jianghushinian.cn/2024/09/06/go-error-guidelines-error-exception-errno/
- 如何规范 RESTful API 的业务错误处理:https://jianghushinian.cn/2023/03/04/how-to-standardize-the-handling-of-restful-api-business-errors/
- 本文 GitHub 示例代码:https://github.com/jianghushinian/blog-go-example/tree/main/error/pkg-errors
联系我
- 公众号:Go编程世界
- 微信:jianghushinian
- 邮箱:jianghushinian007@outlook.com
- 博客:https://jianghushinian.cn