JAVA熱部署,通過agent進行程式碼增量熱替換!!!
在前說明:好久沒有更新部落格了,這一年在公司做了好多事情,包括程式碼分析和熱部署替換等黑科技,一直沒有時間來進行落地寫出一些一文章來,甚是可惜,趁著中午睡覺的時間補一篇介紹性的文章吧。
首先熱部署的場景是這樣的,公司的專案非常多,真個BU事業部的專案加起來大約上幾百個專案了,有一些專案本地無法正常啟動,所以一些同學在修改完程式碼,或者是在普通的常規任務開發過程中都是盲改,然後去公司的程式碼平臺進行釋出,噁心的事情就在這裡,有的一些專案從構建到釋出執行大約30分鐘,所以每次修改程式碼到程式碼見效需要30分鐘的週期,這個極大的降低了公司的開發效率,一旦惰性成習慣,改變起來將十分的困難,所以我們極需要一個在本地修改完程式碼之後,可以秒級在服務端生效的神器,這樣,我們的熱部署外掛就誕生了。
熱部署在業界本身就是一個難啃的骨頭,屬於逆向程式設計的範疇,JVM有類載入,那麼熱部署就要去做解除安裝後重新載入,Spring有上下文註冊,spring Bean執行初始化生命週期,熱部署就要去做類的銷燬,重新初始化,裡面設計到的細節點非常之多,業界的幾款熱部署的處理方式也不盡相同,由於需要巨大的底層細節需要處理,所以目前上想找到一個完全覆蓋所有功能的熱部署外掛是幾乎不可能的,一般大家聽到的熱部署外掛主要是國外的一些專案比如商業版本的jrebel,開源版的springloaded,以及比較粗暴的spring dev tools。當前這些專案都是現成的複雜開源專案或者是閉包的商業專案,想去自行修改匹配自己公司的專案,難度是非常之大。閒話少說,進入正文
1、整體設計方案
2、走進agent
instrument 規範:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html?is-external=true
Class VirtualMachine:https://docs.oracle.com/javase/8/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html#loadAgent-java.lang.String-
Interface ClassFileTransformer:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/ClassFileTransformer.html
2.1、JVM啟動前靜態Instrument
Javaagent是java命令的一個引數。引數 javaagent 可以用於指定一個 jar 包,並且對該 java 包有2個要求:
-
這個 jar 包的 MANIFEST.MF 檔案必須指定 Premain-Class 項。
-
Premain-Class 指定的那個類必須實現 premain() 方法。
premain 方法,從字面上理解,就是執行在 main 函式之前的的類。當Java 虛擬機器啟動時,在執行 main 函式之前,JVM 會先執行-javaagent所指定 jar 包內 Premain-Class 這個類的 premain 方法 。
在命令列輸入 java可以看到相應的引數,其中有 和 java agent相關的:
-agentlib:<libname>[=<選項>] 載入本機代理庫 <libname>, 例如 -agentlib:hprof 另請參閱 -agentlib:jdwp=help 和 -agentlib:hprof=help -agentpath:<pathname>[=<選項>] 按完整路徑名載入本機代理庫 -javaagent:<jarpath>[=<選項>] 載入 Java 程式語言代理, 請參閱 java.lang.instrument
該包提供了一些工具幫助開發人員在 Java 程式執行時,動態修改系統中的 Class 型別。其中,使用該軟體包的一個關鍵元件就是 Javaagent。從名字上看,似乎是個 Java 代理之類的,而實際上,他的功能更像是一個Class 型別的轉換器,他可以在執行時接受重新外部請求,對Class型別進行修改。
agent載入時序圖
從本質上講,Java Agent 是一個遵循一組嚴格約定的常規 Java 類。 上面說到 javaagent命令要求指定的類中必須要有premain()方法,並且對premain方法的簽名也有要求,簽名必須滿足以下兩種格式:
public static void premain(String agentArgs, Instrumentation inst) public static void premain(String agentArgs)
JVM 會優先載入 帶 Instrumentation 簽名的方法,載入成功忽略第二種,如果第一種沒有,則載入第二種方法。這個邏輯在sun.instrument.InstrumentationImpl
2.2、Instrumentation類常用API
public interface Instrumentation { //增加一個Class 檔案的轉換器,轉換器用於改變 Class 二進位制流的資料,引數 canRetransform 設定是否允許重新轉換。 void addTransformer(ClassFileTransformer transformer, boolean canRetransform); //在類載入之前,重新定義 Class 檔案,ClassDefinition 表示對一個類新的定義, 如果在類載入之後,需要使用 retransformClasses 方法重新定義。addTransformer方法配置之後,後續的類載入都會被Transformer攔截。 對於已經載入過的類,可以執行retransformClasses來重新觸發這個Transformer的攔截。類載入的位元組碼被修改後,除非再次被retransform,否則不會恢復。 void addTransformer(ClassFileTransformer transformer); //刪除一個類轉換器 boolean removeTransformer(ClassFileTransformer transformer); //是否允許對class retransform boolean isRetransformClassesSupported(); //在類載入之後,重新定義 Class。這個很重要,該方法是1.6 之後加入的,事實上,該方法是 update 了一個類。 void retransformClasses(Class<?>... classes) throws UnmodifiableClassException; //是否允許對class重新定義 boolean isRedefineClassesSupported(); //此方法用於替換類的定義,而不引用現有的類檔案位元組,就像從原始碼重新編譯以進行修復和繼續除錯時所做的那樣。 //在要轉換現有類檔案位元組的地方(例如在位元組碼插裝中),應該使用retransformClasses。 //該方法可以修改方法體、常量池和屬性值,但不能新增、刪除、重新命名屬性或方法,也不能修改方法的簽名 void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException; //獲取已經被JVM載入的class,有className可能重複(可能存在多個classloader) @SuppressWarnings("rawtypes") Class[] getAllLoadedClasses(); }
2.3、instrument原理:
instrument的底層實現依賴於JVMTI(JVM Tool Interface),它是JVM暴露出來的一些供使用者擴充套件的介面集合,JVMTI是基於事件驅動的,JVM每執行到一定的邏輯就會呼叫一些事件的回撥介面(如果有的話),這些介面可以供開發者去擴充套件自己的邏輯。JVMTIAgent是一個利用JVMTI暴露出來的介面提供了代理啟動時載入(agent on load)、代理通過attach形式載入(agent on attach)和代理解除安裝(agent on unload)功能的動態庫。而instrument agent可以理解為一類JVMTIAgent動態庫,別名是JPLISAgent(Java Programming Language Instrumentation Services Agent),也就是專門為java語言編寫的插樁服務提供支援的代理。
2.3.1、啟動時載入instrument agent過程:
-
建立並初始化 JPLISAgent;
-
監聽 VMInit 事件,在 JVM 初始化完成之後做下面的事情:
-
建立 InstrumentationImpl 物件 ;
-
監聽 ClassFileLoadHook 事件 ;
-
呼叫 InstrumentationImpl 的loadClassAndCallPremain方法,在這個方法裡會去呼叫 javaagent 中 MANIFEST.MF 裡指定的Premain-Class 類的 premain 方法 ;
-
-
解析 javaagent 中 MANIFEST.MF 檔案的引數,並根據這些引數來設定 JPLISAgent 裡的一些內容。
2.3.2、執行時載入instrument agent過程:
通過 JVM 的attach機制來請求目標 JVM 載入對應的agent,過程大致如下:
-
建立並初始化JPLISAgent;
-
解析 javaagent 裡 MANIFEST.MF 裡的引數;
-
建立 InstrumentationImpl 物件;
-
監聽 ClassFileLoadHook 事件;
-
呼叫 InstrumentationImpl 的loadClassAndCallAgentmain方法,在這個方法裡會去呼叫javaagent裡 MANIFEST.MF 裡指定的Agent-Class類的agentmain方法。
2.3.3、Instrumentation的侷限性
大多數情況下,我們使用Instrumentation都是使用其位元組碼插樁的功能,或者籠統說就是類重定義(Class Redefine)的功能,但是有以下的侷限性:
-
premain和agentmain兩種方式修改位元組碼的時機都是類檔案載入之後,也就是說必須要帶有Class型別的引數,不能通過位元組碼檔案和自定義的類名重新定義一個本來不存在的類。
-
類的位元組碼修改稱為類轉換(Class Transform),類轉換其實最終都回歸到類重定義Instrumentation#redefineClasses()方法,此方法有以下限制:
-
新類和老類的父類必須相同;
-
新類和老類實現的介面數也要相同,並且是相同的介面;
-
新類和老類訪問符必須一致。 新類和老類欄位數和欄位名要一致;
-
新類和老類新增或刪除的方法必須是private static/final修飾的;
-
可以修改方法體。
-
除了上面的方式,如果想要重新定義一個類,可以考慮基於類載入器隔離的方式:建立一個新的自定義類載入器去通過新的位元組碼去定義一個全新的類,不過也存在只能通過反射呼叫該全新類的侷限性。
2.4、那些年JVM和Hotswap之間的相愛相殺
圍繞著method body的hotSwap JVM一直在進行改進
1.4開始JPDA引入了hotSwap機制(JPDA Enhancements),實現了debug時的method body的動態性
參照:https://docs.oracle.com/javase/8/docs/technotes/guides/jpda/enhancements1.4.html
1.5開始通過JVMTI實現的java.lang.instrument (Java Platform SE 8 ) 的premain方式,實現了agent方式的動態性(JVM啟動時指定agent)
參照:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html
1.6又增加了agentmain方式,實現了執行時動態性(通過The Attach API 繫結到具體VM)。
參照:https://blogs.oracle.com/corejavatechtips/the-attach-api
其基本實現是通過JVMTI的retransformClass/redefineClass進行method body級的位元組碼更新,ASM、CGLib之類基本都是圍繞這些在做動態性。
但是針對Class的hotSwap一直沒有動作(比如Class新增method,新增field,修改繼承關係等等),為什麼?因為複雜度高並且沒有太高的回報。
2.5、如何解決Instrumentation的侷限性
由於JVM限制,JDK7和JDK8都不允許都改類結構,比如新增欄位,新增方法和修改類的父類等,這對於spring專案來說是致命的,假設小龔同學想修改一個spring bean,新增了一個@Autowired欄位,這種場景在實際應用時很多,所以我們對這種場景的支援必不可少。
那麼我們是如何做到的呢,下面有請大名鼎鼎的dcevm,dcevm(DynamicCode Evolution Virtual Machine)是java hostspot的補丁(嚴格上來說是修改),允許(並非無限制)在執行環境下修改載入的類檔案.當前虛擬機器只允許修改方法體(method bodies),decvm,可以增加 刪除類屬性、方法,甚至改變一個類的父類、dcevm 是一個開源專案,遵從GPL 2.0、更多關於dcevm的介紹:
https://www.cnblogs.com/redcreen/archive/2011/06/03/2071169.html
https://www.slideshare.net/wangscu/hotspot-hotswap-who-and-who-are-best-freinds
https://www.cnblogs.com/redcreen/archive/2011/06/14/2080718.html
https://dl.acm.org/doi/10.1145/2076021.2048129
http://ssw.jku.at/Research/Papers/Wuerthinger11PhD/
http://ssw.jku.at/Research/Papers/Wuerthinger10a/
https://dl.acm.org/doi/10.1145/1868294.1868312
https://dl.acm.org/doi/10.1145/1890683.1890688
3、熱部署技術解析
3.1、檔案監聽
首先會在本地和遠端預定義兩個目錄,/var/tmp/xxx/extraClasspath和/var/tmp/xxxx/classes,extraClasspath為我們自定義的拓展classpath url,classes為我們監聽的目錄,當有檔案變更時,通過idea外掛來部署到遠端/本地,觸發agent的監聽目錄,來繼續下面的熱載入邏輯,為什麼我們不直接替換使用者的classPath下面的資原始檔呢,因為業務方考慮到war包的api專案,和spring boot專案,都是以jar包來啟動的,這樣我們是無法直接修改使用者的class檔案的,即使是使用者專案我們可以修改,直接操作使用者的class,也會帶來一系列的安全問題,所以我們採用了拓展classPath url來實現檔案的修改和新增,並且有這麼一個場景,多個業務側的專案引入了相同的jar包,在jar裡面配置了mybatis的xml和註解,這種情況我們沒有辦法直接來修改jar包中原始檔,通過拓展路徑的方式可以不需要關注jar包來修改jar包中某一檔案和xml,是不是很炫酷,同理這種方法可以進行整個jar包的熱替換(方案設計中)。
核心程式碼賞析:
@Override public void run() { //agent啟動時,後臺開啟一個非守護執行緒來監聽檔案的變更 runner = new Thread() { @Override public void run() { try { for (;;) { //processEvents 監聽檔案變更,非同步呼叫Listener if (stopped || !processEvents()) { break; } } } catch (InterruptedException x) { } } }; runner.setDaemon(true); runner.setName("HotSwap Watcher"); runner.start(); dispatcher.start(); }
在agent啟動時,會啟動一個執行緒裡面來輪詢檔案監聽事件,以保證發現檔案變更後文件監聽的邏輯順利執行,核心邏輯在方法processEvents中。
private boolean processEvents() throws InterruptedException { // wait for key to be signaled WatchKey key = watcher.poll(10, TimeUnit.MILLISECONDS); //沒有檔案變更時,直接返回 if (key == null) { return true; } //防止檔案沒有寫完、下面獲取到正在寫入的檔案。停止一會,檔案無論如何也寫完了。 //因為我們檔案變更採用的是Watch檔案監聽。這裡涉及到一個問題 //當java檔案過大,有可能檔案還沒有寫完全,已經被監聽到了,很容易引發EOF,這裡這裡適當休眠一小會 //服務端測試,修改1萬5000行程式碼寫入監聽無壓力。 Thread.sleep ( 200 ); Path dir = keys.get(key); if (dir == null) { return true; } for (WatchEvent<?> event : key.pollEvents()) { WatchEvent.Kind<?> kind = event.kind(); if (kind == OVERFLOW) { continue; } // Context for directory entry event is the file name of entry WatchEvent<Path> ev = cast(event); Path name = ev.context(); //獲取到當前變更的檔案。 Path child = dir.resolve(name); //核心邏輯,交給時間監視器來處理 dispatcher.add(ev, child); if (kind == ENTRY_CREATE) { try { if (Files.isDirectory(child, NOFOLLOW_LINKS)) { //當檔案首次監控到,需要先初始化一下監控的目錄。手動呼叫Listener recursiveFiles(child.toFile ().getAbsolutePath (),dispatcher,ev); registerAll(child); } } catch (IOException x) { } } } boolean valid = key.reset(); if (!valid) { keys.remove(key); // all directories are inaccessible if (keys.isEmpty()) { return false; } if (classLoaderListeners.isEmpty()) { for (WatchKey k : keys.keySet()) { k.cancel(); } return false; } } return true; }
WatchKey key = watcher.poll(10, TimeUnit.MILLISECONDS);來不斷收集監聽事件,當發現有檔案變更/新增時,通過WatchEvent來獲取到檔案目錄和dispatcher處理器來非同步處理檔案變更邏輯【dispatcher.add(ev, child);】
下面再看一下事件處理器做了些什麼事情
private final ArrayBlockingQueue<Event> eventQueue = new ArrayBlockingQueue<>(500); //將監聽到的檔案傳輸給佇列,非同步消費。 public void add(WatchEvent<Path> event, Path path) { eventQueue.offer(new Event(event, path)); } while (true) { eventQueue.drainTo(working); // work on new events. for (Event e : working) { //呼叫各個註冊的Listener callListeners(e.event, e.path); if (Thread.interrupted()) { return; } Thread.yield(); } // crear the working queue. working.clear(); try { Thread.sleep(50); } catch (InterruptedException e1) { // TODO Auto-generated catch block return; } }
這裡程式碼邏輯很清晰,主要是處理監聽事件呼叫所有外掛註冊的Listeners,下一章說一下幾個主要Listener
3.2、匹配監聽邏輯
當發現檔案變更時,去匹配滿足條件的Listener
private void callListeners(final WatchEvent<?> event, final Path path) { //重複註冊的listener刪除掉 synchronized (EventDispatcher.class){ for (Path p:listeners.keySet ()){ if(listeners.get ( p )==null || listeners.get ( p ).size ()==0){ listeners.remove ( p ); } } } boolean matchedOne = false; for (Map.Entry<Path, List<WatchEventListener>> list : listeners.entrySet()) { if (path.startsWith(list.getKey())) { matchedOne = true; for (WatchEventListener listener : new ArrayList<>(list.getValue())) { WatchFileEvent agentEvent = new HotswapWatchFileEvent(event, path); try { //呼叫核心熱載入邏輯 listener.onEvent(agentEvent); } catch (Throwable e) { // LOGGER.error("Error in watch event '{}' listener // '{}'", e, agentEvent, listener); } } } } if (!matchedOne) { LOGGER.error("無匹配 '{}', path '{}'", event, path); } }
發現當前變更的檔案滿足listener的匹配條件時,執行核心方法listener.onEvent(agentEvent);
我們debug看一下這些Listener都是什麼。
Listener功能一覽:
名稱 |
功能 |
---|---|
HotswapPlugin Listener |
當class變更時觸發,將最新的變更後的位元組碼重新reload到JVM中,觸發變更流程,新增方法,欄位,修改方法等內容在此處觸發。spring bean過載,spring mybatis bean過載也在此處觸發。 |
WatchResource Listener |
當發現class/xml等檔案變更時,將變更檔案URL儲存到記憶體,當classloader.findResource等資原始檔獲取時,首先從變更的快取檔案中進行獲取,外掛中遠端反編譯通過此功能來實現,以保證使用者側看到的資原始檔是最新變更的。 |
Spring xml Listener |
當發現spring的xml配置檔案新增變更時,觸發變更事件,通過XmlBeanDefinitionReader的loadBeanDefinitions方法過載xml,重新重新整理spring上下文。 |
Spring bean add Listener |
當發現java bean新增時,首先判斷是否是spring bean,然後將class 位元組碼轉換成BeanDefinition物件,註冊到spring上下文 |
Mybatis xml Listener |
發現xml變更時,發現是mybatis xml,觸發reload mybatis事件。 |
載入流程一覽
3.3、jvm class reload
JVM的位元組碼reload是通過HotswapPlugin Listener來實現的。
@OnClassFileEvent(classNameRegexp = ".*", events = {FileEvent.MODIFY, FileEvent.CREATE},seq = 1,name = "HotswapperPlugin for java reload") public void watchReload(CtClass ctClass, ClassLoader appClassLoader, URL url) throws IOException, CannotCompileException { // fix main start //因為只處理新增,考慮是否已經被classloader載入過。 if (!ClassLoaderHelper.isClassLoaded(appClassLoader, ctClass.getName()) && PluginManager.springbootClassLoader != null) { //PluginManager.springbootClassLoader為使用者程式碼在agent代理時,反向持有 //這裡做相容的目的是因為spring boot專案自定義了一個classloader,而不是APPclassloader ClassLoader springbootClassLoader = PluginManager.springbootClassLoader; if(appClassLoader!=springbootClassLoader){ appClassLoader = springbootClassLoader; if(!ClassLoaderHelper.isClassLoaded(appClassLoader, ctClass.getName())){ return; } } else { return; } } // search for a class to reload Class clazz; try { //讀取到舊的class位元組碼 clazz = appClassLoader.loadClass(ctClass.getName()); } catch (ClassNotFoundException e) { return; } synchronized (reloadMap) { reloadMap.put(clazz, ctClass.toBytecode()); } //觸發JVM熱載入邏輯 scheduler.scheduleCommand(hotswapCommand, 100, Scheduler.DuplicateSheduleBehaviour.SKIP); }
當發現檔案變更時,會通過Listener呼叫watchReload方法,ctClass為新增class的ctClass物件,appClassLoader為載入當前class的classloader,url為當前新增/修改 class的URL地址路徑
appClassLoader.loadClass(ctClass.getName());獲取到原class物件。ctClass.toBytecode()獲取到新修改之後的class二進位制位元組碼。
public void hotswap(Map<Class<?>, byte[]> reloadMap) { if (instrumentation == null) { throw new IllegalStateException("Plugin manager is not correctly initialized - no instrumentation available."); } synchronized (reloadMap) { ClassDefinition[] definitions = new ClassDefinition[reloadMap.size()]; String[] classNames = new String[reloadMap.size()]; int i = 0; for (Map.Entry<Class<?>, byte[]> entry : reloadMap.entrySet()) { classNames[i] = entry.getKey().getName(); definitions[i++] = new ClassDefinition(entry.getKey(), entry.getValue()); } try { LOGGER.reload("重新載入class 位元組碼到JVM開始!!! classes {} (autoHotswap)", Arrays.toString(classNames)); synchronized (hotswapLock) { LOGGER.info ( "觸發jvm redefineClasses,回撥觸發註冊的Transformer,classes : " + Arrays.toString(classNames) ); //批量熱載入 instrumentation.redefineClasses(definitions); } LOGGER.reload("重新載入class 位元組碼到JVM結束!!! classes {} (autoHotswap)", Arrays.toString(classNames)); } catch (Exception e) { throw new IllegalStateException("Unable to redefine classes", e); } reloadMap.clear(); } }
位元組碼批量過載邏輯,通過新的位元組碼二進位制流和舊的class物件生成ClassDefinition定義,instrumentation.redefineClasses(definitions),來觸發JVM過載,過載過後將觸發初始化時spring外掛註冊的transfrom,下一章我們簡單講解一下spring是怎麼過載的
3.4、spring bean過載
spring bean reload核心程式碼如下
public BeanDefinition resolveBeanDefinition(byte[] bytes) throws IOException { Resource resource = new ByteArrayResource(bytes); resetCachingMetadataReaderFactoryCache(); //獲取到新class位元組流的MetadataReader MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource); //判斷當前位元組流物件是否是spring bean if (isCandidateComponent(metadataReader)) { ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader); sbd.setResource(resource); sbd.setSource(resource); if (isCandidateComponent(sbd)) { return sbd; } else { return null; } } else { return null; } }
熟悉spring原始碼的同學這裡應該很清楚,引數byte[]陣列封裝成ByteArrayResource,通過ClassPathBeanDefinitionScanner的metadataReaderFactory的getMetadataReader獲取到MetadataReader,通過ASM位元組碼讀取class byte流來生成類MetadataReader物件,通過isCandidateComponent方法,裡面通過註解分析來check MetadataReader是否是spring bean,如果不是spring bean返回spring的載入流程
synchronized (ClassPathBeanDefinitionScannerAgent.class) { // TODO sychronize on DefaultListableFactory.beanDefinitionMap? ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(candidate); candidate.setScope(scopeMetadata.getScopeName()); String beanName = this.beanNameGenerator.generateBeanName(candidate, registry); if (candidate instanceof AbstractBeanDefinition) { postProcessBeanDefinition((AbstractBeanDefinition) candidate, beanName); } if (candidate instanceof AnnotatedBeanDefinition) { processCommonDefinitionAnnotations((AnnotatedBeanDefinition) candidate); } try { try{ nestHotSwapDestroy = new Object (); //重新整理spring上下文 removeIfExists(beanName); } finally { nestHotSwapDestroy = null; } if (checkCandidate(beanName, candidate)) { BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName); definitionHolder = applyScopedProxyMode(scopeMetadata, definitionHolder, registry); LOGGER.reload("重新註冊spring bean核心邏輯開始, Spring bean '{}', scanner:{}, classLoader:{}", beanName, scanner.getClass().getName(), this.getClass().getClassLoader()); // 擴充套件點:對 BeanDefinition 做後置處理 //處理mybatis註解bean BeanDefinitionPostProcessManager.applyPostProcessAfterScanned(definitionHolder, scanner); //重新註冊spring bean registerBeanDefinition(definitionHolder, registry); //維護一份spring bean依賴,防止出錯時,無法回滾程式碼 mergeDependSpringBean(beanName); DefaultListableBeanFactory bf = maybeRegistryToBeanFactory(); if (bf != null) //重新reload controller bean //註冊URL依賴 ResetRequestMappingCaches.reset(bf, beanName,isSpringBeanAdd); ProxyReplacer.clearAllProxies(); freezeConfiguration(); //重啟spring bean reLoadSpringBean(); } } finally { //doto }
這程式碼是spring載入的核心邏輯,通過BeanDefinition bean定義,來獲取beanName,removeIfExists方法首先來銷燬當前bean,和dependon當前bean的spring bean物件,通過spring啟動時對DefaultListableBeanFactory進行位元組碼增強來記錄下來當前bean和所有依賴當前bean的SetName。核心agent程式碼如下
@OnClassLoadEvent(classNameRegexp = "org.springframework.beans.factory.support.DefaultListableBeanFactory") public static void transform(CtClass clazz, ClassPool classPool) throws NotFoundException, CannotCompileException { CtMethod method = clazz.getDeclaredMethod(PRE_INSTANTIATE_SINGLETONS); LOGGER.info ( "已經對spring boot classLoader進行增強" ); String agentString = "try {" + "java.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();" + "java.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.config.PluginManager\" );" + "java.lang.reflect.Field field = clazz.getDeclaredField ( \"springbootClassLoader\" );" + "field.setAccessible ( true );" + "field.set ( null, classLoader);" + "} catch (Exception e) {" + "e.printStackTrace ();" + "}"; agentString+= "try {\n" + "\t\t\tjava.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();\n" + "\t\t\tjava.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.config.PluginManager\" );\n" + "\t\t\tjava.lang.reflect.Method method = clazz.getDeclaredMethod ( \"enhanceUserClassLoader\",new java.lang.Class[0]);\n" + "\t\t\tmethod.setAccessible ( true );\n" + "\t\t\tmethod.invoke ( null, new Object[0]);\n" + "\t\t} catch (java.lang.Exception e){\n" + "\t\t\te.printStackTrace ( );\n" + "\t\t}"; agentString += "try {\n" + "\t\t\tjava.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();\n" + "\t\t\tjava.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.plugin.spring.scanner.ClassPathBeanDefinitionScannerAgent\" );\n" + "\t\t\tjava.lang.reflect.Method method = clazz.getDeclaredMethod ( \"clearSpringBean\",new java.lang.Class[0]);\n" + "\t\t\tmethod.setAccessible ( true );\n" + "\t\t\tmethod.invoke ( null, new Object[0]);\n" + "\t\t} catch (java.lang.Exception e){\n" + "\t\t\te.printStackTrace ( );\n" + "\t\t}"; method.insertAfter ( agentString ); }
對agent位元組碼增強的小夥伴一定很熟悉上面的操作,在spring啟動過程中就會對當前的位元組碼增強。
BeanDefinitionPostProcessManager.applyPostProcessAfterScanned(definitionHolder, scanner) 如果是spring mybatis bean,去realod mybatis的bean。
registerBeanDefinition(definitionHolder, registry) 重新註冊reload的spring bean。
ResetRequestMappingCaches.reset(bf, beanName,isSpringBeanAdd),這段程式碼非常重要,屬性spring mvc的小夥伴一定知道,對於Controller Bean來說,一般是通過@Controller註解掃描來註冊spring bean的。並且會將將Controller的spring bean @RequestMapping的 URL和當前方法Method做為繫結,以便當使用者通過HTTP訪問專案時,需要對HTTP URL進行解析,然後獲取到快取中的spring bean來進行反射呼叫Controller的,所以對於業務方的MVC專案,我們需要對Controller的RequestMappingHandlerMapping進行解綁和重新繫結,這樣才能支援Controller專案的URL變更/新增和Controller Bean新增等,spring mvc原始碼核心註冊部分原始碼如下:
protected void registerHandlerMethod(Object handler, Method method, T mapping) { this.mappingRegistry.register(mapping, handler, method); }
protected void registerHandlerMethod(Object handler, Method method, T mapping) {
this.mappingRegistry.register(mapping, handler, method);
}
public void register(T mapping, Object handler, Method method) { this.readWriteLock.writeLock().lock(); try { HandlerMethod handlerMethod = createHandlerMethod(handler, method); assertUniqueMethodMapping(handlerMethod, mapping); if (logger.isInfoEnabled()) { logger.info("Mapped \"" + mapping + "\" onto " + handlerMethod); } this.mappingLookup.put(mapping, handlerMethod); List<String> directUrls = getDirectUrls(mapping); for (String url : directUrls) { this.urlLookup.add(url, mapping); } String name = null; if (getNamingStrategy() != null) { name = getNamingStrategy().getName(handlerMethod, mapping); addMappingName(name, handlerMethod); } CorsConfiguration corsConfig = initCorsConfiguration(handler, method, mapping); if (corsConfig != null) { this.corsLookup.put(handlerMethod, corsConfig); } this.registry.put(mapping, new MappingRegistration<T>(mapping, handlerMethod, directUrls, name)); } finally { this.readWriteLock.writeLock().unlock(); } }
上面程式碼很簡單,主要是spring mvc註冊一些URL和Method和spring controller Bean的元資料資訊。我們熱替換需要做的是當發現controller bean變更時,除了修改spring bean,還有解綁、重新註冊繫結mvc資訊到快取
重新繫結核心程式碼邏輯
Class<?> c = getHandlerMethodMappingClassOrNull(); if (c == null) return; Map<String, ?> mappings = BeanFactoryUtils.beansOfTypeIncludingAncestors(beanFactory, c, true, false); if (mappings.isEmpty()) { LOGGER.trace("Spring: no HandlerMappings found"); } try { for (Entry<String, ?> e : mappings.entrySet()) { Object am = e.getValue(); LOGGER.info("Spring: clearing HandlerMapping for {}", am.getClass()); try { Field f = c.getDeclaredField("handlerMethods"); f.setAccessible(true); ((Map<?,?>)f.get(am)).clear(); f = c.getDeclaredField("urlMap"); f.setAccessible(true); ((Map<?,?>)f.get(am)).clear(); try { f = c.getDeclaredField("nameMap"); f.setAccessible(true); ((Map<?,?>)f.get(am)).clear(); } catch(NoSuchFieldException nsfe) { LOGGER.trace("Probably using Spring 4.0 or below", nsfe); } if (am instanceof InitializingBean) { ((InitializingBean) am).afterPropertiesSet(); } } catch(NoSuchFieldException nsfe) { LOGGER.trace("Probably using Spring 4.2+", nsfe); Method m = c.getDeclaredMethod("getHandlerMethods", new Class[0]); Class<?>[] parameterTypes = new Class[1]; parameterTypes[0] = Object.class; Method u = c.getDeclaredMethod("unregisterMapping", parameterTypes); Map<?,?> unmodifiableHandlerMethods = (Map<?,?>) m.invoke(am); Object[] keys = unmodifiableHandlerMethods.keySet().toArray(); CopyOnWriteArraySet<String> controllerBean = new CopyOnWriteArraySet<String> ( ); for (Object key : keys) { LOGGER.trace("Unregistering handler method {}", key); String needReload = isNeedReload ( unmodifiableHandlerMethods.get ( key ) ); if(needReload!=null){ controllerBean.add ( needReload ); u.invoke(am, key); } } unmodifiableHandlerMethods = null; if (isSpringBeanAdd && am.getClass().getSimpleName().equals("RequestMappingHandlerMapping") && isHandler(beanFactory,beanName, am, c)) { controllerBean.add(beanName); } //重新註冊controller bean for(String bean:controllerBean){ Method detectHandlerMethods = c.getDeclaredMethod ( "detectHandlerMethods",new Class[]{Object.class} ); detectHandlerMethods.setAccessible ( true ); detectHandlerMethods.invoke ( am, bean ); } } } } catch (Exception e) { LOGGER.error("Failed to clear HandlerMappings", e); }
現在到了最後一步,我們需要手動去初始化剛剛銷燬的bean
manualDestroyThriftBean (); if(concurrentSkipListSet.size ()==0) return; try { Set<String> beans = new HashSet<> ( ); for (String beanName:concurrentSkipListSet){ if(instances!=null && instances.size ()>0){ for(ClassPathBeanDefinitionScannerAgent classPathBeanDefinitionScannerAgent: instances.values ()){ DefaultListableBeanFactory defaultListableBeanFactory = maybeRegistryToBeanFactory ( classPathBeanDefinitionScannerAgent.registry ); if(defaultListableBeanFactory != null && defaultListableBeanFactory.containsBeanDefinition ( beanName )){ try { defaultListableBeanFactory.getBean ( beanName ); beans.add ( beanName ); } catch (Exception e){ LOGGER.info ( "重新載入spring bean 失敗 bean:" + beanName,e ); } } } } } LOGGER.info(" "); LOGGER.info("\n ___ _ _ ___ ___ ___ ___ ___ \n" + " / __| | | |/ __/ __/ _ / __/ __|\n" + " \\__ | |_| | (_| (_| __\\__ \\__ \\\n" + " |___/\\__,_|\\___\\___\\___|___|___/\n" + " "); LOGGER.info(" "); LOGGER.info ( "spring load beans size:" + beans.size () ); } finally { concurrentSkipListSet.clear (); }
3.5、spring xml過載
當用戶修改/新增spring xml時,需要對xml中所有bean進行過載,核心程式碼
public void reloadBeanFromXml(URL url) { LOGGER.info("Reloading XML file: " + url); this.reader.loadBeanDefinitions(new FileSystemResource(url.getPath())); ResetBeanPostProcessorCaches.reset(maybeRegistryToBeanFactory()); ProxyReplacer.clearAllProxies(); reloadFlag = false; }
核心程式碼邏輯this.reader.loadBeanDefinitions(new FileSystemResource(url.getPath()));通過BeanDefinitionReader 重新reload xml
重新reload之後,將spring 銷燬後重啟。
3.6、mybatis xml 過載
核心程式碼邏輯
public static void reloadConfiguration(URL url) { try { XmlResource mybatisResource = MybatisResourceManager.findXmlResource(url); if (mybatisResource == null) { LOGGER.info("變更 XML 不是 Mybatis XML, xmlPath:{}", url.getPath()); LOGGER.info("xmlResource classLoader:{}", XmlResource.class.getClassLoader()); return; } LOGGER.info("xmlResource objectClassLoader:{}, classLoader:{}", mybatisResource.getClass().getClassLoader(), XmlResource.class.getClassLoader()); LOGGER.info("Mybatis XML 形式熱載入" + url.getPath()); mybatisResource.reload(url); } catch (Exception e) { LOGGER.error(e.getMessage(), e); } }
通過變更的xml url地址來獲取spring專案啟動時,儲存的URL和XmlResource對映,目前mybatis xml不支援新增,只支援修改,重新reload xml來重新整理配置項中的sql和其他配置資訊
/** * 重新載入 xml * * @param url 要 reload 的xml */ public void reloadXML(URL url) throws Exception { XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(url.openConnection().getInputStream(), configuration, loadedResource, configuration.getSqlFragments()); xmlMapperBuilder.parse(); }
4、遠端反編譯
在程式碼中通過外掛右鍵-遠端反編譯即可檢視當前classpath下面最新編譯的最新class檔案,這是如何辦到的的呢,核心程式碼如下:
agentString+= "try {\n" + "\t\t\tjava.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();\n" + "\t\t\tjava.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.config.PluginManager\" );\n" + "\t\t\tjava.lang.reflect.Method method = clazz.getDeclaredMethod ( \"enhanceUserClassLoader\",new java.lang.Class[0]);\n" + "\t\t\tmethod.setAccessible ( true );\n" + "\t\t\tmethod.invoke ( null, new Object[0]);\n" + "\t\t} catch (java.lang.Exception e){\n" + "\t\t\te.printStackTrace ( );\n" + "\t\t}";
上面程式碼是在使用者側啟動DefaultListableBeanFactory時,初始化所有bean之後完成的,在方法preInstantiateSingletons之後會對當前使用者側classloader進行反向持有+ 路徑增強。
public static void enhanceUserClassLoader(){ if(springbootClassLoader != null){ LOGGER.info ( "對使用者classloader進行增強,springbootClassLoader:" + springbootClassLoader ); URLClassLoaderHelper.prependClassPath ( springbootClassLoader ); LOGGER.info ( "對使用者classloader進行增強成功,springbootClassLoader:" + springbootClassLoader ); } }
通過使用程式碼啟動時反射增強classloader,下面來看看核心方法prependClassPath
public static void prependClassPath(ClassLoader classLoader){ LOGGER.info ( "使用者classloader增強,classLoader:" + classLoader ); if(!(classLoader instanceof URLClassLoader)){ return; } URL[] extraClasspath = PropertiesUtil.getExtraClasspath (); prependClassPath( (URLClassLoader) classLoader,extraClasspath); }
其中URL[] extraClasspath = PropertiesUtil.getExtraClasspath ();這裡獲取的是使用者自定義的classpath,每次新增修改class之後都會放進去最新的資原始檔。
public static void prependClassPath(URLClassLoader classLoader, URL[] extraClassPath) { synchronized (classLoader) { try { Field ucpField = URLClassLoader.class.getDeclaredField("ucp"); ucpField.setAccessible(true); URL[] origClassPath = getOrigClassPath(classLoader, ucpField); URL[] modifiedClassPath = new URL[origClassPath.length + extraClassPath.length]; System.arraycopy(extraClassPath, 0, modifiedClassPath, 0, extraClassPath.length); System.arraycopy(origClassPath, 0, modifiedClassPath, extraClassPath.length, origClassPath.length); Object urlClassPath = createClassPathInstance(modifiedClassPath); ExtraURLClassPathMethodHandler methodHandler = new ExtraURLClassPathMethodHandler(modifiedClassPath); ((Proxy)urlClassPath).setHandler(methodHandler); ucpField.set(classLoader, urlClassPath); LOGGER.debug("Added extraClassPath URLs {} to classLoader {}", Arrays.toString(extraClassPath), classLoader); } catch (Exception e) { LOGGER.error("Unable to add extraClassPath URLs {} to classLoader {}", e, Arrays.toString(extraClassPath), classLoader); } } }
只需關注
URL[] origClassPath = getOrigClassPath(classLoader, ucpField);
URL[] modifiedClassPath = new URL[origClassPath.length + extraClassPath.length];
System.arraycopy(extraClassPath, 0, modifiedClassPath, 0, extraClassPath.length);
System.arraycopy(origClassPath, 0, modifiedClassPath, extraClassPath.length, origClassPath.length);這幾行程式碼
首先獲取到使用者側classloader中URLClassPath的URLS,然後在通過反射的方式將使用者配置的extclasspath的路徑設定到URLS陣列中的首位,這樣每次呼叫URLClassLoader的findResource方法都會獲取到最新的資原始檔了。
5、我們支援的功能
功能點 |
是否支援 |
---|---|
修改方法體內容 |
✅ |
新增方法體 |
✅ |
新增非靜態欄位 |
✅ |
新增靜態欄位 |
✅ |
Thrift bean變更 |
✅ |
spring bean中新增@autowired註解 |
✅ |
spring bean中新增zebra dao bean |
✅ |
spring bean中新增mafka client |
✅ |
spring bean中新增Thrfit client |
✅ |
在spring 掃描包base package下,新增帶@Service的bean,並且注入 |
✅ |
新增xml |
✅ |
增加修改靜態塊 |
✅ |
新增修改匿名內部類 |
✅ |
新增修改繼承類 |
✅ |
新增修改介面方法 |
✅ |
新增泛型方法 |
✅ |
修改 annotation sql |
✅ |
修改 xml sql |
✅ |
增加修改靜態塊 |
✅ |
|