注: 本文定义 - 在函数执行前后增加对应的逻辑的操作统称为 MOCK
1, 引子
在某天与 QA 同学进行沟通时, 发现 QA 同学有针对某个方法调用时, 有让该方法停止一段时间的需求, 我对这部分的功能实现非常好奇, 因此决定对原理进行一些深入的了解, 力争找到一种使用者尽可能少的对原有代码进行修改的方式, 以达到对应的 MOCK 要求.
整体的感知程度可以分为三个级别:
硬编码
增加配置
无需任何修改
2, 思路
在对方法进行 mock, 暂停以及异常模拟, 在不知道其原理的情况下, 进行猜想, 思考其具体的实现原理, 整体来说, 最简单的实现模型无外乎两种:
2.1 朴素思路
假设存在如下的函数
- public Object targetMethod(){
- System.out.println("运行");
- }
若想要在函数执行后暂停一段时间, 返回特定 mock 值或抛出特定异常, 那么可以考虑修改对应的函数内容:
- public Object targetMethod(){
- // 在此处加入 Sleep return 或 throw 逻辑
- System.out.println("运行");
- }
或使用类似代理的方法把对应的函数进行代理:
- public Object proxy(){
- // 执行 Sleep return 或 throw 逻辑
- return targetMethod();
- }
- public Object targetMethod(){
- System.out.println("运行");
- }
2.2 略成熟思路
在朴素思路的基础上, 我们可以看出, 实现类似的暂停, mock 和异常功能整体实现方案无外乎两种:
代理模式
深入修改内部函数
在这两种思路的基础上, 我们从代理模式开始考虑 (主要是代理使用的比较多, 更熟悉)
2.2.1 动态代理
说起代理, 最常想到的两个词语就是静态代理和动态代理, 二者却别不进行详述, 对于静态代理模式由于需要大量硬编码, 所以完全可以不用考虑.
针对动态代理来看, 开始考虑最具代表性的 CGLIB 进行调研.
下面的代码为一个典型的使用 CGLIB 进行动态代理的样例 (代理的函数为 HelloInterface.sayHelllo):
- public class DynamicProxy implements InvocationHandler {
- private Object target;
- public DynamicProxy(Object object) {
- this.target = object;
- }
- private void before() {
- System.out.println("before");
- }
- private void after() {
- System.out.println("after");
- }
- @Override
- public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
- Object res = null;
- before();
- try {
- res = method.invoke(target, args);
- } catch (Throwable e) {
- throw e.getCause();
- }
- after();
- return res;
- }
- public static void main(String[] args) throws IOException {
- try {
- SayHello sayHello = new SayHello();
- DynamicProxy dynamicProxy = new DynamicProxy(sayHello);
- HelloInterface helloInterface = (HelloInterface) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), sayHello.getClass().getInterfaces(), dynamicProxy);
- helloInterface.sayHello();
- } catch (Throwable e) {
- e.printStackTrace();
- }
- }
- }
从上面代码可以看出, 对于 CGLIB 的动态代理而言, 需要在原有代码中进行硬编码, 且需要在对象初始化的时候, 使用特定的方式进行初始化. 因此若使用 CGLIB 完成 MOCK, 需要对应代码的的感知程度最高, 达到了硬编码的程度.
2.2.2 AspectJ
由于使用代理方式无法在不对代码进行修改的情况下完成 MOCK, 因此我们抛弃代理方式, 考虑使用修改方法内部代码的方式进行 MOCK.
基于这种思路, 将目光转向了 AspectJ.
在使用 AspectJ 时, 需要定义方法执行前的函数以及方法执行后的函数:
- @Aspect
- public class AspectJFrame {
- private Object before() {
- System.out.println("before");
- return new Object();
- }
- private Object after() {
- System.out.println("after");
- return new Object();
- }
- @Around("aroundPoint()")
- public Object doMock(ProceedingJoinPoint joinPoint) {
- Object object=null;
- before();
- try {
- object = joinPoint.proceed();
- } catch (Throwable throwable) {
- throwable.printStackTrace();
- }
- after();
- return object;
- }
- }
并通过 aop.xml 指定对应的切点以及对应的环绕函数
- <aspectj>
- <aspects>
- <aspect name="com.test.framework.AspectJFrame">
- <before method=""pointcut=""/>
- </aspect>
- </aspects>
- </aspectj>
但是基于以上的实现方式, 需要对原有项目进行一定侵入, 主要包含两部分内容:
在 META-INF 路径下增加 aop.xml
引入对应的切面定义的 jar 包
通过 aspectj 可以完成在硬编码的情况下实现 MOCK, 但是这种实现方式受限于 Aspectj 自身局限, MOCK 的功能代码在编译期就已经添加到对应的函数中了, 最晚可在运行时完成 MOCK 功能代码的添加. 这种方式主要有两个缺点:
对于运行中的 java 进行无法在不重启的条件下执行新增 MOCK
MOCK 功能代码嵌入到目标函数中, 无法对 MOCK 功能代码进行卸载, 可能带来稳定性风险
3, java agent 介绍
由于在上述提到的各种技术都难以很好的支持在对原有项目无任何修改下完成 MOCK 功能的需求, 在查阅资料后, 将目光放至了 java agent 技术.
3.1 什么是 java agent?
java agent 本质上可以理解为一个插件, 该插件就是一个精心提供的 jar 包, 这个 jar 包通过 JVMTI(JVM Tool Interface) 完成加载, 最终借助 JPLISAgent(Java Programming Language Instrumentation Services Agent) 完成对目标代码的修改.
java agent 技术的主要功能如下:
可以在加载 java 文件之前做拦截把字节码做修改
可以在运行期将已经加载的类的字节码做变更
还有其他的一些小众的功能
获取所有已经被加载过的类
获取所有已经被初始化过了的类
获取某个对象的大小
将某个 jar 加入到 bootstrapclasspath 里作为高优先级被 bootstrapClassloader 加载
将某个 jar 加入到 classpath 里供 AppClassloard 去加载
设置某些 native 方法的前缀, 主要在查找 native 方法的时候做规则匹配
3.2 java Instrumentation API
通过 java agent 技术进行类的字节码修改最主要使用的就是 Java Instrumentation API. 下面将介绍如何使用 Java Instrumentation API 进行字节码修改.
3.2.1 实现 agent 启动方法
Java Agent 支持目标 JVM 启动时加载, 也支持在目标 JVM 运行时加载, 这两种不同的加载模式会使用不同的入口函数, 如果需要在目标 JVM 启动的同时加载 Agent, 那么可以选择实现下面的方法:
- [1] public static void premain(String agentArgs, Instrumentation inst);
- [2] public static void premain(String agentArgs);
JVM 将首先寻找 [1], 如果没有发现 [1], 再寻找 [2]. 如果希望在目标 JVM 运行时加载 Agent, 则需要实现下面的方法:
- [1] public static void agentmain(String agentArgs, Instrumentation inst);
- [2] public static void agentmain(String agentArgs);
这两组方法的第一个参数 AgentArgs 是随同 "-javaagent" 一起传入的程序参数, 如果这个字符串代表了多个参数, 就需要自己解析这些参数. inst 是 Instrumentation 类型的对象, 是 JVM 自动传入的, 我们可以拿这个参数进行类增强等操作.
3.2.2 指定 Main-Class
Agent 需要打包成一个 jar 包, 在 ManiFest 属性中指定 "Premain-Class" 或者 "Agent-Class", 且需根据需求定义 Can-Redefine-Classes 和 Can-Retransform-Classes:
- Manifest-Version: 1.0
- preMain-Class: com.test.AgentClass
- Archiver-Version: Plexus Archiver
- Agent-Class: com.test.AgentClass
- Can-Redefine-Classes: true
- Can-Retransform-Classes: true
- Created-By: Apache Maven 3.3.9
- Build-Jdk: 1.8.0_112
3.2.3 agent 加载
启动时加载
启动参数增加 - javaagent:[path], 其中 path 为对应的 agent 的 jar 包路径
运行中加载
使用 com.sun.tools.attach.VirtualMachine 加载
try {
String jvmPid = 目标进行的 pid;
- logger.info("Attaching to target JVM with PID:" + jvmPid);
- VirtualMachine jvm = VirtualMachine.attach(jvmPid);
- jvm.loadAgent(agentFilePath);//agentFilePath 为 agent 的路径
- jvm.detach();
- logger.info("Attached to target JVM and loaded Java agent successfully");
- } catch (Exception e) {
- throw new RuntimeException(e);
- }
- 3.2.4 Instrument
instrument 是 JVM 提供的一个可以修改已加载类的类库, 专门为 Java 语言编写的插桩服务提供支持. 它需要依赖 JVMTI 的 Attach API 机制实现. 在 JDK 1.6 以前, instrument 只能在 JVM 刚启动开始加载类时生效, 而在 JDK 1.6 之后, instrument 支持了在运行时对类定义的修改. 要使用 instrument 的类修改功能, 我们需要实现它提供的 ClassFileTransformer 接口, 定义一个类文件转换器. 接口中的 transform() 方法会在类文件被加载时调用, 而在 transform 方法里, 我们可以利用上文中的 ASM 或 Javassist 对传入的字节码进行改写或替换, 生成新的字节码数组后返回.
首先可以定义如下的类转换器:
- public class TestTransformer implements ClassFileTransformer {
- // 目标类名称, . 分隔
- private String targetClassName;
- // 目标类名称, / 分隔
- private String targetVMClassName;
- private String targetMethodName;
- public TestTransformer(String className,String methodName){
- this.targetVMClassName = new String(className).replaceAll("\\.","\\/");
- this.targetMethodName = methodName;
- this.targetClassName=className;
- }
- // 类加载时会执行该函数, 其中参数 classfileBuffer 为类原始字节码, 返回值为目标字节码, className 为 / 分隔
- public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
- // 判断类名是否为目标类名
- if(!className.equals(targetVMClassName)){
- return classfileBuffer;
- }
- try {
- ClassPool classPool = ClassPool.getDefault();
- CtClass cls = classPool.get(this.targetClassName);
- CtMethod ctMethod = cls.getDeclaredMethod(this.targetMethodName);
- ctMethod.insertBefore("{ System.out.println(\"start\"); }");
- ctMethod.insertAfter("{ System.out.println(\"end\"); }");
- return cls.toBytecode();
- } catch (Exception e) {
- }
- return classfileBuffer;
- }
- }
类转换器定义完毕后, 需要将定义好的类转换器添加到对应的 instrmentation 中, 对于已经加载过的类使用 retransformClasses 对类进行重新加载:
- public class AgentDemo {
- private static String className = "hello.GreetingController";
- private static String methodName = "getDomain";
- public static void agentmain(String args, Instrumentation instrumentation) {
- try {
- List<Class> needRetransFormClasses = new LinkedList<>();
- Class[] loadedClass = instrumentation.getAllLoadedClasses();
- for (int i = 0; i < loadedClass.length; i++) {
- if (loadedClass[i].getName().equals(className)) {
- needRetransFormClasses.add(loadedClass[i]);
- }
- }
- instrumentation.addTransformer(new TestTransformer(className, methodName));
- instrumentation.retransformClasses(needRetransFormClasses.toArray(new Class[0]));
- } catch (Exception e) {
- }
- }
- public static void premain(String args, Instrumentation instrumentation) {
- instrumentation.addTransformer(new TestTransformer(className, methodName));
- }
- }
从上图的代码可以看出, 主方法实现了两个, 分别为 agentmain 和 premain, 其中
premain
用于在启动时, 类加载前定义类的 TransFormer, 在类加载的时候更新对应的类的字节码
agentmain
用于在运行时进行类的字节码的修改, 步骤整体分为两步
注册类的 TransFormer
调用 retransformClasses 函数进行类的重加载
4,java agent 原理简述
4.1 启动时修改
启动时修改主要是在 jvm 启动时, 执行 native 函数的 Agent_OnLoad 方法, 在方法执行时, 执行如下步骤:
创建 InstrumentationImpl 对象
监听 ClassFileLoadHook 事件
调用 InstrumentationImpl 的 loadClassAndCallPremain 方法, 在这个方法里会去调用 javaagent 里 MANIFEST.MF 里指定的 Premain-Class 类的 premain 方法
4.2 运行时修改
运行时修改主要是通过 jvm 的 attach 机制来请求目标 jvm 加载对应的 agent, 执行 native 函数的 Agent_OnAttach 方法, 在方法执行时, 执行如下步骤:
创建 InstrumentationImpl 对象
监听 ClassFileLoadHook 事件
调用 InstrumentationImpl 的 loadClassAndCallAgentmain 方法, 在这个方法里会去调用 javaagent 里 MANIFEST.MF 里指定的 Agentmain-Class 类的 agentmain 方法
4.3 ClassFileLoadHook 和 TransFormClassFile
在 4.1 和 4.2 节中, 可以看出整体流程中有两个部分是具有共性的, 分别为:
- ClassFileLoadHook
- TranFormClassFile
ClassFileLoadHook 是一个 jvmti 事件, 该事件是 instrument agent 的一个核心事件, 主要是在读取字节码文件回调时调用, 内部调用了 TransFormClassFile 函数.
TransFormClassFile 的主要作用是调用 java.lang.instrument.ClassFileTransformer 的 tranform 方法, 该方法由开发者实现, 通过 instrument 的 addTransformer 方法进行注册.
通过以上描述可以看出在字节码文件加载的时候, 会触发 ClassFileLoadHook 事件, 该事件调用 TransFormClassFile, 通过经由 instrument 的 addTransformer 注册的方法完成整体的字节码修改.
对于已加载的类, 需要调用 retransformClass 函数, 然后经由 redefineClasses 函数, 在读取已加载的字节码文件后, 若该字节码文件对应的类关注了 ClassFileLoadHook 事件, 则调用 ClassFileLoadHook 事件. 后续流程与类加载时字节码替换一致.
4.4 何时进行运行时替换?
在类加载完毕后, 对应的想要替换函数可能正在执行, 那么何时进行类字节码的替换呢?
由于运行时类字节码替换依赖于 redefineClasses, 那么可以看一下该方法的定义:
- jvmtiError
- JvmtiEnv::RedefineClasses(jint class_count, const jvmtiClassDefinition* class_definitions) {
- //TODO: add locking
- VM_RedefineClasses op(class_count, class_definitions, jvmti_class_load_kind_redefine);
- VMThread::execute(&op);
- return (op.check_error());
- } /* end RedefineClasses */
其中整体的执行依赖于 VMThread,VMThread 是一个在虚拟机创建时生成的单例原生线程, 这个线程能派生出其他线程. 同时, 这个线程的主要的作用是维护一个 vm 操作队列 (VMOperationQueue), 用于处理其他线程提交的 vm operation, 比如执行 GC 等.
VmThread 在执行一个 vm 操作时, 先判断这个操作是否需要在 safepoint 下执行. 若需要 safepoint 下执行且当前系统不在 safepoint 下, 则调用 SafepointSynchronize 的方法驱使所有线程进入 safepoint 中, 再执行 vm 操作. 执行完后再唤醒所有线程. 若此操作不需要在 safepoint 下, 或者当前系统已经在 safepoint 下, 则可以直接执行该操作了. 所以, 在 safepoint 的 vm 操作下, 只有 vm 线程可以执行具体的逻辑, 其他线程都要进入 safepoint 下并被挂起, 直到完成此次操作.
因此, 在执行字节码替换的时候需要在 safepoint 下执行, 因此整体会触发 stop-the-world.
99, 参考文档
http://lovestblog.cn/blog/2015/09/14/javaagent/
来源: https://www.cnblogs.com/kokov/p/12120033.html