Javassite:使字节码工程变得简单
--在比字节码抽象度更高的java源代码级进行字节码工程
清源(mote_li)译
原文:http://sys-con.com/story/?storyid=38672&DE=1Javassite是一个强大的新的用于字节码工程的库,它允许开发人员给编译过的类增加方法、修改方法等等。不像其他的类似的包,你不需要知道java字节码的知识也不用了解一个class文件的结构,就可以实现上面描述的功能。
节码工程被用于操作、修改一个编译过的类或者用编程的方式新建一个类。节码工程会发生在运行时或者编译的时候,一些技术使用节码操作优化或提高现存class的性能,另一个些技术却使用节码操作来使现存的class更易于使用或者用来避免笨重的代码生成。例如:JDO 1.0 (Java Data Objects)规范需要往简单java类添加进行数据库持久化操作的预编译java字节码;在面向方面编程中,一些新的框架使用节码工程来实现对java类的横切功能;EJB容器,像jboss,在运行期动态的生成新的类以避免EJB代码生成和预编译步骤,这样可以显著的提高开发周期;这相当于jdk在它的java.lang.reflect.Proxy内中所做的字节码操作。
字节码操作对于框架开发者来说是一项复杂和不受欢迎的任务,因为需要付出很大的代价。学习字节码和学习汇编语言很类似,对于开发者来说学习曲线可能会很陡峭,还不仅仅这些,直接写字节码对于维护来说也是一个挑战,因为节码很难阅读和理解。Javassist是一个简化字节码操作的库,它允许开发人员在只有一点字节码或者完全没有字节码知识的情况下完全操作字节码,给开发人员提供了一种细粒度地控制控制字节码的手段。
与Reflection API类似的API
Javassite的第一部分API类似于Reflection API,它让你能够在类被类装载器装载前,查看类的结构。这部分api包括CtClass,CtMehodh和CtField,这些类和Reflection API中的java.lang.Class java.lang.reflect.Method, 和 Field起一样的作用,只是这三个类分别用于描述一个类,一个方法,一个字段的时候这个被描述的类并没有被装载。他们提供了很多类似Reflection API的方法,例如getName, getSuperclass, getMethods, getSignature等等。下面的代码读取org.geometry.Point类,分析这个类的定义,然后打印出它的父类的名字(这篇文章假定,已经引入了javassist.* ):
1. ClassPool pool = ClassPool.getDefault();
2. CtClass pt = pool.get("org.geometry.Point");
3. System.out.println(pt.getSuperclass().getName());
ClassPool对象是CtClass的工厂,它在指定的类路径里搜索每一个类文件,并为一个搜索到的类文件建立一个单例CtClass对象,get方法根据指定的类名返回用于描述该类的CtClass对象。
既然当一个CtClass对象用于描述一个类时这个类并没有被装载,Javassite API也就没有提供新建一个实例的方法(newInstance方法)、调用方法的方法(invoke 方法)、访问一个字段的方法。另一方面这些api却提供了一些用于修改类定义的方法,例如CtClass中的setSuperclass方法用于改变类的父类,Reflection API不支持这种更改父类的操作。一个例子:
4. pt.setSuperclass(pool.get("Figure"));
对Point类定义的修改将使Point类继承Figure,出于一致性的考虑,在上面的例子中我们假设Figure类和Point原本的父类是兼容的。
给Point增减一个方法也是可能的:
5. CtMethod m = CtNewMethod.make("public int xmove(int dx) { x += dx; }", pt);
6. pt.addMethod(m);
使用一段java源代码文本作为参数通过CtNewMethod类的make所创建的CtMothed对象用于表示需要增加的方法,开发人员不需要手工的写一串java虚拟机指令,javassite使用包含在内部的一个专用的java编译器编译java源代码文本。最终,为了把上面的修改反映出来,writeFile方法将被调用。
7. pt.writeFile();
CtClass的writeFile方法能够把修改后的类定义写到类文件里。Javassite能够和类装载器一起工作,我们将在后面讨论这个问题。Javassite不是实现字节码翻译器的第一个类库,例如,Jakarta BCEL就是一个非常流行的字节码工程库,但是,你不能使用Jakarta BCEL实现源代码文本级的操作。如果你想给一个被编译过的类增加一个方法,就必须把方法体的字节码指令列出来,而使用Javassite只需要指定方法体的java源代文本就可以了,就像上面描述的那样。
编辑方法体
编辑方法体的手段同样是使用源代码文本来描述的。开发人员不需要直接操作虚拟机指令。虚拟机指令级操作不是被限制的,Javassite提供了多种典型的手段,如果开发人员需要进行一些Java源代码不能描述的细粒度的操作,他们可以使用Javassite的底层API,本文不会论述这些API,这些API和Jakarta BCEL很类似。
Javassite用于编辑方法体的API是基于AOP思想来进行设计的, Javassite可以支持一些特殊表达式用来替换原来的方法体的方法调用的表达式和访问字段的表达式。
例如,列表1
1. ClassPool pool = ClassPool.getDefault();
2. CtClass cc = pool.get("Screen");
3. CtMethod cm = cc.getDeclaredMethod("draw", new CtClass[0]);
4. cm.instrument(new ExprEditor() {
5. public void edit(MethodCall m) throws CannotCompileException {
6. if (m.getClassName().equals("Point")
7. && m.getMethodName().equals("move"))
8. m.replace("{ System.out.println(\"move\"); $_ = $proceed($$); }");
9. }
10. });
11. cc.writeFile();
首先得到一个用来描述Screen类draw方法的CtMethod对象,然后这个对象在draw方法体内查找所有调用Point类move方法的表达式,所有被找到的表达式都将被下面的语句替换掉。
{ System.out.println("move"); $_ = $proceed($$); }
所以在调用move方法前一调信息将被打印,这条语句使用了一种特殊的语法。
$_ = $proceed($$);
表示使用原来的参数调用原来的方法
使用CtMethod编辑方法,先搜索方法体,当发现方法调用表达式时就会调用给定ExprEditor对象的edit方法。传递给edit方法的参数是一个代表所找到表达式的MethodCall对象,这个对象提供了一些方法用来得到表达式的一些静态属性,edit方法首先判断表达式所调用是否是Point对象的move方法,如果是就用打印消息的语句块替换原来方法调用的语句,这些用来进行替换的语句块在替换前是被Javassite自代的编译器编译成字节码的。
特殊变量
在这些用来进行替代的语句中有许多由$开头的特殊变量,例如:$_代表被替换表达式的结果。$$在替换语句中用来代表被替换表达式中方法调用的参数列表,$proceed代表原来被调用的方法名。原表达式中方法调用的参数分别由$1,$2,$3...等代表,被调用的目标对象由$0代表。当开发者需要改变调用参数的时候这些特殊变量就变得非常有用。例如,用于替换的表达式如下
{ System.out.println("move"); $_ = $proceed($1, 0); }
调用move方法的第二表参数将被置为零。
另一个特殊变量是$args,这是一个对象数组,包含了所有调用原方法的参数。如果调用的参数是简单类型,将被自动装箱成对应的包裹类的对象,然后保存到这个对象数组里。例如,参数是一个int型的值,将被包裹成一个java.lang.Integer 对象被放到$args所代表的对象数组里。当使用java.lang.reflect.Method类中的invoke来调用方法的时候,$args是非常有用的。
Javassist也允许在方法体的开始和结尾插入代码片断,例如,可以通过insertBefore方法把给定的代码块插入到方法体的开始。
1. ClassPool pool = ClassPool.getDefault();
2. CtClass cc = pool.get("Screen");
3. CtMethod cm = cc.getDeclaredMethod("draw", new CtClass[0]);
4. cm.insertBefore("{ System.out.println($1); System.out.println($2); }");
5. cc.writeFile();
这个例子在draw方法体的开始部分插入代码片断,把调用draw方法的两个变量的值打印出来,$1和$2是两个特殊变量,分别代表着调用draw方法的两个参数。
在传递给CtMethod的setMethod方法作为参数的源代文本中$开头的标识符也是特殊变量。接下来的例子增加了两个包裹方法∶
1. CtClass cc = sloader.get("Point");
2. CtMethod m1 = cc.getDeclaredMethod("move");
3. CtMethod m2 = CtNewMethod.copy(m1, cc, null);
4. m1.setName(m1.getName() + "_orig");
5. m2.setBody("{ System.out.println("call"); return $proceed($$);
}", "this", m1.getName());
6. cc.addMethod(m2);
7. cc.writeFile();
程序首先构造一个CtMethod对象来表示Piont类的move方法,再用拷贝的方式构造一个同样代表move方法的CtMethod对象,通过第一个CtMethod对象把Piont类的move方法改名为move_orig,接着通过第二个CtMethod对象的setBody方法修改方法体方法名不变仍然是move,把修改后的move添给Piont类,修改后的move先打印一条消息后再调用move_orig,它成为了原来move方法的包裹方法。setBody方法的第二个参数用于指定$proceed($$)所代表的目标对象,第三个参数是指定$proceed所代表的方法名。
Javassite还提供了为数众多的帮助类和函数,通过它们你可以替换字段访问,改变方法调用,简化在方法体的开始或者结束插入代码。你可以访问 http://www.javassist.org/以获得更多的信息和教程。
执行效率
一些开发者担心使用Javassist在运行时修改类的效率。认为包含在javassite中定制的编译器会使运行效率变得极为低下,但是,在运行时真正耗时的是处理以$开头的特殊变量,相反定制编译器能够生成优化的字节码来减低对运行效率的影响。例如,开发者使用CtMethod类来替换方法调用表达式,如果替换的语句块和被原来的语句块具有等价的效果,那么只是在替换那个表达式的时候需要多付出一个额外循环的代价而已。
ClassLoader
Javassite可以和ClassLoader可以结合起来运行时在类被装载前修改类的字节码。在java里开发人员可以定制ClassLoader来定制类的装载过程,javassite可以利用这个机制在类被装载的过程中修改类定义。
运行时修改和装载类的简单方法就是把调用CtClass对象writeFile方法替换成调用toClass方法,前面的例子中调用writeFile把修改后的类定义写回磁盘上,现在调用toClass方法通过Javassite定制的类装载器装载类,这个方法返回被装载类对应的java.lang.Class对象。例如列表2中的代码动态建立了一个Hello类,并给它增加say()方法,然后装载Hello类并调用say方法。
列表2
1. ClassPool pool = ClassPool.getDefault();
2. CtClass ch = pool.makeClass("Hello");
3. CtClass ci = pool.get("IHello");
4. ch.addInterface(ci);
5. CtMethod m = CtNewMethod.make( "public void say() {System.out.println(\"Hello\"); }", ch);
6. ch.addMethod(m);
7. Class h = ch.toClass();
8. IHello obj = (IHello)h.newInstance();
9. obj.say();
IHello接口的定义如下
1. public interface IHello {
2. void say();
3. }
使用ClassLoaders的技巧
使用toClass非常简单但是必须小心。由于java类装载器的规则可能使开发者非常迷惑或者是遇上ClassCastException异常。例如在列表2的代码中我们在装载Hello类前先用toClass装载IHello接口,修改代码如下:
:
7. Class ih = ci.toClass();
8. Class h = cc.toClass();
9. IHello obj = (IHello)h.newInstance();
10. obj.say();
这样的修改将在第9行触发ClassCastException异常,这是因为第9行出现的IHello接口在Hello类的类定义不同于IHello接口的类定义。
为了理解这样的情况,你必须知道java里可以共存多重类装载器,这些多重类装载器组成树形结构,除了根类装载器外其他类装载器都有一个父类装载器,正常情况下类装载动作会按类装载器的层次关系委托给相应层次的类装载器。
被两个独立的类装载器装载的两个类就算同样的名称和类定义也会变成两个完全不同的类,因为类是不同的,一个类的实例就不能被赋给代表另一个类的变量。在前面的例子中,IHello在第7行被Javassist定制的类装载器(这个类装载器被称为LJ)装载,第8行装载Hello类的时候,ClassLoader缓存了IHello类,Hello是作为这个IHello接口的实现被装载的,这样Hello和所实现的接口都是被LJ装载的。
第9行的IHello是被java缺省的类装载器(我们称为LP)装载的。由于类装载器LP装载LJ,LP就是LJ的父装载器。h.newInstance()创建的对象所实现的IHello接口是被LJ装载而不是被LP装载的,所以第9行会触发异常。如果把第7行删掉,那么第8行LJ就会委托自己的父装载器去装载Hello类所实现的IHello接口。这样第9行的强制转化就不触发异常,Hello类被LJ装载,IHello被LP装载符合java类转载机制的层次关系,所以可以成功的进行强制转化。
为了避免ClassCastException带来的问题,Javassite提供另一种类装载的方式,允许开发人员完全控制类装载行为。开发人员可以定义一些事件监听器,每当客户有装载类的请求的时候就会触发这些事件监听器,可以在适当的时候对需要装载的类进行修改。
总结
Javassite是一个强大的可以在装载时或者运行时操作字节码的JAVA类库。与其他类时的类库相比,Javassite使用比字节码抽象度更高的java源代码。使用Javassite相当的简单,完全不需要详细的掌握字节码指令。