关于异常处理的总结和思考

Link
最近聊到了关于异常处理的问题。最初是 yihong 在频道里分享了 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。
每次返回的时候都需要携带一个上下文信息帮助定位错误,否则由于没有 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 的代码兜底。 底看似兜住了,其实早已千疮百孔。

性能缺陷
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?本文仅当抛砖引玉。
       
       

      © Yanli 盐粒 2022 - 2024