异常
1. 异常分类
JDK提供的异常基础类关系如下:
+---------+ |Throwable| +-+------++ ^ ^ | | +--------++ +-+---+ |Exception| |Error| +----+----+ +-----+ ^ | +--------+-------+ |RuntimeException| +----------------+
异常分两种:
- 继承
Exception
的异常类称为checked exception,一个方法抛出 checked exception,就必须在方法签名中加上throws
声明,否则无法通过编译,这也是称为 “checked” 的原因。调用方如果选择继续向上抛出,则也应在自己的方法加上相应声明。 - 继承
RuntimeException
的异常类称为unchecked exception,编译器不会检查。
2. 什么时候抛出异常
《程序员修炼之道-从小工到专家》第四章介绍了一种被称为“Design By Contract(按合约设计)”的设计思路,它认为,程序中的每个函数在提供某项服务,在开始真正逻辑之前,函数对程序当前的状态有某种期望,函数对服务成功完成后程序的状态有某种承诺。这些期望和承诺可以这样描述:
- Precondition:为了调用函数,必须为真的条件,函数的需求。在 Precondition 无法被满足时,函数不应开始提供服务。Precondition 通常指的是参数的合法性,调用者有责任保证传递的参数的正确性;
- Postcondition:函数保证会做的事情,方法完成时程序的状态,一个use case的happy ending;
- Class Invariant(暂不讨论):类确保从调用者的视角来看,该条件始终为真。在函数执行过程中,不变项不一定会保持,但在函数退出时,不变项必须为真。
举个例子,一个方法向指定用户发送push,它接受两个参数,用户id和message,具体实现是:1)查询用户设置,如果用户关闭了push 则不发送;2)查询用户当天收到的push数,超过阈值则不发送;3)调用第三方API,根据id找到设备token;4)调用第三方API,对指定设备发送push。
对这个方法,id、message不为空这类参数格式约束条件属于 Precondition。方法成功执行后,要么由于用户的设置、收到push太多而选择不push,要么成功向用户发送push,这属于方法的 Postcondition。
函数和调用者之间的合约可以解读为:
如果调用者满足了函数的所有Precondition,那么函数在完成时,所有Postcondition和Class Invariant为真。
所以,什么时候抛异常?
当函数在开始真正业务逻辑之前发现Precondition不满足、业务逻辑进行过程中由于各种原因(业务规则冲突、第三方库调用失败、代码错误等)导致无法满足Postcondition时,抛异常。
以上述push方法为例,如果参数id或message为null,违反了Precondition,方法可以立即抛出IllegalArgumentException
而不进入业务逻辑。另一方面,导致方法无法满足Postcondition的原因有很多,比如用户不存在、数据库连接失败、第三方API调用失败、找不到用户的设备token等等,这些情况也应该抛出异常。虽然用户关闭push、接收push过多也会导致push发送失败,但这属于正常的业务逻辑范畴,不算异常情况。
这里有几个常见问题:
1.为什么不用异常码?
很多人喜欢返回bool表示函数是否成功,或在失败的时候返回null、特殊的异常码。这种方案的第一个问题是,调用方无法知道到底是什么原因导致失败的(返回bool/null);第二个问题是,调用方可以选择忽略异常码,但异常如果不处理则会沿调用栈上浮,到达最上层的统一异常处理器,或导致当前线程退出,从而实现fail-fast,系统不至于处在一个不稳定的、非正确的状态。此外,异常还可以将错误处理代码隔离到catch
块内,保持正常业务代码整洁。
2.参数校验谁做?
调用方有责任保证参数的合法性,但函数本身对调用方应该是防御性的,因此最完备的方案是进行两次校验,一次在调用方,一次在函数内。很多情况下这样显得很繁琐,所以如果函数是在一个可信、可控的环境中被调用的,比如调用方和函数都是你写的,那么函数内的参数校验可以省略;但假如你在写一个公共库,那么函数内的参数校验就是必须的。常见的参数校验库有Guava 的 Preconditions、Bean Validation 1.1 的实现 Hibernate Validator。
3.异常的情况有很多种,用一堆异常类还是一个异常类+各种ErrorCode?
我的答案是用一堆异常类,原因:
CashNotEnoughException
比ServiceException
+ErrorType.CASH_NOT_ENOUGH
更显式;- 不同的异常类可以传递不同的上下文和具体异常信息,如
CashNotEnoughException
可以捎带用户当前的cash,后者无法做到这一点。
3. 抛出什么类型的异常
OK,现在我们要抛异常了,但究竟抛 checked
还是 unchecked
异常呢?
checked的优点是:
- 将异常显式化,强制让caller考虑如何处理异常,不让caller有忘记处理异常的机会
- 可以将这种强制性沿调用栈向上传导。用unchecked,一个caller很难知道调用栈的哪个地方会抛什么异常。
缺点是污染方法签名(包括抛出异常的函数本身、propagate异常的调用方),增加后期维护成本。
主流观点是,可恢复的、业务类的异常用checked exception,不可恢复的异常用unchecked exception,如数据库连接失败、http调用超时。但实践中发现该规则存在的最大问题是,callee如何得知caller是否有能力从某种异常情况下恢复?调用方千变万化,可能A可以恢复B不行,函数本身无法了解这些情况。因此我的选择是,抛弃checked exception,全部使用unchecked exception,并标注在javadoc中,让caller自己决定哪些是自己感兴趣可以处理的异常,这是一种更温和的方式。况且,调用一个方法之前了解其可能抛出的异常,这也是一个合格的调用方应该做的。某些语言如C#、Python取消了checked exception这种设计,个人觉得也是有一定道理的。
4. 异常和日志
调用方对方法抛出的异常,要么处理,要么向上冒泡,为了让异常有迹可循,通常要记录在日志中。我的原则是,在异常被处理的地方打日志,这个地方可能是“真正”的catch并恢复,也可能是一个最外层的统一的异常拦截器,catch所有未被捕获的异常做统一处理。
参考文档
- 《Effective Java》Item 57- Item 65
- 《程序员修炼之道-从小工到专家》第四章
- 为什么 Java 中要使用 Checked Exceptions
- Checked or Unchecked Exceptions?
- Advantages of Exceptions