异常处理是我们日常开发中关注比较少的一块,虽然很多时候并不起眼,但是如果处理不当,很容易使精心设计的程序变得不堪一击。通过学习软件强健度等级划分的概念及常用的异常处理方法,能让我们可以根据用户不同的需求实现不同程度的异常处理,使得系统结构更清晰,代码更加简洁,软件功能更加健壮。
写在前面
这篇文章探讨的主题是异常处理中的等级划分及异常处理的重构方法,具体参考了陈建村所著的《笑谈软件工程:异常处理的设计与重构》,同时也借鉴了《clean code》对异常处理的部分内容。我对书中体会较深的部分列举出来,算是读书笔记与体会。
为什么要谈谈异常处理
作为一名软件开发人员,从小到大你可能学过各式各样的软件设计与方法,从最基础的程序语言、数据结构与算法,到面向对象分析与设计、设计模式、软件架构以及各种敏捷开发实践,包含自动化测试、测试驱动开发、持续集成和敏捷设计原则等。以上,所有的“大师”费尽心力,都在告诉你“如何设计软件的光明面”。
但是,世界是对立的,有光明就有黑暗,黑暗面就是“异常行为”(abnormal behavior),软件设计忽略任何一方,都可能让原本精心规划的设计变得不堪一击。
强健度等级与异常处理策略
这里定义了软件的强健度等级,总共分为四个等级。
等级0:未定义
未定义(undefined)表示当某个服务发生错误的时候,可能会让调用者指导错误发生,也有可能会假装没事。也就是说,使用该服务的人无法确切知道它是否成功达成任务。当错误发生的时候,服务处于不明或是错误状态。异常发生时系统可能会终止,也可能继续执行。
等级1:错误报告
错误报告(error-reorting)代表当某个服务发生错误的时候,一定要让调用者知道,绝对不能假装没事。要达到错误报告强健度等级很简单,就是把所有的异常都往外丢,然后在主程序(整个系统最外层的那个程序)捕捉所有的异常并报告给使用者或开发人员知道。错误报告又称为“早死早投胎”。
等级2:状态恢复
状态恢复除了要求等级1错误报告以外,还要求当错误发生之后,服务必须保证系统仍然处于正确状态。由于整个系统的状态还是正确的,因此,当异常发生之后,系统仍可以执行。
要达到这个等级,必须要多做两件事情。首先是错误处理(error handling),让系统恢复到一个正确的状态。假设有一个服务修改了数据库内容,当异常发生时就要执行回滚(roolback)动作。其次是释放资源(cleanup)。例如把要来的内存、文件、数据库联机等资源释放。状态恢复又称为弱容错(weakly tolerant)。
等级3:行为恢复
这个等级核心就是服务的使命必达。因此,当某个服务执行失败的时候,便要想办法排除困难,总之就是要达成原本被赋予的任务。当发生错误的时候,除了达成等级2的状态恢复之外,还需要“想其他方法来达成原本的任务”,如重试与设计多样性、功能多样性、数据多样性、时序多样性等设计技巧,尝试继续提供服务。
强健度等级的推广
- 首先,在团队中对团队成员倡导强健度等级观念。没错,只需要花一两个小时,因为观念本身很简单,不需要长时间训练便可了解。
- 在没有特殊情况下,默认所有函数一定达到强健度等级1。乍看起来,等级1好像“很不负责任”把所有异常都往外丢,但是如此一来,反而可在开发阶段及时发现问题并加以修复。将问题暴露之后,便有足够的情境可以决定该异常的处理方式,是否将等级1升级到更高的等级。
- 对于特定函数而言(如数据库处理),因为数据库错误会给使用者造成很大的困扰,加上数据库本身有事务功能,因此默认应该达到强健度2等级。
- 除非客户特别要求或是不达到等级3会使系统变得很难用(例如网络数据传递,如果不能自动保证数据可以完整无误地传递到远程,系统将变得很难用),否则,不会特别要求函数做到行为恢复。
异常处理坏味道与重构方案
- 用异常代替错误码
// 坏味道
public synchronized int withdraw(int amount){
if(amount > this.balance){
return -1;
} else {
this.balance = this.balance - amount;
return this.balance;
}
}
// 重构后代码
public synchronized int withdraw(int amount) throws NotEnoughMoneyException {
if(amount > this.balance){
throw new NotEnoughMoneyException();
}
this.balance = this.balance - amount;
return this.balance;
}
动机:以传回值来代表错误状况,会让软件组件连强健度等级1(错误报告)的标准都达不到,因为调用者通常倾向忽略传回值得检查,所以也忽略了错误状况。因此,若采用异常来取代错误码,可以使软件达到强健度1。
- 以未查异常代替忽略已查异常/空的处理程序
// 坏味道
catch(IOException e){
/* ignoring the exception */
e.printStackTrace();
}
// 重构后代码
catch(IOException e){
throw new UnhandledException(e);
}
动机:常用的IDE如eclipse对于Checked异常的处理就是选择忽略:
catch(IOException e) {
/* TODO */
e.printStackTrace();
};
虽然写了TODO注释来提醒自己要“抽空”回来再续这段孽缘,但是注释本身没有什么约束力,绝大多数的时候会选择遗忘,连强健度1的要求都达不到。因此,与其捕捉这一类已查异常并忽略它,不如在捕获后转抛一个未查异常。这时候,你就可以专心处理正常逻辑,而暂时不被这一类已查异常干扰。
- 使用最外层try语句避免意外终止
// 坏味道
static public void main(String[] args){
/*
* 做一大堆事情的主程序
*/
}
// 重构后代码
try{
/*
* 做一大堆事情的主程序
*/
} catch(Throwable e){
logger.log(e);
dialog.show(Dialog.CRITICAL, e.getMessage());
}
动机:按照强健度1要求,所有异常都往外丢,未被捕捉的异常最终都会传递到主程序(或线程)上。要是主程序也没有捕捉这些异常,整个应用程序就会被迫终止,这就是大家非常熟悉的老朋友“程序当掉”。
因此,为了避免应用程序不预期地终止,将最外层程序代码以一个try语句包住,捕捉所有异常类,在页面上显示清楚且容易理解的错误信息,并视需要记录详细出错信息到日志文件中,最后结束应用程序的执行,让它“死得好看一点”。
- 以函数取代嵌套的try语句
// 坏味道
finally{
try{
if(in != null) in.close();
} catch(IOException e){
// log the exception
}
}
// 重构后代码
finally {
cleanup(in);
}
动机:嵌套的循环使得代码结构复杂,难于理解与维护。
- 引入Checkpoint类
// 原代码
public void moveFiles(String srcFolder, String destFolder) throws IOException {
try {
// 复制srcFolder 所有文件到 destFolder
// 可能发生 IOException
} finally {
// 释放资源
}
}
// 重构后代码
public void moveFiles(String srcFolder, String destFolder) throws IOException {
FolderCheckpoint fscp = null;
try {
fscp = new FolderCheckpoint();
fscp.establish(srcFolder);
// 复制 srcFolder 所有档案到 destFolder,
// 可能会发生 IOException
} catch(Exception e) {
fscp.restore();
throw e;
} finally {
fscp.drop();
// 释放资源
}
}
动机:为了达到强健度2的要求,在程序发生异常的时候能使系统状态恢复到正常的状态并持续运作下去,可使用checkpoint方法。checkpoint就是“快照”(snapshot)的观念,一般有三个基本函数,分别是产生检查点establish,恢复数据的restore以及丢弃检查点的drop。
- 引入多才多艺的try块
// 坏味道
public User readUser(String name) throws ReadUserException {
try {
} catch(Exception e){
try {
return readFromLDAP(name);
} catch(IOException ex) {
throw new ReadUserException(ex);
}
}
}
// 重构后代码
public User readUser(String name) throws ReadUserException {
final int maxAttempt = 3;
int attempt = 1;
while(true) {
try {
if(attempt <= 2) return readFromDatabase(name);
else return readFromLDAP(name);
} catch(Exception e) {
if(++attempt > maxAttempt)
throw new ReadUserExcetption(e);
}
}
}
动机:为了达到强健度3的要求,重试也是一种很常见也很有用的异常处理策略。但很多人将catch块当成try块的“备胎”,等同于在catch块中执行重试。这种做法的主要缺点在于“只能重试一次”,如果要多次重试,势必会写出具备嵌套try语句(Nested Try Statement)的程序,者又是另外一个异常处理坏味道。
因此,重新思考try块与catch块的责任分工,让try块负责实现程序正常逻辑(包含主要方案与替代方案),将“备胎(替代方案)”从catch块移到try块,让catch块负责控制重试终止条件与错误报告。
总结
市面上关于异常处理的书籍不多,《异常处理的设计与重构》算是凤毛麟角中的一本。除了上面讲到的强健度等级划分和异常处理重构,书中还讲了异常处理的基本观念及java的异常处理机制等内容。这里我对书中最实用的章节进行了整理与归纳,同学可根据兴趣阅读其他章节,会对上述异常重构的方法有更加深刻的认识。
网友评论