领先一步
VMware 提供培训和认证,助您加速进步。
了解更多注意:代码在此我的 Github 账户上:github.com/joshlong/bootiful-spring-boot-2024-blog。
大家好,Spring 的粉丝们!我是 Josh Long,我隶属于 Spring 团队。我很激动今年能作为主题演讲者并在 微软的 JDConf 上发表演讲。我是一名 Kotlin GDE 和 Java Champion,我认为现在是成为一名 Java 和 Spring Boot 开发者的最佳时机。我之所以这么说,是因为我充分意识到了我们目前所处的时代。Spring Framework 的早期发布距今已有 21 多年,Spring Boot 的早期发布距今已有 11 多年。今年是 Spring Framework 发布 20 周年,Spring Boot 发布 10 周年。所以,当我说现在是成为一名 Java 和 Spring 开发者的最佳时机时,请记住,我在这几十年里已经深耕其中。我热爱 Java 和 JVM,也热爱 Spring,这段经历真是太美妙了。
但现在是最好的时机。以前从未接近过。所以,一如既往,让我们通过访问我在互联网上的第二个最喜欢的地方(仅次于生产环境)——start.spring.io——来开发一个新应用程序,您就会明白我的意思。点击“添加依赖项”,然后选择“Web”、“Spring Data JDBC”、“OpenAI”、“GraalVM Native Support”、“Docker Compose”、“Postgres”和“Devtools”。
给它一个项目名称。我给我的服务起了个名字……“service”。我起名字很在行。我继承了我爸爸的这项天赋。我爸爸起名字也很棒。我小时候,我们家有一只很小的白色小狗,我爸爸给它起了个名字叫White Dog。它做了好几年的家庭宠物。但大约十年后,它失踪了。我不知道后来发生了什么。也许它找了份工作;我不知道。但后来,奇迹般地,另一只白色小狗出现在我们家装有纱窗的门前。于是我们收留了它,我爸爸给它起了个名字叫Too。或者Two。我不知道。总之,起名字这件事,我真的很在行。不过,我妈妈一直告诉我,我能活下来,全靠她给我起了名字……嗯,这大概是真的。
无论如何,选择 Java 21。这一点至关重要。如果您不使用 Java 21,就无法使用 Java 21。所以,您需要 Java 21。但我们也将使用 GraalVM 来发挥其原生镜像的能力。
还没有安装 Java 21?下载它!使用非常棒的 SDKMAN 工具:sdk install java 21-graalce。然后将其设为默认:sdk default java 21-graalce。打开一个新的终端。下载 .zip 文件。
Java 21 非常棒。它比 Java 8 好太多了。在各个方面都技术更优越。它更快、更健壮、语法更丰富。它在道德上也更优越。当您的孩子看到您在生产环境中使用 Java 8 时,他们眼中的羞耻和鄙夷会让他们难以忍受。别这么做。成为您想在世界上看到的改变。使用 Java 21。
您将得到一个 zip 文件。解压它,然后在您的 IDE 中打开它。
我使用的是 IntelliJ IDEA,它安装了一个名为 idea 的命令行工具。
cd service
idea build.gradle
# idea pom.xml if you're using Apache Maven
如果您使用的是 Visual Studio Code,请务必在 Visual Studio Code Marketplace 上安装 Spring Boot Extension Pack。
这个新应用程序将需要与数据库通信;这是一个以数据为中心的应用程序。在 Spring Initializr 上,我们添加了对 PostgreSQL 的支持,但现在我们需要连接到它。我们最不想看到的就是一个很长的 README.md 文件,其中有一个标题为“开发的一百个简单步骤”的部分。我们想要的是`git clone` & 运行的生活方式!
为此,Spring Initializr 生成了一个 Docker Compose compose.yml 文件,其中包含了 Postgres(一款非常棒的 SQL 数据库)的定义。
Docker Compose 文件,compose.yaml
services:
postgres:
image: 'postgres:latest'
environment:
- 'POSTGRES_DB=mydatabase'
- 'POSTGRES_PASSWORD=secret'
- 'POSTGRES_USER=myuser'
ports:
- '5432'
更好的是,Spring Boot 配置为在 Spring Boot 应用程序启动时自动运行 Docker Compose (docker compose up) 配置。无需配置连接详细信息,如 spring.datasource.url 和 spring.datasource.password 等。所有这些都通过 Spring Boot 令人惊叹的自动配置来完成。您一定会喜欢!而且,为了不留下烂摊子,Spring Boot 在应用程序关闭时也会关闭 Docker 容器。
我们希望尽可能快地推进。为此,我们在 Spring Initializr 上选择了 DevTools。它将使我们能够快速推进。核心理念是,Java 的重启速度相当慢。然而,Spring 的重启速度非常快。那么,如果我们有一个监控项目文件夹的进程,能够注意到新编译的 .class 文件,将它们加载到类加载器中,然后创建一个新的 Spring ApplicationContext,丢弃旧的,从而给我们一种实时重新加载的体验呢?这正是 Spring 的 DevTools 所做的。在开发过程中运行它,您会发现重启时间大大缩短!
之所以能这样工作,再次强调,是因为 Spring 启动速度非常快……除非,您每次重启时都要启动一个 PostgreSQL 数据库。我喜欢 PostgreSQL,但是,呃,是的,它不适合在每次您调整方法名称、修改 HTTP 端点路径或优化一些 CSS 时都进行持续的重启。所以,让我们配置 Spring Boot 仅启动 Docker Compose 文件,并让它一直运行,而不是每次都重启。
将此属性添加到 application.properties
spring.docker.compose.lifecycle-management=start_only
我们将从一个简单的记录开始。
package com.example.service;
import org.springframework.data.annotation.Id;
// look mom, no Lombok!
record Customer(@Id Integer id, String name) {
}
我喜欢 Java 记录!您也应该喜欢!不要忽视记录(records)。这个不起眼的小 record 不仅仅是比 Lombok 的 @Data 注解更好的实现方式,它实际上是一系列功能的一部分,这些功能在 Java 21 中得以完善,并且结合在一起,支持所谓的面向数据编程。
Java 语言架构师 Brian Goetz 在他 2022 年的 InfoQ 文章“面向数据编程” 中谈到了这一点。
据分析,Java 在单体(monolith)领域占据主导地位,这归因于其强大的访问控制、良好且快速的编译器、隐私保护等等。Java 使得创建相对模块化、可组合的单体应用程序变得容易。单体应用程序通常是大型、庞杂的代码库,而 Java 支持这一点。事实上,如果您想要模块化并想好好组织您庞大的单体代码库,可以看看 Spring Modulith 项目。
但情况已经发生了变化。如今,我们在系统中表达变化的方式不是通过动态分派和多态性来表达深层抽象类型层次结构的特定实现,而是通过网络上经常临时发送的消息,通过 HTTP/REST、gRPC、Apache Kafka 和 RabbitMQ 等消息传递层。关键在于数据,笨蛋!
Java 已经发展到支持这些新模式。让我们来看看四个关键特性——记录(records)、模式匹配(pattern matching)、智能 switch 表达式(smart switch expressions)和密封类型(sealed types)——来理解我的意思。假设我们从事一个监管严格的行业,例如金融。
想象一下我们有一个名为 Loan 的接口。显然,贷款是受到严格监管的金融工具。我们不希望有人随意添加 Loan 接口的匿名内部类实现,绕过我们为了构建系统而付出的验证和保护。
所以,我们将使用 sealed 类型。密封类型是一种新的访问控制或可见性修饰符。
package com.example.service;
sealed interface Loan permits SecuredLoan, UnsecuredLoan {
}
record UnsecuredLoan(float interest) implements Loan {
}
final class SecuredLoan implements Loan {
}
在示例中,我们明确规定系统中 Loan 有两种实现:SecuredLoan 和 UnsecuredLoan。类默认对子类化是开放的,这违反了密封层次结构所暗示的保证。因此,我们明确地将 SecuredLoan 设为 final。UnsecuredLoan 实现为一个记录(record),并且是隐式 final 的。
记录(Records)是 Java 对元组(tuples)的回应。它们就是元组。只是 Java 是一种命名语言:事物都有名称。这个元组也有一个名字:UnsecuredLoan。如果我们同意它们所隐含的契约,记录(records)就能给我们带来巨大的力量。记录(records)的核心理念是,对象的标识与其字段(称为“组件”)的标识相等。所以在这个例子中,记录(record)的标识等于 interest 变量的标识。如果我们同意这一点,那么编译器就可以为我们提供构造函数,为每个组件提供存储空间,提供 toString 方法、hashCode 方法和 equals 方法。它还会为构造函数中的组件提供访问器。很棒!而且,它还支持解构!语言知道如何提取记录(record)的状态。
现在,假设我想为每种类型的 Loan 显示一条消息。我将编写一个方法。这是最初的、朴素的实现。
@Deprecated
String badDisplayMessageFor(Loan loan) {
var message = "";
if (loan instanceof SecuredLoan) {
message = "good job! ";
}
if (loan instanceof UnsecuredLoan) {
var usl = (UnsecuredLoan) loan;
message = "ouch! that " + usl.interest() + "% interest rate is going to hurt!";
}
return message;
}
这在某种程度上是可行的。但它并没有尽到应有的作用。
我们可以改进它。让我们利用模式匹配,像这样
@Deprecated
String notGreatDisplayMessageFor(Loan loan) {
var message = "";
if (loan instanceof SecuredLoan) {
message = "good job! ";
}
if (loan instanceof UnsecuredLoan usl) {
message = "ouch! that " + usl.interest() + "% interest rate is going to hurt!";
}
return message;
}
好了很多。请注意,我们使用模式匹配来匹配对象的形状,然后将明确可转换的项提取到一个变量 usl 中。实际上,我们甚至不需要 usl 变量,对吧。相反,我们想解引用 interest 变量。所以我们可以修改模式匹配以提取该变量,如下所示。
@Deprecated
String notGreatDisplayMessageFor(Loan loan) {
var message = "";
if (loan instanceof SecuredLoan) {
message = "good job! ";
}
if (loan instanceof UnsecuredLoan(var interest) ) {
message = "ouch! that " + interest + "% interest rate is going to hurt!";
}
return message;
}
如果我注释掉一个分支会怎样?没什么!编译器根本不在乎。我们没有处理代码可能经过的关键路径之一。
同样,我有一个值存储在一个变量 message 中,我将其作为某些条件的副作用进行赋值。如果我们能省略中间变量,直接返回某个表达式,那不是很好吗?让我们看看使用智能 switch 表达式的一个更简洁的实现,这是 Java 中的另一个新颖特性。
String displayMessageFor(Loan loan) {
return switch (loan) {
case SecuredLoan sl -> "good job! ";
case UnsecuredLoan(var interest) -> "ouch! that " + interest + "% interest rate is going to hurt!";
};
}
这个版本使用智能 switch 表达式来返回一个值和模式匹配。如果您注释掉一个分支,编译器会报错,因为——多亏了密封类型——它知道您还没有穷尽所有可能的选项。太棒了!编译器为我们做了大量工作!结果既简洁又富有表现力。大部分情况下。
好了,回到我们正常的进度。添加一个用于 Spring Data JDBC 存储库的接口和一个 Spring MVC 控制器类。然后启动应用程序。请注意,这需要非常长的时间!这是因为后台它正在使用 Docker 守护进程来启动 PostgreSQL 实例。
但从此以后,我们将使用 Spring Boot 的 DevTools。您只需要重新编译。如果应用程序正在运行,并且您正在使用 Eclipse 或 Visual Studio Code,您只需要保存文件:macOS 上的 CMD+S。IntelliJ IDEA 没有 Save 选项;使用 macOS 上的 CMD+Shift+F9 强制构建。很棒。
好了,我们有一个 HTTP Web 端点在监视数据库,但数据库里什么都没有,所以这肯定会失败。让我们用一些模式和一些示例数据来初始化我们的数据库。
添加 schema.sql 和 data.sql。
我们应用程序的 DDL,schema.sql
create table if not exists customer (
id serial primary key ,
name text not null
) ;
我们应用程序的一些示例数据,data.sql
delete from customer;
insert into customer(name) values ('Josh') ;
insert into customer(name) values ('Madhura');
insert into customer(name) values ('Jürgen') ;
insert into customer(name) values ('Olga');
insert into customer(name) values ('Stéphane') ;
insert into customer(name) values ('Dr. Syer');
insert into customer(name) values ('Dr. Pollack');
insert into customer(name) values ('Phil');
insert into customer(name) values ('Yuxin');
insert into customer(name) values ('Violetta');
确保通过将以下属性添加到 application.properties 来告诉 Spring Boot 在启动时运行 SQL 文件。
spring.sql.init.mode=always
重新加载应用程序:macOS 上的 CMD+Shift+F9。在我的电脑上,这个重载时间大约是重启 JVM 和应用程序本身所需时间的 1/3,或者说慢了 66%。太棒了。
它已经启动并运行了。访问 https://:8080/customers 查看结果。成功了!当然,成功了。这是个演示。它总是会成功的。
这一切都是非常标准的操作。十年前您也可以做类似的事情。不过,代码会冗长得多。Java 自那以后取得了长足的进步。当然,速度也无法相比。当然,现在的抽象更好。但是,您本可以做类似的事情——一个监视数据库的 Web 应用程序。
事物在变化。总有新的前沿。现在,新的前沿是人工智能,即 AI。AI:因为对智能的追求显然还不够困难。
AI 是一个巨大的产业,但大多数人在想到 AI 时想到的是利用 AI。您不需要使用 Python 就可以使用大型语言模型(LLM),就像大多数人不需要使用 C 就可以使用 SQL 数据库一样。您只需要集成 LLM,而 Java 在选择和功能方面无与伦比。
在我们上次的盛会 SpringOne 开发者活动(2023 年)上,我们宣布了 Spring AI,这是一个旨在让集成和使用 AI 变得尽可能容易的新项目。
您需要摄入数据,例如来自账户、文件、服务,甚至是一组 PDF。您需要将它们存储在向量数据库中以便于检索,以支持相似性搜索。然后,您需要与 LLM 集成,并向其提供来自该向量数据库的数据。
当然,您可能想要的任何 LLM 都有客户端绑定——Amazon Bedrock、Azure OpenAI、Google Vertex以及Google Gemini、Ollama、HuggingFace,当然还有 OpenAI 本身,但这仅仅是开始。
为 LLM 提供动力的所有知识都内嵌在模型中,然后该模型会影响 LLM 对世界的理解。但该模型有某种过期的日期,之后其知识就会过时。如果模型是在两周前构建的,它就不会知道昨天发生的事情。因此,如果您想构建一个自动助手,例如处理用户关于其银行账户的请求,那么在处理请求时,该 LLM 需要了解世界的最新状态。
您可以在请求中添加信息,并将其用作上下文来指导响应。如果只是这么简单,那倒还好。还有一个难点。不同的 LLM 支持不同的令牌窗口大小。令牌窗口决定了您在给定请求中可以发送和接收多少数据。窗口越小,您可以发送的信息就越少,LLM 的响应也会越不全面。
您可以在这里做的一件事是,将数据放入向量存储,如 pgvector、Neo4j、Weaviate 等,然后将您的 LLM 连接到该向量数据库。向量存储使您能够给定一个词或一组词,找到与它们相似的其他事物。它将数据存储为数学表示,并允许您查询相似的事物。
这个将数据摄入、丰富、分析和消化以指导 LLM 响应的整个过程称为检索增强生成(RAG),Spring AI 支持所有这些。想了解更多,请参阅我关于 Spring AI 的这个 Spring Tips 视频。不过,我们在这里不会利用所有这些功能。只利用一个。
我们在 Spring Initializr 上添加了 OpenAI 支持,因此 Spring AI 已经在类路径中了。像这样添加一个新的控制器
一个由 AI 驱动的 Spring MVC 控制器
package com.example.service;
import org.springframework.ai.chat.ChatClient;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.Map;
@Controller
@ResponseBody
class StoryController {
private final ChatClient singularity;
StoryController(ChatClient singularity) {
this.singularity = singularity;
}
@GetMapping("/story")
Map<String, String> story() {
var prompt = """
Dear Singularity,
Please write a story about the good folks of San Francisco, capital of all things Artificial Intelligence,
and please do so in the style of famed children's author Dr. Seuss.
Cordially,
Josh Long
""";
var reply = this.singularity.call(prompt);
return Map.of("message", reply);
}
}
非常简单!注入 Spring AI 的 ChatClient,使用它向 LLM 发送请求,获取响应,然后将其作为 JSON 返回给 HTTP 客户端。
您需要通过一个属性来配置与 OpenAI API 的连接:spring.ai.openai.api-key=。我在运行程序之前将其导出为环境变量 SPRING_AI_OPENAI_API_KEY。我不会在这里复制我的密钥。请原谅我没有泄露我的 API 凭证。
按 CMD+Shift+F9 重新加载应用程序,然后访问端点:https://:8080/story。LLM 可能需要几秒钟才能生成响应,所以准备好您的咖啡、水或其他饮品,以便快速但令人满意地啜饮。
就是这样!我们生活在一个奇迹的时代!自由放飞的奇点时代!您现在可以做任何事情。
但是,确实花了点时间,对吧?我并不介意计算机花费这些时间!它做得非常出色!我无法做得更快。看看它生成的那个故事!它是一件艺术品。
但确实花了一些时间。这给我们的应用程序带来了可扩展性问题。在后台,当我们调用 LLM 时,我们实际上是在进行网络调用。在代码深处,有一个 java.net.Socket,我们从中获得了一个 java.io.InputStream,它代表了来自服务的数据字节数组。我不知道您是否还记得直接使用 InputStream。这里有一个例子
try (var is = new FileInputStream("afile.txt")) {
var next = -1;
while ((next = is.read()) != -1) {
next = is.read();
// do something with read
}
}
看到我们在第 4 行通过调用 InputStream.read 从 InputStream 中读取字节的部分了吗?我们称之为阻塞操作。如果我们调用 InputStream.read,那么我们必须等到该调用返回后才能进行第 5 行的操作。
如果我们要连接的服务返回的数据太多怎么办?如果服务宕机了怎么办?如果它永远不返回怎么办?如果我们陷入了无限等待怎么办?万一呢?
如果这种情况只发生一次,那会很乏味。但如果它发生在用于处理 HTTP 请求的系统中的每个线程上,那将是对我们服务的生存威胁。这种情况经常发生。这就是为什么有时您可以登录到一个完全无响应的 HTTP 服务,却发现 CPU 基本处于休眠状态——空闲!——什么也没做或做得很少。线程池中的所有线程都处于等待状态,等待着永远不会来的东西。
这是对我们付费购买的宝贵 CPU 容量的巨大浪费。即使是最好的情况也不理想。即使方法最终会返回,这也意味着处理该请求的线程无法用于系统中的其他任何事情。该方法垄断了该线程,因此系统中没有人可以使用它。如果线程便宜且充裕,这不成问题。但它们不是。在 Java 的大部分生命周期中,每个新线程都与操作系统线程一对一对应。而且它并不便宜。每个线程都有一定数量的记账开销。一到两兆字节。因此,您无法创建很多线程,而且您还浪费了本已稀少的线程。太可怕了!谁需要睡觉呢?
一定有更好的方法。
您可以使用非阻塞 IO。例如,那些让人痛苦且复杂的 Java NIO 库。这是一种选择,就像与一群臭鼬一起生活是一种选择一样:太糟糕了!大多数人不会以非阻塞 IO 或常规 IO 的方式思考。我们生活在更高的抽象层面。我们可以使用响应式编程。我*喜欢*响应式编程。我甚至写了一本关于它的书——Reactive Spring。但如果您不习惯像函数式程序员那样思考,那么如何让它工作就不是那么显而易见了。这是一种不同的范式,意味着重写您的代码。
如果我们能两全其美怎么办?使用 Java 21,现在我们可以了!有一个名为虚拟线程(virtual threads)的新功能,可以使这些事情变得容易得多!如果您在这些新的虚拟线程上执行阻塞操作,运行时就会检测到您正在执行阻塞操作——例如 java.io.InputStream.read、java.io.OutputStream.write 和 java.lang.Thread.sleep——然后将该阻塞、空闲的活动从线程中移出并放入 RAM。然后,它基本上会为睡眠设置一个定时器,或监视 IO 的文件描述符,并在此时将线程重新用于其他事情。当阻塞操作完成后,运行时会将其移回线程,并允许它从开始的地方继续,而且几乎不需要更改您的代码。这很难理解,所以让我们通过一个例子来看看。我厚颜无耻地借用了 Oracle 开发者倡导者 José Paumard 的这个例子。
此示例演示了创建 1,000 个线程并在每个线程上休眠 400 毫秒,同时记录这 1,000 个线程中的第一个线程的名称。
package com.example.service;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
@Configuration
class Threads {
private static void run(boolean first, Set<String> names) {
if (first)
names.add(Thread.currentThread().toString());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
@Bean
ApplicationRunner demo() {
return args -> {
// store all 1,000 threads
var threads = new ArrayList<Thread>();
// dedupe elements with Set<T>
var names = new ConcurrentSkipListSet<String>();
// merci José Paumard d'Oracle
for (var i = 0; i < 1000; i++) {
var first = 0 == i;
threads.add(Thread.ofPlatform().unstarted(() -> run(first, names)));
}
for (var t : threads)
t.start();
for (var t : threads)
t.join();
System.out.println(names);
};
}
}
我们使用 Thread.ofPlatform 工厂方法来创建普通的平台线程,它们本质上与自 1990 年代 Java 首次亮相以来我们创建的线程相同。该程序创建了 1,000 个线程。在每个线程中,我们休眠 100 毫秒,共四次。在此期间,我们测试是否在 1000 个线程中的第一个线程上,如果是,我们通过将当前线程的名称添加到集合中来记录它。集合会去重其元素;如果出现相同的名称,集合中仍只有一个元素。
运行程序(CMD+Shift+F9!),您将看到程序的物理行为没有改变。Set<String> 中只有一个名称。为什么没有呢?我们只是一遍又一遍地测试同一个线程。
现在,将该构造函数更改为使用虚拟线程:Thread.ofVirtual。非常简单的更改。现在运行程序。CMD+Shift+F9。
您会看到集合中有多个元素。您根本没有改变代码的核心逻辑。事实上,您甚至只需要更改一件事,但现在,在幕后,编译器和运行时无缝地重写了您的代码,以便当虚拟线程上发生阻塞操作时,运行时会无缝地将您移出,并在阻塞操作完成后将您移回线程。这意味着您之前存在的线程现在可用于系统的其他部分。您的可扩展性将呈指数级增长!
您可能会抗议,我不想更改我所有的代码。首先,这是一个荒谬的论点,更改微不足道。您可能会抗议:我也不想创建线程。说得好。Tony Hoare 在 1970 年代写道,NULL 是十亿美元的错误。他错了。实际上是 PHP。但是,他也详细讨论了使用线程构建系统是多么不可行。您将需要使用更高级别的抽象,如 sagas、actors,或者至少是 ExecutorService。
还有一个新的虚拟线程执行器:Executors.newVirtualThreadPerTaskExecutor。太棒了!如果您使用 Spring Boot,在系统中替换该类型的默认 bean 非常简单。Spring Boot 将会拉入它,并将其用作默认。足够容易。但是,如果您使用的是 Spring Boot 3.2,您肯定在使用 Spring Boot 3.2,对吧?您知道每个版本只支持大约一年,对吧?请务必查看任何给定 Spring 项目的支持策略。如果您使用的是 3.2,那么您只需要向 application.properties 添加一个属性,我们就会为您插入虚拟线程支持。
spring.threads.virtual.enabled=true
太棒了!无需更改代码。现在您应该看到可扩展性大大提高,并且如果您的服务是 IO 密集型的,您也许可以缩减负载均衡器中的实例数量。我的建议?告诉您的老板您将为公司节省大量资金,但坚持您希望将这笔钱添加到您的薪水中。然后部署此更改。搞定!
好了,我们进展很快。我们实现了 git clone & 运行能力。我们有了 Docker compose 支持。我们有 DevTools。我们有非常出色的语言和语法。我们有自由放飞的奇点。我们进展很快。我们有了可扩展性。在构建中添加 Spring Boot Actuator,现在您就有了可观察性。我认为是时候转向生产了。
我想打包这个应用程序,并使其尽可能高效。这里,我的朋友们,我们需要考虑几件事。首先,我们如何容器化应用程序?简单。使用 Buildpacks。容易。请记住,朋友不让朋友写 Dockerfiles。使用 Buildpacks。Spring Boot 原生支持它们:./gradlew bootBuildImage 或 ./mvnw spring-boot:build-image。不过,这不算新,所以下一个问题。
我们如何让这个东西尽可能高效和优化?在我们深入研究之前,我的朋友们,重要的是要记住,Java 已经非常非常非常高效了。我喜欢 2018 年(COVID 疫情前,或公元前)的这篇 博客。
它研究了哪些语言消耗的能源最少,或者说能源效率最高。C 语言能源效率最高。它消耗的电力最少。1.0。这是基线。它对机器很有效。对人无效!绝对不是对人。
然后我们有 Rust 及其零成本抽象。干得好。
然后我们有 C++... 
C++ 真恶心!继续……
然后我们有 Ada 语言,但是……谁在乎呢?
然后我们有 Java,它的效率接近 2.0。我们就约等于 2.0 吧。Java 的效率是 C 的两倍——两次!——或者说是 C 的一半。
到目前为止还不错?太棒了。它仍然排在前 5 名最高效的语言之列!
如果向下滚动列表,您会看到一些惊人的数字。Go 和 C# 的分数在 3.0+ 范围内。向下滚动,我们有 JavaScript 和 TypeScript,其中一个——令我费解——效率是另一个的四倍!
然后我们有 PHP 和 Hack,对它们少说为妙。继续!
然后我们有 JRuby 和 Ruby。朋友们,请记住 JRuby 是用 Java 编写的 Ruby。而 Ruby 是用 C 编写的 Ruby。尽管如此,JRuby 的效率几乎比 Ruby 高三分之一!仅仅因为它用 Java 编写并在 JVM 上运行。JVM 是一个了不起的工具。绝对非凡。
然后……我们有了 Python。而这,嗯,这让我非常伤心!我*喜欢* Python!我从 1990 年代就开始使用 Python!比尔·克林顿担任总统时我才学会 Python!但这些数字并不好。想想看。75.88。我们约等于 76。我不擅长数学。但您知道什么很擅长吗?该死的 Python!让我们问问它。

38!这意味着,如果您运行一个 Java 程序,运行它所需的能量产生一些碳排放,最终会困在大气中,提高温度,而升高的温度又会杀死一棵树,那么在 Python 中运行的等效程序将杀死三十八棵树!那是一片森林!那比比特币还糟糕!我们需要尽快采取行动。我不知道是什么,但必须做点什么。
总之,我想说的是,Java 已经令人惊叹。我认为这是因为人们理所当然的两件事:垃圾回收和即时(JIT)编译器。
垃圾回收,我们都知道它是什么。嘿,甚至白宫在其关于保护软件以确保网络空间构建块安全的最新报告中也赞赏像 Java 这样的垃圾回收、内存安全的语言。
Java 编程语言的垃圾回收器让我们能够编写平庸的软件并勉强蒙混过关。这太棒了!不过,我确实不同意它是原始的 Java 垃圾回收器的说法!那份荣誉属于其他地方,比如 Jesslyn。
JIT 也是另一个了不起的工具。它分析应用程序中经常访问的代码路径,并将它们转换为特定于操作系统和体系结构的本机代码。但它只能对您的一部分代码进行此操作。它需要知道在编译代码时使用的类型是运行时使用的唯一类型。而 Java 中的一些东西——一种非常动态的语言,其运行时更像 JavaScript、Ruby 和 Python 的运行时——允许 Java 程序执行违反此约束的操作。例如序列化、JNI、反射、资源加载和 JDK 代理。请记住,使用 Java,您可以有一个String,其内容是一个 Java 源代码文件,将该字符串编译到文件系统上的.class 文件,将.class 文件加载到ClassLoader中,通过反射创建一个该类的实例,然后——如果该类是一个接口——创建一个该类的 JDK 代理。并且,如果该类实现了java.io.Serializable,就可以将该类实例写入网络套接字并在另一个 JVM 上加载。您可以在不曾明确键入任何超过java.lang.Object 的引用类型的情况下完成所有这些操作!这是一种了不起的语言,而这种动态性使其成为一种非常高效的语言。它也阻碍了 JIT 的优化尝试。
不过,JIT 在力所能及的范围内做得非常出色。结果不言而喻。所以,有人不禁要问:为什么我们不能主动 JIT 整个程序?提前进行?我们可以。有一个名为 GraalVM 的 OpenJDK 发行版,它提供了许多便利功能,通过native-image 编译器等额外工具扩展了 OpenJDK 版本。原生镜像编译器非常棒。但这个原生镜像编译器有同样的限制。它无法对非常动态的东西发挥其魔力。这成了一个问题。因为大多数代码——您的单元测试库、您的 Web 框架、您的 ORM、您的日志库……所有东西!——都使用了一个或多个这些动态行为。
有一个解决办法。您可以以 .json 文件的形式提供配置,放在一个众所周知的目录中:src/main/resources/META-INF/native-image/$groupId/$artifactId/\*.json。这些 .json 文件有两个问题。
首先,“JSON”这个词听起来很蠢。我不喜欢说“JAY-SAWN”这个词。作为一个成年人,我无法相信我们对彼此说这些话。我说法语,而在法语中,你会读成jeeesã。所以,.gison。好多了。闽南语有一个词——gingsong(快乐),也可以用。所以你可以用 .gingsong。选择你的队伍!无论如何,.json 不应该存在。我支持 .gison,但这无关紧要。
第二个问题是,有太多东西是必需的!同样,只要想想您的程序中所有那些有趣的、动态的行为,比如反射、序列化、JDK 代理、资源加载和 JNI!这无穷无尽。您的 Web 框架。您的测试库。您的数据访问技术。我没有时间为每个程序编写手工制作的配置。我甚至没有足够的时间来完成这篇博文!
所以,取而代之的是,我们将使用 Spring Boot 中引入的 3.0 版本之后的 Spring Boot 提前(AOT)引擎。AOT 引擎会分析您 Spring 应用程序中的 bean,并为您生成所需的配置文件。太棒了!甚至还有一个完整的组件模型,您可以利用它来扩展 Spring 到编译时。我不会在这里详细介绍,但您可以阅读我的 免费电子书,或者观看我介绍所有关于 Spring 和 AOT 的免费 YouTube 视频。它们的内容基本相同,只是消费方式不同。
所以让我们用 Gradle 启动构建,./gradlew nativeCompile,或者如果您使用 Apache Maven,则使用 ./mvnw -Pnative native:compile。您可能想在此跳过测试……此构建将花费一些时间。请记住,它正在分析您代码库中的所有内容——无论是类路径上的库、JRE,还是您代码本身中的类——以确定应该保留哪些类型,以及应该丢弃哪些类型。结果是一个精益、高效、闪电般快速的运行时机器,但代价是编译时间非常、*非常*慢。
它耗时太长了,以至于会卡住我的流程。它让我停滞不前,等待着。我就像这篇博文前面提到的平台线程一样:阻塞了!我感到无聊。等待。等待。我现在终于明白了 著名的 XKCD 漫画。
有时我开始哼歌。或者主题曲。或者电梯音乐。您知道 电梯音乐听起来是什么样的,对吧?持续不断,永无止境。所以,我想,如果每个人都能听到电梯音乐,那不是很好吗?所以我问了。我得到了一些很棒的回应。
其中一个建议是,我们应该播放任天堂 64 电子游戏《007:黄金眼》——第一部皮尔斯·布鲁斯南饰演詹姆斯·邦德的电影——的原声带中的电梯音乐。我喜欢这个建议。
一个回复建议添加一个哔哔声会很有用。我完全同意。我那该死的微波炉在完成时会发出叮!的声音。为什么我那价值数百万行的编译器不行呢?
然后我们得到了这个回复,来自我最喜欢的医生之一,在 GraalVM 团队工作的 Niephaus 博士。他说添加电梯音乐只能治标,不能治本,根本问题在于如何让 GraalVM 在时间和内存方面更有效率。

好的。但他确实分享了这个有前途的原型!

我相信它很快就会被合并……
总之!如果您检查编译,它现在应该完成了。它位于 ./build/native/nativeCompile/ 文件夹中,名为 service。在我的机器上,编译花了 52 秒。我的天!
运行它。它会失败,因为,再说一遍,我们过着 git clone & 运行的生活!我们没有指定任何连接凭证!所以,用指定 SQL 数据库连接详细信息的环境变量来运行它。这是我在我的机器上使用的脚本。这只在类 Unix 系统上运行,并且适用于 Maven 或 Gradle。
#!/usr/bin/env bash
export SPRING_DATASOURCE_URL=jdbc:postgresql:///mydatabase
export SPRING_DATASOURCE_PASSWORD=secret
export SPRING_DATASOURCE_USERNAME=myuser
SERVICE=.
MVN=$SERVICE/target/service
GRADLE=$SERVICE/build/native/nativeCompile/service
ls -la $GRADLE && $GRADLE || $MVN
在我的机器上,它大约在 100 毫秒内启动!像火箭一样!而且,显然,如果我使用像 Spring Cloud Function 这样的东西来构建 AWS Lambda 风格的函数即服务,它会更快,因为我不需要打包 HTTP 服务器。事实上,如果纯粹的启动速度是*我*真正想要的全部,那么我甚至可能会使用 Spring 对 Project CRaC 的出色支持。这与本文无关。我并不特别在意,因为这是一个独立的、长期的服务。我关心的是资源使用情况,由常驻集大小 (RSS) 表示。请注意进程标识符 (PID)——它会在日志中。如果 PID 是,比如说,55,那么可以使用 ps 实用程序获取 RSS,该实用程序几乎在所有类 Unix 系统上都可用。
ps -o rss 55
它会输出一个以千字节为单位的数字;将其除以一千,您将得到以兆字节为单位的数字。在我的机器上,它运行只需要 100MB 多一点。您无法在那么小的内存中运行 Slack!我敢打赌,Chrome 中单个浏览器标签页占用的内存就这么多,或者更多!
因此,我们得到了一个程序,它尽可能简洁,同时又易于开发和迭代。它使用虚拟线程为我们提供无与伦比的可扩展性。它作为一个独立的、自包含的、特定于操作系统和体系结构的本机映像运行。哦!而且,它支持自由放飞的奇点!
我们生活在一个美好的时代。现在是成为一名 Java 和 Spring 开发者的最佳时机。我希望我已经成功地说服了您。
我以及 Spring 团队的其他成员将在微软的 JDConf 2024 上发表演讲!
注册并参加我的会议:JDConf:Bootiful Spring Boot 3
JDConf 上其他值得参加的 Spring 会议:
如果您喜欢这篇博文,我希望您订阅我们的 YouTube 频道,我每周都会在 Spring Tips 播放列表中发布新视频。当然,您可以在 Biodrop 上找到我的 Twitter/X、网站、YouTube 频道、书籍、播客等等。谢谢!