2024 年的 Spring Boot 之美【译】

AI 推荐语:文章《Bootiful Spring Boot in 2024 (part 1)》由 Josh Long 撰写,介绍了使用 Spring Boot 开发应用程序的新趋势和最佳实践。Josh 强调现在是 Java 和 Spring Boot 开发者的黄金时期,Spring Boot 的功能和易用性在过去几年里有了显著提升。Josh 讲解了如何使用 Java 21 的新特性,特别强调了通过 GraalVM 实现的 Native Image 支持。他还讨论了如何通过 Docker Compose 简化数据库连接和管理过程,并展示了 Spring Boot 的自动配置功能如何简化开发流程。文章还深入讨论了 Java 21 的改进之处,如更快的性能、更丰富的语法,以及通过记录(Records)和数据导向编程的支持。Josh 使用了一个简单的示例来说明如何定义和使用记录(Record),并提出了利用 Spring Boot 和 Java 21 的新功能如虚拟线程来提高开发效率和应用性能的可能性。

原文链接: Bootiful Spring Boot in 2024 (part 1)

代码示例: github.com/joshlong/bootiful-spring-boot-2024-blog

演讲视频: Bootiful Spring Boot 3 x by Josh Long


大家好,Spring 粉丝们!我是 Josh Long,在 Spring 团队任职。今年我很高兴能在 Microsoft 的 JDConf 上做主题演讲。作为 Kotlin GDE 和 Java Champion,我认为现在是 Java 和 Spring Boot 开发者的黄金时代。这话我是在完全了解我们目前所处历史阶段的情况下说的。现在离 Spring 框架和 Spring Boot 首次发布已分别过去了 21 年和 11 年,今年是 Spring 框架和 Spring Boot 的 20 周年和 10 周年纪念。所以,当我说现在是成为 Java 和 Spring 开发者的最佳时机时,请记住我在这个行业已经奋斗了几十年。我热爱 Java 和 JVM,我热爱 Spring,这段经历非常棒。

但现在是最好的时光,前所未有。所以,让我们像往常一样开始一个新的应用开发旅程,前往我在互联网上第二喜欢的地方 start.spring.io,你就会明白我说的。点击 Add Dependencies 并选择 WebSpring Data JDBCOpenAIGraalVM Native SupportDocker ComposePostgresDevtools

给项目起一个名字。我把我的服务命名为“service”。我在起名方面很有一套,这一点我继承自我父亲。我的父亲在取名方面也很有天赋。我小时候,我们家有一只小白狗,我父亲给它起名叫 White Dog,它是我们家很多年的宠物。但大约十年后,它就不见了。我不确定后来它怎样了。也许它找到了工作,我不清楚。但后来神奇的是,另一只小白狗出现在我们家的纱门前。我们便收留了它,我父亲给它起名叫 Too,或者是 Two。我不确定。不管怎样,他在取名方面很有一套。尽管如此,我妈妈总是对我说,幸好是她给我起的名字......嗯,这很可能是真的。

无论如何,选定 Java 21。这一点非常关键。如果你不使用 Java 21,那你就无法体验到它的优势。所以,你需要 Java 21。同时,我们还会使用 GraalVM 以利用其 Native Image 功能。

还没有安装 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
# 如果你使用的是 Apache Maven
# idea pom.xml
 

如果你使用 Visual Studio Code,一定要在 Visual Studio Code Marketplace 上安装 Spring Boot 扩展包

这个新应用会与数据库交互,这是一个以数据为中心的应用。在 Spring Initializr 上,我们添加了对 PostgreSQL 的支持,但现在我们需要连接到它。我们不想要一个长长的 README.md 文件,里面有一个标题为“开发的一百个简单步骤”的章节。我们想要的是 git clone 然后马上运行的体验!

为此,Spring Initializr 生成了一个 Docker Compose compose.yml 文件,其中包含了 Postgres 定义,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 配置了自动运行 Docker Compose (docker compose up),无需手动配置连接细节如 spring.datasource.urlspring.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;
 
// 妈妈看这里,不需要 Lombok!
record Customer(@Id Integer id, String name) {
}
 

我喜欢 Java 的记录(record)功能!你也应该喜欢它们!不要忽视 record。这个简单的 record 不仅仅是像 Lombok 的 @Data 注解那样更好的做某事的方式,它实际上是 Java 21 中几个特性的一部分,这些特性共同支持一种称为 数据导向编程(data-oriented programming)的概念。

Java 语言架构师 Brian Goetz 在他 2022 年的 InfoQ 文章 中讨论了这一点。

Java 之所以能在单体应用世界占据主导地位,是因为其强大的访问控制、优秀且迅速的编译器、隐私保护等。Java 让创建相对模块化、可组合的单体应用变得简单。单体应用通常是大型、广泛的代码库,Java 支持这种结构。实际上,如果你想要模块化并想要合理组织你的大型单体代码库,请参阅 Spring Modulith 项目。

但情况已经发生变化。如今,我们表达系统变化的途径不再是通过动态派发和多态性的抽象类型层次结构的专门实现,而是通过经常是临时的消息在 HTTP/REST、gRPC、Apache Kafka 和 RabbitMQ 等消息基础设施中跨线传递。关键在于数据!

Java 已经演变以支持这些新模式。让我们看看四个关键特性:记录、模式匹配、智能 switch 表达式和封闭类型,以更好地理解。假设我们工作在一个高度监管的行业,例如金融。

想象我们有一个名为 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 的实现:SecuredLoanUnsecuredLoan。默认情况下,类是开放继承的,这违反了密封层次结构所暗示的保证。因此,我们明确将 SecuredLoan 声明为 finalUnsecuredLoan 作为 record 实现,并且隐式为 final

记录是 Java 对元组的回应。它们本质上是元组。只是 Java 是一种名义语言:事物有名称。这个元组也有一个名称:UnsecuredLoan。如果我们同意它们所隐含的契约,那么记录就会给我们很大的权力。记录的核心理念是,对象的身份等于记录中字段的身份,它们被称为“组件”。所以在这个例子中,记录的身份等同于 interest 变量的身份。如果我们同意这一点,那么编译器就可以为我们提供构造函数,它可以为每个组件提供存储空间,它可以为我们提供 toString 方法、hashCode 方法和 equals 方法。它还会在构造函数中为组件提供访问器。太棒了!而且,它支持解构!语言知道如何从记录中提取状态。

现在,假设我想为每种 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.sqldata.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 和应用程序本身所需时间的三分之一,或减少了 66%。非常显著。

它已经启动并运行。访问 http://localhost:8080/customers 查看结果。它工作了!当然,它工作了。这是一个演示,它总是会成功的。

这些都是相当标准的东西。十年前你本可以做类似的事情。当然,那时的代码会更加冗长。但自那以后,Java 已经取得了飞跃式的进步。当然,那时的速度和现在无法比拟。另外,现在的抽象更加完善。但你确实可以做类似的事情 -- 一个管理数据库的 Web 应用。

情况在变化,总是有新的前沿。现在,新的前沿是人工智能,即 AI。

AI 是一个巨大的产业,但当大多数人谈论 AI 时,他们实际上指的是利用 AI。你不需要使用 Python 来使用大型语言模型 (LLM),就像大多数人不需要使用 C 来使用 SQL 数据库一样。你只需要与 LLMs 集成,在这里 Java 为选择和能力提供了无与伦比的优势。

在我们上一次重要的 SpringOne 开发者活动中,2023 年,我们推出了 Spring AI,一个旨在使集成和使用 AI 尽可能简单的新项目。

你可能想要摄取数据,例如来自账户、文件、服务甚至一组 PDF。你会希望将它们存储在一个向量数据库中以支持相似性搜索,并便于检索。然后你会想要将它们与一个 LLM 集成,提供那个向量数据库中的数据。

当然,存在任何你可能想要的 LLM 的提供方 -- Amazon BedrockAzure OpenAIGoogle Vertex Google GeminiOllamaHuggingFace,当然还有 OpenAI 本身,但这只是开始。

LLM 的所有知识都内置在一个模型中,然后这个模型为 LLM 的世界观提供信息。但这个模型有一种过期日期,之后它的知识会过时。如果模型是两周前构建的,它就不会知道昨天发生的事情。所以如果你想构建,比如说,一个自动助手来处理用户关于他们银行账户的请求,那么这个 LLM 在执行此操作时需要掌握最新的世界状态。

你可以在你发出的请求中添加信息,并使用它作为上下文来通知响应。如果事情仅此而已,那也不算太坏。但还有另一个问题。不同的 LLM 支持不同的令牌窗口大小。令牌窗口决定了你可以为给定请求发送和接收多少数据。窗口越小,你可以发送的信息就越少,LLM 在其响应中的信息量也就越少。

这里你可以做的一件事是将数据放在一个向量存储中,例如 pgvectorNeo4jWeaviate 等,然后将你的 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 客户端。

你需要用属性 spring.ai.openai.api-key= 配置你对 OpenAI API 的连接。我将其作为环境变量 SPRING_AI_OPENAI_API_KEY 导出,然后运行程序。我不会在这里公开我的密钥,请原谅我不泄露我的 API 凭据。

使用 CMD+Shift+F9 重新加载应用程序,然后访问端点:http://localhost:8080/story。LLM 可能需要几秒钟来产生响应,所以准备好一杯咖啡或一杯水,享受这短暂但令人满足的时刻。

AI 响应

我浏览器中的 JSON 响应,启用了 JSON 格式化插件。

就在那里!我们生活在一个奇迹的时代!奇迹的时代!现在你可以做任何事情。

但这确实花了一些时间,不是吗?我不怪计算机花了这么长时间!它做得很好!我做不到这么快。只是看看它生成的故事!这是艺术品。

但这确实花了一段时间。这对我们的应用程序有可扩展性的影响。在幕后,当我们向我们的 LLM 发出请求时,我们正在进行网络调用。在代码的深处,有一个 java.net.Socket,我们从中获得了一个 java.io.InputStream,代表来自服务的数据的 byte 数组。我不知道你是否还记得直接使用 InputStream。这是一个例子:

    try (var is = new FileInputStream("afile.txt")) {
        var next = -1;
        while ((next = is.read()) != -1) {
            next = is.read();
            // do something with read
        }
    }
 

看到我们通过调用 InputStream.readInputStream 中读取字节的那部分了吗?我们称之为阻塞操作。如果我们在第四行调用 InputStream.read,那么我们必须等到调用返回才能到达第五行。

如果我们连接的服务返回的数据太多怎么办?如果服务宕机了呢?如果它永远不返回怎么办?如果我们被困住了,永远等待怎么办?如果

如果这只发生一次,这是乏味的。但如果这在系统中用于处理 HTTP 请求的每个线程上都可能发生,这对我们的服务来说是一个存在的威胁。这种情况很多。这就是为什么有可能登录到一个其他不响应的 HTTP 服务并发现 CPU 基本上在睡觉 -- 闲置! -- 几乎什么都没做或做得很少。线程池中的所有线程都处于等待状态,等待一些不会到来的东西。

这对我们支付的宝贵 CPU 来说是一个巨大的浪费。即使最好的情况也不够好。即使该方法最终会返回,这仍然意味着正在处理该请求的线程对系统中的任何其他事情都不可用。该方法正在垄断该线程,所以系统中的其他任何人都不能使用它。如果线程便宜且充足,这不会是问题。但事实并非如此。在 Java 的大部分生命周期中,每个新线程都与一个操作系统线程一对一配对。这并不便宜。每个线程都有一定数量的簿记。所以你无法创建很多线程,你正在浪费你所拥有的少数几个线程。恐怖!谁还需要睡觉呢?

必须有更好的方式。

你可以使用非阻塞 IO。像 hemorrhoid-inducing 和复杂的 Java NIO 库这样的东西。这是一个选择,就像和一家臭鼬家族生活在一起一样:它很臭!我们大多数人不以非阻塞 IO 或常规 IO 的方式思考。我们生活在抽象层次的更高阶层上。我们可以使用反应式编程。我爱反应式编程。我甚至写了一本关于它的书 -- Reactive Spring。但如果你不习惯像函数式程序员那样思考,那么如何使其工作就不那么明显了。这是一个不同的范式,并且意味着你的代码重写。

如果我们可以既要非阻塞蛋糕又要吃它怎么办?现在我们可以使用 Java 21!有一个名为虚拟线程的新特性使这些东西变得容易得多!如果你在这些新的虚拟线程上做一些阻塞的事情,运行时将检测到你正在做一些阻塞的事情 -- 像 java.io.InputStream.readjava.io.OutputStream.writejava.lang.Thread.sleep -- 并将那个阻塞的、空闲的活动从线程移出并移到 RAM 中。然后,它基本上会为 sleep 设定一个蛋形计时器,或监视文件描述符进行 IO,并让运行时在此期间将线程用于其他东西。当阻塞动作完成时,运行时将其移回到线程上,并让它从停止的地方继续运行,几乎不需要更改代码。这很难理解,所以让我们通过一个例子来看看。我毫不羞愧地借用了这个示例,来自 Oracle Developer Advocate José Paumard

此示例演示创建 1000 个线程并在每个线程上 sleep 400 毫秒,同时注意这 1000 个线程中的第一个的名称。

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 -> {
 
            // 存储所有 1000 个线程
            var threads = new ArrayList<Thread>();
 
            // 用 Set<T> 去重
            var names = new ConcurrentSkipListSet<String>();
 
            // 感谢 Oracle 的 José Paumard
            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 年代以来创建的线程没有什么不同。该程序创建了 1000 个线程。在每个线程中,sleep 100 毫秒,四次。在此期间,我们检查我们是否是 1000 个线程中的第一个,如果是,我们通过将其添加到集合中来记录当前线程的名称。集合通过其元素去重;如果相同的名称出现多次,集合中仍然只有一个元素。

运行程序(CMD+Shift+F9!)你会看到程序的物理特性没有改变。Set<String> 中只有一个名称。为什么不会呢?我们一次又一次地测试同一个线程。

现在,将该构造函数更改为使用虚拟线程Thread.ofVirtual。超级简单的更改。现在运行程序。CMD+Shift+F9。

你会看到集合中有多个元素。你根本没有改变代码的核心逻辑。而且你甚至只需要更改件事,但现在,在幕后编译器和运行时无缝地重写了你的代码,以便当虚拟线程上发生某些阻塞事件时,运行时无缝地将你从线程上取下并在阻塞事务结束后将你放回线程。这意味着你之前存在的线程现在可以用于系统的其他部分。你的可扩展性将会突破屋顶!

你可能会抗议,好吧,我不想改变所有的代码。首先,这是一个荒谬的论点,变化是微不足道的。你可能会抗议:我也不想从事创建线程的业务。好观点。托尼·霍尔(Tony Hoare)在 1970 年代写道,NULL 是一个 10 亿美元的错误。他错了。事实上,是 PHP。但是,他还长篇大论地讨论了使用线程构建系统是多么不可行。你将想要使用更高阶的抽象,如 saga、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 大流行之前,或 BC

Which Programming Languages Use the Least Electricity?

它研究了哪些语言使用的电力最少,或者是最节能的。C 是最节能的。它使用的电力最少。1.0。它是基准。它很高效......对于机器。不是人!绝对,不是 人。

然后我们有 Rust 和它的零成本抽象。做得好。

然后我们有 C++... gross

C++ 是恶心的!继续……

然后我们有 Ada 语言,但是...谁在乎?

然后我们有 Java,几乎是 2.0。我们就四舍五入说 2.0。Java 是 C 的两倍 -- 两倍! -- 效率低下。或者是 C 的一半效率。

到目前为止还好吧?太好了。它是前五种最高效的语言之一!

如果你滚动列表,你会看到一些惊人的数字。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!让我们问它。

python doing math

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 编译器。它很棒,但这个 native-image 编译器有相同的约束,它不能为非常动态的事情施展魔法。这是一个问题。因为大多数代码 -- 你的单元测试库、你的 Web 框架、你的 ORMs、你的日志库......一切! -- 都使用了这些动态行为中的一个或全部。

有一个逃生舱口。你可以以 .json 文件的形式为 GraalVM 编译器提供配置,在一个众所周知的目录中:src/main/resources/META-INF/native-image/$groupId/$artifactId/\\*.json。这些 .json 文件有两个问题。

首先,“JSON”这个词听起来很愚蠢。我不喜欢说“JAY-SAWN”这个词。作为一个成年人,我不敢相信我们彼此说这些话。我会说法语,在法语中,你会发音为jeeesã。所以,.gison。更好。闽南语有一个词 -- gingsong(幸福),也可以工作。所以你可以有 .gingsong。选择你的团队!无论如何,.json 不应该成立。我是 .gison 团队的,但无关紧要。

第二个问题是,哦,需要的东西太多了!再想想你的程序在哪些地方做这些有趣的、动态的事情,如反射、序列化、JDK 代理、资源加载和 JNI!这是无尽的。你的 Web 框架、你的测试库、你的数据访问技术我没有时间为每个程序编写手工制作的配置文件。我甚至没有足够的时间来完成这篇博客!

所以,相反,我们将使用 3.0 中引入的 Spring Boot 提前时间(AOT)引擎。AOT 引擎分析你的 Spring 应用中的 bean 并为你生成必需的配置文件。太好了!还有一个你可以使用的整个组件模型,它扩展了 Spring 到编译时。我不会在这里详细介绍所有这些,但你可以阅读我的免费电子书或观看我关于 Spring 和 AOT 的免费 YouTube 视频。基本上是相同的内容,只是消费方式不同。

让我们用 Gradle 启动构建,./gradlew nativeCompile,或者如果你使用 Apache Maven,./mvnw -Pnative native:compile。你可能想跳过这个......这个构建会花一些时间。记住,它正在分析你代码库中的一切 -- 无论是类路径上的库、JRE 还是你代码中的类 -- 来确定它应该保留哪些类型,哪些应该丢弃。结果是一个精简、高效、快速启动的运行时机器,但代价是非常、非常 慢的编译时间。

它花的时间如此之长,以至于它有点阻碍了我的流程。它让我停下来,等待。我就像前面这篇博客中提到的那些平台线程:被阻塞!我开始无聊。等待。等待。我现在终于明白这个著名的 XKCD 漫画

XKCD

有时我开始哼歌,或主题歌,或电梯音乐。你知道电梯音乐听起来像什么,对吧?无尽,无休止。所以,我想,如果每个人都听到电梯音乐,那不是很好吗?所以我问了。我得到了一些很棒的回复。

一个建议,来自我们的朋友,我们应该从 Nintendo 64 视频游戏的原声带中播放这首电梯音乐,皮尔斯·布鲁斯南(Pierce Brosnan)首次扮演詹姆斯·邦德(James Bond),Goldeneye。我喜欢它。

adinn elevator music

Goldeneye 有一些了不起的电梯音乐!

有一个回应建议有一个哔哔声会很有用。完全同意。我的愚蠢微波炉在完成时会发出 叮! 声。我的多百万行编译器为什么不行呢?

ivan beeps

叮!

然后我们得到了这个回应,来自我最喜欢的专家之一,Dr. Niephaus,他在 GraalVM 团队工作。他说增加电梯音乐只会修复问题的症状,而不是导致问题的原因,即使 GraalVM 在时间和内存方面更加高效。

doctor niephaus

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

graalvm prototype

我敢肯定它很快就会合并......

无论如何!如果你检查编译,现在应该完成了。它在 ./build/native/nativeCompile/ 文件夹中,名为 service。在我的机器上,编译花了 52 秒。哎呀!

运行它。它会失败,因为,我们生活在 git clone 然后运行的生活方式中!我们没有指定任何连接凭证!所以,用环境变量运行它,指定你的 SQL 数据库连接详细信息。这是我在我的机器上使用的脚本。这只适用于类 Unix 操作系统,并且适用于 Maven 或 Gradle。

#!/usr/bin/env bash
 
export SPRING_DATASOURCE_URL=jdbc:postgresql://localhost/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 风格的函数即服务(FaaS),因为我不需要打包 HTTP 服务器,它会更快。事实上,如果纯粹的启动速度是我真正想要的全部,那么我甚至可能使用 Spring 对 Project CRaC 的惊人支持。那不是这里的重点。我并不真正关心那个,因为这是一个独立的、长寿命的服务。我关心的是资源使用情况,由 驻留集大小 (RSS) 代表。注意进程标识符 (PID) -- 它会在日志中。如果 PID 是,比方说,55,那么使用 ps 实用程序获取 RSS,几乎在所有 Unix 上都可用:

ps -o rss 55

它会以千字节为单位输出一个数字;除以一千,你会得到以兆字节为单位的数字。在我的机器上,运行它只需要稍微超过 100MB 的内存。你甚至无法在那么少的内存中运行 Slack!我敢打赌你在 Chrome 中的单个浏览器标签占用的内存就有这么多,或者更多!

所以,我们有一个程序,尽可能简洁,同时易于开发和迭代。并且它使用虚拟线程来提供无与伦比的可扩展性。它作为一个独立的、自包含的、特定于操作系统和架构的 Native Image 运行。哦!而且,它支持奇妙的奇迹!

我们生活在一个了不起的时代。成为 Java 和 Spring 开发者的时机从未如此之好。我希望我也说服了你。