前言
AOP作为一种预编译技术,可以通过预编译或在运行时进行动态代理,实现不修改源代码的情况下给程序添加统一的功能。要上手应用这项技术,通过一些字节码工具就可以做到,并不要求开发者一定要掌握字节码相关的知识,但是在面对一些较为复杂的场景时,理解字节码的规范,就能更好的处理疑难问题。
本文主要从使用层面上介绍ASM和Javassist这两个字节码处理工具的基本用法,并且会通过一些示例来展示二者适用的场景。由于笔者Javassist使用方面的经验多一些,因此会多分享一些关于Javassist的用法。
ASM与Javassist
如果提前了解过,就能发现仅从使用方面来,看这二者就有很大区别。Javassist通过一系列的api,能够将编写成字符串形式的源代码插入到已存在的逻辑中、或者创建一个新的类/method/field。ASM则需要使用比较“朴素”的方式插入指令,需要使用者掌握一些简单的字节码规范。
在运行效率上,因为ASM的操作层级更低,不需要Javassist那样处理字符串格式的源代码、做语法逻辑、参数的校验,因此ASM的效率一般高于Javassist。与之相对的,ASM有一部分错误要在运行期间才能发现,而且由于是字节码层面的问题,进行排查又对开发者的知识掌握有所要求。
Javassist的使用
Javassist通过声明一个ClassPool来确定其进行类寻找的范围,在内存中,使用CtClass来表示一个类,对CtClass实例进行操作就可以实现AOP的功能。
可以将CtClass理解为编写的代码在内存中的抽象,能够根据规则对类文件进行修改。在目标类被classloader加载前,对其进行的改动均可以在运行时生效。
CtClass获取方法和域的方式和用于反射的Class相同。比如使用getDeclaredMethods()可以获取当前类下直接声明的所有方法,使用getMethods()获取当前类所有的public方法(不包括父类)。
得到CtClass的实例后可以对方法,域进行增删改,而获取到CtMethod实例后,可以对执行逻辑进行修改。
最基本的AOP操作就可以利用上述方法来完成,比如通过APT拿到被注解的,需要被处理的类,然后统一对某些类或方法进行处理(增加log,统计时长等等),最后调用writeFile()写入到class文件中,就完成了一个AOP流程。
简单的插入方法、成员、代码足以应付较多的场景,不过若是一些复杂场景下需要判断某个方法是否被调用,或是计划将某些调用替换成其他的调用,简单的插入API就没什么太多作用了。好在Javassist提供了Expr包下的API,很好的简化了基于mehtod/field调用的处理逻辑。
代码编辑/检测
在CtClass或者CtMethod的实例上调用instrument方法,传入ExprEditor的子类,就可以在相应的override中,处理目标方法中重要的逻辑。目前支持的处理有field的访问、方法调用、构造函数调用、构造对象、
cast、构造数组、Instanceof、catch/finally块。
上述override的参数都继承自Expr,顾名思义,该类代表语句,通过其提供的API就可以知道一些相关的信息,并且使用replace方法,可以将该语句替换成其他的语句。Expr中比较常用的是field访问(FieldAccess)和method调用(MethodCall),用一个常见的例子来演示ExprEditor的用法:将所有System.out.println()的方法调用替换成logger的调用
ctClass.instrument(object : ExprEditor() {
var isSystemOut = false
override fun edit(f: FieldAccess?) {
if (f == null) {
return
}
// 判断out对象是否取自System类
if (f.fieldName == "out"
&& f.className == "java.lang.System"
) {
isSystemOut = true
}
}
override fun edit(m: MethodCall?) {
if (m == null) {
return
}
// 判断println是否是PrintStream声明的方法
if (isSystemOut
&& m.className == "java.io.PrintStream"
&& m.methodName == "println"
) {
// 替换方法调用
m.replace("com.android.utils.Logger.log(\$args);")
}
isSystemOut = false
}
})
上面的示例中展示了FieldAccess和MethodCall两种Expr的使用,比如通过getClassName()可以拿到声明field和method的class,判断来源;通过where(),可以获得当前访问field或调用method的方法(或是构造函数)。
replace参数中的$args是Javassist提供的占位符,这些占位符在不同位置使用时会有不同的表示,以$0, $1, $2, ...为例,在以CtMethod实例为操作对象时(比如在insertBefore, insertAfter等方法中),1, 0表示调用该方法的实例(如果是static方法则为null),2等等表示调用方法的参数。
其余更详细的占位符说明可见Javassist指南。
有一些需要注意的点:
Expr获取方法或类信息的API都是在调用时检索的,在replace之后调用这些API会无法得到预期的返回值replace()调用或者insertXXX()调用时,javassist都会做编译校验,比如需要插入的语句语法正确,不能访问不在ClassPool中的类和方法等等
使用instrument()方法和ExprEditor配合可以完成大多数场景下的AOP需求,不过还是有些场景力所不能及,比如,需要在一个方法中检索所有this调用的方法、或是列举所有方法中的字面常量。要办到这些就需要更低抽象层次的API,这时javassist的bytecode API就派上了用场。
字节码API
在javassist.bytecode包下的类提供的API,可以让开发者进行字节码层面的操作,使用这些api有些类似于ASM的tree API, javassist.bytecode.ClassFile类就相当于ASM tree API中的ClassNode,是一整个class文件在内存中的体现。
通过一个检索方法中字符常量的例子来演示字节码API的使用方法:
val constPool = classFile.constPool
classFile.methods.forEach {
val ci = it.codeAttribute.iterator()
ci.begin()
while (ci.hasNext()) {
val pos = ci.next() //根据指令长度取到下一个指令的位置
val code = ci.byteAt(pos) //根据位置拿到具体指令
if (code == Opcode.LDC) {
val constIndex = ci.byteAt(pos + 1)//参数为stringIndex
println(constPool.getStringInfo(constIndex))
return@forEach
}
if (code == Opcode.LDC_W) {
val constIndex = ci.u16bitAt(pos + 1)//wide指令取两个字节作为参数
println(constPool.getStringInfo(constIndex))
}
}
}
上述方法中所做的是,拿到所有classFile中的methodInfo,通过这些info的CodeIterator进行指令的遍历,借用CodeIterator所封装的API,可以较方便的定位到每一条指令,再根据虚拟机规则进行处理。这种方式会直接处理字节码,排查错误会更加有难度一些。
一些注意事项
在使用Javassist时会遇到一些问题,在文章中稍微分享一下
- 一个CtClass无法在get()之后调用detach()两次。因为CtClass本身的缓存使用的是HashTable,该数据结构不能使用null作为key,而在第二次detach时,获取到的实例为null,用该key值操作时会产生NPE。
- 由于有ClassPool,这个类会持有CtClass的实例,并且不会主动释放,处理大量文件时需要注意内存情况,在合适的时机
detach()已经处理完成的类。
ASM的使用
ASM的抽象层次更接近class文件本身,将符合虚拟机标准的class stream或是一个class文件传递给ClassReader,再为其指定一个ClassVisitor,ASM会使用visitor模式将文件的内容封装过后传递给回调接口,这个过程不需要将整个class文件完整读到内存中,类似SAX的方式,基于回调事件来表示一个类的数据与结构,其处理方式比较适合检索或是简单的修改,如果需要处理复杂的数据结构,或是和class文件内多个模块有所关联的情况,就可以考虑使用ASM提供的tree api了。
ASM Tree API
Tree API 会将整个文件加载入内存,安装格式将class映射为内存中的数据结构ClassNode。ASM中的ClassNode和Javassist bytecode api中的ClassFile相似,或者说,javassist bytecode api整体实际上和ASM tree api都非常相似。
适用情况
如果刚开始学习AOP相关的知识点,想尽快上手处理实际问题,那么建议使用Javassist,针对一些简单的情况来说,Javassist的使用几乎没有学习成本,编译期就会有一些校验,方便发现和解决问题。
不过也是这些校验和相关的计算,拖慢了执行速度。如果你的处理比较复杂,很可能会被Javassist的校验束缚,在不了解Javassist内部的一些处理逻辑时,绕过这些校验也颇为麻烦,这时就可以考虑使用ASM来进行处理;在对运行速度要求很高的时候,也同样可以选择ASM。