在Java程序开发中,会遇到各种异常,常见的NullPonitException(NPE),IllegalArgumentException等等,Java的优雅之处,很大一部分就在于这些刚开始看着头痛的异常中,一起来了解下

Exceptions

首先,什么是异常,通俗来讲,异常,是我们程序出现错误,并无法按照预期执行下去的情况,会抛出一个异常,它会告诉你,某个地方出错了,请示你该如何解决或者修改你的代码。

那么我们可以简单理解为,这是一个消息,一旦程序遇到问题,原定逻辑无法执行,程序非常忠实于开发者,它不会自己决定该怎么做,此时,程序开始收集信息,例如程序在A类执行到118行代码的地方,遇到了一个无法解决的问题,原定逻辑设置了循环了10[0-9]次,但给定的数组里只有8条数据,该怎么解决?

当执行到index=8时,给定数组中不存在第8个索引,这里我们遇到的异常是ArrayIndexOutOfBoundsException,给你传递了一个消息:Index: 8, Size: 8,告诉你说,你给了我一个只有8个数据的List,却要我找位置在第8索引,数组越界了,同时,他给你带上了异常栈,最上面是这个异常最开始发生的代码所在位置,然后一级一级往外传,每跳出一层,就记录一级的信息,在没有异常处理的情况下,直接跳到main方法中,展示到控制台中,并终止自己。

了解了异常是什么,下面来了解下Java 自身的异常体系

Throwable

Throwable就是Java异常体系的起点,基于所有Java的类都显式或隐式的继承于Object类,所以说异常体系的Root对象,就是Throwable,Throwabl旗下有两员悍将,Error,Exception。

其中Error是主战将军,轻易不出手,出手即打残,一旦我们看到Error级别的错误,表示JVM检测到无法预期的错误,因为是JVM导致的错误,并非程序可以改变,所以最多我们可以看到异常信息,无法捕获处理。根据错误去找环境哪里配置的不正确,或者重装一次Java,通常会解决问题

另外一个Exception类,是文官,文官通常讲究游说之道,哪里不合适我们再谈谈,但这种谈也分强硬方CheckedException和弱势方RunntimeException,第一种是直接继承自Exception,这种强硬的方式,必须要给出处理办法,如果你调用的方法中可能抛出这种错,那么你必须在方法调用的地方进行处理,或者选择继续往上抛,否则编译不通过,常见的CheckedExceptionIOException。另外一种方式,就很柔和了,但是阴损,这种方式就是继承自RunntimeException的异常,处理不处理都行,不往上层抛也行,反正也不告诉你这块能出异常,你要遇到了就加上异常处理,你要遇不到,就等客户遇到了告诉你。

下面是一张Throwable的Diagrams图,看不清的朋友右键图片下载到本地放大来看
描述

自定义异常体系

是不是可以基于Java的异常体系来搭建属于我们自己的呢?

首先我来描述一个场景,一个电商购物车下单业务,主业务逻辑为

  1. 校验库存,减库存
  2. 校验商品可售性
  3. 校验购物车预设的活动满减策略、组合策略、是否跨店铺
  4. 购物车拆单、子单分别发往对应商家
  5. 商家发货。。。

这个场景相对复杂,请同学们想象一下,这个下单场景调用很深,各种服务和类嵌套,其中校验商品可售性的逻辑有大概10个类,如果单纯用return来控制逻辑,会有多乱,每层返回都要做一次校验,一旦忘记拦截,就会造成意想不到的结果。

此时就需要异常来控制了,通常这种复杂场景都会有主流程服务来控制,按照1-5步骤,顺序执行服务,每个步骤就是一个场景行为(action), 那么如果用异常来处理,我只要要在对应的场景行为调用上套一层自己的业务异常处理类就可以了对吧

此时我需要区分,什么错是我们业务异常,什么是数据库异常,如果我们没有自定义异常体系,只能依赖拦截RuntimeException来做是不可能实现上述需求的,那么解决这个首先,我们先创建一个类,假设ElBasicException extend RuntimeException,并实现所需要的构造函数,通常我们不会直接在业务中抛出BasicException,假设我们在控制层做参数校验,检测出某一个参数长度超长,这时候我们可以新建一个异常,命名为ElParameterByteArrayOutOfMaxException,并继承于ElBasicException,抛出这异常,并在全局异常处理器中拦截,判断如果 e instanceof ElBasicException,那么就走我们业务的处理机制,如果不是,那么返回“系统错误“。

来看下实现:

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
40
41
42
43
44
/**
* 扩展运行时异常
* since 2019/12/7
*
* @author eddie
*/
@Slf4j
public class ExtendRuntimeException extends RuntimeException{

private ErrorMessage errorMessage;


public ExtendRuntimeException() {
this(ErrorMessage.EMPTY_ERROR_MESSAGE);
this.errorMessage = ErrorMessage.EMPTY_ERROR_MESSAGE;
log.error("无法定义的异常, {} : {}", getErrorCode(), getErrorMessage());
}

public ExtendRuntimeException(ErrorMessage errorMessage) {
super(errorMessage.getErrorMessage());
this.errorMessage = errorMessage;
log.error("{} : {}", getErrorCode(), getErrorMessage());
}

public ExtendRuntimeException(ErrorMessage errorMessage, Throwable cause) {
super(errorMessage.getErrorMessage(), cause);
this.errorMessage = errorMessage;
log.error("{} : {}", getErrorCode(), getErrorMessage(), cause);
}

public String getErrorCode(){
if (Objects.isNull(errorMessage)){
return "";
}
return errorMessage.getErrorCode();
}

public String getErrorMessage(){
if (Objects.isNull(errorMessage)){
return "";
}
return errorMessage.getErrorMessage();
}
}

扩展基类不用太复杂,但这里可以加一些监控配置,比如这里打日志,通过重写logbackappender,把这里的异常写入ELK监控里,不过appender要搞成异步缓存的,缓存一个队列,打印和网络传输都走移步,曾经有人测试异步打印和同步打印的性能区别,结果我忘记了,但差距不小。这里如果还加了网络IO,还有握手挥手的时间,更应该异步处理。

这里的ErrorMessage其实是对错误信息的封装,其中包含错误码、错误描述等自定义信息,想放啥放啥,还可以做一些时区,日志格式、多语言等封装
我们的所有异常都要基于这个异常来扩展,比如如果是DDD领域模型开发的话,每个域都应该有一个自己的基类异常,那领域服务中的异常都应该继承于这个异常,最终我们在下单服务中捕获异常,就直接捕获ExtendRuntimeException就好了

那还有个问题,如果每个域服务中的异常都不一样,那下单服务中该如何处理呢?这里就涉及到了文章开头提及的集权异常控制中心

异常控制中心

是不是可以基于Java的异常体系来搭建属于我们自己的呢?

首先我来描述一个场景,一个电商购物车下单业务,主业务逻辑为

  1. 校验库存,减库存
  2. 校验商品可售性
  3. 校验购物车预设的活动满减策略、组合策略、是否跨店铺
  4. 购物车拆单、子单分别发往对应商家
  5. 商家发货。。。

这个场景相对复杂,请同学们想象一下,这个下单场景调用很深,各种服务和类嵌套,其中校验商品可售性的逻辑有大概10个类,如果单纯用return来控制逻辑,会有多乱,每层返回都要做一次校验,一旦忘记拦截,就会造成意想不到的结果。

此时就需要异常来控制了,通常这种复杂场景都会有主流程服务来控制,按照1-5步骤,顺序执行服务,每个步骤就是一个场景行为(action), 那么如果用异常来处理,我只要要在对应的场景行为调用上套一层自己的业务异常处理类就可以了对吧

此时我需要区分,什么错是我们业务异常,什么是数据库异常,如果我们没有自定义异常体系,只能依赖拦截RuntimeException来做是不可能实现上述需求的,那么解决这个首先,我们先创建一个类,假设ElBasicException extend RuntimeException,并实现所需要的构造函数,通常我们不会直接在业务中抛出BasicException,假设我们在控制层做参数校验,检测出某一个参数长度超长,这时候我们可以新建一个异常,命名为ElParameterByteArrayOutOfMaxException,并继承于ElBasicException,抛出这异常,并在全局异常处理器中拦截,判断如果 e instanceof ElBasicException,那么就走我们业务的处理机制,如果不是,那么返回“系统错误“。

来看下实现:

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
40
41
42
43
44
/**
* 扩展运行时异常
* since 2019/12/7
*
* @author eddie
*/
@Slf4j
public class ExtendRuntimeException extends RuntimeException{

private ErrorMessage errorMessage;


public ExtendRuntimeException() {
this(ErrorMessage.EMPTY_ERROR_MESSAGE);
this.errorMessage = ErrorMessage.EMPTY_ERROR_MESSAGE;
log.error("无法定义的异常, {} : {}", getErrorCode(), getErrorMessage());
}

public ExtendRuntimeException(ErrorMessage errorMessage) {
super(errorMessage.getErrorMessage());
this.errorMessage = errorMessage;
log.error("{} : {}", getErrorCode(), getErrorMessage());
}

public ExtendRuntimeException(ErrorMessage errorMessage, Throwable cause) {
super(errorMessage.getErrorMessage(), cause);
this.errorMessage = errorMessage;
log.error("{} : {}", getErrorCode(), getErrorMessage(), cause);
}

public String getErrorCode(){
if (Objects.isNull(errorMessage)){
return "";
}
return errorMessage.getErrorCode();
}

public String getErrorMessage(){
if (Objects.isNull(errorMessage)){
return "";
}
return errorMessage.getErrorMessage();
}
}

扩展基类不用太复杂,但这里可以加一些监控配置,比如这里打日志,通过重写logbackappender,把这里的异常写入ELK监控里,不过appender要搞成异步缓存的,缓存一个队列,打印和网络传输都走移步,曾经有人测试异步打印和同步打印的性能区别,结果我忘记了,但差距不小。这里如果还加了网络IO,还有握手挥手的时间,更应该异步处理。

这里的ErrorMessage其实是对错误信息的封装,其中包含错误码、错误描述等自定义信息,想放啥放啥,还可以做一些时区,日志格式、多语言等封装
我们的所有异常都要基于这个异常来扩展,比如如果是DDD领域模型开发的话,每个域都应该有一个自己的基类异常,那领域服务中的异常都应该继承于这个异常,最终我们在下单服务中捕获异常,就直接捕获ExtendRuntimeException就好了

那还有个问题,如果每个域服务中的异常都不一样,那下单服务中该如何处理呢?这里就涉及到了文章开头提及的集权异常控制中心

异常控制中心

首先定义一个异常处理声明接口,这个接口有两个方法,一个是返回该领域标识符,另外一个是处理异常逻辑

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
/**
* 异常处理器负责的领域
* since 2019/12/22
*
* @author eddie
*/
public interface ExceptionHandler {

/**
* 返回异常处理器所在领域
* @return 异常处理器所在领域
*/
String ofExceptionDomain();

/**
* 处理运行时异常
* @param e 异常
* @return 错误信息
*/
ErrorMessage handleException(ExtendRuntimeException e);

/**
* 处理 <p>checked</p> 异常
* @param e 异常
* @return 错误信息
*/
ErrorMessage handleException(ImportantErrorException e);

}

最近在公司内接触到了另一个想法,只使用一个异常,例如我们这里的ExtendRuntimeException,然后里面会有丰富的接口,包括扩展执行等等,最终通过错误码来处理不同的逻辑,这样固然简单,但局限了一些东西,不是特别灵活

  1. 如果同一种类的异常有多个错误码,例如
    • 金额异常(103) 错误码 10301 金额格式不正确
    • 金额异常(103) 错误码 10302 货币种类不支持
    • 金额异常(103) 错误码 10303 汇率波动,请稍后重试
    • 。。。。
  2. 只想捕获自己的异常,如果所有的异常都是ExtendRuntimeException,那别人写的异常也会被我捕获

所以互相比较来看,如果业务复杂,团队较大,需要管理的领域团队不同,就是用相对灵活一点的方案。
至于handleException方法,为什么还返回一个ErrorMessage。其实是为了翻译异常。有些内部异常的错误码只希望打印在控制台上,不透出给外部,另外异常控制中心不应该处理业务逻辑,也就是说异常一旦抵达这里,只能返回给前台了。

通常按照规范,一个合格的系统,所有的异常都应该是有预期的,也就是不会抛出我们不可预见的错误,预期中的错误,都应该有通用或特殊处理的逻辑来捕获处理,预期外的错误,遇到就需要立即解决,或在异常抛出位置进行转译,但不推荐这么做。

另外,最好只在必须要抛异常来触发某些操作时抛异常,否则尽量减少抛异常的次数和抛异常的可能,因为我们知道Java的方法调用其实是压栈,方法调用结束就会弹栈,如果在方法调用中出现异常,它会记录当前栈的信息,回到上一层,上一层没有捕获继续往上走,每一次弹出栈都会记录一次信息,属于昂贵消耗,尤其我们在Spring项目中,Spring代码调用层次很深,并且在Spring内部不会为你处理异常信息,所以通常spring项目报错,我们会看到很长很长的异常栈信息,新手甚至找不到这个错误信息是由哪个地方引起的。

ps:理论上我们不应该使用异常来控制业务逻辑,另外异常处理会挂载堆栈信息,调用栈越深,效率越低。但有时系统过于复杂时,为了减少系统复杂度,用异常来中断后续操作也不失为一种优雅的方案