Java 编程的动态性,第 5 部分: 动态转换类
[原文链接,非原创,转载请保留出处:http://www.ibm.com/developerworks/cn/java]
学习如何在使用 Javassist 加载类时修改它们
在经过一段时间的休息之后,Dennis Sosnoski 又回来推出了他的 Java 编程的动态性系列的第 5 部分。您已在前面的文章中看到了如何编写用于转换 Java 类文件以改变代码行为的程序。在本期中,Dennis将展示如何使用 Javassist 框架,把转换与实际的类加载过程结合起来,用以进行灵活的“即时”面向方面的特性处理。这种方法允许您决定想要在运行时改变的内容,并潜地在每次运行程序时做出不同的修改。在整个过程中,您还将更深入地了解向JVM 中加载类的一般问题。
在第 4 部分“ 用 Javassist 进行类转换”中,您学习了如何使用 Javassist 框架来转换编译器生成的 Java 类文件,同时写回修改过的类文件。这种类文件转换步骤对于做出持久变更是很理想的,但是如果想要在每次执行应用程序时做出不同的变更,这种方法就不一定很方便。对于这种暂时的变更,采用在您实际启动应用程序时起作用的方法要好得多。
JVM 体系结构为我们提供了这样做的便利途径――通过使用 classloader 实现。通过使用 classloader 挂钩(hook),您可以拦截将类加载到 JVM 中的过程,并在实际加载这些类之前转换它们。为了说明这个过程是如何工作的,我将首先展示类加载过程的直接拦截,然后展示 Javassist 如何提供了一种可在您的应用程序中使用的便利捷径。在整个过程中,我将利用取自本系列以前文章中的代码片断。
加载区域
运行 Java 应用程序的通常方式是作为参数向 JVM 指定主类。这对于标准操作没有什么问题,但是它没有提供及时拦截类加载过程的任何途径,而这种拦截对大多数程序来说是很有用的。正如我在第 1 部分“ 类和类装入”中所讨论的,许多类甚至在主类还没有开始执行之前就已经加载了。要拦截这些类的加载,您需要在程序的执行过程中进行某种程度的重定向。
幸运的是,模拟 JVM 在运行应用程序的主类时所做的工作是相当容易的。您所需做的就是使用反射(这是在不得 第 2 部分 中介绍的)来首先找到指定类中的静态 main()
方法,然后使用预期的命令行参数来调用它。清单 1 提供了完成这个任务的示例代码(为简单起见,我省略了导入和异常处理语句):
清单 1. Java 应用程序运行器
public class Run { public static void main(String[] args) { if (args.length >= 1) { try { // load the target class to be run Class clas = Run.class.getClassLoader(). loadClass(args[0]); // invoke "main" method of target class Class[] ptypes = new Class[] { args.getClass() }; Method main = clas.getDeclaredMethod("main", ptypes); String[] pargs = new String[args.length-1]; System.arraycopy(args, 1, pargs, 0, pargs.length); main.invoke(null, new Object[] { pargs }); } catch ... } } else { System.out.println ("Usage: Run main-class args..."); } } }
要使用这个类来运行 Java 应用程序,只需将它指定为 java
命令的目标类,后面跟着应用程序的主类和想要传递给应用程序的其他任何参数。换句话说,如果用于运行 Java 应用程序的命令为:
java test.Test arg1 arg2 arg3
您相应地要通过如下命令使用 Run
类来运行应用程序:
java Run test.Test arg1 arg2 arg3
拦截类加载
就其本身而言,清单 1 中短小的 Run
类不是非常有用。为了实现拦截类加载过程的目标,我们需要采取进一步的动作,对应用程序类定义和使用我们自己的 classloader。
正如我们在第 1 部分中讨论的,classloader 使用一个树状层次结构。每个 classloader(JVM 用于核心 Java 类的根 classloader 除外)都具有一个父 classloader。Classloader 应该在独自加载类之前检查它们的父 classloader,以防止当某个层次结构中的多个 classloader 加载同一个类时可能引发的冲突。首先检查父 classloader 的过程称为 委托――classloader 将加载类的责任委托给最接近根的 classloader,后者能够访问要加载类的信息。
当 清单 1 中的 Run
程序开始执行时,它已经被 JVM 默认的 System classloader(您定义的 classpath 所指定的那一个)加载了。为了符合类加载的委托规则,我们需要对相同的父 classloader 使用完全相同的 classpath 信息和委托,从而使我们的 classloader 成为 System classloader 的真正替代者。幸运的是,JVM 当前用于 System classloader 实现的 java.net.URLClassLoader
类提供了一种检索 classpath 信息的容易途径,它使用了 getURLs()
方法。为了编写 classloader,我们只需从 java.net.URLClassLoader
派生子类,并初始化基类以使用相同的 classpath 和父 classloader 作为加载主类的 System classloader。清单 2 提供了这种方法的具体实现:
清单 2. 一个详细的 classloader
public class VerboseLoader extends URLClassLoader { protected VerboseLoader(URL[] urls, ClassLoader parent) { super(urls, parent); } public Class loadClass(String name) throws ClassNotFoundException { System.out.println("loadClass: " + name); return super.loadClass(name); } protected Class findClass(String name) throws ClassNotFoundException { Class clas = super.findClass(name); System.out.println("findclass: loaded " + name + " from this loader"); return clas; } public static void main(String[] args) { if (args.length >= 1) { try { // get paths to be used for loading ClassLoader base = ClassLoader.getSystemClassLoader(); URL[] urls; if (base instanceof URLClassLoader) { urls = ((URLClassLoader)base).getURLs(); } else { urls = new URL[] { new File(".").toURI().toURL() }; } // list the paths actually being used System.out.println("Loading from paths:"); for (int i = 0; i < urls.length; i++) { System.out.println(" " + urls[i]); } // load target class using custom class loader VerboseLoader loader = new VerboseLoader(urls, base.getParent()); Class clas = loader.loadClass(args[0]); // invoke "main" method of target class Class[] ptypes = new Class[] { args.getClass() }; Method main = clas.getDeclaredMethod("main", ptypes); String[] pargs = new String[args.length-1]; System.arraycopy(args, 1, pargs, 0, pargs.length); Thread.currentThread(). setContextClassLoader(loader); main.invoke(null, new Object[] { pargs }); } catch ... } } else { System.out.println ("Usage: VerboseLoader main-class args..."); } } }
我们已从 java.net.URLClassLoader
派生了我们自己的 VerboseLoader
类,它列出正在被加载的所有类,同时指出哪些类是由这个 loader 实例(而不是委托父 classloader)加载的。这里同样为简洁起见而省略了导入和异常处理语句。
VerboseLoader
类中的前两个方法 loadClass()
和 findClass()
重载了标准的 classloader 方法。 loadClass()
方法分别针对 classloader 请求的每个类作了调用。在此例中,我们仅让它向控制台打印一条消息,然后调用它的基类版本来执行实际的处理。基类方法实现了标准 classloader 委托行为,即首先检查父 classloader 是否能够加载所请求的类,并且仅在父 classloader 无法加载该类时,才尝试使用受保护的findClass()
方法来直接加载该类。对于 findClass()
的 VerboseLoader
实现,我们首先调用重载的基类实现,然后在调用成功(在没有抛出异常的情况下返回)时打印一条消息。
VerboseLoader
的 main()
方法或者从用于包含类的 loader 中获得 classpath URL 的列表,或者在与不属于 URLClassLoader
的实例的 loader 一起使用的情况下,简单地使用当前目录作为唯一的 classpath 条目。不管采用哪种方式,它都会列出实际正在使用的路径,然后创建VerboseLoader
类的一个实例,并使用该实例来加载命令行上指定的目标类。该逻辑的其余部分(即查找和调用目标类的 main()
方法)与 清单 1 中的 Run
代码相同。
清单 3 显示了 VerboseLoader
命令行和输出的一个例子,它用于调用清单 1 中的 Run
应用程序:
清单 3. 清单 2 中的程序的例子输出
[dennis]$ java VerboseLoader Run Loading from paths: file:/home/dennis/writing/articles/devworks/dynamic/code5/ loadClass: Run loadClass: java.lang.Object findclass: loaded Run from this loader loadClass: java.lang.Throwable loadClass: java.lang.reflect.InvocationTargetException loadClass: java.lang.IllegalAccessException loadClass: java.lang.IllegalArgumentException loadClass: java.lang.NoSuchMethodException loadClass: java.lang.ClassNotFoundException loadClass: java.lang.NoClassDefFoundError loadClass: java.lang.Class loadClass: java.lang.String loadClass: java.lang.System loadClass: java.io.PrintStream Usage: Run main-class args...
在此例中,唯一直接由 VerboseLoader
加载的类是 Run
类。 Run
使用的其他所有类都是核心 Java 类,它们是通过父 classloader 使用委托来加载的。这其中的大多数(如果不是全部的话)核心类实际上都会在 VerboseLoader
应用程序本身的启动期间加载,因此父 classloader 将只返回一个指向先前创建的 java.lang.Class
实例的引用。
Javassist 拦截
清单 2 中的 VerboseClassloader
展示了拦截类加载的基本过程。为了在加载时修改类,我们可以更进一步,向 findClass()
方法添加代码,把二进制类文件当作资源来访问,然后使用该二进制数据。Javassist 实际上包括了直接完成此类拦截的代码,因此与其进一步扩充这个例子,我们不如看看如何使用 Javassist 实现。
使用 Javassist 来拦截类加载的过程要依赖我们在 第 4 部分 中使用的相同 javassist.ClassPool
类。在该文中,我们通过名称直接从ClassPool
请求类,以 javassist.CtClass
实例的形式取回该类的 Javassist 表示。然而,那并不是使用 ClassPool
的唯一方式――Javassist 还以 javassist.Loader
类的形式,提供一个使用 ClassPool
作为其类数据源的 classloader。
为了允许您在加载类时操作它们, ClassPool
使用了一个 Observer 模式。您可以向 ClassPool
的构造函数传递预期的观察者接口(observer interface)的一个实例 javassist.Translator
。每当从 ClassPool
请求一个新的类,它都调用观察者的 onWrite()
方法,这个方法能够在ClassPool
交付类之前修改该类的表示。
javassist.Loader
类包括一个便利的 run()
方法,它加载目标类,并且使用所提供的参数数组来调用该类的 main()
方法(就像在 清单 1 中一样)。清单 4 展示了如何使用 Javassist 类和这个方法来加载和运行目标应用程序类。这个例子中简单的 javassist.Translator
观察者实现仅只是打印一条关于正在被请求的类的消息。
清单 4. Javassist 应用程序运行器
public class JavassistRun { public static void main(String[] args) { if (args.length >= 1) { try { // set up class loader with translator Translator xlat = new VerboseTranslator(); ClassPool pool = ClassPool.getDefault(xlat); Loader loader = new Loader(pool); // invoke "main" method of target class String[] pargs = new String[args.length-1]; System.arraycopy(args, 1, pargs, 0, pargs.length); loader.run(args[0], pargs); } catch ... } } else { System.out.println ("Usage: JavassistRun main-class args..."); } } public static class VerboseTranslator implements Translator { public void start(ClassPool pool) {} public void onWrite(ClassPool pool, String cname) { System.out.println("onWrite called for " + cname); } } }
下面是 JavassistRun
命令行和输出的一个例子,其中使用它来调用 清单 1 中的 Run
应用程序。
[dennis]$java -cp .:javassist.jar JavassistRun Run onWrite called for Run Usage: Run main-class args...
运行时定时
我们在 第 4 部分中分析过的方法定时修改对于隔离性能问题来说可能一个很有用的工具,但它的确需要一个更灵活的接口。在该文中,我们只是将类和方法名称作为参数传递给程序,程序加载二进制类文件,添加定时代码,然后写回该类。对于本文,我们将把代码转换为使用加载时修改方法,并将它转换为可支持模式匹配,用以指定要定时的类和方法。
在加载类时更改代码以处理这种修改是很容易的。在清单 4 中的 javassist.Translator
代码的基础上,当正在写出的类名称与目标类名称匹配时,我们可以仅从 onWrite()
调用用于添加定时信息的方法。清单 5 展示了这一点(没有包含 addTiming()
的全部细节――请参阅第 4 部分以了解这些细节)。
清单 5. 在加载时添加定时代码
public class TranslateTiming { private static void addTiming(CtClass clas, String mname) throws NotFoundException, CannotCompileException { ... } public static void main(String[] args) { if (args.length >= 3) { try { // set up class loader with translator Translator xlat = new SimpleTranslator(args[0], args[1]); ClassPool pool = ClassPool.getDefault(xlat); Loader loader = new Loader(pool); // invoke "main" method of target class String[] pargs = new String[args.length-3]; System.arraycopy(args, 3, pargs, 0, pargs.length); loader.run(args[2], pargs); } catch (Throwable ex) { ex.printStackTrace(); } } else { System.out.println("Usage: TranslateTiming" + " class-name method-mname main-class args..."); } } public static class SimpleTranslator implements Translator { private String m_className; private String m_methodName; public SimpleTranslator(String cname, String mname) { m_className = cname; m_methodName = mname; } public void start(ClassPool pool) {} public void onWrite(ClassPool pool, String cname) throws NotFoundException, CannotCompileException { if (cname.equals(m_className)) { CtClass clas = pool.get(cname); addTiming(clas, m_methodName); } } } }
模式方法
如清单 5 所示,除了使方法定时代码在加载时工作外,在指定要定时的方法时增加灵活性也是很理想的。我最初使用 Java 1.4java.util.regex
包中的正则表达式匹配支持来实现这点,然后意识到它并没有真正带来我想要的那种灵活性。问题在于,用于选择要修改的类和方法的有意义的模式种类无法很好地适应正则表达式模型。
那么哪种模式对于选择类和方法 有意义呢?我想要的是在模式中使用类和方法的任何几个特征的能力,包括实际的类和方法名称、返回类型,以及调用参数类型。另一方面,我不需要对名称和类型进行真正灵活的比较――简单的相等比较就能处理我感兴趣的大多数情况,而对该比较添加基本的通配符就能处理其余的所有情况了。处理这种情况的最容易方法是使模式看起来像标准的 Java 方法声明,另外再进行一些扩展。
关于这种方法的例子,下面是几个与 test.StringBuilder
类的 String buildString(int)
方法相匹配的模式:
java.lang.String test.StringBuilder.buildString(int) test.StringBuilder.buildString(int) *buildString(int) *buildString
这些模式的通用模式首先是一个可选的返回类型(具有精确的文本),然后是组合起来的类和方法名称模式(具有“*”通配字符),最后是参数类型列表(具有精确的文本)。如果提供了返回类型,必须使用一个空格将它与方法名称匹配相隔离,而参数列表则跟在方法名称匹配后面。为了使参数匹配更灵活,我通过两种方式来设置它。如果所给的参数是圆括号括起的列表,它们必须精确匹配方法参数。如果它们是使用方括号(“[]”)来括起的,所列出的类型全都必须作为匹配方法的参数来提供,不过该方法可以按任何顺序使用它们,并且还可以使用附加的参数。因此 *buildString(int, java.lang.String)
将匹配其名称以“buildString”结尾的任何方法,并且这些方法精确地按顺序接受一个 int
类型和一个 String
类型的参数。 *buildString[int,java.lang.String]
将匹配具有相同名称的方法,但是这些方法接受两个 或更多的 参数,其中一个是 int
类型,另一个是 java.lang.String
类型。
清单 6 给出了我编写来处理这些模式的 javassist.Translator
子类的简略版本。实际的匹配代码与本文并不真正相关,不过如果您想要查看它或亲自使用它,我已将它包括在了下载文件中(请参阅 参考资料)。使用这个 TimingTranslator
的主程序类是 BatchTiming
,它也包括在下载文件中。
清单 6. 模式匹配转换程序
public class TimingTranslator implements Translator { public TimingTranslator(String pattern) { // build matching structures for supplied pattern ... } private boolean matchType(CtMethod meth) { ... } private boolean matchParameters(CtMethod meth) { ... } private boolean matchName(CtMethod meth) { ... } private void addTiming(CtMethod meth) { ... } public void start(ClassPool pool) {} public void onWrite(ClassPool pool, String cname) throws NotFoundException, CannotCompileException { // loop through all methods declared in class CtClass clas = pool.get(cname); CtMethod[] meths = clas.getDeclaredMethods(); for (int i = 0; i < meths.length; i++) { // check if method matches full pattern CtMethod meth = meths[i]; if (matchType(meth) && matchParameters(meth) && matchName(meth)) { // handle the actual timing modification addTiming(meth); } } } }
后续内容
在上两篇文章中,您已经看到了如何使用 Javassist 来处理基本的转换。对于下一篇文章,我们将探讨这个框架的高级特性,这些特性提供用于编辑字节代码的查找和替换技术。这些特性使得对程序行为做出系统性的变更很容易,其中包括诸如拦截所有方法调用或所有字段访问这样的变更。它们是理解为什么 Javassist 是 Java 程序中提供面向方面支持的卓越框架的关键。请下个月再回来看看如何能够使用 Javassist 来揭示应用程序中的方面(aspect)。
参考资料
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文.
- 在这里下载本文的 源代码。
- Javassist 由东京工业大学数学与计算科学系的 Shigeru Chiba 发起。它最近已加入了开放源代码的 JBoss应用服务器项目,该项目是添加新的面向方面编程特性的基础。从 Sourceforge 网站上的 JBoss 项目文件页面下载 Javassist 的当前版本。
- 通过教程“ 了解 Java ClassLoader”( developerWorks,2001 年 4 月)了解关于构建自己的特殊类加载器的所有细节。
- 在 Peter Haggar 的“ Java bytecode: Understanding bytecode makes you a better programmer”一文( developerWorks,2001 年 7 月)中找出关于 Java 字节代码设计的更多信息。
- 想要找到关于面向方面编程的更多信息吗?请尝试阅读 Nicholas Lesiecki 撰写的“ 使用面向 Aspect 的编程改进模块性”一文( developerWorks,2002 年 1 月),其中提供了关于使用 AspectJ 语言的概述。
- 开放源代码 Jikes 项目为 Java 编程语言提供了一个非常快速和高度兼容的编译器,可以使用它按以前的风格生成字节代码 ―― 从 Java 源代码开始。
- 请访问 Developer Bookstore 获得技术书籍的详细列表,其中包括数百本 Java 相关的图书。
- 在 developerWorks Java 技术专区 可以找数百篇有关 Java 技术的参考资料。