文章

Go 优雅地处理错误

这篇文章没有叫「最佳实践」,因为我也在摸索阶段,目前只是总结一下个人认为比较好的技巧,不敢说其普适性如何。如果看官 dalao 发现了缺陷或者更好地方案,欢迎评论提出。

当前 Go 版本 1.17.5

Go 的错误处理设计

关于 Go 的错误处理,一直争议不断。其实 Go 本身的争议也不少 😂。这里不进行评价,只是简单地和主流语言对比,总结一下 Go 的特征。

作为一个历史悠久的语言,C 的错误处理非常混乱。典型情况下,C 通过返回值表示执行是否成功,至于失败的具体原因,通过额外的全局变量传递。这种设计的致命缺陷是错误与返回结果混淆在一起,例如函数 int atoi(const char *str) ,如果转换出错应该返回什么呢?0?-1?其实都不合理,因为任何数字都有可能代表一个正常的结果。

为此,部分开发者选择利用指针传递结果,而返回值仅表示是否出错。这确实解决了上述问题,但又导致了「参数」语义的混淆。没有经验的同学看到什么入参出参搞的一脸懵比。

而近现代语言通常采用 try-catch 的思想。例如 java 与 python。这种模型既分离的错误与返回值和参数,也提供了结构化处理的可能。通过面相对象的思想,开发者可以自定义错误类、子类,它们又可以包装其他错误,确保错误上下文不会丢失。


Go 则与众不同。它利用多返回值的特性做到了错误的分离,因此,很多 Go 的函数最后一个返回值都是一个 error 来标识是否出错。相比于 C 这是一个进步,但没有提供灵活的捕获机制,这就仁者见仁智者见智了。

我个人的观点是:不应该试图在 A 语言中复刻 B 语言的功能,而是根据语言自身的特点,在宏观理念的指导下,实现针对性的错误处理方案。 后文将以这个观点为继续进行探索。

基本理念

虽然语法不同,但是一些语言无关的思想还是很重要的,我们应该尽量遵循。

首先,一个错误信息,至少要具有两个作用:

  1. 给程序看。可以根据错误类型进入处理分支。
  2. 给人看。告诉我们到底发生了什么。

不要重复处理错误

私以为这是很容易踩的一个坑,考虑下面的代码:

func foo() (Result, error) {
 result, err := repository.Find(id)
 if err != nil {
   log.Errof(err)
   return Result{}, err
 }
  return result, nil
}

这里打印了错误(相当于处理),然后返回它。可以想象,foo() 的调用方会再次打印,再返回。最终一个错误会打印出一大堆东西。

错误要包含调用栈

调用栈是 debug 的基本需要。如果仅仅是一层层返回 error,那么最顶层的函数将收到最底层的错误。试想你用 Go 写了一个 web 后端,在处理一个支付请求时出现了 io 错误。这对 debug 毫无帮助——无法定位哪个环节出错。

另一个不好的方案是,每一层只返回自己的错误,例如:

func pay(){
  if err := checkOrder(); err!=nil {
    return errors.New("支付异常")
  }
  return nil
}

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return errors.New("计算金额异常")
  }
  return nil
}

func calcMoney() error {
  if err := querySql(); err!=nil {
    return errors.New("查询数据库异常")
  }
  return nil
}

这种情况和上一种相反:顶层函数只能得到最近的错误,对引发的原因一无所知,显然这不是我们希望的。

错误要是结构化的

有聪明的同学改造了一下代码来解决丢失调用栈的问题:

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return fmt.Errorf("计算金额异常:%s", err)
  }
  return nil
}

确实,修改后顶层函数得到的异常如下:

支付异常:计算金额异常:查询数据库异常

现在基本上可以看出调用栈了,但仅限于人类。这个错误是否属于数据库异常?计算机无从得知。我们可以通过自然语言处理来解决,除非你疯了。

所以需要结构化错误——错误有包含关系,子错误是父错误的一种,顶层函数可以轻松判断异常类型。

错误要有上下文

这一点很好理解了,我们希望错误日志包含一些相关的数据,比如用户 id,订单 id 等。

值得注意的是,这个原则要和「不重复处理」结合,否则将会得到这样的天书 log:

支付异常 uid=123, orderId=456, reqId=328952104:计算金额异常 uid=123, orderId=456, reqId=328952104:查询数据库异常 uid=123, orderId=456, reqId=328952104

实践

错误链

曾经,在 Go 中做到保留调用栈和结构化是很麻烦的,为此 errors 开源库被广泛使用。不过 Go 1.13 一定程度上增强了错误处理,Go 2 也计划进一步改善。因此这个库已进入维护状态。

现在,调用栈不再需要我们自己构造,通过 fmt.Errorf("... %w", ..., err) 就可以包装一个错误,层层套娃形成错误链。相应的,通过 errors.Is()errors.As() 可以判断一个错误(链)是否是(包含)另一个错误,前者要求严格相等,后者只需类型一致。不知不觉中,「结构化」也基本上实现了。

等等!为什么说「基本」?虽然标准库提供了判断错误类型的方法,但是错误类型是啥?相比 java 中抛出一个具体异常,Go 中基本上只会返回底层错误接口 error,而没有具体结构体,那怎么判断呀?难道又要回到 err.msg 的时代?当然不是,包装一个错误,除了使用 fmt 之外,还可以自己定义一个结构体。实际上,fmt.Errorf() 返回的是 wrapError,相当于是便捷函数,用于无需明确错误类型的场景。

自定义结构体

自定义错误结构体不仅帮助识别错误类型,还顺便解决了下上下文问题。通过简单的 string 自然是 OK 的,不过为了让上下文本身也可以被程序识别,更好的办法是作为结构体的一个字段:

type orderError struct {
  orderId int
  msg string
  err error
}

func (e *orderError) Error() string {
    return e.msg
}

func (e *orderError) Unwrap() error {
    return e.err
}

这个结构体不仅实现了 error 接口,还额外拥有 Unwrap() 方法,这样就可以包装其他异常,确保不丢失调用栈。

那么就可以这么来返回:

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return return orderError{123, "计算金额异常", err}
  }
  return nil
}

公开 OR 私有

有了自己的结构体,随之而来的就是它需要公开吗?这个问题标准库已经给出了答案:一般不需要。所以我们见到的大多数函数,只返回 error 而不是 xxxError。根据网上的资料,这么做旨在隐藏实现细节,提高 lib 的灵活性,减少升级时需要考虑的兼容问题。

不公开结构体,也就是意味着外部无法通过 errors.As() 判断了,为此,需要公开一个函数帮助外部确认这是否是属于本 lib 的错误。

func IsOrderError(err error) bool {
  return errors.As(err, orderError)
}

Error Check Hell

上面已经总结了返回错误的结构,符合一开始提出的基本理念。在实际代码中,错误检查可能会充斥着项目,甚至每一个调用都裹着一个 if 来及时打断并返回——因为 Go 没有 throw 或 raise 机制。看下面这个恶心的例子:

func checkPersion(*p Persion) error {
  if err := checkAttr(p.name); err != nil{
    return err
  }
  if err := checkAttr(p.age); err != nil{
    return err
  }
  if err := checkAttr(p.country); err != nil{
    return err
  }
  if err := checkAttr(p.work); err != nil{
    return err
  }
  return nil
}

抽取匿名函数

此方法适用于连续调用同一个函数。

把错误检查抽取到匿名函数中,若已经存在错误,那么不真正执行,直接返回。

func checkPersion(*p Persion) error {
  var err error
  check := func(attr interface{}){
    if err != nil{
      return
    }
    err = checkAttr(attr)
  }

  check(p.name)
  check(p.age)
  check(p.country)
  check(p.work)
  // more check
  return err
}

利用 panic

⚠️ 使用 panic 来代替 error 是错误的习惯。不要滥用此技巧。

// checkAttr() 不再返回 error 而是直接 panic
func checkAttr(attr interface{}) {
  if attr == nil{
    panic(checkErr{...})
  }
}

func checkPersion(*p Persion) (err error) {
  defer func() {
    if r := recover(); r != nil {
      // 恢复 checkAttr() 的 panic 转为 error
      err = r.(checkErr)
    }
  }()

  // do any thing
}

用 panic 简化 check 的关键在于 recover 时只处理已知的错误,对于未知情况应该继续传递 panic。因为 panic 原则上仅用于不可恢复的严重错误(例如数组越界),如果不分情况一律 recover 则可能会掩盖 bug 引发未知的后果。

有的网站给出下面这种写法,非常不推荐,除非你知道自己在干嘛:

func checkPersion(*p Persion) (err error) {
  defer func() {
    if r := recover(); r != nil {
      var ok bool
      // 这里对于未知错误也一并捕获了
      err, ok = r.(error)
      if !ok {
        err = fmt.Errorf("failed to check persion: %v", r)
      }
    }
  }()
  // do any thing
}

尽管很多网站都宣传不要滥用 panic,但我认为,如果像第一个例子那样,确保自己只捕获已知的异常来简化 error check,应该不算做滥用——此时 panic 不会对包外部的调用者造成任何影响——原来会 panic 的现在依然会 panic,原来会返回 error 的现在依然只返回 error。Go 的官方文档 effective go 也承认了这种用法:

With our recovery pattern in place, the do function (and anything it calls) can get out of any bad situation cleanly by calling panic. We can use that idea to simplify error handling in complex software.

有了 recovery 模式,我们就可以随时通过调用 panic 简单地摆脱异常情况,可以使用该思想来简化复杂软件中的错误处理。

参考