读书

借助 Spring Boot 和 GraalVM 实现原生 Ja

2022-08-09  本文已影响0人  技术栈

Java 在主导着企业级应用。但是在云中,采用 Java 的成本要比其竞争者更高。使用 GraalVM 进行原生编译降低了在云中 Java 的成本:它所创建的应用启动更快,使用的内存也更少。

原生编译为 Java 用户带来了很多的问题:原生 Java 会如何改变开发方式?我们在什么情况下该转向原生 Java?在什么情况下又该避免转向原生 Java?要使用原生 Java,我们该采用哪个框架?本系列的文章将回答这些问题。

Java 社区包罗万象

我们知道,Java 是一个神奇的生态系统,很难列出哪些东西是最适合我们的。这样做的话,清单似乎是无穷无尽的。但是,要说出它的几个缺点也不是那么困难。正如本文所说的,在 JRE 上运行的应用往往需要 10 秒钟或更长的时间才能启动,并需要数百或数千兆字节的内存。

这样的性能在如今的世界并不处于领先的位置。有些新的领域和机会正在出现:函数即服务产品、容器化与容器编排。它们有一个共同点,即对启动速度和内存占用有很高的要求。

迈向 GraalVM!

GraalVM 提供了一个前进的方向,但它也有一定的代价。GraalVM 是 OpenJDK 的替代方案,它有一个名为 Native Image 的工具,支持预先(ahead-of-time,AOT)编译。

AOT 编译与常规的 Java 编译有一些差异。正如本文所概述的那样,Native Image 消除了 Java 中“所有不必要的东西”。那么,Native Image 是如何知道 Java 或 Spring Boot 中哪些是不必要的呢?

Native Image 会探查我们的源码,并确定所有可达的代码,也就是通过调用或我们代码的使用所能链接到的代码。其他的所有内容,不管是位于应用的 classpath 下还是位于 JRE 中,都会被视为不必要的,它们都会被抛弃掉。

当我们做了一些 Native Image 无法明确知道该怎么处理的事情时,麻烦就来了。毕竟,Java 是一个非常动态化的语言。我们有可能会创建这样一个 Java 应用:在运行时,将一个字符串编译成文件系统中一个合法 Java 类文件,并将其加载到 ClassLoader 中,然后使用反射创建它的实例或者为其创建代理。我们还可能会将实例序列化到磁盘上,然后将其加载到另外一个 JVM 中。在这个过程中,我们可能不需要链接任何比java.lang.Object更具体的类。但是,如果这些类型没有被放到原生可执行堆中,所有的这些方式在原生 Java 中是无法正常运行的。

但是,我们并没有失去任何东西。我们可以在一个配置文件中告诉 Native Image 要保留哪些类型,这样,在运行时使用反射、代理、classpath 资源加载、JNI 等特性的时候,它依然可以运行。

现在,Java 和 Spring 生态系统非常庞大。所有的东西都要进行配置将会非常痛苦。所以我们有了两种方案:1)教会 Spring 尽可能避免使用这些机制,或者 2)教会 Spring 尽可能多地提供配置文件,这个配置文件必然要包含 Spring 框架和 Spring Boot,并且要在一定程度上包含 Spring Boot 支持的第三方集成功能。友情剧透一下,这两种方案我们都需要!

Spring Native

Spring 团队在 2019 年启动了 Spring Native 项目,为 Spring Boot 生态系统引入了原生可执行程序编译的功能。它已经为多种不同的方式提供了研究场所。但是,Spring Native 并没有从根本上改变 Framework 5.x 或 Spring Boot 2.x。而且它也绝不是终点,只是漫长旅程中的第一步:它已经为下一代 Spring Framework(6.x)和 Spring Boot(3.x)证明了很多概念,这两个版本预计都会在 2022 年晚些时候发布。这些新一代的项目会进行更多的优化,所以前景看起来是非常光明的!鉴于这些版本尚未发布,我们将会在本文中研究一下 Spring Native。

Spring Native 会对发送给 Native Image 的源码进行转换。比如,Spring Native 会将spring.factories服务加载机制转换为静态类,从而使 Spring Native 应用知道要使用它们。它会将所有的 Java 配置类(带有@Configuration注解的类)转换成 Spring 的函数式配置,从而消除应用及其依赖的反射。

Spring Native 还会自动分析我们的代码,探测需要 GraalVM 配置的场景,并以编程的方式提供这些配置。Spring Native 为 Spring、Spring Boot 以及第三方集成提供了线索(hint)类。

第一个 Spring Native 应用:JPA、Spring MVC 和 H2

我们开始使用 Spring Native 的方式与所有其他 Spring 项目相同:访问Spring Initializr,点击 cmd + B(或 Ctrl + B)或者 Add Dependencies,并选择 Spring Native。

Spring Initializr 会配置 Apache Maven 和 Gradle 构建。随后,只需添加必要的依赖即可。我们先从一些典型的依赖开始。将 Artifact 名称改为 jpa,接下来添加如下依赖:Spring Native、Spring Web、Lombok、H2 DatabaseSpring Data JPA。请确保选择 Java 17,当然你也可以选择 Java 11,但这就像你挥舞着一个橡胶做的小鸡满世界乱跑,这看上去非常傻,对吧?点击“Generate”,解压生成的项目并将其导入到你最喜欢的 IDE 中。

这个样例非常简单,将JpaApplication.java类改成如下所示:

package com.example.jpa;

import lombok.*;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.*;
import javax.persistence.*;
import java.util.Collection;
import java.util.stream.Stream;

@SpringBootApplication
public class JpaApplication {

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

@Component
record Initializr(CustomerRepository repository)
       implements ApplicationRunner {

   @Override
   public void run(ApplicationArguments args) throws Exception {
       Stream.of("A", "B", "C", "D")
               .map(c ->; new Customer(null, c))
               .map(this.repository::save)
               .forEach(System.out::println);
   }
}

@RestController
record CustomerRestController(CustomerRepository repository) {

    @GetMapping("/customers")
    Collection<Customer> customers() {
        return this.repository.findAll();
    }
}

interface CustomerRepository extends JpaRepository<Customer, Integer> {
}

@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Table (name = "customer")
class Customer {
    @Id
    @GeneratedValue
    private Integer id;
    private String name;
}

我们也可以将测试以原生可执行文件的形式进行编译和运行。但是需要注意的是,有些内容还不能很好的运行,比如 Mockito。我们修改测试类JpaApplicationTests.java,使其如下所示:

package com.example.jpa;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.util.Assert;

@SpringBootTest
class JpaApplicationTests {

    private final CustomerRepository customerRepository;

    @Autowired
    JpaApplicationTests(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    @Test
    void contextLoads() {
        var size = this.customerRepository.findAll().size();
        Assert.isTrue(size > 0, () -> "there should be more than one result!");
    }

}

在本文中,我将会展示 macOS 下的命令。对于 Windows 和 Linux,请相应的进行调整。

我们可以按照常规的方式运行应用和测试,比如在终端中运行mvn spring-boot:run命令。直接运行这些样例其实是个不错的主意,至少可以保证应用能够正常运行。但是,这并不是我们的目的。相反,我们想要将应用及其测试编译成 GraalVM 原生应用。

如果你看过 pom.xml 文件的话,你就会发现里面有很多额外的配置,它们搭建了 GraalVM 原生镜像并添加了一个 Maven profile(叫做native)以支持构建原生可执行文件。我们可以使用mvn clean package像以往那样编译应用。也可以使用mvn -Pnative clean package对应用进行原生编译。需要记住的是,你需要将 GraalVM 设置为成自己的 JDK。这个过程会持续几分钟,所以现在是来一杯茶、咖啡、水或其他饮品的时间。我就是这么做的,因为我需要它。当我回来的时候,我看到了如下所示的输出:

...
13.9s (16.9% of total time) in 71 GCs | Peak RSS: 10.25GB | CPU load: 5.66
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  03:00 min
[INFO] Finished at: 2022-04-28T17:57:56-07:00
[INFO] ------------------------------------------------------------------------

我们花费了三分钟的时间来编译原生测试,如果测试成功的话,还会编译原生应用本身。在这个过程中,Native Image 使用了高达 10.25GB 的 RAM。为了加快讲解的速度,在后文中我将会跳过编译和运行测试的过程。所以,当我们编译下面的样例时,将会使用如下的命令:

mvn -Pnative -DskipTests clean package

编译时间因应用的 classpath 不同而有所差异。根据经验,如果跳过编译测试的话,我的大多数构建将会需要 1 分钟到 90 秒的时间。例如,本应用包含了 JPA(和 Hibernate)、Spring Data、H2 数据库、Apache Tomcat 和 Spring MVC。

运行应用:

./target/jpa

在我的机器上,将会看到:

…Started TraditionalApplication in 0.08 seconds (JVM running for 0.082)

非常不错,80 毫秒,也就是千分之八十秒!更棒的,该应用几乎不占用任何内存。我使用如下的脚本来测试应用的RSS(resident set size)

#!/usr/bin/env bash  
PID=$1
RSS=`ps -o rss ${PID} | tail -n1`
RSS=`bc <<< "scale=1; ${RSS}/1024"`
echo "RSS memory (PID: ${PID}): ${RSS}M"

我们需要正在运行的应用的进程 ID(PID)。在 macOS 上,我可以通过运行pgrep jpa来获取它。我所使用的脚本如下所示:

~/bin/RSS.sh $(pgrep jpa)
RSS memory (PID: 35634): 96.9M

大约 97MB 的 RAM。这个数值可能会因运行应用的操作系统和架构的不同而有所差异。在 Intel 上的 Linux 和 M1 上的 macOS 中运行应用时,这个值就是不一样的。与 JRE 应用相比,这当前是一个明显的改进,但依然并不是最好的。

我喜欢反应式编程,而且我认为它更适合我现在的工作负载。我创建了一个类似的反应式应用。它不仅耗费了更少的空间(原因很多,包括 Spring Data R2DBC 支持 Java 17 的 record 语法),应用的编译时间是 1:14(差不多快了两分钟),启动时间是 0.044 秒。它占用的内存少了 35%,大约为 63.5MB。这个应用每秒还可以处理更多的请求。所以,它的编译和执行速度更快,内存效率更高,启动更快并且能够处理更高的流量。我说的是,在各方面这都是一笔不亏的买卖。

集成应用

Spring 不仅仅是 HTTP 端点,还有很多其他的东西。它包括很多框架,比如 Spring Batch、Spring Integration、Spring Security、Spring Cloud 以及不断增加的其他框架,它们都提供了对 Spring Native 的良好支持。

我们看一个 Spring Integration 的应用样例。Spring Integration 是一个支持企业级应用集成(enterprise-application integration,EAI)的框架。Gregor Hohpe 和 Bobby Woolf 的开创性著作Enterprise Integration Patterns为集成模式提供了通用的术语。Spring Integration 提供了实现这些模式的抽象。

返回 Spring Initializr,将项目命名为 integration,并选择 Java 17,添加Spring NativeSpring IntegrationSpring Web,然后点击Generate。我们需要在pom.xml文件中手动添加一个依赖项:

<dependency>
  <groupId>org.springframework.integration</groupId>
  <artifactId>spring-integration-file</artifactId>
  <version>${spring-integration.version}</version>
</dependency>

修改IntegrationApplication.java的代码,如下所示:

package com.example.integration;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.IntegrationFlows;
import org.springframework.integration.file.dsl.Files;
import org.springframework.integration.file.transformer.FileToStringTransformer;
import org.springframework.integration.transformer.GenericTransformer;

import java.io.File;

@SpringBootApplication
public class IntegrationApplication {

    @Bean
    IntegrationFlow integration(@Value("file://${user.home}/Desktop/integration") File root) {
        var in = new File(root, "in");
        var out = new File(root, "out");
        var inboundFileAdapter = Files
                .inboundAdapter(in)
                .autoCreateDirectory(true)
                .get();
        var outboundFileAdapter = Files
                .outboundAdapter(out)
                .autoCreateDirectory(true)
                .get();
        return IntegrationFlows //
                .from(inboundFileAdapter, spec -> spec.poller(pm -> pm.fixedRate(1000)))//
                .transform(new FileToStringTransformer())
                .transform((GenericTransformer<String, String>) source -> new StringBuilder(source)
                        .reverse()
                        .toString()
                        .trim())
                .handle(outboundFileAdapter)
                .get();
    }

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

这个应用非常简单:它会监控一个目录($HOME/Desktop/integration/in)中的新文件。一旦发现新文件,它就会创建一个副本,其String内容与源文件恰好相反,并将其写入到$HOME/Desktop/integration/out中。在 JRE 上,该应用的启动时间为 0.429 秒。这已经非常不错了,接下来我们看一下将其转换成 GraalVM 可执行文件会带来什么变化。

mvn -Pnative -DskipTests clean package

该应用的编译时间为 55.643 秒。它的启动时间(./target/integration)为 0.029 秒,占用了 35.5MB 的 RAM。很不错!

我们可以看到,没有所谓的典型结果。编译过程的输入对输出有着很大的影响。

将应用带入生产环境

在某个时间点,我们可能希望将应用部署到生产环境中,如今典型的生产环境就是 Kubernetes 了。Kubernetes 以容器的方式运行。Buildpacks项目背后的核心概念是集中和重用将应用制品转换成容器的习惯性做法。使用 Buildpacks 的方式有很多,可以借助 pack CLI,也可以在 Kubernetes 集群中使用KPack,还可以使用 Spring Boot 的构建插件。我们将使用最后一种方式,因为它仅需要 Docker Desktop 即可。

mvn spring-boot:build-image

该命令会在容器内构建原生可执行文件,所以我们会得到一个包含 Linux 原生二进制文件的 Linux 容器。随后,我们可以通过 docker tag 和 docker push 为其添加标签并推送至所选择的容器 registry 中。当我在 2022 年 5 月撰写这篇文章的时候,在 M1 架构的 Mac 上,Docker Buildpacks 仍然有点不稳定。但我相信这很快就会得到解决。

为 Native Image 提供一些线索

在到目前为止所看到的样例中,为了让应用能够以原生可执行文件的形式运行,我们并没有做其他更多的事情。按照上述默认的配置,它自然就可以运行。在大多数情况下,这种易用性就是我们期望的结果。但有时候,我们需要给 Native Image 提供一些线索,正如我在前面的“迈向 GraalVM!”章节所提到的那样。

我们看一下另外一个样例。首先,进入 Spring Initializr,将项目命名为 extensions,选择 Java 17 并添加Spring Native,然后点击Generate。接下来,我们会手动添加一个在 Initialzr 上不存在的依赖项:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-json</artifactId>
</dependency>

我们在这里的目标是看一下当出错的时候,会发生些什么。Spring Native 提供了一组线索,允许我们很容易地增强默认的配置。将ExtensionsApplication.java修改为如下所示:

package com.example.extensions;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.framework.ProxyFactoryBean;

import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.core.io.ClassPathResource;
import org.springframework.nativex.hint.*;
import org.springframework.stereotype.Component;
import org.springframework.util.*;

import java.io.InputStreamReader;
import java.util.List;
import java.util.function.Supplier;

@SpringBootApplication
public class ExtensionsApplication {
    public static void main(String[] args) {
        SpringApplication.run(ExtensionsApplication.class, args);
    }
}

@Component
class ReflectionRunner implements ApplicationRunner {
    
    private final ObjectMapper objectMapper ;

    ReflectionRunner(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    record Customer(Integer id, String name) {
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        var json = """
                [
                 { "id" : 2, "name": "Dr. Syer"} ,
                 { "id" : 1, "name": "Jürgen"} ,
                 { "id" : 4, "name": "Olga"} ,
                 { "id" : 3, "name": "Violetta"}  
                ]
                """;
        var result = this.objectMapper.readValue(json, new TypeReference<List<Customer>>() {
        });
        System.out.println("there are " + result.size() + " customers.");
        result.forEach(System.out::println);
    }
}

@Component
class ResourceRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
        var resource = new ClassPathResource("Log4j-charsets.properties");
        Assert.isTrue(resource.exists(), () -> "the file must exist");
        try (var in = new InputStreamReader(resource.getInputStream())) {
            var contents = FileCopyUtils.copyToString(in);
            System.out.println(contents.substring(0, 100) + System.lineSeparator() + "...");
        }
    }
}

@Component
class ProxyRunner implements ApplicationRunner {

    private static Animal buildAnimalProxy(Supplier<String> greetings) {
        var pfb = new ProxyFactoryBean();
        pfb.addInterface(Animal.class);
        pfb.addAdvice((MethodInterceptor) invocation -> {
            if (invocation.getMethod().getName().equals("speak"))
                System.out.println(greetings.get());

            return null;
        });
        return (Animal) pfb.getObject();
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        var cat = buildAnimalProxy(() -> "meow!");
        cat.speak();

        var dog = buildAnimalProxy(() -> "woof!");
        dog.speak();
    }

    interface Animal {
        void speak();
    }
}

这个样例包含了三个 ApplicationRunner 实例,Spring 应用在启动的时候会运行它们。每个 Bean 都会做一些让 GraalVM Native Image 感觉不爽的事情。但是,在 JVM 上,它们能够很好地运行:mvn spring-boot:run

第一个ApplicationRunner,即ReflectionRunner,会读取 JSON 数据并使用反射将它的结构映射到一个 Java 类Customer上。它无法正常运行,因为 Native Image 将会移除Customer类。使用mvn -Pnative -DskipTests clean package构建应用,并使用./target/extensions运行它。我们将会看到“com.oracle.svm.core.jdk.UnsupportedFeatureError: Proxy class defined by interfaces”这样的错误。

我们可以使用@TypeHint注解来修复该问题。添加如下的内容到ExtensionsApplication类上:

@TypeHint(types =  ReflectionRunner.Customer.class, access = { TypeAccess.DECLARED_CONSTRUCTORS, TypeAccess.DECLARED_METHODS })

在这里,我们声明我们希望对ReflectionRunner.Customer的构造器和方法进行反射访问。对于不同类型的反射,还有其他的TypeAccess值。

第二个ApplicationRunner,即ResourceRunner,会从 classpath 下某个依赖的.jar中加载文件。它也无法正常运行,并且会提示“java.lang.IllegalArgumentException: the file must exist”这样的错误。原因在于该文件位于其他的.jar中,而不是在我们的应用代码中。如果文件位于src/main/resources中的话,加载资源是可以正常运行的。我们可以使用@ResourceHint注解来解决这个问题。将如下的内容添加到ExtensionsApplication类中:

@ResourceHint(patterns = "Log4j-charsets.properties", isBundle = false)

第三个ApplicationRunner,即ProxyRunner,创建了一个 JDK 代理。代理会创建相关类型的子类或实现类。Spring 支持两种类型的代理,即 JDK 代理和 AOT 代理。JDK 代理仅限于使用 Java java.lang.reflect.Proxy的接口。AOT 代理则是 Spring 特有的,并不是 JRE 的一部分。JDK 代理通常是给定具体类的子类,也可能是接口。Native Image 需要知道我们的代理要使用哪些接口和具体类。

继续将第三个应用编译为原生可执行文件。Native Image 将会给出一条友好的错误信息“com.oracle.svm.core.jdk.UnsupportedFeatureError: Proxy class defined by interfaces”,并且会列出所有 Spring 试图要代理的接口。请注意这些类型:com.example.extensions.ProxyRunner.Animalorg.springframework.aop.SpringProxyorg.springframework.aop.framework.Advisedorg.springframework.core.DecoratingProxy。我们将会使用它们为ExtensionsApplication添加如下的线索:

@JdkProxyHint(types = {
    com.example.extensions.ProxyRunner.Animal.class,
    org.springframework.aop.SpringProxy.class,
    org.springframework.aop.framework.Advised.class,
    org.springframework.core.DecoratingProxy.class
})

如果你现在尝试构建(mvn -DskipTests -Pnative clean package)并运行(./target/extensions)样例的话,就不会有任何问题了。

构建期和运行期的 Processor

Spring 有很多的Processor实现。Spring Native 提供了一些新的Processor接口,它们只会在构建期激活。这些Processor会动态地为构建过程提供线索信息。理想情况下,这些Processor的实现会位于一个可重用的库中。访问 Spring Initializr,将项目命名为 processors,并添加Spring Native。在 IDE 中打开生成的项目,在pom.xml文件中移除build节点,这样会删除所有的 Maven 插件配置。接下来,我们需要手动添加一个新的库:

 <dependency>
  <groupId>org.springframework.experimental</groupId>
  <artifactId>spring-aot</artifactId>
  <version>${spring-native.version}</version>
  <scope>provided</scope>
</dependency>

Maven 构建会生成一个常规的 Java “.jar”制品,我们可以像对待任意 Maven “.jar”那样对其进行安装和部署:mvn -DskipTests clean install

这个新的库会引入新的类型,包括:

我发现自己大多数时候都在和这两个接口打交道。在每个接口中,我们都可以得到一个可供探测的引用以及一个注册表的引用,我们据此能够以编程的方式向注册表中贡献线索内容。如果使用BeanNativeConfigurationProcessor,我们会得到一个 bean 元数据的实例,它代表了 bean factory 中的一个 bean。如果使用BeanFactoryNativeConfigurationProcessor的话,我们会得到对整个BeanFactory本身的引用。需要注意的是,我们只能使用 bean 的名称和BeanDefinition实例,无法使用真正的 bean。BeanFactory能够知道所有在运行时会存在的对象,但是它此时还没有实例化它们。相反,它的作用是帮助我们理解运行中的应用中 bean 的样子,比如类、方法等,以便于得到适当的线索信息。

我们不能以常规 Spring bean 的形式来注册这些Processor类型,而是要在spring.factories服务加载器中进行注册。所以,鉴于BeanFactoryNativeConfigurationProcessor的实现名为com.example.nativex.MyBeanFactoryNativeConfigurationProcessorBeanNativeConfigurationProcessor的实现名为com.example.nativex.MyBeanNativeConfigurationProcessorspring.factories文件如下所示:

org.springframework.aot.context.bootstrap.generator.infrastructure.nativex.BeanFactoryNativeConfigurationProcessor=\
  com.example.nativex.MyBeanFactoryNativeConfigurationProcessor
org.springframework.aot.context.bootstrap.generator.infrastructure.nativex.BeanNativeConfigurationProcessor=\
  com.example.nativex.MyBeanNativeConfigurationProcessor

借助这些 Processor 类型,我们可以很容易地在 Spring Native 应用中消费集成功能或库。

上一篇下一篇

猜你喜欢

热点阅读