异常通知:面向方面的模型

作者:Barry Ruzek
07/04/2007

摘要

有效的异常处理策略是一大架构关注点,它超越了独立应用程序组件的边界。有效的Java异常(Dev2Dev中文版,2007年2月)概述了错误-意外事件(Fault-Contingency)异常模型,消除了在Java应用程序中使用已检查还是未检查异常的迷惑。使用传统Java技术实现这种模型要求所有组件都遵循一组规则和行为。这也就暗中表明原本无关的组件间的耦合需要为意料之外的失误和故障留出空间。在错误-意外事件异常模型中应用面向方面的技术需要首先处理这一关注点,并允许其他组件专心于其主要工作。

本文解释了为什么基于错误-意外事件异常模型的异常处理方面是对传统实现的重大改进。还提供了使用AspectJ(Java的一种面向方面的扩展)创建的异常处理方面的完整示例,来展示这种概念。文中提供的代码可在BEA WebLogic 9.2 和 Tomcat 5.0 应用服务器上运行。

AOP 与架构

作为一名应用程序架构师,您的主要职责是制订决策来监管组件之间的关系。架构决策会影响到组件的设计方法、组件用来协作的模式以及所遵循的惯例。如果决策合理、沟通充分,并得到了项目团队的遵从,那么就会得到一个易于理解、维护和扩展的软件系统。每个人都希望得到这样的结果,但实现起来却极具挑战。架构是跨组件的,它要求组件执行某些操作,或者避免特定的行为,以使一切在一个总体愿景下协调工作。

开发团队是由人构成的,而人都是不完美的。即便最出色的开发团队也会在维护架构愿景的纯净方面遇到麻烦。团队可以利用两种传统的对策来避免架构违规。第一种对策是规定对设计和编码进行定期审查。第二种是构建框架。审查的目标是在问题刚刚出现时发现问题。而框架提供了一种可重用的基础架构,其约束的目标是从一开始就预防问题,避免问题出现。

面向方面的设计是应对架构关注点的第三种选择。它不是将架构行为分散到所有无关的组件,而是将行为封装在一个方面中,并在特定执行点应用。面向方面编程(AOP)方面的工作自20世纪90年代就已启动,但可以公正地说,AOP的广泛采用依然有待时日。或许造成这种情况的原因之一就是缺乏令人鼓舞的典范,表明这种技术能够带来怎样的收益。一个引人注目的AOP实例应具有如下特点:

  • 有价值——解决公认的问题
  • 没有AOP就难以解决
  • 使用AOP可轻松解决

跟踪方法执行的常用示例是展示方面功能的好办法,但并不那么令人鼓舞——或许应该说,鼓舞的程度对于大多数人来说还不够,不足以投入人力物力去学习这种技术,不足以成为在下一个项目中使用AOP的理由。其实存在更好的例子,但您需要详查所有记录了方法的例子,来找到所需的那些。

在许多软件项目中,Java应用程序中的异常处理就是一个公认的关注点。管理不善的异常规程将导致难以理解和维护的易出错代码。一致的异常处理方法对于多数应用程序来说都是一项重大收益。即便在团队采用了异常的架构模型时,确保每个模型都符合模型也需要不懈的努力和深入的洞察力。看上去异常处理模型似乎是探索AOP的不错方法。这是否能成为一个鼓舞人心的例子,一切由您决定。

错误-意外事件异常模型

异常处理方面从您希望应用到整个应用程序中的一个模型或者一组行为开始。错误-意外事件异常模型提供了一种切实可行的方式来考虑执行软件时所遇到异常。该模型将不规则的输出描述为意外事件(Contingency)或错误(Fault)。意外事件是一种可选输出,能够使用组件目标用途的词汇表加以描述。方法或构造方法的调用方具有处理其意外事件输出的战略。另一方面,错误是无法在语义契约方面进行描述的一种故障,仅可就实现细节进行描述。举例来说,考虑一个Account Service,它带有一个getAccount()方法,在为此方法提供一个Account ID后,它将返回Account对象。很容易就能设想出可能出现的意外事件,例如“No such account”或“Invalid Account ID”,是就方法的目标用途表述的。要预计可能出现的错误,您首先需要了解getAccount() 方法是怎样实现的。它是否因未连接到数据库而接收到了一个SQLException?或许存在超时,正在等待一个宕机维护的Web服务。或许一个丢失的文件(本应存在)导致了FileNotFoundException。此处的要点在于getAccount() 的调用方不应对实现有任何了解,也不应被迫为预计到的任何错误捕捉已检查异常。

错误-意外事件异常模型的一个简单Java实现具有三个基本概念:意外事件异常、错误异常、错误屏障。方法和构造方法使用意外事件异常来通知作为其契约一部分的可选输出。意外事件异常是已检查的异常,因此编译器将帮助确保调用方考虑到所有约定的输出。错误异常用于通知特定于实现的故障。错误异常是未检查的异常,运行中的代码通常会避免捕捉这些异常,将这一责任留给作为错误屏障的类处理。错误屏障的主要责任就是在获得错误异常时为正在处理的活动提供一个出色的出口。这种出色的出口通常包含对正在处理的故障的表示,例如在用户界面(如果有用户界面)上显示一条道歉信息,或者通过其他方式向“外部世界”指出故障。

传统的实现使用RuntimeException的一个子类(比如FaultExceptio)来表示错误异常。作为一个未检查的异常,FaultException可在未在方法和构造方法的签名中被显式捕获或声明的情况下抛出。因此,这种异常完全可能在未被错误屏障捕获或处理时处于未发现的状态。意外事件异常基于Exception的一个子类(比如ContingencyException),该子类使Java编译器可以检查此类异常。由于ContingencyException是语义契约的完整组成部分,因此可以借助编译器来保证调用方具备处理此类异常的战略。

模型中的组件需要遵循一组使一切正常工作的规范。首先,组件不能抛出FaultException 或 ContingencyException子类以外的异常。其次,组件必须避免捕获FaultException,将这一责任留给错误屏障。组件负责处理它们所调用的外部方法抛出的异常,并在必要时将其转换为FaultException或ContingencyException。任何未捕获的RuntimeException都被视为错误,需要错误屏障的关注。这些规则非常简单,有效地消除了应用程序代码中混乱、令人迷惑的异常序列。通过清晰地将错误划分出来,由错误屏障负责处理,使用低级代码处理错误条件的诱惑得到了极大的消减。错误不再干预应由意外事件异常处理的情况,意外事件异常的目的显然是在组件间传输有意义的信息。

传统实现的不足之处

错误-意外事件异常模型的传统实现是对临时异常处理的极大改进,但离理想还相去甚远。所有的组件都必须遵循规范,即便这些组件之间再无其他关联。确保它们确实遵循了规范的惟一方法就是审查代码。可能有一个组件无意中捕获了错误异常,使之无法传递给错误屏障。如果发生这种情况,您可以顺利从那个出色的出口退出并离开,但没有任何办法去诊断所发生的错误。

传统实现给错误屏障设定了两方面的责任。其固有的责任就是完美地终止处理序列。由于其位置靠近调用堆栈的顶端,因此错误屏障了解周围环境,了解哪些内容能够构成恰当的输出响应。另外一种责任是记录与错误相关的分析信息,以使人们了解发生了什么。它具有这种责任的惟一原因就是没有其他合适的位置来完成这个任务。如果系统需要多个错误屏障(有些系统确实需要),那么每个错误屏障都必须包含类似的逻辑,来捕获可用信息。

修正一个问题的能力取决于可用信息的质量。实际上,传统实现能够提供的信息仅限于RuntimeException能够提供的那些:堆栈跟踪和错误消息。每一名Java程序员都会乐于在没有任何与实际发生情况有关的线索的前提下,启动一次堆栈跟踪。堆栈跟踪将显示发生了什么、在哪里发生,但不会显示为什么发生这样的情况。理想情况下,您希望了解哪些方法被调用,以及它们是怎样被调用的——传入各方法且导致错误的参数类型和值。将代码分散到每一个方法之中并在输入时记录其参数这种方法令人不满、不切实际、易于出错,如果未实际出现任何错误,那么所做的一切都是白费功夫。

方面、切入点和通知

方面编程正是为解决此类问题而出现的。在我们的例子中,应用程序内的所有组件都必须关注错误和意外事件的规则。如果一个类中出现失误,会波及众多不相关的类,导致较大的异常模型出现故障。同样,我们可以使用错误屏障来完成记录分析信息的任务,尽管其自身的角色只是了解如何为外部世界生成一般响应并执行清除操作。

AOP的理念是将所需行为封装在一个实体中:方面。一个方面包含在应用程序中某些定义好的点上运行的逻辑。所运行的这种逻辑就称为通知。应用通知的点称为连接点。可通过定义切入点来指定一组应用通知的连接点。切入点基本上就是一个表达式,过滤应用程序中所有潜在连接点,并根据标准(如接入点的类型和各种类型的模式匹配)来选择部分连接点。如果恰当地制作了方面,它会执行一些操作,若不使用方面,这些操作将分散在应用程序之中。将一切都集中到一处之后,应用程序中的其他组件即可集中关注其主要任务。最终得到更出色的组件内聚,这是人人都希望得到的结果。

示例应用程序包含我们的异常处理方面,它是使用Java语言的一个超集AspectJ构建的。这种语言支持对于任何应用程序中的异常处理都非常重要的连接点多样性。在Java应用程序的执行过程中,可以通过多种方式生成并捕获异常。方法执行只是其中的一种方式。异常处理方面需要考虑构造方法的执行、对象初始化和类初始化,这些都可能导致异常。此外还要考虑显式抛出和捕获异常的位置。AspectJ的切入点语言支持实现理想模型所需的一切。

 

ExceptionHandling方面

ExceptionHandling方面在设计时就考虑到了最大化灵活性,因此它只有两个编译时依赖项。它们是表示错误和意外事件的两个Java类:

  • FaultException — 表示错误条件的RuntimeException类的一个子类。
  • ContingencyException - 表示意外事件输出的Exception类的一个子类。子类表示具体的意外事件条件。

方面假设(并强制)应用程序的其他部分按照模型规则使用这些类。AspectJ系统的绝妙特性之一就是能够“为编译器编程”,从而实施超越标准Java语言规则的策略。在我们的例子中,我们希望鼓励开发人员以错误和意外事件为依据进行思考,并清楚地加以区分。由于我们的架构提供了一个ContingencyException基类,我们希望确保开发人员仅使用该类的子类来表示意外事件条件。通过这种做法,就能够避免开发人员尝试声明一个抛出(比如说)SQLException的方法,此方法应将任何意料之外的JDBC问题都视为错误。

ExceptionHandling方面使用切入点来检测声明了已检查异常而非基于ContingencyException的异常的方法和构造方法。违规将作为编译错误标出,这种方式能确保相关人员注意到问题。

package exception;



import java.io.ByteArrayOutputStream;

import java.io.PrintStream;

import java.util.Stack;



import org.aspectj.lang.JoinPoint;

import org.aspectj.lang.reflect.CodeSignature;



public abstract aspect ExceptionHandling {

        ...



    pointcut methodViolation():

        execution(* *(..) throws (Exception+

            && !ContingencyException+

            && !RuntimeException+));



    declare error:

        methodViolation():

            "Method throws a checked exception that is not 

                                      a ContingencyException";



    pointcut constructorViolation():

        execution(*.new(..) throws (Exception+

            && !ContingencyException+

            && !RuntimeException+));



    declare error:

        constructorViolation():

            "Constructor throws a checked exception that is not

                                         a ContingencyException";



        ...

}

清单 1. 编译时异常策略实施

在下面的例子中,Transaction类的commit()方法声明其抛出SQLException,而在这种环境中SQLException应视为错误,因而它违背了异常策略。编译器以ExceptionHandling方面中的声明为依据,将这一违规作为编译错误标出。rollback()方法将意料之外的SQLException视为错误处理,符合此模型,因而不会出现任何标记。为本文开发的示例是使用安装了AspectJ Development Tools (AJDT) 1.4.1插件的Eclipse 3.2.1开发的。

Exception Policy Violation Flagged as Compile Error
图 1.异常策略违规将生成一个错误标记

异常通知连接点

ExceptionHandling方面使用exceptionAdvicePoints()切入点来为任何能够抛出异常的执行序列应用通知。这个方面多次使用此切入点,在可能抛出异常时注入处理。切入点包含如下连接点:

  • 所有方法执行
  • 所有构造方法执行
  • 所有对象初始化
  • 所有对象预初始化
  • 所有类初始化

由于ExceptionHandling方面具有自己的方法、自己的构造方法,也要经历类和对象的初始化过程,上述连接点中有一些就处于这个方面之中。通常这不是什么好事,会在方面尝试发布自己的通知时引起递归循环。为避免这样的可能性,exceptionAdvicePoints()切入点明确地在上述连接点中排除了部分连接点:

  • 方面自身的词法作用域(lexical scope)内的任何连接点
  • 其子方面的词法作用域内的任何连接点
  • 通知执行控制流内的任何连接点
public abstract aspect ExceptionHandling {

        ...



    pointcut exceptionAdvicePoints():

           (execution (* *.*(..))

         || execution (*.new(..))

         || initialization(*.new(..))

         || preinitialization(*.new(..))

         || staticinitialization(*))

         && !within(ExceptionHandling+)

         && !cflow(adviceexecution());



        ...

}

清单 2. 异常通知点

现在,ExceptionHandling方面能够为自身以外的所有应用程序组件应用与异常相关的通知。不会尝试为那些可能是作为执行其自身通知的结果运行的方法应用通知。

运行时异常转换

如果一个执行序列抛出了任何类型的Throwable,“抛出后”通知将运行。如果异常是FaultException或ContingencyException,通知不会采取任何措施。否则,通知会将违规的异常替换为FaultException的一个新实例,将未被捕获的异常作为诱因。请注意,方面的编译时异常策略实施简化了通知必须进行的检查。

public abstract aspect ExceptionHandling {

  ...



  after() throwing(Throwable throwable):exceptionAdvicePoints(){

    if (!(throwable instanceof FaultException || 

          throwable instanceof ContingencyException)) {

        throw new FaultException("Unhandled exception: ",

            throwable);

    }

  }



  ...

}

清单 3.运行时异常转换

页面: 1, 2

下一页 »