阿里妹导读:在平台级的 Java 系统中,动态脚本技术是不可或缺的一环。本文分享了一种 Java 动态脚本实现方案,给出了其中的关键技术点,并就类重名问题、生命周期、安全问题等做出进一步讨论,欢迎同学们共同交流。
前言
繁星是一个数据服务平台,其核心功能是:用户配置一段 SQL,繁星产出对应的 HSF/TR/SOA/Http 取数接口。
繁星引擎流程图如下:
一次查询请求经过引擎的管道,被各个阀门处理后就得到了相应的结果数据。图中高亮的两个阀门就是本文讨论的重点:前置脚本与后置脚本。
温馨提示:动态脚本就意味着代码发布跳过了公司内部发布平台,做不到监控、灰度、回滚三板斧,容易引发线上故障,因此业务系统中强烈不推荐使用该技术。
当然 Java 动态脚本技术一般使用场景也比较少,主要在平台性质的系统中可能用到,比如 leetcode 平台,D2 平台,繁星数据服务平台等。本文权当技术探索和交流。
功能描述
对 Javascript 熟悉的同学知道,eval() 函数,例如:
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 文件中,核心代码如下:
//项目所在路径
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");
}
//会得到结果: Hello,Kitty! 我是Cat。
用编程方式编译和加载
上面两种方式都有一个明显的缺点,就是需要依赖于 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 的类加载机制采用双亲委派模式,类加载器收到加载请求时,会委派自己的父加载器去执行加载任务,因此所有的加载任务都会传递到顶层的类加载器,只有当父加载器无法处理时,子加载器才自己去执行加载任务。下面这幅图相信大家已经很熟悉了。
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 的情况。
在上图中可以看到加载类的变化图以及堆大小呈锯齿状,说明动态加载类能够被有效的被回收。
安全问题
让用户写脚本,并且在服务器上运行,光是想想就知道是一件非常危险的事情,因此如何保证脚本的安全,是必须严肃对待的一个问题。
类的白名单及黑名单机制
在用户写的 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 动态脚本技术还涉及到很多其他细节,需要在使用过程中不断总结。也欢迎大家一起交流~