一起学习
对于一个能够访问源代码的经验丰富的Java开发人员来说,任何程序都可以被看作是博物馆里透明的模型。类似线程转储(dump)、方法调用跟踪、断点、切面(profiling)统计表等工具可以让我们了解程序目前正在执行什么操作、刚才做了什么操作、未来将做什么操作。但是在产品环境中情况就没有那么明显了,这些工具一般是不能够使用的,或最多只能由受过训练的开发者使用。支持团队和最终用户也需要知道在某个时刻应用程序正在执行什么操作。
为了填补这个空缺,我们已经发明了一些简单的替代品,例如日志文件(典型情况下用于服务器处理)和状态条(用于GUI应用程序)。但是,由于这些工具只能捕捉和报告可用信息的一个很小的子集,并且通常必须把这些信息用容易理解的方式表现出来,所以程序员趋向于把它们明确地编写到应用程序中。而这些代码会缠绕着应用程序的业务逻辑,当开发者试图调试或了解核心功能的时候,他们必须"围绕这些代码工作",而且还要记得功能发生改变后更新这些代码。我们希望实现的真正功能是把状态报告集中在某个位置,把单个状态消息作为元数据(metadata)来管理。
在本文中我将考虑使用嵌入GUI应用程序中的状态条组件的情形。我将介绍多种实现这种状态报告的不同方法,从传统的硬编码习惯开始。随后我会介绍Java 1.5的大量新特性,包括注解(annotation)和运行时字节码重构(instrumentation)。
状态管理器(StatusManager)
我的主要目标是建立一个可以嵌入GUI应用程序的JStatusBar Swing组件。图1显示了一个简单的Jframe中状态条的样式。
由于我不希望直接在业务逻辑中引用任何GUI组件,我将建立一个StatusManager(状态管理器)来充当状态更新的入口点。实际的通知会被委托给StatusState对象,因此以后可以扩展它以支持多个并发的线程。图2显示了这种安排。
现在我必须编写代码调用StatusManager的方法来报告应用程序的进程。典型情况下,这些方法调用都分散地贯穿于try-finally代码块中,通常每个方法一个调用。
public void connectToDB (String url) {
StatusManager.push("Connecting to database");
try {
...
} finally {
StatusManager.pop();
}
}
这些代码实现了我们所需要功能,但是在代码库中数十次、甚至于数百次地复制这些代码之后,它看起来就有些混乱了。此外,如果我们希望用一些其它的方式访问这些消息该怎么办呢?在本文的后面部分中,我将定义一个用户友好的异常处理程序,它共享了相同的消息。问题是我把状态消息隐藏在方法的实现之中了,而没有把消息放在消息所属的接口中。
面向属性编程
我真正想实现的操作是把对StatusManager的引用都放到代码外面的某个地方,并简单地用我们的消息标记这个方法。接着我可以使用代码生成(code-generation)或运行时反省(introspection)来执行真正的工作。XDoclet项目把这种方法归纳为面向属性编程(Attribute-Oriented Programming),它还提供了一个框架组件,可以把自定义的类似Javadoc的标记转换到源代码之中。
但是,JSR-175包含了这样的内容,Java 1.5为了包含真实代码中的这些属性提供了一种结构化程度更高的格式。这些属性被称为"注解(annotations)",我们可以使用它们为类、方法、字段或变量定义提供元数据。它们必须被显式声明,并提供一组可以包含任意常量值(包括原语、字符串、枚举和类)的名称-值对(name-value pair)。
注解(Annotations)
为了处理状态消息,我希望定义一个包含字符串值的新注解。注解的定义非常类似接口的定义,但是它用@interface关键字代替了interface,并且只支持方法(尽管它们的功能更像字段):
public @interface Status {
String value();
}
与接口类似,我把@interface放入一个叫做Status.java的文件中,并把它导入到任何需要引用它的文件中。
对我们的字段来说,value可能是个奇怪的名称。类似message的名称可能更适合;但是,value对于Java来说具有特殊的意义。它允许我们使用@Status("...")代替@Status(value="...")来定义注解,这明显更加简捷。
我现在可以使用下面的代码定义自己的方法:
@Status("Connecting to database")
public void connectToDB (String url) {
...
}
请注意,我们在编译这段代码的时候必须使用-source 1.5选项。如果你使用Ant而不是直接使用javac命令行建立应用程序,那么你需要使用Ant 1.6.1以上版本。
作为类、方法、字段和变量的补充,注解也可以用于为其它的注解提供元数据。特别地,Java引入了少量注解,你可以使用这些注解来定制你自己的注解的工作方式。我们用下面的代码重新定义自己的注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Status {
String value();
}
@Target注解定义了@Status注解可以引用什么内容。理想情况下,我希望标记大块的代码,但是它的选项只有方法、字段、类、本地变量、参数和其它注解。我只对代码感兴趣,因此我选择了METHOD(方法)。
@Retention注解允许我们指定Java什么时候可以自主地抛弃消息。它可能是SOURCE(在编译时抛弃)、CLASS(在类载入时抛弃)或RUNTIME(不抛弃)。我们先选择SOURCE,但是在本文后部我们会更新它。
重构源代码
现在我的消息都被编码放入元数据中了,我必须编写一些代码来通知状态监听程序。假设在某个时候,我继续把connectToDB方法保存源代码控件中,但是却没有对StatusManager的任何引用。但是,在编译这个类之前,我希望加入一些必要的调用。也就是说,我希望自动地插入try-finally语句和push/pop调用。
XDoclet框架组件是一种Java源代码生成引擎,它使用了类似上述的注解,但是把它们存储在Java源代码的注释(comment)中。XDoclet生成整个Java类、配置文件或其它建立的部分的时候非常完美,但是它不支持对已有Java类的修改,而这限制了重构的有效性。作为代替,我可以使用分析工具(例如JavaCC或ANTLR,它提供了分析Java源代码的语法基础),但是这需要花费大量精力。
看起来没有什么可以用于Java代码的源代码重构的很好的工具。这类工具可能有市场,但是你在本文的后面部分可以看到,字节码重构可能是一种更强大的技术。 重构字节码
不是重构源代码然后编译它,而是编译原始的源代码,然后重构它所产生的字节码。这样的操作可能比源代码重构更容易,也可能更加复杂,而这依赖于需要的准确转换。字节码重构的主要优点是代码可以在运行时被修改,不需要使用编译器。
尽管Java的字节码格式相对简单,我还是希望使用一个Java类库来执行字节码的分析和生成(这可以把我们与未来Java类文件格式的改变隔离开来)。我选择了使用Jakarta的Byte Code Engineering Library(字节码引擎类库,BCEL),但是我还可以选用CGLIB、ASM或SERP。
由于我将使用多种不同的方式重构字节码,我将从声明重构的通用接口开始。它类似于执行基于注解重构的简单框架组件。这个框架组件基于注解,将支持类和方法的转换,因此该接口有类似下面的定义:
public interface Instrumentor
{
public void instrumentClass (ClassGen classGen,Annotation a);
public void instrumentMethod (ClassGen classGen,MethodGen methodGen,Annotation a);
}
ClassGen和MethodGen都是BCEL类,它们使用了Builder模式(pattern)。也就是说,它们为改变其它不可变的(immutable)对象、以及可变的和不可变的表现(representation)之间的转换提供了方法。
现在我需要为接口编写实现,它必须用恰当的StatusManager调用更换@Status注解。前面提到,我希望把这些调用包含在try-finally代码块中。请注意,要达到这个目标,我们所使用的注解必须用@Retention(RetentionPolicy.CLASS)进行标记,它指示Java编译器在编译过程中不要抛弃注解。由于在前面我把@Status声明为@Retention(RetentionPolicy.SOURCE)的,我必须更新它。
在这种情况下,重构字节码明显比重构源代码更复杂。其原因在于try-finally是一种仅仅存在于源代码中的概念。Java编译器把try-finally代码块转换为一系列的try-catch代码块,并在每一个返回之前插入对finally代码块的调用。因此,为了把try-finally代码块添加到已有的字节码中,我也必须执行类似的事务。
下面是表现一个普通方法调用的字节码,它被StatusManager更新环绕着:
0: ldc #2; //字符串消息
2: invokestatic #3; //方法StatusManager.push:(LString;)V
5: invokestatic #4; //方法 doSomething:()V
8: invokestatic #5; //方法 StatusManager.pop:()V
11: return
下面是相同的方法调用,但是位于try-finally代码块中,因此,如果它产生了异常会调用StatusManager.pop():
0: ldc #2; //字符串消息
2: invokestatic #3; //方法 StatusManager.push:(LString;)V
5: invokestatic #4; //方法 doSomething:()V
8: invokestatic #5; //方法 StatusManager.pop:()V
11: goto 20
14: astore_0
15: invokestatic #5; //方法 StatusManager.pop:()V
18: aload_0
19: athrow
20: return
Exception table:
from to target type
5 8 14 any
14 15 14 any
你可以发现,为了实现一个try-finally,我必须复制一些指令,并添加了几个跳转和异常表记录。幸运的是,BCEL的InstructionList类使这种工作相当简单。
在运行时重构字节码
现在我拥有了一个基于注解修改类的接口和该接口的具体实现了,下一步是编写调用它的实际框架组件。实际上我将编写少量的框架组件,先从运行时重构所有类的框架组件开始。由于这种操作会在build过程中发生,我决定为它定义一个Ant事务。build.xml文件中的重构目标的声明应该如下:
<instrument class="com.pkg.OurInstrumentor">
<fileset dir="$(classes.dir)">
<include name="**/*.class"/>
</fileset>
</instrument>
为了实现这种事务,我必须定义一个实现org.apache.tools.ant.Task接口的类。我们的事务的属性和子元素(sub-elements)都是通过set和add方法调用传递进来的。我们调用执行(execute)方法来实现事务所要执行的工作--在示例中,就是重构<fileset>中指定的类文件。
public class InstrumentTask extends Task {
...
public void setClass (String className) { ... }
public void addFileSet (FileSet fileSet) { ... }
public void execute () throws BuildException {
Instrumentor inst = getInstrumentor();
try {
DirectoryScanner ds =fileSet.getDirectoryScanner(project);
// Java 1.5 的"for" 语法
for (String file : ds.getIncludedFiles()) {
instrumentFile(inst, file);
}
} catch (Exception ex) {
throw new BuildException(ex);
}
}
...
}
用于该项操作的BCEL 5.1版本有一个问题--它不支持分析注解。我可以载入正在重构的类并使用反射(reflection)查看注解。但是,如果这样,我就不得不使用RetentionPolicy.RUNTIME来代替RetentionPolicy.CLASS。我还必须在这些类中执行一些静态的初始化,而这些操作可能载入本地类库或引入其它的依赖关系。幸运的是,BCEL提供了一种插件(plugin)机制,它允许客户端分析字节码属性。我编写了自己的AttributeReader的实现(implementation),在出现注解的时候,它知道如何分析插入字节码中的RuntimeVisibleAnnotations和RuntimeInvisibleAnnotations属性。BCEL未来的版本应该会包含这种功能而不是作为插件提供。
编译时刻的字节码重构方法显示在示例代码的code/02_compiletime目录中。
但是这种方法有很多缺陷。首先,我必须给建立过程增加额外的步骤。我不能基于命令行设置或其它编译时没有提供的信息来决定打开或关闭重构操作。如果重构的或没有重构的代码需要同时在产品环境中运行,那么就必须建立两个单独的.jars文件,而且还必须决定使用哪一个。
在类载入时重构字节码
更好的方法可能是延迟字节码重构操作,直到字节码被载入的时候才进行重构。使用这种方法的时候,重构的字节码不用保存起来。我们的应用程序启动时刻的性能可能会受到影响,但是你却可以基于自己的系统属性或运行时配置数据来控制进行什么操作。
Java 1.5之前,我们使用定制的类载入程序可能实现这种类文件维护操作。但是Java 1.5中新增加的java.lang.instrument程序包提供了少数附加的工具。特别地,它定义了ClassFileTransformer的概念,在标准的载入过程中我们可以使用它来重构一个类。
为了在适当的时候(在载入任何类之前)注册ClassFileTransformer,我需要定义一个premain方法。Java在载入主类(main class)之前将调用这个方法,并且它传递进来对Instrumentation对象的引用。我还必须给命令行增加-javaagent参数选项,告诉Java我们的premain方法的信息。这个参数选项把我们的agent class(代理类,它包含了premain方法)的全名和任意字符串作为参数。在例子中我们把Instrumentor类的全名作为参数(它必须在同一行之中):
-javaagent:boxpeeking.instrument.InstrumentorAdaptor=
boxpeeking.status.instrument.StatusInstrumentor
现在我已经安排了一个回调(callback),它在载入任何含有注解的类之前都会发生,并且我拥有Instrumentation对象的引用,可以注册我们的ClassFileTransformer了:
public static void premain (String className,
Instrumentation i)
throws ClassNotFoundException,
InstantiationException,
IllegalAccessException
{
Class instClass = Class.forName(className);
Instrumentor inst = (Instrumentor)instClass.newInstance();
i.addTransformer(new InstrumentorAdaptor(inst));
}
我们在此处注册的适配器将充当上面给出的Instrumentor接口和Java的ClassFileTransformer接口之间的桥梁。
public class InstrumentorAdaptor
implements ClassFileTransformer
{
public byte[] transform (ClassLoader cl,String className,Class classBeingRedefined,
ProtectionDomain protectionDomain,byte[] classfileBuffer)
{
try {
ClassParser cp =new ClassParser(new ByteArrayInputStream(classfileBuffer),className ".java");
JavaClass jc = cp.parse();
ClassGen cg = new ClassGen(jc);
for (Annotation an : getAnnotations(jc.getAttributes())) {
instrumentor.instrumentClass(cg, an);
}
for (org.apache.bcel.classfile.Method m : cg.getMethods()) {
for (Annotation an : getAnnotations(m.getAttributes())) {
ConstantPoolGen cpg =cg.getConstantPool();
MethodGen mg =new MethodGen(m, className, cpg);
instrumentor.instrumentMethod(cg, mg, an);
mg.setMaxStack();
mg.setMaxLocals();
cg.replaceMethod(m, mg.getMethod());
}
}
JavaClass jcNew = cg.getJavaClass();
return jcNew.getBytes();
} catch (Exception ex) {
throw new RuntimeException("instrumenting " className, ex);
}
}
...
}
这种在启动时重构字节码的方法位于在示例的/code/03_startup目录中。
异常的处理
文章前面提到,我希望编写附加的代码使用不同目的的@Status注解。我们来考虑一下一些额外的需求:我们的应用程序必须捕捉所有的未处理异常并把它们显示给用户。但是,我们不是提供Java堆栈跟踪,而是显示拥有@Status注解的方法,而且还不应该显示任何代码(类或方法的名称或行号等等)。
例如,考虑下面的堆栈跟踪信息:
java.lang.RuntimeException: Could not load data for symbol IBM
at boxpeeking.code.YourCode.loadData(Unknown Source)
at boxpeeking.code.YourCode.go(Unknown Source)
at boxpeeking.yourcode.ui.Main 2.run(Unknown Source)
at java.lang.Thread.run(Thread.java:566)
Caused by: java.lang.RuntimeException: Timed out
at boxpeeking.code.YourCode.connectToDB(Unknown Source)
... 更多信息
这将导致图1中所示的GUI弹出框,上面的例子假设你的YourCode.loadData()、YourCode.go()和YourCode.connectToDB()都含有@Status注解。请注意,异常的次序是相反的,因此用户最先得到的是最详细的信息。
为了实现这些功能,我必须对已有的代码进行稍微的修改。首先,为了确保在运行时@Status注解是可以看到的,我就必须再次更新@Retention,把它设置为@Retention(RetentionPolicy.RUNTIME)。请记住,@Retention控制着JVM什么时候抛弃注解信息。这样的设置意味着注解不仅可以被编译器插入字节码中,还能够使用新的Method.getAnnotation(Class)方法通过反射来进行访问。
现在我需要安排接收代码中没有明确处理的任何异常的通知了。在Java 1.4中,处理任何特定线程上未处理异常的最好方法是使用ThreadGroup子类并给该类型的ThreadGroup添加自己的新线程。但是Java 1.5提供了额外的功能。我可以定义UncaughtExceptionHandler接口的一个实例,并为任何特定的线程(或所有线程)注册它。
请注意,在例子中为特定异常注册可能更好,但是在Java 1.5.0beta1(#4986764)中有一个bug,它使这样操作无法进行。但是为所有线程设置一个处理程序是可以工作的,因此我就这样操作了。
现在我们拥有了一种截取未处理异常的方法了,并且这些异常必须被报告给用户。在GUI应用程序中,典型情况下这样的操作是通过弹出一个包含整个堆栈跟踪信息或简单消息的模式对话框来实现的。在例子中,我希望在产生异常的时候显示一个消息,但是我希望提供堆栈的@Status描述而不是类和方法的名称。为了实现这个目的,我简单地在Thread的StackTraceElement数组中查询,找到与每个框架相关的java.lang.reflect.Method对象,并查询它的堆栈注解列表。不幸的是,它只提供了方法的名称,没有提供方法的特征量(signature),因此这种技术不支持名称相同的(但@Status注解不同的)重载方法。
实现这种方法的示例代码可以在peekinginside-pt2.tar.gz文件的/code/04_exceptions目录中找到。
取样(Sampling)
我现在有办法把StackTraceElement数组转换为@Status注解堆栈。这种操作比表明看到的更加有用。Java 1.5中的另一个新特性--线程反省(introspection)--使我们能够从当前正在运行的线程中得到准确的StackTraceElement数组。有了这两部分信息之后,我们就可以构造JstatusBar的另一种实现。StatusManager将不会在发生方法调用的时候接收通知,而是简单地启动一个附加的线程,让它负责在正常的间隔期间抓取堆栈跟踪信息和每个步骤的状态。只要这个间隔期间足够短,用户就不会感觉到更新的延迟。
下面使"sampler"线程背后的代码,它跟踪另一个线程的经过:
class StatusSampler implements Runnable
{
private Thread watchThread;
public StatusSampler (Thread watchThread)
{
this.watchThread = watchThread;
}
public void run ()
{
while (watchThread.isAlive()) {
// 从线程中得到堆栈跟踪信息
StackTraceElement[] stackTrace =watchThread.getStackTrace();
// 从堆栈跟踪信息中提取状态消息
List<Status> statusList =StatusFinder.getStatus(stackTrace);
Collections.reverse(statusList);
// 用状态消息建立某种状态
StatusState state = new StatusState();
for (Status s : statusList) {
String message = s.value();
state.push(message);
}
// 更新当前的状态
StatusManager.setState(watchThread,state);
//休眠到下一个周期
try {
Thread .sleep(SAMPLING_DELAY);
} catch (InterruptedException ex) {}
}
//状态复位
StatusManager.setState(watchThread,new StatusState());
}
}
与增加方法调用、手动或通过重构相比,取样对程序的侵害性(invasive)更小。我根本不需要改变建立过程或命令行参数,或修改启动过程。它也允许我通过调整SAMPLING_DELAY来控制占用的开销。不幸的是,当方法调用开始或结束的时候,这种方法没有明确的回调。除了状态更新的延迟之外,没有原因要求这段代码在那个时候接收回调。但是,未来我能够增加一些额外的代码来跟踪每个方法的准确的运行时。通过检查StackTraceElement是可以精确地实现这样的操作的。
通过线程取样实现JStatusBar的代码可以在peekinginside-pt2.tar.gz文件的/code/05_sampling目录中找到。
在执行过程中重构字节码
通过把取样的方法与重构组合在一起,我能够形成一种最终的实现,它提供了各种方法的最佳特性。默认情况下可以使用取样,但是应用程序的花费时间最多的方法可以被个别地进行重构。这种实现根本不会安装ClassTransformer,但是作为代替,它会一次一个地重构方法以响应取样过程中收集到的数据。
为了实现这种功能,我将建立一个新类InstrumentationManager,它可以用于重构和不重构独立的方法。它可以使用新的Instrumentation.redefineClasses方法来修改空闲的类,同时代码则可以不间断执行。前面部分中增加的StatusSampler线程现在有了额外的职责,它把任何自己"发现"的@Status方法添加到集合中。它将周期性地找出最坏的冒犯者并把它们提供给InstrumentationManager以供重构。这允许应用程序更加精确地跟踪每个方法的启动和终止时刻。
前面提到的取样方法的一个问题是它不能区分长时间运行的方法与在循环中多次调用的方法。由于重构会给每次方法调用增加一定的开销,我们有必要忽略频繁调用的方法。幸运的是,我们可以使用重构解决这个问题。除了简单地更新StatusManager之外,我们将维护每个重构的方法被调用的次数。如果这个数值超过了某个极限(意味着维护这个方法的信息的开销太大了),取样线程将会永远地取消对该方法的重构。
理想情况下,我将把每个方法的调用数量存储在重构过程中添加到类的新字段中。不幸的是,Java 1.5中增加的类转换机制不允许这样操作;它不能增加或删除任何字段。作为代替,我将把这些信息存储在新的CallCounter类的Method对象的静态映射中。
这种混合的方法可以在示例代码的/code/06_dynamic目录中找到。
概括
图4提供了一个矩形,它显示了我给出的例子相关的特性和代价。
你可以发现,动态的(Dynamic)方法是各种方案的良好组合。与使用重构的所有示例类似,它提供了方法开始或终止时刻的明确的回调,因此你的应用程序可以准确地跟踪运行时并立即为用户提供反馈信息。但是,它还能够取消某种方法的重构(它被过于频繁地调用),因此它不会受到其它的重构方案遇到的性能问题的影响。它没有包含编译时步骤,并且它没有增加类载入过程中的额外的工作。
未来的趋势
我们可以给这个项目增加大量的附件特性,使它更加适用。其中最有用的特性可能是动态的状态信息。我们可以使用新的java.util.Formatter类把类似printf的模式替换(pattern substitution)用于@Status消息中。例如,我们的connectToDB(String url)方法中的@Status("Connecting to %s")注解可以把URL作为消息的一部分报告给用户。
在源代码重构的帮助下,这可能显得微不足道,因为我将使用的Formatter.format方法使用了可变参数(Java 1.5中增加的"魔术"功能)。重构过的版本类似下面的情形:
public void connectToDB (String url) {
Formatter f = new Formatter();
String message = f.format("Connecting to %s", url);
StatusManager.push(message);
try {
...
} finally {
StatusManager.pop();
}
}
不幸的是,这种"魔术"功能是完全在编译器中实现的。在字节码中,Formatter.format把Object[]作为参数,编译器明确地添加代码来包装每个原始的类型并装配该数组。如果BCEL没有加紧弥补,而我又需要使用字节码重构,我将不得不重新实现这种逻辑。
由于它只能用于重构(这种情况下方法参数是可用的)而不能用于取样,你可能希望在启动的时候重构这些方法,或最少使动态实现偏向于任何方法的重构,还可以在消息中使用替代模式。
你还可以跟踪每个重构的方法调用的启动次数,因此你还可以更加精确地报告每个方法的运行次数。你甚至于可以保存这些次数的历史统计数据,并使用它们形成一个真正的进度条(代替我使用的不确定的版本)。这种能力将赋予你在运行时重构某种方法的一个很好的理由,因为跟踪任何独立的方法的开销都是很能很明显的。
你可以给进度条增加"调试"模式,它不管方法调用是否包含@Status注解,报告取样过程中出现的所有方法调用。这对于任何希望调试死锁或性能问题的开发者来说都是无价之宝。实际上,Java 1.5还为死锁(deadlock)检测提供了一个可编程的API,在应用程序锁住的时候,我们可以使用该API把进程条变成红色。
本文中建立的基于注解的重构框架组件可能很有市场。一个允许字节码在编译时(通过Ant事务)、启
动时(使用ClassTransformer)和执行过程中(使用Instrumentation)进行重构的工具对于少量其它新项目来说毫无疑问地非常有价值。
总结
在这几个例子中你可以看到,元数据编程(meta-programming)可能是一种非常强大的技术。报告长时间运行的操作的进程仅仅是这种技术的应用之一,而我们的JStatusBar仅仅是沟通这些信息的一种媒介。我们可以看到,Java 1.5中提供的很多新特性为元数据编程提供了增强的支持。特别地,把注解和运行时重构组合在一起为面向属性的编程提供了真正动态的形式。我们可以进一步使用这些技术,使它的功能超越已有的框架组件(例如XDoclet提供的框架组件的功能)。
下载本文示例代码
体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程体验Java 1.5中面向(AOP)编程
阅读(297) | 评论(0) | 转发(0) |