SpringBoot3-秘籍-全-
SpringBoot3 秘籍(全)
原文:
zh.annas-archive.org/md5/03856d9ba57ded8ee30c8c15c1691585译者:飞龙
前言
Spring Boot 是 Java 在 Web 和微服务开发中最受欢迎的框架。它允许你通过遵循“约定优于配置”的方法,以最小的配置创建生产级的应用程序。
Spring Boot 始终在演变和适应最新的技术趋势。其生态系统允许你与任何技术集成,从数据库到 AI,并使用诸如可观测性和安全性等跨度功能。你可以用它来开发几乎任何类型的应用程序。
在本书中,我们将通过实际操作的方式涵盖最常见的场景,并帮助你掌握使用大量功能的基础。
本书面向的对象
本书面向希望获得现代 Web 开发专业知识的 Java 开发者、设计复杂系统的架构师、经验丰富的 Spring Boot 开发者和希望跟上最新趋势的技术爱好者,以及需要解决日常挑战的软件工程师。需要具备 Java 的实际操作经验。在云上的开发经验将很有用,但不是必需的。
本书涵盖的内容
第一章, 构建 RESTful API,教你如何使用 Spring Boot 3 编写、消费和测试 RESTful API。
第二章, 使用 OAuth2 保护 Spring Boot 应用程序,展示了如何部署授权服务器并使用它来保护 RESTful API 和网站。你将学习如何使用 Google 账户进行用户认证。你还将学习如何使用 Azure AD B2C 保护应用程序。
第三章, 可观测性、监控和应用管理,探讨了如何利用 Spring Boot 中 Actuator 提供的可观测性功能。本章使用 Open Zipkin、Prometheus 和 Grafana,消费由 Spring Boot 应用程序暴露的可观测性数据以进行监控。
第四章, Spring Cloud,介绍了如何使用 Spring Cloud 开发由多个微服务组成的分布式系统。我们将使用 Eureka Server、Spring Cloud Gateway、Spring Config 和 Spring Boot Admin。
第五章, 使用 Spring Data 与关系型数据库进行数据持久化和集成,深入探讨了如何使用 Spring Data JPA 将应用程序与 PostgreSQL 集成。你将定义仓库并使用Java 持久化查询语言(JPQL)和原生 SQL。你将学习使用事务、数据库版本控制和 Flyway 以及 Testcontainers 进行集成测试。
第六章,使用 Spring Data 与 NoSQL 数据库进行数据持久性和集成,解释了使用如 MongoDB 和 Cassandra 等 NoSQL 数据库的优缺点,教你如何应对 NoSQL 数据库的一些常见挑战,例如数据分区或并发管理。你将使用 Testcontainers 对 MongoDB 和 Cassandra 进行集成测试。
第七章,寻找瓶颈并优化你的应用程序,描述了如何使用 JMeter 对 Spring Boot 应用程序进行负载测试,应用不同的优化,如缓存或构建本地应用程序,并将改进与原始结果进行比较。你还将学习一些有用的技术来准备本地应用程序,例如使用 GraalVM 追踪代理。
第八章,Spring Reactive 和 Spring Cloud Stream,探讨了如何使用 Spring Reactive 处理高并发场景,创建一个反应式 RESTful API、一个反应式 API 客户端以及 PostgreSQL 的 R2DBC 驱动程序。你将学习如何创建一个使用 Spring Cloud Stream 连接到 RabbitMQ 服务器的基于事件的驱动应用程序。
第九章,从 Spring Boot 2.x 升级到 Spring Boot 3.0,解释了如何手动将 Spring Boot 2.6 应用程序升级到 Spring Boot 的最新版本。在升级到 Spring Boot 3 之前,你需要准备应用程序,并在升级后逐步修复所有问题。你还将学习如何使用 OpenRewrite 来自动化迁移过程的一部分。
为了充分利用这本书
你将需要 JDK 21 来阅读这本书的所有章节。在第九章中,你还需要 JDK 11 和 JDK 17。我建议使用 SDKMAN!这样的工具在你的电脑上安装和配置 SDK。如果你使用 Windows,你可以使用 JDK 安装程序。
我为所有示例使用了 Maven 作为依赖和构建系统。你可以选择在你的电脑上安装它,但书中创建的所有项目都使用了 Maven Wrapper,它会在需要时下载所有依赖项。
如果你是一名 Windows 用户,我推荐使用Windows 子系统 Linux(WSL),因为这本书中使用的某些辅助工具在 Linux 上可用,而且书中 GitHub 仓库中的脚本仅在 Linux 上进行了测试。实际上,我也是一名 Windows 用户,并使用 WSL 为这本书准备的所有示例。
我还推荐安装 Docker,因为它是运行这本书中集成的某些服务(如 PostgreSQL)的最简单方式。Docker 是运行由不同应用程序组成、在您的计算机上相互通信的分布式系统的最佳选择。此外,大多数集成测试都使用 Testcontainers,这需要 Docker。
我试图在本书中解释所有示例,而不需要特定的 IDE 要求。我主要使用 Visual Studio Code,因为它与 WSL 的集成非常出色,但您可以使用任何其他您偏好的 IDE,例如 IntelliJ 或 Eclipse。
| 本书中涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| OpenJDK 21 | Windows、macOS 或 Linux |
| OpenJDK 11 和 17 | Windows、macOS 或 Linux |
| Docker | Windows(推荐与 WSL 集成),macOS 或 Linux |
| Prometheus | 在 Docker(推荐)或 Windows、macOS 或 Linux 上原生运行 |
| Grafana | 在 Docker(推荐)或 Windows、macOS 或 Linux 上原生运行 |
| OpenZipkin | 在 Docker(推荐)或 Windows、macOS 或 Linux 上原生运行 Java |
| PostgreSQL | 在 Docker(推荐)或 Windows、macOS 或 Linux 上原生运行。 |
| MongoDB | 在 Docker(推荐)或 Windows、macOS 或 Linux 上原生运行 |
| Apache Cassandra | 在 Docker(推荐)或 Linux 上原生运行 |
| RabbitMQ | 在 Docker(推荐)或 Windows、macOS 或 Linux 上原生运行 |
| JMeter | Windows、macOS 或 Linux |
| 一种 IDE,如 Visual Studio Code/IntelliJ | Windows、macOS 或 Linux |
如果您使用的是本书的数字版,我们建议您自己输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。如果代码有更新,它将在现有的 GitHub 仓库中更新。一些食谱使用之前的食谱作为起点。在这些情况下,我在每个食谱的start子文件夹中提供一个工作版本,在end文件夹中提供一个完整版本。
我们还有来自我们丰富的图书和视频目录的其他代码包,可在 github.com/PacktPublishing/ 获取。查看它们!
使用约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“此行为可以使用@Transactional注解的传播属性进行配置。”
代码块设置如下:
em.getTransaction().begin();
// do your changes
em.getTransaction().commit();
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
public int getTeamCount() {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM teams", Integer.class);
}
任何命令行输入或输出都按以下方式编写:
docker-compose -f docker-compose-redis.yml up
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“如果您现在运行应用程序,您将看到一个名为match-events-topic.score.dlq的新队列。”
小贴士或重要注意事项
显示如下。
部分
在本书中,您会发现一些频繁出现的标题(准备工作、如何操作...、工作原理...、更多内容...和参见)。
为了清楚地说明如何完成食谱,请按照以下方式使用这些部分。
准备工作
本节告诉您在食谱中可以期待什么,并描述了如何设置任何软件或任何为食谱所需的初步设置。
如何操作…
本节包含遵循食谱所需的步骤。
工作原理...
本节通常包含对前一个节段发生事件的详细解释。
更多内容...
本节包含有关食谱的附加信息,以便您对食谱有更深入的了解。
参见
本节提供了对食谱其他有用信息的链接。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 将邮件发送给我们。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了Spring Boot 3.0 烹饪书,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,这将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在路上阅读,但又无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
请放心,现在,每购买一本 Packt 图书,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不仅限于此,您还可以获得独家折扣、时事通讯以及每天收件箱中的优质免费内容。
按照以下简单步骤获取这些好处:
- 扫描二维码或访问以下链接

packt.link/free-ebook/9781835089491
-
提交您的购买证明
-
就这些!我们将直接将免费 PDF 和其他福利发送到您的邮箱
第一部分:Web 应用和微服务
在本部分,我们介绍了 RESTful API、分布式应用和 Spring Cloud 微服务的基础知识,以及跨功能特性,如安全和可观察性。
本部分包含以下章节:
-
第一章, 构建 RESTful API
-
第二章, 使用 Oauth2 保护 Spring Boot 应用
-
第三章, 可观察性、监控和应用管理
-
第四章, Spring Cloud
第一章:构建 RESTful API
在现代云应用程序中,RESTful API 对于无缝数据交换至关重要,它使得服务之间能够实现互操作性、可扩展性和高效通信。Spring Boot 通过提供快速、高效开发、自动配置和集成工具的框架,简化了 RESTful API 的编写。
在本章中,您将获得创建 RESTful 服务和无缝地从其他应用程序中消费它们的技能。您还将学习如何使用 Spring Boot 和其他流行工具提供的功能为您的 RESTful API 创建自动化测试。
在本章中,我们将介绍以下主要食谱:
-
创建 RESTful API
-
定义 API 暴露的响应和数据模型
-
在 RESTful API 中管理错误
-
测试 RESTful API
-
使用 OpenAPI 来记录我们的 RESTful API
-
使用 FeignClient 从另一个 Spring Boot 应用程序中消费 RESTful API
-
使用 RestClient 从另一个 Spring Boot 应用程序中消费 RESTful API
-
模拟 RESTful API
技术要求
要完成本章的食谱,您需要一个运行任何操作系统(我使用的是 Windows Subsystem for Linux – WSL 上的 Ubuntu)的计算机,一个编辑器,如 Visual Studio Code (code.visualstudio.com/) 或 IntelliJ Idea (www.jetbrains.com/idea/)),以及 Java OpenJDK 17 或更高版本。
来自不同供应商的 Java 有多种发行版——如果您已经安装了一个,您可以继续使用它;如果您需要安装一个,可以使用 Eclipse Adoptium 发行版(adoptium.net/)。
如果您使用 Visual Studio Code,我建议安装 Java 扩展包(marketplace.visualstudio.com/items?itemName=vscjava.vscode-java-pack)和 Spring Boot 扩展包(marketplace.visualstudio.com/items?itemName=vmware.vscode-boot-dev-pack)。
如果您没有用于执行 HTTP 请求的工具,可以使用 curl (curl.se/) 或 Postman (www.postman.com/)。
最后,您可以在 GitHub 上下载完整的项目:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/.
您需要一个 git 客户端来从本书的 GitHub 仓库下载代码(git-scm.com/downloads)。
创建 RESTful API
RESTful API是一种标准化的方式,软件组件可以通过 HTTP 方法和 URL 在互联网上进行通信。你应该学习它,因为它对于现代 Web 和云应用开发是基础。它促进了可扩展、灵活和无状态的通信,使开发者能够设计高效且广泛兼容的系统。理解 RESTful API 对于构建和集成服务至关重要。
当我还是个孩子的时候,我玩足球球员卡片,用我多余的卡片和朋友交换。我的孩子们,几十年后,还在玩这个游戏。在本章中,你将创建一个管理系统,用于管理足球卡片交易游戏,包括球队、球员、专辑和卡片。在这个菜谱中,你将创建一个暴露足球球员创建、读取、更新和删除(CRUD)操作的 RESTful API。
准备中
要创建 RESTful API,Spring Boot 提供了一个名为 Spring Initializr 的强大工具。你可以在浏览器中使用start.spring.io/打开这个工具。我们将使用这个工具创建一个包含所有依赖项的 Spring Boot 项目。这个工具也与代码编辑器如 VSCode 和 IntelliJ(高级版)很好地集成。
如何操作...
让我们使用 Spring Initializr 创建一个 RESTful 项目,并使用典型的 HTTP 操作创建我们的第一个端点:
- 在你的浏览器中打开
start.spring.io,你会看到以下屏幕:

图 1.1:Spring Initializr
Spring Initializr 允许你配置项目,并生成带有必要文件和依赖项的结构,你可以将其用作应用程序的起点。在这个起始页面上,设置以下配置:
-
在项目部分,选择Maven
-
在语言部分,选择Java
-
在Spring Boot部分,选择最新稳定版本——在撰写本书时,这是3.1.4
-
在依赖项部分,选择Spring Web
-
在
com.packt中执行以下操作 -
对于
football -
《Spring Boot 3 Cookbook》演示项目
-
对于打包,选择Jar
-
对于Java,选择21
-
一旦你配置了前面的选项,你可以选择生成、探索或分享…的选项:
-
如果你点击探索,你可以在下载之前探索项目。
-
如果你点击分享,将生成一个你可以与他人分享的 URL。例如,我们的配置将生成以下 URL:
start.spring.io/#!type=maven-project &language=java&platformVersion=3.1.3&packaging=jar&jvmVersion=1 7&groupId=compackt&artifactId=football&name=football&description=Demo%20project%20for%20Spring%20Boot%203%20Cookbook&packageName=com.packt.football&dependencies=web. 如果你打开它,它将配置如图 1.1所示的选项。 -
如果您点击 生成,它将下载项目结构的 ZIP 文件。现在点击此选项。
-
-
解压文件。现在您有了基本的项目结构,但您还没有任何 API。如果您尝试运行应用程序,您将收到 HTTP 404 Not Found 响应。
-
在
src/main/java/com/packt/football文件夹中,创建一个名为PlayerController.java的文件,并包含以下内容以创建 RESTful 端点:package com.packt.football; import java.util.List; import org.springframework.web.bind.annotation.*; @RequestMapping("/players") @RestController public class PlayerController { @GetMapping public List<String> listPlayers() { return List.of("Ivana ANDRES", "Alexia PUTELLAS"); } } -
要运行它,请在项目根目录中打开一个终端并执行以下命令:
./mvnw spring-boot:run此命令将构建您的项目并启动应用程序。默认情况下,Web 容器监听端口
8080。 -
执行一个 HTTP 请求以查看结果。您可以在浏览器中打开
http://localhost:8080/players,或者使用 curl 等工具执行请求:curl http://localhost:8080/players您将收到控制器返回的玩家列表。
-
通过添加更多动词来增强您的 RESTful 端点。在
PlayerController.java文件中,执行以下操作:-
实现一个用于创建玩家的 POST 请求:
@PostMapping public String createPlayer(@RequestBody String name) { return "Player " + name + " created"; } -
添加另一个 GET 请求以返回一个玩家:
@GetMapping("/{name}") public String readPlayer(@PathVariable String name) { return name; } -
添加一个 DELETE 请求以删除一个玩家:
@DeleteMapping("/{name}") public String deletePlayer(@PathVariable String name) { return "Player " + name + " deleted"; } -
实现一个用于更新玩家的 PUT 请求:
@PutMapping("/{name}") public String updatePlayer(@PathVariable String name, @RequestBody String newName) { return "Player " + name + " updated to " + newName; }
-
-
按照第 5 步中的说明再次执行应用程序并测试您的端点。在终端中输入以下命令执行 GET 请求:
curl http://localhost:8080/players/Ivana%20ANDRES Ivana ANDRES使用 curl 执行 POST 请求:
curl --header "Content-Type: application/text" --request POST --data 'Itana BONMATI' http://localhost:8080/players Player Itana BONMATI created Perform a DELETE request:curl --header "Content-Type: application/text" --request DELETE http://localhost:8080/players/Aitana BONMATI
And you will receive this output:玩家 Aitana BONMATI 已被删除
它是如何工作的...
通过将 Spring Web 依赖项添加到我们的项目中,Spring Boot 自动将 Tomcat 服务器嵌入到应用程序中。start.spring.io 并监听端口 8080,这是 Tomcat 的默认端口。然而,由于应用程序中没有配置映射,它总是响应 404 Not Found 错误。
通过添加 PlayerController 类,我们通知 Spring Boot 应将其 PlayerController 类注册到依赖容器中作为实现类。通过添加 PlayerController 类。由于我们在类级别上应用了这些,我们已使用 players 前缀配置了此类中的所有请求。
最后一步是将请求映射到处理方法。这是通过使用映射注解来完成的:
-
@GetMapping:将 GET 请求映射到方法 -
@PostMapping:将 POST 请求映射到方法 -
@PutMapping:将 PUT 请求映射到方法 -
@DeleteMethod:将 DELETE 请求映射到方法
这些映射注解是 @RequestMapping 的一个特殊化,因为它们通知 Web 容器如何将请求映射到其处理器,在这种情况下,使用注解的方法。
请记住,每个控制器(即,一个 GetMapping 或 PostMapping)只能有一个这种映射类型,除非您提供更多配置来细化映射。在这个例子中,您可以看到有两个 @GetMapping 实例,但 readPlayer 被注解了额外的元素,因此它被映射到其类前缀 players 加上名称。这意味着所有以 /players/anything 开头的 GET 请求都将映射到这个方法。
到目前为止,这些额外信息尚未在方法中配置。要使用所有这些额外的 HTTP 请求信息在您的方 法中,您可以使用以下注解:
-
@GetMapping("/{name}") public String readPlayer(@PathVariable String name)将路径的最后一部分映射到name方法参数。 -
@RequestBody:这将把请求体映射到方法参数。
-
@RequestHeader:这将把请求头映射到方法参数。
-
@RequestParam:您可以使用这个注解来映射请求参数,例如查询字符串参数、表单数据或 multipart 请求中的部分。
只需用之前的注解装饰我们的类,Spring Boot 就能设置好 Web 应用程序容器来管理请求。还有一些注解尚未介绍,但我们已经介绍了创建 RESTful API 的基础知识。
还有更多...
即使我们所创建的 RESTful 端点非常简单,我也故意添加了这些方法 - 默认的 GET、带有标识符的 GET、POST、PUT 和 DELETE。这个选择是基于它与执行 CRUD 和列表操作的最普遍语义相一致。
在我们的资源是足球球员的上下文中,我们有以下操作:
-
默认情况下,
GET通常返回资源列表,在我们的例子中,是所有球员 -
带有标识符的
GET返回一个特定的球员 -
POST创建一个新的资源 -
PUT更新一个资源 -
DELETE删除一个资源
此外,HTTP 状态码响应在 RESTful 操作的语义中非常重要。在这个菜谱中,响应不是按照标准方式管理的。在接下来的菜谱中,我们将扩展这一点,学习 Spring Boot 如何促进正确处理响应。
参见
如果您想了解更多关于 API 设计的信息,您可以访问以下页面:
定义响应和 API 暴露的数据模型
在前一个菜谱中,我们创建了一个非常简单的 RESTful API。为了开发一个为消费者提供良好用户体验的 RESTful API,必须结合使用标准响应代码和一致的数据模型。在这个菜谱中,我们将通过返回标准响应代码并为我们的玩家端点创建数据模型来增强先前的 RESTful API。
准备工作
您可以使用之前菜谱中生成的项目或从 GitHub 仓库下载样本:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。
您可以在chapter1/recipe1-2/start文件夹中找到启动此练习的代码。
如何做...
在这个菜谱中,我们将创建一个文件夹结构来包含我们项目中的不同类型的类。我们将定义一个数据模型来在我们的 RESTful API 中公开,以及一个提供 API 所需操作的服务。
注意,以下步骤中创建的所有内容都将位于src/main/java/com/packt/football文件夹或您在需要时创建的子文件夹中。让我们开始吧:
-
创建一个名为
model的文件夹。然后在这个文件夹中创建一个名为Player.java的文件,内容如下:public record Player(String id, int jerseyNumber, String name, String position, LocalDate dateOfBirth) { } -
创建一个名为
exceptions的文件夹。在这个文件夹中创建两个文件:-
第一个,
AlreadyExistsException.java,应包含以下内容:package com.packt.football.exceptions; public class AlreadyExistsException extends RuntimeException { public AlreadyExistsException(String message) { super(message); } } -
第二个,
NotFoundException.java,应包含以下内容:package com.packt.football.exceptions; public class NotFoundException extends RuntimeException { public NotFoundException(String message) { super(message); } }
-
-
创建另一个名为
services的文件夹,并在该文件夹中创建一个名为FootballService的类。这个类管理我们 RESTful API 所需的所有操作。在这个文件中执行以下操作:-
首先创建这个类:
@Service public class FootballService { }
这个类将使用
Map管理数据,将所有玩家保存在内存中。-
现在,让我们定义一个
Map<String, Player>字段并初始化它。(为了简洁起见,我只创建了两个条目,但在 GitHub 仓库中,您将找到更多):private final Map<String, Player> players = Map.ofEntries( Map.entry("1884823", new Player("1884823", 5, "Ivana ANDRES", "Defender", LocalDate.of(1994, 07, 13))), Map.entry("325636", new Player("325636", 11, "Alexia PUTELLAS", "Midfielder", LocalDate.of(1994, 02, 04 )))); -
定义我们 RESTful API 所需的操作:
- 首先列出玩家:
public List<Player> listPlayers() { return players.values().stream() .collect(Collectors.toList()); }- 然后返回一个玩家(请注意,如果玩家不存在,它将抛出一个异常):
public Player getPlayer(String id) { Player player = players.get(id); if (player == null) throw new NotFoundException("Player not found"); return player; }- 添加一个新玩家(请注意,如果玩家已经存在,它将抛出一个异常):
public Player addPlayer(Player player) { if (players.containsKey(player.id())) { throw new AlreadyExistsException("The player already exists"); } else { players.put(player.id(), player); return player; } }- 更新一个玩家(请注意,如果玩家尚未存在,它将抛出一个异常):
public Player updatePlayer(Player player) { if (!players.containsKey(player.id())) { throw new NotFoundException("The player does not exist"); } else { players.put(player.id(), player); return player; } }- 删除一个玩家(请注意,如果玩家不存在,它将无错误地继续):
public void deletePlayer(String id) { if (players.containsKey(id)) { players.remove(id); } }
-
-
接下来,在
PlayerController类中,修改控制器以使用我们新的服务并公开新创建的数据模型:-
在
PlayerController类中添加一个FootballService字段,并创建一个带有类型为FootballService的参数的构造函数来初始化它:@RequestMapping("/players") @RestController public class PlayerController { private FootballService footballService; public PlayerController(FootballService footballService) { this.footballService = footballService; } } -
创建管理玩家的操作。我们将使用我们最近创建的服务来管理该功能。如前一个菜谱中所述,我们将装饰我们的类方法来管理 RESTful 端点方法,并将调用我们的
Football服务类的服务:@GetMapping public List<Player> listPlayers() { return footballService.listPlayers(); } @GetMapping("/{id}") public Player readPlayer(@PathVariable String id) { return footballService.getPlayer(id); } @PostMapping public void createPlayer(@RequestBody Player player) { footballService.addPlayer(player); } @PutMapping("/{id}") public void updatePlayer(@PathVariable String id, @RequestBody Player player) { footballService.updatePlayer(player); } @DeleteMapping("/{id}") public void deletePlayer(@PathVariable String id) { footballService.deletePlayer(id); }
-
-
在
application根目录下,打开终端并执行以下命令以运行应用程序:./mvnw spring-boot:run -
通过执行以下
curl命令来测试应用程序,以获取所有球员:curl http://localhost:8080/players [{"id":"325636","jerseyNumber":11,"name":"Alexia PUTELLAS","position":"Midfielder","dateOfBirth":"1994-02- 04"},{"id":"1884823","jerseyNumber":5,"name":"Ivana ANDRES","position":"Defender","dateOfBirth":"1994-07-13"}]
它是如何工作的...
在这个菜谱中,我们定义了一个名为 Player 的新记录类型。Spring Boot 自动将此对象序列化为响应体,该响应体可以以 JSON 或 XML 等格式发送给客户端。
Spring Boot 使用消息转换器来完成此序列化。消息转换器的选择和序列化格式取决于客户端请求中的 Accept 头。默认情况下,Spring Boot 将响应序列化为 JSON。
关于记录
基于 equals()、hashCode() 和 toString() 构造函数的记录组件。此功能旨在简化主要封装数据的类的创建。Spring Boot 3 使用 Java 17 或更高版本。
如果你有特殊的序列化要求,你可以通过实现自己的 WebMvcConfigurer 并覆盖 configureMessageConverters 方法来配置自己的消息转换器。你可以在 Spring 框架文档中找到更多信息:docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/config/annotation/WebMvcConfigurer.html#configureMe%20ssageConverters(java.util.List。
Spring Boot 对 HTTP 状态码的默认处理可以总结如下:
-
当执行过程没有生成异常时,它将以 HTTP 200 状态响应。
-
如果端点未实现方法,它将返回一个 405 方法不允许 错误。
-
如果尝试获取一个不存在的资源,例如,一个应用程序未管理的路径,它将返回 404 未找到。
-
如果请求无效,它将返回 400 错误请求。
-
在发生异常的情况下,它产生一个 HTTP 500 内部服务器 错误。
-
还有其他与安全相关的操作,我们将在后面的章节中讨论,这些操作可能会返回 401 未授权 或 403 禁止。
在某些场景中,这种行为可能已经足够,但如果你想要为你的 RESTful API 提供适当的语义,你应该在找不到资源时返回 404 状态码。检查下一个菜谱以了解如何处理这些场景。
注意,FootballService 类被注解为 @Service、@Controller、@Bean 等。由于 PlayerController 类对 FootballService 有依赖,当 Spring Boot 实例化 PlayerController 时,它传递 FootballService 类的一个实例。
管理 RESTful API 中的错误
在上一食谱中,我们通过使用复杂的数据结构增强了我们的 RESTful API。然而,应用程序无法管理一些常见的错误或返回标准的响应代码。在本食谱中,我们将通过管理常见错误并返回符合标准的一致响应代码来增强之前的 RESTful API。
准备工作
你可以使用上一食谱生成的项目,或者从 GitHub 仓库github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/下载样本。
你可以在chapter1/recipe1-3/start文件夹中找到开始此练习的代码。
如何做到这一点...
在本食谱中,我们将修改上一食谱中创建的 RESTful API,以处理应用程序可能引发的异常,并将返回最合适的 HTTP 响应代码。
在以下步骤中创建的所有内容都将位于src/main/java/com/packt/football文件夹或你将创建的子文件夹中。让我们开始吧:
-
如果你尝试检索一个不存在的玩家或创建相同的玩家两次,它将抛出异常。结果将是一个 HTTP 500 服务器错误:
curl http://localhost:8080/players/99999 {"timestamp":"2023-09- 16T23:18:41.906+00:00","status":500,"error":"Internal Server Error","path":"/players/99999"} -
为了更一致地管理此错误,我们将在
PlayerController类中添加一个新的notFoundHandler方法来管理NotFoundException错误:@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "Not found") @ExceptionHandler(NotFoundException.class) public void notFoundHandler() { } -
接下来,我们将添加另一个名为
alreadyExistsHandler的方法来管理AlreadyExistsException错误:@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Already exists") @ExceptionHandler(AlreadyExistsException.class) public void alreadyExistsHandler() { } -
在
application根目录下,打开一个终端并执行以下命令以运行应用程序:./mvnw spring-boot:run -
通过执行以下 curl 命令来测试应用程序:
-
执行以下命令以获取一个不存在的玩家:
curl http://localhost:8080/players/99999 {"timestamp":"2023-09- 16T23:21:39.936+00:00","status":404,"error":"Not Found","path":"/players/99999"} -
注意,通过返回
HTTP 404 Not Found响应,我们的应用程序遵循标准的 RESTful API 语义。HTTP 404 表示你尝试获取一个不存在的资源,在我们的例子中是玩家 9999。 -
让我们验证我们的应用程序是否按预期管理了
AlreadyExistsException。执行以下请求以创建玩家两次:data="{'id': '8888', 'jerseyNumber':6, 'name':'Cata COLL'," data=${data}" 'position':'Goalkeeper', " data=${data}" 'dateOfBirth': '2001-04-23'}" curl --header "Content-Type: application/json" --request POST \ --data $data http://localhost:8080/players
第一次运行时将没有任何错误,并返回
HTTP 200代码。第二次运行将返回HTTP400代码。 -
它是如何工作的...
正如我们在上一食谱中学到的,Spring Boot 管理最常见的 HTTP 状态代码。在本食谱中,我们展示了如何管理其他特定于我们应用程序逻辑的场景,并需要一致的 HTTP 状态代码。
为了给 RESTful API 提供适当的语义,当资源未找到时,你应该返回404状态码。在某些场景中,将FootballService的签名更改为在找不到玩家时返回 null 值是有意义的。然而,如果控制器返回 null,响应将仍然是HTTP 200。为了避免这种行为,我们添加了@ExceptionHandler注解来添加一个处理方法来管理特定类型的异常,以及@ResponseStatus注解来管理在该特定方法处理程序中返回的 HTTP 状态码。
还有更多...
在你的代码中,你可以更明确地控制响应码。而不是直接在控制器中使用你的数据模型,你可以返回ResponseEntity,这允许你明确指定状态码。以下是如何以这种方式实现getPlayer的示例:
@GetMapping("/{id}")
public ResponseEntity<Player> readPlayer(@PathVariable String id)
{
try {
Player player = footballService.getPlayer(id);
return new ResponseEntity<>(player, HttpStatus.OK);
} catch (NotFoundException e) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
另一个替代方案是使用带有@ControllerAdvice注解的类来为所有控制器提供一个全局处理器:
package com.packt.football;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<String>
handleGlobalException(NotFoundException ex) {
return new ResponseEntity<String>(ex.getMessage(), HttpStatus.NOT_FOUND);
}
}
这样,你可以为应用程序中所有 RESTful 端点提供一致的错误处理。
测试 RESTful API
手动测试应用程序可能会很累,尤其是在处理难以验证的挑战性场景时。此外,它在开发生产率方面缺乏可扩展性。因此,我强烈建议应用自动化测试。
默认情况下,Spring Boot 包括提供单元和集成测试基本组件的Testing starter。在这个菜谱中,我们将学习如何为我们的 RESTful API 实现单元测试。
准备工作
在这个菜谱中,我们将为之前菜谱中创建的 RESTful API 创建单元测试。如果你还没有完成,我准备了一个工作版本。你可以在书的 GitHub 仓库中找到它,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。你可以在这个菜谱的chapter1/recipe1-4/start文件夹中找到启动此菜谱的代码。
如何做...
让我们在我们的 RESTful API 中添加一些测试,以便在更改应用程序时验证它:
-
我们将首先在
src/test文件夹中为我们的 RESTful 控制器创建一个新的测试类。让我们将新类命名为PlayerControllerTest,并使用@WebMvcTest注解,如下所示:@WebMvcTest(value = PlayerController.class) public class PlayerControllerTest { } -
现在,定义一个类型为
MockMvc的字段,并使用@Autowired注解。@Autowired private MockMvc mvc; -
然后,创建另一个类型为
FootballService的字段,并使用@MockBean注解。 -
现在我们已经准备好编写我们的第一个测试:
- 让我们创建一个方法来验证我们的 RESTful API 何时返回玩家。将新方法命名为
testListPlayers:
@Test public void testListPlayers() throws Exception { }重要的是要注意,它应该使用
@Test注解。- 测试的第一件事是配置
FootballService。以下行配置FootballService在调用listPlayers方法时返回两位玩家的列表:
Player player1 = new Player("1884823", 5, "Ivana ANDRES", "Defender", LocalDate.of(1994, 07, 13)); Player player2 = new Player("325636", 11, "Alexia PUTELLAS", "Midfielder", LocalDate.of(1994, 02, 04)); List<Player> players = List.of(player1, player2); mvc field created in *step 2* to emulate the HTTP calls and validate it’s behaving as expected: - 让我们创建一个方法来验证我们的 RESTful API 何时返回玩家。将新方法命名为
MvcResult result = mvc.perform(MockMvcRequestBuilders .get("/players") .accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(MockMvcResultMatchers .jsonPath("$", hasSize(2)))
.andReturn();
上述代码执行了一个接受 application/JSON 内容的 GET 请求。预期结果是 OK,这意味着任何 200 到 299 之间的 HTTP 状态码。预期结果是一个包含两个元素的 JSON 数组。最后,我们将结果保存在result变量中。
- 由于我们将结果保存在了
result变量中,我们可以执行额外的验证。例如,我们可以验证返回的球员数组是否完全符合预期:
String json = result.getResponse().getContentAsString();
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
List<Player> returnedPlayers = mapper.readValue(json,
mapper.getTypeFactory().constructCollectionType(List.class, Player.class));
testReadPlayer_doesnt_exist in the PlayerControllerTest class. Remember to annotate it with @Test:
@Test
public void testReadPlayer_doesnt_exist() throws Exception {
}
1. Let’s arrange the `getPlayer` method of the `FootballService` class to throw a `NotFoundException` when trying to get the player `1884823`. For that, use the following code:
String id = "1884823";
given(footballService.getPlayer(id))
使用步骤 2中定义的.mvc字段来模拟请求,然后验证其行为是否符合预期:
mvc.perform(MockMvcRequestBuilders.get("/players/" + id).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
要执行测试,请使用以下命令:
mvn test
当你执行package或install目标时,也会执行test目标,除非你明确禁用测试执行。
通常,你也可以从你喜欢的 IDE 中执行测试。
它是如何工作的...
默认情况下,Spring Initializr 包括对`spring-boot-starter-test`的依赖。这个依赖提供了创建测试所需的所有必要组件。让我们描述在这个菜谱中使用的元素:
+ `@WebMvcTest`: 当你将此注解应用于测试类时,它禁用了 Spring Boot 默认的自动配置,并仅应用与 MVC 测试相关的配置。这意味着它不会注册带有`@Service`注解的`FootballService`类,但它会注册带有`@RestController`注解的`PlayerController`类。
+ `@MockBean`: 由于我们的`FootballService`类没有自动配置(因为我们使用了`@WebMvcTest`),我们可以注册自己的`FootballService`实现。`@MockBean`注解允许我们模拟`FootballService`的实现,替换任何之前的 bean 注册。
+ `given`: 这种方法模拟了一个方法,并允许我们指定其行为。例如,使用`thenReturn`在调用`given`指定的方法时设置返回值。
+ `MockMvc`: 这模拟了 Web 服务器的行为,并允许你测试你的控制器,而无需部署应用程序。在执行模拟请求时,它返回`ResultActions`,提供了验证控制器行为是否符合预期的方法,例如`andExpect`方法。
在这个菜谱中,我们使用了其他 JUnit 实用工具,例如`assertArrayEquals`来比较两个数组的元素。JUnit 和其他测试库提供的实用工具非常广泛,我们不会在这本书中详细涵盖所有内容。然而,当我介绍它们时,我会解释测试实用工具。
还有更多...
你可以作为一个练习为我们的 RESTful API 公开的其他方法编写测试。我还为这个 RESTful API 准备了一些测试。你可以在书的 GitHub 仓库[`github.com/PacktPublishing/Spring-Boot-3.0-Cookbook`](https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook)中找到它们——最终版本在`chapter1/recipe1-4/end`文件夹中。
参见
在这本书中,我在编写测试时应用了**安排-执行-断言**(**AAA**)原则:
+ **安排**:在这个步骤中,你通过设置“执行”步骤运行所需的条件来准备你想要测试的类。
+ **执行**:在这个步骤中,你执行你正在测试的操作
+ **断言**:在这个步骤中,你验证是否达到了预期的结果
此外,还有**安排-执行-断言-清理**(**AAAC**)的变体,它增加了一个最后一步来清理测试所做的任何更改。理想情况下,最后一步不应该必要,因为我们可以模拟任何需要清理状态的处理组件或服务。
使用 OpenAPI 来文档我们的 RESTful API
现在我们有了 RESTful API,我们可以创建一个消费者应用程序。我们可以创建一个应用程序并仅执行 HTTP 请求。这将要求我们向消费者提供我们应用程序的源代码,并且他们需要理解它。但如果他们正在用不同的语言开发他们的应用程序,并且他们不知道 Java 和 Spring Boot 呢?出于这个原因,OpenAPI 被创建出来。OpenAPI 是一个用于文档 RESTful API 的标准,可以用来生成客户端应用程序。它被广泛采用,并支持不同的语言和框架。Spring Boot 对 OpenAPI 的支持非常出色。
在这个菜谱中,我们将学习如何为我们的 RESTful API 添加 OpenAPI 支持,并使用 OpenAPI 提供的工具来消费它。
准备工作
在这个菜谱中,我们将增强之前菜谱中创建的 RESTful API。如果你还没有完成之前的菜谱,你可以在书的 GitHub 仓库[`github.com/PacktPublishing/Spring-Boot-3.0-Cookbook`](https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook)中找到一个工作版本。
你可以在`chapter1/recipe1-5/start`文件夹中找到开始这个练习的代码。
注意
OpenAPI 3.0 是 Swagger 在 SmartBear 捐赠给 OpenAPI 倡议后的新名称。你可能会发现很多文档仍然使用*Swagger*这个名字来指代 OpenAPI。
如何做...
让我们使用 OpenAPI 来文档我们的 RESTful API,并从漂亮的 OpenAPI 用户界面开始测试:
1. 打开 RESTful API 项目的`pom.xml`文件,并添加 SpringDoc OpenAPI Starter WebMVC UI 依赖项,`org.springdoc:springdoc-openapi-starter-webmvc-ui`。要添加依赖项,将以下 XML 插入到`<dependencies>`元素中:
```java
<dependencies>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc- ui</artifactId>
<version>2.2.0</version>
</dependency>
</dependencies>
```
重要
为了简洁,我从代码片段中删除了其他依赖项,但你应该在你自己的代码中保留所有这些依赖项。
1. 现在,您可以执行此应用程序并在浏览器中打开以下 URL:`http://localhost:8080/v3/api-docs`。它以 OpenAPI 格式返回您的 RESTful API 的描述。您还可以打开 `http://localhost:8080/swagger-ui/index.html` 以获得一个用于与您的 API 交互的友好用户界面。

图 1.2:我们的 RESTful API 的 Open API (Swagger) UI
1. 如您所见,它公开了应用程序中定义的所有 RESTful 操作以及使用的数据模型,在这种情况下是 `Player`。现在您可以使用浏览器执行任何可用的操作。
它是如何工作的...
`org.springdoc:springdoc-openapi-starter-webmvc-ui` 依赖项在运行时检查应用程序以生成端点的描述。OpenAPI 的核心是可在 http://localhost:8080/v3/api-docs 找到的服务定义。这是一个遵循 OpenAPI 架构的 JSON 文档,描述了应用程序中托管的 RESTful 端点。端点是路径、HTTP 方法、参数、响应和数据模式的组合。
OpenAPI 依赖项提供的另一个有趣的功能是使用 OpenAPI 架构提供基本交互的友好 UI。它可以替代 `curl` 来测试 RESTful 服务,因为它不需要记住所有可能的参数。
使用 FeignClient 从另一个 Spring Boot 应用程序消费 RESTful API
现在我们有一个 RESTful API,并且它已经得到了适当的文档,我们可以创建一个消费者应用程序。有许多工具可以从 OpenAPI 规范生成客户端代码,但在这个项目中,我们将为了学习目的手动创建客户端代码。
准备工作
我们将增强前面菜谱中创建的 RESTful API。如果您还没有完成,您可以在本书的 GitHub 仓库 [`github.com/PacktPublishing/Spring-Boot-3.0-Cookbook`](http://ebay.co.uk) 中找到一个工作版本。
您可以在 `chapter1/recipe1-6/start` 文件夹中找到开始此练习的代码。
我们将再次使用 Spring Initializr 工具创建一个新的 Spring Boot 应用程序 ([`start.spring.io`](https://start.spring.io))。
如何操作...
我们将创建一个 Spring Boot 应用程序,它将消费在前面菜谱中创建的 Football RESTful API:
1. 首先,我们将创建一个新的 Spring Boot 应用程序。打开 [`start.spring.io`](https://start.spring.io) 并使用与 *创建 RESTful API* 菜单中相同的参数,除了以下选项需要更改:
+ 对于 `albums`
+ 对于 **依赖项**,选择 **Spring Web** 和 **OpenFeign**

图 1.3:消费者应用的 Spring Initializr
1. 生成项目以下载 ZIP 文件。解压缩项目并打开 `pom.xml`。
1. 创建一个名为 `Player` 的记录并添加以下代码:
```java
public record Player(String id, Integer jerseyNumber,
String name, String position,
LocalDate dateOfBirth) {
}
```
1. 创建一个名为 `FootballClient` 的接口并添加以下代码:
```java
@FeignClient(name = "football", url = "http://localhost:8080")
public interface FootballClient {
@RequestMapping(method = RequestMethod.GET,
value = "/players")
List<Player> getPlayers();
}
```
1. 创建一个名为 `AlbumsController.java` 的控制器,代码如下:
```java
@RestController
@RequestMapping("/albums")
public class AlbumsController {
private final FootballClient footballClient;
public AlbumsController(FootballClient footballClient) {
this.footballClient = footballClient;
}
@GetMapping("/players")
public List<Player> getPlayers() {
return footballClient.getPlayers();
}
}
```
通过添加 `@EnableFeignClients` 注解修改 `AlbumsApplication` 应用程序类:
```java
@EnableFeignClients
@SpringBootApplication
public class AlbumsApplication {
}
```
1. 现在通过在您的终端中执行以下命令来运行应用程序:
```java
./mvnw spring-boot:run \
-Dspring-boot.run.arguments=-- server.port=8081
```
额外的参数是运行此应用程序监听端口 `8081`,而不是默认的 `8080`。另一个应用程序正在监听端口 `8080`,因此我们需要避免端口冲突。
1. 然后测试应用程序:
```java
curl http://localhost:8081/albums/players
```
您将收到类似以下响应:
```java
[{"id":"1884823","jerseyNumber":5,"name":"Ivana ANDRES","position":"Defender","dateOfBirth":"1994-07- 13"},{"id":"325636","jerseyNumber":11,"name":"Alexia PUTELLAS","position":"Midfielder","dateOfBirth":"1994-02-04"}]
```
它是如何工作的...
Feign 是一个 `@RequestMapping`,例如 `@GetMapping`、`@PostMapping` 和 `@PutMapping`,用于指定 HTTP 方法以及 URL 路径。实际上,这些注解与服务器端应用程序中使用的注解相同。
您可以将 Feign 客户端接口注入到您的 Spring 组件中,并使用它来发送 HTTP 请求。Spring Cloud Feign 将根据接口定义自动生成并执行 HTTP 请求。通过在应用程序类上装饰 `@EnableFeignClients`,它会扫描应用程序中带有 `@FeignClient` 注解的接口并生成客户端。
在控制器中,我们可以通过 Spring Boot 依赖注入简单地使用 Feign 客户端。
请注意,我们传递了一个额外的参数 `-Dspring-boot.run.arguments=-- server.port=8081` 来执行客户端应用程序。原因是 RESTful API 已经在使用端口 `8080`,因此我们需要在不同的端口上执行客户端应用程序。
更多...
除了 Feign 之外,还有其他选项可以执行请求。我决定使用 Feign,因为它与 Spring Cloud 组件(如 Eureka 服务器)的集成非常出色。在接下来的菜谱中,我们将看到如何与 Spring Cloud 集成以及它如何在客户端进行负载均衡。
在本菜谱中,客户端的大部分代码可以使用 IDE 集成或独立工具自动生成。这些工具特别有用,可以保持客户端代码与服务器描述同步。这些工具使用 RESTful API 暴露的 OpenAPI 描述来生成客户端代码:
+ OpenAPITools: [`github.com/OpenAPITools/openapi-generator`](https://github.com/OpenAPITools/openapi-generator)
+ swagger-codegen: [`github.com/swagger-api/swagger-codegen`](https://github.com/swagger-api/swagger-codegen)
这两个项目都提供了一个命令行工具和一个 Maven 插件来生成客户端代码。
从另一个 Spring Boot 应用程序使用 RestClient 消费 RESTful API
在这个菜谱中,我们将使用 Spring 框架 6.1 中引入的新组件,并在 Spring Boot 3.2 版本中可用。在先前的菜谱中,我们通过在客户端应用程序中创建一个接口并定义目标服务中可用的相同方法来创建一个 FeignClient。通过使用 RestClient 组件,我们将获得一个提供 HTTP 库抽象的流畅 API。它允许将 Java 对象转换为 HTTP 请求,反之亦然,从 HTTP 响应创建对象。
准备工作
我们将增强在*使用 OpenAPI 记录我们的 RESTful API*菜谱中创建的 RESTful API。如果您还没有完成,您可以在书的 GitHub 仓库中找到一个工作版本,网址为 [`github.com/PacktPublishing/Spring-Boot-3.0-Cookbook`](https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook)。
您可以在 `chapter1/recipe1-7/start` 文件夹中找到启动此练习的代码。
我们将再次使用 Spring Initializr 工具创建一个新的 Spring Boot 应用程序 ([`start.spring.io`](https://start.spring.io))。
如何做到这一点...
我们将再次使用 Spring Initializr 工具创建一个新的 Spring Boot 应用程序,该应用程序将消耗在*使用 OpenAPI 记录我们的 RESTful API*菜谱中创建的 RESTful API:
1. 让我们从使用 Spring Initializr 工具创建一个新的 Spring Boot 应用程序开始。为此,在您的浏览器中打开 [`start.spring.io`](https://start.spring.io),并使用与*创建 RESTful API*菜谱中相同的参数,除了更改以下选项:
+ 对于 `albums`
+ 对于**依赖项**,选择**Spring Web**
1. 现在,创建一个名为 `AlbumsConfiguration` 的配置类,在其中我们定义一个 `RestClient` 实例:
```java
@Configuration
public class AlbumsConfiguration {
@Value("${football.api.url:http://localhost:8080}")
String baseURI;
@Bean
RestClient restClient() {
return RestClient.create(baseURI);
}
}
```
注意我们使用 `@Value` 注解定义了一个字段来配置远程服务器的 URL。
1. 接下来,创建一个名为 `FootballClientService` 的服务类。这个类将使用 Spring Boot 容器在构造函数中注入 `RestClient` 实例:
```java
@Service
public class FootballClientService {
private RestClient restClient;
public FootballClientService(RestClient restClient) {
this.restClient = restClient;
}
}
```
1. 现在,您可以使用 `RestClient` 从远程 RESTful API 获取数据。您可以创建一个名为 `getPlayers` 的方法,如下所示:
```java
public List<Player> getPlayers() {
return restClient.get().uri("/players").retrieve()
.body(new ParameterizedTypeReference<List<Player>>(){ });
}
```
1. 接下来,您可以创建另一个方法来从远程 RESTful API 获取单个玩家:
```java
public Optional<Player> getPlayer(String id) {
return restClient.get().uri("/players/{id}", id)
.exchange((request, response) -> {
if (response.getStatusCode().equals(HttpStatus.NOT_FOUND)) {
return Optional.empty();
}
return Optional.of(response.bodyTo(Player.class));
});
}
```
1. 最后,您可以使用 `FootballClientService` 服务创建一个 Album RESTful API。我创建了一个示例版本,您可以在书的 GitHub 仓库中找到。
它是如何工作的...
在这个菜谱中,我们没有创建任何额外的类型来复制远程 RESTful API。相反,我们使用了 `RestClient` 来使用 Fluent API 风格执行请求,即使用方法的结果来链式调用另一个方法。这种 Fluent API 设计易于阅读,因此被称为“Fluent”。
让我们分析在 `getPlayer` 方法中我们做了什么:
+ 我们首先调用了`get`方法,该方法返回一个可以用来设置请求属性的对象,例如 URI、头部和其他请求参数。我们只是通过使用`uri`方法设置了远程地址。请注意,此地址附加到`AlbumsConfiguration`类中定义的基本地址。
+ 当我们调用`exchange`方法时,RestClient 执行了对远程 RESTful API 的调用。然后,`exchange`方法提供了一个处理器来管理响应。
+ 在响应处理器中,我们控制当找不到播放器时会发生什么,在这种情况下,我们返回一个空对象。否则,我们使用`bodyTo`方法,该方法允许传递一个用于反序列化响应的类型。在这个例子中,我们使用了`Player`类。
`getPlayers`的代码与`getPlayer`非常相似;主要区别在于结果是播放器的`List`。为了指定这一点,有必要使用`ParameterizedTypeReference`类传递一个泛型类型。为了捕获泛型类型,需要定义`ParameterizedTypeReference`的子类,我们通过定义一个匿名内联类来实现。这就是为什么我们添加了新的`ParameterizedTypeReference<List<Player>>(){ }`,包括最后的括号`{ }`。
在这个食谱中,我们在`AlbumsConfiguration`类中使用了`@Value`注解。这个注解允许我们从外部源注入值,例如从配置文件或环境变量中。值`"${football.api.url:http://localhost:8080}"`意味着它将首先尝试获取`footbal.api.url`配置属性。如果没有定义,它将采用默认值,`http://localhost:8080`。
你会看到属性的格式会根据它们是在`application.properties`文件中定义还是在`application.yml`文件中定义而改变。在`application.properties`文件中,你会看到完整的属性在单行中。那就是`football.api.url=http://localhost:8080`。
另一方面,`application.yml`文件可以嵌套属性,所以你会看到以下内容:
football:
api:
url: http://locahost:8080
在这本书中,我将在大多数情况下使用`application.yml`文件,但你也会遇到`application.properties`格式,例如在使用环境变量时。
模拟 RESTful API
使用远程服务作为我们之前食谱中所做的主要缺点是,当你测试客户端应用程序时,你需要远程服务正在运行。为了应对这种场景,你可以**模拟**一个远程服务器。通过**模拟**,我的意思是模拟组件或服务的行为了,在这种情况下,是远程服务。
在测试中模拟远程依赖项可以有几个原因。其中一个主要原因是它允许你在隔离的环境中测试你的代码,无需担心远程依赖项的行为。如果远程依赖项不可靠或速度慢,或者你想要测试难以用远程依赖项复制的不同场景,这特别有用。
在这个食谱中,我们将学习如何在测试执行期间使用 Wiremock 模拟我们的 Albums 应用程序中的远程 `Football` 服务。
准备工作
在这个食谱中,我们将为我们在 *从另一个 Spring Boot 应用程序使用 RestClient 消费 RESTful API* 食谱中构建的应用程序创建测试。如果你还没有完成那个食谱,我准备了一个可以在本书的 GitHub 仓库中找到的工作版本,该仓库位于 [`github.com/PacktPublishing/Spring-Boot-3.0-Cookbook`](https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook),在 `chapter1/recipe1-8/start`。
如何操作...
我们将向我们的项目添加 Wiremock 依赖项,然后我们将能够为我们的 Albums 应用程序创建隔离的测试:
1. 首件事是向 Albums 项目添加 Wiremock 依赖项。要做到这一点,打开 `pom.xml` 文件并添加以下依赖项:
```java
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.0.1</version>
<scope>test</scope>
</dependency>
```
1. 现在,我们可以为我们的 `FootballClientService` 创建一个测试类。让我们将测试类命名为 `FootballClientServiceTest`。我们将使用 `@SpringBootTest` 注解传递一个带有远程服务器地址的属性:
```java
@SpringBootTest(properties = { "football.api.url=http://localhost:7979" })
public class FootballClientServiceTests {
}
```
1. 然后,我们需要在测试中设置一个 Wiremock 服务器。将以下内容添加到 `FootballClientServiceTest` 类中:
```java
private static WireMockServer wireMockServer;
@BeforeAll
static void init() {
wireMockServer = new WireMockServer(7979);
wireMockServer.start();
WireMock.configureFor(7979);
}
```
1. 现在,我们可以声明一个 `FootballClientService` 字段,该字段将由 Spring Boot 注入。用 `@Autowired` 注解它:
```java
@Autowired
FootballClientService footballClientService;
```
1. 然后,编写一个测试来验证 `getPlayer` 方法。
1. 将测试命名为 `getPlayerTest`:
```java
@Test
public void getPlayerTest() {
```
1. 让我们先安排远程服务的结果。将以下代码添加到测试中:
```java
WireMock.stubFor(WireMock.get(WireMock.urlEqualTo("/players/325636"))
.willReturn(WireMock.aResponse()
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": "325636",
"jerseyNumber": 11,
"name": "Alexia PUTELLAS",
"position": "Midfielder",
"dateOfBirth": "1994-02-04"
}
""")));
```
1. 接下来,调用 `getPlayer` 方法。此方法依赖于远程服务:
```java
Optional<Player> player = footballClientService.getPlayer("325636");
```
1. 然后验证结果:
```java
Player expectedPlayer =new Player("325636", 11, "Alexia PUTELLAS", "Midfielder", LocalDate.of(1994, 2, 4));
assertEquals(expectedPlayer, player.get());
```
1. 作为练习,你可以为 `FootballClientService` 类的其余方法创建测试,也可以创建其他场景的测试,例如模拟远程服务器不同的响应。你可以在本书的 GitHub 仓库中找到一些准备好的测试。
它是如何工作的...
Wiremock 是一个用于 API 模拟测试的库。它可以作为一个独立工具运行,也可以作为一个库运行,就像我们在本食谱中所做的那样。Wiremock 只在测试中是必要的,因此我们将 `scope` 依赖配置为 `test`。与 Spring Boot 版本 3.2.x 存在已知的不兼容性。Spring Boot 使用 Jetty 12,而 Wiremock 依赖于 Jetty 11。为了避免这种不兼容性,我们使用了 `wiremock-standalone` 依赖项,而不是 `wiremock` 依赖项,因为它包含了所有必需的依赖项。
Wiremock 项目不是 Spring Boot 框架的一部分,然而,它是 Spring 项目中模拟服务的一个流行选择。
在这个食谱中,我们使用了`@SpringBootTest`注解,因为它使用了 SpringBoot 上下文,并允许通过`properties`字段传递自定义环境变量。我们使用这些属性来传递我们配置 Wiremock 的远程服务器地址。我们使用不同的服务器地址以避免与实际远程服务器发生冲突,无论出于何种原因它可能正在该机器上运行。
我们还使用了`@BeforeAll`来在每次测试执行之前运行 Wiremock 服务器初始化。在这个初始化中,我们配置 Wiremock 服务器监听端口`7979`,与`properties`字段中传递的配置相匹配。
使用`StubFor`,我们为远程服务器配置了所需的行为:当接收到对`/players/325636`的`GET`请求时,它应该返回一个包含模拟玩家的 JSON。其余的只是正常的测试验证,以确保结果符合预期。
参见
你可以在项目的网页上找到更多关于 Wiremock 的信息:[`www.wiremock.io/`](https://www.wiremock.io/)。
第二章:使用 OAuth2 保护 Spring Boot 应用程序
开放授权 2.0(OAuth 2.0)是一个提供 Web 和移动应用程序安全授权的开放标准协议。它允许用户在不共享其凭据(如用户名和密码)的情况下,将一个网站(称为“资源服务器”)上的有限访问权限授予另一个网站或应用程序(称为“客户端”)。这意味着资源服务器永远不会看到用户的凭据。OAuth 2.0 被广泛用于启用单点登录(SSO)、访问第三方 API 和实现安全的授权机制。SSO 允许用户使用单个 ID 登录到多个相关但独立的独立应用程序。一旦登录到应用程序,用户就不需要重新输入凭据来访问其他应用程序。
OpenID Connect(OIDC)是一个建立在 OAuth 2.0 之上的开放标准,用于用户身份验证。它与 OAuth 2.0 一起使用,以实现用户数据的安全访问。一个例子是当应用程序允许您使用 Google 账户登录时。通常,它们可以请求访问您 Google 账户配置文件的部分内容或代表您与账户交互的权限。
在本章中,我们将学习如何部署一个基本的 Spring 授权服务器,我们将在本书的大多数食谱中使用它。然后,我们将了解保护应用程序最常见的情况,从 RESTful API 到 Web 应用程序。最后,我们将应用相同的概念,但使用两种流行的云解决方案:Google 账户用于用户身份验证和 Azure AD B2C 用于可扩展的端到端身份验证体验。
Spring Boot 为 OAuth2 和 OIDC 提供了极大的支持,无论使用的是哪种身份/授权服务器。它管理所有供应商实现的标准 OAuth2/OpenID 概念。
在本章中,我们将涵盖以下食谱:
-
设置 Spring 授权服务器
-
使用 OAuth2 保护 RESTful API
-
使用 OAuth2 和不同作用域保护 RESTful API
-
配置具有 OpenID 身份验证的 MVC 应用程序
-
使用 Google 账户登录
-
将 RESTful API 与云身份****提供者(IdP)集成
技术要求
本章具有与第一章相同的技术要求。因此,我们需要一个编辑器,例如 Visual Studio Code 或 IntelliJ,Java OpenJDK 21 或更高版本,以及一个执行 HTTP 请求的工具,例如curl或 Postman。
对于某些场景,您需要一个 Redis 服务器。在本地运行 Redis 服务器的最简单方法是使用 Docker。
对于使用 Google 账户登录食谱,您需要一个 Google 账户。
对于将 RESTful API 与云身份提供者集成食谱,我使用了 Azure Entra(以前称为 Azure Active Directory)作为身份验证提供者。您可以在azure.microsoft.com/free/search上创建一个免费账户,并获得 200 美元的信用额度。
本章中将要演示的所有食谱都可以在以下位置找到:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/tree/main/chapter2
设置 Spring 授权服务器
Spring 授权服务器是 Spring 框架下的一个项目,它提供了创建授权服务器所需的组件。在本食谱中,你将部署一个非常简单的授权服务器,你将在本章的大部分食谱中使用它。在接下来的食谱中,你将继续定制这个服务器以实现每个练习的目标。
此服务器的配置仅用于演示目的。授权服务器在管理和授予对受保护资源的访问方面发挥着至关重要的作用。如果你计划在生产中使用它,我建议遵循docs.spring.io/spring-authorization-server/reference/overview.html项目中的说明。无论如何,本书中我们将解释的原则已经调整为 OAuth2 规范和公认的实践。因此,你将能够将在这里学到的知识应用到任何其他授权服务器。
准备工作
要创建 Spring 授权服务器,我们将使用 Spring Initializr。您可以通过浏览器使用start.spring.io/打开此工具,或者如果您已经将其集成到代码编辑器中,也可以使用它。
我假设您对 OAuth2 有基本的了解。然而,我在也见部分添加了一些链接,如果您需要了解一些概念,这些链接可能很有用。
如何操作...
在本食谱中,我们将使用 Spring Initializr 创建一个 Spring 授权服务器,并进行非常基本的配置以创建应用程序注册。最后,我们将测试应用程序注册并分析结果。按照以下步骤操作:
-
打开
start.spring.io,就像在第一章中创建 RESTful API 食谱时做的那样,并使用相同的参数,除了以下选项:-
对于
footballauth -
对于依赖项,选择OAuth2 授权服务器:
-

图 2.1:Spring 授权服务器的 Spring Initializr 选项
点击生成按钮下载项目,然后将内容解压缩到你的工作文件夹中。
-
现在,我们需要配置授权服务器。为此,我们将在
resources文件夹中创建一个application.yml文件,内容如下:server: port: 9000 spring: security: oauth2: authorizationserver: client: basic-client: registration: client-id: "football" client-secret: "{noop}SuperSecret" client-authentication-methods: - "client_secret_post" authorization-grant-types: - "client_credentials" scopes: - "football:read"我们刚刚定义了一个可以使用客户端凭证流进行认证的应用程序。
-
现在,您可以执行您的授权服务器。您可以通过向 http://localhost:9000/.well-known/openid-configuration 发送请求来检索服务器的配置。如路径所示,这是一个众所周知的端点,所有 OAuth2 兼容的供应商都实现它以公开客户端应用程序的相关配置。大多数客户端库都可以仅从这个端点进行配置。
-
为了验证其是否正常工作,我们可以执行我们客户端的认证。您可以通过执行以下
POST请求并通过curl来完成此操作:curl --location 'http://localhost:9000/oauth2/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=client_credentials' \ --data-urlencode 'client_id=football' \ --data-urlencode 'client_secret=SuperSecret' \ --data-urlencode 'scope=football:read'您应该看到一个类似以下响应:
{"access_token":"eyJraWQiOiIyMWZkYzEyMy05NTZmLTQ5YWQtODU2 Zi1mNjAxNzc4NzAwMmQiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJiYXNp Yy1jbGllbnQiLCJhdWQiOiJiYXNpYy1jbGllbnQiL CJuYmYiOjE2OTk1NzIwNjcsInNjb3BlIjpbInByb2ZpbGUiXSwiaXNzIj oiaHR0cDovL2xvY2FsaG9zdDo5MDAwIiwiZXhwIjoxNjk5NTcyMzY3LCJ pYXQiOjE2OTk1NzIwNjd9.TavlnbirP_4zGH8WaJHrcCrNs5ZCnStqqiX Kc6pakfvQPviosGdgo9vunq4ogRZWYNjXOS5GYw0XlubSj0UDznnxSLyx 7tR7cEZJSQVHc6kffuozycJ_xl5yzw6_Kv_pJ4fP00b7pbHWO8ciZKUhmW -Pvt5TV8sMFY-uNzgsCtiN5EYdplMUfZdwHMy8yon3bUah8Py7RoAw1bIE ioGUEiK5XLDaE4yGdo8RyyBv4wj3mw6Bs8dcLspLKWXG5spXlZes6XCaSu 0ZXtLE09AgA_Gmq0kwmhWXgnpGKuCkhkXASyJXboQD9TR0y3yTn_aNeiuV MPzX4DQ7IaCKzgmaYg","scope":"profile","token_type":"Bearer","expires_in":299} -
现在,您可以复制
access_token字段的值,在您的浏览器中打开jwt.ms,并将该值粘贴到那里。在解码令牌标签页中,您可以查看令牌的解码形式;如果您点击声明标签页,您可以看到每个字段的解释:

图 2.2:在 jwt.ms 中解码的 JWT 令牌
- 恭喜——您已部署了 Spring 授权服务器,并成功配置了授权应用程序。
它是如何工作的...
Spring OAuth2 授权服务器包含创建授权服务器所需的所有组件。通过在application.yml中提供的配置,它创建了一个具有client-id值为basic-client的应用程序。让我们看看用于应用程序的参数以及它们是如何工作的:
-
client-id是我们创建的应用程序的标识符。在这种情况下,它是football。 -
client-secret是应用程序的密钥。通过在密钥中使用{noop}前缀,我们告诉 Spring Security 密码未加密,可以直接使用。 -
client-authentication-methods用于指定此应用程序如何进行认证。通过使用client_secret_post方法,我们可以确保客户端 ID 和密钥将以POST请求的形式发送。我们还可以配置其他方法,例如client_secret_basic,在这种情况下,客户端 ID 和密钥将以 HTTP 基本模式发送——即在 URL 中。 -
通过
authorization-grant-types,我们指定了此应用程序允许的授权流。通过设置client_credentials,我们正在配置一个没有用户界面的应用程序,例如后台服务器应用程序。如果您有一个将与应用户交互的应用程序,您可以选择其他选项,例如authorization_code。 -
最后,通过
scopes,我们正在配置此应用程序允许的作用域。在这种情况下,仅仅是football:read作用域。
Spring OAuth2 授权服务器将此配置保存在内存中。正如你可能猜到的,这只是为了演示和开发目的。在生产环境中,你需要持久化这些数据。Spring OAuth2 授权服务器为 JPA 存储库提供支持。
我们使用 JWT MS (jwt.ms) 检查由我们的授权服务器签发的访问令牌。这个工具只是解码令牌并描述标准字段。还有一个名为 JWT IO (jwt.io) 的流行工具,它也允许你验证令牌,但它不会解释每个字段。
还有更多…
你可以遵循 Spring OAuth2 授权服务器项目的说明,使用 JPA 实现核心服务:docs.spring.io/spring-authorization-server/docs/current/reference/html/guides/how-to-jpa.html。
你可以使用 Spring Data JPA 支持的任何关系型数据库,例如 PostgreSQL,我们在 第五章 中使用过。
参见
在本章中,我们将管理许多 OAuth2 概念,如果你没有先前的知识,这可能很难理解。
例如,了解不同的令牌类型非常重要:
-
access_token:这包含授权服务器授予的所有授权信息,资源服务器将进行验证。 -
id_token:此令牌用于会话管理,通常在客户端应用程序中,例如用于自定义用户界面。 -
refresh_token:此令牌用于在即将过期时获取新的access_tokens和id_tokens。refresh_token被视为秘密,因为其有效期比其他令牌长,不仅可以用于获取已授权应用程序的新鲜令牌,还可以用于新的应用程序。因此,需要相应地保护此令牌。
我强烈建议熟悉基本的 OAuth2 流程及其主要目的:
-
客户端 凭证流:
这是 simplest 的流程,在本次食谱中使用过。它适用于无需用户交互的应用程序——例如,用于与其他应用程序通信的服务器应用程序。它们可以通过不同的方式认证,例如使用密钥,如本食谱中所示,证书或其他更复杂的技术。
-
授权码 授权流:
这是为了验证网页和移动应用程序。这是双因素认证流程,用户进行认证并允许应用程序访问请求的作用域。然后,认证端点会发放一个短期有效的代码,该代码应在令牌端点兑换以获取访问令牌。之后,应该对应用程序(而非用户)进行认证。根据认证方式的不同,此流程有两种变体:
-
使用客户端 ID 和密钥。这适用于保密应用程序,例如那些可以保密的应用程序。这包括服务器应用程序。
-
使用客户端 ID 和挑战,也称为 证明密钥挑战交换 (PKCE)。这适用于公共应用程序,例如那些无法保密的应用程序,如移动应用程序,或者仅存在于浏览器中的应用程序,如 单页应用程序 (SPAs)。
-
-
refresh_token。
还有更多流程,但这些都是本章将使用的基本流程。
使用 OAuth2 保护 RESTful API
保护资源 – 在这种情况下,一个 RESTful API – 是 OAuth 的核心功能。在 OAuth2 中,资源服务器将授权访问第三方服务器的权限委托给授权服务器。在本菜谱中,你将学习如何配置一个 RESTful API 应用程序,以便它可以授权由你的 Spring 授权服务器发出的请求。
我们将继续使用我们的足球数据管理示例。你将通过只允许授权服务器授权的客户端来保护你的 Football API。
准备工作
在本菜谱中,你将重用你在 设置 Spring 授权服务器 菜谱中创建的授权服务器。如果你还没有完成,你可以使用我准备好的授权服务器。你可以在本书的 GitHub 仓库中找到它,网址为 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook,在 chapter4/recipe4-2/start 文件夹中。
如何操作...
在本菜谱中,你将创建一个新的 RESTful API,并使用之前菜谱中创建的客户端注册将其配置为 资源服务器。按照以下步骤操作:
-
首先,使用 Spring Initializr (
start.spring.io) 创建一个 RESTful API。使用与你在 第一章 中 创建 RESTful API 菜单中相同的选项,但以下选项需要更改:-
对于
footballresource。 -
对于 依赖项,选择 Spring Web 和 Oauth2 资源服务器:
-

图 2.3:受保护 RESTful API 的 Spring Initializr 选项
点击 GENERATE 下载包含你的项目模板的 ZIP 文件。将其解压到你的工作文件夹中,并在代码编辑器中打开它。
-
我们可以创建一个简单的 REST 控制器,其中包含一个返回球队列表的方法。为此,创建一个名为
Football.java的类,并包含以下内容:@RequestMapping("/football") @RestController public class FootballController { @GetMapping("/teams") public List<String> getTeams() { return List.of("Argentina", "Australia", "Brazil"); } }现在,让我们使用之前菜谱中创建的授权服务器来配置我们的应用程序以进行授权。为此,在
resources文件夹中创建一个application.yml文件,并包含以下内容:spring: security: oauth2: resourceserver: jwt: audiences: - football HTTP Error 401 Unauthorized error. -
我们首先需要从我们的授权服务器获取一个访问令牌。为此,你可以使用
curl执行以下请求:curl --location 'http://localhost:9000/oauth2/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=client_credentials' \ --data-urlencode 'client_id=football' \ --data-urlencode 'client_secret=SuperSecret' \ --data-urlencode 'scope=football:read'响应将看起来像这样:

图 2.4:授权服务器签发的访问令牌
-
复制访问令牌值并在下一个请求中使用它:
curl -H "Authorization: Bearer <access_token> with the value of the access token you obtained in the previous request, as shown in *Figure 2**.4*.Now, the resource server will return the expected result, along with a list of teams. -
现在,你有了由授权服务器签发的令牌保护的我们的 RESTful API。
它是如何工作的……
在这个简化的例子中,你看到了 OAuth2 中的授权是如何工作的。有一个授权服务器签发带有授权信息的令牌。然后资源服务器——我们的 RESTful API——检查令牌的有效性并应用提供的配置。授权服务器可以以不同的格式签发令牌,但最常见的是通过.)符号:
-
标头包含管理令牌所需的元数据。它使用 base64 进行编码。
-
负载包含实际数据和关于令牌的声明。它包含诸如过期时间、发行者和自定义声明等信息。它使用 base64 进行编码。
-
签名是通过使用编码的标头和编码的负载,以及一个密钥,并使用标头中指定的签名算法来对它们进行签名来创建的。签名用于验证令牌的真实性和完整性,以确保令牌是由授权服务器签发的,并且没有被其他人修改。
资源需要验证令牌的真实性和完整性。为此,它需要验证签名。授权服务器提供了一个端点,可以下载用于签名令牌的证书的公钥。因此,授权服务器首先需要知道该端点的位置。我们可以在application.yml文件中手动配置此信息,但幸运的是,Spring Resource Server 知道如何自动检索授权服务器所有相关信息。只需配置issuer-uri属性,它就知道如何检索其余信息。
几乎所有市场上的授权服务器,如果我们向发行者 URI 添加以下路径:.well-known/openid-configuration,都可以提供已知的OpenId端点。当资源服务器第一次需要验证 JWT 时,它会调用该端点——在我们的例子中,是http://localhost:9000/.well-known/openid-configuration——并检索它所需的所有信息,例如授权和令牌端点、JSON Web Key Set(JWKS)端点,该端点包含签名密钥,等等。JWKS 是授权服务器可以用来签名令牌的公钥。客户端可以下载这些密钥以验证 JWT 的签名。
既然我们已经知道了资源服务器如何验证令牌是由授权服务器签发的,我们就需要了解如何验证令牌是否针对我们的 RESTful API。在 application.yml 文件中,我们已经配置了 audiences 字段。这表示令牌有效的实体以及令牌旨在为谁或什么服务。aud 声明有助于确保 JWT 只被预期的接收者或资源服务器接受。aud 声明是 JWT 有效载荷的一部分。在我们的案例中,解码 base64 后的有效载荷看起来如下:
{
"sub": "football",
"aud": "football",
"nbf": 1699671850,
"scope": [
"football:read"
],
"iss": "http://localhost:9000",
"exp": 1699672150,
"iat": 1699671850
}
只需设置 issuer-uri 和 audiences,我们就能确保只有由我们的授权服务器签发以及旨在为我们应用程序/受众签发的 JWT 会被接受。Spring 资源服务器执行其他标准检查,例如过期时间(exp 声明)和不可用之前(nbf 声明)。其他任何内容都会以 HTTP 401 未授权 错误被拒绝。在 使用 OAuth2 和不同作用域保护 RESTful API 的配方中,我们将学习如何使用其他声明来增强保护。
需要注意的是,从 Spring 资源服务器角度来看,客户端如何获取访问令牌并不重要,因为这个责任已经委托给了授权服务器。授权服务器可能需要根据访问的资源类型进行不同级别的验证。以下是一些验证的示例:
-
客户端 ID 和密钥,如本示例所示。
-
多因素认证。对于具有用户交互的应用程序,授权服务器可能认为用户名和密码不足以进行认证,并强制使用第二个认证因素,例如认证应用程序、证书等。
-
如果应用程序尝试访问特定的作用域,可能需要显式同意。我们经常在社交网络中看到这种情况,当第三方应用程序需要访问我们个人资料的部分内容或试图执行特殊操作,如代表我们发布时。
使用 OAuth2 和不同作用域保护 RESTful API
在前面的配方中,我们学习了如何保护我们的应用程序。在这个配方中,我们将学习如何应用更细粒度的安全措施。我们需要为应用程序应用不同级别的访问权限:一种通用的读取访问形式供我们的 RESTful API 的消费者使用,以及管理访问权限,以便我们可以更改数据。
为了将不同级别的访问应用于 API,我们将使用标准的 OAuth2 概念范围。在 OAuth 2.0 中,scope是一个参数,用于指定客户端应用程序从用户和授权服务器请求的访问级别和权限。它定义了客户端应用程序代表用户可以执行哪些操作或资源。范围有助于确保用户对其数据和资源授予访问权限的部分有所控制,并允许进行细粒度的访问控制。在具有用户交互的应用程序中,授予范围可能意味着用户明确同意。对于没有用户交互的应用程序,它可以配置为管理同意。
在我们的足球应用程序中,你将创建两个访问级别:一个用于只读访问,另一个用于管理访问。
准备就绪
在这个配方中,我们将重用设置 Spring 授权服务器配方中的认证服务器和我们在使用 OAuth2 保护 RESTful API配方中创建的资源服务器。如果你还没有完成这些配方,你可以在本书的 GitHub 存储库中找到工作版本,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook,在chapter4/recipe4-3/start文件夹中。
如何操作...
让我们在授权服务器中创建football:read和football:admin范围,并在资源服务器中应用管理它们的配置:
-
你应该做的第一件事是确保在授权服务器中定义了范围。为此,请转到你在设置 Spring 授权服务器配方中创建的项目在
resources文件夹中的application.yml文件。如果你使用了我提供的实现,如该配方中的准备就绪部分所述,你可以在footballauth文件夹中找到该项目。确保应用程序提到了football:read和football:admin范围。application.yml文件中的应用程序配置应如下所示:spring: security: oauth2: authorizationserver: client: football: registration: client-id: "football" client-secret: "{noop}SuperSecret" client-authentication-methods: - "client_secret_post" authorization-grant-types: - "client_credentials" scopes: - "football:read" FootballController controller class, create a method named addTeam that’s mapped to a POST action:@PostMapping("/teams")
public String addTeam(@RequestBody String teamName){
返回
teamName + " added";}
You can do a more complex implementation, but for this exercise, we can keep this emulated implementation. -
现在,配置资源服务器,使其能够管理范围。为此,创建一个配置类,该类公开一个
SecurityFilterChainbean:@Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.authorizeHttpRequests(authorize -> authorize.requestMatchers(HttpMethod.GET, "/football/teams/**").hasAuthority( "SCOPE_football:read").requestMatchers( HttpMethod.POST, "/football/teams/**") .hasAuthority("SCOPE_football:admin") .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .build(); } }注意,作为
SecurityFilterChain的一部分,我们使用HttpMethod、请求路径和所需的权限,通过两种范围定义了几个requestMatchers。 -
现在我们有了所需的配置,让我们运行应用程序并执行一些测试以验证其行为:
- 首先,从授权服务器获取访问令牌,请求仅
football:read范围。你可以通过运行以下curl命令来执行请求:
curl --location 'http://localhost:9000/oauth2/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=client_credentials' \ --data-urlencode 'client_id=football' \ --data-urlencode 'client_secret=SuperSecret' \ --data-urlencode '200.However, let’s say you try to perform the POST request to create a team:curl -H "Authorization: Bearer <access_token>" -H "Content-Type: application/text" --request POST --data 'Senegal' http://localhost:8080/football/teams -v
- 首先,从授权服务器获取访问令牌,请求仅
关于 curl 的说明
注意,我们开始使用 -v 参数。它提供详细的响应,以便我们可以看到某些失败的原因。
它将返回 HTTP 403 禁止错误,详细信息如下:
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
- 现在,让我们使用适当的范围检索另一个访问令牌:
curl --location 'http://localhost:9000/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=football' \
--data-urlencode 'client_secret=SuperSecret' \
--data-urlencode 'scope=football:read football:admin'
注意,我们一次可以请求多个范围。
如果我们使用新的访问令牌执行创建球队的请求,我们会看到它按预期工作,并返回类似 Senegal added 的内容。
- 这样,我们的应用程序就得到了保护,并且我们已经为我们的资源服务器应用了不同的保护级别。
它是如何工作的…
SecurityFilterChain bean 是一个用于配置拦截和处理传入 HTTP 请求的安全过滤器的组件。在这里,我们创建了一个 SecurityFilterChain bean,它查找两个匹配的模式:匹配 /football/teams/** 路径模式的 GET 请求和匹配相同路径模式的 POST 请求。GET 请求应该有 SCOPE_football:read 权限,而 POST 应该有 SCOPE_football:admin 权限。一旦配置了 SecurityFilterChain,它就会应用于所有传入的 HTTP 请求。然后,如果匹配模式的请求没有所需的范围,它将引发 HTTP 403 forbidden 响应。
为什么使用 SCOPE_ 前缀?这是由默认的 JwtAuthenticationConverter 创建的。该组件将 JWT 转换为 Authentication 对象。默认的 JwtAuthenticationConverter 由 Spring Security 连接,但您也可以注册自己的转换器,如果您想要不同的行为。
还有更多…
可以在 JWT 上执行更多验证。例如,验证请求的一种常见方式是检查其角色。
您可以通过注册 SecurityFilterChain bean 来验证请求的角色。假设您在授权服务器中定义了一个管理员角色。在这里,您可以在资源服务器中配置一个 SecurityFilterChain bean,以确保只有具有 ADMIN 角色的用户可以在 football/teams 路径上执行 POST 请求,如下所示:
@Bean
public SecurityFilterChain filterChainRoles(HttpSecurity
http) throws Exception {
return http.authorizeHttpRequests(authorize ->
authorize.requestMatchers(HttpMethod.POST,
"football/teams/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults()))
.build();
}
您可以进行其他检查以验证您的令牌。在这种情况下,您可以使用 OAuth2TokenValidator。例如,您可能想验证给定的声明是否存在于您的 JWT 中。为此,您可以创建一个实现 OAuth2TokenValidator 的类:
class CustomClaimValidator implements
OAuth2TokenValidator<Jwt> {
OAuth2Error error = new OAuth2Error("custom_code",
"This feature is only for special football fans",
null);
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getClaims().containsKey("specialFan")){
return OAuth2TokenValidatorResult.success();
}
else{
return
OAuth2TokenValidatorResult.failure(error);
}
}
}
参见
我建议您查看 Spring OAuth2 资源服务器项目文档docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html,以获取更多详细信息。
使用 OpenID 身份验证配置 MVC 应用程序
我们想为我们的足球迷创建一个新的网络应用程序。为此,我们必须在用户访问应用程序时进行用户身份验证。我们将使用访问令牌来访问受保护的 RESTful API。
在这个食谱中,我们将学习如何使用 Spring OAuth2 Client 保护 MVC Web 应用程序并获取其他受保护资源的访问令牌。
如果您计划使用 SPA,您将需要为您目标环境寻找 OpenID 认证的库。
准备工作
对于这个食谱,您将重用您在设置 Spring 授权服务器食谱中创建的授权服务器应用程序以及您在使用 OAuth2 和不同作用域保护 RESTful API食谱中创建的应用程序。如果您还没有完成它们,我已经为这两个项目准备了可工作的版本。您可以在本书的 GitHub 存储库中找到它们,在chapter4/recipe4-4/start文件夹中。github.com/PacktPublishing/Spring-Boot-3.0-Cookbook
认证过程涉及一些重定向,并需要管理受保护应用程序的会话。我们将使用 Redis 来维护应用程序的会话。您可以在您的计算机上下载 Redis 并执行它,但正如我们在其他食谱中所做的那样,您可以在 Docker 上部署 Redis。为此,只需在您的终端中执行以下命令:
docker run --name spring-cache -p 6379:6379 -d redis
此命令将下载 Redis 社区镜像,如果它尚未存在于您的计算机上,并将启动 Redis 服务器,使其在端口6379上监听,无需任何凭证。在生产环境中,您可能希望保护此服务,但在本食谱中,我们将为了简单起见保持它开放。
如何操作…
在这个食谱中,您将创建一个新的 Web 应用程序并将其与您在之前的食谱中创建的现有授权服务器和 RESTful API 集成。按照以下步骤操作:
-
首先,您需要在授权服务器中创建客户端注册。为此,打开授权服务器
resources文件夹中的application.yml文件——即您在设置 Spring 授权服务器食谱中创建的项目。添加新的客户端注册,如下所示:spring: security: oauth2: authorizationserver: client: football-ui: registration: client-id: "football-ui" client-secret: "{noop}TheSecretSauce" client-authentication-methods: - "client_secret_basic" authorization-grant-types: - "authorization_code" - "refresh_token" - "client_credentials" redirect-uris: - "http://localhost:9080/login/oauth2/code/football-ui" scopes: - "openid" - "profile" - "football:read" - "football:admin" require-authorization-consent: true -
由于我们想要对用户进行认证,您至少需要创建一个用户。为此,在授权服务器中的同一
application.yml文件中添加以下配置:User: name: "user" password: "password"user元素应与oauth2元素对齐。请记住,.yml文件中的缩进非常重要。您可以更改用户名和密码,并设置您想要的值。保持此配置,因为您将在 Web 应用程序中稍后使用它。
-
现在,让我们为我们的 Web 应用程序创建一个新的 Spring Boot 应用程序。您可以使用Spring Initializr,就像您在第一章中创建 RESTful API 食谱中所做的那样,但更改以下选项:
-
对于
footballui -
对于依赖项,选择Spring Web、Thymeleaf、Spring Session、Spring Data Redis (Access+Driver)、OAuth2 Client和OAuth2 Security:
-

图 2.5:Web 应用程序的 Spring Initializr 选项
点击生成以将项目模板作为 ZIP 文件下载。在您的开发文件夹中解压缩文件,并在您首选的代码编辑器中打开它。
-
org.thymeleaf.extras: thymeleaf-extras-springsecurity6之间存在已知的兼容性问题。为此,请打开项目的pom.xml文件,并添加以下依赖项:<dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity6 </artifactId> <version>3.1.1.RELEASE</version> </dependency> -
让我们首先创建我们的 Web 页面。由于我们遵循
Controller类,该类将填充模型并在视图中展示。视图是通过 Thymeleaf 模板引擎渲染的:- 首先,创建名为
FootballController的Controller类,并提供一个用于主页的简单方法:
@Controller public class FootballController { @GetMapping("/") public String home() { return "home"; } }-
此方法返回视图的名称。现在,我们需要创建视图。
-
对于视图,我们应该为 Thymeleaf 创建一个新的模板。默认模板位置是
resources/template文件夹。在同一个文件夹中,创建一个名为home.html的文件,内容如下:
<!DOCTYPE HTML> <html> <head> <title>The great football app</title> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> </head> <body> <p>Let's see <a href="/myself"> who you are </a>. You will need to login first!</p> </body> </html>这是一个非常基本的页面,但现在它包含了一个我们想要保护的链接。
- 首先,创建名为
-
现在,我们必须配置应用程序,使其可以使用授权服务器进行身份验证,并强制所有页面(除了我们刚刚创建的首页)进行身份验证:
- 要将 Web 应用程序与授权服务器集成,请打开
resources文件夹中的application.yml文件,并配置 OAuth2 客户端应用程序,如下所示:
spring: security: oauth2: client: registration: football-ui: client-id: "football-ui" client-secret: "TheSecretSauce" redirect-uri: "{baseUrl}/login/oauth2/code/ {registrationId}" authorization-grant-type: authorization_code scope: openid,profile,football:read, football:admin provider: football-ui: SecurityConfiguration and create a SecurityFilterChain bean: - 要将 Web 应用程序与授权服务器集成,请打开
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
public SecurityFilterChain
defaultSecurityFilterChain(HttpSecurity http)
throws Exception {
http.authorizeHttpRequests(
(authorize) -> authorize
.requestMatchers("/").permitAll()
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults());
return http.build();
}
}
使用此配置,您已保护了所有页面,除了根页面。因此,让我们创建其余的页面。
-
接下来,我们需要创建一个页面来显示用户信息。为此,在同一个
FootballController控制器中,创建一个新的方法,如下所示:@GetMapping("/myself") public String user(Model model, @AuthenticationPrincipal OidcUser oidcUser) { model.addAttribute("userName", oidcUser.getName()); model.addAttribute("audience", oidcUser.getAudience()); model.addAttribute("expiresAt", oidcUser.getExpiresAt()); model.addAttribute("claims", oidcUser.getClaims()); return "myself"; }在这里,我们要求 Spring Boot 将
OidcUser注入为方法参数,并创建一个我们将用于视图的名为myself的模型。现在,在
resources/templates文件夹中创建一个名为myself.html的文件。将以下内容放入<body>中,以显示Model数据:<body> <h1>This is what we can see in your OpenId data</h1> <p>Your username <span style="font-weight:bold" th:text="${userName}" />! </p> <p>Audience <span style="font-weight:bold" th:text="${audience}" />.</p> <p>Expires at <span style="font-weight:bold" th:text="${expiresAt}" />.</p> </div> <h2>Here all the claims</h2> <table> <tr th:each="claim: ${claims}"> <td th:text="${claim.key}" /> <td th:text="${claim.value}" /> </tr> </table> <h2>Let's try to use your rights</h2> <a href="/teams">Teams</a> </body>如您所见,有一个链接到
/teams。此链接将打开一个新的页面,显示团队。团队页面从您在使用 OAuth2 和不同范围保护 RESTful API配方中创建的 RESTful API 检索数据。- 让我们在
FootballController中创建一个新的方法,以便我们可以获取团队。为此,您将使用 Spring Boot OAuth2 客户端获取访问令牌:
@GetMapping("/teams") public String teams(@RegisteredOAuth2AuthorizedClient("football-ui") OAuth2AuthorizedClient authorizedClient, Model model) { RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + authorizedClient.getAccessToken() .getTokenValue()); HttpEntity<String> entity = new HttpEntity<>(null, headers); ResponseEntity<String> response = restTemplate.exchange( "http://localhost:8080/football/teams", HttpMethod.GET, entity, String.class); model.addAttribute("teams", response.getBody()); return "teams"; }您需要在
Authorization头中传递访问令牌,前缀为Bearer字符串。- 要允许 Web 应用程序使用 RESTful API,您需要将
football-ui,Web 应用程序受众,作为接受受众包括在内。为此,在您创建的使用 OAuth2 和不同范围保护 RESTful API配方中,打开resources文件夹中的application.yml文件,并将football-ui添加到audiences属性中。application.yml文件应如下所示:
spring: security: oauth2: resourceserver: jwt: audiences: - football - football-ui issuer-uri: http://localhost:9000- 在我们开始新的应用程序之前,还有一个重要的细节需要说明:我们需要配置 Redis。对于它,所需的唯一设置是
hostname和port。为此,再次打开football-ui项目的application.yml文件,并设置以下配置:
spring: data: redis: host: localhost port: 6379- 最后要配置的设置是应用程序端口号。资源服务器已经使用端口号
8080。为了避免端口冲突,我们需要更改football-ui项目的端口号。为此,在同一个application.yml文件中,添加以下设置:
server: port: 9080您可以设置任何尚未使用的端口号。请注意,这是授权服务器配置的一部分。如果您修改端口号,您需要修改授权服务器中的配置。
- 现在,您可以运行应用程序。在您的浏览器中转到 http://localhost:9080:
![图 2.6:应用程序的主页]()
- 让我们在
图 2.6:应用程序的主页
在这里,您将看到主页,这是唯一一个未受保护的路由。如果您点击你是谁链接,您将被重定向到授权服务器的登录页面:

图 2.7:授权服务器的登录页面
应用您在步骤 2中配置的用户名和密码,然后点击登录按钮。您将被重定向到同意页面,在那里您应该允许应用程序请求的权限:

图 2.8:授权服务器中的同意页面
点击提交同意;您将被重定向到应用程序。OAuth 客户端应用程序将通过兑换由认证端点生成的访问代码来获取 ID 令牌、访问令牌和刷新令牌来完成此过程。这一过程对您来说是透明的,因为它由 OAuth2 客户端管理。
完成此操作后,您将返回到您创建的页面上的应用程序,以显示用户认证数据:

图 2.9:包含用户 OpenID 数据的应用程序页面
如果您点击teams数据:

图 2.10:显示 RESTful API 数据的应用程序页面
通过这样,您的 Web 应用程序被 OpenID 保护,并且它可以调用另一个 OAuth2 受保护的资源。
它是如何工作的…
在这个项目中,您通过使用 OIDC 保护了您的 Web 应用程序。它是一种 OAuth 2.0 的扩展认证协议。它为用户提供了一种标准化的方式,使用他们在 IdP 的现有账户登录 Web 应用程序或移动应用程序。在我们的练习中,我们使用了授权服务器作为 IdP。
OIDC 服务器通常在.well-known/openid-configuration提供发现端点。如果没有提供,那么它可能被管理员有意隐藏。该端点提供了客户端应用程序进行认证所需的所有信息。在我们的应用程序中,我们使用了授权代码授权流程。它涉及几个步骤:
-
首先,客户端应用程序将用户重定向到认证页面,请求应用程序使用所需的权限范围。
-
然后,用户完成认证。根据授权服务器提供的功能,它可能使用复杂的机制来验证用户,例如证书、多个认证因素,甚至生物识别特征。
-
如果用户已认证,授权服务器可能会根据客户端应用程序请求的权限范围要求用户同意或不需要。
-
一旦认证,授权服务器将用户重定向到客户端应用程序,提供一个短暂的授权代码。然后,客户端应用程序将在令牌端点(由发现端点提供)兑换授权代码。授权服务器将返回包含已同意权限范围的令牌。用户未同意的权限范围将不会出现在颁发的令牌中。授权服务器返回以下令牌:
-
包含会话信息的 ID 令牌。此令牌不应用于授权,仅用于认证目的。
-
访问令牌包含授权信息,例如已同意的权限范围。如果应用程序需要权限范围,它应验证返回的权限范围并相应地管理它们。
-
刷新令牌,用于在令牌过期前获取新令牌。
-
由于此过程中涉及许多重定向,客户端应用程序需要保持用户状态,因此需要会话管理。Spring 框架提供了一个方便的方法来使用 Redis 管理会话。
请记住,客户端应用程序在启动时需要访问发现端点。因此,请记住在客户端应用程序启动之前启动您的授权服务器。
在这个练习中,您已将根页面配置为唯一允许的无认证页面。要访问任何其他页面,都必须进行认证。因此,仅通过尝试导航到/myself或/teams,就会启动授权过程。
参见
许多现代应用程序都是 SPA(单页应用程序)。这类应用程序主要在浏览器中运行。此外,请注意,许多库实现了 OIDC(OpenID Connect)。我建议使用经过 OpenID 认证的库,因为它们已经过同行验证。
即使单页应用程序(SPA)越来越受欢迎,我还没有解释这种类型应用程序的认证,因为它与 Spring Boot 无关。然而,如果您有兴趣将 SPA 与 Spring 授权服务器集成,我建议您遵循 Spring OAuth2 授权服务器项目在 https://docs.spring.io/spring-authorization-server/docs/current/reference/html/guides/how-to-pkce.html 上的指南。
使用 Google 账户登录
您的足球应用程序有了新的需求:您的用户希望使用他们的 Gmail 账户登录到您的应用程序。
要实现此场景,您需要将您的授权服务器配置为 OAuth2 客户端,Google 账户作为其身份提供者(IdP)。您将学习如何在 Google Cloud 中创建 OAuth2 客户端 ID 并将其集成到您的应用程序中。
准备工作
对于这个食谱,您将重用您在 设置 Spring 授权服务器 和 使用 OAuth2 和不同作用域保护 RESTful API 食谱中创建的 Spring 授权服务器,以及您在 配置具有 OpenID 认证的 MVC 应用程序 食谱中创建的 Web 应用程序。MVC 应用程序将会话存储在 Redis 中。您可以在 Docker 中运行 Redis 服务器,如 配置具有 OpenID 认证的 MVC 应用程序 食谱中所述。
如果您尚未完成这些食谱,我已经准备了一个工作版本。您可以在本书的 GitHub 仓库 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook 中的 chapter4/recipe4-5/start 文件夹中找到它们。
由于您将应用程序与 Google 账户集成,您需要一个 Google 账户。如果您还没有 Google 账户,您可以在 accounts.google.com 上创建一个。
如何操作…
让我们从在 Google 中创建一个 OAuth2 客户端开始,然后使用提供的配置来配置我们的授权服务器,以便它可以使用 Google 账户登录到您的应用程序:
-
让我们从打开 Google Cloud 控制台
console.cloud.google.com/开始。您需要使用您的 Google 账户登录。一旦完成,您将看到 Google Cloud 主页:- 首先,您需要创建一个项目:

图 2.11:Google Cloud 主页
-
要创建项目,请点击选择 项目。
-
在 选择项目 对话框中,点击 新建项目:

图 2.12:创建新项目
- 命名项目 – 例如,
springboot3-cookbook:

图 2.13:新建项目设置
-
点击 创建。这个过程需要几分钟。完成后将出现通知。创建完成后,选择项目。
-
现在我们有一个项目,让我们为我们的 Web 应用程序配置同意页面。为此,打开APIs & Services菜单并选择凭据:

图 2.14:凭据菜单
在凭据页面上,您将看到一个创建应用程序同意页面的提醒:

图 2.15:带有突出显示配置 OAuth 同意屏幕的凭据主页
点击配置同意屏幕按钮并按照说明配置同意页面。对于用户类型,选择外部并点击创建。
对于此类用户,应用程序将以测试模式启动。这意味着只有一些测试用户能够使用它。一旦您完成开发过程并且应用程序准备就绪,您就可以发布它。为此,您的网站必须经过验证。我们不会在这个菜谱中发布应用程序,但如果您计划在应用程序中使用它,您需要完成此步骤。
在选择用户类型为外部后,您必须完成以下四个步骤:
-
首先,我们有OAuth 同意屏幕:
-
在这里,您应该配置应用程序的名称。例如,您可以将其设置为Spring Boot 3 烹饪书。
-
您应该配置一个用户支持电子邮件地址。您可以使用与您的 Google 账户相同的电子邮件地址。
-
您还应该配置开发者联系信息电子邮件。同样,您可以使用与您的 Google 账户相同的电子邮件地址。
-
其余的参数是可选的。我们现在不需要配置它们。
-
-
对于更新选定的作用域,点击添加或删除作用域并选择openid、userinfo.email和userinfo.profile:

图 2.16:选择作用域
-
然后,点击更新。
-
在测试用户步骤中,点击添加用户以添加一些测试用户,他们将在应用程序发布之前能够访问应用程序。您可以添加不同的 Google 账户来测试应用程序。
-
在摘要步骤中,您将看到您的同意屏幕的摘要。您可以点击返回仪表板以返回到OAuth 同意****屏幕页面。
-
接下来,我们将创建客户端凭据。为此,再次导航到凭据页面,如图 图 2**.17 所示。一旦你进入凭据页面,点击+ 创建凭据并选择OAuth 客户端 ID:

图 2.17:选择 OAuth 客户端 ID 凭据
-
对于应用程序类型,选择Web 应用程序
-
对于
football-gmail -
对于授权重定向 URI,添加 http://localhost:9000/login/oauth2/code/football-gmail:

图 2.18:创建 OAuth 客户端 ID
点击创建。将出现一个对话框,告知你客户端已创建,并显示客户端 ID和客户端密钥详细信息。我们需要这些数据来配置授权服务器,所以请妥善保管:

图 2.19:创建的 OAuth 客户端
有一个按钮可以下载包含配置的 JSON 文件。点击它并将 JSON 文件保存在安全的地方,因为它包含使用客户端所需的凭证。
-
现在,我们可以配置 Spring OAuth2 授权服务器:
- 首先,我们需要添加 OAuth2 客户端依赖项。为此,打开
pom.xml文件并添加以下依赖项:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client </artifactId> </dependency>- 接下来,打开
resources文件夹中的application.yml文件,并添加 Google 的客户端配置:
spring security: oauth2: client: registration: football-gmail: client-id: "replace with your client id" client-secret: "replace with your secret" redirect-uri: "{baseUrl}/login/oauth2/code/ {registrationId}" authorization-grant-type: authorization_code scope: openid,profile,email provider: football-gmail: issuer-uri: https://accounts.google.com user-name-attribute: given_name-
将
client-id和client-secret字段替换为你在步骤 2中获得的值。 -
配置授权服务器的最后一步是定义安全检查的行为。为此,创建一个名为
SecurityConfig的配置类:
@Configuration @EnableWebSecurity public class SecurityConfig { }- 然后,添加一个
SecurityFilterChain豆:
@Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception{ OAuth2AuthorizationServerConfiguration .applyDefaultSecurity(http); http.getConfigurer( OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); http .exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint( "/oauth2/authorization/ football-gmail"), new MediaTypeRequestMatcher( MediaType.TEXT_HTML) ) ) .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults())); return http.build(); }上一段代码配置了 Spring Security 使用 OAuth 2.0 和 OpenID Connect 1.0 进行身份验证,以及接受某些请求的 JWT 访问令牌。例如,它将接受提供 JWT 访问令牌以获取用户信息的请求。
你还需要添加另一个
SecurityFilterChain豆,但优先级较低。它将启动 OAuth2 登录过程,这意味着它将以客户端应用程序的身份启动身份验证,如application.yml文件中配置的那样:@Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize .anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()); return http.build(); }上一段代码配置了 Spring Security,要求对所有请求进行身份验证,并使用 OAuth 2.0 进行登录。
- 首先,我们需要添加 OAuth2 客户端依赖项。为此,打开
-
通过这样,你的 OAuth2 授权服务器已经配置为要求使用 Google 账户进行身份验证。现在,你可以运行所有环境:
-
运行你刚刚配置的授权服务器
-
运行你在“使用不同作用域配置 OAuth2 保护 RESTful API”菜谱中创建的 RESTful API 服务器
-
运行你在“使用 OpenID 身份验证配置 MVC 应用程序”菜谱中创建的 Web 应用程序
当你导航到
http://localhost:9080/myself时,你将需要使用 Google 账户进行登录: -

图 2.20:使用 Google 账户登录
登录后,你会看到应用程序是相同的。现在,你可以使用授权服务器颁发的声明来调用 RESTful API。
它是如何工作的…
由于我们仅使用 Google 进行登录委派,因此授权服务器不需要维护用户存储库,尽管它仍然负责发行令牌。这意味着当应用程序请求识别用户时,授权服务器将登录重定向到 Google。一旦返回,授权服务器将继续发行令牌。因此,您不需要更改 MVC Web 应用程序或 RESTful API 中的代码。
可以配置 MVC 应用程序,使其绕过授权服务器并直接登录到 Google 账户。您只需将 MVC Web 应用程序中的 OAuth2 客户端配置替换为在授权服务器中使用的客户端配置即可。然而,在这种情况下,您将无法使用 Google 发行的访问令牌来保护您的 RESTful API。这是因为 Google 访问令牌仅用于 Google 服务,并且它们不是标准的 JWT。
主要复杂性在于配置安全链,因为有许多选项可用。在SecurityConfig类中,有两个具有不同优先级的Beans。
SecurityConfig类定义了两个SecurityFilterChain bean。在这里,SecurityFilterChain实际上是 Spring Security 用于执行各种安全检查的过滤器链。链中的每个过滤器都有特定的角色,例如用户身份验证。
第一个SecurityFilterChain bean 的顺序设置为 1,这意味着它将是第一个被咨询的过滤器链。此过滤器链已配置为为 OAuth 2.0 授权服务器应用默认安全设置。它还通过oidc(Customizer.withDefaults())方法调用启用 OpenID Connect 1.0。
配置还指定,如果用户未经过身份验证,则应将其重定向到 OAuth 2.0 登录端点。这是通过使用具有 URL /oauth2/authorization/football-gmail 的LoginUrlAuthenticationEntryPoint来完成的。
过滤器链还配置为接受 JWT 访问令牌用于用户信息和/或客户端注册。这是通过使用oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))方法调用来完成的。
第二个SecurityFilterChain bean 的顺序设置为 2,这意味着如果第一个过滤器链没有处理请求,则会咨询它。anyRequest().authenticated() 方法链意味着任何请求都必须经过身份验证。
oauth2Login(Customizer.withDefaults()) 方法调用配置应用程序使用 OAuth 2.0 进行身份验证。Customizer.withDefaults() 方法调用用于应用 OAuth 2.0 登录的默认配置。
参见
如您所见,在授权服务器中集成第三方身份验证只需配置 IdP 的客户端应用程序。因此,如果您需要与另一个社交提供者集成,您将需要获取客户端应用程序数据。
如果您想与 GitHub 集成,您可以在 https://github.com/settings/developers 页面上创建一个应用注册。
对于 Facebook,您可以在 https://developers.facebook.com/apps 开发者页面上创建您的应用程序。
将 RESTful API 与云身份提供者(IdP)集成
随着您的应用程序越来越受欢迎,您决定将身份验证委托给云身份提供者(IdP),因为它们提供了针对复杂威胁的高级保护。您决定使用 Azure AD B2C。此服务旨在面向公众的应用程序,允许客户登录和注册,以及自定义用户旅程、社交网络集成和其他有趣的功能。
在本配方中您将学习的内容可以应用于其他云身份提供者,例如 Okta、AWS Cognito、Google Firebase 以及许多其他。Spring Boot 提供了专门的启动器,可以进一步简化与身份提供者(IdP)集成的过程。
准备工作
在本配方中,我们将集成在 使用 OpenID 身份验证配置 MVC 应用程序 配方中准备的应用程序。如果您还没有完成该配方,我已准备了一个可在此书的 GitHub 仓库中找到的工作版本,该仓库位于 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook 的 chapter4/recipe4-6/start 文件夹中。本配方还要求使用 Redis,如 使用 OpenID 身份验证配置 MVC 应用程序 配方中所述。在您的计算机上部署它的最简单方法是使用 Docker。
由于我们将与 Azure AD B2C 集成,您需要一个 Azure 订阅。如果您没有,您可以在 https://azure.microsoft.com/free 上创建一个免费账户。Azure AD B2C 提供了一个免费层,允许每月最多 50,000 活跃用户。
如果您没有 Azure AD B2C 租户,请在开始此配方之前按照learn.microsoft.com/azure/active-directory-b2c/tutorial-create-tenant中的说明创建一个。
如何操作…
按照以下步骤构建与 Azure AD B2C 的顺畅登录/注册流程,并学习如何将其无缝连接到您的 Spring Boot 应用程序以进行大规模用户身份验证:
- 您需要做的第一件事是在 Azure AD B2C 中创建一个应用注册。应用注册与您在之前配方中在 Spring Authorization Server 中创建的客户端注册相同。您可以在 应用 注册 部分创建应用注册:

图 2.21:在 Azure AD B2C 中创建应用注册
-
在
Football UI上。 -
对于
http://localhost:9080/login/oauth/code作为重定向 UI 的值:

图 2.22:Azure AD B2C 中的应用注册选项
点击注册以继续应用程序注册过程。
- 一旦创建了应用程序注册,你需要配置一个客户端秘密。你可以在你创建的应用程序注册的证书和秘密部分中这样做:

图 2.23:创建新的客户端秘密
-
一旦你创建了秘密,Azure AD B2C 生成的秘密值将出现。你现在应该复制这个值,因为它将不再可用。请妥善保管,因为你稍后需要它。
-
最后,我们必须创建一个用户流程。用户流程是一个配置策略,可以用来设置最终用户的认证体验。设置以下选项:
-
对于
SUSI;这是一个代表“注册和登录”的缩写。 -
对于身份提供者,选择电子邮件注册。
-
对于用户属性和令牌声明,选择给定名和姓氏。对于这两个属性,勾选收集属性和返回****声明框。
-
保持其余选项不变:
-

图 2.24:创建用户流程页面
点击创建以创建用户流程。
-
现在,让我们配置我们的应用程序。首先,你需要添加适当的依赖项。为此,打开 Web 应用程序的
pom.xml文件并添加org.springframework.boot:spring-boot-starter-oauth2-client依赖项:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client </artifactId> </dependency> -
然后,你需要在
resources文件夹中的application.yml文件中配置 Azure AD B2C 设置。用 B2C 设置替换 Oauth2 客户端设置。文件应如下所示:server: port: 9080 spring: cloud: azure: active-directory: b2c: enabled: true base-uri: https://sb3cookbook.b2clogin.com/ sb3cookbook.onmicrosoft.com/ credential: client-id: aa71b816-3d6e-4ee1-876b-83d5a60c4d84 client-secret: '<the secret>' login-flow: sign-up-or-sign-in logout-success-url: http://localhost:9080 user-flows: sign-up-or-sign-in: B2C_1_SUSI user-name-attribute-name: given_name data: redis: host: localhost port: 6379在
client-secret字段中,设置你在步骤 4中保留的值。我建议将秘密值用引号括起来,因为秘密可能包含保留字符,这可能导致在处理application.yaml文件时出现意外的行为。 -
要完成 OpenID 配置,允许用户使用 Azure AD B2C 登录你的应用程序,你需要通过应用 Azure AD OIDC 配置器来调整安全链。为此,通过在构造函数中添加
AadB2cOidcLoginConfigurer允许 Bean 注入来修改SecurityConfiguration类,然后在现有的defaultSecurityFilterChain方法中使用它,如下所示:private final AadB2cOidcLoginConfigurer configurer; public SecurityConfiguration(AadB2cOidcLoginConfigurer configurer) { this.configurer = configurer; } @Bean public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize .requestMatchers("/").permitAll() .anyRequest().authenticated()) .apply(configurer); return http.build(); } -
到这一点,你可以运行你的 Web 应用程序并通过 Azure AD B2C 进行认证。然而,还有一件事待办,那就是用 Azure AD B2C 保护 RESTful API 服务器。
为了解决这个问题,你可以修改依赖项。为此,打开 RESTful API 项目的
pom.xml文件,并将org.springframework.boot:spring-boot-starter-oauth2-resource-server依赖项替换为com.azure.spring:spring-cloud-azure-starter-active-directory-b2c:<dependency> <groupId>com.azure.spring</groupId> <artifactId>spring-cloud-azure-starter-active- directory-b2c</artifactId> </dependency> -
现在,修改
application.yml文件,以便它配置 Azure B2C 客户端注册:spring: cloud: azure: active-directory: b2c: enabled: true profile: tenant-id: b2b8f451-385b-4b9d-9268-244a8f05b32f credential: client-id: aa71b816-3d6e-4ee1-876b-83d5a60c4d84 base-uri: https://sb3cookbook.b2clogin.com user-flows: sign-up-or-sign-in: B2C_1_SISU -
现在,你可以运行 Web 应用程序和 RESTful 服务器,因为它们都受到 Azure AD B2C 的保护:
- 打开你的浏览器并导航到
http://localhost:8080/myself。由于该方法受保护,你将被重定向到Azure AD B2C 注册或登录页面:
- 打开你的浏览器并导航到

图 2.25:Azure AD B2C 默认的注册或登录页面
- 如果你点击立即注册链接,你可以创建一个新用户:

图 2.26:注册页面
-
第一步是提供一个有效的电子邮件地址。一旦你完成这个步骤,点击发送验证码。你将收到一封包含验证码的电子邮件,你需要在页面上提供这个验证码。一旦验证成功,你就可以填写其余字段。
-
当你返回到页面时,你会看到 Azure AD B2C 提供的声明:

图 2.27:我们的网页显示了 Azure B2C 提供的声明
-
如果你点击团队链接,你会看到与在使用 OpenID 身份验证配置 MVC 应用程序菜谱中看到相同的数据。
-
如果你点击注销链接,你将被重定向到默认的注销页面:

图 2.28:默认的注销页面
- 最后,如果你点击注销按钮,你将被重定向到 Azure AD B2C 的注销端点。
它是如何工作的…
com.azure.spring:spring-cloud-azure-starter-active-directory-b2c依赖项包括我们在之前的菜谱中使用的 Spring OAuth2 客户端和 Spring OAuth2 资源启动器。在这些启动器之上,它还包括特定组件以适应 Azure AD B2C 特定的功能。例如,发现端点不能仅从发行者 URL 推断出来,因为它依赖于正在使用的 Azure AD B2C 策略。
Azure AD B2C 入门教程将 Azure 门户中使用的配置映射到application.yml文件上的配置。除此之外,应用程序不需要进行特定更改,因为它遵循 OAuth2 规范。
在 Azure AD B2C 中,我们定义了一个应用程序注册。这相当于 Spring Authorization Server 中的客户端概念。
Azure AD B2C 允许我们定义不同的策略,以便我们可以自定义用户体验。我们创建了一个使用默认设置的注册和登录流程的策略,但你也可以定义编辑个人资料或重置密码策略。其他有趣的特性包括定义自定义界面和集成其他身份提供者。例如,与谷歌账户、社交媒体提供者如 Facebook 和 Instagram 以及企业身份提供者如 Azure Entra 集成相当容易。
这个解决方案的主要优势之一是用户可以自己注册;他们不需要管理员为他们做这件事。
这个配方并不打算回顾 Azure AD B2C 的所有可能性——它已经提供,以帮助您了解如何将您的 Spring Boot 应用程序与 Azure AD B2C 集成。
还有更多...
一个有趣且可能更常见的场景是,当我们的 RESTful API 使用与 UI 应用程序不同的应用程序注册时。当我构建 RESTful API 时,我通常会考虑一件事:应该让多个客户端能够消费它。这适用于不同的场景,例如网页和移动版本,或者允许第三方应用程序消费一些 API。在这种情况下,您可以为您 RESTful API 创建一个专门的应用程序注册,并创建具有不同访问级别的不同应用程序角色。然后,您可以将相应的角色分配给消费者应用程序。
当您为 RESTful API 创建应用程序注册时,您可以通过打开清单并包括应用程序所需的角色来创建角色。例如,我们可以创建 football.read 角色以供一般消费者访问,以及 football.admin 角色以供管理访问。它看起来会是这样:

图 2.29:具有两个应用程序角色的应用程序注册清单
然后,在 RESTful 应用程序注册区域,转到 暴露 API 并分配一个 应用程序 ID URI 值:

图 2.30:分配应用程序 ID URI 值
然后,我们可以为 RESTful API 分配权限。转到 API 权限 并分配 应用程序权限:

图 2.31:将应用程序权限分配给消费者应用程序
现在,RESTful API 有了自己的应用程序注册。这意味着您可以使用自己的受众来配置应用程序,而不是 UI 应用程序。要配置这一点,请转到 RESTful API 的 application.yml 文件,并将 client-id 属性更改为 RESTful API 应用程序注册的客户端 ID。
如果您想使用应用程序角色提供不同的访问级别,您将需要使用 Azure AD B2C 启动器中的 AadJwtGrantedAuthoritiesConverter。您可以在 SecurityConfig 类中注册该 Bean,如下所示:
@Bean
public Converter<Jwt, Collection<GrantedAuthority>>
aadJwtGrantedAuthoritiesConverter() {
return new AadJwtGrantedAuthoritiesConverter();
}
@Bean
public JwtAuthenticationConverter
aadJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new
JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(
aadJwtGrantedAuthoritiesConverter());
return converter;
}
默认情况下,Spring OAuth2 资源服务器仅转换 scope 声明,并且应用程序角色将包含在 roles 声明中。转换器为每个角色生成带有 APPROLE_ 前缀的权限。因此,您可以使用这些权限来限制访问,如下所示:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.GET,
"/football/teams/**").hasAnyAuthority(
"APPROLE_football.read",
"APPROLE_football.admin")
.requestMatchers(HttpMethod.POST,
"/football/teams/**").hasAnyAuthority(
"APPROLE_football.admin")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults()))
.build();
}
这就是具有应用程序角色的访问令牌的有效载荷看起来像:
{
«aud»: «fdc345e8-d545-49af-aa1a-04a087364c8b»,
"iss": "https://login.microsoftonline.com/b2b8f451-385b-
4b9d-9268-244a8f05b32f/v2.0",
"iat": 1700518483,
"nbf": 1700518483,
"exp": 1700522383,
"aio": "ASQA2/8VAAAAIXIjK+
28DPOc4epV22pKGfqdRSnps2dtReyZY7MPhpk=",
"azp": "aa71b816-3d6e-4ee1-876b-83d5a60c4d84",
"azpacr": "1",
"oid": "d88d83d6-421f-41e2-ba99-f49516fd439a",
"rh": "0.ASQAUfS4sls4nUuSaCRKjwWzL-hFw_
1F1a9JqhoEoIc2TIskAAA.",
«roles»: [
"football.read",
"football.admin"
],
"sub": "d88d83d6-421f-41e2-ba99-f49516fd439a",
"tid": "b2b8f451-385b-4b9d-9268-244a8f05b32f",
"uti": "JSxYHbHkpUS91mwBtxNaAA",
"ver": "2.0"
}
通过这样做,只有允许的客户端应用程序才能消费 RESTful API。
第三章:可观测性、监控和应用管理
监控和可观测性是管理和维护现代应用程序健康、性能和可靠性的关键方面。在面向微服务应用程序中,有多个不同服务的实例同时运行以提供解决方案,可观测性和监控有助于理解这些服务之间的交互并识别问题。
在大型环境中,监控发挥着至关重要的作用,能够跟踪资源利用率和性能指标。这反过来又促进了资源动态扩展,以有效满足系统的需求。这在云计算环境中特别有用,在那里您为使用的资源付费,并且您可以根据用户的实际需求调整应用程序资源。没有监控,您如何知道您的应用程序是否以 100%的 CPU 运行,响应时间如此之慢以至于用户放弃使用您的应用程序?
当您的应用程序中运行着多个微服务并且出现问题时,可观测性对于识别失败的组件和错误发生的上下文至关重要。
可观测性和监控对于持续改进也非常重要。您可以使用从监控中获得的知识来做出数据驱动的决策,提高性能,并在一段时间内完善解决方案。
Spring Boot 通过 Actuator 不仅提供监控功能,还提供管理能力,允许您在生产环境中与应用程序交互。这种能力不仅允许您检测应用程序中的潜在问题,还有助于在运行时进行故障排除。
在本章中,您将深入了解在 Spring Boot 应用程序中激活可观测性和监控功能。我们将从在您的应用程序中提供健康检查开始。在这里,您将学习如何利用应用程序通过流行的开源解决方案生成数据。本章还将涵盖在您的系统中创建跟踪,使您能够关联不同微服务之间的活动并使用 Zipkin 进行探索。此外,您还将学习如何使用 Prometheus 和 Grafana 监控应用程序公开的指标。除了 Spring Boot 及其相关组件提供的标准指标之外,您还将生成针对应用程序特定情况的定制指标并对其进行监控。一旦您的应用程序既可监控又可观测,您还可以在考虑市场上众多适合生产环境的强大监控解决方案的同时,将其与商业工具集成。最后,您将学习如何在运行时更改应用程序设置,以便您可以排除应用程序的故障。
在本章中,我们将介绍以下食谱:
-
将 Actuator 添加到您的应用程序中
-
创建自定义 Actuator 端点
-
使用探针和创建自定义健康检查
-
实现分布式跟踪
-
访问标准度量
-
创建您自己的度量
-
将您的应用程序与 Prometheus 和 Grafana 集成
-
修改运行中应用程序的设置
技术要求
在本章中,我们需要运行不同的工具,例如 Prometheus、Grafana 和 Zipkin。通常,在您的计算机上运行它们的最简单方法是使用 Docker。您可以从其产品页面获取 Docker:www.docker.com/products/docker-desktop/。我将解释如何在其对应的配方中部署每个工具。
本章中将展示的所有配方都可以在以下位置找到:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/tree/main/chapter3。
将 Actuator 添加到您的应用程序中
因此,您计划开发一个新的 RESTful API 来完成您与足球相关的服务套件。您担心应用程序的响应性以及我们提供弹性服务的目标。因此,您非常关注正确监控应用程序的健康状况。
在您开始监控应用程序之前,您的应用程序应该是可监控的。为此,您决定开始使用 Spring Boot Actuator。
Spring Boot Actuator 包含了一组与 Spring 框架打包的生产就绪功能。它集成了各种内置工具和端点,旨在允许您在生产环境中监控、管理和与 Spring Boot 应用程序交互。Actuator 简化了理解并解决 Spring Boot 应用程序运行时行为的过程。
Actuator 模块公开了多个端点,包括 health、metrics、info、dump 和 env 等,为运行中的应用程序提供操作洞察。一旦包含了这个依赖项,您就有很多开箱即用的端点可用。自定义和扩展这些端点很容易实现,并在配置方面提供了灵活性。
在这个配方中,您将学习如何将 Spring Boot Actuator 包含到您的项目中,并使用一些开箱即用的端点。
准备工作
在这个配方中,我们将使用 Spring Initializr 工具创建一个应用程序。正如您在本书的先前章节中所做的那样,您可以通过访问 start.spring.io 或将其集成到您最喜欢的代码编辑器中来使用此工具。
如何操作…
让我们创建一个启用了 Actuator 的项目,并开始探索提供的端点:
-
使用 Spring Initializr 工具创建一个项目。打开
start.spring.io并使用与 第一章 中的 创建 RESTful API 配方 相同的参数,除了更改以下选项:-
对于
fooballobs -
对于依赖项,选择Spring Web和Spring Boot Actuator
-
-
下载使用 Spring Initializr 生成的模板,并将其内容解压到您的工作目录中。
-
如果您现在运行应用程序,您可以通过/actuator/health 访问健康端点。在运行应用程序之前,我们将暴露一些端点。为此,在
resources文件夹中创建一个application.yml文件,并添加以下内容:management: endpoints: web: exposure: include: http://localhost:8080/actuator/health: This endpoint provides health information about your application. It is very useful in containerized environments such as Kubernetes to ensure that your application is up and running. -
localhost:8080/actuator/env:此端点返回应用程序的环境变量。 -
http://localhost:8080/actuator/metrics:此端点返回一个包含应用程序已暴露的度量值的列表。您可以通过将名称附加到度量端点来获取任何已暴露的度量值的值。例如,要获取process.cpu.usage,您可以请求 http://localhost:8080/actuator/metrics/process.cpu.usage。 -
http://localhost:8080/actuator/beans:此端点返回一个包含在 IoC 容器中注册的 bean 的列表——即可以注入到其他 bean 中的 bean 列表。 -
http://localhost:8080/actuator/loggers:此端点返回应用程序的日志级别和日志记录器的列表。它还允许在运行时修改日志级别。 -
在这个菜谱中,您只暴露了一些可用的端点。您可以在
docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints找到内置端点的完整列表。
它是如何工作的...
当您将 Actuator 集成到您的应用程序中时,它提供了一组端点,可用于监控您的应用程序和管理其行为。除了内置端点之外,它还允许您添加自己的端点。
端点可以被启用或禁用。默认情况下,除了关闭端点之外,所有端点都是启用的——正如其名称所暗示的,您可以使用它来优雅地关闭应用程序。然后,端点可以被暴露,这意味着它们可以通过 HTTP 请求或 JMX 远程访问。默认情况下,只有健康端点是暴露的。在这本书中,我们将主要关注 HTTP,因为它可以与标准监控工具一起使用,这些工具不是针对 Java 生态系统的。HTTP 仅适用于 Web 应用程序;如果您正在开发其他类型的应用程序,您将需要使用 JMX。
根据您使用的组件,将暴露更多数据。例如,当您包含 Spring Data JPA 时,Spring Data 度量值变得可用,因此您将需要配置 Spring Data 监控的相关度量值,如打开连接数。
还有更多...
Actuator 提供的一些端点可能会暴露非常敏感的信息。因此,健康端点是默认暴露的唯一端点。如果您的应用程序只能在虚拟网络内部访问或受到防火墙的保护,也许您可以保持端点开放。无论您的应用程序是否公开暴露,或者您只是想控制谁可以访问您的 Actuator 端点,您可能希望像在第二章中解释的那样保护它们。例如,安全配置可能如下所示:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorize ->
authorize.requestMatchers("/actuator/**")
.hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
}
您可以参考 Spring Boot 的官方文档docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.security以获取更多详细信息。
相关内容
除了 Spring Boot 提供的端点和使用的组件外,Actuator 还提供了一个灵活的实现,允许您创建自己的端点。在本章的后面部分,您将学习如何创建自己的 Actuator 端点、指标和自定义健康检查。
参考以下食谱以获取更多信息:创建自定义 Actuator 端点、创建自定义健康检查和创建您自己的指标。
创建自定义 Actuator 端点
在我们的示例中,我们正在开发一个新的 RESTful API,该 API 需要从 blob 存储加载一个文件。该文件不经常更改,这意味着它在应用程序启动时加载到内存中,并且不会自动重新加载。您需要知道加载的文件版本,并且希望在出现新版本时强制重新加载。
要实现此功能,您将使用自定义 Actuator 端点。此端点将有一个 GET 操作来返回当前文件版本,以及一个 POST 方法来重新加载文件。
准备工作
在本食谱中,您将重用您在 将 Actuator 添加到您的应用程序 食谱中创建的应用程序。我在本书的 GitHub 仓库中准备了一个工作版本,该版本位于 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。您可以在 chapter3/recipe3-2/start 文件夹中找到它。
如何操作...
让我们修改 RESTful API,使其从文件夹中加载文件并返回一些结果。完成此操作后,您需要创建一个自定义 Actuator 端点,该端点返回已加载的文件。您还需要配置端点以重新加载文件:
-
首先,创建一个类来加载文件并保持内容在内存中:
- 让我们将其命名为
FileLoader并添加以下代码:
public class FileLoader { private String fileName; private List<String> teams; private String folder; public FileLoader(String folder) { this.folder = folder; } }- 要加载文件并保持内容在内存中,请添加以下代码:
private void loadFile(String fileName) throws Exception { this.fileName = fileName; ObjectMapper mapper = new ObjectMapper(); File file = new File(fileName); teams = mapper.readValue(file, new TypeReference<List<String>>() { }); }- 现在,添加一个公共方法,以便您可以加载在构造函数中传入的文件夹中找到的第一个文件:
public void loadFile() throws IOException { Files.list(Paths.get(folder)) .filter(Files::isRegularFile) .findFirst() .ifPresent(file -> { try { loadFile(file.toString()); } catch (Exception e) { e.printStackTrace(); } }); } - 让我们将其命名为
-
接下来,创建一个带有
@Endpoint注解的类来定义自定义 Actuator 端点。将其命名为FootballCustomEndpoint:@Endpoint(id = "football") public class FootballCustomEndpoint { private FileLoader fileLoader; FootballCustomEndpoint(FileLoader fileLoader){ this.fileLoader = fileLoader; } }此类在构造函数中接收一个
FileLoader对象以执行必要的操作。 -
现在,在
FootballCustomEndpoint中创建自定义端点操作:- 创建一个带有
@ReadOperation注解的方法来检索正在使用的文件版本:
@ReadOperation public String getFileVersion(){ return fileLoader.getFileName(); }- 创建一个带有
@WriteOperation注解的方法来刷新文件:
@WriteOperation public void refreshFile(){ try { fileLoader.loadFile(); } catch (Exception e) { e.printStackTrace(); } } - 创建一个带有
-
接下来,您需要为
FileLoader和FootballCustomEndpoint类创建 bean:- 创建一个名为
FootballConfiguration的类,并使用@Configuration注解:
@Configuration public class FootballConfiguration { @Value("${football.folder}") private String folder; }-
注意有一个字段带有
@Value注解。它将从配置中加载要加载的文件的文件夹路径。 -
创建一个生成
FileLoaderbean 的方法:
@Bean public FileLoader fileLoader() throws IOException{ FileLoader fileLoader = new FileLoader(folder); return fileLoader; }- 现在,创建一个生成
FootballCustomEndpoint的方法:
@Bean public FootballCustomEndpoint footballCustomEndpoint(FileLoader fileLoader){ return new FootballCustomEndpoint(fileLoader); } - 创建一个名为
-
由于
FileLoader需要通过loadFile方法加载文件,因此您需要创建一个实现ApplicationRunner接口的类:@Component public class DataInitializer implements ApplicationRunner { private FileLoader fileLoader; public DataInitializer(FileLoader fileLoader) { this.fileLoader = fileLoader; } @Override public void run(ApplicationArguments args) throws Exception { fileLoader.loadFile(); } } -
修改
resources文件夹中的application.yml文件:- 添加一个设置,提供要加载的文件的文件夹路径:
football: folder: teams- 添加新的执行器端点:
management: endpoints: web: exposure: include: health,env,metrics,beans,loggers,teams in the root of the project and a file named 1.0.0.json. As content, add an array containing teams – for example, [ "Argentina", "Australia", "Brazil"]. -
创建一个示例 RESTful 控制器,返回由
FileLoader类加载到内存中的内容:@RestController @RequestMapping("/football") public class FootballController { private FileLoader fileLoader; public FootballController(FileLoader fileLoader){ this.fileLoader = fileLoader; } @GetMapping public List<String> getTeams(){ return fileLoader.getTeams(); } } -
服务现在已准备好测试。执行应用程序并执行以下请求:
- 使用自定义执行器端点获取当前文件版本。为此,打开您的终端并执行以下
curl请求:
curl http://localhost:8080/actuator/football-
您将收到文件名作为响应 - 即
teams/1.0.0.json。 -
让我们创建文件的新版本。将文件重命名为
1.0.1.json,并在teams数组中添加一个新元素,如下所示:
[ "Senegal", "Argentina", "Australia", "Brazil"]- 现在,使用自定义执行器端点刷新应用程序中的文件。为此,在您的终端中执行以下
curl请求:
curl --request POST http://localhost:8080/actuator/football-
再次检查当前文件版本;你现在将得到
teams/1.0.0.json。 -
您还可以使用 RESTful API 验证结果是否与文件内容相符。
- 使用自定义执行器端点获取当前文件版本。为此,打开您的终端并执行以下
它是如何工作的...
通过创建一个带有@Endpoint注解的 bean,Actuator 通过 JMX 和 HTTP 公开所有带有@ReadOperation、@WriteOperation和@DeleteOperation注解的方法。此示例与常规 RESTful 端点没有太大区别,但目的不同,因为它用于管理您开发的应用程序或库。当然,您可以实现自己的自定义执行器端点,但通常 Actuator 端点作为其他组件的一部分提供,可能需要公开一些内部信息或行为。例如,数据库驱动程序(如 PostgreSQL)、数据库连接池管理器(如 HikariCP)和缓存系统(如 Redis)通常提供 Actuator 端点。如果您计划创建某种将被他人使用的系统或库,并且您对在运行时便于管理某些内部信息感兴趣,Actuator 端点是一个很好的解决方案。
ApplicationRunner 是一个在应用程序启动后立即执行组件。当 Spring Boot 执行时,ApplicationRunner 还未准备好接受请求。你可以定义多个 ApplicationRunner。一旦所有 ApplicationRunner 组件执行完毕,应用程序就准备好接受请求。
使用探针和创建自定义健康检查
你的新足球交易服务正被足球迷们迅速采用。这个服务用于在球迷之间交换带有足球运动员照片的贴纸。为了加速这个过程,该服务在应用程序的内存中缓存了一些数据。在你开始处理请求之前,你需要确保缓存已填充。
在正常情况下,足球交易服务运行良好;然而,在负载过重的情况下,应用程序实例开始退化,经过一些不稳定后,最终变得无响应。为了应对这种情况,你在实验室环境中准备了一些压力测试。然而,你意识到应用程序开始退化的原因是连接数据库时出现问题。同时,你意识到当应用程序有超过 90 个待处理订单时,这类问题就会发生。在你找到最终解决方案的同时,你决定在应用程序无法处理更多请求时暴露出来,并创建一个健康检查来验证它是否能够连接到数据库。
探针主要用于容器编排器,如 Kubernetes,以验证应用程序是否准备好接受请求,以及当它已经开始工作时指示它处于活动状态。在 Kubernetes 中,它们被称为就绪和存活探针。
健康检查是一种验证应用程序是否已准备好工作的机制——例如,它能够连接到数据库。
在这个菜谱中,你将学习如何暴露就绪检查,如何更改你的存活状态,以及如何创建一个自定义健康检查,该检查可以被托管平台或监控系统用来确定应用程序实例的健康状况以及应用程序何时准备好接受请求。
准备工作
在这个菜谱中,你将重用你在 创建自定义 Actuator 端点 菜谱中创建的应用程序。我在本书的 GitHub 仓库中准备了一个工作版本,位于 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。你可以在 chapter3/recipe3-3/start 文件夹中找到它。
在这个菜谱中,你将验证应用程序是否能够连接到应用程序数据库。我们将使用 PostgreSQL 作为数据库。为了在本地运行 PostgreSQL,我们将使用 Docker。你只需在终端中执行以下命令即可下载并启动数据库:
docker run -e POSTGRES_USER=packt -e POSTGRES_PASSWORD=packt -p 5432:5432 --name postgresql postgres
如果您在第五章中创建了任何数据库,您可以在此处重用它。这个菜谱不执行任何真实查询——它只是验证它是否可以连接。如果您在容器中没有创建数据库,您可以使用psql工具创建数据库。为此,在您的终端中执行以下命令:
psql -h localhost -U packt
您将被提示输入密码。指定packt并按intro。您将连接到 PostgreSQL 终端。执行以下命令以创建数据库:
CREATE DATABASE football;
现在,您可以通过执行quit;命令退出数据库。
如何做到这一点...
在这个菜谱中,您将配置您的应用程序,使其能够管理探针并创建一个自定义的健康检查来验证应用程序是否可以连接到数据库:
-
首先,更新
resources文件夹中的application.yml文件,以便它能够启用准备就绪和存活探针。为此,包括以下内容:management: endpoint: health: probes: enabled: true -
接下来,创建一个模拟足球交易服务的类。命名为
TradingService:@Service public class TradingService { }此类将管理交易请求。在交易请求时,如果它检测到有超过 90 个挂起的订单,它将通知您应用程序无法处理更多请求。为此,它将使用
ApplicationEventPublisher,该对象将被注入到构造函数中:private ApplicationEventPublisher applicationEventPublisher; public TradingService(ApplicationEventPublisher applicationEventPublisher) { this.applicationEventPublisher = applicationEventPublisher; }接下来,定义一个返回挂起订单数量的方法。我们将通过返回 0 到 100 之间的随机数来模拟:
public int getPendingOrders() { Random random = new Random(); return random.nextInt(100); }最后,您可以创建一个管理交易操作的方法。如果有超过 90 个挂起的订单,它将改变应用程序的状态:
public int tradeCards(int orders) { if (getPendingOrders() > 90) { AvailabilityChangeEvent.publish(applicationEventPublisher, new Exception("There are more than 90 pending orders"), LivenessState.BROKEN); } else { AvailabilityChangeEvent.publish(applicationEventPublisher, new Exception("working fine"), LivenessState.CORRECT); } return orders; } -
现在,配置数据库连接:
- 添加 Spring Data JDBC 和 PostgreSQL 依赖项。为此,在
pom.xml文件中添加以下依赖项:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jdbc</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency>- 将以下配置添加到
resources文件夹中的application.yml文件中:
spring: datasource: url: jdbc:postgresql://localhost:5432/football username: packt password: packt - 添加 Spring Data JDBC 和 PostgreSQL 依赖项。为此,在
-
现在,让我们创建一个健康指标:
- 为了做到这一点,创建一个名为
FootballHealthIndicator的类,该类实现了HealthIndicator接口:
@Component public class FootballHealthIndicator implements HealthIndicator { }- 由于它将连接到数据库,请在构造函数中注入
JdbcTemplate:
private JdbcTemplate template; public FootballHealthIndicator(JdbcTemplate template) { this.template = template; }- 现在,重写健康方法,以便您可以执行连接性检查:
@Override public Health health() { try { template.execute("SELECT 1"); return Health.up().build(); } catch (DataAccessException e) { return Health.down().withDetail("Cannot connect to database", e).build(); } } - 为了做到这一点,创建一个名为
-
在测试应用程序之前,您可以修改
FileLoader类,模拟它,使其加载文件需要几秒钟。您可以通过修改loadFile方法并添加以下代码来实现这一点。这将使应用程序在加载文件之前等待 10 秒:try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } -
现在,让我们测试应用程序的准备就绪状态:
- 在运行应用程序之前,在您的终端中执行以下命令:
watch curl http://localhost:8080/actuator/health/readiness此命令将每秒执行一次就绪探针的请求。
- 启动应用程序。您将看到
watch命令的输出会发生变化。首先,它将显示为OUT_OF_SERVICE:

图 3.1:准备状态设置为 OUT_OF_SERVICE
- 在 10 秒或您在步骤 5中配置的时间后,它将变为UP:

图 3.2:就绪状态变为 UP
-
现在,测试应用程序的存活状态:
- 再次执行一个
watch命令,但这次,向存活探针的端点发送请求:
watch curl http://localhost:8080/actuator/health/readiness watch -n 1 -x curl --request POST -H "Content-Type: application/json" --data "1" http://localhost:8080/football记住,如果有超过 90 个挂起的请求,它将标记自己为失败。由于选择的是 0 到 100 之间的随机数,因此有 10%的可能性它会失败。
你会看到就绪端点返回 Actuator 健康端点的
watch命令: - 再次执行一个
watch curl http://localhost:8080/actuator/health
- 它会每次都返回UP。为了验证它是否能够检测到无法连接到数据库的情况,停止 PostgreSQL 容器。为此,请运行以下命令:
docker stop postgresql
你会看到 Actuator 端点响应时间会更长,并且响应将是DOWN。
它是如何工作的…
当 Spring Boot 检测到它在 Kubernetes 上运行时,会自动启用就绪和存活探针,但你也可以手动启用它们。在本菜谱中,我们明确启用了它们,但如果你在 Kubernetes 上运行应用程序,这将自动完成。
就绪和存活探针不应检查任何外部组件。它们应该验证应用程序内部是否就绪,并且能够响应。另一方面,健康检查应该验证所有依赖组件是否可用。
Spring Boot 应用程序的生命周期会经历不同的状态,并且每次状态改变时都会生成事件。在这里,我不会解释所有可能的应用程序状态;相反,我将专注于就绪探针期间的相关状态。第一个状态是starting。一旦 Spring Boot 初始化了组件,它就会变为started。在这个时候,它还没有准备好,因此需要运行应用程序中定义的所有ApplicationRunner和CommandLineRunner实例。一旦它们全部执行完毕,它就会变为ready。在这个菜谱中,我们在loadFile方法中引入了 10 秒的延迟。在这段时间内,就绪状态是OUT_OF_SERVICE。一旦它加载了文件,它就会变为UP。
如果你想了解更多,请查看以下 Spring Boot 文档:docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.spring-application.application-events-and-listeners。
在检查其他组件时要小心。首先,如果它是另一个服务,例如我们在本菜谱中创建的服务,它可能也会有探针和健康检查。通过你的服务进行检查可能是多余的。其次,尝试进行轻量级检查;否则,你可能会生成过多的负载,这可能导致性能问题。在本菜谱中,我们使用的 SQL 命令是SELECT 1。这个命令连接到数据库,但不需要从连接本身获取数据库引擎的计算资源。
相关内容
健康检查不一定意味着你需要检查你应用程序的所有依赖项的健康状况。相反,你应该检查你的应用程序是否有任何可以通过减少负载或重启来解决的问题。如果你的应用程序依赖于一个无响应的服务,并且你将你的应用程序标记为不健康,应用程序实例将被重新启动。然而,如果你的问题在另一个应用程序中,问题不会消失,应用程序将一次又一次地重新启动,而不会解决问题。对于这种情况,考虑实现一个断路器解决方案。请参阅spring.io/guides/gs/cloud-circuit-breaker/以获取有关如何使用 Spring Cloud 实现此功能的指导。
实现分布式跟踪
到目前为止,你已经创建了一个包含两个微服务的解决方案,即足球交易微服务和客户端微服务。除了其他功能外,交易微服务提供了球员排名。客户端微服务通过添加从交易微服务获得的排名来增强球员列表。
分布式跟踪作为一个关键工具出现,因为它提供了一种系统化的方法来监控、分析和优化微服务之间请求的流动。分布式跟踪是一种监控和可视化请求在分布式系统各个组件之间传播的方法,提供了关于性能、延迟和服务之间依赖性的见解。
在这个菜谱中,你将学习如何为你的微服务启用分布式跟踪,将数据导出到 Zipkin,并访问结果。
Zipkin 是一个开源的分布式跟踪系统,它帮助开发者跟踪、监控和可视化请求在分布式系统中的各种微服务之间的路径,为性能和依赖提供有价值的见解。在这个菜谱中你将了解的 Zipkin 知识可以轻松地适应其他工具。
准备工作
在这个菜谱中,我们将使用 Zipkin 可视化跟踪。你可以使用 Docker 在你的计算机上部署它。为此,打开你的终端并执行以下命令:
docker run -d -p 9411:9411 openzipkin/zipkin
之前的命令将下载一个包含 OpenZipkin 服务器的镜像,如果你还没有,然后启动服务器。
我们将重用我们在使用探针和创建自定义健康检查菜谱中创建的交易服务。如果你还没有完成,不要担心——我已经在这个书的 GitHub 仓库中准备了一个工作版本,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。它可以在chapter3/recipe3-4/start文件夹中找到。
如何做到这一点...
让我们在现有的交易服务中启用分布式跟踪并创建新的客户端服务。对于新的客户端服务,我们需要确保也启用了分布式跟踪。在开始之前,请确保您的 OpenZipkin 服务器正在运行,如准备部分中所述:
-
首先,在您在使用探针和创建自定义健康检查配方中创建的交易微服务中启用分布式跟踪:
- 为此,打开
pom.xml文件并添加以下依赖项:
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-zipkin</artifactId> </dependency>- 第一个依赖项是
resources文件夹中的application.yml文件之间的桥梁,并添加以下设置:
management tracing: sampling: probability: 1.0-
默认情况下,采样率仅设置为 10%。这意味着只有 10%的跟踪被发送。通过此更改,您将发送 100%的跟踪。
-
在相同的
application.yml文件中,添加以下配置:
spring: application: name: trading-service此更改不是强制性的,但有助于在分布式跟踪中识别服务。
- 为此,打开
-
接下来,在将被客户端微服务消费的足球交易微服务中创建排名端点。为此,在
FootballController中创建以下方法:@GetMapping("ranking/{player}") public int getRanking(@PathVariable String player) { logger.info(«Preparing ranking for player {}», player); if (random.nextInt(100) > 97) { throw new RuntimeException("It's not possible to get the ranking for player " + player + " at this moment. Please try again later."); } return random.nextInt(1000); }为了模拟随机错误,此方法在从 0 到 99 的随机数大于 97 时抛出异常——也就是说,2%的时间。
-
接下来,创建一个新的应用程序,该应用程序将充当客户端应用程序。像往常一样,您可以使用Spring Initializr工具创建模板:
-
打开
start.spring.io,使用与第一章中创建 RESTful API配方中相同的参数,但更改以下选项:-
对于
fooballclient: -
对于
pom.xml文件,添加以下依赖项:<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-zipkin</artifactId> </dependency>
-
-
在客户端应用程序中添加一个 RESTful 控制器:
- 命名为
PlayersController:
@RestController @RequestMapping("/players") public class PlayersController { }- 此应用程序必须调用交易服务。为此,它将使用
RestTemplate。为了实现服务调用的关联,您应该使用RestTemplateBuilder来创建RestTemplate。然后,将RestTemplateBuilder注入到控制器的构造函数中:
private RestTemplate restTemplate; public PlayersController(RestTemplateBuilder restTemplateBuilder) { this.restTemplate = restTemplateBuilder.build(); }- 现在,您可以创建调用其他应用程序交易服务的控制器方法:
@GetMapping public List<PlayerRanking> getPlayers() { String url = "http://localhost:8080/football/ranking"; List<String> players = List.of("Aitana Bonmatí", "Alexia Putellas", "Andrea Falcón"); return players.stream().map(player -> { int ranking = this.restTemplate.getForObject(url + "/" + player, int.class); return new PlayerRanking(player, ranking); }).collect(Collectors.toList()); } - 命名为
-
在
application.yml文件中配置客户端应用程序跟踪:management: tracing: sampling: probability: 1.0 spring: application: name: football-client与在交易服务中一样,您应该将
sampling设置为1.0,以便记录 100%的跟踪。为了区分客户端应用程序和交易服务应用程序,将spring.application.name属性设置为football-client。 -
为了避免与交易应用程序的端口冲突,配置客户端应用程序,使其使用端口
8090。为此,将以下参数添加到application.yml文件中:server: port: 8090 -
现在,您可以测试应用程序。调用客户端应用程序;它将对交易服务进行多次调用。要向客户端应用程序发送连续请求,您可以在终端中执行以下命令:
watch curl http://localhost:8090/players -
最后,打开 Zipkin 查看跟踪。为此,请在浏览器中转到
http://localhost:9411/:
-

图 3.3:Zipkin 主页
在主页面上,点击 运行查询 以查看已生成的跟踪:

图 3.4:Zipkin 中的根跟踪
在这个页面上,您将看到客户端应用程序的跟踪是根跟踪。由于我们引入了一个随机错误,您将看到有失败和成功的跟踪。如果您点击任何这些跟踪的 显示 按钮,您将看到两个 RESTful API 的跟踪。将有一个针对客户端服务的请求和针对交易服务的嵌套请求:

图 3.5:跟踪细节,包括嵌套跟踪
您也可以通过点击顶部栏上的 依赖关系 链接来查看服务之间的依赖关系:

图 3.6:在 Zipkin 中查看服务之间的依赖关系
在这里,您可以查看 football-client 应用程序和 trading-service 应用程序之间的依赖关系。
它是如何工作的…
Micrometer 是一个库,允许您在不依赖特定供应商的情况下对应用程序进行仪表化。这意味着如果您决定使用其他工具,如 Wavefront 而不是 Zipkin,您的代码将不会改变。
io.micrometer:micrometer-tracing-bridge-otel 依赖项在 Micrometer 和 OpenTelemetry 之间创建了一个桥梁,之后 io.opentelemetry:opentelemetry-exporter-zipkin 依赖项将 OpenTelemetry 导出至 Zipkin。如果您想使用其他工具来监控跟踪,只需更改这些依赖项,无需进行任何额外的代码更改。
默认将跟踪发送到 Zipkin 的地址是 http://localhost:9411。这就是为什么我们不需要显式配置它。在生产环境中,您可以使用 management.zipkin.tracing.endpoint 属性。
在这个菜谱中,我们使用了 RestTemplateBuilder。这很重要,因为它通过向出站请求添加跟踪头来自定义配置 RestTemplate。然后,目标服务收集可用于将调用应用程序中的嵌套跟踪从客户端应用程序的根跟踪中收集的跟踪头。在响应式应用程序中,您应该使用 WebClient.Builder 而不是 RestTemplateBuilder。
在这个菜谱中,我们配置了 100%的采样率。这意味着我们将所有跟踪发送到跟踪服务器。我们这样做是为了学习目的;通常,您不应该在生产环境中这样做,因为您可以通过部署服务器(例如,通过 Zipkin)或使用云中的托管服务时摄入大量数据来超载跟踪服务器。摄入的数据量直接影响监控系统——也就是说,您摄入的数据越多,成本就越高。然而,即使您部署自己的跟踪服务器,您也需要进行扩展。所以,无论哪种方式,它都可能增加您的总体成本。在一个大规模系统中,拥有 10%的采样率就足以检测服务之间的问题以及了解组件之间的依赖关系。
还有更多...
Micrometer 跟踪创建跨度——也就是说,代表特定操作执行的工作单元或分布式跟踪的片段,对于每个请求。跨度捕获有关持续时间、上下文以及与相应操作相关的任何关联元数据的信息。
您可以通过使用ObservationRegistry组件启动观察来创建跨度。例如,假设TradingService有不同的重要部分,您希望跟踪,例如收集数据和处理数据。您可以在代码中为这些创建不同的跨度。
为了实现这一点,您需要使用 Spring Boot 依赖容器将ObservationRegistry注入到您的控制器中。为此,您需要在控制器构造函数中定义ObservationRegistry参数:
private final ObservationRegistry observationRegistry;
public FootballController(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
}
然后,您必须在代码中创建观察结果:
@GetMapping("ranking/{player}")
public int getRanking(@PathVariable String player) {
Observation collectObservation = Observation.createNotStarted("collect", observationRegistry);
collectObservation.lowCardinalityKeyValue("player", player);
collectObservation.observe(() -> {
try {
logger.info("Simulate a data collection for player {}", player);
Thread.sleep(random.nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Observation processObservation = Observation.createNotStarted("process", observationRegistry);
processObservation.lowCardinalityKeyValue("player", player);
processObservation.observe(() -> {
try {
logger.info("Simulate a data processing for player {}", player);
Thread.sleep(random.nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
});
return random.nextInt(1000);
}
注意,观察结果包括具有lowCardinalityKeyValue的玩家,以便通过这些数据找到跨度。
注意
为了简洁,已删除部分代码。您可以在本书的 GitHub 仓库中找到完整版本,网址为 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。
现在,在 Zipkin 中,您可以看到嵌套在trading-service中的自定义跨度:

图 3.7:Zipkin 中的自定义跨度
trading-service跨度包含两个嵌套跨度,并且两者都有一个自定义标签,指定了玩家的名字。
访问标准指标
您的足球交易服务通过被足球迷采用而持续增长。您需要了解它如何表现更好,以便您可以在优化提供服务的资源的同时适应需求。
您可以使用 Spring Boot Actuator 及其相关组件提供的标准指标,以实时洞察您的应用程序的行为。例如,您可以了解您的应用程序使用了多少 CPU 和内存,或者在垃圾收集(GC)中花费了多长时间。这些是基本的指标,可以帮助您了解应用程序的性能。
其他指标可能更为微妙,例如由 Web 容器 Tomcat 提供的指标——例如,活跃会话的数量、拒绝的会话数量以及已过期的会话数量。同样,默认为 hikaricp 的数据库连接池也暴露了一些指标。例如,您可以查看活跃会话的数量、等待会话的数量或被拒绝的会话数量。这类指标可以成为您应用程序中问题的指示器,这些问题仅通过使用如 CPU 和内存利用率等经典指标难以检测。
在本食谱中,您将学习如何访问标准指标以及如何检测一些常见应用程序问题。您还将学习如何使用 JMeter 进行负载测试,但这不是本食谱的主要目的。
准备就绪
在本食谱中,您将重用 实现分布式跟踪 食谱中创建的应用程序。如果您还没有完成该食谱,我已准备了一个工作版本,您可以在本书的 GitHub 仓库中找到,网址为 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/,在 chapter3/recipe3-5/start 文件夹中。这些应用程序依赖于 PostgreSQL,并且如前一个食谱中所述,将活动导出到 Zipkin。PostgreSQL 和 Zipkin 都可以使用 Docker 在本地运行。
在本食谱中,我们将使用 JMeter,一个流行的负载测试工具,进行一些负载测试。您可以从项目网站 jmeter.apache.org/download_jmeter.cgi 下载 JMeter。在这里,您可以下载包含 JMeter 二进制文件的 ZIP 文件,并将其解压;无需进一步安装。要运行 JMeter,请转到您解压二进制文件的文件夹,打开 bin 文件夹。在这里,您将找到根据您的操作系统启动 JMeter 的不同脚本。对于基于 Unix 的操作系统,您可以运行 jmeter.sh 脚本,而对于 Windows,您可以运行 jmeter.bat 脚本。
我已创建了两个 JMeter 脚本,用于对应用程序施加一些负载。您可以在本书的 GitHub 仓库中找到它们,在 chapter3/recipe3-5/jmeter 文件夹中。
如何操作…
在本食谱中,我们将使用 准备就绪 部分中提到的 JMeter 脚本来为足球应用程序生成工作负载。然后,我们将观察 Spring Boot 及其相关组件提供的指标。按照以下步骤操作:
-
在运行第一个负载测试之前,请确保交易应用程序正在运行,并且
metrics端点已公开。如 将 Actuator 添加到您的应用程序 食谱中所述,这可以通过将metrics值添加到management.endpoints.web.exposure.include参数来完成。如果您遵循了前面的食谱或使用了我在 准备就绪 部分中解释的工作版本,则application.yml文件应如下所示:management: endpoints: web: exposure: include: health,env,loadTeams.jmx script. You can find it in the chapter3/recipe3-5/jmeter folder, as explained in the *Getting ready* section. This script makes a request to the application’s /football path and returns a list of teams. This process is executed by 30 threads infinitely.You can adjust some parameters of the load tests depending on the resources of your development computer. For instance, I used 30 threads to overload my computer, but maybe you need more or even fewer threads than that:

图 3.8:JMeter 中的线程数
如果你想调整线程数,请点击主线程组并调整线程数(用户)。
一旦应用程序准备就绪,你可以运行 JMeter 脚本。
-
让我们观察应用程序的指标。转到
http://localhost:8080/actuator/metrics以查看暴露的指标完整列表。你可以通过将指标名称附加到/actuator/metrics路径来获取任何这些指标。通常,你会得到与 CPU 和内存相关的计数器:-
通过
http://localhost:8080/actuator/metrics/process.cpu.usage,你可以得到应用程序进程正在使用的 CPU 百分比 -
通过
http://localhost:8080/actuator/metrics/system.cpu.usage,你可以得到系统正在使用的 CPU 百分比 -
通过
http://localhost:8080/actuator/metrics/jvm.memory.used,你可以得到应用程序正在使用的内存量
例如,
process.cpu.usage指标的结果如下所示:{ "name": "system.cpu.usage", "description": "The \"recent cpu usage\" of the system the application is running in", "measurements": [ { "statistic": "VALUE", "value": 0.48494983277591974 } ], "availableTags": [] } -
-
停止测试 - 你需要创建一个新的端点来访问数据库。为此,请按照以下步骤操作:
- 创建一个新的
DataService类并将JdbcTemplate注入到构造函数中:
@Service public class DataService { private JdbcTemplate jdbcTemplate; public DataService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } }- 现在,创建一个调用数据库的方法。为了模拟慢速数据库查询,你可以使用
pg_sleepPostgreSQL 命令。此命令等待指定数量的秒数或秒数的分数:
public String getPlayerStats(String player) { Random random = new Random(); jdbcTemplate.execute("SELECT pg_sleep(" + random.nextDouble(1.0) + ")"); return "some complex stats for player " + player; } Now, you can run the application. - 创建一个新的
-
最后,运行另一个 JMeter 脚本,对该相同的
/football路径发起请求并返回一个球队列表,以及新的路径/stats/{player},它执行对数据库的长请求。再次,30 个线程无限运行这些请求。
它是如何工作的...
在第一次负载测试中,我们可以看到应用程序的 CPU 存在瓶颈。在现实场景中,可以使用 CPU 指标来自动扩展应用程序,例如通过添加应用程序的新实例。这就是在重负载下我们可能预期的瓶颈类型。
在第二次负载测试中,没有物理资源瓶颈,但有一个耗时较长的查询并阻塞了一个无法用于其他请求的连接。在现实场景中,你可以增加连接池中可用的连接数,但只能增加到一定限制,因为这是一个非常昂贵且有限的资源。
如果你查看system.cpu.usage和process.cpu.usage,你会看到值远低于前一次负载测试中观察到的1.0。
你还可以查看与数据库连接池相关的指标。Spring Data 中的默认数据库连接池是 HikariCP,与此组件相关的所有指标都是hikaricp.*。让我们考虑以下指标:
-
hikaricp.connections.max: 此值指定hikaricp将在 PostgreSQL 服务器中打开的最大真实数据库连接数。在测试执行期间,此数值不会改变,因为在应用程序生命周期中该值是静态的。默认情况下,它设置为10。 -
hikaricp.connections.active: 这是活动连接数——即正在数据库服务器中执行某些操作的连接。在轻负载下,此数字将小于最大值。由于数据库操作时间较长(长达 1 秒),并且只有 10 个最大连接就有 30 个并发线程,因此在 JMeter 脚本的执行期间,此数字将是 10 或接近 10。 -
hikaricp.connections.pending: 当连接池中没有可用的连接时,此指标将请求排队。此指标指定了等待可用连接的连接数。在 JMeter 脚本的执行期间,此数字将大于 1。 -
hikaricp.connections.timeout: 如果一个请求等待超过给定的时间——默认为 30 秒——它将超时。在执行 JMeter 脚本后,你会看到这个指标将大于 1。
打开物理数据库连接是一项昂贵的操作。为了避免创建连接的开销,存在一种称为数据库连接池的机制,它保持一些已创建的连接以供使用。当进程需要连接到数据库时,它会从池中获取连接,一旦操作完成,就将其返回到池中。在第二次压力测试中,由于它们完成时间较长,因此没有连接,它们花费了很长时间才返回到池中。当没有可用连接时,连接池将排队连接,直到其中一个被释放。这就是为什么你会看到pending连接的原因。过了一段时间,你会看到超时连接。这些是排队超过 30 秒的连接。
这种情况也会影响 Web 容器。默认情况下,服务 HTTP 请求的线程数是有限的,也存在一个线程池。当没有更多可用的线程时,Web 容器(在这种情况下,是 Tomcat)将排队请求。在这种情况下,当一个 HTTP 请求主要在等待依赖项完成时,它似乎出现了响应式框架。在这种情况下,应用程序使用特殊类型的线程——非阻塞线程——这些线程旨在进行 I/O 操作。这些类型的线程允许应用程序在等待外部服务响应的同时继续处理其他任务。
参见
你可以使用标准的监控工具来可视化你的指标。在将应用与 Prometheus 和 Grafana 集成的菜谱中,你将学习如何将应用指标与 Prometheus 集成,并使用 Grafana 进行可视化。这些是两个流行的开源工具,它们是云原生计算基金会(CNCF)的一部分。
创建自己的指标
到目前为止,你在你的足球交易服务中创建了一个新功能,用户可以列出一张卡片进行交换,另一个用户可以对该交易卡片进行竞标。当收到新的竞标时,它将在内存中排队,直到提交,因为它需要一系列复杂的验证。人们对这个新功能有很多期望,你想要确保它运行良好。因此,你想要监控收到的竞标,有多少竞标正在等待提交,以及这个过程持续了多长时间。
在这个菜谱中,你将学习如何使用Micrometer创建自定义指标。Micrometer 是一个开源的 Java 应用指标收集库,它与 Spring Boot Actuator 集成得非常好。其他库可以使用 Micrometer 生成的遥测数据导出到不同的监控系统。
有不同类型的指标:
-
计数器:正如其名所示,它计算某事发生的次数。我们可以使用这种类型的指标来找出收到了多少竞标。
-
仪表:这个指标在给定时刻提供一个值。我们可以用它来找出有多少竞标正在等待处理。
-
计时器:这个指标测量给定操作的持续时间。我们可以用它来找出每个竞标花费的时间。
准备工作
在这个菜谱中,我们将重用访问标准指标菜谱中的项目。如果你还没有完成那个菜谱,我已经准备了一个工作版本。你可以在本书的 GitHub 仓库中找到它,在github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/的chapter3/recipe3-6/start文件夹中。
为了模拟新功能的工作负载,我创建了一个 JMeter 脚本。你可以在本书的 GitHub 仓库中找到它,在chapter3/recipe3-6/jmeter文件夹中。你可以从项目网站jmeter.apache.org/download_jmeter.cgi下载 JMeter。在这里,你可以下载一个包含 JMeter 二进制的 ZIP 文件,并将其解压——不需要进一步的安装。要运行 JMeter,请转到您解压二进制的文件夹,然后打开bin文件夹。在这里,您可以找到根据您的操作系统启动 JMeter 的不同脚本。对于 Unix,您可以运行jmeter.sh脚本,而对于 Windows,您可以运行jmeter.bat脚本。
如何做到这一点...
在这个菜谱中,你将把你自定义的指标集成到足球交易应用中。这个增强功能将提供对应用在运行时性能的更深入了解:
-
前往你的交易应用程序并创建一个名为
AuctionService的新服务类:- 将
MeterRegistry注入到构造函数中。在同一个构造函数中,创建一个用于收到的竞标的计数器,一个用于处理竞标持续时间的计时器,以及一个用于等待确认的竞标的仪表。
@Service public class AuctionService { private Map<String, String> bids = new ConcurrentHashMap<>(); private Counter bidReceivedCounter; private Timer bidDuration; Random random = new Random(); public AuctionService(MeterRegistry meterRegistry) { meterRegistry.gauge("football.bids.pending", bids, Map::size); this.bidReceivedCounter = meterRegistry.counter("football.bids.receieved"); this.bidDuration = meterRegistry.timer("football.bids.duration"); } }-
注意,
gauge返回用于在内存中保持已收到的竞标的映射的大小。 -
现在,创建一个处理竞标的方法。在这个方法中,你将使用
bidDuration计时器来测量操作持续时间,并使用bidReceivedCounter增加收到的竞标数量。 -
在名为
tradeCards的新方法中使用ordersTradedCounter和tradedDuration指标。该方法应如下所示:
public void addBid(String player, String bid) { bidDuration.record(() -> { bids.put(player, bid); bidReceivedCounter.increment(); try { Thread.sleep(random.nextInt(20)); } catch (InterruptedException e) { e.printStackTrace(); } bids.remove(player); }); } - 将
-
接下来,在
FootballController类中公开此功能:- 将你的新
AuctionService注入到构造函数中:
private AuctionService auctionService; public FootballController(AuctionService auctionService) { this.auctionService = auctionService; }-
注意,为了简化,所有其他参数和字段都已省略。由于我们正在重用之前菜谱中的相同项目,你应在构造函数中拥有更多参数,并且还应拥有其他字段。
-
创建一个新的方法,该方法将使用新服务展示球员的竞标:
@PostMapping("/bid/{player}") public void addBid(@PathVariable String player, @RequestBody String bid) { auctionService.addBidAOP(player, bid); } - 将你的新
-
现在,你可以运行应用程序并开始生成一些负载。为此,在 JMeter 中打开
loadBids.jmx文件。你可以在这个书的 GitHub 仓库中找到此文件,网址为 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/,在chapter3/recipe3-6/jmeter文件夹中。然后,在 JMeter 中运行脚本并保持运行,同时观察指标。 -
观察你创建的计数器:
-
如果你打开
http://localhost:8080/actuator/metrics上的 Actuator 指标端点,你会看到已创建的新指标:football.bids.duration、football.bids.pending和football.bids.received。如果你将这些指标的名称附加到 Actuator 指标端点,你将得到每个指标的价值。 -
打开
http://localhost:8080/actuator/metrics/football.bids.received以获取已收到的竞标数量。你会看到竞标总数。 -
打开
http://localhost:8080/actuator/metrics/football.bids.duration以获取竞标处理持续时间。 -
打开
http://localhost:8080/actuator/metrics/football.bids.pending以获取待处理的竞标数量。
对于计数器和持续时间,通常,监控工具也会提供一个基于总值和观察频率计算出的速率。在性能分析方面,了解竞标处理速率比总数量更有趣。同样,这也适用于持续时间。
-
-
停止 JMeter 脚本。
它是如何工作的...
MeterRegistry类注册指标,之后它们将自动在 Actuator 指标端点中公开。
gauge调用分配给指标的代理。此代理将根据观察频率执行。在本食谱中,我们显式地调用端点。如果您使用监控工具,它将定期被观察。请注意,此操作应尽可能轻量,因为它将被频繁调用。
计时指标衡量了提供给代理执行所花费的时间。
计数器指标增加计数器的值。如果您在调用increment方法时没有提供值,就像我们在本食谱中所做的那样,它将只增加 1。您可以将一个数字作为方法增加的参数,此时它将根据提供的数字增加计数器的值。这个数字始终应该是正数。
还有更多...
您可以使用ObservedAspect bean 以更声明性的方式创建指标。
要将依赖项添加到AOP starter,请在您的pom.xml文件中包含以下内容:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
要配置ObserverAspect bean,请将以下方法添加到Football配置类中:
@Bean
ObservedAspect observedAspect(ObservationRegistry observationRegistry) {
return new ObservedAspect(observationRegistry);
}
在这一点上,您可以在代码中使用@Observed注解来自动生成指标。例如,在本食谱中,我们可以用@Observed注解AuctionService类:
@Observed(name = "football.auction")
@Service
public class AuctionService {
}
然后,您可以简化类,因为您不需要在构造函数中显式创建计数器。在addBidAOP方法中,您只需要关注应用逻辑:
public void addBidAOP(String player, String bid) {
bids.put(bid, player);
try {
Thread.sleep(random.nextInt(20));
} catch (InterruptedException e) {
e.printStackTrace();
}
bids.remove(bid);
}
当您运行应用程序并且使用AuctionService(第一次使用方法时指标是延迟创建的)时,您将看到 Actuator 指标端点中有两个新的指标:
-
football.auction:为您的注解类中定义的方法提供通用计数器 -
football.auction.active:为您的注解类中定义的方法提供活动执行的计数器
以下是从http://localhost:8080/actuator/endpoint/football.auction获取的football.auction指标的示例:
{
"name": "football.auction",
"baseUnit": "seconds",
"measurements": [
{
"statistic": "COUNT",
"value": 1648870
},
{
"statistic": "TOTAL_TIME",
"value": 15809.168264051
},
{
"statistic": "MAX",
"value": 0.02272261
}
],
"availableTags": [
{
"tag": "method",
"values": [
"addBidAOP"
]
},
{
"tag": "error",
"values": [
"none"
]
},
{
"tag": "class",
"values": [
"com.packt.footballobs.service.AuctionService"
]
}
]
}
您可以使用标签获取特定方法的指标。例如,要获取addBidAOP方法的指标,您可以执行以下请求:http://localhost:8080/actuator/metrics/football.auction?tag=method:addBidAOP。
此服务在本书的 GitHub 仓库中实现,位于https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook的chapter3/recipe3-8/end文件夹中。如前所述,该指标是延迟创建的,因此您应该调用此服务以使其可用。您可以通过在终端中执行以下curl请求来实现:
curl http://localhost:8080/football/bid/357669 \
--request POST \
--data "200"
将您的应用程序与 Prometheus 和 Grafana 集成
您拥有一个成功的足球交易应用,并且可以通过调用各种 Actuator 端点来观察它。然而,这种方式观察应用过于手动。因此,您希望有一个系统允许您自动化应用监控的方式。
在本食谱中,您将学习如何使用 Prometheus 可以使用的格式公开您的应用程序指标,之后您将使用 Prometheus 数据作为 Grafana 的数据源。Prometheus 是一个开源的监控解决方案,它收集和聚合指标作为时间序列数据,然后实时存储事件,以便事件可以用于监控您的应用程序。Grafana 是一个开源的可视化工具,允许您创建自定义仪表板、图表,甚至警报。Grafana 可以使用的数据源之一是 Prometheus 收集的数据。由于易于使用、灵活性和可扩展性,这两个工具的组合是一个非常受欢迎的选择。
准备工作
在本食谱中,您将重用 创建您自己的指标 食谱的结果。如果您还没有完成它,我已经准备了一个工作版本。您可以在本书的 GitHub 仓库 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/ 中找到它,在 chapter3/recipe3-7/start 文件夹中。
您将使用 Prometheus 和 Grafana 服务器。像往常一样,在您的本地计算机上运行 Prometheus 和 Grafana 最简单的方法是使用 Docker。
要下载并启动 Prometheus,请在您的终端中运行以下命令:
docker run -d --name prometheus -p 9090:9090 \
-v prometheus.yml:/etc/prometheus/prometheus.yml \
prom/prometheus
此命令使用 -v 参数将卷挂载到名为 prometheus.yml 的文件中。此文件包含 Prometheus 的配置。配置将在 如何做 它… 部分作为本食谱的一部分进行描述和创建。
要下载并启动 Grafana,请在您的终端中运行以下命令:
docker run -d --name grafana -p 3000:3000 grafana/grafana
为了模拟新功能的工作负载,我创建了一个 JMeter 脚本。您可以在本书的 GitHub 仓库中找到它,在 chapter3/recipe3-7/jmeter 文件夹中。您可以从项目的网站 jmeter.apache.org/download_jmeter.cgi 下载 JMeter。从这里,下载包含 JMeter 二进制文件的 ZIP 文件,并将其解压;不需要进一步安装。要运行 JMeter,请转到您解压二进制文件的文件夹,然后打开 bin 文件夹。在这里,您将找到不同的脚本以启动 JMeter,具体取决于您的操作系统。对于 Unix,您可以运行 jmeter 脚本,而对于 Windows,您可以运行 jmeter.bat 脚本。
如何做…
首先,我们将配置我们的应用程序,使其公开 Prometheus 端点。之后,我们将设置 Prometheus 和 Grafana,以便我们可以摄取应用程序提供的数据:
-
让我们从向交易应用程序公开 Prometheus 端点开始。为此,需要两个步骤:
- 将以下依赖项添加到
pom.xml文件中:
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>- 公开 Prometheus 端点。为此,打开
resources文件夹中的application.yml文件,并添加以下突出显示的属性:
management: endpoint: health: probes: enabled: true prometheus: enabled: true endpoints: web: exposure: include: health,env,metrics,beans,loggers,football,prometheus - 将以下依赖项添加到
-
您可以运行应用程序并打开 Prometheus 端点 http://localhost:8080/actuator/prometheus。
-
下一步是运行 Prometheus 并配置它以消费新暴露的端点。你可以通过创建一个
.yaml配置文件并将其挂载到 Prometheus Docker 镜像上来配置 Prometheus:- Prometheus 将托管在 Docker 上,而应用程序将托管在你的计算机上,即 Docker 主机。第一个任务是获取你计算机的 IP 地址。在 Linux 上,你可以在终端中运行以下命令:
ip addr show- 在 Windows 上,你可以在你的终端中运行以下命令:
ipconfig-
如果你在一个 WSL 终端中运行你的应用程序的
ip addr show。 -
例如,当我运行
ip addr show时,我接口的 IP 地址是172.26.109.186。我将使用这个值来配置 Prometheus YAML 文件。 -
让我们继续,使用上一步中获得的 IP 地址创建配置文件。在项目的根目录下,创建一个名为
prometheus.yml的应用程序,内容如下:
global: scrape_interval: 3s scrape_configs: - job_name: 'football_trading_app' metrics_path: '/actuator/prometheus' static_configs: - targets: ['172.26.109.186:8080']-
注意,我们配置了应用程序暴露的指标路径,目标是我们的应用程序的 IP 地址和端口。
-
现在,使用配置文件运行 Prometheus 容器。为此,在创建配置文件的同一目录中,在终端中执行以下命令:
docker run -d --name prometheus -p 9090:9090 \ prom/prometheus image, exposing port 9090 and mounting the prometheus.yml file in the container filesystem at /etc/prometheus/prometheus.yml. $(pwd) is a command substitution in Linux that is used to insert the current directory.
- 现在,Prometheus 应该正在运行并抓取你的应用程序以获取可观察性数据。要验证它是否正常工作,你可以在 http://localhost:9090 打开 Prometheus,然后打开状态菜单并选择目标:
![图 3.9:Prometheus 目标]()
图 3.9:Prometheus 目标
验证你的目标状态是否正常工作。它应该是UP。
- 你可以使用 Prometheus 来可视化你应用程序的数据。转到 Prometheus 主页,搜索任何指标,然后点击执行以查看数据。如果你选择图形选项卡,你将看到图形形式的数据:

图 3.10:在 Prometheus 中可视化数据
-
Prometheus 中可用的可视化功能有点有限,但我们可以使用 Grafana 并将其连接到 Prometheus 以实现更好的可视化:
- 确保 Grafana 正在运行。如准备就绪部分所述,你可以在终端中执行以下命令来运行 Grafana:
docker run -d --name grafana -p 3000:3000 grafana/grafana- 现在,你可以在浏览器中打开以下地址来打开 Grafana:
http://localhost:3000。你将需要输入你的凭证。你可以使用默认凭证——即用户设置为admin,密码设置为admin。
-
接下来,你需要将 Prometheus 作为 Grafana 数据源连接。此时,两个容器都在 Docker 中运行:
-
首先,你需要获取 Docker 中的 Prometheus IP 地址。你可以通过检查容器来获取此信息。执行以下命令以获取容器的 IP 地址:
- 要检索容器 ID,运行以下命令:
docker ps- 我的容器 ID 是
5affa2883c43。在运行以下命令时,用你的容器 ID 替换它:
docker inspect 5affa2883c43 | grep IPAddress我的终端看起来像这样:
-

图 3.11:使用 docker inspect 获取容器的 IP 地址
- 现在,打开左侧菜单并选择连接 | 数据源:

图 3.12:打开数据源
在搜索栏中点击Prometheus:

图 3.13:选择 Prometheus 作为数据源
然后,配置172.17.0.3,但你的值可能不同。端口号是9090:

图 3.14:配置 Prometheus 服务器 URL 属性
对于其余参数,你可以保留默认值。在页面底部,你会找到保存并测试按钮。点击它。此时,你可以通过构建仪表板来开始可视化数据。
- 最后,创建一个仪表板来可视化待处理竞标的数量。转到
football_bids_pending,然后点击运行查询。将时间范围更改为过去 30 分钟。最后,点击保存:

图 3.15:配置面板
现在,保存你的仪表板。将其命名为待处理竞标。
- 运行负载测试以查看指标如何在面板中可视化。你可以使用我创建的 JMeter 脚本来生成一些流量。你可以在本书的 GitHub 仓库中找到它,在
chapter3/recipe3-7/jmeter文件夹中。github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。Grafana 面板应该看起来像这样:

图 3.16:在 Grafana 中可视化的待处理竞标
通过这样,你已经学会了如何在 Grafana 等强大工具中可视化你的指标。
它是如何工作的…
Prometheus 是一个可扩展的工具,可以使用导出器。这些导出器是在 Prometheus 中运行的作业,如果它们使用适当的格式公开,可以从外部源获取数据。本食谱的作业定期从外部源抓取数据。在本食谱中,我们配置了我们的应用程序以导出 Prometheus 可以理解的数据格式,然后我们配置了一个目标来检索这些数据。
使用 Prometheus 的一些好处如下:
-
它可以从多个来源获取指标——不仅限于应用程序,还包括基础设施组件。
-
它允许使用 PromQL,这是一种用于查询和聚合数据的语言。你可以将来自多个来源的数据组合起来,以提取用于监控的相关信息。
-
你可以根据查询和定义的阈值创建警报。例如,我们可以使用 CPU 使用率阈值或我们的待处理竞标来发送警报。
Grafana 可以从不同的来源获取数据;其中之一是 Prometheus。这种组合在监控解决方案中非常受欢迎。Grafana 可以用于高级可视化,它还允许你创建警报并发送通知。这非常重要,因为它提高了监控自动化过程。
在这个菜谱中,我们使用了这些流行的开源工具,但相同的做法也可以用于其他商业工具。通常,监控工具管理跟踪、日志和指标,并添加可视化功能,如仪表板和通过不同渠道的警报。
需要考虑的一个重要问题是,在何时应该使用跟踪或指标进行监控。跟踪在显示服务之间的关系以及使用事务本身的数据查找特定操作方面非常有用。这有助于找到问题的根本原因。跟踪的主要问题是,在高操作量的场景中,生成的大量数据可能非常庞大,通常,跟踪会被采样,以便所有生成的数据都可以被处理,并且成本可以得到控制。
另一方面,指标汇总测量值,并且它们定期导出这些汇总测量值以创建时间序列数据。然后,生成的数据是恒定的,无论目标系统管理的流量如何。指标的主要优势是它们不需要采样,生成数据相当精确。因此,指标更适合某些类型的警报。然而,当你需要找到问题的根本原因时,跟踪更适合。
更改正在运行的应用程序的设置
到目前为止,你已经为你的成功的足球交易应用程序添加了日志,并且它接收了相当多的流量。程序在不同的地方创建日志。这些日志可以帮助你了解程序在运行时做了什么。并非每个日志都同等重要。因此,程序使用各种日志级别,从调试到错误日志。按日志级别排序可以防止创建过多的日志。然而,你想要确保可以在不重新启动或重新部署应用程序的情况下更改要处理的日志的最小级别。
一些 Spring Boot Actuator 端点允许你在运行时进行更改,无需重新启动应用程序。日志端点是这些端点之一,因为它允许你更改日志的最小级别。
在这个菜谱中,你将学习如何更改正在运行的应用程序的日志级别。
准备工作
在这个菜谱中,你将重用将你的应用程序与 Prometheus 和 Grafana 集成菜谱的结果。如果你还没有完成它,我已经准备了一个工作版本。你可以在本书的 GitHub 仓库中找到它,网址为 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/,在chapter3/recipe3-8/start文件夹中。
如何操作...
在这个配方中,你将调整足球交易应用程序,使其生成不同重要级别的日志。一旦完成,你将学习如何在运行时更改级别:
-
首先,让我们向
TradingService类添加一些日志:- 为该类创建一个日志记录器。你可以为此定义一个静态成员:
private static final Logger logger = LoggerFactory.getLogger(TradingService.class);- 然后,向
getPendingOrders方法添加调试和信息日志记录:
public int getPendingOrders() { logger.debug("Ensuring that pending orders can be calculated"); Random random = new Random(); int pendingOrders = random.nextInt(100); logger.info(pendingOrders + " pending orders found"); return pendingOrders; }- 你也可以为
tradeCards方法添加一些日志记录:
public int tradeCards(int orders) { if (getPendingOrders() > 90) { logger.warn("There are more than 90 orders, this can cause the system to crash"); AvailabilityChangeEvent.publish(applicationEventPublisher, new Exception("There are more than 90 pending orders"), LivenessState.BROKEN); } else { logger.debug("There are more less than 90 orders, can manage it"); AvailabilityChangeEvent.publish(applicationEventPublisher, new Exception("working fine"), LivenessState.CORRECT); } return orders; } -
现在,你可以执行一些请求并验证信息是否被记录。你可以在你的终端中执行以下命令来每秒执行一个请求:
watch -n 1 -x curl --request POST -H "Content-Type: application/json" --data "1" http://localhost:8080/football你会看到只有
INFO和WARN日志被处理:

图 3.17:只有 INFO 和 WARN 日志被处理
这是因为默认级别是INFO。这意味着只有INFO或更高优先级的级别会被记录。
-
你可以通过调用 Actuator 的
loggers端点来验证日志级别。访问 http://localhost:8080/actuator/loggers。你会看到可用的日志级别,以及你应用程序中定义的日志记录器。你会看到有一个为你服务类com.packt.footballobs.service.TradingService的日志记录器,并且有效级别是INFO。 -
假设你已经在应用程序中检测到一个问题,并且你想激活
DEBUG级别。让我们通过使用 Actuator 的loggers端点来更改它。为此,你只需要执行以下请求:curl --request POST \ -H 'Content-Type: application/json' \ -d '{"configuredLevel": "DEBUG"}' \ http://localhost:8080/actuator/loggers/com.packt.footballobs.service.TradingService你会看到它现在还生成了
DEBUG级别的日志:

图 3.18:生成 DEBUG 和更高优先级的临界日志
如果你验证了步骤 3中解释的loggers端点,你会看到TradingService类现在有两个属性:
-
configuredLevel:DEBUG -
effectiveLevel:DEBUG
-
现在你已经验证了日志,你决定将日志级别更改为
WARN,因为DEBUG和INFO日志产生了太多的噪音,你可以运行以下命令:curl --request POST \ -H 'Content-Type: application/json' \ -d '{"configuredLevel": "WARN"}' \ http://localhost:8080/actuator/loggers/com.packt.footballobs.service.TradingService
如果你验证了步骤 3中解释的loggers端点,你会看到TradingService的级别是WARN。如果你继续发送请求,你会看到只有WARN日志被输出。
它是如何工作的…
正如我们在创建自定义 Actuator 端点的配方中看到的,一些端点实现了更新和删除操作。loggers端点允许你更改日志级别。当你需要在生产环境中查找问题时,这是一个非常有用的功能,因为你不再需要重新启动你的应用程序。
在高流量应用中,你通常会希望设置较高的日志级别,例如WARN。这是警告级别,通常用于指示存在潜在问题或异常,应该引起注意。它表示的情况可能不一定是一个错误,但如果未解决,可能会导致问题。使用更高日志级别,如WARN的原因是日志通常由监控系统保存。如果应用生成太多日志,处理和保留它们需要更多资源,这可能会造成成本增加。同时,DEBUG和INFO日志不是关键的,它们可能会生成过多信息,使得找到问题的根本原因变得更加困难。
还有更多...
其他标准端点是 Spring Boot 的一部分,允许你在运行时进行更改。例如,sessions端点允许你检索和删除用户会话。
第四章:Spring Cloud
在现代系统中,您可能会发现几个微服务相互交互。Spring Cloud 提供了易于部署的组件,这些组件简化了分布式系统的交互和协调,以应对大规模应用程序的挑战,如可扩展性、可用性、可观察性和弹性。
在本章中,您将学习如何使用 Spring Cloud 组件开发一个可扩展且具有弹性的分布式系统。您将基于前几章的学习内容,并在本 Spring Cloud 配置中配置安全和可观察性。这将帮助您有效地监控和排查您的分布式架构。到本章结束时,您将了解如何设计和开发云原生应用程序。
最后,您将学习如何部署 Spring Boot Admin,这是一个在 Spring 生态系统中被广泛使用的开源项目。该项目提供了一个用户友好的 Web 界面,使您能够集中监控和管理多个 Spring Boot 应用程序。此外,它可以轻松地与其他 Spring Cloud 组件集成。
在本章中,我们将介绍以下主要菜谱:
-
设置 Eureka Server
-
在 Eureka Server 中集成应用程序
-
扩展 RESTful API
-
设置 Spring Cloud Gateway
-
测试 Spring Cloud Gateway
-
设置 Spring Cloud Config
-
保护 Spring Cloud Gateway
-
将分布式跟踪与 Spring Cloud 集成
-
部署 Spring Boot Admin
技术要求
本章需要一些服务在您的计算机上运行,例如 OpenZipkin。像往常一样,在您的计算机上运行它们的最简单方法是使用 Docker。您可以从 Docker 产品页面 https://www.docker.com/products/docker-desktop/获取 Docker。我将在相应的菜谱中解释如何部署每个工具。
设置 Spring Cloud Config菜谱需要 Git 仓库。您可以通过免费创建 GitHub 账户来创建一个(github.com/join)。
您可以在此处找到本章所有菜谱的代码:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/tree/main/chapter4.
设置 Eureka Server
Eureka Server 是一个服务注册中心,在微服务架构中用于注册其他应用程序可以发现的实例。这是一个非常有价值的服务,它允许服务动态地定位和相互通信。此服务注册中心对已注册服务的实例执行健康检查,自动移除不健康或不响应的实例。当服务需要与其他服务通信时,Eureka Server 提供可用的实例,允许负载均衡。
在此菜谱中,您将学习如何创建一个实现 Eureka Server 的应用程序。
准备工作
此菜谱没有其他额外要求。
如何操作...
在这个菜谱中,我们将创建一个新的 Eureka 服务器,我们将在其余的菜谱中重用它。让我们开始吧:
-
首先,我们将为 Eureka 服务器创建一个新的应用程序。为此,打开
start.spring.io并使用与 第一章 中 “创建 RESTful API” 菜谱中相同的参数,除了更改以下选项:-
对于
registry -
对于 依赖项,选择 Eureka Server
-
-
然后,在生成的项目中,在
resources文件夹中,创建一个名为application.yml的文件,并设置以下配置:server: port: 8761 eureka: client: registerWithEureka: false fetchRegistry: false -
接下来,打开
RegistryApplication类,并使用@EnableEurekaServer注解:@EnableEurekaServer @SpringBootApplication public class RegistryApplication -
现在,您可以启动应用程序。
-
让我们验证 Eureka 服务器是否正在运行。在您的浏览器中打开
http://locahost:8761:

图 4.1:Eureka 服务器
在 Eureka 服务器页面上,您可以查看有关服务器的一般信息,最重要的是,注册在服务器上的应用程序。现在,我们还没有任何应用程序注册。一旦我们将以下菜谱中的应用程序连接起来,我们将在 当前注册的实例 下看到它们。
它是如何工作的...
Spring Boot 应用程序中的 Eureka 服务器依赖项允许您设置和运行服务注册。当您使用 @EnableEurekaServer 注解时,Eureka 服务器自动配置被激活。Eureka 服务器应用程序必须进行配置,以便它可以停止自己作为服务注册,这就是为什么 eureka.client.registerWithEureka 和 eureka.client.fetchRegistry 设置都设置为 false 的原因。其他必需的 Eureka 服务器配置是端口号。我们已将 Eureka 服务器配置为监听端口 8761。
在 Eureka 服务器中集成应用程序
在这个菜谱中,我们将把两个应用程序集成到之前菜谱中部署的 Eureka 服务器中。一个应用程序提供足球数据,另一个应用程序消费这些数据。我们将使用 Eureka 服务器来注册这两个应用程序,此时消费者将使用 Eureka 服务器来发现提供者应用程序。
准备工作
除了之前菜谱中部署的 Eureka 服务器外,我们还将重用我们在 第一章 中的 “定义 API 暴露的响应和数据模型” 和 “从另一个 Spring Boot 应用程序消费 RESTful API” 菜谱中创建的应用程序。
作为起点,您可以使用我在本书的存储库中准备的应用程序:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。您可以在 chapter4/recipe4-2/start 文件夹中找到代码。
如何操作...
我们将把来自 第一章 的 从另一个 Spring 应用程序使用 RestClient 消费 RESTful API 菜单中的 football 和 albums 应用程序集成到 Eureka 服务器中,该服务器我们在之前的菜谱中部署。让我们进行必要的调整:
-
首先,我们将修改应用程序,使它们连接到 Eureka 服务器实例。我们将从
football应用程序开始。进行以下更改:-
将以下依赖项添加到
pom.xml文件中:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka- client</artifactId> </dependency> -
确保在
pom.xml文件中配置了 Spring Cloud 的依赖管理:<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> -
确保在
pom.xml文件中定义了spring-cloud.version属性:<properties> <java.version>21</java.version> <spring-cloud.version>2022.0.4</spring-cloud.version> </properties> -
在
resources文件夹中,添加一个名为application.yml的文件,内容如下:server: port: 0 spring: application: name: FootballServer eureka: client: serviceUrl: defaultZone: http://localhost:8761/eureka/
-
-
启动
football应用程序。 -
在这一点上,您将能够看到注册在 Eureka 服务器上的应用:

图 4.2:注册在 Eureka 服务器上的 RESTful 应用
-
接下来,通过以下更改修改 RESTful API
albums消费者应用程序:-
将
org.springframework.cloud:spring-cloud-starter-netflix-eureka-client和org.springframework.cloud:spring-cloud-starter-openfeign依赖项添加到pom.xml文件中:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> -
在
AlbumsApplication.类中添加@EnableDiscoveryClient注解:@EnableDiscoveryClient @EnableFeignClients @SpringBootApplication public class AlbumsApplication { -
在
resources文件夹中添加一个application.yml文件,配置如下:spring: application: name: AlbumsServer eureka: client: serviceUrl: FootballClient class, changing @FeignClient by setting just the target application name:@FeignClient("FootballServer")
public interface FootballClient {
@RequestMapping(method = RequestMethod.GET, value = "/players")
List
getPlayers(); }
注意,我们不再使用远程 RESTful API 服务器地址,只需使用应用程序名称。
-
-
现在,您可以运行
albums应用程序。 -
最后,您可以测试整个部署。为此,执行以下
curl请求:curl http://localhost:8080/albums/players消费者应用程序将通过询问 Eureka 服务器来发现服务器应用程序的可用实例,之后它将调用服务器应用程序并返回结果。
它是如何工作的...
要设置客户端连接到 Eureka 服务器,需要添加 org.springframework.cloud:spring-cloud-starter-openfeign 和 org.springframework.cloud:spring-cloud-starter-netflix-eureka-client 依赖项,并配置连接到 Eureka 服务器。客户端的配置包括以下内容:
-
eureka.client.serviceUrl.defaultZone: 这是 Eureka 服务器地址。在我们的例子中,这是http://localhost:8761/eureka。 -
spring.appication.name: 这是用于发现服务的名称。
OpenFeign 和 Eureka 客户端使用 Eureka 服务器来发现服务的实例。记住,在 @OpenFeignClient 配置中,我们使用服务器应用程序名称而不是服务器地址。OpenFeign 客户端连接到 Eureka 服务器,请求已注册为该服务的实例,并返回一个。
对于客户端来说,这更为直接,因为事先知道服务器实例的地址是不必要的。
发现机制对于服务器应用程序也非常方便,因为它们不需要托管在预定义的服务器和端口上。你可能已经注意到,RESTful API 服务器被配置为 server.port=0,这意味着它将在一个随机端口启动。服务器地址和端口在注册到 Eureka 服务器时被存储。当消费者应用程序请求 Eureka 服务器时,它返回有关注册实例的信息——即服务器地址和端口。这个特性很有帮助,因为我们本地运行应用程序,我们不需要关心每个实例运行在哪个端口上。在之前的菜谱中,我们启动了一个在端口 8080 上的应用程序,另一个在 8081 上。在 扩展 RESTful API 服务 菜谱中,我们将看到可以有一个给定服务的多个实例。
还有更多...
Eureka 服务器的一个关键特性是检测不健康或无响应的应用程序实例,并将它们从注册表中移除。这个特性要求注册的服务使用Actuator。Spring Actuator 提供了生产就绪的功能,帮助你监控和管理你的 Spring 应用程序。它特别适用于微服务和其他分布式系统,在这些系统中,操作可见性和管理至关重要。你可以通过以下代码将 Actuator 依赖项包含到你的项目中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
你可以在项目页面找到有关 Actuator 的更多信息:docs.spring.io/spring-boot/docs/current/reference/html/actuator.html。
扩展 RESTful API
扩展是一种通过为给定服务添加多个实例来提高系统可用性和容量的技术。
在现代应用程序平台中,例如 Kubernetes 这样的容器编排器或 Azure App Services 或 AWS Elastic Beanstalk 这样的云服务提供商托管平台,系统可能会自动扩展和缩减。例如,在 Kubernetes 中,你可以配置一个自动扩展规则,当过去 5 分钟的平均 CPU 使用率超过 70% 时,增加你的服务实例数量。你也可以以另一种方式配置它——当你的应用程序使用率低时,你可以缩减应用程序。这意味着你可以减少应用程序实例的数量。
扩展应用程序不一定需要自动化;你可以手动扩展它,就像我们在本菜谱中所做的那样。
扩展涉及将传入请求分配到多个服务实例。在这个菜谱中,我们将学习如何使用 Eureka 服务器的能力来注册和发现实例,以便将请求分配到可用的服务实例。
准备工作
在这个菜谱中,我们将使用在之前菜谱中使用的服务:
-
**Eureka Server**:此服务将作为服务注册表并提供服务发现 -
**RESTful API**:这将提供一个由客户端应用程序消费的服务 -
**Client application**:这将消费 RESTful API
如果你还没有完成前面的食谱,你可以在本书的 GitHub 存储库中找到完成的练习,网址为 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。
你可以在chapter4/recipe4-3/start文件夹中找到启动此食谱的代码。
如何做到...
我们将修改 RESTful API,使其返回服务实例的信息。这样,我们可以验证请求是否在可用的实例之间均衡。然后,我们将执行多个 RESTful API 实例。让我们开始吧:
-
在
RESTful API项目中的resources文件夹中,修改application.yml文件,在文件开头添加以下属性:instance: instance-id: ${spring.application.name}:${random.int}文件应该看起来像这样:
football: instanceId: ${random.uuid} server: port: 0 spring: application: name: FootballServer eureka: client: serviceUrl: defaultZone: http://localhost:8761/eureka/ instance: ServiceInformationController and write the following code:@RequestMapping("/serviceinfo")@RestControllerpublic class ServiceInformationController {@Value("${football.instanceId}")private String instanceId;@GetMappingpublic String getInstanceId() {return instanceId;}} -
执行三个 RESTful API 实例。我们不会使用
mvnw spring-boot:run,而是构建 JAR 文件并使用 Java 运行时执行它。为此,请按照以下步骤操作:- 在项目的根目录下,使用以下命令构建应用程序:
./mvnw package- 然后,打开三个终端,在所有终端中执行以下命令:
java -jar ./target/football-0.0.1-SNAPSHOT.jar- 在
localhost:8761打开 Eureka 服务器。你会看到三个 RESTful API 服务的实例:

图 4.3:运行着三个 FootballServer 实例的 Eureka 服务器
-
在客户端应用程序项目中,进行以下更改:
-
在
FootballClient类中,添加以下方法:@RequestMapping(method = RequestMethod.GET, value="/serviceinfo") String getServiceInfo(); -
在
AlbumsController控制器中,添加以下方法:@GetMapping("/serviceinfo") public String getServiceInfo(){ return footballClient.getServiceInfo(); }
-
-
现在,启动客户端应用程序并多次测试应用程序。你可以通过多次执行以下
curl请求来实现:curl http://localhost:8080/albums/serviceinfo当你多次执行前面的命令时,你会看到结果发生变化:

图 4.4:从客户端应用程序执行 RESTful API 的结果
客户端应用程序将请求分配到 Eureka 服务器注册的服务实例中,导致不同的结果。
它是如何工作的...
当 Eureka 客户端启动时,它会在 Eureka 服务器中注册自己。注册详情包括服务名称和网络位置。在注册过程之后,客户端发送心跳来通知服务器它仍然存活。在这个练习中,我们使用相同的服务名称启动了三个 RESTful API 服务器的实例;每个实例都有一个单独的网络位置。
消费者应用程序中的 Feign 客户端使用 Eureka 服务器来发现 RESTful API 服务器应用程序的可用实例。这样,它可以在服务实例之间平衡请求。
仅用于演示目的,我们添加了一个配置设置,football.InstanceId,具有唯一的随机值以区分服务实例。为了检索该配置,我们使用了@Value注解。Spring Boot 在应用程序启动时注入了该值。
设置 Spring Cloud Gateway
当创建具有不同服务的复杂应用程序时,我们不希望将所有这些服务暴露给消费者应用程序,以避免不必要的复杂性暴露。为了应对这种情况,我们可以使用Spring Cloud Gateway。Spring Cloud Gateway 可以部署成这样,它是消费者应用程序唯一可访问的组件,而其余的服务将通过内部访问或仅从 Spring Cloud Gateway 访问。这如图图 4.5所示:

图 4.5:典型的 Spring Cloud Gateway 部署
关于部署的注意事项
根据解决方案的复杂性和要求,我建议使用额外的网络保护措施,例如第 7 层负载均衡器、Web 应用防火墙(WAF)或其他保护机制。为了学习目的,我将在本书中不描述它们,而是专注于 Spring 和 Spring Cloud 机制。
除了作为提供独特入口点的 API 网关的角色外,Spring Cloud Gateway 还有有趣的优点:
-
负载均衡: 它可以在可用的服务实例之间平衡请求。
-
动态路由: Spring Cloud Gateway 可以与服务注册中心,如 Eureka 服务器,集成,并动态路由请求。
-
安全: 它可以使用认证和授权提供者,例如 Spring Security 和 OAuth2,并将它们传播到下游服务。如果您需要为您的消费者应用程序配置 CORS,可以在一个地方完成这项操作。
-
SSL 终止: 您可以配置 Spring Cloud Gateway 以终止 SSL/TLS 连接并将未加密的流量传递到服务。使用此功能,您可以将 SSL/TLS 解密从服务中卸载。
-
速率限制: 您可以实现速率限制以防止您的服务被滥用。
-
请求/响应转换: 您可以使用 Spring Cloud Gateway 来转换请求和响应 – 例如,通过添加请求或响应头。您还可以将有效载荷格式,如 XML,转换为 JSON。这些转换可以在网关级别应用;因此,没有必要修改您的下游服务。
-
断路器:你可以使用 Spring Cloud Gateway 来实现断路器,以优雅地处理故障。例如,你可以防止请求发送到不健康的服务。
一些额外的优点包括请求过滤、全局异常处理、日志和监控以及路径重写。
我建议访问 https://spring.io/projects/spring-cloud-gateway 的项目页面以获取更多详细信息。
在这个食谱中,我们将部署 Spring Cloud Gateway 的一个实例,并将其与我们在前面的食谱中部署的 Eureka 服务器集成,以将请求路由到已注册的服务。
准备工作
在这个食谱中,我们将使用我们在前面的食谱中实现的项目:
-
Eureka 服务器:此服务将作为服务注册表并提供服务发现。
-
football应用。 -
album应用。
如果你还没有完成前面的食谱,你可以在本书的 GitHub 仓库中找到完成的练习,网址为 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。启动此食谱的代码可以在chapter4/recipe4-4/start文件夹中找到。
如何操作...
让我们部署一个 Spring Cloud Gateway。我们将配置网关,使其公开 RESTful API 的一些功能:
-
访问
start.spring.io,使用与你在创建 RESTful API食谱中相同的参数,除了更改以下选项:-
对于
gateway -
对于依赖项,选择Gateway和Eureka 发现客户端
-
-
在你下载的项目中,在
resources文件夹中创建一个名为application.yml的文件,内容如下:spring: application: name: GatewayServer cloud: gateway: routes: - id: players uri: lb://footballserver predicates: - Path=/api/players/** filters: - StripPrefix=1 eureka: client: serviceUrl: defaultZone: http://localhost:8761/eureka/ -
现在,你可以运行网关应用。需要注意的是,其他应用也应该在运行。
-
通过执行以下请求来测试网关:
curl http://localhost:8080/api/players你应该能看到 RESTful API 的结果。
-
现在,让我们在
Albums中的其他 RESTful API 应用中添加一个新的方法,然后将其作为新的路由添加到 Spring Cloud Gateway 中。因此,打开AlbumsController控制器并添加以下方法:@GetMapping public List<String> getAlbums(){ return List.of("Album 1", "Album 2", "Album 3"); } -
在同一个项目中,打开
application.yml文件并添加以下属性:server: port: 0现在,在 Spring Cloud Gateway 配置中添加一个新的路由。为此,打开 Spring Cloud Gateway 项目的
application.yml文件并添加以下突出显示的文本。为了清晰起见,我添加了整个配置文件:spring: application: name: GatewayServer cloud: gateway: routes: - id: players uri: lb://footballserver predicates: - Path=/api/players/** filters: - StripPrefix=1 - id: albums uri: lb://albumsserver predicates: - Path=/api/albums/** filters: - StripPrefix=1 eureka: client: serviceUrl: defaultZone: http://localhost:8761/eureka/ -
重新启动 Spring Cloud Gateway,并通过执行以下
curl请求来测试新的路由:curl http://localhost:8080/api/albums现在,你应该能看到第二个 RESTful API 的响应。
它是如何工作的...
在这个食谱中,我们连接了 Spring Cloud Gateway 到 Eureka 服务器。为此,我们只需要包含 Eureka 发现客户端及其配置——即在application.yml文件中的eureka.client.serviceUrl.defaultZone属性。
一旦连接到 Eureka 服务器,我们配置了一些路由。路由定义指定了当请求匹配标准时要采取的准则和动作的组合。
我们通过使用谓词来建立路由定义的标准。具体来说,我们配置了两个路由:一个使用/api/players/**路径模式,另一个使用/api/albums/**。此配置规定第一个路由将匹配以/api/player开头的请求,而第二个路由将匹配以/api/albums开头的请求。例如,一个如http://localhost:8080/api/player的请求将匹配第一个路由。除了请求路径之外,您还可以利用其他请求属性,例如头部、查询参数或请求主机。
由于目标服务在一种情况下期望请求为/players,在另一种情况下为/albums,且两种情况都不包含/api,因此删除路径的这一部分是必要的。我们使用StripPrefix=1过滤器来配置这一点,该过滤器删除了路径的第一部分。
最后,那些路由需要击中目标服务,因此我们使用uri属性来配置这一点。我们本可以使用 DNS 主机和端口,例如http://server:8081,但相反,我们使用了lb://servicename。使用这种方法,我们配置 Spring Cloud Gateway 使用 Eureka 发现目标服务并利用客户端负载均衡。我们本地部署了所有服务,区分每个实例的唯一方法是通过动态为每个服务分配端口。
注意
要动态分配端口,我们设置server.port=0属性。
如果托管环境提供了替代的负载均衡方法,则可以使用它。例如,在 Kubernetes 环境中,您可以为您的服务创建一个具有多个运行实例的部署。通过这样做,您的服务可以通过 Kubernetes DNS 进行发现,底层基础设施将进行请求均衡。
参见
我建议阅读 Spring Cloud Gateway 文档,您可以在以下链接找到:spring.io/projects/spring-cloud-gateway。熟悉路由功能,并了解如何使用请求中所有可用的属性来配置您的路由。
断路器(Circuit Breaker)也是一个有趣的设计模式,它可以非常有助于优雅地处理故障。如果您不熟悉这个模式,我建议查看这篇Azure Cloud Design Patterns文章:learn.microsoft.com/azure/architecture/patterns/circuit-breaker。好消息是这个模式相对容易使用 Spring Cloud Gateway 实现——有关更多详细信息,请参阅spring.io/guides/gs/gateway/。
测试 Spring Cloud Gateway
由于 Spring Cloud Gateway 规则是在运行时处理的,因此有时很难进行测试。除了规则本身外,目标应用程序也必须处于运行状态。
在这个菜谱中,我们将学习如何使用Spring Cloud Contract Stub Runner启动器测试 Spring Cloud Gateway,该启动器使用 Wiremock 库模拟目标服务。
准备工作
在这个菜谱中,我们将为之前菜谱中设置的 Spring Cloud Gateway 项目创建测试。我准备了一个 Spring Cloud Gateway 的工作版本,以防你还没有设置它。你可以在本书的 GitHub 仓库中找到它:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。启动此菜谱的代码可以在chapter4/recipe4-5/start文件夹中找到。我已经添加了之前菜谱中使用的所有项目——即football、albums和gateway项目——但我们在这里只会使用gateway。
如何做到这一点...
在这个菜谱中,我们将调整网关项目以允许测试执行。让我们开始吧:
-
首先,我们将添加Spring Cloud Contract Stub Runner启动器。为此,在
gateway项目的pom.xml文件中添加以下依赖项:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency>注意,这个依赖项仅用于测试目的。
-
接下来,修改
application.yml配置以参数化目标 URI。替换spring.cloud.gateway.routes.uri中的地址,使其使用配置参数:spring: application: name: GatewayServer cloud: gateway: routes: - id: players uri: ${PLAYERS_URI:lb://footballserver} predicates: - Path=/api/players/** filters: - StripPrefix=1 - id: albums uri: ${ALBUMS_URI:lb://albumsserver} predicates: - Path=/api/albums/** filters: - StripPrefix=1 eureka: client: serviceUrl: defaultZone: http://localhost:8761/eureka/ -
在创建我们的第一个测试之前,我们需要设置测试类。让我们在
test文件夹中创建一个名为RoutesTests的新类。为了设置它,你必须执行以下操作:-
使用
@AutoConfigureWireMock(port =)0注解类 -
使用
properties字段用@SpringBootTest注解类,以传递目标 URI -
添加
WebTestClient字段,Spring Boot 测试上下文将注入此字段
这个类的骨架应该看起来像这样:
@AutoConfigureWireMock(port = 0) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "PLAYERS_URI=http://localhost:${wiremock.server.port}", "ALBUMS_URI=http://localhost:${wiremock.server.port}", }) public class RoutesTester { @Autowired private WebTestClient webClient; } -
-
现在,我们可以创建我们的第一个测试。我们将添加一个带有
@Test注解的新方法来检查players路由:- 将方法命名为
playersRouteTest:
@Test public void playersRouteTest() throws Exception- 首先,安排目标服务器在调用
/players路径时的响应。我们将使用 Wiremock 库:
stubFor(get(urlEqualTo("/players")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBody(""" [ { "id": "325636", "jerseyNumber": 11, "name": "Alexia PUTELLAS", "position": "Midfielder", "dateOfBirth": "1994-02-04" }, { "id": "396930", "jerseyNumber": 2, "name": "Ona BATLLE", "position": "Defender", "dateOfBirth": "1999-06-10" } ]""")));- 现在,我们可以通过使用
WebTestClient并断言它按预期工作来调用 Spring Cloud Gateway:
webClient.get().uri("/api/players").exchange() .expectStatus().isOk() .expectBody() .jsonPath("$[0].name").isEqualTo("Alexia PUTELLAS") .jsonPath("$[1].name").isEqualTo("Ona BATLLE"); - 将方法命名为
-
现在,你可以使用相同的方法测试
albums路由。这本书的 GitHub 仓库包含更多关于 Spring Cloud Gateway 的测试:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。
它是如何工作的...
当配置 Spring Cloud Gateway 项目时,需要考虑两个依赖项:Eureka Server 和目标 RESTful API。然而,主要目的是在测试期间验证网关路由。为了实现这一点,我们移除了对 Eureka Server 的依赖,并允许配置目标 RESTful API URI。通过在 步骤 2 中使用 ${key:default} 语法,我们创建了一个回退机制,该机制使用配置的负载均衡器地址。如果没有提供值,则默认为原始 URI。此语法指定如果提供了密钥,则使用该密钥;否则,使用冒号符号之后指定的默认值。
使用之前描述的配置机制和由 Spring Cloud Contract Stub Runner 启动器提供的 Wiremock,我们配置了远程 RESTful API 的地址,考虑到 Wiremock 服务器正在本地主机上运行,端口由 Wiremock 服务器提供。在 @AutoConfigureWireMock 注解中,我们使用了端口 0 以确保端口被随机分配。然后,使用 ${wiremock.server.port},我们检索了分配的端口。
测试的其余部分遵循我们在 第一章 中解释的 Mocking a RESTful API 菜单中相同的模拟机制。请注意,模拟的 RESTful API 对 /players 进行响应,而测试请求 /api/players。在这个测试中,我们想要验证 Spring Cloud Gateway 的配置是否正确,因此当向 /api/players 发起请求时,它将调用重定向到 /players 路径上的目标 API。只要测试实现正确且 Spring Cloud Gateway 配置得当,测试应该没有问题通过。
设置 Spring Cloud Config
Spring Cloud Config 允许对应用程序进行集中式配置管理,允许您将配置属性存储在中央存储库中,并将它们分发到连接的服务。
它提供了以下功能,以及其他功能:
-
它允许版本控制配置 - 例如,使用 git 作为后端来存储配置。使用此功能,您可以跟踪更改并审计配置,并在需要时方便执行回滚到先前版本。
-
它允许无需重新启动服务即可进行动态配置更新。
-
它将配置外部化;因此,可以在不修改或重新部署服务的情况下进行配置更改。
在本菜谱中,我们将部署配置服务器并将我们的现有 RESTful API 连接到配置服务。
准备工作
对于本菜谱,您需要一个 Git 仓库。我建议使用 GitHub,因为这个菜谱已经与该服务进行了测试和验证,但我预计如果您使用其他 git 提供商也不会有任何问题。如果您想使用 GitHub 而且还没有账户,请访问 github.com。您还需要一个 git 客户端。
我将重用我们在之前的菜谱中配置的 RESTful API。这些是我们必须配置的服务:
-
football(RESTful API) -
albums(RESTful API) -
gateway
如果你还没有完成之前的菜谱,你可以使用本书 GitHub 仓库中的完成菜谱:https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。
启动此菜谱的代码可以在chapter4/recipe4-6/start文件夹中找到。
如何做到这一点...
在这个菜谱中,我们将使用 Spring Initializr 创建一个新的服务来托管 Spring Cloud Config。接下来,我们将配置该服务以使用 GitHub 仓库作为后端。最后,我们将连接现有服务到配置服务器。让我们开始吧:
-
打开
start.spring.io,使用与你在第一章中创建 RESTful API 菜谱时相同的参数,除了以下选项:-
对于
config -
对于依赖项,选择Config Server
-
-
在你的 GitHub 账户中创建一个 GitHub 仓库。由于我们不会管理任何机密内容,该仓库可以是公开的。将其命名为
spring3-recipes-config。 -
在你的计算机上克隆存储库。为此,打开一个 Terminal 并执行以下命令,将
felipmiguel替换为你的 GitHub 账户名称:git clone https://github.com/felipmiguel/spring3-recipes-config这将创建
spring3-recipes-config作为该存储库的根文件夹。在以下步骤中,我们将创建文件夹中的文件,稍后会将这些文件推送到 GitHub 的中央仓库。
-
在配置存储库的根目录中,创建以下文件:
-
application.yml,内容如下:server: port: 0 eureka: client: serviceUrl: defaultZone: http://localhost:8761/eureka/ instance: instance-id: ${spring.application.name}:${random.int} -
gatewayserver.yml,内容如下:server: port: 8080 spring: cloud: gateway: routes: - id: players uri: ${PLAYERS_URI:lb://footballserver} predicates: - Path=/api/players/** filters: - StripPrefix=1 - id: albums uri: ${ALBUMS_URI:lb://albumsserver} predicates: - Path=/api/albums/** filters: - StripPrefix=1
-
-
接下来,将文件推送到
github.com。为此,在存储库根目录的 Terminal 中执行以下命令:git commit -m "Initial configuration" . git push -
将你的存储库配置为配置服务的后端。为此,转到配置服务项目,在
resources文件夹中添加一个名为application.yml的文件,内容如下(确保将[your account]替换为你的 GitHub 账户名称):server.port: 8888 spring: cloud: config: server: git: ConfigApplication class and add the @EnableConfigServer annotation. It should look like this:@EnableConfigServer
@SpringBootApplication
public class ConfigApplication
-
现在,你可以启动配置服务器。
-
接下来,修改项目以便可以连接到配置服务器。为此,遵循以下步骤:
-
将依赖项添加到所有我们想要连接到配置服务器的应用程序的
pom.xml文件中。这些应用程序是football、album、registry和gateway:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-config</artifactId> </dependency> -
配置所有我们想要连接到配置服务器的应用程序的
application.yml文件。所有这些都将包含配置服务器配置和相应应用程序的名称。例如,album服务将如下所示:spring: config: import: optional:configserver:http://localhost:8888 application: name: AlbumsServer -
对于
football服务(RESTful API 服务),设置以下内容:football: instanceId: ${random.uuid} spring: config: import: optional:configserver:http://localhost:8888 application: name: FootballServer -
对于
gateway服务,设置以下内容:spring: config: import: optional:configserver:http://localhost:8888 application: name: gatewayserver
-
-
现在,是时候验证一切是否正常工作了。让我们启动所有服务。
-
通过向 Spring Cloud Gateway 执行请求来测试服务:
curl http://localhost:8080/api/players -
验证它返回一个包含玩家列表的 JSON 文件。
它是如何工作的...
Spring Boot 提供了一种可扩展的机制,通过使用 spring.config.import 设置从外部源加载配置。添加 org.springframework.cloud:spring-cloud-starter-config 依赖项将注册一个扩展,可以从配置服务器检索配置。
要设置配置服务器,唯一的要求是添加 org.springframework.cloud:spring-cloud-config-server 依赖项,并使用 @EnableConfigServer 注解启用配置服务器。启用配置服务器会公开一个端点,允许消费者应用程序查询其配置。配置端点公开以下路径:
/{application}/{profile}[/{label}]
/{application}-{profile}.yml
/{label}/{application}-{profile}.yml
/{application}-{profile}.properties
/{label}/{application}-{profile}.properties
让我们看看每个路径片段:
-
application是由spring.application.name属性配置的应用程序名称。 -
profile是当前活动的配置文件。默认情况下,配置文件的名字是default。 -
label指代一个 Git 分支;如果没有指定,则应用于默认分支。
我们的应用程序为配置服务器提供了以下查询:
-
football:因为它包含spring.application.name=FootballServer属性,所以它请求http://localhost:8888/FootballServer-default.yml -
albums:其应用名称为AlbumsServer,因此它请求http://localhost:8888/AlbumsServer-default.yml -
gateway:其应用名称为GatewayServer,因此它请求http://localhost:8888/GatewayServer-default.yml
您可以通过执行请求来查看结果。例如,对于 GatewayServer,您可以运行以下命令:
curl http://localhost:8888/GatewayServer-default.yml
结果应该看起来像这样:
server:
port: 8080
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
spring:
cloud:
gateway:
routes:
- id: players
uri: ${PLAYERS_URI:lb://footballserver}
predicates:
- Path=/api/players/**
filters:
- StripPrefix=1
- id: albums
uri: ${ALBUMS_URI:lb://albumsserver}
predicates:
- Path=/api/albums/**
filters:
- StripPrefix=1
让我们分析一下配置服务器做了什么。配置服务器通过合并它在 Git 仓库中找到的配置来解析配置:
-
基本配置从
application.yml文件开始。 -
它将基本配置与请求应用程序的更具体配置合并。更具体的配置使用
[应用名称].yml文件定义,其中[应用名称]在spring.application.name属性中定义。在我们的场景中,我们没有为football和albums应用程序定义特定的配置文件,但我们为gateway服务定义了gatewayserver.yml文件。通过这样做,gateway将合并application.yml和gatewayserver.yml的内容。 -
如果在多个文件中定义了设置,则使用最具体的设置。在这种情况下,由
gatewayserver.yml定义的设置将优先于在application.yml中定义的设置。您可以通过server.port设置看到这种行为,它在两个文件中都指定了,并采用最具体的设置。
还有更多...
在生产环境中,你可能想要保护你的应用程序配置。因此,你必须使用私有 git 仓库,你的配置服务将需要身份验证,你的秘密,如连接字符串,将被加密。你可以使用 Spring Cloud Config 来完成所有这些。我建议访问spring.io/projects/spring-cloud-gateway项目页面以获取有关配置的详细信息。
与配置相关的另一个令人兴奋的功能是能够动态刷新配置而不需要重新启动应用程序。你可以通过使用 Spring Actuator 来实现这一点。我们将在后面的章节中重新讨论这个话题。
我们在这个配方中只使用了非敏感信息,但应用程序通常管理我们不希望公开的配置,例如数据库连接字符串或访问其他系统的凭证。
我们应该采取的第一项措施是移除对配置仓库的公开访问。我们可以使用私有仓库,并在 Config 服务器中配置 git 凭证,如下所示:
spring:
cloud:
config:
server:
git:
uri: https://github.com/PacktPublishing/Spring-Boot-3.0- Cookbook-Config
username: theuser
password: strongpassword
为了避免在 git 仓库中存储敏感信息,Spring Cloud Config 有一个扩展,可以与 Vault 服务集成,例如 Hashicorp Vault 和 Azure Key Vault。存储在 git 仓库中的配置文件包含对存储在 Vault 服务中的秘密的引用。应用程序解析配置,从 Vault 服务检索引用的秘密。
参见
有关更高级的场景,请参阅 Spring Cloud Config 快速入门指南docs.spring.io/spring-cloud-config/docs/current/reference/html/。
将分布式跟踪与 Spring Cloud 集成
随着组成football应用程序套件的服务数量的增加,你部署了以下 Spring Cloud 组件:Spring Cloud Gateway、Eureka Server(注册和发现服务)和 Spring Cloud Configuration。你想要配置分布式跟踪来监控跨微服务的交易。
在这个配方中,你将集成分布式跟踪与 Actuator 和 OpenZipkin 到一个由不同的应用程序微服务和 Spring Cloud 组件组成的系统中。
准备工作
你将使用 OpenZipkin 监控分布式事务。正如在第三章中“实现分布式跟踪”配方中解释的那样,你可以在计算机上使用 Docker 部署一个 OpenZipkin 服务器。为此,你可以在你的终端中运行以下命令:
docker run -d -p 9411:9411 openzipkin/zipkin
你将重用 设置 Spring Cloud Config 脚本的输出结果。我已准备了一个工作版本,以防你尚未完成该脚本。你可以在本书的 GitHub 仓库中找到它,位于 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/ 的 chapter4/recipe4-7/start 文件夹中。它包括以下项目:
-
config:Spring Cloud Config 服务。 -
registry:Spring Cloud 注册和发现服务。 -
gateway:Spring Cloud Gateway。它暴露了football和albums服务。 -
football:提供关于球队和球员信息的football服务。 -
albums:管理贴纸相册的albums服务。它使用football服务。
如何操作...
让我们配置我们的 Spring Cloud 解决方案,以便我们可以将分布式跟踪与 OpenZipkin 集成。
-
你必须在所有项目中添加对 Actuator、Micrometer 与 OpenTelemetry 的桥梁以及 OpenTelemetry 到 OpenZipkin 的导出器的依赖。为此,将以下依赖项添加到所有
pom.xml项目文件中,即config、registry、gateway、football和albums项目:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-zipkin</artifactId> </dependency> -
albums项目还使用OpenFeign客户端对football项目进行了一些调用。因此,你还需要将以下依赖项添加到该项目中:<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing</artifactId> </dependency> <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-micrometer</artifactId> </dependency> -
现在,让我们更改配置以启用 100% 的采样。由于我们使用的是中央配置服务器,我们可以更改包含所有应用程序配置的仓库中的配置。在我的例子中,该仓库托管在
github.com/felipmiguel/spring3-recipes-config。正如 设置 Spring Cloud Config 脚本所述,你应该将felipmiguel替换为你的 GitHub 账户。在我的仓库中,我在application.yml文件中添加了以下配置:management: tracing: sampling: probability: 1.0你可以在配置仓库中为这个特性创建一个分支。一旦完成,你将需要通过在客户端的
application.yml文件中添加以下设置来修改客户端的配置:spring cloud: config: label: <your branch name>然后,你应该将
<your branch name>替换为你创建在 GitHub 中的分支名称。 -
现在,你可以运行应用程序了。你应该首先启动
config服务,然后是registry,此时你可以按任意顺序启动其余服务。 -
让我们测试一下解决方案。你可以运行以下请求进行测试:
curl http://localhost:8080/api/players curl http://localhost:8080/api/albums curl http://localhost:8080/api/albums/players在这种情况下,请求最初由
gateway处理,并由albums服务提供,该服务同时调用football服务。 -
最后,你可以在 OpenZipkin 中看到跟踪信息。为此,在浏览器中打开
http://localhost:9411。转到 查找跟踪 来查看跟踪。你会看到一些在gateway中启动的跟踪。这些是在 步骤 5 中执行的那些:

图 4.6:Spring Cloud 的分布式跟踪
其他跟踪来自与 Eureka 服务器同步的应用程序。
如果你打开 gatewayserver 的跟踪,其中包含五个跨度(即对应于 /api/albums/players 的那个),你会看到 gateway 服务器调用了 albums 服务器,该服务器又调用了 football 服务器:

图 4.7:从网关服务器开始的分布式跟踪,该服务器调用专辑服务,而该服务反过来又调用足球服务
如果你打开 依赖关系 部分,你会看到微服务之间的依赖关系:

图 4.8:Spring Cloud 微服务之间的依赖关系
在复杂场景中,当需要了解不同微服务之间的相互关系时,此视图很有趣。
它是如何工作的...
如在 第三章 中解释的 实现分布式跟踪 菜单所示,只需添加 Actuator 和 Micrometer 依赖项,应用程序就会使用默认配置将跟踪发送到 OpenZipkin 服务器。默认配置是 OpenZipkin 服务器的 http://localhost:9411 和 10% 的采样率。采样意味着只有一部分跟踪被处理,因此默认情况下只处理 10%。为了演示目的,我们希望发送 100% 的跟踪;因此,我们利用集中式配置的优势,只更改了配置存储库中的 application.yml 文件。
albums 应用程序使用 OpenFeign 客户端,默认情况下,它不会像 WebClient.Builder 和 RestTemplateBuilder 那样传播分布式跟踪。因此,我们需要向 io.micrometer:micrometer-tracing 和 io.github.openfeign:feign-micrometer 添加两个额外的依赖项。另一方面,Spring Cloud Gateway 使用 WebClient.Builder 向下游服务发送请求。因此,Spring Cloud Gateway 可以正确地创建和传播跟踪,无需额外的配置。
部署 Spring Boot Admin
在部署了几个微服务之后,你会欣赏有一个单独的控制台来监控和管理所有这些服务。Spring Boot Admin 是一个开源社区项目,它提供了一个可以管理和监控 Spring Boot 应用程序的 Web 界面。
准备中
你将重用将分布式跟踪与 Spring Cloud 集成食谱中的应用程序。如果你还没有完成那个食谱,我已经准备了一个工作版本。你可以在本书的 GitHub 仓库中找到它,在chapter4/recipe4-8/start文件夹中。
如何做到...
我们需要部署一个 Spring Boot Admin 服务器,并确保它连接到发现服务以监控和管理所有应用程序。按照以下步骤操作:
-
首先,使用Spring Initializr工具为 Spring Boot Admin 创建一个新的应用程序。打开
start.spring.io,使用与第一章中创建 RESTful API食谱相同的参数,除了以下选项:-
对于
fooballadmin -
对于依赖项,选择Spring Web、Codecentric 的 Spring Boot Admin (Server)、Config Client和Eureka Discovery Client
-
-
接下来,你必须配置 Spring Boot Admin。为此,在
resources文件夹中添加一个application.yml文件,内容如下:spring: application: name: admin-server config: import: optional:configserver:http://localhost:8888 cloud: config: label: distributed-tracing我在这个配置中使用了
spring.cloud.config.label。由于我不想将不同食谱的配置混合在一起,我为本章的食谱创建了一个新的分支,其名称为distributed-tracing。然而,如果你在同一个 GitHub 仓库和同一个分支中创建了所有配置,这个设置就不再必要了。 -
需要额外的配置,但这次应该在中央仓库中完成,因为我们正在使用 Spring Cloud Config 服务。在我的情况下,配置保存在
github.com/felipmiguel/spring3-recipes-config;你应该用你的 GitHub 账户替换felipmiguel,如设置 Spring Cloud Config食谱中所述。如前一步所述,我在distributed-tracing分支中准备了更改:- 首先,通过 Spring Cloud Gateway 暴露 Spring Boot Admin。为此,在
gatewayserver.yml文件中创建一个新的路由,如下所示:
spring: cloud: gateway: routes: - id: players uri: lb://footballserver predicates: - Path=/api/players/** filters: - StripPrefix=1 - id: albums uri: lb://albumsserver predicates: - Path=/api/albums/** filters: - StripPrefix=1 - id: admin uri: lb://admin-server predicates: - Path=/admin/** filters: admin-server.yml in your GitHub repository with the following content: - 首先,通过 Spring Cloud Gateway 暴露 Spring Boot Admin。为此,在
spring:
boot:
admin:
ui:
application.yml file in your GitHub repository:
management:
endpoints:
web:
exposure:
include: health,env,metrics,beans,loggers,prometheus
tracing:
sampling:
probability: 1.0
The last step before we run the application is configuring the Spring Boot Admin application to enable Admin Server and the Spring Cloud Discovery client. For that, open the `FootballAdminApplication` class and add the following annotations:
@SpringBootApplication
@EnableAdminServer
@EnableDiscoveryClient
public class FootballadminApplication
Now, you can run the Spring Boot Admin application. Remember that you will need to run the rest of the applications that were reused from the *Integrating distributed tracing with Spring Cloud* recipe and that the `config` and `registry` services should start before the other services. As the Spring Boot Admin service is exposed through Spring Cloud Gateway, you can open `http://locahost:8080/admin` to access Spring Boot Admin:

Figure 4.9: The initial Spring Boot Admin page. It defaults to the Applications view
When you access Spring Boot Admin, it redirects you to the **Applications** view. It retrieves the list from Eureka Server. On the application, if you click on the green check on the left-hand side, you will be redirected to the application details page:

Figure 4.10: Application details in Spring Boot Admin
Depending on how many Actuator endpoints are enabled in that application, you will see either more or fewer options in the left pane. As you activate the `health`, `env`, `metrics`, `beans`, and `loggers` endpoints, you will see **Details**, **Metrics**, **Environment**, **Beans**, and **Loggers**. If you open **Loggers**, you will see all loggers defined by the application. As you did in the *Changing settings in a running application* recipe in *Chapter 3*, you can change the log level, but this time from a nice UI:

Figure 4.11: Loggers in Spring Boot Admin
There are two more views on the top bar:
* **Wallboard**: This shows the applications running in the wallboard view
* **Journal**: This shows the events that are happening in the Discovery service
How it works...
Spring Boot Admin may work without Eureka Server, but you would need to configure each application as a client of Spring Boot Admin. Instead, we configured Spring Boot Admin to discover the applications using Eureka Server. Connecting to Eureka Server requires Eureka Client. The Config service centralizes the configuration, which is why we used the Config Client.
Spring Boot Admin gets the list of applications and their instances from Eureka Server. Then, using the Actuator endpoint of each instance, it can get all the details of the application. The more Actuator endpoints are enabled, the more details can be shown. We used the central configuration to allow the desired endpoints in one single place.
Spring Boot Admin can run out of Spring Cloud Gateway; however, it makes sense to centralize the access through Spring Cloud Gateway in this example. Keep in mind that some Actuator endpoints may expose sensitive information. With this design, you only need to expose Spring Cloud Gateway while you keep the rest of the services with no public exposure. Then, you can set up OAuth2, as explained in the *Protecting Spring Cloud Gateway* recipe. When configuring Spring Boot Admin behind a reverse proxy, setting the `spring.boot.admin.ui.public-url` property is necessary.
Protecting Spring Cloud Gateway
When implementing Spring Cloud Gateway, it can serve as a system’s single entry point. For this reason, protecting Spring Cloud Gateway with OAuth2 is a good idea. This allows for centralizing authentication and authorization in Spring Cloud Gateway, eliminating the need for your client to reauthenticate with each service behind it.
You want to place your `football` RESTful API, which is protected with OAuth2, behind Spring Cloud Gateway. So, you’ll also need to protect Spring Cloud Gateway with OAuth2.
In this recipe, you’ll learn how to configure Spring Cloud Gateway as a resource server and pass the token that you receive to the downstream service.
Getting ready
In this exercise, you will need the following:
* An authorization server. You can reuse Spring Authorization Server, which you created in the *Setting up Spring Authorization Server* recipe in *Chapter 2*, for this purpose.
* A resource server. The RESTful API you created in the *Protecting a RESTful API using OAuth2* recipe in *Chapter 2*, can be reused here.
* A Spring Cloud Gateway server. You can reuse the Spring Cloud Gateway server you created in the *Setting up Spring Cloud Gateway* recipe. You can always reuse the latest version of the Spring Cloud Gateway server in later recipes. I’m using the initial setup for simplicity.
* Eureka Server. You can reuse the Eureka Server application you created in the *Setting up Eureka* *Server* recipe.
If you haven’t completed the previous recipes yet, I’ve prepared a working version for all of them in this book’s GitHub repository at [`github.com/PacktPublishing/Spring-Boot-3.0-Cookbook`](https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook), in the `chapter4/recipe4-9/start` folder.
How to do it...
In this recipe, we’ll set our RESTful API behind Spring Cloud Gateway and then protect Spring Cloud Gateway with OAuth2\. Let’s begin:
1. First, configure the RESTful API so that it’s registered in Eureka Server. For that, add the Eureka Client dependency to the RESTful API’s `pom.xml` file:
```
`<dependency>`
`<groupId>org.springframework.cloud</groupId>`
`<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>`
</dependency>
```java
As it is part of Spring Cloud, you should also include the corresponding dependency management in the `pom.xml` file, as follows:
```
`<dependencyManagement>`
`<dependencies>`
`<dependency>`
`<groupId>org.springframework.cloud</groupId>`
`<artifactId>spring-cloud-dependencies</artifactId>`
`<version>${spring-cloud.version}</version>`
`<type>pom</type>`
`<scope>import</scope>`
</dependency>
</dependencies>
</dependencyManagement>
```java
Add a project-level property to configure the Spring Cloud version:
```
`<properties>`
`<spring-cloud.version>2022.0.4</spring-cloud.version>`
</properties>
```java
Now, you can add the Eureka Server configuration to the `application.yml` file:
```
eureka:
client:
`serviceUrl:`
默认区域: http://localhost:8761/eureka/
```java
Though not required, I recommend configuring the application port randomly and assigning a name to the Spring Boot application. With this configuration, you won’t need to care about port conflicts, and you’ll make the application discoverable by name. For that, in the `application.yml` file, add the following lines:
```
spring:
application:
名称: football-api
server:
端口: 0
```java
I’ve added the `spring` label for clarity, but the `application.yml` file should have it defined.
2. Next, configure Spring Cloud Gateway as a resource server. For that, you will need to add the Spring OAuth2 Resource Server dependency to your `pom.xml` file:
```
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
```java
Then, configure `application.yml` with the application registration settings. We’ll use the same configuration that we did for the RESTful API:
```
spring
security:
oauth2:
resourceserver:
jwt:
audiences:
- football
- football-ui
issuer-uri: http://localhost:9000
```java
3. Now, configure Spring Cloud Gateway with the route to the RESTful API:
```
spring:
cloud:
gateway:
routes:
- id: teams
uri: lb://football-api
predicates:
- 路径=/football/**
cloud label.
```java
4. Now that the application is behind Spring Cloud Gateway, which is protected using OAuth2, you can test the application. Remember to run the Eureka and Authorization projects before running the Spring Cloud Gateway and RESTful API projects.
First, you’ll need to obtain an access token from the authorization server to test the application. For that, execute the following command in your Terminal:
```
curl --location 'http://localhost:9000/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' --data-urlencode 'client_id=football' \
--data-urlencode 'client_secret=SuperSecret' --data-urlencode 'scope=football:read'
curl --location http://localhost:8080/football/teams -H "Authorization: Bearer <access token> with the access token you obtained from the authorization server.
```java
You will see the result that’s returned by the RESTful API:

Figure 4.12: Using the RESTful API through Spring Cloud Gateway, which is protected with OAuth2
The result contains a list of teams.
How it works...
Spring Cloud Gateway acts as a resource server. This means it will require a valid access token to be issued by our authorization server.
Spring Cloud Gateway will relay the access token to the downstream RESTful API. Both will validate the access token. You can configure Spring Cloud Gateway with the first level of OAuth2 validation. For example, you can validate the token issuer and the token scopes. Then, if you need more fine-grained validation, you can do so on the RESTful API.
第二部分:数据库技术
几乎所有应用程序都需要高效地持久化和访问数据,为此,Spring Boot 提供了许多选择,从关系型数据库和 NoSQL 数据库到仓库、模板、Java 持久化查询语言(JPQL)和原生 SQL。
本部分包含以下章节:
-
第五章, 使用 Spring Data 与数据持久化和关系型数据库集成
-
第六章, 使用 Spring Data 与数据持久化和 NoSQL 数据库集成
第五章:使用 Spring Data 与关系型数据库进行数据持久化和集成
大多数应用程序以某种方式处理他们的数据,这需要使用数据库引擎。本章讨论了关系型数据库,这是最广泛使用的数据库技术。关系型数据库仍然是各种应用程序场景中灵活和可靠的选项。它们的组织化、表格化数据存储格式和定义良好的模式适合许多用途。此外,关系型数据库提供了诸如强制数据完整性、支持复杂查询和遵循 ACID 原则(原子性、一致性、隔离性、持久性)等基本好处。它们被证明是适用于从简单到关键任务应用的各种应用程序的合适选择。
Spring Data 是 Spring 框架的一个组件,旨在简化 Java 应用程序中的数据访问。它提供了一个一致的编程模型和与各种数据存储(包括关系型数据库和其他类型的数据库)交互的抽象层。
Spring Data 为关系型数据库提供了两个模块:Spring Data JPA 和 Spring Data JDBC。
-
Spring Data JPA。此模块提供了与 Java 持久化 API(JPA)的集成,允许开发人员使用 对象关系映射(ORM)原则与关系型数据库一起工作。其中一个好处是大多数代码是数据库无关的,不是为了创建一个完全独立于数据库的应用程序,而是为了无论底层数据库如何都能重用所学知识。在复杂的应用程序中,利用特定供应商的功能对于项目的成功可能是决定性的,因此我建议使用数据库引擎提供的所有功能。试图创建一个可以在任何数据库中部署的应用程序会导致你的应用程序只使用所有数据库中可用的最小公共集合。
-
Spring Data JDBC。此模块提供了更直接的数据访问方法,侧重于使用纯 SQL 查询以及 Java 对象和数据库表之间的直接数据映射。
我们将使用 Spring Data JPA 处理最常见的数据访问场景。从基本数据操作,如创建、读取、更新、删除(CRUD)到更高级的任务,如复杂查询、事务以及数据库模式初始化和模式升级。
我们将使用 PostgreSQL 作为数据库引擎,因为它开源、广泛采用、多平台,并且围绕它有一个充满活力的社区。但如上所述,我们可以使用相同的原理来创建一个使用其他关系型数据库引擎的应用程序,例如 MySQL、SQL Server 或 Oracle。
在本章中,我们将涵盖以下主要主题:
-
将您的应用程序连接到 Postgresql
-
创建和更新数据库模式
-
创建 CRUD 存储库。
-
使用 JPQL。
-
使用原生查询。
-
更新操作
-
动态查询
-
使用事务
-
使用 Spring Data JDBC。
技术要求
对于本章,您需要一个 PostgreSQL 服务器。在本地环境中部署它的最简单方法是使用 Docker。您可以从产品页面获取 Docker:www.docker.com/products/docker-desktop/
如果您想在您的计算机上安装 PostgreSQL,您可以从项目页面下载它:www.postgresql.org/download/
我还推荐安装 PgAdmin 来访问数据库。您可以使用它来观察应用程序在数据库中执行的变化。您可以从项目页面下载它:www.pgadmin.org/download/
您可以使用其他工具,例如 Visual Studio Code 或 IntelliJ 的插件。
如前一章所述,您需要一个代码编辑器和 OpenJDK。
本章中将要展示的所有菜谱都可以在以下位置找到:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/tree/main/chapter5.
将您的应用程序连接到 PostgreSQL
您想要创建一个 RESTful API 来为您的最终用户提供足球数据。为了管理这些数据,我们决定使用关系型数据库,因为我们感兴趣的是提供数据一致性和高级查询功能。
在这个菜谱中,我们将连接一个应用程序,一个 RESTful API,到一个 PostgreSQL 数据库。为此,我们首先要做的是在 Docker 中部署一个 PostgreSQL 数据库。
在这个菜谱中,您将学习如何创建一个基本的应用程序,该应用程序连接到 PostgreSQL 数据库,并使用 JdbcTemplate 执行基本的 SQL 查询。
准备工作
对于这个菜谱,您需要一个 PostgreSQL 数据库。如果您已经有一个可用的服务器,您可以使用它。否则,您可以使用 Docker 在您的计算机上部署一个 PostgreSQL。为此,您可以在终端中执行以下命令来下载和执行一个 PostgreSQL 实例:
docker run -itd -e POSTGRES_USER=packt -e POSTGRES_PASSWORD=packt -p 5432:5432 --name postgresql postgres
您将有一个 PostgreSQL 服务器可供使用,监听端口 5432,用户名和密码为packt。如果您想更改这些参数,您可以修改上面的命令。
您需要一款工具来对 PostgreSQL 执行一些操作。我将使用命令行工具 psql。在 Ubuntu 上,您可以使用默认的包管理器apt来安装它:
sudo apt install postgresql-client
作为 psql 的替代方案,您可以使用 PgAdmin 通过一个友好的 UI 连接到数据库。我将仅使用 psql 的命令行示例进行说明,但如果您想的话,您也可以使用 PgAdmin 来执行数据库脚本。请按照官方页面www.pgadmin.org/download/上的说明在您的计算机上安装它。
您可以在本书的 GitHub 仓库中找到 sql 脚本,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/.
如往常一样,我们将使用 Spring Initializr 工具创建我们的项目,或者如果你更喜欢,可以使用你最喜欢的 IDE 或编辑器中的集成工具。
如何操作...
一旦我们准备好了如准备就绪中所述的 PostgreSQL 服务器,我们将创建一个数据库。之后,我们将创建一个 Spring Boot 应用程序,该应用程序将连接到数据库以执行一个简单的查询。
-
首先,下载 GitHub 仓库中可用的 postgresql 脚本。它们位于
chapter5/recipe5-1/start/sql目录下。有两个脚本文件:-
db-creation.sql。此脚本创建一个名为football的数据库,包含两个表:teams和players。 -
insert-data.sql。此脚本在teams和players表中插入示例数据。
-
-
接下来,我们将执行数据库中的脚本。为此,打开一个终端并执行以下命令以使用psql工具在 PostgreSQL 中执行脚本。
psql -h localhost -U packt -f db-creation.sql psql -h localhost -U packt -f insert-data.sql它将请求密码。输入packt,如准备就绪部分中配置的那样。如果你在准备就绪部分使用了不同的参数,请相应地使用。
或者,你也可以使用PgAdmin工具代替psql工具。
-
我们刚刚创建的数据库模式看起来如下:

图 5.1:数据库模式。使用 PgAdmin 工具导出。
-
让我们使用Spring Initializr工具创建一个新的 Spring Boot 应用程序,该应用程序连接到数据库。我们将使用与第一章中创建 RESTful API配方相同的参数,除了更改以下选项:
-
footballpg -
依赖项:Spring Web,Spring Data JPA,PostgreSQL Driver
-
-
接下来,我们将配置应用程序以连接到 PostgreSQL 数据库。为此,在
resources文件夹中创建一个application.yml文件,并设置以下内容:spring: datasource: url: jdbc:postgresql://localhost:5432/football username: packt password: packt -
现在,创建一个名为
TeamsService的新服务类。此类将使用 JdbcTemplate 对数据库执行查询。为此,需要注入一个 JdbcTemplate。@Service public class TeamsService { private JdbcTemplate jdbcTemplate; public TeamsService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } } -
我们将在TeamsService中创建一个方法来获取团队总数。你可以将此方法命名为
getTeamCount:public int getTeamCount() { return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM teams", Integer.class); }我们使用了 jdbcTemplate 的 queryForObject 方法来执行一个 SQL 查询以获取单个值。
-
你现在可以使用此服务创建一个 RestController。我创建了一个使用
TeamsService的示例控制器。你可以在本书的仓库中找到它,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。
它是如何工作的...
在我们的配置文件 application.yml 中,我们定义了一个数据源。在数据源内部,我们使用了 URL、用户名和密码。然而,也可以定义其他属性。或者,我们也可以只使用 URL。URL 包含重要信息,例如数据库的类型(在我们的情况下,是 PostgreSQL)、主机、端口和数据库名。尽管可以在 URL 中传递用户名和密码,但我们使用了特定的字段来提高清晰度。
由于我们指定了 PostgreSQL 作为数据库,因此确保类路径中注册了驱动程序至关重要。我们通过添加 PostgreSQL 驱动程序的依赖项来实现这一点。
通过在配置文件中定义数据源,数据源对象被注册到依赖容器中。Spring Data JPA 使用该数据源在需要时创建 JdbcTemplate,例如,它创建一个 JdbcTemplate 实例并将其注入到 TeamsService 类中。
JdbcTemplate 处理资源的创建和释放,并将 SQLExceptions 转换为 Spring 的 DataAccessExceptions。在此示例中,我们使用了一个非常简单的查询,它不需要任何参数并返回一个 Integer。尽管如此,JdbcTemplate 允许向你的查询传递参数并将结果映射到复杂类。我们不会在本书中过多扩展这些功能;相反,我们将深入研究 JPA 和 Hibernate 功能,以将复杂实体和关系映射到类。从 使用 Hibernate 食谱开始,我们将看到这一点。
还有更多...
JdbcTemplate 可以用来检索不仅限于标量值的查询结果。例如,假设我们有一个 Team 类,我们可以定义以下方法,使用 query 方法检索所有团队:
public List<Team> getTeams() {
return jdbcTemplate.query("SELECT * FROM teams", (rs, rowNum) -> {
Team team = new Team();
team.setId(rs.getInt("id"));
team.setName(rs.getString("name"));
return team;
});
}
在此示例中,我们使用一个匿名 RowMapper,它将每一行转换为一个 Team 对象。
你也可以向你的查询传递参数。例如,让我们检索一个特定的团队:
public Team getTeam(int id) {
return jdbcTemplate.queryForObject(
"SELECT * FROM teams WHERE id = ?",
new BeanPropertyRowMapper<>(Team.class),
id);
}
这次,我们使用 BeanPropertyRowMapper 将结果行映射到 Team。这个类推断目标属性以映射结果行的列。
使用 JdbcClient 访问数据库
在上一个食谱中,我们使用 JdbcTemplate 访问数据库。JdbcClient 是一个增强的 JDBC 客户端,它提供了一种流畅的交互模式。JdbcClient 自 Spring Framework 6.1 以来被引入,并且自 Spring Boot 3.2 起可用。
在本食谱中,我们将通过执行一些简单的数据库查询来学习如何使用 JdbcClient。
准备工作
在这个菜谱中,我们需要一个 PostgreSQL 数据库。你可以重用之前在连接你的应用程序到 PostgreSQL菜谱中创建的相同数据库。你也可以重用同一个菜谱中的项目,因为依赖项是相同的。我已经准备了一个你可以用作此菜谱起点的工作版本。你可以在本书的 GitHub 仓库中找到它,在chapter5/recipe5-2/start文件夹中。
如何做到这一点...
让我们使用 JdbcClient 而不是 JdbcTemplate 来准备一些查询。
-
让我们从创建一个名为
PlayersService的新服务类并在这个构造函数中注入一个 JdbcClient 开始:@Service public class PlayersService { private JdbcClient jdbcClient; public PlayersService(JdbcClient jdbcClient) { this.jdbcClient = jdbcClient; } } -
创建一个名为
Player的类。这个类应该有与在连接你的应用程序到 PostgreSQL菜谱中创建的players表相同的字段。你可以在本书的仓库中找到这个类的实现,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。 -
现在,我们可以在
PlayersService中创建方法来与数据库交互:-
让我们创建一个名为
getPlayers的方法来检索所有球员:public List<Player> getPlayers() { return jdbcClient.sql("SELECT * FROM players") .query(Player.class) .list(); } -
我们可以创建一个名为
getPlayer的方法来检索单个Player。我们可以在 SQL 查询中使用一个参数。Public Player getPlayer(int id) { return jdbcClient.sql("SELECT * FROM players WHERE id = :id") .param("id", id) .query(Player.class) .single(); } -
让我们创建一个名为
createPlayer的方法来创建一个新的Player:public Player createPlayer(Player player) { GeneratedKeyHolder keyHolder = new GeneratedKeyHolder(); jdbcClient.sql(""" INSERT INTO players (jersey_number, name, position, date_of_birth, team_id) VALUES (:jersey_number, :name, :position, :date_of_birth, :team_id) """) .param("name", player.getName()) .param("jersey_number", player.getJerseyNumber()) .param("position", player.getPosition()) .param("date_of_birth", player.getDateOfBirth()) .param("team_id", player.getTeamId()) .update(keyHolder, "id"); player.setId(keyHolder.getKey().intValue()); return player; }
-
-
你可以创建一个使用
PlayerService的控制器。我已经准备了一个可以在本书的 GitHub 仓库中找到的工作版本:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。
它是如何工作的...
JdbcClient 用来创建数据库连接的机制类似于 JdbcTemplate。Spring Data JPA 使用在应用程序中配置的数据源,并将其注入到 JdbcClient 中。
JdbcClient 提供了一个流畅的方式来与数据库交互,使开发更加直观,并减少了样板代码。它允许非常容易地使用命名参数,就像我们在getPlayer和createPlayer方法中看到的那样。它还提供了自动映射,无需定义RowMapper来处理每一行。
使用 ORM 访问数据库
通过执行 SQL 请求访问数据库可以是高效的,并且适用于简单的应用程序。然而,当应用程序变得更加复杂且数据库模式增长时,使用一个对象关系映射(ORM)框架通过面向对象编程(OOP)层访问数据库可能是有趣的。在 Java 中,最流行的 ORM 框架可能是Hibernate,而 Spring Data JPA 使用 Hibernate 作为其默认的Java 持久化 API(JPA)实现。
在这个菜谱中,我们将创建可以映射到数据库模式的实体类,并且我们将与数据库交互而不需要写一行 SQL。
准备工作
您需要为这个菜谱创建一个 PostgreSQL 数据库。您可以使用 连接您的应用程序到 PostgreSQL 菜谱中创建的数据库。如果您还没有完成那个菜谱,您可以完成那个菜谱的前两步来创建数据库。
如何做到这一点...
一旦我们准备好了 PostgreSQL 服务器,如 准备就绪 中所述,我们将创建一个数据库,并填充一些数据。之后,我们将创建一个 RESTful 项目以连接到数据库并检索数据。
-
让我们创建一个连接到这个数据库的项目。我们将在浏览器中打开 https://start.spring.io 来使用 Spring Initializr 工具。
我们将使用与 第一章 中的相同参数,以下是一些特定参数:
-
footballpg -
依赖项:Spring Web,Spring Data JPA,PostgreSQL 驱动程序
-
-
在这个项目中,创建实体类以映射数据库表。
-
创建一个名为
TeamEntity.java的文件,包含一个与表teams映射的类:@Table(name = "teams") @Entity public class TeamEntity { @Id private Integer id; private String name; @OneToMany(cascade = CascadeType.ALL, mappedBy = "team") private List<PlayerEntity> players; } -
创建一个名为
PlayerEntity.java的文件,包含与表players映射的类。@Table(name = "players") @Entity public class PlayerEntity { @Id private Integer id; private Integer jerseyNumber; private String name; private String position; private LocalDate dateOfBirth; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id") private TeamEntity team; }
-
-
创建两个仓库接口以使用我们在上一步创建的实体访问数据库。
-
创建一个名为
TeamRepository.java的文件,包含以下接口:public interface TeamRepository extends CrudRepository<TeamEntity, Integer>{ } -
创建一个名为
PlayerRepository.java的文件,包含以下接口:public interface PlayerRepository extends JpaRepository<PlayerEntity, Integer>{ List<PlayerEntity> findByDateOfBirth(LocalDate dateOfBirth); List<PlayerEntity> findByNameContaining(String name); }
-
-
使用两个仓库创建一个名为
FootballService的服务类:@Service public class FootballService { private PlayerRepository playerRepository; private TeamRepository teamRepository; public FootballService(PlayerRepository playerRepository, TeamRepository teamRepository) { this.playerRepository = playerRepository; this.teamRepository = teamRepository; } } -
创建两个表示公开数据的类:
-
Team:public record Team(Integer id, String name, List<Player> players) { } -
Player:public record Player(String name, Integer jerseyNumber, String position, LocalDate dateOfBirth) { }
-
-
添加一些方法来根据不同的标准查找球员:
-
搜索包含给定字符串在名称中的球员:
public List<Player> searchPlayers(String name) { return playerRepository.findByNameContaining(name) .stream() .map(player -> new Player(player.getName(), player.getJerseyNumber(), player.getPosition(), player.getDateOfBirth())) .toList(); } -
通过出生日期搜索球员:
public List<Player> searchPlayersByBirthDate(LocalDate date) { return playerRepository.findByDateOfBirth(date) .stream() .map(player -> new Player(player.getName(), player.getJerseyNumber(), player.getPosition(), player.getDateOfBirth())) .toList(); }
-
-
添加一个方法来返回一个包含其球员的
Team:@Transactional(readOnly=true) public Team getTeam(Integer id) { TeamEntity team = teamRepository.findById(id).orElse(null); if (team == null) { return null; } else { return new Team(team.getId(), team.getName(), team.getPlayers() .stream() .map(player -> new Player(player.getName(), player.getJerseyNumber(), player.getPosition(), player.getDateOfBirth())) .toList()); } } -
创建一个新的团队:
public Team createTeam(String name) { Random random = new Random(); TeamEntity team = new TeamEntity(); Integer randomId = random.nextInt(); if (randomId < 0) { randomId = random.nextInt(); } team.setId(randomId); team.setName(name); team = teamRepository.save(team); return new Team(team.getId(), team.getName(), List.of()); } -
更新玩家的位置:
public Player updatePlayerPosition(Integer id, String position) { PlayerEntity player = playerRepository.findById(id).orElse(null); if (player == null) { return null; } else { player.setPosition(position); player = playerRepository.save(player); return new Player(player.getName(), player.getJerseyNumber(), player.getPosition(), player.getDateOfBirth()); } } -
现在,您可以使用服务创建一个控制器来公开应用程序的逻辑。您可以在书籍的 GitHub 存储库中找到一个完整的示例,网址为
github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。 -
现在配置应用程序以连接到 PostgreSQL 数据库。在
resources文件夹下,创建一个名为application.yml的文件。设置以下配置:spring: jpa: database-platform: org.hibernate.dialect.PostgreSQLDialect open-in-view: false datasource: url: jdbc:postgresql://localhost:5432/football username: packtd password: packtd -
现在您可以执行并测试应用程序。您可以使用 第一章 中的说明使用 curl 测试应用程序。我还提供了一个脚本,您可以在存储库中找到该脚本,其中包含此应用程序的 curl 请求。它位于
chapter2/recipe2-1/end/scripts/requests.sh。
它是如何工作的...
Hibernate 是一个对象关系映射(ORM)框架。它的主要目标是弥合 Java 编程语言和关系数据库之间的差距。使用如@Entity、@Table、@Id、@OneToMany、@ManyToOne 等注解,Hibernate 将类映射到数据库表。这些映射的类被称为实体。Hibernate 还提供其他功能,如事务管理、查询能力、缓存和延迟加载。
Hibernate 是 Spring Data JPA 的默认 JPA 提供者。Spring Data JPA 允许你定义仓库接口来与你的数据模型交互。只需通过扩展CrudRepository接口,它就会自动生成必要的 JPA 操作,为你的实体提供创建、读取、更新和删除操作。当使用JpaRepository时,Spring Data JPA 会根据方法名称生成必要的 JPA 查询。
例如,我们使用了findByDateOfBirth来创建一个方法,该方法通过出生日期返回所有球员,以及findByNameContaining来返回所有名字包含给定字符串的球员。所有这些都不需要写一行 SQL 代码!
如果你对命名约定不熟悉,我强烈建议检查项目文档。请参阅docs.spring.io/spring-data/jpa/reference/#repository-query-keywords。
除了读取数据的操作外,CrudRepository和JpaRepository还有一个名为save的方法。这个方法允许你更新现有实体或创建新的实体(如果它们还不存在的话)。它们还有一些删除实体的方法,例如delete、deleteById和其他方法。
即使有了 Spring Data JPA 提供的抽象,理解 Spring Data 的一些内部工作原理也是至关重要的。在这个菜谱中,我在控制器和仓库之间使用了一个名为FootballService的中层组件。你也可以直接从控制器调用仓库,然而这种方法有一些注意事项。为了更好地理解它,让我们深入探讨返回一个团队及其球员的操作。
Hibernate 有两种方式加载与实体相关的实体:TeamEntity有一个用@OneToMany 注解的成员来管理其球员实体。当你的应用程序使用TeamEntity类的getPlayers方法时,Hibernate 会尝试通过向数据库发送请求来加载球员。默认情况下,@OneToMany 关系以懒加载模式加载,而@ManyToOne 以急加载模式加载。总的来说,懒加载意味着,如果你不使用这个关系,就不会向数据库发送请求。如果你在控制器中通过仓库检索团队并尝试返回TeamEntity,它将序列化实体到一个 Json 对象中,遍历所有属性,包括球员。在请求执行的这一阶段,没有打开会话来访问数据库,你将收到一个异常。有几种方法可以解决这个问题:
-
通过使用急加载模式同时检索团队和球员。在某些场景中可能是有效的,但它可能导致不必要的数据库请求。
-
通过允许在视图中打开连接。这可以通过使用
spring.jpa.open-in-view=true来实现。这是一个反模式,我强烈建议不要使用它。我在处理一个项目时遇到了与这个反模式相关的一个糟糕的经历。我遇到了一个与性能和可用性相关的问题,但系统有资源,任何组件似乎都处于压力之下。最后,我意识到由于这个
open-in-view选项,应用程序中存在连接泄漏。找到根本原因并解决这个 bug 是我遇到的最具挑战性的任务之一,因为找出根本原因并不明显。顺便说一句,
spring.jpa.open-in-view=true是 Spring Boot 的默认值,所以请记住这一点,除非你有我无法想象的好理由,否则请将其配置为 false。 -
在检索数据时创建一个会话或事务,包括懒关系。这是本菜谱中采用的方法。当我们从
TeamEntity映射到Team时,我们使用了getPlayers方法,因此从数据库中检索了数据。由于FootballService中的getTeam方法被标记为@Transactional,所有请求都在同一个事务/会话中发生。由于这是一个读操作,你可以设置@Transactional(readOnly = true),为你的事务提供一个成本更低的隔离模式。 -
通过执行一个
Join查询来在一次数据库请求中检索团队及其球员。这是实现此场景的最有效方式。我们将在本章的另一道菜谱中看到如何实现它。
这就是创建Service类而不是直接在 RESTful API 中返回实体的原因。
还有更多...
在这个练习中,我们使用了一个现有的数据库,并手动创建了实体和仓库来与数据库交互。还有另一种方法,我们将在本章的后续菜谱中解决,它首先定义实体然后自动生成数据库。对于这两种场景,都有工具可以帮助你完成这项任务,可以非常机械。例如,你可以使用 IntelliJ 的 JPA Buddy 插件,plugins.jetbrains.com/plugin/15075-jpa-buddy。它有一个基本的免费版本,对于简单场景已经足够,还有一个付费版本,具有高级功能。
在这个菜谱中,我们创建了一些代码来将实体转换为其他对象,也称为数据传输对象(DTO)。这可能会在你的项目中添加很多样板代码。有一些库可以自动化 Java Bean 之间的映射,非常适合这种场景。例如 Mapstruct(https://mapstruct.org/)。Spring Data JPA 支持使用 Mapstruct 将实体转换为 DTO 以及相反。出于学习目的,我在菜谱中没有使用它。
参见
如果你想了解更多关于视图中的打开会话(OSIV)反模式的细节,我建议你阅读这篇文章 vladmihalcea.com/the-open-session-in-view-anti-pattern/。
从我们的代码创建数据库模式
在我们的应用程序中创建数据库模式及其相应的实体,如前一个菜谱所示,需要大量的重复工作。相反,我们可以创建我们的实体,并可以自动生成数据库模式。在这个菜谱中,我们将使用 Spring Data JPA 根据应用程序的实体模型生成数据库模式。
准备工作
对于这个菜谱,你需要与上一个菜谱相同的工具,即一个可以在 Docker 容器或你的电脑上运行的 PostgreSQL 服务器。
我们将使用前一个菜谱中生成的相同代码。如果你没有完成它,你可以在书的 GitHub 仓库 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/ 中找到一个完成的菜谱。
如何做到这一点...
我们将使用之前关于足球队和球员的例子。然而,在这个菜谱中,我们不会使用现有的数据库和创建映射实体,而是相反的方向。我们将使用和调整已经创建的实体来生成数据库模式。让我们开始:
-
创建一个名为
football2的新数据库。-
在你的终端中打开 psql。
psql -h localhost -U packtd -
执行以下 SQL 命令以创建数据库:
CREATE DATABASE football2;
-
-
我们不会手动生成实体的标识符,而是依赖数据库的自动标识符生成器。为此,我们将修改我们实体的
@Id注解。-
修改
TeamEntity中的成员 ID,如下所示:@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; -
用
PlayerEntity做同样的事情。
-
-
打开
application.yml文件,并添加spring.jpa.generate-ddl=true和spring.sql.init.mode=always属性。文件应该看起来像这样:spring: jpa: database-platform: org.hibernate.dialect.PostgreSQLDialect open-in-view: false generate-ddl: true sql: init: mode: always datasource: url: jdbc:postgresql://localhost:5432/football2 username: packtd password: packtd如果你运行应用程序,数据库模式将自动创建。
-
修改
FootballService类中的createTeam方法:public Team createTeam(String name) { TeamEntity team = new TeamEntity(); team.setName(name); team = teamRepository.save(team); return new Team(team.getId(), team.getName(), List.of()); }在这里,我们移除了团队标识符的生成,而是让它自动生成。
-
将位于 GitHub 存储库中的文件
chapter5/recipe5-4/start/data.sql复制到资源文件夹。这个文件夹位于src/main/resources。 -
执行应用程序。
-
按照第 1 章 中所述,通过向应用程序执行请求来测试应用程序。我还提供了一个脚本,你可以在存储库中找到这个脚本,其中包含对该应用程序的 curl 请求。它位于
chapter5/recipe5-4/end/scripts/requests.sh。你将看到数据库模式已初始化,并且已经有数据。
它是如何工作的...
通过配置应用程序为 *spring.jpa.generate-ddl=true,Spring Data 将自动从项目中定义的实体生成数据模式。它将使用注解根据目标数据库生成模式。例如,我们在 PlayerEntity 和 TableEntity 中的 id 字段都使用了 @GeneratedValue。它被转换成 PostgreSQL 序列。以 TeamEntity 为例,这是在 PostgreSQL 中的结果:
CREATE TABLE IF NOT EXISTS public.teams
(
id integer NOT NULL DEFAULT nextval('teams_id_seq'::regclass),
name character varying(255) COLLATE pg_catalog."default",
CONSTRAINT teams_pkey PRIMARY KEY (id)
)
CREATE SEQUENCE IF NOT EXISTS public.teams_id_seq
INCREMENT 1
START 1
MINVALUE 1
MAXVALUE 2147483647
CACHE 1
OWNED BY teams.id;
Spring Boot 能够创建模式和初始化数据。它从 optional:classpath*:schema.sql 加载模式脚本,从 optional:classpath*:data.sql 加载数据脚本。我们只明确提供了数据脚本,并且我们确实让 Spring Boot 通过 generate-ddl 设置来生成模式。除了数据脚本之外,你也可以提供模式脚本,而不是让 Spring Boot 为你生成它们。对于复杂的应用程序,你可能需要特定的数据库设置。
如前所述,在这个菜谱中,我们让 Spring Boot 执行数据库初始化。默认情况下,Spring Boot 只有在它认为数据库是一个内存嵌入数据库(如 H2)时才会执行初始化。为了强制对 PostgreSQL 进行初始化,我们使用了参数 spring.sql.init.mode=always。
这个菜谱中遵循的方法旨在用于开发环境。在生产环境中,我们可能有相同应用的多实例,并且多个实例尝试初始化数据库可能会引起问题。即使有机制确保只有一个应用实例更新数据库,这个过程也可能需要时间并减慢应用初始化速度。重要的是要注意,其中一些脚本应该只执行一次。例如,在这个菜谱中,我们使用了data.sql,它使用显式的 id 值在两个表中插入记录。如果您尝试执行两次,将产生唯一约束验证错误。对于初始化,您最可能希望在所有应用实例启动之前执行此过程。例如,在 Kubernetes 中,您可以通过使用 Init Containers 来实现这一点,请参阅 https://kubernetes.io/docs/concepts/workloads/pods/init-containers/。
对于生产环境,存在其他工具,如 Flyway 和 Liquibase,它们由 Spring Boot 支持。这些工具提供了对数据库创建的更多控制,提供了版本控制和迁移。在下一个菜谱中,我们将使用 Flyway 来创建和迁移数据库的模式。
还有更多...
在这个菜谱中,我们只使用了所有可能性中的一些选项来定制我们的实体,但几乎可以控制数据库模式定义的任何方面。仅举几个例子:
-
@Entity: 使用@Entity注解一个类表示它是一个 JPA 实体,应该映射到数据库表。每个实体类对应数据库中的一个表,类中的每个字段对应表中的一个列。 -
@Table: 它用于指定实体应映射到的数据库表的详细信息。您可以使用它来设置表名、模式和其他属性。 -
@Column: 它允许您配置实体字段到数据库列的映射。您可以指定诸如列名、长度、可空性和唯一约束等属性。 -
@JoinColumn: 它用于指定表示关系外键的列。它通常与@ManyToOne或@OneToOne一起使用,以指定连接列的名称和其他属性。 -
@Transient: 被标记为@Transient的字段不会映射到数据库列。这个注解用于那些应该从数据库持久化中排除的字段。 -
@Embedded和@Embeddable: 这些注解用于在实体中创建嵌入对象。@Embeddable应用于一个类,而@Embedded用于实体,表示嵌入类的实例应作为实体的一部分进行持久化。 -
@Version: 它用于指定乐观锁的版本属性。它通常应用于数字或时间戳字段,用于防止对同一记录的并发更新。
参见
docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto.data-initialization
使用 Testcontainers 的 PostgreSQL 集成测试
在开发组件的测试时,最大的挑战之一是管理数据库等依赖服务。虽然创建模拟或使用像 H2 这样的内存数据库可能是一种解决方案,但这些方法可能会隐藏我们应用程序中潜在的问题。Testcontainers 是一个开源框架,它提供了在 Docker 容器上运行的流行数据库和其他服务的临时实例。这为测试应用程序提供了一种更可靠的方法。
在这个菜谱中,您将学习如何使用 Testcontainers 创建依赖于 PostgreSQL 的集成测试。
准备工作
在这个菜谱中,我们将为上一个菜谱中创建的应用程序创建一些测试,即 从我们的代码创建数据库模式。如果您还没有完成上一个菜谱,我准备了一个工作版本作为这个菜谱的起点。您可以在本书的 GitHub 仓库中找到它:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。
Testcontainers 需要在您的计算机上安装 Docker。
如何做到这一点...
让我们通过创建利用真实 PostgreSQL 数据库的测试来提高我们应用程序的可靠性。
-
首先,我们需要包含 Testcontainers 启动器和 PostgreSQL Testcontainer 依赖项。您可以通过在项目的
pom.xml文件中添加以下依赖项来实现:<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <scope>test</scope> </dependency> -
接下来,创建一个测试类,您可以将其命名为
FootballServiceTest。让我们为 TestContainers 设置这个类。为此,我们需要:-
使用
@SpringBootTest注解该类。 -
使用
@TestContainers注解该类。 -
配置一个上下文初始化器,使用我们在测试期间创建的 PostgreSQL 容器配置应用程序上下文。为了设置初始化器,我们可以使用
@ContextConfiguration注解该类。
类定义看起来是这样的:
@SpringBootTest @Testcontainers @ContextConfiguration(initializers = FootballServiceTest.Initializer.class) public class FootballServiceTest正如您所看到的,有一个对
FootballServiceTest.Initializer类的引用,我们还没有描述。它将在以下步骤中解释。 -
-
现在,我们将定义一个静态字段,使用 PostgreSQL 容器:
static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:latest") .withDatabaseName("football") .withUsername("football") .withPassword("football"); -
让我们使用刚刚创建的容器来配置应用程序。现在是我们创建
FootballServiceTest.Initializer类的时候了。在 FootballServiceTest 中创建一个名为 Initializer 的类:static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> { public void initialize(ConfigurableApplicationContext configurableApplicationContext) { TestPropertyValues.of( "spring.datasource.url=" + postgreSQLContainer.getJdbcUrl(), "spring.datasource.username=" + postgreSQLContainer.getUsername(), "spring.datasource.password=" + postgreSQLContainer.getPassword()) .applyTo(configurableApplicationContext.getEnvironment()); } }初始化器使用 PostgreSQLContainer 设置覆盖数据源配置。
-
配置 Testcontainers 的最后一步是启动容器,这可以通过使用
@BeforeAll注解在所有测试开始之前完成。让我们创建一个启动容器的函数:@BeforeAll public static void startContainer() { postgreSQLContainer.start(); } -
现在,我们可以正常创建测试。例如,让我们创建一个创建团队的测试:
@Autowired FootballService footballService; @Test public void createTeamTest() { Team team = footballService.createTeam("Jamaica"); assertThat(team, notNullValue()); Team team2 = footballService.getTeam(team.id()); assertThat(team2, notNullValue()); assertThat(team2, is(team)); }
它是如何工作的...
@Testcontainers注解会搜索所有带有@Container 标记的字段,并触发它们的容器生命周期方法。声明为静态字段的容器,如本菜谱中所示,在测试方法之间是共享的。这意味着容器仅在执行任何测试方法之前启动一次,并在最后一个测试方法执行后停止。如果容器被声明为实例字段,它将为每个测试方法启动和停止。
PostgreSQLContainer 是一个专门的 Testcontainer 模块,它将数据库的属性暴露出来,以方便我们在测试中进行连接。我们使用了 JdbcUrl、用户名和密码来覆盖配置。
正如您所看到的,我们不需要模拟任何存储库来为 FootballService 类创建测试。另一个巨大的优点是,数据库在每次测试执行周期中都会被重新创建,因此测试是可重复和可预测的。
数据库模式版本控制和升级
随着我们的应用程序的发展,我们需要保持数据库与我们的 Java 实体同步。这可能是一个复杂且容易出错的任务。为了应对这种情况,有一些工具可以管理数据库模式和数据库迁移。此类工具的几个示例是 Flyway 和 Liquibase,它们都受 Spring Boot 支持。
除了数据库迁移功能本身之外,Flyway 还提供了以下功能:
-
版本控制以跟踪应用到数据库的迁移和待处理的迁移。
-
它可以集成到开发环境和构建自动化工具中,例如 Maven 或 Gradle。
-
可重复迁移。每次 Flyway 运行时,都会执行可重复迁移,确保数据库保持所需状态。
-
回滚和撤销操作。Flyway 可以自动生成 SQL 脚本以撤销特定的迁移,以便在出现问题时进行回滚。
-
它可以在您的项目初始化期间执行迁移。
-
它提供了一些独立工具,可以在 Java 项目之外使用。
当在您的项目中使用时,它需要不同的配置来执行迁移,例如注册特定的 bean。Spring Boot 简化了这种集成,将必要的配置最小化到仅一些应用程序设置,除非您需要更高级的操作。
准备工作
您可以将上一道菜谱中完成的练习作为这道菜谱的起点。如果您还没有完成,您可以在书的 GitHub 仓库中找到一个完整版本,网址为 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。您将需要相同的工具,PostgreSQL 和 Docker。
Flyway 是由 Redgate 维护的解决方案,为个人和非商业项目提供免费版,并为付费支持版。对于这道菜谱,我们可以使用库,但请记住,在生产环境中使用 Flyway 可能需要 Redgate 许可证。有关详细信息,请参阅 https://documentation.red-gate.com/fd/licensing-164167730.html。
如何操作...
在这个菜谱中,我们将使用 Flyway 创建数据库的初始版本,然后应用更改。我们将学习如何轻松地与 Spring Boot 一起使用它。
-
创建一个名为
football3的新数据库。-
在您的终端中打开 psql。
psql -h localhost -U packtd -
执行以下 SQL 命令以创建数据库:
CREATE DATABASE football3;
-
-
添加 Flyway 依赖项。在您的
pom.xml文件中添加依赖项org.flywaydb:flywaycore。<dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-core</artifactId> </dependency> -
创建数据库创建脚本。Flyway 脚本的默认位置在
src/main/resources/db下。将文件命名为V1_InitialDatabase.sql并添加以下内容以创建teams和players表:CREATE TABLE teams ( id SERIAL PRIMARY KEY, name VARCHAR(255) ); CREATE TABLE players ( id SERIAL PRIMARY KEY, jersey_number INT, name VARCHAR(255), position VARCHAR(255), date_of_birth DATE, team_id INT REFERENCES teams(id) );您也可以使用此脚本向数据库中填充数据,例如添加
teams和players。INSERT INTO teams(id, name) VALUES (1884881, 'Argentina'); INSERT INTO players(id, jersey_number, name, "position", date_of_birth, team_id) VALUES (357669, 2, 'Adriana SACHS', 'Defender', '1993-12-25', 1884881)在本书的 GitHub 仓库中,您可以找到更多与此脚本相关的数据,如果您愿意,可以将其复制并粘贴到您的项目中。
迁移文件命名约定
迁移文件应遵循命名约定:V<版本>__<名称>.sql,版本可以是<主版本>_<次版本>,但这是可选的。请注意,在<版本>和<名称>之间有两个下划线符号。
-
启动应用程序。
如果您查看输出日志,您将看到类似以下的消息:

图 5.2:显示迁移执行的应用程序日志。
Flyway 创建了一个名为flyway_schema_history的新表来管理架构历史,并执行了我们上面创建的脚本。您可以使用命令*\dt*在 PostgreSQL 中获取表列表。

图 5.3:显示 Flyway 最近创建的数据库中表列表。
现在数据库已经有了管理我们应用程序所需的表。
-
让我们为我们的应用程序创建一个迁移。我们需要在我们的应用程序中管理足球比赛,并且我们需要知道球员的身高和体重。
-
比赛将由一个名为
MatchEntity的新实体来管理。它将有两个字段引用比赛的球队、比赛日期以及每队进球数。它应该看起来像这样:@Entity @Table(name = "matches") public class MatchEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private LocalDate matchDate; @ManyToOne @JoinColumn(name = "team1_id", nullable = false) private TeamEntity team1; @ManyToOne @JoinColumn(name = "team2_id", nullable = false) private TeamEntity team2; @Column(name = "team1_goals", columnDefinition = "integer default 0") private Integer team1Goals; @Column(name = "team2_goals", columnDefinition = "integer default 0") private Integer team2Goals; } -
现有的实体
PlayerEntity应该有两个新属性来管理球员的身高和体重。private Integer height; private Integer weight; -
现在我们需要为数据库创建 SQL 脚本。在
src/main/resources/db中创建一个名为V2__AddMatches.sql的新 sql 文件。在数据库中添加必要的更改以支持应用程序。 -
创建
matches表CREATE TABLE matches( id SERIAL PRIMARY KEY, match_date DATE, team1_id INT NOT NULL REFERENCES teams(id), team2_id INT NOT NULL REFERENCES teams(id), team1_goals INT default 0, team2_goals INT default 0 ); -
修改
players表以添加身高和体重两列。ALTER TABLE players ADD COLUMN height INT; ALTER TABLE players ADD COLUMN weight INT; -
我们还可以为现有球员设置值,为了简单起见,我们可以为所有球员设置相同的值:
UPDATE players SET height = 175, weight = 70;
-
-
执行应用程序。
如果您检查日志,您将看到 Flyway 应用的结构迁移。

图 5.4:显示新架构迁移的应用程序日志。
- 在应用程序启动期间,数据库被初始化。为了确保迁移按预期工作,你可以使用 Testcontainers 来验证它。你可以使用这种方法检查数据库中是否存在某些数据。书中 GitHub 存储库中提供了一些测试,假设数据库中存在某些值。
它是如何工作的...
在将 Flyway 依赖项添加到你的项目中时,它将在应用程序启动时检查迁移脚本。如果有迁移脚本,它将连接到数据库,并查看通过查看flyway_schema_history表已应用的迁移。如果该表尚不存在,它将创建它。然后,它将按顺序执行所有尚未应用的迁移。例如,在我们的示例中,如果你指向一个空数据库启动应用程序,它将首先应用V1__InitialDatabase.sql,然后是V2__AddMatches.sql。
Flyway 还使用flyway_schema_history表来在应用迁移时控制并发。如果你有多个应用程序实例,它们都将尝试执行相同的程序:
-
第一个应用程序实例将检查预期的版本是否与通过查看
flyway_schema_history表部署的版本相同。 -
如果部署的版本是预期的,它们将正常继续应用程序。
-
如果版本不同,它将锁定
flyway_schema_history表,并应用迁移。 -
其余的应用程序实例将等待直到
flyway_schema_history表被释放。 -
迁移完成后,第一个应用程序实例将更新
flyway_schema_history表中的版本,并将其释放。 -
然后,其余的应用程序实例将按照步骤 1 检查版本。由于它们已经部署,它们将正常继续,而无需再次应用迁移。
Flyway 还执行另一种验证,即检查迁移文件是否已被修改。它是通过生成内容的校验和并将其保存到flyway_schema_history中来实现这一点的。校验和是从内容中生成的一种签名,可用于验证内容是否未被修改。这种验证的目的是确保过程的一致性和可重复性。
重要
一旦应用了迁移,不要修改脚本文件。如果你需要修复迁移,请创建一个新的迁移来执行修复。
请记住,大型迁移,例如需要数据转换的迁移,可能会在数据库上产生锁,并可能导致你的应用程序出现潜在的停机时间,因为应用程序将不会在迁移完成之前完成初始化。
还有更多...
Flyway 提供了一个强大的机制来执行一致的迁移,并保持你的代码与数据库模式同步。它提供了强大的版本控制和回滚/撤销操作以及可重复迁移的机制。
如果您的应用与其他组件有关且不仅仅是您的应用有复杂需求,Flyway 提供了一个名为回调的机制来调用与迁移相关的额外操作,例如重新编译存储过程、重新计算物化视图或刷新缓存等。如果您有这种需求,我建议您查看以下文档:documentation.red-gate.com/flyway/flyway-cli-and-api/concepts/callback-concept。
使用 Flyway 的一个缺点是,即使没有迁移要应用,它也可能减慢应用启动过程。因此,Flyway 还提供了独立工具来管理迁移,例如桌面 UI 和命令行工具。这些工具有助于在不添加任何依赖项到您的项目的情况下,独立执行迁移过程和相关操作。
参见
在这个练习中,我专注于将 Flyway 作为一个管理数据库版本的工具,但 Spring Boot 也提供了对 Liquibase 的支持。正如 Flyway 一样,Liquibase 可以在应用启动时执行迁移,并使用独立的工具,如 CLI。它有免费和付费版本。我建议您评估这两个工具,并使用更适合您需求的那个。
使用 JPQL
JPQL代表Java 持久化查询语言。它是一个平台无关的查询语言,用于使用Java 持久化 API(JPA)查询和操作存储在关系数据库中的数据。
JPQL 的语法与 SQL 相似,但它操作在对象级别,允许开发者以 Java 对象及其关系为术语编写查询,而不是以数据库表和列为术语。这使得 JPQL 成为与基于 Java 的应用和对象关系映射框架(如 Hibernate)一起工作的开发者的更自然选择。
JPQL 的一些关键特性和概念包括:
-
实体类:JPQL 查询是针对 Java 实体类编写的,这些实体类是代表数据库表的 Java 对象。
-
面向对象的查询:JPQL 允许您以面向对象的方式查询和操作数据,使用 Java 类及其属性的名称。
-
关系:JPQL 支持基于实体之间关系的数据查询,例如一对一、一对多和多对多关联。
-
可移植性:JPQL 查询是以一种与底层数据库系统无关的方式编写的,这使得在不更改查询的情况下切换数据库成为可能。
-
类型安全:JPQL 查询在编译时进行类型检查,减少了运行时错误的风险。
JPQL 是处理基于 Java 的应用数据的有力工具。它允许开发者以与 Java 编程的面向对象特性更一致的方式表达数据库查询。
准备工作
对于这个菜谱,我们不需要与之前菜谱相比额外的工具。作为这个练习的起点,我们将使用之前菜谱的完成版本。如果你没有完成它,你可以在书籍仓库github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/中找到它。
如何做到这一点...
在这个菜谱中,我们将使用 JPQL 添加一些高级查询来增强之前菜谱中创建的仓库。我们将添加两个额外的实体,AlbumEntity和CardEntity来模拟卡片交易游戏。数据模型将如下所示:

图 5.5:PostgreSQL 数据模型
让我们开始:
-
添加新的实体和新的仓库。我们需要创建一个新的迁移。
-
AlbumEntity:
@Table(name = "albums") @Entity public class AlbumEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String title; private LocalDate expireDate; @OneToMany private List<CardEntity> cards; } -
CardsEntity:
@Table(name = "cards") @Entity public class CardEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @ManyToOne @JoinColumn(name = "album_id") private AlbumEntity album; @ManyToOne @JoinColumn(name = "player_id") private PlayerEntity player; } -
AlbumRepository:
public interface AlbumRepository extends JpaRepository<AlbumEntity, Integer> { }
现在创建一个新的 Flyway 迁移来创建表。为此,创建一个名为
V3__AddAlbums.sql的文件并创建表。CREATE TABLE albums ( id SERIAL PRIMARY KEY, title VARCHAR(255), expire_date DATE ); CREATE TABLE cards ( id SERIAL PRIMARY KEY, album_id INTEGER REFERENCES albums(id), player_id INTEGER REFERENCES players(id) );此脚本可在书籍仓库中找到,包括一些示例数据。
-
-
在我们的交易卡片游戏中,
CardEntitity实体代表用户拥有的卡片。我们将创建一个方法来获取我们拥有的一定队伍的球员。要做到这一点,在AlbumRepository中添加以下方法:@Query("SELECT p FROM PlayerEntity p JOIN p.cards c WHERE c.album.id = :id AND p.team.id = :teamId") public List<PlayerEntity> findByIdAndTeam(Integer id, Integer teamId); -
我们想知道我们还没有哪些球员。为了找出答案,在 AlbumsRepository 中添加以下方法:
@Query("SELECT p FROM PlayerEntity p WHERE p NOT IN (SELECT c.player FROM CardEntity c WHERE c.album.id=:id)") public List<PlayerEntity> findByIdMissingPlayers(Integer id); -
让我们找出特定比赛的双方球员。在
MatchRepository中添加以下方法:@Query("SELECT p1 FROM MatchEntity m JOIN m.team1 t1 JOIN t1.players p1 WHERE m.id = ?1 UNION SELECT p2 FROM MatchEntity m JOIN m.team2 t2 JOIN t2.players p2 WHERE m.id = ?1") public List<PlayerEntity> findPlayersByMatchId(Integer matchId); -
获取一个队伍及其球员。要做到这一点,在
TeamRepository中添加以下方法:@Query("SELECT t FROM TeamEntity t JOIN FETCH t.players WHERE t.id = ?1") public Optional<TeamEntity> findByIdWithPlayers(Integer id);现在可以在
FootballService中使用此方法来获取队伍。如果你还记得从菜谱“将你的应用程序连接到 Postgresql”,我们通过在getTeam方法中添加@Transactional注解来实现了一个避免 Open Session In View 反模式的机制。有了这个新的TeamRepository方法,它将在同一个会话中检索队伍及其球员,因此不再需要@Transactional。public Team getTeam(Integer id) { TeamEntity team = teamRepository.findByIdWithPlayers(id).orElse(null); if (team == null) { return null; } else { return new Team(team.getId(), team.getName(), team.getPlayers() .stream() .map(player -> new Player(player.getName(), player.getJerseyNumber(), player.getPosition(), player.getDateOfBirth())) .toList()); } } -
查找球员列表。通过在
PlayerRepository中添加以下方法来修改:@Query("SELECT p FROM PlayerEntity p WHERE p.id IN (?1)") List<PlayerEntity> findListOfPlayers(List<Integer> players);此方法也可以仅通过使用命名约定来实现,无需
@Query注解。List<PlayerEntity> findByIdInList(List<Integer> players); -
查找包含特定字符串的球员列表。通过在
PlayerRepository中添加以下方法来修改:List<PlayerEntity> findByNameLike(String name); -
查找以特定字符串开头的球员列表。通过在
PlayerRepository中添加以下方法来修改:List<PlayerEntity> findByNameStartingWith(String name); -
按升序排序一个队伍的球员。修改
PlayerRepository并添加以下仓库。List<PlayerEntity> findByTeamId(Integer teamId, Sort sort); playerRepository.findByTeamId(id, Sort.by("name").ascending())你可以决定如何排序结果。
-
我们有分页结果的选择。这意味着如果结果集很大,我们可以将其分成页面并检索。
JpaRepository已经提供了方法重载来分页结果。例如,findAll方法可以接收一个可分页的参数来控制结果应该如何分页。Page<PlayerEntity> page = playerRepository.findAll(Pageable.ofSize(size).withPage(pageNumber));您可以将此参数添加到任何使用自定义查询的方法中。例如,我们可以在
AlbumsRepository中创建以下方法:@Query("SELECT p FROM PlayerEntity p JOIN p.cards c WHERE c.album.id = :id") public List<PlayerEntity> findByIdPlayers(Integer id, Pageable page); -
我们还可以使用 JPQL 来返回聚合结果。例如,让我们创建一个查询来获取给定位置上每个团队的球员数量。
@Query("SELECT p.team.name as name, count(p.id) as playersCount FROM PlayerEntity p WHERE p.position = ?1 GROUP BY p.team ORDER BY playersCount DESC") public List<TeamPlayers> getNumberOfPlayersByPosition(String position);如您所见,结果不是一个实体,而是该位置上的团队名称和球员数量的名称。为了返回这个结果,我们使用了一个自定义的结果,它被实现为一个接口。
public interface TeamPlayers { String getName(); Integer getPlayersCount(); }接口应该有匹配投影查询结果的 getter 方法。
-
创建一个 RESTful 控制器和一个服务来使用生成的这些方法。在本书的 GitHub 仓库中有一个使用在此菜谱中创建的仓库的 RESTful API。
在仓库中,您还可以找到调用在此菜谱中创建的 RESTful API 方法的脚本。
它是如何工作的...
当使用 Spring Data JPA 的应用程序启动时,它执行几个重要的操作来使其工作。
-
Spring 应用程序上下文初始化。它设置管理 Spring 组件的环境,包括仓库。
-
Spring Boot 扫描组件并检测仓库。它检查带有
@Repository注解的类和扩展JpaRepository的接口。 -
对于每个仓库接口,Spring Data JPA 在运行时生成一个具体的实现。在我们的场景中,这意味着它将每个在仓库中定义的方法取出来,并生成特定的查询。在这一步,它通过使用命名约定或使用
@Query注解来验证是否可以生成实现。在这一步,它还验证查询,所以如果我们编写了一个无效的查询或者它不能从命名约定中生成实现,它将失败。 -
在生成实现后,它将它们注册为应用程序上下文中的 bean,现在它们对应用程序的其余部分都是可用的。
JPA 和 JPQL 的一个重要优点是查询引用了我们代码中定义的实体,因此它可以早期检测查询/实体映射不匹配。当使用原生查询时,这是无法实现的。
另一个优点是它抽象了底层数据库。作为一个开发者,这是一个有趣的功能,因为它使得迁移到新的数据库更快。
还有更多...
您可以通过使用spring.jpa.show-sql配置变量来激活 SQL 日志记录。检查和调试生成的原生查询很有趣。请记住,这可能会减慢您的应用程序并生成大量的日志。我建议只在开发中使用此设置。
使用原生查询
JPQL 是一个非常强大的机制,可以以底层数据库的抽象方式访问关系数据库。原生查询指的是直接对数据库执行 SQL 语句。根据您的需求,您可能需要考虑使用原生查询,例如:
-
执行复杂 SQL 操作,这些操作在 JPQL 中不易表达,例如涉及子查询或特定数据库函数的查询。
-
当你需要微调查询的性能时,利用特定数据库的优化、索引和提示来提高查询执行时间。
-
特定数据库的功能,例如可以管理 JSON 结构的数据库,可能有不同的实现方式。
-
批量操作:原生查询在执行大量记录的批量插入、更新或删除操作时通常更有效,因为它们绕过了 JPQL 带来的实体管理和缓存开销。
请记住,与 JPQL 相比,使用原生查询有一些权衡。
-
类型安全:正如我们在上一个菜谱中看到的,JPQL 提供了类型安全,这意味着查询结果以强类型对象返回,并且在应用程序启动时验证查询。使用原生查询时,你通常与无类型的结果集一起工作,如果不正确处理,可能会引入运行时错误,而且这种情况通常会在使用原生查询时出现。
原生查询可能更难维护和重构,因为它们涉及嵌入在 Java 代码中的 SQL 字符串。JPQL 查询更自包含且易于管理。
-
可移植性:原生查询在不同数据库系统之间不可移植。如果你的应用程序需要支持多个数据库,你可能需要为每个数据库编写特定的查询,并且你需要学习每个数据库 SQL 方言的具体差异。
在这个菜谱中,我们将向我们的足球应用程序引入一个新功能,即比赛时间线。时间线是足球比赛中发生的所有事件;由于我们不希望将可以管理为事件的内容进行约束,我们将部分信息保存为 JSON。PostgreSQL 对 JSON 有出色的支持,但在大多数场景中,编写原生查询是必要的。
准备工作
对于这个菜谱,我们不需要与之前菜谱相比额外的工具。作为这个练习的起点,我们将使用之前菜谱的完成版本。如果你没有完成它,你可以在本书的仓库中找到它,网址为 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。我还准备了一些脚本,用于为数据库创建一些示例数据。它将在菜谱步骤中解释。
如何操作...
在这个菜谱中,我们将创建一个新的表格来管理比赛事件。这个表格将包含一个包含 JSON 文档的列。
-
要创建新表格,我们将创建一个新的 Flyway 迁移。在
src/main/resources/db/migration文件夹中创建一个名为V4__AddMatchEvents.sql的文件,内容如下。CREATE TABLE match_events ( id BIGSERIAL PRIMARY KEY, match_id INTEGER NOT NULL, event_time TIMESTAMP NOT NULL, details JSONB, FOREIGN KEY (match_id) REFERENCES matches (id) ); CREATE PROCEDURE FIND_PLAYERS_WITH_MORE_THAN_N_MATCHES(IN num_matches INT, OUT count_out INT) LANGUAGE plpgsql AS $$ BEGIN WITH PLAYERS_WITH_MATCHES AS (SELECT p.id, count(m.id) AS match_count FROM players p, matches m WHERE p.team_id = m.team1_id OR p.team_id = m.team2_id GROUP BY p.id HAVING count(m.id) > num_matches) SELECT COUNT(1) INTO count_out FROM PLAYERS_WITH_MATCHES; END; $$;此外,我还准备了一个名为
V4_1__CreateSampleEvents.sql的迁移,您可以在 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/找到它。这个迁移在match_events表中插入事件,这样您就可以尝试操作它。这里您可以看到一个比赛事件详情的示例:{ "type": 24, "description": "Throw In", "players": [ 467653, 338971 ], "mediaFiles": [ "/media/93050144.mp4", "/media/6013333.mp4", "/media/56559214.mp4" ] } -
创建一个新的实体来管理这个表。创建一个名为
MatchEventEntity的类:@Table(name = "match_events") @Entity public class MatchEventEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "event_time") private LocalDateTime time; @JdbcTypeCode(SqlTypes.JSON) private MatchEventDetails details; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "match_id", nullable = false) private MatchEntity match; } -
我们将在另一个名为
MatchEventDetails的类中映射 JSON 内容。您可以使用其他更灵活的数据结构,如 Map。public class MatchEventDetails { private Integer type; private String description; private List<Integer> players; private List<String> mediaFiles; } -
创建一个新的
JpaRepository并命名为MatchEventRepository:public interface MatchEventRepository extends JpaRepository<MatchEventEntity, Long> { } -
我们将在仓库中创建一个新的方法来检索给定类型比赛中所有的事件。事件类型只是 JSON 内容的属性。为了执行此查询,我们需要使用 PostgreSQL 特定的语法来查询 JSON 内容。要使用原生查询,我们只需在
@Query注解中指定属性nativeQuery =true。@Query(nativeQuery = true, value = "SELECT me.* FROM match_events me WHERE me.match_id = ?1 AND CAST(me.details -> 'type' as INT) = ?2") public List<MatchEventEntity> findByIdIncludeEventsOfType(Integer matchId, Integer eventType); -
我们将实现一个仓库方法来检索与特定足球比赛相关的、指定球员参与的事件。
@Query(nativeQuery = true, value = "SELECT me.id, me.match_id, me.event_time, " + me.details FROM match_events me CROSS JOIN LATERAL " + jsonb_array_elements(me.details->'players') AS player_id " + "WHERE me.match_id = ?1 AND CAST(player_id as INT) = ?2") List<MatchEventEntity> findByMatchIdAndPlayer(Integer matchId, Integer playerId); -
在
PlayerRepository中,我们将创建一个新的方法来映射存储过程。@Procedure("FIND_PLAYERS_WITH_MORE_THAN_N_MATCHES") int getTotalPlayersWithMoreThanNMatches(int num_matches);现在您可以使用这些仓库创建一个服务和控制器。在 GitHub 仓库中,我扩展了现有的控制器以调用新的仓库方法。您还可以找到一个调用新控制器方法的脚本。
它是如何工作的...
在这个例子中,我们使用了 JSON,因为它非常适合存储灵活、可扩展的数据,而且不需要像表格那样有已知列和类型。PostgreSQL 对 JSON 有很好的支持,然而 JPQL 对这种场景的支持更为有限。这就是为什么您需要使用原生查询。
注意
即使 PostgreSQL 对 JSON 的支持非常好,但它并不像对常规列那样优化。如果文档中有经常使用的信息,最好将其移动到常规列。PostgreSQL 支持对 JSON 属性的索引,您需要评估在您的特定场景中哪种方法最好。
我们使用的MatchEventDetail只是一个类,不是一个实体。无论如何,这也需要提前了解 JSON 数据的模式以避免序列化错误。如果您需要一个更灵活的方法,可以使用一个简单的 Map 或 String 来映射该列。
JSON 支持只是在这个 PostgreSQL 案例中一个原生功能的示例,但还有其他场景可能需要使用原生查询。例如,复杂查询难以或无法使用 JPQL 表达,如子查询和批量操作。
当执行原生查询时,Spring Data JPA 和 Hibernate 不会检查 SQL 命令本身,然而执行结果应该映射到结果实体。在编写 SQL 语句时请记住这一点。例如,如果在这个菜谱中我们这样写:
SELECT me.id, me.match_id, me.event_time, me.details FROM match_events me CROSS JOIN LATERAL jsonb_array_elements(me.details->'players') AS player_id
WHERE me.match_id = ?1 AND CAST(player_id as INT) = ?2)
这应该匹配一个 List<MatchEventEntity>。根据我们对 MatchEventEntity 的定义,它期望查询结果包含列 id、event_time、details 和 match_id。所以在使用查询别名时请注意这一点。例如,看以下查询,它将在运行时产生错误:
SELECT me.id @Procedure annotation, Spring Data JPA and Hibernate can invoke the stored procedure. As it happens with Native Queries, you are responsible to make sure that the incoming parameters and results match with the method invocation. If you change any of them, it can cause errors in runtime.
There’s more...
An important difference seen in Native queries compared to JPQL queries is that the queries cannot be validated against your entities, hence you need to be careful as it can fail at runtime. I recommend checking all queries first in tools such as `PgAdmin` for PostgreSQL, or a similar tool for the database you choose. I also recommend preparing a good set of tests using the native queries. In the book’s GitHub repository I created some tests to validate the queries used in this recipe.
See also
If your solution is more dependent on JSON documents, with schema flexibility, rather than well-defined schemas with complex relationships and transactional integrity needs that PostgreSQL and other relational databases can offer, then you may want to consider other database technologies, such as Document databases. There are many solutions in the market, like MongoDB, Azure CosmosDB, AWS DocumentDb. We will cover MongoDB in the following chapter.
Updating Operations
In previous recipes, we just performed queries against the database. In this recipe, we will use Spring Data JPA to modify the data of our database.
We will continue with our football sample. In this recipe, we will create operations to manage the trading card albums. A user may have albums, an album has cards, and the card references a player. To complete an album, the user needs to have cards with all players. So, they need to know what cards are missing. Users can buy albums and cards, but they cannot choose the cards. They can have repeated players. Users can trade cards. So, they can exchange all unused cards with another user.
Getting ready
For this recipe, we don’t need additional tools compared to previous recipes. As a starting point of this exercise, we will use the completed version of the previous recipe. If you didn’t complete it, you can find it in the book’s repository at [`github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/`](https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/).
How to do it...
To complete this recipe, we will add a new Entity to manage the users. We will modify Albums and Cards as they have an owner now. Later we will create some operations that involve data modification to manage the cards and trading operations.
1. We will first create a new database migration using Flyway. To do that, create a file named `V5__AddUsers.sql` `in src/main/resources/db/migration`.
Then, we will create a table for users, and we’ll update the cards and albums to reference the user.
```
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255)
);
ALTER TABLE albums ADD COLUMN owner_id INTEGER REFERENCES users(id);
ALTER TABLE cards ADD COLUMN owner_id INTEGER REFERENCES users(id);
ALTER TABLE cards ADD CONSTRAINT cards_album_player_key UNIQUE (album_id, player_id);
```java
Note the constraint in the table cards to avoid repeating players in the same album.
2. Add a new entity named `UserEntity` to map with the new table:
```
@Table(name = "users")
@Entity
public class 用户实体 {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String username;
@OneToMany(mappedBy = "所有者")
private List<卡片实体> 拥有卡片;
@OneToMany(mappedBy = "所有者")
private Set<专辑实体> 拥有专辑;
}
```java
3. Modify `CardEntity` and `AlbumEntity` to reference an owner user.
```
@ManyToOne
@JoinColumn(name = "所有者 _id")
private 用户实体 所有者;
```java
4. `CardEntity` can be modified to reflect the unique constraint that a player can be only once in an album:
```
@Table(name = "cards", uniqueConstraints = { @UniqueConstraint(columnNames = { "album_id", "player_id" }) })
@Entity
public class 卡片实体 {
}
```java
5. Let’s start managing the data. We will start with just using the methods already provided by `JpaRepository`.
Let’s create a `UserRepository`:
```
public interface 用户存储库 extends JpaRepository<用户实体, Integer> {
}
```java
`JpaRepository` provides a method named `save`. This method creates or updates the entity provided. We can use it in this way:
```
private 用户存储库 用户存储库;
public 用户 创建用户(String name) {
用户实体 用户 = new 用户实体();
用户.setUsername(name);
用户 = 用户存储库.findById(userId).orElseThrow();
return new 用户(user.getId(), user.getUsername());
}
```java
In the same way, we can create an album that references a user:
```
public 专辑 购买专辑(Integer userId, String title) {
专辑实体 专辑 = new 专辑实体();
专辑.setTitle(title);
专辑.setExpireDate(LocalDate.now().plusYears(1));
专辑.setOwner(用户存储库.findById(userId).orElseThrow());
专辑 = 专辑存储库.save(专辑);
return new 专辑(专辑.getId(), 专辑.getTitle(), 专辑的所有者().getId());
}
```java
We can also save multiple entities at the same time by calling the method `saveAll`. As an example, let’s define a method to buy cards:
```
public List<Card> 购买卡片(Integer userId, Integer count) {
Random rnd = new Random();
List<球员实体> 球员 = 获取可用球员();
用户实体 所有者 = 用户存储库.findById(userId).orElseThrow();
List<CardEntity> 卡片 = Stream.generate(() -> {
CardEntity card = new CardEntity();
card.setOwner(owner);
card.setPlayer(players.get(rnd.nextInt(players.size())));
return 卡片;
}).limit(count).toList();
return 卡片存储库.saveAll(卡片)
.stream()
.map(card -> new Card(card.getId(), card.getOwner().getId(), Optional.empty(),
new 球员(card.getPlayer().getName(), card.getPlayer().getJerseyNumber(),
card.getPlayer().getPosition(), card.getPlayer().getDateOfBirth())))
.collect(Collectors.toList());
}
```java
Our users will buy batches of cards. We need to generate the cards; we will generate the cards selecting a random player for each card, then we’ll save them in a single `saveAll` operation.
6. Once we have the cards, we want to use them in our albums. Using them constitutes assigning them to an album. If we want to use just the method provided by JpaRepository, we should perform the following steps:
1. Get all the available cards, that is the ones that have not been assigned to an album.
2. Get all missing players. That is all players that are not in the cards assigned to an album.
3. Take all available cards that are in the missing players. These are the cards to be assigned to albums.
4. Verify that you only use a player in an album once.
5. Save the cards.
All these steps involve requests to the database.
Or we can obtain the same result with just one request to the database by using an `UPDATE` `SQL` command:
```
UPDATE 卡片
SET 专辑 _id = r.album_id
FROM
(SELECT available.album_id, (SELECT c2.id from cards c2 where c2.owner_id=?1 AND c2.player_id = available.player_id AND c2.album_id IS NULL LIMIT 1) as card_id
FROM
(SELECT DISTINCT a.id as album_id, c.player_id FROM albums a CROSS JOIN cards c WHERE a.owner_id=?1 AND c.owner_id=?1 AND c.album_id IS NULL AND c.player_id NOT IN (SELECT uc.player_id from cards uc WHERE uc.album_id = a.id)) as r
WHERE 卡片.id = r.card_id
```java
We can use this command in our `CardRepository`:
```
@Modifying
@Query(nativeQuery = true, value = "UPDATE cards " +
"SET album_id = r.album_id " + //
"FROM " + //
"(SELECT available.album_id, (SELECT c2.id from cards c2 where c2.owner_id=?1 AND c2.player_id = available.player_id AND c2.album_id IS NULL LIMIT 1) as card_id " + //
"FROM " + //
"(SELECT DISTINCT a.id as album_id, c.player_id FROM albums a CROSS JOIN cards c WHERE a.owner_id=?1 AND c.owner_id=?1 AND c.album_id IS NULL AND c.player_id NOT IN (SELECT uc.player_id from cards uc WHERE uc.album_id = a.id)) available) as r " +
"WHERE cards.id = r.card_id " +
"RETURNING cards.*")
List<CardEntity> assignCardsToUserAlbums(Integer userId);
```java
Remember to include `@Modifying` annotation. As this is a PostgreSQL command, it requires `nativeQuery=true` in the `@``Query` annotation.
7. We can transfer a card to another user. If the card was used in an album, it should be unlinked. This can be done in different ways, we will implement the same using a JPQL Query:
```
@Modifying
@Query(value = "UPDATE CardEntity " +
" SET album = null, " +
" owner= (SELECT u FROM UserEntity u WHERE u.id=?2) " +
"WHERE id = ?1 ")
Integer transferCard(Integer cardId, Integer userId);
```java
In this case, we need to ensure that this method is executed in the context of a transaction. We can do it decorating the calling method with a `@``Transactional` annotation:
```
@Transactional
public Optional<Card> transferCard(Integer cardId, Integer userId) {
Integer count = cardsRepository.transferCard(cardId, userId);
if (count == 0) {
return Optional.empty();
} else {
…
}
}
```java
8. Next, we’ll learn how to exchange cards from user to another. Again, we can do it in our business logic using the methods provided by `JpaRepository` by performing the following actions:
1. Get the available cards from one user, these are the ones not assigned to an album usually because they are repeated players.
2. Get the missing players on the albums of the other user.
3. Change the owner of the cards of the first user that are in the list of the missing players of the other user.
Or we can do it in a single `SQL` `UPDATE` statement:
```
@Modifying
@Query(nativeQuery = true, value = "UPDATE cards " +
"SET owner_id=?2 " +
" FROM (select c1.id from cards c1 where c1.owner_id=?1 and c1.album_id IS NULL AND c1.player_id IN (select p2.id from players p2 where p2.id NOT IN (SELECT c2.player_id FROM cards c2 WHERE c2.owner_id=?2)) LIMIT ?3) cards_from_user1_for_user2 " +
"WHERE cards.id = cards_from_user1_for_user2.id " +
"RETURNING cards.*")
List<CardEntity> tradeCardsBetweenUsers(Integer userId1, Integer userId2, Integer count);
```java
I created a service class and a dedicated RESTful controller to perform all the operations above. The code, including a script to call the RESTful controller, is in the GitHub repository.
How it works...
In JPA, there is the concept of *Persistence* *Context* or just *Persistence Context*. The Context is mostly managed by *EntityManager*, which is responsible for managing the lifecycle of the JPA entities. It covers the following aspects:
* **Entity Management**: The persistence context is responsible for managing the entities. When you retrieve data from the database using Spring Data JPA, the resulting entities are managed by the persistence context. This means that changes to these entities are tracked, and you can use the persistence context to synchronize these changes with the database.
* **Identity Management**: The persistence context ensures that there is a single in-memory representation of an entity for a given database row. If you load the same entity multiple times, you will get the same Java object instance, ensuring consistency and avoiding duplicate data.
* `flush` method of `JpaRepository`, or implicitly, for instance at the end of a transaction.
* **Caching**: The persistence context provides a first-level cache. It stores managed entities in memory, which can improve application performance by reducing the number of database queries required for entity retrieval during a transaction.
* **Lazy Loading**: The persistence context can enable lazy loading of related entities. When you access a property representing a related entity, Spring Data JPA can automatically fetch that related entity from the database, if it’s not already in the persistence context.
* **Transaction Synchronization**: The persistence context is typically bound to the scope of a transaction. This ensures that changes to entities are persisted to the database when the transaction is committed. If the transaction is rolled back, the changes are discarded.
In Spring Data JPA, the `EntityManager` is the central component for managing the persistence context. It provides methods for persisting, retrieving, and managing entities.
In addition to the methods already provided by the `JpaRepository` like `save` and `saveAll`, you can use `@Modifying` annotation. In Spring Data JPA, the `@Modifying` annotation is used to indicate that a method in a Spring Data JPA repository interface is a modifying query method. Modifying query methods are used to perform data modification operations like INSERT, UPDATE, or DELETE in the database.
When you mark a method in a Spring Data JPA repository with the `@Modifying` annotation, it changes the behavior of that method in the following ways:
* `@Modifying` annotation indicates that the method is intended to execute a non-select query, such as an `UPDATE` or `DELETE` statement.
* `@Modifying` annotation, the return type is not inferred automatically. Instead, you should explicitly specify the return type. Typically, the return type is `int` or `void`. For example, in the example above, `transferCard` returns an Integer. That number represents the number of rows affected. With Native Queries it is possible to return data, as shown in method `tradeCardsBetweenUsers`. PostgreSQL can return the rows impacted using the keyword `RETURNING` in the `UPDATE` command. This behavior can change depending on the Database engine.
* `@Modifying`, it will trigger a flush of the persistence context to synchronize the changes with the database.
* **Transaction Requirement**: Modifying query methods should be executed within a transaction context. If the method is called without an active transaction, it will typically result in an exception when using JPQL queries. For Native Queries, this behavior does not apply. We will cover in more detail transaction management in another recipe of this chapter.
In this recipe, we used JPQL queries and native queries. As mentioned in previous recipes, JPQL has the primary advantage of using your Entities, being able to make a type-safety check, and abstracting the complexities of accessing the underlying database.
Native Queries can be necessary when you need to fine tune your queries and optimize the access to the database. Taking the example of `assignCardsToUserAlbums`, the same operation using just JPQL and business logic in your Java application will require several calls to the database, transferring data from the database and to the database. This communication overhead is not a negligible cost for large-scale applications. In the implementation of `assignCardsToUserAlbums`, it is just one single call to PostgreSQL that performs all the updates and returns just the cards updated to be returned to the caller component.
See also
Check the *Using Dynamic Queries* and *Using Transactions* recipes to deeper dive into `EntityManager` and transactions management in Spring Data JPA.
Using Dynamic Queries
In Spring Data JPA, a dynamic query refers to a type of query that is constructed at runtime based on various conditions or parameters. Dynamic queries are particularly useful when you need to build and execute queries that can vary based on user input or changing criteria. Instead of writing a static query with fixed criteria, you create a query that adapts to different scenarios.
Dynamic queries can be constructed using the Criteria API, or by creating the query statement dynamically using JPQL or Native SQL. The Criteria API provides a programmatic and type-safe way to define queries to the database.
In previous recipes, we used the naming convention of the `JpaRepositories` when creating the repository methods. Spring Data JPA generates the queries dynamically using the same mechanism we will explain in this recipe.
In this recipe, we will implement the following functionalities:
* Search players using different criteria, for instance by name, height, or weight.
* Search match events in a time range.
* Delete match events in a time range.
* Search the missing players that a user of the card trading game does not have yet.
Getting ready
For this recipe, we don’t need additional tools compared to previous recipes. As the starting point of this exercise, we will use the completed version of *Updating Operations* recipe. If you didn’t complete it, you can find it in the book repository at https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/.
How to do it...
For this recipe, we will create a new service component to perform all dynamic queries.
1. To do that, first create a new `Service` named `DynamicQueriesService`. The service requires an `EntityManager`. For that reason, we need to declare a parameter in the constructor to ensure that the IoC container injects it.
```
@Service
public class DynamicQueriesService {
}
```java
2. In this service, we can create a method to search players using different criteria. Each criteria are optional, so we need to construct the query dynamically depending on the parameters provided. We will use the `CriteriaBuilder` class for that purpose.
```
Public List<PlayerEntity> searchTeamPlayers(Integer teamId, Optional<String> name, Optional<Integer> minHeight,
Optional<Integer> maxHeight,
Optional<Integer> minWeight, Optional<Integer> maxWeight) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<PlayerEntity> cq = cb.createQuery(PlayerEntity.class);
Root<PlayerEntity> player = cq.from(PlayerEntity.class);
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(player.get("team").get("id"), teamId));
if (name.isPresent()) {
predicates.add(cb.like(player.get("name"), name.get()));
}
if (minHeight.isPresent()) {
predicates.add(cb.ge(player.get("height"), minHeight.get()));
}
if (maxHeight.isPresent()) {
predicates.add(cb.le(player.get("height"), maxHeight.get()));
}
if (minWeight.isPresent()) {
predicates.add(cb.ge(player.get("weight"), minWeight.get()));
}
if (maxWeight.isPresent()) {
predicates.add(cb.le(player.get("weight"), maxWeight.get()));
}
cq.where(predicates.toArray(new Predicate[0]));
TypedQuery<PlayerEntity> query = em.createQuery(cq);
return query.getResultList();
}
```java
In this example, we used the criteria query as a parameter for the `EntityManager` method `createQuery`, then we used the query to retrieve the results.
3. Let’s implement another example, this time using JPQL statements. We will search events of a match in a time range:
```
public List<MatchEventEntity> searchMatchEventsRange(Integer matchId, Optional<LocalDateTime> minTime, Optional<LocalDateTime> maxTime) {
String command = "SELECT e FROM MatchEventEntity e WHERE e.match.id=:matchId ";
if (minTime.isPresent() && maxTime.isPresent()) {
command += " AND e.time BETWEEN :minTime AND :maxTime";
} else if (minTime.isPresent()) {
command += " AND e.time >= :minTime";
} else if (maxTime.isPresent()) {
command += " AND e.time <= :maxTime";
}
TypedQuery<MatchEventEntity> query = em.createQuery(command, MatchEventEntity.class);
query.setParameter("matchId", matchId);
if (minTime.isPresent()) {
query.setParameter("minTime", minTime.get());
}
if (maxTime.isPresent()) {
query.setParameter("maxTime", maxTime.get());
}
return query.getResultList();
}
```java
Now the query is created using a String that is passed again to the `createQuery` method of the `EntityManager`.
As you can see, the command contains named parameters that must be passed to the query.
4. We can use Native SQL commands as well. Let’s search for the players that a user doesn’t have yet for his or her album.
```
public List<PlayerEntity> searchUserMissingPlayers(Integer userId) {
Query query = em.createNativeQuery(
"SELECT p1.* FROM players p1 WHERE p1.id NOT IN (SELECT c1.player_id FROM cards c1 WHERE c1.owner_id=?1)",
PlayerEntity.class);
query.setParameter(1, userId);
return query.getResultList();
}
```java
To execute the native query, we now pass the String containing the native SQL command to the `createNativeQuery` method.
5. Now we will create a method to perform a delete operation. We will delete the events of a match in a certain time range. We will use JPQL to perform this functionality.
```
public void deleteEventRange(Integer matchId, LocalDateTime start, LocalDateTime end) {
em.getTransaction().begin();
Query query = em.createQuery("DELETE FROM MatchEventEntity e WHERE e.match.id=:matchId AND e.time BETWEEN :start AND :end");
query.setParameter("matchId", matchId);
query.setParameter("start", start);
query.setParameter("end", end);
query.executeUpdate();
em.getTransaction().commit();
}
```java
To perform an update, we need to call the `executeUpdate` method. Note that this type of modifying operation requires an active transaction.
How it works...
As explained in the previous recipe, in Spring Data JPA, the `EntityManager` is the central component for managing the persistence context. It provides methods for persisting, retrieving, and managing entities.
When using JPQL, the `EntityManager` compiles the query, not only to validate the syntax but also to check the consistency with the Entities of our project, then translates the query to native SQL and binds the parameters. After executing the query, `EntityManager` maps the results into the managed entities. The resulting entities are tracked by the `EntityManager` for any change or further persistence operation.
If you have a query that will be executed multiple times, you can use a Named Query, as it is precompiled and cached for better performance. For that, you can call the `createNamedQuery` method.
For Native queries, it is a bit simpler as it doesn’t compile nor validates the consistency, and it directly executes the query, mapping the results to the Entity specified. As discussed in previous recipes, it has advantages and trade-offs that you will need to evaluate depending on the needs of your application.
In the examples, we used Criteria Query and just a String containing the command. In general, I prefer Criteria Query because using it helps you avoid typos while building your query. In addition, Criteria Query is protected against SQL Injection attacks. If you build your query just concatenating Strings, be sure that you don’t use parameters provided by the user directly as it will make your query vulnerable to SQL Injection attacks. When using parameters provided by the user, be sure that they are always passed as query parameters and never directly concatenated to the command string. See for example the method `searchMatchEventsRange`. In it, the parameters influence the SQL command generated, but they are always passed as query parameters.
There’s more...
I created a controller to use the methods created in this recipe. You can find it in the book’s GitHub repository. In that project I enabled swagger UI, so you can use it to test the methods.
See also
There is a lot of literature about SQL Injection. If you are not familiar with it, you can check the Wikipedia page: [`en.wikipedia.org/wiki/SQL_injection`](https://en.wikipedia.org/wiki/SQL_injection).
Using Transactions
Transactions play a crucial role when working with databases to ensure data consistency and integrity. In Spring Data JPA, you can manage transactions using the Spring Framework’s transaction management capabilities.
In previous recipes, we implicitly used transactions, as some features require its usage; for instance, using `@Modifiying` annotation creates a transaction behind the scenes. In this recipe, we will learn more about transactions and how to use them in Spring Boot applications.
As an example of using transactions in Spring Boot applications, we will use them to manage the trading card operations between users. In a high-concurrency scenario, we want to ensure that users can exchange their cards with consistency and integrity.
Getting ready
For this recipe, we don’t need additional tools compared to previous recipes. As a starting point of this exercise, you can use a project that I prepared with the previous recipes in this chapter. You can find it in the book’s repository at https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/.
How to do it...
To implement the card trading scenario, we will enhance the `AlbumsService` to manage the card exchange between users consistently.
1. Open the service `AlbumsService` and find the method `tradeAllCards`. The functionality we want to achieve with this method is the following:
* Users can have repeated cards.
* They can exchange cards between them.
* They want to exchange the cards necessary to complete the albums.
* A trading operation involves the same number of cards from each user. If Sara gives three cards to Paul, Paul should give three cards to Sara in return.
To implement this functionality, first we need to know how many cards two users can exchange:
```
Integer potentialUser1ToUser2 = cardsRepository.countMatchBetweenUsers(userId1, userId2);
Integer potentialUser2ToUser1 = cardsRepository.countMatchBetweenUsers(userId2, userId1);
Integer count = Math.min(potentialUser1ToUser2, potentialUser2ToUser1);
```java
If both users have cards that can be exchanged, then they exchange a number of cards from one user to another, and then the same in the other direction. To avoid the same cards being exchanged in both directions, once a user receives the cards, they are used. Therefore these cards are not available for exchange.
```
ArrayList<CardEntity> result = new ArrayList<>(
cardsRepository.tradeCardsBetweenUsers(userId1, userId2, count));
useAllCardAvailable(userId2);
result.addAll(cardsRepository.tradeCardsBetweenUsers(userId2, userId1, count));
useAllCardAvailable(userId1);
```java
2. We want to perform all actions described in step 1consistently, and in case of an error, we want users to have the same cards they had before starting the trading operation. For this, we only need to annotate the method with `@Transactional`. The full method should look like this:
```
@Transactional
public List<Card> tradeAllCards(Integer userId1, Integer userId2) {
Integer potentialUser1ToUser2 = cardsRepository.countMatchBetweenUsers(userId1, userId2);
Integer potentialUser2ToUser1 = cardsRepository.countMatchBetweenUsers(userId2, userId1);
Integer count = Math.min(potentialUser1ToUser2, potentialUser2ToUser1);
if (count > 0) {
ArrayList<CardEntity> result = new ArrayList<>(
cardsRepository.tradeCardsBetweenUsers(userId1, userId2, count));
useAllCardAvailable(userId2);
result.addAll(cardsRepository.tradeCardsBetweenUsers(userId2, userId1, count));
useAllCardAvailable(userId1);
}
```java
There is some boilerplate code used to return data to be consumed by the RESTful API. It has been omitted for brevity, but you can find the full method in the GitHub repository.
If there is an exception during the execution of this method, the transaction will be automatically rolled-back, keeping the state as it was before starting the transaction. Let’s modify this method to add some validations.
We will check that the number of cards traded are the same. For that, change the invocation to `tradeCardsBetweenUsers` as follows:
```
ArrayList<CardEntity> result1 = new ArrayList<>(
cardsRepository.tradeCardsBetweenUsers(userId1, userId2, count));
useAllCardAvailable(userId2);
ArrayList<CardEntity> result2 = new ArrayList<>(
cardsRepository.tradeCardsBetweenUsers(userId2, userId1, count));
useAllCardAvailable(userId1);
if (result1.size() != result2.size()) {
throw new RuntimeException("Users have different number of cards");
}
```java
3. We can achieve a similar functionality by controlling the isolation of the transaction. You can do it by changing `@Transactional` annotation properties. If you set the transaction isolation level to `Serializable`, you ensure that the data used in your transaction is not read by any other transaction.
```
@Transactional(isolation = Isolation.SERIALIZABLE)
public List<Card> tradeAllCards(Integer userId1, Integer userId2) {
Integer potentialUser1ToUser2 = cardsRepository.countMatchBetweenUsers(userId1, userId2);
Integer potentialUser2ToUser1 = cardsRepository.countMatchBetweenUsers(userId2, userId1);
Integer count = Math.min(potentialUser1ToUser2, potentialUser2ToUser1);
if (count > 0) {
ArrayList<CardEntity> result1 = new ArrayList<>(
cardsRepository.tradeCardsBetweenUsers(userId1, userId2, count));
useAllCardAvailable(userId2);
ArrayList<CardEntity> result2 = new ArrayList<>(
cardsRepository.tradeCardsBetweenUsers(userId2, userId1, count));
useAllCardAvailable(userId1);
…
}
}
```java
This solution may seem more convenient as it simplifies the code and ensures consistency. However, in most scenarios, it can be an overkill. This kind of isolation is very costly for the database engine and can cause database locks and contention, degrading the performance of our application. It is a powerful mechanism but should be used wisely. We will explain in more detail in *How it* *works* section.
How it works...
When we use the `@Transactional` annotation in a method, Spring Data JPA creates a transaction when the method is invoked. If the method completes without errors, it commits the transaction and changes are confirmed. If an exception is thrown, Spring Data JPA rollbacks the transaction, setting the data to its previous state.
The `@Transactional` annotation can be applied at the class level. Then, Spring Data JPA applies the same behavior to all methods in that class.
An important concept to understand is transaction isolation. Why do we need to care about it? The key concept is concurrency. In concurrent systems, such as web applications, there are multiple operations simultaneously. Taking our card trading example, let’s figure something out. During special events, there can be thousands or millions of users exchanging their cards. What happens if, for example, while trading between Sara and Paul, it turns out that Paul has already exchanged his cards with Joanna? If we don’t control this scenario, it can happen that Sara gives her cards to Paul and Paul gives nothing to Sara. As we saw in the exercise, we can use some business logic to control this situation, or we can use higher isolation levels in the transaction. I recommend using higher isolation levels only when strictly required, as it hurts performance in high concurrent transactions.
In the example above we used `Serializable` isolation, which is the highest level of isolation. There are more in Spring Data JPA:
* `Isolation.DEFAULT`: The default isolation level is determined by the underlying database.
* `Isolation.READ_UNCOMMITTED`: Allows dirty reads, non-repeatable reads, and phantom reads.
* `Isolation.READ_COMMITTED`: Prevents dirty reads but allows non-repeatable reads and phantom reads.
* `Isolation.REPEATABLE_READ`: Prevents dirty reads and non-repeatable reads but allows phantom reads.
* `Isolation.SERIALIZABLE`: Provides the highest level of isolation, preventing dirty reads, non-repeatable reads, and phantom reads.
Keep in mind that the implementation of the isolation level relies on the underlying database engine, and there are some levels that may not be supported.
To define the Isolation levels, I used some terms that is worth explaining in detail:
* **Dirty Reads**: A dirty read occurs when one transaction reads data that has been modified by another transaction but not yet committed. In our example, it could be cards that were available for one user, that are no longer available once they have been exchanged.
* **Non-Repeatable Reads** (Uncommitted Data): Non-repeatable reads (or uncommitted data) occur when a transaction reads the same data multiple times during its execution, but the data changes between reads due to updates by other transactions.
* **Phantom Reads**: Phantom reads occur when a transaction reads a set of records that satisfy a certain condition, and then, in a subsequent read of the same records, additional records match the condition due to inserts by other transactions.
There is another concept that we haven’t used in the example but is a core part of the Spring Data JPA. It is transaction propagation. What happens if a method annotated as `@Transactional` calls another `@Transactional` method? Are they executed in the same transaction or different ones? This behavior can be configured using the propagation attribute of `@Transactional` annotation. For instance:
@Transactional(propagation = Propagation.REQUIRES_NEW)
These are propagation possible values:
* REQUIRED (Default): If an existing transaction doesn’t exist, a new transaction is started. If an existing transaction does exist, the method participates in that transaction.
* REQUIRES_NEW: A new transaction is always started, suspending any existing transactions. The method always runs in a new transaction, even if there was an ongoing transaction before.
* NESTED: Creates a “nested” transaction within the existing transaction, allowing for savepoints. If the nested transaction fails, it can roll back to the savepoint without affecting the outer transaction.
* NOT_SUPPORTED: The method runs without a transaction context. If an existing transaction exists, it’s suspended while the method runs.
* MANDATORY: Requires an existing transaction to be present. If no transaction exists, an exception is thrown.
* NEVER: The method must not be run within a transaction context. If a transaction exists, an exception is thrown.
With propagation options, you can decide if you want to commit or rollback all changes, or you want to allow that some parts of the changes can be committed or rolled-back independently.
As you can see there many options related to transactions. I tend to use the default behavior and keep everything as simple as possible and use the available options when they are necessary.
There’s more...
Here, we have used a declarative approach to implement transactions, but you can execute transactions with `EntityManager`, for instance when using Dynamic Queries.
em.getTransaction().begin();
// do your changes
em.getTransaction().commit();
Keep in mind that this way is more manual, so you need to properly manage the exceptions to rollback the transaction when needed. Usually, you will begin and commit your transaction inside a `try` block and you will rollback the transaction when an exception happens. That will look like this:
try {
em.getTransaction().begin();
…
em.getTransaction().commit();
} catch (Exception e) {
em.getTransaction().rollback();
}
It is important to close the transactions as soon as possible, as depending on the level of isolation, they can lock data in the database and cause unwanted contention.
See also
It is possible to create distributed transactions involving more than one microservice, but doing so can be complex and comes with challenges. Distributed transactions that span multiple microservices require careful design and consideration of the distributed nature of microservices architectures. Traditional **two-phase commit** (**2PC**) is one way to achieve distributed transactions, but it’s often avoided due to its complexity and potential for blocking and performance issues. Instead, many microservices architectures favor patterns like the Saga pattern or compensation-based transactions.
Here are some approaches for handling distributed transactions across multiple microservices:
* **Saga Pattern**: It is a way to maintain data consistency in a microservices architecture without relying on distributed transactions. In a saga, each microservice performs its part of the transaction and publishes events to inform other services about their actions. If an error occurs, compensating transactions are executed to revert previous actions. This pattern allows for eventual consistency and is often preferred in microservices.
* **Asynchronous Messaging**: Instead of tightly coupling microservices in a distributed transaction, you can use asynchronous messaging (e.g., with message queues) to communicate between services. Microservices can publish events when they complete their part of the work, and other services can consume these events and act accordingly.
* **Compensating Transactions**: In cases where something goes wrong, compensating transactions can be used to undo the changes made by a microservice. This is part of the Saga pattern and can help maintain data consistency.
* **API Gateway**: An API gateway can be used to orchestrate requests to multiple microservices as part of a single transaction. It can provide an API endpoint that aggregates multiple requests and enforces transactional semantics.
* **Distributed Transaction Coordinator** (**DTC**): While not commonly used in microservices architectures, you can implement a DTC that spans multiple microservices. However, this approach can introduce complexity and potential performance bottlenecks.
第六章:数据持久性和 Spring Data 与 NoSQL 数据库集成
SQL 和 NoSQL 数据库提供了一种灵活且可扩展的数据存储和检索方法,与传统的数据库相比,它们可能更适合某些用例。NoSQL 数据库旨在实现水平扩展、灵活性、性能、高可用性和全球分布。然而,因此您会失去关系型数据库可以提供的完整 SQL 实现的致性、ACID 合规性和表达性。
重要的是要注意,NoSQL 数据库不是一刀切解决方案,它们的适用性取决于您应用程序的需求。在某些情况下,SQL 和 NoSQL 数据库的组合可能是满足组织内不同数据存储和检索需求的最佳方法。
在本章中,我们将使用一些最受欢迎的 NoSQL 数据库。它们各自对数据访问有不同的方法,但 Spring Boot 在所有这些数据库中都简化了开发体验。
首先,我们将学习如何使用 MongoDB,这是一个面向文档的数据库,它以类似 JSON 的对象存储数据。我们将涵盖 MongoDB 中的数据访问基础,以及其他高级场景,例如索引、事务和乐观并发持久性。
接下来,我们将学习如何使用 Apache Cassandra。它是一个宽列存储数据库,这意味着它以灵活的模式存储数据在表中,并支持列族数据模型。我们将学习如何执行高级查询,以及如何在其中管理乐观并发持久性。
在本章中,我们将涵盖以下菜谱:
-
将您的应用程序连接到 MongoDB
-
使用 Testcontainers 与 MongoDB
-
MongoDB 中的数据索引和分片
-
在 MongoDB 中使用事务
-
使用 MongoDB 管理并发
-
将您的应用程序连接到 Apache Cassandra
-
使用 Testcontainers 与 Apache Cassandra
-
使用 Apache Cassandra 模板
-
使用 Apache Cassandra 管理并发
技术要求
对于本章,您需要一个 MongoDB 服务器和一个 Apache Cassandra 服务器。在两种情况下,在您的本地环境中部署它们的最简单方法是通过使用 Docker。您可以从其产品页面www.docker.com/products/docker-desktop/获取 Docker。我将在相应的菜谱中解释如何使用 Docker 安装 MongoDB 和 Cassandra。
如果您想在您的计算机上安装 MongoDB,可以遵循产品页面上的安装说明:www.mongodb.com/try/download/community。
如果您需要访问 MongoDB,您可以使用 MongoDB Shell 或 MongoDB Compass,这两个都可以在www.mongodb.com/try/download/tools找到。我将在本章中使用 MongoDB Shell,因此我建议您安装它。
对于 Cassandra,您可以遵循cassandra.apache.org/doc/latest/cassandra/getting_started/installing.html中的说明。
本章将展示的所有菜谱都可以在github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/tree/main/chapter6找到。
将您的应用程序连接到 MongoDB
在这个菜谱中,我们将学习如何在 Docker 中部署 MongoDB 服务器。接下来,我们将创建一个 Spring Boot 应用程序,并使用 Spring Data MongoDB 将其连接到我们的 MongoDB 服务器。最后,我们将初始化数据库并对已加载的数据执行一些查询。
我们将通过足球队伍和球员的场景来展示在 MongoDB 中管理数据的不同方法,与诸如 PostgreSQL 这样的关系型数据库相比。
准备工作
对于这个菜谱,我们将使用 MongoDB 数据库。在您的计算机上部署它的最简单方法是使用 Docker。您可以从www.docker.com/products/docker-desktop/的产品页面下载 Docker。
安装 Docker 后,您可以运行一个 MongoDB 的单实例或执行一个运行在副本集中的集群。在这里,您将部署一个运行在副本集中的集群。对于这个菜谱来说这不是必要的,但对于后续的菜谱来说却是必要的,因为它需要支持事务。我已经准备了一个脚本以简化集群的部署。这个脚本使用docker-compose部署集群;一旦部署,它将初始化副本集。您可以在本书的 GitHub 仓库github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/中的chapter3/recipe3-2/start文件夹中找到这个脚本。
您需要 MongoDB Shell 来连接到 MongoDB 服务器。您可以从www.mongodb.com/try/download/shell下载它。
您还需要mongoimport工具将一些数据导入数据库。它是 MongoDB 数据库工具的一部分。按照产品页面上的说明进行安装:www.mongodb.com/docs/database-tools/installation/installation/。
数据加载后,将看起来像这样:
{
"_id": "1884881",
"name": "Argentina",
"players": [
{
"_id": "199325",
"jerseyNumber": 1,
"name": "Vanina CORREA",
"position": "Goalkeeper",
"dateOfBirth": "1983-08-14",
"height": 180,
"weight": 71
},
{
"_id": "357669",
"jerseyNumber": 2,
"name": "Adriana SACHS",
"position": "Defender",
"dateOfBirth": "1993-12-25",
"height": 163,
"weight": 61
}
]
}
每个队伍都有一个球员列表。记住这个结构,以便更好地理解这个菜谱。
您可以使用MongoDB Shell连接到数据库。我们将使用它来创建一个数据库,并用一些数据初始化它。您可以在本书的 GitHub 仓库github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/中找到加载数据的脚本。脚本和数据位于chapter3/recipe3-1/start/data文件夹中。
如何操作...
让我们使用 Spring Data MongoDB 创建一个项目,并创建一个存储库来连接到我们的数据库。数据库管理足球团队,包括球员。我们将创建一些查询来获取团队和球员,并将实现操作来更改我们的数据。按照以下步骤操作:
-
使用 Spring Initializr 工具创建一个项目。打开
start.spring.io并使用与 第一章 中 创建 RESTful API 菜谱相同的参数,除了以下选项:-
对于
footballmdb -
对于 依赖项,选择 Spring Web 和 Spring Data MongoDB
-
-
下载使用 Spring Initializr 工具生成的模板,并将其内容解压缩到您的工作目录中。
-
首先,我们将配置 Spring Data MongoDB 以连接到我们的数据库。为此,在
resources文件夹中创建一个application.yml文件。它应该看起来像这样:spring: data: mongodb: uri: mongodb://127.0.0.1:27017/?directConnection=true database: football -
现在,创建一个名为
Team的类,并使用@Document(collection = teams)进行注解。它应该看起来像这样:@Document(collection = "teams") public class Team { @Id private String id; private String name; private List<Player> players; }注意,我们还用
@Id装饰了属性 ID,并在我们的类中使用List<Player>。在 MongoDB 中,我们将有一个名为teams的单个数据集合。每个团队将包含球员。 -
接下来,创建
Player类:public class Player { private String id; private Integer jerseyNumber; private String name; private String position; private LocalDate dateOfBirth; private Integer height; private Integer weight; }Player类不需要任何特殊注解,因为它的数据将被嵌入到Team文档中。 -
现在,创建一个用于管理在 MongoDB 中持久化的团队的存储库:
public interface TeamRepository extends MongoRepository<Team, String>{ } -
就像
JpaRepository一样,只需通过从MongoRepository扩展我们的TeamRepository接口,我们就已经有了在 MongoDB 中操作Team文档的基本方法。我们现在将使用这个存储库。为此,创建一个名为FootballService的新服务:@Service public class FootballService { private TeamRepository teamRepository; public FootballService(TeamRepository teamRepository) { this.teamRepository = teamRepository; } }现在,我们可以在我们的服务中创建一个新的方法,用于通过其
Id值检索一个团队。这个服务中的方法可以使用TeamRepository中的findById方法,该方法是通过对MongoRepository进行扩展而可用的:public Team getTeam(String id) { return teamRepository.findById(id).get(); } public Optional<Team> findByName(String name);我们还可以创建一个方法来查找包含字符串的团队名称:
public List<Team> findByNameContaining(String name); -
现在,我们将创建一个用于查找球员的方法。为此,我们需要查看团队以找到球员。可以通过使用
@Query注解来实现:@Query(value = "{'players._id': ?0}", fields = "{'players.$': 1}") public Team findPlayerById(String id); -
如您所见,查询的
value属性不是 SQL,它在fields属性中对应于我们想要从文档中检索的字段——在这种情况下,只是文档的players字段。此方法将返回一个只包含一个球员的Team对象。让我们看看如何使用这个方法。为此,在
FootballService中创建一个名为findPlayerById的方法:public Player getPlayer(String id) { Team team = teamRepository.findPlayerById(id); if (team != null) { return team.getPlayers().isEmpty() ? null : team.getPlayers().get(0); } else { return null; } }我们将使用
MongoRepository的save方法来 upsert 团队,以及使用delete/deleteById来在数据库中做出更改:-
FootballService类中的saveTeam:public Team saveTeam(Team team) { return teamRepository.save(team); } -
现在,创建一个通过其 ID 删除团队的方法:
public void deleteTeam(String id) { teamRepository.deleteById(id); }
-
在这个菜谱中,我们实现了一个使用 MongoRepository 来执行与我们的 MongoDB 数据库交互的基本操作的服务。我已经创建了一个 RESTful API 来公开由本菜谱中创建的 FootballService 服务实现的方法。我还创建了一个脚本来向 RESTful API 发送请求。您可以在本书的 GitHub 仓库中找到所有这些内容,在 chapter6/reciper6-1/end 文件夹中。
它是如何工作的...
当应用程序启动时,Spring Data MongoDB 会扫描应用程序以查找 MongoRepository 接口。然后,它为存储库中定义的方法生成实现,并将接口实现注册为 bean 以使其对应用程序的其余部分可用。为了推断接口的实现,它使用方法的命名约定;有关更多详细信息,请参阅 https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#repository-query-keywords。Spring Data MongoDB 还会扫描带有 @Query 注解的方法以生成这些方法的实现。
关于 @Query 注解,Spring Data MongoDB 可以执行某些验证,但你应该记住 MongoDB 是按设计灵活的。这意味着它不假设某个字段应该存在或不存在。它将返回一个 null 值。请注意,如果结果与您预期的不同,您的查询可能存在拼写错误。
在 findPlayerById 中,我们实现了一个查询以返回文档中的数组元素。理解 MongoDB 返回的数据非常重要。当我们想要找到球员 430530 时,它返回一个容器文档,一个具有 id 值为 1882891 的 Team 对象,仅包含属性 players 和一个仅包含一个元素的数组 - 即具有 ID 430530 的球员。它看起来像这样:
[
{
"_id": "1882891",
"players": [
{
"_id": "430530",
"jerseyNumber": 2,
"name": "Courtney NEVIN",
"position": "Defender",
"dateOfBirth": {
"$date": "2002-02-11T23:00:00Z"
},
"height": 169,
"weight": 64
}
]
}
]
注意
我为了学习目的在团队集合中包含了球员。如果您有类似的场景,并且您在集合中搜索数组元素时预期将执行大量查询,您可能更喜欢为该数组拥有一个 MongoDB 集合。在这种情况下,我会将球员存储在它们自己的集合中。这将执行得更好,并且可扩展性更强。
在这里,MongoRepository 提供了三种方法来保存数据:
-
save: 此方法如果文档不存在则插入文档,如果文档已存在则替换它。这种行为也称为 upsert。 -
saveAll: 此方法的行为与save相同,但它允许您同时持久化多个文档。 -
insert: 此方法向集合中添加一个新的文档。因此,如果文档已存在,它将失败。此方法针对插入操作进行了优化,因为它不会检查文档的先前存在。
save 和 saveAll 方法会完全替换已存在的文档。如果你只想更新实体的一些属性,也称为部分文档更新,你需要使用 Mongo 模板。
更多...
我建议在更高级的场景中查看 MongoTemplate,例如当你需要部分更新时。以下是一个示例,如果你只想更新团队名称:
public void updateTeamName(String id, String name) {
Query query = new Query(Criteria.where("id").is(id));
Update updateName = new Update().set("name", name);
mongoTemplate.updateFirst(query, updateName, Team.class);
}
如你所见,它允许你定义查询对象的 where 条件,并允许 更新 操作,定义你想要更新的字段。在这里,MongoTemplate 是 Spring Data MongoDB 用于创建 MongoRepository 接口实现的核心理念组件。
使用 Testcontainers 与 MongoDB
当创建依赖于 MongoDB 的集成测试时,我们有两种选择:在我们的应用程序中嵌入一个内存数据库服务器或使用 Testcontainers。内存数据库服务器可能与我们的生产系统略有不同。出于这个原因,我建议使用 Testcontainers;它允许你使用一个在 Docker 中托管并启用所有功能的真实 MongoDB 数据库。
在这个菜谱中,我们将学习如何设置 MongoDB Testcontainer 以及如何执行一些初始化脚本,以便我们可以将测试数据插入到数据库中。
准备工作
执行 Testcontainers 需要一个与 Docker-API 兼容的运行时。你可以通过遵循官方网页上的说明来安装 Docker:www.docker.com/products/docker-desktop/。
在这个菜谱中,我们将为在 将你的应用程序连接到 MongoDB 菜谱中创建的项目添加测试。我已创建了一个可工作的版本,以防你还没有完成它。你可以在本书的 GitHub 仓库中找到它,在 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook 的 chapter6/recipe6-2/start 文件夹中。在这个文件夹中,你还会找到一个名为 teams.json 的文件。这将用于初始化测试数据。
如何做...
让我们通过使用 Testcontainers 创建自动化测试来增强我们的项目:
-
首先,我们需要包含 Testcontainers 依赖项。为此,打开
pom.xml文件并添加以下依赖项:<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>mongodb</artifactId> <scope>test</scope> </dependency> -
由于我们需要在测试执行期间初始化数据库中的数据,将 准备阶段 中描述的
team.json文件复制到tests/resources/mongo文件夹中。 -
接下来,创建一个测试类。让我们称它为
FootballServiceTest,并使用@SpringBootTest和@Testcontainers注解该类:@SpringBootTest @Testcontainers class FootballServiceTest -
我们将继续设置测试类,通过创建 MongoDB 容器。正如我们将在下一步看到的,我们需要用一些数据初始化数据库。为此,我们将把 步骤 2 中描述的
teams.json文件复制到容器中。我们将创建容器并按以下方式传递文件:static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo").withCopyFileToContainer( MountableFile.teams.json file. To import the data, we’ll use the *mongoimport* tool:@BeforeAll
static void startContainer() throws IOException, InterruptedException {
mongoDBContainer.start();
importFile("teams");
}
static void importFile(String fileName) throws IOException, InterruptedException {
Container.ExecResult res = mongoDBContainer.execInContainer("mongoimport", "--db=football", "--collection=" + fileName, "--jsonArray", fileName + ".json");
if (res.getExitCode() > 0){
throw new RuntimeException("MongoDB not properly initialized");
}
}
Note that this step should be performed before the tests start. That’s why it’s annotated with `@BeforeAll`. -
现在,我们应该配置上下文,使其使用在 Testcontainers 中托管的 MongoDB 数据库。为此,我们将使用
@DynamicPropertySource注解:@DynamicPropertySource static void setMongoDbProperties(DynamicPropertyRegistry registry) { registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl); } -
现在 MongoDB 仓库已经配置好了,我们可以继续进行正常的测试实现。让我们将
FootballService注入到测试类中,并实现一个简单的测试,用于检索Team对象:@Autowired private FootballService footballService; @Test void getTeam() { Team team = footballService.getTeam("1884881"); assertNotNull(team); } -
您可以实现其余功能的测试。我为
FootballService类创建了一些基本的测试。您可以在本书的 GitHub 仓库中找到它们,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook,在chapter6/recipe6-2/end文件夹中。
工作原理...
正如我们在第五章的使用 Testcontainers 进行 PostgreSQL 集成测试配方中看到的,通过添加@Testcontainers注解,所有声明为静态的容器都可用于类中的所有测试,并在最后一个测试执行后停止。在这个配方中,我们使用了专门的MongoDBContainer容器;它提供了服务器的 URL,我们可以用它来配置测试上下文。这种配置是通过使用@DynamicPropertySource注解来完成的,正如我们在步骤 6中看到的。
在这个配方中,我们学习了如何将文件复制到容器中并在其中执行程序。resources文件夹中的所有文件都在运行时可用。我们将teams.json文件复制到容器中,然后使用mongoimport工具将数据导入 MongoDB。这个工具在 MongoDB Docker 镜像中可用。在容器中执行此工具的一个优点是不需要指定数据库服务器地址。
MongoDB 中的数据索引和分片
在这个配方中,我们将管理足球比赛及其时间线——即比赛期间发生的事件。一个事件可能涉及一名或两名球员,我们必须考虑到球员的粉丝想要访问所有涉及他们最喜欢的球员的行动。我们还将考虑比赛及其事件的数量每天都在增长,因此我们需要准备我们的应用程序以支持所有负载。
在这个菜谱中,我们将介绍一些关键概念,以使您的应用程序具有高性能和可扩展性。MongoDB,就像关系数据库一样,允许您创建索引以优化数据访问。如果您计划使用相同的参数访问某些数据,创建索引以优化数据读取是值得的。当然,您将需要更多的存储和内存,并且写操作将受到影响。因此,您需要计划和分析您的应用程序需求。
随着您数据量的增加,您将需要扩展您的 MongoDB 数据库。分片是一种数据库架构和分区技术,用于在分布式系统中的多个服务器或节点上水平分区数据。通过分片,您可以通过添加更多服务器并将数据分布到它们上(使用分片)来扩展数据库。分片确保同一分片中的所有数据都将位于同一服务器上。
在这个菜谱中,我们将使用索引和分片在我们的足球应用程序中,同时利用 Spring Data MongoDB 提供的功能。我们将使用 Spring Data MongoDB 的其他有趣功能,例如从其他文档引用文档。
准备工作
我们将使用与第一个菜谱中相同工具,将您的应用程序连接到 MongoDB —— 也就是说,Docker、MongoDB 以及 MongoDB 工具,如Mongo Shell和mongoimport。
我们将重用将您的应用程序连接到 MongoDB菜谱中的代码。如果您还没有完成它,不要担心——我已经在这个书的 GitHub 仓库中准备了一个工作版本,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。您可以在chapter6/recipe6- 3/start中找到它。我还创建了一个脚本,用于使用mongoimport工具将数据加载到数据库中。您可以在chapter6/recipe6-3/start/data文件夹中找到它。
与将您的应用程序连接到 MongoDB菜谱中提供的数据相比,数据略有不同。我将球员移动到了他们自己的 MongoDB 集合中,并添加了新的集合来管理比赛和比赛事件。如果您想保留上一个菜谱中的数据,我建议您为这个菜谱创建一个新的数据库。您可以通过简单地更改调用mongoimport工具时的--db参数来实现这一点。调用将如下所示:
mongoimport --uri="mongodb://127.0.0.1:27017/?directConnection=true"
--db=football2 --collection=teams --jsonArray < teams.json
如何操作...
首先,我们将把球员的数据托管在他们自己的 MongoDB 集合中。球员对于新的需求来说将是重要的实体,因此他们应拥有自己的集合。然后,我们将创建比赛和事件的文档类。我们将学习如何使用 Spring Data MongoDB 注解来配置 MongoDB 索引和分片。按照以下步骤操作:
-
让我们从配置球员自己的 MongoDB 集合开始。使用
@Document注释Player类:@Document(collection = "players") public class Player { @Id private String id; }使用
@Id注解注释id字段。我们这样做是因为它将是文档标识符。现在,从
Team中删除players字段。 -
接下来,创建比赛及其事件的类。对于比赛,我们将创建一个名为
Match的类:@Document(collection = "matches") public class Match { @Id private String id; private LocalDate matchDate; @Indexed @DBRef(lazy = false) private Team team1; @Indexed @DBRef(lazy = false) private Team team2; private Integer team1Goals; private Integer team2Goals; }注意,我们开始使用两个新的注解,
@Indexed和@DBRef。它们将在本食谱的 How it works... 部分中完全解释。对于比赛事件,我们将创建一个名为
MatchEvent的类:@Sharded(shardKey = { "match" }) @Document(collection = "match_events") public class MatchEvent { @Id private String id; @Field(name = "event_time") private LocalDateTime time; private Integer type; private String description; @Indexed @DBRef private Player player1; @Indexed @DBRef private Player player2; private List<String> mediaFiles; @DBRef private Match match; }通过这样,我们介绍了
@Sharded和@Field注解。 -
为了能够使用新类,我们将为每个类创建一个存储库——即
PlayerRepository、MatchRepository和MatchEventRepository。让我们详细看看
MatchEventRepository。它将实现我们所需的要求:-
返回比赛中的所有事件
-
返回比赛中的所有球员事件:
public interface MatchEventRepository extends MongoRepository<MatchEvent, String>{ @Query(value = "{'match.$id': ?0}") List<MatchEvent> findByMatchId(String matchId); @Query(value = "{'$and': [{'match.$id': ?0}, {'$or':[ {'player1.$id':?1}, {'player2.$id':?1} ]}]}") List<MatchEvent> findByMatchIdAndPlayerId(String matchId, String playerId); }
-
-
到目前为止,我们可以运行我们的应用程序,因为 Spring Data MongoDB 组件已经就绪。然而,并非所有索引都已被创建。如果我们想在应用程序中创建它们,我们需要创建一个配置类,该类扩展
AbstractMongoClientConfiguration,指示 Spring Mongo DB 自动创建索引:@Configuration public class MongoConfig extends AbstractMongoClientConfiguration { @Override protected boolean autoIndexCreation() { return true; } } -
现在,我们可以使用这些存储库创建一个服务,以实现我们应用程序的新要求,同时以优化的方式连接到 MongoDB。我已经创建了一个服务和 RESTful 控制器来演示这些存储库的使用。我还使用 Testcontainers 添加了一些测试。您可以在本书的 GitHub 仓库中找到它们,网址为
github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/,在chapter6/recipe6-3/end文件夹中。
How it works...
首先,我将解释在这个食谱中使用到的注解的影响。
@DBRef 注解是引用另一个文档的一种方式,但请记住,这是一个由 Spring Data MongoDB 实现的机制,而不是由数据库引擎本身实现的。在 MongoDB 中,引用完整性的概念不存在,它应该在应用程序级别进行管理。在这里,@DBRef 将文档表示为一个具有三个字段的对象:
-
$ref:这包含被引用的集合 -
$id:这包含被引用文档的 ID -
$db:这包含被引用文档的数据库
例如,这里有一个对团队 1882891 的引用:
{
"$ref": "teams",
"$id": "1882891",
"$db": "football"
}
Spring Data MongoDB 可以使用这个注解来自动检索引用的文档。我们可以使用 lazy 属性来指定这种行为。默认情况下,它是 true,这意味着 Spring Data MongoDB 不会自动检索它。如果您将其设置为 false,它将自动检索引用的文档。我们使用这个注解来检索比赛文档,以自动检索比赛两支队伍的信息。
@Indexed 注解,正如你可能已经猜到的,在 MongoDB 中创建了一个索引。然后,使用索引字段的查询将更快地执行读操作。
@Sharded 注解告诉 MongoDB 如何将集合分布到各个分片中。集群中的服务器可以托管一个或多个分片。我们也可以将分片视为指定哪些文档将托管在同一个服务器上的方式。在我们的案例中,我们感兴趣的是通过匹配检索事件。这就是我们配置 match 作为分片键的原因。选择一个好的分片键对于使我们的应用程序性能良好和可扩展至关重要,因为它将影响工作负载在服务器之间的分布方式。
当在一个分片集合中执行查询时,MongoDB 应该确定该请求是否可以在单个分片中执行,或者是否需要将查询分散到多个分片。它将从分片中收集结果,进行聚合,然后将结果返回给客户端。如果你故意需要水平扩展查询,这是一个非常好的机制。可能发生的情况是,请求不需要分散,可以在单个分片中执行,但由于分片键选择错误,它被当作分布式查询执行。结果是,它将消耗比预期更多的资源,因为更多的服务器将执行不必要的查询,因此需要聚合结果。
分片涉及将数据库划分为更小的部分,称为分片,这些分片可以托管在单个服务器上。一个服务器可以托管多个分片,并且分片会在服务器之间进行复制以提高可用性。服务器的数量可以根据负载自动增加或减少。分片对于管理大型数据集和大型集群非常有用,这些集群通常部署在云中。例如,MongoDB Atlas 可以托管在云提供商,如 Azure、Amazon Web Services(AWS)和Google Cloud Platform(GCP)上,允许调整服务器的数量以满足实际需求。然而,在数据库托管在计算机上的单个容器中,如我们的示例中,分片不会提供任何显著的好处。在更大的部署中,分片是实现我们目标的关键特性。
我们没有在 MatchEvent 中显式创建 match 的索引,但由于它是分片键,它被隐式创建。
最后,我们使用了 @Field 注解。这用于将我们的文档类中的一个字段映射到 MongoDB 中的不同字段。在我们的案例中,我们将类中的 time 字段映射到 MongoDB 中的 event_time 字段。
还有更多...
在使用 MongoDB 或其他面向文档的数据库设计数据层时,应做出一些决策。例如,我们应该在同一个集合中混合不同类型的对象,还是应该将每种类型的文档保存在不同的集合中?
在同一个集合中拥有不同类型的对象,如果它们共享一些公共字段并且您想通过这些字段执行查询,或者您想从不同的对象中聚合数据,这是有意义的。对于其他场景,可能更好的是将每种类型的文档放在其自己的集合中。这有助于创建索引并促进分片创建。
在这个菜谱中,我们没有混合不同类型的文档,这也是 Spring Data MongoDB 在持久化文档时引入名为_class的字段的原因。例如,这是在创建新队伍时持久化的文档:
{
"_id": "99999999",
"name": "Mars",
"_class": "com.packt.footballmdb.repository.Team"
}
另一个需要做出的决定是,我们是否应该在文档中嵌入一些数据,或者这些数据应该在其自己的文档中。在将您的应用程序连接到 MongoDB菜谱中,我们将球员嵌入到他们的队伍中,而在这个菜谱中,我们将该信息移动到其自己的集合中。这可能取决于可嵌入文档的重要性或独立性。在这个菜谱中,球员需要自己的文档,因为它们可以直接从其他文档中引用,例如比赛事件。
可能还有其他原因,例如对嵌入式实体的预期写并发性。例如,我们可以在比赛中嵌入事件。然而,在比赛期间,我们可以假设会有大量事件发生。这个操作将需要在比赛文档上进行大量写操作,这将需要更多的一致性管理。
在 MongoDB 中使用事务
我们希望创建一个新的服务,用户可以购买一个虚拟代币,该代币可以用来获取这个新游戏中的虚拟商品。主要商品是带有玩家图片和其他信息的卡片,一种虚拟贴纸。
我们需要实现两个操作:代币购买和卡片购买。对于代币购买,有一个支付验证。卡片只能用代币购买。当然,如果用户有足够的代币,他们也将能够购买卡片。
由于我们需要确保代币和卡片余额的一致性,我们将需要使用事务与我们的 MongoDB 存储库一起使用。
在这个菜谱中,我们将了解更多的 MongoDB 事务以及它们与关系型数据库事务的不同之处。
准备工作
我们将使用与将您的应用程序连接到 MongoDB菜谱中相同的工具——即 Docker 和 MongoDB。
我们将重用在 MongoDB 中数据索引和分片菜谱中的代码。如果您还没有完成它,不要担心——我已经在这个书的 GitHub 仓库中准备了一个工作版本,在github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。它可以在chapter6/recipe6-4/start文件夹中找到。
MongoDB 事务
MongoDB 事务在独立服务器上不受支持。在 将您的应用程序连接到 MongoDB 配方中,我提供了一个使用副本集部署集群的脚本。
在 在 Testcontainers 中部署 MongoDB 集群 的配方中,我们将介绍如何使用容器部署多个服务器以测试 MongoDB 事务。
如何做到这一点...
我们需要创建一个数据模型来支持我们新服务中的用户和卡片。稍后,我们将创建一个使用 MongoDB 事务来执行涉及用户和卡片的操作一致性的服务。我们将配置我们的应用程序以支持事务。按照以下步骤操作:
-
让我们从创建将存储在 MongoDB 中的对象的管理类开始:
- 首先,我们将创建一个名为
User的类:
@Document(collection = "users") public class User { @Id private String id; private String username; private Integer tokens; }- 接下来,我们将创建一个名为
Card的类:
@Document(collection = "cards") public class Card { @Id private String id; @DBRef private Player player; @DBRef private User owner; } - 首先,我们将创建一个名为
-
接下来,我们需要创建相应的
MongoRepository接口。让我们开始吧:- 创建一个名为
UserRepository的接口:
public interface UserRepository extends MongoRepository<User, String>{ }- 另外一个名为
CardRepository的接口:
public interface CardRepository extends MongoRepository<Card, String>{ } - 创建一个名为
-
现在,我们需要创建一个服务类来管理我们应用程序的业务逻辑。为此,创建一个名为
UserService的类。请记住用@Service注解该类:@Service public class UserService { } -
此服务将需要我们创建的新存储库——即
UserRepository和CardRepository,以及我们在 MongoDB 中的数据索引和分片 配方中创建的PlayerRepository。我们还需要MongoTemplate。我们将创建一个包含这些存储库的构造函数,之后 Spring Boot 依赖项管理器将注入它们:@Service public class UserService { private UserRepository userRepository; private PlayerRepository playersRepository; private CardRepository cardsRepository; private MongoTemplate mongoTemplate; public UserService(UserRepository userRepository, PlayerRepository playersRepository, CardRepository cardsRepository, MongoTemplate mongoTemplate) { this.userRepository = userRepository; this.playersRepository = playersRepository; this.cardsRepository = cardsRepository; this.mongoTemplate = mongoTemplate; } -
接下来,我们将实现我们的业务逻辑:
- 创建一个名为
buyTokens的购买令牌的方法:
public Integer buyTokens(String userId, Integer tokens) { Query query = new Query(Criteria.where("id").is(userId)); Update update = new Update().inc("tokens", tokens); UpdateResult result = mongoTemplate.updateFirst(query, update, User.class, "users"); return (int) result.getModifiedCount(); }- 创建一个名为
buyCards的购买卡片的方法:
@Transactional public Integer buyCards(String userId, Integer count) { Optional<User> userOpt = userRepository.findById(userId); if (userOpt.isPresent()) { User user = userOpt.get(); List<Player> availablePlayers = getAvailablePlayers(); Random random = new Random(); if (user.getTokens() >= count) { user.setTokens(user.getTokens() - count); } else { throw new RuntimeException("Not enough tokens"); } List<Card> cards = Stream.generate(() -> { Card card = new Card(); card.setOwner(user); card.setPlayer(availablePlayers.get( random.nextInt(0, availablePlayers.size()))); return card; }).limit(count).toList(); List<Card> savedCards = cardsRepository.saveAll(cards); userRepository.save(user); return savedCards.size(); } return 0; } - 创建一个名为
-
为了在我们的应用程序中允许事务,我们需要注册一个
MongoTransactionManager实例。为此,在我们的MongoConfig类中添加以下方法:@Bean MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) { return new MongoTransactionManager(dbFactory); }
现在,我们的应用程序可以使用事务来原子性地执行操作。
它是如何工作的...
默认情况下,Spring Data MongoDB 中禁用了 MongoDB 原生事务。这就是为什么我们需要注册 MongoTransactionManager 的原因。一旦配置完成,当我们用 @Transactional 注解一个方法时,它将创建一个事务。
非常重要的是要注意,事务提供原子操作,这意味着所有操作要么全部保存,要么全部不保存,但它们不支持隔离。buyCards 方法将保存所有 cards 和 user 上的更改,或者它将保存所有这些更改。
与关系型数据库中的事务相比,一个重要的区别是没有锁定或隔离。如果我们修改了在另一个请求中 buyCards 修改的同一 User 文档,它将引发一个 写冲突异常。MongoDB 是以性能和可扩展性为代价,牺牲了 ACID 事务的功能而设计的。我们将在 使用 MongoDB 管理并发 配方中更详细地学习如何管理并发。
如您可能已经意识到的那样,buyTokens 方法不使用事务。主要原因是不需要这样做。单个文档中的所有操作都被视为隔离和原子的。由于唯一更新的字段是 tokens,我们使用了 inc 操作来修改值。这个操作器的优点是它在服务器上以原子方式执行,即使在高并发环境中也是如此。如果我们对涉及单个文档的操作使用事务,当两个请求正在更新同一文档时,可能会引发写冲突异常。如果您将其与关系型数据库中事务的行为进行比较,这种行为可能会显得有些反直觉。
相关内容
除了 $inc 之外,MongoDB 中还有其他适用于并发场景的原子操作值得了解。它们可以应用于字段和数组。有关更多详细信息,请参阅 www.mongodb.com/docs/v7.0/reference/operator/update/。
在 Testcontainers 中部署 MongoDB 集群
MongoDB 事务仅在多服务器集群中受支持。然而,正如 使用 Testcontainers 与 MongoDB 配方中解释的那样,MongoDBContainer 使用的是单个服务器部署。因此,我们无法用它来对新功能的购买卡片集成测试进行测试,因为它需要事务。
在这个配方中,我们将学习如何设置多个 Testcontainers 并配置 MongoDB 集群。有了这个,我们将能够实现购买卡片功能的集成测试。
准备工作
这个配方将实现 在 MongoDB 中使用事务 配方的集成测试。如果您还没有完成它,不用担心——我已经准备了一个版本,您可以从这个版本开始这个配方。您可以在本书的 GitHub 仓库中找到它,网址为 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook,在 chapter6/recipe6-5/start 目录下。
如何操作...
在这个配方中,我们将使用 Testcontainers 设置 MongoDB 集群并测试涉及事务的功能。让我们开始吧!
-
由于新的购买卡片功能是,我们将创建一个新的测试类
UserServiceTest并在这个类中设置一切。由于它使用 Testcontainers,我们将使用@Testcontainers注解这个类:@SpringBootTest @Testcontainers class UserServiceTest -
接下来,我们将创建 MongoDB 集群。它将由三个在同一网络中部署的 MongoDB 容器组成:
- 声明一个
Network静态字段。这个类是 Testcontainers 库的一部分,它允许我们定义一个 Docker 网络:
static Network mongoDbNetwork = Network.newNetwork();-
现在,创建三个具有以下属性的静态
GenericContainer字段:-
每个字段将使用最新的
mongoDocker 镜像。 -
每个字段将具有相同的网络。
-
这三个容器将公开端口
27017。 -
每个容器将具有不同的网络别名:
mongo1、mongo2和mongo3。 -
三个容器将以
mongod命令启动,该命令初始化 MongoDB 集群,唯一的区别是绑定 IP 主机名。每个容器将使用其网络别名。
-
-
这里,我们有第一个字段,
mongoDBContainer1:
static GenericContainer<?> mongoDBContainer1 = new GenericContainer<>("mongo:latest") .withNetwork(mongoDbNetwork) .withCommand("mongod", "--replSet", "rs0", "--port", "27017", "--bind_ip", "localhost,mongo1") .withNetworkAliases("mongo1") .withExposedPorts(27017);- 其他字段,
mongoDBContainer2和mongoDBContainer3,与mongoDBContainer1声明相同,但我们必须将mongo1分别更改为mongo2和mongo3。
- 声明一个
-
现在已经声明了三个 MongoDB 容器,下一步是启动容器并初始化 MongoDB 副本集。我们需要在服务器上执行以下 MongoDB 命令:
rs.initiate({ _id: "rs0", members: [ {_id: 0, host: "mongo1"}, {_id: 1, host: "mongo2"}, {_id: 2, host: "mongo3"} ]})我创建了一个名为
buildMongoEvalCommand的实用方法,用于格式化命令,以便它们可以在 MongoDB 中执行。我们将在任何测试执行之前执行 MongoDB 副本集初始化。为此,我们将使用@BeforeAll注解:String initCluster = """ rs.initiate({ _id: "rs0", members: [ {_id: 0, host: "mongo1"}, {_id: 1, host: "mongo2"}, {_id: 2, host: "mongo3"} ] }) """; mongoDBContainer1.start(); mongoDBContainer2.dependsOn(mongoDBContainer1).start(); mongoDBContainer3.dependsOn(mongoDBContainer2).start(); mongodb address in the application using the @DynamicPropertySource annotation:@DynamicPropertySource
static void setMongoDbProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.mongodb.uri", () -> {
String mongoUri = "mongodb://" + mongoDBContainer1.getHost() + ":" + mongoDBContainer1.getMappedPort(27017) + "/?directConnect=true";
return mongoUri;
});
UserService 类的 buyCards 方法:
@Test void buyCards() { User user = new User(); user.setUsername("Sample user"); User createdUser = userService.createUser(user); Integer buyTokens = 10; userService.buyTokens(createdUser.getId(), buyTokens); Integer requestedCards = 1; Integer cardCount = userService.buyCards(user.getId(), requestedCards); assertThat(cardCount, is(requestedCards)); // do more assert }为了清晰起见,一些代码片段已被简化或省略。您可以在本书的 GitHub 仓库中找到更多详细信息:
github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。
它是如何工作的...
可在 Testcontainers 项目中作为模块使用的MongoDBContainer容器仅作为单服务器部署工作。因此,我们不是使用MongoDBContainer,而是使用了GenericContainer。之后,我们适应了 Testcontainers,以便我们可以设置连接你的应用程序到 MongoDB食谱中准备就绪部分中解释的脚本。为此,我们做了以下操作:
-
创建了一个 Docker 网络。
-
在容器中部署了至少三个 MongoDB 服务器。
-
初始化了 MongoDB 副本集。副本集是一组 Mongo 进程,它们协同工作以维护相同的数据集。我们可以将其视为一个集群。
如您可能已经注意到的,我们在连接到 MongoDB 集群时使用了directConnection设置。此设置意味着我们直接连接到集群中的一个特定节点。当连接到副本集时,通常,连接字符串指定所有集群节点,客户端连接到最合适的节点。我们使用directConnection的原因是节点可以使用网络别名相互发现。毕竟,它们在同一个网络中,可以使用 DNS 名称。然而,我们开发的应用程序运行在我们的开发计算机上,该计算机托管容器,但它位于不同的网络中,无法通过名称找到节点。如果我们处于同一个网络中,MongoDB 连接字符串将如下所示:
mongodb://mongo1:27017,mongo2:27017,mongo3:27017/football?replicaSet=rs0
在这种情况下,客户端将连接到适当的节点。要执行事务,必须连接到主服务器。我们开发的应用程序在执行这些事务时可能会失败,因为它没有连接到主服务器。
注意
buildMongoEvalCommand 方法已从 Testcontainer 项目的原始 MongoDBContainer 容器中改编而来。您可以在 github.com/testcontainers/testcontainers-java/blob/main/modules/mongodb/src/main/java/org/testcontainers/containers/MongoDBContainer.java 找到原始代码。
使用 MongoDB 管理并发
在这个菜谱中,我们将实现一个在用户之间交换玩家卡片的功能。有些卡片更难获得,这导致它们有更高的需求。因此,虽然许多用户试图找到它们,但只有一个人可能得到它。这是一个高并发的场景。
用户可以使用一定数量的代币交换或购买另一个用户的卡片。我们将实施的过程包括以下步骤:
-
首先,我们需要检查买家是否有他们承诺的代币。
-
然后,我们将从买家那里减去代币数量,并添加给卖家。
-
最后,我们将更改卡片的所有者。
MongoDB 通过文档的版本控制系统支持乐观并发控制。每个文档都有一个版本号(通常称为 修订 或 版本 字段),每当文档被修改时,该版本号都会递增。当多个客户端同时尝试更新同一文档时,使用版本号来检测冲突,如果存在冲突,则拒绝更改。
随着我们需要控制用户没有在其他事物上花费代币以及卡片没有被与其他用户交换,我们将为 cards 和 users 添加版本支持。
准备就绪
我们将使用在 将您的应用程序连接到 MongoDB 菜谱中使用的相同工具 - 那就是 Docker 和 MongoDB。
我们将重用 在 Testcontainers 中部署 MongoDB 集群 菜谱中的代码。如果您还没有完成它,不要担心 - 我已经在这个书的 GitHub 仓库中准备了一个工作版本,在 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。您可以在 chapter6/recipe6-4/start 文件夹中找到它。
如何做到这一点...
让我们为我们的 Card 和 User 文档添加版本控制支持,并实现一个具有乐观并发控制的卡片交换事务:
-
首先,我们将修改涉及我们功能的类,以便它们支持乐观并发。我们将通过添加一个带有
@Version注解的新字段来实现这一点:- 通过添加一个名为
version的新Long字段来修改User类:
@Version private Long version;- 并且将相同的
version字段添加到Card类中。
- 通过添加一个名为
-
接下来,我们将创建一个名为
TradingService的新服务:@Service public class TradingService { private CardRepository cardRepository; private UserRepository userRepository; public TradingService(CardRepository cardRepository, UserRepository userRepository) { this.cardRepository = cardRepository; this.userRepository = userRepository; } }在这里,
CardRepository和UserRepository被添加到构造函数中,因为我们将在实现卡片交换业务逻辑时需要它们。 -
现在,我们将创建两个方法来实现业务逻辑。一个将使用
@Transactional注解来控制所有更改的原子性,另一个用于控制并发异常:-
业务逻辑方法应该看起来如下:
@Transactional private Card exchangeCardInternal(String cardId, String newOwnerId, Integer price) { Card card = cardRepository.findById(cardId).orElseThrow(); User newOwner = userRepository.findById(newOwnerId).orElseThrow(); if (newOwner.getTokens() < price) { throw new RuntimeException("Not enough tokens"); } newOwner.setTokens(newOwner.getTokens() - price); User oldOwner = card.getOwner(); oldOwner.setTokens(oldOwner.getTokens() + price); card.setOwner(newOwner); card = cardRepository.save(card); userRepository.saveAll(List.of(newOwner, oldOwner)); return card; } -
控制并发的函数应该看起来像这样:
public boolean exchangeCard(String cardId, String newOwnerId, Integer price) { try{ exchangeCardInternal(cardId, newOwnerId, price); return true; } catch (OptimisticLockingFailureException e) { return false; } }
通过这个机制,我们可以控制对我们的文档执行的并发操作。现在,您可以实现一个 RESTful API,该 API 将使用这个业务逻辑。我在本书的 GitHub 仓库中准备了一个工作示例,网址为
github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。它可以在chapter6/recipe6-6/end中找到。 -
作用原理...
通过添加 @Version 注解,保存操作不仅检查 id 值是否相同,还检查注解了 version 的字段。生成的查询看起来像这样:
Query query = new
Query(Criteria.where("id").is(id).and("version").is(version));
Update update = new Update().set("tokens", value).inc("version", 1);
mongoTemplate.updateFirst(query, update, User.class);
如果这个操作失败,它将抛出 OptimisticLockingFailureException 异常。
根据业务需求,我们可能需要重试操作或直接放弃它们,就像我们在场景中做的那样。如果用户已经卖出了您想要的卡片,您应该寻找另一张。
由于我们需要修改三个不同的文档,我们使用了事务。我们使用 @Transactional 注解进行声明式事务管理。如果我们想回滚该事务中已执行的改变,我们需要抛出异常。这就是为什么我们在 exchangeCardInternal 方法中让 Spring Data MongoDB 抛出 OptimisticLockingFailureException 并在 exchangeCard 中捕获它的原因。
将您的应用程序连接到 Apache Cassandra
在这个菜谱中,我们希望创建一个系统,允许用户发布与比赛、球员或比赛事件相关的评论。我们决定使用 Apache Cassandra,因为它具有高可扩展性和低延迟能力。
在这个菜谱中,我们将学习如何使用 Spring Data for Apache Cassandra 存储库将我们的 Spring Boot 应用程序连接到 Apache Cassandra 服务器。
准备工作
对于这个菜谱,我们将使用 Apache Cassandra 数据库。在您的电脑上部署 Apache Cassandra 最简单的方法是使用 Docker 容器。您可以通过执行以下 docker 命令来完成此任务:
docker run -p 9042:9042 --name cassandra -d cassandra:latest
此命令将下载最新的 Apache Cassandra Docker 镜像,如果您电脑上还没有,并且将启动一个监听端口 9042 的 Cassandra 服务器。
在启动服务器后,您需要在容器内创建一个 cqlsh 脚本:
docker exec -it cassandra cqlsh -e "CREATE KEYSPACE footballKeyspace WITH replication = {'class': 'SimpleStrategy'};"
在创建 Keyspace 之前,您可能需要等待几秒钟,以便 Cassandra 服务器完成初始化。
如何操作...
让我们创建一个支持 Apache Cassandra 的项目。我们将使用已经熟悉的 Spring Data 的 Repository 概念来连接到 Apache Cassandra:
-
首先,我们将使用Spring Initializr工具创建一个新的 Spring Boot 项目。像往常一样,打开
start.spring.io。我们将使用与第一章中创建 RESTful API食谱中相同的参数,除了我们将使用以下参数:-
对于
footballcdb -
对于依赖项,选择Spring Web和Spring Data for Apache Cassandra
-
-
接下来,我们将创建一个名为
Comment的类。这代表了我们新功能的数据。如果字段是主键的一部分,我们需要用
@Table注解类,并用@PrimaryKeyColumn注解字段。如果我们想将字段映射到 Cassandra 的不同列名,可以使用@Column:@Table public class Comment { @PrimaryKeyColumn(name = "comment_id", ordinal = 0, type = PrimaryKeyType.PARTITIONED, ordering = Ordering.DESCENDING) private String commentId; private String userId; private String targetType; private String targetId; private String content; private LocalDateTime date; public Set<String> labels = new HashSet<>(); Comment table will include the comment content, the date, and the user posting the comment. It will also include information about the target of the comment – that is, a player, a match, or any other component we may have in our football application. -
我们需要为
Comment创建一个新的Repository,它继承自CassandraRepository:public interface CommentRepository extends CassandraRepository<Comment, String>{ }与 Spring Data 的
Repository一样,它提供了一些方法来操作Comment实体,例如findById、findAll、save和其他方法。 -
由于我们将在显示其他实体(如比赛或球员)时检索评论,我们需要在
CommentRepository中创建一个方法来通过目标类型和目标本身获取评论:@AllowFiltering List<Comment> findByTargetTypeAndTargetId(String targetType, String targetId);注意,与其他 Spring Data 中的仓库一样,它可以通过方法名推断查询来实现接口。
重要的一点是,我们需要用
@AllowFiltering注解来注解方法,因为我们不是通过主键检索数据。 -
我们现在可以使用
CommentRepository创建一个服务来实现我们的应用程序需求。我们将命名该服务为CommentService并确保它包含以下内容:@Service public class CommentService { private CommentRepository commentRepository; public CommentService(CommentRepository commentRepository){ this.commentRepository = commentRepository; } } -
现在,我们必须创建功能。我们将创建一个创建评论的方法和几个检索所有评论的方法:
-
我们将使用一个记录来接收评论数据:
public record CommentPost(String userId, String targetType, String targetId, String commentContent, Set<String> labels) { } -
让我们定义
postComment方法,以便我们可以创建一个新的评论:public Comment postComment(CommentPost commentPost) { Comment comment = new Comment(); comment.setCommentId(UUID.randomUUID().toString()); comment.setUserId(commentPost.userId()); comment.setTargetType(commentPost.targetType()); comment.setTargetId(commentPost.targetId()); comment.setContent(commentPost.commentContent()); comment.setDate(LocalDateTime.now()); comment.setLabels(commentPost.labels()); return commentRepository.save(comment); } -
现在,我们可以创建一个方法来检索所有评论:
public List<Comment> getComments() { return commentRepository.findAll(); } -
我们可以检索所有评论,但检索与另一个实体相关的评论更有意义。例如,获取关于球员的评论更为常见:
public List<Comment> getComments(String targetType, String targetId) { return commentRepository.findByTargetTypeAndTargetId( targetType, targetId); }
-
-
最后,我们需要配置应用程序,使其能够连接到我们的 Cassandra 服务器。在这个食谱的准备就绪部分,我提供了使用 Docker 部署它的说明,包括如何创建 Keyspace。要配置应用程序,请在
resources文件夹中创建一个application.yml文件。添加以下内容:spring: cassandra: keyspace-name: footballKeyspace schema-action: CREATE_IF_NOT_EXISTS contact-points: localhost local-datacenter: datacenter1 port: 9042 -
现在我们有了提供评论功能所需的组件。我们创建了
CassandraRepository并连接到了 Cassandra 服务器。我在这本书的 GitHub 仓库中创建了一个 RESTful API 来消费这个服务。您可以在github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/找到它,位于chapter6/recipe6-7/end。
它是如何工作的...
正如我们在其他 Spring Data 项目中看到的那样,当您创建一个从 CassandraRepository 扩展的接口时,Spring Data for Apache Cassandra 会生成一个实现,并将该实现注册为一个 bean 以使其对其他组件可用。
它可以使用命名约定和 @Query 注解来生成实现。两种方式都使用 Cassandra 模板生成实现,这将在下一个菜谱中详细介绍。
我们还没有介绍 CQL,这是一种与 SQL 语法相似的编程语言,但与 Cassandra 作为 NoSQL 技术的重要区别。例如,它不支持 JOIN 查询。
注意,在 findByTargetTypeAndTargetId 方法中,我们使用了 @AllowFiltering。Cassandra 是一个为高可用性和可伸缩性而设计的 NoSQL 数据库,但它通过限制它可以高效处理的查询类型来实现这些功能。Cassandra 优化了基于主键或聚类列快速检索数据。当您在 Cassandra 中查询数据时,预期您至少提供主键组件以有效地定位数据。
然而,在某些情况下,您可能需要执行在非主键列上过滤数据的查询。这类查询在 Cassandra 中效率不高,因为它们可能需要全表扫描,并且在大型数据集上可能非常慢。您可以使用 @AllowFiltering 注解明确告诉 Spring Data for Apache Cassandra 您了解性能影响,并且尽管其潜在的低效性,您仍想执行此类查询。
参考信息
如果您计划与 Cassandra 一起工作,建议您熟悉 CQL。您可以在项目页面上找到更多关于它的信息:cassandra.apache.org/doc/stable/cassandra/cql/。
使用 Testcontainers 与 Cassandra
为了确保我们应用程序的可靠性,我们需要在 Cassandra 项目上运行集成测试。类似于 MongoDB,我们有两种在 Cassandra 上运行测试的选项——要么使用内存中的嵌入式 Cassandra 服务器,要么使用 Testcontainers。然而,我推荐使用带有 Cassandra 服务器的 Testcontainers,因为它使用真实的 Cassandra 实例,从而消除了任何潜在的不兼容性问题。
在这个菜谱中,我们将学习如何使用 Testcontainers Cassandra 模块为我们的 Comments 服务创建集成测试。
准备工作
在这个菜谱中,我们将为我们在 连接您的应用程序到 Apache Cassandra 菜谱中创建的 Comments 服务创建一个集成测试。如果您还没有完成这个菜谱,您可以使用我准备的项目。您可以在本书的 GitHub 仓库中找到它,位于 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/ 的 chapter6/recipe6-8/start 文件夹中。
如何操作...
你准备好将你的应用程序提升到下一个层次了吗?让我们开始准备它,以便它可以运行 Testcontainers 并看看我们如何改进它!
-
我们将首先将 Testcontainers 依赖项添加到我们的
pom.xml文件中——即通用的 Testcontainers 依赖项和 Cassandra Testcontainers 模块:<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>cassandra</artifactId> <scope>test</scope> </dependency> -
接下来,在测试的
resources文件夹中创建一个名为createKeyspace.cql的文件。此文件应包含 Cassandra Keyspace 创建命令:CREATE KEYSPACE footballKeyspace WITH replication = {'class': 'SimpleStrategy'}; -
现在,我们可以为我们的
CommentService创建一个测试类。你可以将测试类命名为CommentServiceTest。在我们开始创建测试之前,我们需要设置 Testcontainer。为此,请执行以下操作:- 使用
@Testcontainers注解测试类:
@Testcontainers @SpringBootTest class CommentServiceTest-
声明一个静态的
CassandraContainer字段:-
在这里,我们将指定 Cassandra Docker 镜像。我们将使用默认的
cassandra镜像。 -
我们必须在容器初始化过程中应用要执行的 Cassandra 脚本——即
createKeyspace.cql,我们在 连接你的应用程序到 Apache Cassandra 菜单的 准备就绪 部分中定义了它。 -
我们还必须公开 Cassandra 监听连接的端口——即端口
9042:
-
static CassandraContainer cassandraContainer = (CassandraContainer) new CassandraContainer("cassandra") .withInitScript("createKeyspace.cql") .withExposedPorts(@BeforeAll annotation for that purpose: - 使用
@BeforeAll
static void startContainer() throws IOException, InterruptedException {
cassandraContainer.start();
}
- 最后一个 Testcontainers 配置涉及在应用程序上下文中设置 Cassandra 连接设置。为此,我们将使用
@DynamicPropertySource以及之前声明的cassandraContainer字段提供的属性:
@DynamicPropertySource
static void setCassandraProperties(DynamicPropertyRegistry registry) {
registry.add("spring.cassandra.keyspace-name", () -> "footballKeyspace");
registry.add("spring.cassandra.contact-points", () -> cassandraContainer.getContactPoint().getAddress());
registry.add("spring.cassandra.port", () -> cassandraContainer.getMappedPort(9042));
registry.add("spring.cassandra.local-datacenter", () -> cassandraContainer.getLocalDatacenter());
}
-
现在,我们可以创建我们的集成测试。让我们将其命名为
postCommentTest:@Autowired CommentService commentService; @Test void postCommentTest() { CommentPost comment = new CommentPost("user1", "player", "1", "The best!", Set.of("label1", "label2")); Comment result = commentService.postComment(comment); assertNotNull(result); assertNotNull(result.getCommentId()); }
它是如何工作的...
org.testcontainers:cassandra 依赖项包含 CassandraContainer 类,该类提供了设置集成测试 Testcontainer 所需的大部分功能。它允许我们指定我们想要使用的 Docker 镜像。
在这里,withInitScript 通过从测试的类路径中获取文件来在 Cassandra 中执行 CQL 脚本。这简化了执行,因为不需要考虑文件复制和客户端工具的可用性。我们使用了这个功能来创建 Keyspace,就像我们在 连接你的应用程序到 Apache Cassandra 菜单的 准备就绪 部分中所做的那样。
我们不需要手动检查容器服务是否准备好接受连接。Testcontainers 会自动等待服务准备好以启动测试。
最后,我们使用了 CassandraContainer 类公开的属性来配置连接。我们使用 getContactPoint 方法获取服务器主机地址,使用 getPort 方法获取容器公开的端口,以及使用 getLocalDatacenter 方法获取模拟的数据中心名称。
使用 Apache Cassandra 模板
我们可能希望以比 CassandraRepository 提供的更灵活的方式访问 Cassandra 中托管的数据。例如,我们可能希望使用动态或复杂的查询从我们的评论系统中检索数据,批量执行操作,或访问低级功能。在这些情况下,使用 Cassandra 模板更方便,因为它提供了更多对 Cassandra 功能的低级访问。
在这个菜谱中,我们将实现一个功能,该功能将使用不同的参数动态搜索评论,例如日期范围、标签等。为此,我们将使用 Cassandra 模板。
准备工作
我们将使用与 将你的应用程序连接到 Apache Cassandra 菜谱中相同的工具 – 那就是 Docker 和 Apache Cassandra。
要完成这个菜谱,你需要为 使用 Testcontainers 与 Cassandra 菜谱创建的项目。如果你还没有完成那个菜谱,不要担心 – 你可以使用我在本书的 GitHub 仓库中准备的全版本项目,该仓库位于 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。它可以在 chapter6/recipe6-9/start 文件夹中找到。
如何操作...
在这个菜谱中,我们将增强我们在上一个菜谱中创建的评论服务,添加新的搜索功能,以便用户可以使用他们想要的任何参数:
-
首先,我们需要将
CassandraTemplate注入到我们的CommentService类中。为此,修改构造函数,使其让 Spring 依赖注入容器注入CassandraTemplate:@Service public class CommentService { private CommentRepository commentRepository; private CassandraTemplate cassandraTemplate; public CommentService(CommentRepository commentRepository, CassandraTemplate cassandraTemplate) { this.commentRepository = commentRepository; this.cassandraTemplate = cassandraTemplate; } } -
现在,为
getComments方法添加一个新的重载:public List<Comment> getComments(String targetType, String targetId, Optional<String> userId, Optional<LocalDateTime> start, Optional<LocalDateTime> end, Optional<Set<String>> labels)此方法有两种类型的参数:必填和可选。
我们假设用户将始终检索与目标实体关联的评论 – 例如,一个玩家或一场比赛。因此,
targetType和targetId参数是必填的。其余的参数是可选的;因此,它们被定义为
Optional<T>。 -
在这个新方法中,我们将使用
QueryBuilder组件来创建我们的查询:Select select = QueryBuilder.selectFrom("comment").all() .whereColumn("targetType") .isEqualTo(QueryBuilder.literal(targetType)) .whereColumn("targetId") .isEqualTo(QueryBuilder.literal(targetId));这里,我们使用
selectFrom选择了comment表,并使用whereColumn设置了必填列targetType和targetId。其余的可选字段将使用
whereColumn,但仅当它们提供时:if (userId.isPresent()) { select = select.whereColumn("userId") .isEqualTo(QueryBuilder.literal(userId.get())); } if (start.isPresent()) { select = select.whereColumn("date") .isGreaterThan(QueryBuilder .literal(start.get().toString())); } if (end.isPresent()) { select = select.whereColumn("date") .isLessThan(QueryBuilder .literal(end.get().toString())); } if (labels.isPresent()) { for (String label : labels.get()) { select = select.whereColumn("labels") .contains(QueryBuilder.literal(label)); } } -
最后,我们可以通过使用
select方法来使用CassandraTemplate的查询。让我们来做吧:return cassandraTemplate.select(select.allowFiltering().build(), Comment.class);这里,我们使用了
allowFiltering。由于我们不是使用主键,我们需要告诉 Cassandra 我们假设查询可能效率不高。 -
我们为我们的评论服务实现了新功能,使用
CassandraTemplate执行动态查询。现在,你可以创建一个 RESTful API 接口来与这个新功能交互。我已经创建了一个使用新功能的示例 RESTful API,并为评论服务准备了集成测试。你可以在本书的 GitHub 仓库中找到这些测试,位于github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/的chapter6/recipe6-9/end文件夹中。
它是如何工作的...
Spring Data for Apache Cassandra 在 Spring Boot 依赖注入容器中注册了一个 CassandraTemplate bean。它用于内部实现 将你的应用程序连接到 Apache Cassandra 菜谱中描述的存储库。通过这样做,它可以被 Spring Boot 注入到我们的组件中。
您可以通过连接谓词来组合 CQL 字符串,但这容易在查询中引入错误。这就是为什么我们使用了QueryBuilder。正如我在将您的应用程序连接到 Apache Cassandra配方中解释的那样,当我们进行不使用表主键的查询时,我们需要设置allowFiltering。
还有更多...
我们可以通过构建一个动态 CQL 语句的字符串来执行相同的查询。这看起来会是这样:
public List<Comment> getCommentsString(String targetType,
String targetId,
Optional<String> userId,
Optional<LocalDateTime> start,
Optional<LocalDateTime> end,
Optional<Set<String>> labels) {
String query = "SELECT * FROM comment WHERE targetType ='"
+ targetType + "' AND targetId='" + targetId + "'";
if (userId.isPresent()) {
query += " AND userId='" + userId.get() + "'";
}
if (start.isPresent()) {
query += " AND date > '" + start.get().toString() + "'";
}
if (end.isPresent()) {
query += " AND date < '" + end.get().toString() + "'";
}
if (labels.isPresent()) {
for (String label : labels.get()) {
query += " AND labels CONTAINS '" + label + "'";
}
}
query += " ALLOW FILTERING";
return cassandraTemplate.select(query, Comment.class);
}
使用 Apache Cassandra 管理并发
我们希望通过添加一个新功能来增强我们的评论系统:对评论进行点赞。我们将在我们的评论中添加一个计数器,以显示收到的正面投票。
这个简单的需求在高并发场景中可能会变得复杂。如果有多个用户正在对一个评论进行点赞,可能会发生我们没有更新评论最新版本的情况。为了应对这种场景,我们将使用 Cassandra 的乐观并发方法。
准备工作
我们将使用在将您的应用程序连接到 Apache Cassandra配方中使用的相同工具——即 Docker 和 Apache Cassandra。
起始点将是我们在使用 Apache Cassandra 模板配方中创建的项目。如果您还没有完成,不要担心——您可以使用我在本书 GitHub 仓库中准备的全版本项目,该仓库位于github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。它可以在chapter6/recipe6-10/start文件夹中找到。
如何实现...
在这个配方中,我们将使用乐观并发实现点赞功能。但在那之前,我们需要准备我们的评论实体。让我们开始吧:
-
我们首先需要做的是创建一个新字段,该字段将存储评论收到的点赞数。所以,让我们通过添加一个名为
upvotes的新字段来修改Comment类:private Integer upvotes; -
我们需要修改 Cassandra 服务器中的表模式。为此,我们需要连接到服务器并执行一个
cqlsh命令。最简单的方法是通过连接到 Docker 容器。以下命令将在cqlsh中打开一个交互式会话docker exec -it cassandra cqlsh USE footballKeyspace; ALTER TABLE Comment ADD upvotes int;现在,您可以通过执行
quit;命令退出cqlsh。在 Cassandra 中无法分配默认值。如果您数据库中已有评论,Cassandra 将为
upvotes字段返回一个null值。因此,我们需要相应地管理这种情况。 -
现在,是时候在新的操作中使用新字段了。我们将通过创建一个名为
upvoteComment的新方法在我们的CommentService服务中实现这个操作:public Comment upvoteComment(String commentId) {接下来,我们将检索第一条评论。我们可以使用现有的
CommentRepository或CassandraTemplate。我们将使用CommentRepository,因为它更简单:Comment comment = commentRepository.findByCommentId(commentId).get();现在,我们需要更新点赞字段,但我们将保持当前值:
Integer currentVotes = comment.getUpvotes(); if (currentVotes == null) { comment.setUpvotes(1); } else { comment.setUpvotes(currentVotes + 1); }接下来,我们将使用当前值来创建条件。只有当我们更新当前值时,我们才会应用更改:
CriteriaDefinition ifCriteria = Criteria .where(ColumnName.from("upvotes")) .is(currentVotes); EntityWriteResult<Comment> result = cassandraTemplate .update(comment, UpdateOptions.builder() .ifCondition(ifCriteria) .build());现在,我们需要检查结果是否是我们所期望的:
if (result.wasApplied()) { return result.getEntity(); }如果结果不是我们所期望的,我们可以在执行之间等待几毫秒的情况下重试操作几次,但这将取决于应用程序的要求。
现在,您可以为此新功能实现一个 RESTful API。我已经在本书的 GitHub 仓库中准备了一个示例 RESTful API 和集成测试,该仓库位于github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/,在chapter6/recipe6-10/end文件夹中。
它是如何工作的...
在 Cassandra 中,乐观并发管理的关键是条件更新命令。在 CQL 中,Cassandra 提供了一个IF子句,我们可以在CassandraTemplate中使用它。使用这个IF子句,您可以在满足某些条件的情况下有条件地更新数据,这些条件包括检查数据的当前状态。
我们可以在评论表中创建一个version字段来实现一个机制,就像我们在使用 MongoDB 管理并发配方中看到的那样。然而,Spring Data for Apache Cassandra 没有提供任何特殊的能力来自动管理这一点,因此我们需要自己实现它。此外,我们预计comment实体不会有任何其他变化,因此我们可以使用点赞来控制行是否已被修改。upvotes字段是我们的version字段。
第三部分:应用程序优化
在大规模应用程序中,了解瓶颈在哪里以及如何改进它们是必要的。在本部分中,我们将遵循一种系统性的方法来优化和衡量我们应用的改进。我们还将使用诸如响应式编程和事件驱动设计等高级技术。
本部分包含以下章节:
-
第七章,寻找瓶颈和优化您的应用程序
-
第八章,Spring Reactive 和 Spring Cloud Stream
第七章:寻找瓶颈并优化你的应用程序
如果你不遵循系统化的方法,找到使你的应用程序表现低于预期的原因可能会很困难。在优化应用程序时,重要的是将你的努力集中在事实而不是猜测上。因此,在本章中,我们将利用第3 章中的工具和经验,通过分析应用变更的足迹来解决一些常见挑战。
在本章中,你将学习如何使用可观察性工具来找到你应用程序的瓶颈,并应用一些常见的应用程序优化技术,如缓存和运行时调整。你还将学习如何通过使用自 Spring Boot 3 发布以来一直支持的本地应用程序来提高你的应用程序的启动时间和资源消耗。
我们将运行一些负载测试,以对我们的应用程序施加压力,并学习如何分析结果。
在本章中,我们将介绍以下食谱:
-
调整数据库连接池
-
缓存依赖项
-
使用共享缓存
-
使用 Testcontainers 与 Redis 缓存一起使用
-
使用 Spring Boot 创建原生镜像
-
使用 GraalVM 跟踪代理配置本地应用程序
-
使用 Spring Boot 创建原生可执行文件
-
从 JAR 文件创建原生可执行文件
技术要求
我创建了一个应用程序,我们将在本章中对其进行优化。这个应用程序提供了一些 RESTful API 来管理足球数据。该应用程序使用 PostgreSQL 作为数据存储库。你可以在 https://github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/上找到它,在chapter7/football文件夹中。这个应用程序已经配置了可观察性,通过 Actuator 暴露了 Prometheus 端点。要监控应用程序,你可以使用 Prometheus 和 Grafana。
Prometheus 配置
你需要配置 Prometheus,如3 章中“将你的应用程序与 Prometheus 和 Grafana 集成”食谱中所述。我已经准备好了prometheus.yml文件。你需要获取你计算机的 IP 地址并将其设置在prometheus.yml文件中。
我创建了一个 Grafana 仪表板来监控应用程序的性能。为了制作它,我使用了以下仪表板作为起点,并对其进行了调整以适应我们的目的:grafana.com/grafana/dashboards/12900-springboot-apm-dashboard/。
除了 PostgreSQL、Prometheus 和 Grafana 之外,我们还将使用 Redis 来处理一些食谱。像往常一样,在计算机上运行所有这些服务的最简单方法是使用 Docker。你可以在产品页面:www.docker.com/products/docker-desktop/上获取 Docker。我将在相应的食谱中解释如何部署每个工具。
您可能需要一个工具来在 PostgreSQL 中执行 SQL 脚本。您可以使用psql命令行工具或更用户友好的PgAdmin工具。您可以在第五章中查看将应用程序连接到 PostgreSQL的菜谱以获取更多详细信息。
我准备了一些 JMeter 测试来在应用程序上生成一些负载。您可以从项目网站jmeter.apache.org下载 JMeter。
对于一些与原生应用程序相关的菜谱,您将需要GraalVM JDK。您可以根据官方网站www.graalvm.org/downloads/上的说明进行安装。
本章将要演示的所有菜谱都可以在以下位置找到:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/tree/main/chapter7
调整数据库连接池
数据库连接是一种昂贵的资源,当它们第一次创建时可能需要一些时间。因此,Spring Boot 使用一种称为连接池的技术。当使用连接池时,应用程序不会直接与数据库建立连接;相反,它向连接池请求一个可用的连接。当应用程序不需要连接时,它会将其返回到池中。连接池通常在应用程序启动时创建一些连接。当连接返回到池中时,它们不会被关闭,而是由应用程序的其他部分重用。
在操作应用程序时,一个常见的挑战是确定连接池的大小。如果大小太小,在一定的负载下,一些请求会因为等待连接池中的连接变得可用而花费更长的时间。如果连接池太大,它将在数据库服务器上浪费资源,因为打开的连接是昂贵的。
在这个菜谱中,我们将学习如何使用标准指标和监控工具在 Spring Boot 应用程序中监控数据库连接池。我们将使用在第三章中学到的技术和工具。
准备工作
在这个菜谱中,您将优化我已经为此目的准备的应用程序。您可以在书籍的 GitHub 仓库github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/中找到该应用程序,在chapter7/football文件夹中。我建议将文件夹的内容复制到您的当前工作目录,因为我们将对每个菜谱在基础项目上应用不同的优化。
应用程序使用 PostgreSQL 作为数据库引擎,并配置了使用 Zipkin、Prometheus 和 Grafana 进行监控。你可以在 Docker 中运行所有这些依赖服务;为此,我在chapter7/docker文件夹中准备了一个docker-compose-base.yml文件。你可以通过在包含文件的目录中打开终端并执行以下命令来运行此docker-compose-base.yml文件:
docker-compose -f docker-compose-base.yml up
Prometheus 服务有一个名为prometheus.yml的配置文件,其中包含应用程序抓取配置。它指向我的电脑 IP,但你需要将其更改为你的 IP 配置。你应该配置 Prometheus 数据源和SpringBoot APM 仪表板。有关更多详细信息,请参阅第三章中的将应用程序与 Prometheus 和 Grafana 集成配方。
我已经准备了一个 JMeter 测试来在应用程序上生成工作负载。你可以在chapter7/jmeter/Football.jmx中找到它。此测试模拟了示例足球交易应用程序的常见用例。测试执行以下步骤:
-
一个用户购买了一些卡片。
-
另一位用户购买了一些卡片。
-
两位用户都试图在他们自己的专辑中使用这些卡片。
-
然后,第一位用户从第二位用户那里获得了所有可用的卡片,反之亦然,第二位用户从第一位用户那里获得了所有可用的卡片。
-
两位用户检查来自另一位用户的卡片上的球员。
-
他们之间交换他们可用的卡片。
测试有 10 个线程同时运行,请求之间没有思考时间。
如何做到这一点...
我们将启动应用程序,并确保我们在 Grafana 中看到应用程序指标。准备好寻找应用程序瓶颈并优化它了吗?让我们行动起来吧!
-
首先,我们将启动应用程序,并检查我们是否在 Grafana 中看到应用程序指标。我将假设你已经按照准备就绪部分中解释的那样启动了所有依赖服务:
-
在
http://localhost:3000打开 Grafana,然后打开 SpringBoot APM 仪表板。 -
确保你可以在基本静态和HikariCP 静态部分看到数据。
-
-
启动 JMeter 应用程序并打开
football.jmx文件,该文件位于chapter7/jmeter文件夹中。 -
执行 JMeter 测试,等待其完成。测试执行可能需要几分钟才能完成:
-
在测试执行过程中,检查 Grafana 中HikariCP 统计部分的连接指标。
-
你会看到存在挂起的连接:
-

图 7.1:Hikari 连接指标
你还可以看到连接获取时间值始终超过 4 毫秒。

图 7.2:连接获取时间
- 你可以通过打开摘要 报告项来查看结果摘要。

图 7.3:摘要报告
您也可以在测试运行时查看它们,但基线将在完成后确定。

图 7.4:总结报告结果 – 基线结果
在我的环境中,总吞吐量为 987.5 每秒请求(RPS),最常用的请求是 get-user-player,总共有 145,142 个请求,吞吐量为 798 RPS。请注意,get-user-player 操作的平均时间为 6 毫秒。请将您电脑上执行此测试的结果保存下来,因为我们在优化后将会比较它们。
-
现在,我们将通过增加数据库连接的最大数量来更改 HikariCP 设置。为此,打开
resources文件夹中的application.yml文件,并将spring.datasource.hikari.maximum-pool-size设置增加至10。 -
让我们重复相同的性能测试并看看差异。但在那之前,让我们清理数据以在相同的条件下执行测试:
-
我准备了一个名为
cleanup.sql的脚本,您可以通过运行它来清理数据库。您可以在chapter7/dbscripts文件夹中找到它。 -
在 JMeter 中,使用 清除所有 按钮重置结果。
-
-
测试完成后,将结果与基线进行比较。我的电脑上的结果如下:
-
总吞吐量为 1,315 RPS。这比基线 987.5 RPS 大约提高了 33% 的性能。
-
get-user-player 请求的吞吐量为 1,085.3 RPS。这比基线 798 RPS 大约提高了 36% 的性能。
-
get-user-player 操作的平均响应时间为 2 毫秒。在基线中,它是 6 毫秒。这快了三倍。
如果您在 Grafana 中查看 HikariCP 统计信息,您将看到没有挂起的连接,并且连接获取时间已经减少。我的电脑上的连接获取时间指标始终低于 10 微秒。
-
它是如何工作的...
Spring Boot 使用 HikariCP 作为 JDBC 数据源连接池。如果您没有指定任何池大小,默认值为 10。为了学习目的,我在初始示例中将最大连接数配置为四个。在初始负载测试期间,我们在 Grafana 中观察到挂起的连接数在整个测试期间始终保持在零以上。这意味着始终有一个请求正在等待可用的数据库连接。
正如我们在连接获取时间指标中看到的那样,平均来说,获取连接所需的时间为 4 毫秒。这意味着对于每个请求,我们需要为每个涉及的数据库操作添加 4 毫秒。对于像 get-user-player 这样的快速操作,在没有连接可用时,所需时间是两倍。一旦我们增加了连接池的大小,这个操作就提高了其性能,并且在这个场景中是最常用的操作。
其余的操作也受益于这种新的配置,但由于可用连接的请求时间较长,相对性能提升并不高。
在这个配方中,我们专注于数据库连接的数量。但同样的方法可以应用于其他类型的应用程序指标,例如 Tomcat 并发线程的数量。您可以使用应用程序暴露的可观察性数据,并相应地调整您的设置以适应您的负载。
还有更多...
在这个配方中,我们通过增加在某一时刻同时使用的最大连接数来固定连接可用性,即 10 个连接。正如所述,数据库连接是一种昂贵的资源,应该明智地使用。让我们考虑一个具有多个服务实例的场景。为您的应用程序提供的每个额外连接都应该乘以实例的数量。比如说,您有 10 个应用程序实例;那么,任何额外的连接都应该乘以 10。
在基准测试执行期间,我们检测到最多有六个挂起的连接,因此我们将这六个连接添加到最初的四个连接中。如果最大挂起连接数仅在少数几个峰值期间发生,我们可以将最大连接数调整为比检测到的最大值少 1 或 2 个连接。例如,在我们的场景中,我们可以将最大连接数调整为 9,重复负载测试,并观察其影响。
另一个潜在的调整是配置最小和最大连接数。然后,如果有峰值且没有可用连接,HikariCP 将创建一个数据库连接。记住创建数据库连接所需的时间和这个连接将空闲的时间。当定义了最小和最大连接数时,HikariCP 可以在空闲时关闭物理连接。如果峰值太短,您可能会创建一个连接,其创建时间将比等待可用连接更长,然后您将有一个空闲连接在数据库服务器上消耗资源。
缓存依赖
我们想要优化的足球交易应用中最常见的流程如下:有时,用户购买一些卡片,并在他们的专辑中使用后,试图与其他用户交换他们已经拥有的冗余卡片。在开始交换过程之前,用户会查看其他用户可用的球员。可能会有数千甚至数百万张卡片,但足球运动员的总数大约为 700,他们不断从足球交易应用中检索。
现在,您想要优化应用程序的性能。因此,您正在考虑使用缓存机制来避免从数据库检索频繁访问但很少更改的数据。
在这个配方中,您将学习如何识别数据库瓶颈以及如何应用 Spring Boot 提供的缓存机制。您将学习如何使用您在第三章中了解到的可观察性工具来衡量改进。
准备工作
在这个配方中,您将继续优化我为这个目的准备的应用程序。您可以使用来自调整数据库连接池配方版本的版本。您可以在本书的 GitHub 存储库中找到该应用程序,在github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/的chapter7/recipe7-2/start文件夹中。
如我们在前面的配方中解释的那样,您可以通过运行位于chapter7/docker文件夹中的docker-compose-base.yml Docker Compose 文件来在 Docker 中运行所有依赖服务。为此,打开一个终端并执行以下命令:
docker-compose -f docker-compose-base.yml up
我们将使用之前配方中使用的相同的 JMeter 测试。您可以在chapter7/jmeter/football.jmx中找到它。
如何操作…
让我们从执行 JMeter 负载测试以确定性能基线开始。然后,我们将对应用程序的不同部分应用缓存,并测量改进:
- 我们可以使用来自调整数据库连接池配方测试的 JMeter 执行结果。

图 7.5:JMeter 总结报告 – 基线请求吞吐量详情
在我的环境中,总吞吐量为 1,340.3 RPS,最常用的请求是get-user-player,总共有 145,683 个请求,吞吐量为 1,085.3 RPS。请将执行此测试的结果保存在您的计算机上,因为我们在优化后将会比较它们。
-
现在我们有了应用程序基线,我们将启用缓存:
- 首先,将Spring Cache Abstraction启动器添加到
pom.xml文件中:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>- 接下来,在
FootballApplication类中,添加@EnableCaching注解:
@EnableCaching @SpringBootApplication public class FootballApplication { - 首先,将Spring Cache Abstraction启动器添加到
-
接下来,我们将修改
FootballService类的getPlayer方法以缓存响应。这是在@Cacheable中调用的方法如下:@Cacheable(value = "players") public Player getPlayer(Integer id) { return playerRepository.findById(id).map(p -> playerMapper.map(p)).orElse(null); } -
让我们再次执行 JMeter 测试。但在那之前,让我们清理数据以在相同条件下执行测试:
-
我准备了一个名为
cleanup.sql的脚本,您可以通过运行它来清理数据库。您可以在chapter7/dbscripts文件夹中找到它。 -
在 JMeter 中,使用清除所有按钮重置结果。
-
-
一旦测试完成,检查结果并与基线进行比较。我的计算机上的结果如下:

图 7.6:在 FootballService 上应用缓存后的总结报告
-
总吞吐量从 1,340.3 RPS 跃升至 1,806.7 RPS,大约提高了 34%的性能。
-
get-user-player 请求为 1,458.5 RPS,基线为 1,085.3 RPS,这意味着性能也提高了大约 34%。
-
其余的请求也增加了大约 34% 的整体吞吐量。例如,get-user-cards 从 74.5 RPS 上升到 100.1 RPS,其他请求从 37.2 RPS 上升到 50.1 RPS。
-
让我们在应用程序的不同位置使用缓存。不是在
FootballService中应用@Cacheable注解,而是在PlayersController类的getPlayer方法中应用注解:@Cacheable(value = "players") @GetMapping("/{id}") public Player getPlayer(@PathVariable Integer id) { return footballService.getPlayer(id); }
它是如何工作的...
通过添加 Spring Cache Abstraction 启动器并使用 @EnableCaching 注解,Spring Boot 会检查 Beans 中公共方法上是否存在缓存注解,并创建一个代理来拦截方法调用并相应地处理缓存行为;在我们的例子中,是带有 @Cacheable 注解的方法。Spring Boot 注册了一个 CacheManager Bean 来处理缓存项,因为我们没有指定任何特定的 CacheManager。Spring Boot 使用默认实现,一个 ConcurrentHashMap 对象,并在处理过程中进行管理。这种方法适用于不经常变化且数据集较小的元素。否则,你可能想使用外部共享缓存。在下一个菜谱中,我们将处理这种情况。
在这个菜谱中,我们只优化了 get-user-player。它是这个菜谱中所有操作的最佳候选者。原因是修改数据频率较高的操作不适合缓存,所以 buy-cards、use-cards 和 trade-cards 不能被缓存,因为它们修改数据并且经常被使用。唯一只读取数据的操作是 get-user-cards 和 get-user-player。get-user-cards 不是一个好的候选者,因为用户拥有的卡片每次购买、交换或用于专辑时都会改变,这意味着缓存将频繁更新。此外,用户数量很高,大约有 100,000,所以将这些元素添加到应用程序内存中可能是适得其反的。另一方面,get-user-player 只检索球员信息。这些信息变化非常不频繁,而且只有几百名球员。因此,get-user-player 是缓存的最佳候选者。
通过在 FootballService 类中添加缓存,该操作的吞吐量显著提高,但它也使其他操作受益。原因是尽管这是一个快速的数据库请求,但它是最频繁的操作。可用的数据库连接数由 hikaricp 连接池定义;我们配置了 10 个连接。所有操作都应该从 hikaricp 获取连接。由于最频繁的操作减少了,其他操作获取连接的速度更快。
还有更多...
我建议你在运行测试时检查应用在 Grafana 中暴露的指标。在这个场景中,有两个主要区域需要观察:
-
基本统计:在这里,我们可以找到每个应用的经典指标:
-
CPU 使用率:这通常是要求高的计算应用的限制因素。在我的电脑上的测试中,它始终低于 70%。
-
堆内存使用:这是我们的应用使用的堆内存。它可能会限制我们应用的性能。
-
非堆内存使用:这是我们的应用使用的所有其他内存。它通常占应用总内存使用的不到 30%,并且其使用比堆内存更稳定。
-
-
HikariCP 统计:正如我们在前面的菜谱中所见,HikariCP 是 Spring Boot 中的默认数据库连接池。创建到 PostgreSQL 或任何其他数据库引擎的连接都是昂贵的。你可以检查以下与 HikariCP 相关的指标:
-
活跃:这是池外用于在数据库中执行操作的连接数量。
-
闲置:这是池中可供使用以备不时之需的可用连接数量。
-
挂起:这是等待可用连接以访问数据库的操作数量。理想情况下,这个指标应该是 0。
-
连接创建时间:这是创建到数据库的物理连接所花费的时间。
-
连接使用时间:这是连接被返回到池中之前的使用时长。
-
连接获取时间:这是获取连接所需的时间。当有闲置连接时,所需时间会非常低。当有挂起的连接时,所需时间会更高。
-

图 7.7:Grafana 中的 HikariCP 指标
你可能想要缓存操作,就像我们在本菜谱中所做的那样,以减少对数据库的连接次数。
在下一个菜谱中,我们将学习如何使用 Redis 作为外部缓存以及如何更新它。
使用共享缓存
样本足球交易应用需要覆盖一个新的场景。一些足球运动员可以胜任不同的位置,有时是后卫,有时是中场。球员不会频繁更换位置,但这种情况可能发生。正如我们在前面的菜谱中学到的,缓存球员可以显著提高应用性能。我们假设同时运行多个应用实例是可能的,也是推荐的。当一个球员被更新时,所有应用实例都应该返回球员的最新版本。
在这个菜谱中,我们将学习如何使用所有应用实例共享的外部缓存,以及当底层数据被修改时如何更新缓存。
准备工作
在这个菜谱中,我们将重用前一个菜谱生成的应用程序,因为它已经配置好了缓存。我在 GitHub 仓库中准备了一个工作版本,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。它位于chapter7/recipe7-3/start文件夹中。
应用程序使用 PostgreSQL 作为数据库引擎,配置了 Zipkin、Prometheus 和 Grafana 以进行可观察性。
由于我们将添加对 Redis 的缓存支持,我们需要一个 Redis 服务器。在您的计算机上运行 Redis 的最简单方法是使用 Docker。
我准备了一个名为docker-compose-redis.yml的 Docker Compose 文件,其中包含所有依赖服务,即 PostgreSQL、Zipkin、Prometheus、Grafana 和 Redis。你可以在chapter7/docker文件夹中找到该文件。要运行所有依赖服务,请在chapter7/docker文件夹中打开一个终端并运行以下命令:
docker-compose -f docker-compose-redis.yml up
我为这个菜谱准备了一个 JMeter 测试来生成负载。你可以在chapter7/jmeter/Football-updates.jmx中找到它。除了前一个菜谱中实现的流程外,它还会不时更新足球运动员的位置。
如何操作...
我们将首先准备应用程序以使用 Redis,然后确保当球员被修改时缓存会更新:
-
首先,我们将添加
Spring Data Redis启动器依赖项。为此,只需在pom.xml文件中添加以下依赖项:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> -
你需要添加以下依赖来管理
LocalDate字段:<dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> <version>2.16.1</version> </dependency> -
接下来,我们需要配置 Redis。为此,我们将注册一个
RedisCacheConfigurationBean。让我们创建一个新的配置类;你可以命名为RedisConfig:@Configuration public class RedisConfig { @Bean public RedisCacheConfiguration cacheConfiguration() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); Jackson2JsonRedisSerializer<Player> serializer = new Jackson2JsonRedisSerializer<>(mapper, Player.class); return RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .disableCachingNullValues() .serializeValuesWith(SerializationPair.fromSerializer(serializer)); } } -
最后,你必须确保当底层数据更新时,缓存也会更新。让我们通过在
FootballService类中添加@CacheEvict注解来修改updatePlayerPosition方法:@CacheEvict(value = "players", key = "#id") public Player updatePlayerPosition(Integer id, String position) -
现在,你可以运行 JMeter 测试来验证应用程序并测量性能影响。为此,我准备了一个名为
Football-updates.jmx的测试。你可以在chapter7/jmeter文件夹中找到它。这个测试会随机但非常不频繁地更新球员的位置,然后检索球员以验证其位置是否已更新。

图 7.8:JMeter 测试,显示球员更新的详细信息
在我的计算机上,总吞吐量为 1,497.5 RPS,get-user-players为 1,210.6 RPS。Redis 缓存的性能略低于进程内缓存。然而,将缓存外部化使得可以通过添加更多实例来实现水平扩展。
它是如何工作的...
当添加外部缓存实现时,应用程序需要将需要缓存的对象序列化以通过网络发送并将它们保存到 Redis 中。默认的 Redis 配置可以管理基本类型,如String或int,无需额外配置。然而,在这个示例应用程序中,我们需要缓存Player对象。为了使用默认配置,Player类应该实现Serializable接口。
为了避免修改我们的领域类,我们配置了一个Jackson2JsonRedisSerializer序列化器。这个序列化器将对象表示为 JSON 字符串。玩家有一个关于birthDate字段的限制,因为它属于LocalDate类型,无法使用默认实现来管理。这就是我们添加com.fasterxml.jackson.datatype:jackson-datatype-jsr310依赖并在ObjectMapper中注册JavaTimeModule以用于RedisCacheConfiguration的原因。
考虑使用外部缓存存储库的影响很重要:
-
正如我们刚刚学习的,我们必须确保缓存的对象可以被序列化。
-
你还需要考虑网络延迟。我在我的电脑上本地执行了所有负载测试,所以没有网络延迟。在实际环境中,它也可能影响应用程序的性能。
-
缓存服务器可能成为新的瓶颈。Redis 非常高效,但它可能意味着需要向你的解决方案添加新资源,例如新服务器。
在我的负载测试结果中,我没有注意到显著的性能差异,因为所有操作都在同一台电脑上运行;然而,你可能在具有跨不同服务器分布的服务器的生产环境中期望有轻微的差异。
如果你将 Redis 运行在不同的服务器上,你必须配置服务器地址。默认情况下,Spring Data Redis启动器假定 Redis 运行在localhost并监听端口6379。
在这个菜谱中,我们使用了@CacheEvict注解来更新缓存。这个注解通过键来删除条目。默认情况下,这个注解使用所有方法参数作为缓存条目的键。然而,updatePlayerPosition方法有两个参数:玩家的id和新的position。因为键只是玩家的id,所以我们指定了@CacheEvict注解的position字段中。其他选项,如清除所有条目,不适用于我们的场景。
使用带有 Redis 缓存的 Testcontainers
如果你在前一个菜谱中执行了示例项目中可用的自动化测试,你可能已经注意到使用需要 Redis 的方法的测试失败。原因是测试执行期间 Redis 不可用。
在这个菜谱中,我们将学习如何使用 Testcontainers 设置作为 Docker 容器托管的 Redis 服务器。
准备工作
在这个菜谱中,我们将为在使用共享缓存菜谱中创建的项目创建测试。如果你还没有完成,请使用我为这个菜谱准备的版本作为起点。你可以在书的 GitHub 仓库github.com/PacktPublishing/Spring-Boot-3.0-Cookbook中的chapter7/recipe7-4/start文件夹中找到它。
随着我们使用 Testcontainers,你需要在电脑上安装 Docker。
如何做…
我们喜欢可靠的应用程序。让我们让我们的测试工作起来!
-
我们将在
FootballServiceTest类中做出所有更改。所以,打开它并添加一个新的静态字段,类型为GenericContainer。我们将暴露默认的 Redis 端口6379,并使用最新的redis镜像:static GenericContainer<?> redisContainer = new GenericContainer<>("FootballServiceTest.Initializer class, by adding the properties to configure the connection to Redis:static class Initializer
implements ApplicationContextInitializer
{ public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
TestPropertyValues.of(
"spring.datasource.url=" + postgreSQLContainer.getJdbcUrl(),
"spring.datasource.username=" + postgreSQLContainer.getUsername(),
"spring.datasource.password=" + postgreSQLContainer.getPassword(),
"spring.data.redis.host=" + redisContainer.getHost(),
"spring.data.redis.port=" + redisContainer.getMappedPort(6379))
.applyTo(configurableApplicationContext.getEnvironment());
}
}
-
最后,在执行测试之前启动容器:
@BeforeAll public static void startContainer() { postgreSQLContainer.start(); redisContainer.start(); } -
你现在可以运行测试了。它们应该能正常工作!
它是如何工作的…
为了将 Redis 集成到我们的测试中,我们只需要一个可用的 Redis 服务器。Testcontainers 中有一个专门的 Redis 模块。你可以在testcontainers.com/modules/redis/找到它。由于集成相当简单,我们可以使用GenericContainer而不是专门的RedisContainer。
如我们在之前的菜谱中学习到的,通过在我们的测试类中添加@Testcontainers注解,它将自动扫描所有容器字段并将它们集成到测试中。FootballServiceTest已经通过集成 PostgreSQL 而注解了@Testcontainers。我们只需要添加一个新的容器,在这种情况下就是GenericContainer,并执行基本的配置来设置它。具体如下:
-
使用最少的配置:镜像和暴露的端口来配置容器。
-
在应用程序上下文中设置 Redis 配置连接数据。我们在
FootballServiceTest.Initializer类中做了这件事。Redis 启动器期望在spring.data.redis下配置。我们添加了主机和端口,但只需要端口。默认情况下,它期望主机在localhost。 -
在测试执行之前启动容器。我们在带有
@BeforeAll注解的方法中做了这件事。
使用 Spring Boot 创建原生镜像
通常,当我们使用面向微服务的方法设计解决方案时,我们想象我们可以通过添加和删除我们应用程序的新实例来轻松扩展我们的应用程序,我们也想象这个过程是立即发生的。然而,启动我们应用程序的新实例可能需要比我们最初预期的更长的时间。Spring Boot 在应用程序启动期间协调 Bean 初始化、依赖注入和事件处理,并且这些步骤中的大多数都是动态发生的。这对小型应用程序来说不是一个大问题,但对于复杂的应用程序,这个过程可能需要几分钟才能完成。
在设计应用程序时,另一个重要因素是高效使用计算资源。我们希望应用程序尽可能少地消耗内存并高效处理工作负载。
对于这种场景,我们可以考虑创建原生应用程序,即作为特定处理器家族和操作系统的最终二进制文件构建的应用程序。一个普通的 Java 应用程序生成中间代码,该代码在应用程序运行时由 Java 虚拟机 (JVM) 处理并转换为二进制代码。在原生应用程序中,这个过程发生在构建时。
在这个菜谱中,我们将学习如何创建一个新的 Spring Boot 原生应用程序。
准备工作
对于这个菜谱,我们需要 Docker。你可以查看本章的 技术要求 部分以获取更多信息。
如何做到这一点…
让我们使用 Spring Boot 创建一个原生应用程序!
-
打开 Spring Boot Initializr 工具,访问
start.spring.io,并使用与你在 第一章 中 创建一个 RESTful API 菜单中使用的相同选项,并使用相同的参数,除了以下选项:-
对于
footballnative -
对于 依赖项,选择 Spring Web 和 GraalVM 原生支持
-
-
接下来,创建一个示例 RESTful 控制器;例如,创建一个
TeamController控制器和一个返回团队列表的方法:@RequestMapping("/teams") @RestController public class TeamController { @GetMapping public List<String> getTeams() { return List.of("Spain", "Zambia", "Brazil"); } } -
你可以像往常一样在 JVM 上运行应用程序,但我们现在要创建一个原生 Docker 镜像。为此,打开你的终端并执行以下 Maven 命令:
mvnw -Pnative spring-boot:build-image请耐心等待,因为这个步骤可能需要几分钟才能完成,具体取决于你电脑的资源。
-
构建完成后,你可以在终端中执行以下命令来运行我们的原生应用程序的 Docker 镜像:
docker run --rm -p 8080:8080 footballnative:0.0.1-SNAPSHOT -
现在,你可以像通常一样对 RESTful 应用程序进行请求;例如,你可以使用以下
curl命令:curl http://localhost:8080/teams
它是如何工作的…
GraalVM 本地支持依赖添加了一个新的 native 配置文件,可以与标准的 Spring Boot build-image 目标一起使用,以生成针对 GraalVM 的镜像。GraalVM 是一个 Java 运行时环境,可以将您的应用程序 即时编译(AOT)成原生可执行文件,具有低资源消耗、快速启动和增强的安全性。为了创建原生镜像,Maven 插件在 Docker 容器中使用 Paketo Buildpacks 构建本地的 GraalVM 可执行文件。Paketo Buildpacks 是一套由社区驱动的工具,简化了将应用程序作为容器镜像构建和部署的过程。这就是为什么您不需要在您的计算机上下载 GraalVM 工具的原因。
结果是一个包含我们的应用程序作为原生可执行文件的 Docker 镜像。仅作为性能改进的参考,该应用程序在我的运行 JVM 的计算机上启动需要大约 1.5 秒,而原生镜像完成同样的操作只需要 0.07 秒。这大约是 21 倍的快。然而,当运行 10,000 个请求时,两个版本的总体吞吐量相当接近,JVM 版本的性能略好。这可能是因为原生版本运行在 Docker 上,而 JVM 直接运行在我的计算机上。我准备了一个 JMeter 测试,您可以使用它来比较您计算机上的结果。您可以在书籍的 GitHub 仓库 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook 中的 chapter7/jmeter 文件夹找到一个名为 teams-native.jmx 的测试。
原生应用程序并不是适用于所有场景的万能药。您需要考虑,某些功能在运行时需要动态处理,并且它们难以用原生应用程序处理。如果您的应用程序没有快速启动时间的要求,原生应用程序不会带来很多好处,并且可能会有很多不便。从性能的角度来看,JVM 应用程序在长期内与原生应用程序表现相当。也就是说,在预热后,它的工作效果与原生应用程序一样;在内存管理方面可能会有一些改进,但在性能方面它们相当相似。
使用 GraalVM 跟踪代理配置原生应用程序
在上一个菜谱中创建的小型本地应用程序看起来非常有前景,因此我们决定构建一个更大的足球应用程序作为原生应用程序。
在 使用 Spring Boot 创建本地镜像 菜谱中创建的应用程序不需要任何特殊配置。但是,本地应用程序是 AOT 构建的。这意味着编译器需要静态分析所有代码并检测运行时到达的代码。有一些 Java 技术,如 Java 本地接口(JNI)、反射、动态代理对象和类路径资源,仅通过静态代码分析很难检测到。本地编译器可以使用配置文件将所需的组件包含在最终的二进制文件中。正如你可能已经想到的,困难的部分是通过检测要包含在最终二进制文件中的组件来配置这些文件。为此,GraalVM 提供了一个代理,在常规 JVM 应用程序上执行应用程序时追踪对这些类型技术的所有使用。
在这个菜谱中,我们将构建本章提供的示例应用程序的本地图像。如果你尝试直接以本地图像构建应用程序,你将在运行时遇到一些错误。在这个菜谱中,我们将学习如何使用 GraalVM 追踪代理来查找所有必需的组件,并为现有应用程序构建本地图像。然后,你将能够在 Docker 中运行你的应用程序。
准备工作
在这个菜谱中,我们将适应你在 使用 Testcontainers 与 Redis 缓存一起使用 菜谱中创建的应用程序。如果你还没有完成,你可以使用我提供的功能项目作为这个菜谱的起点。你可以在本书的 GitHub 仓库中找到它:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook 中的 chapter7/recipe7-6/start 文件夹。
你需要在你的计算机上安装 GraalVM JDK。你可以按照官方网站上的说明进行安装:www.graalvm.org/downloads/。
应用程序依赖于 PostgreSQL、Redis 和其他服务。正如我们将在 如何操作... 部分中看到的,我们将以 Docker 容器的形式运行应用程序。为了方便在开发计算机上执行,我准备了一个名为 docker-compose-all.yml 的 Docker Compose 文件,其中包含应用程序和所有依赖服务。
如何操作...
让我们构建一个本地的可执行镜像文件,用于我们的 Spring Boot 应用程序。我们将看到它现在运行得多快!记住,我们最初创建这个应用程序时是一个常规的 JVM 应用程序:
-
首先,我们将向我们的
pom.xml应用程序添加 GraalVM 本地支持 插件。你应该在build/plugins元素中包含以下配置:<plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> </plugin> -
接下来,我们还需要添加 Hibernate Enhance 插件。你应该在
build/plugins元素中包含以下配置:<plugin> <groupId>org.hibernate.orm.tooling</groupId> <artifactId>hibernate-enhance-maven-plugin</artifactId> <version>${hibernate.version}</version> <executions> <execution> <id>enhance</id> <goals> <goal>enhance</goal> </goals> <configuration> <enableLazyInitialization>true</enableLazyInitialization> <enableDirtyTracking>true</enableDirtyTracking> <enableAssociationManagement>true</enableAssociationManagement> </configuration> </execution> </executions> </plugin> -
在此步骤中,我们将使用 GraalVM JVM 并带有特殊设置来运行应用程序,以跟踪应用程序在运行时使用的组件。原生编译器将使用这些跟踪将那些组件包含在最终的二进制可执行文件中:
-
此步骤需要你使用 GraalVM JVM。根据你使用的安装方法不同,切换 Java 版本的方式可能不同。我使用了 SDKMAN! 工具,它只需在你的终端中执行以下命令:
sdk use java 21-graalce -
为了确保你使用正确的 JVM 版本,请在你的终端中执行以下命令:
java -version -
确认响应中包含 GraalVM。作为一个参考,这是我在我的电脑上执行此命令时的输出:
-

图 7.9:GraalVM JVM 的示例 java -version 输出
-
正常构建应用程序,即通过在 Maven 中执行
package目标。在应用程序根目录的终端中执行此命令:./mvnw package -
此命令为你的应用程序创建 JAR 文件。默认情况下,文件名将是
football-0.0.1-SNAPSHOT.jar,它将在target目录中创建。 -
现在,运行 GraalVM 跟踪工具。这是通过执行指定 JVM 代理的应用程序来实现的,即指定
-agentlib:native-image-agent参数并传递保存配置输出的文件夹。我们将设置原生编译器期望的特殊配置文件夹,即src/main/resources/META-INF/native-image。这是如何执行指定 GraalVM 跟踪工具的应用程序的方法:java -Dspring.aot.enabled=true -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image -jar target/football-0.0.1-SNAPSHOT.jar
-
现在我们应用程序已经启动并运行,让我们确保我们覆盖了所有基础。执行应用程序的每个路径非常重要,这样我们就可以跟踪所有动态组件,并确保一切准备就绪以构建原生应用程序。你会发现
src/main/resources/META-INF/native-image文件夹包含几个 JSON 文件。当你完成所有应用程序路径的执行后,你可以停止应用程序。
-
是时候构建原生应用程序了!你可以通过执行以下 Maven 命令来完成:
mvn localhost, you will need to specify some settings as environment variables. To make it easier for you, I’ve prepared a Docker Compose file. I named it docker-compose-all.yml, and you can find it in the book’s GitHub repository.On my computer, the native version takes just 1.29 seconds to be ready to accept requests, compared to 6.62 seconds for the JVM version.
它是如何工作的…
如 使用 Spring Boot 创建原生映像 菜单中所述,将 GraalVM Native Support 添加到我们的应用程序将创建一个新的 Spring Boot 配置文件,我们可以使用它来构建具有我们应用程序原生版本的新 Docker 映像。
一些 Hibernate 操作在运行时生成 Hibernate 代理实例。如果我们不包括 Hibernate Enhance 插件,原生编译器在构建时没有所需的引用。因此,我们需要在我们的应用程序中包含此插件。
在像在使用共享缓存菜谱中创建的简单应用程序中,我们可以跳过步骤 3和步骤 4,直接构建本地应用程序。然而,我们会意识到许多操作不起作用。这是因为静态构建分析没有检测到一些动态加载的组件,这些组件大多与 Hibernate 相关。为了解决这个问题,GraalVM 提供了跟踪代理工具。这个工具跟踪 JNI、Java 反射、动态代理对象(java.lang.reflect.Proxy)或类路径资源的所有使用情况,并将它们保存在指定的文件夹中。生成的文件如下:
-
jni-config.json: 这包含 JNI 相关信息 -
reflect-config.json: 这包含反射相关细节 -
proxy-config.json: 这包含动态代理对象详情 -
resource-config.json: 这包含类路径资源信息 -
predefined-classes-config.json: 这包含预定义类的元数据 -
serialization-config.json: 这包含序列化相关数据
然后,本地编译器可以使用此配置将引用的组件包含在最终的本地可执行文件中。采用这种方法,我们可能会找到大多数在运行时使用的组件,但某些组件可能无法检测到。在这种情况下,我们需要手动包含它们。
由于我们将应用程序作为容器运行,它是在 Docker 的上下文中执行的。这意味着为了定位依赖的服务,例如 PostgreSQL,需要指定内部 Docker DNS 名称。在先前的菜谱中,所有依赖服务都可以使用localhost访问。因此,需要指定所有依赖组件的地址,例如,通过设置环境变量,而设置这些环境变量的最简单方法是通过创建 Docker Compose 文件。
还有更多...
我执行了我们在使用共享缓存菜谱中使用的相同的 JMeter 测试,以比较在 JVM 上运行和作为本地应用程序运行的同一种应用程序的结果。在下面的图中,你可以看到作为本地应用程序运行的结果:

图 7.10:在 Docker 上运行的本地图像的 JMeter 吞吐量
结果可能看起来令人惊讶,因为作为本地应用程序运行的应用程序的性能明显低于 JVM 版本。
有两个因素需要考虑:
-
应用程序现在运行在 Docker 上,而运行在 JVM 上的应用程序是直接在我的计算机上执行的
-
一旦在 JVM 上运行的应用程序完成了即时编译(JIT),与运行相比,性能没有显著提升。
在下一个菜谱中,我们将以本地方式构建应用程序,而不是在容器上运行。然后,我们将能够比较在类似条件下运行的应用程序。
使用 Spring Boot 创建本地可执行文件
在之前的菜谱中,我们构建了原生应用程序以作为容器运行。尽管这对于大多数现代云原生场景来说是一个方便的解决方案,但我们可能需要构建一个原生可执行文件,以便在没有容器引擎的情况下直接执行。
在这个菜谱中,我们将学习如何配置我们的计算机以使用 GraalVM JDK 构建原生应用程序。
准备就绪
在这个菜谱中,我们将重用 使用 GraalVM 追踪代理配置原生应用程序 菜谱的结果。我准备了一个应用程序版本,您可以用作此菜谱的起点。您可以在本书的 GitHub 仓库中找到它,网址为 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/,在 chapter7/recipe7-6/start 文件夹中。
您需要在您的计算机上安装 GraalVM JDK 版本 21。您可以通过访问官方网站上的说明进行操作,网址为 www.graalvm.org/downloads/.
应用程序依赖于一些服务,如 PostgreSQL 和 Redis。为了方便在您的计算机上执行这些服务,您可以使用在 使用共享 缓存 菜谱中准备的 docker-compose-redis.yml 文件。
如何操作……
现在,我们将构建我们的应用程序作为一个原生镜像,可以直接在我们的计算机上执行:
-
确保您在此过程中使用 GraalVM JVM。为此,执行以下命令:
java -version确认消息中包含 GraalVM,如图 图 7**.5 所示。
-
接下来,我们将构建原生可执行文件。为此,打开一个终端,将目录更改为根应用程序文件夹,并执行以下命令:
mvn -Pnative native:compile原生构建所需的时间比常规 JVM 构建长,甚至可能长达几分钟。
-
现在,我们的二进制可执行文件位于
target文件夹中。它的名称与项目相同,这次没有版本后缀。如果您使用 Windows,它将是football.exe;在类 Unix 系统中,它将只是football。现在是运行应用程序的时候了。由于我使用的是 Linux,我将在我的终端中执行以下命令:cd target ./football确保依赖的服务,如 PostgreSQL 和 Redis,正在运行。正如在 准备就绪 部分中解释的那样,您可以使用
docker-compose-redis.ymlDocker Compose 文件来运行所有依赖的服务。
如何工作……
正如我们在 使用 GraalVM 追踪代理配置原生应用程序 菜谱中所做的那样,我们必须为原生构建准备我们的应用程序。在这个菜谱中,我们重用了应用程序,并且我们已经有了 GraalVM 需要生成原生应用程序的动态组件的提示。然而,如果您从头开始,您将需要像在 使用 GraalVM 追踪代理配置原生 应用程序 菜谱中所做的那样准备配置。
Spring Boot GraalVM Native Support 启动器包括原生配置文件和native:compile目标。这个启动器已经包含在我们在这个菜谱中重用的应用程序中。这次,编译过程是在您的计算机上运行,而不是在容器中执行。
还有更多...
我们可以使用 JMeter 执行负载测试。这个场景与使用 Testcontainers 与 Redis 缓存菜谱中测试的场景相似,因为两个应用程序都是直接在计算机上运行的,而依赖的服务运行在 Docker 上。以下是我计算机上执行相同 JMeter 测试的结果:

图 7.11:原生应用程序的 JMeter 摘要
对于get-user-player的吞吐量是 622.1 RPS,与使用 JVM 版本实现的 566.3 RPS 相比,这大约提高了 9.86%。对于总请求量,它是 773.5 RPS,与 699.2 RPS 相比,大约提高了 10.6%。
您必须考虑使用原生镜像的利弊。主要好处是快速启动时间和更好的内存管理和性能。主要的权衡是准备构建镜像的复杂性,需要所有必要的提示以避免由于动态组件导致的运行时错误。这种配置可能非常痛苦且难以检测。您还需要考虑构建应用程序所需的时间,这可能会比 JVM 对应版本长得多。
从 JAR 创建原生可执行文件
正如我们在完成前一个菜谱的过程中所意识到的,构建原生镜像所需的时间远比构建常规 JVM 应用程序所需的时间多。在特定环境中,另一个重要的考虑因素是 GraalVM 目前不支持跨平台构建。这意味着如果我们需要为 Linux 构建应用程序,因为它是服务器环境中最受欢迎的平台,但我们的开发计算机是 Windows 或 macOS 计算机,我们无法直接构建该应用程序。出于这些原因,继续使用常规 JVM 开发流程并在持续集成(CI)平台上创建原生可执行文件可能是一个不错的选择。例如,您可以创建一个用于创建原生可执行文件的 GitHub 操作。这样,我们可以在开发过程中保持生产力,我们不需要更改我们的开发平台,并且我们可以针对我们的应用程序的平台。
在这个菜谱中,我们将使用 GraalVM JDK 中的native-image工具为我们足球应用程序生成原生可执行文件。
准备中
对于这个配方,我们将使用来自使用 Spring Boot 创建原生可执行文件配方的结果。使用native-image工具创建原生可执行文件需要一个 AOT 处理的 JAR。如果您计划将另一个应用程序转换为原生可执行文件,请按照上一个配方中的说明生成 AOT 处理的 JAR 文件。如果您还没有完成上一个配方,我准备了一个可用的版本,您可以用它作为本配方的起点。您可以在本书的 GitHub 仓库中找到它,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/,在chapter7/recipe7-8/start文件夹中。
您需要native-image工具。此工具是 GraalVM JDK 的一部分。
如何做到这一点…
您可以使用 JVM 正常工作,并将原生构建用于 CI。让我们看看那时您需要做什么!
-
第一步是确保您生成一个经过 AOT 处理的 JAR。为此,在项目的根目录中打开您的终端,并使用 Maven 的
native配置文件打包 JAR 文件。为此,请执行以下命令:./mvnw -Pnative package -
接下来,我们将为我们的原生可执行文件创建一个新的目录。让我们称它为
native。我们将在target目录内创建此目录:mkdir target/native将您的当前目录更改为新创建的目录:
cd target/native -
现在,我们将从在步骤 1中创建的 JAR 文件中提取类。我们将使用 JDK 的一部分,即 JAR 工具:
jar -xvf ../football-0.0.1-SNAPSHOT.jar -
我们可以构建原生应用程序。为此,我们将使用
native-image工具。我们需要设置以下参数:-
-H:name=football: 这是指定的可执行文件名;在我们的例子中,它将是football。 -
@META-INF/native-image/argfile:@符号表示该参数是从文件中读取的。指定的文件(argfile)可能包含用于原生图像生成过程的附加配置选项或参数。 -
-cp: 此参数设置原生图像的类路径。我们必须传递当前目录、BOOT-INF/classes目录以及BOOT-INF/lib中包含的所有文件。此参数将如下所示:-cp .:BOOT-INF/classes:`find BOOT-INF/lib | tr '\```java` n' ':'`.
Then, to execute the
native-imagetool, you should execute the following command:native-image -H:Name=football @META-INF/native-image/argfile \ -cp .:BOOT-INF/classes:`find BOOT-INF/lib | tr '\n' ':'` ```java -
-
Now, you have our application built as a native executable. You can execute it just by executing the following command in your terminal:
./football
它是如何工作的…
由于我们重用了上一个配方中的应用程序,我们已定义了提示。有关更多详细信息,请参阅使用 GraalVM 跟踪代理配置原生应用程序配方。为了使它们可用于原生构建,我们必须使用native配置文件打包我们的应用程序。
一个 JAR 文件包含我们应用程序的类和资源在一个 ZIP 文件中。我们可以使用标准的 ZIP 工具,但 JAR 工具对我们的目的来说更加方便。我们使用 -xvf 参数与要处理的 JAR 文件一起传递。x 参数指示工具提取内容。f 表示它将从作为参数传递的文件中获取内容。最后,v 只是用来生成详细输出;我们可以去掉这个参数。
对于 native-image 工具,我们需要传递 BOOT-INF/lib 目录中包含的所有文件。不幸的是,cp 参数不承认通配符。在类 Unix 系统中,你可以使用 find 和 tr 工具。find 列出目录中的文件,而 tr 移除 \n 和 : 字符。\n 是换行符。
第八章:Spring Reactive 和 Spring Cloud Stream
在高并发场景中,可能需要不同的应用程序方法,例如需要低延迟和响应性的资源密集型操作,如 输入/输出 (I/O) 限定的任务。在本章中,我们将了解两个解决此类场景的 Spring Boot 项目。
Spring Reactive 是 Spring 对反应式处理场景的响应。反应式处理是一种范式,允许开发者构建 非阻塞、异步的应用程序,可以处理 背压。非阻塞意味着当应用程序等待外部资源响应时,例如调用外部 Web 服务或数据库时,应用程序不会阻塞处理线程。相反,它重用处理线程来处理新的请求。背压是一种处理下游组件无法跟上上游组件数据生产速率的情况的机制。对于这些机制,Spring Reactive 可以在高并发场景和资源密集型操作中使用。
Spring WebFlux 是与我们在前几章中使用的 Spring 模型-视图-控制器 (MVC) 相当的反应式 Web 框架。为了促进不同 Web 框架之间的过渡,Spring WebFlux 反映了 Spring MVC 中的名称和注解。
Spring Data Reactive Relational Database Connectivity (R2DBC) 是使用反应式驱动程序集成关系型数据库的规范。与传统阻塞驱动程序相比,它还应用了熟悉的抽象。
Spring Cloud Stream 是一个用于构建高度可扩展的事件驱动分布式应用程序的框架,这些应用程序通过共享消息系统连接。您可以使用反应式编程与 Spring Cloud Stream 一起使用,但 Spring Cloud Stream 的主要目标是创建松散耦合的分布式应用程序,这些应用程序可以独立扩展。与反应式试图优化运行时执行不同,Spring Cloud Stream 为创建可以处理一定程度的异步的分布式应用程序提供了基础。Spring Reactive 和 Spring Cloud Stream 可以在高并发场景中结合使用,并且是互补的。
在本章的第一部分,我们将通过学习如何使用 Spring WebFlux 和 Spring Data R2DBC 与 PostgreSQL 一起使用来探索 Spring Reactive。在第二部分,我们将学习如何在使用 RabbitMQ 作为消息服务的同时使用 Spring Cloud Stream。你将学到的知识可以应用于其他消息服务,例如 Kafka,或者云提供商提供的其他服务,例如 Amazon Kinesis、Azure Event Hub 或 Google PubSub。
在本章中,我们将介绍以下食谱:
-
创建一个反应式 RESTful API
-
使用反应式 API 客户端
-
测试反应式应用程序
-
使用 Spring Data R2DBC 连接到 PostgreSQL
-
使用 Spring Cloud Stream 和 RabbitMQ 构建事件驱动应用程序
-
使用 Spring Cloud Stream 和 RabbitMQ 路由消息
-
使用 Spring Cloud Stream 进行错误处理
技术要求
在本章中,我们需要一个 PostgreSQL 服务器和一个 RabbitMQ 服务器。在您的计算机上运行它们的最简单方法是使用 Docker。您可以从官方站点 docs.docker.com/engine/install/ 获取 Docker。我将在相应的食谱中解释如何部署每个工具。
本章中将要演示的所有食谱都可以在以下位置找到:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/tree/main/chapter8.
创建一个反应式 RESTful API
Spring Reactive 是 Spring 的一项倡议,它提供了可以在我们的 Spring Boot 应用程序中使用的反应式编程特性和功能。它旨在支持异步和非阻塞编程。但异步和非阻塞编程是什么?为了理解这些概念,最好从传统的模型开始,即非反应式编程模型。
在传统模型中,当 Spring Boot 应用程序收到一个请求时,一个专用的线程处理该请求。如果该请求需要与另一个服务通信,例如数据库,处理线程将阻塞,直到它从其他服务收到响应。可用的线程数量有限,因此如果您的应用程序需要高并发但主要等待其依赖服务完成,这种同步阻塞模型可能存在限制。
在反应式模型中,异步和非阻塞编程在并发请求之间重用线程,并且不会因 I/O 操作(如网络调用或文件操作)而阻塞。
反应式编程特别适合构建需要高并发和可伸缩性的应用程序,例如处理许多并发连接的 Web 应用程序或实时数据处理系统。
在这个食谱中,我们将使用 Spring WebFlux 通过反应式编程构建一个 RESTful API。Spring WebFlux 是 Spring 中的一个模块,它使构建 Web 应用程序时能够使用反应式编程。
准备工作
这个食谱没有额外的要求。我们将使用 Spring Initializr 工具生成项目,一旦下载,您可以使用您喜欢的 集成开发环境(IDE)或编辑器进行更改。
如何操作...
在这个食谱中,我们将创建一个 RESTful API 应用程序。这次,我们将使用反应式编程来创建它,而不是像 第一章 中的食谱那样。按照以下步骤操作:
-
打开
start.spring.io,并使用与 第一章 中 创建 RESTful API 食谱中相同的参数,除了以下选项需要更改:-
对于
cards -
对于 依赖项,选择 Spring Reactive Web
-
-
在
cards项目中,添加一个名为Card的记录。定义记录如下:public record Card(String cardId, String album, String player, int ranking) { } -
在同一文件夹中,添加一个名为
CardsController的控制器:@RequestMapping("/cards") @RestController public class CardsController-
添加一个名为
getCards的方法,用于检索所有卡片:@GetMapping public Flux<Card> getCards() { return Flux.fromIterable( List.of( new Card("1", "WWC23", "Ivana Andres", 7), new Card("2", "WWC23", "Alexia Putellas", 1))); } -
然后添加另一个方法来检索卡片:
@GetMapping("/{cardId}") public Mono<Card> getCard(@PathVariable String cardId) { return Mono.just(new Card(cardId, "WWC23", "Superplayer", 1)); }
在 WebFlux 中,
Flux<T>用于返回对象流,而Mono<T>用于返回单个对象。在非反应式编程中,它们将是返回List<T>的Flux<T>和返回T的Mono<T>的等价物。在此控制器中,
Flux<Card> getCards()返回多个Card类型的对象,而Mono<Card> getCard仅返回一张卡片。 -
-
现在,添加一个名为
SampleException的异常类,实现一个新的自定义异常:public class SampleException extends RuntimeException { public SampleException(String message) { super(message); } } -
然后,向
CardsController添加两个更多方法,以演示如何在 WebFlux 中实现错误处理:@GetMapping("/exception") public Mono<Card> getException() { throw new SampleException("This is a sample exception"); } @ExceptionHandler(SampleException.class) public ProblemDetail handleSampleException(SampleException e) { ProblemDetail problemDetail = ProblemDetail .forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage()); problemDetail.setTitle("sample exception"); return problemDetail; }getException方法总是抛出异常,而handleSampleException处理SampleException类型的异常。 -
现在,在卡片项目的根目录中打开一个终端,并执行以下命令:
./mvnw spring-boot:run我们现在有了正在运行的 RESTful API 服务器。
-
您可以通过向
http://locahost:8080/cards发送请求来测试应用程序。您可以使用curl来完成此操作:curl http://localhost:8080/cards您还可以通过请求
http://localhost:8080/exception来测试错误处理的工作方式。您将看到它将返回一个HTTP400结果。
它是如何工作的...
我们在本菜谱中使用了与 Spring Web 相同的注解来定义控制器。然而,方法返回的是Mono和Flux类型,而不是传统对象,这表明响应将异步生成。Mono和Flux是 WebFlux 中反应式编程模型的核心接口。Mono用于最多产生一个结果的异步操作,而Flux用于返回零个或多个元素的异步操作。
反应式编程围绕反应流的概念。反应流使用非阻塞背压模型异步数据流。我提到了一些可能听起来很奇怪的概念,所以让我来澄清它们:
-
非阻塞:这指的是与 I/O 相关的操作,例如发送 HTTP 请求,这些操作避免了线程阻塞。这使您能够在不为每个请求分配专用线程的情况下执行大量并发请求。
-
背压:这是一种确保数据仅以可以消费的速度产生的机制,以防止资源耗尽。例如,当下游组件无法跟上上游组件发出的数据时,可能会发生这种情况。WebFlux 自动管理背压。
还有更多...
除了本菜谱中使用的基于注解的编程模型之外,WebFlux 还支持以下代码的cards RESTful API:
-
首先,创建一个处理逻辑的类:
public class CardsHandler { public Flux<Card> getCards() { return Flux.fromIterable(List.of( new Card("1", "WWC23", "Ivana Andres", 7), new Card("2", "WWC23", "Alexia Putellas", 1))); } public Mono<Card> getCard(String cardId) { return Mono.just( new Card(cardId, "WWC23", "Superplayer", 1)); } } -
还有一个用于配置应用程序的:
@Configuration public class CardsRouterConfig { @Bean CardsHandler cardsHandler() { return new CardsHandler(); } @Bean RouterFunction<ServerResponse> getCards() { return route(GET("/cards"), req -> ok().body(cardsHandler().getCards(), Card.class)); } @Bean RouterFunction<ServerResponse> getCard(){ return route(GET("/cards/{cardId}"), req -> ok().body( cardsHandler().getCard( req.pathVariable("cardId")), Card.class)); } }
基于注解的编程更类似于传统的非响应式编程模型,而函数式编程可以更加表达性,尤其是在复杂的路由场景中。函数式风格更适合处理高并发和非阻塞场景,因为它自然地与响应式编程集成。
使用基于注解或函数式编程是个人偏好的问题。
使用响应式 API 客户端
我们有一个 RESTful API,现在是时候以非阻塞的方式使用它了。我们将创建一个调用另一个 RESTful API 的响应式 RESTful API。
在这个食谱中,我们将创建一个消费 API 的响应式应用程序。我们将学习如何使用响应式 WebClient 对目标 RESTful API 执行请求。
准备工作
在这个食谱中,我们将消费在创建一个响应式 RESTful API食谱中创建的应用程序。如果您还没有完成,我准备了一个可用的版本,您可以用它作为本食谱的起点。您可以在书的 GitHub 仓库github.com/PacktPublishing/Spring-Boot-3.0-Cookbook中的chapter8/recipe8-2/start文件夹中找到它。
您可以运行目标项目,并在整个食谱中保留它。
如何做到这一点...
我们将为我们的 RESTful API 创建一个高效的消费者应用程序:
-
首先,我们将使用 Spring Boot Initializr 工具创建一个新的应用程序。您可以使用与第一章中创建一个 RESTful API食谱中相同的选项,除了更改以下选项:
-
对于
consumer -
对于依赖项,选择Spring Reactive Web
-
-
由于我们在运行消费者应用程序的同时运行
cards应用程序,我们需要更改应用程序监听请求的端口。我们将设置8090作为服务器端口。我们还将为目标足球服务 URL 创建一个配置。为此,打开resources文件夹中的application.yml文件,并设置以下内容:server: port: 8090 footballservice: url: http://localhost:8080 -
现在,创建一个名为
Card的记录,内容如下:public record Card(String cardId, String album, String player, int ranking) { } -
然后,我们将创建一个名为
ConsumerController的控制器类,该类将消费目标 RESTful API。因此,这个控制器需要一个 WebClient。为此,将ConsumerController设置如下:@RequestMapping("/consumer") @RestController public class ConsumerController { private final WebClient webClient; public ConsumerController(@Value("${footballservice.url}") String footballServiceUrl) { this.webClient = WebClient.create(footballServiceUrl); } }控制器现在有一个 WebClient,允许我们在客户端应用程序中以非阻塞的方式执行请求。
-
创建一个方法来消费来自其他应用程序的操作,该操作返回
Card实例的流。为此,在ConsumerController中添加以下方法:@GetMapping("/cards") public Flux<Card> getCards() { return webClient.get() .uri("/cards").retrieve() .bodyToFlux(Card.class); } -
创建一个方法来消费返回单个对象的方法,通过向
ConsumerController类添加以下方法:@GetMapping("/cards/{cardId}") public Mono<Card> getCard(@PathVariable String cardId) { return webClient.get() .uri("/cards/" + cardId).retrieve() .onStatus(code -> code.is4xxClientError(), response -> Mono.empty()) .bodyToMono(Card.class); } -
然后,创建一个方法来管理来自远程服务器的不同响应代码:
@GetMapping("/error") public Mono<String> getFailedRequest() { return webClient.get() .uri("/invalidpath") .exchangeToMono(response -> { if (response.statusCode() .equals(HttpStatus.NOT_FOUND)) return Mono.just("Server returned 404"); else if (response.statusCode() .equals(HttpStatus.INTERNAL_SERVER_ERROR)) return Mono.just("Server returned 500: " + response.bodyToMono(String.class)); else return response.bodyToMono(String.class); }); } -
现在让我们运行消费者应用程序。当我们向客户端应用程序发出请求时,它将调用服务器 RESTful API 应用程序。记住,我们已经按照“准备就绪”部分中的说明启动了服务器 RESTful API 服务器应用程序。在
consumer项目的根目录中打开一个终端并执行以下操作:./mvnw spring-boot:run -
现在,测试
consumer应用程序。记住它监听端口8090,而服务器应用程序监听端口8080。在终端中执行以下命令:curl http://localhost:8090/consumer/cards curl http://localhost:8090/consumer/cards/7 curl http://localhost:8090/consumer/error它将返回
Remote Server return 404。消费者应用程序尝试调用服务器 RESTful API 服务器应用程序中不存在的方法。消费者应用程序处理来自服务器的 HTTP 响应代码,在这种情况下,HttpStatus.NOT_FOUND以返回最终响应消息,即Remote Serverreturn 404。
它是如何工作的...
在这个例子中,我们消费了一个使用响应式技术实现的 RESTful API,但从消费者的角度来看,这并不重要。我们可以消费任何 RESTful API,无论其内部实现如何。
重要的是,当我们利用非阻塞客户端时,如果消费者应用程序也是响应式的,它将从中受益。当我们向消费者应用程序发出请求时,它将对cards应用程序发出另一个请求。由于我们在consumer应用程序中使用响应式客户端,它不会在cards应用程序响应时阻塞线程,从而使该线程可用于处理其他请求。这样,应用程序可以管理比传统阻塞线程应用程序更高的并发性。
测试响应式应用程序
与非响应式 Spring Boot 应用程序一样,我们希望自动化测试我们的响应式应用程序,Spring Boot 为这些场景提供了出色的支持。
在这个配方中,我们将学习如何使用 Spring Boot 在添加Spring Reactive Web启动器时默认提供的组件来创建测试。
准备就绪
在这个配方中,我们将为“使用响应式 API 客户端”配方中使用的项目创建测试。如果你还没有完成那个配方,你可以使用我准备的完成版本作为这个配方的起点。你可以在书的 GitHub 仓库中找到它,在chapter8/recipe8-3/start文件夹中。github.com/PacktPublishing/Spring-Boot-3.0-Cookbook
如何做到这一点...
我们喜欢健壮和可靠的应用程序。我们将使用我们的响应式应用程序来实现这一点:
-
由于从“使用响应式 API 客户端”配方创建的应用程序使用了 Spring Boot Initializr 工具,只需添加 Spring Reactive Web 启动器,测试依赖项就已经包含在内了。你可以检查
pom.xml文件是否包含以下依赖项:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-test</artifactId> <scope>test</scope> </dependency> -
现在,我们将使用
cards应用程序启动测试。创建一个名为CardsControllerTest的新测试类。记住,它应该创建在test文件夹下;你可以在src/test/java/com/packt/cards文件夹中创建它:-
测试类应该使用
@WebFluxTest注解:@WebFluxTest(CardsController.class) public class CardsControllerTests -
然后,我们将注入一个
WebTestClient字段。为此,使用@Autowired注解新字段:@Autowired WebTestClient webTestClient; -
现在,我们可以使用
webTestClient字段来模拟对反应式 RESTful API 的调用。例如,让我们创建一个测试/cards路径,它返回一个类型为Card的列表。为此,创建一个使用@Test注解的新方法:@Test void testGetCards() { webTestClient.get() .uri("/cards").exchange() .expectStatus().isOk() .expectBodyList(Card.class); } -
让我们测试
/cards/exception路径。出于学习目的,此路径始终返回404代码,一个错误请求结果;并且正文是ProblemDetail类型。测试方法可能如下所示:@Test void testGetException() { webTestClient.get() .uri("/cards/exception").exchange() .expectStatus().isBadRequest() .expectBody(ProblemDetail.class); }
-
-
接下来,我们将为
consumer应用程序创建测试。由于我们想要独立于cards应用程序测试此应用程序,我们需要模拟cards应用程序服务器。正如我们在第一章中学习的模拟 RESTful API食谱中,我们将使用 WireMock 库。为此,打开项目consumer的pom.xml文件并添加以下依赖项:<dependency> <groupId>com.github.tomakehurst</groupId> <artifactId>wiremock-standalone</artifactId> <version>3.0.1</version> <scope>test</scope> </dependency> -
现在我们有了所有依赖项,我们将创建一个新的测试类,命名为
ConsumerControllerTest,并在编写测试之前对其进行准备:- 首先,使用
@SpringBootTest注解类并设置以下一些配置选项:
@SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {ConsumerApplication.class, ConsumerController.class, ConsumerControllerTests.Config.class}) public class ConsumerControllerTests-
注意,我们在
classes字段中设置了一个新类,它目前还不存在,ConsumerControllerTests.Config。它用于配置 MockServer,正如你很快就会看到的。 -
接下来,我们需要设置 WireMock 服务器。为此,我们将创建一个名为
Config的配置子类,它将定义一个WireMockServerbean:
@TestConfiguration static class Config { @Bean public WireMockServer webServer() { WireMockServer wireMockServer = new WireMockServer(7979); wireMockServer.start(); return wireMockServer; } }- 然后,我们需要配置用于 reactive WebClient 的新远程服务器的 URI。我们需要设置
footballservice.url应用程序上下文变量。为了执行此动态配置,我们将使用@DynamicPropertySource注解。为此,在ConsumerControllerTests类中定义一个静态方法:
@DynamicPropertySource static void setProperties(DynamicPropertyRegistry registry) { registry.add("footballservice.url", () -> "http://localhost:7979"); }- 为了完成测试准备,我们将注入
WebTestClient和WireMockServer,我们将在测试中使用。为此,使用@Autowired注解定义字段:
@Autowired private WebTestClient webTestClient; @Autowired private WireMockServer server; - 首先,使用
-
我们现在可以编写测试了。例如,我们将创建一个获取卡片的测试:
- 我们可以将其命名为
getCards:
@Test public void getCards()- 首先,我们将安排模拟卡片服务器将返回的内容。为此,我们将模拟一组小的结果以供学习:
server.stubFor(WireMock.get(WireMock.urlEqualTo("/cards")) .willReturn( WireMock.aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(""" [ { "cardId": "1", "album": "WWC23", "player": "Ivana Andres", "ranking": 7 }, { "cardId": "2", "album": "WWC23", "player": "Alexia Putellas", "ranking": 1 } ]""")));- 然后,我们可以使用
webTestClient执行请求并验证结果:
webTestClient.get().uri("/consumer/cards") .exchange().expectStatus().isOk() .expectBodyList(Card.class).hasSize(2) .contains(new Card("1", "WWC23", "Ivana Andres", 7), new Card("2", "WWC23", "Alexia Putellas", 1)); - 我们可以将其命名为
-
你可以为应用程序的其余功能编写测试。我创建了一些示例测试,你可以在书的 GitHub 仓库中找到它们,网址为
github.com/PacktPublishing/Spring-Boot-3.0-Cookbook,在chapter8/recipe8-3/end文件夹中。
它是如何工作的...
使用@WebFluxTest注解,我们可以定义仅关注 WebFlux 相关组件的测试类。这意味着它将禁用所有组件的配置,除了与 WebFlux 相关的组件。例如,它将配置带有@Controller或@RestController注解的类,但不会配置带有@Service注解的类。有了这个,Spring Boot 可以注入WebTestClient,我们可以用它来对我们的应用程序服务器执行请求。
在消费者应用程序中,我们需要模拟cards服务。我不会深入细节,因为机制与在第一章中解释的Mocking a RESTful API菜谱中相同。我们使用了一个带有@TestConfiguration注解的配置子类。这个注解允许配置可以与测试一起使用的 bean。在我们的例子中,我们只需要WireMockServer。然后,我们使用@DynamicPropertySource注解动态配置模拟服务器的 URI。
注意
要引用Config类,我们使用了ConsumerControllerTests.Config而不是仅仅Config。这样做的原因是它是ConsumerControllerTests类的一个子类。
我们使用了webEnvironment字段,将SpringBootTest.WebEnvironment.RANDOM_PORT赋值给它。这意味着测试将以服务的形式在随机端口上托管应用程序。我们使用这个选项是为了避免与远程服务器发生端口冲突。
使用 Spring Data R2DBC 连接到 PostgreSQL
使用 Reactive 数据库驱动程序是有意义的,因为我们需要将我们的 Reactive 应用程序连接到 PostgreSQL。这意味着当应用程序向数据库发出请求时,应用程序不会被阻塞。有一个 Java 规范,名为R2DBC,用于使用反应式驱动程序集成 SQL 数据库。Spring 框架通过 Spring Data R2DBC 支持 R2DBC,它是更大的 Spring Data 家族的一部分。
Spring Data R2DBC 将 R2DBC 的熟悉 Spring 抽象应用于其中。您可以使用R2dbcEntityTemplate,使用 Criteria API 和 Reactive Repositories 运行语句,以及其他功能。
在这个菜谱中,我们将学习如何使用 Reactive Repositories 连接到 PostgreSQL,以及 Reactive 和非 Reactive Repositories 之间的一些区别。我们还将学习如何配置 Flyway 进行数据库迁移。
准备工作
对于这个菜谱,我们需要一个 PostgreSQL 数据库。您可以使用第五章中“Connecting your application to PostgreSQL”菜谱的“准备工作”部分的说明。一旦您安装了 Docker,如上述菜谱中所述,您可以使用以下命令在 Docker 上运行 PostgreSQL 服务器:
docker run -itd -e POSTGRES_USER=packt -e POSTGRES_PASSWORD=packt -p 5432:5432 --name postgresql postgres
我还为此菜谱准备了一个起始项目,其中包含我们将用作数据实体以映射到数据库表的类,以及我们在 There’s more 部分用于 Flyway 迁移的数据库初始化脚本。你可以在本书的 GitHub 仓库中找到该项目,位于 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook,在 chapter8/recipe8-4/start 文件夹中。
如何做到这一点...
我们将配置一个应用程序以连接到 PostgreSQL。让我们变得反应式:
-
首先,我们将确保我们有所有必需的依赖项。为此,打开项目的
pom.xml文件并添加以下依赖项:-
org.springframework.boot:spring-boot-starter-webflux -
org.springframework.boot:spring-boot-starter-test -
io.projectreactor:reactor-test -
org.springframework.boot:spring-boot-starter-data-r2dbc -
org.postgresql:r2dbc-postgresql
-
-
接下来,我们将使用 R2DBC 驱动程序配置数据库连接。为此,打开
application.yml文件并添加以下配置:spring: application: name: football r2dbc: url: r2dbc:postgresql://localhost:5432/football username: packt password: packt注意,数据库 URL 不以
jdbc:开头,而是以r2dbc:开头。 -
然后,我们将配置我们想要映射到数据库的实体类。这些类位于
repo文件夹中。为了准备这些类,为每个类遵循以下步骤:-
将
@Table注解添加到类中。你可以将其名称设置为在数据库中定义的名称。 -
将
@Id注解添加到标识字段。我在所有实体类中将此字段命名为Id。
你可以在这里将
CardEntity类视为一个例子:@Table(name = "cards") public class CardEntity { @Id private Long id; private Optional<Long> albumId; private Long playerId; private Long ownerId; } -
-
我们可以为我们的实体创建存储库。例如,对于
CardEntity,我们将创建CardsRepository如下:public interface CardsRepository extends ReactiveCrudRepository<CardEntity, Long> { }你可以为其他实体做同样的操作。
-
我们将在
PlayersRepository中添加一个方法来通过名称查找玩家。为此,只需将以下方法定义添加到PlayersRepository接口:public Mono<PlayerEntity> findByName(String name); -
让我们创建一个新的服务来管理玩家。你可以将其命名为
PlayersService,因为它使用PlayersRepository,我们将将其作为参数添加到构造函数中,并让 Spring Boot 做其魔法注入:@Service public class PlayersService { private final PlayersRepository playersRepository; public PlayersService(PlayersRepository playersRepository) { this.playersRepository = playersRepository; } } -
现在,我们将使用存储库创建几个方法。例如,一个通过 ID 获取玩家的方法,另一个通过名称获取玩家的方法:
public Mono<Player> getPlayer(Long id) { return playersRepository.findById(id) .map(PlayerMapper::map); } public Mono<Player> getPlayerByName(String name) { return playersRepository.findByName(name) .map(PlayerMapper::map); }注意,这两种方法都使用了一个名为
PlayerMapper的类。我作为起始项目的一部分提供了这个类,用于在实体和应用程序返回的对象之间创建映射。 -
现在我们来创建一个更复杂的东西。我们将检索一张卡片及其相关数据,即如果已经分配,则包括
Album,以及卡片中的Player。- 让我们创建一个名为
CardsService的新服务类。这个服务需要CardsRepository、PlayersRepository和AlbumsRepository。我们将创建一个带有每种类型参数的构造函数:
@Service public class CardsService { private final CardsRepository cardsRepository; private final PlayersRepository playersRepository; private final AlbumsRepository albumsRepository; public CardsService(CardsRepository cardRepository, PlayersRepository playersRepository, AlbumsRepository albumsRepository) { this.cardsRepository = cardRepository; this.playersRepository = playersRepository; this.albumsRepository = albumsRepository; } }- 现在,添加一个获取类型为
Card的项的方法:
public Mono<Card> getCard(Long cardId) { return cardsRepository.findById(cardId) .flatMap(this::retrieveRelations) .switchIfEmpty(Mono.empty()); }- 如您在
getCard方法中看到的,有一个对retrieveRelations方法的引用。retrieveRelations方法从数据库中检索Player,如果定义了,还会检索Album。当然,我们将使用响应式方法来完成所有这些操作:
protected Mono<Card> retrieveRelations(CardEntity cardEntity) { Mono<PlayerEntity> playerEntityMono = playersRepository.findById(cardEntity.getPlayerId()); Mono<Optional<AlbumEntity>> albumEntityMono; if(cardEntity.getAlbumId() != null && cardEntity.getAlbumId().isPresent()){ albumEntityMono = albumsRepository.findById( cardEntity.getAlbumId().get()) .map(Optional::of); } else { albumEntityMono = Mono.just(Optional.empty()); } return Mono.zip(playerEntityMono, albumEntityMono) .map(tuple -> CardMapper.map(cardEntity, tuple.getT2(), tuple.getT1())); } - 让我们创建一个名为
-
您可以实现响应式 RESTful 端点来公开此功能,如本章中创建响应式 RESTful API配方中所述。我在本书的 GitHub 存储库中准备了一些示例,您可以在
chapter8/recipe8-4/end文件夹中找到,网址为github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/。我建议您查看还有更多部分,因为它包括 Flyway 来初始化数据库和一些使用Testcontainers的测试。
它是如何工作的...
显然,响应式实体和ReactiveCrudRepository与它们的非响应式对应物非常相似。ReactiveCrudRepository提供了相同的基本方法,例如findById和save,但存在一些重要差异:
-
响应式仓库不管理实体之间的关系。因此,我们没有定义任何
@OneToMany或@ManyToOne字段。实体之间的关系应该在我们的应用程序中显式管理,就像我们在getCard和retrieveRelations方法中所做的那样。 -
响应式仓库允许您定义遵循与非响应式仓库相同命名约定的方法,但返回
Mono用于单个结果和Flux用于多个结果。这些方法在数据库中转换为查询。您可以在 R2DBC 网页上找到有关命名约定的更多详细信息,网址为docs.spring.io/spring-data/relational/reference/r2dbc/query-methods.html。 -
我们在这个配方中没有使用它,但可以使用
@Query注解并提供一个 SQL 查询。这是一个原生查询;不支持 JPQL。
响应式编程模型利用请求的异步和非阻塞过程。请注意,在getCard方法中,当找到卡片时,专辑和玩家会异步且同时检索。通过使用Mono.zip方法实现并行,该方法允许同时执行多个非阻塞过程。
还有更多...
Flyway 不直接支持 R2DBC 驱动程序,但可以通过一些调整来使用。让我们看看如何操作:
-
首先,您必须在您的
pom.xml文件中添加 Flyway 依赖项:<dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-core</artifactId> </dependency> -
由于我们希望自动验证数据库迁移,我们还将包括
Testcontainers支持。对于Testcontainers,无需进行任何特定的响应式调整。<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <scope>test</scope> </dependency> -
现在,我们需要明确配置 Flyway 的连接。这样做的原因是 Flyway 只支持 JDBC 驱动程序;因此,我们需要指定数据库 URL 的
jdbc:版本。这种配置可以在application.yml文件中应用。它应该看起来像这样:spring: r2dbc: url: r2dbc:postgresql://localhost:5432/football username: packt password: packt flyway: url: jdbc:postgresql://localhost:5432/football user: packt password: packt我们可以在支持
Testcontainers的测试中设置此配置。让我们看看测试PlayersService的类可能看起来像什么:@Testcontainers @SpringBootTest @ContextConfiguration(initializers = PlayersServiceTest.Initializer.class) class PlayersServiceTest { @Autowired private PlayersService playersService; static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:latest") .withDatabaseName("football") .withUsername("football") .withPassword("football"); static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> { public void initialize(ConfigurableApplicationContext configurableApplicationContext) { TestPropertyValues.of( "spring.flyway.url=" + postgreSQLContainer.getJdbcUrl(), "spring.flyway.user=" + postgreSQLContainer.getUsername(), "spring.flyway.password=" + postgreSQLContainer.getPassword(), "spring.r2dbc.url=" + postgreSQLContainer.getJdbcUrl().replace("jdbc:", "r2dbc:"), "spring.r2dbc.username=" + postgreSQLContainer.getUsername(), "spring.r2dbc.password=" + postgreSQLContainer.getPassword()) .applyTo(configurableApplicationContext.getEnvironment()); } } @BeforeAll public static void startContainer() { postgreSQLContainer.start(); }
注意,上下文配置从 PostgreSQL 测试容器获取数据库配置。由于 PostgreSQLContainer 只返回 URL 的 JDBC 版本,我们将 jdbc: 字符串替换为 r2dbc: 以用于 R2DBC 驱动程序,同时保留 Flyway 的 JDBC URL 版本。
其余的都是标准的 Flyway 配置。示例项目在 resources/db/migration 默认文件夹中提供了数据库初始化脚本。
使用 Spring Cloud Stream 和 RabbitMQ 的事件驱动应用程序
我们希望通过使用比赛中的事实(如进球或红牌)来增强我们的足球应用程序的体验。我们可以使用这些信息来准备包含比赛期间发生的所有事件的时序表或更新比赛得分。我们预计将来可以使用这些信息来实现其他功能,例如实时准备球员统计数据或使用统计数据创建球员排名。
对于这种场景,我们可以应用一个事件驱动架构设计。这种设计包括检测、处理和实时发生的事件的反应。通常有两种类型的组件:事件生产者和事件消费者,它们是松散耦合的。Spring Cloud Stream 是支持使用共享消息系统(如 Kafka 或 RabbitMQ)进行通信的事件驱动应用程序的 Spring 项目。
在这个配方中,我们将学习如何使用 Spring Cloud Stream 创建一个应用程序,该应用程序可以发出足球比赛事件,并创建一个订阅这些事件的另一个应用程序。我们还将学习如何配置应用程序以使用 RabbitMQ 作为消息系统。
准备工作
在这个配方中,我们将使用 RabbitMQ。您可以通过遵循官方网站 www.rabbitmq.com/docs/download 上的说明在您的计算机上安装它。我建议在本地使用 Docker 运行它。在您的终端中输入以下命令,您可以在 Docker 上下载并运行 RabbitMQ:
docker run -p 5672:7672 -p 15672:15672 \
-e RABBITMQ_DEFAULT_USER=packt \
-e RABBITMQ_DEFAULT_PASS=packt \
rabbitmq:3-management
使用的镜像包括管理门户。您可以使用 packt 作为用户名和密码,在 http://localhost:15672 访问它。
如何操作...
我们将创建两个应用程序,并将它们通过 RabbitMQ 连接起来。我们将利用 Spring Cloud Stream 的力量来实现这一点:
-
我们将首先使用 Spring Boot Initializr 工具创建两个应用程序:
-
matches:此应用程序将产生比赛事件,并将它们发布到 RabbitMQ -
timeline:此应用程序将处理发布到所有事件以创建比赛时间线
为了做到这一点,为每个应用程序重复此步骤。在浏览器中打开
start.spring.io,并使用与第一章中在创建 RESTful API中相同的选项,除了以下选项:-
对于
matches或timeline。 -
对于依赖项,不要选择任何启动器
我们将向三个应用程序添加以下依赖项:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-stream-binder-rabbit</artifactId> </dependency> -
-
下一步将是配置
matches应用程序以发出事件:- 首先,我们将定义一个将用于表示比赛事件的记录:
public record MatchEvent(Long id, Long matchId, LocalDateTime eventTime, int type, String description, Long player1, Long player2) { }-
您可以创建一个嵌套的构建器类来简化操作。为了简洁起见,我没有在这个示例中包含构建器代码,但您可以在书籍的 GitHub 存储库中找到实现。
-
接下来,我们将在
MatchesApplication类中定义一个 bean,该 bean 配置Supplier<Message<MatchEvent>>:
@Bean public Supplier<MatchEvent> matchEvents() { Random random = new Random(); return () -> { return MatchEvent.builder() .withMatchId(1L) .withType(random.nextInt(0, 10)) .withEventTime(LocalDateTime.now()) .withDescription("random event") .withPlayer1(null) .withPlayer2(null) .build(); }; }-
此 bean 生成带有随机分配给
type字段的MatchEvent消息。 -
最后,我们将配置应用程序以使用 RabbitMQ。我们将把刚刚创建的 bean 绑定到名为
match-events-topic的 RabbitMQ 交换机,并配置绑定以将路由键消息属性映射到eventType消息头。为此,打开application.yml文件并设置以下配置:
spring: rabbitmq: host: localhost username: packt password: packt port: 5672 cloud: stream: bindings: matchEvents-out-0: destination: match-events-topic确保在准备就绪部分中使用的
spring.rabbitmq属性与启动 RabbitMQ 容器的参数一致。验证绑定名称是否与暴露 bean 的方法匹配。现在,
matches应用程序已准备好开始产生事件。您现在可以启动它。 -
接下来,我们将配置
timeline应用程序。该应用程序将消费matches应用程序产生的所有事件。为此,请执行以下操作:- 首先,创建一个
Consumer<MatchEvent>bean。打开TimelineApplication类并添加以下代码:
@Bean public Consumer<MatchEvent> processMatchEvent() { return value -> { System.out.println("Processing MatchEvent: " + value.type()); }; }-
为了清晰起见,我们没有创建一个包含三个应用程序之间共享代码的库。因此,您还需要在这个项目中定义
MatchEvent记录。 -
对于这个项目,我们只需要配置 RabbitMQ 配置,将名为
timeline的输入队列绑定到在步骤 2中创建的match-events-topic交换机,并将processMatchEvent函数绑定到输入队列。这可以通过以下方式配置application.yml文件:
spring: rabbitmq: host: localhost username: packt password: packt port: 5672 cloud: stream: function: bindings: processMatchEvent-in-0: input bindings: input: destination: match-events-topic group: timeline时间线应用程序现在已准备好处理消息。只需运行应用程序,它就会开始处理消息。您将在控制台看到事件处理时的消息。
- 首先,创建一个
它是如何工作的...
spring-cloud-stream-binder-rabbit 依赖项包括 Spring Cloud Stream 启动器,并为 RabbitMQ 提供特定的绑定。Spring Cloud Stream 启动器提供了创建无特定底层消息技术引用的事件驱动应用程序所需的抽象。在我们的应用程序中,我们没有在代码中使用任何显式的 RabbitMQ 组件。因此,我们只需更改依赖项和配置,就可以切换到另一个消息系统,例如 Kafka,而无需更改代码。
Spring Cloud Stream 允许将注册为 bean 的 Supplier 函数绑定到给定的目的地,在我们的例子中是 match-events-topic。这意味着 Supplier 函数产生的消息将被发送到该目的地。我们使用 spring-cloud-stream-binder-rabbit,它包括绑定到 RabbitMQ 的绑定。当我们启动 matches 应用程序时,RabbitMQ 创建了一个 交换机。交换机是生产者和消费者应用程序之间的中介组件。生产者应用程序永远不会直接向消费者发送消息。然后,根据交换机的类型,消息被发送到一个或多个队列或被丢弃。当我们启动 matches 应用程序而 timeline 应用程序尚未运行时,交换机在 RabbitMQ 中创建,但由于没有订阅者,消息被丢弃。在 RabbitMQ 门户中,您会看到如下内容:

图 8.1:RabbitMQ 中的 match-events-topic
如您所见,应用程序每秒调用一次 Supplier 函数,这是 Spring Cloud Stream 的默认配置。尽管如此,由于尚未定义如何路由消息,因此消息被丢弃。实际上,如果您检查 队列和流 选项卡,您将看到没有定义任何队列。
在 timeline 应用程序中,我们配置了 match-event-topic 交换机和消息被转发到的目标队列之间的绑定。它定义在 spring.cloud.stream.binding.input 属性中。我们使用 destination 属性指定了 match-events-topic,并使用 group 属性定义了目标队列。然后,通过 spring.cloud.stream.function.bindings 属性,我们定义了该队列与注册为 bean 以处理消息的函数之间的链接。启动 timeline 应用程序后,您将看到 match-events-topic 有一个绑定,它将 match-events-topic 交换机连接到名为 match-events-topic.timeline 的队列。您可以在 RabbitMQ 中检查这一点。它应该看起来像这样:

图 8.2:绑定到 match-events-topic 的 Timeline 队列
如您在 RabbitMQ 门户中看到的那样,Spring Cloud Stream 创建了交换机和队列,并配置了绑定以将所有消息路由到队列。
还有更多...
在这个食谱中,我们使用了函数式方法来发送消息,让 Spring Stream Cloud 调用 Supplier 函数来生成消息。在许多场景中,我们必须更明确地决定何时发送消息。为此,你可以使用 StreamBridge 组件。这个组件让你在只指定要使用的绑定的情况下发送消息。
让我们看看一个例子。你可以创建一个服务组件,它在构造函数中接收一个 StreamBridge,然后 Spring Boot 将在运行时注入一个实例:
@Service
public class MatchService {
private StreamBridge streamBridge;
private final String bindingName;
public MatchService(StreamBridge streamBridge,
@Value("${spring.cloud.stream.bindings.matchEvents-out-0.destination}") String bindingName) {
this.streamBridge = streamBridge;
this.bindingName = bindingName;
}
}
然后,你可以使用 StreamBridge 来发送消息:
public void createEvent(MatchEvent matchEvent) {
streamBridge.send(bindingName, matchEvent);
}
参见
RabbitMQ 根据消息的路由方式提供不同类型的交换:
-
直接: 消息根据路由键被转发到一个队列。
-
扇出: 消息被转发到所有有界队列,无论路由键是什么。
-
主题: 消息根据交换中定义的模式和队列中定义的路由键路由到有界队列。这是默认类型。在下一个食谱中,我们将进一步探讨这个场景。
-
头信息: 这与主题交换类似,但 RabbitMQ 使用消息头而不是路由键来路由消息。
使用 Spring Cloud Stream 和 RabbitMQ 路由消息
我们决定利用上一个食谱中生成的匹配事件来更新足球比赛的比分。我们将创建一个新的应用程序,订阅进球事件以实现这个功能。
在这个食谱中,我们将学习如何配置我们的 Spring Cloud Stream 生产者应用程序,根据消息头设置路由键。然后,我们将学习如何配置消费者应用程序,根据路由键的模式匹配设置队列绑定。
准备就绪
这个食谱从 基于 Spring Cloud Stream 和 RabbitMQ 的事件驱动应用程序 食谱的结果开始。我准备了一个工作版本,以防你还没有完成那个食谱。你可以在书的 GitHub 仓库中找到它,在 chapter8/recipe8-6/start 文件夹中。github.com/PacktPublishing/Spring-Boot-3.0-Cookbook
正如 基于 Spring Cloud Stream 和 RabbitMQ 的事件驱动应用程序 食谱中一样,你需要一个 RabbitMQ 服务器。要在你的计算机上设置 RabbitMQ 服务,请遵循该食谱中 准备就绪 部分的说明。
如何做到这一点...
让我们对生产者应用程序进行一些必要的调整,然后我们将使用 Spring Cloud Stream 的强大功能来为我们设置所有 RabbitMQ 绑定:
-
首先,我们将修改
matches生产者应用程序以在消息中包含一些头信息。为此,打开MatchesApplication类并按如下方式修改matchEvents方法:-
修改方法的签名;而不是返回
MatchEvent,我们将返回Message<MatchEvent>。 -
我们将使用
MessageBuilder来创建返回的Message<MatchEvent>。 -
我们将包括一个名为
eventType的新头信息。我们假设type字段等于2的事件是目标。
该方法应如下所示:
@Bean public Supplier<Message<MatchEvent>> matchEvents() { Random random = new Random(); return () -> { MatchEvent matchEvent = MatchEvent.builder() .withMatchId(1L) .withType(random.nextInt(0, 10)) .withEventTime(LocalDateTime.now()) .withDescription("random event") .withPlayer1(null) .withPlayer2(null) .build(); MessageBuilder<MatchEvent> messageBuilder = MessageBuilder.withPayload(matchEvent); if (matchEvent.type() == 2) { messageBuilder.setHeader("eventType", "football.goal"); } else { messageBuilder.setHeader("eventType", "football.event"); } return messageBuilder.build(); }; } -
-
接下来,我们将更改
matches应用程序的配置,将eventType头信息的值分配给路由键。为此,我们将在application.yml文件中配置 rabbit 生产者绑定routing-key-expression属性。Spring Cloud 配置应如下所示:spring: cloud: stream: rabbit: bindings: matchEvents-out-0: producer: score. -
接下来,创建一个返回
Consumer<MatchEvent>的 bean。你可以在ScoreApplication类中定义它。这个函数将处理接收到的事件。它可能看起来像这样:@Bean public Consumer<MatchEvent> processGoals() { return value -> { logger.info("Processing goal from player {} at {} ", value.player1(), value.eventTime()); }; } -
现在,配置绑定到
match-event-topic交换机的绑定。在这个应用程序中,我们将使用模式匹配设置绑定路由键。正如我们在 步骤 1 和 步骤 2 中定义的,目标将具有football.goal的值。因此,配置将如下所示:spring: cloud: stream: rabbit: bindings: input: consumer: bindingRoutingKey: football.goal.# function: bindings: processGoals-in-0: input bindings: input: destination: match-events-topic group: score -
你可以运行
score应用程序。你应该检查它是否只接收目标事件。如果你运行我们在 使用 Spring Cloud Stream 和 RabbitMQ 的驱动应用程序 菜谱中创建的timeline应用程序,你会看到它接收所有事件,包括目标。
小贴士
请记住,Spring Cloud Stream 不会在停止应用程序时自动删除绑定。因此,你可能需要手动删除先前执行的绑定、交换机或队列。你可以从 RabbitMQ 门户中这样做。
它是如何工作的...
这个菜谱中的关键概念是 路由键,这是生产者添加到消息头部的属性。然后交换机可以使用路由键来决定如何路由消息。在消费者端,可以定义一个绑定来根据路由键将队列与交换机链接起来。
在这个菜谱中,我们使用了 routing-key-expression 来根据消息的属性(如 header 或 payload)设置路由键。Spring RabbitMQ binder 允许使用 Spring 表达式语言(SpEL)来定义消息的路由键。binder 评估表达式并设置路由键的值。
如果你查看 RabbitMQ 生成的消息,你会看到 Routing Key 和 headers 的值。

图 8.3:RabbitMQ 中的消息显示路由键
在消费者端,当将队列绑定到交换机时,可以使用模式匹配值。与给定模式匹配的路由键的消息将被转发到相应的队列。如果你查看 RabbitMQ 门户中的 match-events-topic 的绑定,你会看到绑定了两个队列,每个队列使用不同的路由键。

图 8.4:使用不同路由键绑定的队列
使用此配置,RabbitMQ 只会将匹配football.goal.#路由键的消息发送到match-events-topic.score队列。也就是说,所有路由键以football.goal开头的消息。它将所有消息发送到match-events-topic.timeline队列,因为#符号充当通配符。
参考信息
您可以在项目的页面docs.spring.io/spring-framework/reference/core/expressions.html上找到更多关于 SpEL 的信息。它支持在运行时查询和操作对象图,并且所有 Spring 项目都广泛使用它。
使用 Spring Cloud Stream 处理错误
我们在先前的菜谱中开发的得分应用程序已经成为足球比赛的关键,因为它管理着最终的比分。因此,我们需要确保解决方案的健壮性。
Spring Cloud Stream 可以实现高可用性、松耦合、弹性系统。原因是底层消息传递系统,如 RabbitMQ,提供了不同的机制来确保消息被发送到预期的目的地。首先,消息可以在交付之前排队。如果由于任何原因,消费者应用程序尚未准备好或发生暂时性故障,它将在准备好后再次处理排队中的消息。您可以使用相同的机制增加消费者数量以提高系统的吞吐量。您还可以在应用程序发生暂时性故障时配置重试策略,或者将其转发到名为死信队列(DLQ)的特殊队列。DLQ 是消息传递系统中的常见机制;DLQ 可以接收所有由于任何原因无法正常处理的消息;其主要目的是提供保留无法处理的消息的能力,这不仅限于技术上的暂时性错误,还包括与软件问题相关的错误。
在这个菜谱中,我们将学习如何为我们的应用程序配置死信队列(DLQ)和重试策略,并了解在出现错误时应用程序的表现。
准备工作
本菜谱使用使用 Spring Cloud Stream 和 RabbitMQ 路由消息菜谱的结果作为起点。如果您尚未完成,可以使用我在书中 GitHub 仓库中准备的版本,该版本可在github.com/PacktPublishing/Spring-Boot-3.0-Cookbook的chapter8/recipe8-7/start文件夹中找到。
正如使用 Spring Cloud Stream 和 RabbitMQ 构建事件驱动应用程序菜谱中所述,您将需要一个 RabbitMQ 服务器。要在您的计算机上设置 RabbitMQ 服务,请遵循该菜谱中准备就绪部分的说明。
如何操作...
在我们为应用程序配置错误处理之前,我们将在我们的应用程序中模拟一些暂时性错误:
-
让我们修改我们的分数应用程序,在消息处理时引入随机错误。我们将模拟 80%的时间发生错误。为此,打开
ScoreApplication类的processGoals方法,并用以下代码替换代码:@Bean public Consumer<MatchEvent> processGoals() { Random random = new Random(); return value -> { if (random.nextInt(0, 10) < 8) { logger.error("I'm sorry, I'm crashing..."); throw new RuntimeException("Error processing goal"); } logger.info("Processing a goal from player {} at {} ", value.player1(), value.eventTime()); }; } -
现在,我们将配置应用程序以自动创建一个 DLQ。为此,打开
score应用程序的application.yml文件,并将spring.cloud.string.rabbit.bindings.input.consumer部分中的autoBindDlq属性设置为true。它应该看起来像这样:spring: cloud: stream: rabbit: bindings: input: consumer: bindingRoutingKey: football.goal.# match-events-topic.score.dlq. It’s bound to the dead-letter exchange (DLX).

图 8.5:DLQ
-
现在,我们将配置重试策略。例如,我们可以在消息被路由到 DLQ 之前配置三次尝试,并将处理消息的最大时间设置为 1 秒,即其
spring.cloud.string.rabbit.bindings.input.consumer.maxAttempts=3。 -
spring.cloud.string.rabbit.bindings.input.consumer.ttl=1000。这个属性以毫秒为单位表示。 -
您现在可以运行应用程序。如果您让它运行一段时间,您将在分数应用程序中看到一些错误,并且一些消息将到达死信队列(DLQ)。

图 8.6:DLQ 消息
- 您还可以停止并重新启动
score应用程序。在 RabbitMQ 门户中,您可以验证消息是否已排队在match-events-topic.score队列中,并且一旦应用程序再次准备好,它将处理所有挂起的消息。
它是如何工作的...
当一个队列绑定到一个交换机时,每当交换机中有新消息时,新消息就会被转发到队列。消息在队列中持续,直到被处理或 TTL 过期。在我们的案例中,消费者应用程序,即score应用程序,会尝试读取和处理;如果由于任何原因在完成处理之前失败,消息将返回到队列。这个过程可以执行配置的尝试次数,在我们的案例中,是三次尝试。最后,如果消息无法处理或 TTL 过期,则消息将被转发到 DLQ。
在我们的示例中,我们为了演示目的配置了 80%的失败率。如果我们降低失败率,到达 DLQ 的消息会更少。
该解决方案更加健壮,因为一旦消息被排队,我们可以确保它将被处理,无论score应用程序是否可用。score应用程序可能由于许多原因在给定时间内不可用,包括暂时性错误和计划性维护。对于这些功能,这种类型的解决方案在微服务架构和云解决方案中非常流行。假设消息服务具有高可用性,例如,通过部署具有冗余服务器的集群或仅使用云提供商提供的平台即服务(PaaS)。在 Spring Cloud Stream 页面spring.io/projects/spring-cloud-stream上,您可以找到兼容的绑定器;其中一些由 Spring Cloud 团队直接维护,而其他一些由合作伙伴维护。
参见
您可以配置多个消费者应用程序。如果一个消费者实例无法处理一条消息,另一个实例可以处理它。通过这种方法,通过添加更多消费者来扩展应用程序是可能的。这种设计被称为竞争消费者。您可以在 Azure 架构中心找到关于此场景的良好描述:learn.microsoft.com/en-us/azure/architecture/patterns/competing-consumers。该实现建议使用 Azure 产品,但您可以使用其他技术,如 RabbitMQ,应用相同的原理。
其他设计模式依赖于我们工具箱中值得拥有的队列系统。例如:
-
异步请求-回复:在此模式中,您可能需要快速响应用户应用程序,例如网页浏览器。然而,操作可能需要更长的时间才能响应。为了解决这个问题,应用程序将请求保存到队列中,并异步处理它。应用程序公开一个端点以获取请求状态;然后,客户端可以定期检查请求状态。
-
基于队列的负载均衡。某些应用程序可能依赖于具有有限容量的后端,但可能会出现负载峰值。在这些具有不可预测需求的情况下,队列充当缓冲区,消费者应用程序以不会超出后端容量的速度处理请求。
第四部分:从旧版本升级到 Spring Boot 3
在应用程序的生命周期中,大部分投入的时间都与维护相关。一个成功的应用程序可能持续数年或数十年。在这段时间里,它可能需要升级以实现其进化。您可能有一个希望进化并利用 Spring Boot 3 功能的现有应用程序。在本部分,我们将学习如何将现有应用程序从 Spring Boot 2 升级到 Spring Boot 3。
本部分包含以下章节:
- 第九章**, 从 Spring Boot 2.x 升级到 Spring Boot 3.0*
第九章:从 Spring Boot 2.x 升级到 Spring Boot 3.0
在应用程序的生命周期中,大部分投入的时间都与维护相关。一个成功的应用程序可能持续数年或数十年。在这段时间里,它可能需要升级以适应其发展。你可能有一个想要利用 Spring Boot 3 特性进行演化的应用程序。在本章中,我们将使用一个我使用 Spring Boot 2.6 创建的示例应用程序,并在每个菜谱中进行逐步升级。本章中的菜谱应按顺序完成,因为我们将使用一个菜谱的结果作为下一个菜谱的起点。一些菜谱可能不会产生一个可工作的版本,因为在后续的菜谱中可能存在需要修复的编译错误。最后一个菜谱,使用 OpenRewrite 进行迁移自动化,可以在不完成任何先前菜谱的情况下进行。然而,它需要一些在先前菜谱中解释的手动操作。
在本章中,我们将介绍以下菜谱:
-
准备应用程序
-
准备 Spring Security
-
检测属性变更
-
将项目升级到 Spring Boot 3
-
升级 Spring Data
-
管理 Actuator 变更
-
管理网络应用程序变更
-
使用 OpenRewrite 进行迁移自动化
技术要求
在本章中,我们除了 JDK 和 IDE 之外不需要任何额外的工具,就像前几章一样。
请记住,Spring Boot 3.0 需要 Java 17 或更高版本。要将现有项目迁移到 Spring Boot 3.0 并使用 Java 11,您必须升级到 Java 17。
在迁移到 Spring Boot 3.0 之前,我建议你升级到最新的 Spring Boot 2 版本,2.7.x。
在本章中,我们将使用一个 Spring Boot 2 示例,并对其进行修改,直到最终迁移到 Spring Boot 3 的最新版本。该应用程序访问 PostgreSQL 数据库和 Cassandra 数据库。我们将使用 Docker 运行这两个服务器。在您的计算机上运行 Docker 的官方页面是 docs.docker.com/engine/install/。该应用程序有一些基于 Testcontainers 的测试,因此您需要在您的计算机上安装 Docker。
要在 Docker 上运行 PostgreSQL,你可以在你的终端中运行以下命令:
docker run -itd -e POSTGRES_USER=packt -e POSTGRES_PASSWORD=packt \
-p 5432:5432 --name postgresql postgres
您需要创建一个名为 football 的数据库。您可以通过运行以下 PSQL 命令来完成此操作:
CREATE DATABASE football;
要在 Docker 上运行 Cassandra,你可以在你的终端中运行以下命令:
docker run -p 9042:9042 --name cassandra -d cassandra:latest
本章中将展示的所有菜谱都可以在以下位置找到:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook/tree/main/chapter9。
接下来,我们将学习如何准备应用程序。
准备应用程序
在 Spring Boot 的每个版本中,一些组件被标记为弃用,并且通常会有一个更改建议以避免弃用组件。由于从 Spring Boot 2 升级到 Spring Boot 3 是一个重大变更,强烈建议升级到最新的 Spring Boot 2 版本。
在将应用程序迁移到 Spring Boot 3 之前,以下准备工作是推荐的:
-
将 Spring Boot 版本升级到最新的 2.7.x 版本。在撰写本书时,它是 2.7.18。这将有助于升级到 Spring Boot 3。
-
将 Java 版本更新到 Java 17,这是 Spring Boot 3 的最小支持版本。
-
解决所有弃用组件。
在这个菜谱中,我们将准备一个使用 Spring 2.6 和 Java 11 的示例应用程序。到菜谱结束时,应用程序将使用 Spring 2.7.18,Java 17,并且所有弃用组件和 API 都将得到解决。
准备工作
在这个菜谱中,你将使用 Spring 2.6 和 Java 11 准备一个应用程序。示例应用程序位于本书的 GitHub 仓库github.com/PacktPublishing/Spring-Boot-3.0-Cookbook的chapter9/football文件夹中。
根据你的操作系统,你可能需要使用不同的工具来管理当前的 JDK 版本。例如,你可以在 Linux 和 Mac 上使用 SDKMAN!工具。你可以通过遵循项目页面sdkman.io/上的说明来安装它。
要运行应用程序,你需要在你的计算机上运行一个 PostgreSQL 服务器。要获取它,请遵循技术 要求部分中的说明。
如何操作...
让我们把应用程序放在起跑线上。准备, steady, go!
-
首先,我们将解决所有弃用警告。例如,如果你通过执行
mvn compile来编译足球项目,你将看到一个关于DataSourceInitializationMode类的弃用警告。如果你打开该类的文档docs.spring.io/spring-boot/docs/2.6.15/api/org/springframework/boot/jdbc/DataSourceInitializationMode.html,你将看到以下信息:弃用。
自 2.6.0 起弃用,在 3.0.0 中移除,以 DatabaseInitializationMode 取而代之
如果你使用 IDE,如 IntelliJ 或 Visual Studio Code,你可以在编辑器中直接看到此信息。例如,在 Visual Studio Code 中,你会看到以下内容:

图 9.1:Visual Studio Code 中的弃用消息
-
现在,让我们将
DataSourceInitializationMode替换为DatabaseInitializationMode。这个更改非常直接,只需要简单的替换即可。其他弃用更改可能需要一些代码重构。通常,弃用类的文档会指导更改的实现。 -
下一步将是将 Spring Boot 版本更新到 2.7.18。为此,打开
pom.xml文件并找到以下代码片段:<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.15</version> <relativePath/> <!-- lookup parent from repository --> </parent>这指的是正在使用的 Spring Boot 版本。在突出显示的代码中,你可以看到实际版本是 2.6.15。让我们将其更新到可用的最新版本 2。在撰写本书时,它是 2.7.18。
如果你重新编译应用程序,你将在
SecurityConfiguration.java文件中看到一些与 Spring Security 相关的弃用警告。我们将在 升级 Spring Security 的配方中修复这些警告。 -
现在,将 Java 版本更新到 Java 17。你应该确保你正在使用 JDK 17:
-
如果你使用的是 Linux 或 Mac,你可以使用 SDKMan!
-
在 Windows 上,你可能需要更新 Java Home 环境变量。
-
不论操作系统如何,你都可以使用 IDE 来管理 Java 版本:
-
例如,在 Visual Studio Code 中,你可以遵循
code.visualstudio.com/docs/java/java-project#_configure-runtime-for-projects中的说明来配置项目的 Java 运行时。 -
在 IntelliJ 中,你可以遵循 https://www.jetbrains.com/help/idea/sdk.html#manage_sdks 中的说明来完成此操作。
-
你还应该在项目中更改 Java 版本。为此,打开
pom.xml文件并查找属性java.version:<properties> <java.version>11</java.version> <testcontainers.version>1.19.7</testcontainers.version> </properties>将
java.version属性替换为17。 -
-
再次构建应用程序并验证,只有与
SecurityConfiguration.java文件相关的弃用警告。再次,如果有其他弃用警告,请尝试使用弃用警告消息中提出的替代解决方案。
它是如何工作的...
每次有新版本发布时,都可能有一些组件被标记为弃用,并且正如我们在本配方中看到的,通常也已知它们将在哪个版本中被移除。逐步安装升级并跳过中间版本是很方便的。正如我们在本例中看到的,迁移所有修订版本,如 2.7.1、2.7.2 等,是不必要的。然而,你不应该跳过小版本,如 2.7.x,因为可能会有新的组件被标记为弃用,并在版本 3 中被移除。跳过 2.7 升级将不会让你看到警告和替代方案。突然间,你会发现你使用的类找不到。
参见
Spring Boot 3.0 使用 Spring Framework 6.0。如果你的项目对先前版本有明确的依赖,你也需要升级它们。你可以在 github.com/spring-projects/spring-framework/wiki/Upgrading-to-Spring-Framework-6.x 找到 Spring Framework 的迁移指南。
准备 Spring Security
Spring Boot 3 中关于安全的主要变化是从 Spring Security 5 升级到 Spring Security 6。与这次升级相关的有很多变化,但在本食谱中,我们将关注最常见的一个,即它的配置方式。
在 Spring Security 5 中,大多数设置都是通过扩展WebSecurityConfigurerAdapter类来配置的,而在 Spring Security 6 中,这些更改通过在我们的应用程序中配置特定的 bean 来应用。
在这个食谱中,我们将WebSecurityConfigurerAdapter类转换为一个配置类,该类公开了应用等效配置的 bean。
准备工作
本食谱的起点是准备应用程序食谱的结果。我准备了一个工作版本,以防你还没有完成。你可以在本书的 GitHub 仓库github.com/PacktPublishing/Spring-Boot-3.0-Cookbook的chapter9/recipe9-2/start文件夹中找到它。
如何做到这一点...
让我们通过适配与 Spring Security 相关的已弃用组件来完成我们应用程序的准备:
-
如果你现在以这种方式编译项目,你会看到
SecurityConfig类包含一些弃用警告。让我们来处理它们:-
WebSecurityConfigurerAdapter类已弃用。现在,应用程序将不再扩展任何类。 -
withDefaultPasswordEncoder方法已弃用,不推荐在生产环境中使用。然而,如文档所述,它对于演示和入门是可接受的。我们不会更改它。
-
-
SecurityConfig类被注解为@EnableWebSecurity。此外,它应该被注解为@Configuration。它应该看起来像这样:@Configuration @EnableWebSecurity public class SecurityConfig -
接下来,我们将更改接收
AuthenticationManagerBuilder作为参数的configure方法,以创建一个配置InMemoryUserDetailsManagerbean 的方法:@Bean InMemoryUserDetailsManager userDetailsManager() { UserDetails userAdmin = User.withDefaultPasswordEncoder() .username("packt") .password("packt") .roles("ADMIN") .build(); UserDetails simpleUser = User.withDefaultPasswordEncoder() .username("user1") .password("user1") .roles("USER") .build(); return new InMemoryUserDetailsManager(userAdmin, simpleUser); } -
现在,我们将用返回
WebSecurityCustomizerbean 的方法替换接收WebSecurity的configure方法。它应该看起来像这样:@Bean WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web.ignoring() .antMatchers("/security/public/**"); } -
为了完成
SecurityConfig类,我们必须用创建SecurityFilterChainbean 的方法替换接收HttpSecurity参数的configure方法:@Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .authorizeRequests(authorizeRequests -> { try { authorizeRequests .antMatchers("/").permitAll() .antMatchers("/security/private/**") .hasRole("ADMIN") .and() .httpBasic(); } catch (Exception e) { e.printStackTrace(); } }).build(); } -
现在,如果我们执行测试,我们会发现使用
@WebMvcTest注解的控制器相关的测试不再按预期工作。我们将在FootballControllerTest和SecurityControllerTest类中包含@Import注解来修复它。-
FootballControllerTest:@WebMvcTest(FootballController.class) @Import(SecurityConfig.class) class FootballControllerTest -
SecurityControllerTest:@WebMvcTest(SecurityController.class) @Import(SecurityConfig.class) class SecurityControllerTest
-
-
最后,执行测试并验证所有测试都通过。
它是如何工作的...
主要变化是WebSecurityConfigurerAdapter类的弃用。这个类在 Spring Security 6 中被移除,因此从 Spring Boot 3 开始也被移除。在这个食谱中,我们为将项目升级到 Spring Boot 3 做好了平滑的安全迁移准备。新方法是为我们想要配置的每个安全方面创建 bean。
Spring Security 6 引入了新的方法来替换 antMatcher 方法;例如,requestMatcher 方法。为了避免在这个阶段有更多更改,我们还没有替换 antMatcher 方法。但是,一旦我们升级到 Spring Boot 3,我们就会这样做,因为这将不再被支持。
在本食谱中,我们继续使用 withDefaultPasswordEncoder。尽管它已被弃用且不建议在生产环境中使用,但在开发环境中使用它是可接受的。Spring Boot 2 中存在弃用警告,并且该方法不会很快被移除。弃用是为了警告生产环境中的使用。
由于安全设置现在是使用 @Configuration 注解的配置类定义的,因此将它们导入我们的 @WebMvcTest 测试中是必要的。@WebMvcTest 注解仅加载 MVC 相关组件,并且默认情况下不会加载 SecurityConfig。因此,将它们导入我们的 @WebMvcTest 测试中是必要的。
参见
从 Spring Security 5 升级到 Spring Security 6 包含的更改比本食谱中处理的变化要多。您可以在官方网站上找到完整的迁移指南,网址为 docs.spring.io/spring-security/reference/6.0/migration/index.html。
检测属性更改
正如我们在本书中学到的,开发 Spring Boot 应用程序时的主要任务之一是配置其组件。需要注意的是,每次发布新的组件版本时,属性可能会有所变化。这尤其是在升级到主要版本时,例如从 2.x 版本升级到 3.0 版本。通常,这些变化是渐进的。例如,一个属性可能在某个版本中被标记为 已弃用,这意味着它将在未来的版本中被移除。因此,建议在升级时不要跳过任何版本。例如,如果您计划从版本 2.6 升级到版本 3.2,最好首先升级到版本 2.7,然后是 3.0,然后是 3.1,最后是 3.2。
为了解决版本之间的属性更改,Spring Boot 提供了一个名为 Spring Boot Properties Migrator 的工具。这是一个依赖项,它会在应用程序启动时分析应用程序环境并打印诊断信息。
在本食谱中,我们将使用 Spring Boot Properties Migrator 在我们的项目中检测配置文件中的弃用属性并修复它们。
准备工作
我们将使用 Preparing Spring Security 食谱的结果开始本食谱。如果您还没有完成它,可以使用我准备的项目作为起点。您可以在本书的 GitHub 仓库中找到它,网址为 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook,在 chapter9/recipe9-3/start 文件夹中。
您需要在计算机上运行 PostgreSQL 服务器和 Cassandra 才能运行应用程序。有关说明,请参阅本章中的 技术要求 部分。
如何操作...
我们的应用程序使用了过时的属性;让我们使用 Spring Boot Properties Migrator 来修复它们!
-
首先,我们将 Spring Boot Properties Migrator 依赖项添加到我们的项目中。为此,打开
pom.xml文件,并添加以下依赖项:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-properties-migrator</artifactId> <scope>runtime</scope> </dependency> -
接下来,您可以运行应用程序,并看到属性文件中应该迁移的属性的错误。例如,我在终端中执行了
mvn spring-boot:run,并收到了以下消息:

图 9.2 – 使用 Spring Boot 属性迁移执行应用程序时出错
如您所见,我们需要迁移 spring.datasource.initialization-mode 属性,并使用 spring.sql.init.mode 代替。
-
然后,我们将
spring.datasource.initialization-mode属性替换为spring.sql.init.mode。为此,打开application.yml文件,找到initialization-mode属性,并将其删除。然后,添加spring.sql.init.mode属性。spring配置应如下所示:spring: application: name: football jpa: database-platform: org.hibernate.dialect.PostgreSQLDialect open-in-view: false datasource: url: jdbc:postgresql://localhost:5432/football username: packt password: packt hikari: maximum-pool-size: 4 sql: init: application.yml file. The complete file can be found in the book’s GitHub repository. -
CustomDatasourceService类的dataSourceInicitalizationMode参数引用了spring.datasource.initialization-mode配置属性。我们必须指向新的属性,spring.sql.init.mode。为此,将构造函数中的@Value注解替换为新属性。它应该看起来像这样:public CustomDatasourceService(@Value("${spring.sql.init.mode}") DatabaseInitializationMode dataSourceInitializationMode) { this.dataSourceInitializationMode = dataSourceInitializationMode; } -
现在更改已应用,让我们再次执行应用程序。您可以通过在终端中运行
spring-boot:run命令来完成此操作。您将看到应用程序正常运行。
-
现在属性已迁移,最后一步是删除 Spring Boot Properties Migration 依赖项。为此,打开
pom.xml文件,并删除您在 步骤 1 中添加的依赖项。
它是如何工作的...
Spring Boot Properties Migration 依赖项首先尝试将过时的属性映射到新的属性,如果可能的话,然后打印警告。如果没有直接映射到新属性,它将抛出错误并打印问题。在本菜谱中,我们使用了 Spring Boot Properties Migration 无法自动映射的依赖项。因此出现了错误。一旦修复,Spring Boot 迁移不会显示任何额外的错误。
由于 Spring Boot Properties Migration 在应用程序启动时执行额外的检查,它可能会减慢应用程序的运行速度。一旦修复了过时的属性,从应用程序中删除 Spring Boot Properties Migration 是一个好习惯。
将项目升级到 Spring Boot 3
在这个菜谱中,我们将通过更新我们项目中的引用来迈出 Spring Boot 3 的第一步。当我们升级到 Spring Boot 3 时,我们会看到一些需要解决的编译错误;在这个菜谱中,我们将迈出修复这些错误的第一步。
Spring Boot 3 依赖于 Jakarta EE 9 或更高版本,而 Spring Boot 则依赖于 Jakarta 7 和 8。在 Jakarta 9 中,所有命名空间从 javax.* 更改为 jakarta.*。这可能是升级到 Spring Boot 3 时可以看到的最大影响,因为它需要更改我们项目中许多引用。
在这个菜谱中,我们将最终将我们的项目升级到 Spring Boot 3,这将需要将所有 javax 命名空间引用更新为 jakarta。我们还将执行 Spring Security 的最新更新。在本菜谱结束时,应用程序仍将无法工作;你必须完成两个菜谱,Upgrading Spring Data 和 Managing Actuator changes,才能使其工作。
准备工作
我们将使用 Detecting property changes 菜谱的结果作为本菜谱的起点。如果你还没有完成它,可以使用我准备的版本。你可以在本书的 GitHub 仓库中找到它,网址为 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook,在 chapter9/recipe9-4/start 文件夹中。
如何操作...
我们将升级应用程序到 Spring Boot 3,并会看到与 Jakarta EE 相关的错误。让我们修复它们并开始使用 Spring Boot 3!
-
首先,我们将升级项目以使用 Spring Boot 3。为此,打开
pom.xml文件并找到以下代码片段:<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> <!-- lookup parent from repository --> </parent>你应该将版本属性替换为最新的 Spring Boot 版本,在撰写本书时,该版本是 3.2.4。
-
你会看到项目无法编译,因为所有对
javax.*命名空间的引用都不再有效。要修复它,请将所有文件中的javax替换为jakarta。需要做出大量的更改,所以我建议使用你喜欢的编辑器的替换功能。例如,你可以在 Visual Studio Code 中使用 Search: Replace in files 命令:

图 9.3:在 Visual Studio Code 中的文件中将 javax 替换为 jakarta
-
如果你尝试编译项目,你会看到仍有三个文件存在编译错误:
-
MatchEventEntity.java:存在与数据库中映射为 JSON 的字段相关的错误。这个问题将在 Upgrading Spring Data 菜谱中解决。 -
FootballConfig.java:存在与HttpTrace相关的错误。这个问题将在 Managing Actuator 更改 菜谱中修复。 -
SecurityConfig.java:存在与 Spring Security 变更相关的新错误,我们将在以下步骤中修复。
-
-
要修复
SecurityConfig类的问题,我们需要做以下几步:-
在
webSecurityCustomizer方法中将antMatchers调用替换为requestMatchers调用。它应该看起来像这样:@Bean WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web.ignoring() .requestMatchers("/security/public/**"); } -
在
filterChain方法中,我们需要进行几个更改:-
将
authorizeRequests替换为authorizeHttpRequests。 -
将
requestMatchers("/")替换为anyRequest()。 -
将
antMatchers调用替换为requestsMatchers。 -
我们需要切换前两个调用中的调用顺序。
-
删除
and调用,只需将authorizeHttpRequests与httpBasic调用链式即可。
@Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .authorizeHttpRequests(authorizeRequests -> { authorizeRequests .requestMatchers("/security/private/**") .hasRole("ADMIN") .anyRequest().permitAll(); }) .httpBasic(withDefaults()) .build(); } -
-
它是如何工作的...
Jakarta EE 以前是 javax,现在是 jakarta,这使得 Jakarta EE 能够独立于 Oracle 发展,因为它保留了某些 Java 商标。这些更改仅意味着命名空间的变化,没有行为变化。因此,只需替换命名空间,应用程序就可以正常工作。
在本食谱中,我们直接从 Spring Boot 2.7.18 迁移到 Spring Boot 3.2.4。如果我们首先迁移到中间版本,我们会看到与 Spring Security 相关的错误作为弃用警告。鉴于我们了解逐步弃用和升级的机制,我决定为了简洁性跳过中间版本。我建议您在项目中逐步迁移。弃用警告消息可以指导大多数更改。然而,有一个变化不是很明显。在 Spring Boot 6 中,permitAll 方法对于 requestMatcher 已被弃用,应替换为 anyRequest,并且该方法应在链的末尾调用;如果您在之后链式调用更多匹配器,则在运行时将抛出异常。
升级 Spring Data
Spring Boot 3 默认使用 Hibernate 6.1,而 Spring Boot 2 使用 Hibernate 5。因此,我们需要准备应用程序以匹配 Hibernate 6.1。
Hibernate 使用 Jakarta EE,这需要将 javax.* 命名空间升级到 jakarta.*,但我们已经在 Spring 3.0 的第一步 食谱中完成了这一步。
Hibernate 6.1 中的一些更改是内部的,但一些 API 发生了变化,应在应用程序中进行升级。
与 Spring Data 配置相关的某些更改是针对 Cassandra 的特定更改。
在本食谱中,我们将对示例应用程序进行必要的更改,以使其与 Hibernate 6.1 和 Cassandra 保持一致。
准备工作
我们将使用 Spring 3.0 的第一步 食谱的结果作为本食谱的起点。如果您尚未完成,可以使用我创建的版本。您可以在书籍的 GitHub 仓库中找到它,位于 github.com/PacktPublishing/Spring-Boot-3.0-Cookbook 的 chapter9/recipe9-5/start 文件夹中。
如前文所述,示例应用程序使用 PostgreSQL 服务器数据库和 Cassandra,测试使用 Testcontainers。因此,您需要在您的计算机上运行 Docker。
如何操作...
让我们在应用程序中进行必要的 Spring Data 调整:
-
我们将首先修复应用程序的配置:
-
我们将用
PostgreSQLDialect替换PostgreSQL82Dialect。为此,我们将打开application.yml文件,定位spring.jpa.database-platform属性,然后设置以下值:spring: jpa: database-platform: spring.data.cassandra.* settings now should be spring.cassandra.*. In the same application.yml file, ensure that the cassandra settings are defined as follows:spring:
cassandra:
keyspace-name: footballKeyspace
schema-action: CREATE_IF_NOT_EXISTS
contact-points: localhost
local-datacenter: datacenter1
session-name: cassandraSession
port: 9042
注意,现在
cassandra位于spring下。之前,cassandra位于spring.data下。 -
-
基于 Testcontainers 的测试创建了一个 Cassandra 容器,然后设置了应用程序上下文的设置。这些测试应该与新设置结构中的
spring.cassandra而不是spring.data.cassandra保持一致。你可以通过在你的编辑器中使用字符串替换功能来应用这个更改。例如,你可以在 Visual Studio Code 中使用搜索:替换文件功能:

图 9.4:在 Visual Studio Code 中替换 spring.data.cassandra 引用
-
接下来,我们将使用 Hibernate 6 的新功能来定义我们实体的 JSON 字段。为此,打开
MatchEventEntity类并修改details字段的注解如下:@JdbcTypeCode(SqlTypes.JSON) private MatchEventDetails details;我们将hypersistence utils类型替换为定义 JSON 字段。
-
由于我们不再需要特定的类型来定义 JSON 字段,你可以从
pom.xml文件中删除以下依赖项:<dependency> <groupId>io.hypersistence</groupId> <artifactId>hypersistence-utils-hibernate-55</artifactId> <version>3.7.3</version> </dependency> -
在这一点上,我们希望利用项目的测试。然而,有一些与
httptrace端点相关的编译错误。现在我们将注释掉FootballConfig类中的所有代码以避免编译错误,并且我们将在这个管理 Actuator 更改菜谱中处理这个组件。 -
接下来,我们将运行测试以验证应用程序仍然可以工作。你可以从 IDE 中运行测试,或者只需在你的终端中运行以下 Maven 命令:
mvn test你会看到与服务相关的测试都成功了,但有两个测试,
findPlayerById和countPlayers失败了。还有其他与控制器相关的测试也失败了,但我们将它们包含在管理 Web 应用程序更改菜谱中。这些测试失败是由于 Hibernate 6 中的一些行为变化。让我们修复它们:-
DynamicQueriesService类中的findPlayerById方法使用序数参数绑定。Hibernate 6 中序数参数的行为发生了变化。为了修复这个问题,按照以下方式修改DynamicQueriesService类中的findPlayerById:public Player findPlayerById(Integer id) { Query query = em.createQuery( "SELECT p FROM PlayerEntity p WHERE p.id=?1", PlayerEntity.class); query.setParameter(1, id); return playerMapper.map((PlayerEntity) query.getSingleResult()); } -
这个更改很微妙。在查询字符串中,将
?0替换为?1;setParameter方法中的参数是1而不是0。 -
countPlayers测试验证了DynamicQueriesService类中同名的方法。要修复这个测试,请执行以下操作:- 首先,将
DynamicQueriesService类中countPlayers方法的return类型从BigInteger更改为Long。它应该看起来像这样:
public Player findPlayerById(Integer id) { Query query = em.createQuery( "SELECT p FROM PlayerEntity p WHERE p.id=?1", PlayerEntity.class); query.setParameter(1, id); return playerMapper .map((PlayerEntity) query.getSingleResult()); } - 首先,将
- 然后,更新测试以匹配
Long类型的返回类型:
@Test void countPlayers() { Long count = dynamicQueriesService.countPlayers(); assertThat(count, not(0)); }你可以重新运行与服务相关的测试,现在它们应该会成功。
-
它是如何工作的...
Spring Data 依赖于 Hibernate 来实现其大部分功能。在 Spring Boot 3 中,它默认使用 Hibernate 6.1。因此,大多数与 Spring Data 升级相关的任务都与 Hibernate 升级相关。
与 Hibernate 升级相关的一个变化是将引用从javax.*更改为jakarta.*。我们在这个菜谱中没有解释这一点,因为它已经在Spring 3.0 的第一步菜谱中讨论过了;在升级到 Spring Boot 3 或 Hibernate 6 时,你应该记住这一点。
在 Hibernate 6 中,属性spring.jpa.database-platform不再使用特定的版本值。因此,PostgreSQL82Dialect已被弃用,应该用不带版本号的数据库方言替换。由于我们使用 PostgreSQL 作为关系型数据库,我们将PostgreSQL82Dialect替换为PostgreSQLDialect。如果你使用其他数据库引擎,如 MySQL,你应该使用不带版本特定方言的MySQLDialect。
在 Hibernate 6 中,返回BIGINT的查询现在映射到Long类型。在之前的版本中,它们被错误地映射到BigInteger。计数子句的结果是BIGINT,因此我们需要在countPlayers方法中更改它。尽管我们应用程序中的玩家数量可以用整数表示,但如果我们的表更大,它可能会在运行时引起类型转换错误。
Hibernate 6 改变了绑定序数参数的方式,现在使用基于 1 的排序而不是基于 0 的排序。
Hibernate 6 引入了在数据库支持的情况下将实体字段映射为 JSON 参数的可能性。在 Hibernate 6 之前,必须使用第三方库,例如 Hypersistence。由 Vlad Mihalcea 开发的这个出色的库在之前的版本中对于 JSON 字段管理很有用,但在 Hibernate 6 中不再需要。我们可以保留这个依赖项并将其升级到与 Hibernate 6 匹配的版本。更多信息可以在github.com/vladmihalcea/hypersistence-utils找到。
与 Spring Data 相关的其他变化与 Hibernate 无关。例如,spring.data属性前缀现在被保留用于 Spring Data;因此,spring.data.cassandra已移动到spring.cassandra。
在这个菜谱中,我们只涵盖了与本书中使用的 Spring Data 相关的变化。我建议你查看github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide#data-access-changes中的迁移指南。
还有更多...
Spring Boot 3 引入了一些功能,这些功能有助于我们在应用程序中使用标准数据库技术。在 Spring Boot 3 之前,我们可以使用替代解决方案,并且没有必要迁移它们;然而,了解它们的存在是值得的。在本节中,我们将演示其中两个:存储过程和 JQL 中的联合子句。
你可以使用@Procedure注解代替@Query注解。@Query注解必须是原生的,并使用call子句。例如,你可以按照以下方式修改PlayerRepository接口中的getTotalPlayersWithMoreThanNMatches方法:
@Procedure("FIND_PLAYERS_WITH_MORE_THAN_N_MATCHES")
Integer getTotalPlayersWithMoreThanNMatches(int num_matches);
Hibernate 6 JQL 现在支持Union子句。例如,我们可以在MatchRepository接口中这样写findPlayersByMatchId:
@Query("SELECT p1 FROM MatchEntity m JOIN m.team1 t1 JOIN t1.players p1 WHERE m.id = ?1 UNION SELECT p2 FROM MatchEntity m JOIN m.team2 t2 JOIN t2.players p2 WHERE m.id = ?1")
public List<PlayerEntity> findPlayersByMatchId(Integer matchId);
参见
在这个菜谱中,我们涵盖了 Hibernate 5 到 6 迁移的一些场景,但还有很多。如果你在项目迁移过程中发现与 Hibernate 相关的问题,请查看 Hibernate 迁移指南:
-
Hibernate 6.0 迁移指南:
docs.jboss.org/hibernate/orm/6.0/migration-guide/migration-guide.html -
Hibernate 6.1 迁移指南:
docs.jboss.org/hibernate/orm/6.1/migration-guide/migration-guide.html
管理执行器更改
大多数执行器更改都与暴露端点的默认行为相关。例如,JMX 端点行为与 Web 端点行为保持一致;因此,它默认只暴露Health端点,而之前所有 JMX 端点都默认暴露。如果你的项目依赖于该功能,你必须显式地暴露它。
除了执行器的默认行为外,我们的项目还使用httptrace端点,这改变了行为和所需的实现。在这个菜谱中,我们将修复httptrace端点,并做出必要的配置更改以保持与执行器相同的操作。
准备工作
要开始这个菜谱,你需要上一个菜谱的结果,即升级 Spring Data。如果你还没有完成,我在书的 GitHub 仓库中准备了一个版本,你可以在chapter9/recipe9-6/start文件夹中找到它:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook
除了我们之前菜谱的要求外,我们还需要一个 JMX 客户端来探索执行器 JMX 端点的行为。我们可以使用 JConsole,它是 JDK 的一部分。
如何操作...
我们首先将修复httptrace端点,然后我们将调整执行器以使其在 Spring Boot 2 中的行为一致:
-
要修复
httptrace配置,打开application.yml文件,并替换以下内容:-
将 Web 端点
httptrace替换为httpexchanges。 -
将
management.trace.http.enabled替换为management.httpexchanges.recording.enabled。 -
它应该看起来像这样:
management: endpoints: web: exposure: include: health,env,metrics,beans,loggers,httpexchanges httpexchanges: recording: application.yml file. -
接下来,对于
FootballConfig类,将HttpTraceRepository接口替换为HttpExchangeRepository,将InMemoryHttpTraceRepository替换为InMemoryHttpExchangeRepository。请记住,我们在上一个菜谱中注释了这个类的内容,以便能够编译解决方案;现在,我们将处理这个组件。FootballConfig类应该看起来如下:@Configuration public class FootballConfig { @Bean public HttpExchangeRepository httpTraceRepository() { return new InMemoryHttpExchangeRepository(); } } -
现在,我们可以运行应用程序并验证新的
httpexchanges存储库。要验证它,在您的浏览器中打开它或使用地址http://localhost:8080/actuator/httpexchanges执行 curl 命令。它应该返回对我们应用程序的最新请求。 -
接下来,我们将验证应用程序是否暴露了与 Spring Boot 2 相同的 JMX 端点。当应用程序正在运行时,运行 JConsole 工具。为此,打开您的终端并运行
jconsole。您会看到它显示了在您的计算机上运行的 Java 应用程序列表:
-

图 9.5:JConsole 进程选择
选择应用程序并点击连接。可能会出现一个表示安全连接失败的消息,建议您使用非安全连接。使用非安全连接。
如果您打开org.springframework.boot命名空间,只会出现Health端点:

图 9.6:Spring Boot 3 默认暴露的 JMX 端点
在 Spring Boot 3 之前,所有端点默认启用。为了达到相同的行为,打开application.yml文件并设置management.endpoints.jmx.exposure.include=*属性。它应该看起来如下:
management:
endpoints:
jmx:
exposure:
include: '*'
如果您重新启动应用程序并使用 JConsole 连接到应用程序,您会看到它现在暴露了所有 MBean 端点,就像 Spring Boot 的早期版本一样:

图 9.7:使用 Spring Boot 3 暴露的所有 JMX 端点
您可以在图 9.7中看到暴露的端点。
它是如何工作的...
Spring Boot 团队将httptrace的名称更改为避免与Micrometer Tracing混淆。因此,它已被重命名为http exchanges。此更改还影响了支持实现跟踪的存储库。在这个例子中,我们使用了一个内存存储库,我们只将其用于演示目的。在生产环境中,您可能会使用持久存储库。
在 Spring Boot 3 中,JMX 端点仅暴露 Health 端点以与其网络对应端点保持一致。在 Spring Boot 3 和之前的版本中,仅在网络端点暴露 Health 端点。某些端点可能会泄露敏感信息或提供不希望访问。除了 Health 端点之外的所有端点都没有启用,以减少攻击面。在这个配方中,我们暴露了所有 JMX 端点。然而,建议你只暴露真正必要的那些端点。
参见
在这个配方中,我们涵盖了与 Actuator 相关的一些场景。然而,我建议你回顾 Spring Boot 3 迁移指南,并验证你是否使用了 Spring Boot 早期版本中需要关注的 Actuator 功能。你可以在这个指南中找到:github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide#actuator-changes。
管理网络应用程序的更改
网络应用程序,以及我们在这本书中主要使用的 RESTful 应用程序,在 Spring Boot 2 和 3 之间有一些行为上的变化。其中一些可能会影响你的应用程序。一些变化与内部组件相关,除非你的应用程序依赖于这些内部组件,否则你不会受到影响。Spring Boot 2 和 3 之间的变化如下:
-
server.max-http-header-size属性是一个设置,表示应用程序管理的请求头可能的最大大小。这个属性根据所使用的内嵌网络服务器而有所不同。它已经被server.max-http-request-header-size取代,并且由所有可能的内嵌网络服务器一致管理。 -
优雅关闭的阶段已经改变。当一个应用程序关闭时,Spring Boot 会发送不同阶段的事件,应用程序可以订阅这些事件以执行自定义关闭操作。
-
如果你使用 Jetty 内嵌网络服务器而不是默认的 Tomcat,你需要将 Servlet API 设置为 5.0。Jetty 目前还不支持 Spring Boot 3 默认使用的 Servlet API 6.0。
-
Spring Boot 3 使用的 Spring Framework 6 移除了对 Apache HttpClient 的支持。如果你在 Spring Boot 早期版本的应用程序中使用了 Apache HttpClient,你可能会注意到行为上的变化。
如前所述,除非你的应用程序明确依赖于这些功能之一,否则你不会注意到这些变化。然而,有一个可能会影响你的应用程序的行为变化。在 Spring Boot 3 之前的版本中,以 / 结尾的 URL 会与不带该尾随斜杠的控制器匹配。例如,GET /teams 和 GET /teams/ 在我们的应用程序中会匹配相同的控制器。在 Spring Boot 3 中,除非我们为它准备应用程序,否则 GET /teams/ 将会失败。
在这个菜谱中,我们将准备我们的应用程序来管理请求的尾部斜杠,就像在之前的版本中一样,以确保依赖于我们的应用程序的客户端不会受到 Spring Boot 升级的影响。
准备工作
我们将使用 Managing Actuator changes 菜谱的输出结果来制作这个菜谱。我准备了一个完成的版本,以防你还没有这样做。你可以在书的 GitHub 仓库中找到它:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook 中的 chapter9/recipe9-7/start 文件夹。
如何做到这一点...
让我们确保我们的应用程序消费者不会因为我们的 Spring Boot 3 升级而受到干扰!
-
让我们添加一个自定义的 Web 配置。为此,创建一个名为
WebConfiguration的文件,该文件实现了WebMvcConfigurer接口:@Configuration public class WebConfiguration implements configurePathMatch method as follows:@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.setUseTrailingSlashMatch(true);
}
-
现在,你可以通过在浏览器中打开
http://localhost:8080/teams/来验证应用程序的行为。你可以检查它是否带或不带尾部斜杠都能正常工作。 -
如果你运行应用程序测试,你会意识到现在所有测试都成功了,因为只有与控制器中尾部斜杠相关的测试仍然失败。
它是如何工作的...
而不是依赖于可能改变的自定义行为,尾部斜杠已被弃用,以强制显式匹配,以使应用程序在出现新版本时更加稳定和可预测。
Spring Boot 提供了机制来保持与之前版本相同的行怍。然而,你可能已经意识到 setUseTrailingSlashMatch 方法已被弃用。这是为了警告开发者关于这种不推荐的行为,并强制进行显式匹配的迁移。
更多...
同样的方法也可以用于 WebFlux。你不会实现 WebMvcConfigurer,而是实现 WebFluxConfigurer。它看起来像这样:
@Configuration
public class WebConfiguration implements WebFluxConfigurer {
@Override
public void configurePathMatching(PathMatchConfigurer configurer){
configurer.setUseTrailingSlashMatch(true);
}
}
参见
我建议你查看 Spring Boot 3 迁移的官方指南:github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide#web-application-changes。正如在介绍这个菜谱时提到的,如果你的应用程序依赖于受迁移影响的某些内部组件,你可以找到更多关于修复你的问题的详细信息。
使用 OpenRewrite 进行迁移自动化
在本章中,我们手动进行了所有迁移升级。然而,在大型的代码库中,这种方法可能太慢且容易出错。一些工具试图自动化这个过程,其中最受欢迎的是 OpenRewrite。OpenRewrite 是一个平台工具,旨在重构任何源代码。根据其文档,它旨在消除开发者仓库的技术债务。它提供了一种运行源代码重构菜谱的机制。一个流行的开源菜谱解决了本章的主题:Spring Boot 迁移。
准备工作
在这个菜谱中,我们将使用针对 Spring Boot 2.6.15 的原始示例。你可以在书的 GitHub 仓库的 chapter9/football 文件夹中找到它:github.com/PacktPublishing/Spring-Boot-3.0-Cookbook。
如何操作...
我们将从 Spring 2.6 升级到 Spring 3.2.4,自动化大部分过程。七个菜谱合为一!
-
让我们先向我们的项目中添加 OpenRewrite 插件。为此,将以下片段添加到
pom.xml文件的插件部分:<plugin> <groupId>org.openrewrite.maven</groupId> <artifactId>rewrite-maven-plugin</artifactId> <version>5.27.0</version> <configuration> <activeRecipes> <recipe>org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_7</recipe> </activeRecipes> </configuration> <dependencies> <dependency> <groupId>org.openrewrite.recipe</groupId> <artifactId>rewrite-spring</artifactId> <version>5.7.0</version> </dependency> </dependencies> </plugin>在编写这本书时,OpenRewrite 插件的最新版本是 5.7.0。当你尝试时,请使用最新版本。
我们使用 OpenRewrite 菜谱升级到 Spring 2.7,因为我们将会逐步进行升级。在接下来的步骤中,我们将升级到 Spring Boot 3.0,然后是 3.1,最后是 3.2。
-
接下来,我们将执行 OpenRewrite 插件。为此,打开项目根目录中的终端并执行以下 Maven 命令:
./mvnw rewrite:run你会看到它做出了一些更改:
-
它将 Spring Boot 版本升级到
2.7.18。 -
它向
org.junit.jupiterter:junit-jupiter添加了一个测试依赖。 -
它仅在
application.yml文件中将属性spring.datasource.initialization-mode替换为spring.sql.init.mode。请注意这个更改,因为在下一步中我们需要用到它。 -
它修改了测试类中对
org.junit的引用,将其替换为org.junit.jupiter的等效项。
-
-
让我们检查升级到 Spring Boot 2.7 是否可行。
-
如果你运行测试,你会看到依赖于 Testcontainers 的测试无法通过。这是因为 OpenRewrite 排除了 Testcontainers 所需的一些依赖项。要修复它,打开
pom.xml并移除 Testcontainers 依赖项上的exclusionjunit:<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>${testcontainers.version}</version> <scope>test</scope> <!-- <exclusions> <exclusion> <groupId>junit</groupId> <artifactId>junit</artifactId> </exclusion> </exclusions> --> </dependency>
在这个例子中,我只是为了清晰起见注释了
exclusions部分。在你的代码中,你可以移除它。-
一旦排除问题得到解决,你就会发现它无法加载应用程序上下文,因为它无法解析
spring.datasource.initialization-mode配置。要修复它,打开CustomDatasourceService类并修改构造函数中使用的@Value注解。你应该将spring.datasource.initialization-mode设置替换为spring.sql.init.mode设置。 -
重新运行测试,你会看到所有测试都成功了。
-
-
是时候开始使用 Spring Boot 3.0 了。为了进行这次升级,打开
pom.xml文件,并将activeRecipes/recipe修改为org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0。OpenRewrite 插件配置应如下所示:<configuration> <activeRecipes> <recipe>org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0</recipe> </activeRecipes> </configuration> -
然后,再次执行插件。这次升级所做的更改如下:
-
它将所有
javax引用替换为jakarta。 -
它迁移了 Spring Security 的更改。它正确地迁移了
UserDetailsManager和WebSecurityCustomizer。然而,正如我们在 步骤 7 中将看到的,SecurityFilterChainbean 需要一些调整。 -
它迁移了
application.yml文件中定义的设置:-
它将
management.trace.http.enabled替换为management.httpexchanges.recording.enabled。 -
它将
spring.data.cassandra设置迁移到spring.cassandra。
-
-
-
在 Spring Boot 3.0 升级中,应用程序无法编译。让我们修复编译错误:
-
DataSourceInitializationMode类已在 Spring Boot 3 中被移除,但 OpenRewrite 没有迁移它。正如我们在 准备应用程序 菜单中研究的那样,这个更改可以通过将DataSourceInitializationMode替换为DatabaseInitializationMode来轻松修复。 -
一些 Actuator 的更改已正确应用,但其他更改没有。
application.yml已正确修改,将management.trace.http.enabled替换为management.httpexchanges.recording.enabled。然而,HttpTraceRepository和InMemoryHttpTraceRepository没有迁移。您可以将它们替换为HttpExchangeRepository和InMemoryHttpExchangeRepository。有关更多详细信息,请参阅 管理 Actuator 变更 菜单。 -
在
MatchEventEntity类的details字段中,将注解@Type(JsonType.class)替换为@JdbcTypeCode(SqlTypes.JSON)。
-
-
接下来,让我们修复测试:
-
如 步骤 3 中所述,您应从 Testcontainers 依赖中移除
exclusionjunit。 -
由于我们不再使用 Hypersistence 库,我们可以将其从我们的项目中删除。有关更多详细信息,请参阅 升级 Spring Data 菜单。
-
OpenRewrite 已正确地将
application.yml文件中的spring.data.cassandra.*设置迁移到spring.cassandra.*。然而,它没有修改测试中的引用。为了修复这个问题,只需将所有对spring.data.cassandra的引用替换为spring.cassandra。有关更多详细信息,请参阅 升级 Spring Data 菜单。 -
MVC 测试无法工作。为了修复它们,我们需要在
FootballControllerTest和SecurityControllerTest类中包含@Import(SecurityConfig.class)注解。如前所述,SecurityFilterChainbean 已迁移,但需要一些调整。如 升级项目到 Spring Boot 3 菜单中所述,我们需要切换一些调用的顺序。方法应如下所示:@Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(requests -> requests .requestMatchers("/security/private/**").hasRole("ADMIN") .requestMatchers("/**").permitAll()) .httpBasic(withDefaults()); return http.build(); } -
OpenRewrite 没有将
BigInteger迁移到Long,也没有迁移我们在升级 Spring Data配方中研究的参数顺序绑定。要修复它,请应用升级 Spring Data配方中解释的两种更改。 -
检查尾部斜杠的测试失败了。如果我们想保持相同的行为,我们需要添加一个
WebMvcConfigurer,如管理 Web 应用程序更改配方中所述。
应用这些修复后,测试和应用程序都应正常工作。
-
-
接下来,让我们升级到 Spring Boot 3.1。为此,将
pom.xml文件中的配方更改为org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_1。在编写这本书时,我发现当运行此配方时与 Testcontainers 版本相关的问题。消息类似于以下内容:
[ERROR] Failed to execute goal org.openrewrite.maven:rewrite-maven-plugin:5.27.0:run (default-cli) on project football: Execution default-cli of goal org.openrewrite.maven:rewrite-maven-plugin:5.27.0:run failed: Error while visiting chapter9/recipe9-8/end/football/pom.xml: java.lang.IllegalStateException: Illegal state while comparing versions : [1.19.7] and [${testcontainers.version}.0.0.0.0.0.0]. Metadata = [null]为了避免运行时错误,将
pom.xml文件中的testcontainers.version项目变量替换为 Testcontainers 依赖项中的实际版本。例如,请参阅以下依赖项:<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>${testcontainers.version}</version> <scope>test</scope> </dependency>用以下内容替换:
<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>1.19.7</version> <scope>test</scope> </dependency>执行 OpenRewrite 插件后,您将看到只有少量更改应用于
pom.xml文件。您需要再次从 Testcontainers 依赖项中移除exclusionjunit。 -
在 Spring Boot 3.1 升级中,没有编译错误。然而,一些测试失败了。只需将
spring.jpa.database-platform更改为org.hibernate.dialect.PostgreSQLDialect,如升级 Spring Data配方中所述即可修复。应用此修复后重新运行测试;所有测试都应成功。 -
最后,升级到 Spring Boot 3.2。为此,将 OpenRewrite 配方更改为
org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2。同样,您需要再次移除exclusionjunit,但这次不需要其他操作。如果您运行测试,它们应该成功,并且应用程序将平稳运行。
它是如何工作的...
OpenRewrite 加载了您的代码表示,称为无损语义树(LSTs),然后通过使用访问者对该表示进行修改。一旦应用了访问者,OpenRewrite 将 LSTs 转换回代码。一个 OpenRewrite 配方是一组访问者。例如,一个访问者将 LST 中的 javax 引用更改为 jakarta 引用,另一个访问者更改 Spring Data 配置设置,等等,将 LSTs 转换为最终的升级版本。最后,转换后的 LSTs 被转换为代码。
通常,一个 OpenRewrite 配方定义会将一个 Spring Boot 版本迁移到下一个版本,例如从 2.6 迁移到 2.7。OpenRewrite Maven 插件会检测从当前应用程序版本到目标版本应应用的所有配方,然后按顺序应用这些配方以使升级逐步进行。有关更多信息,请参阅更多部分。
如你所意识到,在这个食谱中,许多场景没有被现有的食谱覆盖。OpenRewrite 食谱是开源的,并由社区维护。它们处理最常见的迁移场景。对于本章,我尝试准备了一个包含一些不太常见但也不太罕见的场景的样本,例如使用 BigInteger 类的 Hibernate 场景。无论如何,了解每个升级所做的更改是很重要的,这样如果出现错误,我们就可以手动修复它。
拥有一个好的测试集总是有帮助的,因为它们可能有助于检测版本之间的行为变化。在本章中,我们使用了广泛的测试,特别是 Testcontainers。它们在访问 PostgreSQL 和 Cassandra 时帮助检测了不兼容性。
还有更多...
在这个食谱中,我们进行了逐步升级,但你可以通过应用 OpenRewrite 的 org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2 食谱直接运行迁移。你必须应用在逐步迁移期间执行的相同附加修复:
-
在执行
OpenRewrite插件之前,使用一个常量版本替换 Testcontainers 版本。 -
在 Testcontainers 依赖中移除
exclusionjunit。 -
将
DataSourceInitializationMode替换为DatabaseInitializationMode。 -
将
HttpTraceRepository和InMemoryHttpTraceRepository替换为HttpExchangeRepository和InMemoryHttpExchangeRepository。 -
将注解
@Type(JsonType.class)替换为@JdbcTypeCode(SqlTypes.JSON)。 -
将所有对
spring.data.cassandra的引用替换为spring.cassandra。 -
在
FootballControllerTest和SecurityControllerTest类中添加@Import(SecurityConfig.class)注解。 -
修复
SecurityConfig类中的SecurityFilterChainbean。 -
将
BigInteger类替换为Long并在DynamicQueriesService类中替换类的参数顺序绑定。 -
如果你想要保留尾部斜杠行为,添加一个
WebMvcConfigurer。 -
将
org.hibernate.dialect.PostgreSQL82Dialect替换为org.hibernate.dialect.PostgreSQLDialect。
参见
我推荐访问 OpenRewrite 网站 docs.openrewrite.org。这里有许多可以用来维护我们代码的食谱,不仅限于 Spring Boot 迁移。
其他工具旨在尽可能自动化迁移过程。例如,Spring 团队开发了一个名为 Spring Boot Migrator 的实验性项目。更多信息请见 github.com/spring-projects-experimental/spring-boot-migrator。




浙公网安备 33010602011771号