处理复杂和不熟悉 Java 代码的技术
级别:中级
Abhijit Belapurkar(
[email protected])
高级技术架构师,Infosys Technologies Limited
2004 年 3 月
如果您曾经接管并且必须维护某个基于 Java 的应用程序,那么本文就是为您准备的。作者 Abhijit Belapurkar 将向您展示如何使用面向方面编程(aspect-oriented programming,AOP)来对即使最不透明的遗留应用程序获得前所未有的见解。
软件系统通常从一组有限的得到良好理解的需求开始。然而,随着大多数成功系统的演进,它们承担起越来越多的需求,体现在无数的功能和非功能性方面。在一个企业环境中,您最终很容易向这个混乱的模块组合添加许多第三方库和框架,它们全都彼此交互,并在系统日常工作的表面之下相互配合。实际上,用不了多少年,最初具有很简单、可管理的需求集的系统就会变成庞然大物:难于控制和笨拙的代码。
于是步入这种环境的 Java 开发人员就有了一个日常维护和改进的新任务。如果您就是这个开发人员,那么您的第一个任务就是深刻理解该系统的结构。理解结构将是增强系统和诊断不可避免会发生的问题的关键。当然,第一次探究任何未知的系统都是说起来容易做起来难。在某些情况下,您能够咨询原先的开发人员,而在其他情况下却不能。但是即使能够找到原先的开发团队,有些系统也会因为太过庞大,而无法在没有机械帮助下熟悉和理解它。
虽然有许多可用的工具能够帮助您理解复杂的程序(请参阅 参考资料),但是大多数工具都很昂贵、学习起来很耗时间,并且功能范围有限(也就是说,如果该工具无法满足需要,您将求助无门)。在本文中,我将建议一种替代的方法。面向方面编程是成熟的编程范型,它可以应用于广泛的编程场景,包括遗留应用程序的理解和维护。
请注意,本文假设您大致熟悉 AspectJ 之下的 AOP,特别是 AspectJ 的静态和动态横切技术。虽然我将在下一节提供关于 AOP 横切的简要概述,但是您应该参考 参考资料,获取更多信息。
总体概述
基于 Java 的 AOP 使用了灵活而丰富的表达语言,您可以使用它以近乎无限种方式来分解复杂的应用程序。基于 Java 的 AOP 的语法类似于 Java 语言,您应该很容易就会掌握它。一旦掌握,AOP 就是一种具有许多应用的编程技术。除了理解遗留系统内部细节外,您还可以使用 AOP 来非强制性地重构和增强这样的系统。虽然本文将完全使用 AspectJ,不过这里讨论的大多数技术都可移植到其他流行的基于 Java 的 AOP 实现,比如 AspectWerkz 和 JBossAOP(请参阅 参考资料)。
关于横切
任何应用程序都由多个功能性和系统性关注点(concern)组成。功能性 关注点与应用程序的日常使用相关,而 系统性 关注点则与系统的整体健康和维护相关。例如,一个银行应用程序的功能性关注点包括账户维护和允许借/贷操作,它的系统性关注点包括安全、事务、性能和审计日志记录。即使使用最好的编程方法学来开发应用程序,您最终也会发现它的功能性和系统性关注点会以跨越多个应用程序模块的形式相互混杂在一起。
横切 是一种 AOP 技术,用于确保独立的关注点保持模块化,同时仍然足够灵活地在整个应用程序中的不同点应用。横切包括静态和动态两种类别。动态横切 体现为通过在感兴趣的特定点织入(weave in)新的行为来改变对象的执行行为。静态横切 允许我们通过注入(inject in)附加的方法和/或属性来直接改变对象的结构。
静态横切的语法与动态横切很不相同。以下术语适用于动态横切:
连接点(join point)是 Java 程序中的某个特定执行点,比如某个类中的一个方法。
切入点(pointcut)是特定于语言的结构,它表示或捕捉某个特定的连接点。
通知(advice)是在到达某个特定的切入点时要执行的一段代码(通常是一个横切功能)。
方面(aspect)是定义切入点和通知以及它们之间的映射的一个结构。方面由 AOP 编译器用来在现有对象中的特定执行点织入附加功能。
本文中的所有代码演示都将利用动态横切。请参阅 参考资料,获得关于静态横切的更多信息。
AspectJ 之下的 AOP
为了学习本文中的例子,您应该熟悉以下特定于 AspectJ 之下的 AOP 的特性。
AspectJ 提供一个名为 ajc 的编译器/字节代码织入器,它编译 AspectJ 和 Java 语言文件。ajc 根据需要将方面交织在一起,以产生与任何 Java 虚拟机(1.1 或更高版本)相容的 .class 文件。
AspectJ 支持如下这样的方面,即这些方面规定某个特定的连接点应该永远不会到达。如果 ajc 进程判断出情况不是这样,它将发出一个编译时警告或错误(具体取决于该方面)。
应用程序和系统分析
在下面几节中,您将学习两种使用 AOP 的不同的应用程序和系统分析机制。第一种机制我称之为 静态分析,它要求您做以下事情:
从 CVS(或您所使用的其他任何代码版本控制系统)中把整个应用程序代码库签出到本地区域中。
修改生成文件(build file)以使其使用 AspectJ 编译器(ajc)。
在适当的位置包括方面(aspect)类。
执行一次完整的系统生成过程。
第二种机制我称之为 动态分析,它要求您不仅在本地区域生成(build)系统,而且要运行该系统的具体用例(use case),同时将运行时反射中的信息收集到运行系统中。在下面几节中,我将详细讨论每种机制,同时使用代码例子来说明关键概念。
静态分析
我将研究许多对遗留应用程序执行静态分析的技术,并将它们应用于三种常见的维护场景,如下所示:
评估接口变更所带来的影响。
识别死的或不可到达的代码。
创建松散耦合。
下面就让我们开始吧!
评估接口变更所带来的影响
面向对象的遗留应用程序或系统应该包含许多模块,它们向客户端公开定义良好的接口。假设您接受了一个整合新的需求的任务,这个任务需要改变现有的接口。由于不熟悉代码,您首先面临的挑战就是弄清改变这个接口将对系统的客户端带来的影响。为方便说明这里的例子,这里隐含的影响只不过就是简单地改变每个客户端,以使方法调用符合改变后的签名。因此,对于确定实现新需求的最佳方法以及确定该需求对于给定的系统是否值得来说,知道该接口的所有客户端也许是有帮助的。我将首先使用静态分析来确定该接口的客户端,然后评估接口变更给系统带来的影响。
这种技术基于这样一些方面,它们在发现特定的连接点可达时发出编译时警告。在这种情况下,您将编写连接点来捕捉针对该特定接口的所有方法的调用。您必须首先在本地区域中提取出应用程序代码库,修改生成文件以使其使用 AspectJ 编译器以及在适当的位置包括方面类,然后运行系统的完整生成文件。如果正确地捕捉到了连接点,您预期会看到关于向代码库中的目标方法发出调用的编译时警告。
如清单 1 所示的编译时方面检测并显示调用接口 com.infosys.setl.apps.AppA.InterfaceA(及其实现)的所有类,同时执行(一个或多个)实现者(implementor)类本身。 因此对于示例 InterfaceA、它的实现者类 ClassA 以及调用者类 ClassB,该方面将生成一个关于调用 ClassB 中的 a.methodA() 的编译时警告。
清单 1. 使用 InterfaceA 的类
package com.infosys.setl.apps.AppA;
public interface InterfaceA
{
public String methodA();
public int methodB();
public void methodC();
}
package com.infosys.setl.apps.AppA;
public class ClassA implements InterfaceA
{
public String methodA()
{
return "Hello, World!";
}
public int methodB()
{
return 1;
}
public void methodC()
{
System.out.println("Hello, World!");
}
}
package com.infosys.setl.apps.AppB;
import com.infosys.setl.apps.AppA.*;
public class ClassB
{
public static void main(String[] args)
{
try
{
InterfaceA a =
(InterfaceA)Class.forName("com.infosys.setl.apps.AppA.ClassA").newInstance();
System.out.println(a.methodA());
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
package com.infosys.setl.aspects;
public aspect InterfaceCallersAspect
{
pointcut callerMethodA(): call (* com.infosys.setl.apps.AppA.InterfaceA.methodA(..)) &&
!within(com.infosys.setl.apps.AppA.InterfaceA+);
pointcut callerMethodB(): call (* com.infosys.setl.apps.AppA.InterfaceA.methodB(..)) &&
!within(com.infosys.setl.apps.AppA.InterfaceA+);
pointcut callerMethodC(): call (* com.infosys.setl.apps.AppA.InterfaceA.methodC(..)) &&
!within(com.infosys.setl.apps.AppA.InterfaceA+);
declare warning: callerMethodA(): "Call to InterfaceA.methodA";
declare warning: callerMethodB(): "Call to InterfaceA.methodB";
declare warning: callerMethodC(): "Call to InterfaceA.methodC";
}
接口变更对实现者类的影响可通过如清单 2 所示的方面来确定。对于前述清单中的示例类,这个方面为 ClassA 生成了一个编译时警告。
清单 2. 实现 InterfaceA 的类
package com.infosys.setl.aspects;
public aspect InterfaceImplAspect
{
pointcut impls(): staticinitialization (com.infosys.setl.apps.AppA.InterfaceA+) &&
!(within(com.infosys.setl.apps.AppA.InterfaceA));
declare warning: impls(): "InterfaceA Implementer";
}
识别死代码
系统随着增强请求断断续续地加入而变得零碎。每当对系统或应用程序的某个部分作出更改,清楚这样的变更对其他部分的影响是很重要的。例如,重构模块 A 中的代码可能导致其他某个地方的代码(不管是某个类中的方法还是整个类本身)变成不可到达(或死的),因为控制/数据流已被更新而绕过了它。对死代码置之不理,最终可能会因为系统中的未用代码过多而导致性能问题。
幸运的是,用于确定代码增强所带来的影响的相同技术(如前一节所示)也可应用于识别死的或不可达的代码。就像 清单 1 所示的编译时方面可用于检测被调用的接口中的所有方法一样,它们也可以指出那些不属于该接口的方法。这个接口中不会 生成编译时警告的任何方法都可以认为是死的,因而可以安全地删除。
创建松散耦合
有时您可能必须维护这样的遗留应用程序,即它是使用硬编码的调用或针对特定组件而不是针对接口来开发的。修改这样的系统(例如添加新的组件或替代组件)可能相当麻烦,也非常具有挑战。幸运的是,可以使用 AOP 来从系统中分离特定的组件,并将它们替换为通用的、基于接口的组件。这样做确保了实际的实现者可以动态地插入。
考虑一下代码清单 3 所示的示例类。ClassA 的 methodA 方法具有硬编码形式的日志记录,其中的调用是直接针对 System.out 发出的。
清单 3. 具有硬编码日志记录调用的类
package com.infosys.setl.apps.AppA;
public class ClassA
{
public int methodA(int x)
{
System.out.println("About to double!");
x*=2;
System.out.println("Have doubled!");
return x;
}
public static void main(String args[])
{
ClassA a = new ClassA();
int ret = a.methodA(2);
}
}
将现有的日志记录调用手动替换为针对通用日志记录器接口的调用显然很麻烦。更好的选择是使用如清单 4 所示的方面来查找和替换所有这样的调用。然后可以通过 LoggerInterface 来提供通用的日志记录器接口,工厂类 LoggerFactory 可用于获得特定的日志记录器实例(代码清单 4 中的 SETLogger)。
清单 4. 替换硬编码调用的通用接口
package com.infosys.setl.utils;
public interface LoggerInterface
{
public void log(String logMsg, Object target);
}
package com.infosys.setl.utils;
public class LoggerFactory
{
private static LoggerFactory _instance = null;
private LoggerFactory(){}
public static synchronized LoggerFactory getInstance()
{
if (_instance == null)
_instance = new LoggerFactory();
return _instance;
}
public LoggerInterface getLogger()
{
LoggerInterface l = null;
try
{
l = (LoggerInterface)Class.forName("com.infosys.setl.utils.SETLogger").newInstance();
}
catch (Exception e)
{
e.printStackTrace();
}
finally
{
return l;
}
}
}
package com.infosys.setl.utils;
public class SETLogger implements LoggerInterface
{
public void log(String logMsg, Object target)
{
System.out.println(logMsg);
}
}
package com.infosys.setl.aspects;
import com.infosys.setl.utils.*;
public aspect UnplugAspect
{
void around(String logMsg, Object source):
call (void java.io.PrintStream.println(String)) && within(com.infosys.setl.apps.AppA.ClassA) &&
!within(com.infosys.setl.aspects..*) && args(logMsg) && target(source)
{
logger.log(logMsg, source);
}
private LoggerInterface logger = LoggerFactory.getInstance().getLogger();
}
动态分析
本节将研究几种动态分析技术,然后同样将它们应用于熟悉的维护场景。 将使用动态分析来解决的场景如下:
生成动态调用图。
评估异常对一个系统的影响。
基于特定条件定制系统。
回忆一下,动态 AOP 分析提要求将应用程序代码库签出到本地区域中,再修改生成文件以使其使用 AspectJ 编译器并在适当位置包括方面类,之后生成整个系统,然后运行系统所针对的用例。假设您正确地指定了感兴趣的连接点,您将能够通过反射把丰富的信息收集到运行的应用程序中。
生成动态调用图
单个用例不变地遍历多个程序模块。通过在您的思维中完整地运行一遍代码的执行序列,从而形成这种遍历路径的一种精神表示形式,这种方法是经常使用的。显然,随着遍历路径变得更长,记住所有这些信息就更困难,因此对于较大的系统来说并不实用。在本节中,您将学习如何生成一个动态的调用图,它是一个嵌套的跟踪轨迹,描绘了用例从开头运行到结尾的过程中,堆栈帧在执行堆栈上压入和弹出的过程。
在清单 5 中,您可以看到如何使用一个编译时方面来动态生成一个反映用例执行流程的调用图。
清单 5. 生成控制流图
package com.infosys.abhi;
public class ClassA
{
public void methodA(int x)
{
x*=2;
com.infosys.abhi.ClassB b = new com.infosys.abhi.ClassB();
b.methodB(x);
}
}
package com.infosys.abhi;
public class ClassB
{
public void methodB(int x)
{
x*=2;
com.infosys.bela.ClassC c = new com.infosys.bela.ClassC();
c.methodC(x);
}
}
package com.infosys.bela;
public class ClassC
{
public void methodC(int x)
{
x*=2;
com.infosys.bela.ClassD d = new com.infosys.bela.ClassD();
d.methodD(x);
}
}
package com.infosys.bela;
public class ClassD
{
public void methodD(int x)
{
x*=2;
}
}
package com.infosys.setl.aspects;
public aspect LogStackAspect
{
private int callDepth=0;
pointcut cgraph(): !within(com.infosys.setl.aspects..*) && execution(* com.infosys..*.*(..));
Object around(): cgraph()
{
callDepth++;
logEntry(thisjoin point, callDepth);
Object result = proceed();
callDepth--;
logExit(thisjoin point, callDepth);
return result;
}
void logEntry(join point jp, int callDepth)
{
StringBuffer msgBuff = new StringBuffer();
while (callDepth >0)
{
msgBuff.append(" ");
callDepth--;
}
msgBuff.append("->").append(jp.toString());
log(msgBuff.toString());
}
void logExit(join point jp, int callDepth)
{
StringBuffer msgBuff = new StringBuffer();
while (callDepth >0)
{
msgBuff.append(" ");
callDepth--;
}
msgBuff.append("<-").append(jp.toString());
log(msgBuff.toString());
}
void log(String msg)
{
System.out.println(msg);
}
}
Output:
=======
->execution(void com.infosys.abhi.ClassA.methodA(int))
->execution(void com.infosys.abhi.ClassB.methodB(int))
->execution(void com.infosys.bela.ClassC.methodC(int))
->execution(void com.infosys.bela.ClassD.methodD(int))
<-execution(void com.infosys.bela.ClassD.methodD(int))
<-execution(void com.infosys.bela.ClassC.methodC(int))
<-execution(void com.infosys.abhi.ClassB.methodB(int))
<-execution(void com.infosys.abhi.ClassA.methodA(int))
AspectJ 还提供了一个名为 cflowbelow 的结构,它通过一个切入点来指出每个被挑选出的连接点的控制流之后的连接点(执行点)。通过使用这个结构,您可以把调用图截取到任意深度。这对于如下情形是很有用的,其中控制流完整运行一个较大的循环,导致相同的调用图输出一次又一次地重复(这反过来又导致图的尺寸增大,从而降低了其适用性)。
评估异常的影响
通常,您需要调试一个生产环境中产生的异常。这样的异常可能导致不良的影响,例如将堆栈跟踪转储到用户界面上,或者没有释放诸如共享锁之类的争用性资源。
虽然评估异常对整体系统的影响很重要,但是很难在开发环境中模拟某些异常。这可能是由异常(比如网络异常)的性质决定的,或者由于开发环境并不是生产环境的精确副本。例如,生成环境中由于数据库破坏而发生的异常,在不确切知道破坏发生的具体位置的情况下就很难模拟。捕捉生产数据库的快照以转移到开发服务器上也可能无法实现。
除了模拟异常以便弄清问题存在于应用程序中的何处之外,您还希望能够测试代码修复,以确保它能正确地处理该问题。除非可以在补丁代码中产生该异常并观察到它是如何被处理的,否则这就是有问题的。在本节中,您将看到如何使用 AOP 技术来模拟因为调用某个对象的方法而抛出异常的过程,而不必准确地重新创建导致该异常真正被抛出的运行时条件。
考虑一下 清单 6 所示的示例类 com.infosys.setl.apps.AppA.ClassA。对这个类的 methodA() 的调用可能在某些环境下导致抛出一个 java.io.IOException。我们想知道的是在抛出这个异常时 methodA()(同一个清单中所示的类 com.infosys.setl.apps.AppB.ClassB)的调用者的行为。然而,您不希望耗费时间和资源来建立产生某个具体异常的条件。
为解决这个问题,可以使用方面 GenException 的切入点 genExceptionA 来中断 methodA() 的执行,从而在运行时导致抛出一个 java.io.IOException。然后您可以测试应用程序(这里表示为 ClassB)是否能够按规定处理该异常,如清单 6 所示。(当然,您也可以将该通知修改为“after”通知,它可以在 methodA() 的执行之后执行,如切入点 genExceptionAfterA 所示。)
注意在现实场景中,ClassA 可能是诸如 JDBC 驱动程序之类的第三方库。
清单 6. 从运行的代码中生成异常
package com.infosys.abhi;
import java.io.*;
public class ClassA
{
public void methodA() throws IOException
{
System.out.println("Hello, World!");
}
}
package com.infosys.bela;
public class ClassB
{
public static void main(String[] args)
{
try
{
com.infosys.abhi.ClassA a = new com.infosys.abhi.ClassA();
a.methodA();
com.infosys.abhi.ClassC c = new com.infosys.abhi.ClassC();
System.out.println(c.methodC());
}
catch (java.io.IOException e)
{
e.printStackTrace();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
package com.infosys.abhi;
public class ClassC
{
public String methodC()
{
System.out.println("Hello, World!");
return "Hi, World!";
}
}
package com.infosys.setl.aspects;
public aspect GenException
{
pointcut genExceptionA():
execution(public void com.infosys.abhi.ClassA.methodA() throws java.io.IOException);
pointcut genExceptionC():
call(void java.io.PrintStream.println(String)) &&
withincode(public String com.infosys.abhi.ClassC.methodC());
pointcut genExceptionAfterA():
call(public void com.infosys.abhi.ClassA.methodA() throws java.io.IOException);
void around() throws java.io.IOException : genExceptionA()
{
java.io.IOException e = new java.io.IOException();
throw e;
}
after() throws java.io.IOException : genExceptionAfterA()
{
java.io.IOException e = new java.io.IOException();
throw e;
}
after() throws java.lang.OutOfMemoryError : genExceptionC()
{
java.lang.OutOfMemoryError e = new java.lang.OutOfMemoryError();
throw e;
}
}
在类似的场景中,您可能遇到应用程序抛出未检查的异常(比如像 NullPointerException 这样的 java.lang.RuntimeException 的子类)或错误(比如像 OutOfMemoryError 这样的 java.lang.Error 的子类)的情况。这两种异常类型都难于在开发环境中模拟。相反,您可以使用切入点 genExceptionC 连同对应的 after 通知(如上面所示)来导致运行代码抛出一个 OutOfMemory 错误,然后调用 ClassC 的 methodC() 中的 System.out.println()。
基于特定条件定制系统
通常,AOP 在横切多个系统模块的关注点环境中是理想的。然而,对于仅集中于系统的特定部分的关注点,AOP 也有用武之地。例如,您可能需要对系统作出一些非常明确的变更,比如让顾客保持愉快,或者反映地方法规中的某个变更。有些变更甚至可能是暂时的(也就是说,它们必须在经过特定的时间段之后从系统中删除)。
在这样的情况下,您也许会发现,由于以下这些原因,对代码进行分支以便直接在代码中作出相关的增强可能无法实现:
您最终可能必须在 CVS 中管理具有不同版本的多个代码分支。这是一个管理问题,它增加了出错的可能性。即使假设所有请求的增强都是给总体产品方向带来积极影响的有益特性,首先通过方面引入这些特性也会给开发团队提供一试身手的机会,而不必将变更提交给 CVS。
如果必须从系统删除某个临时特性,那么如果该特性是直接引入代码中的,所需要的回归测试将是很耗资源并且很容易出错的。另一方面,如果该特性是通过 AOP 引入的,那么删除过程就只是从生成文件(build file)中排除该方面之后重新编译系统。
幸运的是,很容易通过方面织入这样的定制特性。织入过程可以在编译时进行(使用 AspectJ),或者在加载时进行(使用 AspectWerkz)。在设计方面时,谨记 AOP 实现可能具有关于环境公开(context exposure)的固有限制(例如,AspectJ 不允许通过执行环境向通知代码公开方法的本地变量)是很重要的。然而一般来讲,使用方面来定制系统将带来更清洁的实现,它使得隔离的关注点与系统代码的基线版本更清楚地分离。
结束语
在本文中,您学习了如何使用面向方面编程来理解和非强制性地维护大型的复杂遗留系统。本文结合两种分析技术和动态横切的强大能力来解决许多常见维护场景的问题,同时强调错误诊断和非强制性地增强现有代码。如果您负责某个遗留应用程序的日常维护,那么这里展示的所有技术都应该证明是很宝贵的。
参考资料
可以从 AspectJ 项目主页 下载 AspectJ。
通过“使用面向 Aspect 的编程改进模块性”(developerWorks,2002 年 1 月)获得关于 AOP 和 AspectJ 的深入介绍。
通过 AspectJ 和模仿对象的测试灵活性(developerWorks,2002 年 5 月)了解关于最知名的基于 Java 的 AOP 风格的更多信息。
“AOP 解决紧密耦合的难题”(developerWorks,2004 年 2 月)是关于静态横切技术的实用介绍。
AspectWerkz 是一个轻量级、开放源代码、基于 Java 的 AOP 实现。
JBossAOP 是基于 Java 的 AOP 世界中的另一个竞争者。
Rigi 是一个交互式的可视化工具(由加拿大大不列颠哥伦比亚省维多利亚大学计算机科学系的研究人员开发),它旨在帮助您更好地理解软件,并重新编制文档。
Klocwork InSight 可用于直接从现有源代码(C、C++ 和 Java 代码)中提取软件设计的精确图形视图,以便全面了解应用程序的结构和设计。
从 developerWorks Java 技术专区 可以找到关于 Java 编程各个方面的数百篇文章。
请访问 Developer Bookstore,获得技术书籍的详细列表,其中包括数百本 Java 相关的图书。
还可以参阅 Java 技术专区教程主页,从 developerWorks 获得免费的 Java 相关教程的完整列表。
关于作者
Abhijit Belapurkar 从印度德里市的印度理工学院获得了计算机科学方面的技术学士学位。他在分布式应用程序的体系结构和安全方面拥有近 10 年的经验,并且在使用 Java 平台构建 N-层应用程序方面拥有 5 年以上的经验。他当前在印度班加罗尔市的 Infosys Technologies Limited 担任 J2EE 方面的高级技术架构师。