一套优雅的 Go 错误问题解决方案 https://mp.weixin.qq.com/s/RFF2gSikqXiWXIaOxQZsxQ
张敏 腾讯技术工程 2021-10-06
作者:andruzhang,腾讯 IEG 后台开发工程师
在使用 Go 开发的后台服务中,对于错误处理,一直以来都有多种不同的方案,本文探讨并提出一种从服务内到服务外的错误传递、返回和回溯的完整方案,还请读者们一起讨论。
问题提出
在后台开发中,针对错误处理,有三个维度的问题需要解决:
- 函数内部的错误处理: 这是一个函数在执行过程中遇到各种错误时的错误处理。这是一个语言级的问题
- 函数/模块的错误信息返回: 一个函数在操作错误之后,要怎么将这个错误信息优雅地返回,方便调用方(也要优雅地)处理。这也是一个语言级的问题
- 服务/系统的错误信息返回: 微服务/系统在处理失败时,如何返回一个友好的错误信息,依然是需要让调用方优雅地理解和处理。这是一个服务级的问题,适用于任何语言
函数内部的错误处理
一个面向过程的函数,在不同的处理过程中需要 handle 不同的错误信息;一个面向对象的函数,针对一个操作所返回的不同类型的错误,有可能需要进行不同的处理。此外,在遇到错误时,也可以使用断言的方式,快速中止函数流程,大大提高代码的可读性。
在许多高级语言中都提供了 try ... catch
的语法,函数内部可以通过这种方案,实现一个统一的错误处理逻辑。而即便是 C
这种 “中级语言” 虽然没有,但是程序员也可以使用宏定义的方式,来实现某种程度上的错误断言。
但是,对于 Go 的情况就比较尴尬了。
Go 的错误断言
我们先来看断言,我们的目的是,仅使用一行代码就能够检查错误并终止当前函数。由于没有 throw
,没有宏,如果要实现一行断言,有两种方法。
第一种是把 if
的错误判断写在一行内,比如:
if err != nil { return err }
第二种方法是借用 panic
函数,结合 recover
来实现:
func SomeProcess() (err error)
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
assert := func(cond bool, f string, a ...interface{}) {
if !cond {
panic(fmt.Errorf(f, a...))
}
}
// ...
err = DoSomething()
assert(err == nil, "DoSomething() error: %w", err)
// ...
}
这两种方法都值得商榷。
首先,将 if
写在同一行内的问题有:
- 这种写法,虽然理论上符合 Go 代码规范,但是在实操中,花括号不换行这一点还是有点争议的,笔者在实际代码中也很少见到过
- 不够直观,而且在花括号中也不方便写其他语句,原因是 Go 的规范中强烈不建议使用
;
来分隔代码语句(if
判断除外)
至于第二种方法,我们要分情况看;
- 首先
panic
的设计原意,是在当程序或协程遇到严重错误,完全无法继续运行下去的时候,才会调用(比如段错误、共享资源竞争错误)。这相当于 Linux 中FATAL
级别的错误日志。仅仅用来进行普通的错误处理(ERROR
级别),杀鸡用牛刀了。 panic
调用本身,相比于普通的业务逻辑,的系统开销是比较大的。而错误处理这种事情,可能是常态化逻辑,频繁的 panic - recover 操作,也会大大降低系统的吞吐。
不过使用 panic 来断言的方案,虽然在业务逻辑中基本上不用,但在测试场景下则是非常常见的。测试嘛,用牛刀有何不可?稍微大一点的系统开销也没啥问题。对于 Go 来说,非常热门的单元测试框架 goconvey 就是使用 panic
机制来实现单元测试中的断言,用的人都说好。
综上,在 Go 中,对于业务代码,笔者不建议采用断言,遇到错误的时候建议还是老老实实采用这种格式:
if err := DoSomething(); err != nil {
// ...
}
而在单测代码中,则完全可以大大方方地采用类似于 goconvey
之类基于 panic 机制的断言。
Go 的_try ... catch_
众所周知 Go 是没有 try ... catch
的,而且从官方的态度来看,短时间内也没有考虑的计划。但程序员有这个需求呀。笔者采用的方法,是将需要返回的 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 写法导致错误处理前置,而正常逻辑后置了,从可读性的角度来说非常不友好。因此也希望读者能够指教。同时还是希望 Go 官方能够继续迭代,支持这种语法。
函数/模块的错误信息返回
这一点在 Go 里面,一开始看起来还是比较统一的,这就是 Go 最开始就定义的 error
类型,以系统标准的方式,统一了进程内函数级的错误返回模式。调用方使用 if err != nil
的统一模式,来判断一个调用是不是成功了。
但是随着 Go 的逐步推广,由于 error
接口的高自由度,程序员们对于 “如何判断该错误是什么错误” 的时候,出现了分歧。
Go 1.13 之前
在 Go 1.13 之前,对于 error 类型的传递,有三种常见的模式:
==
流派
这个流派很简单,就是将各种错误信息直接定义为一个类枚举值的模式,比如:
var (
ErrRecordNotExist = errors.New("record not exist")
ErrConnectionClosed = errors.New("connection closed")
// ...
)
当遇到相应的错误信息时,直接返回对应的 error 类枚举值就行了。对于调用方也非常方便,可以采用 switch - case
来判断错误类型:
switch err {
case nil:
// ...
case ErrRecordNotExist:
// ...
default:
// ...
}
个人觉得这种设计模式本质上还是 C
error code 模式。
类型断言流派
这种流派则是充分使用了 “error
是一个 interface
” 的特性,重新自定义一个 error 类型。一方面是用不同的类型来表示不同的错误分类,另一方面则能够实现对于同一错误类型,能够给调用方提供更佳详尽的信息。举个例子,我们可以定义多个不同的错误类型如下:
type ErrRecordNotExist errImpl
type ErrPermissionDenined errImpl
type ErrOperationTimeout errImpl
type errImpl struct {
msg string
}
func (e *errImpl) Error() string {
return e.msg
}
对于调用方,则通过以下代码来判断不同的错误:
if err == nil {
// OK
} else if _, ok := err.(*ErrRecordNotExist); ok {
// 处理记录不存在的错误
} else if _, ok := err.(*ErrPermissionDenined); ok {
// 处理权限错误
} else {
// 处理其他类型的错误
}
fmt.Errorf
流派
if err := DoSomething(); err != nil {
return fmt.Errorf("DoSomething() error: %v", err)
}
这种模式,一方面可以透传底层错误,另一方面又可以添加自定义的信息。但对于调用方而言,灾难在于如果要判断某一个错误的具体类型,只能用 strings.Contains()
来实现,而错误的具体描述文字是不可靠的,同一类型的信息可能会有不同的表达;而在 fmt.Errorf
的过程中,各个业务添加的额外信息也可能会有不同的文字,这带来了极大的不可靠性,提高了模块之间的耦合度。
Go 1.13 之后
在 go 1.13 版本发布之后,针对 fmt.Errorf
增加了 wraping
功能,并在 errors
包中添加了 Is()
和 As()
函数。关于这个模式的原理和使用已经有很多文章了,本文就不再赘述。
这个功能,合并并改造了前文的所谓 “== 流派” 和 “fmt.Errorf” 流派,统一使用 errors.Is()
函数;此外,也算是官方对类型断言流派的认可(专门用 As()
函数来支持)。
在实际应用中,函数/模块透传错误时,应该采用 Go 的 error wrapping 模式,也就是 fmt.Errorf()
配合 %w
使用,业务方可以放心地添加自己的错误信息,只要调用方统一采用 errors.Is()
和 errors.As()
即可。
服务/系统的错误信息返回
传统方案
服务/系统层面的错误信息返回,大部分协议都可以看成是 code - message
模式或者是其变体:
code
是数字或者预定义的字符串,可以视为整型或者是字符串类型的枚举值- 如果是数字的话,大部分情况下是使用 0 表示成功,小部分则采用一个比较规整的十进制数字表示成功,比如 1000、10000 等
- 如果是预定义的字符串,那么是使用 "success"、"OK" 等字符串表示成功,或者是直接以空字符串、甚至是不返回字符串字段来表示成功
message
字段则是错误信息的具体描述,大部分情况下都是一个人类可读的句子- 一般而言,只有当 code 表示错误的时候,这个 message 字段才有返回的必要。
这种模式的特点是:code
是给程序代码使用的,代码判断这是一个什么类型的错误,进入相应的分支处理;而 message
是给人看的,程序可以以某种形式抛出或者记录这个错误信息,供用户查看。
存在问题
在这一层面有什么问题呢?code
for computer,message
for user,好像挺好的。
但有时候,我们可能会收到用户/客户反馈一个问题:“XXX 报错了,帮忙看看什么问题?”。用户看不懂我们的错误提示吗?
在笔者的经验中,我们在使用 code - message 机制的时候,特别是业务初期,难以避免的是前后端的设计文案没能完整地覆盖所有的错误用例,或者是错误极其罕见。因此当出现错误时,提示暧昧不清(甚至是直接提示错误信息),导致用户从错误信息中找到解决方案
在这种情况下,尽量覆盖所有错误路径肯定是最完美的方法。不过在做到这一点之前,码农们往往有下面的解决方案:
- 遇到未定义错误时,后端在 code 中返回一个统一的错误码,并且将详细的错误信息记录在
message
中。不过这个模式有下面的问题: - 客户端提示此类信息时,如果将
message
信息直接展示,可能会展示很多让用户看不懂(也没必要看懂)的文字,而且文字可能会很长(万一是一个 panic 信息),这对用户来说非常不友好 - 如果开发者不注意,message 信息可能会暴露程序细节,比如连接 DB 失败的信息里可能会涉及数据库的用户名、IP。敏感信息一旦暴露,轻则安全教育,重则高压线伺候
- 还是类似上面的方法,返回统一的错误码,message 则直接用一个通用的 “unknown error” 或 ”未知错误,请联系 XXX“ 之类的提示信息。但是这个时候,我们要怎么查错呢?
- 如果主调方是另一个模块的话还好,用户肯定是个程序员,这个时候只要对对方提供 requestID / trackID 过来就行了。
- 如果对方是个普通用户,难道让用户 F12 看控制台吗?(别笑,我们还真让用户这么干过……)如果是移动端,那可一点看的机会都没;如果将 traceID 暴露给用户,那么长的 ID,谁记得住啊。
既要隐藏信息,又要暴露信息,我可以摔盘子吗……
解决方案
这里,笔者从日益普及的短信验证码有了个灵感——人的短期记忆对 4 个字符还是比较强的,因此我们可以考虑把错误代码缩短到 4 个字符——不区分大小写,因为如果人在记忆时还要记录大小写的话,难度会增加不少。
怎么用 4 个字符表示尽量多的数据呢?数字+字母总共有 36 个字符,理论上使用 4 位 36 进制可以表示 36x36x36x36 = 1679616 个值。因此我们只要找到一个针对错误信息字符串的哈希算法,把输出值限制在 1679616 范围内就行了。
这里我采用的是 MD5 作为例子。MD5 的输出是 128 位,理论上我可以取 MD5 的输出,模 1679616 就可以得到一个简易的结果。实际上为了减少除法运算,我采用的是取高 20 位(0xFFFFF)的简易方式(20 位二进制的最大值为 1048575),然后将这个数字转成 36 进制的字符串输出。
当出现异常错误时,我们可以将 message 的提示信息如下展示:“未知错误,错误代码 30EV,如需协助,请联系 XXX”。顺带一提,30EV
是 "Access denied for user 'db_user'@'127.0.0.1'"
的计算结果,这样一来,我就对调用方隐藏了敏感信息。
至于后台侧,还是需要实实在在地将这个哈希值和具体的错误信息记录在日志或者其他支持搜索的渠道里。当用户提供该代码时,可以快速定位。
这种方案的优点很明显:
- 能够提供足够的信息,用户可以记住代码,从而反馈给开发侧进行 debug。
- 对于同一个错误,由于哈希的特点,计算结果是相同的。即便出现了碰撞,那么只要输入的数据不至于太多,还是能够快速区分的。
- 由于不论多长的错误信息,反馈到前端都只有四个字符,因此后端在记录错误信息的时候,可以放心地基于 Go 1.13 的 error wraping 机制进行嵌套,从而记录足够的错误信息
简易的错误码生成代码如下:
import (
// ...
"github.com/martinlindhe/base36"
)
var (
replacer = strings.NewReplacer(
" ", "0",
"O", "0",
"I", "1",
)
)
// ...
func Err2Hashcode(err error) (uint64, string) {
u64 := hash(err.Error())
codeStr := encode(u64)
u64, _ = decode(codeStr)
return u64, codeStr
}
func encode(code uint64) string {
s := fmt.Sprintf("%4s", base36.Encode(code))
return replace.Replace(s)
}
func decode(s string) (uint64, bool) {
if len(s) != 4 {
return 0, false
}
s = strings.Replace(s, "l", "1", -1)
s = strings.ToUpper(s)
s = replace.Replace(s)
code := base36.Decode(s)
return code, code > 0
}
// hash 函数可以自定义
func hash(s string) uint64 {
h := md5.Sum([]byte(s))
u := binary.BigEndian.Uint32(h[0:16])
return uint64(u & 0xFFFFF)
}
当然这种方案也有局限性,笔者能想到的是需要注意以下两点:
- 生成
error
时要避免记录随机数据、不可重放数据、千人千面的数据,比如说时间、账户号、流水 ID 等等信息,尽可能使用户进行统一操作时,能够生成相同的错误码。 - 由于数字
1
和字母I
、数字0
和字母O
很类似,因此需要进行统一转换,避免出现歧义。这就是为什么在Err2Hashcode
中,对 hash 结果 encode 之后要重新 decode 一次再返回的原因。
此外,笔者需要再强调的是:在开发中,针对各种不同的、正式的错误用例依然需要完整覆盖,尽可能通过已有的 code - message 机制将足够清晰的信息告知主调方。这种 hashcode 的错误代码生成方法,仅适用于错误用例遗漏、或者是快速迭代过程中,用于发现和调试遗漏的错误用例的临时方案。