取消

.NET/C# 建议的异常处理原则

“体验”一词早已泛滥却又能够粗略地表达开发团队对客户端产品的要求,“质量”在 卡诺模型(KANO Model) 中是“必备特性”——做得好了用户感觉不到,做得差一点儿用户就会破口大骂

本文将以提升客户端 GUI 产品质量为目标,谈谈 .NET/C# 中建议的异常处理方式。(如果想了解更具体的应该抛出什么异常,请前往我的另一篇文章 应该抛出什么异常? - 吕毅


不恰当的异常处理会带来什么影响?

DEMO 和学习资料是无所谓的,找个地方写写 try-catch-finally 就完了……但是一旦这一行为迁移到了大型产品或软件系统中,不恰当的异常处理将会带来严重的体验下降或者巨大的额外维护成本。

严重的体验下降

众所周知,如果应用中存在大量未经处理的异常,那么应用分分钟崩溃死掉。如果软件面向最终用户,那么用户将不停地遭遇闪退或者“停止工作”。如果面向服务器或其他系统,频繁挂掉也几乎意味着服务不可用。

这是异常处理“不足”造成的影响。

不过,处理“不足”这种情况大家见得少,因为实际开发中更多遇到的不是很多异常未经处理,而是各种异常都处理掉了。算是“过度”吧。

巨大的额外维护成本

如果我们在各个功能上都加上 try-catch 块,在 catch 里吞掉异常,软件确实是不会崩了,但部分功能代码也不会执行执行了。本来用户那里崩溃一下还能逼着开发者去调查一下原因,现在连崩溃都看不到,甚至都不知道软件已经濒临挂掉的边缘。积少成多的这些小错误会瞬间积累,形成一组复杂的不可描述和预知的现象。没有人能说明这现象背后到底是哪个模块的错误导致的。于是,分析一个用户反馈的错误将变得非常低效,每一次错误都难以说出具体出错的模块到底是哪个——软件的质量只有日益下降,维护成本持续升高了。

不要说在每个 catch 块里记了 log,log 是开发者们从来不会主动去看的文件,从来都是出了问题才看的,而且看了也只能修复 BUG,解决不了问题!

我举个例子:软件为用户储存一份文档,在此过程中发生了异常却被吞掉了(就算记了 log);那么用户极有可能得到一份缺失重要内容的损坏的文档——看 log 能帮用户找回损失吗?!

总揽全局——分层的异常处理

异常的处理可以分为四个层:

  1. 任务的执行细节
  2. 调用任务执行的顶级 UI、顶级命令或包含完整功能的 API
  3. 线程级别和应用程序域级别
  4. 驱动模块或应用程序的框架


▲ 上图在垂直方向上存在直接调用关系,而在水平方向上是不同时机上的调用

其中第 4 层并没有出现在上图中,因为它并不能按照执行时机或调用关系来定位,而是可能出现在上图中的任何一处。

在不同的层上应该做不同的事情,如果每一层都做正确的处理,那么便能够既保留足够的异常信息供开发人员分析,又不会因为异常致使用户用起来感觉软件不稳定。

接下来,我们将分别说明在每一层应该做些什么,原则是什么。

定出原则——职责分明

执行细节

执行细节通常有这些代码:

  • 组件库/公共组件
  • 业务实现代码

这些代码几乎都是要被调用才会开始执行,但在编写时一般较难预见到调用方的使用方式和时机。

它的异常处理原则是:

  • 提前判断参数和状态,不满足则抛出异常
    如果调用方需要提前准备一些状态或参数才能正常执行,那么必须提前判断这些状态;如果判断不通过,需要抛出异常提示调用方需要正确地调用。(如果非私有方法的判断已经足够了,内部的私有方法可以不用再做判断。)
  • 执行方法承诺的任务,若无法履行承诺,则抛出异常
    如果调用的更底层的方法抛出了异常,要么保留这些异常对外抛出(推荐),要么抛出自己的异常并将底层异常包装为内部异常。
  • 如果异常会导致状态错误或应用程序功能雪崩,需要恢复并重新抛出异常
    catch 是用来恢复错误的,而不是用来防止崩溃的。finally 是用来恢复状态的。

需要说明的是,这部分代码通常是一层嵌一层地调用,是每一层都要注意以上原则。

顶级 UI/命令或 API

对异常的处理本不应该区分具体的业务实现还是顶级命令或 UI 的,在我试图推荐的异常处理方式中,它也应该遵循前面执行细节里的三项处理原则。但实际在执行的过程中,如果不把顶级命令和 UI 单独拿出来说,会有理解上的困难。

  • 对顶级 UI 或命令来说,提前判断的参数通常是用户的输入和当前应用程序的若干状态。
    对用户输入来说,提前从交互上防止用户出错是最佳的方式,但也不可避免会存在遗漏,这时肯定不能直接抛个异常给用户;所以此时的最佳处理方案是给出适当的 UI 反馈以告知用户出现的问题和建议的恢复方法。
    对程序当前的状态来说,如果不符合执行某个命令的要求,这个命令应该被禁用并告知用户禁用的原因;而不是执行时抛个异常或者什么都不做。

  • 对顶级 UI 或命令来说,承诺的任务已经开始包含必要的异常处理以及与此处理相关的交互。
    也就是说,catch已知的几种异常并用友好的 UI 交互形式与用户进行互动也是承诺的一部分。既然承诺的任务能够达成,也不需要抛出异常。(未知原因的异常依然不应该私自处理,因为这依然会导致问题难以定位,何况还是未知异常。)

应用程序级别对外公开的 API 考虑到安全性问题,考虑到第三方调用者参差不齐的水平,也会考虑有限地通过 UI 交互来吞掉部分已知的异常。而这时也如以上所说,这些处理也是此 API 承诺任务的一部分。

程序统一处理

Dispatcher.UnhandledException 可以处理掉当前 UI 线程上未经处理的异常;AppDomain.UnhandledException 可以让我们知道当前应用程序域中所有未经处理的异常。

正是因为统一处理的存在,才使得我们可以放心大胆地在业务代码中抛出能够足够描述当前异常原因的异常而不用担心应用程序会频繁地挂掉。

不过统一处理的地方能够进行的处理操作有限,比如记个 log 之类,毕竟不知道业务需求。所以并不要指望在统一处理时能够恢复错误,错误还是需要到各个业务方去恢复的。

框架

框架代码可能被业务代码调用,也可能调用业务代码。无论哪种,框架从来都不能相信业务代码按照要求和契约来编程。

处理框架代码被调用时,以正常实现细节被调用的异常处理原则一样即可——确保参数正确,承诺完成并且不完成就抛出异常。

处理框架调用业务代码时,几乎一定要处理业务代码任何种类崩溃的情况。也就是说,几乎需要恢复错误然后重新抛出异常。

本文会经常更新,请阅读原文: https://blog.walterlv.com/post/suggestions-for-handling-exceptions.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 ([email protected])

登录 GitHub 账号进行评论