概要
Java 提供了一个丰富的异常处理框架,但是许多程序员发现:跳过这个丰富的异常处理框架只使用类属Exceptions要容易得多。本文探讨了产生、捕捉和忽视类属Exceptions的风险,并为处理复杂软件项目内的一般的复杂异常建议了最好的处理方法。
在最近的一个项目中,有一块代码实现源代码的清除。因为它有许多不同的调用,很有可能会产生六个不同的异常。原来的程序员在试图简化代码(或者保存键入程序)之后宣告:该程序还不止产生6个不同的异常。这就使得代码调用必须包装在一个可以捕捉Exception 的try/catch 块中。该程序员确定:因为该代码用于清除源代码,失败并不重要,所以catch 块为空,就好像系统关闭了一样。
很明显,这不是最好的编程解决方法,但是好像也看不出有什么大的错误,除了在源代码的第三行有一个小小的逻辑问题:
Listing 1. Original cleanup code
private void cleanupConnections() throws ExceptionOne, ExceptionTwo {
for (int i = 0; i < connections.length; i++) {
connection[i].release(); // Throws ExceptionOne, ExceptionTwo
connection[i] = null;
}
connections = null;
}
protected abstract void cleanupFiles() throws ExceptionThree, ExceptionFour;
protected abstract void removeListeners() throws ExceptionFive, ExceptionSix;
public void cleanupEverything() throws Exception {
cleanupConnections();
cleanupFiles();
removeListeners();
}
public void done() {
try {
doStuff();
cleanupEverything();
doMoreStuff();
}
catch (Exception e) {}
}
在代码的另一个部分,connections 排列没有初始化,直到第一个连接创建为止。但是如果连接没有创见,那么连接排列则为null 。所以在某些情况下,对connections[i].release()的调用将产生NullPointerException。这也是一个相对容易的解决方法。只要添加一个对connections != null的检查。
但是,并没有报告异常。cleanupConnections()产生一个异常, cleanupEverything()又产生一个异常,最后异常被done()捕捉到。done()方法对一场不做任何处理,甚至也不记录一下。因为 只能通过done()调用cleanupEverything() ,所以看不到异常。因此代码也没有得到解决。
因此,在这个失败案例中,没有调用cleanupFiles()和removeListeners() 方法(所以它们的资源没有释放),也没有调用doMoreStuff(),这样done()中的最终处理没有完成。更糟糕的是,系统关闭时没有调用done();相反的,程序只调用done()来完成每个事务。所以每个事务中的资源都漏掉了。
这个问题无疑是一个主要的问题:错误没有报告,资源遗漏。但是代码本身似乎毫无问题,并且从代码的编写方式看,这个问题难于回溯。但是,应用几个简单的指导方针,就可以发现并解决问题:
· 不忽视异常
· 不捕捉类属Exception
· 不产生类属Exception
不忽视异常
Listing 1的代码中最明显的一个问题是:程序中的错误完全被忽视。产生了非预期的异常(异常本质上就是非预期的),而带抹布准备处理该异常。一场甚至没有报告因为代码假设:预期的异常不会出现。
在多数情况下,异常至少应该记录。几个记录包(见补充栏的"Logging Exceptions") 可以记录系统错误和异常,而且丝毫不影响系统的性能。大多数的记录系统也允许打印堆栈路径,以便提供关于异常出现的地点和原因的有价值的信息。最后,因为记录通常都写入文件内,我们可以回顾和分析异常的纪录。要查看记录堆栈路径的范例,见补充栏的Listing 11 。
记录异常在某些特殊情况下并不重要。如在finally 子句就是这样。
finally 子句中的异常
在 Listing 2中,有些数据是从文件中读取的。无论异常是否读取数据文件都需要关闭,所以close()方法可包装在finally子句中。但是如果异常关闭了文件,我们也拿它没办法:
Listing 2
public void loadFile(String fileName) throws IOException {
InputStream in = null;
try {
in = new FileInputStream(fileName);
readSomeData(in);
}
finally {
if (in != null) {
try {
in.close();
}
catch(IOException ioe) {
// Ignored
}
}
}
}
注意loadFile()仍然报告IOException给调用方法,只要实际数据装载失败的原因在于 I/O (输入/输出)的问题。也请注意:即使忽视close()的异常,代码仍然会使用注释将它明确的标出来,以便让从事该代码的人都清楚这件事。你可以应用同样的程序来清除所有的I/O 信息流、关闭插件和JDBC连接等等。
忽视异常的一个重要原因是保证只有一个方法被包裹在忽视的try/catch块内(这样封装块内的其他方法仍然可以调用)而且只捕捉到某个特殊的异常。这种特殊情况本质上区别于类属Exception的捕捉。 在其他情况下,一场应该(至少)记录,最好是使用堆栈路径记录。
不捕捉类属Exception
通常在一个复杂的软件内,给定的源代码执行的方法将产生一系列异常。动态装载类和初始化对象将产生几种不同的异常,包括ClassNotFoundException、InstantiationException, IllegalAccessException和ClassCastException。
忙碌的程序员往往不添加四个不同的catch块到try 块上,而是将方法调用包裹在可以捕捉类属Exception的try/catch块内 (如下Listing 3所示)。尽管这看起来没什么不好,但是还是会产生一些意想不到的负面影响。例如:如果 className()为 null, Class.forName()将会给出NullPointerException,这个异常可以捕捉到。
在这种情况下,块将会捕捉它并不想捕捉的异常,因为NullPointerException 是RuntimeException 的子集,而RuntimeException相应的又是 Exception的子集。这样类属catch (Exception e)将捕捉RuntimeException的所有子集,包括NullPointerException、 IndexOutOfBoundsException、和ArrayStoreException。而实际上,程序员并不愿意捕捉那些异常的。
在Listing 3,null className 将导致NullPointerException,它指出调用方法的类名无效:
Listing 3
public SomeInterface buildInstance(String className) {
SomeInterface impl = null;
try {
Class clazz = Class.forName(className);
impl = (SomeInterface)clazz.newInstance();
}
catch (Exception e) {
log.error("Error creating class: " + className);
}
return impl;
}
类属catch 子句的另一个后果是记录受到限制因为catch 并不知道被捕捉的特殊异常。有些程序员在面对这种问题的时候,总是添加一个查看异常类型的检查(如Listing 4),这是与使用catch块的初衷相违背的:
Listing 4
catch (Exception e) {
if (e instanceof ClassNotFoundException) {
log.error("Invalid class name: " + className + ", " + e.toString());
}
else {
log.error("Cannot create class: " + className + ", " + e.toString());
}
}
Listing 5提供了一个特例,可能有些程序员会感兴趣。他不需要 instanceof操作器因为它可以捕捉到特殊异常。每一个检查过的异常(ClassNotFoundException、 InstantiationException、 IllegalAccessException)都被捕捉并得到处理。产生ClassCastException (类完全装载,但是并不执行SomeInterface 接口)的特殊情况也可以通过检查该异常得以校验。
Listing 5
public SomeInterface buildInstance(String className) {
SomeInterface impl = null;
try {
Class clazz = Class.forName(className);
impl = (SomeInterface)clazz.newInstance();
}
catch (ClassNotFoundException e) {
log.error("Invalid class name: " + className + ", " + e.toString());
}
catch (InstantiationException e) {
log.error("Cannot create class: " + className + ", " + e.toString());
}
catch (IllegalAccessException e) {
log.error("Cannot create class: " + className + ", " + e.toString());
}
catch (ClassCastException e) {
log.error("Invalid class type, " + className
+ " does not implement " + SomeInterface.class.getName());
}
return impl;
}
在某些情况下,再次给出已知异常(或者创建新异常)比在方法中处理该异常更可取。这就使得调用方法可通过将异常放入一个已知的上下文中来处理异常。
以下的Listing 6提供了buildInterface()方法的备选版本,在装载和初始化类的过程中如果出现问题它就会给出 ClassNotFoundException。在本例中,调用方法确实收到一个恰当的初始化的对象或者异常。因此,调用方法不需要检查返回对象是否为空。
注意:此例使用了Java 1.4 方法:创建一个新的异常,它包裹在另一个异常之内,以便保存原始的堆栈路径信息。否则,堆栈路径显示方法buildInstance()创建了异常,而实际上应该是newInstance()产生了潜在的异常:
Listing 6
public SomeInterface buildInstance(String className)
throws ClassNotFoundException {
try {
Class clazz = Class.forName(className);
return (SomeInterface)clazz.newInstance();
}
catch (ClassNotFoundException e) {
log.error("Invalid class name: " + className + ", " + e.toString());
throw e;
}
catch (InstantiationException e) {
throw new ClassNotFoundException("Cannot create class: " + className, e);
}
catch (IllegalAccessException e) {
throw new ClassNotFoundException("Cannot create class: " + className, e);
}
catch (ClassCastException e) {
throw new ClassNotFoundException(className
+ " does not implement " + SomeInterface.class.getName(), e);
}
}
在某些情况下,代码可恢复某些错误条件。在这种情况下,捕捉特殊异常很重要,这样代码才能计算出条件是否可以恢复。运用这种思想分析一下Listing 6 中的类范例。
在Listing 7中,代码返回一个用于无效className 的默认对象,但是给出了一个用于非法操作的异常,像无效数据类型转换或者安全违背。
注意: IllegalClassException 是一个域异常类,这里提到它只是为了演示。
Listing 7
public SomeInterface buildInstance(String className)
throws IllegalClassException {
SomeInterface impl = null;
try {
Class clazz = Class.forName(className);
return (SomeInterface)clazz.newInstance();
}
catch (ClassNotFoundException e) {
log.warn("Invalid class name: " + className + ", using default");
}
catch (InstantiationException e) {
log.warn("Invalid class name: " + className + ", using default");
}
catch (IllegalAccessException e) {
throw new IllegalClassException("Cannot create class: " + className, e);
}
catch (ClassCastException e) {
throw new IllegalClassException(className
+ " does not implement " + SomeInterface.class.getName(), e);
}
if (impl == null) {
impl = new DefaultImplemantation();
}
return impl;
}
何时应该捕捉类属Exception
当捕捉类属Exception很方便而且有必要的时候就可以捕捉类属Exception。这些情况都是很特殊的,但是它对于大型的容错系统来说很重要。在 Listing 8中,请求总是依次从请求队列中读取和处理。但是如果请求处理过程中出现任何异常的话(要么是BadRequestException 要么是RuntimeException的任何一个子类,包括NullPointerException),那么循环时在处理之外可捕捉到该异常。所以任何错误都可使得处理循环停止,而且不能执行任何保存命令。这就表示请求处理过程中处理错误的方式不好:
Listing 8
public void processAllRequests() {
Request req = null;
try {
while (true) {
req = getNextRequest();
if (req != null) {
processRequest(req); // throws BadRequestException
}
else {
// Request queue is empty, must be done
break;
}
}
}
catch (BadRequestException e) {
log.error("Invalid request: " + req, e);
}
}
我们可以采用一个更好的方式来完成请求处理:对逻辑做两个完美的改变,如Listing 9。首先,将try/catch 快移到请求处理循环内。这样,处理循环内的任何错误都可捕捉到并得到处理,而且它们也不会导致循环破裂。因此,循环继续处理请求,即使单个请求处理失败也没关系。其次,修改try/catch块,使他捕捉类属Exception,这样任何异常都可在循环内捕捉到而且请求也可以继续进行:
Listing 9
public void processAllRequests() {
while (true) {
Request req = null;
try {
req = getNextRequest();
if (req != null) {
processRequest(req); // Throws BadRequestException
}
else {
// Request queue is empty, must be done
break;
}
}
catch (Exception e) {
log.error("Error processing request: " + req, e);
}
}
}
捕捉类属Exception听起来好像违背了这部分开始时所说的原则——的确这样。但是具体情况具体分析。在此例中,捕捉类属Exception是为了防止单个异常造成整个系统停止。在请求、事务或者事件在循环内处理的情况下,循环需要继续进行,即使处理过程中出现异常也要进行。
在Listing 9中,处理循环中的try/catch块可看作最高级别异常处理器,这个最高级别异常处理器需要捕捉和记录出现在该级别的代码上的任何异常。这样,一场就不会被忽视和丢失,但是异常并不打断需要处理的请求的剩余部分。
每一个大型的复杂的系统都有一个最高级别的异常处理器(也许每个子系统一个,取决于系统执行处理的方式)。最高级别的异常处理器无意解决产生异常的根本原因,但是它应该不必停止处理就能够捕捉和记录问题。这并不是说所有的异常都应该在这个级别给出。任何可在更低级别处理的异常,也就是说,当问题出现时哪里的逻辑了解更多的条件,就在哪里处理异常。但是如果异常在较低级别不能得到处理的话,继续前进并一路上给出异常。这样,所有那些不可恢复的错误只会在一个地方(最高级别的异常处理器)得到处理,而不是贯穿整个系统。
不给出类属Exception
Listing 1 中给出的整个问题在程序员决定给出来自cleanupEverything()方法的类属Exception时就开始了。当方法给出六种不同的异常时代码变得相当混乱:方法的宣言变得不可读,调用方法被迫捕捉六种不同的异常,见Listing 10:
Listing 10
public void cleanupEverything() throws
ExceptionOne, ExceptionTwo, ExceptionThree,
ExceptionFour, ExceptionFive, ExceptionSix {
cleanupConnections();
cleanupFiles();
removeListeners();
}
public void done() {
try {
doStuff();
cleanupEverything();
doMoreStuff();
}
catch (ExceptionOne e1) {
// Log e1
}
catch (ExceptionTwo e2) {
// Log e2
}
catch (ExceptionThree e3) {
// Log e3
}
catch (ExceptionFour e4) {
// Log e4
}
catch (ExceptionFive e5) {
// Log e5
}
catch (ExceptionSix e6) {
// Log e6
}
}
但是即使代码有点凌乱,它至少还是清楚的。使用特殊的异常可避免几个非常现实的问题:给出类属Exception隐藏了根本问题的细节,从而没有机会来处理真正的问题。而且,给出类属Exception强迫到该方法的任何调用要么捕捉类属 Exception (捕捉类属Exception 有问题,如前面讨论的一样)要么通过重新给出类属Exception传播问题。
一般来说,当方法宣布它正给出类属Exception ,它无非是下面两个原因中的一个:一种情况是方法调用了几种其他的可能给出许多不同异常的方法(像Mediator 或者Façade 设计模式)并且隐藏了异常条件的细节。该方法不是创建和给出域级别的异常(用来包裹低级别的异常),它只是简单的宣布它要给出异常而且不管结果。另一种情况是方法例示并给出了类属Exception (throw new Exception()) 因为程序员根本就没有思考要使用何种异常才能真正的表达他所处的情况。
这两种情况都只要稍微思考设计一下就可以解决:详细的域级别的异常真正应该如何给出? 这个设计包括简单的宣布方法要给出可能实际存在的异常。另一个方法是创建一个域级别的异常来包裹给出的异常并且宣布一下。在大多数情况下,方法给出的异常(或者异常组)应该尽可能的详细。详细的异常可提供有关错误条件的更多信息从而允许错误情况得到处理或者至少是得到详细记录。
类属Exception类为 checked exception,意思是对宣布它给出Exception 的方法的任何调用必须要么宣布它自己给出Exception ,要么包裹方法调用在能够捕捉类属Exception 的try/catch 块内。我前面已经解释过与该方法有关的问题。
谨慎使用类属Exception
本文探讨了处理类属Exception 的几个方面:它们不应该给出而且它们也不能被忽视,它们应该很少(只有在特殊情况下)被捕捉。它们不提供允许你有效处理它们的详细信息,也不提供允许你不再捕捉你不想捕捉的异常的详细信息。
异常是Java的一个强大的组成部分,只要使用正确,就能提高程序员的效率并缩短你的开发周期,特别是在测试和调试阶段。如果异常使用不正确,它们就会与你作对:隐藏你系统内的问题。注意你使用类属Exception的位置和方式。