乐者为王

Do one thing, and do it well.

异常处理最佳实践

英文原文:http://www.onjava.com/pub/a/onjava/2003/11/19/exceptions.html

异常处理的问题之一是知道何时以及如何使用它。在这篇文章中,我将讨论一些异常处理的最佳实践。我也会总结最近关于使用Checked异常的争论。

作为程序员,我们想要编写解决问题的高质量的代码。不幸的是,异常作为我们代码的意外结果出现。没有人喜欢意外结果,因此我们很快找到我们自己的方法去解决它们。我曾经见过一些聪明的程序员以下列方式处理异常:

1
2
3
4
5
6
7
public void consumeAndForgetAllExceptions() {
    try {
        // some code that throws exceptions
    } catch (Exception ex) {
        ex.printStacktrace();
    }
}

上面的代码有什么问题?

一旦一个异常被抛出,正常的程序执行被挂起,并且控制权转移到catch块。catch块捕获异常后什么也没做。在catch块后面的程序继续执行,就好象什么也没发生。

下面的代码怎么样?

1
2
public void someMethod() throws Exception {
}

这是一个空方法,它里面没有任何代码。空方法如何能抛出异常呢?Java不能阻止你这么做。最近我正好遇见过类似的代码:方法声明抛出异常,但方法里没有实际产生那个异常的代码。当我问那个程序员时,他回复说:“我知道,它会让API变得糟糕,但我一直都是这么做的,而且这样做很管用。”

C++社区花了好几年去确定如何使用异常。这种争论在Java社区刚刚开始。我曾经见过几个Java程序员与异常的使用作斗争。如果没有正确使用,异常会减缓你的程序,因为它需要内存和CPU能力去创建、抛出和捕获异常。如果过度使用,会使代码难以阅读,让使用API的程序员感到挫折。我们都知道挫折感(frustrations)会导致特殊技巧( hacks)和代码味道。客户端代码只要忽略异常或者抛出它们就可以规避这个问题,就像前面的两个例子。

异常的本质

大体上说,有三种不同的情况可以导致异常被抛出:

  • 由于编程错误的异常: 在此类别中,异常是由于编程错误产生的(比如,NullPointerException和IllegalArgumentException)。客户端代码通常不能做关于编程错误的任何事情。
  • 由于客户端代码错误的异常: 客户端代码尝试API不允许的东西,从而违反了它的契约。客户端可以采取一些替代的做法,如果在异常中有提供有用的信息。例如:当分析一个非格式良好的XML文档时一个异常被抛出。异常包含有XML文档中引起问题的位置信息。客户端可以使用该信息去采取恢复措施。
  • 由于资源失败的异常: 当资源失败时生成的异常。例如:系统内存不足或者网络连接失败。资源失败的客户端响应是上下文驱动的。客户端可以过一段时间后重试操作或者仅仅记录资源失败和使应用程序停止。

Java中异常的类型

Java定义了两类异常:

  • Checked异常: 继承自Exception类的异常是Checked异常。客户端代码必须处理被API抛出的Checked异常,不是在catch子句中就是通过throw子句向外转发。
  • Unchecked异常: RuntimeException也是扩展自Exception。不过,所有从RuntimeException继承的异常享有特殊待遇。对客户端代码是否处理它们没有要求,因此它们被称为Unchecked异常。

举例来说,图1显示了NullPointerException的层次结构。

图1. 示例异常层次结构

在这个图中,NullPointerException扩展自RuntimeException,因此是一个Unchecked异常。

我曾经见过Checked异常的重度使用和Unchecked异常的最小使用。最近,在Java社区有激烈的争论关于Checked异常和其真正的价值。这场争论源于Java似乎是第一个带有Checked异常的主流OO语言这个事实。C++和C#根本没有Checked异常,在这些语言中所有的异常都是Unchecked的。

由较低层引发的Checked异常是强制性的,要求调用层必须捕获或者抛出它。如果客户端代码无法有效地处理异常,在API和它客户端之间的Checked异常契约很快就会转变为不必要的负担。客户端代码的程序员可能会走捷径,通过使用一个空的catch块抑制异常或者仅仅抛出它。实际上,它只是把负担放到了客户端调用者身上。

Checked异常也被指责破坏了封装。考虑以下代码:

1
2
public List getAllAccounts() throws FileNotFoundException, SQLException {
}

方法getAllAccounts()抛出两个Checked异常。该方法的客户端必须显式地处理这些特定实现的异常,即使它不知道在getAllAccounts()中什么文件或数据库调用失败,或者没有业务提供文件系统或数据库逻辑。因此,异常处理在方法和它的调用者之间强制造成了一个不适当的紧密耦合。

设计API的最佳实践

说了这么多,现在让我们谈谈如何设计一个正确抛出异常的API。

1. 当确定使用Checked异常还是Unchecked异常时,问问你自己:“当异常发生时客户端代码可以采取什么动作?”

如果客户端可以采取一些替代动作从异常中恢复,把它设置为Checked异常。如果客户端不能做任何有用的事情,那就把它设置为Unchecked异常。这里的有用的的意思是采取措施从异常中恢复而不是仅仅记录异常。总结来说:

当异常发生时客户端的反应 异常类型
客户端代码不能做任何事情 把它设置为Unchecked异常
客户端代码将基于异常中的信息采取一些有用的恢复操作 把它设置为Checked异常

此外,对于所有的编程错误尽量使用Unchecked异常。Unchecked异常有个好处就是不会强制客户端API显式地处理它们。它们传播到你想捕获它们的地方,或者直接就是报告异常。Java的API有许多Unchecked异常,像NullPointerException、IllegalArgumentException和IllegalStateException。我更倾向于使用Java提供的标准异常而不是创建自己的。这会让我的代码容易理解和避免增加代码的内存占用。

2. 保持封装性。

不要把特定实现的Checked异常逐步上升到更高的层。例如,不要把来自数据访问代码中的SQLException传播到业务对象层。业务对象层不需要了解SQLException。你有两个选择:

  • 把SQLException转换成别的Checked异常,如果客户端代码期望从异常中恢复。
  • 把SQLException转换成一个Unchecked异常,如果客户端代码不能做关于它的任何事情。

大多数时候,客户端代码不能做关于SQLExceptions的任何事情。不要犹豫去把它们转换成Unchecked异常。考虑下面的代码片段:

1
2
3
4
5
6
7
public void dataAccessCode() {
    try {
        // some code that throws SQLException
    } catch(SQLException ex) {
        ex.printStacktrace();
    }
}

这里的catch块只是抑制异常而且什么也不做。理由是关于SQLException我的客户端没有什么能做的。以下列方式处理它怎么样?

1
2
3
4
5
6
7
public void dataAccessCode() {
    try {
        // some code that throws SQLException
    } catch(SQLException ex) {
        throw new RuntimeException(ex);
    }
}

它把SQLException转换成RuntimeException。如果SQLException发生,catch子句抛出一个新的RuntimeException。执行线程挂起,报告异常。不管怎样,我没有用不必要的异常处理让我的业务对象层变得糟糕,特别是因为它不能做关于SQLException的任何事情。如果我的捕获需要根异常的原因,我可以利用自JDK 1.4起在所有异常类中有效的getCause()方法。

当SQLException发生时如果你确信业务层能采取一些恢复动作,你可以把它转换成一个更有意义的Checked异常。但我发现大多数时间仅仅抛出RuntimeException就足够。

3. 如果不能给客户端代码提供有用的信息就不要试图去创建新的定制异常。

下面的代码有什么问题?

1
2
public class DuplicateUsernameException extends Exception {
}

它没有给客户端代码提供任何有用的信息,除了一个指示的异常名字。不要忘记Java的Exception类就像其它类,在其中你可以添加你认为客户端代码将调用来获得更多信息的方法。

我们可以给DuplicateUsernameException添加有用的方法,像:

1
2
3
4
5
public class DuplicateUsernameException extends Exception {
    public DuplicateUsernameException(String username) {}
    public String requestedUsername() {}
    public String[] availableNames() {}
}

新版本提供两个有用的方法:requestedUsername()返回请求的名字;availableNames()返回与请求的名字相似的一组有效用户名。客户端可以使用这些方法去告知请求的用户名是无效的和其它的用户名是有效的。但如果你不打算添加额外的信息,那么就抛出一个标准的异常:

1
throw new Exception("Username already taken");

如果你认为当用户名已被占用时客户端代码除了日志记录不打算采取任何动作,那么抛出一个Unchecked异常就更好:

1
throw new RuntimeException("Username already taken");

或者,你甚至可以提供一个方法检查是否用户名已被占用。

值得重复的是,Checked异常被用于那些客户端API可以基于异常中的信息采取一些富有成效的动作的情况中。对于所有的编程式错误尽量使用Unchecked异常。它们让你的代码可读性更强。

4. 用文档说明异常。

你可以使用Javadoc的@throws标签来说明API抛出的Checked和Unchecked异常。不过,我倾向于编写单元测试来说明异常。测试允许我看见动作中的异常,因此能被当作可以执行的文档。无论你做什么,都有一些办法让客户端代码可以通过它们了解你的API抛出的异常。这里是一个用于测试IndexOutOfBoundsException的示例单元测试:

1
2
3
4
5
6
7
8
public void testIndexOutOfBoundsException() {
    ArrayList blankList = new ArrayList();
    try {
        blankList.get(10);
        fail("Should raise an IndexOutOfBoundsException");
    } catch (IndexOutOfBoundsException success) {
    }
}

当blankList.get(10)被调用时上面的代码将会抛出IndexOutOfBoundsException。如果没有抛出异常,fail("Should raise an IndexOutOfBoundsException")语句会显式地让测试失败。通过为异常编写单元测试,你不仅说明异常是如何工作的,也通过测试特殊场景让你的代码更加健壮。

使用异常的最佳实践

下一组最佳实践显示客户端代码将如何处理抛出Checked异常的API。

1.总是要做些清理工作

如果你使用像数据库连接或网络连接这样的资源,确保你已经把它们清理干净。如果你调用的API只使用Unchecked异常,你仍然应该在使用后用try-finally块清理资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void dataAccessCode() {
    Connection conn = null;
    try {
        conn = getConnection();
        // some code that throws SQLException
    } catch(SQLException ex) {
        ex.printStacktrace();
    } finally {
        DBUtil.closeConnection(conn);
    }
}

class DBUtil {
    public static void closeConnection(Connection conn) {
        try {
            conn.close();
        } catch(SQLException ex) {
            logger.error("Cannot close connection");
            throw new RuntimeException(ex);
        }
    }
}

DBUtil是一个可以关闭连接的实用工具类。重点是finally块的使用,无论异常是否被捕获它都会执行。在这个例子中,finally关闭连接,如果关闭连接有问题就抛出一个RuntimeException。

2. 不要使用异常控制流程

生成栈跟踪是昂贵的,栈跟踪的价值是在调试。在一个流程控制的情况中,栈跟踪将被忽略,因为客户端仅仅想知道如何处理。

在下面的代码中,一个定制的异常MaximumCountReachedException被用来控制流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void useExceptionsForFlowControl() {
    try {
        while (true) {
            increaseCount();
        }
    } catch (MaximumCountReachedException ex) {
    }
    // continue execution
}

public void increaseCount()
    throws MaximumCountReachedException {
    if (count >= 5000)
        throw new MaximumCountReachedException();
}

useExceptionsForFlowControl()使用一个无限循环增加计数直到异常被抛出。这不仅使代码难以阅读,而且让代码变得更慢。仅在特殊情况下使用异常处理。

3. 不要抑制或忽略异常

当来自API的一个方法抛出Checked异常时,它试图告诉你应该采取一些反向动作。如果Checked异常对你毫无意义,不要犹豫去把它转换成一个Unchecked异常并且再次抛出它,但是不要通过用{}捕获异常来忽略它,然后继续,好像什么也没发生过。

4. 不要捕获顶级异常

Unchecked异常继承自RuntimeException类,RuntimeException又继承自Exception。通过捕获Exception类,你也捕获了RuntimeException,如以下代码所示:

1
2
3
try {
} catch(Exception ex) {
}

上面的代码同样忽略了Unchecked异常。

5. 只记录异常一次

记录相同的异常栈跟踪超过一次可能会让程序员在检查关于异常原始源的栈跟踪时被迷惑。因此,只记录它一次。

总结

这些都是异常处理最佳实践的一些建议。我无意开始一场Checked异常与Unchecked异常之间的宗教战争。你必须根据你的需求自定义设计和用法。我相信,假以时日,我们会找到更好的方法用异常编码。

我要感谢Bruce Eckel、Joshua Kerievsky和Somik Raha他们对我在写这篇文章时的支持。

相关资源

Comments