前言
在前說明:好久沒有更新博客了,這一年在公司做了好多事情,包括代碼分析和熱部署替換等黑科技,一直沒有時間來進行落地寫出一些一文章來,甚是可惜,趁着中午睡覺的時間補一篇介紹性的文章吧。
首先熱部署的場景是這樣的,公司的項目非常多,整個BU事業部的項目加起來大約上幾百個項目了,有一些項目本地無法正常啟動,所以一些同學在修改完代碼,或者是在普通的常規任務開發過程中都是盲改,然后去公司的代碼平台進行發布,惡心的事情就在這里,有的一些項目從構建到發布運行大約30分鍾,所以每次修改代碼到代碼見效需要30分鍾的周期,這個極大的降低了公司的開發效率,一旦惰性成習慣,改變起來將十分的困難,所以我們極需要一個在本地修改完代碼之后,可以秒級在服務端生效的神器,這樣,我們的熱部署插件就誕生了。
熱部署在業界本身就是一個難啃的骨頭,屬於逆向編程的范疇,JVM有類加載,那么熱部署就要去做卸載后重新加載,Spring有上下文注冊,spring Bean執行初始化生命周期,熱部署就要去做類的銷毀,重新初始化,里面設計到的細節點非常之多,業界的幾款熱部署的處理方式也不盡相同,由於需要巨大的底層細節需要處理,所以目前上想找到一個完全覆蓋所有功能的熱部署插件是幾乎不可能的,一般大家聽到的熱部署插件主要是國外的一些項目比如商業版本的jrebel,開源版的springloaded,以及比較粗暴的spring dev tools。當前這些項目都是現成的復雜開源項目或者是閉包的商業項目,想去自行修改匹配自己公司的項目,難度是非常之大。閑話少說,進入正文
前言一:什么是熱部署
所謂熱部署,就是在應用正在運行的時候升級軟件,卻不需要重新啟動應用。對於Java應用程序來說,熱部署就是在運行時更新Java類文件,同時觸發spring的一些列重新加載過程。在這個過程中不需要重新啟動,並且修改的代碼實時生效
前言二:為什么我們需要熱部署
RD每天本地重啟服務5-12次,單次大概3-8分鍾,每天向Cargo部署3-5次,單次時長20-45分鍾,部署頻繁頻次高、耗時長。插件提供的本地和遠程熱部署功能可讓將代碼變更秒級生效,RD日常工作主要分為開發自測和聯調兩個場景,下面分別介紹熱部署在每個場景中發揮的作用:
前言三:熱部署難在哪,為什么業界沒有好用的開源工具
熱部署不等同於熱重啟,像tomcat或者spring boot tool dev這種熱重啟相當於直接加載項目,性能較差,增量文件熱部署難度很大,需要兼容各種中間件和用戶寫法,技術門檻高,需要對JPDA(Java Platform Debugger Architecture)、java agent、字節碼增強、classloader、spring框架、Mybatis框架等集成解決方案等各種技術原理深入了解才能全面支持各種框架,另外需要IDEA插件開發能力,形成整體的產品解決方案。現在有了熱部署,代碼就是任人打扮的小姑娘!
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/xxx/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包的熱替換(方案設計中)。下面簡單介紹一下核心監聽器,
3.2、jvm class reload
JVM的字節碼批量重載邏輯,通過新的字節碼二進制流和舊的class對象生成ClassDefinition定義,instrumentation.redefineClasses(definitions),來觸發JVM重載,重載過后將觸發初始化時spring插件注冊的transfrom,下一章我們簡單講解一下spring是怎么重載的。
新增class我們如何保證可以加載到classloader上下文中?由於項目在遠程執行,所以運行環境復雜,有可能是jar包方式啟動(spring boot),也有可能是普通項目,也有可能是war web項目,針對這種情況我們做了一層classloader url拓展
User classLoader是框架自定義的classLoader統稱,例如Jetty項目是WebAppclassLoader,其中Urlclasspath為當前項目的lib文件件下,例如spring boot項目也是從當前項目中BOOT-INF/lib/,等等,不同框架的自定義位置稍有不同。所以針對這種情況 我們必須拿到用戶的自定義classloader,如果常規方式啟動的,比如普通spring xml項目借助plus發布,這種沒有自定義classloader,是默認AppClassLoader,所以我們在用戶項目啟動過程中借助agent字節碼增強的方式來獲取到真正的用戶classloader。
我們做的事情:找到用戶使用的子classloader之后通過反射的方式來獲取classloader中的元素Classpath,其中classPath中的URL就是當前項目加載class時需要的所有運行時class環境,並且包括三方的jar包依賴等。
我們獲取到URL數組,把我們自定義的拓展classpath目錄加入到URL數組的首位,這樣當有新增class時,我們只需要將class文件放到拓展classpath對應的包目錄下面即可,當有其他bean依賴新增的class時,會從當前目錄下面查找類文件。
為什么不直接對Appclassloader進行加強?而是對框架的自定義classloader進行加強
考慮這樣一個場景,框架自定義類加載器中有ClassA,然后這個時候用戶新增了一個Class B需要熱加載,B class里面有A的引用關系,如果我們增強AppClassLoader時,初始化B實例時ClassLoader.loadclass首先從UserClassLoader開始找classB,依靠雙親委派原則,B是被Appclassloader加載的,因為B依賴了類A,所以當前AppClassLoader加載B一定是找不到的,這個時候匯報ClassNotFoundException。也就是說我們對類加載器拓展一定要拓展最上層的類加載器,這樣才會達到我們想要的效果。
3.3、spring bean重載
spring bean reload過程中,bean的銷毀和重啟流程,其中細節點涉及的比較多。主要內容如下圖展示:
首先當修改java class D時,通過spring classpathScan掃描校驗當前修改的bean是否是spring bean(注解校驗)然后觸發銷毀流程(BeanDefinitionRegistry.removeBeanDefinition)此方法會將當前spring 上下文中的 bean D 和依賴 spring bean D的 Bean C 一並銷毀,但是作用范圍僅僅在當前spring 上下文,若C被子上下文中的Bean B 依賴,是無法更新子上下文中的依賴關系的,此時,當有流量打進來,Bean B中關聯的Bean C還是熱部署之前的對象,所以熱部署失敗,所以我們在spring初始化過程中,需要維護一個父子上下文的對應關系,當子上下文變時若變更范圍涉及到Bean B時,需要重新更新子上下文中的依賴關系,所以當有多上下文關聯時需要維護多上下文環境,並且當前上下文環境入口需要reload。入口指:spring mvc controller,Mthrift和pigeon,對不同的流量入口,我們采用不同的reload策略。RPC框架入口主要操作為解綁注冊中心,重新注冊,重新加載啟動流程等,對Spring mvc controller主要是解綁和注冊url Mappping來實現流量入口類的變化切換
3.4、spring xml重載
當用戶修改/新增spring xml時,需要對xml中所有bean進行重載
重新reload之后,將spring 銷毀后重啟。
注意:xml修改方式改動較大,可能涉及到全局的Aop的配置以及前置和后置處理器相關的內容,影響范圍為全局,所以目前只放開普通的xml bean標簽的新增/修改,其他能力酌情逐步放開。
3.5、mybatis xml 重載
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、我們支持的功能
功能點 |
是否支持 |
---|---|
修改方法體內容 |
✅ |
新增方法體 |
✅ |
新增非靜態字段 |
✅ |
新增靜態字段 |
✅ |
spring bean中新增@autowired注解 |
✅ |
在spring 掃描包base package下,新增帶@Service的bean,並且注入 |
✅ |
新增xml |
✅ |
增加修改靜態塊 |
✅ |
新增修改匿名內部類 |
✅ |
新增修改繼承類 |
✅ |
新增修改接口方法 |
✅ |
新增泛型方法 |
✅ |
修改 annotation sql(Mybatis) |
✅ |
修改 xml sql(Mybatis) |
✅ |
增加修改靜態塊 |
✅ |
匿名內部類新增,修改 |
✅ |
內部類新增,修改 |
✅ |
新增,刪除extend父類,implement 接口 |
✅ |
父類或接口新增方法,刪除方法 |
✅ |
泛型方法,泛型類 |
✅ |
多文件熱部署 |
✅ |
spring boot項目 |
✅ |
war包項目 |
✅ |
修改spring xml (只修改bean標簽) |
✅ |
新增@Configuration @Bean |
✅ |
pigeon服務框架 |
✅ |
@Transactional 注解新增/修改,注解參數修改 |
✅ |
序列化 框架支持 | ✅ |
dubbo alibaba | ✅ |
dubbo apache | ✅ |
dubbox | ✅ |
motan | ✅ |
刪除繼承的class |
❌ |
枚舉 字段修改 |
❌ |
修改static字段值 |
❌ |
其他功能迭代挖掘ing |
☺ |
6、強大到令人窒息的多文件熱部署以及源碼交流
由於篇幅原因和文采捉急,沒有辦法完整的寫出熱部署過程中遇到的各種各樣稀奇古怪和無法解釋的問題,和其中的坎坷經歷。更多的功能需求迭代建議和agent源碼技術交流可以加入QQ群來詳細交流,QQ群號:825199617