Java系列 | 遠程熱部署在美團的落地實踐

語言: CN / TW / HK

Sonic是美團內部研發設計的一款用於熱部署的IDEA插件,本文其實現原理及落地的一些技術細節。在閲讀本文之前,建議大家先熟悉一下Spring源碼Spring MVC 源碼Spring Boot源碼Agent字節碼增強JavassistClassloader等相關知識。

1 前言

1.1 什麼是熱部署

所謂熱部署,就是在應用正在運行時升級軟件,卻不需要重新啟動應用。對於Java應用程序來説,熱部署就是在運行時更新Java類文件,同時觸發Spring以及其他常用第三方框架的一系列重新加載的過程。在這個過程中不需要重新啟動,並且修改的代碼實時生效,好比是戰鬥機在空中完成加油,不需要戰鬥機熄火降落,一系列操作都在“運行”狀態來完成。

1.2 為什麼我們需要熱部署

據瞭解,美團內部很多工程師每天本地重啟服務高達5~12次,單次大概3~8分鐘,每天向Cargo(美團內部測試環境管理工具)部署3~5次,單次時長20~45分鐘,部署頻繁頻次高、耗時長,嚴重影響了系統上線的效率。而插件提供的本地和遠程熱部署功能,可讓將代碼變更“秒級”生效。一般而言,開發者日常工作主要分為開發自測和聯調兩個場景,下面將分別介紹熱部署在每個場景中發揮的作用。

圖 1

1.2.1 開發自測場景

一般來講,在用插件之前,開發者修改完代碼還需等待3~8分鐘啟動時間,然後手動構造請求或協調上游發請求,耗時且費力。在使用完熱部署插件後,修改完代碼可以一鍵增量部署,讓變更“秒級”生效,能夠做到快速自測。而對於那些無法本地啟動項目,也可以通過遠程熱部署功能使代碼變更“秒級”生效。

圖 2

1.2.2 聯調場景

通常情況下,在使用插件之前,開發者修改代碼經過20~35分鐘的漫長部署,需要聯繫上游聯調開發者發起請求,一直要等到遠程服務器查看日誌,才能確認代碼生效。在使用熱部署插件之後,開發者修改代碼遠程熱部署能夠秒級(2~10s)生效,開發者直接發起服務調用,可以節省大量的碎片化時間(熱部署插件還具備流量回放、遠程調用、遠程反編譯等功能,可配合進行使用)。

圖 3

所以,熱部署插件希望解決的痛點是:在可控的條件內,幫助開發者減少頻繁編譯部署的次數,節省碎片化的時間。最終為開發者每天節約出一定量的編碼時間

1.3 熱部署難在哪

為什麼業界目前沒有好用的開源工具?因為熱部署不等同於熱重啟,像Tomcat或者Spring Boot DevTools此類熱重啟模式需要重新加載項目,性能較差。增量熱部署難度較大,需要兼容常用的中間件版本,需要深入啟動銷燬加載流程。以美團為例,我們需要對JPDA(Java Platform Debugger Architecture)、Java Agent、ASM字節碼增強、Classloader、Spring框架、Spring Boot框架、MyBatis框架、Mtthrift(美團RPC框架)、Zebra(美團持久層框架)、Pigeon(美團RPC框架),MDP(美團快速開發框架)、XFrame(美團快速開發腳手架)、Crane(美團分佈式任務調度框架)等眾多框架和技術原理深入瞭解才能做到全面的兼容和支持。另外,還需要IDEA插件開發能力,形成整體的產品解決方案閉環,美團的熱部署插件Sonic正是在這種背景下應運而生。

圖 4

1.4 Sonic可以做什麼

Sonic是美團內部研發設計的一款IDEA插件,旨在通過低代碼開發輔助遠程/本地熱部署,解決Coding、單測編寫執行、自測聯調等階段的效率問題,提高開發者的編碼產出效率。數據統計表明,開發者日常大概有35%時間用於編碼的產出。如果想提高研發效率,要麼擴大編碼產出的時間佔比,要麼提高編碼階段的產出效率,而Sonic則聚焦提高編碼階段的產出效率。

目前,使用Sonic熱部署可以解決大部分代碼重複構建的問題。Sonic可以使用户在本地編寫代碼一鍵部署到遠程環境,修改代碼、部署、聯調請求、查看日誌,循環反覆。如果不考慮代碼修改時間,通常一個循環需要20~35分鐘,而使用Sonic可以把整個時長縮短至5~10秒,而且能夠給開發者帶來高效沉浸式的開發體驗。在實際編碼工作中,多文件修改是家常便飯,Sonic對多文件的熱部署能力尤為突出,它可以通過依賴分析等手段來對多文件批量進行遠程熱部署,並且支持Spring Bean Class、普通Class、Spring XML、MyBatis XML等多類型文件混合熱部署。

那麼跟業界現有的產品相比,Sonic有哪些優劣勢呢?下面我們嘗試給出幾種產品的對比,僅供大家參考:

特性 JRebel Spring Boot DevTools IDEA熱加載 Tomcat熱加載 Spring Loader Sonic
遠程Debug 基於Debug協議修改
修改方法體內容 ✅效率低 ✅效率低
新增方法體 ✅效率低 ✅效率低
Jar包變更 ✅效率低 ✅效率低
Spring MVC ✅效率低 ✅效率低
多文件熱部署 ✅效率低 ✅效率低
新增泛型方法 ✅效率低 ✅效率低
新增非靜態字段 ✅效率低 ✅效率低
新增靜態字段 ✅效率低 ✅效率低
新增修改繼承類 ✅效率低 ✅效率低
新增修改接口方法 ✅效率低 ✅效率低
新增修改匿名內部類 ✅效率低 ✅效率低
增加修改靜態塊 ✅效率低 ✅效率低
FastJson ✅效率低 ✅效率低
Cglib ✅效率低 ✅效率低
MyBatis Annotation ✅效率低 ✅效率低
MyBatis XML ✅效率低 ✅效率低
Gson ✅效率低 ✅效率低
Jackson ✅效率低 ✅效率低
Jdk代理 ✅效率低 ✅效率低
Log4j ✅效率低 ✅效率低
Slf4J ✅效率低 ✅效率低
Logback ✅效率低 ✅效率低
Spring Tx ✅效率低 ✅效率低
Spring 新增Xml ✅效率低 ✅效率低
Spring Bean ✅效率低 ✅效率低
Spring Boot ✅效率低 ✅效率低
Spring Validator ✅效率低 ✅效率低
遠程熱部署 配置繁瑣
IDEA插件集成

上表未把Sofa-Ark、Osgi、Arthas列舉,此類屬於插件化、模塊化應用框架,以及Java在線診斷工具,核心能力非熱部署。值得注意的是,Spring Boot DevTools只能應用在Spring Boot項目中,並且它不是增量熱部署,而是通過Classloader迭代的方式重啟項目,對大項目而言,性能上是無法接受的。雖然,JRebel支持三方插件較多,生態龐大,但是對於國產的插件不支持,例如FastJson等,同時它還存在遠程熱部署配置侷限,對於公司內部的中間件需要個性化開發,並且是商業軟件,整體的使用成本較高。

1.5 Sonic遠程熱部署落地推廣的實踐經驗

相信大家都知道,對於技術產品的推廣,尤其是開發、測試階段使用的產品,由於遠離線上環境,推動力、執行力、產品功能閉環能否做好,是決定着該產品是否能在企業內部落地並得到大多數人認可的重要的一環。此外,因為很多開發者在開發、測試階段已逐漸形成了“固化動作”,如何改變這些用户的行為,讓他們擁抱新產品,也是Sonic面臨的艱鉅挑戰之一。我們從主動溝通、零成本(或極低成本)快速接入、自動化腳本,以及產品自動診斷、收集反饋等方向出發,踐行出了四條原則。

圖 6

2 整體設計方案

2.1 Sonic結構

Sonic插件由4大部分組成,包括腳本端、插件端、Agent端,以及Sonic服務端。腳本端負責自動化構建Sonic啟動參數、服務啟動等集成工作;IDEA插件端集成環境為開發者提供更便捷的熱部署服務;Agent端隨項目啟動負責熱部署的功能實現;服務端則負責收集熱部署信息、失敗上報等統計工作。如下圖所示:

圖 7

2.2 走進Agent

2.2.1 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.2.2 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.2.3 啟動時和運行時加載Instrument Agent過程

圖 8

2.3 那些年JVM和HotSwap之間的“相愛相殺”

圍繞着Method Body的HotSwap JVM一直在進行改進。從1.4版本開始,JPDA引入HotSwap機制(JPDA Enhancements),實現Debug時的Method Body的動態性。大家可參考文檔:enhancements1.4
1.5版本開始通過JVMTI實現的java.lang.instrument(Java Platform SE 8)的Premain方式,實現Agent方式的動態性(JVM啟動時指定Agent)。大家可參考文檔:package-summary

1.6版本又增加Agentmain方式,實現運行時動態性(通過The Attach API 綁定到具體VM)。大家可參考文檔:package-summary 。基本實現是通過JVMTI的retransformClass/redefineClass進行method、body級的字節碼更新,ASM、CGLib基本都是圍繞這些在做動態性。但是針對Class的HotSwap一直沒有動作(比如Class添加method、添加field、修改繼承關係等等),為什麼會這樣呢?因為複雜度過高,且沒有很高的回報。

2.4 Sonic如何解決Instrumentation的侷限性

由於JVM限制,JDK 7和JDK 8都不允許改類結構,比如新增字段,新增方法和修改類的父類等,這對於Spring項目來説是致命的。比如開發同學想修改一個Spring Bean,新增一個@Autowired字段,此類場景在實際應用時很多,所以Sonic對此類場景的支持必不可少。

那麼,具體是如何做到的呢?這裏要提一下“大名鼎鼎”的Dcevm。Dcevm(DynamicCode Evolution Virtual Machine)是Java Hostspot的補丁(嚴格上來説是修改),允許(並非無限制)在運行環境下修改加載的類文件。當前虛擬機只允許修改方法體(Method,Body),而Decvm可以增加、刪除類屬性、方法,甚至改變一個類的父類,Dcevm是一個開源項目,遵從GPL 2.0協議。更多關於Dcevm的介紹,大家可以參考:Wuerthinger10a以及GitHub Decvm

值得一提的是,在美團內部,針對Dcevm的安裝,Sonic已經打通HULK,集成發佈鏡像即可完成(本地熱部署可結合插件功能實現一鍵安裝熱部署環境)。

3 Sonic熱部署技術解析

3.1 Sonic整體架構模型

上一章節我們主要介紹了Sonic的組成。下圖詳細介紹了Sonic在運行期間各個組成部分的工作職責,由它們形成一整套完備的技術產品落地閉環方案:

圖 9

3.2 Sonic功能流轉

Sonic通過NIO監聽本地文件變更,觸發文件變更事件,例如Class新增、Class修改、Spring Bean重載等事件流程。下圖展示了一次熱部署單個文件的生命週期:

圖 10

3.3 文件監聽

Sonic首先會在本地和遠程預定義兩個目錄,/var/tmp/sonic/extraClasspath/var/tmp/sonic/classes。extraClasspath為Sonic自定義的拓展Classpath URL,classes為Sonic監聽的目錄,當有文件變更時,通過IDEA插件來部署到遠程/本地,觸發Agent的監聽目錄,來繼續下面的熱加載邏輯:

圖 11

為什麼Sonic不直接替換用户ClassPath下面的資源文件呢?因為考慮到業務方WAR包的API項目、Spring Boot、Tomcat項目、Jetty項目等,都是以JAR包來啟動的,這樣是無法直接修改用户的Class文件的。即使是用户項目可以修改,直接操作用户的Class,也會帶來一系列的安全問題。

所以,Sonic採用拓展ClassPath URL路徑來實現文件的修改和新增。並且存在這麼一種場景,多個業務側的項目引入相同的JAR包,在JAR裏面配置MyBatis的XML和註解。在此類情況下,Sonic沒有辦法直接來修改JAR包中源文件,通過拓展路徑的方式可以不需要關注JAR包,來修改JAR包中某一文件和XML。同理,採用此類方法可以進行整個JAR包的熱替換。下面我們簡單介紹一下Sonic的核心監聽器,如下圖所示:

圖 12

3.4 JVM Class Reload

JVM的字節碼批量重載邏輯,通過新的字節碼二進制流和舊的Class對象生成ClassDefinition定義,instrumentation.redefineClasses(definitions),來觸發JVM重載,重載過後將觸發初始化時Spring插件註冊的Transfrom。接下來,我們簡單講解一下Spring是怎麼重載的。

新增class Sonic如何保證可以加載到Classloader上下文中?由於項目在遠程執行,所以運行環境複雜,有可能是JAR包方式啟動(Spring Boot),也有可能是普通項目,也有可能是War Web項目,針對此類情況Sonic做了一層Classloader URL拓展。

圖 13

User ClassLoader是框架自定義的ClassLoader統稱,例如Jetty項目是WebAppclassLoader。其中Urlclasspath為當前項目的lib文件件下,例如Spring Boot項目也是從當前項目BOOT-INF/lib/路徑中加載CLass等等,不同框架的自定義位置稍有不同。所以針對此類情況,Agent必須拿到用户的自定義Classloader,如果是常規方式啟動的,比如普通Spring XML項目,藉助Plus(美團內部服務發佈平台)發佈,此類沒有自定義Classloader,是默認AppClassLoader,所以Agent在用户項目啟動過程中,藉助字節碼增強的方式來獲取到真正的用户Classloader。

圖 14

找到用户使用的子Classloader之後,通過反射的方式來獲取Classloader中的元素Classpath,其中ClassPath中的URL就是當前項目加載Class時需要的所有運行時Class環境,並且包括三方的JAR包依賴等。

Sonic獲取到URL數組,把Sonic自定義的拓展Classpath目錄加入到URL數組首位,這樣當有新增Class時,Sonic只需要將Class文件複製到拓展Classpath對應的包目錄下面即可,當有其他Bean依賴新增的Class時,會從當前目錄下面查找類文件。

為什麼不直接對Appclassloader進行加強?而是對框架的自定義Classloader進行加強?

圖 15

考慮這樣一個場景,框架自定義類加載器中有ClassA,此時用户新增ClassB需要熱加載,B Class裏面有A的引用關係,如果增強AppClassLoader,初始化B實例時ClassLoader。loadclass首先從UserClassLoader開始加載ClassB的字節碼,依靠雙親委派原則,B被Appclassloader加載,因為B依賴類A,所以當前AppClassLoader加載B一定是加載不到的,此時會拋出ClassNotFoundException異常。所以對類加載器拓展,一定要拓展最上層的類加載器,這樣才會達到使用者想要的效果。

3.5 Spring Bean重載

Spring Bean Reload過程中,Bean的銷燬和重啟流程,主要內容如下圖展示:

圖 16

首先當修改Java Class D時,通過Spring ClasspathScan掃描校驗當前修改的Bean是否Sprin 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.6 Spring XML重載

當用户修改/新增Spring XML時,需要對XML中所有Bean進行重載。

圖 17

重新Reload之後,將Spring銷燬後重啟。需要注意的是:XML修改方式改動較大,可能涉及到全局的AOP的配置以及前置和後置處理器相關的內容,影響範圍為全局,所以目前只放開普通的XML Bean標籤的新增/修改,其他能力酌情逐步放開。

3.7 MyBatis 熱部署

Spring MyBatis熱部署的主要處理流程是在啟動期間獲取所有Configuration路徑,並維護它和Spring Context的對應關係,在熱部署Class、XML時去匹配Configuration,從而重新加載Configuration以達到熱部署的目的。

圖 18

4 總結

4.1 熱部署功能一覽

上一章節主要講述了Spring Bean、Spring MVC、MyBatis的重載流程,Sonic還支持其它常用的開發框架,豐富的框架支持和兼容能力是Sonic的基石,下面列舉一些Sonic支持的常用的第三方框架:

圖19 美團內部框架以及常用開源框架

截止目前,Sonic已經支持絕大部分常用第三方框架的熱加載,常規業務開發幾乎無需重啟服務。並且在美團內部的成功率已經高達99.9%以上,真正地讓熱部署來代替常規部署構建成為一種可能。

4.2 IDE插件集成

Sonic也提供了功能強大的IDEA插件,讓用户進行沉浸式開發,遠程熱部署也變得更加便利。

圖 20

4.3 推廣使用情況

截止到發稿時,Sonic在美團使用人數3000+,應用項目數量2000+。該項目還獲得了美團內部2020年下半年到家研發平台“最佳效率團隊”獎。

5 作者簡介

凱哥、佔峯、李晗、龔炎、程驍、玉龍等,均來自美團/到家研發平台。

6 參考文章

閲讀美團技術團隊更多技術文章合集

前端 | 算法 | 後端 | 數據 | 安全 | 運維 | iOS | Android | 測試

| 在公眾號菜單欄對話框回覆【2021年貨】、【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可查看美團技術團隊歷年技術文章合集。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行為,請發送郵件至[email protected]申請授權。