级别: 中级
李 夷磊 (liyilei@cn.ibm.com), 软件工程师, IBM
邱 小侠 (qiuxiaoxia@gmail.com), 南京大学研究生, 南京大学研究生
蔡 一超 (caiyichao@gmail.com), 学生, 南京大学软件学院
2006 年 12 月 21 日
随 着 AOP(Aspect Oriented Programming) 的发展,代码动态生成已然成为 Java 世界中不可或缺的一环。本文将介绍一种小巧轻便的 Java 字节码操控框架 ASM,它能方便地生成和改造 Java 代码。著名的框架,如 Hibernate 和 Spring 在底层都用到了 ASM。比起传统的 Java 字节码操控框架,BCEL 或者 SERP,它具有更符合现代软件模式的编程模型和更迅捷的性能。本文主要分为四个部分:首先将 ASM 和其他 Java 类生成方案作对比,然后大致介绍 Java 类文件的组织,最后针对最新的 ASM 3.0,描述其编程框架,并给出一个使用 ASM 进行 AOP 的例子,其中讲述调整函数内容,生成派生类,以及静态和动态生成类的方法。
ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格定义格式的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有符号:类名称,方法,属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能根据用户要求生成新类。
与 BCEL 和 SERL 不同,ASM 提供了更为现代的编程模型。对于 ASM 来说,Java class 被描述为一棵树;使用 “Visitor” 模式遍历整个的二进制结构;事件驱动的处理方式使得用户只需要关注于对其编程有意义的部分,而不必了解 Java 类文件格式的所有细节:ASM 框架提供了默认的 “response taker” 处理这一切。
动 态生成 Java 类的是与 AOP 密切相关的。AOP 的初衷在于软件设计世界中存在这么一类代码,零散而又耦合:零散是由于一些公有的功能(诸如那个著名的 log 例子),分散在所有模块之中;同时改变 log 功能又会影响到所有的模块。出现这样的缺陷,很大程度上是由于传统的 OOP(Object Oriented Programming) 注重了继承关系为代表的“纵向”关系,而对于拥有相同功能或者说方面 (Aspect) 的模块之间的“横向”关系不能很好地表达。
诸如,目前有一个既有的银行管理系统,包括 Bank, Customer, Account, Invoice 等。现在要加入一个安全检查模块, 对已有类的所有操作之前都必须进行一次安全检查。
而 Bank, Customer,Account,Invoice 是代表不同的事务,派生自不同的父类,很难在高层上加入关于 Security Checker 的共有功能。对于没有多继承的 Java 来说,更是这样。
传统的解决方案是使用 Decorator 模式,它可以在一定程度上改善耦合,而功能仍旧是分散的——每个需要 Security Checker 的类都必须要派生一个 Decorator,每个需要 Security Checker 的方法都要被 wrap。
下面我们以 Account 类为例看一下 Decorator:
首先,我们有一个 SecurityChecker 类,其静态方法 checkSecurity 执行安全检查功能:
public class SecurityChecker { |
另一个是 Account 类:
public class Account { |
若想对 operation 加入对 SecurityCheck.checkSecurity() 调用,标准的 Decorator 需要先定义一个 Account 类的接口:
public interface Account { |
然后把原来的 Account 类定义为一个实现类:
public class AccountImpl extends Account{ |
定义一个 Account 类的 Decorator,并包装 operation 方法:
public class AccountWithSecurityCheck implements Account{ |
在 这个简单的例子里,改造一个类的一个方法还好,如果是变动整个模块,Decorator 很快就会演化成另一个噩梦。动态改变 Java 类就是要解决 AOP 的问题,提供一种得到系统支持的可编程的方法,自动化地生成或者增强 Java 代码。这种技术已经广泛应用于最新的 Java 框架内,如 Hibernate,Spring 等。
最 直接的改造 Java 类的方法莫过于直接改写 class 文件。Java 规范详细说明了class 文件的格式,直接编辑字节码确实可以改变 Java 类的行为。直到今天,还有一些 Java 高手们使用最原始的工具,如 UltraEdit 这样的编辑器对 class 文件动手术。是的,这是最直接也是最有效的方法,但是要求使用者对 Java class 文件的格式了熟于心:小心地推算出想改造的函数相对文件首部的偏移量,同时重新计算 class 文件的校验码以通过 Java 虚拟机的安全机制
Java 5 中提供的 Instrument 包也可以提供类似的功能:启动时往 Java 虚拟机中挂上一个用户定义的 hook 程序,可以在装入特定类的时候改变特定类的字节码,从而改变该类的行为。但是其缺点也是明显的:
尽管 Instrument 可以改造类,但事实上,Instrument 更适用于监控和控制虚拟机的行为。
一种比较理想且流行的方法是使用 java.lang.ref.proxy。我们仍旧使用上面的例子,给 Account 类加上 checkSecurity 功能:
首 先,Proxy 编程是面向接口的。下面我们会看到,Proxy 并不负责实例化对象,和 Decorator 模式一样,要把 Account 定义成一个接口,然后在 AccountImpl 里实现 Account 接口。接着实现一个 InvocationHandler,每当 Account 方法被调用的时候,虚拟机都会实际调用这个 InvocationHandler 的 invoke 方法:
class SecurityProxyInvocationHandler implements InvocationHandler{ |
最后,在应用程序中指定 InvocationHandler 生成代理对象:
public static void main(String[] args) { |
其不足之处在于:
ASM 能够通过改造既有类,直接生成需要的代码。增强的代码是硬编码在新生成的类文件内部的,没有反射带来性能上的付出。同时,ASM 与 Proxy 编程不同,不需要为增强代码而新定义一个接口,生成的代码可以覆盖原来的类,或者是原始类的子类。它是一个普通的 Java 类而不是 proxy 类,甚至可以在应用程序的类框架中拥有自己的位置,派生自己的子类。
相比于其他流行的 Java 字节码操纵工具,ASM 更小更快。ASM 具有类似于 BCEL 或者 SERP 的功能,而只有 33k 大小,而后者分别有 350k 和 150k。同时,同样类转换的负载,如果 ASM 是 60% 的话,BCEL 需要 700%,而 SERP 需要 1100% 或者更多。
ASM 已经被广泛应用于一系列 Java 项目,AspectWerkz, AspectJ, BEA WebLogic, IBM AUS, OracleBerkleyDB, Oracle TopLink,Terracotta, RIFE, EclipseME, Proactive, Speedo ,Fractal, EasyBeans, BeanShell, Groovy, Jamaica, CGLIB, dynaop, Cobertura, JDBCPersistence, JiP, SonarJ, Substance L&F, Retrotranslator。Hibernate 和 Spring 也通过 cglib,另一个更高层一些的自动代码生成工具使用了 ASM。
|
所 谓 Java 类文件,就是通常用 javac 编译器产生的 .class 文件。这些文件具有严格定义的格式。为了更好的理解 ASM,首先对 Java 类文件格式作一点简单的介绍。Java 源文件经过 javac 编译器编译之后,将会生成对应的二进制文件(如下图所示)。每个合法的 Java 类文件都具备精确的定义,而正是这种精确定义,才使得 Java 虚拟机得以正确读取和解释所有的 Java 类文件。
Java 类文件是 8 位字节的二进制流。数据项按顺序存储在 class 文件中,相邻的项之间没有间隔,这使得 class 文件变得紧凑,减少存储空间。在 Java 类文件中包含了许多大小不同的项,由于每一项的结构都有严格规定,这使得 class 文件能够从头到尾被顺利地解析。下面让我们来看一下 Java 类文件的内部结构,以便对此有个大致的认识。
例如,一个最简单的 Hello World 程序:
public class HelloWorld{ |
从上图中可以看到,一个 Java 类文件大致可以归为 10 个项:
事 实上,使用 ASM 动态生成类,不需要像早年的 class hacker 们,熟知 class 文件的每一段,以及它们的功能、长度、偏移量以及编码方式。ASM 会给我们照顾好这一切的,我们只要告诉 ASM 要改动什么就可以了——当然,我们首先得知道要改什么:对类文件格式了解的越多,我们就更能使用好 ASM 这个利器。
|
ASM 通过树这种数据结构来表示复杂的字节码结构,并利用 Push 模型来对树进行遍历,在遍历过程中对字节码进行修改。所谓的 Push 模型类似于简 单的 Visitor 设计模式,因为需要处理字节码结构是固定的,所以不需要专门抽象出一种 Vistable 接口,而只需要提供 Visitor 接口。所谓 Visitor 模式和 Iterator 模式有点类似,它们都被用来遍历一些复杂的数据结构,Visitor 相当于用户派出的代表,深入到算法内部,由算法安排访问行程。Visitor 代表可以更换,但对算法流程无法干涉,因此是被动的,这也是它和 Iterator 模式由用户主动调遣算法方式的最大的区别。
在 ASM 中,提供了一个 ClassReader 类,这个类可以直接由字节数组或由 class 文件间接的获得字节码数据,它能正确的分析字节码,构建出抽象的树在内存中表示字节码。它会调用 accept 方法,这个方法的接受一个实现 ClassVisitor 接口的具体实例作为参数,然后依次调用 ClassVisitor 接口的各个方法。字节码空间上的偏移被转换成 visit 事件时间上调用的先后,所谓 visit 事件是指对各种不同 visit 函数的调用,ClassReader 知道如何以此调用各种 visit 函数。在这个过程中用户无法对操作进行干涉,所以遍历的算法是确定的,用户可以做的是提供不同的 Visitor 来对字节码树进行不同的修改。ClassVisitor 会产生一些子过程,比如 visitMethod 会返回一个实现 MethordVisitor 接口的实例,visitField 会返回一个实现 FieldVisitor 接口的实例,完成子过程后控制返回到父过程,继续访问下一节点。因此对于 ClassReader 来说,其内部顺序访问是有一定要求的。实际上用户还可以不通过 ClassReader 类,自行手工控制这个流程,只要按照一定的顺序,各个 visit 事件被先后正确的调用,最后就能生成相应的可以被正确加载的字节码。当然获得更大灵活性的同时也加大了调整字节码的复杂度。
各个 ClassVisitor 通过职责链 (Chain-of-responsibility) 模式,可以非常简单的封装对字节码的各种修改,而无须关注字节码的字节偏移,因为这些实现细节都对于用户都被隐藏了,用户要做的只是覆写相应的 visit 函数。
ClassAdaptor 类实现了 ClassVisitor 接口所定义的所有函数,当新建一个 ClassAdaptor 对象的时候,需要传入一个实现了 ClassVisitor 接口的对象,作为职责链中下一个访问者 (Visitor),这些函数的默认实现就是简单的把调用委派给这个对象,然后依次传递下去形成职责链。当用户需要对字节码进行调整时,只需从 ClassAdaptor 类派生出一个子类,覆写需要修改的方法,完成相应功能后再把调用传递下去。这样,用户无需考虑字节偏移,就可以很方便的控制字节码。
每 个 ClassAdaptor 类的派生类可以仅封装单一功能,比如删除某函数,修改字段可见性等等,然后再加入到职责链中,这样耦合更小,重用的概率也更大,但代价是产生很多小对象, 而且职责链的层次太长的话也会加大系统调用的开销,用户需要在底耦合和高效率之间作出选择。用户可以通过控制职责链中 visit 事件的过程,对类文件进行如下操作:
删除类的字段、方法、指令:只需在职责链传递过程中中断委派,不访问相应的 visit 方法即可,比如删除方法时只需直接返回 null,而不是返回由 visitMethod 方法返回的 MethodVisitor 对象。
class DelLoginClassAdapter extends ClassAdapter { |
修改类、字段、方法的名字或修饰符:在职责链传递过程中替换调用参数。
class AccessClassAdapter extends ClassAdapter { |
增加新的类、方法、字段
ASM 的最终的目的是生成可以被正常装载的 class 文件,因此其框架结构为客户提供了一个生成字节码的工具类 —— ClassWriter。它实现了 ClassVisitor 接口,而且含有一个 toByteArray() 函数,返回生成的字节码的字节流,将字节流写回文件即可生产调整后的 class 文件。一般它都作为职责链的终点,把所有 visit 事件的先后调用(时间上的先后),最终转换成字节码的位置的调整(空间上的前后),如下例:
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS); |
综上所述,ASM 的时序图如下:
|
我 们还是用上面的例子,给 Account 类加上 security check 的功能。与 proxy 编程不同,ASM 不需要将 Account 声明成接口,Account 可以仍旧是一个实现类。ASM 将直接在 Account 类上动手术,给 Account 类的 operation 方法首部加上对 SecurityChecker.checkSecurity 的调用。
我们首先,我们将 从 ClassAdapter 继承一个类。ClassAdapter 是 ASM 框架提供的一个默认类,负责沟通 ClassReader 和 ClassWriter。如果想要改变 ClassReader 处读入的类,然后从 ClassWriter 处输出,可以重写相应的 ClassAdapter 函数。这里,为了改变 Account 类的 operation 方法,我们将重写 visitMethdod 方法。
class AddSecurityCheckClassAdapter extends ClassAdapter{ |
下一步就是定义一个继承自 MethodAdapter 的 AddSecurityCheckMethodAdapter,在"operation"方法首部插入对 SecurityChecker.checkSecurity() 的调用。
class AddSecurityCheckMethodAdapter extends MethodAdapter { |
其中,visitCode() ClassReader 读到每个方法的首部时被调用,在这个重写方法里,我们用
visitMethodInsn(Opcodes.INVOKESTATIC, "SecurityChecker","checkSecurity", "()V"); |
最后,我们将集成上面定义 ClassAdapter,ClassReader和ClassWriter 产生修改后的 Account 类文件:
import java.io.File; |
执行完这段程序后,我们会得到一个新的 Account.class 文件,如果我们用
public class Main{ |
使用这个 Account,我们会得到:
SecurityChecker.checkSecurity ... |
也是说,在 Account 原来的 operation 内容执行之前,进行了 SecurityChecker.checkSecurity() 检查。
上 面给出的例子是直接改造 Account 类本身的,从此 Account 类的 operation 方法必须进行 checkSecurity 检查。但事实上,我们有时仍希望保留原来的 Account 类,因此把生成类定义为原始类的子类是更符合 AOP 原则的做法。下面介绍如何将改造后的类定义为 Account 的子类 Account$EnhancedByASM。其中主要有两项工作:
在 AddSecurityCheckClassAdapter 类中,将重写 visit 方法:
public void visit(final int version, final int access, final String name, |
public MethodVisitor visitMethod(final int access, final String name, |
class ChangeToChildConstructorMethodAdapter extends MethodAdapter { |
public class SecureAccountGenerator { |
|
最后,我们比较一下 ASM 和其他实现 AOP 底层技术:
| AOP 底层技术 | 功能 | 性能 | 面向接口编程 | 编程难度 |
|---|---|---|---|---|
| 直接改写 class 文件 | 完全控制类 | 无明显性能代价 | 不要求 | 高,要求对 class 文件结构和 Java 字节码有深刻了解 |
| JDK Instrument | 完全控制类 | 无论是否改写,每个类装入时都要执行hook程序 | 不要求 | 高,要求对 class 文件结构和 Java 字节码有深刻了解 |
| JDK Proxy | 只能改写 method | 反射引入代价 | 要求 | 低 |
| ASM | 几乎能完全控制类 | 无明显性能代价 | 不要求 | 中,能操纵需要改写部分的 Java 字节码 |
李夷磊,目前就职于 IBM 中国开发中心 Harmony 开发团队,主要负责类库开发的技术工作,对 Java, OS 以及 system programming 有浓厚的兴趣。 | ||
| 邱小侠 (qiuxiaoxia@gmail.com), 对 Open Source 项目和 Java 编程有浓厚的兴趣。 | |
| 蔡一超,就读 | |
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/374079/viewspace-130037/ ,如需转载,请注明出处,否则将追究法律责任。