Golang 语言语法中,错误处理机制是一个非常有特色的设计,它是基于防御性编程思想的设计。不过今天这篇文章不讨论 Golang 错误处理的语法设计问题,相反,今天想思考的是,Golang 里的错误日志应该怎样处理以及打印比较好。
5 点建议
-
使用错误栈的方式;
-
使用逻辑栈信息,而非代码调用栈;
-
使用
fmt.Errorf
,不用pkg/errors
第三方模块; -
避免使用依赖标准库
fmt
格式化字符串的日志方法; -
转换外部错误,基于内部错误类型判断。
使用错误栈的方式
我从转 Golang 开发以来,从看过的 Golang 代码以及自己的实践来说,大概会有以下几种个人认为不是太合理的错误日志打印方式:
-
每一个函数调用处在发现错误时都打印错误信息;
-
约定只在最里层或者最外层函数调用处发现错误时打印错误信息,进一步细分的话,还区分是否会在错误里携带调用栈信息;
-
没有明确规范,在整个调用链的任何一处或者多处调用发现错误时都有可能打印错误信息。
第一种方式,好处是不会遗漏调用链路上的所有调用节点信息,但是在实际应用场景里,服务的线程是并发执行的,不同线程打印的日志行之间相互交错,这种方式打印的同一个链路上的日志非常散乱,导致尽管日志里有全部错误相关的日志,但是却难以简单快速过滤出相关而非干扰的日志行,所谓的好处名存实亡,还占据大量磁盘空间。
第二种方式,最大的问题是可能缺失对于错误排查所需的一些上下文信息。大多数函数调用都发生在跨层代码逻辑的调用上,如果只在最里层调用处打印错误,则一般缺少最外层请求的大多数参数信息,想象一个存储层代码调用的例子。而另外一种思路是通过记录代码调用栈,可以帮助开发人员还原程序执行路径,进而通过阅读源码以及推理还原请求的上下文信息,这种方式确实能够提高问题排查处理的效率。但是只是纯粹代码调用栈信息的话,一方面会有大量业务无关的代码栈信息可能被记录到日志造成存储空间浪费,另一方面是仍旧可能缺失一些关键的上下文信息,这些信息可能也是问题定位的必要元素。
第三种方式,本质上是开发者对错误处理本身缺乏思考以及团队缺乏相关的编码规范,看起来这种问题挺低级,但是并不少见。这种自然是最应该避免的。我在此之前,自己也没有好好思考过这个问题。
第一第二种方式,想要有效定位错误根源,本质上都是需要记录错误发生时的调用栈信息,以便我们知道错误是怎么一路出现的,所以我们得到第一个共识:错误需要携带调用栈信息。
使用逻辑栈信息,而非代码调用栈
顺着第一点,我们明白了调用栈信息的重要性。关于调用栈,一种最直观的方式就是程序的函数调用栈,这种方式一定程度上并不是面向人的,尽管它详细记录了每个调用栈所在的源代码文件以及行数。比如 Golang 程序在遇到 panic 中打印的调用栈信息:
panic: a problemgoroutine 1 [running]:main.main() /tmp/sandbox4213436970/prog.go:15 +0x27Program exited.
这种方式看起来,往往只是一堆文件名和函数名的栈信息,避免不了需要回到源码中进行阅读,如果不是熟悉业务的开发人员,则可能难以快速理解问题产生的原因。
在我看来,另外一种思路是,如果我可以人为地在代码中主动记录错误发现时所在的位置以及参数等,不也是一种调用栈的思想吗?而且,这种方式下,我还可以额外增加必要的上下文信息。比如我期待拥有类似这样的日志来回溯错误发生的过程,它最大的优点是面向开发人员友好以及偏业务描述的:
handle upload failed, caused by: parse file failed, format: JSON,caused by: open file failed, caused by: file not found, path: /path/to/file
这种日志下,信息是偏向于开发者易于理解的,阅读下来,很容易理解程序的目的以及所遇到的异常情况。日志里的“handle upload failed” 等是一种逻辑上的调用链路,而“format: JSON”以及“path: /path/to/file” 则是必要的上下文信息。
具体到 Golang 的设计的考虑,如果需要在错误中获取被调函数的调用栈信息,则需要依赖 Golang 的运行时实现,这将会导致程序比较明显的性能开销。
所以,综合考虑错误信息的引导性以及对程序的性能友好,应该使用逻辑栈信息,而避免使用代码调用栈。
使用 fmt.Errorf
,不用 pkg/errors
第三方模块
这一点是第2点的延伸。
在早期的 Golang 版本中,标准库中并没有对于错误栈信息的支持,从 Golang 1.13 开始,Golang 在 fmt.Errorf
标准库函数中增加了一个新的格式化占位符 %w
的支持,w
是 Wrap 的缩写,意即对原始错误对象进行一层包装。比如为了实现上节的逻辑栈,代码类似:
func main() { cause := errors.New("file not found, path: /path/to/file") err := fmt.Errorf("open file failed, %w", cause) err = fmt.Errorf("parse file failed, format: JSON, %w", err) err = fmt.Errorf("handle upload failed, %w", err) fmt.Println(err) // output: handle upload failed, parse file failed, format: JSON, open file failed, file not found, path: /path/to/file}
Golang 1.13 除了 fmt.Errorf
的这个新功能,相应地在 errors
标准库中也增加了 errors.Is
以及 errors.As
两个新函数,前者用于判断制定错误的错误链上是否存在特定的错误值,而后者用于尝试将 error
值转换为具体的错误值。在此不展开,有兴趣的朋友可以点击《Go语言(golang)新发布的1.13中的Error Wrapping深度分析》一文了解更多用法。
值得一提的是,Golang 1.13 的这个新特性,应该是源自 pkg/errors 这个第三方包的设计,所以早期大家可能会使用其实现上面的错误栈:
func main() { cause := errors.New("file not found, path: /path/to/file") err := errors.WithMessage(cause, "open file failed") err = errors.WithMessage(err, "parse file failed, format: JSON") err = errors.WithMessage(err, "handle upload failed") fmt.Println(err) // output: handle upload failed: parse file failed, format: JSON: open file failed: file not found, path: /path/to/file}
但是由于 Golang 已经实现这个错误栈的功能,pkg/errors
已经将项目归档,并且建议开发人员使用 Golang 官方实现的版本,这也是为了应用程序本身更好的向前兼容 Golang 2.0。
避免使用依赖标准库 fmt
格式化字符串的日志方法
在标准库的日志功能实现中,其基于 fmt
标准库实现,而后者又重度依赖于反射的工作,这些都导致了比较高的 CPU 开销以及细碎的内存分配,前者通过挤占 CPU 时间片直接影响程序性能,而后者因为加大了运行时垃圾回收工作负担间接影响程序性能。
那怎么办好呢?可以考虑类似 uber-go/zap 这类针对性能优化的第三方日志库。zap 主要通过几个角度优化性能:
-
使用延迟加载机制避免不必要的计算,比如有些日志需要 Debug 日志级别才需打印,那在以 Info 日志级别启动的程序中,这部分日志其实是不打印的,不打印也就没有计算的需要,所以延迟加载有助于在高级别日志场景下直接省略格式化日志的工作;
-
使用显式的 Fields 机制,zap 可以避免大量的反射需求,另外结合零分配的 JSON 序列化编码器,提高了性能。
转换外部错误,基于内部错误类型判断
所谓外部错误,是指由自身应用程序源代码之外所定义的错误,比如系统调用的错误、rpc 服务返回的错误以及数据库读写操作错误等。应用程序设计讲究分层与解耦,如果没有对底层函数调用遇到的外部错误进行转换,则意味着上层逻辑与下层实现的耦合,破坏了低耦合性。比如,对于一个业务逻辑层的代码来说,它所依赖的数据库层函数应该给它统一的内部错误,比如 DBConnectFailed
,而不是后者依赖某个 SDK 所定义的 MySQLError
或者 PGError
这类错误。
参考资料
-
防御性编程
-
Go语言(golang)新发布的1.13中的Error Wrapping深度分析
-
uber-go/zap: README
文章评论