在使用 Go 开发的后台服务中,对于错误处理,一直以来都有多种不同的方案,本文探讨并提出一种从服务内到服务外的错误传递、返回和回溯的完整方案,还请读者们一起讨论。
问题提出
在后台开发中,针对错误处理,有三个维度的问题需要解决:
- 函数内部的错误处理: 这指的是一个函数在执行过程中遇到各种错误时的错误处理。这是一个语言级的问题
- 函数/模块的错误信息返回: 一个函数在操作错误之后,要怎么将这个错误信息优雅地返回,方便调用方(也要优雅地)处理。这也是一个语言级的问题
- 服务/系统的错误信息返回: 微服务/系统在处理失败时,如何返回一个友好的错误信息,依然是需要让调用方优雅地理解和处理。这是一个服务级的问题,适用于任何语言
针对这三个维度的问题,笔者准备写三篇文章一一说明。首先本文就是第一篇:函数内部的错误处理
高级语言的错误处理机制
一个面向过程的函数,在不同的处理过程中需要 handle 不同的错误信息;一个面向对象的函数,针对一个操作所返回的不同类型的错误,有可能需要进行不同的处理。此外,在遇到错误时,也可以使用断言的方式,快速中止函数流程,大大提高代码的可读性。
在许多高级语言中都提供了 try ... catch
的语法,函数内部可以通过这种方案,实现一个统一的错误处理逻辑。而即便是 C
这种 “中级语言”,虽然没有 try catch
,但是程序员也可以使用宏定义配合 goto LABEL
的方式,来实现某种程度上的错误断言和处理。
Go 的错误断言
在 Go 的情况就比较尴尬了。我们先来看断言,我们的目的是,仅使用一行代码就能够检查错误并终止当前函数。由于没有 throw
、没有宏,如果要实现一行断言,有两种方法。
方法一:单行 if + return
第一种是把 if
的错误判断写在一行内,比如:
if err != nil { return err }
这种方法有值得商榷的点:
- 虽然符合 Go 的代码规范,但是在实操中,if 语句中的花括号不换行这一点还是非常有争议的,并且笔者在实际代码中也很少见到过
- 代码不够直观,大致浏览代码的时候,断言代码不显眼,而且在花括号中除了
return
之外也没法别的了,原因是 Go 的规范中强烈不建议使用;
来分隔多条语句(if
条件判断除外)
因此,笔者强烈不建议这么做。
方法二:panic + recover
第二种方法是借用 panic
函数,结合 recover
来实现,如以下代码所示:
func SomeProcess() (err error)
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
assert := func(cond bool, e error) {
if !cond {
panic(e)
}
}
// ...
err = DoSomething()
assert(err == nil, fmt.Errorf("DoSomething() error: %w", err))
// ...
}
这种方法好不好呢?我们要分情况看
首先,panic
的设计原意,是在当程序或协程遇到严重错误,完全无法继续运行下去的时候,才会调用(_比如段错误、共享资源竞争错误_)。这相当于 Linux 中 FATAL
级别的错误日志,用这种机制,仅仅用来进行普通的错误处理(ERROR
级别),杀鸡用牛刀了。
其次,panic
调用本身,相比于普通的业务逻辑的系统开销是比较大的。而错误处理这种事情,可能是常态化逻辑,频繁的 panic - recover
操作,也会大大降低系统的吞吐。
但是话虽这么说,使用 panic 来断言的方案,虽然在业务逻辑中基本上不用,但在测试场景下则是非常常见的。测试嘛,用牛刀有何不可?稍微大一点的系统开销也没啥问题。对于 Go 来说,非常热门的单元测试框架 goconvey 就是使用 panic
机制来实现单元测试中的断言,用的人都说好。
结论建议
综上,在 Go 中,对于业务代码,笔者是不建议采用断言的,遇到错误的时候建议还是老老实实采用这种格式:
if err := DoSomething(); err != nil {
// ...
}
而在单测代码中,则完全可以大大方方地采用类似于 goconvey
之类基于 panic 机制的断言。
Go 的 try ... catch
众所周知,Go(当前版本 1.17)是没有 try ... catch
的,而且从官方的态度而言,短时间内也没有明确的计划。但是程序员有这个需求呀。这里也催生出了集中解决方案
defer 函数
笔者采用的方法,是将需要返回的 err
变量在函数内部全局化,然后结合 defer
统一处理:
func SomeProcess() (err error) { // <-- 注意,err 变量必须在这里有定义
defer func() {
if err == nil {
return
}
// 这下面的逻辑,就当作 catch 作用了
if errors.Is(err, somepkg.ErrRecordNotExist) {
err = nil // 这里是举一个例子,有可能捕获到某些错误,对于该函数而言不算错误,因此 err = nil
} else if errors.Like(err, somepkg.ErrConnectionClosed) {
// ... // 或者是说遇到连接断开的操作时,可能需要做一些重连操作之类的;甚至乎还可以在这里重连成功之后,重新拉起一次请求
} else {
// ...
}
}()
// ...
if err = DoSomething(); err != nil {
return
}
// ...
}
这种方案要特别注意变量作用域问题。
比如前面的 if err = DoSomething(); err != nil {
行,如果我们将 err = ...
改为 err := ...
,那么这一行中的 err
变量和函数最前面定义的 (err error)
不是同一个变量,因此即便在此处发生了错误,但是在 defer 函数中无法捕获到 err 变量了。
在 try ... catch
方面,笔者其实没有特别好的方法来模拟,即便是上面的方法也有一个很让人头疼的问题:defer 写法导致错误处理前置,而正常逻辑后置了。
命名的错误处理函数
要解决前文提及的 defer 写法导致错误处理前置的问题,有第一种解决方法是比较常规的,那就是将 defer 后面的匿名函数改成一个命名函数,抽象出一个专门的错误处理函数。这个时候我们可以将上一段函数进行这样的改造:
func SomeProcess() error {
// ...
if err = DoSomething(); err != nil {
return unifiedError(err)
}
// ...
}
func unifiedError(err error) error {
if errors.Is(err, somepkg.ErrRecordNotExist) {
return nil // 有可能捕获到某些错误,对于该函数而言不算错误,因此 err = nil
} else if errors.Like(err, somepkg.ErrConnectionClosed) {
return fmt.Errorf("handle XXX error: %w", err)
// ...
} else {
return err
}
}
这样就舒服一些了,至少逻辑前置,错误处理后置。不过读者肯定会发现——这不是什么语言都可以这么搞嘛?诚然,这怎么看都不像是对 try ... catch
的模拟,但这种方法依然很推荐,特别是错误处理代码很长的时候。
goto LABEL
理论上,我们可以通过 goto
语句,将错误处理后置,比如:
func SomeProcess() error {
// ...
if err = DoSomething(); err != nil {
goto ERR
}
// ...
return nil
ERR:
// ...
}
对 C
语言比较熟悉的同学可能会觉得很亲切,因为在 Linux 内核中就有大量这种写法。这种写法呢,笔者其实说不出具体不好的地方,但是这个看起来很像 C 的写法,其实限制很多,反而比起 C 而言,需要注意的地方也更多:
- 仅限于 ANSI-C 的话,要求所有的局部变量都需要前置声明,这就避免了因为变量作用域而带来的同名变量覆盖;但 Go 需要注意这个问题。
- C 支持宏定义,配合前文可以实现断言,使得错误处理语句可以做得比较优雅;而 Go 不支持
- Go 经常有很多匿名函数,匿名函数无法
goto
到外层函数的标签,这也限制了goto
的使用
不过笔者倒也不是不支持使用 goto
,只是觉得在现有机制下,还是使用前两种模式比较符合 Go 的习惯。
下一篇文章是《如何在 Go 中优雅的处理和返回错误(2)——函数/模块的错误信息返回》,笔者详细整理了 Go 1.13 之后的 error wrapping
功能,敬请期待~~
本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。
原作者: amc,欢迎转载,但请注明出处。
原文标题:《如何在 Go 中优雅的处理和返回错误(1)——函数内部的错误处理》
发布日期:2021-09-30
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。