企業級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