遠端熱部署的落地與思考-動態編譯篇
來源:轉轉技術
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/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Java系列 | 遠端熱部署在美團的落地實踐Java熱部署
- Java動態編譯和熱更新Java編譯
- Flutter 動態化熱更新的思考與實踐Flutter
- JIT-動態編譯與AOT-靜態編譯:java/ java/ JavaScript/Dart亂談編譯JavaScriptDart
- Android:JNI與NDK(二)交叉編譯與動態庫,靜態庫Android編譯
- 編譯lua動態庫編譯
- 有關Linux的可執行程式——動態編譯、靜態編譯、readelfLinux行程編譯
- 【C++】使用VS2022開發可以線上遠端編譯部署的C++程式C++編譯
- 深入理解Java的動態編譯Java編譯
- 後端編譯與優化後端編譯優化
- 500行程式碼手寫docker開篇-goland遠端編譯環境配置行程DockerGoLand編譯
- Java動態編譯優化——提升編譯速度(N倍)Java編譯優化
- Dubbo原始碼之動態編譯原始碼編譯
- 基於.net standard 的動態編譯實現編譯
- mingw下編譯zlib quazip動態庫編譯
- go 交叉編譯,部署Go編譯
- 如何利用遠端桌面連線動態IPvps?
- Natasha 4.0 探索之路系列(三) 基本的動態編譯編譯
- Django遠端部署--命令收集Django
- Java編譯與反編譯Java編譯
- python學習-fabric(高效遠端自動化部署工具)Python
- Windows IDEA 專案(Scala+Sbt、Scala+Maven)建立與遠端部署到Linux(遠端部署其它專案也適用)WindowsIdeaMavenLinux
- C編譯: 動態連線庫 (.so檔案)編譯
- IjkPlayer. 可編譯及動態除錯native編譯除錯
- Linux下快速靜態編譯Qt以及Qt動態/靜態版本共存Linux編譯QT
- 蘇寧易購:前後端分離架構的落地思考後端架構
- 前端路由與後端路由的思考前端路由後端
- 通過tomcat的ManagerServlet遠端部署專案TomcatServlet
- 在動態IP下遠端連線計算機計算機
- 原始碼編譯,Apache DolphinScheduler前後端分離部署解決方案原始碼編譯Apache後端
- 群暉下虛擬機器編譯部署WOW服務端TrinityCore虛擬機編譯服務端
- 從fdk_aac編碼器到自動靜態編譯FFmpeg編譯
- 移動端配適與掌握動態 REMREM
- 編譯原理入門篇|一篇文章理解編譯全過程編譯原理
- 關於MNN工程框架編譯出來的靜態庫和動態庫的使用框架編譯
- GmSSL3.X編譯iOS和Android動態庫編譯iOSAndroid
- Google Protocol buffer 學習筆記.下篇-動態編譯GoProtocol筆記編譯
- 教你如何動態除錯 iOS App(反編譯App)除錯iOSAPP編譯