UML软件工程组织

AOP@Work: 设计切入点来避免模式密集
作者:Wes Isberg

在“JUnit: A Cook's Tour”一文中,作者 Erich Gamma 和 Kent Beck 讨论了 JUnit 的设计。他们指出,与很多成熟框架中的关键抽象一样,TestCase 也有很高的模式密集,易于使用而难以修改。在 AOP@Work 系列的第四期文章中,Wes Isberg 重温了 Cook's Tour,说明如何通过使用 AOP 切入点设计来代替面向对象设计,在一定程度上避免导致成熟的设计难以修改的模式密集。

即使是最好的 Java™ 程序,也会随着时间的推移而老化。为了满足新的需求,设计也在不断演化,关键对象承担着各种模式角色,直到它们变得难以使用或者难以修改,最终不得不重构或者重写系统。面向方面的编程(AOP)提供了一些将特性结合起来提供服务的更优雅的方法,这些方法可以减少交互、降低工作量、延长设计和代码的寿命。

本文将分析 Erich Gamma 和 Kent Beck 在“JUnit: A Cook's Tour”)一文中提出的设计。对于他们提出的每种 Java 模式,都给出一种 AspectJ 替代方案,并说明这种方案是否满足下列标准设计目标:

  • 功能性:提供的服务是否强大、有用?
  • 可用性:客户能否方便地得到服务?
  • 可扩展性:程序变化时是否容易扩展或者调整?
  • 结合(分解)性:能否与其他部分协作?
  • 保护:面对运行时错误或者级联错误,如何保障 API 的安全?
  • 可理解性:代码是否清晰易懂?

设计的每一步中,Gamma 和 Beck 都面临着两难选择,比如可用性与可维护性、可理解性与结合性。在所有的选择中,他们采取的都是简单可用的路线,即便这意味着要放弃次要的目标。因此,他们的设计使得编写单元测试变得很容易。但我还是要问一问,如果使用 AOP 的话,能否避免其中一部分设计取舍呢?

这样问也许看起来不够通情达理,有些过于苛求。JUnit 把自己的工作做得很好,设计中的取舍被很多开发人员所了解,并认为是很正常的做法。要看看 AOP 能否做得更好,我必须问自己一些问题,比方说,能否增加更多的特性,使其更适合那些需要更多服务但不能满足 JUnit 最起码要求的客户。我这样做不是为了改变 JUnit,而是要在达到主要目标的同时不放弃次要的设计目标。

本文中所有的例子都使用了 AspectJ,但也可用于其他 AOP 方法,而且即使刚接触 AspectJ,这些例子也很容易理解。(事实上,阅读过 Cook's Tour 或者了解设计模式,可能要比您使用过 AspectJ 或 JUnit 更有帮助。)要下载本文中的源代码,请单击页面顶部或底部的 代码 图标(或请参阅下载)。

 

使用 Command 模式还是进行假设?
下面是 Gamma 和 Beck 写在“JUnit: A Cook's Tour”开头的一段话:

测试用例通常存在于开发人员的脑子里,但实现起来有不同的方式,如打印语句、调试器表达式、测试脚本。如果想要让测试处理起来更容易,则必须使它们成为对象。

为了使测试成为对象,他们使用了 Command 模式,该模式“将请求封装成对象,从而可以……建立请求队列或者记录请求”。能不能再简单一点呢?

既然焦点是可用性,有点奇怪的是,Gamma 和 Beck 也了解开发人员可以用不同的方式编写测试,但他们却坚持认为,开发人员应该只用一种方式编写测试,即封装成一个对象。为什么这样做呢?为了让测试使用起来更容易。可难就难在:要享受服务的好处,就必须按照这种形式。

这种权衡影响了设计的成形和演化。可以以特定客户机为目标,按照某种可用性和能力的组合来构建系统。如果客户机改变了,那么可以增加一些层次或者改变可用性与能力的组合,每次都要使用和围绕着已经建立的系统。幸运的话,系统可能有足够的灵活度,这个演化的过程最终会集中到客户机解决方案上。Gamma 和 Beck 用模式密集 来表达这种集中:

一旦发现真正要解决的问题,就可以开始“压缩”解决方案,形成一个越来越密集的在此起决定作用的模式场。

设计造成的模式密集
将测试用例确定为关键抽象并使用 Command 封装它之后,Cook's Tour 进一步确定了新的需求,为表示这一关键抽象的对象增加了新的特性。下面的图示对此做作了很好的说明:

图 1. JUnit 模式框图
JUnit 模式框图

Gamma 和 Beck 遵循了(或者应该说指引着)现在的标准设计过程:发现关键抽象,并将它们封装到对象中,添加模式来安排对象担任的角色和提供的服务。不幸的是,正是这些造成了模式密集。关键抽象的职责和关系在不断增加,直到像步入中年的父母一样只能按照老套的习惯行事。(如果需求超过了它们的能力,那么它们随后会陷入困境。)

给定一个测试用例……
AOP 提供了描述抽象的另一种方法:说明连接点的切入点。连接点 是程序执行中可以有效连接行为的点。连接点的类型取决于 AOP 的方式,但所有连接点在一般程序修改中都应该是稳定的,容易作出有意义的说明。可以使用切入点 指定程序的连接点,用通知(advice)指定连接的行为。通知就是陈述“如果 X,则 Y”的一种方式。

Command 模式说,“我不关心运行的代码是什么,把它放在该方法中就行了。”它要求将代码放在命令类的命令方法中,对于 JUnit 而言,该命令方法是 TestrunTest() 方法,如 TestCase

public class MainTest extends TestCase {
  public void runTest(...) {...}
}

相反,切入点说“让某个连接点作为测试用例吧。”它只要求测试用例是某个 连接点。不需要将代码放在特定类的特定方法中,只需要用切入点指定一个测试用例:

pointcut testCase() : ... ;

比如,可以将测试用例定义为 Runnable.run() 或main 方法,当然也可以使用 JUnit 测试:


pointcut testCase() : execution(void Runnable+.run());
pointcut testCase() : execution(static void main(String[]));
pointcut testCase() : execution(public void Test+.run(TestResult));
pointcut testCase() : execution(public void TestCase+.test*());

切入点的可用性
切入点的可用性非常突出。在这里,只要测试能够被切入点选择,就可以作为测试用例,即使它不是作为测试编写的。如果能够通过通知而不是通过 API 提供服务,那么就可以减少开发人员在这些服务上的工作量。

通过 AOP,开发人员不需要做什么就能提供服务。这就产生了一种新的 API 客户机:不需要了解它,但可以为它提供服务,或者说它依赖于该服务。对于一般的 API,客户机和提供者之间有明确的契约和调用的具体时间。而对于 AOP,更像是人们依赖于政府的方式:无论叫警察、到 DMV 登记,还是吃饭或者上银行,人们都(无论是否意识到)仰仗于规则,在规定好的点(无论是否明确)上操作。

将 AOP 纳入视野之后,可用性就变成了一个更广泛的连续体,从 API 契约到基于容器的编程模型,再到 AOP 的多种形式。可用性的问题,也从服务接口对客户机公开了多少功能,转变成了客户机希望或需要对服务了解多少以及如何选择(无论是司机、骗子,还是应聘者)。

可重用性
与方法一样,也可以将切入点声明成抽象的;即在通知中使用切入点,但是让子方面具体声明它。通常,抽象切入点规定的不是具体的时间或地点(如宾夕法尼亚大道 1600 号,星期二),而是很多人感兴趣的一般事件(如选举)。然后可以说明关于此类事件的事实(如,“选举中,新闻机构……”,或者“选举后,胜利者……”),用户可以指定某项选举的时间、地点和人物。如果将测试用例作为抽象切入点,那么我敢说,很多测试装置的特性都能用“如果 X,则 Y”的形式表示,而且不需要知道如果 X 的很多细节,就能够编写大多数则 Y 的结论。

如何使用通知来实现特性,同时又避免模式密集带来的危险呢?在类中添加新特性时,每个新成员都能看到其他可见的成员,这增加了理论上的复杂性。相反,AspectJ 最大限度地减少了通知之间的交互。一个连接点上的两个通知彼此是不可见的,它们都只绑定在它们声明的连接点上下文变量中。如果一个通知影响到另一个通知,并且需要排序,那么我可以规定它们的相对优先级,而不需要知道所有的通知并规定完整的顺序。每个通知都使用最少的连接点信息,仅透漏类型安全、异常检查等必需的自身信息。(AspectJ 在 AOP 技术中差不多是惟一支持这一级别的封装的。)由于减少了交互,与在类中添加成员相比,向连接点添加通知所增加的复杂性要小得多。

至于 Cook's Tour 的其他部分,我使用 testCase() 切入点实现了 Gamma 与 Beck 添加到 TestCase 中的特性。在其中的每一步中,我都努力避免他们必须要做的那些取舍,评估顺序对连接点是否重要,避免对连接点上下文作出假设,支持能够想到的各种 API 客户机。

是使用模板方法还是使用 around 通知?
使用 Command 封装测试代码之后,Gamma 和 Beck 认识到使用某种通用数据装置测试的一般流程:“建立数据装置、对装置运行某些代码并检查结果,然后清除装置”。为了封装该过程,他们使用了 Template Method 模式:

该模式的目的是,“定义操作中算法的框架,将某些步骤推迟到子类中。Template Method 允许子类重定义算法中的某些步骤,而不需要改变算法的结构。”

在 JUnit 中,开发人员使用 setUp()cleanUp()TestCase 管理数据。JUnit 设施负责在运行每个测试用例之前和之后调用这些方法;TestCase 使用模板方法 runBare() 来实现这一点:

public void runBare() throws Throwable {
  setUp();
  try {
    // run the test method 
    runTest();
  } finally {
    tearDown();
  }
}

在 AspectJ 中,如果代码需要在连接点之前和之后运行,可以结合使用 before 通知和 after 通知,或者像下面这样单独使用 around 通知:

/** around each test case, do setup and cleanup */
Object around() : testCase() {
  setup(thisJoinPoint);
  try {
    // continue running the test case join point
    return proceed();
  } finally {
     cleanup(thisJoinPoint);
   }
}       
protected void setup(JoinPoint jp) {}
protected void cleanup(JoinPoint jp) {}

这样的通知提供了三个自由度:

  • 可用于支持 around 通知的任何连接点。

  • 可用于任何类型的测试,因为对运行的代码没有任何假设。

  • 通过将装置的建立/清除代码放在可以被覆盖或者委托实现的方法中,可以适应不同类型测试对象所需的不同的装置管理方式。有些可能管理自己的数据,如 TestCase;有些可能得益于依赖性倒置(dependency inversion),在外部建立配置。

    但是,这些方法都使用 JoinPoint,在连接点提供了可用于任何上下文的 Object(可能包含 this 对象、 target 对象和任何参数)。使用 JoinPoint 将使 Object 向下强制转换成实际的类型,从而获得了类型安全的一般性。(下面我将介绍一种不损失一般性而获得类型安全的方法。)

通知提供了和 Template Method 相同的保证但没有 Java 实现的约束。在 JUnit 中,TestCase 必须控制命令方法来实现模板方法,然后为实现真正的测试还要委派给另一个方法,为 command 代码创建 TestCase 专用的协议。因此,虽然 Command 使得测试很容易操纵,command 契约对开发人员而言实际上从 TestTestCase 是不同的,因而使得 API 的职责更难以理解。

使用 Collecting Parameter 还是使用 ThreadLocal?
Cook's Tour 继续它的漫步:“如果 TestCase 在森林中运行,那么谁还关心它的结果呢?”当然,Gamma 和 Beck 的回答是:需要记录失败和总结经验。为此,他们使用了 Collecting Parameter 模式:

如果需要收集多个方法的结果,应该在方法中添加一个参数传递收集结果的对象。

JUnit 将结果处理封装在一个 TestResult 中。从这里,订阅者可以找到所有测试的结果,测试装置可以在这里管理需要的结果集合。为了完成采集工作,Template Method TestResult.runProtected(..) 将测试执行放在 startend 辅助调用(housekeeping call)之间,把抛出的异常解释为负的测试结果。

结合性
现在有了 N>1 个模式,模式实现之间的交互如何呢?如果对象可以很好地协作,则称为可结合的。类似地,模式实现可能直接冲突(比如两者需要不同的超类)、并存但不交互,或者并存且以或多或少富有成效的方式进行交互。

在 JUnit 中,装置关注点和结果收集关注点的相互作用形成了 TestCaseTestResult 共享的调用顺序协议,如下所示:

Test.runTest(TestResult) calls...
  TestResult.run(TestCase) calls...
    TestResult.runProtected(Test, Protectable) calls...
      Protectable.protect() calls...
        TestCase.runBare() calls...
          Test.runTest() ...
          (TestCase.runTest() invokes test method...)

这表明模式密集使得代码很难修改。如果要修改装置模板方法或者收集参数,就必须在 TestResultTestCase (或者子类)中同时修改二者。另外,因为测试装置的 setUp()cleanUp() 方法在结果处理(result handling)的受保护上下文中运行,该调用序列包含了设计决策:装置代码中抛出的任何异常都视作测试错误。如果希望单独报告装置错误,那么不但要同时修改两个组件,还必须修改它们相互调用的方式。AspectJ 能否做得更好一点呢?

在 AspectJ 中,可以使用通知提供同样的保证但避免了锁定调用的顺序:

/** Record test start and end, failure or error */
void around(): testCase() {
  startTest(thisJoinPoint);
  try {
    proceed();
    endTest(thisJoinPoint);
  } catch (Error e) {
    error(thisJoinPoint, e);
  } catch (Exception e) {
    failure(thisJoinPoint, e);
  }
}

与上述的装置处理通知一样,这可以用于任何类型的测试或者结果收集,但实现该方法需要向下类型转换。这一点将在后面进行修正。那么该通知如何与装置通知交互呢?这依赖于首先运行的是什么。

谁先开始?
在 JUnit 中,结果收集和装置管理的模板方法必须(永远?)按照固定的调用顺序。在 AspectJ 中,大量通知可以在一个连接点上运行,而无需知道该连接点上的其他通知。如果不需要交互,那么可以(应该)忽略它们运行的顺序。但是,如果知道其中一个可能影响另一个,则可使用优先级控制运行的顺序。本例中,如果赋予结果处理通知更高的优先级,那么连接点在运行的时候,结果处理通知就会在装置处理通知之前运行,可以调用 proceed(..) 来运行后者,最后再收回控制权。下面是运行时的顺序:

# start running the join point
start result-handling around advice; proceed(..) invokes.. 
  start fixture-handling around advice; proceed(..) invokes.. 
    run underlying test case join point
  finish fixture-handling around advice
finish result-handling around advice
# finish running the join point

如果需要,可以显式控制两个通知的优先级,不论通知是否在相同或不同的方面中,甚至是来自其他方面。在这里因为顺序决定了装置错误是否作为测试错误报告的设计决策,可能希望显式设置优先级。我也可以使用单独的方面声明装置错误的处理策略:

aspect ReportingFixtureErrors {
  // fixture errors reported by result-handling 
  declare precedence: ResultHandling+, FixtureHandling+;
}

这两个 Handling 方面不需要知道对方的存在,而两个 JUnit 类 TestResultTestCase,必须就谁首先运行命令达成一致。如果以后要改变这种设计,只需要修改 ReportingFixtureErrors 即可。

Collecting Parameter 的可用性
多数 JUnit 测试开发人员都不直接使用 TestResult,就是说在调用链的每个方法中要作为参数来传递它,Gamma 和 Beck 称之为“签名污染”。相反,他们提供了 JUnit 断言来通知失效或者展开测试。

TestCase 扩展了 Assert,后者定义了一些有用的 static assert{something}(..) 方法,以检查和记录失效。如果断言失败,那么这些方法将抛出 AssertionFailedErrorTestResult 在结果处理装置模板方法中捕获这些异常并进行解释。这样,JUnit 就巧妙地回避了 API 用户来回传递收集参数的关注点,让用户忘掉了 TestResult 的要求。JUnit 将结果报告关注点和验证与日志服务捆绑在了一起。

捆绑
捆绑使用户更难于选择需要的服务。可以使用 Assert.assert{something}(..)TestCase 绑到 TestResult 上,进一步限制收集参数的灵活性。这样对测试增加了失效实时处理(fast-fail)语义, 即使有些测试可能希望在确认失效后继续执行。为了直接报告结果, JUnit 测试可以实现 Test,但这样就失去了 TestCase 的其他特性(可插接的选择器、装置处理、重新运行测试用例等)。

这是模式密集的另一个代价:API 用户常常被迫接受或者拒绝整个包。另外,虽然将问题捆绑到一起可能比较方便,但有时候会降低可用性。比如,很多类或方法常量首先作为 JUnit 断言写入,如果不自动触发异常这些常量,则可以在产品诊断中重复使用它们。

如上所述,AspectJ 可以支持 JUnit 断言风格的结果处理,但能否在支持希望得到直接结果收集的灵活性的 API 用户的同时,又单独决定何时展开测试呢?甚至允许用户定义自己的结果收集器报告中间结果?我认为能够做到。这一种解决方案包括四部分:(1) 支持结果收集器的工厂;(2)组件在不污染方法签名的情况下使用结果收集器;(3) 可以在直接报告给结果收集器后展开测试;(4) 保证正确报告抛出的异常。撰写 Cook's Tour 的时候这些还很难做到这些,但是现在有了新的 Java API 和 AspectJ,所以这一切都变得很容易。

ThreadLocal 收集器
为了让所有组件都能使用结果收集器和实现工厂,我使用了一个公共静态方法来获得线程本地(thread-local)结果收集器。下面是 TestContext 结果收集器的框架:

public class TestContext {
  static final ThreadLocal<TestContext> TEST_CONTEXT 
    = new ThreadLocal<TestContext>();

  /** Clients call this to get test context */
  public static TestContext getTestContext(Object test) { 
    ...     
  }
  ...
}    

方法 getTestContext(Object test) 可支持结果收集器和测试之间的不同联系(每个测试、每个套件、每个线程、每个 VM),但 TestContext 的子类型需要向下强制转换,不支持其他类型。

展开测试
抛出异常不仅要展开测试,还将报告错误。如果测试客户机直接使用 getTestContext(..) 通知错误,那么需要展开测试而不是报告更多的错误。为此,需要声明一个专门的异常类,指出已经告知结果。API 契约方式需要定义抛出异常的客户机和捕捉异常的装置都需要知道的类。为了向客户机隐藏类型细节,可以像下面这样声明一个返回用户抛出的异常的方法:

public class TestContext {
  ...
  public Error safeUnwind() { 
    return new ResultReported();
  }

  private static class ResultReported extends Error {}
}

然后测试抛出 TestContext 定义的所有异常:


 public void testClient() { 
    ...
    TestContext tc = TestContext.getTestContext(this);
    tc.addFailure(..);
    ..
    throw tc.safeUnwind(); // could be any Error
  }
}

这样就把测试和 TestContext 绑定到了一起,但是 safeUnwind() 仅供那些进行自己的结果报告的测试使用。

保证异常被报告
下面是为 TestContext 收集结果的通知。这个通知具有足够的通用性,可用于不同的测试用例和不同的 TestContext 子类型:

/** Record for each test start and end or exception */
void around() : testCase() {
  ITest test = wrap(getTest(thisJoinPoint));          
  TestContext testContext = TestContext.getTestContext(test); 
  testContext.startTest(test);
  try {
    proceed();
    testContext.endTest(test);
  } catch (ResultReported thrown)  {
    testContext.checkReported(test);
  } catch (Error thrown) { 
    testContext.testError(test, null, thrown);
  } catch (Throwable thrown) {
    testContext.testFailure(test, null, thrown);
  }
}

protected abstract Object getTest(JoinPoint jp);

因为该通知加强了 TestContext 的不变性,所以我把这个方面嵌套在 TestContext 中。为了让装置开发人员指定不同的测试用例,切入点和方法都是抽象的。比如,下面将其用于 TestCase

aspect ManagingJUnitContext 
  extends TestContext.ManagingTestResults {
    
  public pointcut testCase() : within(testing.junit..*) 
    && execution(public !static void TestCase+.test*());

  protected Object getTest(JoinPoint jp) {
    assert jp.getTarget() instanceof TestCase;
    return jp.getTarget();
  }
}

我在一个重要的地方限制了这一解决方案:around 通知声明它返回 void。如果我声明该通知返回 Object,就可以在任何连接点上使用该通知。但是因为要捕获异常需要正常返回,我还需要知道返回的是什么 Object。我可以返回 null 然后等待好消息,但我更愿意向任何子方面表明该问题,而不是等它在运行时抛出 NullPointerException

虽然声明 void 限制了 testCase() 切入点的应用范围,但是这样降低了复杂性,增强了安全性。 AspectJ 中的通知具有和 Java 语言中的方法相同的类型安全和异常检查。通知可以声明它抛出了一个经过检查的异常,如果切入点选择了不抛出异常的连接点,那么 AspectJ 将报告错误。类似地,around 通知可以声明一个返回值((上面的“void”),要求所有链接点具有同样的返回值。最后,如果通过绑定具体的类型来避免向下类型转换(比如使用 this(..),参见后述),那么就必须能够在连接点上找到这种类型。这些限制保证了 AspectJ 通知和 Java 方法具有同样的构建时安全性(不同于基于反射或代理的 AOP 方法)。

有了这些限制,就可以同时支持有客户机控制的和没有客户机控制这两种情况下的结果收集,不必依赖客户机来加强不变性。无论对于新的客户机类型、新的结果收集器类型,还是和 TestContext 类及其子类型的何种交互,这种解决方案都是可扩展的。

Adapter、Pluggable Selector 还是配置?
Cook's Tour 提出用 Pluggable Selector 作为由于为每个新测试用例创建子类造成的“类膨胀”的解决方案。如作者所述:

想法是使用一个可参数化的类执行不同的逻辑,不需要子类化……Pluggable Selector 在实例变量中保存一个……方法选择器。

于是,TestCase 担负了使用 Pluggable Selector 模式将 Test.run(TestResult) 转化成 TestCase.test...()Adapter 角色,可以用 name 字段作为方法选择器。TestCase.runTest() 方法反射调用和 name 字段对应的方法。这种约定使得开发人员通过添加方法就能增加测试用例。

这样方便了 JUnit 测试开发人员,但是增加了装置开发人员修改和扩展的难度,为了适应 runTest(),构造函数 TestCase(String name) 的参数必须是不带参数的公共实例方法的名称。结果,TestSuite 实现了该协议,因此如果需要修改 TestCase.runTest() 中的反射调用,就必须修改 TestSuite.addTestSuite(Class),反之亦然。要基于 TestCase 创建数据驱动或规格驱动的测试,就必须为每种配置创建单独的套件,在套件名中包含配置,用 TestSuite 定义后配置每个测试。

配置连接点
AspectJ 能否更进一步出来测试,而不仅仅是选择处理测试配置呢?在一个连接点上配置测试有两种方法。

首先,可以通过改变连接点上的上下文来直接配置连接点,如方法参数或者执行对象本身。执行 main(String[]) 方法的一个简单例子是用不同的 String[] 数组生成一些测试,并反复运行连接点。稍微复杂一点的,可以结合使用连接点上两类不同的变量。下面的通知将检查测试能否在所有彩色和单色打印机上工作:

void around(Printer printer) : testCase() && context(printer) {
  // for all known printers...
  for (Printer p : Printer.findPrinters()) {
    // try both mono and color...
    p.setMode(Printer.MONOCHROME);
    proceed(p);
    p.setMode(Printer.COLOR);
    proceed(p);
  }
  // also try the original printer, in mono and color
  printer.setMode(Printer.MONOCHROME);
  proceed(printer);
  printer.setMode(Printer.COLOR);
  proceed(printer);
}

虽然这段代码是针对 Printer 的,但无论测试的是打印还是初始化,无论 Printer 是方法调用的目标还是方法参数,都没有关系。因此即使通知要求某种具体的类型,这或多或少与引用来自何处是无关的;这里通知将连接点和如何获得上下文都委派给了定义切入点的子方面。

配置测试的第二种方法(更常用)是对测试组件使用 API。Printer 的例子说明了如何明确设置模式。为了更一般化,可以支持泛化的适配器接口 IConfigurable,如下所示:

public abstract aspect Configuration {

  protected abstract pointcut configuring(IConfigurable s);

  public interface IConfigurable {
    Iterator getConfigurations();
    void configure(Object input);
  }

  void around(IConfigurable me) : configuring(me) {
    Iterator iter = me.getConfigurations();
    while (iter.hasNext()){
      me.configure(iter.next());
      proceed(me);
    }
  }
}

该通知只能用于某些上下文是 IConfigurable 的情况,但是如果能运行,那么可以运行底层连接点多次。

如何与连接点上的其他测试类型、其他通知、运行该连接点的其他代码交互呢?对于测试而言,如果测试不是 IConfigurable 的,那么该通知将不运行。这里没有矛盾。

对于其他通知,假设将 configuring() 定义为 testCase() 并包括其他的通知,因为这样可以高效地创建很多测试,结果和装置通知都应该有更低的优先级,以便能够管理和报告不同的配置与结果。此外,配置应该以某种形式包含在结果收集器用来报告结果的测试标识中;这是那些知道测试可配置、可标识的组件的职责(下面一节还将进一步讨论这些组件)。

对于运行连接点的代码,与通常的 around 通知不同的是,它对每个配置都调用 proceed(..) 一次,因此底层连接点可运行多次。在这里通知应该返回什么结果呢?与结果处理通知一样,我惟一能确定的是 void,因此,我限制该通知返回 void,并把这个问题交给编写切入点的测试开发人员。

各取所需
假设我是一位装置开发人员,需要调整来适应新的测试,如果必须在测试类中实现 IConfigurable,那么 看起来似乎不得不增加测试的“模式密集”。为了避免这种情况,可以在 AspectJ 中声明其他类型的成员或者父类,包括接口的默认实现,只要所有定义保持二进制兼容即可。使用内部类型声明增加了通知的类型安全,从而更容易避免从 Object 的向下类型转换。

是不是像其他成员那样增加了目标类型的复杂性呢?其他类型的公共成员声明是可见的,因此在理论上可能增加目标类型的复杂性。但是,也可以将这些成员声明为某个方面私有的其他类型,因此,只有这个方面才能使用它们。这样就可以装配组合对象,而不会造成把所有成员在类中声明可能造成的一般冲突和交互。

下面的代码给出了一个例子,用 init(String) 方法使 Run 适应于 IConfigurable


public class Run {

  public void walk() { ... }

  public void init(String arg) { ... }
}


public aspect RunConfiguration extends Configuration {

  protected pointcut configuring(IConfigurable s) : 
    execution(void Run+.walk()) && target(s);
    
  declare parents : Run implements IConfigurable;

  /** Implement IConfigurable.getConfigurations() */
  public Iterator Run.getConfigurations() {
    Object[] configs = mockConfigurations();
    return Arrays.asList(configs).iterator();
  }

  /** Implement IConfigurable.configure(Object next) */
  public void Run.configure(Object config) {
    // hmm - downcast from mockConfigurations() element
    String[] inputs = (String[]) config; 
    for (String input: inputs) {
      init(input);
    }
  }

  static String[][] mockConfigurations() {
      return new String[][] { {"one", "two"}, {"three", "four"}};
  }
}

测试标识符
测试标识符可由结果报告、选择或配置以及底层的测试本身共享。在一些系统中,只需要告诉用户哪些测试正在运行即可;在另外一些系统中,需要一个惟一的、一致的键来说明那些失败的测试获得通过(bug 修正),哪些通过的测试失败了(回归)。JUnit 仅提供了一种表示,它绕过了共享的需要,使用 String Object.toString() 来获得 String 表示。AspectJ 装置也可作同样的假设,但是也可用上面所述的 IConfigurable 来补充测试,根据系统需要为给定类型的测试计算和存储标识符。“相同的”测试可以根据需要来配置不同的标识符(比如,用于诊断和回归测试的标识符),这减少了 Java 语言中模式密集可能造成的冲突。虽然配置对于方面和配置的组件是本地的(从而可以是私有的),对于很多关注点,标识符都可以是可见的,所以可以用公共接口表示它。

使用组合还是使用递归?
Cook's Tour 认识到装置必须运行大量的测试——“一套一套的测试”。Composite 模式可以很好地满足这种要求:

该模式的目的是,“把对象组合到树状结构中,以表示部分-整体关系。组合使客户机能够统一地看待单个对象和对象的组合。”
Composite 模式引入了三个参与方:Component、Composite 与 Leaf。Component 声明了我们希望用来与测试交互的接口。Composite 实现了该接口,并维护一个测试集合。Leaf 表示 Composite 中的测试用例,该用例符合 Component 接口。

这就形成了 JUnit 设计环,因为 Test.runTest(..) Command 接口是 Leaf TestCase Composite TestSuite 实现的 Component 接口。

可维护性
Cook's Tour 支出,“应用 Composite 时,我们首先想到的是应用它是多么复杂。”该模式中,节点和叶子的角色被添加到已有的组件上,并且它们都需要知道自己在实现组件接口时的职责。它们之间定义了调用协议,并由节点实现,节点也包含子节点。这意味着节点知道子节点,同时装置也知道节点。

在 JUnit 中,TestSuite(已经)非常了解 TestCase,JUnit 测试运行者假设要通过加载 suite 类来生成一个套件。从配置中可以看到,支持可配置的测试需要管理测试套件的生成。组合增加了模式密集。

Composite 模式在 AspectJ 中可使用内部类型声明实现,如上面关于配置的一节所述。在 AspectJ 中,所有成员都是在一个方面中声明的,而不是分散在已有的类中。这样更容易发现角色是否被已有类的关注点污染了,在查看实现的时候也更容易了解这是一个模式(而不仅仅是类的另一个成员)。最后,组合是可用抽象方面实现的模式之一,可以使用标签接口来规定担任该角色的类。这意味着可以编写可重用的模式实现。(关于设计模式的 AspectJ 实现的更多信息,请参阅 Nicholas Lesiecki 所撰写的“Enhance design patterns with AspectJ”,参见 参考资料。)

递归
AspectJ 能否不借助 Composite 模式而满足原来的需要呢?AspectJ 提供了运行多个测试的很多方法。上面关于配置的例子是一种方法:把一组子测试和一个测试关联,使用通知在切入点 recursing() 选择的连接点上递归运行各个成分。该切入点规定了应该递归的组合操作:

// in abstract aspect AComposite

/** tag interface for subaspects to declare */
public interface IComposite {}

/** pointcut for subaspects to declare */
protected abstract pointcut recursing(IComposite c);

/** composites have children */
public ArrayList<IComposite> IComposite.children 
    = new ArrayList<IComposite>();

/** when recursing, go through all subtree targets */
void around(IComposite c) : recursing(c)  {
  // recurse...
}

下面说明了如何将该方面应用于 Run


public aspect CompositeRun extends AComposite {
  declare parents : Run implements IComposite;
  
  public pointcut recursing(IComposite c) : 
    execution(void Run+.walk()) && target(c);
}

将连接点封装为对象
在连接点上递归?这就是有趣的地方。在 AspectJ around 通知中,可以使用 proceed(..) 运行连接点的其他部分。为了实现递归,可以通过将 proceed(..) 调用封装在匿名类中来隐藏连接点的其他部分。为了在递归方法中传递,匿名类应该扩展方法已知的包装器类型。比如,下面定义了 IClosure 包装器接口,将 proceed(..) 包装到 around 通知中,并把结果传递给 recurse(..) 方法:

// in aspect AComposite...


/** used only when recursing here */
public interface IClosure {
    public void runNext(IComposite next);
}

/** when recursing, go through all subtree targets */
void around(IComposite c) : recursing(c)  {
  recurseTop(c, new IClosure() {
    // define a closure to invoke below
    public void runNext(IComposite next) { 
      proceed(next); 
    }});
}

/** For clients to find top of recursion. */
void recurseTop(IComposite targ, IClosure closure) {
    recurse(targ, closure);
}

/** Invoke targ or recurse through targ's children. */
void recurse(IComposite targ, IClosure closure) {
  List children 
    = (null == targ?null:targ.children);
  if ((null == children) || children.isEmpty()) {
    // assume no children means leaf to run
    closure.runNext(targ);
  } else {
    // assume children mean not a leaf to run
    for (Object next: children) {
      recurse((IComposite) next, closure);
    }        
  }
}

使用 IClosure 可以结合 Command 模式和使用 proceed(..) 的通知的优点。与 Command 类似,它也可以用新规定的参数在运行或重新运行中传递。与 proceed(..) 类似,它隐藏了连接点中其他上下文、其他低优先级通知和底层连接本身的细节。它和连接点一样通用,比通知更安全(因为上下文更加隐蔽),并且和 Command 一样可以重用。因为对目标类型没有要求,所以,与 Command 相比,IClosure 的结合性更好。

如果逐渐习惯于封闭 proceed(..),不必感到奇怪,对许多 Java 开发人员来说,这仅仅是一种很常见的怪事。如果在连接点完成之后调用 IClosure 对象,那么结果可能有所不同

可用性
RunComposite 方面将这种组合解决方案应用于 Run 类,只需要用 IComposite 接口标记该类,并定义 recursing() 切入点即可。但是,为了将组件安装到树中,需要添加子类,这意味着某个组装器组件必须知道 Run 是带有子类的 IComposite。下面显示了组件以及它们之间的关系:

Assembler, knows about...
  Run component, and
  CompositeRun concrete aspect, who knows about...
    Run component, and
    AComposite abstract aspect

您可能希望让 CompositeRun 方面也负责发现每次运行的子类(就像带配置的组合那样),但使用单独的装配器意味着不必将 Run 组合(对于所有运行都是一样的)与 Run 组合的特定应用(随着联系子类和特定 Run 子类的方式不同而变)搅在一起。面向对象依赖性的规则依赖于稳定性的方向,特别是,(变化更多的)具体的元素应该取决于是否要完全依赖于(更稳定的)抽象成分。按照这一原则,上面的依赖性似乎不错。

结合性
与配置一样,组合通知(应用于测试用例时)应该优先于装置和结果报告。如果配置影响到测试的确认,那么组合也应该优先于配置。按照这些约束,可得到下面的顺序:

Composition      # recursion
  Configuration  # defining test, identity
    Context      # reporting results
      Fixture    # managing test data
        test     # underlying test

作为设计抽象的切入点
上面就是我对 JUnit Cook's Tour 的评述。我所讨论的所有面向方面的设计解决方案都可在本文的代码压缩包中找到。这些解决方案具有以下特点:

  • 依赖于切入点而不是类型,要么不作任何假定,要么将上下文规格推迟到具体的子方面中。
  • 都是独立的,可单独使用。
  • 可以在同一系统中多次重用。
  • 可以共同工作,有时候需要定义它们的相对优先级。
  • 不需要修改客户机部分。
  • 与 JUnit 相比,可做的工作更多,需要客户机的干预也更少。

对于给定的 Java 模式,AspectJ 提供了完成同一任务的多种方式,有时候是一个简单的短语。这里采用了在可重用解决方案中使用切入点和做最少假定的方法,这些主要是为了说明 AspectJ 如何通过封装通知和切入点来减少交互,从而很容易在连接点上改变行为。有时候,可以使用具体的(非重用的)方面,将特性组合到一个方面中;或者使用内部类型声明来实现对应的 Java 模式可能更清楚。但是这些解决方案示范了最大限度地减少一个连接点上的交互的技术,从而使将切入点用作一流设计抽象变得更简单。

切入点仅仅是减少重重假设的设计方法的第一步。应在可能不需要对象的地方真正利用切入点。如果对象是必需的,那么应该尝试在方面中使用内部类型声明来组合对象,从而使不同的(模式)角色保持区别,即使在同一个类中定义也是如此。与面向对象编程一样,应避免不同的组件了解对方。如果必须彼此了解,比较具体的一方应该知道抽象的一方,装配器应该知道各个部分。如果彼此都要知道,那么这种关系应该尽量简练、明确、稳定和可实施的。

全速 AOP
AspectJ 1.0 是三年前发布的。多数开发人员都看过并尝试了 AspectJ 的入门应用,即模块化跟踪这样的横切关注点。但是有些开发人员更进一步,尝试进行我所说的“全速 AOP”:

  • 设计失败和设计成功一样平常。
  • 重用(或者可重用)切入点这样的横切规格。
  • 方面可能有很多互相依赖的成分。
  • 方面用于倒置依赖和解耦代码。
  • 方面用于连接组件或子系统。
  • 方面打包成可重用的二进制库。
  • 系统中有很多方面。一些对另一些无关紧要,但还有一些则依赖于另一些。
  • 虽然方面可能是不可插接的,但是插入后可以增加基本功能或者结构。
  • 可以重构库代码创建更好的连接点模型。

是什么让一些开发人员裹足不前呢?在最初听说 AOP 或者学习基础知识后,似乎进入了一个平台阶段。比如进入了这样的思维陷阱:

AOP 模块化了横切关注点,因此我要在代码中寻找横切关注点。我找到了所有需要的跟踪、同步等关注点,因此不再需要做其他事了。

这个陷阱就像在面向对象编程初期单纯按照“is-a”和“has-a”思考一样。寻找单个的关注点(即使是横切关注点),就丢失了关系和协议,在规范化为模式时,关系和协议是编码实践的支柱。

另一种思维陷阱是:

AOP 模块化横切关注点。因此应该寻找那些分散和纠缠在代码中的代码。这些代码似乎都很好的本地化了,因此不需要 AOP。

虽然分散和纠缠可能是非模块化横切关注点的标志,AOP 除了收集分散的代码或者纠缠在一起的复杂方法或对象之外,还有很多用处。

最后,最难以避开的思维陷阱是:

AOP 用新的语言设施补充了面向对象编程,因此应该用于解决面向对象编程不能解决的关注点。面向对象编程解决了所有关注点,因此我不需要 AOP。

本文中没有讨论横切关注点,我重新实现的多数解决方案照目前来看都是经过很好模块化的。我给出的代码并非完全成功的(特别是与 JUnit 比较),但给出这些代码的目的并不仅仅是说明它们能够做到或者证明代码能更好地本地化,而在于提出面向对象开发人员是否必须忍受此类设计权衡的问题。我相信,如果在实现主要目标的同时能够不放弃次要目标,那么就可以避免编写难以使用或修改的代码。

结束语
重温“JUnit: A Cook's Tour”,更好地理解 AspectJ 减少和控制连接点交互的方法,这是在设计中有效使用切入点的关键。模式密集可能导致成熟的面向对象框架难以修改,但这是面向对象开发人员设计系统的方法所带来的自然结果。本文提供的解决方案,即用切入点代替对象,尽可能地避免了交互或者最大限度地减少了交互,避免了 JUnit 的不灵活性,本文还展示了您如何能够在自己的设计中做到这一点。通过避免开发人员已经逐渐认可的设计权衡,这些解决方案表明,即使您认为代码已经被很好地模块化了,AOP 仍然很有用。希望本文能鼓励您在更多的应用程序中全面使用 AOP。

下载

描述 Name Size Download method
Source code j-aopwork7code.zip 41 KB FTP

关于下载方法的信息

 

版权所有:UML软件工程组织