8.3 字节代码增强

上一节介绍的动态编译Java源代码方式的目的在于产生新的字节代码,其输入是Java源代码。在某些情况下,可能无法获得相关的源代码,而只得到二进制的字节代码。如果希望对字节代码进行处理,需要用到字节代码增强技术。字节代码增强的含义是对已有的Java字节代码进行修改,从而改变其运行时的行为。Java字节代码格式的开放性使这种增强方式成为可能。增强的工作可以在程序运行之前或运行时完成。在程序运行之前的做法是先对程序的字节代码进行处理,得到修改后的字节代码,再由虚拟机来运行。通常是在基本的编译过程完成之后,再使用工具对编译之后的字节代码进行处理。在程序运行时的做法是在字节代码被虚拟机加载之前对字节代码进行修改。通常由增强代理或类加载器来完成修改工作。一般来说,自己开发的程序适合使用第一种做法,原因是可以避免运行时修改所带来的开销。而框架一般使用第二种做法,原因是框架本身无法在运行之前进行任何处理,只能在运行时进行处理。有些框架则两种做法都支持,如Apache OpenJPA既提供了Ant任务对使用OpenJPA的程序在运行之前进行处理,又可以通过增强代理在运行时进行。

字节代码增强技术在很多场景中都有应用。使用它可以灵活高效地解决某些问题,比如典型的AOP编程中的方法拦截功能。还可以利用字节代码增强技术进行代码生成。由于Java字节代码的格式是公开和规范的,对字节代码进行操作并不是一件复杂的事。不过字节代码格式本身比较复杂,最好借助工具的支持来进行修改。有不少工具可以用于操作字节代码,如OpenJPA使用的serp[1]和Play框架使用的Javassist[2]。下面要介绍的操纵字节代码的工具是ASM,它在AspectJ、JRuby和Jython等框架中都有使用。

8.3.1 使用ASM

ASM是一个轻量级的Java字节代码操作工具。ASM为字节代码中的各种结构和数据提供了一种面向对象的表示方式,使开发人员不需要了解常量池等具体细节,就可以很方便地创建新的字节代码或对已有的字节代码进行修改。ASM所提供的API在设计上比较贴近于字节代码的原始格式,这使得ASM操作字节代码的性能比较高,同时也要求使用者对字节代码格式有比较深入的了解。尤其在用ASM生成方法体的字节代码时,需要使用者对Java虚拟机的指令有比较详细的了解。不过,ASM也提供了一些工具帮助开发人员查看字节代码的内容,以及生成相关的使用ASM的代码。

从之前对字节代码格式的介绍中可以看出,字节代码实际上采用了一种松散的树形组织结构。类中包含域、方法和属性,而域和方法又有各自具体的结构。对字节代码的处理,可以与同样是树形结构的XML文档的处理方式进行类比。XML文档通常有SAX和DOM两种处理方式。SAX是基于事件的,而DOM是基于文档的树形结构的。ASM的API可以与XML文档的两种处理API相对应。ASM中基本的字节代码处理API是基于事件的,类似于SAX。当在处理过程中遇到特定的结构时,会产生对应的事件。通过对事件的处理来操作字节代码。在具体的实现上,采用了访问者模式对树形结构进行遍历。在事件API的基础上,ASM也提供了类似于DOM的树形API。这两种API的优缺点可以同样类比SAX和DOM。

ASM对于字节代码的操作有3个典型的场景,分别是字节代码的读取、生成和修改。读取的含义是指查看一个已有的字节代码中的内容,适合进行相关的代码分析;生成的含义是指从零开始生成一个Java类或接口的字节代码,由虚拟机来运行,适合代码生成;修改的含义则是指对已有的字节代码进行修改,适合功能增强。

1.读取字节代码

在介绍对字节代码的读取之前,先介绍ASM中的核心接口org.objectweb.asm.ClassVisitor。正如ClassVisitor接口的名称所表示的含义一样,这个接口是对字节代码中的类或接口信息进行访问的访问者。该接口中所包含的方法用于对类中包含的不同部分进行访问,比如visitField方法可以访问一个域的信息,visitMethod方法访问一个方法的信息。读取字节代码的操作由ClassVisitor接口的实现对象和org.objectweb.asm.ClassReader类的对象结合起来完成。ClassReader类可以读取包含字节代码的字节流,也可以根据类名来查找并读取。ClassReader类的accept方法可以接受一个ClassVisitor接口的实现对象作为参数。当ClassReader类的对象在读取过程中遇到类中的不同结构时,会调用ClassVisitor接口的实现对象中的相关方法。比如读取一个方法的时候,ClassVisitor接口的实现对象中的visitMethod方法会被调用,调用时的实际参数中包含了当前方法的相关信息。如果从事件的角度来看,ClassReader类的对象是事件的生产者,ClassVisitor接口的实现对象是事件的消费者。事件的具体来源是已有的字节代码,对事件的处理逻辑则由开发人员提供。

下面通过一个具体的示例来介绍ClassVisitor接口和ClassReader类的使用方式。这个示例要实现的功能很简单,统计一个类中包含的方法的个数。完整的实现如代码清单8-12所示。先介绍一下ClassVisitor接口中的方法。visit方法表示的是访问类的基本信息,包括版本号、访问控制标记和修饰符、名称、类型签名、父类名称和实现的接口名称等。这些信息是与字节代码格式中的内容直接对应的。visitInnerClass方法表示的是访问类中包含的内部类的信息。visitOuterClass方法表示的是访问当前类的外部类的信息。visitSource方法表示的是访问类对应的源文件的信息。visitAttribute方法表示的是访问属性的信息。visitField方法表示的是访问类中的一个域,参数中包含了域的基本信息。如果需要继续访问域中包含的详细信息,那么需要返回一个org.objectweb.asm.FieldVisitor接口的实现对象。visitAnnotation方法表示的是访问类中的注解的信息。如果需要继续访问注解的详细信息,那么需要返回一个org.objectweb.asm.AnnotationVisitor接口的实现对象。visitMethod方法表示的是访问类中包含的方法的信息。同样,可以返回一个org.objectweb.asm.MethodVisitor接口的实现对象来访问方法的具体信息。从这些方法的名称和参数可以看出,ASM的ClassVisitor接口采用了与字节代码进行直接对应的方式。熟悉字节代码格式的开发人员很容易进行对应。使用访问者模式的实现也很清晰。对域、注解和方法的处理比较特殊,这是因为它们包含的信息比较复杂,需要另外的访问者接口来表示。

代码清单8-12 统计方法个数的ClassVisitor接口的实现


public class MethodCounter implements ClassVisitor{

private int count=0;

public void visit(int version, int access, String name, String signature, String superName, String[]interfaces){

}

public AnnotationVisitor visitAnnotation(String desc, boolean visible){

return null;

}

public void visitAttribute(Attribute attr){

}

public void visitEnd(){

}

public FieldVisitor visitField(int access, String name, String desc, String signature, Object value){

return null;

}

public void visitInnerClass(String name, String outerName, String innerName, int access){

}

public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[]exceptions){

count++;

return null;

}

public void visitOuterClass(String owner, String name, String desc){

}

public void visitSource(String source, String debug){

}

public int getCount(){

return count;

}

public static void main(String[]args)throws IOException{

ClassReader reader=new ClassReader("java.lang.String");

MethodCounter counter=new MethodCounter();

reader.accept(counter,0);

System.out.println(counter.getCount());

}

}


代码清单8-12中的示例只对类中包含的方法感兴趣,因此只在visitMethod方法中添加了具体实现。在visitMethod方法的实现中直接修改计数器的值。由于不需要获取方法的详细信息,因此不需要使用自定义的MethodVisitor接口的实现对象,直接返回null即可。在使用时,由ClassReader类的对象负责读取String类的字节代码,通过accept方法交由MethodCounter类的对象来处理。

2.生成字节代码

在有些情况下,可能需要从零开始生成一个类或接口对应的字节代码,比如生成程序运行所需的辅助代码。ASM中与ClassReader类对应的org.objectweb.asm.ClassWriter类可以用来创建Java类或接口的字节代码。ClassWriter类实现了ClassVisitor接口。这也是ASM设计中比较巧妙的一个地方。ClassVisitor接口不仅可以访问字节代码中的内容,还可以用来生成相关的内容。在进行访问时,ClassVisitor接口中的visit、visitField和visitMethod等方法是被动调用的,用来通知在字节代码中发现了对应的结构,而相关的信息作为方法调用时的实际参数来传递。在生成字节代码时,ClassVisitor接口中的方法由使用者来调用。调用者提供的参数作为字节代码中相关结构的值。从事件的角度来看,ClassVisitor接口不仅可以作为事件的消费者,还可以作为事件的生产者。

在第2章介绍Java语言的动态性时提到,很多动态类型语言可以生成Java字节代码,并且在Java虚拟机上执行这些字节代码。这些语言直接生成所需的字节代码,而不需要通过Java编译器。下面通过设计一门小的语言来说明ASM工具生成字节代码的用处。这门语言是DRAW,用来进行简单的图形绘制,只包含MOVETO和LINETO两条指令,分别表示移动到某个位置,以及从当前位置画线到另外一个位置。DRAW语言可以看成是领域特定语言(Domain Specific Language, DSL)的一个简单示例。代码清单8-13给出了DRAW语言的示例代码,其功能是绘制一个三角形。

代码清单8-13 DRAW语言的示例代码


MOVETO 30 30

LINETO 30 100

LINETO 70 100

LINETO 30 30


使用DRAW语言来编写绘制图形时的指令,这些指令对于普通用户来说是容易理解的。要把DRAW语言的代码转换成可以在虚拟机上运行的字节代码才能让用户看到绘制的结果。利用Java平台中的Java 2D API可以进行图形绘制。与代码清单8-13中的DRAW语言代码对应的Java代码如代码清单8-14所示。

代码清单8-14 与DRAW语言对应的Java代码


public class DrawingComponent extends Component{

public void paint(Graphics g){

g.drawLine(30,30,30,100);

g.drawLine(30,100,70,100);

g.drawLine(70,100,30,30);

}

}


从实现的角度来说,一种做法是把DRAW语言的代码转换成Java源代码,再使用之前介绍的动态编译技术来进行编译。这种做法的不足之处在于不够直接,性能会受到一定程度的影响。更好的做法是直接从DRAW语言的代码中生成Java字节代码。生成一个Java类对应的字节代码并不是一件容易的事情。在字节代码中所要考虑的细节比源代码要多不少。一般的做法是对所需要生成的字节代码进行逆向处理。ASM提供了两组工具类,可以对生成字节代码的操作提供辅助。第一组类用来以直观的方式输出字节代码中的内容,方便开发人员查看字节代码中各部分的细节。这组类中比较重要的是org.objectweb.asm.util.TraceClassVisitor类,用来输出Java类的信息。第二组类用来产生生成字节代码所需的使用ASM的源代码。这组类中比较重要的是org.objectweb.asm.util.ASMifierClassVisitor类。开发人员可以先编写与所要生成的字节代码相对应的Java代码,再编译此Java代码得到字节代码。对于字节代码使用ASMifierClassVisitor类可以得到使用ASM进行字节代码生成时应该编写的Java代码。这些Java代码可以作为具体实现的基础。

先使用ASMifierClassVisitor类对代码清单8-14中的DrawingComponent类产生的字节代码进行处理,得到基本的使用ASM的Java代码。再以此Java代码为基础,得到完整的字节代码生成的实现,如代码清单8-15所示。在DrawingCodeGenerator类中,创建了一个ClassWriter类的对象。在创建时使用了标记ClassWriter.COMPUTE_MAXS,表明由ClassWriter类的对象自动计算栈大小和局部变量个数的最大值。如果不使用此标记,在调用MethodVisitor接口的实现对象的visitMaxs方法时,需要手动计算这两个值。如果计算出错,字节代码无法正确运行。字节代码生成的基本逻辑是先生成字节代码中类的基本信息,再生成paint方法的内容,最后根据DRAW语言代码的内容生成paint方法中调用drawLine方法的相关指令代码。

代码清单8-15 生成DRAW语言对应的字节代码


public class DrawingCodeGenerator implements Opcodes{

private ClassWriter writer=new ClassWriter(ClassWriter.COMPUTE_MAXS);

private MethodVisitor mv=null;

private int currentX=0;

private int currentY=0;

public byte[]generate(String sourceCode)throws IOException{

generateClassInfo();

generatePaintMethod(sourceCode);

writer.visitEnd();

return writer.toByteArray();

}

private void generateClassInfo(){

writer.visit(V1_7,ACC_PUBLIC+ACC_SUPER,"com/java7book/chapter8/asm/DrawingComponent",null,"java/awt/Component",null);

mv=writer.visitMethod(ACC_PUBLIC,"<init>","()V",null, null);

mv.visitCode();

mv.visitVarInsn(ALOAD,0);

mv.visitMethodInsn(INVOKESPECIAL,"java/awt/Component","<init>","()V");

mv.visitInsn(RETURN);

mv.visitMaxs(1,1);

mv.visitEnd();

}

private void generatePaintMethod(String sourceCode)throws IOException{

mv=writer.visitMethod(ACC_PUBLIC,"paint","(Ljava/awt/Graphics;)V",null, null);

mv.visitCode();

BufferedReader reader=new BufferedReader(new StringReader(sourceCode));

String line=null;

while((line=reader.readLine())!=null){

if(line.startsWith("MOVETO")){

handleMoveTo(line);

}

else if(line.startsWith("LINETO")){

handleLineTo(line);

}

}

mv.visitInsn(RETURN);

mv.visitMaxs(0,0);

mv.visitEnd();

}

private void handleMoveTo(String line){

String pos=line.substring("MOVETO".length());

String[]parts=pos.split("");

currentX=Integer.parseInt(parts[0]);

currentY=Integer.parseInt(parts[1]);

}

private void handleLineTo(String line){

String pos=line.substring("LINETO".length());

String[]parts=pos.split("");

int x=Integer.parseInt(parts[0]);

int y=Integer.parseInt(parts[1]);

mv.visitVarInsn(ALOAD,1);

mv.visitIntInsn(BIPUSH, currentX);

mv.visitIntInsn(BIPUSH, currentY);

mv.visitIntInsn(BIPUSH, x);

mv.visitIntInsn(BIPUSH, y);

mv.visitMethodInsn(INVOKEVIRTUAL,"java/awt/Graphics","drawLine","(IIII)V");

currentX=x;

currentY=y;

}

}


3.修改字节代码

使用ASM的另外一个场景是对已有的字节代码进行修改。通常由ClassReader类、ClassWriter类和org.objectweb.asm.ClassAdapter类来共同完成。ClassAdapter类实现了ClassVisitor接口,可以作为ClassReader类和ClassWriter类之间的桥梁。ClassReader类的对象在读取字节代码时,把ClassAdapter类的对象作为它所产生的事件的消费者。当事件产生时,ClassAdapter类的对象中的相关方法会被调用。ClassAdapter类的对象在创建时,需要指定另外一个ClassVisitor接口的实现对象作为参数。这个ClassVisitor接口的实现作为事件的消费者。当ClassAdapter类的对象中的方法被调用时,默认的行为是直接调用所封装的ClassVisitor接口的实现对象中的对应方法。这相当于把ClassReader类的对象所产生的事件原封不动地传递到作为消费者的ClassVisitor接口的实现对象中。如果需要进行修改操作,可以覆写ClassAdapter类中的对应方法来改变相关的逻辑。例如,如果希望删除原始字节代码中的名为“test”的方法,可以在ClassAdapter类的visitMethod方法的实现中检查当前方法的名称。如果名称为“test”,则直接返回null,不把该事件传递给ClassAdapter类的对象封装的ClassVisitor接口的实现对象。在这种情况下,被封装的ClassVisitor接口的实现对象就看不到这个方法,相当于这个方法被删除。ClassAdapter类中的所有方法都可以通过被覆写的方式来实现对原始字节代码的修改。

下面通过一个具体的示例来进行说明。该示例的场景是在原始的Java类中添加静态域来跟踪该Java类被创建出来的对象实例的个数。在原始的Java类实现中并没有这样的能力。出于调试的目的,可以修改原始类的字节代码,添加这样的功能。从实现的角度来说,只需要在类中添加一个公开的静态变量作为计数器,并在构造方法中修改计数器的值即可。每次构造方法被调用时,计数器的值会加1。从源代码的角度来讲,进行这样的修改并不困难,但修改已有的字节代码需要比较复杂的实现。主要的复杂性体现在如何找到正确的添加方式,否则会造成字节代码无法运行的后果。

比较合适的做法是先编写修改之后的字节代码对应的Java源代码,再将其编译成字节代码。使用ASM提供的工具来比较修改前后的字节代码的差异。通过这些差异,可以知道要做出的修改。代码清单8-16中给出了完整的示例。在覆写ClassAdapter类的visit方法的实现中,通过visitField方法创建出保存对象实例数量的instanceCount域。在visitMethod方法的实现中,如果当前方法的名称是“<init>”,即构造方法,则返回一个继承自MethodAdapter类的UpdateInstanceCounterAdapter类的对象。在UpdateInstanceCounterAdapter类中通过覆写visitInsn方法来添加相关的指令。如果当前指令代码是与方法返回或异常抛出相关的,说明当前指令是方法返回前的最后一条指令。在此指令之前添加修改类静态域instanceCount值的指令。

代码清单8-16 记录Java类的对象实例个数的字节代码修改方式


public class InstanceCounter extends ClassAdapter implements Opcodes{

private static class UpdateInstanceCounterAdapter extends MethodAdapter

implements Opcodes{

private String className;

public UpdateInstanceCounterAdapter(String className, MethodVisitor mv){

super(mv);

this.className=className;

}

public void visitInsn(int opcode){

if((opcode>=IRETURN&&opcode<=RETURN)||opcode==ATHROW){

mv.visitFieldInsn(GETSTATIC, className,"instanceCount","I");

mv.visitInsn(ICONST_1);

mv.visitInsn(IADD);

mv.visitFieldInsn(PUTSTATIC, className,"instanceCount","I");

}

mv.visitInsn(opcode);

}

public void visitMaxs(int maxStack, int maxLocals){

mv.visitMaxs(maxStack+2,maxLocals);

}

}

private String className;

public InstanceCounter(ClassVisitor cv){

super(cv);

}

public void visit(int version, int access, String name, String signature,

String superName, String[]interfaces){

cv.visit(version, access, name, signature, superName, interfaces);

className=name;

FieldVisitor fv=cv.visitField(ACC_PUBLIC+ACC_STATIC,"instanceCount","I",null, null);

fv.visitEnd();

}

public MethodVisitor visitMethod(int access, String name, String desc, String

signature, String[]exceptions){

MethodVisitor mv=cv.visitMethod(access, name, desc, signature, exceptions);

if("<init>".equals(name)){

mv=new UpdateInstanceCounterAdapter(className, mv);

}

return mv;

}

public static void main(String[]args)throws IOException{

ClassReader reader=new ClassReader("com.java7book.chapter8.asm.CreatedObject");

ClassWriter writer=new ClassWriter(0);

InstanceCounter counter=new InstanceCounter(writer);

reader.accept(counter,0);

byte[]byteCode=writer.toByteArray();

Files.write(Paths.get("bin","com","java7book","chapter8","asm","CreatedObject.class"),byteCode);

}

}


在运行时,创建一个ClassReader类的对象来读取原始的字节代码,同时创建一个ClassWriter类的对象来输出修改之后的字节代码。进行修改操作的InstanceCounter类使用ClassWriter类的对象作为参数。ClassReader类的对象产生的事件在经过InstanceCounter类的对象处理之后,被传递给ClassWriter类的对象。由ClassWriter类的对象负责修改后的字节代码的生成。

4.树形API

除了基本的事件API之外,ASM中也有树形API。树形API把字节代码的信息读取到内存中,用不同的对象来表示。树形API相对于事件API来说,所需的内存更多,运行速度也更慢。不过在某些情况下,树形API有自己的优势。对字节代码进行的某些操作可能需要获取一些字节代码相关的全局信息。这些信息通过事件API是不能在一次操作中获取的,因为这些信息只有在处理全部结束之后才能统计出来,所以,事件API至少需要两次处理才能完成。第一次收集信息,第二次进行实际的处理。而树形API由于已经把全部信息读入内存,因此获取全局信息是很容易的。

同样是计算类中包含的方法的个数,相对于代码清单8-12中给出的使用事件API的实现来说,使用树形API则简单很多,如代码清单8-17所示。

代码清单8-17 使用树形API计算方法个数


public class TreeMethodCounter{

public int count(String className)throws IOException{

ClassReader reader=new ClassReader(className);

ClassNode cn=new ClassNode();

reader.accept(cn,0);

return cn.methods!=null?cn.methods.size():0;

}

public static void main(String[]args)throws IOException{

TreeMethodCounter counter=new TreeMethodCounter();

int count=counter.count("java.lang.String");

System.out.println(count);

}

}


ASM中树形API的Java类都在org.objectweb.asm.tree包中。对于字节代码中出现的各种结构,都有Java类与之对应。如ClassNode类表示一个Java类或接口,FieldNode类和MethodNode类分别表示域和方法。ClassNode类实现了ClassVisitor接口。使用ClassNode类的对象访问一个ClassReader类的对象读取的字节代码,可以得到字节代码在内存中的完整表示形式。通过ClassNode类的对象可以访问字节代码中包含的各种结构,如ClassNode类的对象的methods域表示的是包含所有方法的MethodNode类的对象的列表。

[1]serp工具的网址是http://serp.sourceforge.net/。

[2]Javassist工具的网址是http://www.javassist.org/。