注:本文定義-在函數執行前後增加對應的邏輯的操作統稱爲MOCK
1、引子
在某天與QA同學進行溝通時,發現QA同學有針對某個方法調用時,有讓該方法停止一段時間的需求,我對這部分的功能實現非常好奇,因此決定對原理進行一些深入的了解,力爭找到一種使用者盡可能少的對原有代碼進行修改的方式,以達到對應的MOCK要求。
整體的感知程度可以分爲三個級別:
- 硬編碼
- 增加配置
- 無需任何修改
2、思路
在對方法進行mock,暫停以及異常模擬,在不知道其原理的情況下,進行猜想,思考其具體的實現原理,整體來說,最簡單的實現模型無外乎兩種:
2.1 樸素思路
假設存在如下的函數
1 public Object targetMethod(){
2 System.out.println("運行");
3 }
若想要在函數執行後暫停一段時間、返回特定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時,需要定義方法執行前的函數以及方法執行後的函數:
@Aspectpublic 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.0preMain-Class: com.test.AgentClassArchiver-Version: Plexus ArchiverAgent-Class: com.test.AgentClassCan-Redefine-Classes: trueCan-Retransform-Classes: trueCreated-By: Apache Maven 3.3.9Build-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,那麽可以看一下該方法的定義:
jvmtiErrorJvmtiEnv::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。