1、概览 {#1概览}
本年将带你了解原生镜像(Native Image)的相关知识,以及如何使用 Spring Boot 和 GraalVM 构建原生镜像应用。
本文使用的是 Spring Boot 3,但是在末尾会教你如何解决与 Spring Boot 2 的差异问题。
2、原生镜像 {#2原生镜像}
原生(本地)镜像是一种将 Java 代码构建为独立可执行文件的技术。该可执行文件包括应用程序类、其依赖项的类、运行时库类以及来自 JDK 的静态链接本地代码。JVM 被打包到原生镜像中,因此在目标系统上不需要任何 Java 运行环境,但构建产物依赖于平台。因此,需要为每个支持的目标系统进行一次构建,在使用 Docker 等容器技术时会更加简单,将容器构建为一个目标系统,可以部署到任何 Docker 运行时。
2.1、GraalVM 和 Native Image Builder {#21graalvm-和-native-image-builder}
通用递归应用和算法语言虚拟机(Graal VM)是一个高性能的 JDK 发行版,专为 Java 和其他 JVM 语言编写,同时支持 JavaScript、Ruby、Python 和其他几种语言。它提供了一个原生镜像生成器(Native Image builder),这是一个从 Java 应用中生成原生代码并将其与 VM 一起打包成独立可执行文件的工具。Spring Boot Maven 和 Gradle Plugin 除了少数 例外情况(Mockito 目前不支持原生测试),正式支持该工具。
2.2、两个特性 {#22两个特性}
在构建原生镜像时,会遇到两个典型特性。
Ahead-Of-Time(AOT)编译是将高级 Java 代码编译成本地可执行代码的过程。通常由 JVM 的即时编译器 (JIT) 在运行时进行编译,这样可以在执行应用程序时进行观察和优化。在 AOT 编译的情况下,这一优势就不复存在了。
通常,在进行 AOT(Ahead-of-Time)编译之前,可以选择进行一个单独的步骤,称为 AOT 处理,即从代码中收集元数据并提供给 AOT 编译器。将这两个步骤分开是有意义的,因为 AOT 处理可以是针对特定框架的,而 AOT 编译器更加通用。下面的图片给出了一个概览:
Java 平台的另一个特点是,只需将 JAR 放入类路径,就能在目标系统上进行扩展。通过启动时的反射和注解扫描,就能在应用中获得扩展行为。
不幸的是,这会减慢启动时间,而且不会带来任何好处,尤其是对于云原生应用,因为在云原生应用中,服务器运行时和 Java 基类都打包到了 JAR 中。因此,可以放弃这一功能,然后可以使用闭环优化(Closed World Optimization)来构建应用。
这两项特性都减少了运行时所需的工作量。
2.3、优势 {#23优势}
原生镜像具有各种优势,如即时启动和减少内存消耗。它们可以打包成轻量级的容器镜像,以实现更快、更高效的部署,并减少攻击面。
2.4、限制 {#24限制}
由于采用了 "闭环优化",在编写应用代码和使用框架时必须注意一些 限制。简而言之:
- 类初始化器可以在构建时执行,以实现更快的启动和更好的性能峰值。但必须意识到,这可能会破坏代码中的一些假设,例如,在加载文件时,该文件必须在构建时可用。
- 反射和动态代理在运行时成本很高,因此在 "闭环优化" 假设下,在构建时进行了优化。在构建时执行时,可以不受限制地在类初始化器中使用。任何其他用途都必须向 AOT 编译器公布,Native Image builder 会尝试通过执行静态代码分析来达到这一目的。如果分析失败,就必须通过 配置文件 等方式提供相关信息。
- 这同样适用于所有基于反射的技术,如 JNI 和序列化(Serialization)
- 此外,Native Image builder 还提供了自己的原生接口,比 JNI 简单得多,开销也更低。
- 对于原生镜像构建,字节码在运行时不再可用,因此无法使用针对 JVMTI 的工具进行调试和监控。因此,必须使用本地调试器和监控工具。
对于 Spring Boot,运行时不再完全支持 配置文件、条件 Bean 和 .enable
属性等功能。如果使用配置文件,则必须在构建时指定。
3、基本设置 {#3基本设置}
在构建原生镜像之前,必须先安装工具。
3.1、GraalVM 和 Native Image {#31graalvm-和-native-image}
首先,按照 安装说明 安装当前版本的 GraalVM 和 Native Image Builder(Spring Boot 需要 22.3 版)。确保安装目录可通过 GRAALVM_HOME
环境变量获取,并将 <GRAALVM_HOME>/bin
添加到 PATH
变量中。
3.2、Native 编译器 {#32native-编译器}
在构建过程中,Native Image builder 会调用特定于平台的本地编译器。因此,需要根据平台遵循 "先决条件" 说明 来获取这个本地编译器。这将使构建依赖于平台。必须意识到,只能在特定于平台的命令行中运行构建。例如,使用 Git Bash 在 Windows 上运行构建是行不通的。需要使用 Windows 命令行。
3.3、Docker {#33docker}
首先,必须安装 Docker,这是稍后运行原生镜像所必需的。Spring Boot Maven 和 Gradle 插件使用 Paketo Tiny Builder 构建容器。
4、使用 Spring Boot 配置和构建项目 {#4使用-spring-boot-配置和构建项目}
使用 Spring Boot 的原生构建功能非常简单。例如,使用 Spring Initializr 创建项目并添加应用代码。然后,使用 GraalVM 的 Native Image builder 构建原生镜像,需要使用 GraalVM 本身提供的 Maven 或 Gradle 插件扩展我们的构建。
4.1、Maven {#41maven}
Spring Boot Maven 插件 的 Goal (目标)包括 AOT 处理(即不是 AOT 编译本身,而是为 AOT 编译器收集元数据,例如在代码中注册反射的使用)和构建可通过 Docker 运行的 OCI 镜像。可以直接调用这些 Goal:
mvn spring-boot:process-aot
mvn spring-boot:process-test-aot
mvn spring-boot:build-image
我们不需要这样做,因为 Spring Boot Parent POM 定义了一个 native
配置文件,将这些 Goal 绑定到构建中。需要使用这个已激活的配置文件(Profile)进行构建:
mvn clean package -Pnative
如果还想执行本地测试,还可以激活第二个 Profile:
mvn clean package -Pnative,nativeTest
如果要构建原生镜像,就必须添加 native-maven-plugin
的相应 Goal。因此,也可以定义一个 native Profile。由于该插件由 parent POM 管理,因此可以不声明版本号:
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
目前,本地测试执行不支持 Mockito。因此,可以将此配置添加到 POM 中,从而排除 Mocking 测试或直接跳过本地测试:
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<skipNativeTests>true</skipNativeTests>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
4.2、在没有 Parent POM 的情况下使用 Spring Boot {#42在没有-parent-pom-的情况下使用-spring-boot}
如果不能从 Spring Boot Parent POM 继承,而是将其作为 import
scope 依赖,就必须自己配置插件和 Profile。然后,必须将其添加到 POM 中:
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${native-build-tools-plugin.version}</version>
<extensions>true</extensions>
</plugin>
</plugins>
</pluginManagement>
</build>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<builder>paketobuildpacks/builder:tiny</builder>
<env>
<BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
</env>
</image>
</configuration>
<executions>
<execution>
<id>process-aot</id>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<classesDirectory>${project.build.outputDirectory}</classesDirectory>
<metadataRepository>
<enabled>true</enabled>
</metadataRepository>
<requiredVersion>22.3</requiredVersion>
</configuration>
<executions>
<execution>
<id>add-reachability-metadata</id>
<goals>
<goal>add-reachability-metadata</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>nativeTest</id>
<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-test-aot</id>
<goals>
<goal>process-test-aot</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<classesDirectory>${project.build.outputDirectory}</classesDirectory>
<metadataRepository>
<enabled>true</enabled>
</metadataRepository>
<requiredVersion>22.3</requiredVersion>
</configuration>
<executions>
<execution>
<id>native-test</id>
<goals>
<goal>test</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<properties>
<native-build-tools-plugin.version>0.9.17</native-build-tools-plugin.version>
</properties>
4.3、Gradle {#43gradle}
Spring Boot Gradle 插件 为 AOT 处理(即不是 AOT 编译本身,而是为 AOT 编译器收集元数据,例如在代码中注册反射的使用)和构建可通过 Docker 运行的 OCI 镜像提供了 Task(任务):
gradle processAot
gradle processTestAot
gradle bootBuildImage
构建原生镜像,必须添加用于 构建 GraalVM 原生镜像的 Gradle 插件:
plugins {
// ...
id 'org.graalvm.buildtools.native' version '0.9.17'
}
然后,通过如下命令运行测试并构建项目。
gradle nativeTest
gradle nativeCompile
目前,本地测试执行不支持 Mockito。因此,可以通过如下配置 graalvmNative
扩展来排除 Mocking 测试或跳过本地测试:
graalvmNative {
testSupport = false
}
5、扩展原生镜像构建配置 {#5扩展原生镜像构建配置}
如前所述,必须为 AOT 编译器注册反射、类路径扫描、动态代理等每种用法。由于 Spring 的内置原生支持是一项非常新的功能,目前并非所有 Spring 模块都有内置支持,因此目前需要自己添加这一功能。这可以通过手动创建构建配置来实现。不过,使用 Spring Boot 提供的接口会更方便,这样 Maven 和 Gradle Plugins 都能在 AOT 处理过程中使用我们的代码来生成构建配置。
原生提示(Native Hints)是一种指定额外原生配置的方法。
让我们看两个当前缺少内置支持的示例,以及如何将其添加到我们的应用中使其正常工作。
5.1、示例:Jackson 的 PropertyNamingStrategy
{#51示例jackson-的-propertynamingstrategy}
在 MVC Web 应用中,Jackson 会将 REST Controller 方法的每个返回值序列化,并将每个属性自动命名为一个 JSON 元素。我们可以通过在应用 application properties 文件中配置 Jackson 的 PropertyNamingStrategy
来影响全局名称映射:
spring.jacksonproperty-naming-strategy=SNAKE_CASE
SNAKE_CASE
是 PropertyNamingStrategies
类型中一个静态成员的名称。不幸的是,该成员是通过反射解析的。因此,AOT 编译器需要知道这一点,否则会抛出异常:
Caused by: java.lang.IllegalArgumentException: Constant named 'SNAKE_CASE' not found
at org.springframework.util.Assert.notNull(Assert.java:219) ~[na:na]
at org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
$Jackson2ObjectMapperBuilderCustomizerConfiguration
$StandardJackson2ObjectMapperBuilderCustomizer.configurePropertyNamingStrategyField(JacksonAutoConfiguration.java:287) ~[spring-features.exe:na]
为了实现这一点,可以通过以下简单的方法实现并注册 RuntimeHintsRegistrar
:
@Configuration
@ImportRuntimeHints(JacksonRuntimeHints.PropertyNamingStrategyRegistrar.class)
public class JacksonRuntimeHints {
static class PropertyNamingStrategyRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
try {
hints
.reflection()
.registerField(PropertyNamingStrategies.class.getDeclaredField("SNAKE_CASE"));
} catch (NoSuchFieldException e) {
// ...
}
}
}
}
注:自 3.0.0-RC2 版起,在 Spring Boot 中解决此问题的 Pull Request 求已被合并,因此开箱即可在 Spring Boot 3 中使用。
5.2、示例:GraphQL Schema 文件 {#52示例graphql-schema-文件}
如果想实现 GraphQL API,需要创建一个 Schema 文件,并将其放置在 classpath:/graphql/*.graphqls
下,这样它就可以被 Spring 的 GraphQL 自动配置自动检测到。这是通过 classpath 扫描完成的,同时也会在集成的 GraphiQL 测试客户端的欢迎页面上体现出来。因此,为了在本地可执行文件中正确工作,AOT 编译器需要知道这一点。
可以用同样的方法进行注册:
@ImportRuntimeHints(GraphQlRuntimeHints.GraphQlResourcesRegistrar.class)
@Configuration
public class GraphQlRuntimeHints {
static class GraphQlResourcesRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources()
.registerPattern("graphql/**/")
.registerPattern("graphiql/index.html");
}
}
}
Spring GraphQL 团队已经开始着手 这项工作,因此可能会在未来的版本中内置这项功能。
6、编写测试 {#6编写测试}
要测试 RuntimeHintsRegistrar
的实现,甚至不需要运行 Spring Boot 测试,而是创建一个简单的 JUnit 测试,如下:
@Test
void shouldRegisterSnakeCasePropertyNamingStrategy() {
// arrange
final var hints = new RuntimeHints();
final var expectSnakeCaseHint = RuntimeHintsPredicates
.reflection()
.onField(PropertyNamingStrategies.class, "SNAKE_CASE");
// act
new JacksonRuntimeHints.PropertyNamingStrategyRegistrar()
.registerHints(hints, getClass().getClassLoader());
// assert
assertThat(expectSnakeCaseHint).accepts(hints);
}
如果要通过集成测试进行测试,可以检查 Jackson ObjectMapper
的配置是否正确:
@SpringBootTest
class JacksonAutoConfigurationIntegrationTest {
@Autowired
ObjectMapper mapper;
@Test
void shouldUseSnakeCasePropertyNamingStrategy() {
assertThat(mapper.getPropertyNamingStrategy())
.isSameAs(PropertyNamingStrategies.SNAKE_CASE);
}
}
要使用本地模式进行测试,必须运行 nativeTest
:
# Maven
mvn clean package -Pnative,nativeTest
# Gradle
gradle nativeTest
如果需要为 Spring Boot 测试提供特定于测试的 AOT 支持,可以实现一个 TestRuntimeHintsRegistrar
或使用 AotTestExecutionListener
接口实现一个 TestExecutionListener
。详情请参见 官方文档。
7、Spring Boot 2 {#7spring-boot-2}
Spring 6 和 Spring Boot 3 在原生镜像构建方面迈出了一大步。但在之前的主要版本中,这也是可能的。只需知道,目前还没有内置支持,也就是说,有一个 Spring Native Initiative 计划处理此主题。因此,必须在项目中手动加入和配置。对于 AOT 处理,有一个单独的 Maven 和 Gradle 插件,它并没有合并到 Spring Boot 插件中。当然,集成库也没有像现在这样提供原生支持(将来会更多)。
7.1、Spring Native 依赖 {#71spring-native-依赖}
添加 spring-native
依赖:
<dependency>
<groupId>org.springframework.experimental</groupId>
<artifactId>spring-native</artifactId>
<version>0.12.1</version>
</dependency>
对于 Gradle 项目,Spring AOT 插件会自动添加 Spring Native。
注意,每个 Spring Native 版本只支持特定的 Spring Boot 版本,例如,Spring Native 0.12.1 只支持 Spring Boot 2.7.1。因此,应确保在 pom.xml
中使用兼容的 Spring Boot Maven 依赖。
7.2、构建 {#72构建}
要构建 OCI 镜像,需要明确配置构建包(build pack)。
使用 Maven 时,需要使用 spring-boot-maven-plugin 和 Paketo Java buildpacks 进行原生镜像配置:
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<builder>paketobuildpacks/builder:tiny</builder>
<env>
<BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
</env>
</image>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
使用各种可用的 Builder 中的 tiny
Builder (如 base
和 full
)来构建原生镜像。此外,通过将 BP_NATIVE_IMAGE
环境变量设置为 true
来启用 buildpack。
同样,在使用 Gradle 时,可以在 build.gradle
文件中添加 tiny
builder 和 BP_NATIVE_IMAGE
环境变量:
bootBuildImage {
builder = "paketobuildpacks/builder:tiny"
environment = [
"BP_NATIVE_IMAGE" : "true"
]
}
7.3、Spring AOT Plugin {#73spring-aot-plugin}
接下来,需要添加 Spring AOT 插件,它可以执行 AOT 转换,有助于改善原生镜像的占用空间和兼容性。
在 pom.xml
中添加 spring-aot-maven-plugin Maven 依赖:
<plugin>
<groupId>org.springframework.experimental</groupId>
<artifactId>spring-aot-maven-plugin</artifactId>
<version>0.12.1</version>
<executions>
<execution>
<id>generate</id>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
</plugin>
同样,对于 Gradle 项目,可以在 build.gradle
文件中添加最新的 org.springframework.experimental.aot
依赖:
plugins {
id 'org.springframework.experimental.aot' version '0.10.0'
}
此外,如前所述,这将自动在 Gradle 项目中添加 Spring Native 依赖。
Spring AOT 插件提供了 几种确定源生成的选项 例如,removeYamlSupport
和 removeJmxSupport
等选项可分别移除 Spring Boot Yaml 和 Spring Boot JMX 支持。
7.4、构建并运行镜像 {#74构建并运行镜像}
使用 Maven 命令来构建和运行 Spring Boot 项目的原生镜像。
$ mvn spring-boot:build-image
7.5、原生镜像构建 {#75原生镜像构建}
接下来,添加一个名为 native
的配置文件(Profile),该配置文件支持一些插件的构建,如 native-maven-plugin
和 spring-boot-maven-plugin
:
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.9.17</version>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>build</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<classifier>exec</classifier>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
该 Profile 将在打包阶段调用构建中的 native-image
编译器。
不过,在使用 Gradle 时,需要在 build.gradle
文件中添加最新的 org.graalvm.buildtools.native
插件:
plugins {
id 'org.graalvm.buildtools.native' version '0.9.17'
}
就这样!通过在 Maven package
命令中提供 native Profile,就可以构建原生镜像了:
mvn clean package -Pnative
8、总结 {#8总结}
本文介绍了如何使用 Spring Boot 和 GraalVM 的原生构建工具来构建原生镜像。
Ref:https://www.baeldung.com/spring-native-intro