1. 程式人生 > 其它 >玩轉 Java 動態編譯,秀了秀了~!

玩轉 Java 動態編譯,秀了秀了~!

來源:https://zhenbianshu.github.io

問題

之前的文章從Spring 的環境到 Spring Cloud 的配置中提到過,我們在使用 Spring Cloud 進行動態化配置,它的實現步驟是先將動態配置通過 @Value 注入到一個動態配置 Bean,並將這個 Bean 用註解標記為 @RefreshScope,在配置變更後,這些動態配置 Bean 會被統一銷燬。

之後 Spring Cloud 的 ContextRefresher 會將變更後的配置作為一個新的 Spring Environment 載入進 ApplicationContext,由於 Scoped Bean 都是 Lazy Init 的,它們會在下一次使用時被使用新的 Environment 重新建立。

這套動態配置載入流程在使我們服務更加靈活的同時,也帶來了很大的風險。首先從業務上,修改配置不像上線這麼”重量級”,不必要找 QA 進行迴歸測試,這就有可能引發一系列奇怪的 Bug,而且長時間發現不了,另外,Spring Cloud 本身沒有 “fallback” 機制,一旦配置的資料型別出了問題,就會導致服務不可用。

為此,我給 Spring Cloud 提了個 issue,但作者認為變動太大,不好改也不必改。

其實我也明白這個問題的困境,每個人都得為自己要修改的配置負責,即使框架支援了 fallback,但將錯誤吞掉,配置修改後不生效也沒什麼變化可能也並不符合使用者的期望。所以,儘量讓使用者要修改的配置正確成為了新的目標。

基於這種需求,我添加了一個動態配置的校驗器,但實現裡一部分程式碼來自 github,所以本文在總結思路的同時,也幫助我理解所有程式碼。

整體思路

由於框架層沒法做太多事情,所以我的計劃是將這些配置取出來,構造出一個獨立的 Java 類,並在服務外新建一個 ApplicationContext 試圖通過構造出來的 Java 類初始化一個 Spring Bean,如果這個 Spring Bean 初始化過程中報錯了,說明配置是有問題的。

動態編譯

通過配置構造 Java 類

首先要通過 .properties 檔案構造出一個 Java 類,但問題是在配置裡我們是不知道這些配置將要被怎麼使用的,不知道它要被 Spring EL 如何處理,又將被轉成什麼型別。

這裡我採用的策略是給配置添加註釋,註釋裡使用一定的格式宣告 EL 表示式和要生成的欄位型別,當然這種實現有點 low,有人提議把這些資訊放到配置項的 key 裡,之後會再進行優化。

把各個欄位解析完成後放到準備到的類模板中,就生成了一個 Config.java 類字串,之後就要將這個字串編譯成位元組碼並由 Spring 載入成 Bean。

JavaCompiler

由於 Config.java 是在執行時生成的,所以編譯也只能在執行時了,萬幸 Java 有提供 javax.util.JavaCompiler 類進行 Java 類的動態編譯,省去了”寫入檔案 —— 命令列編譯 —— 類載入 —— 清理檔案” 的複雜流程。

JavaCompiler 的典型應用示例如下:

JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();
JavaFileManager fileManager = javaCompiler.getStandardFileManager(null, null, null);
CompilationTask task = javaCompiler.getTask(out, fileManager, diagnosticListener, options, classes, compilationUnits);
task.call();
FileObject outputFile = fileManager.getFileForOutput(null, null, null, null);
outputFile.getCharContent(true);

流程如下圖:JavaCompiler 通過 JavaFileManager 管理輸入和輸出檔案,使用時通過 getTask() 方法提交一個非同步 CompilationTask 進行程式碼編譯,程式碼編譯時,JavaCompiler 通過 getCharContent() 從傳入的 compilationUnits 獲取到 .java 檔案內容,把編譯後的結果呼叫 CompiledByteCode 的 openOutputStream() 方法寫到 CompiledByteCode 物件裡。

委託模式

由於 JavaCompiler 的預設實現都是通過檔案進行的,這不符合我的期望,我需要的是輸入和輸出都在記憶體進行,所以需要修改 JavaCompiler 的實現,JavaCompiler、JavaFileManager、JavaFileObject(Input/Output) 分別使用委託模式實現。 其中 JavaFileManager 已經有 ForwardingJavaFileManager 的實現,JavaFileObject 也有 SimpleJavaFileObject 的實現,我們繼承其實現後重寫部分方法即可。

我參考的原始碼:https://github.com/trung/InMemoryJavaCompiler

Spring Bean 例項化

要將 Config 類例項化成 Bean,我們可以在 xml 裡預定義它,在編譯結束後建立一個簡易的 FileSystemXmlApplicationContext 例項化這個 xml 內的 Bean。

類載入器

首先要讓 Spring 能夠載入到這些編譯好的位元組碼,這就需要 ClassLoader 的配合。類載入器的預設實現不可能知道去載入我們記憶體裡編譯好的位元組碼,只好新加一個 ClassLoader,實現也很簡單,繼承 ClassLoader 抽象類,並實現 findClass 方法即可。

class MemoryClassLoader extends ClassLoader {
	@Override
	protected Class<?> findClass(String name) throws ClassNotFoundException {
	    // 在 CompiledByteCode 類裡將編譯後的位元組碼放到 classLoader 的 classBytes 欄位內。
		byte[] buf = classBytes.get(name);
		if (buf == null) {
			return super.findClass(name);
		}
		return defineClass(name, buf, 0, buf.length);
	}
}

配置和實現

由於 Config Bean 的初始化依賴動態配置,我們還要把這些配置也新增到 Spring 環境內,我們知道 Spring 環境配置是由多個 PropertySource 構成的,向裡面新增一個實現即可。然後就可以呼叫 application 的 refresh() 方法初始化上下文了,另外 Config Bean 被設定為懶載入了,不要忘記 get 一下使其被建立。

最終的程式碼如下:

FileSystemXmlApplicationContext applicationContext = new FileSystemXmlApplicationContext();
applicationContext.setClassLoader(memoryClassLoader);
applicationContext.setConfigLocation("classpath*:/test.xml");
Map<String, Object> propertyMap = buildDynamicPropertyMap();
MapPropertySource mapPropertySource = new MapPropertySource("validate_source", propertyMap);
applicationContext.getEnvironment().getPropertySources().addFirst(mapPropertySource);
applicationContext.refresh();
applicationContext.getBean("config");

小結

小專案完成的過程中,複習了很多知識,也嘗試了業務程式碼中幾乎不會用到的設計模式,充滿了挑戰性。

當然它現在還有配置不夠方便、錯誤提示不夠明確、沒解決配置 namespace 等問題,留到後面慢慢優化吧~

近期熱文推薦:

1.1,000+ 道 Java面試題及答案整理(2021最新版)

2.終於靠開源專案弄到 IntelliJ IDEA 啟用碼了,真香!

3.阿里 Mock 工具正式開源,幹掉市面上所有 Mock 工具!

4.Spring Cloud 2020.0.0 正式釋出,全新顛覆性版本!

5.《Java開發手冊(嵩山版)》最新發布,速速下載!

覺得不錯,別忘了隨手點贊+轉發哦!