深圳幻海软件技术有限公司 欢迎您!

Go版本大于1.13,程序里这样做错误处理才地道

2023-02-28

大家好,这里是每周都在陪你进步的网管。之前写过几篇关于Go错误处理的文章,发现文章里不少知识点都有点落伍了,比如Go在1.13后对错误处理增加了一些支持,最大的变化就是支持了错误包装(ErrorWrapping),以前想要在调用链路的函数里包装错误都是用"github.com/pkg/errors"

大家好,这里是每周都在陪你进步的网管。

之前写过几篇关于 Go 错误处理的文章,发现文章里不少知识点都有点落伍了,比如Go在1.13后对错误处理增加了一些支持,最大的变化就是支持了错误包装(Error Wrapping),以前想要在调用链路的函数里包装错误都是用"github.com/pkg/errors"这个库。

Go 在2019年发布的Go1.13版本也采纳了错误包装,并且还提供了几个很有用的工具函数让我们能更好地使用包装错误。这篇文章就来主要说一下这方面的知识点,不过开始我们还是再次强调一下使用 Go Error 的误区,避免我们从其他语言切换过来时给自己后面挖坑。

自定义错误要实现error接口

这一条估计很多人都知道,但是文章开头开始先从这个惯例开始,因为我以前待过一个PHP转Go的研发团队,可能大家一开始都不太会,才有了这种错误的使用方式。

首先我们再复述一遍,Go​通过error类型的值表示程序里的错误。

error​类型是一个内建接口类型,该接口只规定了一个返回字符串值的Error方法。

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

Go​程序的函数经常会返回一个error值

package strconv

func Atoi(s string) (int, error) {
  ....
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

调用者通过测试error​值是否是nil来进行错误处理。

i, err := strconv.Atoi("42")
if err != nil {
    fmt.Printf("couldn't convert number: %v\n", err)
    return
}
fmt.Println("Converted integer:", i)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

error为nil​时表示成功;非nil的error表示失败。

说完 Go​ 里 error 最基本的使用方式后,接下来说项目里的自定义错误类型。假如项目在 Dao 层定义了一个这样的错误类型来记录数据库查询错误。

type MyError struct {
    Sql   string
    Param string
    Err   error
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

假如,这个自定义的MyError​不去实现error​接口,Dao 层里的函数返回的都是MyError的话。

func FindUserRowByPhoneMyError(userId int) (user User, MyError error) {
  ......
}
  • 1.
  • 2.
  • 3.

那么使用这些 Dao 函数的代码逻辑层都得引入dao.MyError​这个额外的类型。有人会说,我把MyError​定义在公共包里,所有代码逻辑层、Dao 层都用这个common.MyError总没啥问题了吧。

使用上乍一看没什么问题,但其实最大的问题就是不兼容、不符合Go语言对错误的接口约束,就没法对自定义错误类型使用Go对error提供的其他功能了,比如说后面要介绍的错误包装。

所以针对自定义的错误类型,我们也要让他变成一个真正的Go error,方法就是让它实现error接口定义的方法。

func (e *MyError) Error() string {
    return fmt.Sprintf("sql: %s, params: %s, err: %s", e.Sql, e.Param, e.Err.Error())
}
  • 1.
  • 2.
  • 3.

包装错误

在现实的程序应用里,一个逻辑往往要经多多层函数的调用才能完成,那在程序里我们的建议Error Handling 尽量留给上层的调用函数做,中间和底层的函数通过错误包装把自己要记的错误信息附加再原始错误上再返回给外层函数。

比如像下面这样:

func doAnotherThing() error {
    return errors.New("error doing another thing")
}

func doSomething() error {
    err := doAnotherThing()
    return fmt.Errorf("error doing something: %v", err)
}

func main() {
    err := doSomething()
    fmt.Println(err)
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

这段代码从打印错误信息的输出上看没什么问题,但是深层次的问题很明显,我们丢失了原来的err​,因为它已经被我们的fmt.Errorf函数转成一个新的字符串了。

基于这个背景,很多开源三方库提供了错误包装、追加错误调用栈等功能,用的最多的就是"github.com/pkg/errors"这个库,提供了下面几个主要的包装错误的功能。

//只附加新的信息
func WithMessage(err error, message string) error

//只附加调用堆栈信息
func WithStack(err error) error

//同时附加堆栈和信息
func Wrap(err error, message string) error
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

Go官方在2019年发布1.13​版本,自己也增加了对错误包装的支持,不过并没有提供什么Wrap​函数,而是扩展了fmt.Errorf​函数,加了一个%w来生成一个包装错误。

e := errors.New("原始错误")
w := fmt.Errorf("外面包了一个错误%w", e)
  • 1.
  • 2.

Go1.13​引入了包装错误后,同时为内置的errors​包添加了3个函数,分别是Unwrap、Is和As。

先来聊聊Unwrap,顾名思义,它的功能就是为了获取到包装错误里那个被嵌套的error。

func Unwrap(err error) error {
    //先判断是否是wrapping error
 u, ok := err.(interface {
  Unwrap() error
 })
 //如果不是,返回nil
 if !ok {
  return nil
 }
 //否则则调用该error的Unwrap方法返回被嵌套的error
 return u.Unwrap()
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

这里需要注意的是,嵌套可以有很多层,我们调用一次errors.Unwrap​函数只能返回往里一层的error​,如果想获取更里面的,需要调用多次errors.Unwrap​函数。最终如果一个error​不是warpping error,那么返回的是nil。

如果想得到最原始的error,建议自己封装个工具函数,类似这样

func Cause(err error) error {
 for err != nil {
    err = errors.Unwrap(err)
 }
 return err
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

对于我们文章开头定义的那个自定义错误MyError​想要把它变成可包装的Error的话,还需要实现一个Unwrap()方法。

func (e *MyError) Unwrap() error { return e.Err }
  • 1.

有了包装错误后,像具体某种错误的判断和错误的类型转换也得需要跟进改一下才行。这就是errors​包在1.13​后新增的另外两个工具函数Is和As的作用。接下来我们一个个来说。

errors.Is

在Go 1.13之前没有包装错误的时候,程序里要判断是不是同一个error可以直接简单粗暴的:

if err == os.ErrNotExists {
  ......
}
  • 1.
  • 2.
  • 3.

这样我们就可以通过判断来做一些事情。但是现在有了包装错误后这样办法就不完美的,因为你根本不知道返回的这个err​是不是一个嵌套的error,嵌套了几层。所以基于这种情况,Go为我们提供了errors.Is函数。

func Is(err, target error) bool
  • 1.

如果err​和目标错误target​是同一个,那么返回true。

如果err​ 是一个包装错误,目标错误target​也包含在这个嵌套错误链中的话,那么也返回true。

下面是一个使用errors.Is判断是否是同一错误的例子。


var ErrDivideByZero = errors.New("divide by zero")

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, ErrDivideByZero
    }
    return a / b, nil
}

func main() {
    a, b := 10, 0
    result, err := Divide(a, b)
    if err != nil {
        switch {
        case errors.Is(err, ErrDivideByZero):
            fmt.Println("divide zero error")
        default:
            fmt.Printf("unexpected division error: %+v\n", err)
        }
        return
    }

    fmt.Printf("%d / %d = %d\n", a, b, result)
}
  • 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.

errors.As

同样在没有包装错误前,我们要把error 转换为一个具体类型的error,一般都是使用类型断言或者 type switch,其实也就是类型断言。

if pathErr, ok := err.(*os.PathError); ok {
 fmt.Println(pathErr.Path)
}
  • 1.
  • 2.
  • 3.

但是有了包装错误之后,返回的err可能是已经被嵌套了,这种方式就不能用了,所以Go为我们在errors​包里提供了As函数。

func As(err error, target interface{}) bool
  • 1.

As​ 函数所做的就是遍历错误的嵌套链,从里面找到类型符合的error,然后把这个error赋给target参数,这样我们在程序里就可以使用转换后的target了,因为这里有赋值,所以target必须是一个指针,这个也算是Go内置包里的一个惯例了,像json.Unmarshal也是这样。

所以把上面的例子用As 函数实现就变成了酱婶:

var pathErr *os.PathError
if errors.As(err, pathErr) {
 fmt.Println(pathErr.Path)
}
  • 1.
  • 2.
  • 3.
  • 4.

总结

这篇文章主要是更新一下Error处理在Go 1.13以后新增的功能点,以前的文章介绍的更多的还是使用"pkg/errors"那个包的方式,主要是前两年以前公司用的Go版本一直是1.12,所以这部分知识我一直没更新过来,这里简单做个梳理。