返回

基于过滤器实现异常处理的探索

正如你所看到的那样,今天我想和大家聊聊异常处理这个话题。对于异常处理这个话题,我相信大家都有各自的方法论。而我今天想和大家探讨的这种异常处理方案,我将其称之为基于过滤器的异常处理。我不知道这种定义是否准确,我们的项目上在要引入 AOP 的概念以后,我们对异常处理的关注点就从try-catch转向Interceptor。虽然首席架构极力推荐,使用 Unity 框架来拦截代码中的各种异常,可从我最初纠结于"return"和"throw"的取舍,到现在我可以灵活地使用和捕捉自定义异常,对我而言老老实实地实践异常处理的经典做法,比使用 AOP 这样一种高大上的概念要有意义地多,因为我相信在某些情况下,我们并不是真正地了解了异常处理。

异常和错误

或许是因为人类对机器时代充满了近乎苛刻的憧憬,我们的计算机程序在开始设计的时候,就被告知不允许出现错误,甚至我们的教科书上会用一种充满传奇色彩的口吻,来讲述一个因为粗心的工程师计算错了小数点而导致航天飞行器机毁人亡的故事。可是人类常常会对自己选择宽容,而对他人则选择严格,这种观点在整个数字时代更为凸显,当我们无法容忍一个糟糕的应用程序的时候,无论曾经人们为此付出过多少努力,在这一瞬间他们的价值都将不复存在。我们的这种苛刻迫使我们不允许软件出现错误,我们尝试通过各种各样的测试来避免错误发生,可是事实上软件工程实践最终会演变为一个妥协的产物,这意味着我们任何的形式化方法最终都会失败,没有人可以保证一生都不会犯错,而软件工程师同样是人,为什么我们一定要求他们不可以犯错呢?

我们不得不承认软件产品是一个持续演进的过程,如果抛开商业意义上的Deadline来说,实际上软件是永远没有写完的那一天的,这就是为什么工程师都有点理想主义的原因,不考虑外界环境因素的变化,而期待软件永远不会有新的问题产生,这实在是一种苛刻地要求。好了,我们在这里频繁地提到错误,那么在软件工程学意义上的异常和错误分别是指什么呢?具体来讲,异常是指我们可以明确预测到它会发生并且需要我们进一步处理的流程,而错误是指我们无法明确预测到它会发生并且它会程序流程中断而导致程序崩溃,所以我认为区分"异常"和"错误"最直观、最简单粗暴的方法就是,如果你捕捉到了一个异常并处理了这个异常,那么它就是异常。反之,如果任由异常导致程序 Crash,那么它就是错误。如果我们因为畏惧异常而给所有方法增加 try-catch,我不得不遗憾得告诉你,你还没有真正明白什么是异常。

在早期的 Win32 API 中,微软大量使用了错误码来表示方法执行过程中发生的错误,这样就引出异常处理中的第一个问题,我们到底是应该是使用错误码还是异常来表示方法执行中发生的错误?事实上这两者在程序的表达能力上等价的,它们都可以向调用者传达"异常发生“这个事件,譬如我们在集合中查找一个元素,如果元素不存在则返回-1,这其实就是一个使用错误码来表示"错误“的经典案例,显然这种从 C/C++时代遗留下来的传统解释了 Win32 API 为什么会选择这样的设计方式,换言之,选择哪种方式,本质上是一种从 API 风格、代码风格和性能指标等方面综合考虑后的结果,错误码这种方式的缺陷主要在于,错误码不能明确地告诉调用者到底发生了什么错误,除非我们定义更多的错误代码,而且在没有引入可空类型以前,我们没有办法避免错误码污染返回值的值域,比如在这个例子,如果集合中恰好有一个元素-1,那么通过-1 这个返回值我们是没有办法判断出,这个-1 到底是不是因为方法内部发生了错误而返回-1.

好了,现在我们来说说异常,异常在主流的编程语言里基本上是一个标配。异常可以保存从异常抛出点到异常捕获点间的相关信息,所以异常相比错误码可以持有更多的信息,或许你可以尝试去设计一种数据结构来让返回值更丰富:)。我们常常听到"使用异常会降低程序性能"这样的说法,可这部分性能上的差异仅仅是因为,我们需要在抛出异常的时候给调用者更多的信息,所以这是一个非常公平的事情。第二个问题,我们是不是在所有情况下都使用异常?使用异常的好处是它可以让我们以一种更安全的方式去处理异常,可一旦发生了异常程序的性能就会降低,所以我们可以看到.NET 中提供 TryParse 这样的方法,这其实是在告诉我们:如果预测到异常一定会发生,正确的策略不是去捕捉它而是去回避它。在《编写高质量的 C#代码》一书中曾建议:不要在 foreach 内部使用 try-catch,就是这个道理,即采用防御式编程的策略来回避异常,而不是总是抛出异常。

那么,总结下行文至此的观点:异常是强类型的,类型安全的分支处理技术,而错误码是弱类型的,类型不安全的分支处理技术。元组等可以让函数返回多个返回值的技术,从理论层面上可以模拟异常,即将更多的细节信息返回给调用者,可是这种方式相比由运行时提供支持的异常机制,在性能指标和堆栈调用上都存在缺陷。异常在被运行时抛出来的时候,程序性能是下降的,这是因为调用者需要更多的细节信息,所以不建议在所有场合都抛出异常,建议使用防御式编程的策略去回避异常,直到确定程序没有办法处理下去的时候再抛出异常。理论上所有自定义的异常都应该去捕捉并处理,否则定义这些自定义异常是没有意义的。异常处理应该拥有统一的入口,在代码中到处 try-catch 和记日志是种非常丑陋的做法,理论上应该坚决摒弃。

Checked Exception

最近垠神写了一篇新的文章《Kotlin 和 Checked Exception》,在这篇文章中垠神提到了 Checked Exception 这种针对异常处理的设计,而恰好我这篇文章写的同样是异常处理,并且我在下面提到的基于过滤器的异常处理方案,实际上就是为了解决这种 Checked Exception 的问题,虽然在.NET 中不存在 Checked Exception。

要了解什么是 Checked Exception,要从 Java 中的异常机制说起。Java 中的异常类全部继承自 Throwable,它有两个直接子类 Error 和 Exception,通常情况下 Error 是指 Java 虚拟机中发生错误,所以 Error 不需要捕捉或者抛出,因为对此表示无能为力;而 Exception 则是指代码逻辑中发生错误,这类错误需要调用者去捕捉和处理。那么在这样的分类下,Java 中的异常可以分为 Checked Exception(受检查的异常)和 Unchecked Exception(未受检查的异常),前者需要需要方法强制实现 throws 声明或者是使用 try-catch,如果不这样做编辑器就会直接报错,后者就相对宽容啦,没有这样霸道的条款,可是诡异的是 RuntimeException 是一个 UncheckedException,可它居然是继承自 Exception 而不是 Error,这实在令人费解,Java 的设计模式果然博大精深。

那么对一个 Checked Exception,Java 的处理方式是十分地霸道的,我们一起来看下面这段代码:

void Foo(string fileName) throws FileNotFoundException
{
  if(...) throw new FileNotFoundException();
}

我们可以注意到 Java 强制让 Foo()方法实现了 throws 声明,原因是在该方法内部可能会引发 FileNotFoundException,如果我们不遵从这一"霸王条款",那么我们的代码将无法通过编译,而在调用者层面上,Java 的霸道则体现在要求调用者使用 try-catch 结构处理这种异常,或者继续使用 throws 声明来使异常继续向上传递,我更喜欢将这种设计称之为一种理想状态下的异常处理机制,比如我们读写一个文件的时候,除了 FileNotFoundException 以外,可能还会遇到 FileLoadException、PathTooLongException、EndOfStreamException 等等的异常,如果这些异常在业务层面上是无差别的,那么我认为将异常细分到如此精细的程度是没有意义的,因为对用户而言这个时候它关心的是否成功读写了这个文件,具体的异常原因用户并不想真的知道,可是 Java 的 Checked Exception 在面对这种处境的时候,整体而言是显得力不从心的,因为我们不得不在方法从声明该方法会引出哪些异常,这对方法的编写者和方法的调用者来说都很痛苦。

垠神这篇文章其实在说一个问题,Checked Exception 鼓励开发者主动告知调用者来捕获特定异常,这种思路完全是没有问题的,问题是调用者如何能够知道它需要捕获哪些异常,我们不可能每次都通过"转到定义”功能去看一个方法会引发哪些异常,垠神从 PL 的角度出发,想到了通过代码静态分析的方法来处理异常,垠神吐槽的其实是不分青红皂白滥用 try-catch 的做法,实际上 Java 标准库里对异常处理相当混乱,虽然官方鼓励使用 Checked Exception,但是标准库实现和工程实践上不乏将异常包装为 RuntimeException 来规避 Check 的做法,我认为 Checked Exception 在工程学意义上最大贡献是,在开发阶段该抛出什么异常就应该抛出异常,因为这样可以方便我们快读定位问题,而到了发布阶段则应该将这些异常都 catch 住即可,这样用户就不会看到这些奇葩的异常。换句话说,我们不必在程序中去处理所有的异常,而是将异常机制作为我们定位问题的工具,去捕获那些有可能出现的异常即可。

C#中其实是由类似 Checked Exception 的概念存在的,不过所有的 Check 都不是强制去实现的,我们知道.NET 中一个方法会抛出哪些异常完全是由注释来说明的,XML 注释中的 exception 节点表示该方法会引发何种异常,我们一起来看下面的例子:

/// <exception cref="MasterFileFormatCorruptException"></exception>
/// <exception cref="MasterFileLockedOpenException"></exception>
public static void ReadRecord(int flag) 
{
     if (flag == 1)
        throw new MasterFileFormatCorruptException();
     else if (flag == 2)
        throw new MasterFileLockedOpenException();
     // …
} 

可以注意到 C#采用的是一种相对温和的策略,即文档会明确告诉你,某个方法是否会引发异常以及引发哪些异常,但是是否要捕获这些异常则完全由调用者决定,我认为这是 C#之父 Hejlsberg 在权衡后在工程实践上选择的一种妥协,因为 Java 的 Checked Exception 理想主义色彩稍重,并不是在所有场景下我们都需要去处理所有的异常,所以 Checked Exception 带来的问题是,即使在只需要捕获基类异常的情况下,我们依然不得不去捕获各种子类异常,这难道不有点矫枉过正的感觉吗?事实上所有工程实践中,不分青红皂白直接捕获 Exception 父类的做法,就是因为调用者完全不想关注发生得异常细节,这是垠神在文章中吐槽的"糟糕的代码",C#相对 Java 在异常处理上好的一点就是,优秀的工程师会自觉地处理异常,如果他们清楚地知道异常会发生就一定会去捕获异常。你不能强迫他们去做他们不喜欢做的事情。

让异常处理更优雅

好了,现在我们来考虑这样一个问题,设计 Checked Exception 的初衷是为了让我们处理业务逻辑中不同的具体的异常,当这些异常在业务逻辑层面上无差别的时候,其实我们可以完全忽略这些异常的细节,因为不管是哪种具体的异常,在业务逻辑层面都被认为是任务执行失败,这种情况下我们直接捕获基类异常即可,例如在读写文件的例子中我们关注 IOException 即可。那么如果这些具体异常在业务逻辑层面上存在差异呢?这种情况下我们就应该向 Checked Exception 方向靠拢,下面我们来一起听一个实际的故事。

我们的项目上需要从多个相互独立的系统中抓取数据并生成报表,因为这些系统在设计上都存在缺陷,所以在抓取数据的过程中非常容易出现错误,所以我们必须非常谨慎地处理这些异常,用户要求我们必须一种视觉友好的方式将报表输出出来,当异常发生时我们需要将抓取失败的数据高亮显示出来,并输出相关的错误信息来提醒用户来 Check 这些信息,而事实上每种异常发生的时候其处理逻辑是完全不同的。为此我们定义了将近 10 种的自定义异常,并为用户设计了完善的操作日志记录机制,这一切听起来非常不错,最终写出来的代码大概是下面这个样子:

try
{
  Foo()
} catch (ExceptionA ex) {
  
} catch(ExceptionB ex) {
  
} catch(ExceptionC ex) {
  
} catch(ExceptionD ex) {
  
}
// ...

相信在 Java 的相关工程实践中,这种教科书般的代码基本上是异常处理的金科玉律啦,可是这种代码实现写起来会让人感觉头重脚轻,因为我们所有重要的逻辑都写在了 catch 块里,这让我非常不喜欢这种"臃肿”的代码,而且事实上在这个案例中 catch 块里的代码具备可重用的可能,所以我决定以一种更优雅的方式来重构这段代码。数学领域中有一个不变的真理,即任何问题都可以通过引入一个新的问题来得到解决,这个理论在编程中同样适用,因为在公司里受到同事 Wesley 的影响,我对 Ioc 和 AOP 都从思想上有了一定认识,公司在推行 Unity 的过程中我并没有看到多少实际的意义,所以我对"道"的重视远远超过对"术"的追求,因为我相信框架学习起来并不会花费多少实践,可怕的是你从来不试图去了解框架背后的秘密,所以我借由 AOP 中拦截器和 MVC 中过滤器的概念,想到了我接下来要说的这种异常处理的方案。

其实我们在这里的核心目的是为了消除分支,所以采用多态是我们重构这部分代码的第一步。我们首先定义一个针对 Foo 方法的异常基类 ExceptionBase,然后让这里的 ExceptionA、ExceptionB、ExceptionC 等全部继承自 ExceptionBase,这样我们就可以将这些具体的异常子类向上转型为 ExceptionBase 统一进行处理。与此同时,我们注意到处理各种异常子类的逻辑各不相同,虽然我们可以直接将处理异常的逻辑写到异常中(通过一个虚方法来实现),可这样会造成异常子类里的职责负荷,我更希望异常子类是一个朴素的贫血模型,所以我们这里引入过滤器的概念(不知道这样叫是否合适),Filter 全部继承自 FilterBase,它有一个 Invoke 的方法,我们最终会在这里实现异常处理的方法,为此我们需要定义各种各样的 Filter,然后通过 Attribute 将每一种 Filter 和特定的 Exception 关联起来,比如我们希望在所有异常的地方打日志,那么我们只需要实现一个 LoggerFilter,然后给所有的 Exception 添加 Attribute,这可以显著改善我们的代码。

现在,我们只需要给 ExceptionBase 类提供一个 GetFilter()方法,该方法的返回值类型为 FilterBase,我们将通过反射来创建一个 Filter 并将其返回,所以我们写出来的代码会是下面这个样子:

[ExceptionFilter(FilterName = "FilterA")]
class ExceptionA : ExceptionBase { }

[ExceptionFilter(FilterName = "FilterB")]
class ExceptionB : ExceptionBase { }

[ExceptionFilter(FilterName = "FilterC")]
class ExceptionC : ExceptionBase { }

此时此刻,我们的关注点就从一堆 catch 块中转移到了不同的 filter 中,虽然我们编写代码的工作量没有减少,但这样的做法无疑增强了代码的可维护性,因为我们只需要到不同的 Filter 里去修改逻辑即可,我在书上看到这样一句话,无论什么时候合并代码总是比拆分代码要容易,当你不确定某个功能是否要放在特定模块中的时候,最好的解决方案就是将他们完全独立设计。降低代码的耦合度是一件我们常常挂在嘴边的事情,可是如何去真正地降低代码耦合度,这件事情需要我们一直思考下去。好了,现在我们完成了分支结构上的精简,而最终调用的代码会是:

try
{
  Foo()
} catch (ExceptionBase ex)
{
  var filter = ex.GetFilter();
  filter.Invoke();
}

现在的代码是不是比原来清新了好多呢?虽然这相比真正的 AOP 还是稍显稚嫩,可它的出现成功地让一段"丑陋"的代码变得优雅起来,我们不必再担心修改异常处理流程时会原来的代码产生副作用,如果需要增加新的处理逻辑我们继续派生异常类和过滤器即可,这就是代码设计上以不变应万变的道理,你要相信程序员都是非常懒惰的,如果有可以不用修改代码就适应变化的设计,他们是一定会喜欢的,因为这个世界对程序员并不友好,如果你不想你的代码被这个世界修改得面目全非,最好的选择就是不给它们这样的机会,他们说人长大一定会变成自己讨厌的样子,我想告诉这个世界永远不要忘记初心。

好了,这篇文章唠叨到现在,写了大概 5000 多字,花了我整整两个下午的时间,我在向 Paul 和 John 询问这个问题的看法时,他们都告诉我这个问题没有好的解决方案或者是劝我不要做这样探索,可是事实上我只用了半天时间就完成了这个设计,在去年的时候我曾向房燕良前辈请教好异常处理的问题,当时我的关注点主要在使用错误码还是使用异常。在上一个项目中,我对于异常处理其实实践得并不好,因为我一直不知道哪里是捕获异常的入口,我个人并不认同直接捕获到异常直接 throw 这种做法,因为你在自定义异常的时候就应该想清楚,哪些异常是需要捕获并处理的,哪些异常时可以直接让它 Crash 的,如果每个人都仅仅是抛出异常而不去拦截异常,那么异常机制设计得再好又有什么用呢,关于 Java 的异常有一个梗,是说 Java 的异常给出了详细的堆栈信息,可就是不直接告诉你到底是哪里异常了,事实证明我设计的这个方案运行的很好,其实我很想吐槽操作日志真的有存在的必要吗?很多时候,我们要学会遵从自己内心的声音,所谓世间难道不就是我们吗?真正绑架我们的永远都只有别人,让我们时刻谨记:万物为虚,万事皆允。

Built with Hugo v0.110.0
Theme Stack designed by Jimmy
已创作 267 篇文章,共计 1008057 字