企業級java增量熱部署解決方案

語言: CN / TW / HK

 前言

在前說明:好久沒有更新部落格了,這一年在公司做了好多事情,包括程式碼分析和熱部署替換等黑科技,一直沒有時間來進行落地寫出一些一文章來,甚是可惜,趁著中午睡覺的時間補一篇介紹性的文章吧。

首先熱部署的場景是這樣的,公司的專案非常多,真個BU事業部的專案加起來大約上幾百個專案了,有一些專案本地無法正常啟動,所以一些同學在修改完程式碼,或者是在普通的常規任務開發過程中都是盲改,然後去公司的程式碼平臺進行釋出,噁心的事情就在這裡,有的一些專案從構建到釋出執行大約30分鐘,所以每次修改程式碼到程式碼見效需要30分鐘的週期,這個極大的降低了公司的開發效率,一旦惰性成習慣,改變起來將十分的困難,所以我們極需要一個在本地修改完程式碼之後,可以秒級在服務端生效的神器,這樣,我們的熱部署外掛就誕生了。

熱部署在業界本身就是一個難啃的骨頭,屬於逆向程式設計的範疇,JVM有類載入,那麼熱部署就要去做解除安裝後重新載入,Spring有上下文註冊,spring Bean執行初始化生命週期,熱部署就要去做類的銷燬,重新初始化,裡面設計到的細節點非常之多,業界的幾款熱部署的處理方式也不盡相同,由於需要巨大的底層細節需要處理,所以目前上想找到一個完全覆蓋所有功能的熱部署外掛是幾乎不可能的,一般大家聽到的熱部署外掛主要是國外的一些專案比如商業版本的jrebel,開源版的springloaded,以及比較粗暴的spring dev tools。當前這些專案都是現成的複雜開源專案或者是閉包的商業專案,想去自行修改匹配自己公司的專案,難度是非常之大。閒話少說,進入正文

前言一:什麼是熱部署

所謂熱部署,就是在應用正在執行的時候升級軟體,卻不需要重新啟動應用。對於Java應用程式來說,熱部署就是在執行時更新Java類檔案,同時觸發spring的一些列重新載入過程。在這個過程中不需要重新啟動,並且修改的程式碼實時生效

前言二:為什麼我們需要熱部署

程式設計師每天本地重啟服務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外掛開發能力,形成整體的產品解決方案。現在有了熱部署,程式碼就是任人打扮的小姑娘!

前言四:為什麼我們不用spring boot devtools

有一些朋友問我,為什麼不直接使用spring boot devtools,有兩方面原因吧,第一它僅僅只使用在spring boot專案中,對於普通的java專案以及spring xml專案是不支援的,最主要的第二點它的熱載入方案實際上和tomcat熱載入是一樣的,只不過它的熱載入通過巢狀classloader的方式來完成,這個classloader每次只加載class file變更的class二進位制檔案,這樣就會來帶一個問題,在非常龐大的專案面前(啟動大約10min+)這種情況,它就顯得很蒼白。這歸根結底的原因是在於他的reload範圍實在是太大了,對於一些小專案還可以,但是一些比較龐大的專案實際使用效果還是非常感人的。

1、整體設計方案

 

 

2、走進agent

instrument 規範:http://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html?is-external=true

Class VirtualMachine:http://docs.oracle.com/javase/8/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html#loadAgent-java.lang.String-

Interface ClassFileTransformer:http://docs.oracle.com/javase/8/docs/api/java/lang/instrument/ClassFileTransformer.html

2.1、JVM啟動前靜態Instrument

Javaagent是java命令的一個引數。引數 javaagent 可以用於指定一個 jar 包,並且對該 java 包有2個要求:

  1. 這個 jar 包的 MANIFEST.MF 檔案必須指定 Premain-Class 項。

  2. 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過程:

  1. 建立並初始化 JPLISAgent;

  2. 監聽 VMInit 事件,在 JVM 初始化完成之後做下面的事情:

    1. 建立 InstrumentationImpl 物件 ;

    2. 監聽 ClassFileLoadHook 事件 ;

    3. 呼叫 InstrumentationImpl 的loadClassAndCallPremain方法,在這個方法裡會去呼叫 javaagent 中 MANIFEST.MF 裡指定的Premain-Class 類的 premain 方法 ;

  3. 解析 javaagent 中 MANIFEST.MF 檔案的引數,並根據這些引數來設定 JPLISAgent 裡的一些內容。

2.3.2、執行時載入instrument agent過程:

通過 JVM 的attach機制來請求目標 JVM 載入對應的agent,過程大致如下:

  1. 建立並初始化JPLISAgent;

  2. 解析 javaagent 裡 MANIFEST.MF 裡的引數;

  3. 建立 InstrumentationImpl 物件;

  4. 監聽 ClassFileLoadHook 事件;

  5. 呼叫 InstrumentationImpl 的loadClassAndCallAgentmain方法,在這個方法裡會去呼叫javaagent裡 MANIFEST.MF 裡指定的Agent-Class類的agentmain方法。

2.3.3、Instrumentation的侷限性

大多數情況下,我們使用Instrumentation都是使用其位元組碼插樁的功能,或者籠統說就是類重定義(Class Redefine)的功能,但是有以下的侷限性:

  1. premain和agentmain兩種方式修改位元組碼的時機都是類檔案載入之後,也就是說必須要帶有Class型別的引數,不能通過位元組碼檔案和自定義的類名重新定義一個本來不存在的類。

  2. 類的位元組碼修改稱為類轉換(Class Transform),類轉換其實最終都回歸到類重定義Instrumentation#redefineClasses()方法,此方法有以下限制:

    1. 新類和老類的父類必須相同;

    2. 新類和老類實現的介面數也要相同,並且是相同的介面;

    3. 新類和老類訪問符必須一致。 新類和老類欄位數和欄位名要一致;

    4. 新類和老類新增或刪除的方法必須是private static/final修飾的;

    5. 可以修改方法體。

除了上面的方式,如果想要重新定義一個類,可以考慮基於類載入器隔離的方式:建立一個新的自定義類載入器去通過新的位元組碼去定義一個全新的類,不過也存在只能通過反射呼叫該全新類的侷限性。

2.4、那些年JVM和Hotswap之間的相愛相殺

圍繞著method body的hotSwap JVM一直在進行改進

1.4開始JPDA引入了hotSwap機制(JPDA Enhancements),實現了debug時的method body的動態性

參照:http://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)

參照:http://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html

1.6又增加了agentmain方式,實現了執行時動態性(通過The Attach API 繫結到具體VM)。

參照:http://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的介紹:

http://www.cnblogs.com/redcreen/archive/2011/06/03/2071169.html

http://www.slideshare.net/wangscu/hotspot-hotswap-who-and-who-are-best-freinds

http://www.cnblogs.com/redcreen/archive/2011/06/14/2080718.html

http://dl.acm.org/doi/10.1145/2076021.2048129

http://ssw.jku.at/Research/Papers/Wuerthinger11PhD/

http://ssw.jku.at/Research/Papers/Wuerthinger10a/

http://dl.acm.org/doi/10.1145/1868294.1868312

http://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