本文分析一下 error 的演进历程以及最佳实践,从而对 error 有一个整体的认识以及标准库里面 error 使用上的一些问题。
本文目录结构
error 的演进历程
1.13 之前的 error
pkg/errors
1.13 error
pkg/errors 适配 1.13 error
2.0 error 提议
获取 panic 调用栈
error 最佳实践
error 的演进历程
1.13 之前的 error Go 在 1.13 之前的 error 实现非常简单,本质上是一个 type error interface { Error() string },我们通过 New(), fmt.Errorf() 方法创建的 error 是一种 errorString 类型,只是简单的嵌套了一个 string 字段,正是因为功能比较简单,所以实际使用过程中会遇到一些问题。
// errorString is a trivial implementation of error.
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
首先是在排查问题时,只能自己增加描述信息,层层叠加打印,会造成一个业务上线以后,有可能打几十个错误日志,这些日志散落在系统里面,查询的时候要把整个上下文关联起来看非常麻烦:
func main() {
err := test1()
if err != nil {
log.Println("test1 error", err)
return
}
}
func test1() error {
err := test2()
if err != nil {
log.Println("test2 error", err)
return err
}
return nil
}
func test2() error {
_, err := test3()
if err != nil {
log.Println("test2 error", err)
return err
}
return nil
}
func test3() (string, error) {
m := make(map[string]string)
b, err := json.Marshal(m)
if err != nil {
log.Println("test3 error", err)
return "", err
}
return string(b), err
}
当然我们也可以通过 fmt.Errorf(“more info: %v”, err) 包装的方式往上层抛,在最上层打印日志,但是这样会引入两个问题,所以并不推荐这种包装方式来处理 error: 1.根因的丢失,如一些 sentinel errors(预定义的特定错误)如果包装之后就变成了一个 error,这种情况下我们通过 == 来判断两个 error 就不再成立了,只能通过对新的 error 进行字符串匹配的方式来判断是否包含原始的 error,实现上不优雅; 2.如果我们获取到的是一个自定义的 error,那么通过 fmt.Errorf(“more info: %v”, err) 的方式包装之后就变成了 errorString 类型的 error,我们想对这个 error 进行类型断言成自定义的 error 就永远都是 false 了。
func test3() error {
return fmt.Errorf("test3 err, %v", sql.ErrNoRows)
}
func test2() error {
return sql.ErrNoRows
}
func test1() error {
return test2()
}
func main() {
err := test1()
if err != nil {
if err == sql.ErrNoRows { // 正常情况下可以直接做等值判断处理
fmt.Printf("data not found, %+v\n", err)
}
}
err = test3()
if err != nil {
if strings.Contains(err.Error(), sql.ErrNoRows.Error()) { // 包装之后只能进行字符串匹配的方式判断是否相等
fmt.Printf("data not found, %+v\n", err)
}
}
}
最后还有一点是我们不能通过 error 记录程序调用的堆栈信息,这对于我们排查问题是非常不友好的。
pkg/errors
正是因为 Go error 存在以上槽点,因此在 Go1.13 之前,诞生了很多对错误处理的库,增加了 Wrap 的功能,其中 2016 年开源的github.com/pkg/errors是比较简洁的一种,并且功能非常强大,受到了大量开发者的欢迎,这里以 pkg/errors 为例来看下对 1.13 之前版本 error 存在的问题的解决方案。 首先是日志的层层打印和调用栈记录问题,我们可以在错误的源头通过 errors.WithStack\errors.New\ errors.Errorf\errors.Wrap\errors.Wrapf 等方法将堆栈信息保持起来,上层再调用 errors.WithMessage 将错误包装起来继续往上层返回,最后在程序入口打印日志就可以了,使用%+v可以打印整条链路的错误信息和堆栈信息:
func main() {
err := test1()
if err != nil {
log.Println(err)
// log.Printf("%+v", err) 打印调用栈信息
return
}
}
func test1() error {
err := test2()
if err != nil {
return errors.WithMessage(err, "test1 error") // 中间层通过 WithMessage 包装 error 返回
}
return nil
}
func test2() error {
err := test3()
if err != nil {
return errors.WithMessage(err, "test2 error") // 中间层通过 WithMessage 包装 error 返回
}
return nil
}
// 报错的源头,使用 errors.WithStck、errors.New、errors.Errorf、errors.Wrap 保存调用栈
func test3() error {
_, err := os.Open("no exist")
if err != nil {
// 方式一:通过 errors.WithStack 保存调用栈信息
return errors.WithStack(err)
}
// 方式二:通过 errors.New 保存调用栈信息
// return errors.New("test3 errors happens")
// 方式三:通过 errors.Errorf 保存调用栈信息
// return errors.Errorf("test3 errors happens, id: %v", id)
// 方法四:通过 errors.Wrap 保存调用栈信息
// return errors.Wrap(err, "test3 error")
return err
}}
// 打印结果:不同的 errors 通过 : 分隔,有层次
test1 error: test2 error: open no exist: no such file or directory
然后是根因丢失问题,之前如果我们对 error 进行包装之后只通过 string 包含关系来判断是否是原因的 error,现在可以通过 errors.Cause 方法来判断 err 是否是我们预定义的 error:
func test3() error {
return errors.Wrap(sql.ErrNoRows, "test3 err")
}
func test2() error {
err := test3()
return errors.Wrap(err, "test2 err")
}
func test1() error {
err := test2()
return errors.Wrap(err, "test1 err")
}
func main() {
err := test1()
if err != nil {
if errors.Cause(err) == sql.ErrNoRows { // 调用 Cause 获取原始的 error
fmt.Printf("data not found, %v\n", err)
}
}
}
// 打印输出:
data not found, test1 err: test2 err: test3 err: sql: no rows in result set
最后一个是对于自定义的 error,之前我们对 error 包装之后就还原不了这个 error 类型了,现在可以通过 Cause 方法还原自定义的 error 进行断言判断:
type customError struct {
s string
}
func (ce *customError) Error() string {
return ce.s
}
func main() {
_, err := openFile()
if err != nil {
if _, ok := errors.Cause(err).(*customError); ok {
fmt.Println("err is file error")
}
}
}
func openFile() ([]byte, error) {
return nil, errors.WithStack(&customError{"test"})
}
1.13 error
在 2019 年 09 月,Go1.13 正式发布,对错误处理 errors 标准库进行了一些改进,引入了 Wrapping Error 的概念(并没有提供 Wrap 方法,而是直接扩展了 fmt.Errorf 方法增加了 %w 表示来 Wrap Error),并增加了 Is/As/Unwarp 三个方法,用于对所返回的错误进行二次处理和识别。 1.13 实现 Wrap 的实现也比较简单,通过自定义了一个 wrapError 类型包装了一个 error 以及 message 字段,经过多次 warp 之后的 error 组成了一个链表形式的 error,通过 Unwrap 调用可以还原包装的 err,注意的是每调用一次函数 errors.Unwarp 只能返回最外面的一层 error,如果想获取更里面的,需要调用多次 errors.Unwarp 函数。
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.13 中 error 的用法,在底层返回 error 之后,在上层获取到 error 直接通过 fmt.Errorf(“some other info, %w”, err) 往上层返回就可以了。 之前说到的 error 进行包装之后根因判断的问题,现在可以通过 Is 方法来实现了,Is 方法会循环使用 Unwrap 方法一层层’剥开’ 嵌套的 error 里面的 error,然后和需要判断的 error 进行比较,如果两者相等则为 true。
func test3() error {
return fmt.Errorf("test3 err: %w", sql.ErrNoRows)
}
func test2() error {
err := test3()
return fmt.Errorf("test2 err: %w", err)
}
func test1() error {
err := test2()
return fmt.Errorf("test1 err: %w", err)
}
func main() {
err := test1()
if err != nil {
if errors.Is(err, sql.ErrNoRows) { // 调用 Is 获取 error 是否是 sql.ErrNoRows
fmt.Printf("data not found, %v\n", err)
}
}
}
// 打印输出:
data not found, test1 err: test2 err: test3 err: sql: no rows in result set
另外判断一个 error 是否是自定义的 error,之前 error 包装了之后就获取不到了,现在通过 As 方法可以很方便的实现,关键是也不再需要通过断言的方式来实现了:
type customError struct {
s string
}
func (ce *customError) Error() string {
return ce.s
}
func main() {
var cerror *customError
_, err := openFile()
if err != nil {
if errors.As(err, &cerror) {
fmt.Println("err type is customError")
}
}
}
func openFile() ([]byte, error) {
return nil, &customError{"test"}
}
// 打印结果:
err type is customError
pkg/errors 适配 1.13 error
pkg/error 库为了适配 1.13 error 中的用法,使代码风格统一,对 withStack、withMessage 等 error 结构体实现了 Unwrap 方法,并且引入了 Is/As 方法:
import (
stderrors "errors"
)
func Is(err, target error) bool { return stderrors.Is(err, target) }
func As(err error, target interface{}) bool { return stderrors.As(err, target) }
func Unwrap(err error) error {
return stderrors.Unwrap(err)
}
这样我们在使用 pkg/error 对 error 包装之后也可以通过 1.13 中的用法对 error 进行还原以及类型判断,不需要再调用 Cause 方法来判断了:
import (
"fmt"
"github.com/pkg/errors"
)
func test3() error {
return errors.Wrap(sql.ErrNoRows, "test3 err")
}
func test2() error {
err := test3()
return errors.Wrap(err, "test2 err")
}
func test1() error {
err := test2()
return errors.Wrap(err, "test1 err")
}
func main() {
err := test1()
if err != nil {
if errors.Is(err, sql.ErrNoRows) { // 直接调用 Is 方法判断,不需要调用 Cause 方法
fmt.Printf("data not found, %v\n", err)
}
}
}
判断 error 类型是否是自定义的 error 同样可以调用 As 方法来判断,不需要向之前使用 Cause 获取到原始的 error 之后进行断言判断:
import (
"fmt"
"github.com/pkg/errors"
)
type customError struct {
s string
}
func (ce *customError) Error() string {
return ce.s
}
func main() {
var cerror *customError
_, err := openFile()
if err != nil {
if errors.As(err, &cerror) {
fmt.Println("err is customError")
}
}
}
func openFile() ([]byte, error) {
return nil, errors.WithStack(&customError{"test"})
}
1.13 的 error 与 pkg/error 相比较,少了保留报错的堆栈信息,另外通过 “%w” 占位符的功能比直接通过 warp 方法的方式显得不够简明,而且 pkg/errors 在设计上也适配了 1.13 error 中的用法,所以从整体实用性方面,我还是比较推荐试用 pkg/error 来处理 error。
2.0 error 提议
在工程实践中,error 被吐槽的最多的应该是 if err! = nil 的判断了,如果逻辑比较复杂,代码中会有一大堆错误处理的判断,显得非常冗长拖沓,比如下面的例子中只有 4 行函数的调用,但是处理 error 的代码达到了 12 行。
x, err := test1()
if err != nil {
// handle error
}
y, err := test2()
if err != nil {
// handle error
}
z, err := test3()
if err != nil {
// handle error
}
s, err := test4()
if err != nil {
// handle error
}
...
所以在 Golang 2 提案中,Error Handling 作为一个重大改变被提了出来:增加了 check 和 handler 两个关键字来统一处理 error,check用来负责显示地标记错误,handle 用来定义错误处理逻辑,一旦 check到指定错误,便会进入相应的错误处理逻辑。 通过 check 与 handler 使得整体的代码逻辑变得更加简洁,错误可以统一在 handle 处得到处理,类似于try/catch:
func CopyFile(src, dst string) error {
handle err {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
r := check os.Open(src)
defer r.Close()
w := check os.Create(dst)
handle err {
w.Close()
os.Remove(dst) // (only if a check fails)
}
check io.Copy(w, r)
check w.Close()
return nil
}
获取 panic 调用栈
我们通过 recover 函数捕获到的异常是一个 interface,我们可以通过判断这个 interface 是不是 nil 从而判断程序有没有发生 panic 并且捕获 panic 防止程序退出,但是只有 panic 的信息是不够的,我们需要通过 recover 的日志定位到是哪行代码出现了问题。
func main() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
test1()
test2()
}
func test1() {
var m map[string]int
m["1"] = 1
}
func test2() {
var m map[string]int
m["2"] = 1
}
所以在 recover 打日志的时候一般都需要借助 debug.Stack() 手动把函数调用栈信息带上,或者直接调用 debug.PrintStack() 打印出 defer 函数的调用栈信息。
func main() {
defer func() {
if err := recover(); err != nil {
fmt.Printf("err: %v, catch panic: %s", err, debug.Stack()) // 通过 debug.Stack() 可以捕获函数的调用栈信息
}
}()
test1()
test2()
}
func test1() {
var m map[string]int
m["1"] = 1
}
func test2() {
var m map[string]int
m["2"] = 1
}
error 最佳实践
通过以上的分析,我们还是最推荐使用 pkg/error 的方法来处理 error,所以这里的最佳实践主要针对业务代码对 pkg/error 全链路改造上的一些经验总结: 第一:在出错的源头,数据库调用、RPC调用或者规则校验之类进行堆栈保留,以前使用标准库的 errors,现在使用 pkg/errors 的 New/Errorf 可以返回,这个时候把当前的堆栈上下文已经保留了。 第二:如果调用的是自己业务基础库里面的来自其他库的一个返回,就不再二次处理直接透传,直接往上抛。比如说调了bpackage 的方法,他返回一个error,这个时候直接往上抛,不进行WithStack包装。因为第一个人进行了 WithStack 或者 Errorf/New 包调了以后,已经把堆栈保存了,没有必要保存第二次,所以来自同包内的方法返回我就退出,因为可能被处理过,如果需要增加一些额外的上下文信息可以调用 errors.WithMessage 方法。 第三:当我们和 Go 的标准库或者第三方库交互的时候,我们需要 WithStack 把错误记录下来,我就知道是第三方库某个地方报了错,但是对于标准库返回的,像 sql.ErrNoRows 这种,建议不包,如果包了就是破坏了以前业务代码,会导致判断不成立,因为我们不可能要求所有业务都用 Cause/Is 方法还原根因再判断,这个对以前的有破坏。 第四:在顶端打日志,不要每个地方打,最好能将错误打印封装到统一的中间件中来打印。 参考:www.sohu.com/a/342949702…
今天的文章Golang 标准库 tips — error分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/23616.html