关于异常处理的总结和思考
Link
最近聊到了关于异常处理的问题。最初是 yihong 在频道里分享了 Frost Ming 的文章,我也转发到了自己频道,于是引起了许多朋友的讨论。正好之前在字节的工作中一大痛点也是异常处理的问题,因此也总结一下讨论和我的看法。
两种不同的范式
本节摘自 Frost Ming 的文章
以 Python 为例,抛出异常的方式是:
处理异常的方式是:
以 Go 为例,返回错误的方式是:
处理错误的方式是:
看上去它们完成的事情差不多,但如果我们去掉错误处理的代码,不管它,会变成这样:
Python:
Go:
两者造成的结果截然不同,Python 会上报异常,Go 会忽略错误。这代表了两种不同的哲学,前者若不处理错误即上报异常,让上层处理,而后者若不处理错误,则继续执行。
关于 back out (返回错误)的观点
支持 back out
对开发者来说足够明确
来自 Frost Ming
用 Go 语言的时候,你一看它返回了一个
err
,脑子就永远有根弦,要么必须写 if err != nil
,要么主动用 _
忽略掉错误,采用任何一种方式,就算是再粗心的程序员,都清晰地知道自己在做什么,反而更有利于及时的处理错误。 写 Go 的时候感觉自己一直在 if err != nil
正是因为每一个错误都被兜住了,不会漏掉。反对 back out
无法处理的 exception 要层层 return
来自 Frost Ming
尴尬的是,不是所有错误在本函数中都能处理,对于无法处理的错误,只能把错误返回给上层,而上层也不一定能处理,于是就一直 return。
来自 t.me/kazurin
每次返回的时候都需要携带一个上下文信息帮助定位错误,否则由于没有 stack trace,错误分析工作会变得非常麻烦
对于非致命错误,go 语言比较通用的做法是在每一层返回的时候用 errors.Wrap() 给 err 附加一层上下文信息,例如
errors.Wrap(err, "failed to open config file")
;rust 的做法是用 anyhow 库的 result.context()
扩展方法,或者自己创建一个用来包装的 enum,与 throw 时带上栈信息的目的是差不多的,实质上都是牺牲了程序员的便利来换取性能。打断链式调用
我个人的痛点其实就是文章里讲的,有些错误是需要在上层处理的,层层返回实在有点痛苦。我能接受Rust的?,但Go就完全是折磨人了。
有些操作写成链式就很自然,非要我给每一个中间值都想个变量名反而比较困难,把错误放在返回值里就很难写出这种代码了。
关于 bail out (抛出异常)的观点
支持 bail out
来自 Frost Ming:
一个例子是用户交互程序, 你需要把一些关键错误信息显示在界面上,而这个错误的来源,可能是任意层级深度的,这时异常抛出的「直达天听」的优势就显现出来了。
反对 bail out
bail out 的 exception 没有合理的 catch
来自 Frost Ming:
调用者不知道调用的这段代码会不会报错,报什么错,这就导致程序永远会在无法预料的情况下崩溃。恰巧,现在两种主要的动态语言,Python 和 Javascript,都采用的这种方式。而一些开发者,为了保住 SLO 和 KPI,就会用
try: <一大坨> except: pass
的代码兜底。 底看似兜住了,其实早已千疮百孔。性能缺陷
来自 t.me/kazurin
Throw 会带来语言和编译层面的不必要的复杂度,例如 non-local control flow,stack unwinding 等等的。这两种错误处理在底层的实现方式真的会很不一样。
想要打印栈信息的话,编译型语言(指最终编译为机器码的语言)除了 stack unwinding 之外,应该没有能兼顾高效率和无感知的做法(go 和 rust 在发生 panic 的时候都会做 stack unwinding,所以才能看到错误栈)。C++ 的异常处理就是用 stack unwinding 实现的,而 go 和 rust 都只在发生致命错误的时候使用(为了打印栈信息和清理资源)。
“bail out 和 back out 各有优劣” 当然是正确的说法,不过我还想进一步讨论一下,异常处理在具体场景下应该怎么做。
batch 型任务和 pipeline 型任务
batch 型任务
batch 型任务指的是,每一个子任务都不影响其后的子任务,例如:
- 有一批“同质化”的任务,使用的参数是同类的、处理的方法是相同的:
- 一个大任务由一些互不相关的任务构成,且大任务应该“尽可能完成”:
这种任务中,我们希望任何一个任务的失败都不会后面的任务。这意味着每一个任务都要 catch 其产生的所有异常,以免打断其它的任务。
“同质化”的任务例如:一个 API 服务器,同一个 API 收到的每个 request 都会用相同的函数处理,一次函数运行如果产生了异常,通常框架会有兜底的 catch,对 request 返回 500 response。
更常见的又例如:一个 for 中写了很长的复杂逻辑,那么这一大段逻辑应该有兜底的 catch,打印日志后 continue 进行下一个 loop。
一个大任务由一些互不相关的任务构成,且大任务应该“尽可能完成”,举个例子比如:游戏中玩家登录的时候,我们要为玩家初始化一系列功能模块。如果在一个功能模块初始化的过程中抛出异常(bail out),那么整个登录流程就被打断,玩家无法进入游戏。如果为每一个模块初始化加上 catch,那么玩家可以正常登录游戏,只有部分异常模块的功能无法使用。
可想而知,对于 batch 型的任务,也许 back out 的写法对开发者更友好。因为这种任务意味着不应该抛出任何异常,使得整个任务失败。
pipeline 型任务
pipeline 型任务则意味着,一个子任务的输出是另一个子任务的输入。此时一个子任务的失败就意味着整个任务的失败:
或者链式的调用:
对于这种类型的任务,bail out 就是比较方便的写法了。如果使用 back out 的语言,那么饱受诟病的
if err != nil
指的就是这种情况了。而 rust 提供的语法糖则相对友好一些。服务型软件和工具型软件
服务型软件指的是:“开发者”和“用户”有着清晰的分界,抛出的异常不应该让用户感知到,但应该让开发者感知到。
例如一个 GUI 应用,任何情况下都不应该导致程序崩溃。好的做法比如用弹窗提醒用户“该功能出现异常,建议反馈给开发者”。
上文提及的 API 服务器也是同理,任何错误都不应导致服务器崩溃,至少要兜底给用户返回 500 response。
而工具型软件通常意味着,“开发者”和“用户”不是明确分界的,因而异常本身也是接口的一部分。
这可能是一种语言特定的 SDK,例如 Python 的 requests 就会抛出多种 Exception 。这种软件往往允许抛出异常,交给用户(下游开发者)来处理。
当然还有中间地带:允许抛出异常,但只允许事先规定好的异常类型。例如陈皓前辈所提倡的,尽量使用 HTTP status code 表示错误:
世界并不总是非黑即白
现实中,我们写的函数可能有一个链式调用语句,又有一个 for 循环。它不是纯粹的 batch 或者 pipeline。抛出异常有时候可以直接抛出,有时候应该包装为预先规定好的另一种异常,有时又必须兜底处理所有异常。
个人看来,back out 风格的 rust 提供的语法糖似乎是比较好的做法,兼顾了不同的需求。bail out 风格的 Java 似乎也处理的比较好。
Java 也是用第一种异常抛出的方式,但由于它有完善的异常标注和静态检查,异常也不会随意泄漏导致程序崩溃。—— Frost Ming
在现实情况下,不同的语言各自又有什么样的 best practice?本文仅当抛砖引玉。
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.