文章目录
- 认识
- Java支持脚本语言的意义
- Java对JavaScript的支持
- Rhino/Nashorn概述
- Nashorn的目的
- 实践操作
- HelloWorld
- 执行脚本文件代码
- 脚本语言使用Java的变量
- 执行脚本方法/函数
- 脚本语言使用Java的类对象
- 脚本语言实现Java的接口
- 脚本的多个作用域
- 脚本语言使用Java的数据类型
- 创建java对象实例
- 访问静态方法和属性
- JDK中测试脚本的工具
- 安全分析
- Nashorn与GraalVM的未来
- 项目实践案例参考
- 场景描述
- 【设计思考】
- 总结
- 参考文档
认识
Java支持脚本语言的意义
JDK 6.0 增加了对脚本语言的支持,原理上是通过将脚本语言编译成字节码(Byte Code)实现的。这样,脚本语言也能享用Java平台的诸多优势,包括可移植性,安全等。另一方面,由于编译成字节码后再执行,因此比原来边解释边执行的效率要高出很多。在加入对脚本语言的支持后,Java语言本身也享有了以下的好处:
【1】许多脚本语言都有动态特性。例如,不需要在使用一个变量之前先声明它、并且可以用一个变量存放完全不同类型的对象、不需要进行强制类型转换(因为转换都是自动进行的)。现在Java语言也可以通过对脚本语言的支持而间接获得这种灵活性。
【2】通过引入脚本语言可以轻松实现 Java 应用程序的扩展性和自定义。我们可以把原来分散在Java应用程序中的业务规则提取出来,转而用JavaScript来实现。
Java对JavaScript的支持
JDK 6.0包含了一个基于Mozilla Rhino的脚本语言引擎,以支持JavaScript。请注意,这并不说明JDK 6.0只支持JavaScript,任何第三方都可以自己实现一个JSR-223兼容的脚本引擎,使得JDK 6.0支持其他的脚本语言。例如,如果想让JDK支持Python,那么可以自己按照 JSR 223 的规范实现一个 Python 的脚本引擎类,也就是自己动手实现javax.script.ScriptEngine和javax.script.ScriptEngineFactory两个接口。Scripting API 是用于在 Java 程序中书写脚本语言程序的 API, Scripting API 包含在javax.script包中。这个包中含有一个ScriptEngineManager类,它是使用Scripting API的入口。
JSR 223中规范了在Java虚拟机上运行的脚本语言与Java程序之间的交互方式。JSR 233是JavaSE6的一部分,在Java表中API中的包是javax.script。目前Java虚拟机支持比较多的脚本语言,比较流行的有JavaScript、Scala、JRuby、Jython和Groovy等。
Rhino/Nashorn概述
Rhino和Nashorn都是用Java实现的JavaScript引擎。它们自身都是普通的Java程序,运行在JVM上。Rhino是在JDK 6.0设计开发的脚本语言引擎,Nashorn引擎是在Java8设计被用来替代Rhino引擎的。
Nashorn是一个完全重写的实现,努力实现了与Java简便交互、高性能和对JavaScript ECMA规范的精确一致性。Nashorn是达到百分之百完全符合规范要求的JavaScript实现,并且在大多数装载上比Rhino至少快20倍。
Nashorn采用完全编译的方法,但是对运行时中的编译器进行了优化,这样JavaScript源代码就不会在程序执行开始之前编译。这意味着无须专门为Nashorn编写,而JavaScript代码仍然可以很轻易地部署到平台上。
当我在JDK11的环境下运行javax.script包下的类时,IDE提示我Nashorn引擎将会在未来的版本中被移除。但是在目前JDK运行是不会有问题的,而且即使引擎被移除也不会影响到javax.script包下API的使用。
Nashorn的目的
在Java和JVM生态系统中,Nashorn有多种用途。首先,它为JavaScript开发者提供了一个可用的环境,用于探索JVM的功能。其次,它让企业继续利用对Java技术的现有投资,采用JavaScript作为一门开发语言。最后,它为HotSpot使用的先进虚拟机技术提供了一个很好的工程样板。
JavaScript不断发展,应用范围越来越宽,以前只能在浏览器中使用,而现在则能在更通用的计算和服务器端使用。Nashorn在稳固的Java现有生态系统和一波有前途的新技术之间架起了一座桥梁。
实践操作
HelloWorld
// 创建一个脚本执行引擎管理器
ScriptEngineManager factory = new ScriptEngineManager();
// 创建一个JavaScript引擎
ScriptEngine engine = factory.getEngineByName("nashorn");
// 执行JavaScript代码
engine.eval("print('Hello, World')");
执行脚本文件代码
/**
* 根据指定的JS文件执行JavaScript代码
*/
@Test
public void test2() throws Exception {
// 创建一个脚本执行引擎管理器
ScriptEngineManager factory = new ScriptEngineManager();
// 创建一个JavaScript引擎
ScriptEngine engine = factory.getEngineByName("nashorn");
// 根据指定的JS文件执行JavaScript代码
engine.eval(new java.io.FileReader("src/main/resources/test2.js"));
}
JS文件内容:
print(“执行JS脚本文件”)
脚本语言使用Java的变量
/**
* 脚本变量
*/
@Test
public void test3() throws Exception {
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn");
File f = new File("src/main/resources/test.txt");
// 将File对象作为变量公开给脚本
engine.put("file", f);
// 执行JAVA传入的对象方法以及脚本方法
engine.eval("print(file.getAbsolutePath())");
}
运行在脚本宿主机(Scripting Host)中的脚本语言,它的执行权限和能访问的设备范围是很有限的,往往要借助其他的手段(如ActiveX Control、Applet)才能访问磁盘文件,正是因为这个原因,在JavaScript语言中甚至没有定义文件对象类型。
上面示例中通过JAVA语言构建了一个File对象,然后将该File对象传递给了JS脚本引擎,然后在JS中就可以操作文件对象了。
执行脚本方法/函数
/**
* 执行脚本函数
*/
@Test
public void test4() throws Exception {
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn");
// 定义一个JavaScript方法
String script = "function hello(name) { print('Hello, ' + name); }";
// 执行脚本
engine.eval(script);
// 由ScriptEngines实现的接口,其方法允许调用以前已执行的脚本中的过程。
Invocable inv = (Invocable) engine;
// 调用全局函数,并传入参数
inv.invokeFunction("hello", "测试");
}
脚本引擎本身并不负责调用在脚本中定义的方法,而是将其转换成实现 javax.script. Invocable 接口的 inv 对象,由 inv 对象利用invokeFunction()方法来调用脚本方法。
invokeFunction()接受不定数量的入参,但第一个入参必须是方法名,后面的入参是将要传递给脚本方法的参数,均为 java.lang.Object 类型。JavaScript 是一种弱类型定义的语言,由脚本引擎将 java.lang.Object 类型的参数转换为脚本语言能接受的值。
脚本语言使用Java的类对象
JavaScript不仅能使用Java为之定义的变量,而且能够完整地引入Java的类包。凭借这一点,我们可以发现:JavaScript可以实现Java所能实现的全部功能。
示例:在JS脚本中获取数据库连接并查询打印数据,为了方面这里直接使用一个hutool的数据库连接工具,该工具使用不在赘述。
创建一个js脚本文件,编写如下代码
var DB = Java.type("cn.hutool.db.Db")
print(DB.use().findAll("sys_user"))
@Test
public void test1() throws Exception {
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn");
engine.eval(new java.io.FileReader("src/main/java/com/zy/core_java/script_jsr/js_file/demo1.js"));
}
执行测试,在JS中操作JAVA类对象,可以看到可以在JS脚本中正常操作获取数据。
脚本语言实现Java的接口
/**
* 通过脚本实现Java接口
*/
@Test
public void test5() throws Exception {
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn");
// JavaScript脚本代码
String script = "function run() { print('run called'); }";
// 执行脚本代码
engine.eval(script);
Invocable inv = (Invocable) engine;
// 通过Invocable实现接口
Runnable r = inv.getInterface(Runnable.class);
// Java创建一个线程并运行脚本实现的代码
Thread th = new Thread(r);
th.start();
th.join();
}
这里使用javax.script.Invocable 接口的另一个方法getInterface()来获取JavaScript定义的Interface,并将其转换为实现特定的Java Interface的对象。
javax.script.Invocable接口的getInterface()方法接收两个入参:第1个入参是从JavaScript中获取的变量(也就是待转换成特定Java Interface的对象),第2个入参是转换目标Interface的类名,本例将把obj对象转换成实现java.lang.Runnable接口的对象。注意:这种转换不是任意的,如果待转换的对象不具备目标Interface的方法,将返回null。
脚本的多个作用域
@Test
public void test6() throws Exception {
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn");
// 添加一个变量
engine.put("x", "hello");
// 执行脚本,输出:hello
engine.eval("print(x);");
// 构建一个不同的脚本上下文
ScriptContext newContext = new SimpleScriptContext();
newContext.setBindings(engine.createBindings(), ScriptContext.ENGINE_SCOPE);
Bindings engineScope = newContext.getBindings(ScriptContext.ENGINE_SCOPE);
// 在新的执行引擎作用域中添加与上面相同名称的变量
engineScope.put("x", "world");
// 执行脚本,注意这里指定新的执行作用域上下文
engine.eval("print(x);", newContext);
}
脚本语言使用Java的数据类型
通过ScriptEngine 我们可以在JavaScript脚本中声明创建Java语言的数据类型。
创建一个测试js文件
var arrayListType = Java.type("java.util.ArrayList")
var intType = Java.type("int")
var stringArrayType = Java.type("java.lang.String[]")
var int2DArrayType = Java.type("int[][]")
/**
* 访问和使用JAVA类
*/
@Test
public void test7()throws Exception{
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn");
engine.eval(new java.io.FileReader("src/main/resources/test7.js"));
}
在JavaScript脚本中声明创建Java语言的数据类型需要传入一个全限定名称路径。然后就可以基于这个类型创建实例对象。
如果是一个内部类话,需要按照如下的路径方式,以$符号间隔内部类
var ftype = Java.type(“java.awt.geom.Arc2D$Float”)
该类对应的全限定名称:java.awt.geom.Arc2D.Float
创建java对象实例
创建测试脚本文件test8.js,并写入如下代码
var ArrayListType = Java.type("java.util.ArrayList")
var arrayList = new ArrayListType
arrayList.add("a")
arrayList.add("b")
print(arrayList)
/**
* 访问和使用JAVA类
*/
@Test
public void test8()throws Exception{
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn");
engine.eval(new java.io.FileReader("src/main/resources/test8.js"));
}
可以看到输出结果是一个数组,也就是说在js脚本里面正常使用了JAVA数据类型ArrayList
访问静态方法和属性
我们通过Java.type(“java.util.ArrayList”)的方式就可以获取到一个JAVA类,上面通过new的方式创建了实例对象。除此之外,对于一些类静态方法和属性,我们可以直接通过Java.type的方法值去操作。
创建一个测试类,提供一个静态方法和静态属性
package com.zy.scriptengine;
public class Test9 {
public static final String name = "Alice";
public static String hello() {
return "hello " + name;
}
}
创建一个测试脚本文件test9.js,并写如下代码
var Test9 = Java.type("com.zy.scriptengine.Test9")
print(Test9.name)
print(Test9.hello())
/**
* 访问和使用JAVA静态方法和属性
*/
@Test
public void test9()throws Exception{
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn");
engine.eval(new java.io.FileReader("src/main/resources/test9.js"));
}
可以看到正常方法对应JAVA类的静态方法和静态属性了。
JDK中测试脚本的工具
JDK 6.0中有一个命令行工具——jrunscript,可以在JDK 6.0安装目录的bin子目录下找到这个命令行工具。jrunscript是一个脚本语言的解释程序,它独立于脚本语言,但默认是用JavaScript,我们可以用jrunscript来测试自己写的脚本语言是否正确。
这对于我们开发测试脚本文件比较适用,可以在编写完脚本后通过该工具进行验证,因为并不是所有的脚本语言语法都能够完全被JAVA执行引擎所兼容。
测试程序如下所示:print(JSON.parse(‘{“name”:“张三”}’).name),可以看到JS中常用的JSON解析函数可以运行使用。
若想在Nashorn中执行名为my.js的JavaScript文件,使用jrunscript命令即可:jrunscript my.js
除了Nashorn,jrunscript还能使用其他脚本引擎。如果需要使用其他引擎,可以通过-l选项指定:jrunscript -l nashorn my.js
安全分析
【1】通过ScriptEngineManager加载恶意代码。ScriptEngineManager有两个构造函数,其中一个构造函数的参数是ClassLoader类型。恶意攻击者可以使用ScriptEngineManager在实例化时会通过URLClassLoader去指定的位置加载一个恶意类。URLClassLoader在将恶意类加载到本地后会直接将其实例化,从而触发写在恶意类的构造函数中的恶意代码。
【2】代码注入攻击,ScriptEngineManager可以动态的加载脚本文件,而脚本文件是可以被存放在代码中,外部文件中,数据库中,配置中心等等。一方面安全检查工具无法对这些动态加载的代码进行侦查,另一方面恶意攻击者如果修改了脚本文件那么系统运行就会有很大安全隐患。
Nashorn与GraalVM的未来
备注:本段内容摘抄自《Java技术手册(原书第7版)》
2018年春天,甲骨文首次发布了GraalVM(https://github.com/oracle/graal) ,它是甲骨文实验室的一个研究项目,可能会用于替换掉当前Java运行时环境(HotSpot)。这项研究工作可以看作是几个独立但相互关联的项目,它是为HotSpot打造的新JIT编译器,也是一个新的、支持多语言的虚拟机。我们将JIT编译器称为Graal,将新VM称为GraalVM。
Graal使用新的JVM编译器接口(JVMCI,以JEP 243的形式提供)来接入HotSpot,但也可以独立使用,它是GraalVM的主要部分。Graal技术已经出现并随同Java 11发布,但是它仍然被认为不能完全适用于大多数生产场景用例。
Graal的一些功能可以看作是JSR 223(Java平台的脚本编写)的替代品,但是Graal方法比以前的HotSpot中类似的技术更进一步、更深入。该特性依赖于GraalVM和Graal SDK,Graal SDK是GraalVM默认类路径的一部分,但是应该作为依赖项被显式地包含在项目中。下面是一个简单的例子,我们只是从Java调用一个JavaScript函数:
自从Java 6引入了编写脚本的API,多语言支持功能的基本形态就存在了。随着Nashorn的到来,以及其中基于动态调用的JavaScript实现,多语言功能在Java 8中有了显著的提升。
GraalVM技术真正与众不同的是,在这个生态系统中显式地包含了一个SDK以及支持工具,用于支持多语言,并使这些语言作为共同平等和配合互动的公民在底层VM上运行。在运行于GraalVM的诸多语言中,Java只是其中之一(尽管它很重要)。
这一步的关键是一个名为Truffle的组件和一个简单的、不加修饰的VM,即能够执行JVM字节码的底层VM。Truffle提供了一个运行时和库,用于为非Java语言创建解释器。一旦这些解释器运行,Graal编译器将介入并将解释器编译成快速的机器代码。为了开箱即用,GraalVM附带了JVM字节码、JavaScript和LLVM支持,随着时间的推移还将添加其他语言。GraalVM方法意味着,例如,JS运行时可以调用另一个运行时的对象的方法,并进行无缝的类型转换(至少对于简单的情况可以做到)。JVM工程师们已经讨论了很长一段时间(至少10年),在语义和类型系统非常不同的语言之间具有可替换性的能力,并且随着GraalVM的到来,它朝着主流迈出了非常重要的一步。
GraalVM对Nashorn的意义在于,甲骨文已经宣布他们打算放弃Nashorn,并最终将其从Java的发行版中删除。预期的替代品是GraalVM版本的JavaScript,但目前还没有时间表,甲骨文承诺在替代品完全准备好之前不会删除Nashorn。
项目实践案例参考
场景描述
【1】项目中有一个数据同步的任务,其中主要的流程是将系统A的几十个字段映射为系统B的对应字段。
【2】这其中字段的名称可能不一致,数据类型可能不一致,部分字段可能需要进行特殊转换。每个业务的情况也是不一样的。
【3】此外,有一个非常重要的事项是这个功能需要做成一个可适配的,即如果有新的业务进来,那么最好能够不修改代码就可以完成对应业务数据转换。
【4】添加一个新的业务场景,最好可以不进行重新部署就完成功能上线。
【设计思考】
针对这个需求使用ScriptEngine来实现更多是基于该特性灵活性的优点。实现过程大致如下:
【1】设计一个满足字段转换的数据结构,考虑了这个数据同步的场景,设计出大致数据结构如下
{
srcName: "nameA",
targetName:"nameB",
method: function(sycData){
}
}
其中srcName是源系统的字段名称,targetName是目标系统的字段名称,method是转换方法,可选的参数,该方法的作用是满足一些需要特定转换才能完成映射的字段。方法的传参可以根据实际场景进行选择调整。
【2】在上面的入门实例中演示是在JAVA代码中构建JS脚本字符串,或者将脚本存储到一个JS文件中。但是当下并没有采取与之相同的方式,为了满足脚本执行以及业务扩展的灵活性,我们将js脚本存储到数据表中。
【3】存储到数据表中之后,我们通过编写一个简单的表单页面,并集成了一个简单的JS格式化代码高亮插件,然后将数据表的JS脚本通过这个表单进行操作和展示。当需要调整业务时在后台表单页面修改JS脚本即可完成实时的更新。
【4】针对脚本的执行需要编写一个JAVA执行方法,在这个方法里面需要根据实际情况适当的传入一个JAVA对象,日志对象等。对于被Spring管理的Bean也可以传入到JS脚本中,对于ORM对象也可以进行传入,JS中可以正常执行数据的增删改查。
总结
【1】对于多变的计算公式,应用程序扩展/定制:你可以“外部化”应用程序的各个部分,例如配置脚本、业务逻辑/规则和金融应用程序的数学表达式。
【2】对于规则引擎类似的功能实现,对于其中的业务流程,动态脚本使用也可以参考使用这种脚本语言特性。
【3】下图是Kettle软件工具的截图,可以看到下面有很多的脚本处理方式,Kettle就是Java编写的,我自己就使用过很多次其中的动态脚本的功能,那么对于这类场景来说,我们就可以去使用ScriptEngineManager去帮助我们实现。
【4】对于上面的项目实践参考案例而言,在一些业务流程相似并且需要不停机动态切换业务场景下,我们可以尝试着使用类似的解决方案去实现。
【5】使用脚本化编程可以帮助我们实现更加灵活的解决方式,但是我们需要注意使用过程的安全策略的处理,避免遭受恶意攻击。
【6】动态脚本和静态语言在处理数据类型和变量是有所不同的,这些需要我们在使用中特别注意,避免出现运行异常。
参考文档
【1】Java平台上JavaScript引擎—Rhino/Nashorn概述
https://www.zhoulujun.cn/html/webfront/browser/webkit/2020_0718_8520.html
【2】JEP 372: Remove the Nashorn JavaScript Engine
https://openjdk.org/jeps/372
【3】Java语言的动态性支持(一)ScriptEngineManager
https://blog.csdn.net/tbdp6411/article/details/46817211
【4】《征服RIA——基于JavaScript的Web客户端开发》
【5】Java Scripting Programmer’s Guide
http://cr.openjdk.java.net/~sundar/8015969/webrev.00/raw_files/new/docs/JavaScriptingProgrammersGuide.html