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 动态脚本技术还涉及到不少其余细节,须要在使用过程当中不断总结。也欢迎你们一块儿交流~

相关文章
相关标签/搜索