首页 > 代码库 > 编写高质量代码:改善Java程序的151个建议(第8章:异常___建议110~113)
编写高质量代码:改善Java程序的151个建议(第8章:异常___建议110~113)
不管人类的思维有多么缜密,也存在" 智者千虑必有一失 "的缺憾。无论计算机技术怎么发展,也不可能穷尽所有的场景___这个世界是不完美的,也是有缺陷的。完美的世界只存在于理想中。
对于软件帝国的缔造者来说,程序也是不完美的,异常情况会随时出现,我们需要它为我们描述例外事件,需要它处理非预期的情景,需要它帮我们建立“完美世界”。
建议110:提倡异常封装
Java语言的异常处理机制可以去确保程序的健壮性,提高系统的可用率,但是Java API提供的异常都是比较低级的(这里的低级是指 " 低级别的 " 异常),只有开发人员才能看的懂,才明白发生了什么问题。而对于终端用户来说,这些异常基本上就是天书,与业务无关,是纯计算机语言的描述,那该怎么办?这就需要我们对异常进行封装了。异常封装有三方面的优点:
(1)、提高系统的友好性
例如,打开一个文件,如果文件不存在,则回报FileNotFoundException异常,如果该方法的编写者不做任何处理,直接抛到上层,则会降低系统的友好性,代码如下所示:
public static void doStuff() throws FileNotFoundException { InputStream is = new FileInputStream("无效文件.txt"); /* 文件操作 */ }
此时doStuff的友好性极差,出现异常时(如果文件不存在),该方法直接把FileNotFoundException异常抛到上层应用中(或者是最终用户),而上层应用(或用户要么自己处理),要么接着抛,最终的结果就是让用户面对着" 天书 " 式的文字发呆,用户不知道这是什么问题,只是知道系统告诉他" 哦,我出错了,什么错误?你自己看着办吧 "。
解决办法就是封装异常,可以把异常的阅读者分为两类:开发人员和用户。开发人员查找问题,需要打印出堆栈信息,而用户则需要了解具体的业务原因,比如文件太大、不能同时编写文件等,代码如下:
public static void doStuff2() throws MyBussinessException{ try { InputStream is = new FileInputStream("无效文件.txt"); } catch (FileNotFoundException e) { //方便开发人员和维护人员而设置的异常信息 e.printStackTrace(); //抛出业务异常 throw new MyBussinessException(); } /* 文件操作 */ }
(2)、提高系统的可维护性
看如下代码:
public void doStuff3(){ try{ //doSomething }catch(Exception e){ e.printStackTrace(); } }
这是大家很容易犯的错误,抛出异常是吧?分类处理多麻烦,就写一个catch块来处理所有的异常吧,而且还信誓旦旦的说" JVM会打印出栈中的错误信息 ",虽然这没错,但是该信息只有开发人员自己看的懂,维护人员看到这段异常时基本上无法处理,因为需要到代码逻辑中去分析问题。
正确的做法是对异常进行分类处理,并进行封装输出,代码如下:
public void doStuff4(){ try{ //doSomething }catch(FileNotFoundException e){ log.info("文件未找到,使用默认配置文件...."); e.printStackTrace(); }catch(SecurityException e1){ log.info(" 无权访问,可能原因是......"); e1.printStackTrace(); } }
如此包装后,维护人员看到这样的异常就有了初步的判断,或者检查配置,或者初始化环境,不需要直接到代码层级去分析了。
(3)、解决Java异常机制自身的缺陷
Java中的异常一次只能抛出一个,比如doStuff方法有两个逻辑代码片段,如果在第一个逻辑片段中抛出异常,则第二个逻辑片段就不再执行了,也就无法抛出第二个异常了,现在的问题是:如何才能一次抛出两个(或多个)异常呢?
其实,使用自行封装的异常可以解决该问题,代码如下:
class MyException extends Exception { // 容纳所有的异常 private List<Throwable> causes = new ArrayList<Throwable>(); // 构造函数,传递一个异常列表 public MyException(List<? extends Throwable> _causes) { causes.addAll(_causes); } // 读取所有的异常 public List<Throwable> getExceptions() { return causes; }}
MyException异常只是一个异常容器,可以容纳多个异常,但它本身并不代表任何异常含义,它所解决的是一次抛出多个异常的问题,具体调用如下:
public void doStuff() throws MyException { List<Throwable> list = new ArrayList<Throwable>(); // 第一个逻辑片段 try { // Do Something } catch (Exception e) { list.add(e); } // 第二个逻辑片段 try { // Do Something } catch (Exception e) { list.add(e); } // 检查是否有必要抛出异常 if (list.size() > 0) { throw new MyException(list); } }
这样一来,DoStuff方法的调用者就可以一次获得多个异常了,也能够为用户提供完整的例外情况说明。可能有人会问:这种情况会出现吗?怎么回要求一个方法抛出多个异常呢?
绝对有可能出现,例如Web界面注册时,展现层依次把User对象传递到逻辑层,Register方法需要对各个Field进行校验并注册,例如用户名不能重复,密码必须符合密码策略等,不要出现用户第一次提交时系统显示" 用户名重复 ",在用户修改用户名再次提交后,系统又提示" 密码长度小于6位 " 的情况,这种操作模式下的用户体验非常糟糕,最好的解决办法就是异常封装,建立异常容器,一次性地对User对象进行校验,然后返回所有的异常。
建议111:采用异常链传递异常
设计模式中有一个模式叫做责任链模式(Chain of Responsibility) ,它的目的是将多个对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止,异常的传递处理也应该采用责任链模式。
上一建议中我们提出了异常需要封装,但仅仅封装还是不够的,还需要传递异常。我们知道,一个系统友好性的标志是用户对该系统的" 粘性",粘性越高,系统越友好,粘性越低系统友好性越差,那问题是怎么提高系统的“粘性”呢?友好的界面和功能是一个方面,另外一个方面就是系统出现非预期情况的处理方式了。
比如我们的JavaEE项目一般都有三层结构:持久层,逻辑层,展现层,持久层负责与数据库交互,逻辑层负责业务逻辑的实现,展现层负责UI数据库的处理,有这样一个模块:用户第一次访问的时候,需要从持久层user.xml中读取信息,如果该文件不存在则提示用户创建之,那问题来了:如果我们直接把持久层的异常FileNotFoundException抛弃掉,逻辑层根本无法得知发生了何事,也就不能为展现层提供一个友好的处理结果了,最终倒霉的就是发展层:没有办法提供异常信息,只能告诉用户说“出错了,我也不知道出什么错了”___毫无友好性可言。
正确的做法是先封装,然后传递,过程如下:
(1)、把FIleNotFoundException封装为MyException。
(2)、抛出到逻辑层,逻辑层根据异常代码(或者自定义的异常类型)确定后续处理逻辑,然后抛出到展现层。
(3)、展现层自行决定要展现什么,如果是管理员则可以展现低层级的异常,如果是普通用户则展示封装后的异常。
明白了异常为什么要传递,那接着的问题就是如何传递了。很简单,使用异常链进行异常的传递,我们以IOException为例来看看是如何传递的,代码如下:
public class IOException extends Exception { public IOException() { super(); } //定义异常原因 public IOException(String message) { super(message); } //定义异常原因,并携带原始异常 public IOException(String message, Throwable cause) { super(message, cause); } //保留原始异常信息 public IOException(Throwable cause) { super(cause); }}
在IOException的构造函数中,上一个层级的异常可以通过异常链进行传递,链中传递异常的代码如下所示:
try{ //doSomething }catch(Exception e){ throw new IOException(e); }
捕捉到Exception异常,然后把它转化为IOException异常并抛出(此种方式也叫作异常转译),调用者获得该异常后再调用getCause方法即可获得Exception的异常信息,如此即可方便地查找到产生异常的基本信息,便于解决问题。
结合上一建议来说,异常需要封装和传递,我们在进行系统开发时不要" 吞噬 " 异常,也不要赤裸裸的抛出异常,封装后再抛出,或者通过异常链传递,可以达到系统更健壮,更友好的目的。
建议112:受检异常尽可能转化为非受检异常
为什么说是" 尽可能"的转化呢?因为" 把所有的受检异常(Checked Exception)"都转化为非受检异常(Unchecked Exception)" 这一想法是不现实的:受检异常是正常逻辑的一种补偿手段,特别是对可靠性要求比较高的系统来说,在某些条件下必须抛出受检异常以便由程序进行补偿处理,也就是说受检异常有合理存在的理由,那为什么要把受检异常转化为非受检异常呢?难道说受检异常有什么缺陷或者不足吗?是的,受检异常确实有不足的地方:
(1)、受检异常使接口声明脆弱
OOP(Object Oriented Programming,面向对象程序设计) 要求我们尽量多地面向接口编程,可以提高代码的扩展性、稳定性等,但是涉及异常问题就不一样了,例如系统初期是这样设计一个接口的:
interface User{ //修改用户密码,抛出安全异常 public void changePassword() throws MySecurityException;}
随着系统的开发,User接口有了多个实现者,比如普通的用户UserImpl、模拟用户MockUserImpl(用作测试或系统管理)、非实体用户NonUserImpl(如自动执行机,逻辑处理器等),此时如果发现changePassword方法可能还需要抛出RejectChangeException(拒绝修改异常,如自动执行正在处理的任务时不能修改其代码),那就需要修改User接口了:changePassword方法增加抛出RejectChangeException异常,这会导致所有的User调用者都要追加了对RejectChangeException异常问题的处理。
这里产生了两个问题:一、 异常是主逻辑的补充逻辑,修改一个补充逻辑,就会导致主逻辑也被修改,也就是出现了实现类 " 逆影响 " 接口的情景,我们知道实现类是不稳定的,而接口是稳定的,一旦定义了异常,则增加了接口的不稳定性,这是面向对象设计的严重亵渎;二、实现的变更最终会影响到调用者,破坏了封装性,这也是迪米特法则所不能容忍的。
(2)、受检异常使代码的可读性降低
一个方法增加可受检异常,则必须有一个调用者对异常进行处理,比如无受检异常方法doStuff是这样调用的:
public static void main(String[] args) { doStuff(); }
doStuff方法一旦增加受检异常就不一样了,代码如下:
public static void main(String[] args) { try{ doStuff(); }catch(Exception e){ e.printStackTrace(); } }
doStuff方法增加了throws Exception,调用者就必须至少增加4条语句来处理该异常,代码膨胀许多,可读性也降低了,特别是在多个异常需要捕捉的情况下,多个catch块多个异常处理,而且还可能在catch块中再次抛出异常,这大大降低了代码的可读性。
(3)、受检异常增加了开发工作量
我们知道,异常需要封装和传递,只有封装才能让异常更容易理解,上层模块才能更好的处理,可这会导致低层级的异常没玩没了的封装,无端加重了开发的工作量。比如FileNotFoundException进行封装,并抛出到上一个层级,于是增加了开发工作量。
受检异常有这么多的缺点,那有没有什么方法可以避免或减少这些缺点呢?有,很简单的一个规则:将受检异常转化为非受检异常即可,但是我们也不能把所有的受检异常转化为非受检异常,原因是在编码期上层模块不知道下层模块会抛出何种非受检异常,只有通过规则或文档来描述,可以这样说:
- 受检异常提出的是" 法律下的自由 ",必须遵守异常的约定才能自由编写代码。
- 非受检异常则是“ 协约性质的自由 ”,你必须告诉我你要抛什么异常,否则不会处理。
以User接口为例,我们在声明接口时不再声明异常,而是在具体实现时根据不同的情况产生不同的非受检异常,这样持久层和逻辑层抛出的异常将会由展现自行决定如何展示,不再受异常的规则约束了,大大简化开发工作,提高了代码的可读性。
那问题又来了,在开发和设计时什么样的受检异常有必要化为非受检异常呢?" 尽可能 " 是以什么作为判断依据呢?受检异常转换为非受检异常是需要根据项目的场景来决定的,例如同样是刷卡,员工拿着自己的工卡到考勤机上打考勤,此时如果附近有磁性物质干扰,则考勤机可以把这种受检异常转化为非受检异常,黄灯闪烁后不做任何记录登记,因为考勤失败这种情景不是" 致命 "的业务逻辑,出错了,重新刷一下即可。但是到银行网点取钱就不一样了,拿着银行卡到银行取钱,同样有磁性物质干扰,刷不出来,那这种异常就必须登记处理,否则会成为威胁银行卡安全的事件。汇总成一句话:当受检异常威胁到了系统的安全性,稳定性,可靠性、正确性时,则必须处理,不能转化为非受检异常,其它情况则可以转化为非受检异常。
注意:受检异常威胁到系统的安全性,稳定性、可靠性、正确性时,不能转换为非受检异常。
建议113:不要在finally块中处理返回值
在finally代码块中处理返回值,这是考试和面试中经常出现的题目。虽然可以以此来出考试题,但在项目中绝对不能再finally代码块中出现return语句,这是因为这种处理方式非常容易产生" 误解 ",会误导开发者。例如如下代码:
public class Client113 { public static void main(String[] args) { try { System.out.println(doStuff(-1)); System.out.println(doStuff(100)); } catch (Exception e) { System.out.println("这里是永远不会到达的"); } } //该方法抛出受检异常 public static int doStuff(int _p) throws Exception { try { if (_p < 0) { throw new DataFormatException(" 数据格式错误 "); } else { return _p; } } catch (Exception e) { // 异常处理 throw e; } finally { return -1; } }}
对于这段代码,有两个问题:main方法中的doStuff方法的返回值是什么?doStuff方法永远都不会抛出异常吗?
答案是:doStuff(-1)的值是-1,doStuff(100)的值也是-1,调用doStuff方法永远都不会抛出异常,有这么神奇?原因就是我们在finally代码块中加入了return语句,而这会导致出现以下两个问题:
(1)、覆盖了try代码块中的return返回值
当执行doStuff(-1)时,doStuff方法产生了DataFormatException异常,catch块在捕捉此异常后直接抛出,之后代码执行到finally代码块,就会重置返回值,结果就是-1了。也就是出现先返回,再重置返回的情况。
有人可能会思考,是不是可以定义变量,在finally中修改后return呢?代码如下:
public static int doStuff() { int a = 1; try { return a; } catch (Exception e) { } finally { // 重新修改一下返回值 a = -1; } return 0; }
该方法的返回值永远是1,不会是-1或0(为什么不会执行到" return 0 " 呢?原因是finally执行完毕后该方法已经有返回值了,后续代码就不会再执行了),这都是源于异常代码块的处理方式,在代码中try代码块就标志着运行时会有一个Throwale线程监视着该方法的运行,若出现异常,则交由异常逻辑处理。
我们知道方法是在栈内存中运行的,并且会按照“ 先进后出 ”的原则执行,main方法调用了doStuff方法,则main方法在下层,doStuff方法在上层,当doStuff方法执行完" return a " 时,此方法的返回值已经确定int类型1(a变量的值,注意基本类型都是拷贝值,而不是引用),此时finally代码块再修改a的值已经与doStuff返回者没有任何关系了,因此该方法永远都会返回1.
继续追问,那是不是可以在finally代码块中修改引用类型的属性以达到修改返回值的效果呢?代码如下:
class Person { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; }}
public static Person doStuffw() { Person person = new Person(); person.setName("张三"); try { return person; } catch (Exception e) { } finally { // 重新修改一下值 person.setName("李四"); } person.setName("王五"); return person; }
此方法的返回值永远都是name为李四的Person对象,原因是Person是一个引用对象,在try代码块中的返回值是Person对象的地址,finally中再修改那当然会是李四了。
(2)、屏蔽异常
为什么明明把异常throw出去了,但main方法却捕捉不到呢?这是因为异常线程在监视到有异常发生时,就会登记当前的异常类型为DataFormatException,但是当执行器执行finally代码块时,则会重新为doStuff方法赋值,也就是告诉调用者" 该方法执行正确,没有产生异常,返回值为1 ",于是乎,异常神奇的消失了,其简化代码如下所示:
public static void doSomeThing(){ try{ //正常抛出异常 throw new RuntimeException(); }finally{ //告诉JVM:该方法正常返回 return; } }
public static void main(String[] args) { try { doSomeThing(); } catch (RuntimeException e) { System.out.println("这里是永远不会到达的"); } }
上面finally代码块中的return已经告诉JVM:doSomething方法正常执行结束,没有异常,所以main方法就不可能获得任何异常信息了。这样的代码会使可读性大大降低,读者很难理解作者的意图,增加了修改的难度。
在finally中处理return返回值,代码看上去很完美,都符合逻辑,但是执行起来就会产生逻辑错误,最重要的一点是finally是用来做异常的收尾处理的,一旦加上了return语句就会让程序的复杂度徒然上升,而且会产生一些隐蔽性非常高的错误。
与return语句相似,System.exit(0)或RunTime.getRunTime().exit(0)出现在异常代码块中也会产生非常多的错误假象,增加代码的复杂性,大家有兴趣可以自行研究一下。
注意:不要在finally代码块中出现return语句。
编写高质量代码:改善Java程序的151个建议(第8章:异常___建议110~113)