阿里妹導讀:在平臺級的 Java 系統中,動態腳本技術是不可或缺的一環。本文分享了一種 Java 動態腳本實現方案,給出了其中的關鍵技術點,並就類重名問題、生命周期、安全問題等做出進一步討論,歡迎同學們共同交流。
文末福利:Java 學習路線。
繁星是一個數據服務平臺,其核心功能是:用戶配置一段 SQL,繁星產出對應的 HSF/TR/SOA/Http 取數接口。一次查詢請求經過引擎的管道,被各個閥門處理後就得到了相應的結果數據。圖中高亮的兩個閥門就是本文討論的重點:前置腳本與後置腳本。溫馨提示:動態腳本就意味著代碼發布跳過了公司內部發布平臺,做不到監控、灰度、回滾三板斧,容易引發線上故障,因此業務系統中強烈不推薦使用該技術。當然 Java 動態腳本技術一般使用場景也比較少,主要在平臺性質的系統中可能用到,比如 leetcode 平臺,D2 平臺,繁星數據服務平臺等。本文權當技術探索和交流。對 Javascript 熟悉的同學知道,eval() 函數,例如:這裡我們要做的和 eval 類似,就是希望輸入一段 Java 代碼,伺服器按照代碼中的邏輯執行。在繁星中前置腳本的功能就是可以對用戶的輸入參數進行自定義的處理,後置腳本的功能就是可以對資料庫中查詢到的結果做進一步加工。要實現動態腳本的需求,首先可能會想到 Groovy,但是使用 Groovy 有幾大缺點:https://kbtdatacenter-read.oss-cn-zhangjiakou.aliyuncs.com/fusu-share/dynamic-script.zip--dynamic-script-advance-discuss -code-javac -command-javac -facade 我們首先定義好一個接口,例如 Animal,然後用戶在自己的代碼中實現 Animal 接口。相當於用戶提供的是 Animal 的實現類 Cat,這樣系統加載了用戶的 Java 代碼後,可以很方便的利用 Java 多態特性,訪問到對應的方法。這樣既方便了用戶書寫規範,同時平臺使用起來也簡單。首先回顧如何使用命令行來編譯 Java 類,並且運行。首先對 facade 模塊打一個 jar 包,方便後續依賴:進入到模塊 command-javac 的 resources 文件夾下(絕對路徑因人而異):cd /Users/fusu/d/group/fusu-share/dynamic-script/command-javac/src/main/resourcesjavac -cp .:/Users/fusu/d/group/fusu-share/dynamic-script/facade/target/facade-1.0.jar Cat.javajava -cp .:/Users/fusu/d/group/fusu-share/dynamic-script/facade/target/facade-1.0.jar Cat有了上面的控制臺命令行操作,很容易想到用 Java 的 Process 類調用命令行工具執行 javac 命令,然後使用 URLClassLoader 來加載生成的 class 文件。代碼位於模塊 command-javac 下的 ProcessJavac.java 文件中,核心代碼如下:String projectPath = PathUtil.getAppHomePath();
Process process = null;
String cmd = String.format("javac -cp .:%s/facade/target/facade-1.0.jar -d %s/command-javac/src/main/resources %s/command-javac/src/main/resources/Cat.java", projectPath, projectPath, projectPath);
System.out.println(cmd);
process = Runtime.getRuntime().exec(cmd);
readProcessOutput(process);
int exitVal = process.waitFor();if (exitVal == 0) { System.out.println("javac執行成功!" + exitVal);} else { System.out.println("javac執行失敗" + exitVal); return;}
String classFilePath = String.format("%s/command-javac/src/main/resources/Cat.class", projectPath);String urlFilePath = String.format("file:%s", classFilePath);URL url = new URL(urlFilePath);URLClassLoader classLoader = new URLClassLoader(new URL[]{url});
Class<?> catClass = classLoader.loadClass("Cat");Object obj = catClass.newInstance();if (obj instanceof Animal) { Animal animal = (Animal) obj; animal.hello("Kitty");}上面兩種方式都有一個明顯的缺點,就是需要依賴於 Cat.java 文件,以及必須產生 Cat.class 文件。在繁星平臺中,自然希望這個過程都在內存中完成,儘量減少 IO 操作,因此使用編程方式來編譯 Java 代碼就顯得很有必要了。代碼位於模塊 code-javac 下的 CodeJavac.java 文件中,核心代碼如下:String className = "Cat";String projectPath = PathUtil.getAppHomePath();String facadeJarPath = String.format(".:%s/facade/target/facade-1.0.jar", projectPath);
Iterable<? extends JavaFileObject> compilationUnits = new ArrayList<JavaFileObject>() {{ add(new JavaSourceFromString(className, getJavaCode()));}};
List<String> options = new ArrayList<>();options.add("-classpath");options.add(facadeJarPath);
JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager standardJavaFileManager = javaCompiler.getStandardFileManager(null, null, null);ScriptFileManager scriptFileManager = new ScriptFileManager(standardJavaFileManager);
StringWriter errorStringWriter = new StringWriter();
boolean ok = javaCompiler.getTask(errorStringWriter, scriptFileManager, diagnostic -> { if (diagnostic.getKind() == Diagnostic.Kind.ERROR) {
errorStringWriter.append(diagnostic.toString()); }}, options, null, compilationUnits).call();
if (!ok) { String errorMessage = errorStringWriter.toString(); throw new RuntimeException("Compile Error:{}" + errorMessage);}
final Map<String, byte[]> allBuffers = scriptFileManager.getAllBuffers();final byte[] catBytes = allBuffers.get(className);
FsClassLoader fsClassLoader = new FsClassLoader(className, catBytes);Class<?> catClass = fsClassLoader.findClass(className);Object obj = catClass.newInstance();if (obj instanceof Animal) { Animal animal = (Animal) obj; animal.hello("Moss");}
代碼中主要使用到了系統編譯器 JavaCompiler,調用它的 getTask 方法就相當於命令行中執行 javac,getTask 方法中使用自定義的 ScriptFileManager 來搜集二進位結果,以及使用 errorStringWriter 來搜集編譯過程中可能出錯的信息。最後藉助一個自定義類加載器 FsClassLoader 來從二進位數據中加載出類 Cat。上文介紹了動態腳本的實現關鍵點,但是還有諸多問題需要討論,筆者把主要的幾個問題拋出來,簡單討論一下。JVM 的類加載機制採用雙親委派模式,類加載器收到加載請求時,會委派自己的父加載器去執行加載任務,因此所有的加載任務都會傳遞到頂層的類加載器,只有當父加載器無法處理時,子加載器才自己去執行加載任務。下面這幅圖相信大家已經很熟悉了。JVM 對於一個類的唯一標識是 (Classloader,類全名),因此可能出現這種情況,接口 Animal 已經加載了,但是我們用 CustomClassLoader 去加載 Cat 時,提示說 Animal 找不到。這就是因為 Animal 和 Cat 不是被同一個 Classloader 加載的。由於 defineClass 方法是 protected 的,因此要用 byte[] 來加載 class 就需要自定義一個 classloader,如何指定這個 Classloader 的父加載器就比較有講究了。公司內部的 Java 系統都是採用的 pandora,pandora 有自己的類加載器以及線程加載器,因此我們以接口 Animal 的加載器 animalClassLoader 為標準,將線程 ClassLoader 設置為 animalClassLoader,同時將自定義的 ClassLoader 的父加載器指定為 animalClassLoader。代碼位於模塊 advance-discuss 下,參考代碼如下:public FsClassLoader(ClassLoader parentClassLoader, String name, byte[] data) { super(parentClassLoader); this.fullyName = name; this.data = data;}
ClassLoader animalClassLoader = Animal.class.getClassLoader();Thread.currentThread().setContextClassLoader(animalClassLoader);FsClassLoader fsClassLoader = new FsClassLoader(animalClassLoader, className, catBytes);當我們只動態加載一個類時,自然不用擔心類全名重複的問題,但是如果需要加載多個相同類時,就有必要進行特殊處理了,可以利用正則表達式捕獲用戶的類名,然後增加隨機字符串的方式來規避重名問題。從上文中,我們知道 JVM 對於一個類的唯一標識是(Classloader,類全名),因此只要能保證我們自定義的 Classloader 是不同的對象,也能夠避免類重名的問題。Java 腳本動態化必須考慮垃圾回收的問題,否則隨著 Class 被加載的越來越多,系統的內存很快就不夠用了。我們知道在 JVM 中,對象實例在沒有被引用後會被 GC (Garbage Collection 垃圾回收),Class 作為 JVM 中一個特殊的對象,也會被 GC(清空方法區中 Class 的信息和堆區中的 java.lang.Class 對象。這時 Class 的生命周期就結束了)。從上面三個條件可以推出,JVM 自帶的類加載器(Bootstrap 類加載器、Extension 類加載器)所加載的類,在 JVM 的生命周期中始終不會被 GC。自定義的類加載器所加載的 Class 是可以被 GC 的,因此在編碼時,自定義的 Classloader 一定做成局部變量,讓其自然被回收。為了驗證 Class 的 GC 情況,我們寫一個簡單的循環來觀察,模塊 advance-discuss 下的 AdvanceDiscuss.java 文件中:for (int i = 0; i < 1000000; i++) { compileAndRun(i);
if (i % 10000 == 0) { System.gc(); }}
System.gc();System.out.println("休息10s");Thread.currentThread().sleep(10 * 1000);打開 Java 自帶的 jvisualvm 程序(位於 JAVA_HOME/bin/jvisualvm),可以可視化的觀看到 JVM 的情況。在上圖中可以看到加載類的變化圖以及堆大小呈鋸齒狀,說明動態加載類能夠被有效的被回收。讓用戶寫腳本,並且在伺服器上運行,光是想想就知道是一件非常危險的事情,因此如何保證腳本的安全,是必須嚴肅對待的一個問題。在用戶寫的 Java 代碼中,我們需要規定用戶允許使用的類範圍,試想用戶調用 File 來操作伺服器上的文件,這是非常不安全的。javassist 庫可以對 Class 二進位文件進行分析,藉助該庫我們可以很容易地得到 Class 所依賴的類。代碼位於模塊 advance-discuss 下的 JavassistUtil.java 文件中,以下是核心代碼:public static Set<String> getDependencies(InputStream is) throws Exception {
ClassFile cf = new ClassFile(new DataInputStream(is)); ConstPool constPool = cf.getConstPool(); HashSet<String> set = new HashSet<>(); for (int ix = 1, size = constPool.getSize(); ix < size; ix++) { int descriptorIndex; if (constPool.getTag(ix) == ConstPool.CONST_Class) { set.add(constPool.getClassInfo(ix)); } else if (constPool.getTag(ix) == ConstPool.CONST_NameAndType) { descriptorIndex = constPool.getNameAndTypeDescriptor(ix); String desc = constPool.getUtf8Info(descriptorIndex); for (int p = 0; p < desc.length(); p++) { if (desc.charAt(p) == 'L') { set.add(desc.substring(++p, p = desc.indexOf(';', p)).replace('/', '.')); } } } } return set;}拿到依賴後,就可以首先使用白名單來過濾,以下這些包或類只涉及簡單的數據操作和處理,是被允許的:java.lang,java.util,com.alibaba.fastjson,java.text,[Ljava.lang (java.lang下的數組,例如 `String[]`)[D (double[])[F (float[])[I (int[])[J (long[])[C (char[])[B (byte[])[Z (boolean[])但是有個別的包下的類也比較危險,需要過濾掉,這時候就需要用黑名單再做一次篩選,這些包或類是不被允許的:java.lang.Threadjava.lang.reflect有可能用戶的代碼中包含死循環,或者執行時間特別長,對於這種有問題的邏輯在編譯時是無法感知的,因此還需要使用單獨的線程來執行用戶的代碼,當出現超時或者內存佔用過大的情況就直接 kill。上面討論的都是從編譯到執行的完整過程,但是有時候用戶的代碼沒有變更,我們去執行時就沒有必要再次去編譯了,因此可以設計一個緩存策略,當用戶代碼沒有發生變更時,就使用懶加載策略,當用戶的代碼發生了變更就釋放之前加載好的 Class,重新加載新的代碼。當系統重啟時,相當於所有的類都被釋放了需要重新加載,對於一些比較重要的腳本,可能短暫的懶加載時間也是難以接受的,對於這種就需要單獨搜集,在系統啟動的時候根據系統一起加載進內存,這樣就可以當健康檢查通過時,保證類已經加載好了,從而有效縮短響應時間。由於篇幅問題,緩存問題、及時加載問題只做了簡單的討論。當然 Java 動態腳本技術還涉及到很多其他細節,需要在使用過程中不斷總結。也歡迎大家一起交流~
福利來了含 6 大學習階段(Java 語言基礎、資料庫開發、Java web 開發、Java 開發框架及工具、面試技巧)、26 門免費課程、871 課時教學視頻、3 等級自測考試。排名第一的程式語言,從入門到實戰,助你全面掌握 Java 開發技能。
識別下方二維碼,或點擊」閱讀原文「立即學習: