Golang 标准库 tips -- error

本文分析一下 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 方法还原根因再判断,这个对以前的有破坏。
第四:在顶端打日志,不要每个地方打,最好能将错误打印封装到统一的中间件中来打印。
参考:https://www.sohu.com/a/342949702_657921

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值