一个软件系统,只是可以提供相应的功能,还不能算是完成了预期的目标,
它还要满足一些非功能方面的限制条件。
健壮性就是一个典型的例子,
它反映了系统对于规范要求以外输入情况的处理能力。
为了实现健壮性,我们不仅需要考虑所有可能的输入,
更重要的是要考虑异常流,即系统在遭遇异常时究竟如何表现。
这里有一个常见的误区,那就是,人们为了防止系统崩溃,
不论是否出现错误,总是让它看起来是正常,
这其实是一种自欺欺人的做法。
这种做法不仅不能够提高系统的健壮性,
反而人为的向定位问题增添了很多障碍,提高了沟通成本,
出错的场景被隐藏掉了,只有接口的提供者才知道原因。
那么如何设计一个健壮的系统呢?
本文提供了一些思路。
1. Fail Fast 哲学
说的是一个子系统在遇到问题的时候,应该尽早崩溃。
常见的if-return
模式(或者if-throw
),就是Fail Fast思想的一种应用。
1 | function failSlow() { |
以上两个JavaScript函数,表现了不同的处理问题风格。
如果代码后文要用到x
,且当前没有x
,那不如尽早返回。
还有一种写法,
1 | function assertPrecondition(x) { |
以上代码,通过assert
对函数的前置条件进行了断言。
使用if-return
或者assert
,会在很大程度上避免嵌套if
的出现,
否则,我们就不得不在很多if
条件中编写业务逻辑。
例如,以下展示了嵌套if
的某个场景,
1 | if (x != null) { |
改成assert
后,将前置条件与正常的业务逻辑解耦了。
1 | assert(x != null); |
2. 不要吃掉异常
如果子系统无法处理某个错误,那就把它抛出来,而不是吃掉它。
下面我们来看一个例子,
1 | function application() { |
以上代码,我们在application
函数中调用了service
,
但是service
吃掉了异常。
那么一旦application
的用户指出了错误,定位问题就是一件麻烦事,
application
的编写者,要去询问每一个service
的提供者,到底出现了什么状况。
我们再来看下,如果不吃掉异常会怎样,
1 | function application() { |
以上代码,我们修改了service
函数,
它确实捕获了异常,但是如果这个异常service
无法处理,那就向上抛出去。
这样的话,甚至根本轮不到application
的用户指出错误了,
application
自己都能拿到异常的调用栈信息,
并且,即使由用户指出了,也能不依赖于service
来定位问题的原因。
可能有人会说,如果异常都被抛出了,没有人处理怎么办?
是的,吃掉异常不也是没人处理它吗?
所以,抛出异常就相当于显式的向调用者提问,这个异常到底应该如何处理。
换句话说,
异常被静默处理,应该是大家协商后的决定。
3. 让异常成为一种约定
异常和异常的处理应该是一体的,它们应该被统一的设计和使用。
各个系统的边界处,应该如何反馈异常,以及这些异常应该如何被处理,应该事先被约定好。
这样才会减少冗余的个性化的异常处理逻辑。
例如,使用面向切面编程,
我们可以在系统的边界上定义一个异常切面,统一处理错误。
1 | function application() { |
以上代码,为了简洁起见,我们用装饰器实现了一个异常切面,
目前,装饰器是ECMAScript TC39 Stage2的特性。
在这个切面中,我们统一的将异常写入了日志中,
再将异常抛出。
4. 主动抛出异常
有了异常切面,我们就不用担心未被捕获的异常了,
向系统中添加一种异常,总是伴随着修改异常切面来完成。
有了这种异常处理机制,我们就可以主动抛出一些业务异常了。
例如,上文中提到的assert
,如果不满足前置条件断言,
我们可以主动抛异常。
1 | // 1. 新增异常 |
注:
如果认为每次都修改exceptionAOP
是一件麻烦事,我们可以这样做,
1 | // 1. 新增异常 |
其中exceptionHandler
是我们编写的辅助对象,具体实现方式可以灵活选择,
在切面上的用法如下,
1 | const exceptionAOP = Class => { |
5. 先考虑异常处理
在设计系统的时候,异常以及异常处理方式应该事先考虑,
否则人们就会按照各自的便利来编写逻辑,
这样很容易产生兼容性问题。
例如,有些接口可能会通过返回异常值来反馈异常,
而有的接口会通过上抛异常来反馈,
这样在处理方式上就很难统一处理,业务逻辑写起来会比较繁琐。
即使已经确定为返回异常值的方式,也容易出现以下问题,
1 | // 接口1 |
以上两个接口的数据类型是不同的,
第一个接口用isSuccess
来标志异常,第二个用succeed
,
那么这两个接口的调用者,就不得不分别处理它们。
因此,这是下层服务返回了两种异常,但是期望它们用相同的方式处理,
这很显然是系统设计的不恰当。
并且,当我们意识到这个问题的时候,再进行修改就难了,
抛出异常和处理异常的代码要同时修改,是一种接口变更,
会涉及兼容性问题。
结语
我们可能都体会过,知道错误但是不知道原因的那种憋闷感,
在整个调用链路上,一环一环的排查,结果发现是某个服务吃掉了异常。
如果有日志可循,这还不是难事,最多是花费一些时间,
但如果日志都不健全,定位一个问题简直是难于登天,甚至要手动debug才行。
究其原因可能是,人们想要隐藏错误,以使得系统看似健壮。
所以,哪有不出错的系统,我们只能及时修正。