最近在项目代码中,遇见异常滥用的情形,会带来什么样的后果呢?
1. 代码可读性变差,业务逻辑难以理解
异常流与业务状态流混在一起,无法从接口协议层面理解业务代码,只能深入到方法(Method)内部才能准确理解返回值的行为。
可看一下代码:
public UserProfile findByID(long user_id) {
Map<String, Object> cond = new HashMap<String, Object>();
cond.put("id", user_id);
UserProfile userInfo = null;
try {
userInfo = DBUtil.selecta(UserProfile.class, "user_info", cond);
} catch (Throwable e) {
log.error(e, "UserProfile findByID");
}
return userInfo;
}
DAO 层负责数据库的基本操作,该方法返回值为查询结果用户对象数据。代码强行抓了所有的异常,并以 null 返回,后来人无法确认 null 是代表该用户不存在还是出现异常。
2. 代码健壮性变差,异常信息被随意捕捉,甚至被吃掉
同样上述代码,首先抓了 Throwable 这个所有异常,包括 Error (后文会介绍异常体系)。代码内部隐藏了问题,只是打印了一行日志,并且让程序可以正常继续往后走,带来的不确定性和风险都很大,这也极大的影响代码的健壮。
3. 破坏架构的分层清晰,职责单一的原则,为系统扩展带来很大阻碍
随着系统的发展,往往会沉淀出一些平台系统,比如调用监控。会负责统一采集系统的各类信息,因为这样的错误异常处理,将很难统一分离出异常信息。
那我们在实际编写代码的如何正确考虑异常的使用呢?
首先了解下 Java 异常的设计初衷。
Exceptions are the customary way in Java to indicate to a calling method that an abnormal condition has occurred. 一个很重要的概念 —— 不正常情形。Java 异常旨在处理方法调用时不正常情形,但我们该如何理解 “不正常情形”?
看下图,JDK 给我们定义了以下异常体系:
根节点是 Throwable,代表 Java 内所有可以 Catch 的异常都继承此,向下有两类,Exception 和 Error,日常用到较多的都是 Exception,Error 一般留给 JDK 内部自己使用,比如内存溢出 OutOfMemoryError,这类严重的问题,应用进程什么都做不了,只能终止。用户抓住此类 Error,一般无法处理,尽快终止往往是最安全的方式,既然什么都干不了就没必要抓住了。Exception 是应用代码要重点关心的,其下又分为运行时异常 RuntimeException 和编译时异常,各自区分就不在详述了。
了解异常的结构后,接下来解决两个重要问题,何时抛异常和抛什么异常,何时抓异常和抓什么异常 何时会有异常抛出,总结起来有以下三个典型的场景:
1. 调用方(Client)破坏了协议
说白了就是调用方法时没有按照约定好的规范来传参数,典型的比如参数是个非空集合却传入了空值。这种破坏协议的还可以细分两类,一类是调用方从接口形式上不易觉察的规则但需要在出现时给调用方些强提示,带些信息上去,这时异常就是特别好的方式;另一类是调用方可以明确看到的规则,正常情况会正常处理协议,不会产生破坏,但可能因为 bug 导致破坏协议。
public void method(String[] args) {
int temperature = 0;
if (args.length > 0) {
try {
temperature = Integer.parseInt(args[0]);
}
catch(NumberFormatException e) {
throw new IllegalArgumentException(
"Must enter integer as first argument, args[0]="+args[0],e);
}
}
// 其他代码
}
要求传入整数,但转换数字时出错,此时可抛出特定异常并附上提示信息
2. (Method)知道有问题,但自己处理不了
这里 "有问题",可能是调用方破坏了协议,但是单独提出来是要从被调用方出发考虑,比如 Method 内部有读取文件操作,但发现文件并不存在
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("Must give filename as first arg.");
return;
}
FileInputStream in = null;
try {
in = new FileInputStream(args[0]);
}
catch (FileNotFoundException e) {
System.out.println("Can't find file: " + args[0]);
return;
}
// 其他代码
}
FileInputStream 在创建时抛出了 FileNotFoundException,显然出现该问题时 FileInputStream 是处理不了的。
3. 预料不到的情形
空指针异常、数组越界是这种典型的场景,一般是由于有代码分支被忽略
了解了何时会出现异常,但是需要抛出异常时是选择编译时异常还是运行时异常呢? 很多人可能会说,很简单啊,需要调用方 catch 的就编译时,否则运行时。问题来了,什么时候需要调用方 catch?
分析编译时和运行时对代码编写的影响,可以总结出来区分时考虑的点有:调用方能否处理、严重程度、出现的可能性。
调用方能处理 -> 编译时
调用方不能处理 -> 运行时
严重程度高 -> 运行时
出现可能性低 -> 运行时
本人细化了这个分类的考虑过程如下:
首先从调用方开始考虑,如果是调用方破坏了协议,则抛出运行时异常,这类异常一般出现可能性较低,调用方已知,所以没必要强制调用方抓此异常。
然后如果问题出现被调用方,无法正常执行完成工作,这时候考虑该问题调用方是否可以处理,如果能处理,比如文件找不到、网络超时,则抛出编译时异常,否则比如磁盘满,抛运行时异常
解决了何时抛异常和抛什么异常,接下来是调用这些有异常的代码时,何时 catch 和 catch 什么异常呢? 攻守不分离... 免不了俗,总结一下几点供大家探讨:
- 不要轻易抓 Throwable,图省事可能会带来巨大的隐患
try {
someMethod();
} catch (Throwable e) {
log.error("method has failed", e);
}
应该尽量只去抓关注的异常,明确 catch 的都是什么具体的异常
- 自己处理不了,不要抓
比如上文 DB 可能会有异常,在 DAO 层是处理不了这种问题的,交由上层处理。抓异常宜晚不宜早,抛异常宜早不宜迟。
- 切忌抓了,又把异常吞掉,不留下一丝痕迹
抓住异常,打行日志完事儿,不是一个好习惯。
- 切忌抓异常了将异常状态流和业务状态流混在一起,这样你算是彻底抛弃了 Java 的异常机制