遠端熱部署的落地與思考-動態編譯篇

架构师修行手册發表於2024-03-14


來源:轉轉技術

  • 1、背景

    • 1.1 、真實工作場景

    • 1.2、專案背景

  • 2、預期目標

  • 3、選講問題分析

  • 4、方案選擇

  • 5、探索實踐

  • 6、總結與展望


遠端熱部署(代號名稱Mark42、Jarvis)是參考美團Sonic並結合轉轉的業務場景研發的一款熱部署元件,由Java Agent與IDEA外掛組成。

整個熱部署全流程涉及知識範圍廣泛,三言兩語無法描述清楚,全流程會拆分成專題的形式進行分享。本文主要選講在落地過程中遇到的一些Sonic未提及的問題與自己的思考感悟。

通讀前建議閱讀美團原文:遠端熱部署在美團的落地實踐,原文講述到相關技術介紹、原理、實現方案等不再贅述。

1、背景

1.1 、真實工作場景

某次前後端聯調時的對話(部分內容存在虛構):

H師傅:果子果子,你這介面返回的結果好像不太對啊,是不是寫反了啊~

:啊,不能吧,稍等我看看哈~

H師傅:你看一下~

:woc,大於等於寫反了,我改一下~

H師傅:好小子,抓緊抓緊~

:改完了,就一個符號,已經在編譯部署了~

🕙五分鐘後......

:部署完了,你再看一下~

H師傅:好了,沒問題了~

H師傅:果子,這裡返回的文案要不要把最後一句刪掉,不太通順~

:有道理,PM同意了,我刪一下~

🕙又過了五分鐘......

:編譯部署完了,你在看一下~

H師傅:可以的 可以的~


原本一兩分鐘可以完成的工作,由於程式碼的改動、編譯部署等待導致前後端同學各自浪費了十多分鐘,極大的影響了協作效率。

如果能擁有一種“魔法”,使得後端的程式碼像前端一樣“熱更新”,那該是一件多麼幸福的事情!

1.2、專案背景

作為一名業務側的一線開發同學,一直把高優支援業務放在首位。由於業務系統相對複雜,且受限於公司架構歷史原因,使得開發者在開發過程中往往都是“一次性編寫”程式碼,等業務邏輯實現的差不多,“看”上去沒問題,就部署到Docker容器中進行自測查漏補缺,當遇到極為複雜的場景,就需要進行遠端Debug協助,發現問題後修改程式碼,再次部署,反反覆覆。

正因如此我們每天少不了Beetle(公司內部編譯管理及釋出管理輕量級效率平臺)多次編譯與部署的迴圈反覆的操作,一行小小的程式碼改動就需要走完一整個流程才能使得程式碼生效,嚴重影響了開發自測、聯調、提測的效率。

遠端熱部署的落地與思考-動態編譯篇

面對如此“長”的流程,能否對其進行簡化,儘可能的減少編譯部署次數,使得修改後的程式碼快速生效,減少使用者等待時間。

遠端熱部署的落地與思考-動態編譯篇

2、預期目標

日常開發場景中,最大限度的幫助開發者減少程式碼提交、編譯、部署的次數,節省因等待而造成的碎片化時間,使得開發者只需把主要精力放在編碼實現,間接提升開發效率。

3、選講問題分析

“熱部署”簡單講就是在Java程式執行時更新Java類檔案,即JVM的位元組碼過載,透過新的位元組碼二進位制流和舊的Class物件生成ClassDefinition定義,同時過載或初始化Spring容器以及第三方框架,達到“不停機”狀態更新

思考一個問題:新的位元組碼二進位制流也就是位元組碼檔案(.class 檔案)從何而來呢?

無非存在兩種解法:

1、本地編譯Java原始碼,將生成的.class檔案推到遠端伺服器;

2、直接將Java原始碼推到遠端伺服器,由遠端伺服器進行編譯生成.class檔案;

我們來逐一解析兩種方案成本與利弊:

方案1:成本低,易實現,使用者在本地先執行編譯操作,透過IDEA開發工具完成,但由於IDEA工具和Maven等構建工具之間的相容性問題,經常出現本地編譯不透過的情況,當然也可以透過Maven的Install命令編譯整個服務檔案,但是這種方案操作時間長,不人性化。其次還存在潛在的安全性問題:本地開發Jdk環境與伺服器Jdk環境不一致等。

遠端熱部署的落地與思考-動態編譯篇

方案2:難度係數高,實現複雜,但卻是更優解。首先由使用者將修改後的Java原始碼推到遠端伺服器,由遠端伺服器進行動態編譯生成.class檔案,整個過程對使用者透明。

問題:

①極多數服務都是Springboot - Fat Jar(將一個Jar及其依賴的三方Jar全部打到一個包中,這個包即為FatJar)這種結構方式。想要動態編譯則需要從ClassLoader中恢復ClassPath,但Springboot - Fat Jar是一個整體的jar包,恢復出來的路徑不合法(Url轉換成File不存在),這就導致動態編譯時找不到程式碼中引用的各種類。

遠端熱部署的落地與思考-動態編譯篇

②LomBok依賴丟失問題:Lombok主要是在編譯.class檔案期間,生成Get/Set/Hash/Equals/ToString等方法,使實體物件更簡潔,所以像Lombok這樣的依賴只作用於編譯階段,編譯完成就沒用了,對於有“程式碼潔癖”的同學會選擇從依賴Jar包裡排除掉。這樣子可能會導致我們修改、新增實體類時動態編譯失敗,找不到依賴。

Maven如下配置:

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.6</version>
<scope>provided</scope>
</dependency>


<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>

③動態編譯時ClassLoader的處理。美團熱部署作者龍哥說:所有的遠端和本地執行不一致的問題,百分之99在ClassLoader的問題上找。動態編譯需相容目前公司現有服務型別以及後續可能存在型別。

公司內部Java服務型別分為三種:SpringBoot服務、SCF服務、ZZJava服務,不同服務型別打包方式不同。

SpringBoot:Spring Boot服務,LaunchedURLClassLoader載入依賴資源

SCF服務:歷史SCF(內部RPC)框架內嵌Spring服務模式,服務啟動前需解壓服務所有依賴,得到絕對路徑後作為-classpath引數,透過AppClassLoader載入依賴資源(檢視完整啟動命令足有2-3W字元,可怕😨)

ZZJava服務:基於SpringBoot自定義的一種專案結構、打包及啟動、停止標準,依舊為Spring Boot服務,透過LaunchedURLClassLoader載入依賴資源

4、方案選擇

方案1:每次打包Docker映象時新增Dockerfile命令,解壓服務Jar包到指定位置,獲得BOOT-INF絕對路徑,並在JVM啟動命令中新增絕對路徑引數,服務執行時可取得BOOT-INF絕對路徑,並將其作為options -classpath引數呼叫getTask方法編譯程式碼。

    CompilationTask getTask(Writer out,
JavaFileManager fileManager,
DiagnosticListener<? super JavaFileObject> diagnosticListener,
Iterable<String> options,
Iterable<String> classes,
Iterable<? extends JavaFileObject> compilationUnits);

實現方案雖簡單,但目前架構不支援自定義Dockerfile命令,無法做到通用解壓服務,且依然無法解決像Lombok、Mapstruct等問題,某些情景下編譯還會報錯,可用性低。

方案2:解決Fatjar模式下的動態編譯

思考一下SpringBoot服務為什麼可以讀取Fatjar的資源

一句話描述可以總結為SpringBoot自定義了URL Handler處理邏輯,將巢狀的jar轉換為URL,透過URLClassLoader的addURL方法新增獲取資源,完整細節可以翻閱SpringBoot原始碼檢視。

    public URL getUrl() throws MalformedURLException {
if (this.url == null) {
String file = this.rootFile.getFile().toURI() + this.pathFromRoot + "!/";
file = file.replace("file:////", "file://"); // Fix UNC paths
// 這裡返回的時候 new了一個Handler來處理URL
this.url = new URL("jar", "", -1, file, new Handler(this));
}
return this.url;
}

Handler繼承了URLStreamHandler,重寫了openConnection方法來處理獲取JarURLConnection,最終透過JarURLConnection的getInputStream方法返回位元組流。

    @Override
protected URLConnection openConnection(URL url) throws IOException {
if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) {
return JarURLConnection.get(url, this.jarFile);
}
try {
return JarURLConnection.get(url, getRootJarFileFromUrl(url));
} catch (Exception ex) {
return openFallbackConnection(url, ex);
}
}

我們回到URLClassLoader,URLClassLoader重寫了findClass方法,透過雙親委託載入資源

    protected Class<?> findClass(final String name) throws ClassNotFoundException
{
final Class<?> result;
try {
result = AccessController.doPrivileged(
new PrivilegedExceptionAction<Class<?>>() {
public Class<?> run() throws ClassNotFoundException {
String path = name.replace('.', '/').concat(".class");
// 這裡呼叫URLClassPath的getResource方法
Resource res = ucp.getResource(path, false);
if (res != null) {
try {
return defineClass(name, res);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
} catch (ClassFormatError e2) {
if (res.getDataError() != null) {
e2.addSuppressed(res.getDataError());
}
throw e2;
}
} else {
return null;
}
}
}, acc);
} catch (java.security.PrivilegedActionException pae) {
throw (ClassNotFoundException) pae.getException();
}
if (result == null) {
throw new ClassNotFoundException(name);
}
return result;
}

最終呼叫到URLClassPath的getResource方法

        Resource getResource(final String name, boolean check) {
final URL url;
try {
url = new URL(base, ParseUtil.encodePath(name, false));
} catch (MalformedURLException e) {
throw new IllegalArgumentException("name");
}
final URLConnection uc;
try {
if (check) {
URLClassPath.check(url);
}
// 這裡就會呼叫到URLStreamHandler的openConnection方法
uc = url.openConnection();
InputStream in = uc.getInputStream();
if (uc instanceof JarURLConnection) {
/* Need to remember the jar file so it can be closed
* in a hurry.
*/
JarURLConnection juc = (JarURLConnection)uc;

boolean firstLoad = jarfile == null;

jarfile = JarLoader.checkJar(juc.getJarFile());

if (firstLoad && JarLoadEvent.isEnabled()) {
Tooling.notifyEvent(JarLoadEvent.jarLoadEvent(url, jarfile));
}
}
} catch (Exception e) {
return null;
}
return new Resource() {
public String getName() { return name; }
public URL getURL() { return url; }
public URL getCodeSourceURL() { return base; }
public InputStream getInputStream() throws IOException {
//JarURLConnection的getInputStream方法
return uc.getInputStream();
}
public int getContentLength() throws IOException {
return uc.getContentLength();
}
};
}

既然SpringBoot已經幫我們處理好Fatjar的資源讀取,我們將直接複用其能力獲取載入的資源。

5、探索實踐

Agent啟動時,透過位元組碼增強Spring框架。在Spring框架初始化時獲取其ClassLoader並反射儲存到Agent全域性靜態欄位(SpringBoot服務為LaunchedURLClassLoader,SCF服務為AppClassLoader)。當觸發動態編譯時(Agent執行期),針對於SpringBoot服務,我們將複用SpringBoot解析Fatjar的這個能力,透過LaunchedURLClassLoader獲取完整的URL資源,透過URL解析來得到JavaFileObject,從而完成動態編譯。

針對於缺失的Lombok、Mapstruct等依賴以及自定義新增的jar包,我們可以手動新增URL資源。

    public DynamicCompiler(ClassLoader userClassLoader) {
if (javaCompiler == null) {
throw new IllegalStateException("Can not load JavaCompiler from javax.tools.ToolProvider#getSystemJavaCompiler(), please confirm the application running in JDK not JRE.");
}

standardFileManager = javaCompiler.getStandardFileManager(null, null, null);

options.add("-Xlint:unchecked");
options.add("-g");

List<URL> urlList = new ArrayList<>();
//新增自定義jar資源
urlList.addAll(getCustomJarUrl());
//獲取userClassLoader載入的資源(SpringBoot服務 LaunchedURLClassLoader)
urlList.addAll(getClassLoaderUrl(userClassLoader));

// 向上查詢父類
ClassLoader appClassLoader = getAppClassLoader(userClassLoader);

//DynamicClassLoader同樣繼承URLClassLoader
dynamicClassLoader = new DynamicClassLoader(urlList.toArray(new URL[0]), appClassLoader);
}

解析URL獲取JavaFileObject

    private List<JavaFileObject> processJar(URL packageFolderURL) {
List<JavaFileObject> result = new ArrayList<>();
try {
String jarUri = packageFolderURL.toExternalForm().substring(0, packageFolderURL.toExternalForm().lastIndexOf("!/"));

JarURLConnection jarConn = (JarURLConnection) packageFolderURL.openConnection();
String rootEntryName = jarConn.getEntryName();
if (StringUtils.isBlank(rootEntryName)){
return new ArrayList<>();
}

int rootEnd = rootEntryName.length() + 1;

Enumeration<JarEntry> entryEnum = jarConn.getJarFile().entries();
while (entryEnum.hasMoreElements()) {
JarEntry jarEntry = entryEnum.nextElement();
String name = jarEntry.getName();
if (name.startsWith(rootEntryName) && name.indexOf('/', rootEnd) == -1 && name.endsWith(CLASS_FILE_EXTENSION)) {
URI uri = URI.create(jarUri + "!/" + name);
String binaryName = name.replaceAll("/", ".");
binaryName = binaryName.replaceAll(CLASS_FILE_EXTENSION + "$", "");

result.add(new CustomJavaFileObject(binaryName, uri));
}
}
} catch (Exception e) {
throw new RuntimeException("Wasn't able to open " + packageFolderURL + " as a jar file", e);
}
return result;
}

動態編譯獲取位元組碼

    public Map<String, byte[]> buildGetByteCodes() {

errors.clear();
warnings.clear();

JavaFileManager fileManager = new DynamicJavaFileManager(standardFileManager, dynamicClassLoader);

DiagnosticCollector<JavaFileObject> collector = new DiagnosticCollector<>();
JavaCompiler.CompilationTask task = javaCompiler.getTask(null, fileManager, collector, options, null, compilationUnits);

try {

if (!compilationUnits.isEmpty()) {
boolean result = task.call();

if (!result || collector.getDiagnostics().size() > 0) {

for (Diagnostic<? extends JavaFileObject> diagnostic : collector.getDiagnostics()) {
switch (diagnostic.getKind()) {
case NOTE:
case MANDATORY_WARNING:
case WARNING:
warnings.add(diagnostic);
break;
case OTHER:
case ERROR:
default:
errors.add(diagnostic);
break;
}

}
if (!errors.isEmpty()) {
return new HashMap<>();
}
}
}

return dynamicClassLoader.getByteCodes();
} catch (ClassFormatError e) {
throw new DynamicCompilerException(e, errors);
} finally {
compilationUnits.clear();
}
}

Mapstruct編譯過程較為特殊,首先會根據介面生成介面的實現類,進而生成位元組碼,getJavaFileForOutput方法需要根據kind型別判斷一下,不能忽略SOURCE型別,不然會導致Mapstruct介面的位元組碼檔案裡儲存的是實現類的Java程式碼,進而導致JVM的位元組碼過載錯誤。

    @Override
public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) {

if (JavaFileObject.Kind.SOURCE.equals(kind)) {
// 原始碼
for (StringSource stringSource : this.sourceCodes) {
if (stringSource.getClassName().equals(className)) {
return stringSource;
}
}

StringSource stringSource = new StringSource(className);
sourceCodes.add(stringSource);
//這裡可以存一下動態生成的原始碼,編譯完成後輸出到資料夾
// classLoader.registerCompiledSource(stringSource);
return stringSource;

} else {
// 位元組碼
for (MemoryByteCode byteCode : this.byteCodes) {
if (byteCode.getClassName().equals(className)) {
return byteCode;
}
}

MemoryByteCode innerClass = new MemoryByteCode(className);
byteCodes.add(innerClass);
classLoader.registerCompiledSource(innerClass);
return innerClass;
}
}

注:動態編譯時一定要新增-g引數生成完整除錯資訊,不然熱部署程式碼Debug會發現方法棧內變數沒有名字、Jacoco布林陣列透出、slot對不上等問題。(坑了我半年多一直沒發現原因)

做完以上動作,你就可以任意的動態編譯Java原始碼,得到位元組碼檔案了。

到這就完成了遠端熱部署準備工作了。

6、總結與展望

經常被問到做熱部署的夙願是什麼:

遠端熱部署的初心不是代替掉Beetle釋出部署流程,而是儘可能減少使用者編譯部署次數,節省使用者碎片化的時間,希望可以做到一次部署,“任意”修改

初版互動圖:

遠端熱部署的落地與思考-動態編譯篇

部分功能UI互動展示圖:遠端熱部署的落地與思考-動態編譯篇


目前Mark42已經支援以下功能

框架/功能狀態
遠端熱部署
遠端動態編譯
熱部署程式碼遠端Debug
遠端Agent日誌
遠端服務日誌
批次熱部署
IDEA外掛整合
修改方法體內容
新增方法體
新增泛型方法
新增非靜態欄位
新增修改靜態欄位
新增修改繼承類
新增修改介面方法
新增修改匿名內部類
新增修改靜態塊
FastJson
Jackson
Jdk代理
Spring
Spring MVC
Avenger
Fsmx狀態機
ZZMQ
MyBatis
Mapstruct
XXL-JOB
SCF
......

熱部署還有很長的路要走,跟美團Sonic相比,這僅僅是剛開始

ToDoList:

框架/功能狀態
Configuration配置bean支援已支援、測試中
xml檔案配置bean支援已支援、測試中
SCF Agent級別呼叫待開發
遠端單測支援待開發
遠端反編譯待開發
究極體 Spring loader替換dcevm待開發


來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70027824/viewspace-3008923/,如需轉載,請註明出處,否則將追究法律責任。

相關文章