Golang 语言语法中,错误处理机制是一个非常有特色的设计,它是基于防御性编程思想的设计。不过今天这篇文章不讨论 Golang 错误处理的语法设计问题,相反,今天想思考的是,Golang 里的错误日志应该怎样处理以及打印比较好。
5 点建议fmt.Errorfpkg/errorsfmt使用错误栈的方式我从转 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 的运行时实现,这将会导致程序比较明显的性能开销。
所以,综合考虑错误信息的引导性以及对程序的性能友好,应该使用逻辑栈信息,而避免使用代码调用栈。
使用 ,不用 第三方模块这一点是第2点的延伸。
fmt.Errorf%wwfunc 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}
fmt.Errorferrorserrors.Iserrors.Aserror值得一提的是,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}
pkg/errors避免使用依赖标准库 格式化字符串的日志方法fmt那怎么办好呢?可以考虑类似 uber-go/zap 这类针对性能优化的第三方日志库。zap 主要通过几个角度优化性能:
使用延迟加载机制避免不必要的计算,比如有些日志需要 Debug 日志级别才需打印,那在以 Info 日志级别启动的程序中,这部分日志其实是不打印的,不打印也就没有计算的需要,所以延迟加载有助于在高级别日志场景下直接省略格式化日志的工作;
使用显式的 Fields 机制,zap 可以避免大量的反射需求,另外结合零分配的 JSON 序列化编码器,提高了性能。
DBConnectFailedMySQLErrorPGError参考资料防御性编程
Go语言(golang)新发布的1.13中的Error Wrapping深度分析
uber-go/zap: README