造你自己的 GraalVM Native Image 命令行应用

Posted on Nov 18, 2023

写在前面

云原生声量最大的一段时间里 Java 时常被人诟病应用启动速度太慢了,而且占用内存也很大,由于云原生应用目标包括快速启动、快速响应、快速扩容、快速收缩,因此 Java 一直被认为不适合云原生应用。但是,随着 GraalVM 的曝光,这个问题得到了部分解决,GraalVM 可以将 Java 应用编译成本地可执行文件,它被称为 Native Image,这样就不需要安装 Java 运行时(JRE)了,直接运行即可。

最早体验到 Native Image 是通过 QuarkusPicocli 开发一些命令行应用(CLI App),个人感觉针对不同的平台打包出不同的二进制文件的编译和部署成本比 Go/Rust/Node.js 语言都高,Native Image 的限制也比较多,比如反射、动态代理、动态类加载等,不得不以声明式告诉 GraalVM 哪些类有哪些动态行为。后来 Spring Boot 也支持了 Native Image,那些老毛病健在,只不过生态规模更大罢了。

GraalVM 能显著提升 Java 应用程序的性能还是挺可信的,比如 GraalVM for JDK 21 is here!Migrating 10MinuteMail from Java to GraalVM Native,尤其相较于传统 JVM 应用的这两个指标:

  • 启动时间 startup time
  • 单位时间与内存的吞吐量 requests/GB-s

先决条件

  • 安装 Java 21 或更高版本,现在挺流行使用 SDKMAN! 安装和管理多版本 JDK,我习惯在 Download Azul JDKs 下载 OpenJDK。
    • 在 Windows 平台,在 WSL 之外,也可以只通过类似于 alias 的别名动态切换 JDK 版本,比如编辑 PowerShell 的 $profile 指向的文件:
    # reload env path
    Function Refresh {
        $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
    }
    
    Function SetJdk($version) {
        $env:JAVA_HOME = "C:\Program Files\Zulu\zulu-$version"
        Refresh
    }
    
    Function Jdk8 {
        SetJdk 8
    }
    
    Function Jdk21 {
        $env:GRAALVM_HOME = "C:\Program Files\Java\graalvm-community-openjdk-21.0.1+12.1"
        SetJdk 21 
    }
    
    Set-Alias -Name j8 -Value Jdk8
    Set-Alias -Name j21 -Value Jdk21
    
  • 安装 GraalVM for JDK 21 或更高版本,个人通常在 GraalVM Community Edition 下载适用于各 Java 版本的社区版。
    • 若在 Windows 平台,还需要下载 Visual Studio,并在安装时勾选 Desktop development with C++ 等组件,详情参考 Installation on Windows Platforms
    • 一般来说,使用默认的安装路径在编译时可以绕过不少坑(Error: Failed to find xxx in a Visual Studio installation),不得不迁移 Visual Studio 到其它盘时,建议在命令提示符使用 mklink 创建符号链接,比如:
    mklink /d "C:\Program Files\Microsoft Visual Studio" "D:\Program Files\Microsoft Visual Studio"
    
  • 安装 MavenGradle

Quarkus + Picocli

与 Spring Boot 的 Spring Initializr 类似,Quarkus 也提供了生成快速开始项目的在线工具 code.quarkus.io,可以搜索选择依赖生成你的初始代码。为什么首选 picocli 与 quarkus 的整合包 quarkus-picocli?而不是单独添加它?因为它们的整合包已经帮你解决了构建 Native Image 时的一些问题,比如 java.lang.NoClassDefFoundError: …,详情参考 quarkus-picocli # Native Image

@Command(
        name = "<your_command_name>", description = "<your_command_descripiton>",
        mixinStandardHelpOptions = true, version = "<your_command_version>"
)
public class App implements Runnable {
    // your command options and parameters

    public static void main(String[] args) {
        var app = new App();
        var cli = new CommandLine(app);
        int code = cli.execute(args);
        System.exit(code);
    }

    @Override
    public void run() {
        // your command logic
    }
}

这是我最常用的命令行应用的模板之一,它只需要一个源文件 App.java,其中的方法 main 既是 Quarkus 应用程序也是 Picocli 命令行应用的入口,它的参数 args 会被传递给 Picocli 的 CommandLine 对象,而 CommandLine 对象会解析 args 并调用 App 对象的 run 方法,这样就可以在 run 方法中编写命令行应用的逻辑了。

.
├── mvnw
├── mvnw.cmd
├── pom.xml
├── README.md
└── src
    ├── main
    │   ├── docker
    │   │   ├── Dockerfile.jvm
    │   │   ├── Dockerfile.legacy-jar
    │   │   ├── Dockerfile.native
    │   │   └── Dockerfile.native-micro
    │   ├── java
    │   │   └── dev
    │   │       └── example
    │   │           └── App.java
    │   └── resources
    │       └── application.properties
    └── test
        └── java
            └── dev
                └── example

为了使程序启动时看起来更像 CLI App,可以在 application.properties 添加:

quarkus.banner.enabled=false
quarkus.package.output-name=your-
quarkus.package.runner-suffix=cli

从生成的项目代码不难发现 README.md 已经提示了如何构建 Native Image,此处不使用 Maven Wrapper,而是直接使用自定义安装并配置好环境变量的 Maven 工具:

mvn package -Dnative

以 Windows 为例,构建成功(BUILD SUCCESS)后,可以在 target 目录下看到生成的二进制文件 your-cli.exe,在不同命令行 Shell 上前缀有所不同:

  • 若在 PowerShell 则输入 .\your-cli.exe ... 运行
  • 若在命令提示符(cmd.exe)则输入 cmd /k your-cli.exe ... 运行

遇到最多的是反射问题,以下是官方给出的理由:

在构建本地可执行文件时,GraalVM 基于封闭世界的假设进行操作。它分析调用树并删除所有未直接使用的类/方法/字段。通过反射使用的元素不是调用树的一部分,因此它们会被死代码消除(在其他情况下,如果未直接调用)。要在本地可执行文件中包含这些元素,您需要显式地为反射注册它们。

对于依赖反射来序列化/反序列化的类,可使用 @RegisterForReflection 注解来注册反射类:

@RegisterForReflection
record MyRecord(String id, String name) {}

详情参考 Registering for reflection

Spring Boot + Picocli

Spring Initializr 生成包含依赖 picocli-spring-boot-starter 的项目代码,我们需要两个源文件 App.java 和 Cmd.java,Spring Boot 的命令行应用的入口类需要实现接口 CommandLineRunnerExitCodeGenerator,其中 CommandLineRunner 的 run 方法会被调用,而 ExitCodeGenerator 的 getExitCode 方法会返回退出码,这样就可以在 main 方法中调用 SpringApplication.exit 方法来退出程序了。

@Component
@SpringBootApplication
public class App implements CommandLineRunner, ExitCodeGenerator {
    private final Cmd cmd;
    private final IFactory factory;
    private int exitCode;

    public App(Cmd cmd, IFactory factory) {
        this.cmd = cmd;
        this.factory = factory;
    }

    @Override
    public void run(String... args) throws Exception {
        exitCode = new CommandLine(cmd, factory).execute(args);
    }

    @Override
    public int getExitCode() {
        return exitCode;
    }

    public static void main(String[] args) {
        System.exit(SpringApplication.exit(SpringApplication.run(App.class, args)));
    }
}

具体业务逻辑在 Cmd.java 中实现,它需要实现接口 Callable<Integer>,其中 call 方法会被调用,返回值为退出码。

@Component
@Command(
        name = "<your_command_name>", description = "<your_command_descripiton>",
        mixinStandardHelpOptions = true, version = "<your_command_version>"
)
public class Cmd implements Callable<Integer> {
    // your command options and parameters

    @Override
    public Integer call() throws Exception {
        // your command logic
        return 0;
    }
}

类似地,在 application.properties 设置 spring.main.banner-mode=off,然后在 HELP.md 可以发现如何构建 Native Image:

mvn native:compile -Pnative

看起来编译时好好的,运行时却报错:

  • java.lang.IllegalStateException: Failed to execute CommandLineRunner

解决方案是新增依赖 Picocli Code Generation,它是一个可用于生成构建 GraalVM Native Image 的 Picocli 命令所需元数据的注解处理器,以 Maven 为例:

<dependency>
    <groupId>info.picocli</groupId>
    <artifactId>picocli-codegen</artifactId>
    <version>4.7.5</version>
    <scope>provided</scope>
</dependency>

由于使用了 HTTPS,不料遭遇了异常:

  • java.net.MalformedURLException: Accessing an URL protocol that was not enabled. The URL protocol HTTPS is supported but not enabled by default. It must be enabled by adding the –enable-url-protocols=https option to the native-image command.

于是在 native-maven-plugin 添加构建参数:

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
    <configuration>
        <buildArgs>
            <arg>--enable-url-protocols=https</arg>
        </buildArgs>
    </configuration>
</plugin>

还以万事大吉了,没想到有些用例在运行 jar 时没问题,而在运行 native image 时却异常,通过异常堆栈追踪源码仍然不好确定缺了哪些配置项或元数据,最终从 How to register method for runtime reflection with GraalVM? 得到启发,通过 native-image-agent 来修复,它是一个 Java Agent,可以在运行 jar 时生成元数据,然后在构建 native 时使用它们:

src/main/resources/META-INF/native-image/
├── agent-extracted-predefined-classes
├── jni-config.json
├── predefined-classes-config.json
├── proxy-config.json
├── reflect-config.json
├── resource-config.json
└── serialization-config.json

操作步骤如下所示:

  1. 运行 mvn -Pnative native:compile (编译通过后,假设在目录 target 下可执行文件和 jar 分别为 your-app 和 your-app.jar)

  2. 新建目录 src/main/resources/META-INF/native-image 后运行 java -Dspring.aot.enabled=true -agentlib:native-image-agent=config-output-dir=./src/main/resources/META-INF/native-image -jar target/your-app.jar (若在 Windows 则注意路径写法,以及使用 -D"xxx" 代替 -Dxxx)

  3. 尝试通过测试覆盖所有可能的控制流,以便在运行时文件 reflection-config.json 中包含关于反射调用的所有信息,完成后使用 Ctrl + C 停止应用程序

  4. 再次运行 mvn -Pnative native:compile

  5. 最终运行可执行文件 target/your-app

本文首发于 https://h2cone.github.io/

参考资料