胡琪

为今天工作,为明天投资,为未来孵化一些东西

使用javassist操作java字节码文件

使用ASM操作java字节码中我们讲到过主流的修改java字节码的框架包括ASM和javassist,其中ASM的使用在使用ASM操作java字节码中已经讲解过了,这里为何打算讲下javassist操作java字节码文件呢?因为虽然javassist能够完成的功能ASM都能完成,但是在实际的开发过程中发现这2个库其实各具优点,它们分别适合在不同的场景下使用,换而言之,在不同的场景下选择合理的库能够使得我们开发时的代码量和开发时间大大缩短。因此推荐大家对这2个库的使用及其优缺点都能够掌握,这样在实际工作中遇到需要修改java字节码的需求时能够根据实际应用场景更灵活的选择用哪个库从而更好更快的完成开发任务。

javassist

javassost官网:http://jboss-javassist.github.io/javassist/其中官方文档地址为:http://jboss-javassist.github.io/javassist/tutorial/tutorial.html 。同ASM一样javassist也提供了一个模拟java类的类叫做CtClass(compile-time class),即编译时类,该类提供了一系列的操作类的接口,比如获取该类定义的所有方法的函数getDeclaredMethods​(),获取该类所有域的getDeclaredFields​()函数等,如下是部分函数说明截图:

《使用javassist操作java字节码文件》

具体的API可以在开发时用到的时候再查询官方文档,总之CtClass对象是javassist框架提供给我们的操作类的接口,有了该对象我们就可以调用该对象的一系列方法来对类进行操作,那么如何得到一个CtClass对象呢?这就涉及到另外一个类ClassPool了,可以简单理解为存放CtClass对象的容器,其主要作用是用来保存已经存在的CtClass供下次继续访问,可以简单理解为类缓存池(The ClassPool object is a container of CtClass object representing a class file. It reads a class file on demand for constructing a CtClass object and records the constructed object for responding later accesses)。ClassPool包含2大类重要的方法用来得到一个CtClass对象,分别是get系列和make系列,如下所示:

《使用javassist操作java字节码文件》

《使用javassist操作java字节码文件》

这些不同参数的重载函数可以概括为2大类,一类是通过直接传递类名称得到CtClass,一类是通过I/O流操作来得到。前者需要这些类本身是在java的加载路径中的,比如程序中的类,而后者可以操作本身不在java加载路径中的类。比如磁盘随意某个目录下的字节码文件。注意前者是不能操作不在java加载类路径中的类的.

所以一般获取一个类的CtClass对象的代码如下:

得到CtClass对象之后我们就可以调用该类一系列的API接口做相应操作,其中一般在实际开发中对函数做操作比较多,比如在函数开始处增加一段log日志,修改函数体的实现等。如下是一个非常简单的在每个函数的开始打印一段log的代码:

这是一个操作原字节码文件进行修改后保存为新字节码的一个简单示例,这个示例非常简单,但麻雀虽小五脏俱全,这个示例能够非常清晰展示出使用javassist包含的三大步骤:

  1. 通过I/O操作获取CtClass对象
  2. 通过CtClass的接口对字节码内容进行修改
  3. 通过CtClass的toBytecode()函数得到修改后的字节数组,通过I/O操作保存得到修改后的字节码文件

另外从这个示例中也可以看到和ASM不同的是javassist插入新代码是通过类似java字符串源码形式传给相应接口的,比如insertBefore,而ASM是通过字节码指令的形式插入的。也正是这个差异导致了ASM和javassist在不同场景下开发的难易度不同。因为ASM是通过字节码指令插入的,所以ASM更适合插入一些固定内容代码,比如插入一段打印log的日志,因为内容固定,我们可以很容易使用bytecode插件得到要插入的字节码的内容。一旦要插入的内容是动态变化的,比如在所有函数中插入一段输出该函数参数的值的代码。因为函数参数类型,参数个数以及顺序都不同,这个此时是无法通过bytecode插件得到要插入的字节码的内容,只能在运行过程中动态的根据函数参数生成要插入的字节码。如果使用ASM则需要我们手动写一个插入指令算法满足所有的case。但是如果使用javassist会发现相对使用ASM而言非常简单。再比如在美团热修复Robust中有一段在原始类的每个函数中插入一个类似桥函数的代码,这个函数的作用是当需要进行热修复时携带原函数传入的参数的值跳到热修复函数逻辑中执行,很显然这也是一个动态插入指令的过程,只有到运行时才能确定插入的指令的字节码内容。

CtMethod类的重要API和语言扩展

对于一个java源文件而言,最重要的及部分就是类,方法,域成员。同样的javassist框架也提供了3个对应的接口来代表这3个部分,即CtClass代表类,CtMethod代表方法,CtField代表域成员。这3个类提供了一系类的API用来对相应部分进行操作。

在方法体的开始/结尾处添加代码

CtMethod和CtConstructor提供了insertBefore(),insertAfter() 和 addCatch() 方法将代码片段插入到已经存在的方法的方法体中,插入的代码片段是java原文本格式的(The users can specify those code fragments with source text written in Java),就像前面提到的那个简单示例一样,因为Javassist包含一个用于处理源文本的简单Java编译器。它接收用Java编写的源文本并将其编译为Java字节码,然后将该字节码将内联到方法体中,换而言之像ASM那样插入字节码的这一步javassist替我们做了。也正以为如此我们可以使用javassist的语言扩展功能,在插入的源文本中以$开头的多个标识符具有特殊含义,其中比较常用和重要的如下所示:

  • $0, $1, $2, …:代表this(非静态方法)和函数的参数
  • $args:代表方法的参数数组,类型是Object[]
  • $cflow:表示递归调用的深度
  • $r:方法返回结果类型,用来做强制类型转换,比如:Object result = … ; $_ = ($r)result;
  • $_:代表该方法的结果的值,类型是方法的返回类型,如果返回类型为void,则$ _的类型为Object,$ _的值为null。注意该变量只能在CtMethod 的insertAfter() 和 CtConstructor中插入代码时使用,不能在CtMethod 的insertBefore()中使用
  • $sig:表示方法声明的形参类型的数组
  • $class:表示当前正在修改的方法所在的类

如下是一个简单的把函数的所有参数传递给一个参数为对象数组的函数且按照原函数的参数类型返回的代码示例:

可以看到这个功能实际上就是一个动态插入字节码的过程,因为不同函数其函数参数类型,个数,顺序是不一样的,如果使用ASM那么此时插入原函数参数这块的字节码内容需要我们自己处理所有case的情况,但是使用javassist的语言扩展功能只需要一个$args就搞定了。这也是为何前面说过javassist和ASM各具优点的原因。在这种应用场景下很显然使用javassist能够大大减少代码开发量。在前面的中也说过ASM的所有插入代码本质上都是在原函数中新增加代码,不会直接替换原函数的代码体中的指令,也没有提供清空原函数指令的函数,如果要直接替换原函数代码体需要我们自己处理,但是此处的javassist的setBody却可以很容易的做到这点。

修改方法体

CtMethod和CtConstructor提供setBody()来替换整个方法体,用来将给定的源文本代码编译为java字节码然后替换整个方法体,因此该API也是通过java源文本的形式插入代码,如果给定的源文本为空,则替换后的主体只包含一个返回语句,该语句返回零或空值,除非结果类型为void。setBody()同样可以使用$开头的语言扩展。其中前面insertBefore()/insertAfter()中提到的扩展标识符中除了$_不可用外,其余都可使用。

代码示例请参看上面的代码示例。

添加新方法或者域

CtNewMethod 和 CtNewConstructor 提供了几个工厂方法来创建 CtMethod 或 CtConstructor 对象。特别是make() 方法可以通过源代码来CtMethod 或 CtConstructor 对象,此时前面提到的语言扩展同样适用于make方法。注意但是前面提到的扩展标识符中的$_不可用。

新增方法和域这个就比较简单了,因为新增一般不会涉及到对原方法中的变量的引用,只需要像我们在java文件中增加一个方法一样写,然后将其放到一个字符串中传给CtMethod的make方法就行。如下是官网的一个简单示例:

《使用javassist操作java字节码文件》

即make方法的第一个参数是要增加的方法的内容的字符串,第二个参数是CtClass对象,表示将该方法增加到哪个类中。

新增域也比较简单,包括2中方式,一种是使用API创建域,然后添加到类中,如下是官网的一段代码示例:

《使用javassist操作java字节码文件》

其中第二段示例给添加的域进行了初始化,注意这个初始化时字符串形式的,比如给int类型的z赋值0,第二个参数为“0”,同时该参数可以是任意合法的java表达式,只要表达式的返回值类型和域类型匹配即可。

另外一种是使用CtField的make方法通过java源文本形式创建,然后添加到类中,比如上面一段代码可以改写为以下代码:

《使用javassist操作java字节码文件》

即直接调用make方式以类似java纯文本的形式创建域,然后使用addField添加到类对象中即可。

总结

ASM和javassist各具优点,javassist包含一个用于处理源文本的简单Java编译器,为我们提供了语言扩展功能,在插入的源文件文件中以$开头的多个标识符具有特殊含义,这些语言扩展特性在某些业务场景下大大简化了我们插入指令的逻辑。

在不同场景下使用合适的框架能够大大减少我们的代码开发量和排错成本。一般说来如果要插入代码/指令的场景具备以下2个条件时建议使用javassist。

  1. 要插入的字节码指令不是固定的,会随着函数形参个数,类型,顺序,函数返回值类型等动态改变的场景下
  2. 直接替换原函数的整个方法体实现,而不是在原实现前面或者后面插入一段新的代码片段

 

打赏

点赞

发表评论

电子邮件地址不会被公开。 必填项已用*标注