@levinzhang
2022-07-02T10:04:33.000000Z
字数 16526
阅读 520
by
Spring Boot 3和Spring Framework 6预计会在2022年底发布,它们将会提供对原生Java的内置支持。对于Spring Framework 5.x和Spring Boot 2.x来说,Spring Native是最合适的方案。Spring Native为Spring庞大的生态系统库提供了支持。它还有一个组件模型,允许我们为其他的库扩展对原生编译的支持。
本文是“Native Compilations Boosts Java”系列文章的一部分。你可以通过RSS订阅接收新文章更新通知。
Java在主导着企业级应用。但是在云中,采用Java的成本要比其竞争者更高。使用GraalVM进行原生编译降低了在云中Java的成本:它所创建的应用启动更快,使用的内存也更少。
原生编译为Java用户带来了很多的问题:原生Java会如何改变开发方式?我们在什么情况下该转向原生Java?在什么情况下又该避免转向原生Java?要使用原生Java,我们该采用哪个框架?本系列的文章将回答这些问题。
我们知道,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.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.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。