要说 JVM 的未来那有很多的可能,但在云原生如日中天、Serverless 日渐成熟、新语言百花齐放的当下,跨语言、Native 支持、高性能低资源占用的技术必定是其璀璨的明珠,而 GraalVM 正是这样一个承载了 JVM 未来,将 Java 带入下一波技术浪潮的弄潮儿,本文我们就来实践下 GraalVM 集成支持。
Java 的问题
在讲 GraalVM 前我们先回看下 Java 当前遇到的问题,概括而言如下:
-
云时代的掉队者,由于 Java 启动的高延时、对资源的高占用、导致在 Serverless 及 FaaS 架构下力不从心,在越来越流行的边缘计算、IoT 方向上也是难觅踪影
-
系统级应用开发的旁观者,Java 语言在业务服务开发中孤独求败,但在系统级应用领域几乎是 C、C++、搅局者 Go、黑天鹅 Rust 的天下
-
移动应用、敏捷应用的追随者,移动应用中 Android 逐步去 Java,前端又是 JS 的世界,敏捷开发方面前有 Ruby、Python 后有 NodeJS
有人说 Java 吃老本,不思进取,也对也不对吧,毕竟 Java 作为企业级软件开发最主流的语言,兼容稳定远胜于创新求变,所以 Java 很苦恼,即便是看似激进的 JDK 版本策略也敌不过臃肿守旧的印象。
怎么办?要兼容稳定,那么别打语法、API、字节码创新的心思,Java 本身就那样了,但它背后的 JVM 却有更多的选择。Java 的问题可以让 JVM 来补救,说资源占用高那先来个 JPMS 模块化(但目前看貌似并不成功),说启动延迟大那咱支持 AoT 搞 Native 吧,说对系统级应用、移动应用、敏捷应用支持不好那你行你上,我把你们都包进来纳入到我 JVM 大生态中,这就是 GraalVM 正在做的。
GraalVM 简述
GraalVM 是一个新的 JVM,原本用于替换 HotSpot 的 C2 编译器,后来独立成 JVM 的一个产品,它很新但架不住对 Native Image、多语言集成、高性能特性的诱惑,就连“后知后觉”的 Spring 也着手相关的支持工作,而新新的框架诸如 quarkus、micronaut 都已提供了比较好的支持。
但是问题来了,你说得这么好,为什么不见人用,国内外找了一圈都是些介绍性的文章?原因嘛,因为 GraalVM 要解决的问题有很多,现有的应用、框架都需要一定的改造。前面扯了这么多,接下来才是本文的重点:以实例切入带各位体验下 GraalVM 的集成改造。
实例:Dew-Common GraalVM 集成
Dew-Common( https://github.com/gudaoxuri/dew-common )是笔者开源的一个 Java 基础工具包,包含了 Json、Bean(反射)、Package Scan、JS 交互、Shell 调用等常用操作的支持,拿这个工具包做 GraalVM 的集成可以比较全面的检验集成的效果。
前置准备
安装 GraalVM 及相关的依赖,GraalVM 支持 Linux、Windows 及 MacOS,但一般推荐在 Linux 下操作。笔者使用的是 Windows,Windows 10 2004 版本的 WSL2 提供了完整的 Linux 内核,非常适合开发调试(Windows 是最好的 Linux 发行版本😂)。
# https://github.com/graalvm/graalvm-ce-builds/releases 下载需要的版本
# 解压,设置PATH/JAVA_HOME
# 添加多语言环境(可选,gu是GraalVM Updater的命令)
# 没有JS?因为JS内置了,GraalVM带了Node环境,支持各类常用的NPM包!
gu install ruby
gu install r
gu install python
gu install wasm
# 添加Native Image(可选,一般都会安装,这是GraalVM的一大亮点)
gu install native-image
gu install llvm-toolchain
Note:详见 https://www.graalvm.org/getting-started/#install-graalvm
POM 改造
<dependencies>
<dependency> (1)
<groupId>org.graalvm.sdk</groupId>
<artifactId>graal-sdk</artifactId>
<version>${graalvm.version}</version>
<scope>provided</scope>
</dependency>
<!-- HotSpot 兼容处理 --> (2)
<dependency>
<groupId>org.graalvm.truffle</groupId>
<artifactId>truffle-api</artifactId>
<version>${graalvm.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.graalvm.js</groupId>
<artifactId>js</artifactId>
<version>${graalvm.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.graalvm.js</groupId>
<artifactId>js-scriptengine</artifactId>
<version>${graalvm.version}</version>
<scope>runtime</scope>
</dependency>
</dependencies>
...
<profiles>
<profile>
<id>native</id> (3)
<dependencies>
<!-- 去除 HotSpot 兼容处理 --> (4)
<dependency>
<groupId>org.graalvm.truffle</groupId>
<artifactId>truffle-api</artifactId>
<version>${graalvm.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.graalvm.js</groupId>
<artifactId>js</artifactId>
<version>${graalvm.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.graalvm.js</groupId>
<artifactId>js-scriptengine</artifactId>
<version>${graalvm.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> (5)
<configuration>
<argLine>
-agentlib:native-image-agent=access-filter-file=${project.basedir}/src/main/resources/META-INF/native-image/com.ecfront.dew/common/agent-access-filter.json,config-output-dir=${project.basedir}/src/main/resources/META-INF/native-image/com.ecfront.dew/common/
</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.graalvm.nativeimage</groupId>
<artifactId>native-image-maven-plugin</artifactId> (6)
<version>${graalvm.version}</version>
<executions>
<execution>
<goals>
<goal>native-image</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<skip>false</skip>
<mainClass>${mainClass}</mainClass>
<imageName>${imageName}</imageName>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
-
一般情况下我们不需要引入额外的依赖,但如果需要执行跨语言操作就必须引入
graal-sdk
依赖,该依赖提供了 GraalVM 特有的语法 API,注意 scope 为 provided,即它只作用于编译、测试阶段,运行时不需要 -
下面的几个包是用于跨语言操作的兼容处理,在 GraalVM 环境不需要,但在 HotSpot 必须引入
-
使用特定的 profile 执行 Native Image 打包操作
-
Native Image 由 GraalVM 的 SubstrateVM(定制轻量 VM)运行,不需要第 2 步引入的兼容依赖,所以这里做了排除
-
调用
mvn test
附加执行参数,用于 Native Image 动态调用的代码收集,后文会细讲 -
Native Image 打包的核心插件,这里需要指定 main 方法,可指定镜像的名称
Tip:GraalVM 没有集成 javax
包,所以如果需要诸如 validation 注解则需要手工引入 jakarta.validation-api
依赖
小结如下:
-
只是将程序运行在 GraalVM 下,那么只要把 GraalVM 缺失的依赖(如上面说的
javax
包)引入即可 -
要做跨语言操作,那么完成第 1、2 步骤即可
-
要支持 Native Image 则必须完成后续的步骤
跨语言调用
JSR 223 规范下脚本调用
private static final ScriptEngineManager SCRIPT_ENGINE_MANAGER = new ScriptEngineManager(); (1)
private Invocable invocable;
private ScriptHelper(Invocable invocable) {
this.invocable = invocable;
}
/**
* Build script helper.
*
* @param jsFunsCode the js funs code
* @param addCommonCode the add common code
* @return the script helper
* @throws RTScriptException the rt script exception
*/
public static ScriptHelper build(String jsFunsCode, boolean addCommonCode) throwsRTScriptException {
Compilable jsEngine = (Compilable) SCRIPT_ENGINE_MANAGER.getEngineByName("nashorn");
if (addCommonCode) {
jsFunsCode = "var $ = Java.type('com.ecfront.dew.common.$');\r\n" + jsFunsCode; (2)
}
try {
CompiledScript script = jsEngine.compile(jsFunsCode);
script.eval();
return new ScriptHelper((Invocable) script.getEngine());
} catch (ScriptException e) {
throw new RTScriptException(e);
}
}
/**
* Execute.
*
* @param <T> the type parameter
* @param jsFunName the js fun name
* @param args the args
* @return the t
* @throws RTScriptException the rt script exception
* @throws RTReflectiveOperationException the rt reflective operation exception
*/
public <T> T execute(String jsFunName, Object... args) throws RTScriptException, RTReflectiveOperationException {
try {
return (T) invocable.invokeFunction(jsFunName, args);
} catch (ScriptException e) {
throw new RTScriptException(e);
} catch (NoSuchMethodException e) {
throw new RTReflectiveOperationException(e);
}
}
-
用 ScriptEngineManager 定义脚本引擎管理器
-
添加对 Java 方法的调用支持
上面是 JSR 223 规范下的使用方式,使用了 nashorn
引擎,但在 JDK11 下已经标记过时,后期会移除,为什么移除?自然是因为了有 GraalVM,Java 官方也推荐使用 GraalVM 运行脚本。
GraalVM 下的脚本调用
private final Context context;
private final ScriptKind scriptKind;
/**
* Build script helper.
*
* @param scriptKind the script kind
* @param scriptFunsCode the script funs code
* @param addCommonCode the add common code
* @return the script helper
* @throws RTScriptException the rt script exception
*/
public static ScriptHelper build(ScriptKind scriptKind, String scriptFunsCode, boolean addCommonCode) throws RTScriptException {
try {
Context context = Context.newBuilder().allowAllAccess(true).build();
if (addCommonCode) {
switch (scriptKind) {
case JS:
scriptFunsCode = "const $ = Java.type('com.ecfront.dew.common.$')\r\n" + scriptFunsCode;
break;
case PYTHON:
// ...
default:
throw new RTScriptException("Script kind {" + scriptKind.toString() + "} NOT exist.");
}
}
context.eval(Source.newBuilder(scriptKind.toString(), scriptFunsCode, "src.js").build());
return new ScriptHelper(context, scriptKind);
} catch (IOException e) {
throw new RTScriptException(e);
}
}
/**
* Execute.
*
* @param <T> the type parameter
* @param funName the fun name
* @param returnClazz the return clazz
* @param args the args
* @return the t
*/
public <T> T execute(String funName, Class<T> returnClazz, Object... args) {
return context.getBindings(scriptKind.toString()).getMember(funName).execute(args).as(returnClazz);
}
在语法层面变动比较大,但套路类似。
Note:Dew-Common 相关代码详见 https://github.com/gudaoxuri/dew-common/blob/master/src/main/java/com/ecfront/dew/common/ScriptHelper.java
Note:GraalVM Polyglot 详见 https://www.graalvm.org/docs/reference-manual/polyglot/
Classpath 相关
当我们打包成 Native Image 时 GraalVM 内置的 SubstrateVM 对 Classpath 相关的操作需要注意,这里举几个例子:
# 正常应该是当前的classpath,但输出为null
ClassLoader.getSystemResource("") > null
# 对于Jar包外打印,正常应该为当前的classpath,Native Image与Jar内打印一样,输出为空
new File("").getPath() >
# XX为某些Class
# 对于Jar包外打印,正常应该为当前的classpath
# 对于jar包内打印,正常应该是当前的Jar路径,但输出的是Native Image文件路径
XX.class.getProtectionDomain().getCodeSource().getLocation().getPath() > /mnt/c/Users/i/OneDrive/workspaces/1.personal/dew/dew-common/it/target/NativeImageTest
# 正常应该是当前的classpath,但输出为null
Thread.currentThread().getContextClassLoader().getResource("") > null
我们还需要注意在 Native Image 中好像没有 package 的概念( https://github.com/oracle/graal/issues/1108 ),导致我们无法对“jar 包”做遍历,如 https://github.com/gudaoxuri/dew-common/blob/master/src/main/java/com/ecfront/dew/common/ClassScanHelper.java 下的 scan 就无法实现。
反射处理
看过 GraalVM 介绍的话大家都应该知道 Native Image 是基于静态代码可达分析,而对于反射方法的操作是无法自动发现的。这个影响很大,比如我们常用的 BeanCopy、Json 与 Java 对象的互转、动态代理等会有不同程度的限制。
这些动态调用需要我们来告诉 GraalVM,GraalVM 为我们提供了一个 agent 用于运行期自动收集相关的数据,收集时要确保所有动态调用都被执行到。
下面以 Dew-Common 为例子说明下如何操作:
-
所有相关的代码都写成单元测试
-
配置 Native Image 到/src/main/resources/META-INF/native-image/com.ecfront.dew/common/native-image.properties(InfoQ 显示有问题,详见:https://www.idealworld.group/2020/06/12/getting-started-with-graalvm/)
-
配置 Agent 的过滤器到/src/main/resources/META-INF/native-image/com.ecfront.dew/common/agent-access-filter.json(InfoQ 显示有问题,详见:https://www.idealworld.group/2020/06/12/getting-started-with-graalvm/)
-
为单元测试添加参数,更完整的见
POM改造
章节 -
运行单元测试
上面的操作会在调用 mvn test -P native
后会把单元测试收集的包含反射、代理等动态操作写入 config-output-dir 指定的目录下。
这样我们可以配置 native-image-maven-plugin
(见 POM 改造章节) , 该插件默认会去 META-INF/native-image/<groupId>/<artifactId>/
找对应的 Native Image 配置及 Agent 收集信息,调用该插件 mvn package -P native
完成 Native Image 打包。
测试
经过上述操作,只要单元测试覆盖全面那么 Native Image 应该就可以正常工作了,但作为类库,我们还需要有集成测试以确保符合我们的预期。相关的操作可参见 Dew-Common it
目录下的测试工程。
总结
本文简单地介绍了 GraalVM 的使用,但 GraalVM 的 Native Image 目前并不完善,比如对 Spring 的支持还很有限,Spring 有对应的 spring-graalvm-native
( https://github.com/spring-projects-experimental/spring-graalvm-native )工程,该工程还没有 Release,问题很多。不过在今年晚些时候应该可以 Ready,届时我们再一起体现下 Spring Native 的魅力。