字节码增强技术实践
一、字节码是什么
Java程序都是跑在JVM上的,我们日常所编写的 java文件需要先编译为.class文件然后才可以被类加载器加载后进入到JVM中,被正确识别后才能运行,而这个.class文件里的内容就是我们今天要说的字节码。
我们可以通过命令:javap -verbose + 类名 查看字节码内容,如下:
二、实现思路
我们先看一张流程图:
我们手工编写 Java 文件,然后编译成 .class文件,最终通过类加载器将类加载进 JVM。如果我们不想更改源码,但是又想对程序做一些修改,让程序按照我们预期去运行,那么我们可以在上图中的编译和加载这两个步骤去进行,即:如果我们可以把.class文件的内容改成我们所需要的,我们的预期就得以实现了。
三、应用场景
1、流量回放
我们可以在程序的入口处,修改入口处的字节码,增加流量进入时的存储逻辑,当流量进来时,就可以按照我们自己的格式和要求将流量落地,进而给后续的回放提供支撑.有些团队会选择在代码中加注解来识别,比如基于Spring的aop或cglib来达到这种效果,但是这样会对代码有一定的侵入性.个人感觉不如原生字节码注入好.
2、各类开关
我们经常会有一些需求比如线上线下环境要实现不同的逻辑处理,如调用第三方接口有验签或加密时,可能为了线下的mock方便就会加各种开关来关闭掉,此时其实我们就可以使用字节码技术来做到不修改源代码逻辑,随意控制开关的有无.
3、异常代码注入
现在有一个比较好玩的技术叫混沌工程,几年前有一些说法叫故障演练,在注入故障时,除了系统级的故障外,其实我们也可以模拟一些代码级的故障,比如各种异常返回,抛出异常,方法执行超时等等.那么这些随机的,各种异常的代码故障,都可以应用字节码修改的手段来完成.目前这块也有比较成熟的框架供使用,后续会有介绍
四、JVMTI
- JVMTI: JVM Tool Interface 是JVM提供的native编程接口,是JVM为我们提供的一套针对java程序的"后门"API,方便我们做字节码增强,剖析,调试,监控,分析线程等。
- Instrument: JVM提供的一个可以修改已加载类的接口,可以对java程序做agent和attach两种方式的修改。
- agent: 借助于Instrument相关api,在启动程序时指定本地一个jar包,在将.class文件加载进内存之后,运行main方法之前,完成相关增强任务.这种方式有一个问题就是每次想要完成增强都需要重新启动一下进程来完成增强任务,如果不想重新启动就完成修改,那么就需要使用第二种方式attach。
- attach: 与agent的区别是,此种方式不需要重新启动,可以对运行中的进程直接挂载来完成增强的任务,在attach成功之后,具体实现即对已经加载的class重新加载一次,来完成增强任务。
五、类库支持
当前主要有大神级的ASM, 以及大侠级的 javassist,其他也有一些,不过多是基于ASM做的各种二次开发和扩展来的。我们的演示将会以 javassist来展开,这款框架主要是简单,上手快,可以快速出结果。
六、agent演示
1、创建一个 SpringBoot 工程,我们要对这个工程的代码进行无修改式的侵入
@RestController public class PingController { @RequestMapping("/ping") public String ping(){ System.out.println("ping init"); return "pong"; } }
@SpringBootApplication public class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); } }
我们的目的是要修改 PingController 中的 ping() 方法的逻辑,在未修改时,ping()方法应该返回的是 “pong”。
2、新建普通工程 agent
导入 javassist 依赖
jar 包配置
编写agent入口方法 premain
实现字节码的增强
public class AccessLogTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String classPath, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { CtClass ctClass = null; try { if (null == classPath || classPath.trim().length() == 0) { return classfileBuffer; } // 将classPath转成className形式 String className = classPath.replaceAll("/", "."); if (!TARGET_CLASS_NAME.equals(className)) { // 如果不是目标要增强的类则结束 return classfileBuffer; } // 获取class加载的池子,从中取出我们要去增强的类 ClassPool classPool = ClassPool.getDefault(); classPool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader())); // 目标类: PingController ctClass = classPool.get(className); // 目标方法: ping CtMethod ctMethod = ctClass.getDeclaredMethod(TARGET_METHOD_NAME); // 对目标方法的前后实施增强 // ctMethod.insertBefore("Thread.sleep(10000L);"); ctMethod.insertBefore("if(1>0) {return \"hello world.\";}"); // ctMethod.insertBefore("System.out.println(\"access log begin.\");"); ctMethod.insertAfter("System.out.println(\"access log end.\");"); // 指定行数进行增强 // ctMethod.insertAt(18,"System.out.println(\"access log 18.\");"); // 增强结束后写回结果 ctClass.writeFile(); return ctClass.toBytecode(); } catch (Exception e) { // 暂时不做相关异常处理 e.printStackTrace(); } finally { if (null != ctClass) { ctClass.detach(); } } return classfileBuffer; } }
我们可以在指定方法的最前面、最后面以及某一行加上指定的代码来改变方法的逻辑。
上面这段代码我们是在目标类的目标方法的最前面加上了:
if(1>0) { return "hello world."; }
方法的最后加上了:
System.out.println("access log end.");
打包 + 测试
- 打包 SpringBoot工程;
- 打包 agent 模块;
- 使用如下命令运行 SpringBoot 工程
- java -javaagent:/path/to/local/agent-1.0.0-SNAPSHOT.jar -jar /path/to/springboot-service/web-server-1.0.0-SNAPSHOT.jar
再次请求,返回的是“Hello,world.”,没有 agent侵入之前,返回的是“pong”。
七、attach演示
1、SpringBoot工程还是使用 agent演示中的那一个
2、新建普通工程 agent
导入 javassist 依赖
jar 包配置
编写attach入口方法agentmain
实现字节码增强的AccessLogTransformer与agent一致
编写attach main方法
public class AttachMain { public static void main(String[] args) { // String targetPid = "基于jps命令获取到的web进程id"; // String agentPath = "将attach打包后的jar包路径"; /** * 这里attach的是本地的进程 * 如果需要attach远程的进程,则需要让远程机器开启rmi的支持并且给rmi开个端口 * rmi://192.168.182.128:8000 */ String targetPid = "27156"; String agentPath = "E:\\Java_Project\\jvm-demo\\attach\\target\\libs\\attach-1.0.0-SNAPSHOT.jar"; List<VirtualMachineDescriptor> vmDescriptors = VirtualMachine.list(); VirtualMachineDescriptor targetJvmDescriptor = vmDescriptors.stream() .filter(descriptor -> descriptor.id().equals(targetPid)) .findFirst() .orElseThrow(() -> new IllegalStateException("none target jvm exists")); VirtualMachine virtualMachine = null; try { virtualMachine = VirtualMachine.attach(targetJvmDescriptor); virtualMachine.loadAgent(agentPath); } catch (Exception e) { e.printStackTrace(); } finally { if (virtualMachine != null) { try { virtualMachine.detach(); } catch (IOException e) { // ignore e.printStackTrace(); } } } } }
测试
- 启动 SpringBoot 程序,并获取 PID;
- 打包attach模块,并获取jar包路径;
- 将 AttachMain 类的 main 方法中的 targetPid 和agentPath 替换为以上两个值;
- 运行AttachMain 类的 main 方法;
再次请求,返回的是“Hello,world.”,没有 agent侵入之前,返回的是“pong”。
attach 方式的优点是相对于 agent方式,不用重启目标服务,但是得获取到目标服务的 PID,以及 attach包的路径。