bytebuddy与javaagent结合使用
最近在研究可观测链路时,发现工具里在进行代码插桩时,使用的是butebuddy工具。我之前了解过asm和javaassist两种工具,对比来说asm更加高效但偏向底层,javaassist更加高级但性能略差。那需要一个折中一点的工具,让我们能够进行高效的字节码操作的同时不损失性能。bytebuddy正是这样的一个工具,他提供了较为高级的api来操作字节码,但性能依旧强悍,极大的降低了开发门槛。
bytebuddy本身并不是一种新的字节码技术,而是对asm进行了封装,让我们不必深入了解字节码工具以及jvm指令。同时butebuddy还有一个很大的优点是其直接提供了与javaagent深入结合的工具byte-buddy-agent。我们大部分字节码操作的场景都是在使用agent上,这样可以直接使用的工具能极大的方便我们开发。
接下来用一个较为简单的案例展示一下bytebuddy进行agent开发的步骤,案例的功能是拦截所有以com.example.demo开头的类的方法,并在方法前后注入逻辑。
前提
准备好java环境以及maven(方便构建agent包以及依赖),我的版本是java8,maven3.8.1
需要读者对javaagent的作用以及使用有一定了解
agent包
1.引入依赖pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.example</groupId> <artifactId>test-agent</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <bytebuddy.version>1.14.9</bytebuddy.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy</artifactId> <version>${bytebuddy.version}</version> </dependency> <dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy-agent</artifactId> <version>${bytebuddy.version}</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>8</source> <target>8</target> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>3.3.0</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifestEntries> <Premain-Class>com.study.agent.StudyAgentBoot</Premain-Class> <Can-Redefine-Classes>true</Can-Redefine-Classes> <Can-Retransform-Classes>true</Can-Retransform-Classes> </manifestEntries> </archive> </configuration> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project>
2. 编写注入代码,注意有两种方式,一种是代码嵌入式,一种是拦截器模式(名字为本人理解)。两种方式的区别后面会讲,这儿新建两种代码增强类,功能都是一样的,函数执行前后打印出函数的入参以及出参,有异常时打印异常
2.1 代码嵌入式增强类
public class MethodTracer { @Advice.OnMethodEnter public static void onMethodEnter( @Advice.Origin String method, @Advice.AllArguments Object[] args) { System.out.println("Entering: " + method + "|| params" + Arrays.toString(args)); } @Advice.OnMethodExit(onThrowable = Throwable.class) public static void onMethodExit( @Advice.Origin String method, @Advice.Return Object returnValue, @Advice.Thrown Throwable throwable) { if (throwable == null){ System.out.println("Exiting: " + method + " with result: " + returnValue); }else { System.out.println("Exception in: " + method + ": " + throwable.getMessage()); } } }
2.2 拦截器模式增强类
public class MethodTracer2 { @RuntimeType public Object intercept( @This Object obj, @AllArguments Object[] allArguments, @SuperCall Callable<?> zuper, @Origin Method method) throws Exception { System.out.println("Entering: " + method.getName() + "|| params" + Arrays.toString(allArguments)); try { Object result = zuper.call(); System.out.println("Exiting: " + method.getName() + " with result: " + result); return result; } catch (Exception e) { System.out.println("Exception in: " + method.getName() + ": " + e.getMessage()); throw e; } } }
3.agent类 这儿有两个premain函数,上面的是演示代码嵌入式,下面的是拦截模式
public class StudyAgentBoot { public static void premain(String arguments, Instrumentation instrumentation) { System.out.println("Initializing MethodTraceAgent..."); new AgentBuilder.Default() .type(ElementMatchers.nameStartsWith("com.example.demo")) .transform((builder, type, classLoader, module, protectionDomain) -> builder.method(ElementMatchers.any()) .intercept(Advice.to(MethodTracer.class))) .installOn(instrumentation); } // public static void premain(String arguments, Instrumentation instrumentation) { // System.out.println("Initializing MethodTraceAgent..."); // new AgentBuilder.Default() // .ignore(ElementMatchers.nameStartsWith("net.bytebuddy")) // .type(ElementMatchers.nameStartsWith("com.example.demo")) // .transform((builder, type, classLoader, module, protectionDomain) -> // builder.method(ElementMatchers.named("findBySerialNo")).intercept(MethodDelegation.to(new MethodTracer2()))) // .installOn(instrumentation); // } }
4. 命里行执行mvn clean package进行打包
测试项目
项目比较简单 一个service类,一个main函数 模拟我们的业务方
package com.example.demo; public class GoodsOrderService { public String findBySerialNo(String serialNo){ return "1" + serialNo + "|||"; } }
package com.example.demo; public class Main { public static void main(String[] args) throws InterruptedException { GoodsOrderService goodsOrderService = new GoodsOrderService(); String bySerialNo = goodsOrderService.findBySerialNo("124124"); System.out.println(bySerialNo); while (true){ } } }
在idea的启动类里面的vm.options参数里面加上agent参数 -javaagent:your workspace\test-agent-1.0-SNAPSHOT-jar-with-dependencies.jar
我们在执行main函数后不出意外可以得到如下结果,可以看到我们的代码增强功能已经成功实现,入参和出参都被打印了出来
Initializing MethodTraceAgent... Entering: public java.lang.String com.example.demo.GoodsOrderService.findBySerialNo(java.lang.String)|| params[124124] Exiting: public java.lang.String com.example.demo.GoodsOrderService.findBySerialNo(java.lang.String) with result: 1124124||| 1124124|||
增强原理与两种方式
那这个是怎么实现的呢? 这儿就涉及到上面遗留的问题,两种增强模式。想要理解这个,我们还需要一个工具,把运行时的GoodsOrderService找到并反编译看看,类具体被修改成了什么样。这儿使用java自带的工具。
进入到本地java的bin目录下 执行如下指令 ./java -cp ../lib/sa-jdi.jar sun.jvm.hotspot.HSDB,如果顺利的话会弹出一个可交互的界面,点击左上角的file>attach to hotspot process,然后输入自己的进程id后就顺利链接上了,然后在点击左上角tools>Class Browser 搜索GoodsOrderService可以看到下面已经成功找到,
嵌入模式
可以发现类里面现在有一个findBySerialNo方法,一个findBySerialNo$original$Gca4CnId方法且加了修饰符private synthetic ,关于synthetic 修饰符,是编译器添加的修饰符,我们无法直接使用,详细的可以自己搜索了解。
我们打开findBySerialNo$original$Gca4CnId 这个方法,由于加了synthetic修饰符,无法直接反编译,所以我们用工具看看里面的代码内容,可以发现这个方式里面的代码变成了我们原本的findBySerialNo业务代码,即在入参前拼接1,入参后拼接|||
打开原本的findBySerialNo方法,发现原本方法已经改变了,由于这个方法内容较多,但是可以反编译,我们直接反编译看看,点上面的create.class file 然后用idea打开这个class,我们主要看看findBySerialNo方法变成啥样了
public String findBySerialNo(String var1) { System.out.println("Entering: " + "public java.lang.String com.example.demo.GoodsOrderService.findBySerialNo(java.lang.String)" + "|| params" + Arrays.toString(new Object[]{var1})); GoodsOrderService var2 = this; String var3 = var1; String var7; Throwable var8; label24: { String var10000; try { var10000 = var2.findBySerialNo$original$Gca4CnId(var3); } catch (Throwable var6) { var8 = var6; var7 = null; break label24; } var7 = var10000; var8 = null; } if (var8 == null) { System.out.println("Exiting: " + "public java.lang.String com.example.demo.GoodsOrderService.findBySerialNo(java.lang.String)" + " with result: " + var7); } else { System.out.println("Exception in: " + "public java.lang.String com.example.demo.GoodsOrderService.findBySerialNo(java.lang.String)" + ": " + var8.getMessage()); } if (var8 != null) { throw var8; } else { return var7; } }
看到这个想必就很容易理解我说的嵌入式代码,这种增强方式的原理就是 将我们原本的业务函数修改了函数名为findBySerialNo$original$Gca4CnId,并添加修饰符synthetic,然后再生成一个新的findBySerialNo函数,将我们一开始定义的代码增强逻辑直接嵌入到函数前后,并在中间执行var2.findBySerialNo$original$Gca4CnId(var3) 也就是原本的业务逻辑,这样就做到了代码增强的功能,当然这个var2字段在反编译文件是看不到的,因为对象的属性和方法都是编译期加进去的添加了synthetic修饰符,无法反编译。不过还是可以通过上面的交互工具看到的。
相信弄到这里如果有实践的话已经看到了该模式的缺点,就是增强代码直接嵌入到了原本函数,会导致所有函数前后都会加上这些代码显得很臃肿,那我们再来看看拦截模式
拦截模式
将agent类注释逻辑调整,放开拦截模式的代码,同样的打包,执行,并使用交互工具打开类,可以看到应用直接生成了两个类GoodsOrderService,以及GoodsOrderService$auxiliary$gkF1t3FY
我们反编译后的两个类一起看
public class GoodsOrderService { public GoodsOrderService() { } static { ClassLoader.getSystemClassLoader().loadClass("net.bytebuddy.dynamic.Nexus").getMethod("initialize", Class.class, Integer.TYPE).invoke((Object)null, GoodsOrderService.class, -1440179496); cachedValue$ZDwrdDlh$g12muu0 = GoodsOrderService.class.getMethod("findBySerialNo", String.class); } public String findBySerialNo(String var1) { return (String)delegate$k378eq1.intercept(this, new Object[]{var1}, new auxiliary.gkF1t3FY(this, var1), cachedValue$ZDwrdDlh$g12muu0); } }
class GoodsOrderService$auxiliary$gkF1t3FY implements Runnable, Callable { private GoodsOrderService argument0; private String argument1; GoodsOrderService$auxiliary$gkF1t3FY(GoodsOrderService var1, String var2) { this.argument0 = var1; this.argument1 = var2; } static { ClassLoader.getSystemClassLoader().loadClass("net.bytebuddy.dynamic.Nexus").getMethod("initialize", Class.class, Integer.TYPE).invoke((Object)null, GoodsOrderService$auxiliary$gkF1t3FY.class, 429360412); } public void run() { this.argument0.findBySerialNo$original$sgDc3lqc$accessor$ZDwrdDlh(this.argument1); } public Object call() throws Exception { return this.argument0.findBySerialNo$original$sgDc3lqc$accessor$ZDwrdDlh(this.argument1); } }
这儿其实可以看到 原本的GoodsOrderService的findBySerialNo已经被改造了,会直接调用我们一开始的trace2里面定义的方法intercept,那我们原本的方法调用怎么办呢,可以看到原本的业务代码以及类被重新生成封装到了GoodsOrderService$auxiliary$gkF1t3FY 。 原本的findBySerialNo也被修改成了findBySerialNo$original$sgDc3lqc$accessor$ZDwrdDlh (该方法定义在GoodsOrderService中且被synthetic修饰,内部逻辑是原本的业务代码),但是内部的业务逻辑是没有变化的。该类的入参和调用被封装成了一个callable/runnable
然后就回到了一开始trace2里面的逻辑
该模式的优点是增强代码不会全部嵌入到类里面,而是所有的代码从原本逻辑改为执行该intercept逻辑,要清晰的多,且方便我们做统一管理。
结尾
本文主要写明了bytebuddy在使用javaagent情况下如何进行字节码增强,可以看到使用起来是要比asm简单很多,即便不了解jvm指令和字节码规范也是可以使用的。后续介绍的两种增强模式,目前看着opentelementry用的第一种,skywalking用的第二种,看着应该是各有优劣吧。
回到一开始的初衷,即可观测链路的实现,使用字节码工具,我们可以做到业务方无感知无代码侵入的情况下,将链路追踪逻辑写入代码,且不依赖其他的框架例如spring等。