作者: 賦蘇 阿里技術(shù)
阿里妹導(dǎo)讀:在平臺(tái)級(jí)的 JAVA 系統(tǒng)中,動(dòng)態(tài)腳本技術(shù)是不可或缺的一環(huán)。本文分享了一種 Java 動(dòng)態(tài)腳本實(shí)現(xiàn)方案,給出了其中的關(guān)鍵技術(shù)點(diǎn),并就類重名問(wèn)題、生命周期、安全問(wèn)題等做出進(jìn)一步討論,歡迎同學(xué)們共同交流。
文末福利:Java 學(xué)習(xí)路線。
前言
繁星是一個(gè)數(shù)據(jù)服務(wù)平臺(tái),其核心功能是:用戶配置一段 SQL,繁星產(chǎn)出對(duì)應(yīng)的 HSF/TR/SOA/Http 取數(shù)接口。
繁星引擎流程圖如下:
一次查詢請(qǐng)求經(jīng)過(guò)引擎的管道,被各個(gè)閥門處理后就得到了相應(yīng)的結(jié)果數(shù)據(jù)。圖中高亮的兩個(gè)閥門就是本文討論的重點(diǎn):前置腳本與后置腳本。
溫馨提示:動(dòng)態(tài)腳本就意味著代碼發(fā)布跳過(guò)了公司內(nèi)部發(fā)布平臺(tái),做不到監(jiān)控、灰度、回滾三板斧,容易引發(fā)線上故障,因此業(yè)務(wù)系統(tǒng)中強(qiáng)烈不推薦使用該技術(shù)。
當(dāng)然 Java 動(dòng)態(tài)腳本技術(shù)一般使用場(chǎng)景也比較少,主要在平臺(tái)性質(zhì)的系統(tǒng)中可能用到,比如 leetcode 平臺(tái),D2 平臺(tái),繁星數(shù)據(jù)服務(wù)平臺(tái)等。本文權(quán)當(dāng)技術(shù)探索和交流。
功能描述
對(duì) JavaScript 熟悉的同學(xué)知道,eval() 函數(shù),例如:
eval('console.log(2+3)')
就會(huì)在控制臺(tái)中打出 5。
這里我們要做的和 eval 類似,就是希望輸入一段 Java 代碼,服務(wù)器按照代碼中的邏輯執(zhí)行。在繁星中前置腳本的功能就是可以對(duì)用戶的輸入?yún)?shù)進(jìn)行自定義的處理,后置腳本的功能就是可以對(duì)數(shù)據(jù)庫(kù)中查詢到的結(jié)果做進(jìn)一步加工。
為什么是 Java 腳本?
Groovy
要實(shí)現(xiàn)動(dòng)態(tài)腳本的需求,首先可能會(huì)想到 Groovy,但是使用 Groovy 有幾大缺點(diǎn):
- Groovy 雖然也是運(yùn)行在 JVM,但是語(yǔ)法和 Java 有一些差異,對(duì)于只會(huì) Java 的同學(xué)來(lái)說(shuō)有一定學(xué)習(xí)成本。
- 動(dòng)態(tài)類型,缺乏約束。有時(shí)候太過(guò)于靈活自由也是缺點(diǎn),尤其是對(duì)于平臺(tái)說(shuō)來(lái)。
- 需要額外引入 Groovy 的引擎 jar 包,大小 6.2M,屬實(shí)不小,對(duì)于有代碼強(qiáng)迫癥的我來(lái)說(shuō)這會(huì)是一個(gè)重要考慮因素。
Java
采用 Java 來(lái)實(shí)現(xiàn)動(dòng)態(tài)腳本的功能有以下優(yōu)點(diǎn):
- 學(xué)習(xí)成本低,在阿里最主要的語(yǔ)言就是 Java,會(huì) Java 幾乎是每個(gè)工程師必備的技能,因此上手難度幾乎為零。
- Java 可以規(guī)定接口約束,從而使得用戶寫的前后置腳本整齊劃一,方便管理和治理。
- 可以實(shí)時(shí)編譯和錯(cuò)誤提示,方便用戶及時(shí)訂正問(wèn)題。
實(shí)現(xiàn)方式
代碼工程說(shuō)明
本文的代碼工程:
https://kbtdatacenter-read.oss-cn-zhangjiakou.aliyuncs.com/fusu-share/dynamic-script.zip
??????--dynamic-script------advance-discuss //深度討論腳本動(dòng)態(tài)化技術(shù)中的一些細(xì)節(jié)------code-javac //使用代碼執(zhí)行編譯加載運(yùn)行任務(wù)------command-javac //演示用命令行的方式動(dòng)態(tài)編譯和加載java類------facade //提供單獨(dú)的接口包,方便整個(gè)演示過(guò)程流暢進(jìn)行實(shí)現(xiàn)方案設(shè)計(jì)
我們首先定義好一個(gè)接口,例如 Animal,然后用戶在自己的代碼中實(shí)現(xiàn) Animal 接口。相當(dāng)于用戶提供的是 Animal 的實(shí)現(xiàn)類 Cat,這樣系統(tǒng)加載了用戶的 Java 代碼后,可以很方便的利用 Java 多態(tài)特性,訪問(wèn)到對(duì)應(yīng)的方法。這樣既方便了用戶書寫規(guī)范,同時(shí)平臺(tái)使用起來(lái)也簡(jiǎn)單。
使用控制臺(tái)命令行
首先回顧如何使用命令行來(lái)編譯 Java 類,并且運(yùn)行。
首先對(duì) facade 模塊打一個(gè) jar 包,方便后續(xù)依賴:
??????cd 項(xiàng)目根目錄mvn install進(jìn)入到模塊 command-javac 的 resources 文件夾下(絕對(duì)路徑因人而異):
??????# 進(jìn)入到Cat.java所在的目錄cd /Users/fusu/d/group/fusu-share/dynamic-script/command-javac/src/main/resources# 使用命令行工具javac編譯,linux/mac 上cp分隔符使用 : windown使用 ;javac -cp .:/Users/fusu/d/group/fusu-share/dynamic-script/facade/target/facade-1.0.jar Cat.java# 運(yùn)行java -cp .:/Users/fusu/d/group/fusu-share/dynamic-script/facade/target/facade-1.0.jar Cat# 得到結(jié)果# > I'm Cat Main使用 Process 調(diào)用 javac 編譯
有了上面的控制臺(tái)命令行操作,很容易想到用 Java 的 Process 類調(diào)用命令行工具執(zhí)行 javac 命令,然后使用 URLClassLoader 來(lái)加載生成的 class 文件。代碼位于模塊 command-javac 下的 ProcessJavac.java 文件中,核心代碼如下:
//項(xiàng)目所在路徑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執(zhí)行成功!" + exitVal);} else { System.out.println("javac執(zhí)行失敗" + 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");}//會(huì)得到結(jié)果: Hello,Kitty! 我是Cat。用編程方式編譯和加載
上面兩種方式都有一個(gè)明顯的缺點(diǎn),就是需要依賴于 Cat.java 文件,以及必須產(chǎn)生 Cat.class 文件。在繁星平臺(tái)中,自然希望這個(gè)過(guò)程都在內(nèi)存中完成,盡量減少 IO 操作,因此使用編程方式來(lái)編譯 Java 代碼就顯得很有必要了。代碼位于模塊 code-javac 下的 CodeJavac.java 文件中,核心代碼如下:
???????//類名String className = "Cat";//項(xiàng)目所在路徑String projectPath = PathUtil.getAppHomePath();String facadeJarPath = String.format(".:%s/facade/target/facade-1.0.jar", projectPath);//需要進(jìn)行編譯的代碼Iterable<? extends JavaFileObject> compilationUnits = new ArrayList<JavaFileObject>() {{ add(new JavaSourceFromString(className, getJavaCode()));}};//編譯的選項(xiàng),對(duì)應(yīng)于命令行參數(shù)List<String> options = new ArrayList<>();options.add("-classpath");options.add(facadeJarPath);//使用系統(tǒng)的編譯器JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();StandardJavaFileManager standardJavaFileManager = javaCompiler.getStandardFileManager(null, null, null);ScriptFileManager scriptFileManager = new ScriptFileManager(standardJavaFileManager);//使用stringWriter來(lái)收集錯(cuò)誤。StringWriter errorStringWriter = new StringWriter();//開始進(jìn)行編譯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(); //編譯出錯(cuò),直接拋錯(cuò)。 throw new RuntimeException("Compile Error:{}" + errorMessage);}//獲取到編譯后的二進(jìn)制數(shù)據(jù)。final Map<String, byte[]> allBuffers = scriptFileManager.getAllBuffers();final byte[] catBytes = allBuffers.get(className);//使用自定義的ClassLoader加載類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");}//會(huì)得到結(jié)果: Hello,Moss! 我是Cat。代碼中主要使用到了系統(tǒng)編譯器 JavaCompiler,調(diào)用它的 getTask 方法就相當(dāng)于命令行中執(zhí)行 javac,getTask 方法中使用自定義的 ScriptFileManager 來(lái)搜集二進(jìn)制結(jié)果,以及使用 errorStringWriter 來(lái)搜集編譯過(guò)程中可能出錯(cuò)的信息。最后借助一個(gè)自定義類加載器 FsClassLoader 來(lái)從二進(jìn)制數(shù)據(jù)中加載出類 Cat。
深入討論
上文介紹了動(dòng)態(tài)腳本的實(shí)現(xiàn)關(guān)鍵點(diǎn),但是還有諸多問(wèn)題需要討論,筆者把主要的幾個(gè)問(wèn)題拋出來(lái),簡(jiǎn)單討論一下。
ClassLoader 范圍問(wèn)題
JVM 的類加載機(jī)制采用雙親委派模式,類加載器收到加載請(qǐng)求時(shí),會(huì)委派自己的父加載器去執(zhí)行加載任務(wù),因此所有的加載任務(wù)都會(huì)傳遞到頂層的類加載器,只有當(dāng)父加載器無(wú)法處理時(shí),子加載器才自己去執(zhí)行加載任務(wù)。下面這幅圖相信大家已經(jīng)很熟悉了。
JVM 對(duì)于一個(gè)類的唯一標(biāo)識(shí)是 (Classloader,類全名),因此可能出現(xiàn)這種情況,接口 Animal 已經(jīng)加載了,但是我們用 CustomClassLoader 去加載 Cat 時(shí),提示說(shuō) Animal 找不到。這就是因?yàn)?Animal 和 Cat 不是被同一個(gè) Classloader 加載的。
由于 defineClass 方法是 protected 的,因此要用 byte[] 來(lái)加載 class 就需要自定義一個(gè) classloader,如何指定這個(gè) Classloader 的父加載器就比較有講究了。
公司內(nèi)部的 Java 系統(tǒng)都是采用的 pandora,pandora 有自己的類加載器以及線程加載器,因此我們以接口 Animal 的加載器 animalClassLoader 為標(biāo)準(zhǔn),將線程 ClassLoader 設(shè)置為 animalClassLoader,同時(shí)將自定義的 ClassLoader 的父加載器指定為 animalClassLoader。代碼位于模塊 advance-discuss 下,參考代碼如下:
???????/*FsClassLoader.java*/public FsClassLoader(ClassLoader parentClassLoader, String name, byte[] data) { super(parentClassLoader); this.fullyName = name; this.data = data;}/*AdvanceDiscuss.java*///接口的類加載器ClassLoader animalClassLoader = Animal.class.getClassLoader();//設(shè)置當(dāng)前的線程類加載器Thread.currentThread().setContextClassLoader(animalClassLoader);//...//使用自定義的ClassLoader加載類FsClassLoader fsClassLoader = new FsClassLoader(animalClassLoader, className, catBytes);通過(guò)這些保障,就不會(huì)出現(xiàn)找不到類的問(wèn)題了。
類重名問(wèn)題
當(dāng)我們只動(dòng)態(tài)加載一個(gè)類時(shí),自然不用擔(dān)心類全名重復(fù)的問(wèn)題,但是如果需要加載多個(gè)相同類時(shí),就有必要進(jìn)行特殊處理了,可以利用正則表達(dá)式捕獲用戶的類名,然后增加隨機(jī)字符串的方式來(lái)規(guī)避重名問(wèn)題。
從上文中,我們知道 JVM 對(duì)于一個(gè)類的唯一標(biāo)識(shí)是(Classloader,類全名),因此只要能保證我們自定義的 Classloader 是不同的對(duì)象,也能夠避免類重名的問(wèn)題。
Class 生命周期問(wèn)題
Java 腳本動(dòng)態(tài)化必須考慮垃圾回收的問(wèn)題,否則隨著 Class 被加載的越來(lái)越多,系統(tǒng)的內(nèi)存很快就不夠用了。我們知道在 JVM 中,對(duì)象實(shí)例在沒有被引用后會(huì)被 GC (Garbage Collection 垃圾回收),Class 作為 JVM 中一個(gè)特殊的對(duì)象,也會(huì)被 GC(清空方法區(qū)中 Class 的信息和堆區(qū)中的 java.lang.Class 對(duì)象。這時(shí) Class 的生命周期就結(jié)束了)。
Class 要被回收,需要滿足以下三個(gè)條件:
- NoInstance:該類所有的實(shí)例都已經(jīng)被 GC。
- NoClassLoader:加載該類的 ClassLoader 實(shí)例已經(jīng)被 GC。
- NoReference:該類的 java.lang.Class 沒有被引用 (XXX.class,使用了靜態(tài)變量/方法)。
從上面三個(gè)條件可以推出,JVM 自帶的類加載器(Bootstrap 類加載器、Extension 類加載器)所加載的類,在 JVM 的生命周期中始終不會(huì)被 GC。自定義的類加載器所加載的 Class 是可以被 GC 的,因此在編碼時(shí),自定義的 Classloader 一定做成局部變量,讓其自然被回收。
為了驗(yàn)證 Class 的 GC 情況,我們寫一個(gè)簡(jiǎn)單的循環(huán)來(lái)觀察,模塊 advance-discuss 下的 AdvanceDiscuss.java 文件中:
???????for (int i = 0; i < 1000000; i++) { //編譯加載并且執(zhí)行 compileAndRun(i); //10000個(gè)回收一下 if (i % 10000 == 0) { System.gc(); }}//強(qiáng)制進(jìn)行回收System.gc();System.out.println("休息10s");Thread.currentThread().sleep(10 * 1000);打開 Java 自帶的 jvisualvm 程序(位于 JAVA_HOME/bin/jvisualvm),可以可視化的觀看到 JVM 的情況。
在上圖中可以看到加載類的變化圖以及堆大小呈鋸齒狀,說(shuō)明動(dòng)態(tài)加載類能夠被有效的被回收。
安全問(wèn)題
讓用戶寫腳本,并且在服務(wù)器上運(yùn)行,光是想想就知道是一件非常危險(xiǎn)的事情,因此如何保證腳本的安全,是必須嚴(yán)肅對(duì)待的一個(gè)問(wèn)題。
類的白名單及黑名單機(jī)制
在用戶寫的 Java 代碼中,我們需要規(guī)定用戶允許使用的類范圍,試想用戶調(diào)用 File 來(lái)操作服務(wù)器上的文件,這是非常不安全的。javassist 庫(kù)可以對(duì) Class 二進(jìn)制文件進(jìn)行分析,借助該庫(kù)我們可以很容易地得到 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;}拿到依賴后,就可以首先使用白名單來(lái)過(guò)濾,以下這些包或類只涉及簡(jiǎn)單的數(shù)據(jù)操作和處理,是被允許的:
??java.lang,java.util,com.alibaba.fastjson,java.text,[Ljava.lang (java.lang下的數(shù)組,例如 `String[]`)[D (double[])[F (float[])[I (int[])[J (long[])[C (char[])[B (byte[])[Z (boolean[])但是有個(gè)別的包下的類也比較危險(xiǎn),需要過(guò)濾掉,這時(shí)候就需要用黑名單再做一次篩選,這些包或類是不被允許的:
??????java.lang.Threadjava.lang.reflect線程隔離
有可能用戶的代碼中包含死循環(huán),或者執(zhí)行時(shí)間特別長(zhǎng),對(duì)于這種有問(wèn)題的邏輯在編譯時(shí)是無(wú)法感知的,因此還需要使用單獨(dú)的線程來(lái)執(zhí)行用戶的代碼,當(dāng)出現(xiàn)超時(shí)或者內(nèi)存占用過(guò)大的情況就直接 kill。
緩存問(wèn)題
上面討論的都是從編譯到執(zhí)行的完整過(guò)程,但是有時(shí)候用戶的代碼沒有變更,我們?nèi)?zhí)行時(shí)就沒有必要再次去編譯了,因此可以設(shè)計(jì)一個(gè)緩存策略,當(dāng)用戶代碼沒有發(fā)生變更時(shí),就使用懶加載策略,當(dāng)用戶的代碼發(fā)生了變更就釋放之前加載好的 Class,重新加載新的代碼。
及時(shí)加載問(wèn)題
當(dāng)系統(tǒng)重啟時(shí),相當(dāng)于所有的類都被釋放了需要重新加載,對(duì)于一些比較重要的腳本,可能短暫的懶加載時(shí)間也是難以接受的,對(duì)于這種就需要單獨(dú)搜集,在系統(tǒng)啟動(dòng)的時(shí)候根據(jù)系統(tǒng)一起加載進(jìn)內(nèi)存,這樣就可以當(dāng)健康檢查通過(guò)時(shí),保證類已經(jīng)加載好了,從而有效縮短響應(yīng)時(shí)間。
后記
由于篇幅問(wèn)題,緩存問(wèn)題、及時(shí)加載問(wèn)題只做了簡(jiǎn)單的討論。當(dāng)然 Java 動(dòng)態(tài)腳本技術(shù)還涉及到很多其他細(xì)節(jié),需要在使用過(guò)程中不斷總結(jié)。也歡迎大家一起交流~