异常先行

一个软件系统,只是可以提供相应的功能,还不能算是完成了预期的目标,

它还要满足一些非功能方面的限制条件。


健壮性就是一个典型的例子,

它反映了系统对于规范要求以外输入情况的处理能力。


为了实现健壮性,我们不仅需要考虑所有可能的输入,

更重要的是要考虑异常流,即系统在遭遇异常时究竟如何表现。


这里有一个常见的误区,那就是,人们为了防止系统崩溃,

不论是否出现错误,总是让它看起来是正常,

这其实是一种自欺欺人的做法。


这种做法不仅不能够提高系统的健壮性,

反而人为的向定位问题增添了很多障碍,提高了沟通成本,

出错的场景被隐藏掉了,只有接口的提供者才知道原因。


那么如何设计一个健壮的系统呢?

本文提供了一些思路。


1. Fail Fast 哲学

Fail FastErlang语言的编程哲学之一,

说的是一个子系统在遇到问题的时候,应该尽早崩溃。


常见的if-return模式(或者if-throw),就是Fail Fast思想的一种应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
function failSlow() {
if (x != null) {
// 使用x做一些事情
}
}

function failFast(x) {
if (x == null) {
return;
}

// 使用x做一些事情
}

以上两个JavaScript函数,表现了不同的处理问题风格。

如果代码后文要用到x,且当前没有x,那不如尽早返回


还有一种写法,

1
2
3
4
5
function assertPrecondition(x) {
assert(x == null);

// 使用x做一些事情
}

以上代码,通过assert对函数的前置条件进行了断言


使用if-return或者assert,会在很大程度上避免嵌套if的出现,

否则,我们就不得不在很多if条件中编写业务逻辑。


例如,以下展示了嵌套if的某个场景,

1
2
3
4
5
6
7
if (x != null) {
if (y > 0) {
if (z) {
// 业务逻辑
}
}
}


改成assert后,将前置条件与正常的业务逻辑解耦了。

1
2
3
4
5
assert(x != null);
assert(y > 0);
assert(z);

// 业务逻辑


2. 不要吃掉异常

如果子系统无法处理某个错误,那就把它抛出来,而不是吃掉它。


下面我们来看一个例子,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function application() {
service();

// 其他service
}

function service() {
try {
// 业务逻辑
} catch (e) {
// 什么也不做
// 或者即使是写日志
}
}


以上代码,我们在application函数中调用了service

但是service吃掉了异常。


那么一旦application的用户指出了错误,定位问题就是一件麻烦事,

application的编写者,要去询问每一个service的提供者,到底出现了什么状况。


我们再来看下,如果不吃掉异常会怎样,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function application() {
service();

// 其他service
}

function service() {
try {
// 业务逻辑
} catch (e) {
// 写日志
throw e;
}
}


以上代码,我们修改了service函数,

它确实捕获了异常,但是如果这个异常service无法处理,那就向上抛出去。


这样的话,甚至根本轮不到application的用户指出错误了,

application自己都能拿到异常的调用栈信息,

并且,即使由用户指出了,也能不依赖于service来定位问题的原因。


可能有人会说,如果异常都被抛出了,没有人处理怎么办?

是的,吃掉异常不也是没人处理它吗?

所以,抛出异常就相当于显式的向调用者提问,这个异常到底应该如何处理。


换句话说,

异常被静默处理,应该是大家协商后的决定。


3. 让异常成为一种约定

异常和异常的处理应该是一体的,它们应该被统一的设计和使用。

各个系统的边界处,应该如何反馈异常,以及这些异常应该如何被处理,应该事先被约定好。

这样才会减少冗余的个性化的异常处理逻辑。


例如,使用面向切面编程

我们可以在系统的边界上定义一个异常切面,统一处理错误。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function application() {
MyService.service();

// 其他service
}

// 装饰器是一个高阶函数
const exceptionAOP = Class => {
const service = Class.service;

Class.service = function (...args) {
try {
return service.call(this, ...args);
} catch (e) {
// 写日志
throw e;
}
};

return Class;
};

class MyService {
@exceptionAOP
static service() {
// 业务逻辑
}
}


以上代码,为了简洁起见,我们用装饰器实现了一个异常切面,

目前,装饰器是ECMAScript TC39 Stage2的特性。


在这个切面中,我们统一的将异常写入了日志中,

再将异常抛出。


4. 主动抛出异常

有了异常切面,我们就不用担心未被捕获的异常了,

向系统中添加一种异常,总是伴随着修改异常切面来完成。

有了这种异常处理机制,我们就可以主动抛出一些业务异常了。


例如,上文中提到的assert,如果不满足前置条件断言,

我们可以主动抛异常。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 1. 新增异常
class PreconditionException extends Error { };

const exceptionAOP = Class => {
const { service } = Class;

Class.service = function (...args) {
try {
return service.call(this, ...args);
} catch (e) {

// 2. 新增处理方式
if (e instanceof PreconditionException) {
// 写日志,前置条件断言失败
throw e;
}

// 写日志
throw e;
}
};

return Class;
};

const assert = condition => {
if (!condition) {
throw new PreconditionException;
}
}

class MyService {
@exceptionAOP
static service(x) {
assert(x);

// 业务逻辑
}
}


注:

如果认为每次都修改exceptionAOP是一件麻烦事,我们可以这样做,

1
2
3
4
5
6
7
8
9
10
11
// 1. 新增异常
class PreconditionException extends Error { };

// 2. 新增处理方式
exceptionHandler.add({
exception: PreconditionException,
handler: e => {
// 写日志,前置条件断言失败
throw e;
}
});


其中exceptionHandler是我们编写的辅助对象,具体实现方式可以灵活选择,

在切面上的用法如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const exceptionAOP = Class => {
const service = Class.service;

Class.service = function (...args) {
try {
return service.call(this, ...args);
} catch (e) {
// 使用exceptionHandler处理异常
exceptionHandler.handle(e);
}
};

return Class;
};


5. 先考虑异常处理

在设计系统的时候,异常以及异常处理方式应该事先考虑,

否则人们就会按照各自的便利来编写逻辑,

这样很容易产生兼容性问题


例如,有些接口可能会通过返回异常值来反馈异常,

而有的接口会通过上抛异常来反馈,

这样在处理方式上就很难统一处理,业务逻辑写起来会比较繁琐。


即使已经确定为返回异常值的方式,也容易出现以下问题,

1
2
3
4
5
6
7
8
9
10
11
12
13
// 接口1
{
isSuccess: boolean,
message: string,
data: object
}

// 接口2
{
succeed: boolean,
msg: string,
data: object
}

以上两个接口的数据类型是不同的,

第一个接口用isSuccess来标志异常,第二个用succeed

那么这两个接口的调用者,就不得不分别处理它们。


因此,这是下层服务返回了两种异常,但是期望它们用相同的方式处理,

这很显然是系统设计的不恰当。


并且,当我们意识到这个问题的时候,再进行修改就难了,

抛出异常和处理异常的代码要同时修改,是一种接口变更

会涉及兼容性问题。


结语

我们可能都体会过,知道错误但是不知道原因的那种憋闷感

在整个调用链路上,一环一环的排查,结果发现是某个服务吃掉了异常。


如果有日志可循,这还不是难事,最多是花费一些时间,

但如果日志都不健全,定位一个问题简直是难于登天,甚至要手动debug才行。

究其原因可能是,人们想要隐藏错误,以使得系统看似健壮。


所以,哪有不出错的系统,我们只能及时修正