我们知道,Java 是一个神奇的生态系统,很难列出哪些东西是最适合我们的。这样做的话,清单似乎是无穷无尽的。但是,要说出它的几个缺点也不是那么困难。正如 本系列文章 所说的,在 JRE 上运行的应用往往需要 10 秒钟或更长的时间才能启动,并需要数百或数千兆字节的内存。
这样的性能在如今的世界并不处于领先的位置。有些新的领域和机会正在出现:函数即服务产品、容器化与容器编排。它们有一个共同点,即对启动速度和内存占用有很高的要求。
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 支持的第三方集成功能。友情剧透一下,这两种方案我们都需要!
要运行样例项目,你需要在自己的机器上安装 GraalVM。GraalVM 有 安装指南。如果你使用 Mac 的话,也可以 使用 SDKMAN! 来安装 GraalVM。
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 的方式与所有其他 Spring 项目相同:访问 Spring Initializr,点击 cmd + B(或 Ctrl + B)或者 Add Dependencies,并选择 Spring Native。
Spring Initializr 会配置 Apache Maven 和 Gradle 构建。随后,只需添加必要的依赖即可。我们先从一些典型的依赖开始。将 Artifact 名称改为 jpa,接下来添加如下依赖:Spring Native、
Spring Web、
Lombok、
H2 Database和Spring 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 Native、Spring Integration、Spring 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 即可。请 点击官网 了解 Docker Desktop 的更多信息。
mvn spring-boot:build-image
该命令会在容器内构建原生可执行文件,所以我们会得到一个包含 Linux 原生二进制文件的 Linux 容器。随后,我们可以通过 docker tag 和 docker push 为其添加标签并推送至所选择的容器 registry 中。当我在 2022 年 5 月撰写这篇文章的时候,在 M1 架构的 Mac 上,Docker Buildpacks 仍然有点不稳定。但我相信这很快就会得到解决。
在到目前为止所看到的样例中,为了让应用能够以原生可执行文件的形式运行,我们并没有做其他更多的事情。按照上述默认的配置,它自然就可以运行。在大多数情况下,这种易用性就是我们期望的结果。但有时候,我们需要给 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.DECLARE
在这里,我们声明我们希望对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.Animal、org.springframework.aop.SpringProxy、org.springframework.aop.framework.Advised和org.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)样例的话,就不会有任何问题了。
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。
这个新的库会引入新的类型,包括:
BeanFactoryNativeConfigurationProcessor:它在构建期的行为等同于BeanFactoryPostProcessor
BeanNativeConfigurationProcessor:它在构建期的行为等同于BeanPostProcessor
我发现自己大多数时候都在和这两个接口打交道。在每个接口中,我们都可以得到一个可供探测的引用以及一个注册表的引用,我们据此能够以编程的方式向注册表中贡献线索内容。如果使用BeanNativeConfigurationProcessor,我们会得到一个 bean 元数据的实例,它代表了 bean factory 中的一个 bean。如果使用BeanFactoryNativeConfigurationProcessor的话,我们会得到对整个BeanFactory本身的引用。需要注意的是,我们只能使用 bean 的名称和BeanDefinition实例,无法使用真正的 bean。BeanFactory能够知道所有在运行时会存在的对象,但是它此时还没有实例化它们。相反,它的作用是帮助我们理解运行中的应用中 bean 的样子,比如类、方法等,以便于得到适当的线索信息。
我们不能以常规 Spring bean 的形式来注册这些Processor类型,而是要在spring.factories服务加载器中进行注册。所以,鉴于BeanFactoryNativeConfigurationProcessor的实现名为com.example.nativex.MyBeanFactoryNativeConfigurationProcessor,BeanNativeConfigurationProcessor的实现名为com.example.nativex.MyBeanNativeConfigurationProcessor,spring.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 应用中消费集成功能或库。我写了一个库(com.joshlong:hints:0.0.1),里面包含了各种集成功能(如 Kubernetes Java 客户端、Fabric8 Kubernetes Java 客户端、Spring GraphQL、Liquibase 等),这些集成功能不大适合放到官方的 Spring Native 版本中。目前这就是一个大杂烩,但结果是很酷的:只要把相关的功能添加到 classpath 中,就像 Spring Boot 的自动配置一样,我们就会得到一个很棒的结果!
我希望你能够从这个关于 Spring Native 原生可执行文件的简单介绍中有所收获。请继续关注 Spring 博客 和我的 Twitter (@starbuxman) ,以获取更多信息。
作者介绍:
Josh Long(Twitter 为 @starbuxman)是第一个 Spring 开发者倡导者,始于 2010 年。Josh 是一个 Java Champion,写了 6 本图书(包括 O'Reilly 的“Cloud Native Java: Designing Resilient Systems with Spring Boot, Spring Cloud, and Cloud Foundry”和“Reactive Spring”)和制作了许多畅销的视频培训(包括与 Spring Boot 联合创始人 Phil Webb 合作的“Building Microservices with Spring Boot Livelessons”),并且是开源贡献者(Spring Boot、Spring Integration, Spring Cloud、Activiti 和 Vaadin 等)、播客(“A Bootiful Podcast”)和 YouTuber。
原文链接:
https://www.infoq.com/articles/native-java-spring-boot/
点击底部阅读原文 访问 InfoQ 官网,获取更多精彩内容!
点个在看少个 bug 👇