捕获和抛掷一般的异常会让你很快在不知不觉中陷入困境。 概要 Java提供了丰富的异常处理框架,但是尽管它很丰富和简单易用,却发现许多程序员很容易忽略它。这篇文章探究了抛掷、捕获和忽略普通异常的风险,并且提出在面对一个综合的大型软件项目时如何处理复杂异常的最佳方案。(2003年10月3日 By Paul Philion) 在最近的工作的一个软件项目中,我发现了一段处理资源清理工作的方法的代码。因为它有许多不同的调用形式,所以它会潜在的抛出6个不同的异常。(编写这段代码的)最初的程序员试图简化代码(或者是想节省输入),声明这个方法将抛出Exception异常,而不是那六个不同的异常。这使得调用代码被封装在捕获Exception的try/catch块中。他决定那样做是因为这些代码是为资源清理的目的,失败的情况并不重要,所以catch块为空而不管系统以何种方式关闭。 很明显,这不是最佳的编程实践。除了在原始代码第三行有一点逻辑问题之外,别的没有什么明显的错误。 程序清单1:原始的清理代码 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在第一个连接被建立时初始化。但是如果没有一个被建立,那么connections将是空的。所以在某种情况下,语句connections[i].release()的调用结果将会引发一个NullPointerException异常。这是一个相当容易修复的问题。仅仅添加一个检查语句“connections!=null”即可。 然而,有一个异常永远都不会被报告,它先被cleanupConnections()抛出,接着又被cleanupEverything()抛出,最后在done()中被捕获。done()方法的异常处理没有做任何事情,甚至没有日志记录。因为cleanupEverything()仅通过done()被调用,这个异常因此永远不会被看到。所以代码也永远不会被修复。 这样的话,假设现在cleanupConnections()执行失败,那么cleanupFiles()和removeListeners()方法永远都不会被调用(资源也就永远不会释放),并且doMoreStuff()也从不会被调用,这样在done()中的最后处理永远不会完成。为了使事情更糟,在系统关闭时done()不被调用,相反在完成每个事务时被调用,因而资源泄漏会出现在每个事务中。 问题很明显,主要是:错误没有被报告和资源泄漏。但是代码本身似乎没有错误,并且从代码编写方式中,这个问题增加了跟踪的难度。然而,通过应用一些简单的指导原则,这个问题就会被找出和修复: 不要忽略异常 不要捕获泛型的Exceptions 不要抛掷泛型Exceptions 不要忽略异常 在代码清单1中最明显的问题是在程序中的一个错误被完全忽略。一个非预期的异常(异常天生就是非预期的)被抛出,并且代码也没有打算处理那个异常。这个异常甚至没有被报告因为代码假设预期的异常并不重要。 在大多数情况下,一个异常至少应该被记录。有几个日志包(请看“Logging Exceptions”)可以记录系统错误和对系统没有重大影响的异常。 大多数日志系统也允许栈记录被打印,这样就提供了在哪和为什么会发生异常的有价值的信息。最后,因为一般情况下日志都被写到文件中,所以一条异常记录能被查看和分析。你可以看一下在“Logging Exceptions的代码清单11中有一个记录堆栈跟踪的例子。 在某些特殊情况下,日志异常并不是关键的,比如在finally子句中的资源清理是其中之一。 finally中的异常(Exceptions) 在下面的代码清单2中,需要从一个文件中读取数据。无论在读数据时是否发生异常文件都需要关闭,所以close()方法被放在finally子句中。但是如果在关闭文件时再发生错误,那就无能为力了。 代码清单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 } } }} 注意如果由于I/O的问题而使数据装入失败,那么对loadFile()方法的调用仍然会抛出一个IOException异常。另外一点,即使异常在close()中被忽略,代码的声明(在注释语句行)也使它对任何使用它的人清晰易读。你可以应用这种方式去清理任何I/O流、关闭socket和JDBC连接等等。 在忽略异常时关键是要确保在要忽略的try/catch块只有一个方法(这样别的方法才可能在其之外被调用)并且有一个指定的异常被捕获。这种特殊的情况与捕获一个普通的Exception有明显的不同。在所有其它的情况下,异常应该(至少)被记入日志,其内容用堆栈跟踪记录很合适。 不要捕获泛型异常 在复杂的软件中,经常会有一些特定的代码块执行时会抛出多种不同异常的方法。动态装入一个类和实例化一个对象都可能会产生几个不同的异常,包括ClassNotFoundException, InstantiationException, IllegalAccessException, 和 ClassCastException。 一个繁忙的程序员在遇到这种情况时可能简单的把方法调用包在一个只会捕获泛型异常Exception的try/catch块,而不是添加四个不同的catch块到try块后面(看下面的代码清单3)。这看起来似乎无可置否,却会产生一些无意识的副面效果。例如,如果className()是null,那么Class.forName()将会抛出一个NullPointerException异常并在这个方法中被捕获。在这种情况下,catch块将捕获此异常虽然它从没打算去捕获这样一个异常,只是因为NullPointerException是RuntimeException的一个子类,而且RuntimeException又是Exception的一个子类。所以一个普通的catch(Exception e)将会捕获所有RuntimeException的子类,包括NullPointerException, IndexOutOfBoundsException, 和ArrayStoreException。通常,一个程序员并不打算去捕获这些异常。 在代码清单3中,null的className会导致一个NullPointerException异常产生,它告诉在调用的方法中类名无效。 代码清单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不知道到底那一个特殊的异常被捕获。有些程序员在面对这种问题的时候,采取添加检测的手段去查看异常的类型(代码清单4),而这正好与使用catch块的目的相背离。 代码清单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()); } } 代码清单5提供一种完整的捕获特殊异常的例子,一些程序员可能会对它感趣。操作符instanceof不是必须的因为这个特殊的异常自会被捕获。每一个被检查的异常(ClassNotFoundException, InstantiationException, IllegalAccessException) 会被捕获和处理。对于一个类装入正确,但是却没有实现SomeInterface接口这种特殊情况会产生一个ClassCastException异常,这个异常也会被查证。 代码清单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;} 在某些情况下,更好的方法是重新抛出一个已知的异常(或者叫创建一个新的异常)而不是试图去在当前这个方法中处理。这允许调用方法通过放置这个异常到一个已知的上下文中去处理这种错误情形。 下面的代码清单6提供了一个buildInterface()方法的替换版本。如果在装入和实例化类时发生问题,这个版本会抛出一个ClassNotFoundException异常。在这个例子中,调用方法会确保得到一个正确的实例化对象或者是一个异常。这样调用方法就不需要去检查返回的对象是否为空了。 注意这个例子使用了Java 1.4的方法来创建一个已经被另外的异常封装的新的异常,以便保存原始的堆栈跟踪信息。否则,堆栈跟踪将指明方法buildInstance()是引起异常的源,而不是潜在的由newInstance()抛出的异常。 代码清单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); }} 在有些情况下,这段代码可能无法从某种错误状态恢复,这时,捕获一个特殊的异常以使代码能指出某种状态是否是可恢复的就变得很重要了。请试着以这种观点去看代码清单6中类实例化的例子。 在代码清单7中,如果className无效,程序会返回一个缺省的对象,并且抛出一个异常以指明非法的操作,比如错误的转型或访问权限不够。 注意:IllegalClassException是一系列的异常类,为示范的目的而在这提及(译注:并不是Java标准库所带)。 代码清单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;} 什么时候应捕获一个泛型异常? 在某些情况下捕获一个泛型异常是可以的,比如当它很便利且必需去捕获一个泛型异常的时候。这种情况非常特殊,且对于大型、允许失败的系统来说很重要。在代码清单8中,从一个请求队列中读取请求并顺序处理。但是,当请求被处理时候如果有任何异常发生(一个BadRequestException或任何RuntimeException的子类,包括NullpointerException),则那个异常就会被while循环外部所捕获。这样任何错误都会引起循环终止并且任何剩余的请求都不会被处理。那意味着在请求处理期间去处理一个错误是一种较差的方法。 代码清单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); }} 操作请求处理的一种较好的方法是对上述代码的逻辑作两个重要的改变,首先,将try/catch块移入请求处理的循环中。那样的话任何错误都会在循环内部被捕获和处理,并且不会引起循环终止。因而,循环会继续处理请求,即使一个单个的请求失败。第二,更改这个try/catch块使它去捕获一个泛型的异常。这样,任何异常都在循环内部被捕获,请求也得以继续处理。请看下面代码清单9: 代码清单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); } }} 捕获一个泛型异常听起来好像直接与本篇开始的观点相违背,确实如此。但是,这只是在非常非常特殊的的环境下。在这种情况下,捕获一个泛型的异常以防止一个单个的异常终止整个系统运行。 在请求、事务或事件在一个循环中被处理的情况下,即使在处理期间有异常被抛出,那个循环仍需要执行以便继续去处理。 在代码清单9中,在处理循环中try/catch块被认为是顶级异常管理者(the top-level exception handler),并且它需要捕获和记录所有在这个代码级别引起的异常。这样,异常没有被忽略,也不会丢失,并且异常不会中断余下需要处理的请求。 每个大型、复杂的系统都有一个顶级异常管理者(或者是每一个子系统,这取决于系统如何完成处理的)。顶级异常管理者无意去修复由异常引起的潜在问题,并且能在不终止处理的情况下捕获和记录这个问题。这并不是暗示所有的异常都应该在这个级别被抛出。任何异常如果能在较低级别被处理,则就应该那么做。如果那样,异常处理对问题发生时的状态逻辑就会知道的更多。但是,如果异常不能在较低级别被处理,那么抛出它到更高级别,这样,所有那些不可恢复的错误都将在一处被处理(顶级异常管理者),而不是遍及整个系统。 不要抛掷泛型异常 出现在程序清单1中的整个问题是在程序员决定从cleanupEverything()方法中抛掷泛型异常开始的,如此一来代码变得很优美,而当一个方法抛出6个不同异常时则会变得杂乱:方法声明变得得以理解,调用方法也不得不捕获那6个不同的异常,就像代码清单10那样。 代码清单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(即throw new Exception())的地方,因为程序员认为异常事实上不应该被用来表达这种情形。 这两方面的问题只要稍微思考和设计,就都可解决。什么是细节?那一层异常真的应该被抛掷吗?这个设计可能包括仅仅声明这个方法会抛掷一些确实会发生的异常。另一个选择是创建一层异常去封装抛掷和声明异常的东西。在大多数情况下,被方法抛掷的异常(或者叫一系列异常)应该尽可能的详细。这种更详细的异常会提供更多关于错误状态的信息,这样就允许这种情形被处理或至少详细的被记录。 如果泛型Exception类被选中,那就意味着任何调用一个声明了会抛出Exception方法的方法,要么必须声明它本身也会抛出Exception,要么封装这个方法调用在一个捕获泛型Exception的try/catch块中。我用这种方法在前面解释了这个问题。 小心使用泛型异常 这篇文章探究了处理泛型异常的几个方面:它们永远都不要被抛掷,也不应被忽略。它们应该很少被捕获(只在非常特殊的情况下)。它们不会提供详细信息以允许你去有效的处理它们,所以你不打算那样做时你应该小心捕获异常。 异常是Java中的一种强有力的工具。如果你正确使用,它能使你成为一个更有效率的程序员,并且缩短你的开发周期,特别是在测试和调试时。当异常被错误的使用时,在你的系统中,由于隐藏了问题的所在,你得一次又一次的重复工作。所以你要关注好你在哪和如何使用泛型异常。 |