面向方面编程(AOP)是项大有前途的新技术,但是采用新技术可能有风险(当然,不 采用新技术也会有风险)。与所有的新技术一样,通常来说,最好是沿着一条可以管理风险的路径来采用它们。如果用 AOP 来执行策略和测试,就可以从 AOP 得到降低风险的好处。因为方面不会进入生产,所以不会出现技术破坏代码稳定性或开发过程的风险,但却会有助于开发质量更好的软件。用方面进行测试也是学习方面的工作方式,并体验这项激动人心的新技术的好方法。
组合测试方法
正如我在 第 1 部分 中讨论过的,QA 的目的不是找到所有可能有的 bug —— 因为这是不可能的 —— 而是提升我们对代码按预期工作的确信程度。对于管理有效的 QA 组织,它的挑战就是最大化所花费资源的回报,即确信度。因为所有的测试方法最终都会表现出回报消退(对于等量的付出增加,得到的确信度增加越来越少),而且不同的方法适合寻找不同类型的错误,所以把 QA 付出分散在测试、代码审查和静态分析上,要比把整个 QA 预算只花在其中一项措施上,回报要更好。
FindBugs 这样的静态分析工具是不精确的,但是不精确的分析对于提高软件质量仍然是非常有用和有效的。它们可能发出假警告,例如在无害的构造上触发警告,也可能忽略了 bug,例如没有找出与特定 bug 模式匹配的全部 bug。但是它们仍然能发现真正的 bug,而且只要误报率没有高到让用户厌烦的程度,那么它们仍然对测试付出提供了有价值的回报。
从测试的角度来说,使用 AOP 来验证设计规则与使用静态分析有许多共同之处。静态分析和面向方面编程都不用为了特定的方法或类设计测试用例,而是都鼓励找出违犯规则的全部分类,并创建能够发现代码体中任何违规的工件。另一个相似性就是它们不必非常完善也能够发挥作用;尽管 bug 探测器或测试方面都不能找出所有可能的 bug,甚至有些会发出假警告,它们仍然是非常有用的工具,可以验证代码是否按期望的那样工作。有些 bug 模式用静态工具更容易找出,而另一些用方面会更容易找出 —— 这使得方面成为参与 QA 过程的一个有用的方法。
回页首
简单的测试方面
FindBugs 这样的静态分析工具审计代码但不执行代码;面向方面的工具既提供静态类工具,也提供动态类工具。静态方面可以生成编译时警告或错误;动态方面可以把错误检测代码插入类。
在 第 1 部分 中,我提供了一个简单的 FindBugs 探测器,查找可能潜伏在库中的对 System.gc() 的调用。静态分析能探测的许多 bug 模式(包括这个模式)也能被方面探测到;根据具体的 bug 模式,用静态分析或用方面来做可能会更容易,所以把它们都放在工具库中,可以提高效果。
清单 1 显示了一个简单的动态方面,在要调用 System.gc() 时,抛出 AssertionError。(因为这类 bug 探测器的一个重要作用是不仅要找到您自己代码中的错误,还要找到代码依赖的库中的错误,所以可能需要告诉工具还要分析或处理这些库。)
清单 1. 执行 “不调用 System.gc()” 规则的动态方面
清单 1 演示的动态方式不如使用静态分析进行测试有效,因为它要求程序在方面发现问题之前,实际地执行对 System.gc() 的调用,而不是程序只需包含一个对 System.gc() 的调用,就会被探测到。但是,很快就会看到,动态方面更灵活,因为它们能在方面触发的点上执行任意测试代码,从而对声明的问题提供更精细的控制。
也可以容易地创建一个静态方面,在编译时识别对 System.gc() 的调用,如清单 2 所示。同样,如果想发现在库代码中出现的这个 bug 模式,不仅要处理项目中的代码,还要处理它使用的库。
清单 2. 执行 “不调用 System.gc()” 规则的静态方面
回页首
检查对 Swing 单线程规则的违犯
有一个几乎无法静态地实施的规则是线程限制 —— 指定的对象只能从一个线程访问(有时是特定线程,例如 Swing 事件线程)。Swing 程序的正确性依赖于线程限制,但是对于实施这个规则,从编译器、运行时或类库都得不到任何帮助。如果违犯了这个规则,程序就会被破坏,但是因为在测试时程序可能看起来工作正常,所以问题可能一直暴露不了。
Swing 单线线程规则指定:
Swing 组件和模块只应当从事件分派线程中创建、修改和实现。
单线程规则的早期描述允许 Swing 组件和模块在屏幕上出现之前,由其他线程访问,但是这种方式带来了线程安全问题,所以规则被强化了。使用 SwingUtilities.isEventDispatchThread() 方法,Swing 提供了一个机制,询问 “当前线程是不是事件分派线程?”。所以需要在每个 Swing 的方法调用之前插入代码,检查调用是否是由合适的线程发出的,如果不是由合适线程发出的,就抛出 AssertionError,这样就能在测试中捕捉到对单线程规则的违犯,而不会让它们在生产中造成莫名其妙的故障。
清单 3 演示了一个可以检测许多单线程规则违犯的方面:它有两部分:不应当从事件线程之外调用的方法的列表,以及要插到对这些方法的调用之前的代码。建议(要插入的代码)非常简单:检查当前线程是否是事件线程,如果不是,就抛出 AssertionError。这个方面处理了对 Swing 包中的所有方法以及扩展了最重要的 Swing 类的那些类中的方法的全部调用(以便捕捉用户提供的组件和模块),但是它排除了这些类中已知为可以安全地(或者需要)从多线程调用的方法。安全方法列表并不全面;构建一个全面的列表可能要花费一些额外时间研究 Javadoc,找到所有标记为线程安全的方法。
清单 3. 实施 Swing 的单线程规则的方面
swingMethods() 切入点包含对 javax.swing 包中的所有方法(包括构造函数)的调用。extendsSwing() 切入点代表对所有扩展自 JComponent 或任何 Swing 模型类的类中方法的全部调用。safeMethods() 切入点代表一些已知可以从任何线程安全调用的 Swing 方法。
SwingThreadAspect 并不完美,但是足够了。safeMethods() 切入点没有完全枚举线程安全方法,而且 extendsSwing() 切入点可能也没有包含所有经常被扩展的 Swing 类。但是我们不会把它们用于生产 —— 只是用它们进行测试。它能够不必为每个程序创建新的测试用例就发现 bug,而这就是它的价值所在。而且,像大多数 bug 探测器一样,它可能会在以前以为是正确的程序中找到 bug。
回页首
在调试对象中切换
方面的另一个好应用就是在类的正式版本和 “调试” 版本之间进行切换。创建一个类的调试版本是相当普遍的情况,例如创建一个带有更多日志或错误检测的版本,这个版本因为副作用或性能问题而不适合在生产中使用。但是在需要的时候在调试版本中切换,会很烦琐或者容易出错。如果对象是通过构造函数实例化的,就不得不在代码中找到所有调用构造函数的地方。缓解修改所有构造函数调用的不方便性的一种常用技术是,改用工厂来实例化对象,但是只为了在生产版本和调试版本之间进行选择而使用工厂,会增加复杂性或带来安全漏洞。
如果目的是为了 “在所有实例化 Foo 的地方,都换成实例化 DebuggingFoo”,那么方面为做这件事提供了非常可靠且不需要修改程序的简单机制。作为示例,清单 4 显示了一个方面,它有助于发现死锁,把 ReentrantLock 的所有实例化都替换成 DebuggingLock。(请注意,AspectJ 只修改要求 AspectJ 编译器处理的代码中的调用;Java™ 类库本身中对 ReentrantLock 的实例化不会被替换,除非特意把方面编织到平台库中。)
清单 4. 把所有 ReentrantLock 的实例化替换成 DebuggingLock 的方面
在 Java SE 6 中,运行时对请求执行死锁检测,通过 java.lang.management 中的 ThreadMXBean 接口,或者在请求线程转储时执行。清单 5 显示了 DebuggingLock 的一个可能实现,每次请求锁时,都执行死锁检测,所以可以更迅速地得到死锁的警告。锁定性能要比 ReentrantLock 差,因为每次试图锁定时要做更多的工作,所以这种方式可能不适合在生产中使用。(而且,维护 waitingFor 数据结构时自带的同步,可能会干扰应用程序的计时,从而改变死锁的可能性。)
清单 5. ReentrantLock 的调试版本会在检测到死锁时抛出 AssertionError
要让清单 5 中的 DebuggingLock 版本有帮助,程序必须在测试时实际地发生死锁。因为死锁通常依赖于计时和环境,所以清单 5 中的方法可能还不够。清单 6 显示了另一个版本的 DebuggingLock,它不仅判断是否发生死锁,还会判断给定的一对锁是否由多个线程在不一致的顺序下得到。每次得到锁时,它都查看已经持有的锁的集合,对于每个锁,都记住在这个锁之前某个线程已经请求了这些锁。在试图获得锁之前,lock() 方法都查看已经持有的锁,如果在这个锁之后已经得到了其中一个锁,就抛出 AssertionError。这个实现的空间开销要比前一个版本大得多(因为需要跟踪在给定锁之前所有已经得到的锁),但是它能检测到更大泛围的 bug。它不会检测出所有可能的死锁 —— 只有由两个特定锁之间的不一致顺序造成的死锁,而这是最常见的情况。
清单 6. DebuggingLock 的替代版本,即使死锁没有后果,也能检查出不一致的锁定顺序
结束语
这里描述的方面属于策略实施方面。有些策略是应用程序设计的一部分,例如 “这些方法应当只从类 X 中调用” 或 “什么东西都不要使用 System.out 或 System.err”。其他策略是 API 的接口合约的一部分,例如 Swing 的单线程规则或 EJB 不应当创建线程或调用 AWT 之类的需求。在所有情况下,都可以在开发和测试中使用方面找出是否违犯了这些策略。不论是否在生产中使用方面,它都是测试工具包中的一个优秀工具。
组合测试方法
正如我在 第 1 部分 中讨论过的,QA 的目的不是找到所有可能有的 bug —— 因为这是不可能的 —— 而是提升我们对代码按预期工作的确信程度。对于管理有效的 QA 组织,它的挑战就是最大化所花费资源的回报,即确信度。因为所有的测试方法最终都会表现出回报消退(对于等量的付出增加,得到的确信度增加越来越少),而且不同的方法适合寻找不同类型的错误,所以把 QA 付出分散在测试、代码审查和静态分析上,要比把整个 QA 预算只花在其中一项措施上,回报要更好。
FindBugs 这样的静态分析工具是不精确的,但是不精确的分析对于提高软件质量仍然是非常有用和有效的。它们可能发出假警告,例如在无害的构造上触发警告,也可能忽略了 bug,例如没有找出与特定 bug 模式匹配的全部 bug。但是它们仍然能发现真正的 bug,而且只要误报率没有高到让用户厌烦的程度,那么它们仍然对测试付出提供了有价值的回报。
从测试的角度来说,使用 AOP 来验证设计规则与使用静态分析有许多共同之处。静态分析和面向方面编程都不用为了特定的方法或类设计测试用例,而是都鼓励找出违犯规则的全部分类,并创建能够发现代码体中任何违规的工件。另一个相似性就是它们不必非常完善也能够发挥作用;尽管 bug 探测器或测试方面都不能找出所有可能的 bug,甚至有些会发出假警告,它们仍然是非常有用的工具,可以验证代码是否按期望的那样工作。有些 bug 模式用静态工具更容易找出,而另一些用方面会更容易找出 —— 这使得方面成为参与 QA 过程的一个有用的方法。
回页首
简单的测试方面
FindBugs 这样的静态分析工具审计代码但不执行代码;面向方面的工具既提供静态类工具,也提供动态类工具。静态方面可以生成编译时警告或错误;动态方面可以把错误检测代码插入类。
在 第 1 部分 中,我提供了一个简单的 FindBugs 探测器,查找可能潜伏在库中的对 System.gc() 的调用。静态分析能探测的许多 bug 模式(包括这个模式)也能被方面探测到;根据具体的 bug 模式,用静态分析或用方面来做可能会更容易,所以把它们都放在工具库中,可以提高效果。
清单 1 显示了一个简单的动态方面,在要调用 System.gc() 时,抛出 AssertionError。(因为这类 bug 探测器的一个重要作用是不仅要找到您自己代码中的错误,还要找到代码依赖的库中的错误,所以可能需要告诉工具还要分析或处理这些库。)
清单 1. 执行 “不调用 System.gc()” 规则的动态方面
public aspect GcAspect {
pointcut gcCalls() : call(void java.lang.System.gc());
before() : gcCalls() {
throw new AssertionError("Don't call System.gc!");
}
}
清单 1 演示的动态方式不如使用静态分析进行测试有效,因为它要求程序在方面发现问题之前,实际地执行对 System.gc() 的调用,而不是程序只需包含一个对 System.gc() 的调用,就会被探测到。但是,很快就会看到,动态方面更灵活,因为它们能在方面触发的点上执行任意测试代码,从而对声明的问题提供更精细的控制。
也可以容易地创建一个静态方面,在编译时识别对 System.gc() 的调用,如清单 2 所示。同样,如果想发现在库代码中出现的这个 bug 模式,不仅要处理项目中的代码,还要处理它使用的库。
清单 2. 执行 “不调用 System.gc()” 规则的静态方面
public aspect StaticGcAspect {
pointcut gcCalls() : call(void java.lang.System.gc());
declare error : gcCalls() : "Don't call System.gc!";
}
回页首
检查对 Swing 单线程规则的违犯
有一个几乎无法静态地实施的规则是线程限制 —— 指定的对象只能从一个线程访问(有时是特定线程,例如 Swing 事件线程)。Swing 程序的正确性依赖于线程限制,但是对于实施这个规则,从编译器、运行时或类库都得不到任何帮助。如果违犯了这个规则,程序就会被破坏,但是因为在测试时程序可能看起来工作正常,所以问题可能一直暴露不了。
Swing 单线线程规则指定:
Swing 组件和模块只应当从事件分派线程中创建、修改和实现。
单线程规则的早期描述允许 Swing 组件和模块在屏幕上出现之前,由其他线程访问,但是这种方式带来了线程安全问题,所以规则被强化了。使用 SwingUtilities.isEventDispatchThread() 方法,Swing 提供了一个机制,询问 “当前线程是不是事件分派线程?”。所以需要在每个 Swing 的方法调用之前插入代码,检查调用是否是由合适的线程发出的,如果不是由合适线程发出的,就抛出 AssertionError,这样就能在测试中捕捉到对单线程规则的违犯,而不会让它们在生产中造成莫名其妙的故障。
清单 3 演示了一个可以检测许多单线程规则违犯的方面:它有两部分:不应当从事件线程之外调用的方法的列表,以及要插到对这些方法的调用之前的代码。建议(要插入的代码)非常简单:检查当前线程是否是事件线程,如果不是,就抛出 AssertionError。这个方面处理了对 Swing 包中的所有方法以及扩展了最重要的 Swing 类的那些类中的方法的全部调用(以便捕捉用户提供的组件和模块),但是它排除了这些类中已知为可以安全地(或者需要)从多线程调用的方法。安全方法列表并不全面;构建一个全面的列表可能要花费一些额外时间研究 Javadoc,找到所有标记为线程安全的方法。
清单 3. 实施 Swing 的单线程规则的方面
public aspect SwingThreadAspect {
pointcut swingMethods() : call(* javax.swing..*.*(..))
|| call(javax.swing..*.new(..));
pointcut extendsSwing() : call(* javax.swing.JComponent+.*(..))
|| call(* javax.swing..*Model+.*(..))
|| call(* javax.swing.text.Document+.*(..));
pointcut safeMethods() : call(void JComponent.revalidate())
|| call(void JComponent.invalidate(..))
|| call(void JComponent.repaint(..))
|| call(void add*Listener(EventListener+))
|| call(void remove*Listener(EventListener+))
|| call(boolean SwingUtilities.isEventDispatchThread())
|| call(void SwingUtilities.invokeLater(Runnable))
|| call(void SwingUtilities.invokeAndWait(Runnable))
|| call(void JTextPane.replaceSelection(..))
|| call(void JTextPane.insertComponent(..))
|| call(void JTextPane.insertIcon(..))
|| call(void JTextPane.setLogicalStyle(..))
|| call(void JTextPane.setCharacterAttributes(..))
|| call(void JTextPane.setParagraphAttributes(..));
pointcut edtMethods() : (swingMethods() || extendsSwing()) && !safeMethods();
before() : edtMethods() {
if (!SwingUtilities.isEventDispatchThread())
throw new AssertionError(thisJoinPointStaticPart.getSignature()
+ " called from " + Thread.currentThread().getName());
}
}
swingMethods() 切入点包含对 javax.swing 包中的所有方法(包括构造函数)的调用。extendsSwing() 切入点代表对所有扩展自 JComponent 或任何 Swing 模型类的类中方法的全部调用。safeMethods() 切入点代表一些已知可以从任何线程安全调用的 Swing 方法。
SwingThreadAspect 并不完美,但是足够了。safeMethods() 切入点没有完全枚举线程安全方法,而且 extendsSwing() 切入点可能也没有包含所有经常被扩展的 Swing 类。但是我们不会把它们用于生产 —— 只是用它们进行测试。它能够不必为每个程序创建新的测试用例就发现 bug,而这就是它的价值所在。而且,像大多数 bug 探测器一样,它可能会在以前以为是正确的程序中找到 bug。
回页首
在调试对象中切换
方面的另一个好应用就是在类的正式版本和 “调试” 版本之间进行切换。创建一个类的调试版本是相当普遍的情况,例如创建一个带有更多日志或错误检测的版本,这个版本因为副作用或性能问题而不适合在生产中使用。但是在需要的时候在调试版本中切换,会很烦琐或者容易出错。如果对象是通过构造函数实例化的,就不得不在代码中找到所有调用构造函数的地方。缓解修改所有构造函数调用的不方便性的一种常用技术是,改用工厂来实例化对象,但是只为了在生产版本和调试版本之间进行选择而使用工厂,会增加复杂性或带来安全漏洞。
如果目的是为了 “在所有实例化 Foo 的地方,都换成实例化 DebuggingFoo”,那么方面为做这件事提供了非常可靠且不需要修改程序的简单机制。作为示例,清单 4 显示了一个方面,它有助于发现死锁,把 ReentrantLock 的所有实例化都替换成 DebuggingLock。(请注意,AspectJ 只修改要求 AspectJ 编译器处理的代码中的调用;Java™ 类库本身中对 ReentrantLock 的实例化不会被替换,除非特意把方面编织到平台库中。)
清单 4. 把所有 ReentrantLock 的实例化替换成 DebuggingLock 的方面
public aspect ReentrantLockAspect {
pointcut newLock() : call(ReentrantLock.new());
pointcut newLockFair(boolean fair) :
call(ReentrantLock.new(boolean)) && args(fair);
ReentrantLock around() : newLock() {
return new DebuggingLock();
}
ReentrantLock around(boolean fair) : newLockFair(fair) {
return new DebuggingLock (fair);
}
}
在 Java SE 6 中,运行时对请求执行死锁检测,通过 java.lang.management 中的 ThreadMXBean 接口,或者在请求线程转储时执行。清单 5 显示了 DebuggingLock 的一个可能实现,每次请求锁时,都执行死锁检测,所以可以更迅速地得到死锁的警告。锁定性能要比 ReentrantLock 差,因为每次试图锁定时要做更多的工作,所以这种方式可能不适合在生产中使用。(而且,维护 waitingFor 数据结构时自带的同步,可能会干扰应用程序的计时,从而改变死锁的可能性。)
清单 5. ReentrantLock 的调试版本会在检测到死锁时抛出 AssertionError
public class DebuggingLock extends ReentrantLock {
private static ConcurrentMap<Thread, DebuggingLock> waitingFor
= new ConcurrentHashMap<Thread, DebuggingLock>();
public DebuggingLock() { super(); }
public DebuggingLock(boolean fair) { super(fair); }
private void checkDeadlock() {
Thread currentThread = Thread.currentThread();
Thread t = currentThread;
while (true) {
DebuggingLock lock = waitingFor.get(t);
if (lock == null || !lock.isLocked())
return;
else {
t = lock.getOwner();
if (t == currentThread)
throw new AssertionError("Deadlock detected");
}
}
}
public void lock() {
if (tryLock())
return;
else {
waitingFor.put(Thread.currentThread(), this);
try {
checkDeadlock();
super.lock();
}
finally {
waitingFor.remove(Thread.currentThread());
}
}
}
}
要让清单 5 中的 DebuggingLock 版本有帮助,程序必须在测试时实际地发生死锁。因为死锁通常依赖于计时和环境,所以清单 5 中的方法可能还不够。清单 6 显示了另一个版本的 DebuggingLock,它不仅判断是否发生死锁,还会判断给定的一对锁是否由多个线程在不一致的顺序下得到。每次得到锁时,它都查看已经持有的锁的集合,对于每个锁,都记住在这个锁之前某个线程已经请求了这些锁。在试图获得锁之前,lock() 方法都查看已经持有的锁,如果在这个锁之后已经得到了其中一个锁,就抛出 AssertionError。这个实现的空间开销要比前一个版本大得多(因为需要跟踪在给定锁之前所有已经得到的锁),但是它能检测到更大泛围的 bug。它不会检测出所有可能的死锁 —— 只有由两个特定锁之间的不一致顺序造成的死锁,而这是最常见的情况。
清单 6. DebuggingLock 的替代版本,即使死锁没有后果,也能检查出不一致的锁定顺序
public class OrderHistoryLock extends ReentrantLock {
private static ThreadLocal<Set<OrderHistoryLock>> heldLocks =
new ThreadLocal<Set<OrderHistoryLock>>() {
public Set<OrderHistoryLock> initialValue() {
return new HashSet<OrderHistoryLock>();
}
};
private final Map<Lock, Boolean> predecessors
= new ConcurrentHashMap<Lock, Boolean>();
public OrderHistoryLock() { super(); }
public OrderHistoryLock(boolean fair) { super(fair); }
public void lock() {
boolean alreadyHeld = isHeldByCurrentThread();
for (OrderHistoryLock lock : heldLocks.get()) {
if (lock.predecessors.containsKey(this))
throw new AssertionError("Possible deadlock between "
+ this + " and " + lock);
else if (!alreadyHeld)
predecessors.put(lock, Boolean.TRUE);
}
super.lock();
heldLocks.get().add(this);
}
public void unlock() {
super.unlock();
if (!isHeldByCurrentThread())
heldLocks.get().remove(this);
}
}
结束语
这里描述的方面属于策略实施方面。有些策略是应用程序设计的一部分,例如 “这些方法应当只从类 X 中调用” 或 “什么东西都不要使用 System.out 或 System.err”。其他策略是 API 的接口合约的一部分,例如 Swing 的单线程规则或 EJB 不应当创建线程或调用 AWT 之类的需求。在所有情况下,都可以在开发和测试中使用方面找出是否违犯了这些策略。不论是否在生产中使用方面,它都是测试工具包中的一个优秀工具。