Java 如何實現動態腳本?

簡介:在平臺級的 Java 系統中,動態腳本技術是不可或缺的一環。本文分享了一種 Java 動態腳本實現方案,給出了其中的關鍵技術點,並就類重名問題、生命週期、安全問題等作出進一步討論,歡迎同窗們共同交流。java

image.png

前言

繁星是一個數據服務平臺,其核心功能是:用戶配置一段 SQL,繁星產出對應的 HSF/TR/SOA/Http 取數接口。linux

繁星引擎流程圖以下:
image.png
一次查詢請求通過引擎的管道,被各個閥門處理後就獲得了相應的結果數據。圖中高亮的兩個閥門就是本文討論的重點:前置腳本與後置腳本。正則表達式

舒適提示:動態腳本就意味着代碼發佈跳過了公司內部發布平臺,作不到監控、灰度、回滾三板斧,容易引起線上故障,所以業務系統中強烈不推薦使用該技術。數據庫

固然 Java 動態腳本技術通常使用場景也比較少,主要在平臺性質的系統中可能用到,好比 leetcode 平臺,D2 平臺,繁星數據服務平臺等。本文權當技術探索和交流。編程

功能描述

對 Javascript 熟悉的同窗知道,eval() 函數,例如:json

eval('console.log(2+3)')

就會在控制檯中打出 5。數組

這裏咱們要作的和 eval 相似,就是但願輸入一段 Java 代碼,服務器按照代碼中的邏輯執行。在繁星中前置腳本的功能就是能夠對用戶的輸入參數進行自定義的處理,後置腳本的功能就是能夠對數據庫中查詢到的結果作進一步加工。緩存

爲何是 Java 腳本?

Groovy

要實現動態腳本的需求,首先可能會想到 Groovy,可是使用 Groovy 有幾大缺點:安全

  • Groovy 雖然也是運行在 JVM,可是語法和 Java 有一些差別,對於只會 Java 的同窗來講有必定學習成本。
  • 動態類型,缺少約束。有時候太過於靈活自由也是缺點,尤爲是對於平臺說來。
  • 須要額外引入 Groovy 的引擎 jar 包,大小 6.2M,屬實不小,對於有代碼強迫症的我來講這會是一個重要考慮因素。

Java

採用 Java 來實現動態腳本的功能有如下優勢:服務器

  • 學習成本低,在阿里最主要的語言就是 Java,會 Java 幾乎是每一個工程師必備的技能,所以上手難度幾乎爲零。
  • Java 能夠規定接口約束,從而使得用戶寫的先後置腳本整齊劃一,方便管理和治理。
  • 能夠實時編譯和錯誤提示,方便用戶及時訂正問題。

實現方式

代碼工程說明

本文的代碼工程:
https://kbtdatacenter-read.oss-cn-zhangjiakou.aliyuncs.com/fusu-share/dynamic-script.zip

--dynamic-script
------advance-discuss //深度討論腳本動態化技術中的一些細節
------code-javac //使用代碼執行編譯加載運行任務
------command-javac //演示用命令行的方式動態編譯和加載java類
------facade  //提供單獨的接口包,方便整個演示過程流暢進行

實現方案設計

咱們首先定義好一個接口,例如 Animal,而後用戶在本身的代碼中實現 Animal 接口。至關於用戶提供的是 Animal 的實現類 Cat,這樣系統加載了用戶的 Java 代碼後,能夠很方便的利用 Java 多態特性,訪問到對應的方法。這樣既方便了用戶書寫規範,同時平臺使用起來也簡單。

使用控制檯命令行

首先回顧如何使用命令行來編譯 Java 類,而且運行。

首先對 facade 模塊打一個 jar 包,方便後續依賴:

cd 項目根目錄
mvn install

進入到模塊 command-javac 的 resources 文件夾下(絕對路徑因人而異):

# 進入到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
# 運行
java -cp .:/Users/fusu/d/group/fusu-share/dynamic-script/facade/target/facade-1.0.jar Cat
# 獲得結果
# > I'm Cat Main

使用 Process 調用 javac 編譯

有了上面的控制檯命令行操做,很容易想到用 Java 的 Process 類調用命令行工具執行 javac 命令,而後使用 URLClassLoader 來加載生成的 class 文件。代碼位於模塊 command-javac 下的 ProcessJavac.java 文件中,核心代碼以下:
image.png
image.png

用編程方式編譯和加載

上面兩種方式都有一個明顯的缺點,就是須要依賴於 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來收集錯誤。
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);

//使用自定義的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");
}

//會獲得結果:  Hello,Moss! 我是Cat。

代碼中主要使用到了系統編譯器 JavaCompiler,調用它的 getTask 方法就至關於命令行中執行 javac,getTask 方法中使用自定義的 ScriptFileManager 來蒐集二進制結果,以及使用 errorStringWriter 來蒐集編譯過程當中可能出錯的信息。最後藉助一個自定義類加載器 FsClassLoader 來從二進制數據中加載出類 Cat。

深刻討論

上文介紹了動態腳本的實現關鍵點,可是還有諸多問題須要討論,筆者把主要的幾個問題拋出來,簡單討論一下。

ClassLoader 範圍問題

JVM 的類加載機制採用雙親委派模式,類加載器收到加載請求時,會委派本身的父加載器去執行加載任務,所以全部的加載任務都會傳遞到頂層的類加載器,只有當父加載器沒法處理時,子加載器才本身去執行加載任務。下面這幅圖相信你們已經很熟悉了。
image.png

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 下,參考代碼以下:

/*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();
//設置當前的線程類加載器
Thread.currentThread().setContextClassLoader(animalClassLoader);
//...
//使用自定義的ClassLoader加載類
FsClassLoader fsClassLoader = new FsClassLoader(animalClassLoader, className, catBytes);

經過這些保障,就不會出現找不到類的問題了。

類重名問題

當咱們只動態加載一個類時,天然不用擔憂類全名重複的問題,可是若是須要加載多個相同類時,就有必要進行特殊處理了,能夠利用正則表達式捕獲用戶的類名,而後增長隨機字符串的方式來規避重名問題。

從上文中,咱們知道 JVM 對於一個類的惟一標識是(Classloader,類全名),所以只要能保證咱們自定義的 Classloader 是不一樣的對象,也可以避免類重名的問題。

Class 生命週期問題

Java 腳本動態化必須考慮垃圾回收的問題,不然隨着 Class 被加載的愈來愈多,系統的內存很快就不夠用了。咱們知道在 JVM 中,對象實例在沒有被引用後會被 GC (Garbage Collection 垃圾回收),Class 做爲 JVM 中一個特殊的對象,也會被 GC(清空方法區中 Class 的信息和堆區中的 java.lang.Class 對象。這時 Class 的生命週期就結束了)。

Class 要被回收,須要知足如下三個條件:

  • NoInstance:該類全部的實例都已經被 GC。
  • NoClassLoader:加載該類的 ClassLoader 實例已經被 GC。
  • NoReference:該類的 java.lang.Class 沒有被引用 (XXX.class,使用了靜態變量/方法)。

從上面三個條件能夠推出,JVM 自帶的類加載器(Bootstrap 類加載器、Extension 類加載器)所加載的類,在 JVM 的生命週期中始終不會被 GC。自定義的類加載器所加載的 Class 是能夠被 GC 的,所以在編碼時,自定義的 Classloader 必定作成局部變量,讓其天然被回收。

爲了驗證 Class 的 GC 狀況,咱們寫一個簡單的循環來觀察,模塊 advance-discuss 下的 AdvanceDiscuss.java 文件中:

for (int i = 0; i < 1000000; i++) {
  //編譯加載而且執行
  compileAndRun(i);

  //10000個回收一下
  if (i % 10000 == 0) {
    System.gc();
  }
}

//強制進行回收
System.gc();
System.out.println("休息10s");
Thread.currentThread().sleep(10 * 1000);

打開 Java 自帶的 jvisualvm 程序(位於 JAVA_HOME/bin/jvisualvm),能夠可視化的觀看到 JVM 的狀況。
640.gif
在上圖中能夠看到加載類的變化圖以及堆大小呈鋸齒狀,說明動態加載類可以被有效的被回收。

安全問題

讓用戶寫腳本,而且在服務器上運行,光是想一想就知道是一件很是危險的事情,所以如何保證腳本的安全,是必須嚴肅對待的一個問題。

類的白名單及黑名單機制
在用戶寫的 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.Thread
java.lang.reflect

線程隔離
有可能用戶的代碼中包含死循環,或者執行時間特別長,對於這種有問題的邏輯在編譯時是沒法感知的,所以還須要使用單獨的線程來執行用戶的代碼,當出現超時或者內存佔用過大的狀況就直接 kill。

緩存問題

上面討論的都是從編譯到執行的完整過程,可是有時候用戶的代碼沒有變動,咱們去執行時就沒有必要再次去編譯了,所以能夠設計一個緩存策略,當用戶代碼沒有發生變動時,就使用懶加載策略,當用戶的代碼發生了變動就釋放以前加載好的 Class,從新加載新的代碼。

及時加載問題

當系統重啓時,至關於全部的類都被釋放了須要從新加載,對於一些比較重要的腳本,可能短暫的懶加載時間也是難以接受的,對於這種就須要單獨蒐集,在系統啓動的時候根據系統一塊兒加載進內存,這樣就能夠當健康檢查經過時,保證類已經加載好了,從而有效縮短響應時間。

後記

因爲篇幅問題,緩存問題、及時加載問題只作了簡單的討論。固然 Java 動態腳本技術還涉及到不少其餘細節,須要在使用過程當中不斷總結。也歡迎你們一塊兒交流~

相關文章
相關標籤/搜索