Java开发人员做出的有关架构的最重要的决定之一便是如何使用Java异常模型。Java异常处理成为社区中讨论最多的话题之一。一些人认为Java语 言中的已检查异常(Checked Exceptions)是一次失败的尝试。本文认为错误并不在于Java模型本身,而在于Java库设计人员没有认识到方法失败的两个基本原因。本文提倡 思考异常情况的本质,并描述了有助于用户设计的设计模式。最后,本文讨论了异常处理在面向方面编程(Aspect Oriented Programming)模型中作为横切关注点(crosscutting concern)的情况。如果使用得当,Java异常将对程序开发人员大有裨益。本文将帮助读者正确使用Java异常。
为什么异常非常重要
Java应用程序中的异常处理可以告诉用户构建应用程序的架构强度。架构是指在应用程序的各个层面上所做出的并始终遵守的决策。其中最重要的决 策之一便是应用程序中类、子系统或层之间进行互相通信的方式。方法通过Java异常可以为操作传递另一种结果,因此应用程序架构特别值得我们去关注。
判断Java架构师技能的高低和开发团队是否训练有素,其中比较好的方法是查看应用程序中的异常处理代码。首先需要观察的是有多少代码专门用于 捕捉异常、记录异常、确定发生的事件和异常转化。简洁、紧凑和有条理的异常处理表明团队有使用Java异常的一致方法。当异常处理代码的数量将要超过其他 方面的代码时,可以断定团队成员之间的沟通已经打破(或者这种沟通从一开始就不存在),每个人都用自己的方法来处理异常。
临时异常处理的结果非常具有预见性。如果问团队成员为什么在代码的某个特定点丢弃、捕捉、或忽略某个异常,回答通常是:“除此之外,我不知道怎 么做。”如果问他们在编写代码的异常实际发生时会产生什么情况,他们通常会皱眉,回答类似于:“我不知道。我们从来没有测试过。”
要判断Java组件是否有效地利用了Java异常,可以查看其客户端的代码。如果客户端代码中包含大量计算操作失败时间、原因和处理方法的逻 辑,原因几乎都是由于组件的错误报告设计。有缺陷的报告会在客户端产生大量的“记录和遗留”(log and forget)代码,而没有任何用途。最糟糕的是扭曲的逻辑路径、互相嵌套的try/catch/finally程序块,以及其他导致脆弱和无法管理的应 用程序的混乱。
事后处理异常(或者根本不处理)是造成软件项目混乱和延迟的主要原因。异常处理关系到软件设计的各个方面。为异常建立架构约定应该是项目中首先要做出的决定之一。合理使用Java异常模型将对保持应用程序的简洁性、可维护性和正确性大有帮助。
挑战异常准则
如何正确使用Java异常模型已经成为大量讨论的主题。Java并不是支持异常语义的第一种语言;但是,通过Java编译器可强制使用规则来控 制如何声明和处理特定的异常。许多人认为编译时异常检查对精确软件设计有帮助,它与其他语言特征能够很好地协调起来。图1表明了Java异常的层次结构。
通常,Java编译器会根据java.lang.Throwable强制方法抛出异常,包括其声明中“throw”子句的异常。同样,编译器会 验证方法的客户端是捕获声明异常类型还是指定自己抛出异常类型。这些简单的规则对于全世界的Java开发人员产生了深远的影响。
编译器针对Throwable继承树的两个分支放宽了异常检查行为。java.lang.Error和 java.lang.RuntimeException的子类免于编译时检查。在两者中,软件设计人员通常对运行时异常更感兴趣。通常使用术语“未检查异 常”(unchecked exception)来区分其他的所有“已检查异常”(checked exception)
图 1 Java异常层次结构
我认为已检查异常会受到那些重视Java语言中强类型的人的欢迎。毕竟,由编译器对数据类型产生的约束会鼓励严格编码和精确思维。编译时类型检 查有助于防止在运行时产生难以应付的意外事件。编译时异常检查将起到相同的效果,提醒开发人员注意的是,方法会有潜在的其他结果需要进行处理。
在早期,推荐无论何处都尽量使用已检查异常,以便充分借助编译器生产出无差错软件。Java API库的设计人员显然赞成已检查异常准则,并广泛使用这些异常来模仿在库方法中发生的任何意外事件。在J2SE 5.1 API规范中,已检查异常类型仍然比未检查异常类型多,比例要超过二比一。
对于程序员来说,Java类库中的绝大多数公共方法好像为每一个可能的失败都声明了已检查异常。例如,java.io包对已检查异常IOException的依赖性特别大。至少63个Java库包直接或间接通过其数十个子类之一发布了此异常。
I/O失败很严重但也很少见。另外,程序代码通常没有能力从失败中恢复。Java程序员发现他们必须提供IOException和类似在简单的 Java库方法调用时可能发生的不可恢复的事件。捕捉这些异常只会打乱原有简洁的代码,因为捕捉块并不能改善此类情况。而不捕捉这些异常可能会更糟糕,因 为编译器要求将这些异常加入到方法所抛出的异常列表中。这公开了一些实现细节,优秀的面向对象设计自然会将这些细节隐藏起来。
这种无法成功的情况导致许多严重违反Java编程模式的异常处理。当今的程序员经常被告诫要提防这些情况。同样,在创建工作区方面也产生大量正确和错误的建议。
一些Java天才开始质疑Java已检查异常模型是否是一次失败的尝试。可以确定某个地方出了问题,但是这和Java语言中的异常检查无关。失败的原因是Java API设计人员的思考方式,即他们认为大多数失败情况都相同,并且可以用相同类型的异常来传达。
错误和意外事件
假想金融应用软件中的CheckingAccount类。CheckingAccount属于客户,维护当前余额,并且可以接受存款,根据支票 接受终止支付命令,以及处理入帐的支票。CheckingAccount对象必须协调并发线程的访问,因为每一个线程都可以改变它的状态。 CheckingAccount的processCheck()方法将Check对象作为参数,从账户余额中正常扣除支票金额。但是调用 processCheck()的支票结算客户端必须为两类意外事件做好准备。首先,CheckingAccount 可能有为支票注册的终止支付命令。第二,账户中可能没有足够的资金来支付支票金额。
所以,processCheck()方法使用三种可能的方式来响应其调用者。正常的响应方式是支票得到处理,在方法签名中声明的结果返回给调用 服务。这两类意外事件响应代表了金融领域非常真实的情况,它们需要与支票结算客户端进行通信。所有这三种processCheck()响应都是为模仿典型 的支票账户行为而精心设计的。
在Java中表示意外事件响应的通常方法是定义两种异常,即StopPaymentException和 InsufficientFundsException。客户端忽略这两个异常是不正确的,因为在应用程序正常操作时会被抛出这两个异常。这两个异常有助 于表达方法的所有行为,和方法签名一样十分重要。
客户端可以轻松地处理这两类异常。如果终止支票的支付,客户端可以取得支票进行特殊处理。如果没有足够的资金,为支付此支票,客户端将从客户的储蓄帐户中转移资金,并重新尝试。
这些意外事件可以预见,它是使用CheckingAccount API的自然结果。它们并不表示软件或执行环境的失败。将这些异常条件与实际的失败对比,实际的失败是由于CheckingAccount类的内部实现细节问题造成的。
假设CheckingAccount在数据库中维持持久的状态并使用JDBC API进行访问。在该API中,几乎每一个数据库访问方法都有失败的可能性,但原因与CheckingAccount实现无关。例如,有人会忘记打开数据 库服务器、不小心拔下了网线,或改变了访问数据库的密码。
JDBC依靠单独的已检查异常SQLException来报告一切可能的错误。大多数错误都与数据库的配置、连接和硬件设备有关。 processCheck()方法并不能以有意义的方式处理这些异常条件。很遗憾,因为processCheck()至少知道它自己的实现方式。调用堆栈 中的上游方法能够处理这些问题的可能性会更小。
CheckingAccount示例解释了导致方法执行不能返回预期结果的两个基本原因。下面首先介绍一些描述性术语:
意外事件
是一种可以预见的情况,要求方法做出某种响应,以便能够表达方法所期望的目的。方法的调用者预见这些情况并采取策略应付这些情况。
错误
是一种计划外的情况,它阻止方法达到其预期目的,并且如果不引用方法的内部实现,则无法描述这种情况。
从这两个术语来看,终止支付命令和透支是processCheck()方法两个可能的意外事件。SQL问题代表了可能的错误异常条件。processCheck()的调用者应当提供一种处理意外事件的方式,但当错误发生时,并不能合理地处理该错误。
映射Java异常
对于意外事件和错误,思考其原因将有助于为应用程序架构中的Java异常建立约定。
异常条件 意外事件 错误
认为是(Is considered to be) | 设计的一部分 | 难以应付的意外 |
预期发生(Is expected to happen) | 有规律但很少发生 | 从不 |
谁来处理(Who cares about it) | 调用方法的上游代码 | 需要修复此问题的人员 |
实例(Examples) | 另一种返回模式 | 编程缺陷,硬件故障,配置错误,文件丢失,服务器无法使用 |
最佳映射(Best Mapping) | 已检查异常 | 未检查异常 |
意外事件异常条件完美地映射到Java已检查异常。由于它们是方法语义契约中不可或缺的一部分,因此必须借助编译器来确保问题得到了处理。如果 开发人员坚持在编译器有问题时处理或声明意外事件异常,此时编译器会成为一种阻碍,可以断定此软件设计必须进行部分重构。这其实是一件好事。
错误条件对编程人员来说能够引起关注,而对于软件逻辑却并非如此。“软件诊断学家”收集错误信息以诊断和修复引起错误发生的根源。因此,未检查 Java异常是错误的完美表现方式,它们可以使错误通知完整地过滤调用堆栈上的所有方法,传递到专门用于捕捉错误的层,捕获其中所包含的诊断信息,并为此 活动提供一份受约束的合理结论。错误产生方法并不需要声明,上游代码也不需要捕获它们,方法的实现得到了有效的隐藏——产生最少的代码混乱。
较新的Java API(比如Spring Framework和Java Data Object库)很少或根本不依赖于已检查异常。Hibernate ORM framework从release 3.0起重新定义了关键设备,以免于使用已检查异常。这反映了由这些框架报告的绝大部分异常异常条件是不可恢复的,这些异常源于方法调用的不正确编码或数 据库服务器失效等基本组件原因。实际上,强制调用者去捕捉或声明这样异常几乎没有任何益处。
架构中的错误处理
在架构中有效处理错误的第一步是承认处理错误的必要性。承认这一点对于工程师来说有困难,因为他们认为自己有能力创造无缺陷的软件,并引以为豪。下面这些 理由可能有所帮助。首先,应用程序开发会在常见错误上花费大量的时间。提供程序员产生的错误将使团队诊断和修复这些错误变得十分简单。第二,对于错误异常 条件过度使用Java库中的已检查异常将强制代码来处理这些错误,即使调用顺序完全正确。如果没有适当的错误处理框架,由此产生的暂时异常处理将向应用程 序中插入平均信息量。
成功的错误处理框架必须达到四个目标:
- 使代码混乱最小化
- 捕捉并保留诊断信息
- 通知合适的人员
- 比较得体地退出活动
错误会分散应用程序的真正目的。因此,用于错误处理的代码数量应当最小化,并在理想情况下,应与程序的语义部分隔离。错误处理必须满足纠错人员 的需要。他们需要知道是否发生错误并且获取相关信息以判断错误原因。尽管从定义上说,错误不可恢复,但可靠的错误处理措施将以得体地方式终结出现错误的活 动。
对于错误异常条件使用未检查异常
有许多原因使我们做出使用未检查异常表示错误异常条件的架构性决定。作为对编程错误的回报,Java运行时将抛出RuntimeException的子类 (比如ArithmeticException和ClassCastException),针对架构设定先例。未检查异常使上游方法摆脱了包含无关代码的 要求,从而最大限度地减少了混乱。
错误处理策略应当承认Java库和其他API中的方法可能使用已检查异常来表示应用程序环境下的错误异常条件。在这种情况下,采用架构惯例在其出现的地方捕捉API异常,将它作为错误,并抛出未检查异常来说明错误异常条件并捕捉诊断信息。
在这种情况下抛出的特定异常类型应当由架构本身定义。不要忘记错误异常的主要目的是传达诊断信息并记录,以帮助开发人员发现问题产生的原因。使 用多错误异常类型可能有些过度,因为架构会对它们进行完全相同的处理。在绝大多数情况下,把良好的描述性文本消息嵌入到单独的错误类型中,便可完成此项工 作。使用Java的一般RuntimeException来表示错误条件很容易进行防御。从Java 1.4时起,RuntimeException同其他throwable类一样,支持异常处理链式机制,允许设计人员捕捉并报告导致错误的已检查异常。
设计人员可以定义自己的未检查异常进行报告错误。如果需要使用不支持异常链接机制的Java 1.3或更早版本,这一步是必需的。实现相似的链接功能去捕捉并转换引起应用程序错误的异常相当简单。在错误报告异常中,应用程序可能需要特别的行为。这 可能是为架构创建RuntimeException子类的另一个原因。
建立错误屏障
决定哪些异常要抛出以及何时抛出将成为错误处理框架的重要决定。同样重要的问题是何时捕捉错误异常及其后如何做。这里的目标是使应用程序的功能部分从处理错误的职责中分离出来。关注点分离通常是比较好的做法。负责处理错误的中央设备将为您带来很多的好处。
在错误屏障(fault barrier)模式下,任何应用程序组件都可以抛出错误异常,但只有作为“错误屏障”的组件才可以捕捉到错误异常。开发人员为了处理错误问题在应用程序 中插入了大量复杂代码,而采用此模式可消除大部分此类代码。从逻辑上讲,错误屏障存在于靠近调用堆栈的顶端。在这里,它可以阻断异常向上传播,以避免触发 默认动作。默认动作根据应用程序类型的不同而不同。对于独立的Java应用程序来说,默认动作意味着终止活动线程。对于驻留在应用服务器上的Web应用程 序,默认动作意味着应用服务器会向浏览器发送不友好的(且令人为难的)响应。
错误屏障组件的首要职责是记录包含在错误异常中的信息,以便进行下一步行动。应用程序日志是迄今为止做这件事情最理想的方法。异常的链信息、堆 栈跟踪等对于诊断专家来说都是有价值的信息。发送错误信息最差的地方是通过用户界面。将客户牵涉到应用程序的排错过程中,将对开发人员或客户没有任何益 处。如果开发人员真的把诊断信息添加到了用户界面上,这说明开发人员的记录策略需要改进。
错误屏障的下一个职责是以受控方式停止操作。这个职责的含义由应用程序的设计决定,但是通常会涉及到为等待响应的客户端发出总体响应。如果应用 程序是Web service,这意味着使用soap:Server的<faultcode>和普通<faultstring>失败消息将 SOAP <fault>元素嵌入到响应中。如果应用程序与Web浏览器进行通信,屏障将安排发送普通的HTML响应,表示无法处理此请求。</fault></faultstring></faultcode>
在Struts应用程序中,错误屏障采用全局异常处理程序的形式,配置成可以处理RuntimeException的任何子类。错误屏障类将扩 展org.apache.struts.action.ExceptionHandler,在需要实现自定义处理时重写方法。这将处理由于疏忽产生的错误 条件和处理Struts操作中明显发现的错误条件。图2显示了意外事件异常和错误异常。
图2 意外事件异常和错误异常
如果开发人员正在使用Spring MVC框架,简单地扩展SimpleMappingExceptionResolver并进行配置使其能处理RuntimeExceptio及其子类,便 能建立起错误屏障。通过重写resolveException()方法,在使用超类方法向发送普通错误显示的查看组件发出请求之前,开发人员可以添加任何 自定义处理。
当架构包含错误屏障并且开发人员也意识到了它的存在时,编写一劳永逸的错误异常处理代码的吸引力急剧下降。结果是在应用程序中产生更简洁和更易维护的代码。
架构中的意外事件处理
随着错误处理委托给屏障,主要组件之间的意外事件通信变得更加简单。意外事件代表了另一种方法结果,此结果与主要返回结果同样重要。因此,已检 查异常类型是传递意外事件条件存在性并提供对付异常条件所需信息的良好工具。最佳实践是借助Java编译器来提醒开发人员他们正在使用API的所有方面, 同样需要提供方法结果的全部范围。
通过单独使用方法的返回类型,可以传递简单的意外事件。例如,返回null引用而非实际对象可以说明此对象由于明确的原因而无法创建。Java I/O方法通常返回整数值-1,而不是字节值或字节计数,用来表明文件的结束。如果方法的语义非常简单允许这样做,另一种返回值可以使用这种方式,因为它 们消除了由异常而带来的开销。不利方面是方法调用者负责检测返回值,来查看它是主要结果还是意外事件结果。然而,编译器并不强制调用者做这样的测试。
如果方法具有void返回类型,异常将是表明意外事件发生的唯一方法。如果方法返回对象引用,则返回值所表达的意思仅限于两个值(null和 non-null)。如果方法返回整数值,通过选择确保与主要返回值不相冲冲突的值,就可以表达数个意外事件条件。但是现在已经进入了错误代码检查的范 畴,这是Java异常模型需要避免的情况。
提供有用的信息
定义不同的错误报告异常类型没有任何道理,因为错误屏障会对它们进行同样的处理。意外事件异常差异很大,因为它们会向方法调用者传达各种条件。您的架构可能明确指定这些异常都应该扩展java.lang.Exception或指定的基类。
不要忘记这些异常是完整的Java类型,可以调整特定的字段、方法以及为特殊目的而构建的构造函数。例如,假想的 CheckingAccount processCheck()方法抛出的InsufficientFundsException类型可能包括OverdraftProtection对 象,此对象能够转移资金以弥补另一个账户的资金短缺,此账户的身份取决于设置核算账户的方式。
记录还是不记录
记录错误异常有实际意义是因为它们的目的是吸引开发人员去注意需要纠正的情况。但这并不适用于意外事件异常。它们可能代表相对少见的事件,但是在应用程序 的生命周期内,这些意外事件依然会发生。它们表明了如发生异常应用程序将按照其设计意图进行工作。按照惯例,在意外事件捕捉模块中加入记录代码只会增加混 乱代码而没有任何益处。如果意外事件表示重要事件,最好为方法产生一条记录项,用于在抛出意外事件异常并通知其调用者之前记录此事件。
异常方面
在面向方面编程(Aspect Oriented Programming (AOP))中,错误和意外事件的处理是横切关注点。例如,要实现错误屏障模式,所有参与的类都必须遵守公共约定:
- 错误屏障方法必须驻留在遍历参与类的方法调用的头部。
- 它们都必须使用未检查异常来表示错误条件。
- 它们都必须使用特定的未检查异常类型,以便错误屏障能够接收到。
- 它们都必须从较低层方法中捕捉并转换已检查异常,这些异常在它们的执行环境中被视为错误。
- 它们不能干扰错误异常到屏障的传播。
这些关注点跨越了其他不相关类的边界。结果产生了少量分散错误处理代码并致使屏障类与参与者之间的隐式耦合(尽管对于完全没有使用模式来说是一 次重大改进)。AOP允许将错误处理关注点封装到应用于参与类的公共Aspect中。Java AOP框架(比如AspectJ和Spring AOP)把异常处理作为联接点,错误处理行为(或建议)能够附加到上面。这样,在错误屏障模式中绑定参与者的惯例就有所放宽。错误处理现在可以存在于独立 的非内联方面(out-of-line aspect)中,避免了将“屏障”方法置于方法调用序列的前面。
如果开发人员在架构中使用AOP,错误和意外事件的处理是方面在整个应用程序中应用的理想候选对象。完全探究错误和意外事件处理在AOP中如何运作,这是个令人感兴趣的话题,留作以后讨论。
结束语
尽管Java异常模型在其生命周期内已经引发了激烈的争论,但是当Java异常模型运用得当时,将会带来巨大的价值。作为架构师,应当决定如何 建立最大限度利用模型的惯例。思考一下错误和意外事件异常能够帮助开发人员做出正确的选择。Java异常模型使用得当,将保持应用程序的简洁性、可维护性 和正确性。把面向方面编程技术的错误和意外事件处理作为横切关注点,可为应用程序的架构带来某些明显的优势。
参考资料
- Sun's Exception Tutorial——关于Java异常的所有基本知识
- Does Java Need Checked Exceptions?——Bruce Eckel反对Java中的已检查异常
- Exceptional Java——有关这些话题的充分讨论和值得仿效的架构异常策略
- The Exceptions Debate——来自developerWorks的有关异常的更多背景知识
- The Apache Struts Web Application Framework——有关Struts的信息来源
- The Spring Framework——有关Spring的信息来源
- Wikipedia: Aspect Oriented Programming——对AOP概念的优秀介绍
- The AspectJ Project——有关AspectJ的信息来源