Spring-Boot-启动指南-全-

Spring Boot 启动指南(全)

原文:zh.annas-archive.org/md5/8803f34bb871785b4bbbecddf52d5733

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎

“风筝不是随风上升的,而是逆风上升的。”

约翰·尼尔,来自《企业与毅力》(《每周镜报》)

欢迎来到Spring Boot: Up and Running。很高兴你在这里。

今天有其他的 Spring Boot 书籍可供选择。这些都是由优秀的作者写成的好书。但每位作者在他们的材料中都做出了决定,包括什么要包含在内,什么要排除,如何呈现所选内容,以及许多大大小小的决策,使他们的书籍独特。对于一个作者而言,似乎是可选的材料,对于另一个作者而言可能是绝对必要的。我们都是开发人员,像所有开发人员一样,我们有自己的看法。

我认为有一些遗漏的部分,我觉得这些部分要么是必需的,要么对于对 Spring Boot 新手有帮助。随着我与全球各地越来越多的开发人员互动,这些遗漏的部分列表也在增加,他们在 Spring Boot 的旅程中处于不同的阶段,用不同的方式学习不同的东西。因此出现了这本书。

如果您是 Spring Boot 的新手,或者您觉得强化基础知识将会很有用 — 面对现实,什么时候不强化基础知识都不合适呢? — 这本书就是为您而写的。这是一个温和的介绍,涵盖了 Spring Boot 的关键能力,并进入到这些能力在现实世界中的有用应用。

感谢您加入我的旅程。让我们开始吧!

本书使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序清单,以及在段落内引用程序元素,如变量或函数名,数据库,数据类型,环境变量,语句和关键字。

常量宽度粗体

显示用户应逐字输入的命令或其他文本。

常量宽度斜体

显示应由用户提供值或上下文确定的值替换的文本。

提示

此元素表示提示或建议。

此元素表示一般注释。

警告

此元素表示警告或注意事项。

使用代码示例

附加材料(代码示例,练习等)可在https://resources.oreilly.com/examples/0636920338727下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

这本书旨在帮助您完成工作。一般来说,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了大部分代码,否则无需征得我们的许可。例如,编写一个使用本书中几个代码片段的程序不需要许可。销售或分发 O’Reilly 书籍中的示例需要许可。

引用本书并引用示例代码回答问题无需许可。将本书中大量示例代码整合到产品文档中需要许可。

我们欣赏但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Spring Boot: Up and Running by Mark Heckler (O’Reilly). Copyright 2021 Mark Heckler, 978-1-098-10339-1.”

如果您认为您对代码示例的使用超出了合理使用或上述许可,请随时通过permissions@oreilly.com与我们联系。

致谢

我无法再多次感谢那些鼓励我写这本书以及在我写作期间给予我鼓励的每个人。如果你阅读了早期版本并提供反馈,甚至在 Twitter 上说了一句好话,你都不知道这对我意味着多少。我由衷地感谢你们。

能够实现这一切,并不仅仅是一个有希望的计划,有一些人使之成为可能:

致我的老板、导师和朋友 Tasha Isenberg。Tasha,你与我合作以适应时间表,当形势紧急时,你为我铺平道路,让我能够迎头冲刺并达到关键的截止日期。能在 VMware 内部有一个理解的倡导者和坚定的支持者,我感激不尽。

致 Spring Boot、Spring Cloud、Spring Batch 的创始人,以及无数 Spring 项目的贡献者,David Syer 博士。您的洞察力和反馈真的非常出色和深思熟虑,我对您所做的一切感激不尽。

致 Spring Data 团队成员 Greg Turnquist。感谢你的严谨目光和直率反馈;你提供了宝贵的额外视角,通过这样做使这本书变得更加出色。

致我的编辑们,Corbin Collins 和 Suzanne(Zan)McQuade。从概念到完成,你们无微不至的支持鼓励我创作出最好的作品,并在外部环境似乎要打破的期限下,鼓励我达成目标。我无法要求更好的了。

致 Rob Romano、Caitlin Ghegan、Kim Sandoval 以及整个 O'Reilly 制作团队。你们帮助我走完了最后一英里,不论从字面意义上还是从实际意义上,把这本书真正投入生产

最后但也是最重要的,致我聪明、充满爱心并且极度耐心的妻子 Kathy。说你激励并使我能够做我所做的一切,简直是低估了。从我内心深处,谢谢你为一切

第一章:简介 Spring Boot

本章探讨了 Spring Boot 的三个核心特性以及它们如何作为开发者的增强因子。

Spring Boot 的三大核心特性

Spring Boot 的三个核心特性是简化的依赖管理、简化的部署和自动配置,这些是构建一切的基础。

简化依赖管理的起步

Spring Boot 的一个天才之处在于它使依赖管理变得…可管理。

如果您已经开发了任何重要的软件一段时间,您几乎肯定不得不应对围绕依赖管理的几个头痛。您在应用程序中提供的任何功能通常都需要一些“前线”依赖项。例如,如果您想提供一个 RESTful web API,您必须提供一种方法来通过 HTTP 公开端点,监听请求,将这些端点与处理这些请求的方法/函数绑定,然后构建并返回适当的响应。

几乎每个主要依赖项都包含许多其他次要依赖项,以实现其承诺的功能。继续我们提供 RESTful API 的示例,我们可能期望看到一组依赖项(在某种合理但有争议的结构中),其中包括提供特定格式(例如 JSON、XML、HTML)响应的代码;编组/解编组对象以请求的格式;监听和处理请求并返回相同响应的代码;解码用于创建多功能 API 的复杂 URI 的代码;支持各种传输协议的代码;以及更多。

即使对于这个相当简单的示例,我们在我们的构建文件中已经可能需要大量依赖项。而且在此时,我们甚至还没有考虑我们可能希望在我们的应用程序中包含的功能。

现在,让我们谈谈版本。谈谈每一个依赖项的版本。

将库一起使用需要一定的严谨性,因为特定依赖项的一个版本可能仅已与另一个特定依赖项的特定版本一起测试(或者甚至能够正确工作)。当这些问题不可避免地出现时,它导致了我所说的“依赖性打地鼠”。

就像其同名的嘉年华游戏一样,“依赖性打地鼠”可能是一种令人沮丧的经历。与其同名不同的是,在追逐和解决由于依赖关系之间的不匹配而引起的错误时,没有奖品,只有难以捉摸的结论性诊断和浪费的小时

Spring Boot 及其起步项目登场。Spring Boot 起步项目是围绕一个被证明的前提建立的材料清单(BOM),即您几乎每次提供特定功能时都以几乎相同的方式提供它。

在上一个例子中,每次我们构建一个 API 时,我们都会暴露端点,监听请求,处理请求,对象之间的转换,以及使用一个特定协议通过电线发送和接收数据等等。这种设计/开发/使用模式变化不大;它是整个行业广泛采用的方法,几乎没有变化。像其他类似的模式一样,它方便地被 Spring Boot starter 捕获。

添加一个单一的 starter,例如spring-boot-starter-web,提供所有相关功能在单一应用程序依赖项中。由该单一 starter 包含的所有依赖项也是版本同步的,这意味着它们已经成功地一起测试过,包含的库 A 版本与包含的库 B、C、D 等版本一起正常运行。这极大地简化了您的依赖列表和您的生活,几乎消除了您需要提供应用程序关键功能时可能遇到的难以识别的版本冲突的可能性。

在那些罕见的情况下,当您必须整合由包含的依赖项的不同版本提供的功能时,您可以简单地覆盖已测试的版本。

注意

如果必须覆盖依赖项的默认版本,请这样做...但您应该增加测试的级别,以减少因此引入的风险。

如果对于您的应用程序来说,某些依赖项是不必要的,您也可以将它们排除在外,但同样要注意谨慎。

总的来说,Spring Boot 的 starter 概念大大简化了您的依赖关系,并减少了向应用程序添加整套功能所需的工作。它还显著减少了您测试、维护和升级它们所需的开销。

可执行的 JAR 文件,简化部署过程。

很久以前,在应用服务器漫游地球的日子里,Java 应用程序的部署是一件复杂的事情。

为了提供一个工作的应用程序,比如具有数据库访问权限的微服务今天和几乎所有的单体应用过去和现在,你需要做以下几点:

  1. 安装并配置应用服务器。

  2. 安装数据库驱动程序。

  3. 创建数据库连接。

  4. 创建连接池。

  5. 构建并测试你的应用程序。

  6. 将你的应用程序及其(通常很多的)依赖项部署到应用服务器。

注意,此列表假定您已由管理员配置了机器/虚拟机,并且在某些时候您独立于此过程创建了数据库。

Spring Boot 彻底改变了这种繁琐的部署过程,并将以前的步骤合并为一个,或者也许是两个,如果将一个单一文件复制或cf push到目标视为一个实际的步骤的话。

Spring Boot 并不是所谓的超级 JAR 的起源,但它革新了它。与从应用程序 JAR 文件和所有依赖的 JAR 文件中分离出每个文件,然后将它们组合成单个目标 JAR 文件(有时称为阴影)不同,Spring Boot 的设计者们从一个真正新颖的角度来看待问题:如果我们可以嵌套 JAR 文件,保留它们的预期和交付格式呢?

将 JAR 文件进行嵌套而不是对它们进行阴影处理可以减轻许多潜在问题,因为在依赖 JAR A 和依赖 JAR B 使用不同版本的 C 时,不会遇到潜在的版本冲突问题;它还消除了由于重新打包软件并与其他使用不同许可证的软件组合而导致的潜在法律问题。保持所有依赖的 JAR 文件以其原始格式干净地避免了这些及其他问题。

如果有需要,提取 Spring Boot 可执行 JAR 文件的内容也是非常简单的。在某些情况下这样做有一些很好的理由,我在本书中也会讨论这些理由。现在,只需知道 Spring Boot 可执行 JAR 文件已经为你准备好了。

那个单一的 Spring Boot JAR 文件及其所有依赖使得部署变得轻而易举。与收集和验证所有依赖项是否已部署相比,Spring Boot 插件确保它们全部被压缩到输出的 JAR 文件中。一旦你有了这个,应用程序可以在任何有 Java 虚拟机(JVM)的地方运行,只需执行类似java -jar <SpringBootAppName.jar>的命令。

还有更多。

通过在构建文件中设置一个属性,Spring Boot 构建插件还可以使单个 JAR 完全(自行)可执行。假设 JVM 存在,而不是必须键入或脚本化整个麻烦的java -jar <SpringBootAppName.jar>命令行,你只需简单地键入<SpringBootAppName.jar>(当然,替换为你的文件名),一切都搞定。没有比这更简单的了。

自动配置

对于那些对 Spring Boot 还不熟悉的人来说,自动配置有时被称为“魔术”,可能是 Spring Boot 为开发者带来的最大“乘数效应”。我经常把它称为开发者的超能力:Spring Boot 通过为广泛使用和重复使用的案例带来观点,极大地提升了生产力

软件中的观点?这有什么帮助?!?

如果你做开发时间很长,无疑会注意到某些模式经常重复出现。当然,不是完全一样,但高达 80-90%的时间,事情都在某种设计、开发或活动范围内。

我前面提到过软件中的这种重复,这就是使得 Spring Boot 的启动器惊人一致和有用的原因。这种重复也意味着,当涉及到必须编写的代码以完成特定任务时,这些活动非常适合优化。

借用 Spring Data 的例子,这是一个与 Spring Boot 相关且启用的项目,我们知道每次访问数据库时,我们需要打开某种类型的连接到该数据库。我们也知道当我们的应用程序完成其任务时,必须关闭该连接以避免潜在问题。在此期间,我们可能会使用查询(简单和复杂、只读和写入使能)向数据库发出许多请求,并且这些查询将需要一些努力来正确创建。

现在想象一下我们能够简化所有这些。在我们指定数据库时自动打开连接。在应用程序终止时自动关闭连接。遵循简单而预期的约定,以最小的努力从开发者那里自动创建查询。甚至通过简单约定再次轻松定制那些最小代码,创建可靠一致且高效的复杂定制查询。

这种编码方式有时被称为约定优于配置,如果您对某种约定不熟悉,乍一看可能会有些不适(无论是打算的还是其他)。但如果您以前实现过类似功能,写过数百行重复、令人昏昏欲睡的设置/拆卸/配置代码以完成甚至最简单的任务,那么这就像一股清新的空气。Spring Boot(以及大多数 Spring 项目)遵循约定优于配置的口号,确保如果您遵循简单、成熟和详细记录的约定来做某事,您需要编写的配置代码将是最小甚至完全没有。

自动配置给你超能力的另一种方式是 Spring 团队对“开发者优先”环境配置的激烈关注。作为开发者,当我们能够专注于手头的任务而不是无数的设置琐事时,我们的生产力最高。Spring Boot 是如何实现这一点的呢?

让我们借用另一个与 Spring Boot 相关的项目 Spring Cloud Stream 的例子:当连接到消息平台如 RabbitMQ 或 Apache Kafka 时,开发者通常必须指定某些设置以连接和使用该平台——主机名、端口、凭据等。专注于开发体验意味着在未指定任何设置时提供默认值,有利于开发者在本地工作:localhost、默认端口等。这在观点上是有意义的,因为在开发环境中几乎 100%一致,但在生产环境中则不然。在生产环境中,由于平台和托管环境差异很大,您需要提供特定的值。

使用这些默认设置的共享开发项目还可以消除开发环境设置所需的大量时间。这对你有利,也对你的团队有利。

在一些情况下,您的具体用例可能并不完全符合典型用例的 80–90%,而是属于另外的 10–20%有效用例。在这些情况下,可以有选择地覆盖自动配置,甚至完全禁用它,但当然,您会失去所有的超能力。覆盖某些默认设置通常是通过设置一个或多个属性为您希望的值,或者提供一个或多个 Bean 来完成 Spring Boot 通常会为您自动配置的某些任务。换句话说,当您必须这样做时,通常这是一个非常简单的事情。总之,自动配置是一个强大的工具,默默无闻地为您工作,使您的生活更轻松,您的生产力更高。

摘要

Spring Boot 的三个核心特性是简化的依赖管理、简化的部署以及自动配置。这三者都是可定制的,但您很少需要这样做。这三者共同努力,让您成为一个更好、更高效的开发者。Spring Boot 给您带来飞跃的感觉!

在下一章中,我们将探讨一些在创建 Spring Boot 应用程序时可供选择的优秀选项。选择多多益善!

第二章:选择您的工具并入门

要开始创建 Spring Boot 应用程序很容易,您很快就会看到。 最困难的部分可能是决定您想要选择哪个可用选项。

在本章中,我们将探讨您可以用来创建 Spring Boot 应用程序的一些出色选择:构建系统、语言、工具链、代码编辑器等等。

Maven 还是 Gradle?

从历史上看,Java 应用程序开发人员在项目构建工具方面有几个选择。 随着时间的推移,一些选择因有充分理由而不再受欢迎,现在我们作为一个社区聚集在两个选择周围:Maven 和 Gradle。 Spring Boot 同样支持两者。

Apache Maven

Maven 是一个流行且可靠的构建自动化系统选择。 它已经存在了相当长的时间,最早在 2002 年开始,并于 2003 年成为 Apache Software Foundation 的一个顶级项目。 其声明性方法在当时和现在(仍然)在概念上比替代方案更简单:只需创建一个名为 pom.xml 的 XML 格式文件,其中包含所需的依赖项和插件。 当您执行 mvn 命令时,可以指定完成的“阶段”,以完成像编译、删除先前的输出、打包、运行应用程序等所需的任务:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
		xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
		xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.4.0</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>demo</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>demo</name>
	<description>Demo project for Spring Boot</description>

	<properties>
		<java.version>11</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

Maven 还按约定创建并期望特定的项目结构。 除非您准备好与您的构建工具作斗争,否则通常不应该偏离这种结构,如果有的话,这是一个适得其反的任务。 对于绝大多数项目来说,传统的 Maven 结构完全有效,因此您不太可能需要更改它。 图 2-1 显示了具有典型 Maven 项目结构的 Spring Boot 应用程序。

sbur 0201

图 2-1。在 Spring Boot 应用程序中的 Maven 项目结构
注意

有关 Maven 预期项目结构的更多详细信息,请参阅 The Maven Project’s Introduction to the Standard Directory Layout

如果有一天,当 Maven 的项目约定和/或对构建的严格结构化方法变得过于限制性时,还有另一个绝佳选择。

Gradle

Gradle 是构建 Java 虚拟机(JVM)项目的另一个流行选项。 首次发布于 2008 年,Gradle 利用特定领域语言(DSL)生成了一个既简洁又灵活的 build.gradle 构建文件。 以下是一个 Spring Boot 应用程序的 Gradle 构建文件示例。

plugins {
	id 'org.springframework.boot' version '2.4.0'
	id 'io.spring.dependency-management' version '1.0.10.RELEASE'
	id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
	useJUnitPlatform()
}

Gradle 允许开发人员选择使用 Groovy 或 Kotlin 编程语言进行 DSL。 它还提供了几个功能,旨在减少您等待项目构建的时间,例如以下内容:

  • Java 类的增量编译

  • Java 的编译避免(在没有更改发生的情况下)

  • 项目编译的专用守护程序

在 Maven 和 Gradle 之间做出选择

在这一点上,你选择的构建工具可能听起来并不像是一个选择。为什么不简单地选择 Gradle 呢?

Maven 更为严格的声明性(有人可能会说是有主见的)方法使得从一个项目到另一个项目、从一个环境到另一个环境都保持了一致性。如果你遵循 Maven 的方式,通常不会出现太多问题,让你可以专注于你的代码,而不必过多地纠缠于构建过程中。

作为围绕编程/脚本构建的构建系统,Gradle 有时也会在消化新语言版本的初始发布时遇到问题。Gradle 团队反应迅速,通常能够迅速解决这些问题,但如果你喜欢(或必须)立即深入了解早期版本的语言发布,这值得考虑。

对于构建,Gradle 可能会更快——有时甚至 显著 更快,特别是在较大的项目中。也就是说,对于你典型的基于微服务的项目,类似的 Maven 和 Gradle 项目之间的构建时间可能不会有太大差异。

对于简单项目和具有非常复杂构建需求的项目来说,Gradle 的灵活性可能是一种清新的空气。但特别是在那些复杂的项目中,Gradle 的额外灵活性可能会导致在事情不按预期方式工作时花费更多时间进行调整和故障排除。TANSTAAFL(没有免费午餐)。

Spring Boot 支持 Maven 和 Gradle 两种构建工具,如果你使用 Initializr(将在接下来的章节中介绍),项目和所需的构建文件将被创建,以便让你快速启动。简而言之,尝试两者,然后选择最适合你的方式。Spring Boot 将乐意支持你。

Java 还是 Kotlin?

JVM 上有许多可供使用的语言,但其中两种使用最广泛。一种是最初的 JVM 语言 Java;另一种是相对较新的 Kotlin。在 Spring Boot 中,两者都是完整的一流公民。

Java

取决于你将公开发布的 1.0 版本还是项目起源视为其官方诞生日期,Java 已经存在了 25 或 30 年。然而,它绝不是停滞不前的。自 2017 年 9 月以来,Java 已经采用了六个月的发布周期,导致比以前更频繁地改进特性。维护者已经清理了代码库,并修剪了被新特性所取代的特性,同时引入了由 Java 社区驱动的重要特性。Java 比以往任何时候都更加充满活力。

那种充满活力的创新步伐,再加上 Java 的长寿和持续专注于向后兼容性,意味着全球每天都有无数的 Java 店在维护和创建关键的 Java 应用程序。其中许多应用程序使用 Spring。

Java 构建了几乎整个 Spring 代码库的坚实基础,因此,它是构建 Spring Boot 应用程序的绝佳选择。检查 Spring、Spring Boot 和所有相关项目的代码只需访问其托管的 GitHub 页面,然后在线查看或克隆项目以离线审阅。并且,Java 编写的大量示例代码、示例项目和“入门指南”使得使用 Java 编写 Spring Boot 应用程序可能比市场上任何其他工具链组合都更受支持。

Kotlin

相对而言,Kotlin 是一个新生力量。由 JetBrains 在 2010 年创建,并在 2011 年公开,Kotlin 的创建旨在填补 Java 可用性中的感知空白。Kotlin 从一开始就被设计成:

简洁

Kotlin 需要最少的代码来清晰地向编译器(以及自己和其他开发人员)传达意图。

安全

Kotlin 默认消除了空指针相关的错误,除非开发人员明确覆盖行为以允许它们。

可互操作

Kotlin 旨在与所有现有的 JVM、Android 和浏览器库无摩擦地互操作。

工具友好

在多种集成开发环境(IDE)或命令行中构建 Kotlin 应用程序,就像构建 Java 应用程序一样。

Kotlin 的维护者们以极大的关怀和速度扩展语言的能力。虽然没有将 25 年以上的语言兼容性作为核心设计重点,但他们迅速添加了非常有用的功能,这些功能可能会在 Java 的某些版本中出现。

除了简洁外,Kotlin 也是一种非常流畅的语言。在不深入细节的情况下,几个语言特性有助于这种语言优雅,其中包括扩展函数中缀符号。稍后我会更深入地讨论这个概念,但 Kotlin 使得这样的语法选项成为可能:

infix fun Int.multiplyBy(x: Int): Int { ... }

// calling the function using the infix notation
1 multiplyBy 2

// is the same as
1.multiplyBy(2)

想象一下,定义自己更流畅的“语言内语言”的能力可以成为 API 设计的利器。结合 Kotlin 的简洁性,这使得用 Kotlin 编写的 Spring Boot 应用程序甚至比其 Java 对应版本更短更易读,而不会丢失意图的传达。

Kotlin 自 2017 年秋季发布版本 5.0 以来,就成为 Spring Framework 的全面一等公民,随后通过 Spring Boot(2018 年春季)和其他组件项目获得全面支持。此外,所有 Spring 文档都在扩展以包含 Java 和 Kotlin 的示例。这意味着实际上,你可以像使用 Java 一样轻松地用 Kotlin 编写整个 Spring Boot 应用程序。

在 Java 和 Kotlin 之间做出选择

令人惊讶的是,你实际上并不需要做选择。Kotlin 编译成与 Java 相同的字节码输出;由于 Spring 项目可以同时包含 Java 源文件和 Kotlin,并且可以轻松调用两者的编译器,因此你甚至可以在同一个项目中使用更合理的语言。这种方法算不错吧?

当然,如果你更偏好其中一个,或者有其他个人或专业限制,你显然可以完全使用其中之一开发整个应用程序。有选择总是好的,不是吗?

选择 Spring Boot 的版本

对于生产应用程序,你应该始终使用当前版本的 Spring Boot,但以下是一些临时和狭隘的例外:

  • 你当前正在运行一个较旧版本,但正在按某种顺序进行升级、重新测试和部署应用程序,以至于你还没有到达这个特定的应用程序。

  • 你当前正在运行一个较旧版本,但存在已知冲突或错误,你已向 Spring 团队报告,并被告知等待 Boot 或相关依赖项的更新。

  • 你需要在 GA(正式发布)之前的快照、里程碑或发布候选版本中利用功能,并且愿意接受尚未声明 GA、即“可供生产使用”的代码所固有的风险。

注意

快照、里程碑和发布候选(RC)版本在发布前经过了广泛测试,因此已经付出了大量工作来确保它们的稳定性。然而,在完整的 GA 版本获得批准和发布之前,总是存在 API 更改、修复等的可能性。对于你的应用程序来说风险很低,但你需要自己决定(并测试和确认)在考虑使用任何早期版本软件时这些风险是否可管理。

Spring Initializr

创建 Spring Boot 应用程序有很多种方式,但大多数都会指向一个起点:Spring Initializr,如 图 2-2 所示。

sbur 0202

图 2-2. Spring Initializr

有时简称为其网址,start.spring.io,Spring Initializr 可以从突出的 IDE 项目创建向导、命令行或者最常见的是通过网页浏览器访问。通过网页浏览器使用还提供了一些其他渠道无法(目前)获取的额外实用功能。

要开始以“最佳方式”创建 Spring Boot 项目,请将浏览器指向https://start.spring.io。从那里,我们将选择一些选项然后开始。

要开始使用 Initializr,我们首先选择要与项目一起使用的构建系统。如前所述,我们有两个很好的选择:Maven 和 Gradle。我们选择 Maven 作为示例。

接下来,我们将选择 Java 作为该项目的(语言)基础。

正如您可能已经注意到的,Spring Initializr 为所呈现的选项选择了足够的默认值,以便无需您的任何输入即可创建项目。当您访问此网页时,Maven 和 Java 已经预先选定。当前版本的 Spring Boot 也是如此,对于这个——以及大多数——项目来说,这是您希望选择的版本。

我们可以在项目元数据下留下选项而没有问题,尽管我们将来会修改它们以适应未来的项目。

现在,我们还不包括任何依赖项。这样,我们可以专注于项目创建的机制,而不是任何特定的结果。

在生成项目之前,还有几个 Spring Initializr 的非常好的功能,我想指出,并附带一条侧记。

如果您想在基于当前选择生成项目之前检查项目的元数据和依赖项详细信息,您可以单击“探索”按钮或使用键盘快捷键 Ctrl+Space 打开 Spring Initializr 的项目浏览器(如图 2-3 所示)。然后,Initializr 将向您展示将包含在即将下载的压缩(.zip)项目中的项目结构和构建文件。您可以查看目录/包结构,应用属性文件(稍后详述),以及在构建文件中指定的项目属性和依赖项:因为我们在这个项目中使用 Maven,所以我们的是pom.xml

sbur 0203

图 2-3. Spring Initializr 项目浏览器

这是在下载、解压和加载到您的 IDE 中全新空项目之前,验证项目配置和依赖项的快速便捷方法。

Spring Initializr 的另一个较小的功能,但却受到许多开发人员的欢迎,是暗色模式。通过点击页面顶部显示的Dark UI切换,如图 2-4 所示,您可以切换到 Initializr 的暗色模式,并使其成为每次访问页面时的默认模式。这是一个小功能,但如果您在其他所有地方都保持暗色模式,那么加载 Initializr 时肯定会减少不适感,使体验更加愉快。您会希望继续使用它!

sbur 0204

图 2-4. Spring Initializr,暗色模式下!
注意

除了主应用程序类及其主方法以及空测试之外,Spring Initializr 不会为您生成代码;它根据您的指导为您生成项目。这是一个小区别,但却是一个非常重要的区别:代码生成结果千差万别,通常会在您开始进行更改时束缚您。通过生成项目结构,包括具有指定依赖项的构建文件,Initializr 为您提供了一个运行的起点,以编写您需要利用 Spring Boot 自动配置的代码。自动配置为您提供超能力,而无需约束。

接下来,单击“生成”按钮生成、打包并下载您的项目,将其保存到本地机器上选择的位置。然后导航到下载的 .zip 文件,并解压以准备开发您的应用程序。

直接来自命令行

如果您乐意在命令行上尽可能多地花时间,或者希望最终脚本化项目创建过程,那么 Spring Boot 命令行界面 (CLI) 是为您量身定制的。Spring Boot CLI 具有许多强大的功能,但目前我们将专注于创建新的 Boot 项目。

sbur 0205

图 2-5. 在 SDKMAN 上的 Spring Boot CLI

安装完 Spring Boot CLI 后,您可以使用以下命令创建与刚刚创建的相同项目:

spring init

要将压缩的项目提取到名为 demo 的目录中,您可以执行以下命令:

unzip demo.zip -d demo

等等,怎么会这么简单?用一个词来说,那就是默认设置。Spring CLI 使用与 Spring Initializr 相同的默认设置(Maven、Java 等),允许您仅为希望更改的值提供参数。让我们特别为其中一些默认值提供值(并为项目提取添加一个有用的变化),以更好地了解所涉及的内容:

spring init -a demo -l java --build maven demo

我们仍在使用 Spring CLI 初始化项目,但现在我们提供了以下参数:

  • -a demo (或 --artifactId demo) 允许我们为项目提供一个 artifact ID;在本例中,我们称其为“demo”。

  • -l java (或 --language java) 允许我们指定 Java、Kotlin 或 Groovy¹ 作为此项目的主要语言。

  • --build 是用于构建系统参数的标志;有效的值是 mavengradle

  • -x demo 请求 CLI 提取 Initializr 返回的项目 .zip 文件;请注意,-x 是可选的,并且在没有扩展名的情况下指定文本标签(如我们在这里所做的)会被推断为提取目录。

注意

执行 spring help init 命令可以进一步查看所有这些选项。

当指定依赖关系时,事情会变得更加复杂。正如您可能想象的那样,从 Spring Initializr 提供的“菜单”中选择依赖项非常方便。但是,Spring CLI 的灵活性对于快速启动、脚本化和构建管道非常有用。

还有一件事:默认情况下,CLI 利用 Initializr 提供其项目构建能力,这意味着通过 CLI 或通过 Initializr 网页创建的项目是相同的。在直接使用 Spring Initializr 能力的场景中,这种一致性是非常重要的。

不过,有时组织会严格控制开发者能够使用的依赖项,甚至是创建项目的工具。坦率地说,这种做法让我感到沮丧,因为它会限制组织的灵活性和用户/市场响应能力。如果你在这样的组织中工作,那么在完成任何工作时可能会变得更加复杂。

在这种情况下,您可以创建自己的项目生成器(甚至克隆 Spring Initializr 的存储库)并直接使用生成的网页…或者只暴露 REST API 部分并从 Spring CLI 中使用。为此,只需将此参数添加到之前显示的命令中(当然,要用您的有效 URL 替换 ):

--target https://insert.your.url.here.org

在集成开发环境(IDE)中停留

无论如何创建 Spring Boot 项目,您都需要打开它并编写一些代码以创建有用的应用程序。

有三种主要的集成开发环境(IDE)和许多文本编辑器可以很好地支持开发者。IDE 包括但不限于Apache NetBeansEclipseIntelliJ IDEA。这三种都是开源软件(OSS),在许多情况下都是免费的。²

在本书中,以及在我的日常生活中,我主要使用 IntelliJ Ultimate Edition。在选择 IDE 时,并没有绝对的正确选择,更多取决于个人喜好(或组织的要求或偏好),因此请根据自己的情况选择最适合你和你喜好的工具。大多数主要工具之间的概念转移都非常顺畅。

还有几款编辑器在开发者中拥有大量的追随者。例如,像Sublime Text这样的付费应用程序由于其质量和长期性而拥有激烈的追随者。其他更近期进入这一领域的编辑器,如由 GitHub 创建的Atom(现在由 Microsoft 拥有)和由 Microsoft 创建的Visual Studio Code(简称 VSCode),正在迅速增强其功能和获得忠实的追随者。

在本书中,我偶尔会使用 VSCode 或其从相同代码库构建但已禁用遥测/追踪的对应版本,VSCodium。为了支持大多数开发者期望和/或需要的某些功能,我向 VSCode/VSCodium 添加了以下扩展:

Spring Boot Extension Pack(Pivotal)

这还包括几个其他扩展,如 Spring Initializr Java SupportSpring Boot ToolsSpring Boot Dashboard,它们分别在 VSCode 中便于创建、编辑和管理 Spring Boot 应用程序。

Debugger for Java(Microsoft)

Spring Boot 仪表板的依赖项。

IntelliJ IDEA 快捷键(加藤圭佑)

因为我主要使用 IntelliJ,这使得我更容易在这两者之间切换。

Java™语言支持(Red Hat)

Spring Boot 工具的依赖。

Java 的 Maven(Microsoft)

便于使用基于 Maven 的项目。

还有其他扩展可能对处理 XML、Docker 或其他辅助技术很有用,但对于我们当前的目的来说,这些是必需的。

继续进行我们的 Spring Boot 项目,接下来您将希望在您选择的 IDE 或文本编辑器中打开它。在本书的大多数示例中,我们将使用 IntelliJ IDEA,这是一款由 JetBrains 开发的非常强大的 IDE(使用 Java 和 Kotlin 编写)。如果您已将您的 IDE 与项目构建文件关联起来,您可以在项目目录中双击pom.xml文件(在 Mac 上使用 Finder,在 Windows 上使用 File Explorer,或在 Linux 上使用各种文件管理器),自动将项目加载到 IDE 中。如果没有,请按照其开发者推荐的方式在您的 IDE 或编辑器中打开项目。

注意

许多 IDE 和编辑器提供了一种创建命令行快捷方式的方法,可以通过简短的命令启动和加载项目。例如,IntelliJ 的idea,VSCode/VSCodium 的code,以及 Atom 的atom快捷方式。

Cruising Down main()

现在我们已经在我们的 IDE(或编辑器)中加载了项目,请看一下什么使得一个 Spring Boot 项目(图 2-6)与标准的 Java 应用有些不同。

sbur 0206

图 2-6. 我们的 Spring Boot 演示应用程序的主应用程序类

标准的 Java 应用程序(默认情况下)包含一个空的public static void main方法。当我们执行 Java 应用程序时,JVM 会搜索此方法作为应用程序的起点,如果没有此方法,应用程序启动将失败,并显示类似以下的错误:

Error:
Main method not found in class PlainJavaApp, please define the main method as:
	public static void main(String[] args)
or a JavaFX application class must extend javafx.application.Application

当然,你可以将要在应用程序启动时执行的代码放置在 Java 类的主方法中,Spring Boot 应用程序正是这样做的。在启动时,Spring Boot 应用程序会检查环境、配置应用程序、创建初始上下文,并启动 Spring Boot 应用程序。它通过一个顶级注解和一行代码完成,如图 2-7 所示。

sbur 0207

图 2-7. Spring Boot 应用程序的本质

在本书逐步展开时,我们将深入探讨这些机制的内部工作。现在,可以说,通过设计和默认设置,Boot 在应用程序启动时会为您减少大量繁琐的应用程序设置工作,这样您就可以迅速专注于编写有意义的代码。

总结

本章已经探讨了创建 Spring Boot 应用程序时的一些一流选择。无论您喜欢使用 Maven 还是 Gradle 构建项目,在 Java 或 Kotlin 中编写代码,还是通过 Spring Initializr 提供的 Web 界面或其命令行伙伴 Spring Boot CLI 创建项目,您都可以毫不妥协地利用 Spring Boot 的全部功能和便利。您还可以使用各种支持 Spring Boot 的顶级 IDE 和文本编辑器来处理 Boot 项目。

正如这里和第一章中所述,Spring Initializr 努力为您快速轻松地创建项目。Spring Boot 在开发生命周期中通过以下功能有意义地做出贡献:

  • 简化的依赖管理,从项目创建到开发和维护都起到作用。

  • 自动配置大大减少/消除了在处理问题域之前可能编写的样板代码。

  • 简化的部署使打包和部署变得十分轻松。

不论您在这个过程中做出了哪些构建系统、语言或工具链的选择,所有这些能力都得到了充分支持。这是一个令人惊讶的灵活和强大的组合。

在下一章中,我们将创建我们的第一个真正有意义的 Spring Boot 应用程序:一个提供 REST API 的应用程序。

¹ Spring Boot 仍然支持 Groovy,但远不及 Java 或 Kotlin 广泛使用。

² 有两个选项可供选择:社区版(CE)和旗舰版(UE)。社区版支持 Java 和 Kotlin 应用程序开发,但要获得所有可用的 Spring 支持,您必须使用旗舰版。某些用例符合 UE 的免费许可证条件,或者您当然也可以购买一个。此外,这三个版本都为 Spring Boot 应用程序提供了出色的支持。

第三章:创建您的第一个 Spring Boot REST API

在本章中,我将解释并演示如何使用 Spring Boot 开发一个基本的工作应用程序。由于大多数应用程序涉及将后端云资源暴露给用户,通常通过前端 UI,因此应用程序编程接口(API)是理解和实践的绝佳起点。让我们开始吧。

API 的作用与原因

能够做所有事情的单体应用程序的时代已经结束了。

这并不意味着单体应用程序不再存在,或者它们不会继续存在很长时间。在各种情况下,一个将众多功能打包到一个包中的单体应用程序仍然是有意义的,尤其是在以下环境中:

  • 领域及其边界在很大程度上是未知的。

  • 提供的功能是紧密耦合的,模块交互的绝对性能优先于灵活性。

  • 所有相关能力的扩展要求是已知的和一致的。

  • 功能不是易变的;变化是缓慢的,范围有限,或者两者兼有。

对于其他一切,都有微服务。

当然,这是一个极度简化的说法,但我认为这是一个有用的总结。通过将功能分成更小、更有凝聚力的“块”,我们可以解耦它们,从而有可能实现更灵活、更强大的系统,这些系统可以更快地部署和更容易地维护。

在任何分布式系统中——毫无疑问,一个由微服务组成的系统就是这样的——通信至关重要。没有服务是孤立的。虽然有许多连接应用程序/微服务的机制,但我们通常通过模拟我们日常生活的基本结构——互联网——来开始我们的旅程。

互联网是为通信而建立的。事实上,其前身的设计者,高级研究计划局网络(ARPANET),预见到了即使在“重大中断”事件发生时也需要保持系统间通信的需求。可以合理地推断,一种类似于我们日常生活中使用的一种 HTTP 方法,同样能够让我们通过“过程”创建、检索、更新和删除各种资源。

尽管我很喜欢历史,但我不会深入研究 REST API 的历史,除了说罗伊·菲尔丁在他 2000 年的博士论文中阐述了它们的原则,该原则是建立在 1994 年的HTTP 对象模型之上的。

REST 是什么,为什么它很重要?

正如前面提到的,API 是我们开发人员用来编写以便我们的代码可以使用其他代码的规范/接口:库、其他应用程序或服务。但RESTREST API中代表什么?

REST 是表述性状态转移的首字母缩写,这是一种有点神秘的方式,它表明当一个应用程序与另一个应用程序通信时,应用程序 A 将其当前状态带入,而不是期望应用程序 B 在通信调用之间维护状态——当前和累积的、基于进程的信息。应用程序 A 在每个对应用程序 B 的请求中提供其相关状态的表示。您可以很容易地看出,这为应用程序的生存能力和弹性增加了,因为如果存在通信问题或者应用程序 B 崩溃并重新启动,它不会丢失与应用程序 A 的交互的当前状态;应用程序 A 可以简单地重新发出请求并继续两个应用程序之间停下的地方。

注意

这个通用概念通常被称为无状态应用/服务,因为每个服务在一系列交互中都保持自己的当前状态,而不期望其他服务代表其执行这种操作。

您的 API,HTTP 动词风格

现在,关于那个 REST API——有时称为 RESTful API,这是一种很好、令人放松的方式,不是吗?

在一些互联网工程任务组(IETF)的请求评论(RFCs)中定义了许多标准化的 HTTP 动词。其中,少数几个通常被一致地用于构建 API,还有几个偶尔会被使用。REST API 主要建立在以下 HTTP 动词之上:

  • POST

  • GET

  • PUT

  • PATCH

  • DELETE

这些动词对应我们在资源上执行的典型操作:创建(POST)、读取(GET)、更新(PUTPATCH)和删除(DELETE)。

注意

我承认通过将PUT大致等同于更新资源,稍微模糊了界限,并且通过将POST等同于创建资源的方式稍微减少了一些。我请求读者在我实施并提供澄清的过程中给予理解。

偶尔会使用以下两个动词:

  • OPTIONS

  • HEAD

这些可以用来获取请求/响应对可用的通信选项(OPTIONS)以及获取响应头部分的响应,不包括其主体(HEAD)。

对于本书以及大多数实际生产中的使用,我将专注于第一组,即大量使用的组。为了开始(没有双关意味),让我们创建一个实现非常基本的 REST API 的简单微服务。

回到 Initializr

我们像往常一样从 Spring Initializr 开始,如图 3-1 所示。我已经更改了 Group 和 Artifact 字段以反映我使用的详细信息(请随意使用您喜欢的命名方式),在选项(可选的,任何列出的版本都可以很好地完成)下选择了 Java 11,并且仅选择了 Spring Web 依赖项。正如显示的描述中所示,此依赖项带有多种功能,包括“[构建]使用 Spring MVC 构建 Web,包括 RESTful应用程序”(强调添加)。这正是我们当前任务所需的。

sbur 0301

图 3-1. 创建一个 Spring Boot 项目以构建 REST API

一旦我们在 Initializr 中生成了项目并将结果.zip文件保存在本地,我们将提取压缩的项目文件——通常通过双击在文件浏览器中下载的sbur-rest-demo.zip文件或通过从 shell/终端窗口使用unzip——然后在您选择的 IDE 或文本编辑器中打开现在已提取的项目,以查看类似于图 3-2 的视图。

sbur 0302

图 3-2. 我们的新 Spring Boot 项目,正等待我们开始

创建一个简单的领域

为了处理资源,我们需要编写一些代码来适应一些资源。让我们首先创建一个非常简单的领域类,表示我们想要管理的资源。

我有点咖啡爱好者,正如我的好朋友们——现在包括你——所知道的。考虑到这一点,我将使用一个咖啡领域,其中一个类代表一种特定类型的咖啡,作为本例的领域。

让我们开始创建Coffee类。这对例子至关重要,因为我们需要一个某种资源来演示如何通过 REST API 管理资源。但领域的简单或复杂对于本例来说不重要,所以我会保持简单,专注于目标:最终的 REST API。

如图 3-3 所示,Coffee类有两个成员变量:

  • 一个id字段用于唯一标识特定类型的咖啡

  • 一个name字段,描述咖啡的名称

sbur 0303

图 3-3. 咖啡类:我们的领域类

我将id字段声明为final,这样它只能被分配一次且不能修改;因此,在创建Coffee类的实例时必须分配它,这也意味着它没有修改器方法。

我创建了两个构造函数:一个接受两个参数,另一个在创建Coffee时如果没有提供唯一标识符则提供一个。

接下来,我创建了访问器和修改器方法——或者您更愿意称之为获取器和设置器方法——用于name字段,该字段未声明为final,因此可变。这是一个有争议的设计决策,但对于本例子的即将到来的需求非常合适。

有了这个,我们现在有了一个基本的领域。接下来是 REST 的时候了。

进行 GET 请求

或许最常用的最常用的动词是GET。所以让我们开始吧(双关语)。

@RestController 简介

不要陷得太深,Spring MVC(模型-视图-控制器)的创建是为了在数据、其传递和呈现之间分离关注点,假设视图将作为服务器呈现的网页提供。@Controller注解帮助将各个部分联系在一起。

@Controller@Component注释的一种别名,这意味着在应用启动时,Spring Bean——由 Spring 控制反转(IoC)容器在应用程序中创建和管理的对象——从该类中创建。带有@Controller注释的类可以容纳一个Model对象,以向表示层提供基于模型的数据,并使用ViewResolver来指示应用程序显示特定视图,由视图技术渲染。

注意

Spring 支持多种视图技术和模板引擎,这些将在后续章节中介绍。

还可以指示Controller类通过将@ResponseBody注释添加到类或方法(默认为 JSON)来返回格式化的响应。这将导致方法的对象/可迭代返回值成为 web 请求响应的整个主体,而不是作为Model的一部分返回。

@RestController注释是一个方便的标注,将@Controller@ResponseBody结合成一个描述性注释,简化您的代码并使意图更加明显。一旦我们将类标记为@RestController,我们就可以开始创建我们的 REST API。

让我们GET忙碌起来

REST API 处理对象,对象可以单独出现,也可以作为一组相关对象出现。为了利用我们的咖啡场景,您可能希望检索特定的咖啡;或者您可能希望检索所有咖啡,或者所有被视为深烘焙的咖啡,或者在描述中包含“哥伦比亚”等。为了满足检索一个实例或多个实例的需求,在我们的代码中创建多个方法是一个良好的做法。

我将首先创建一个Coffee对象列表,以支持方法返回多个Coffee对象,如以下基本类定义所示。我将定义变量,用于保存这组咖啡,作为Coffee对象列表。我选择List作为成员变量类型的高级接口,但实际上将在RestApiDemoController类中分配一个空的ArrayList以供使用:

@RestController
class RestApiDemoController {
	private List<Coffee> coffees = new ArrayList<>();
}
注意

接受以最高级别类型(类、接口)作为可以清洁地满足内部和外部 API 的实践是一种推荐做法。这些可能在所有情况下都不匹配,正如这里不匹配。在内部,List提供了使我能够基于我的标准创建最清晰实现的 API 级别;在外部,我们可以定义一个更高级别的抽象,我很快会演示。

始终有一些数据可以检索,以确认一切是否按预期工作。在以下代码中,我为RestApiDemoController类创建一个构造函数,并添加代码以在对象创建时填充咖啡列表:

@RestController
class RestApiDemoController {
	private List<Coffee> coffees = new ArrayList<>();

	public RestApiDemoController() {
		coffees.addAll(List.of(
				new Coffee("Café Cereza"),
				new Coffee("Café Ganador"),
				new Coffee("Café Lareño"),
				new Coffee("Café Três Pontas")
		));
	}
}

如下代码所示,我在RestApiDemoController类中创建了一个方法,该方法返回一个由我们的成员变量coffees表示的可迭代咖啡组。我选择使用Iterable<Coffee>,因为任何可迭代类型都能满足此 API 所需的功能:

使用 @RequestMapping 获取咖啡列表的GET

@RestController
class RestApiDemoController {
	private List<Coffee> coffees = new ArrayList<>();

	public RestApiDemoController() {
		coffees.addAll(List.of(
				new Coffee("Café Cereza"),
				new Coffee("Café Ganador"),
				new Coffee("Café Lareño"),
				new Coffee("Café Três Pontas")
		));
	}

	@RequestMapping(value = "/coffees", method = RequestMethod.GET)
	Iterable<Coffee> getCoffees() {
		return coffees;
	}
}

对于 @RequestMapping 注解,我添加了路径规范为 /coffees,方法类型为 RequestMethod.GET,表示该方法将响应路径为 /coffees 的请求,并且限制请求仅为 HTTP GET 请求。数据的检索由该方法处理,但不处理任何更新。Spring Boot 通过包含在 Spring Web 中的 Jackson 依赖自动执行对象到 JSON 或其他格式的编组和解组操作。

我们可以进一步简化使用另一个便利的注解。使用 @GetMapping 整合指令以仅允许 GET 请求,减少样板代码,只需指定路径,甚至省略 path =,因为不需要参数解决冲突。下面的代码清楚地展示了此注解替换带来的可读性好处:

@GetMapping("/coffees")
Iterable<Coffee> getCoffees() {
    return coffees;
}

进行POST

创建资源时,首选的方法是使用 HTTP POST 方法。

注意

POST 提供资源的详细信息,通常以 JSON 格式,请求目标服务在指定的 URI 下创建该资源。

如下代码片段所示,POST 是一个相对简单的操作:我们的服务接收指定的咖啡详情作为 Coffee 对象(得益于 Spring Boot 的自动编组),并将其添加到我们的咖啡列表中。然后返回请求的应用程序或服务的 Coffee 对象(默认情况下由 Spring Boot 自动解组为 JSON):

@PostMapping("/coffees")
Coffee postCoffee(@RequestBody Coffee coffee) {
    coffees.add(coffee);
    return coffee;
}

PUT 操作

一般来说,PUT 请求用于更新已知 URI 的现有资源。

注意

根据 IETF 的文档《超文本传输协议(HTTP/1.1):语义和内容》,PUT 请求应更新指定的资源(如果存在);如果资源不存在,则应创建它。

下面的代码符合规范:搜索具有指定标识符的咖啡,如果找到,则更新它。如果列表中没有这样的咖啡,则创建它:

@PutMapping("/coffees/{id}")
Coffee putCoffee(@PathVariable String id, @RequestBody Coffee coffee) {
    int coffeeIndex = -1;

    for (Coffee c: coffees) {
        if (c.getId().equals(id)) {
            coffeeIndex = coffees.indexOf(c);
            coffees.set(coffeeIndex, coffee);
        }
    }

    return (coffeeIndex == -1) ? postCoffee(coffee) : coffee;
}

DELETE 操作

要删除资源,我们使用 HTTP DELETE 请求。如下代码片段所示,我们创建了一个方法,接受咖啡标识符作为 @PathVariable 并使用 removeIf Collection 方法从我们的列表中删除适用的咖啡。removeIf 接受一个 Predicate,意味着我们可以提供一个 lambda 表达式来评估是否返回要删除的目标咖啡的布尔值。整洁而简便:

@DeleteMapping("/coffees/{id}")
void deleteCoffee(@PathVariable String id) {
    coffees.removeIf(c -> c.getId().equals(id));
}

等等

虽然这个场景有许多改进的地方,但我将重点放在两个特定的方面上:减少重复和根据规范返回必要的 HTTP 状态码。

为了减少代码中的重复,我将在 RestApiDemoController 类中将通用于该类内所有方法的 URI 映射部分提升到类级别的 @RequestMapping 注解中,即 "/coffees"。然后,我们可以从每个方法的映射 URI 规范中删除相同的 URI 部分,减少文本噪音,如下面的代码所示:

@RestController
@RequestMapping("/coffees")
class RestApiDemoController {
	private List<Coffee> coffees = new ArrayList<>();

	public RestApiDemoController() {
		coffees.addAll(List.of(
				new Coffee("Café Cereza"),
				new Coffee("Café Ganador"),
				new Coffee("Café Lareño"),
				new Coffee("Café Três Pontas")
		));
	}

	@GetMapping
	Iterable<Coffee> getCoffees() {
		return coffees;
	}

	@GetMapping("/{id}")
	Optional<Coffee> getCoffeeById(@PathVariable String id) {
		for (Coffee c: coffees) {
			if (c.getId().equals(id)) {
				return Optional.of(c);
			}
		}

		return Optional.empty();
	}

	@PostMapping
	Coffee postCoffee(@RequestBody Coffee coffee) {
		coffees.add(coffee);
		return coffee;
	}

	@PutMapping("/{id}")
	Coffee putCoffee(@PathVariable String id, @RequestBody Coffee coffee) {
		int coffeeIndex = -1;

		for (Coffee c: coffees) {
			if (c.getId().equals(id)) {
				coffeeIndex = coffees.indexOf(c);
				coffees.set(coffeeIndex, coffee);
			}
		}

		return (coffeeIndex == -1) ? postCoffee(coffee) : coffee;
	}

	@DeleteMapping("/{id}")
	void deleteCoffee(@PathVariable String id) {
		coffees.removeIf(c -> c.getId().equals(id));
	}
}

接下来,我查阅了早前提到的 IETF 文档,并注意到虽然 GET 方法未指定 HTTP 状态码,但建议 POSTDELETE 方法,但要求 PUT 方法响应状态码。为了实现这一点,我修改了 putCoffee 方法,如下面的代码段所示。现在,putCoffee 方法将不仅返回更新或创建的 Coffee 对象,还将返回一个包含该 Coffee 和适当 HTTP 状态码的 ResponseEntity:如果 PUT 的咖啡尚不存在,则返回 201(已创建),如果存在并已更新,则返回 2000(成功)。当然,我们还可以做更多,但当前应用程序代码满足要求,并表示简单且清晰的内部和外部 API:

@PutMapping("/{id}")
ResponseEntity<Coffee> putCoffee(@PathVariable String id,
        @RequestBody Coffee coffee) {
    int coffeeIndex = -1;

    for (Coffee c: coffees) {
        if (c.getId().equals(id)) {
            coffeeIndex = coffees.indexOf(c);
            coffees.set(coffeeIndex, coffee);
        }
    }

    return (coffeeIndex == -1) ?
            new ResponseEntity<>(postCoffee(coffee), HttpStatus.CREATED) :
            new ResponseEntity<>(coffee, HttpStatus.OK);
}

信任,但要验证

代码已经就绪,让我们来测试这个 API。

注意

我使用 HTTPie 命令行 HTTP 客户端处理几乎所有基于 HTTP 的任务。偶尔也会使用 curlPostman,但我发现 HTTPie 是一个功能强大且具有简洁命令行界面的多用途客户端。

如 图 3-4 所示,我查询 coffees 端点以获取当前列表中的所有咖啡。如果没有提供主机名,HTTPie 默认为 GET 请求并假定 localhost,减少了不必要的输入。正如预期的那样,我们看到了我们预填充列表中的所有四种咖啡。

sbur 0304

图 图 3-4. 获取所有咖啡

接下来,我复制了列表中一种咖啡的 id 字段,并将其粘贴到另一个 GET 请求中。图 3-5 显示了正确的响应。

sbur 0305

图 3-5. 获取一种咖啡

使用 HTTPie 执行 POST 请求非常简单:只需将包含 idname 字段的 JSON 表示形式的纯文本文件传输,HTTPie 将会执行 POST 操作。图 3-6 显示了命令及其成功的结果。

sbur 0306

图 3-6. 向列表中添加新咖啡的 POST 操作

正如前面提到的,PUT 命令应允许更新现有资源或在请求的资源不存在时添加新资源。在 图 3-7 中,我指定了我刚添加的咖啡的 id 并向命令传递了另一个具有不同名称的 JSON 对象。结果是,具有 id “99999”的咖啡现在的 name 是 “Caribou Coffee”,而不是之前的 “Kaldi’s Coffee”。返回码也符合预期,为 200(OK)。

sbur 0307

图 3-7. PUT 更新现有咖啡

在 图 3-8 中,我以相同方式发起了 PUT 请求,但引用了 URI 中不存在的 id。应用程序遵循 IETF 指定的行为添加了它,并正确返回了 HTTP 状态码 201(Created)。

sbur 0308

图 3-8. PUT 添加新咖啡

使用 HTTPie 创建 DELETE 请求与创建 PUT 请求非常相似:必须指定 HTTP 动词,并且资源的 URI 必须完整。 图 3-9 显示了结果:HTTP 状态码为 200(OK),表明资源已成功删除,并且没有显示值,因为资源已不存在。

sbur 0309

图 3-9. 删除咖啡

最后,我们重新查询我们的咖啡全列表以确认预期的最终状态。如 图 3-10 所示,我们现在有了一个之前列表中没有的额外咖啡:Mötor Oil Coffee。API 验证成功。

sbur 0310

图 3-10. 获取当前列表中的所有咖啡

摘要

本章演示了如何使用 Spring Boot 开发基本的工作应用程序。由于大多数应用程序涉及将后端云资源暴露给用户,通常通过前端用户界面,我展示了如何创建和发展一个有用的 REST API,可以以多种一致的方式消费,以提供创建、读取、更新和删除几乎每个关键系统中心资源所需的功能。

我检查并解释了 @RequestMapping 注解及其各种便捷注解特化,这些特化与定义的 HTTP 动词相一致:

  • @GetMapping

  • @PostMapping

  • @PutMapping

  • @PatchMapping

  • @DeleteMapping

创建了处理许多这些注释及其关联操作的方法后,我稍微重构了代码以简化它,并在需要时提供了 HTTP 响应代码。验证 API 确认其正确操作。

在下一章中,我将讨论并演示如何将数据库访问添加到我们的 Spring Boot 应用程序中,使其变得更加实用并且为生产准备好。

第四章:将数据库访问添加到您的 Spring Boot 应用程序

正如前一章所讨论的,出于许多非常好的原因,应用程序通常会暴露无状态的 API。然而,在幕后,很少有有用的应用程序是完全短暂的;某种状态通常是为了某事而存储的。例如,每次对在线商店购物车的请求可能都会包含其状态,但一旦下订单,订单的数据就会被保留。有许多方法可以做到这一点,以及共享或路由这些数据的方法,但几乎所有足够大的系统中都会涉及一种或多种数据库。

在本章中,我将演示如何将数据库访问添加到前一章中创建的 Spring Boot 应用程序中。本章旨在简要介绍 Spring Boot 的数据功能,并且后续章节将深入探讨。但在许多情况下,这里介绍的基础仍然适用并提供完全足够的解决方案。让我们深入了解吧。

代码检查

请从代码仓库的分支chapter4begin检出以开始。

为数据库访问设置自动配置

如前所示,Spring Boot 旨在尽可能简化所谓的 80–90% 使用案例:开发人员一遍又一遍地执行的代码和过程模式。一旦识别出模式,Boot 会自动初始化所需的 Bean,使用合理的默认配置。定制一个能力就像提供一个或多个属性值或创建一个定制版本的一个或多个 Bean 一样简单;一旦自动配置检测到变化,它就会退出并遵循开发人员的指导。数据库访问就是一个完美的例子。

我们希望获得什么?

在我们之前的示例应用程序中,我使用了一个ArrayList来存储和维护我们的咖啡列表。这种方法对于单个应用程序来说足够简单,但它确实有其缺点。

首先,它根本不具备弹性。如果您的应用程序或运行该应用程序的平台失败,所有在应用程序运行期间对列表所做的更改——不论持续了几秒钟还是几个月——都会消失。

其次,它不具备可伸缩性。启动应用程序的另一个实例会导致第二个(或后续)应用实例具有其自己独特的咖啡列表。数据不会在多个实例之间共享,因此一个实例对咖啡所做的更改——添加新的咖啡、删除、更新——对于访问不同应用实例的任何人都是不可见的。

显然这不是运行铁路的方式。

我将在接下来的章节中探讨几种不同的方法来完全解决这些非常现实的问题。但现在,让我们奠定一些基础,这些步骤将在未来的道路上非常有用。

添加数据库依赖

要从您的 Spring Boot 应用程序访问数据库,您需要一些东西:

  • 运行中的数据库,无论是由您的应用程序启动/嵌入,还是仅对您的应用程序可访问

  • 数据库驱动程序启用程序化访问,通常由数据库供应商提供。

  • 一个用于访问目标数据库的 Spring Data 模块

某些 Spring Data 模块将适当的数据库驱动程序作为 Spring Initializr 内的单个可选择依赖项包括在内。在其他情况下,例如当 Spring 使用 Java 持久化 API(JPA)访问符合 JPA 的数据存储时,需要选择 Spring Data JPA 依赖项目标数据库的特定驱动程序依赖项,例如 PostgreSQL。

为了从内存构造迈出第一步到持久性数据库,我将从向我们项目的构建文件中添加依赖项和因此的功能开始。

H2 是一个完全用 Java 编写的快速数据库,具有一些有趣且有用的特性。首先,它符合 JPA 标准,因此我们可以像连接任何其他 JPA 数据库(如 Microsoft SQL、MySQL、Oracle 或 PostgreSQL)一样连接我们的应用程序到它上面。它还具有内存和基于磁盘的模式。这使得在我们从内存中的ArrayList转换到内存数据库之后,我们可以选择一些有用的选项:要么将 H2 更改为基于磁盘的持久化,要么(因为我们现在使用的是 JPA 数据库)切换到另一个 JPA 数据库。在那一点上,任何选项都变得简单得多。

为了使我们的应用程序能够与 H2 数据库交互,我将在我们项目的pom.xml<dependencies>部分添加以下两个依赖项:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
注意

H2 数据库驱动程序依赖项的runtime范围表示它将出现在运行时和测试类路径中,但不会出现在编译类路径中。这是对于不需要编译的库采用的良好做法。

一旦保存了更新的pom.xml文件并且(如果必要的话)重新导入/刷新了 Maven 依赖项,您就可以访问所添加依赖项中包含的功能。接下来,是时候写一些代码来使用它了。

添加代码

由于我们已经有了一些管理咖啡的代码,我们需要在添加新的数据库功能时进行一些重构。我发现最好的开始地方是领域类(们),在这种情况下是Coffee

@Entity

如前所述,H2 是一个符合 JPA 标准的数据库,因此我将添加 JPA 注解来连接这些点。对于Coffee类本身,我将添加来自javax.persistence@Entity注解,表示Coffee是一个可持久化的实体,并且对现有的id成员变量,我将添加@Id注解(也来自javax.persistence)来标记它作为数据库表的 ID 字段。

注意

如果类名——在本例中是Coffee——不与期望的数据库表名匹配,@Entity注解接受一个name参数来指定匹配带注解实体的数据表名。

如果你的集成开发环境足够智能,它可能会提示你在Coffee类中仍然缺少某些内容。例如,IntelliJ 会用红色下划线标出类名,并在鼠标悬停时显示有用的弹出窗口,如图 4-1 所示。

sbur 0401

Figure 4-1. JPA Coffee类中缺少的构造函数

Java 持久化 API 要求在从数据库表行创建对象时使用无参数构造函数,因此接下来我将添加这个构造函数。这导致我们的 IDE 显示下一个警告,如图 4-2 所示:为了有一个无参数构造函数,我们必须使所有成员变量可变,即非 final。

sbur 0402

Figure 4-2. 有了无参数构造函数,id不能是 final

从声明id成员变量中删除final关键字解决了这个问题。为了使id可变,我们的Coffee类还需要为id添加一个 mutator 方法,以便 JPA 能够为该成员变量分配一个值,因此我也添加了setId()方法,如图 4-3 所示。

sbur 0403

Figure 4-3. 新的setId()方法

仓库(Repository)

现在将Coffee定义为有效的 JPA 实体,可以进行存储和检索,是时候与数据库建立连接了。

对于一个如此简单的概念,在 Java 生态系统中配置和建立数据库连接长期以来一直是一件相当繁琐的事情。正如在第一章中提到的,使用应用服务器托管 Java 应用程序需要开发人员执行多个繁琐的步骤才能做好准备工作。一旦开始与数据库交互,或者如果直接从 Java 实用程序或客户端应用程序访问数据存储,则需要执行涉及PersistenceUnitEntityManagerFactoryEntityManager API(以及可能的DataSource对象)、打开和关闭数据库等额外步骤。对于开发人员如此频繁地进行的操作来说,这些仪式感很多。

Spring Data 引入了仓库(repositories)的概念。Repository是 Spring Data 中定义的一个接口,作为对各种数据库的有用抽象。Spring Data 中还有其他访问数据库的机制,将在后续章节中详细解释,但各种类型的Repository可以说是最常用的。

Repository本身只是以下类型的一个占位符:

  • 存储在数据库中的对象

  • 对象的唯一 ID/主键字段

当然,关于仓库还有很多内容,这些内容我会在第六章中详细介绍。现在,让我们专注于当前示例中直接相关的两个:CrudRepositoryJpaRepository

还记得我之前提到的使用最高级别接口来编写代码的首选实践吗?虽然JpaRepository扩展了几个接口,并因此包含了更广泛的功能,但CrudRepository涵盖了所有关键的 CRUD 功能,对于我们(到目前为止)简单的应用程序已经足够了。

为了启用我们应用程序的仓库支持,首先需要通过扩展 Spring Data 的Repository接口来定义一个特定于我们应用程序的接口:.interfaceCoffeeRepo

interface CoffeeRepository extends CrudRepository<Coffee, String> {}
注意

定义的两种类型是存储对象类型及其唯一 ID 的类型。

这代表了在 Spring Boot 应用程序中创建仓库的最简表达方式。在某些情况下,定义仓库的查询是可能的,也是非常有用的;在未来的章节中我将深入讨论。但这里有一个“神奇”的部分:Spring Boot 的自动配置考虑了类路径上的数据库驱动(在本例中是 H2)、我们应用程序中定义的仓库接口,以及 JPA 实体Coffee类定义,并为我们创建了一个数据库代理 bean on our behalf。当模式如此明确和一致时,无需为每个应用程序编写几乎相同的样板代码,这使开发人员能够专注于新的、被请求的功能。

实用程序,即“Springing”进入行动

现在是时候让那个仓库投入运行了。我会像前几章一样,分步骤地介绍功能,先引入功能,然后再进行完善。

首先,我将仓库 bean 自动装配/注入到RestApiDemoController中,以便控制器可以在通过外部 API 接收请求时访问它,如图 4-4 所示。

首先,我声明了成员变量:

private final CoffeeRepository coffeeRepository;

接下来,我通过以下方式将其作为构造函数的参数添加:

public RestApiDemoController(CoffeeRepository coffeeRepository){}
注意

在 Spring Framework 4.3 之前,必须在所有情况下在方法上方添加@Autowired注解,以指示参数表示 Spring bean 应自动装配/注入。从 4.3 开始,具有单个构造函数的类不需要为自动装配的参数添加注解,这是一个有用的时间节省功能。

sbur 0404

图 4-4. 将仓库自动装配到RestApiDemoController

当仓库设置完成后,我删除了List<Coffee>成员变量,并在构造函数中将该列表的初始填充更改为将相同的咖啡保存到仓库中,如图 4-4 中所示。

根据图 4-5,立即删除coffees变量会立即标记所有对它的引用为不可解析的符号,因此下一个任务是用适当的仓库交互替换这些引用。

sbur 0405

图 4-5. 替换已移除的coffees成员变量

作为没有参数的简单检索所有咖啡的方法,getCoffees()方法是一个很好的起点。使用内置在CrudRepository中的findAll()方法,甚至不需要更改getCoffees()的返回类型,因为它还返回一个Iterable类型;只需调用coffeeRepository.findAll()并返回其结果即可完成任务,如下所示:

@GetMapping
Iterable<Coffee> getCoffees() {
    return coffeeRepository.findAll();
}

重构getCoffeeById()方法为我们的代码带来了一些洞见,感谢存储库为混合带来的功能。我们不再需要手动搜索匹配的id咖啡列表了;CrudRepositoryfindById()方法为我们处理了,如下代码片段所示。由于findById()返回一个Optional类型,因此我们的方法签名不需要任何更改:

@GetMapping("/{id}")
Optional<Coffee> getCoffeeById(@PathVariable String id) {
    return coffeeRepository.findById(id);
}

postCoffee()方法转换为使用存储库也是一个相当简单的尝试,如下所示:

@PostMapping
Coffee postCoffee(@RequestBody Coffee coffee) {
    return coffeeRepository.save(coffee);
}

使用putCoffee()方法,我们再次看到了CrudRepository所展示的大量节省时间和代码的功能。我使用内置的existsById()存储库方法来确定这是新的还是现有的Coffee,并返回适当的 HTTP 状态代码以及保存的Coffee,如此清单所示:

@PutMapping("/{id}")
ResponseEntity<Coffee> putCoffee(@PathVariable String id,
                                 @RequestBody Coffee coffee) {

    return (!coffeeRepository.existsById(id))
            ? new ResponseEntity<>(coffeeRepository.save(coffee),
                  HttpStatus.CREATED)
            : new ResponseEntity<>(coffeeRepository.save(coffee), HttpStatus.OK);
}

最后,我更新了deleteCoffee()方法以使用CrudRepository内置的deleteById()方法,如下所示:

@DeleteMapping("/{id}")
void deleteCoffee(@PathVariable String id) {
    coffeeRepository.deleteById(id);
}

利用使用CrudRepository的流畅 API 创建的存储库 bean 简化了RestApiDemoController的代码,并使其更加清晰,无论是从可读性还是可理解性方面,都能清晰表达,如完整代码清单所示:

@RestController
@RequestMapping("/coffees")
class RestApiDemoController {
    private final CoffeeRepository coffeeRepository;

    public RestApiDemoController(CoffeeRepository coffeeRepository) {
        this.coffeeRepository = coffeeRepository;

        this.coffeeRepository.saveAll(List.of(
                new Coffee("Café Cereza"),
                new Coffee("Café Ganador"),
                new Coffee("Café Lareño"),
                new Coffee("Café Três Pontas")
        ));
    }

    @GetMapping
    Iterable<Coffee> getCoffees() {
        return coffeeRepository.findAll();
    }

    @GetMapping("/{id}")
    Optional<Coffee> getCoffeeById(@PathVariable String id) {
        return coffeeRepository.findById(id);
    }

    @PostMapping
    Coffee postCoffee(@RequestBody Coffee coffee) {
        return coffeeRepository.save(coffee);
    }

    @PutMapping("/{id}")
    ResponseEntity<Coffee> putCoffee(@PathVariable String id,
                                     @RequestBody Coffee coffee) {

        return (!coffeeRepository.existsById(id))
                ? new ResponseEntity<>(coffeeRepository.save(coffee),
                HttpStatus.CREATED)
                : new ResponseEntity<>(coffeeRepository.save(coffee), HttpStatus.OK);
    }

    @DeleteMapping("/{id}")
    void deleteCoffee(@PathVariable String id) {
        coffeeRepository.deleteById(id);
    }
}

现在,唯一剩下的就是验证我们的应用程序按预期工作并且外部功能保持不变。

注意

测试功能的另一种方法——也是推荐的实践方法——是首先创建单元测试,类似于测试驱动开发(TDD)。我强烈推荐这种方法在真实的软件开发环境中,但我发现当目标是演示和解释离散的软件开发概念时,越少越好;尽可能少地显示以清晰传达关键概念会增加信号,减少噪音,即使噪音后来也很有用。因此,我在本书的后面的一个专门章节中介绍了测试。

保存和检索数据

再次进入领域,亲爱的朋友们,再次:使用 HTTPie 从命令行访问 API。查询咖啡端点会从我们的 H2 数据库中返回与之前相同的四种咖啡,如图 4-6 所示。

复制刚列出的其中一种咖啡的id字段,并将其粘贴到特定于咖啡的GET请求中,将产生如图 4-7 所示的输出。

sbur 0406

图 4-6. 获得所有咖啡

sbur 0407

图 4-7. 获得一杯咖啡

在图 4-8 中,我向应用程序和其数据库POST了一个新的咖啡。

sbur 0408

图 4-8。向列表中POST一个新的咖啡

如前一章所讨论的,PUT命令应允许更新现有资源或如果请求的资源尚不存在则添加一个新的资源。在图 4-9 中,我指定了刚刚添加的咖啡的id,并传递给命令一个修改该咖啡名称的 JSON 对象。更新后,id为“99999”的咖啡现在的name是“Caribou Coffee”,而不是“Kaldi's Coffee”,返回码是 200(OK),如预期的那样。

sbur 0409

图 4-9。对现有咖啡进行PUT更新

接下来我发起了类似的PUT请求,但在 URI 中指定了一个不存在的id。应用程序根据 IETF 指定的行为向数据库添加了一个新的咖啡,并正确返回了 HTTP 状态码 201(已创建),如图 4-10 所示。

sbur 0410

图 4-10。PUT一个新的咖啡

最后,我通过发出DELETE请求来测试删除指定的咖啡,该请求仅返回 HTTP 状态码 200(OK),表示资源已成功删除,因为资源不再存在,根据图 4-11。为了检查我们的最终状态,我们再次查询所有咖啡的完整列表(参见图 4-12)。

sbur 0411

图 4-11。DELETE一个咖啡

sbur 0412

图 4-12。现在在列表中GET所有的咖啡

与以往一样,我们现在有一个额外的咖啡,最初不在我们的存储库中:Mötor Oil Coffee。

一点打磨

像往常一样,有许多地方可以从额外的关注中受益,但我将专注于两个方面:将示例数据的初始填充提取到单独的组件中,并进行一些条件重排序以提高清晰度。

上一章我在RestApiDemoController类中填充了咖啡列表的一些初始值,因此在将其转换为具有存储库访问权限的数据库后,在本章中我保持了同样的结构。更好的做法是将该功能提取到一个可以快速轻松启用或禁用的单独组件中。

有许多方法可以在应用程序启动时自动执行代码,包括使用CommandLineRunnerApplicationRunner并指定 lambda 来实现所需的目标:在这种情况下,创建和保存示例数据。但我更喜欢使用@Component类和@PostConstruct方法来实现相同的功能,原因如下:

  • CommandLineRunnerApplicationRunner生成 bean 方法自动装配一个存储库 bean 时,测试单元会打破在测试中(通常情况下是这样)模拟存储库 bean。

  • 如果您在测试中模拟存储库 bean 或希望在不创建示例数据的情况下运行应用程序,只需注释掉其 @Component 注解即可禁用实际数据填充 bean,这样做快捷而简单。

我建议创建一个类似于下面代码块中展示的 DataLoader 类。将创建示例数据的逻辑提取到 DataLoader 类的 loadData() 方法中,并用 @PostContruct 注解进行标注,将 RestApiDemoController 恢复到其既定的单一目的,即提供外部 API,并使 DataLoader 负责其既定(和显而易见的)目的:

@Component
class DataLoader {
    private final CoffeeRepository coffeeRepository;

    public DataLoader(CoffeeRepository coffeeRepository) {
        this.coffeeRepository = coffeeRepository;
    }

    @PostConstruct
    private void loadData() {
        coffeeRepository.saveAll(List.of(
                new Coffee("Café Cereza"),
                new Coffee("Café Ganador"),
                new Coffee("Café Lareño"),
                new Coffee("Café Três Pontas")
        ));
    }
}

另一个润色的一点是在 putCoffee() 方法中三元运算符的布尔条件微调。在重构方法以使用存储库后,不再需要评估否定条件。从条件中去除否定(!)操作符略微提升了清晰度;当然,交换三元运算符的真和假值是必需的,以保持原始结果,如以下代码所示:

@PutMapping("/{id}")
ResponseEntity<Coffee> putCoffee(@PathVariable String id,
                                 @RequestBody Coffee coffee) {

    return (coffeeRepository.existsById(id))
            ? new ResponseEntity<>(coffeeRepository.save(coffee),
                HttpStatus.OK)
            : new ResponseEntity<>(coffeeRepository.save(coffee),
                HttpStatus.CREATED);
}

Code Checkout Checkup

要获取完整的章节代码,请从代码仓库的 chapter4end 分支检出。

摘要

本章展示了如何将数据库访问添加到上一章创建的 Spring Boot 应用程序中。虽然它旨在简明介绍 Spring Boot 的数据功能,但我提供了以下概述:

  • Java 数据库访问

  • Java 持久化 API(JPA)

  • H2 数据库

  • Spring Data JPA

  • Spring Data 存储库

  • 通过存储库创建示例数据的机制

后续章节将深入探讨 Spring Boot 数据库访问的更多细节,但本章涵盖的基础已经为构建提供了坚实的基础,在许多情况下,这些基础已经足够。

在下一章中,我将讨论和演示 Spring Boot 提供的有用工具,以便在应用程序不按预期方式运行或需要验证其运行情况时,获取对应用程序的洞察。

第五章:配置和检查您的 Spring Boot 应用程序

任何应用程序都可能出现许多问题,其中一些问题甚至可能有简单的解决方案。然而,除了偶尔的猜测,必须在真正解决问题之前确定问题的根本原因。

调试 Java 或 Kotlin 应用程序——或者任何其他应用程序,这是每个开发人员在职业生涯早期都应该学会并在其后不断完善和扩展的基本技能。我并不认为这在所有情况下都是适用的,所以如果你尚未熟悉所选语言和工具的调试能力,请尽快探索你手头的选择。这确实在你开发的每个项目中都非常重要,并可以节省大量时间。

也就是说,调试代码只是确定、识别和隔离应用程序中显现行为的一种级别。随着应用程序变得更加动态和分布式,开发人员通常需要执行以下操作:

  • 动态配置和重新配置应用程序

  • 确定/确认当前设置及其来源

  • 检查和监控应用程序环境和健康指标

  • 临时调整活动应用程序的日志级别以识别根本原因

本章演示如何利用 Spring Boot 的内置配置能力、其自动配置报告和 Spring Boot 执行器来灵活、动态地创建、识别和修改应用程序环境设置。

代码检出检查

请查看代码仓库中的 chapter5begin 分支以开始。

应用程序配置

没有应用程序是孤立的。

当我说这句话时,大多数时候是为了指出这个真理:几乎在每种情况下,一个应用程序在没有与其他应用程序/服务的交互时不能提供其全部效用。但还有另一层意思同样正确:没有应用程序能够在没有以某种形式访问其环境的情况下如此有用。一个静态、不可配置的应用程序是僵化的、不灵活的和受限的。

Spring Boot 应用程序为开发人员提供了多种强大的机制,可以在应用程序运行时动态配置和重新配置其应用程序。这些机制利用了 Spring Environment 来管理来自所有来源的配置属性,包括以下内容:

  • 当开发工具(devtools)处于活动状态时,Spring Boot 开发者工具(devtools)全局设置属性位于 $HOME/.config/spring-boot 目录中。

  • 测试中的 @TestPropertySource 注解。

  • 测试中的 properties 属性,可在 @SpringBootTest 和各种测试注解中用于测试应用程序片段。

  • 命令行参数。

  • 来自 SPRING_APPLICATION_JSON(嵌入在环境变量或系统属性中的内联 JSON)的属性。

  • ServletConfig 初始化参数。

  • ServletContext 初始化参数。

  • 来自 java:comp/env 的 JNDI 属性。

  • Java 系统属性(System.getProperties())。

  • 操作系统环境变量。

  • 仅包含 random.* 属性的 RandomValuePropertySource

  • 打包在 jar 外的特定配置文件的应用程序属性(application-{profile}.properties 和 YAML 变体)。

  • 打包在 jar 中的特定配置文件的应用程序属性(application-{profile}.properties 和 YAML 变体)。

  • 打包在 jar 外的应用程序属性(application.properties 和 YAML 变体)。

  • 打包在 jar 中的应用程序属性(application.properties 和 YAML 变体)。

  • @PropertySource 注解用于 @Configuration 类;请注意,这些属性源在应用程序上下文刷新之前不会添加到 Environment 中,这对于在刷新开始之前读取的某些属性(例如 logging.*spring.main.*)进行配置来说为时已晚。

  • 通过设置 SpringApplication.setDefaultProperties 指定的默认属性。注意:前述属性源按优先级降序列出:来自列表更高位置的属性将替换来自列表较低位置的相同属性。¹

所有这些都可能非常有用,但在本章的代码场景中,我特别选择了其中几个:

  • 命令行参数

  • 操作系统环境变量

  • 打包在 jar 内的应用程序属性(application.properties 和 YAML 变体)。

让我们从应用程序的 application.properties 文件中定义的属性开始,并逐步深入了解。

@Value

@Value 注解可能是将配置设置引入代码中最直接的方法。围绕模式匹配和 Spring 表达语言(SpEL)构建,它简单而强大。

我将从我们应用程序的 application.properties 文件中定义一个属性开始,如 图 5-1 所示。

sbur 0501

图 5-1. 在 application.properties 中定义 greeting-name

为了展示如何使用这个属性,我在应用程序中创建了一个额外的 @RestController 来处理与问候应用程序用户相关的任务,如 图 5-2 所示。

sbur 0502

图 5-2. 问候 @RestController

注意,@Value 注解适用于 name 成员变量,并接受类型为 String 的单个名为 value 的参数。我使用 SpEL 定义 value,将变量名(作为要评估的表达式)放置在 ${} 之间的定界符中。还有一件事需要注意:在这个示例中,SpEL 允许在冒号后设置默认值——“Mirage”,用于在应用程序 Environment 中未定义变量的情况。

在执行应用程序并查询 /greeting 端点时,应用程序如预期地响应“Dakota”,如 图 5-3 所示。

sbur 0503

图 5-3. 具有定义属性值的问候响应

为了验证默认值正在被评估,我在 application.properties 中以 # 注释掉以下行,并重新启动应用程序:

#greeting-name=Dakota

查询 greeting 端点现在返回 Figure 5-4 中所示的响应。由于应用程序的 Environment 中不再定义 greeting-name,因此期望的默认值“Mirage”生效了。

sbur 0504

Figure 5-4. 默认值问候响应

使用自定义属性和 @Value 提供了另一个有用的功能:一个属性的值可以使用另一个属性的值进行推导/构建。

为了演示属性嵌套的工作原理,我们至少需要两个属性。我在 application.properties 中创建了第二个属性 greeting-coffee,如 Figure 5-5 所示。

sbur 0505

Figure 5-5. 属性值传递给另一个属性

接下来,我向我们的 GreetingController 添加了一些代码,以表示一个带有咖啡的问候和我们可以访问以查看结果的端点。请注意,我还为 coffee 的值提供了一个默认值,如 Figure 5-6 所示。

sbur 0506

Figure 5-6. 向 GreetingController 添加咖啡问候

为了验证正确的结果,我重新启动应用程序并查询新的 /greeting/coffee 端点,结果显示在 Figure 5-7 中。请注意,由于问题中的两个属性都在 application.properties 中定义,因此显示的值与这些值的定义一致。

sbur 0507

Figure 5-7. 查询咖啡问候端点

就像生活和软件开发中的所有事物一样,@Value 确实有一些限制。由于我们为 greeting-coffee 属性提供了一个默认值,我们可以将其在 application.properties 中的定义注释掉,@Value 注解仍然会使用 GreetingController 中的 coffee 成员变量正确处理其(默认)值。但是,如果在属性文件中注释掉 greeting-namegreeting-coffee 两者,那么实际上没有任何 Environment 源定义它们,进而在应用程序尝试使用 GreetingController 中的 (现在未定义的) greeting-name 引用 greeting-coffee 时会导致以下错误:

org.springframework.beans.factory.BeanCreationException:
    Error creating bean with name 'greetingController':
        Injection of autowired dependencies failed; nested exception is
        java.lang.IllegalArgumentException:
            Could not resolve placeholder 'greeting-name' in value
            "greeting-coffee: ${greeting-name} is drinking Cafe Ganador"
注意

完整的堆栈跟踪已删除以提高简洁性和清晰性。

另一个 application.properties 中定义的属性并且仅通过 @Value 使用的限制是:它们不被 IDE 认为是应用程序使用的,因为它们只在引号限定的 String 变量中的代码中被引用;因此,与代码没有直接的关联。当然,开发人员可以通过目视检查属性名称和用法的正确拼写,但这完全是手动操作,因此更容易出错。

正如你所想象的那样,一种类型安全且可验证的属性使用和定义机制将是更好的全面选择。

@ConfigurationProperties

欣赏@Value的灵活性,但也意识到其局限性,Spring 团队创建了@ConfigurationProperties。使用@ConfigurationProperties,开发者可以定义属性,将相关属性分组,并以可验证和类型安全的方式引用/使用它们。

例如,如果在应用的application.properties文件中定义了一个未在代码中使用的属性,开发者将看到其名称被突出显示,以标识其为确认的未使用属性。同样地,如果属性定义为String,但与不同类型的成员变量相关联,IDE 将指出类型不匹配。这些都是捕捉简单但频繁错误的宝贵帮助。

为了演示如何使用@ConfigurationProperties,我将从定义一个 POJO 开始,用于封装所需的相关属性:在这种情况下,我们先前引用的greeting-namegreeting-coffee属性。如下所示的代码中,我创建了一个Greeting类来保存这两个属性:

class Greeting {
    private String name;
    private String coffee;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getCoffee() {
        return coffee;
    }

    public void setCoffee(String coffee) {
        this.coffee = coffee;
    }
}

为了注册Greeting以管理配置属性,我添加了如下所示的@ConfigurationProperties注解,并指定了用于所有Greeting属性的前缀。此注解仅为使用配置属性准备该类;还必须告知应用程序处理这种方式注释的类,以便包含在应用程序Environment中的属性。请注意产生的有用错误消息:

sbur 0508

图 5-8. 注解和错误

在大多数情况下,指示应用处理@ConfigurationProperties类并将其属性添加到应用的Environment中最好的方法是将@ConfigurationPropertiesScan注解添加到主应用类中,如下所示:

@SpringBootApplication
@ConfigurationPropertiesScan
public class SburRestDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(SburRestDemoApplication.class, args);
    }

}
注意

除了要求 Boot 扫描@ConfigurationProperties类之外的例外情况是,如果需要有条件地启用某些@ConfigurationProperties类或者正在创建自己的自动配置。然而,在所有其他情况下,应使用@ConfigurationPropertiesScan来扫描和启用类似于 Boot 组件扫描机制中的@ConfigurationProperties类。

为了使用注解处理器生成元数据,使 IDE 能够连接@ConfigurationProperties类和application.properties文件中定义的相关属性之间的关联,我将以下依赖项添加到项目的pom.xml构建文件中:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>
注意

这个依赖项也可以在项目创建时从 Spring Initializr 自动选择并添加。

一旦将配置处理器依赖项添加到构建文件中,就需要刷新/重新导入依赖项并重新构建项目以充分利用它们。要重新导入依赖项,我在 IntelliJ 中打开 Maven 菜单,并点击左上角的重新导入按钮,如 图 5-9 所示。

sbur 0509

图 5-9. 重新导入项目依赖
注意

除非选项被禁用,IntelliJ 也会在更改的 pom.xml 上方显示一个小按钮,允许快速重新导入,而无需打开 Maven 菜单。重导入按钮位于其底部左侧带有圆形箭头的小 m,悬停在第一个依赖项的 <groupid> 条目上时可见;当重新导入完成时,它会消失。

一旦更新了依赖项,我从 IDE 中重新构建项目以整合配置处理器。

现在,要为这些属性定义一些值。返回到 application.properties,当我开始输入 greeting 时,IDE 会显示匹配的属性名,如 图 5-10 所示。

sbur 0510

图 5-10. @ConfigurationProperties 的完整 IDE 属性支持

要使用这些属性替代之前使用的属性,需要进行一些重构。

我可以完全放弃 GreetingController 自己的成员变量 namecoffee 以及它们的 @Value 注解;相反,我创建了一个成员变量用于管理 Greeting bean 现在管理的 greeting.namegreeting.coffee 属性,并通过构造函数注入到 GreetingController 中,如下面的代码所示:

@RestController
@RequestMapping("/greeting")
class GreetingController {
    private final Greeting greeting;

    public GreetingController(Greeting greeting) {
        this.greeting = greeting;
    }

    @GetMapping
    String getGreeting() {
        return greeting.getName();
    }

    @GetMapping("/coffee")
    String getNameAndCoffee() {
        return greeting.getCoffee();
    }
}

运行应用程序并查询 greetinggreeting/coffee 端点会得到 图 5-11 中捕获的结果。

sbur 0511

图 5-11. 检索 Greeting 属性

@ConfigurationProperties bean 管理的属性仍然从 Environment 及其所有潜在来源中获取其值;与基于 @Value 的属性相比,唯一显著缺失的是在注解成员变量中指定默认值的能力。乍看起来可能不太理想,但这并不是问题,因为应用程序的 application.properties 文件通常用于定义应用程序的合理默认值。如果需要不同的属性值以适应不同的部署环境,这些环境特定的值通过其他来源,例如环境变量或命令行参数,被摄入到应用程序的 Environment 中。简而言之,@ConfigurationProperties 简单地强制执行了更好的默认属性值的实践。

潜在的第三方选项

对于@ConfigurationProperties已经令人印象深刻的实用性的进一步扩展,是能够包装第三方组件并将它们的属性合并到应用的Environment中。为了演示这一点,我创建了一个 POJO 来模拟一个可能被整合到应用中的组件。请注意,在典型的使用案例中,当这个特性最有用时,我们会向项目添加一个外部依赖,并参考组件的文档来确定创建 Spring bean 的类,而不像我在这里手动创建。

在接下来的代码清单中,我创建了一个模拟的第三方组件称为Droid,具有两个属性——iddescription——及其关联的访问器和修改器方法:

class Droid {
    private String id, description;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }
}

下一步的操作与真实的第三方组件完全相同:将组件实例化为 Spring bean。可以通过几种方式从定义的 POJO 创建 Spring bean,但对于这个特定的用例来说,最合适的方法是在带有@Configuration注解的类中创建一个@Bean注解的方法,无论是直接还是通过元注解。

一个将@Configuration包含在其定义中的元注解是@SpringBootApplication,它位于主应用程序类上。这就是为什么开发人员经常将 bean 创建方法放在这里的原因。

注意

在 IntelliJ 和大多数其他具有良好 Spring 支持的 IDE 和高级文本编辑器中,可以深入探索 Spring 元注解来探索嵌套的注解。在 IntelliJ 中,Cmd+LeftMouseClick(在 MacOS 上)将展开注解。@SpringBootApplication包含@SpringBootConfiguration,它包含@Configuration,使得与凯文·贝肯(Kevin Bacon)仅有两度分离。

在接下来的代码清单中,我演示了 bean 创建方法以及必需的@ConfigurationProperties注解和prefix参数,指示应将Droid属性合并到Environment中的顶层属性组droid中:

@SpringBootApplication
@ConfigurationPropertiesScan
public class SburRestDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(SburRestDemoApplication.class, args);
    }

    @Bean
    @ConfigurationProperties(prefix = "droid")
    Droid createDroid() {
        return new Droid();
    }
}

正如以往一样,需要重新构建项目以便配置处理器检测到此新配置属性源暴露的属性。执行构建后,我们可以返回到application.properties,看到droid属性现在完整地展现出来,包括类型信息,如图 5-12 所示。

sbur 0512

图 5-12. droid属性和类型信息现在在application.properties中可见

我为droid.iddroid.description分配了一些默认值,用作默认值,如图 5-13 所示。对于所有的Environment属性来说,这是一个养成的好习惯,即使是从第三方获取的属性也不例外。

sbur 0513

图 5-13。在 application.properties 中分配了默认值的 droid 属性

为了验证一切是否按预期运行与 Droid 属性,我创建了一个非常简单的 @RestController,其中包含一个单独的 @GetMapping 方法,如下所示的代码:

@RestController
@RequestMapping("/droid")
class DroidController {
    private final Droid droid;

    public DroidController(Droid droid) {
        this.droid = droid;
    }

    @GetMapping
    Droid getDroid() {
        return droid;
    }
}

构建并运行项目后,我查询新的 /droid 端点,并确认适当的响应,如 图 5-14 中所示。

sbur 0514

图 5-14。查询 /droid 端点以检索来自 Droid 的属性

自动配置报告

如前所述,Boot 通过自动配置为开发人员执行了大量操作:使用所选功能、依赖项和代码来设置应用程序所需的 bean,从而实现所选功能和代码。还提到了根据用例更具体(符合您的用例)地实现功能所需的任何自动配置的能力。但是,如何查看创建了哪些 bean、未创建哪些 bean,以及是什么条件促使了其中任一结果呢?

使用 JVM 的灵活性,通过在几种方式之一中使用 debug 标志,可以很容易地生成自动配置报告:

  • 使用 --debug 选项执行应用程序的 jar 文件:java -jar bootapplication.jar --debug

  • 使用 JVM 参数执行应用程序的 jar 文件:java -Ddebug=true -jar bootapplication.jar

  • debug=true 添加到应用程序的 application.properties 文件

  • 在您的 shell(Linux 或 Mac)中执行 export DEBUG=true 或将其添加到 Windows 环境中,然后运行 java -jar bootapplication.jar

任何方式将肯定值添加到应用程序的 Environment 中,如前所述,都将提供相同的结果。这些只是更常用的选项。

自动配置报告的部分列出了正匹配的条件——这些条件评估为真,并导致执行操作的条件——列在以“Positive matches”为标题的部分中。我在这里复制了该部分标题,以及一个正匹配及其相应的自动配置操作的示例:

============================
CONDITIONS EVALUATION REPORT
============================

Positive matches:
-----------------
   DataSourceAutoConfiguration matched:
      - @ConditionalOnClass found required classes 'javax.sql.DataSource',
      'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType'
      (OnClassCondition)

这种匹配符合我们的预期,尽管确认以下内容总是好的:

  • JPA 和 H2 是应用程序依赖项。

  • JPA 与 SQL 数据源一起使用。

  • H2 是一个嵌入式数据库。

  • 找到支持嵌入式 SQL 数据源的类。

结果,调用了 DataSourceAutoConfiguration

同样,"Negative matches" 部分显示了 Spring Boot 自动配置未执行的操作以及原因,如下所示:

Negative matches:
-----------------
   ActiveMQAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class
          'javax.jms.ConnectionFactory' (OnClassCondition)

在这种情况下,未执行 ActiveMQAutoConfiguration,因为应用程序在启动时未找到 JMS ConnectionFactory 类。

另一个有用的信息片段是列出“无条件类”的部分,这些类无需满足任何条件即可创建。鉴于前一节,我列出了一个特别感兴趣的类:

Unconditional classes:
----------------------
    org.springframework.boot.autoconfigure.context
     .ConfigurationPropertiesAutoConfiguration

正如您所见,ConfigurationPropertiesAutoConfiguration始终被实例化,以管理 Spring Boot 应用程序中创建和引用的任何ConfigurationProperties;它是每个 Spring Boot 应用程序的一部分。

执行器

执行器

n. 特指:用于移动或控制某物的机械装置

Spring Boot Actuator 的原始版本于 2014 年达到了一般可用性(GA),因为它为生产中的 Boot 应用程序提供了宝贵的洞察。通过 HTTP 端点或 Java 管理扩展(JMX)提供正在运行的应用程序的监控和管理功能,Actuator 涵盖并公开了 Spring Boot 的所有生产准备功能。

随着 Spring Boot 2.0 版本的彻底改造,Actuator 现在利用 Micrometer 仪表化库提供度量标准,通过与多种主流监控系统的一致外观类似于 SLF4J 处理各种日志机制,大大扩展了可以在任何给定的 Spring Boot 应用程序中通过执行器集成、监控和公开的范围。

要开始使用执行器,我将在当前项目的pom.xml依赖项部分中添加另一个依赖项。如下片段所示,spring-boot-starter-actuator依赖项提供了必要的功能;为此,它将 Actuator 本身和 Micrometer 一起带到 Spring Boot 应用程序中,并具备几乎零配置的自动配置能力:

<dependencies>
    ... (other dependencies omitted for brevity)
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>

刷新/重新导入依赖后,我再次运行应用程序。应用程序运行时,通过访问其主要端点,我们可以查看执行器默认公开的信息。再次使用 HTTPie 完成此操作,如图 5-15 所示。

sbur 0515

图 5-15. 访问执行器端点,默认配置

执行器的所有信息默认情况下都集中在应用程序的/actuator端点下,但这也是可配置的。

这似乎并不像执行器所创造的那么热闹(和粉丝基地)。但这种简洁是有意的。

执行器可以访问并公开有关正在运行的应用程序的大量信息。此信息对开发人员、运营人员以及可能希望威胁您应用程序安全性的不良个人尤为有用。遵循 Spring Security 的默认安全目标,执行器的自动配置仅公开非常有限的healthinfo响应——事实上,info默认为空集,提供应用程序心跳和其他少量信息(OOTB)。

与大多数 Spring 的事物一样,您可以创建一些非常复杂的机制来控制对各种 Actuator 数据源的访问,但也有一些快速、一致和低摩擦的选项可用。 现在让我们来看看这些。

可以通过属性轻松配置 Actuator,要么使用一组包含的端点,要么使用一组排除的端点。 为了简单起见,我选择了包含路线下内容添加到 application.properties 中:

management.endpoints.web.exposure.include=env, info, health

在此示例中,我指示应用程序(和 Actuator)仅暴露/actuator/env/actuator/info/actuator/health端点(和任何下级端点)。

图 5-16 在重新运行应用程序并查询其/actuator端点后确认了预期结果。

为了充分展示 Actuator 的 OOTB 能力,我可以进一步禁用安全性,仅用于演示目的,通过使用前述的application.properties 设置的通配符:

management.endpoints.web.exposure.include=*
警告

这一点不容忽视:对于敏感数据的安全机制应该仅在演示或验证目的下禁用。永远不要为生产应用程序禁用安全性

sbur 0516

图 5-16. 在指定要包括的端点后访问 Actuator

启动应用程序时进行验证,Actuator 忠实地报告了它当前正在暴露的端点数量和到达它们的根路径——在这种情况下,默认为/actuator——如下所示的启动报告片段。 这是一个有用的提醒/警告,可以快速进行视觉检查,以确保在将应用程序推进到目标部署之前不会暴露更多端点:

INFO 22115 --- [           main] o.s.b.a.e.web.EndpointLinksResolver      :
    Exposing 13 endpoint(s) beneath base path '/actuator'

要检查通过 Actuator 当前可访问的所有映射,只需查询提供的 Actuator 根路径以检索完整列表:

mheckler-a01 :: ~/dev » http :8080/actuator
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/vnd.spring-boot.actuator.v3+json
Date: Fri, 27 Nov 2020 17:43:27 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
    "_links": {
        "beans": {
            "href": "http://localhost:8080/actuator/beans",
            "templated": false
        },
        "caches": {
            "href": "http://localhost:8080/actuator/caches",
            "templated": false
        },
        "caches-cache": {
            "href": "http://localhost:8080/actuator/caches/{cache}",
            "templated": true
        },
        "conditions": {
            "href": "http://localhost:8080/actuator/conditions",
            "templated": false
        },
        "configprops": {
            "href": "http://localhost:8080/actuator/configprops",
            "templated": false
        },
        "env": {
            "href": "http://localhost:8080/actuator/env",
            "templated": false
        },
        "env-toMatch": {
            "href": "http://localhost:8080/actuator/env/{toMatch}",
            "templated": true
        },
        "health": {
            "href": "http://localhost:8080/actuator/health",
            "templated": false
        },
        "health-path": {
            "href": "http://localhost:8080/actuator/health/{*path}",
            "templated": true
        },
        "heapdump": {
            "href": "http://localhost:8080/actuator/heapdump",
            "templated": false
        },
        "info": {
            "href": "http://localhost:8080/actuator/info",
            "templated": false
        },
        "loggers": {
            "href": "http://localhost:8080/actuator/loggers",
            "templated": false
        },
        "loggers-name": {
            "href": "http://localhost:8080/actuator/loggers/{name}",
            "templated": true
        },
        "mappings": {
            "href": "http://localhost:8080/actuator/mappings",
            "templated": false
        },
        "metrics": {
            "href": "http://localhost:8080/actuator/metrics",
            "templated": false
        },
        "metrics-requiredMetricName": {
            "href": "http://localhost:8080/actuator/metrics/{requiredMetricName}",
            "templated": true
        },
        "scheduledtasks": {
            "href": "http://localhost:8080/actuator/scheduledtasks",
            "templated": false
        },
        "self": {
            "href": "http://localhost:8080/actuator",
            "templated": false
        },
        "threaddump": {
            "href": "http://localhost:8080/actuator/threaddump",
            "templated": false
        }
    }
}

Actuator 端点列表提供了捕获和暴露供检查的信息范围的良好概念,但对于好的和坏的行为者特别有用的是以下内容:

/actuator/beans

应用程序创建的所有 Spring bean

*/ /actuator/conditions

创建 Spring bean 所需满足的条件(或不满足的条件); 类似于之前讨论过的条件评估报告

/actuator/configprops

应用程序可访问的所有Environment 属性

/actuator/env

应用程序正在运行的环境的种种方面; 特别有用的是看到每个个体configprop 值的来源

/actuator/health

健康信息(基本或扩展,取决于设置)

/actuator/heapdump

启动堆转储以进行故障排除和/或分析

/actuator/loggers

每个组件的日志级别

/actuator/mappings

所有端点映射和支持详细信息

/actuator/metrics

应用程序当前正在捕获的指标

/actuator/threaddump

启动线程转储以进行故障排除和/或分析

这些,以及其余的预配置执行器端点,在需要时都非常方便,并且易于访问进行检查。继续专注于应用程序的环境,即使在这些端点之中也有同行中的先例。

让执行器打开

正如提到的,执行器的默认安全姿态有意仅公开非常有限的healthinfo响应。实际上,/actuator/health端点提供了一个相当实用的应用程序状态“UP”或“DOWN”。

大多数应用程序都有依赖关系,执行器会跟踪健康信息;除非获得授权,否则它不会公开其他信息。为了显示预配置依赖项的扩展健康信息,我将以下属性添加到application.properties中:

management.endpoint.health.show-details=always
注意

健康指标的show-details属性有三个可能的取值:never(默认值)、when_authorizedalways。在这个例子中,我选择always仅仅是为了演示可能性,但对于每个投入生产的应用程序,正确的选择应该是要么never,要么when_authorized,以限制应用程序扩展的健康信息的可见性。

重新启动应用程序会导致将应用程序的主要组件的健康信息添加到访问/actuator/health端点时的整体应用程序健康摘要中,参见图 5-17。

sbur 0517

图 5-17. 扩展健康信息

使用执行器更环保意识

开发者经常会受到一种毛病的困扰——包括在内的现在这个公司——即当行为与预期不符时,完全了解当前应用程序环境/状态的假设。这并不完全出乎意料,尤其是如果是自己写的异常代码。一个相对快速且非常宝贵的第一步是检查所有的假设。你知道那个值是多少吗?还是你只是确信你知道?

你有检查过吗?

特别是在输入驱动结果的代码中,这应该是一个必需的起点。执行器帮助使这一过程变得轻松。查询应用程序的/actuator/env端点返回所有环境信息;以下是该结果的部分,仅显示到目前为止在应用程序中设置的属性:

{
    "name": "Config resource 'classpath:/application.properties' via location
     'optional:classpath:/'",
    "properties": {
        "droid.description": {
            "origin": "class path resource [application.properties] - 5:19",
            "value": "Small, rolling android. Probably doesn't drink coffee."
        },
        "droid.id": {
            "origin": "class path resource [application.properties] - 4:10",
            "value": "BB-8"
        },
        "greeting.coffee": {
            "origin": "class path resource [application.properties] - 2:17",
            "value": "Dakota is drinking Cafe Cereza"
        },
        "greeting.name": {
            "origin": "class path resource [application.properties] - 1:15",
            "value": "Dakota"
        },
        "management.endpoint.health.show-details": {
            "origin": "class path resource [application.properties] - 8:41",
            "value": "always"
        },
        "management.endpoints.web.exposure.include": {
            "origin": "class path resource [application.properties] - 7:43",
            "value": "*"
        }
    }
}

执行器不仅显示每个定义属性的当前值,还显示其来源,甚至显示定义每个值的行号和列号。但是,如果其中一个或多个值被另一个来源覆盖,例如在执行应用程序时由外部环境变量或命令行参数?

为了演示一个典型的生产绑定应用程序场景,我从应用程序的目录中使用命令行运行mvn clean package,然后使用以下命令执行应用程序:

java -jar target/sbur-rest-demo-0.0.1-SNAPSHOT.jar --greeting.name=Sertanejo

再次查询 /actuator/env,您可以看到有一个新的命令行参数部分,其中只有一个条目greeting.name

{
    "name": "commandLineArgs",
    "properties": {
        "greeting.name": {
            "value": "Sertanejo"
        }
    }
}

遵循之前提到的Environment输入的优先顺序,命令行参数应该覆盖application.properties内部设置的值。查询 /greeting 端点返回预期的“Sertanejo”;同样,通过 SpEL 表达式查询 /greeting/coffee 也将覆盖的值合并到响应中:Sertanejo is drinking Cafe Cereza

通过 Spring Boot Actuator,试图弄清楚错误的、数据驱动行为变得更加简单。

使用 Actuator 增加日志记录的音量

与开发和部署软件中的许多其他选择一样,为生产应用程序选择日志记录级别涉及权衡。选择更多日志记录会导致更多的系统级工作和存储消耗,以及捕获更多相关和不相关的数据。这反过来可能会使寻找难以捉摸的问题变得更加困难。

作为提供 Boot 生产就绪功能的使命的一部分,Actuator 也解决了这个问题,允许开发人员为大多数或所有组件设置典型的日志级别,当关键问题出现时可以临时更改这个级别……所有这些都是在现场、生产中的 Spring Boot 应用中进行的。Actuator 通过向适用的端点发送简单的POST请求来便捷地设置和重置日志级别。例如,图 5-18 显示了org.springframework.data.web的默认日志级别。

sbur 0518

图 5-18. org.springframework.data.web的默认日志级别

特别值得注意的是,由于未为此组件配置日志级别,因此使用了有效级别“INFO”。再次强调,当未提供具体配置时,Spring Boot 会提供合理的默认设置。

如果我收到有关正在运行的应用程序的问题并希望增加日志记录以帮助诊断和解决问题,那么为了特定组件执行此操作所需的全部步骤就是向其 /actuator/loggers 端点发布一个新的 JSON 格式的configuredLevel值,如下所示:

echo '{"configuredLevel": "TRACE"}'
  | http :8080/actuator/loggers/org.springframework.data.web

现在重新查询日志级别,确认 org.springframework.data.web 的记录器现在设置为“TRACE”,将为应用程序提供详尽的诊断日志记录,如图 5-19 所示。

警告

“TRACE”在确定难以捉摸的问题时可能至关重要,但它是一个相当重量级的日志级别,甚至比“DEBUG”还要详细——在生产应用程序中使用可以提供重要的信息,但要注意其影响。

sbur 0519

图 5-19. org.springframework.data.web的新“TRACE”日志级别

代码检查

获取完整的章节代码,请从代码库的chapter5end分支查看。

总结

开发人员必须拥有有用的工具来建立、识别和隔离生产应用程序中表现出的行为是至关重要的。随着应用程序变得越来越动态和分布式,通常需要做以下工作:

  • 配置和动态重新配置应用程序

  • 确定/确认当前设置及其来源

  • 检查和监控应用程序环境和健康指标

  • 临时调整实时应用程序的日志级别以识别根本原因

本章展示了如何利用 Spring Boot 的内置配置能力、其自动配置报告和 Spring Boot 执行器来灵活动态地创建、识别和修改应用程序环境设置。

在下一章中,我将深入探讨数据:如何使用各种行业标准和领先的数据库引擎定义其存储和检索,以及 Spring Data 项目和工具如何以最简单和强大的方式支持它们的使用。

¹ Spring Boot 属性源的优先级顺序

第六章:深入数据

数据可能是一个复杂的话题,有很多要考虑的地方:它的结构和与其他数据的关系;处理、存储和检索选项;各种适用的标准;数据库提供者和机制;等等。数据可能是开发者在职业早期接触到的最复杂的开发方面,也是学习新工具链时的一部分。

这通常是这样的原因,因为几乎所有应用程序没有某种形式的数据,几乎都是毫无意义的。几乎没有应用程序能够在不存储、检索或关联数据的情况下提供任何价值。

作为几乎所有应用程序价值基础的一部分,数据已经引起了数据库提供商和平台供应商的大量创新。但在许多情况下,复杂性仍然存在:毕竟,这是一个有深度和广度的话题。

进入 Spring Data。Spring Data 的宣言 使命 是“为数据访问提供一个熟悉和一致的基于 Spring 的编程模型,同时保留底层数据存储的特殊特性。” 不论数据库引擎或平台如何,Spring Data 的目标是尽可能简化开发者对数据的访问,使其既简单又强大。

本章演示了如何使用各种行业标准和领先的数据库引擎定义数据存储和检索,以及 Spring Data 项目和工具如何通过 Spring Boot 以最简化和强大的方式支持它们的使用。

定义实体

几乎在处理数据的每个案例中,都涉及某种形式的领域实体。无论是发票、汽车还是其他任何东西,数据很少被视为一组不相关的属性。不可避免地,我们认为有用的数据是构成有意义整体的一致性元素池。汽车——无论是在数据中还是在现实生活中——只有在作为一个独特的、充分属性的事物时才是一个真正有用的概念。

Spring Data 为 Spring Boot 应用程序提供了多种不同的机制和数据访问选项,涵盖各种抽象级别。无论开发者为任何给定的用例选择了哪个抽象级别,第一步都是定义用于处理适用数据的任何领域类。

虽然本书范围不包括完全探讨领域驱动设计(DDD),我将使用这些概念作为在本书和后续章节中构建的示例应用程序中定义适用领域类的基础。关于 DDD 的全面探讨,我建议读者参考埃里克·埃文斯关于这一主题的开创性工作,《领域驱动设计:软件核心复杂性应对之道》(https://oreil.ly/DomainDrivDes)。

简单来说,领域类封装了一个具有独立于其他数据的相关性和重要性的主要领域实体。这并不意味着它与其他领域实体无关,只是即使与其他实体无关,它也可以作为一个单元站立并有意义。

要使用 Java 在 Spring 中创建一个领域类,您可以创建一个具有成员变量、适用的构造函数、访问器/修改器以及equals()/hashCode()/toString()方法(以及更多内容)的类。您还可以使用 Java 中的 Lombok 或 Kotlin 中的数据类来创建用于数据表示、存储和检索的领域类。在本章中,我展示了所有这些操作,以演示使用 Spring Boot 和 Spring Data 处理领域时有多么容易。拥有多种选择真是太好了。

在本章的示例中,一旦我定义了一个领域类,我将根据数据使用目标和数据库提供程序的 API 来决定数据库和抽象级别。在 Spring 生态系统中,这通常归结为两种选项之一,具有轻微差异:模板或仓库。

模板支持

为了提供一组“刚刚好”的连贯抽象,Spring Data 为其各种数据源定义了一个名为Operations的接口。这个Operations接口——例如MongoOperationsRedisOperationsCassandraOperations——定义了一组基础操作,可以直接使用以获得最大的灵活性,或者可以构建更高级别的抽象。Template类提供了Operations接口的直接实现。

可以将模板视为一种服务提供者接口(SPI)——直接可用且功能强大,但每次使用它们完成更常见的开发人员面临的用例时都需要许多重复的步骤。对于那些数据访问遵循常见模式的场景,仓库可能是一个更好的选择。而最好的部分是仓库建立在模板之上,因此通过提升到更高的抽象层次,您不会失去任何东西。

仓库支持

Spring Data 从Repository接口定义了所有其他类型的 Spring Data 仓库接口派生出来。例如JPARepositoryMongoRepository(分别提供 JPA 特定和 Mongo 特定的功能),以及更通用的接口如CrudRepositoryReactiveCrudRepositoryPagingAndSortingRepository。这些不同的仓库接口指定了有用的高级操作,如findAll()findById()count()delete()deleteAll()等。

仓库被定义为阻塞和非阻塞交互。此外,Spring Data 的仓库支持使用约定优于配置创建查询,甚至支持直接的查询语句。使用 Spring Boot 与 Spring Data 的仓库使得构建复杂的数据库交互几乎成为一种简单的练习。

我在本书的某个时候展示了所有这些能力。在本章中,我计划通过结合各种实现细节(如 Lombok、Kotlin 等)来覆盖多个数据库选项的关键元素。通过这种方式,我提供了一个广泛且稳定的基础,供后续章节建设使用。

@Before

尽管我非常喜欢咖啡,并依赖它来推动我的应用程序开发,但为了更好地探索本书其余部分涵盖的概念,我觉得需要一个更多才能的领域。作为软件开发人员和飞行员,我认为航空领域日益复杂和数据驱动的世界提供了许多有趣的场景(和迷人的数据),可以在我们深入探讨 Spring Boot 在多种用例中的便利性时进行探索。

要处理数据,我们必须 数据。我开发了一个名为 PlaneFinder 的小型 Spring Boot RESTful Web 服务(可在本书的代码库中找到),用作我可以轮询的 API 网关,用于服务桌上的小设备范围内的当前飞机和位置的数据。该设备接收来自一定距离内飞机的自动相关监视—广播(ADS-B)数据,并与一个在线服务 PlaneFinder.net 共享它们。它还公开了一个 HTTP API,我的网关服务消费它,并简化和向本章中的其他下游服务公开。

更多细节,请先创建一些连接到数据库的服务。

使用 Redis 创建基于模板的服务

Redis 是一种数据库,通常用作内存中的数据存储,用于在服务实例之间共享状态,缓存和代理服务之间的消息。就像所有主要数据库一样,Redis 还有其他功能,但本章重点是简单地使用 Redis 存储和检索从 PlaneFinder 服务中获取的飞机信息。

初始化项目

首先,我们回到 Spring Initializr。从那里,我选择以下选项:

  • Maven 项目

  • Java

  • 当前的 Spring Boot 生产版本

  • 打包:Jar

  • Java:11

以及依赖关系:

  • Spring Reactive Web(spring-boot-starter-webflux

  • Spring Data Redis(Access+Driver)(spring-boot-starter-data-redis

  • Lombok(lombok

接下来,我生成项目并将其保存到本地,解压并在 IDE 中打开它。

开发 Redis 服务

让我们从领域开始。

目前,PlaneFinder API 网关公开了一个 REST 端点:

http://localhost:7634/aircraft

任何(本地)服务都可以查询此端点,并以以下格式(带有代表性数据)接收所有接收机范围内的飞机的 JSON 响应:

[
    {
        "id": 108,
        "callsign": "AMF4263",
        "squawk": "4136",
        "reg": "N49UC",
        "flightno": "",
        "route": "LAN-DFW",
        "type": "B190",
        "category": "A1",
        "altitude": 20000,
        "heading": 235,
        "speed": 248,
        "lat": 38.865905,
        "lon": -90.429382,
        "barometer": 0,
        "vert_rate": 0,
        "selected_altitude": 0,
        "polar_distance": 12.99378,
        "polar_bearing": 345.393951,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-11-11T21:44:04Z",
        "pos_update_time": "2020-11-11T21:44:03Z",
        "bds40_seen_time": null
    },
    {<another aircraft in range, same fields as above>},
    {<final aircraft currently in range, same fields as above>}
]

定义领域类

为了摄取和操作这些飞机报告,我创建了一个 Aircraft 类,如下所示:

package com.thehecklers.sburredis;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;

import java.time.Instant;

@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Aircraft {
    @Id
    private Long id;
    private String callsign, squawk, reg, flightno, route, type, category;
    private int altitude, heading, speed;
    @JsonProperty("vert_rate")
    private int vertRate;
    @JsonProperty("selected_altitude")
    private int selectedAltitude;
    private double lat, lon, barometer;
    @JsonProperty("polar_distance")
    private double polarDistance;
    @JsonProperty("polar_bearing")
    private double polarBearing;
    @JsonProperty("is_adsb")
    private boolean isADSB;
    @JsonProperty("is_on_ground")
    private boolean isOnGround;
    @JsonProperty("last_seen_time")
    private Instant lastSeenTime;
    @JsonProperty("pos_update_time")
    private Instant posUpdateTime;
    @JsonProperty("bds40_seen_time")
    private Instant bds40SeenTime;

    public String getLastSeenTime() {
        return lastSeenTime.toString();
    }

    public void setLastSeenTime(String lastSeenTime) {
        if (null != lastSeenTime) {
            this.lastSeenTime = Instant.parse(lastSeenTime);
        } else {
            this.lastSeenTime = Instant.ofEpochSecond(0);
        }
    }

    public String getPosUpdateTime() {
        return posUpdateTime.toString();
    }

    public void setPosUpdateTime(String posUpdateTime) {
        if (null != posUpdateTime) {
            this.posUpdateTime = Instant.parse(posUpdateTime);
        } else {
            this.posUpdateTime = Instant.ofEpochSecond(0);
        }
    }

    public String getBds40SeenTime() {
        return bds40SeenTime.toString();
    }

    public void setBds40SeenTime(String bds40SeenTime) {
        if (null != bds40SeenTime) {
            this.bds40SeenTime = Instant.parse(bds40SeenTime);
        } else {
            this.bds40SeenTime = Instant.ofEpochSecond(0);
        }
    }
}

这个领域类包含一些有用的注解,可以简化必要的代码和/或增加其灵活性。类级别的注解包括以下内容:

@Data:指示 Lombok 创建 getter、setter、equals()hashCode()toString()方法,从而创建所谓的数据类。@NoArgsConstructor:指示 Lombok 创建一个零参数构造函数,因此不需要参数。@AllArgsConstructor:指示 Lombok 为每个成员变量创建一个带参数的构造函数,因此需要提供所有参数。@JsonIgnoreProperties(ignoreUnknown = true):通知 Jackson 反序列化机制忽略 JSON 响应中没有对应成员变量的字段。

字段级注释在适当的情况下提供了更具体的指导。此类使用的字段级注释示例包括两个:

@Id:指定带注释的成员变量作为数据库条目/记录的唯一标识符 @JsonProperty("vert_rate"):将成员变量与其不同命名的 JSON 字段连接起来。

如果@Data注解会为所有成员变量创建 getter 和 setter 方法,你可能会想为什么我为Instant类型的三个成员变量创建了显式的访问器和变更器。对于这三个成员变量,JSON 值必须通过调用Instant::parse方法从String解析和转换为复杂数据类型。如果该值完全不存在(null),则必须执行不同的逻辑以避免向parse()传递 null,并通过 setter 为相应的成员变量分配一些有意义的替代值。此外,最好通过转换为String来序列化Instant值,因此需要显式的 getter 方法。

定义了一个域类后,现在是创建和配置访问 Redis 数据库的机制的时候了。

添加模板支持

Spring Boot 通过自动配置提供了基本的RedisTemplate功能,如果只需要使用 Redis 操作String值,则您几乎不需要做任何工作(或编写任何代码)。处理复杂的领域对象需要更多的配置,但也不是太多。

RedisTemplate类扩展了RedisAccessor类,并实现了RedisOperations接口。对于本应用程序特别感兴趣的是RedisOperations,因为它指定了与 Redis 交互所需的功能。

作为开发者,我们应该优先编写针对接口而不是实现的代码。这样做可以在不改变代码/API 或过度违反 DRY(不要重复自己)原则的情况下,为手头任务提供最合适的具体实现;只要接口被完全实现,任何具体实现都将像任何其他一样正常工作。

在下面的代码清单中,我创建了一个RedisOperations类型的 bean,返回一个RedisTemplate作为 bean 的具体实现。为了正确配置它以适应传入的Aircraft,我执行以下步骤:

  1. 我创建了一个Serializer,用于在对象和 JSON 记录之间进行转换。由于 Jackson 用于 JSON 值的编组/解组(序列化/反序列化)并且已经存在于 Spring Boot Web 应用程序中,我为Aircraft类型的对象创建了一个Jackson2JsonRedisSerializer

  2. 我创建了一个RedisTemplate,接受String类型的键和Aircraft类型的值,以适应具有String ID 的入站Aircraft。我将被自动自动装配的RedisConnectionFactory bean 插入到此 bean 创建方法的唯一参数——RedisConnectionFactory factory——中,以便template对象可以创建和检索到 Redis 数据库的连接。

  3. 我向template对象提供Jackson2JsonRedisSerializer<Aircraft>序列化器,以便用作默认序列化器。在没有特定分配的情况下,RedisTemplate有许多序列化器被分配为默认序列化器,这是一个有用的功能。

  4. 我创建并指定了一个不同的序列化器用于键,以便模板不会尝试使用默认序列化器——它期望Aircraft类型的对象——将键值转换为String类型。StringRedisSerializer非常好地完成了这项任务。

  5. 最后,我将创建和配置的RedisTemplate作为在应用程序中请求RedisOperations bean 的某个实现时要使用的 bean 返回:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@SpringBootApplication
public class SburRedisApplication {
    @Bean
    public RedisOperations<String, Aircraft>
    redisOperations(RedisConnectionFactory factory) {
        Jackson2JsonRedisSerializer<Aircraft> serializer =
                new Jackson2JsonRedisSerializer<>(Aircraft.class);

        RedisTemplate<String, Aircraft> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setDefaultSerializer(serializer);
        template.setKeySerializer(new StringRedisSerializer());

        return template;
    }

    public static void main(String[] args) {
        SpringApplication.run(SburRedisApplication.class, args);
    }
}

Bringing it all together

现在,已经为使用模板访问 Redis 数据库进行了底层连接,该时候该有回报了。如下面的代码清单所示,我创建了一个 Spring Boot @Component类来轮询PlaneFinder端点,并使用 Redis 模板支持处理接收到的Aircraft记录。

为了初始化PlaneFinderPoller bean 并准备好进行操作,我创建了一个WebClient对象并将其分配给一个成员变量,指向外部PlaneFinder服务暴露的目标端点。PlaneFinder目前在我的本地机器上运行,并监听端口 7634。

PlaneFinderPoller bean 需要访问其他两个 bean 来执行其任务:RedisConnectionFactory(由于 Redis 是应用程序依赖项,由 Boot 的自动配置提供)和RedisOperations的实现,即之前创建的RedisTemplate。这两个 bean 都通过构造函数注入(自动装配)分配到了正确定义的成员变量中:

import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@EnableScheduling
@Component
class PlaneFinderPoller {
    private WebClient client =
            WebClient.create("http://localhost:7634/aircraft");

    private final RedisConnectionFactory connectionFactory;
    private final RedisOperations<String, Aircraft> redisOperations;

    PlaneFinderPoller(RedisConnectionFactory connectionFactory,
                    RedisOperations<String, Aircraft> redisOperations) {
        this.connectionFactory = connectionFactory;
        this.redisOperations = redisOperations;
    }
}

接下来,我创建了处理重要任务的方法。为了使其定期轮询,我利用了之前放置在类级别的@EnableScheduling注解,并为我创建的pollPlanes()方法加上了@Scheduled注解,提供了参数fixedDelay=1000来指定每 1,000 毫秒一次的轮询频率——即每秒一次。方法的其余部分仅包含三个声明性语句:一个用于清除任何先前保存的Aircraft,一个用于检索和保存当前位置,一个用于报告最新捕获结果。

对于第一个任务,我使用自动装配的ConnectionFactory获取与数据库的连接,并通过该连接执行服务器命令来清除所有当前存在的键:flushDb()

第二个语句使用WebClient调用PlaneFinder服务,并检索范围内的飞行器集合及其当前位置信息。响应体被转换为包含注册号的Aircraft对象的Flux,过滤掉不包含注册号的任何Aircraft,转换为AircraftStream,并保存到 Redis 数据库中。对每个有效的Aircraft执行保存操作,通过设置Aircraft注册号和Aircraft对象本身的键值对,使用 Redis 的操作来操作数据值。

注意

Flux是一种在后续章节中介绍的响应式类型,但现在,简单地将其视为无阻塞传递对象的集合即可。

pollPlanes()方法中的最后一个语句再次利用了 Redis 定义的值操作,以检索所有键(通过通配符参数*)并使用每个键检索每个相应的Aircraft值,然后将其打印出来。以下是完成形式的pollPlanes()方法:

@Scheduled(fixedRate = 1000)
private void pollPlanes() {
    connectionFactory.getConnection().serverCommands().flushDb();

    client.get()
            .retrieve()
            .bodyToFlux(Aircraft.class)
            .filter(plane -> !plane.getReg().isEmpty())
            .toStream()
            .forEach(ac -> redisOperations.opsForValue().set(ac.getReg(), ac));

    redisOperations.opsForValue()
            .getOperations()
            .keys("*")
            .forEach(ac ->
                System.out.println(redisOperations.opsForValue().get(ac)));
}

现在(目前为止)PlaneFinderPoller类的最终版本如下所示:

import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@EnableScheduling
@Component
class PlaneFinderPoller {
    private WebClient client =
            WebClient.create("http://localhost:7634/aircraft");

    private final RedisConnectionFactory connectionFactory;
    private final RedisOperations<String, Aircraft> redisOperations;

    PlaneFinderPoller(RedisConnectionFactory connectionFactory,
                    RedisOperations<String, Aircraft> redisOperations) {
        this.connectionFactory = connectionFactory;
        this.redisOperations = redisOperations;
    }

    @Scheduled(fixedRate = 1000)
    private void pollPlanes() {
        connectionFactory.getConnection().serverCommands().flushDb();

        client.get()
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(plane -> !plane.getReg().isEmpty())
                .toStream()
                .forEach(ac ->
                    redisOperations.opsForValue().set(ac.getReg(), ac));

        redisOperations.opsForValue()
                .getOperations()
                .keys("*")
                .forEach(ac ->
                    System.out.println(redisOperations.opsForValue().get(ac)));
    }
}

轮询机制已完全完善,让我们运行应用程序并查看结果。

结果

我的机器上已经运行了PlaneFinder服务,我启动了sbur-redis应用程序来获取、存储和检索 Redis 中的结果,并显示每次PlaneFinder轮询的结果。以下是一个编辑过的、为了简洁而格式化的结果示例:

Aircraft(id=1, callsign=EDV5015, squawk=3656, reg=N324PQ, flightno=DL5015,
route=ATL-OMA-ATL, type=CRJ9, category=A3, altitude=35000, heading=168,
speed=485, vertRate=-64, selectedAltitude=0, lat=38.061808, lon=-90.280629,
barometer=0.0, polarDistance=53.679699, polarBearing=184.333345, isADSB=true,
isOnGround=false, lastSeenTime=2020-11-27T18:34:14Z,
posUpdateTime=2020-11-27T18:34:11Z, bds40SeenTime=1970-01-01T00:00:00Z)

Aircraft(id=4, callsign=AAL500, squawk=2666, reg=N839AW, flightno=AA500,
route=PHX-IND, type=A319, category=A3, altitude=36975, heading=82, speed=477,
vertRate=0, selectedAltitude=36992, lat=38.746399, lon=-90.277644,
barometer=1012.8, polarDistance=13.281347, polarBearing=200.308663, isADSB=true,
isOnGround=false, lastSeenTime=2020-11-27T18:34:50Z,
posUpdateTime=2020-11-27T18:34:50Z, bds40SeenTime=2020-11-27T18:34:50Z)

Aircraft(id=15, callsign=null, squawk=4166, reg=N404AN, flightno=AA685,
route=PHX-DCA, type=A21N, category=A3, altitude=39000, heading=86, speed=495,
vertRate=0, selectedAltitude=39008, lat=39.701611, lon=-90.479309,
barometer=1013.6, polarDistance=47.113195, polarBearing=341.51817, isADSB=true,
isOnGround=false, lastSeenTime=2020-11-27T18:34:50Z,
posUpdateTime=2020-11-27T18:34:50Z, bds40SeenTime=2020-11-27T18:34:50Z)

通过 Spring Data 模板支持的数据库操作提供了一个具有极大灵活性的低级 API。然而,如果你寻求最小的摩擦和最大的生产力与重复性,那么仓库支持则是更好的选择。接下来,我将展示如何从使用模板与 Redis 交互转换为使用 Spring Data 仓库。拥有多种选择是件好事。

转换从模板到仓库

在我们可以使用存储库之前,需要定义一个存储库,Spring Boot 的自动配置在这方面帮助很大。我创建了一个存储库接口如下,扩展了 Spring Data 的CrudRepository并提供要存储的对象类型以及其键:在本例中是AircraftLong

public interface AircraftRepository extends CrudRepository<Aircraft, Long> {}

如第四章中解释的那样,Spring Boot 检测到应用程序类路径上的 Redis 数据库驱动程序,并注意到我们正在扩展 Spring Data 存储库接口,然后自动创建数据库代理,无需额外的代码来实例化它。就这样,应用程序可以访问一个AircraftRepository bean。让我们将其插入并投入使用。

回顾PlaneFinderPoller类,我现在可以替换对RedisOperations的低级引用和操作,并用AircraftRepository替换它们。

首先,我删除了RedisOperations成员变量:

private final RedisOperations<String, Aircraft> redisOperations;

然后用AircraftRepository替换它以自动装配:

private final AircraftRepository repository;

接下来,我用构造函数注入替换了通过构造函数注入的RedisOperations bean,并且分配给适用的成员变量,使得构造函数最终如下:

public PlaneFinderPoller(RedisConnectionFactory connectionFactory,
                    AircraftRepository repository) {
    this.connectionFactory = connectionFactory;
    this.repository = repository;
}

下一步是重构pollPlanes()方法,以替换基于模板的操作为基于存储库的操作。

更改第一条语句的最后一行很简单。使用方法引用进一步简化了 lambda 表达式:

client.get()
        .retrieve()
        .bodyToFlux(Aircraft.class)
        .filter(plane -> !plane.getReg().isEmpty())
        .toStream()
        .forEach(repository::save);

第二个进一步减少,再次包括使用方法引用:

repository.findAll().forEach(System.out::println);

新启用存储库的PlaneFinderPoller现在包含以下代码:

import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@EnableScheduling
@Component
class PlaneFinderPoller {
    private WebClient client =
            WebClient.create("http://localhost:7634/aircraft");

    private final RedisConnectionFactory connectionFactory;
    private final AircraftRepository repository;

    PlaneFinderPoller(RedisConnectionFactory connectionFactory,
                      AircraftRepository repository) {
        this.connectionFactory = connectionFactory;
        this.repository = repository;
    }

    @Scheduled(fixedRate = 1000)
    private void pollPlanes() {
        connectionFactory.getConnection().serverCommands().flushDb();

        client.get()
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(plane -> !plane.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        repository.findAll().forEach(System.out::println);
    }
}

现在不再需要实现RedisOperations接口的 bean,我现在可以从主应用程序类中删除其@Bean定义,留下如下所示的SburRedisApplication

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SburRedisApplication {

    public static void main(String[] args) {
        SpringApplication.run(SburRedisApplication.class, args);
    }

}

只剩下一个小任务和一个非常好的代码减少,以完全启用我们应用中的 Redis 存储库支持。我为Aircraft实体添加了@RedisHash注解,表示Aircraft是要存储在 Redis 哈希中的聚合根,类似于@Entity注解用于 JPA 对象的功能。然后,我删除了之前为Instant类型成员变量所需的显式访问器和修改器,因为 Spring Data 的存储库支持中的转换器轻松处理复杂类型转换。新简化的Aircraft类现在如下所示:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

import java.time.Instant;

@Data
@NoArgsConstructor
@AllArgsConstructor
@RedisHash
@JsonIgnoreProperties(ignoreUnknown = true)
public class Aircraft {
    @Id
    private Long id;
    private String callsign, squawk, reg, flightno, route, type, category;
    private int altitude, heading, speed;
    @JsonProperty("vert_rate")
    private int vertRate;
    @JsonProperty("selected_altitude")
    private int selectedAltitude;
    private double lat, lon, barometer;
    @JsonProperty("polar_distance")
    private double polarDistance;
    @JsonProperty("polar_bearing")
    private double polarBearing;
    @JsonProperty("is_adsb")
    private boolean isADSB;
    @JsonProperty("is_on_ground")
    private boolean isOnGround;
    @JsonProperty("last_seen_time")
    private Instant lastSeenTime;
    @JsonProperty("pos_update_time")
    private Instant posUpdateTime;
    @JsonProperty("bds40_seen_time")
    private Instant bds40SeenTime;
}

在最新的更改完成后,重新启动服务会产生与基于模板方法的输出无法区分的结果,但所需的代码和典礼性明显减少。以下是结果示例,再次经过编辑以缩短并格式化以提高可读性:

Aircraft(id=59, callsign=KAP20, squawk=4615, reg=N678JG, flightno=,
route=STL-IRK, type=C402, category=A1, altitude=3825, heading=0, speed=143,
vertRate=768, selectedAltitude=0, lat=38.881034, lon=-90.261475, barometer=0.0,
polarDistance=5.915421, polarBearing=222.434158, isADSB=true, isOnGround=false,
lastSeenTime=2020-11-27T18:47:31Z, posUpdateTime=2020-11-27T18:47:31Z,
bds40SeenTime=1970-01-01T00:00:00Z)

Aircraft(id=60, callsign=SWA442, squawk=5657, reg=N928WN, flightno=WN442,
route=CMH-DCA-BNA-STL-PHX-BUR-OAK, type=B737, category=A3, altitude=8250,
heading=322, speed=266, vertRate=-1344, selectedAltitude=0, lat=38.604034,
lon=-90.357593, barometer=0.0, polarDistance=22.602864, polarBearing=201.283,
isADSB=true, isOnGround=false, lastSeenTime=2020-11-27T18:47:25Z,
posUpdateTime=2020-11-27T18:47:24Z, bds40SeenTime=1970-01-01T00:00:00Z)

Aircraft(id=61, callsign=null, squawk=null, reg=N702QS, flightno=,
route=SNA-RIC, type=CL35, category=, altitude=43000, heading=90, speed=500,
vertRate=0, selectedAltitude=0, lat=39.587997, lon=-90.921299, barometer=0.0,
polarDistance=51.544552, polarBearing=316.694343, isADSB=true, isOnGround=false,
lastSeenTime=2020-11-27T18:47:19Z, posUpdateTime=2020-11-27T18:47:19Z,
bds40SeenTime=1970-01-01T00:00:00Z)

如果您需要直接访问 Spring Data 模板提供的底层功能,则基于模板的数据库支持是必不可少的。但对于几乎所有常见用例而言,当 Spring Data 为目标数据库提供基于仓库的访问时,最好从那里开始,并且很可能保持在那里。

使用 Java 持久化 API(JPA)创建基于仓库的服务

Spring 生态系统的一个优点是一致性:一旦您学会了如何完成某件事,同样的方法可以用于推动不同组件的成功结果。数据库访问就是一个例子。

Spring Boot 和 Spring Data 为多种不同的数据库提供了仓库支持:符合 JPA 标准的数据库、多种类型的 NoSQL 数据存储以及内存中和/或持久存储。Spring 能够消除开发人员在不同数据库之间切换时遇到的障碍。

为了展示在创建数据感知的 Spring Boot 应用时可以使用的一些灵活选项,我在以下各节中重点介绍了几种不同的方法,同时依赖于 Boot(和 Spring Data)来简化不同但相似服务的数据库部分。首先是 JPA,在这个示例中,我始终使用 Lombok 来减少代码并增加可读性。

初始化项目

再次回到 Spring Initializr。这一次,我选择以下选项:

  • Maven 项目

  • Java

  • 当前生产版本的 Spring Boot

  • 打包:Jar

  • Java:11

并且对于依赖项:

  • Spring 响应式 Web (spring-boot-starter-webflux)

  • Spring Data JPA (spring-boot-starter-data-jpa)

  • MySQL 驱动程序 (mysql-connector-java)

  • Lombok (lombok)

接下来,我生成项目并将其保存在本地,解压并在 IDE 中打开。

注意

与之前 Redis 项目和本章中大多数其他示例一样,每个数据感知服务必须能够访问正在运行的数据库。请参考本书的相关代码库以获取创建和运行适合的容器化数据库引擎的 Docker 脚本。

开发 JPA(MySQL)服务

考虑到第四章使用 JPA 和 H2 数据库构建的示例以及之前的 Redis 基于仓库的示例,明显可以看出使用 MariaDB/MySQL 的基于 JPA 的服务展示了 Spring 一贯的一致性如何增强开发者的生产力。

定义领域类

与本章所有项目一样,我创建一个Aircraft领域类作为主要(数据)关注点。每个不同的项目将围绕一个共同主题进行轻微变化,并在路上指出。这里是以 JPA 为中心的Aircraft领域类结构:

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import java.time.Instant;

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Aircraft {
    @Id
    @GeneratedValue
    private Long id;

    private String callsign, squawk, reg, flightno, route, type, category;

    private int altitude, heading, speed;
    @JsonProperty("vert_rate")
    private int vertRate;
    @JsonProperty("selected_altitude")
    private int selectedAltitude;

    private double lat, lon, barometer;
    @JsonProperty("polar_distance")
    private double polarDistance;
    @JsonProperty("polar_bearing")
    private double polarBearing;

    @JsonProperty("is_adsb")
    private boolean isADSB;
    @JsonProperty("is_on_ground")
    private boolean isOnGround;

    @JsonProperty("last_seen_time")
    private Instant lastSeenTime;
    @JsonProperty("pos_update_time")
    private Instant posUpdateTime;
    @JsonProperty("bds40_seen_time")
    private Instant bds40SeenTime;
}

关于这个版本的Aircraft与先前版本及未来版本的一些特殊之处值得注意。

首先,@Entity@Id@GeneratedValue 注解都从 javax.persistence 包导入。您可能还记得在 Redis 版本(以及其他一些版本)中,@Id 来自于 org.springframework.data.annotation

类级别的注解与使用 Redis 仓库支持的示例中使用的注解密切相关,只是将@RedisHash替换为 JPA 的@Entity注解。要重新访问其他(未更改)所示的注解,请参阅前面提到的较早部分。

字段级别的注解也类似,增加了 @GeneratedValue。顾名思义,@GeneratedValue 表示标识符将由底层数据库引擎生成。开发人员可以—如果需要或必要的话—为键生成提供额外的指导,但对于我们的目的来说,注解本身就足够了。

与 Spring Data 对 Redis 的仓库支持一样,对于类型为Instant的成员变量,不需要显式访问器/修改器,因此(再次)留下了一个非常苗条的Aircraft域类。

创建仓库接口

接下来,我定义了所需的仓库接口,扩展了 Spring Data 的CrudRepository并提供要存储的对象类型及其键:在本例中是AircraftLong

public interface AircraftRepository extends CrudRepository<Aircraft, Long> {}
注意

Redis 和 JPA 数据库都可以很好地使用类型为Long的唯一键值/标识符,因此这与之前 Redis 示例中定义的相同。

将一切都整合在一起

现在来创建PlaneFinder轮询组件并配置它以进行数据库访问。

轮询 PlaneFinder

我再次创建一个 Spring Boot @Component 类来轮询当前位置数据,并处理它接收到的Aircraft记录。

与之前的示例类似,我创建一个WebClient对象,并将其分配给一个成员变量,将其指向端口7634上由PlaneFinder服务公开的目标端点。

正如你从兄弟仓库实现中所期望的那样,代码与 Redis 仓库的最终状态非常相似。我在这个示例中展示了几种不同的方法。

我指示 Lombok—通过其编译时代码生成器—提供一个构造函数,以接收自动连接的AircraftRepository bean,而不是手动创建一个构造函数。Lombok 通过两个注解确定哪些参数是必需的:类上的@RequiredArgsConstructor和成员变量上的@NonNull,指定需要初始化的成员变量。通过将AircraftRepository成员变量注释为一个@NonNull属性,Lombok 创建一个带有AircraftRepository作为参数的构造函数;然后 Spring Boot 忠实地自动连接现有的仓库 bean,以在PlaneFinderPoller bean 中使用。

注意

每次进行轮询时删除数据库中所有存储条目的智慧非常依赖于要求、轮询频率和涉及的存储机制。例如,在每次轮询之前清除内存数据库涉及的成本与删除云托管数据库表中的所有记录的成本大不相同。频繁的轮询也会增加相关的成本。存在其他选择,请明智选择。

要重新查看PlaneFinderPoller中剩余代码的详细信息,请查看 Redis 存储库支持下的相应部分。重构以充分利用 Spring Data JPA 支持,PlaneFinderPoller的完整代码如下清单所示:

import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@EnableScheduling
@Component
@RequiredArgsConstructor
class PlaneFinderPoller {
    @NonNull
    private final AircraftRepository repository;
    private WebClient client =
            WebClient.create("http://localhost:7634/aircraft");

    @Scheduled(fixedRate = 1000)
    private void pollPlanes() {
        repository.deleteAll();

        client.get()
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(plane -> !plane.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        repository.findAll().forEach(System.out::println);
    }
}

连接到 MariaDB/MySQL。

Spring Boot 在运行时使用所有可用信息自动配置应用程序的环境;这是其无与伦比的灵活性的关键之一。由于 Spring Boot 和 Spring Data 支持许多符合 JPA 的数据库,我们需要提供一些关键信息供 Boot 使用,以无缝连接到我们选择的特定应用程序的数据库。对于我环境中运行的此服务,这些属性包括:

spring.datasource.platform=mysql
spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/mark
spring.datasource.username=mark
spring.datasource.password=sbux
注意

在上述示例中,数据库名称和数据库用户名均为“mark”。请使用与您的环境特定的 datasource、username 和 password 值替换。

结果

尽管PlaneFinder服务仍在我的机器上运行,但我启动了sbur-jpa服务来获取、存储和检索(在 MariaDB 中)并显示每次轮询PlaneFinder的结果。以下是结果的示例,已编辑以简洁格式并进行了格式化以提高可读性:

Aircraft(id=106, callsign=null, squawk=null, reg=N7816B, flightno=WN2117,
route=SJC-STL-BWI-FLL, type=B737, category=, altitude=4400, heading=87,
speed=233, vertRate=2048, selectedAltitude=15008, lat=0.0, lon=0.0,
barometer=1017.6, polarDistance=0.0, polarBearing=0.0, isADSB=false,
isOnGround=false, lastSeenTime=2020-11-27T18:59:10Z,
posUpdateTime=2020-11-27T18:59:17Z, bds40SeenTime=2020-11-27T18:59:10Z)

Aircraft(id=107, callsign=null, squawk=null, reg=N963WN, flightno=WN851,
route=LAS-DAL-STL-CMH, type=B737, category=, altitude=27200, heading=80,
speed=429, vertRate=2112, selectedAltitude=0, lat=0.0, lon=0.0, barometer=0.0,
polarDistance=0.0, polarBearing=0.0, isADSB=false, isOnGround=false,
lastSeenTime=2020-11-27T18:58:45Z, posUpdateTime=2020-11-27T18:59:17Z,
bds40SeenTime=2020-11-27T18:59:17Z)

Aircraft(id=108, callsign=null, squawk=null, reg=N8563Z, flightno=WN1386,
route=DEN-IAD, type=B738, category=, altitude=39000, heading=94, speed=500,
vertRate=0, selectedAltitude=39008, lat=0.0, lon=0.0, barometer=1013.6,
polarDistance=0.0, polarBearing=0.0, isADSB=false, isOnGround=false,
lastSeenTime=2020-11-27T18:59:10Z, posUpdateTime=2020-11-27T18:59:17Z,
bds40SeenTime=2020-11-27T18:59:10Z)

该服务按预期工作以轮询、捕获和显示飞机位置。

加载数据。

到目前为止,本章重点讨论了数据流入应用程序时如何与数据库进行交互。如果存在必须持久化的数据——例如,样本、测试或实际种子数据——会发生什么?

Spring Boot 有几种不同的机制来初始化和填充数据库。在这里,我覆盖了我认为最有用的两种方法:

  • 使用数据定义语言(DDL)和数据操作语言(DML)脚本进行初始化和填充。

  • 允许通过休眠(Hibernate)自动从定义的@Entity类创建表结构并通过存储库 bean 进行填充。

每种数据定义和填充方法都有其优缺点。

API 或特定于数据库的脚本

Spring Boot 检查通常的根类路径位置,以查找符合以下命名格式的文件:

  • schema.sql

  • data.sql

  • schema-${platform}.sql

  • data-${platform}.sql

最后两个文件名与开发人员分配的应用程序属性spring.datasource.platform匹配。有效值包括h2mysqlpostgresql和其他 Spring Data JPA 数据库。使用spring.datasource.platform属性和相关的.sql文件的组合使开发人员可以充分利用特定于特定数据库的语法。

使用脚本创建和填充

为了以最简单的方式利用脚本创建和填充 MariaDB/MySQL 数据库,我在sbur-jpa项目的resources目录下创建了两个文件:schema-mysql.sqldata-mysql.sql

为了创建aircraft表结构,我在schema-mysql.sql中添加了以下 DDL:

DROP TABLE IF EXISTS aircraft;
CREATE TABLE aircraft (id BIGINT not null primary key, callsign VARCHAR(7),
squawk VARCHAR(4), reg VARCHAR(6), flightno VARCHAR(10), route VARCHAR(25),
type VARCHAR(4), category VARCHAR(2),
altitude INT, heading INT, speed INT, vert_rate INT, selected_altitude INT,
lat DOUBLE, lon DOUBLE, barometer DOUBLE,
polar_distance DOUBLE, polar_bearing DOUBLE,
isadsb BOOLEAN, is_on_ground BOOLEAN,
last_seen_time TIMESTAMP, pos_update_time TIMESTAMP, bds40seen_time TIMESTAMP);

要使用单个示例行填充aircraft表,我将以下 DML 添加到data-mysql.sql中:

INSERT INTO aircraft (id, callsign, squawk, reg, flightno, route, type,
category, altitude, heading, speed, vert_rate, selected_altitude, lat, lon,
barometer, polar_distance, polar_bearing, isadsb, is_on_ground,
last_seen_time, pos_update_time, bds40seen_time)
VALUES (81, 'AAL608', '1451', 'N754UW', 'AA608', 'IND-PHX', 'A319', 'A3', 36000,
255, 423, 0, 36000, 39.150284, -90.684795, 1012.8, 26.575562, 295.501994,
true, false, '2020-11-27 21:29:35', '2020-11-27 21:29:34',
'2020-11-27 21:29:27');

默认情况下,Boot 会根据任何带有@Entity注解的类自动创建表结构。可以使用以下属性设置覆盖此行为,这里显示了从应用程序的application.properties文件中的属性设置:

spring.datasource.initialization-mode=always
spring.jpa.hibernate.ddl-auto=none

spring.datasource.initialization-mode设置为“always”表示该应用程序期望使用外部(非嵌入式)数据库,并且每次应用程序执行时都应该对其进行初始化。将spring.jpa.hibernate.ddl-auto设置为“none”会禁用 Spring Boot 根据@Entity类自动创建表结构。

要验证前述脚本是否用于创建和填充aircraft表,我访问PlaneFinderPoller类并执行以下操作:

  • repository.deleteAll();语句在pollPlanes()中注释掉是必要的,以避免删除通过data-mysql.sql添加的记录。

  • 同样在pollPlanes()中注释掉client.get()...语句会导致不从外部PlaneFinder服务检索和创建任何附加记录,从而更容易验证。

现在重新启动sbur-jpa服务将导致以下输出(id字段可能不同),经过编辑以简洁和清晰的格式显示:

Aircraft(id=81, callsign=AAL608, squawk=1451, reg=N754UW, flightno=AA608,
route=IND-PHX, type=A319, category=A3, altitude=36000, heading=255, speed=423,
vertRate=0, selectedAltitude=36000, lat=39.150284, lon=-90.684795,
barometer=1012.8, polarDistance=26.575562, polarBearing=295.501994, isADSB=true,
isOnGround=false, lastSeenTime=2020-11-27T21:29:35Z,
posUpdateTime=2020-11-27T21:29:34Z, bds40SeenTime=2020-11-27T21:29:27Z)
注意

保存的唯一记录是data-mysql.sql中指定的那个。

就任何事物的所有方法而言,此表创建和填充方法都有其利弊。其中的优势包括:

  • 直接使用 SQL 脚本的能力,包括 DDL 和 DML,利用现有的脚本和/或 SQL 专业知识

  • 访问特定于所选数据库的 SQL 语法

不足之处并不严重,但应该予以认识:

  • 使用 SQL 文件显然是特定于支持 SQL 的关系数据库。

  • 脚本可以依赖于特定数据库的 SQL 语法,如果选择的底层数据库发生变化,则可能需要编辑。

  • 必须设置两个(2)应用程序属性,以覆盖默认的 Boot 行为。

使用应用程序的仓库来填充数据库

还有另一种方式,我认为特别强大且更灵活:使用 Boot 的默认行为来创建表结构(如果不存在),并使用应用程序的存储库支持来填充示例数据。

要恢复 Spring Boot 从Aircraft JPA @Entity类创建aircraft表的默认行为,我注释掉了刚刚添加到application.properties中的两个属性:

#spring.datasource.initialization-mode=always
#spring.jpa.hibernate.ddl-auto=none

由于这些属性不再被定义,Spring Boot 将不会搜索并执行data-mysql.sql或其他数据初始化脚本。

接下来,我创建一个类,并取一个目的描述性名称如DataLoader。我添加了类级别的注解@Component(这样 Spring 会创建一个DataLoader bean)和@AllArgsConstructor(这样 Lombok 会为每个成员变量创建一个带参数的构造函数)。然后,我添加了一个单一成员变量来持有 Spring Boot 将通过构造函数注入的AircraftRepository bean:

private final AircraftRepository repository;

并添加了一个名为loadData()的方法来清除并填充aircraft表:

@PostConstruct
private void loadData() {
    repository.deleteAll();

    repository.save(new Aircraft(81L,
            "AAL608", "1451", "N754UW", "AA608", "IND-PHX", "A319", "A3",
            36000, 255, 423, 0, 36000,
            39.150284, -90.684795, 1012.8, 26.575562, 295.501994,
            true, false,
            Instant.parse("2020-11-27T21:29:35Z"),
            Instant.parse("2020-11-27T21:29:34Z"),
            Instant.parse("2020-11-27T21:29:27Z")));
}

就是这样。真的。现在重新启动sbur-jpa服务将导致以下输出(id字段可能会有所不同),为简洁起见进行编辑和格式化:

Aircraft(id=110, callsign=AAL608, squawk=1451, reg=N754UW, flightno=AA608,
route=IND-PHX, type=A319, category=A3, altitude=36000, heading=255, speed=423,
vertRate=0, selectedAltitude=36000, lat=39.150284, lon=-90.684795,
barometer=1012.8, polarDistance=26.575562, polarBearing=295.501994, isADSB=true,
isOnGround=false, lastSeenTime=2020-11-27T21:29:35Z,
posUpdateTime=2020-11-27T21:29:34Z, bds40SeenTime=2020-11-27T21:29:27Z)
注意

保存的唯一记录是前一个DataLoader类中定义的记录,只有一个小差异:由于id字段是由数据库生成的(如Aircraft域类规范中所指定的),当记录保存时,数据库引擎会替换提供的id值。

这种方法的优点非常显著:

  • 完全独立于数据库。

  • 任何特定于特定数据库的代码/注解已在应用程序中,只需支持数据库访问。

  • 通过简单地注释掉DataLoader类上的@Component注解,可以轻松禁用。

其他机制

这些都是用于数据库初始化和填充的两种强大且广泛使用的选项,但还有其他选项,包括使用 Hibernate 支持的import.sql文件(类似于之前介绍的 JPA 方法)、使用外部导入以及使用 FlywayDB 等。探索其他众多选项超出了本书的范围,读者可以选择性地进行练习。

使用基于仓库的服务创建 NoSQL 文档数据库

如前所述,在使用 Spring Boot 创建应用程序时,有几种方法可以进一步增强开发人员的生产力。其中之一是通过使用 Kotlin 作为基础应用语言来增加代码简洁性。

关于 Kotlin 语言的详尽探索远超出本书的范围,也有其他的书籍来满足这一角色。不过幸运的是,尽管 Kotlin 在许多有意义的方面与 Java 有所不同,但它与 Java 相似到足以在事物背离“Java 方式”时通过几处恰当的解释来适应其习惯。我将在继续进行时努力提供这些解释;有关背景或额外信息,请参考专门的 Kotlin 书籍。

作为例子,我使用 MongoDB。作为可能最知名的文档数据库,MongoDB 因其良好的工作方式和广泛的使用而广受欢迎,这对开发人员来说通常更容易存储、操作和检索各种数据(有时是混乱的形式)。MongoDB 团队还不断努力改进其功能集、安全性和 API:MongoDB 是第一批提供反应式数据库驱动程序的数据库之一,引领行业将非阻塞访问扩展到数据库级别。

初始化项目

正如您所预期的,我们回到 Spring Initializr 开始工作。对于这个项目,我选择了以下选项(也显示在 图 6-1)——与以前的访问有所不同:

  • Gradle 项目

  • Kotlin

  • 当前生产版本的 Spring Boot

  • 打包:Jar

  • Java:11

并且对于依赖项:

  • Spring 响应式 Web (spring-boot-starter-webflux)

  • Spring 数据 MongoDB (spring-boot-starter-data-mongodb)

  • 嵌入式 MongoDB 数据库 (de.flapdoodle.embed.mongo)

接下来,我生成项目并将其保存在本地,解压并在 IDE 中打开。

sbur 0601

图 6-1. 使用 Spring Boot Initializr 创建 Kotlin 应用程序

关于所选选项的几个特别注意事项:首先,我选择了 Gradle 作为这个项目的构建系统,这是有充分理由的——在 Spring Boot 项目中选择使用 Gradle 仅仅会导致 Gradle 构建文件使用 Kotlin DSL,这与 Groovy DSL 在 Gradle 团队中的支持地位不相上下。请注意,生成的构建文件是 build.gradle.kts —— .kts 扩展名表明它是一个 Kotlin 脚本 —— 而不是您习惯看到的基于 Groovy 的 build.gradle 文件。对于 Spring Boot + Kotlin 应用程序,Maven 也完全可以作为一个很好的构建系统,但作为基于 XML 的声明式构建系统,它不直接使用 Kotlin 或任何其他语言。

其次,我利用了此应用程序的嵌入式 MongoDB 数据库的 Spring Boot Starter 的存在。因为嵌入式 MongoDB 实例仅用于测试,我建议不要在生产环境中使用它;话虽如此,它是一个很好的选择,可以演示 Spring Boot 和 Spring Data 如何与 MongoDB 协作,并且从开发者的角度来看,它与本地部署的数据库功能匹配,而无需安装和/或运行一个容器化的 MongoDB 实例的额外步骤。从(非测试)代码中使用嵌入式数据库所需的唯一调整是在 build.gradle.kts 中更改一行,从这样:

testImplementation("de.flapdoodle.embed:de.flapdoodle.embed.mongo")

到这个:

implementation("de.flapdoodle.embed:de.flapdoodle.embed.mongo")

有了这个,我们准备好创建我们的服务了。

开发 MongoDB 服务

与之前的示例一样,基于 MongoDB 的服务在使用 Kotlin 而不是 Java 作为语言基础时,提供了非常一致的方法和体验。

定义域类

对于这个项目,我创建了一个 Kotlin Aircraft 域类来作为主要(数据)关注点。以下是带有一些观察的新Aircraft域类结构:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant

@Document
@JsonIgnoreProperties(ignoreUnknown = true)
data class Aircraft(
    @Id val id: String,
    val callsign: String? = "",
    val squawk: String? = "",
    val reg: String? = "",
    val flightno: String? = "",
    val route: String? = "",
    val type: String? = "",
    val category: String? = "",
    val altitude: Int? = 0,
    val heading: Int? = 0,
    val speed: Int? = 0,
    @JsonProperty("vert_rate") val vertRate: Int? = 0,
    @JsonProperty("selected_altitude")
    val selectedAltitude: Int? = 0,
    val lat: Double? = 0.0,
    val lon: Double? = 0.0,
    val barometer: Double? = 0.0,
    @JsonProperty("polar_distance")
    val polarDistance: Double? = 0.0,
    @JsonProperty("polar_bearing")
    val polarBearing: Double? = 0.0,
    @JsonProperty("is_adsb")
    val isADSB: Boolean? = false,
    @JsonProperty("is_on_ground")
    val isOnGround: Boolean? = false,
    @JsonProperty("last_seen_time")
    val lastSeenTime: Instant? = Instant.ofEpochSecond(0),
    @JsonProperty("pos_update_time")
    val posUpdateTime: Instant? = Instant.ofEpochSecond(0),
    @JsonProperty("bds40_seen_time")
    val bds40SeenTime: Instant? = Instant.ofEpochSecond(0)
)

需要注意的第一件事是没有看到花括号;简单地说,这个类没有主体。如果你是 Kotlin 的新手,这可能看起来有点不寻常,但是在没有东西放在类(或接口)主体中的情况下,花括号没有添加任何值。因此,Kotlin 不需要它们。

第二件有趣的事情是类名后面紧跟的括号中显示的许多赋值。这些的用途是什么?

Kotlin 类的主构造函数通常是这样显示的:在类头中,紧跟在类名之后。以下是完整的、正式格式的示例:

class Aircraft constructor(<parameter1>,<parameter2>,...,<parametern>)

像 Kotlin 中经常发生的那样,如果一个模式很清晰可辨并且重复一致,它可以被压缩。在参数列表之前删除constructor关键字不会与任何其他语言构造混淆,因此是可选的。

构造函数中包含参数。通过在每个参数前放置var(用于可重复赋值的可变变量)或val(用于单次赋值的值,相当于 Java 的final变量),它也变成了一个属性。 Kotlin 属性在功能上大致相当于 Java 成员变量、其访问器和(如果用var声明)其变化器的组合。

具有包含问号(?)的类型的值,例如,Double?,表示构造函数参数可能被省略。如果是这样,该参数被分配在等号(=)后显示的默认值。

Kotlin 方法(包括构造函数)参数和属性也可以包括注释,就像它们的 Java 对应项一样。@Id@JsonProperty执行与先前 Java 示例中相同的功能。

关于类级别的注解,@Document表示对 MongoDB,Aircraft类型的每个对象都将存储为数据库中的一个文档。与以前一样,@JsonIgnoreProperties(ignoreUnknown = true)只是在sbur-mongo服务中增加了一些灵活性;如果上游PlaneFinder服务的数据提供中随时添加了额外的字段,它们将被简单忽略,sbur_mongo将继续正常运行。

最后需要注意的是在类定义之前的data关键字。创建主要用作数据存储桶以在进程之间传递的领域类是一个频繁的模式。实际上,这种创建所谓数据类的能力在几个方面都有体现;例如,@Data多年来一直是 Lombok 的一个特性。

Kotlin 将此功能整合到语言本身,并添加了data关键字,以表示数据类将自动从类的主构造函数中声明的所有属性派生以下内容:

  • equals()hashCode()函数(Java 有方法;Kotlin 有函数)

  • toString()

  • componentN()函数,每个属性按其声明顺序一个函数。

  • copy()函数

Kotlin 数据类有一些要求和限制,但它们是合理且最小的。详细信息请参考 Kotlin 文档中关于data classes的部分。

注意

其他一个有趣的变化是每架飞机位置的id字段/属性的类型。在 Redis 和 JPA 中,它是一个Long;但 MongoDB 使用String作为其唯一文档标识符。这并没有实际影响,只是需要注意的事项。

创建仓库接口

接下来,我定义了所需的仓库接口,扩展了 Spring Data 的CrudRepository并提供了存储对象及其唯一标识符的类型:AircraftString,如前所述:

interface AircraftRepository: CrudRepository<Aircraft, String>

在这个简明接口定义中有两个有趣的事情:

  1. 在 Kotlin 中没有实际接口体,因此不需要花括号。如果你的 IDE 在创建此接口时添加了它们,可以安全地删除它们。

  2. Kotlin 在此上下文中使用冒号(:)来表示valvar类型,或者在本例中表示一个类或接口扩展或实现另一个。在这个特定的例子中,我定义了一个接口AircraftRepository,它扩展了CrudRepository接口。

注意

还有一个MongoRepository接口,它同时扩展了PagingAndSortingRepository(它又扩展了CrudRepository)和QueryByExampleExecutor,可以在这里使用而不是CrudRepository。但除非需要额外的能力,否则写入满足所有要求的最高级别接口是一个良好的实践和习惯。在这种情况下,CrudRepository对当前需求已足够。

将所有内容汇总

下一步是创建定期轮询PlaneFinder服务的组件。

轮询 PlaneFinder

与早期示例类似,我创建了一个 Spring Boot 组件类PlaneFinderPoller来轮询当前位置数据并处理接收到的任何Aircraft记录,如下所示:

import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToFlux

@Component
@EnableScheduling
class PlaneFinderPoller(private val repository: AircraftRepository) {
    private val client =
        WebClient.create("http://localhost:7634/aircraft")

    @Scheduled(fixedRate = 1000)
    private fun pollPlanes() {
        repository.deleteAll()

        client.get()
            .retrieve()
            .bodyToFlux<Aircraft>()
            .filter { !it.reg.isNullOrEmpty() }
            .toStream()
            .forEach { repository.save(it) }

        println("--- All aircraft ---")
        repository.findAll().forEach { println(it) }
    }
}

我在头部创建了主构造函数,并带有一个AircraftRepository参数。Spring Boot 自动将现有的AircraftRepository bean 自动装配到PlaneFinderPoller组件中供使用,并将其标记为private val以确保以下内容:

  • 以后不可重新分配。

  • 它不作为PlaneFinderPoller bean 的属性对外暴露,因为存储库已经可以在整个应用程序中访问。

接下来,我创建一个WebClient对象并将其分配给一个属性,指向由PlaneFinder服务在 7634 端口上暴露的目标端点。

我使用@Component对类进行注释,以便 Spring Boot 在应用程序启动时创建一个组件(bean),并使用@EnableScheduling来启用通过注解的函数进行定期轮询。

最后,我创建一个函数来删除所有现有的Aircraft数据,通过WebClient客户端属性轮询PlaneFinder端点,将检索到的飞机位置转换并存储在 MongoDB 中,并显示它们。@Scheduled(fixedRate = 1000)导致轮询函数每 1000 毫秒执行一次(每秒一次)。

pollPlanes()函数中还有三个更有趣的事情需要注意,这些都涉及 Kotlin 的 lambda。

首先是,如果 lambda 是函数的最后一个参数,则可以省略括号,因为它们对于清晰度或含义没有任何增加。如果函数只有一个 lambda 参数,这也符合条件。这样做可以减少在有时繁忙的代码行中需要查找的符号数量。

第二个是,如果 lambda 本身具有单个参数,开发人员仍然可以显式指定它,但不是必需的。Kotlin 隐式识别并引用唯一的 lambda 参数作为it,进一步简化 lambda,正如此 lambda 参数传递给forEach()所示:

forEach { repository.save(it) }

最后,函数isNullOrEmpty()操作CharSequence提供了一个非常好的全能功能进行字符串评估。此函数首先执行空值检查,然后如果确定值为非空,则检查其长度是否为零,即为空。开发人员经常只有在属性包含实际值时才能处理它们,而此单一函数一次性执行两个验证步骤。如果在Aircraft的注册属性reg中存在值,则将传递该传入的飞机位置报告;缺少注册值的飞机位置报告将被过滤掉。

所有剩余的位置报告都会被流向存储库进行保存,然后我们查询存储库中的所有持久化文档并显示结果。

结果

使用在我机器上运行的PlaneFinder服务后,我启动了sbur-mongo服务,以获取、存储和检索(在嵌入式 MongoDB 实例中),并显示每次轮询PlaneFinder的结果。以下是编辑过以简短形式呈现的结果,以提高可读性:

Aircraft(id=95, callsign=N88846, squawk=4710, reg=N88846, flightno=, route=,
type=P46T, category=A1, altitude=18000, heading=234, speed=238, vertRate=-64,
selectedAltitude=0, lat=39.157288, lon=-90.844992, barometer=0.0,
polarDistance=33.5716, polarBearing=290.454061, isADSB=true, isOnGround=false,
lastSeenTime=2020-11-27T20:16:57Z, posUpdateTime=2020-11-27T20:16:57Z,
bds40SeenTime=1970-01-01T00:00:00Z)

Aircraft(id=96, callsign=MVJ710, squawk=1750, reg=N710MV, flightno=,
route=IAD-TEX, type=GLF4, category=A2, altitude=18050, heading=66, speed=362,
vertRate=2432, selectedAltitude=23008, lat=38.627655, lon=-90.008897,
barometer=0.0, polarDistance=20.976944, polarBearing=158.35465, isADSB=true,
isOnGround=false, lastSeenTime=2020-11-27T20:16:57Z,
posUpdateTime=2020-11-27T20:16:57Z, bds40SeenTime=2020-11-27T20:16:56Z)

Aircraft(id=97, callsign=SWA1121, squawk=6225, reg=N8654B, flightno=WN1121,
route=MDW-DAL-PHX, type=B738, category=A3, altitude=40000, heading=236,
speed=398, vertRate=0, selectedAltitude=40000, lat=39.58548, lon=-90.049259,
barometer=1013.6, polarDistance=38.411587, polarBearing=8.70042, isADSB=true,
isOnGround=false, lastSeenTime=2020-11-27T20:16:57Z,
posUpdateTime=2020-11-27T20:16:55Z, bds40SeenTime=2020-11-27T20:16:54Z)

预期地,服务通过使用 Spring Boot、Kotlin 和 MongoDB 几乎不费力地轮询、捕获和显示飞机位置。

使用基于存储库的服务创建基于 NoSQL 图数据库的服务

图数据库带来了数据的不同处理方式,特别是它们如何相互关联。市场上有几个图数据库,但在所有实际用途中,领域领导者是 Neo4j。

虽然图论和图数据库设计远远超出了本书的范围,但在 Spring Boot 和 Spring Data 中展示如何最佳地使用图数据库工作则完全属于其范围。本节展示了如何在 Spring Boot 应用程序中使用 Spring Data Neo4j 轻松连接和处理数据。

初始化项目

我们再次回到 Spring Initializr。这一次,我选择了以下选项:

  • Gradle 项目

  • Java

  • 当前生产版本的 Spring Boot。

  • 打包:Jar

  • Java:11

并且还有依赖项:

  • Spring Reactive Web(spring-boot-starter-webflux

  • Spring Data Neo4j(spring-boot-starter-data-neo4j

接下来,我生成项目并将其保存在本地,解压缩并在 IDE 中打开。

我选择了 Gradle 作为这个项目的构建系统,仅仅是为了演示使用 Gradle 创建 Spring Boot Java 应用程序时,生成的build.gradle文件使用 Groovy DSL,但 Maven 同样是一个有效的选择。

注意

与本章中大多数其他示例一样,我在本地托管的容器中运行了一个 Neo4j 数据库实例,准备响应此应用程序。

有了这个,我们就可以创建我们的服务了。

开发 Neo4j 服务

与之前的示例一样,Spring Boot 和 Spring Data 使得使用 Neo4j 数据库的体验高度一致,与使用其他类型的底层数据存储非常一致。图数据存储的全部功能在 Spring Boot 应用程序中是可用且易于访问的,但是上手时间大大缩短。

定义领域类

我再次从定义Aircraft领域开始。没有 Lombok 作为依赖项,我创建它时使用了通常的广泛构造函数、访问器、修改器和支持方法:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;

@Node
@JsonIgnoreProperties(ignoreUnknown = true)
public class Aircraft {
    @Id
    @GeneratedValue
    private Long neoId;

    private Long id;
    private String callsign, squawk, reg, flightno, route, type, category;

    private int altitude, heading, speed;
    @JsonProperty("vert_rate")
    private int vertRate;
    @JsonProperty("selected_altitude")
    private int selectedAltitude;

    private double lat, lon, barometer;
    @JsonProperty("polar_distance")
    private double polarDistance;
    @JsonProperty("polar_bearing")
    private double polarBearing;

    @JsonProperty("is_adsb")
    private boolean isADSB;
    @JsonProperty("is_on_ground")
    private boolean isOnGround;

    @JsonProperty("last_seen_time")
    private Instant lastSeenTime;
    @JsonProperty("pos_update_time")
    private Instant posUpdateTime;
    @JsonProperty("bds40_seen_time")
    private Instant bds40SeenTime;

    public Aircraft() {
    }

    public Aircraft(Long id,
                    String callsign, String squawk, String reg, String flightno,
                    String route, String type, String category,
                    int altitude, int heading, int speed,
                    int vertRate, int selectedAltitude,
                    double lat, double lon, double barometer,
                    double polarDistance, double polarBearing,
                    boolean isADSB, boolean isOnGround,
                    Instant lastSeenTime,
                    Instant posUpdateTime,
                    Instant bds40SeenTime) {
        this.id = id;
        this.callsign = callsign;
        this.squawk = squawk;
        this.reg = reg;
        this.flightno = flightno;
        this.route = route;
        this.type = type;
        this.category = category;
        this.altitude = altitude;
        this.heading = heading;
        this.speed = speed;
        this.vertRate = vertRate;
        this.selectedAltitude = selectedAltitude;
        this.lat = lat;
        this.lon = lon;
        this.barometer = barometer;
        this.polarDistance = polarDistance;
        this.polarBearing = polarBearing;
        this.isADSB = isADSB;
        this.isOnGround = isOnGround;
        this.lastSeenTime = lastSeenTime;
        this.posUpdateTime = posUpdateTime;
        this.bds40SeenTime = bds40SeenTime;
    }

    public Long getNeoId() {
        return neoId;
    }

    public void setNeoId(Long neoId) {
        this.neoId = neoId;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getCallsign() {
        return callsign;
    }

    public void setCallsign(String callsign) {
        this.callsign = callsign;
    }

    public String getSquawk() {
        return squawk;
    }

    public void setSquawk(String squawk) {
        this.squawk = squawk;
    }

    public String getReg() {
        return reg;
    }

    public void setReg(String reg) {
        this.reg = reg;
    }

    public String getFlightno() {
        return flightno;
    }

    public void setFlightno(String flightno) {
        this.flightno = flightno;
    }

    public String getRoute() {
        return route;
    }

    public void setRoute(String route) {
        this.route = route;
    }

    public String getType() {
        return type;
    }

    public void setType(String type) {
        this.type = type;
    }

    public String getCategory() {
        return category;
    }

    public void setCategory(String category) {
        this.category = category;
    }

    public int getAltitude() {
        return altitude;
    }

    public void setAltitude(int altitude) {
        this.altitude = altitude;
    }

    public int getHeading() {
        return heading;
    }

    public void setHeading(int heading) {
        this.heading = heading;
    }

    public int getSpeed() {
        return speed;
    }

    public void setSpeed(int speed) {
        this.speed = speed;
    }

    public int getVertRate() {
        return vertRate;
    }

    public void setVertRate(int vertRate) {
        this.vertRate = vertRate;
    }

    public int getSelectedAltitude() {
        return selectedAltitude;
    }

    public void setSelectedAltitude(int selectedAltitude) {
        this.selectedAltitude = selectedAltitude;
    }

    public double getLat() {
        return lat;
    }

    public void setLat(double lat) {
        this.lat = lat;
    }

    public double getLon() {
        return lon;
    }

    public void setLon(double lon) {
        this.lon = lon;
    }

    public double getBarometer() {
        return barometer;
    }

    public void setBarometer(double barometer) {
        this.barometer = barometer;
    }

    public double getPolarDistance() {
        return polarDistance;
    }

    public void setPolarDistance(double polarDistance) {
        this.polarDistance = polarDistance;
    }

    public double getPolarBearing() {
        return polarBearing;
    }

    public void setPolarBearing(double polarBearing) {
        this.polarBearing = polarBearing;
    }

    public boolean isADSB() {
        return isADSB;
    }

    public void setADSB(boolean ADSB) {
        isADSB = ADSB;
    }

    public boolean isOnGround() {
        return isOnGround;
    }

    public void setOnGround(boolean onGround) {
        isOnGround = onGround;
    }

    public Instant getLastSeenTime() {
        return lastSeenTime;
    }

    public void setLastSeenTime(Instant lastSeenTime) {
        this.lastSeenTime = lastSeenTime;
    }

    public Instant getPosUpdateTime() {
        return posUpdateTime;
    }

    public void setPosUpdateTime(Instant posUpdateTime) {
        this.posUpdateTime = posUpdateTime;
    }

    public Instant getBds40SeenTime() {
        return bds40SeenTime;
    }

    public void setBds40SeenTime(Instant bds40SeenTime) {
        this.bds40SeenTime = bds40SeenTime;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Aircraft aircraft = (Aircraft) o;
        return altitude == aircraft.altitude &&
                heading == aircraft.heading &&
                speed == aircraft.speed &&
                vertRate == aircraft.vertRate &&
                selectedAltitude == aircraft.selectedAltitude &&
                Double.compare(aircraft.lat, lat) == 0 &&
                Double.compare(aircraft.lon, lon) == 0 &&
                Double.compare(aircraft.barometer, barometer) == 0 &&
                Double.compare(aircraft.polarDistance, polarDistance) == 0 &&
                Double.compare(aircraft.polarBearing, polarBearing) == 0 &&
                isADSB == aircraft.isADSB &&
                isOnGround == aircraft.isOnGround &&
                Objects.equals(neoId, aircraft.neoId) &&
                Objects.equals(id, aircraft.id) &&
                Objects.equals(callsign, aircraft.callsign) &&
                Objects.equals(squawk, aircraft.squawk) &&
                Objects.equals(reg, aircraft.reg) &&
                Objects.equals(flightno, aircraft.flightno) &&
                Objects.equals(route, aircraft.route) &&
                Objects.equals(type, aircraft.type) &&
                Objects.equals(category, aircraft.category) &&
                Objects.equals(lastSeenTime, aircraft.lastSeenTime) &&
                Objects.equals(posUpdateTime, aircraft.posUpdateTime) &&
                Objects.equals(bds40SeenTime, aircraft.bds40SeenTime);
    }

    @Override
    public int hashCode() {
        return Objects.hash(neoId, id, callsign, squawk, reg, flightno, route,
                type, category, altitude, heading, speed, vertRate,
                selectedAltitude,  lat, lon, barometer, polarDistance,
                polarBearing, isADSB, isOnGround, lastSeenTime, posUpdateTime,
                bds40SeenTime);
    }

    @Override
    public String toString() {
        return "Aircraft{" +
                "neoId=" + neoId +
                ", id=" + id +
                ", callsign='" + callsign + '\'' +
                ", squawk='" + squawk + '\'' +
                ", reg='" + reg + '\'' +
                ", flightno='" + flightno + '\'' +
                ", route='" + route + '\'' +
                ", type='" + type + '\'' +
                ", category='" + category + '\'' +
                ", altitude=" + altitude +
                ", heading=" + heading +
                ", speed=" + speed +
                ", vertRate=" + vertRate +
                ", selectedAltitude=" + selectedAltitude +
                ", lat=" + lat +
                ", lon=" + lon +
                ", barometer=" + barometer +
                ", polarDistance=" + polarDistance +
                ", polarBearing=" + polarBearing +
                ", isADSB=" + isADSB +
                ", isOnGround=" + isOnGround +
                ", lastSeenTime=" + lastSeenTime +
                ", posUpdateTime=" + posUpdateTime +
                ", bds40SeenTime=" + bds40SeenTime +
                '}';
    }
}

Java 代码确实可能会冗长。公平地说,像领域类这样的情况并不是一个很大的问题,因为虽然访问器和修改器占据了大量空间,但它们可以由 IDE 生成,并且由于其长期稳定性,通常不需要太多维护。也就是说,这确实是很多样板代码,这就是为什么许多开发者在 Java 应用程序中仅仅创建领域类时都使用像 Lombok 或 Kotlin 这样的解决方案。

注意

Neo 要求具有数据库生成的唯一标识符,即使被持久化的实体已包含唯一标识符。为满足此要求,我添加了一个neoId参数/成员变量,并用@IdGeneratedValue对其进行了注释,以便 Neo4j 正确地将此成员变量与其内部生成的值关联起来。

接下来,我添加了两个类级别的注解:

@Node:将每个此record实例指定为 Neo4j 节点Aircraft的一个实例@JsonIgnoreProperties(ignoreUnknown = true):忽略可能添加到PlaneFinder服务端点的新字段

注意,像@Id@GeneratedValue一样,@Node注解来自于 Spring Data Neo4j 的org.springframework.data.neo4j.core.schema包,用于基于 Spring Data Neo4j 的应用程序。

通过这样,我们定义了服务的领域。

创建仓库接口

对于此应用程序,我再次定义了所需的仓库接口,扩展了 Spring Data 的CrudRepository并提供了要存储的对象类型及其键:在这种情况下是AircraftLong

public interface AircraftRepository extends CrudRepository<Aircraft, Long> {}
注意

类似于早期基于 MongoDB 的项目,这里有一个Neo4jRepository接口,它扩展了PagingAndSortingRepository(它又扩展了CrudRepository),可以用来替代CrudRepository;然而,由于CrudRepository是满足所有要求的最高级别接口,我将其用作AircraftRepository的基础。

将所有内容汇总

现在创建组件以轮询PlaneFinder并配置它以访问 Neo4j 数据库。

轮询 PlaneFinder

再次创建一个 Spring Boot @Component类来轮询当前飞机位置并处理接收到的Aircraft记录。

类似于本章其他基于 Java 的项目,我创建了一个WebClient对象,并将其分配给成员变量,指向由PlaneFinder服务在 7634 端口上公开的目标端点。

在没有 Lombok 作为依赖项的情况下,我通过构造函数创建了一个接收@Autowired 的AircraftRepository bean。

如下所示的完整列出了PlaneFinderPoller类,pollPlanes()方法与其他示例几乎相同,这要归功于仓库支持带来的抽象。如果需要重新查看PlaneFinderPoller中其余代码的任何细节,请查阅前面章节中的相应部分:

import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@EnableScheduling
@Component
public class PlaneFinderPoller {
    private WebClient client =
            WebClient.create("http://localhost:7634/aircraft");
    private final AircraftRepository repository;

    public PlaneFinderPoller(AircraftRepository repository) {
        this.repository = repository;
    }

    @Scheduled(fixedRate = 1000)
    private void pollPlanes() {
        repository.deleteAll();

        client.get()
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(plane -> !plane.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        System.out.println("--- All aircraft ---");
        repository.findAll().forEach(System.out::println);
    }
}

连接到 Neo4j

与早期的 MariaDB/MySQL 示例类似,我们需要提供一些关键信息,以便 Boot 能够无缝连接到 Neo4j 数据库。对于我环境中运行的此服务,这些属性包括:

spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=mkheck
注意

将显示的用户名和密码值替换为与您的环境特定的值。

结果

在我的机器上运行PlaneFinder服务后,我启动了sbur-neo服务来获取、存储、检索和显示每次对PlaneFinder进行轮询的结果,Neo4j 作为首选数据存储。以下是结果的示例,经过编辑以简洁化,并进行了格式化以提高可读性:

Aircraft(neoId=64, id=223, callsign='GJS4401', squawk='1355', reg='N542GJ',
flightno='UA4401', route='LIT-ORD', type='CRJ7', category='A2', altitude=37000,
heading=24, speed=476, vertRate=128, selectedAltitude=36992, lat=39.463961,
lon=-90.549927, barometer=1012.8, polarDistance=35.299257,
polarBearing=329.354686, isADSB=true, isOnGround=false,
lastSeenTime=2020-11-27T20:42:54Z, posUpdateTime=2020-11-27T20:42:53Z,
bds40SeenTime=2020-11-27T20:42:51Z)

Aircraft(neoId=65, id=224, callsign='N8680B', squawk='1200', reg='N8680B',
flightno='', route='', type='C172', category='A1', altitude=3100, heading=114,
speed=97, vertRate=64, selectedAltitude=0, lat=38.923955, lon=-90.195618,
barometer=0.0, polarDistance=1.986086, polarBearing=208.977102, isADSB=true,
isOnGround=false, lastSeenTime=2020-11-27T20:42:54Z,
posUpdateTime=2020-11-27T20:42:54Z, bds40SeenTime=null)

Aircraft(neoId=66, id=225, callsign='AAL1087', squawk='1712', reg='N181UW',
flightno='AA1087', route='CLT-STL-CLT', type='A321', category='A3',
altitude=7850, heading=278, speed=278, vertRate=-320, selectedAltitude=4992,
lat=38.801559, lon=-90.226474, barometer=0.0, polarDistance=9.385111,
polarBearing=194.034005, isADSB=true, isOnGround=false,
lastSeenTime=2020-11-27T20:42:54Z, posUpdateTime=2020-11-27T20:42:53Z,
bds40SeenTime=2020-11-27T20:42:53Z)

该服务快速高效,使用 Spring Boot 和 Neo4j 来检索、捕获和显示飞机位置,因为它们被报告。

代码检出检查

对于完整的章节代码,请从代码存储库的chapter6end分支进行检查。

摘要

数据可能是一个复杂的话题,有无数的变量和约束,包括数据结构、关系、适用的标准、提供者和机制等。然而,如果没有某种形式的数据,大多数应用程序提供的价值很少或没有。

作为几乎所有应用价值的基础,“数据”吸引了数据库提供商和平台供应商的大量创新。然而,在许多情况下,复杂性仍然存在,开发人员必须驯服这种复杂性才能释放价值。

Spring Data 的宣布的使命是“提供一个熟悉且一致的、基于 Spring 的数据访问编程模型,同时仍然保留基础数据存储的特殊特性。”无论数据库引擎或平台如何,Spring Data 的目标都是使开发人员对数据的使用尽可能简单且强大。

本章演示了如何使用各种数据库选项和 Spring Data 项目以及能够以最强大的方式使用它们的设施来简化数据存储和检索:通过 Spring Boot。

在下一章中,我将展示如何使用 Spring MVC 的 REST 交互、消息平台和其他通信机制创建命令式应用,以及介绍模板语言支持。虽然本章的重点是从应用程序向下,第七章重点是从应用程序向外。

第七章:使用 Spring MVC 创建应用程序

本章演示了如何使用 Spring MVC 创建 Spring Boot 应用程序,包括 REST 交互、消息平台和其他通信机制,并介绍了模板语言支持。尽管我在上一章中引入了服务之间的交互作为处理数据的 Spring Boot 的众多选项的一部分,但本章的主要焦点从应用程序本身转移到外部世界:其与其他应用程序和/或服务以及最终用户的交互。

代码检出检查

请从代码存储库的 chapter7begin 分支开始检查。

Spring MVC:是什么意思?

像技术中的许多其他事物一样,术语 Spring MVC 有点过载。当有人提到 Spring MVC 时,他们可能指的是以下任何一种:

  • 在 Spring 应用程序中以某种方式实现 Model-View-Controller 模式

  • 特别是在 Spring MVC 组件概念中创建应用程序,如 Model 接口、@Controller 类和视图技术

  • 使用 Spring 开发阻塞/非响应式应用程序

根据上下文,Spring MVC 可以被认为是一种方法和实现。它也可以在 Spring Boot 内外使用。在本书的范围之外,泛应用 Spring 和 Spring MVC 在 Spring Boot 之外的使用都不在讨论范围内。我将专注于使用 Spring Boot 来实现前面列出的最后两个概念。

使用模板引擎的最终用户交互

虽然 Spring Boot 应用程序在后端处理了大量繁重的任务,但 Boot 也支持直接的最终用户交互。尽管像 Java Server Pages (JSP) 这样的长期标准仍然受到 Boot 支持,用于传统应用程序,但大多数当前应用程序要么利用更强大的视图技术,这些技术由仍在演进和维护的模板引擎支持,要么将前端开发转移到 HTML 和 JavaScript 的组合上。甚至可以成功地将这两种选项混合使用,并充分发挥它们各自的优势。

正如我后面在本章中展示的,Spring Boot 与 HTML 和 JavaScript 前端配合得很好。现在,让我们更仔细地看一下模板引擎。

模板引擎为所谓的服务器端应用程序提供了一种生成最终页面的方法,这些页面将在最终用户的浏览器中显示和执行。这些视图技术在方法上有所不同,但通常提供以下功能:

  • 一个模板语言和/或一组标记,定义模板引擎用于生成预期结果的输入

  • 决定要使用的视图/模板以完成所请求资源的视图解析器

除了其他不常用的选项外,Spring Boot 还支持视图技术,如ThymeleafFreeMarkerGroovy MarkupMustache。Thymeleaf 可能是其中使用最广泛的,有几个原因,并为 Spring MVC 和 Spring WebFlux 应用程序提供了出色的支持。

Thymeleaf 使用自然模板:这些文件包含代码元素,但可以直接(并且正确地)在任何标准 Web 浏览器中打开和查看。能够将模板文件视为 HTML 文件使得开发人员或设计人员可以在没有运行服务器进程的情况下创建和演变 Thymeleaf 模板。任何期望对应的服务器端元素的代码集成都被标记为 Thymeleaf 特定的,并且只显示存在的内容。

在之前的努力基础上,让我们使用 Spring Boot、Spring MVC 和 Thymeleaf 构建一个简单的 Web 应用程序,向最终用户展示一个界面,用于查询 PlaneFinder 的当前飞机位置并显示结果。最初,这将是一个基础的概念验证,后续章节将进一步完善。

初始化项目

首先,我们回到 Spring Initializr。从那里,我选择以下选项:

  • Maven 项目

  • Java

  • 当前生产版本的 Spring Boot

  • 打包方式:Jar

  • Java:11

依赖项如下:

  • Spring Web (spring-boot-starter-web)

  • Spring Reactive Web (spring-boot-starter-webflux)

  • Thymeleaf (spring-boot-starter-thymeleaf)

  • Spring Data JPA (spring-boot-starter-data-jpa)

  • H2 数据库 (h2)

  • Lombok (lombok)

下一步是生成项目并将其保存到本地,解压缩并在 IDE 中打开它。

开发飞机位置应用程序

由于此应用程序仅涉及当前状态——即在发出请求时的飞机位置,而非历史记录,因此选择使用内存数据库似乎是一个合理的选择。当然,也可以使用某种Iterable,但是 Spring Boot 对 Spring Data 存储库和 H2 数据库的支持可以满足当前的使用案例,并为计划中的未来扩展奠定基础。

定义领域类

与其他与PlaneFinder交互的项目一样,我创建了一个Aircraft领域类作为主要的(数据)焦点。以下是Aircraft Positions应用程序的Aircraft领域类结构:

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Aircraft {
    @Id
    private Long id;
    private String callsign, squawk, reg, flightno, route, type, category;

    private int altitude, heading, speed;
    @JsonProperty("vert_rate")
    private int vertRate;
    @JsonProperty("selected_altitude")
    private int selectedAltitude;

    private double lat, lon, barometer;
    @JsonProperty("polar_distance")
    private double polarDistance;
    @JsonProperty("polar_bearing")
    private double polarBearing;

    @JsonProperty("is_adsb")
    private boolean isADSB;
    @JsonProperty("is_on_ground")
    private boolean isOnGround;

    @JsonProperty("last_seen_time")
    private Instant lastSeenTime;
    @JsonProperty("pos_update_time")
    private Instant posUpdateTime;
    @JsonProperty("bds40_seen_time")
    private Instant bds40SeenTime;
}

此领域类使用 JPA 定义,以 H2 作为底层符合 JPA 标准的数据库,并利用 Lombok 创建数据类,其中构造函数具有零参数和所有参数,每个成员变量都有一个。

创建存储库接口

接下来,我定义所需的存储库接口,扩展 Spring Data 的CrudRepository,并提供要存储的对象类型及其键:在这种情况下是AircraftLong

public interface AircraftRepository extends CrudRepository<Aircraft, Long> {}

使用模型和控制器处理工作

我已经使用 Aircraft 领域类定义了模型背后的数据;现在是将其整合到 Model 中并通过 Controller 公开它的时候了。

正如在第三章中讨论的那样,@RestController 是一个便利的标记,它将 @Controller@ResponseBody 结合在一起,形成一个描述性的注解,将格式化的响应返回为 JavaScript 对象表示(JSON)或其他数据导向的格式。这导致方法的对象/可迭代返回值成为网页请求的整个响应体,而不是作为 Model 的一部分返回。@RestController 允许创建 API,这是一个特殊但非常常见的用例。

现在的目标是创建一个包括用户界面的应用程序,@Controller 可以实现这一点。在 @Controller 类中,每个使用 @RequestMapping 或其专用别名之一(如 @GetMapping)注释的方法将返回一个 String 值,该值对应于模板文件的名称,但不包括其扩展名。例如,Thymeleaf 文件的扩展名是 .html,因此如果一个 @Controller 类的 @GetMapping 方法返回 String “myfavoritepage”,则 Thymeleaf 模板引擎将使用 myfavoritepage.html 模板来生成并返回生成的页面给用户的浏览器。

注意

视图技术模板默认放置在项目的 src/main/resources/templates 目录下;除非通过应用程序属性或编程手段进行覆盖,否则模板引擎将在此处查找它们。

回到控制器,我创建一个名为 PositionController 的类如下所示:

@RequiredArgsConstructor
@Controller
public class PositionController {
    @NonNull
    private final AircraftRepository repository;
    private WebClient client =
            WebClient.create("http://localhost:7634/aircraft");

    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        repository.deleteAll();

        client.get()
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(plane -> !plane.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        model.addAttribute("currentPositions", repository.findAll());
        return "positions";
    }
}

这个控制器看起来与之前的迭代非常相似,但有几个关键区别。首先,当然是前面讨论过的 @Controller 注解,而不是 @RestController。其次,getCurrentAircraftPositions() 方法具有自动装配的参数:Model model。这个参数是模板引擎使用的 Model bean,用于访问应用程序组件的数据和操作——一旦我们将这些组件作为属性添加到 Model 中。第三个区别是方法的返回类型是 String 而不是类类型,并且实际的返回语句是模板的名称(不包含 .html 扩展名)。

注意

在复杂的领域/应用程序中,我更喜欢通过创建独立的 @Service@Controller 类来更好地分离关注点。在这个例子中,只有一个方法访问单个存储库,所以我将所有的功能都放在 Controller 内部,用于填充底层数据、填充 Model 并将其传递给适当的 View

创建必需的视图文件

作为这一章及未来章节的基础,我创建了一个普通的 HTML 文件和一个模板文件。

由于我想向所有访问者显示一个纯 HTML 页面,并且由于此页面不需要模板支持,因此我直接将index.html放在项目的src/main/resources/static目录中:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Retrieve Aircraft Position Report</title>
</head>
<body>
    <p><a href="/aircraft">Click here</a>
        to retrieve current aircraft positions in range of receiver.</p>
</body>
</html>

对于动态内容,我创建一个模板文件,向否则普通的 HTML 文件添加了 Thymeleaf 标签的 XML 命名空间,然后使用这些标签作为 Thymeleaf 模板引擎的内容注入指南,如以下positions.html文件所示。为了指定这是一个由引擎处理的模板文件,我将其放在项目目录src/main/resources/templates中:

<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Position Report</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<div class="positionlist" th:unless="${#lists.isEmpty(currentPositions)}">

    <h2>Current Aircraft Positions</h2>

    <table>
        <thead>
        <tr>
            <th>Call Sign</th>
            <th>Squawk</th>
            <th>AC Reg</th>
            <th>Flight #</th>
            <th>Route</th>
            <th>AC Type</th>
            <th>Altitude</th>
            <th>Heading</th>
            <th>Speed</th>
            <th>Vert Rate</th>
            <th>Latitude</th>
            <th>Longitude</th>
            <th>Last Seen</th>
            <th></th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="ac : ${currentPositions}">
            <td th:text="${ac.callsign}"></td>
            <td th:text="${ac.squawk}"></td>
            <td th:text="${ac.reg}"></td>
            <td th:text="${ac.flightno}"></td>
            <td th:text="${ac.route}"></td>
            <td th:text="${ac.type}"></td>
            <td th:text="${ac.altitude}"></td>
            <td th:text="${ac.heading}"></td>
            <td th:text="${ac.speed}"></td>
            <td th:text="${ac.vertRate}"></td>
            <td th:text="${ac.lat}"></td>
            <td th:text="${ac.lon}"></td>
            <td th:text="${ac.lastSeenTime}"></td>
        </tr>
        </tbody>
    </table>
</div>
</body>
</html>

对于飞机位置报告页面,我将显示的信息减少到几个特别重要和感兴趣的元素。 positions.html Thymeleaf 模板中有几个值得注意的地方:

首先,如前所述,我使用以下行将 Thymeleaf 标签添加到 XML 命名空间,并使用th前缀:

<html lang="en" >

在定义将显示当前飞机位置的division时,我指示positionList部分仅在数据存在时显示;如果Model中的currentPositions元素为空,则简单地省略整个division

<div class="positionlist" th:unless="${#lists.isEmpty(currentPositions)}">

最后,我使用标准 HTML 表格标记定义表格本身、表头行及其内容。对于表格主体,我使用 Thymeleaf 的each来迭代所有currentPositions并使用 Thymeleaf 的text标签填充每行的列,并通过“${object.property}”变量表达式语法引用每个位置对象的属性。至此,应用程序已准备好进行测试。

结果

使用PlaneFinder服务运行时,我从 IDE 执行Aircraft Positions应用程序。一旦成功启动,我打开一个浏览器选项卡,在地址栏中输入localhost:8080并按回车键。 图 7-1 显示了生成的页面。

sbur 0701

图 7-1。飞机位置应用程序(非常简单)的着陆页面

从这里,我点击点击这里链接以进入飞机位置报告页面,如图 7-1 所示。

sbur 0702

图 7-2。飞机位置报告页面

刷新页面将重新查询PlaneFinder并根据需求更新报告的当前数据。

一个令人耳目一新的花哨

能够请求当前区域内飞机的列表以及它们的确切位置是一件有用的事情。但是,如果一个人愿意,手动刷新页面也可能变得相当乏味,并导致错过很感兴趣的数据。要将定时刷新功能添加到飞机位置报告模板中,只需向页面body添加类似以下的 JavaScript 函数,并指定页面刷新率(以毫秒为单位):

<script type="text/javascript">
    window.onload = setupRefresh;

    function setupRefresh() {
        setTimeout("refreshPage();", 5000); // refresh rate in milliseconds
    }

    function refreshPage() {
        window.location = location.href;
    }
</script>

Thymeleaf 模板引擎将此代码不加修改地传递到生成的页面,并且用户的浏览器以指定的刷新率执行脚本。这并不是最优雅的解决方案,但对于简单的用例,它能够胜任。

传递消息

当用例要求更加严格时,可能需要更复杂的解决方案。前面的代码确实提供了反映最新可用位置数据的动态更新,但其中可能存在的其他问题包括定期请求更新数据可能会有些啰嗦。如果几个客户端不断地请求和接收更新,网络流量可能会相当大。

为了同时满足更复杂的用例并解决网络需求,有助于改变视角:从拉模型转换为推模型,或两者的某种组合。

注意

本节和接下来的内容探讨了向推送模型迈出的两个不同而逐步的步骤,最终从PlaneFinder服务向外推进到一个完全的基于推送的模型。用例将指出(或规定)可能支持其中一种方法或完全不同的其他方法的条件。我将继续在随后的章节中探索和展示额外的替代方案,敬请关注。

消息平台旨在有效地接受、路由和传递应用程序之间的消息。示例包括RabbitMQApache Kafka以及许多其他提供的产品,无论是开源的还是商业的。Spring Boot 和 Spring 生态系统提供了几种不同的选项来利用消息管道,但我最喜欢的是 Spring Cloud Stream。

Spring Cloud Stream 提升了开发者的抽象级别,同时通过应用程序属性、bean 和直接配置提供对支持的平台独特属性的访问。绑定器形成了流平台驱动程序与 Spring Cloud Stream(SCSt)之间的连接,使开发人员能够专注于关键任务——发送、路由和接收消息——这些任务的概念不管底层管道如何都不会有所不同。

提升 PlaneFinder 的能力

首要任务是重构PlaneFinder服务,使用 Spring Cloud Stream 发布消息,供Aircraft Positions(以及任何其他适用的)应用程序使用。

必需的依赖项

我将以下依赖项添加到PlaneFinderpom.xml Maven 构建文件中:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-kafka</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-rabbit</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

需要注意的第二个依赖项实际上是第二个列出的依赖项:spring-cloud-stream。这是 Spring Cloud Stream 的代码依赖,但它不能单独完成任务。如前所述,SCSt 使用绑定器无缝地启用其强大的抽象与各种流平台驱动程序的工作。甚至从 Spring Initializr 访问的 Spring Cloud Stream 入口处也有一个有用的提醒,以此作为提示:

用于构建与共享消息系统连接的高度可伸缩的事件驱动微服务框架(需要绑定器,例如 Apache Kafka、RabbitMQ 或 Solace PubSub+)

要使 Spring Cloud Stream 与消息平台配合工作,它需要一个消息平台驱动程序和与之配套的 binder。在前面的示例中,我包含了一个 RabbitMQ 的 binder+driver 组合 一个 Apache Kafka 的 binder+driver 组合。

提示

如果只包含一个 binder+driver 组合—例如 RabbitMQ—Spring Boot 的自动配置可以明确确定您的应用程序应支持与 RabbitMQ 实例及相关的 exchangesqueues 进行通信,并创建适当的支持 bean,而无需开发人员额外的工作。包含多组 binder+driver 需要我们指定要使用哪一个,但它也允许我们在运行时动态在所有包含的平台之间切换,而不需要更改经过测试和部署的应用程序。这是一种非常强大和有用的功能。

pom.xml 文件需要两个更改。首先是通过将以下行添加到 <properties></properties> 部分来指示要使用的 Spring Cloud 项目级版本:

<spring-cloud.version>2020.0.0-M5</spring-cloud.version>

其次是提供有关 Spring Cloud BOM(Bill of Materials)的指导,从中构建系统可以确定任何在此项目中使用的 Spring Cloud 组件(在本例中为 Spring Cloud Stream)的版本:

<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>
注意

Spring 组件项目的版本经常更新。确定与当前版本的 Spring Boot 经过测试的正确同步版本的简单方法是使用 Spring Initializr。选择所需的依赖项并单击“Explore CTRL+SPACE”按钮将显示具有相应元素和版本的构建文件。

刷新项目的依赖项后,转到代码。

提供飞机位置

由于 PlaneFinder 的现有结构和 Spring Cloud Stream 的简洁、功能性方法,只需要一个小的类来将当前飞机位置发布到 RabbitMQ 以供其他应用程序使用:

@AllArgsConstructor
@Configuration
public class PositionReporter {
    private final PlaneFinderService pfService;

    @Bean
    Supplier<Iterable<Aircraft>> reportPositions() {
        return () -> {
            try {
                return pfService.getAircraft();
            } catch (IOException e) {
                e.printStackTrace();
            }
            return List.of();
        };
    }
}

由于 PlaneFinder 对上游无线电设备的每次轮询会产生当前范围内的飞机位置列表,因此 PlaneFinder 服务通过调用 PlaneFinderServicegetAircraft() 方法创建由 Iterable<Aircraft> 中的 1+ 架飞机组成的消息。 一种观点是,Supplier 每秒钟默认调用一次(可通过应用程序属性重写),以及一些必需/可选的应用程序属性会通知 Spring Boot 的自动配置并启动工作。

应用程序属性

只需要一个属性,尽管其他属性也很有帮助。以下是更新后的 PlaneFinderapplication.properties 文件的内容:

server.port=7634

spring.cloud.stream.bindings.reportPositions-out-0.destination=aircraftpositions
spring.cloud.stream.bindings.reportPositions-out-0.binder=rabbit

server.port 仍然来自第一个版本,并指示应用程序应在端口 7634 上监听。

Spring Cloud Stream 的功能 API 依赖于最小的属性配置(作为基线),以启用其功能。Supplier 只有输出通道,因为它只产生消息。Consumer 只有输入通道,因为它只消费消息。Function 既有输入通道也有输出通道,这是因为它用于将一种东西转换为另一种东西。

每个绑定使用接口(SupplierFunctionConsumer)的 bean 方法名称作为通道名称,以及 inout 和从 07 的通道编号。一旦以 <method>-<in|out>-n 的形式连接,绑定属性就可以为通道定义。

对于此用例唯一需要的属性是 destination,即使如此也是为了方便。指定 destination 名称会导致 RabbitMQ 创建一个名为 aircraftpositions 的交换。

由于我在项目依赖项中包含了 RabbitMQ 和 Kafka 的绑定器和驱动程序,我必须指定应用程序应该使用哪个绑定器。对于这个示例,我选择 rabbit

定义了所有必需和期望的应用程序属性后,PlaneFinder 准备每秒向 RabbitMQ 发布当前飞机位置,以供希望这样做的任何应用程序消费。

扩展飞机位置应用程序

使用 Spring Cloud Stream 将 飞机位置 转换为消费 RabbitMQ 管道消息同样简单。只需对幕后的工作进行少量更改,即可将频繁的 HTTP 请求替换为消息驱动架构。

所需依赖项

就像 PlaneFinder 一样,我将以下依赖项添加到 飞机位置 应用程序的 pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-kafka</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-rabbit</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
注意

正如之前提到的,我为计划中的未来使用包括 RabbitMQ 和 Kafka 的绑定器和驱动程序,但是目前使用案例中只需要 RabbitMQ 集合 — spring-boot-starter-amqpspring-cloud-stream-binder-rabbit — 让 Spring Cloud Stream (spring-cloud-stream) 使用 RabbitMQ。

我还向 pom.xml 添加了两个额外所需的条目。首先,这些内容放在 <properties></properties> 部分,使用 java.version

<spring-cloud.version>2020.0.0-M5</spring-cloud.version>

第二个是 Spring Cloud BOM 信息:

<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>

快速刷新项目的依赖项,然后我们就可以进行下一步了。

消费飞机位置

为了检索和存储列出当前飞机位置的消息,只需要一个额外的小类:

@AllArgsConstructor
@Configuration
public class PositionRetriever {
    private final AircraftRepository repo;

    @Bean
    Consumer<List<Aircraft>> retrieveAircraftPositions() {
        return acList -> {
            repo.deleteAll();

            repo.saveAll(acList);

            repo.findAll().forEach(System.out::println);
        };
    }
}

PlaneFinder中的PositionReporter对应的PositionRetriever类也是一个@Configuration类,在其中我定义了一个用于 Spring Cloud Stream 的 bean:一个消息的Consumer,每个消息包含一个或多个AircraftList。每次收到消息时,Consumer bean 删除内存中的所有位置,保存所有传入的位置,然后将所有存储的位置打印到控制台进行验证。请注意,打印所有位置到控制台的最后一条语句是可选的;它仅用于在开发应用程序时进行确认。

应用程序属性

为了向应用程序提供连接到传入消息流所需的少数剩余信息,我将以下条目添加到application.properties文件中:

spring.cloud.stream.bindings.retrieveAircraftPositions-in-0.destination=
   aircraftpositions
spring.cloud.stream.bindings.retrieveAircraftPositions-in-0.group=
   aircraftpositions
spring.cloud.stream.bindings.retrieveAircraftPositions-in-0.binder=
   rabbit

PlaneFinder一样,该通道是通过连接以下内容定义的,以连字符(-)分隔:

  • 在本例中的 bean 名称,一个Consumer<T> bean。

  • in,因为消费者只消费,因此只有输入。

  • 一个介于07之间的数字,支持最多八个输入。

destinationbinder属性与PlaneFinder相匹配,因为Aircraft Positions应用程序必须指向与PlaneFinder用作输出相同的输入目标,因此两者必须使用相同的消息平台——在本例中是 RabbitMQ。虽然group属性是新的。

对于任何类型的Consumer(包括Function<T, R>的接收部分),可以指定一个group,但并不是必须的;事实上,包含或省略group形成了特定路由模式的起点。

如果消息消费应用程序没有指定组,RabbitMQ 绑定器会创建一个随机唯一名称,并将其分配给自动删除队列中的消费者。这导致每个生成的队列只能由一个消费者服务。这为什么重要?

每当消息到达 RabbitMQ 交换机时,默认情况下会自动将其复制到所有分配给该交换机的队列中。如果一个交换机有多个队列,同一条消息会被发送到每个队列,这被称为扇出模式,在需要将每条消息传递到多个目的地以满足不同需求时非常有用。

如果应用程序指定了它所属的消费者组,该组名称将用于命名 RabbitMQ 中的底层队列。当多个应用程序指定相同的group属性并因此连接到同一队列时,这些应用程序共同实现竞争消费者模式,即到达指定队列的每条消息只会被一个消费者处理。这允许消费者数量根据消息的变化量进行扩展。

注意

如果需要,还可以使用分区和路由键进行更精细和灵活的路由选项。

指定此应用程序的 group 属性可以实现扩展,如果需要多个实例来跟上消息的到达速度。

与控制器联系

由于 Consumer bean 自动检查并处理消息,因此 PositionController 类及其 getCurrentAircraftPositions() 方法变得大大简洁。

所有对 WebClient 的引用都可以移除,因为现在只需获取存储库的当前内容即可获取当前位置列表。简化后的类现在看起来像这样:

@RequiredArgsConstructor
@Controller
public class PositionController {
    @NonNull
    private final AircraftRepository repository;

    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        model.addAttribute("currentPositions", repository.findAll());
        return "positions";
    }
}

经过这些更改,消息生成器(PlaneFinder 应用程序)和消息消费器(Aircraft Positions 应用程序)现在已经完全完成。

注意

为了使用任何外部消息平台,该平台必须正在运行并且可以被应用程序访问。我使用 Docker 运行本地实例的 RabbitMQ;本书关联的存储库中提供了快速创建和启动/关闭的脚本。

结果

在验证了 RabbitMQ 可访问之后,现在是时候启动应用程序并验证一切是否按预期工作了。

虽然这并不是必须的,但我更喜欢先启动消息消费应用程序,这样它就可以准备好接收消息。在这种情况下,这意味着我从我的 IDE 执行 Aircraft Positions

接下来,我启动了新版本的改进后的 PlaneFinder 应用程序。这会启动消息流向 Aircraft Positions 应用程序,正如 Aircraft Positions 应用程序的控制台中所示。这是令人满意的,但我们也可以将这条成功路径一直追踪到最终用户。

返回浏览器并访问 localhost:8080,我们再次看到登陆页面,选择 点击这里,即可进入位置报告。与以前一样,位置报告会自动刷新并显示当前飞机位置;然而,现在这些位置独立地从 PlaneFinder 后台推送到 Aircraft Positions 应用程序,而无需首先接收它们的 HTTP 请求,这使得架构更接近于完全事件驱动系统。

使用 WebSocket 创建会话

在我们创建的分布式系统的第一次迭代中,用于查询和显示当前飞行器位置的分布式系统完全是基于拉取的。用户从浏览器请求(或使用刷新重新请求)最新的位置,浏览器将请求传递给飞行器位置应用程序,后者又将请求中继给PlaneFinder应用程序。然后依次从一个应用程序返回到下一个应用程序。最后一章将我们的分布式系统的中段替换为事件驱动架构。现在,每当PlaneFinder从上游无线设备检索位置时,它将这些位置推送到流式平台管道,而飞行器位置应用程序则消耗这些位置。然而,最后一英里(或者说最后一公里,如果你愿意)仍然是基于拉取的;更新必须通过浏览器刷新手动或自动请求。

标准的请求-响应语义对许多用例非常有效,但它们在很大程度上缺乏响应端"服务器"独立于任何请求启动传输的能力。有各种解决方法和聪明的方式来满足这个用例——每种方式都有其优缺点,其中一些我在接下来的章节中讨论——但其中一种更多才多艺的选择是 WebSocket。

什么是 WebSocket?

WebSocket 简而言之,是一种全双工通信协议,通过单个 TCP 连接连接两个系统。一旦建立 WebSocket 连接,任何一方都可以向另一方发起传输,指定的服务器应用程序可以维护多个客户端连接,实现低开销的广播和聊天类型的系统。WebSocket 连接是通过使用 HTTP 升级标头从标准 HTTP 连接中形成的,一旦握手完成,用于连接的协议从 HTTP 转变为 WebSocket。

WebSocket 在 2011 年由 IETF 标准化,到目前为止,每个主要浏览器和编程语言都支持它。与 HTTP 请求和响应相比,WebSocket 的开销极低;传输不必在每个传输中识别自己和它们的通信条款,从而将 WebSocket 帧格式化为几个字节。借助其全双工能力,服务器处理多个开放连接的能力比其他选项多,以及低开销,WebSocket 是开发人员工具箱中有用的工具。

重构飞行器位置应用程序

虽然我将飞行器位置应用程序称为单一单元,飞行器位置项目包括后端 Spring Boot+Java 应用程序和前端 HTML+JavaScript 功能。在开发过程中,这两部分通常在单一环境中执行,通常是开发者的计算机。尽管它们作为单个单元构建、测试并部署到生产环境中,但在生产环境中的执行分为以下几个部分:

  • 后端 Spring+Java 代码在云中运行,包括(如果适用)生成最终网页以交付给最终用户的模板引擎。

  • 前端 HTML+JavaScript——静态和/或生成的内容——在最终用户的浏览器中显示和运行,无论该浏览器位于何处。

在这一部分中,我保留了现有功能,并添加了系统自动显示飞机位置的能力,这些位置是通过实时数据流报告的。通过前端和后端应用程序之间建立的 WebSocket 连接,后端应用程序可以自由地将更新推送到最终用户的浏览器,并自动更新显示,无需触发页面刷新。

附加依赖项

要向 Aircraft Positions 应用程序添加 WebSocket 能力,我只需要在其 pom.xml 中添加一个依赖项:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

快速刷新项目的依赖项,我们就可以进行下一步了。

处理 WebSocket 连接和消息

Spring 提供了几种不同的方法来配置和使用 WebSocket,但我建议遵循基于 WebSocketHandler 接口的直接实现的清晰路线。由于需要频繁交换基于文本的、即非二进制信息,因此甚至有一个 TextWebSocketHandler 类。我在这里基于此进行构建:

@RequiredArgsConstructor
@Component
public class WebSocketHandler extends TextWebSocketHandler {
    private final List<WebSocketSession> sessionList = new ArrayList<>();
    @NonNull
    private final AircraftRepository repository;

    public List<WebSocketSession> getSessionList() {
        return sessionList;
    }

    @Override
    public void afterConnectionEstablished(WebSocketSession session)
            throws Exception {
        sessionList.add(session);
        System.out.println("Connection established from " + session.toString() +
            " @ " + Instant.now().toString());
    }

    @Override
    protected void handleTextMessage(WebSocketSession session,
            TextMessage message) throws Exception {
        try {
            System.out.println("Message received: '" +
                message + "', from " + session.toString());

            for (WebSocketSession sessionInList : sessionList) {
                if (sessionInList != session) {
                    sessionInList.sendMessage(message);
                    System.out.println("--> Sending message '"
                        + message + "' to " + sessionInList.toString());
                }
            }
        } catch (Exception e) {
                System.out.println("Exception handling message: " +
            e.getLocalizedMessage());
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session,
            CloseStatus status) throws Exception {
        sessionList.remove(session);
        System.out.println("Connection closed by " + session.toString() +
            " @ " + Instant.now().toString());
    }
}

上述代码实现了 WebSocketHandler 接口的两个方法,即 afterConnectionEstablishedafterConnectionClosed,以维护活动 WebSocketSessionList 和记录连接和断开连接。我还实现了 handleTextMessage,将任何传入的消息广播给所有其他活动会话。这个单一类为后端提供了 WebSocket 能力,一旦从 PlaneFinder 通过 RabbitMQ 接收到飞机位置,就可以激活它。

将飞机位置广播到 WebSocket 连接

在其先前版本中,PositionRetriever 类通过 RabbitMQ 消息接收飞机位置列表,并将它们存储在内存中的 H2 数据库中。现在我通过调用一个新的 sendPositions() 方法来构建它,该方法的目的是使用新添加的 @Autowired WebSocketHandler bean 将最新的飞机位置列表发送给所有连接的 WebSocket 客户端,以取代记录确认的 System.out::println 调用:

@AllArgsConstructor
@Configuration
public class PositionRetriever {
    private final AircraftRepository repository;
    private final WebSocketHandler handler;

    @Bean
    Consumer<List<Aircraft>> retrieveAircraftPositions() {
        return acList -> {
            repository.deleteAll();

            repository.saveAll(acList);

            sendPositions();
        };
    }

    private void sendPositions() {
        if (repository.count() > 0) {
            for (WebSocketSession sessionInList : handler.getSessionList()) {
                try {
                    sessionInList.sendMessage(
                        new TextMessage(repository.findAll().toString())
                    );
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

现在我们已经正确配置了 WebSocket,并且有了一种让后端向连接的 WebSocket 客户端广播飞机位置的方法,即一旦收到新的位置列表,下一步就是提供一种让后端应用程序监听并接受连接请求的方法。通过注册先前创建的 WebSocketHandler,使用 WebSocketConfigurer 接口,并在新的 @Configuration 类上加上 @EnableWebSocket 注解来指示应用程序处理 WebSocket 请求来实现这一点:

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    private final WebSocketHandler handler;

    WebSocketConfig(WebSocketHandler handler) {
        this.handler = handler;
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(handler, "/ws");
    }
}

registerWebSocketHandlers(WebSocketHandlerRegistry registry)方法中,我将之前创建的WebSocketHandler bean 绑定到端点ws://hostname:hostport/ws。 应用程序将在此端点上侦听带有 WebSocket 升级标头的 HTTP 请求,并在收到请求时采取相应措施。

注意

如果您的应用程序启用了 HTTPS,则会使用wss://(WebSocket Secure)代替ws://

后端 WebSocket,前端 WebSocket

后端工作完成后,是时候在前端功能中收集回报了。

为了创建一个简单的示例,演示 WebSocket 如何使后端应用程序能够无需用户及其浏览器提示即可推送更新,我创建了以下文件,其中包含一个 HTML 部分和标签以及几行 JavaScript,并将其放置在项目的src/main/resources/static目录中,与现有的index.html一起:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Aircraft Position Report (Live Updates)</title>
    <script>
        var socket = new WebSocket('ws://' + window.location.host + '/ws');

        socket.onopen = function () {
            console.log(
              'WebSocket connection is open for business, bienvenidos!');
        };

        socket.onmessage = function (message) {
            var text = "";
            var arrAC = message.data.split("Aircraft");
            var ac = "";

            for (i = 1; i < arrAC.length; i++) {
                ac = (arrAC[i].endsWith(", "))
                    ? arrAC[i].substring(0, arrAC[i].length - 2)
                    : arrAC[i]

                text += "Aircraft" + ac + "\n\n";
            }

            document.getElementById("positions").innerText = text;
        };

        socket.onclose = function () {
            console.log('WebSocket connection closed, hasta la próxima!');
        };
    </script>
</head>
<body>
<h1>Current Aircraft Positions</h1>
<div style="border-style: solid; border-width: 2px; margin-top: 15px;
        margin-bottom: 15px; margin-left: 15px; margin-right: 15px;">
    <label id="positions"></label>
</div>
</body>
</html>

尽管此页面很短,但它可以更短。socket.onopensocket.onclose函数定义是可以省略的日志函数,而socket.onmessage几乎肯定可以由具有实际 JavaScript 技能和愿望的人进行重构。 这些是关键位:

  • 在 HTML 底部定义的部门和标签

  • 建立和引用 WebSocket 连接的socket变量

  • 解析飞机位置列表并将重新格式化的输出分配给 HTML“positions”标签的innerTextsocket.onmessage函数

一旦我们重建并执行项目,当然可以直接从浏览器访问wspositions.html页面。 但这是为实际用户创建应用程序的不良方式——除非他们知道其位置并将其手动输入到地址栏中,否则无法访问页面及其功能——并且它对于设置为本示例的扩展来说毫无作用。

暂时保持简单,我在现有的index.html中添加另一行,允许用户导航到wspositions.html WebSocket 驱动的页面,除了现有的页面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Retrieve Aircraft Position Report</title>
</head>
<body>
    <p><a href="/aircraft">Click here</a> to retrieve current aircraft positions
        in range of receiver.</p>
    <p><a href="/wspositions.html">Click here</a> to retrieve a livestream of
        current aircraft positions in range of receiver.</p>
</body>
</html>

前端工作现在已经完成,是时候测试 WebSocket 了。

结果

从 IDE 中,我启动Aircraft Positions应用程序和PlaneFinder。 打开浏览器窗口,我访问localhost:8080的前端应用程序,如图 7-3 所示。

sbur 0703

图 7-3. 飞机位置着陆页面,现在有两个选项

从仍然相当简陋的着陆页面中,选择第二个选项——点击这里以检索接收器范围内当前飞机位置的实时流产生了wspositions.html页面和类似于图 7-4 中所示的结果。

sbur 0704

图 7-4. 通过 WebSocket 进行实时更新的飞机位置报告

将显示的数据库记录格式转换为 JSON 格式是一个微不足道的练习,而动态填充表格以显示从后端应用通过 WebSocket 实时接收到的结果则稍微复杂一些。请参考本书的代码仓库获取示例。

提示

完全可以通过命令行构建和运行PlaneFinderAircraft Positions应用;虽然我偶尔这样做,但对于大多数构建/运行周期,我发现直接在 IDE 中运行(和调试)要快得多。

总结

几乎每个应用程序都必须以某种方式与最终用户或其他应用程序进行交互,这需要有效和高效的交互手段。

本章介绍了视图技术——如 Thymeleaf 等模板语言/标签以及处理它们的引擎——以及 Spring Boot 如何使用它们创建和传递功能给最终用户的浏览器。还介绍了 Spring Boot 如何处理静态内容,如标准 HTML 及无需模板引擎处理即可直接交付的 JavaScript。章节的第一个项目迭代展示了一个完全基于拉取模型的 Thymeleaf 驱动应用,该应用在请求时检索并显示范围内的飞机位置示例。

接下来的章节展示了如何利用 Spring Boot 中的 Spring Cloud Stream 和 RabbitMQ 平台的消息传递功能。PlaneFinder应用程序进行了重构,以便每次从上游设备检索到当前飞机位置列表时都推送一次,并且Aircraft Positions应用程序已修改以接受通过 RabbitMQ 管道实时到达的新飞机位置列表。这将两个应用程序之间基于拉取的模型替换为推送模型,使得Aircraft Positions应用程序的后端功能变成了事件驱动。前端功能仍然需要刷新(手动或硬编码)才能更新显示给用户的结果。

最后,在Aircraft Positions应用的后端和前端组件中实现 WebSocket 连接和处理程序代码,使得 Spring+Java 后端应用能够通过 RabbitMQ 管道从PlaneFinder接收到的飞机位置更新 即时推送。这些位置更新会在简单的 HTML+JavaScript 页面中实时显示,并且无需用户或其浏览器发出更新请求,展示了 WebSocket 双向通信的特性,以及其低通信开销和无需请求-响应模式(或其变通方式)。

代码检出检查

想要完整的章节代码,请查看代码仓库中的chapter7end分支。

下一章介绍了响应式编程,并描述了 Spring 如何引领开发和推动多种工具和技术的进展,使其成为多种用例中最佳解决方案之一。具体而言,我将演示如何使用 Spring Boot 和 Project Reactor 来驱动数据库访问,将响应式类型集成到像 Thymeleaf 这样的视图技术中,并将进程间通信提升到意想不到的新水平。

第八章:使用 Project Reactor 和 Spring WebFlux 进行响应式编程

本章介绍了响应式编程,讨论了它的起源和存在的原因,并展示了 Spring 如何引领开发和推进众多工具和技术的发展,使其成为多种使用情况下最佳解决方案之一。具体来说,我展示了如何使用 Spring Boot 和 Project Reactor 驱动对 SQL 和 NoSQL 数据库的数据库访问,将响应式类型与 Thymeleaf 等视图技术集成,并使用 RSocket 将进程间通信提升到意想不到的新水平。

代码检出检查

请查看代码库中的 chapter8begin 分支开始。

响应式编程简介

虽然一本完整的关于响应式编程的论述可以——并且已经,以及将会——占据一整本书,但理解为什么它是一个如此重要的概念是至关重要的。

在典型的服务中,每个请求都会创建一个线程来处理。每个线程都需要资源,因此应用程序能够管理的线程数量是有限的。以一个简化的例子来说,如果一个应用程序可以服务 200 个线程,那么该应用程序可以同时接受来自最多 200 个独立客户端的请求,但不多。任何额外尝试连接服务的请求必须等待线程变得可用。

对于连接的 200 个客户端的性能可能满足要求,也可能不满足,这取决于多种因素。毋庸置疑的是,对于客户端应用程序发起的第 201 个及更多的并发请求,由于服务在等待可用线程时会发生阻塞,响应时间可能会显著恶化。这种可扩展性的硬性停止可以在没有警告和简单解决方案的情况下从非问题变为危机,并且像传统的“投入更多实例来解决问题”的解决方法引入了压力缓解和需要解决的新问题。响应式编程的出现就是为了解决这一可扩展性危机。

响应式宣言指出,响应式系统是:

  • 响应的

  • 弹性的

  • 弹性的

  • 消息驱动的

简而言之,响应式系统的四个关键点结合在一起(在宏观层面上)形成了一个最大程度可用、可扩展和高性能的系统,有效地执行任务所需的最少资源。

从系统层面上说,即多个应用程序/服务共同工作以满足各种使用情况,我们可能注意到大多数挑战涉及应用程序之间的通信:一个应用程序响应另一个应用程序,请求到达时应用程序/服务的可用性,服务根据需求扩展或缩减,一个服务通知其他感兴趣的服务有更新/可用信息等。解决应用程序间交互潜在问题可以在很大程度上减轻和/或解决前面提到的可扩展性问题。

这一观察表明,通信是问题的主要潜在来源,因此也是解决问题的最大机会,这导致了响应式流倡议的发起。响应式流(RS)倡议关注服务之间的交互——即流,包括四个关键元素:

  • 应用程序编程接口(API)

  • 规范

  • 实现示例

  • 技术兼容性套件(TCK)

API 仅包含四个接口:

  • Publisher:事物的创建者

  • Subscriber:事物的消费者

  • Subscription:发布者和订阅者之间的合同

  • Processor:同时包括 Subscriber 和 Publisher,用于接收、转换和分发事物

这种精简的简洁性至关重要,同样重要的是 API 仅由接口而非实现组成。这允许在不同平台、语言和编程模型之间实现各种互操作的实现。

文本规范详细说明了 API 实现的预期和/或必需行为。例如:

If a Publisher fails it MUST signal an onError.

实现示例对于实现者是有用的辅助工具,提供了在创建特定 RS 实现时使用的参考代码。

或许最关键的部分是技术兼容性套件。 TCK 使实现者能够验证和展示其 RS 实现(或其他人的实现)与规范的兼容性水平及当前存在的任何缺陷。知识就是力量,识别出与规范不完全兼容的任何问题可以加速解决,同时向当前库使用者提供警告,直到问题得到解决。

Project Reactor

虽然 JVM 有几种可用的响应式流实现,但是 Project Reactor 是其中最活跃、最先进和性能最优的之一。 Reactor 已被全球许多小型组织和全球科技巨头开发和部署的图书馆、API 和应用程序采纳,并提供了许多关键项目的基础,包括 Spring 的 WebFlux 响应式 Web 能力和 Spring Data 的多个开源和商业数据库的响应式数据库访问。 Reactor 还允许从堆栈顶部到底部以及侧面创建端到端响应式管道,增加了开发和采纳的强劲动力。这是一个百分之百的解决方案。

这为什么重要?

从堆栈的顶部到底部,从最终用户到最低层计算资源,每个交互都提供了一个潜在的粘附点。如果用户的浏览器与后端应用程序之间的交互是非阻塞的,但是应用程序必须等待与数据库的阻塞交互,那么结果就是一个阻塞系统。与应用程序之间的通信情况相同;如果用户的浏览器与后端服务 A 通信,但是服务 A 阻塞等待来自服务 B 的响应,用户获得了什么?可能很少,甚至可能什么都没有。

开发人员通常可以看到切换到 Reactive Streams 为他们和他们的系统带来的巨大潜力。与此相对应的是,相对于命令式编程构造和工具的相对新颖性,以及这种变化所需要的思维方式的变化,可能需要开发人员进行调整和更多的工作,至少在短期内是这样。只要所需的努力明显超过了可扩展性的好处,以及在整体系统中应用反应流的广度和深度,这仍然是一个容易的决定。在系统的所有应用程序中具有反应式管道是两个方面的乘数。

Project Reactor 对 Reactive Streams 的实现简洁而简单,构建在 Java 和 Spring 开发人员已经熟悉的概念之上。类似于 Java 8+的 Stream API,Reactor 最好通过声明性的、链式的操作符使用,通常与 lambda 一起使用。与更为程序化、命令式的代码相比,它首先感觉有些不同,然后相当优雅。熟悉Stream会加速适应和欣赏。

Reactor 将反应流Publisher的概念进行了特殊化,提供了类似于命令式 Java 的构造。与为需要反应流的所有东西使用通用的Publisher不同——将其视为按需的、动态的Iterable——Project Reactor 定义了两种类型的Publisher

Mono:: 发出 0 或 1 个元素 Flux:: 发出 0 到n个元素,一个定义的数量或无限的

这与命令式构造完美地契合。例如,在标准 Java 中,一个方法可以返回类型为 T 的对象或Iterable<T>。使用 Reactor,同样的方法将返回一个Mono<T>或一个Flux<T>——一个对象或可能很多,或者在反应性代码的情况下,这些对象的Publisher

Reactor 也非常自然地适用于 Spring 的观点。根据用例,从阻塞到非阻塞代码的转换可能就像改变项目依赖项和一些方法返回值一样简单,如前所示。本章的示例演示了如何做到这一点,以及向外扩展——向上、向下和横向——从单个反应性应用程序转移到反应性系统,包括反应性数据库访问,以实现最大的收益。

比较 Tomcat 和 Netty

在 Spring Boot 的命令式世界中,Tomcat 是默认的 Servlet 引擎,用于 Web 应用程序,尽管在这个级别上,开发人员还可以选择像 Jetty 和 Undertow 这样的替代方案。作为默认选项,Tomcat 是一个非常合理的选择,因为它经过验证、性能优越,并且 Spring 团队的开发人员已经(仍在)为优化和演进 Tomcat 的代码库做出贡献。它是 Boot 应用程序的出色 Servlet 引擎。

话虽如此,Servlet 规范的许多迭代从根本上是同步的,没有异步能力。Servlet 3.0 开始通过异步请求处理来解决这个问题,但仍然只支持传统的阻塞 I/O。规范的 3.1 版本增加了非阻塞 I/O,使其适用于异步,因此也适用于响应式应用程序。

Spring WebFlux 是 Spring 对应 Spring WebMVC(包名)的响应式实现,通常简称为 Spring MVC 的对应物。Spring WebFlux 基于 Reactor 构建,并使用 Netty 作为默认的网络引擎,就像 Spring MVC 使用 Tomcat 监听和处理请求一样。Netty 是一个经过验证和高性能的异步引擎,Spring 团队的开发人员也为 Netty 做出贡献,以紧密集成 Reactor 并保持 Netty 的功能和性能处于前沿。

就像 Tomcat 一样,您也有选择权。任何 Servlet 3.1 兼容的引擎都可以与 Spring WebFlux 应用程序一起使用,如果您的任务或组织需要的话。然而,Netty 凭借其领先地位和大多数用例的优势,通常是最佳选择。

反应式数据访问

正如之前提到的,最终目标是实现全面的可扩展性和最佳的系统范围吞吐量,这依赖于完全端到端的响应式实现。在最低级别,这取决于数据库访问。

多年来,设计数据库以减少争用和系统性能阻塞的工作已经付出了很多努力。即使在这项令人印象深刻的工作中,许多数据库引擎和驱动程序仍然存在问题,其中包括在没有阻塞请求应用程序的情况下执行操作以及复杂的流控制/反压机制。

分页构造已被用来解决这些约束,但它们并不完美。使用带分页的命令式模型通常需要为每一页发出一个不同范围和/或约束的查询。这需要每次都发出新请求和新响应,而不是像Flux那样的继续操作。类比是从水池中每次舀一杯水(命令式方法)与直接打开水龙头来重新灌满杯子。与“去获取,带回”的命令式操作不同,在响应式情景下,水已经等待流动。

R2DBC 与 H2

在现有版本的 PlaneFinder 中,我使用 Java Persistence API(JPA)和 H2 数据库来存储(在内存中的 H2 实例中)从我监视的本地设备中检索到的飞机位置。JPA 是基于命令规范构建的,因此本质上是阻塞的。看到需要一种非阻塞的响应式方式与 SQL 数据库交互,几位行业领导者和知名人士联手创建和演进了响应式关系数据库连接(R2DBC)项目。

像 JPA 一样,R2DBC 是一个开放的规范,可以与其提供的服务提供者接口(SPI)一起使用,供供应商或其他感兴趣的方进行驱动程序的开发,并为下游开发人员创建客户端库。与 JPA 不同,R2DBC 基于 Project Reactor 的响应式流实现,并且完全响应式和非阻塞。

更新 PlaneFinder

与大多数复杂系统一样,我们目前并不控制整个分布式系统的所有方面和节点。像大多数复杂系统一样,越完全地采纳一种范式,从中可以获得的收益就越多。我会从通信链的起点尽可能接近的地方开始这段“响应式之旅”:在 PlaneFinder 服务中。

重构 PlaneFinder 以使用响应式流 Publisher 类型,如 MonoFlux,是第一步。我会继续使用现有的 H2 数据库,但为了“响应式化”它,需要删除 JPA 项目依赖,并将其替换为 R2DBC 库。我将更新 PlaneFinder 的 pom.xml Maven 构建文件如下:

<!--	Comment out or remove this 	-->
<!--<dependency>-->
<!--    <groupId>org.springframework.boot</groupId>-->
<!--	<artifactId>spring-boot-starter-data-jpa</artifactId>-->
<!--</dependency>-->

<!--	Add this  	    		    -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>

<!--	Add this too  	    	    -->
<dependency>
    <groupId>io.r2dbc</groupId>
    <artifactId>r2dbc-h2</artifactId>
    <scope>runtime</scope>
</dependency>

PlaneRepository 接口必须更新为扩展 ReactiveCrudRepository 接口,而不是其阻塞的对应项 CrudRepository。这个简单的更新如下所示:

public interface PlaneRepository
    extends ReactiveCrudRepository<Aircraft, String> {}

PlaneRepository 的更改会向外扩散,这自然而然地导致下一个停靠点,即 PlaneFinderService 类,其中 getAircraft() 方法返回 PlaneRepository::saveAll 的结果(当找到飞机时),或者 saveSamplePositions() 方法的结果(否则)。将返回值从阻塞的 Iterable<Aircraft> 替换为 Flux<Aircraft>,用于 getAircraft()saveSamplePositions() 方法再次正确指定方法返回值。

public Flux<Aircraft> getAircraft() {
    ...
}

private Flux<Aircraft> saveSamplePositions() {
    ...
}

由于 PlaneController 类的方法 getCurrentAircraft() 调用 PlaneFinderService::getAircraft,现在它返回 Flux<Aircraft>。这需要对 PlaneController::getCurrentAircraft 的签名进行更改如下:

public Flux<Aircraft> getCurrentAircraft() throws IOException {
    ...
}

使用 H2 与 JPA 是一个相当成熟的事务;涉及的规范、相关的 API 和库已经开发了大约十年。R2DBC 是一个相对较新的开发,虽然支持正在迅速扩展,但在 Spring Data JPA 对 H2 的支持中,还有一些功能尚未实现。这并不会增加太多负担,但在选择使用关系数据库(如 H2)时,需要记住这一点,要以响应式的方式进行。

目前,要使用 H2 与 R2DBC,必须为应用程序创建和配置一个ConnectionFactoryInitializer bean。实际上,配置只需要两个步骤:

  • 将连接工厂设置为(已自动配置的)ConnectionFactory bean,作为参数注入

  • 配置数据库“填充器”以执行一个或多个脚本,以初始化或重新初始化数据库,如所需。

请记住,使用 Spring Data JPA 与 H2 时,使用相关的@Entity类来在 H2 数据库中创建相应的表。当使用 H2 与 R2DBC 时,通过标准的 SQL DDL(数据定义语言)脚本手动完成此步骤。

DROP TABLE IF EXISTS aircraft;

CREATE TABLE aircraft (id BIGINT auto_increment primary key,
callsign VARCHAR(7), squawk VARCHAR(4), reg VARCHAR(8), flightno VARCHAR(10),
route VARCHAR(30), type VARCHAR(4), category VARCHAR(2),
altitude INT, heading INT, speed INT, vert_rate INT, selected_altitude INT,
lat DOUBLE, lon DOUBLE, barometer DOUBLE, polar_distance DOUBLE,
polar_bearing DOUBLE, is_adsb BOOLEAN, is_on_ground BOOLEAN,
last_seen_time TIMESTAMP, pos_update_time TIMESTAMP, bds40_seen_time TIMESTAMP);
注意

这是一个额外的步骤,但并非没有先例。许多 SQL 数据库在与 Spring Data JPA 结合使用时都需要这一步;H2 是个例外。

下面是DbConxInit或数据库连接初始化器类的代码。需要的 bean 创建方法是第一个——initializer()——产生所需的ConnectionFactoryInitializer bean。第二个方法生成一个CommandLineRunner bean,一旦类被配置,就会被执行。CommandLineRunner 是一个具有单个抽象方法 run() 的函数接口。因此,我提供了一个 lambda 作为其实现,用一个Aircraft填充(然后列出)PlaneRepository的内容。目前,我已经注释掉了init()方法的@Bean注解,因此该方法从未被调用,CommandLineRunner bean 从未被生成,并且示例记录从未被存储:

import io.r2dbc.spi.ConnectionFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.r2dbc.connection.init.ConnectionFactoryInitializer;
import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator;

@Configuration
public class DbConxInit {
    @Bean
    public ConnectionFactoryInitializer
            initializer(@Qualifier("connectionFactory")
            ConnectionFactory connectionFactory) {
        ConnectionFactoryInitializer initializer =
            new ConnectionFactoryInitializer();
        initializer.setConnectionFactory(connectionFactory);
        initializer.setDatabasePopulator(
            new ResourceDatabasePopulator(new ClassPathResource("schema.sql"))
        );
        return initializer;
    }

//    @Bean // Uncomment @Bean annotation to add sample data
    public CommandLineRunner init(PlaneRepository repo) {
        return args -> {
            repo.save(new Aircraft("SAL001", "N12345", "SAL001", "LJ",
                    30000, 30, 300,
                    38.7209228, -90.4107416))
                .thenMany(repo.findAll())
                    .subscribe(System.out::println);
        };
    }
}

CommandLineRunner lambda 需要一些解释。

结构本身是一个典型的 lambda 表达式,如 x -> { <在此执行的代码> },但其中包含的代码具有一些有趣的 Reactive Streams 特定特性。

第一个声明的操作是repo::save,它保存提供的内容——在本例中是一个新的Aircraft对象——并返回一个Mono<Aircraft>。可以简单地subscribe()到这个结果并打印日志/输出来验证。但是养成的一个好习惯是保存所有所需的示例数据,然后查询存储库以生成所有记录。这样做允许完全验证此时表的最终状态,并应显示所有记录。

请记住,响应式代码不会阻塞,那么我们如何确保所有先前的操作在继续之前都已完成呢?在这种情况下,我们如何确保在尝试检索所有记录之前所有记录都已保存?在 Project Reactor 中,有一些操作符等待完成信号,然后继续链中的下一个函数。then()操作符等待一个Mono作为输入,然后接受另一个Mono继续进行。在之前的示例中显示的thenMany()操作符等待任何上游Publisher的完成,并继续播放一个新的Flux。在生成CommandLineRunner bean 的init方法中,repo.findAll()生成一个Flux<Aircraft>,如预期地填充了账单。

最后,我订阅来自repo.findAll()Flux<Aircraft>输出,并将结果打印到控制台。不需要记录结果,事实上,一个简单的subscribe()就能满足启动数据流的要求。但是为什么需要订阅呢?

除了少数例外情况外, Reactive Streams Publisher 冷发布者,这意味着如果没有订阅者,它们不会执行任何工作或消耗任何资源。这最大化了效率和可伸缩性,这是完全合理的,但对于刚接触响应式编程的人来说,这也提供了一个常见的陷阱。如果不是将Publisher返回给调用代码进行订阅和使用,务必添加subscribe()来激活生成Publisher或操作链。

最后,由于 JPA 和 R2DBC 以及它们支持的 H2 代码之间的差异,需要对领域类Aircraft进行一些更改。 JPA 使用的@Entity注解不再需要,主键关联成员变量id@GeneratedValue注解现在也不再需要。从 PlaneFinder 从 JPA 迁移到使用 H2 的 R2DBC 时,移除这两个及其关联的导入语句是唯一需要的更改。

为了适应之前显示的CommandLineRunner bean(如果需要样本数据),以及其字段限制的构造函数调用,我在Aircraft中创建了一个额外的构造函数来匹配。请注意,只有在您希望创建一个不提供所有参数的Aircraft实例时,才需要这样做,如构造函数 Lombok 基于@AllArgsConstructor注解所要求的。请注意,我从这个有限参数构造函数调用所有参数构造函数:

    public Aircraft(String callsign, String reg, String flightno, String type,
                    int altitude, int heading, int speed,
                    double lat, double lon) {

        this(null, callsign, "sqwk", reg, flightno, "route", type, "ct",
                altitude, heading, speed, 0, 0,
                lat, lon, 0D, 0D, 0D,
                false, true,
                Instant.now(), Instant.now(), Instant.now());
    }

现在是时候验证我们的工作了。

从 IDE 中启动 PlaneFinder 应用程序后,我回到终端窗口中的 HTTPie 来测试更新后的代码:

mheckler-a01 :: OReilly/code » http -b :7634/aircraft
[
    {
        "altitude": 37000,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": "EDV5123",
        "category": "A3",
        "flightno": "DL5123",
        "heading": 131,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-09-19T21:40:56Z",
        "lat": 38.461505,
        "lon": -89.896606,
        "polar_bearing": 156.187542,
        "polar_distance": 32.208164,
        "pos_update_time": "2020-09-19T21:40:56Z",
        "reg": "N582CA",
        "route": "DSM-ATL",
        "selected_altitude": 0,
        "speed": 474,
        "squawk": "3644",
        "type": "CRJ9",
        "vert_rate": -64
    },
    {
        "altitude": 38000,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": null,
        "category": "A4",
        "flightno": "FX3711",
        "heading": 260,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-09-19T21:40:57Z",
        "lat": 39.348558,
        "lon": -90.330383,
        "polar_bearing": 342.006425,
        "polar_distance": 24.839372,
        "pos_update_time": "2020-09-19T21:39:50Z",
        "reg": "N924FD",
        "route": "IND-PHX",
        "selected_altitude": 0,
        "speed": 424,
        "squawk": null,
        "type": "B752",
        "vert_rate": 0
    },
    {
        "altitude": 35000,
        "barometer": 1012.8,
        "bds40_seen_time": "2020-09-19T21:41:11Z",
        "callsign": "JIA5304",
        "category": "A3",
        "flightno": "AA5304",
        "heading": 112,
        "id": 3,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-09-19T21:41:12Z",
        "lat": 38.759811,
        "lon": -90.173632,
        "polar_bearing": 179.833023,
        "polar_distance": 11.568717,
        "pos_update_time": "2020-09-19T21:41:11Z",
        "reg": "N563NN",
        "route": "CLT-RAP-CLT",
        "selected_altitude": 35008,
        "speed": 521,
        "squawk": "6506",
        "type": "CRJ9",
        "vert_rate": 0
    }
]

确认重构后的响应式 PlaneFinder 正常工作后,我们现在可以转向 Aircraft Positions 应用程序。

更新 Aircraft Positions 应用程序

目前aircraft-positions项目使用 Spring Data JPA 和 H2,就像当它是一个阻塞应用程序时的 PlaneFinder 一样。虽然我可以将 Aircraft Positions 更新为使用 R2DBC 和 H2,就像 PlaneFinder 现在所做的那样,但这需要对aircraft-positions项目进行重构,为了探索其他反应式数据库解决方案,这是一个绝佳的机会。

MongoDB 经常处于数据库创新的前沿,事实上,它是第一个为其同名数据库开发完全反应式驱动程序的任何类型的数据库提供商之一。使用 Spring Data 和 MongoDB 开发应用几乎没有摩擦,这反映了其反应式流支持的成熟性。对于飞行器位置的反应式重构,MongoDB 是一个自然的选择。

对构建文件(在本例中为pom.xml)进行一些更改是有必要的。首先,我删除了 Spring MVC、Spring Data JPA 和 H2 的不必要的依赖项:

  • spring-boot-starter-web

  • spring-boot-starter-data-jpa

  • h2

接下来,我为未来的反应式版本添加以下依赖项:

  • spring-boot-starter-data-mongodb-reactive

  • de.flapdoodle.embed.mongo

  • reactor-test

注意

由于WebClientspring-boot-starter-webflux已经是一个依赖项,所以不需要额外添加。

正如第六章中所述,我将在此示例中使用嵌入式 MongoDB。由于嵌入式 MongoDB 通常仅用于测试,因此通常包括一个“测试”的范围;由于我在应用程序执行期间使用此功能,因此我会从构建文件中省略或删除该范围限定符。更新后的 Maven pom.xml 依赖关系如下所示:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintage</groupId>
                <artifactId>junit-vintage-engine</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>de.flapdoodle.embed</groupId>
        <artifactId>de.flapdoodle.embed.mongo</artifactId>
    </dependency>
    <dependency>
        <groupId>io.projectreactor</groupId>
        <artifactId>reactor-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

通过命令行或 IDE 快速刷新依赖项,我们就可以开始重构了。

我首先从对AircraftRepository接口的非常简单的更改开始,将其更改为扩展阻塞CrudRepositoryReactiveCrudRepository

public interface AircraftRepository extends ReactiveCrudRepository<Aircraft, Long> {}

更新PositionController类是一个相当小的任务,因为WebClient已经使用反应式流Publisher类型进行交流。我定义了一个局部变量Flux<Aircraft> aircraftFlux,然后链式调用所需的声明操作来清除先前检索到的飞行器位置,检索新的位置,将它们转换为Aircraft类的实例,过滤掉没有列出飞行器注册号的位置,并将它们保存到嵌入式 MongoDB 存储库中。然后,我将aircraftFlux变量添加到Model中以供用户界面使用,并返回 Thymeleaf 模板的名称进行渲染:

@RequiredArgsConstructor
@Controller
public class PositionController {
    @NonNull
    private final AircraftRepository repository;
    private WebClient client
        = WebClient.create("http://localhost:7634/aircraft");

    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        Flux<Aircraft> aircraftFlux = repository.deleteAll()
                .thenMany(client.get()
                        .retrieve()
                        .bodyToFlux(Aircraft.class)
                        .filter(plane -> !plane.getReg().isEmpty())
                        .flatMap(repository::save));

        model.addAttribute("currentPositions", aircraftFlux);
        return "positions";
    }
}

最后,需要对领域类Aircraft本身进行一些小的更改。类级别的@Entity注解是 JPA 特定的;MongoDB 使用的相应注解是@Document,表示类的实例将存储为数据库中的文档。此外,先前使用的@Id注解引用了javax.persistence.Id,在没有 JPA 依赖项的情况下消失了。将import javax.persistence.Id;替换为import org.springframework.data.annotation.Id;保留了与 MongoDB 一起使用的表标识符上下文。完整的类文件如下所示以供参考:

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import java.time.Instant;

@Document
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Aircraft {
    @Id
    private Long id;
    private String callsign, squawk, reg, flightno, route, type, category;

    private int altitude, heading, speed;
    @JsonProperty("vert_rate")
    private int vertRate;
    @JsonProperty("selected_altitude")
    private int selectedAltitude;

    private double lat, lon, barometer;
    @JsonProperty("polar_distance")
    private double polarDistance;
    @JsonProperty("polar_bearing")
    private double polarBearing;

    @JsonProperty("is_adsb")
    private boolean isADSB;
    @JsonProperty("is_on_ground")
    private boolean isOnGround;

    @JsonProperty("last_seen_time")
    private Instant lastSeenTime;
    @JsonProperty("pos_update_time")
    private Instant posUpdateTime;
    @JsonProperty("bds40_seen_time")
    private Instant bds40SeenTime;
}

运行 PlaneFinder 和 Aircraft Positions 应用程序后,返回浏览器选项卡并在地址栏中输入http://localhost:8080,加载页面,结果如图 8-1 所示。

sbur 0801

图 8-1. 飞机位置应用程序登陆页面,index.html

点击点击这里链接加载飞机位置报告页面,如图 8-2 所示。

sbur 0802

图 8-2. 飞机位置报告页面

每次定期刷新时,页面将重新查询 PlaneFinder,并根据需要更新报告,但有一个非常重要的区别:供显示的多个飞机位置不再是完全形成的、阻塞的List,而是 Reactive Streams 的Publisher,具体是Flux类型。接下来的部分将进一步讨论这一点,但现在重要的是要意识到这种内容协商/适应是无需开发人员努力的。

响应式 Thymeleaf

如第七章中所述,现在绝大多数前端 Web 应用程序都是使用 HTML 和 JavaScript 开发的。这并不改变使用视图技术/模板来实现其目标的许多生产应用程序的存在;也并不意味着这些技术不继续简单有效地满足一系列要求。在这种情况下,对模板引擎和语言进行适应以适应 Reactive Streams 的情况非常重要。

Thymeleaf 在三个不同的层次上支持 RS,允许开发人员选择最适合其需求的一种。如前所述,可以将后端处理转换为利用响应式流,并让 Reactor 通过Publisher(如MonoFlux)向 Thymeleaf 提供值,而不是Object<T>Iterable<T>。这并不会导致响应式前端,但如果关注的主要是将后端逻辑转换为使用响应式流,以消除阻塞并在服务之间实现流控制,则这是部署支持用户界面应用程序的一种无摩擦入门方式,需要的工作量最少。

Thymeleaf 还支持分块和数据驱动模式,以支持 Spring WebFlux,两者都涉及使用 Server Sent Events 和一些 JavaScript 代码来实现数据向浏览器的提供。虽然这两种模式都是完全有效的,但为了实现所需的结果可能需要大量的 JavaScript,这可能会使权衡倾向于模板化+HTML+JavaScript,而不是 100% HTML+JavaScript 前端逻辑。当然,这个决定在很大程度上取决于需求,并应由负责创建和支持该功能的开发人员来决定。

在上一节中,我演示了如何将后端功能迁移到 RS 构造中,以及 Spring Boot 如何使用 Reactor+Thymeleaf 在前端保持功能,帮助简化阻塞应用系统的转换,并最小化停机时间。这足以满足当前的用例,使我们能够在返回(在即将到来的章节中)扩展前端功能之前,考虑进一步改进后端功能的方法。

RSocket 用于完全响应式的进程间通信

本章中,我已经为使用 Reactive Streams 在不同应用程序之间进行进程间通信奠定了基础。虽然创建的分布式系统确实使用了响应式构造,但系统尚未发挥其潜力。通过使用基于更高级别的基于 HTTP 的传输跨越网络边界会由于请求-响应模型而带来限制,甚至仅仅升级到 WebSocket 也无法解决所有问题。RSocket 的创建是为了灵活且强大地消除进程间通信的不足。

什么是 RSocket?

RSocket 是几个行业领导者和尖端创新者合作的结果,是一个可以在 TCP、WebSocket 和 Aeron 传输机制上使用的高速二进制协议。RSocket 支持四种异步交互模型:

  • 请求-响应

  • 请求-流

  • 火而忘

  • 请求通道(双向流)

RSocket 建立在反应式流范式和 Project Reactor 之上,可以实现完全互联的应用程序系统,同时提供增加灵活性和韧性的机制。一旦两个应用程序/服务之间建立连接,客户端与服务器的区别消失了,它们实际上是对等的。任何一方都可以启动四种交互模型之一,并适应所有用例:

  • 一个 1:1 的交互,其中一方发出请求并从另一方接收响应

  • 一个 1:N 的交互,其中一方发出请求并从另一方接收一系列响应

  • 一个 1:0 的交互,其中一方发出请求

  • 一个完全双向的通道,双方都可以自由地发送请求、响应或任何类型的数据流

正如你所见,RSocket 非常灵活。作为一种性能重点的二进制协议,它也非常快速。此外,RSocket 具有韧性,使得可以重新建立断开的连接,并在通信中自动恢复中断的地方。而且由于 RSocket 建立在 Reactor 之上,使用 RSocket 的开发人员可以真正将单独的应用程序视为完全集成的系统,因为网络边界不再对流量控制施加任何限制。

Spring Boot 以其传说中的自动配置,可以说为 Java 和 Kotlin 开发人员提供了使用 RSocket 的最快、最友好的方式。

把 RSocket 投入使用

目前,PlaneFinder 和 Aircraft Positions 应用都使用基于 HTTP 的传输进行通信。将这两个 Spring Boot 应用程序转换为使用 RSocket 是明显的下一步。

将 PlaneFinder 迁移到 RSocket

首先,我将 RSocket 依赖添加到 PlaneFinder 的构建文件中:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-rsocket</artifactId>
</dependency>

快速进行 Maven 重新导入后,就可以开始重构代码了。

暂时,我会保留 /aircraft 的现有端点,并在 PlaneController 中添加一个 RSocket 端点。为了将 REST 端点和 RSocket 端点放置在同一个类中,我将 @RestController 注解中内置的功能解耦成其组成部分:@Controller@ResponseBody

将类级别的 @RestController 注解替换为 @Controller 意味着对于我们希望直接返回 JSON 对象的任何 REST 端点(例如与 getCurrentAircraft() 方法关联的现有 /aircraft 端点),需要向方法中添加 @ResponseBody。这种看似退步的优势在于,然后可以在同一个 @Controller 类中定义 RSocket 端点,将 PlaneFinder 的入口点和出口点放在一个且仅有一个位置:

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;

import java.io.IOException;
import java.time.Duration;

@Controller
public class PlaneController {
    private final PlaneFinderService pfService;

    public PlaneController(PlaneFinderService pfService) {
        this.pfService = pfService;
    }

    @ResponseBody
    @GetMapping("/aircraft")
    public Flux<Aircraft> getCurrentAircraft() throws IOException {
        return pfService.getAircraft();
    }

    @MessageMapping("acstream")
    public Flux<Aircraft> getCurrentACStream() throws IOException {
        return pfService.getAircraft().concatWith(
                Flux.interval(Duration.ofSeconds(1))
                        .flatMap(l -> pfService.getAircraft()));
    }
}

为了创建一个重复发送飞行器位置的流,首先和随后每一秒的流,我创建了getCurrentACStream()方法,并使用@MessageMapping注解它作为一个 RSocket 端点。请注意,由于 RSocket 映射不像 HTTP 地址/端点那样建立在根路径之上,因此在映射中不需要斜杠(/)。

在定义了端点和服务方法之后,下一步是为 RSocket 指定一个端口来监听连接请求。我在 PlaneFinder 的application.properties文件中执行此操作,为基于 HTTP 的server.port添加了一个用于spring.rsocket.server.port的属性值:

server.port=7634
spring.rsocket.server.port=7635

有了这个单个的 RSocket 服务器端口分配,Spring Boot 就足以配置包含应用程序为 RSocket 服务器的所有必要 bean 并执行所有必要的配置。请注意,虽然 RSocket 连接中涉及的两个应用程序中必须有一个起初充当服务器,但一旦连接建立,客户端(发起连接的应用程序)和服务器(监听连接的应用程序)之间的区别就消失了。

通过这些少量更改,PlaneFinder 现在已准备好使用 RSocket。只需启动应用程序即可准备好接收连接请求。

将飞行器位置迁移到 RSocket

再次,添加 RSocket 的第一步是将 RSocket 依赖项添加到构建文件中——在这种情况下是针对飞行器位置应用程序:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-rsocket</artifactId>
</dependency>

不要忘记在继续之前使用 Maven 重新导入并激活项目中的更改。现在,进入代码部分。

类似于我在 PlaneFinder 中所做的,我重构了PositionController类以创建所有进出口的单一点。用@Controller替换类级别的@RestController注解允许包含 RSocket 端点以及基于 HTTP 的(但在这种情况下是模板驱动的)端点,该端点激活positions.html Thymeleaf 模板。

为了使飞行器位置能够作为 RSocket 客户端运行,我通过构造函数注入一个RSocketRequester.Builder bean 来创建一个RSocketRequesterRSocketRequester.Builder bean 是由 Spring Boot 自动创建的,因为将 RSocket 依赖项添加到项目中。在构造函数中,我使用该构建器通过tcp()方法创建到 PlaneFinder 的 RSocket 服务器的 TCP 连接(在本例中)。

注意

在我需要注入一个(RSocketRequester.Builder)用于创建一个不同对象(RSocketRequester)的 bean 时,我必须创建一个构造函数。现在我有了构造函数,我移除了类级别的@RequiredArgsConstructor和成员变量级别的@NonNull Lombok 注解,简单地将AircraftRepository添加到我编写的构造函数中。无论哪种方式,Spring Boot 都会自动装配该 bean,并将其分配给repository成员变量。

要验证 RSocket 连接是否正常工作并且数据正在流动,我创建了一个基于 HTTP 的端点 /acstream,指定它将作为结果返回一系列服务器发送事件(SSE),并使用 @ResponseBody 注解指示响应将直接包含 JSON 格式化的对象。使用在构造函数中初始化的 RSocketRequester 成员变量,我指定了要匹配 PlaneFinder 中定义的 RSocket 端点的 route,发送了一些 data(可选;在这个特定请求中我没有传递任何有用的数据),并检索从 PlaneFinder 返回的 AircraftFlux

import org.springframework.http.MediaType;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;

@Controller
public class PositionController {
    private final AircraftRepository repository;
    private final RSocketRequester requester;
    private WebClient client =
            WebClient.create("http://localhost:7634/aircraft");

    public PositionController(AircraftRepository repository,
                              RSocketRequester.Builder builder) {
        this.repository = repository;
        this.requester = builder.tcp("localhost", 7635);
    }

    // HTTP endpoint, HTTP requester (previously created)
    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        Flux<Aircraft> aircraftFlux = repository.deleteAll()
                .thenMany(client.get()
                        .retrieve()
                        .bodyToFlux(Aircraft.class)
                        .filter(plane -> !plane.getReg().isEmpty())
                        .flatMap(repository::save));

        model.addAttribute("currentPositions", aircraftFlux);
        return "positions";
    }

    // HTTP endpoint, RSocket client endpoint
    @ResponseBody
    @GetMapping(value = "/acstream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<Aircraft> getCurrentACPositionsStream() {
        return requester.route("acstream")
                .data("Requesting aircraft positions")
                .retrieveFlux(Aircraft.class);
    }
}

要验证 RSocket 连接是否可行,并且 PlaneFinder 是否正在向 Aircraft Positions 应用程序提供数据,我启动了 Aircraft Positions 并返回到终端和 HTTPie,添加了 -S 标志到命令中,以在数据到达时将其作为流进行处理,而不是等待响应体完成。下面是结果的一个示例,由于篇幅限制已编辑:

mheckler-a01 :: ~ » http -S :8080/acstream
HTTP/1.1 200 OK
Content-Type: text/event-stream;charset=UTF-8
transfer-encoding: chunked

data:{"id":1,"callsign":"RPA3427","squawk":"0526","reg":"N723YX","flightno":
"UA3427","route":"IAD-MCI","type":"E75L","category":"A3","altitude":36000,
"heading":290,"speed":403,"lat":39.183929,"lon":-90.72259,"barometer":0.0,
"vert_rate":64,"selected_altitude":0,"polar_distance":29.06486,
"polar_bearing":297.519943,"is_adsb":true,"is_on_ground":false,
"last_seen_time":"2020-09-20T23:58:51Z",
"pos_update_time":"2020-09-20T23:58:49Z","bds40_seen_time":null}

data:{"id":2,"callsign":"EDG76","squawk":"3354","reg":"N776RB","flightno":"",
"route":"TEB-VNY","type":"GLF5","category":"A3","altitude":43000,"heading":256,
"speed":419,"lat":38.884918,"lon":-90.363026,"barometer":0.0,"vert_rate":64,
"selected_altitude":0,"polar_distance":9.699159,"polar_bearing":244.237695,
"is_adsb":true,"is_on_ground":false,"last_seen_time":"2020-09-20T23:59:22Z",
"pos_update_time":"2020-09-20T23:59:14Z","bds40_seen_time":null}

data:{"id":3,"callsign":"EJM604","squawk":"3144","reg":"N604SD","flightno":"",
"route":"ENW-HOU","type":"C56X","category":"A2","altitude":38000,"heading":201,
"speed":387,"lat":38.627464,"lon":-90.01416,"barometer":0.0,"vert_rate":-64,
"selected_altitude":0,"polar_distance":20.898095,"polar_bearing":158.9935,
"is_adsb":true,"is_on_ground":false,"last_seen_time":"2020-09-20T23:59:19Z",
"pos_update_time":"2020-09-20T23:59:19Z","bds40_seen_time":null}

这证实了数据通过 RSocket 连接从 PlaneFinder 流向 Aircraft Positions,使用 request-stream 模型进行 Reactive Streams。一切正常。

代码检出检查

要获取完整的章节代码,请从代码存储库中检出 chapter8end 分支。

总结

响应式编程为开发人员提供了一种更好地利用资源的方式,在一个日益分布式的互联系统世界中,扩展可伸缩性的主要关键在于将扩展机制扩展到应用程序边界之外并进入通信渠道。响应式流倡议,特别是 Project Reactor,作为最大化系统范围可伸缩性的强大、高效和灵活的基础。

在本章中,我介绍了响应式编程,并演示了 Spring 如何引领众多工具和技术的发展和进步。我解释了阻塞和非阻塞通信以及提供这些功能的引擎,例如 Tomcat、Netty 等。

接下来,我演示了如何通过重构 PlaneFinder 和 Aircraft Positions 应用程序来使用 Spring WebFlux/Project Reactor 实现对 SQL 和 NoSQL 数据库的响应式数据库访问。Reactive Relational Database Connectivity (R2DBC) 提供了对 Java Persistence API (JPA) 的响应式替代,并与多个 SQL 数据库配合使用;MongoDB 和其他 NoSQL 数据库提供了与 Spring Data 和 Spring Boot 无缝配合的响应式驱动程序。

本章还讨论了响应式类型的前端集成选项,并演示了如果您的应用程序仍在使用生成的视图技术,则 Thymeleaf 提供了有限的迁移路径。未来的章节将考虑其他选项。

最后,我演示了如何通过 RSocket 将进程间通信提升到意想不到的新水平。通过 Spring Boot 的 RSocket 支持和自动配置,可以提供快速的性能、可伸缩性、弹性和开发者生产力的快捷路径。

在接下来的章节中,我将深入探讨测试:Spring Boot 如何实现更好、更快、更容易的测试实践,如何创建有效的单元测试,以及如何磨练和专注于测试以加快构建和测试周期。

第九章:测试 Spring Boot 应用程序以提高生产就绪性

本章讨论和演示了测试 Spring Boot 应用程序的核心方面。尽管测试的主题有许多方面,但我专注于测试 Spring Boot 应用程序的基本元素,这些元素显著提高了每个应用程序的生产就绪性。主题包括单元测试,使用 @SpringBootTest 编写有效单元测试的方法,以及使用 Spring Boot 测试切片来隔离测试对象并简化测试。

代码检出检查

请查看代码库中的分支 chapter9begin 以开始。

单元测试

单元测试作为其他类型应用程序测试的前导,是有充分理由的:单元测试使开发人员能够在开发+部署周期的最早阶段发现和修复错误,并因此以最低的成本修复它们。

简而言之,单元测试 包括验证一个定义的代码单元,尽可能和合理地隔离。随着大小和复杂性的增加,测试的结果数量呈指数增长;减少每个单元测试中的功能量使得每个测试更加可管理,从而增加考虑所有可能结果的可能性。

只有在成功且足够地实现了单元测试后,才应将集成测试、UI/UX 测试等加入混合中。幸运的是,Spring Boot 集成了简化和优化单元测试的功能,并默认在每个使用 Spring Initializr 构建的项目中包含这些功能,使得开发人员能够快速入门并“做正确的事情”。

引入 @SpringBootTest

到目前为止,我主要关注了使用 Spring Initializr 创建的项目中 src/main/java 下的代码,从主应用程序类开始。然而,在每个 Initializr 生成的 Spring Boot 应用程序中,还有一个相应的 src/test/java 目录结构,并且有一个预先创建的(但尚空)测试文件。

也命名为与主应用程序类相对应 - 例如,如果主应用程序类命名为 MyApplication,则主测试类将是 MyApplicationTest - 这种默认的 1:1 对应有助于组织和保持一致性。在测试类内部,Initializr 创建一个单一的测试方法,为空以提供清洁的起点,以便开发从干净的构建开始。您可以添加更多的测试方法,或者更通常地创建其他测试类以并行其他应用程序类,并在每个类中创建 1 个或多个测试方法。

通常情况下,我会鼓励测试驱动开发(TDD),即先编写测试,然后编写代码使测试通过。由于我坚信在介绍 Spring Boot 如何处理测试之前,先理解 Spring Boot 的关键方面非常重要,所以我相信读者会容许我在介绍本章材料之前延迟的做法。

考虑到这一点,让我们回到飞机位置应用程序并编写一些测试。

为了以最清晰、最简洁的方式展示 Spring Boot 提供的广泛的测试功能,我回到了使用 JPA 版本的 AircraftPositions,并将其作为本章测试重点的基础。还有一些其他与测试相关的主题,它们在本项目中没有被完全体现,但会在接下来的章节中进行介绍。

飞机位置应用的重要单元测试

在 AircraftPositions 中,目前只有一个类具有可以被视为有趣行为的类。PositionController公开了一个 API,直接或通过 Web 界面向最终用户提供当前飞机位置,并且该 API 可能执行包括以下操作的动作:

  • 从 PlaneFinder 获取当前飞机位置

  • 将位置存储在本地数据库中

  • 从本地数据库检索位置

  • 直接返回当前位置或通过将它们添加到文档的Model以供网页使用

暂且忽略该功能与外部服务交互的事实,它还触及从用户界面到数据存储和检索的应用程序堆栈的每一层。回顾一个良好的测试方法应该隔离和测试小而紧密功能块的原则,很明显,需要采取迭代的测试方法,从当前代码状态和没有测试的状态向最终优化应用程序组织和测试的状态迈进。这种方法准确反映了典型的面向生产的项目。

注意

由于正在使用的应用程序实际上从未真正“完成”,因此测试也永远不会“完成”。随着应用程序代码的演变,必须审查测试并可能进行修订、删除或添加,以保持测试效果。

我首先创建了一个与PositionController类相似的测试类。不同的 IDE 之间创建测试类的机制不同,当然也可以手动创建。由于我主要使用 IntelliJ IDEA 进行开发,我使用CMD+N键盘快捷键或右键单击,然后选择“Generate”打开 Generate 菜单,然后选择“Test…”选项来创建测试类。IntelliJ 随后显示如图 9-1 所示的弹出窗口。

sbur 0901

图 9-1. 从 PositionController 类发起的创建测试弹出窗口

创建测试弹出窗口中,我保留了默认的“测试库”选项设置为 JUnit 5。自从 Spring Boot 版本 2.2 正式发布以来,JUnit 版本 5 一直是 Spring Boot 应用程序单元测试的默认选项。还支持许多其他选项,包括 JUnit 3 和 4、Spock 和 TestNG 等,但是 JUnit 5 及其 Jupiter 引擎是一个强大的选项,提供了几种功能:

  • 更好地测试 Kotlin 代码(与以前的版本相比)

  • 为所有包含的测试进行一次性实例化/配置/清理测试类更加高效,使用@BeforeAll@AfterAll方法注解。

  • 支持 JUnit 4 和 5 测试(除非明确排除了 JUnit 4 的依赖项)

JUnit 5 的 Jupiter 引擎是默认的,提供了旧版引擎以向后兼容 JUnit 4 单元测试。

我保留了建议的类名PositionControllerTest,选中了生成setup/@BeforetearDown/@After方法的复选框,并选中了在 Figure 9-2 中显示的生成getCurrentAircraftPositions()方法的测试方法的复选框。

sbur 0902

图 9-2. 选择所需选项创建测试弹出窗口

一旦点击 OK 按钮,IntelliJ 将创建PositionControllerTest类,并打开 IDE,如下所示:

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class PositionControllerTest {

    @BeforeEach
    void setUp() {
    }

    @AfterEach
    void tearDown() {
    }

    @Test
    void getCurrentAircraftPositions() {
    }
}

为了在事后尽快构建测试套件,我首先仅仅尽可能地复制了PositionController方法getCurrentAircraftPositions()的现有操作,其上下文与其已成功运行的相同(字面上的)上下文:Spring Boot ApplicationContext

我首先在类级别添加了@SpringBootTest注解。由于最初的目标是尽可能地重现应用程序执行时存在的行为,我指定了一个选项来启动一个嵌入式服务器,并让其监听一个随机端口。为了测试 Web API,我计划使用WebTestClient,它类似于应用程序中使用的WebClient,但专注于测试:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient

目前只有一个单元测试,还没有设置/拆卸所需的内容,我把注意力转向了getCurrentAircraftPositions()测试方法:

@Test
void getCurrentAircraftPositions(@Autowired WebTestClient client) {
    assert client.get()
            .uri("/aircraft")
            .exchange()
            .expectStatus().isOk()
            .expectBody(Iterable.class)
            .returnResult()
            .getResponseBody()
            .iterator()
            .hasNext();
}

需要注意的第一件事是,我为方法内部自动装配了一个WebTestClient bean。我所做的这点极少,仅需使用@AutoConfigureWebTestClient注解即可,该注解放置在类级别,指示 Spring Boot 创建并自动配置WebTestClient

作为@Test方法的全部内容是评估紧随其后的表达式的断言语句。对于这个测试的第一次迭代,我使用 Java 的assert来验证客户端操作链的最终结果是一个boolean true 值,因此测试通过。

表达式本身使用了注入的 WebTestClient bean,在 PositionControllergetCurrentAircraftPositions() 方法上发出 GET 请求到本地端点 /aircraft。一旦请求/响应交换完成,将检查 HTTP 状态码以确保响应是“OK”(200),验证响应体是否包含一个 Iterable,并获取响应。由于响应包含一个 Iterable,因此我使用 Iterator 来确定 Iterable 中是否至少包含一个值。如果是,测试通过。

警告

当前测试中至少有几个小的妥协。首先,按照当前的编写方式,如果供应飞机位置的外部服务(PlaneFinder)不可用,测试将失败,即使 AircraftPositions 中被测试的所有代码都是正确的。这意味着测试不仅仅是测试其目标功能,而是测试了更多内容。其次,由于我仅测试返回带有 1 个或多个元素的可迭代对象,并未检查元素本身,因此测试的范围有些有限。这意味着在 Iterable 中返回任何一种类型的元素,或者是带有无效值的有效元素,都将导致测试通过。我将在接下来的迭代中解决所有这些缺点。

执行测试会提供与图 9-3 类似的结果,表明测试已通过。

sbur 0903

图 9-3. 第一个测试通过

这是一个很好的开始,但是甚至这一个单一的测试也可以显著改进。在扩展我们的单元测试授权之前,让我们清理一下这个测试。

为了更好的测试重构

在绝大多数情况下,为了运行少量测试而加载带有嵌入式服务器和应用程序中所有功能的ApplicationContext是不合适的。正如前面提到的,单元测试应该专注于并在可能的范围内尽可能自包含。表面积越小,外部依赖越少,测试的目标性就越强。这种激光般的关注带来了几个好处,包括更少的被忽视的场景/结果,更高的测试特异性和严谨性,更可读因此更易理解的测试,以及同样重要的速度。

我之前提到过写低价值和无价值的测试是适得其反的,尽管这意味着什么是依赖于上下文的。然而,有一件事可能会阻止开发人员添加有用的测试,那就是执行测试套件所需的时间。一旦达到某个阈值,这种边界也与上下文有关,开发人员可能会因为增加已经显著的构建时间负担而犹豫不前。幸运的是,Spring Boot 有几种方法可以同时提高测试质量和减少测试执行时间。

如果不需要使用 WebClientWebTestClient 来满足 AircraftPosition API 的需求,下一个合乎逻辑的步骤可能是移除类级别 @SpringBootTest 注解中的 webEnvironment 参数。这将导致在 PositionControllerTest 类的测试中加载一个基本的 ApplicationContext,使用 MOCK web 环境,从而减少所需的占用空间和加载时间。由于 WebClient 是 API 的关键部分,因此 WebTestClient 成为测试它的最佳方式,我将用 @WebFluxTest 替换类级别的 @SpringBootTest@AutoConfigureWebTestClient 注解,以简化 ApplicationContext 的同时自动配置并提供 WebTestClient 访问:

@WebFluxTest({PositionController.class})

还有一点需要注意的是 @WebFluxTest 注解:除其他事项外,它还可以接受一个 controllers 参数,指向要为注解测试类实例化的 @Controller bean 类型数组。实际上可以省略 controllers = 部分,正如我所做的那样,只留下 @Controller 类型的数组,本例中仅有一个 PositionController

重新审视代码以隔离行为

正如前面提到的,PositionController 的代码涉及多次数据库调用,并直接使用 WebClient 访问外部服务。为了更好地隔离 API 和底层操作,使 mocking 更精细、更容易和更清晰,我重构了 PositionController,移除了直接定义和使用 WebClient 的部分,并将 getCurrentAircraftPositions() 方法的整体逻辑移到 PositionRetriever 类中,然后注入到并由 PositionController 使用:

import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@AllArgsConstructor
@RestController
public class PositionController {
    private final PositionRetriever retriever;

    @GetMapping("/aircraft")
    public Iterable<Aircraft> getCurrentAircraftPositions() {
        return retriever.retrieveAircraftPositions();
    }
}

第一个可模拟版本的 PositionRetriever 主要由先前在 PositionController 中的代码组成。这一步的主要目标是便于模拟 retrieveAircraftPositions() 方法;通过将这段逻辑从 PositionControllergetCurrentAircraftPositions() 方法中移除,可以模拟上游调用而不是 web API,从而实现对 PositionController 的测试:

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@AllArgsConstructor
@Component
public class PositionRetriever {
    private final AircraftRepository repository;
    private final WebClient client =
            WebClient.create("http://localhost:7634");

    Iterable<Aircraft> retrieveAircraftPositions() {
        repository.deleteAll();

        client.get()
                .uri("/aircraft")
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(ac -> !ac.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        return repository.findAll();
    }
}

通过对代码进行这些更改,可以修订现有的测试,将飞机位置应用程序的功能与外部服务隔离开来,并专注于通过 mocking 访问 web API 所涉及的其他组件/功能,从而简化和加速测试执行。

完善测试

由于重点是测试 Web API,所以最好尽可能多地模拟非实际 Web 交互的逻辑。现在PositionController::getCurrentAircraftPositions调用PositionRetriever来在请求时提供当前飞机位置,因此PositionRetriever是要模拟的第一个组件。Mockito 的@MockBean注解——Mockito 已经自动包含在 Spring Boot 测试依赖中——用模拟的替身替换了通常在应用程序启动时创建的PositionRetriever bean,然后自动注入:

@MockBean
private PositionRetriever retriever;
注意

模拟的 bean 在每次执行测试方法后会自动重置。

然后我转向提供飞机位置的方法PositionRetriever::retrieveAircraftPositions。由于我现在注入了用于测试而不是真实对象的PositionRetriever模拟对象,因此我必须为retrieveAircraftPositions()方法提供一个实现,以便在被PositionController调用时以可预测且可测试的方式响应。

我创建了一对飞机位置,以用作PositionControllerTest类中测试的样本数据,并在setUp()方法中声明Aircraft变量并为其分配代表性值。

    private Aircraft ac1, ac2;

    @BeforeEach
    void setUp(ApplicationContext context) {
        // Spring Airlines flight 001 en route, flying STL to SFO,
        //   at 30000' currently over Kansas City
        ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
                "STL-SFO", "LJ", "ct",
                30000, 280, 440, 0, 0,
                39.2979849, -94.71921, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        // Spring Airlines flight 002 en route, flying SFO to STL,
        //   at 40000' currently over Denver
        ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
                "SFO-STL", "LJ", "ct",
                40000, 65, 440, 0, 0,
                39.8560963, -104.6759263, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());
    }
注意

在开发应用程序的实际操作中,检索的飞机位置数量几乎总是超过一个,通常远远超过一个。请记住,在测试中使用的样本数据集应至少返回两个位置。在后续迭代中,应考虑为类似生产应用程序的额外测试考虑涉及零、一个或非常大量位置的边缘情况。

现在回到retrieveAircraftPositions()方法。Mockito 的when...thenReturn组合在满足指定条件时返回指定的响应。现在已定义了示例数据,我可以提供条件和响应,以便调用PositionRetriever::retrieveAircraftPositions时返回:

@BeforeEach
void setUp(ApplicationContext context) {
    // Aircraft variable assignments omitted for brevity

    ...

    Mockito.when(retriever.retrieveAircraftPositions())
        .thenReturn(List.of(ac1, ac2));
}

有了相关的方法模拟后,现在是时候将注意力转回PositionControllerTest::getCurrentAircraftPositions中的单元测试。

由于我已指示测试实例加载了带有类级注释@WebFluxTest(controllers = {PositionController.class})PositionController bean,并创建了模拟的PositionRetriever bean 并定义了其行为,因此现在可以重构检索位置的测试部分,并对将返回的内容有一定的把握:

@Test
void getCurrentAircraftPositions(@Autowired WebTestClient client) {
    final Iterable<Aircraft> acPositions = client.get()
            .uri("/aircraft")
            .exchange()
            .expectStatus().isOk()
            .expectBodyList(Aircraft.class)
            .returnResult()
            .getResponseBody();

    // Still need to compare with expected results
}

所示的操作链应检索由ac1ac2组成的List<Aircraft>。为了确认正确的结果,我需要将实际结果acPositions与预期结果进行比较。其中一种简单的比较方法是:

assertEquals(List.of(ac1, ac2), acPositions);

这将正确运行,并且测试将通过。在这个中间步骤中,我还可以通过将实际结果与通过对AircraftRepository进行模拟调用获得的结果进行比较,进一步推进事情。通过将以下代码添加到类、setUp()方法和getCurrentAircraftPositions()测试方法中,会产生类似(通过)的测试结果:

@MockBean
private AircraftRepository repository;

@BeforeEach
void setUp(ApplicationContext context) {
    // Existing setUp code omitted for brevity

    ...

    Mockito.when(repository.findAll()).thenReturn(List.of(ac1, ac2));
}

@Test
void getCurrentAircraftPositions(@Autowired WebTestClient client) {
    // client.get chain of operations omitted for brevity

    ...

    assertEquals(repository.findAll(), acPositions);
}
注意

这种变体也会导致通过的测试,但它在某种程度上违反了专注测试的原则,因为现在我将存储库测试的概念与测试 Web API 混合在一起。由于它实际上并没有使用CrudRepository::findAll方法而只是模拟了它,所以测试它并没有增加任何可识别的价值。但是,您可能在某些时候会遇到这类测试,所以我认为值得展示和讨论。

当前的PlaneControllerTest的工作版本现在应该如下所示:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.ApplicationContext;
import org.springframework.test.web.reactive.server.WebTestClient;

import java.time.Instant;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@WebFluxTest(controllers = {PositionController.class})
class PositionControllerTest {
    @MockBean
    private PositionRetriever retriever;

    private Aircraft ac1, ac2;

    @BeforeEach
    void setUp(ApplicationContext context) {
        // Spring Airlines flight 001 en route, flying STL to SFO,
        //    at 30000' currently over Kansas City
        ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
                "STL-SFO", "LJ", "ct",
                30000, 280, 440, 0, 0,
                39.2979849, -94.71921, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        // Spring Airlines flight 002 en route, flying SFO to STL,
        //    at 40000' currently over Denver
        ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
                "SFO-STL", "LJ", "ct",
                40000, 65, 440, 0, 0,
                39.8560963, -104.6759263, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        Mockito.when(retriever.retrieveAircraftPositions())
            .thenReturn(List.of(ac1, ac2));
    }

    @Test
    void getCurrentAircraftPositions(@Autowired WebTestClient client) {
        final Iterable<Aircraft> acPositions = client.get()
                .uri("/aircraft")
                .exchange()
                .expectStatus().isOk()
                .expectBodyList(Aircraft.class)
                .returnResult()
                .getResponseBody();

        assertEquals(List.of(ac1, ac2), acPositions);
    }
}

再次运行会产生一个通过的测试,并且结果与图 9-4 中显示的类似。

sbur 0904

图 9-4. AircraftRepository::getCurrentAircraftPositions 的新、改进的测试

随着满足应用程序/用户需求所需的 Web API 的扩展,应首先指定单元测试(在创建实现这些需求的实际代码之前),以确保正确的结果。

测试片段

我已经多次提到过专注测试的重要性,Spring 还有另一种机制可以帮助开发人员快速而轻松地完成这项工作:测试片段。

Spring Boot 测试依赖spring-boot-starter-test中内置了几个注解,自动配置这些功能片段。所有这些测试片段注解都以类似的方式工作,加载一个ApplicationContext和为指定的片段合理的选择组件。例如:

  • @JsonTest

  • @WebMvcTest

  • @WebFluxText(先前介绍过)

  • @DataJpaTest

  • @JdbcTest

  • @DataJdbcTest

  • @JooqTest

  • @DataMongoTest

  • @DataNeo4jTest

  • @DataRedisTest

  • @DataLdapTest

  • @RestClientTest

  • @AutoConfigureRestDocs

  • @WebServiceClientTest

在早期的一节中,利用@WebFluxTest来执行和验证 Web API,我提到了测试数据存储互动并将其从测试中排除,因为它专注于测试 Web 互动。为了更好地展示数据测试以及测试片段如何有助于针对特定功能,接下来我会进行探讨。

由于当前的 Aircraft Positions 使用 JPA 和 H2 来存储和检索当前位置,因此 @DataJpaTest 完全适用。我开始使用 IntelliJ IDEA 为测试创建一个新类,打开 AircraftRepository 类,并使用与之前相同的方法创建测试类:CMD+N,选择“Test…”,将 JUnit5 作为“Testing Library”,保留其他默认值,并选择 setUp/@BeforetearDown/@After 选项,如 图 9-5 所示。

sbur 0905

图 9-5. 为 AircraftRepository 创建测试弹出窗口
注意

由于 Spring Data Repository bean 通过自动配置向 Spring Boot 应用程序提供通用方法,因此不显示任何方法。下面,我将添加测试方法来演示这些方法的使用,如果您创建自定义 repository 方法,则也可以(并且应该)对其进行测试。

单击“OK”按钮生成测试类 AircraftRepositoryTest

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;

class AircraftRepositoryTest {

    @BeforeEach
    void setUp() {
    }

    @AfterEach
    void tearDown() {
    }
}

首要任务当然是向 AircraftRepositoryTest 类添加测试切片注解 @DataJpaTest

@DataJpaTest
class AircraftRepositoryTest {

    ...

}

添加此单个注解后,执行测试将扫描 @Entity 类并配置 Spring Data JPA repository — 在 Aircraft Positions 应用程序中分别是 AircraftAircraftRepository。如果类路径中存在嵌入式数据库(如此处的 H2),测试引擎也会对其进行配置。通常不会扫描用 @Component 注解标记的类以进行 bean 创建。

为了测试实际的 repository 操作,repository 不能被模拟;由于 @DataJpaTest 注解加载和配置了一个 AircraftRepository bean,因此无需模拟它。我使用 @Autowire 注入 repository bean,并像之前的 PositionController 测试中一样,声明 Aircraft 变量最终作为测试数据:

@Autowired
private AircraftRepository repository;

private Aircraft ac1, ac2;

为了设置这个 AircraftRepositoryTest 类中将存在的测试的适当环境,我创建两个 Aircraft 对象,将每个分配给已声明的成员变量,并在 setUp() 方法中使用 Repository::saveAll 将它们保存到 repository 中。

@BeforeEach
void setUp() {
    // Spring Airlines flight 001 en route, flying STL to SFO,
    // at 30000' currently over Kansas City
    ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
            "STL-SFO", "LJ", "ct",
            30000, 280, 440, 0, 0,
            39.2979849, -94.71921, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    // Spring Airlines flight 002 en route, flying SFO to STL,
    // at 40000' currently over Denver
    ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
            "SFO-STL", "LJ", "ct",
            40000, 65, 440, 0, 0,
            39.8560963, -104.6759263, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    repository.saveAll(List.of(ac1, ac2));
}

接下来,我创建一个测试方法来验证在执行 AircraftRepository bean 上的 findAll() 后返回的结果确实是预期的内容:一个包含在测试的 setUp() 方法中保存的两个飞机位置的 Iterable<Aircraft>

@Test
void testFindAll() {
    assertEquals(List.of(ac1, ac2), repository.findAll());
}
注意

List 扩展 Collection,而 Collection 又扩展 Iterable

运行此测试将提供一个通过的结果,类似于在 图 9-6 中显示的内容。

sbur 0906

图 9-6. findAll() 的测试结果

类似地,我创建了一个测试AircraftRepository方法来查找特定 ID 字段的记录,即findById()。由于测试类的setUp()中调用了Repository::saveAll方法,所以应存储两条记录,我会查询这两条记录并与预期值进行验证。

@Test
void testFindById() {
    assertEquals(Optional.of(ac1), repository.findById(ac1.getId()));
    assertEquals(Optional.of(ac2), repository.findById(ac2.getId()));
}

运行testFindById()测试会显示通过,如图 9-7 所示。

sbur 0907

图 9-7. findById()的测试结果

最后,在所有测试运行完成后,需要进行一些清理工作。我在tearDown()方法中添加了一个语句,用于删除AircraftRepository中的所有记录:

@AfterEach
void tearDown() {
    repository.deleteAll();
}

请注意,在这种情况下,真的没有必要从存储库中删除所有记录,因为它是 H2 数据库的内存实例,在每次测试之前都会重新初始化。然而,这代表了通常会放置在测试类的tearDown()方法中的操作类型。

AircraftRepositoryTest中执行所有测试会产生类似于图 9-8 中显示的通过结果。

sbur 0908

图 9-8. AircraftRepositoryTest中所有测试的测试结果

应用程序在不断演化的过程中,测试永远不会完成。然而,对于目前在 Aircraft Positions 中存在的功能,本章节中编写的测试提供了一个良好的代码验证起点,并在应用程序功能增加时进行持续扩展。

代码检出检查

若要获取完整的章节代码,请查看代码库中的chapter9end分支。

总结

本章讨论并演示了测试 Spring Boot 应用程序的核心方面,重点是测试 Spring Boot 应用程序的基本方面,这些方面最大程度地提高了每个应用程序的生产准备就绪性。涵盖的主题包括单元测试,使用@SpringBootTest进行整体应用程序测试,如何使用 JUnit 编写有效的单元测试以及使用 Spring Boot 测试片段来隔离测试主题并简化测试。

下一章将探讨诸如认证和授权等安全概念。然后,我将演示如何为自包含应用程序实现基于表单的身份验证,以及如何利用 OpenID Connect 和 OAuth2 来实现最大安全性和灵活性,所有这些都将使用 Spring Security。

第十章:保护您的 Spring Boot 应用程序

理解认证和授权的概念对构建安全应用程序至关重要,为用户验证和访问控制提供基础。Spring Security 结合认证和授权的选项与 HTTP 防火墙、过滤器链、广泛使用的 IETF 和万维网联盟(W3C)标准及用于交换的选项等其他机制,帮助锁定应用程序。采用安全的开箱即用思维方式,Spring Security 利用 Boot 强大的自动配置来评估开发者的输入和可用的依赖关系,以在最小的努力下为 Spring Boot 应用程序提供最大的安全性。

本章介绍并解释了安全的核心方面以及它们如何适用于应用程序。我演示了多种将 Spring Security 集成到 Spring Boot 应用程序中以增强应用程序安全姿态的方法,弥补了覆盖中的危险漏洞并减少了攻击面积。

代码结帐检查

请从代码库中检出分支 chapter10begin 开始。

认证和授权

经常一起使用,认证授权 这两个术语相关但又是独立的关注点。

认证

表示、展示或证实某事物(如身份、艺术品或金融交易)的真实性、真实性或真实性的行为、过程或方法;验证某事物的真实性的行为或过程。

授权

1: 授权 的行为 2: 授权的工具:批准

授权 的第一个定义指向 授权 以获取更多信息:

授权

1: 通过或像通过某种公认或适当的权威(如习惯、证据、个人权利或监管权力)背书、授权、证明或允许的习惯由时间授权的 2: 尤其是具有法律权威的投资 3: 古老:证明

授权 的定义反过来指向 证明 以获取更多信息。

尽管有些有趣,但这些定义并不十分清晰。有时,词典定义可能没有我们期望的那么有帮助。我自己的定义如下。

认证

证明某人是他们所声称的人

授权

验证某人是否有权访问特定资源或操作

认证

简单来说,认证 是证明某人(或某物)是其所声称的人(或物,如设备、应用程序或服务)。

认证的概念在物理世界中有几个具体的例子。如果您曾经需要展示像员工工牌、驾驶执照或护照等身份证明来证明您的身份,那么您已经进行了认证。证明某人是其声称的人是一个我们在多种情况下都习以为常的程序,而在物理级别和应用程序级别的认证概念差异微不足道。

身份验证通常涉及以下一项或多项:

  • 你所是的东西

  • 你所知道的东西

  • 你所拥有的东西

注意

这三个因素可以单独使用,也可以组合起来构成多因素身份验证(MFA)。

身份验证在物理世界和虚拟世界中发生的方式当然是不同的。与物理世界中经常发生的人类注视照片 ID 并将其与您当前的外貌进行比较不同,身份验证到应用程序通常涉及键入密码,插入安全密钥或提供生物特征数据(虹膜扫描,指纹等)。这些数据可以更容易地由软件进行评估,而不是与照片的外观进行比较,目前比较难以实现。尽管如此,两种情况下都会对提供的数据进行比较,并且匹配会提供积极的身份验证。

授权

一旦一个人通过身份验证,他们就有可能获得一个或多个个人可以使用的资源和/或允许执行的操作。

注意

在这种情况下,一个人可能是(而且很可能是)一个人类,但是对于应用程序,服务,设备等,根据上下文,相同的概念和访问考虑都适用。

一旦个人的身份得到证明,该个人就会获得对应用程序的一般级别的访问权限。从那里,现在已验证的应用程序用户可以请求访问某些内容。然后,应用程序必须以某种方式确定用户是否被允许访问该资源,即授权。如果是这样,则授予用户访问权限;如果不是,则通知用户,他们缺乏权限导致其请求被拒绝。

Spring Security 简介

除了提供可靠的身份验证和授权选项外,Spring Security 还提供了其他几种机制,帮助开发人员确保其 Spring Boot 应用程序的安全性。由于自动配置,Spring Boot 应用程序根据提供的信息启用每个适用的 Spring Security 功能,甚至由于缺乏更具体的指导。安全功能当然可以根据开发人员的需要进行调整或放宽,以适应其组织的具体要求。

Spring Security 的功能远远超出了本章详尽介绍的范围,但有三个关键功能对理解 Spring Security 模型及其基础至关重要。它们是 HTTP 防火墙,安全过滤器链以及 Spring Security 对 IETF 和 W3C 标准的广泛使用以及对请求和相应的选项。

HTTP 防火墙

虽然确切的数字很难获得,但许多安全妥协始于使用格式不正确的 URI 进行请求,以及系统对其的意外响应。这实际上是应用程序的第一道防线,因此在考虑进一步努力保护应用程序之前,应首先解决这个问题。

自 5.0 版本起,Spring Security 已经包含了一个内置的 HTTP 防火墙,用于审查所有入站请求的问题格式。如果请求存在任何问题,如不良的头部值或格式不正确,则请求将被丢弃。除非开发者进行了覆盖,否则默认实现使用的是名为StrictHttpFirewall的适当命名的实现,快速关闭应用程序安全配置中的第一个且可能最容易被利用的漏洞。

安全过滤器链

Spring Security 提供了一个更为具体、更高级别的入站请求过滤器链,用于处理成功通过 HTTP 防火墙的正常形式的请求。

简而言之,对于大多数应用程序,开发者通过指定一系列过滤器条件,使得入站请求通过这些条件直到匹配一个过滤器。当请求与过滤器匹配时,将评估其相应的条件,以确定是否满足请求。例如,如果到达特定 API 端点的请求与过滤器链中的某个条件匹配,则将检查发出请求的用户是否具有访问所请求资源的适当角色/权限。如果是,则处理请求;如果不是,则通常使用 403 Forbidden 状态码拒绝请求。

如果一个请求通过链中定义的所有过滤器而不匹配任何过滤器,则该请求将被丢弃。

请求和响应头部

IETF 和 W3C 创建了多个基于 HTTP 的交换规范和标准,其中几个与信息安全的安全交换相关。这些头部用于请求或指示特定行为,并定义了允许的值和行为响应。Spring Security 广泛使用这些头部详情来增强您的 Spring Boot 应用程序的安全性姿态。

了解到不同的用户代理可能支持这些标准和规范的一些或全部,Spring Security 通过检查所有已知的头部选项并在请求中查找它们,在响应中适用时提供它们,采用了尽可能覆盖的最佳实践方法。

使用 Spring Security 实现基于表单的身份验证和授权

每天都有无数使用“something you know”身份验证方法的应用程序被使用。无论是用于组织内部的应用程序,直接通过互联网提供给消费者的 Web 应用程序,还是移动设备本地的应用程序,输入用户 ID 和密码对开发者和非开发者来说都是熟悉的例行公事。在大多数情况下,这种提供的安全性已经足以完成手头的任务。

Spring Security 为 Spring Boot 应用程序提供了出色的开箱即用(OOTB)支持,通过自动配置和易于理解的抽象来进行密码验证。本节演示了通过重构Aircraft Positions应用程序以使用 Spring Security 实现基于表单的身份验证的各种起始点。

添加 Spring Security 依赖项

在创建新的 Spring Boot 项目时,通过 Spring Initializr 添加一个更多的依赖项,即Spring Security,可以在不对新应用程序进行额外配置的情况下提供顶级安全性,如图 10-1 所示。

sbur 1001

图 10-1. Spring Initializr 中的 Spring Security 依赖项

更新现有应用程序稍微复杂一点。我将在Aircraft Positionspom.xml Maven 构建文件中添加与 Initializr 添加的两个互补依赖项,一个是用于 Spring Security 本身,另一个用于测试它:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

将 Spring Security 添加到类路径中并且没有代码或配置更改应用时,我重新启动Aircraft Positions进行快速功能检查。这提供了一个很好的机会,了解 Spring Security 在开发者方面所做的工作。

运行PlaneFinderAircraft Positions两个应用后,我返回终端,并再次调用Aircraft Positions/aircraft端点,如下所示:

mheckler-a01 :: ~ » http :8080/aircraft
HTTP/1.1 401
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
Set-Cookie: JSESSIONID=347DD039FE008DE50F457B890F2149C0; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm"
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

{
    "error": "Unauthorized",
    "message": "",
    "path": "/aircraft",
    "status": 401,
    "timestamp": "2020-10-10T17:26:31.599+00:00"
}
注:

为了清晰起见,已删除了一些响应头。

正如你所见,我无法再访问/aircraft端点,因为我的请求收到了401 Unauthorized的响应。由于/aircraft端点目前是从Aircraft Positions应用程序访问信息的唯一途径,这有效地意味着该应用程序已经完全保护免受未经授权的访问。这是一个好消息,但重要的是要理解这是如何发生的,以及如何为合法用户恢复所需的访问权限。

正如我之前提到的,Spring Security 采用“默认安全”的思路,在开发者使用它在 Spring Boot 应用程序中的每个级别配置甚至零配置时尽可能安全。当 Spring Boot 在类路径中找到 Spring Security 时,安全性将使用合理的默认值进行配置。即使没有定义任何用户或指定任何密码或开发者未作出任何其他努力,项目中包含 Spring Security 都表明其目标是创建一个安全的应用程序。

可以想象,这些信息非常少。但是,Spring Boot+Security 自动配置创建了一些基本安全功能的关键 Bean,基于表单认证和使用用户 ID 和密码进行用户授权。从这个逻辑假设合理地得出的下一个问题是:使用什么用户?什么密码?

返回到Aircraft Positions应用程序的启动日志,可以在以下行找到其中一个问题的答案:

Using generated security password: 1ad8a0fc-1a0c-429e-8ed7-ba0e3c3649ef

如果在应用程序中未指定用户 ID 和密码,也未提供其他访问方式,则启用安全性的 Spring Boot 应用程序将默认使用一个名为user的单一用户帐户,并在每次应用程序启动时生成一个新的唯一密码。回到终端窗口,我尝试再次访问应用程序,这次使用提供的凭据:

mheckler-a01 :: ~ » http :8080/aircraft
    --auth user:1ad8a0fc-1a0c-429e-8ed7-ba0e3c3649ef
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
Set-Cookie: JSESSIONID=94B52FD39656A17A015BC64CF6BF7475; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 40000,
        "barometer": 1013.6,
        "bds40_seen_time": "2020-10-10T17:48:02Z",
        "callsign": "SWA2057",
        "category": "A3",
        "flightno": "WN2057",
        "heading": 243,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-10T17:48:06Z",
        "lat": 38.600372,
        "lon": -90.42375,
        "polar_bearing": 207.896382,
        "polar_distance": 24.140226,
        "pos_update_time": "2020-10-10T17:48:06Z",
        "reg": "N557WN",
        "route": "IND-DAL-MCO",
        "selected_altitude": 40000,
        "speed": 395,
        "squawk": "2161",
        "type": "B737",
        "vert_rate": -64
    },
    {
        "altitude": 3500,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": "N6884J",
        "category": "A1",
        "flightno": "",
        "heading": 353,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-10T17:47:45Z",
        "lat": 39.062851,
        "lon": -90.084965,
        "polar_bearing": 32.218696,
        "polar_distance": 7.816637,
        "pos_update_time": "2020-10-10T17:47:45Z",
        "reg": "N6884J",
        "route": "",
        "selected_altitude": 0,
        "speed": 111,
        "squawk": "1200",
        "type": "P28A",
        "vert_rate": -64
    },
    {
        "altitude": 39000,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": "ATN3425",
        "category": "A5",
        "flightno": "",
        "heading": 53,
        "id": 3,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-10T17:48:06Z",
        "lat": 39.424159,
        "lon": -90.419739,
        "polar_bearing": 337.033437,
        "polar_distance": 30.505314,
        "pos_update_time": "2020-10-10T17:48:06Z",
        "reg": "N419AZ",
        "route": "AFW-ABE",
        "selected_altitude": 0,
        "speed": 524,
        "squawk": "2224",
        "type": "B763",
        "vert_rate": 0
    },
    {
        "altitude": 45000,
        "barometer": 1012.8,
        "bds40_seen_time": "2020-10-10T17:48:06Z",
        "callsign": null,
        "category": "A2",
        "flightno": "",
        "heading": 91,
        "id": 4,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-10T17:48:06Z",
        "lat": 39.433982,
        "lon": -90.50061,
        "polar_bearing": 331.287125,
        "polar_distance": 32.622134,
        "pos_update_time": "2020-10-10T17:48:05Z",
        "reg": "N30GD",
        "route": "",
        "selected_altitude": 44992,
        "speed": 521,
        "squawk": null,
        "type": "GLF4",
        "vert_rate": 64
    }
]
注意

如前所述,为了清晰起见,已删除了一些响应标头。

使用正确的默认用户 ID 和生成的密码,我收到了200 OK的响应,并再次可以访问/aircraft端点,从而访问了Aircraft Positions应用程序。

回到Aircraft Positions应用程序,当前应用程序安全状态存在几个问题。首先,只有一个定义的用户,需要访问该应用程序的多个人员必须全部使用该单一帐户。这与安全责任和甚至身份验证的原则背道而驰,因为没有单一的个体可以唯一证明他们是谁。再谈责任问题,如果发生漏洞,如何确定是谁造成或贡献了漏洞?更不用说,如果发生漏洞,锁定唯一用户帐户将禁止所有用户访问;目前没有办法避免这种情况。

现有安全配置的次要问题是如何处理单一密码。每次应用程序启动时,都会自动生成新密码,然后必须与所有用户共享。虽然尚未讨论应用程序的扩展性,但每个启动的Aircraft Positions实例将生成一个唯一密码,需要用户输入该特定应用程序实例的密码。显然可以并且应该做出一些改进。

添加认证

Spring Security 使用UserDetailsService的概念作为其认证能力的核心。UserDetailsService是一个接口,具有一个loadUserByUsername(String username)方法(在实现时)返回一个满足UserDetails接口的对象,从中可以获取关键信息,如用户的名称、密码、授予用户的权限和账户状态。这种灵活性允许使用各种技术进行多种实现;只要UserDetailsService返回UserDetails,应用程序就不需要知道底层实现细节。

要创建一个UserDetailsService bean,我创建一个配置类,在其中定义一个 bean 创建方法。

首先,我创建一个名为SecurityConfig的类,并使用@Configuration进行注解,以便 Spring Boot 能够找到并执行其中的 bean 创建方法。用于身份验证的 bean 是实现UserDetailsService接口的 bean,因此我创建一个名为authentication()的方法来创建并返回该 bean。这是第一次,有意不完整的代码:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig {
    @Bean
    UserDetailsService authentication() {
        UserDetails peter = User.builder()
                .username("peter")
                .password("ppassword")
                .roles("USER")
                .build();

        UserDetails jodie = User.builder()
                .username("jodie")
                .password("jpassword")
                .roles("USER", "ADMIN")
                .build();

        System.out.println("   >>> Peter's password: " + peter.getPassword());
        System.out.println("   >>> Jodie's password: " + jodie.getPassword());

        return new InMemoryUserDetailsManager(peter, jodie);
    }
}

UserDetailServiceauthentication()方法中,我使用User类的builder()方法创建了两个实现UserDetails接口要求的应用对象,并指定了用户名、密码和用户拥有的角色/权限。然后我使用build()方法构建这些用户,并将每个用户分配给一个局部变量。

接下来,我仅仅为演示目的显示密码。这有助于展示本章中的另一个概念,但仅供演示目的

警告

记录密码是一种最坏的反模式。永远不要在生产应用程序中记录密码。

最后,我创建了一个InMemoryUserDetailsManager,使用这两个创建的User对象,并将其作为 Spring bean 返回。InMemoryUserDetailsManager实现了UserDetailsManagerUserDetailsPasswordService接口,使得可以进行用户管理任务,如确定特定用户是否存在、创建、更新和删除用户,以及更改/更新用户的密码。我使用InMemoryUserDetailsManager是为了在演示概念时更加清晰(因为没有外部依赖),但任何实现UserDetailsService接口的 bean 都可以作为认证 bean 提供。

重新启动Aircraft Positions,我尝试进行身份验证,并检索当前飞机位置的列表,结果如下(为简洁起见,删除了一些标题):

mheckler-a01 :: ~ » http :8080/aircraft --auth jodie:jpassword
HTTP/1.1 401
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Length: 0
Expires: 0
Pragma: no-cache
WWW-Authenticate: Basic realm="Realm"
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

这促使一些故障排除。返回到 IDE,堆栈跟踪中有一些有用的信息:

java.lang.IllegalArgumentException: There is no PasswordEncoder
    mapped for the id "null"
	at org.springframework.security.crypto.password
        .DelegatingPasswordEncoder$UnmappedIdPasswordEncoder
            .matches(DelegatingPasswordEncoder.java:250)
                ~[spring-security-core-5.3.4.RELEASE.jar:5.3.4.RELEASE]

这为问题的根源提供了一个提示。检查记录的密码(友情提醒:记录密码仅供演示目的)得到了确认:

>>> Peter's password: ppassword
>>> Jodie's password: jpassword

显然 这些密码是明文的,没有进行任何编码。实现工作和安全的身份验证的下一步是在 SecurityConfig 类中添加一个密码编码器,如下所示:

private final PasswordEncoder pwEncoder =
        PasswordEncoderFactories.createDelegatingPasswordEncoder();

创建和维护安全应用程序的一个挑战在于,安全性必须不断进化。Spring Security 正是出于这个必要性,不仅仅有一个指定的编码器可供插入;它使用一个具有多个可用编码器的工厂,并委托其中一个进行编码和解码任务。

当然,这意味着在前面的示例中,如果没有指定编码器,则必须作为默认值服务。目前 BCrypt 是(非常好的)默认值,但是 Spring Security 编码器架构的灵活委托性质使得在标准演变和/或需求变化时可以轻松地将一个编码器替换为另一个。这种方法的优雅性允许在应用程序用户登录时轻松地将凭据从一个编码器迁移到另一个编码器,从而再次减少了一些虽然对组织至关重要但并不直接提供价值的任务。

现在我已经放置了一个编码器,下一步是使用它来加密用户密码。这可以通过简单地调用密码编码器的 encode() 方法,并传递明文密码来完成,然后收到加密结果。

提示

严格来说,加密一个值也会对该值进行编码,但并非所有编码器都会加密。例如,哈希编码一个值但不一定加密它。也就是说,Spring Security 支持的每种编码算法也都会进行加密;然而,为了支持旧的应用程序,某些支持的算法远不如其他算法安全。始终选择当前推荐的 Spring Security 编码器或选择由 PasswordEncoderFactories.createDelegatingPasswordEncoder() 提供的默认编码器。

经过修订的 SecurityConfig 类的身份验证版本如下所示:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig {
    private final PasswordEncoder pwEncoder =
            PasswordEncoderFactories.createDelegatingPasswordEncoder();

    @Bean
    UserDetailsService authentication() {
        UserDetails peter = User.builder()
                .username("peter")
                .password(pwEncoder.encode("ppassword"))
                .roles("USER")
                .build();

        UserDetails jodie = User.builder()
                .username("jodie")
                .password(pwEncoder.encode("jpassword"))
                .roles("USER", "ADMIN")
                .build();

        System.out.println("   >>> Peter's password: " + peter.getPassword());
        System.out.println("   >>> Jodie's password: " + jodie.getPassword());

        return new InMemoryUserDetailsManager(peter, jodie);
    }
}

我重新启动 Aircraft Positions,然后再次尝试进行身份验证并检索当前飞机位置列表,结果如下(为简洁起见,某些标题和结果已删除):

mheckler-a01 :: ~ » http :8080/aircraft --auth jodie:jpassword
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 24250,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": null,
        "category": "A2",
        "flightno": "",
        "heading": 118,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T16:13:26Z",
        "lat": 38.325119,
        "lon": -90.154159,
        "polar_bearing": 178.56009,
        "polar_distance": 37.661127,
        "pos_update_time": "2020-10-12T16:13:24Z",
        "reg": "N168ZZ",
        "route": "FMY-SUS",
        "selected_altitude": 0,
        "speed": 404,
        "squawk": null,
        "type": "LJ60",
        "vert_rate": 2880
    }
]

这些结果证实了认证已成功(由于空间限制,故意使用不正确的密码进行失败的场景被省略),有效用户可以再次访问暴露的 API。

回顾并且现在查看已编码的密码,我注意到在 IDE 输出中类似以下值:

>>> Peter's password:
    {bcrypt}$2a$10$rLKBzRBvtTtNcV9o8JHzFeaIskJIPXnYgVtCPs5H0GINZtk1WzsBu
>>> Jodie's password: {
    bcrypt}$2a$10$VR33/dlbSsEPPq6nlpnE/.ZQt0M4.bjvO5UYmw0ZW1aptO4G8dEkW

登录的值确认了代码中指定的两个示例密码已经由委托密码编码器成功编码,使用 BCrypt

授权

现在,Aircraft Positions应用程序成功地认证用户,并仅允许这些用户访问其暴露的 API。然而,当前安全配置存在一个相当大的问题:访问 API 的任何部分都意味着可以访问所有部分,无论用户拥有的角色/权限,或者更确切地说,无论用户拥有的角色。

作为这个安全漏洞的一个非常简单的例子,我在Aircraft Position的 API 中添加了另一个端点,通过克隆、重命名和重新映射PositionController类中现有的getCurrentAircraftPositions()方法作为第二个端点。完成后,PositionController如下所示:

import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@AllArgsConstructor
@RestController
public class PositionController {
    private final PositionRetriever retriever;

    @GetMapping("/aircraft")
    public Iterable<Aircraft> getCurrentAircraftPositions() {
        return retriever.retrieveAircraftPositions();
    }

    @GetMapping("/aircraftadmin")
    public Iterable<Aircraft> getCurrentAircraftPositionsAdminPrivs() {
        return retriever.retrieveAircraftPositions();
    }
}

目标是只允许具有“ADMIN”角色的用户访问第二个方法getCurrentAircraftPositionsAdminPrivs()。虽然在这个例子的当前版本中,返回的值与getCurrentAircraftPositions()返回的值相同,但随着应用程序的扩展,这种情况可能不会持续,这个概念仍然适用。

重新启动Aircraft Positions应用程序并返回命令行,我首先以用户 Jodie 的身份登录,以验证对新端点的访问,预期的访问已确认(由于空间限制,省略了第一个端点的访问确认以及部分标题和结果)。

mheckler-a01 :: ~ » http :8080/aircraftadmin --auth jodie:jpassword
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 24250,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": null,
        "category": "A2",
        "flightno": "",
        "heading": 118,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T16:13:26Z",
        "lat": 38.325119,
        "lon": -90.154159,
        "polar_bearing": 178.56009,
        "polar_distance": 37.661127,
        "pos_update_time": "2020-10-12T16:13:24Z",
        "reg": "N168ZZ",
        "route": "FMY-SUS",
        "selected_altitude": 0,
        "speed": 404,
        "squawk": null,
        "type": "LJ60",
        "vert_rate": 2880
    },
    {
        "altitude": 38000,
        "barometer": 1013.6,
        "bds40_seen_time": "2020-10-12T20:24:48Z",
        "callsign": "SWA1828",
        "category": "A3",
        "flightno": "WN1828",
        "heading": 274,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T20:24:48Z",
        "lat": 39.348862,
        "lon": -90.751668,
        "polar_bearing": 310.510201,
        "polar_distance": 35.870036,
        "pos_update_time": "2020-10-12T20:24:48Z",
        "reg": "N8567Z",
        "route": "TPA-BWI-OAK",
        "selected_altitude": 38016,
        "speed": 397,
        "squawk": "7050",
        "type": "B738",
        "vert_rate": -128
    }
]

接下来,我以 Peter 的身份登录。Peter 不应该能够访问映射到/aircraftadmingetCurrentAircraftPositionsAdminPrivs()方法。但情况并非如此;目前,作为经过身份验证的用户,Peter 可以访问一切:

mheckler-a01 :: ~ » http :8080/aircraftadmin --auth peter:ppassword
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 24250,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": null,
        "category": "A2",
        "flightno": "",
        "heading": 118,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T16:13:26Z",
        "lat": 38.325119,
        "lon": -90.154159,
        "polar_bearing": 178.56009,
        "polar_distance": 37.661127,
        "pos_update_time": "2020-10-12T16:13:24Z",
        "reg": "N168ZZ",
        "route": "FMY-SUS",
        "selected_altitude": 0,
        "speed": 404,
        "squawk": null,
        "type": "LJ60",
        "vert_rate": 2880
    },
    {
        "altitude": 38000,
        "barometer": 1013.6,
        "bds40_seen_time": "2020-10-12T20:24:48Z",
        "callsign": "SWA1828",
        "category": "A3",
        "flightno": "WN1828",
        "heading": 274,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T20:24:48Z",
        "lat": 39.348862,
        "lon": -90.751668,
        "polar_bearing": 310.510201,
        "polar_distance": 35.870036,
        "pos_update_time": "2020-10-12T20:24:48Z",
        "reg": "N8567Z",
        "route": "TPA-BWI-OAK",
        "selected_altitude": 38016,
        "speed": 397,
        "squawk": "7050",
        "type": "B738",
        "vert_rate": -128
    }
]

为了让Aircraft Positions应用程序不仅能够简单地认证用户,还能检查用户是否有权限访问特定资源,我重构了SecurityConfig来执行这项任务。

第一步是用@EnableWebSecurity替换类级注解@Configuration@EnableWebSecurity是一个元注解,包含了被移除的@Configuration,仍然允许在注解类中创建 bean 方法;但它还包括@EnableGlobalAuthentication注解,允许 Spring Boot 为应用程序自动配置更多安全性。这为Aircraft Positions应用程序为定义授权机制本身做好了准备。

我重构了SecurityConfig类,使其扩展WebSecurityConfigurerAdapter,这是一个抽象类,具有许多对扩展应用程序 Web 安全性基本配置有用的成员变量和方法。特别是,WebSecurityConfigurerAdapter有一个configure(HttpSecurity http)方法,为用户授权提供了基本实现:

protected void configure(HttpSecurity http) throws Exception {
    // Logging statement omitted

    http
        .authorizeRequests()
            .anyRequest().authenticated()
            .and()
        .formLogin().and()
        .httpBasic();
}

在前述实现中,发出了以下指令:

  • 授权任何经过身份验证的用户的请求。

  • 提供简单的登录和注销表单(开发者可以覆盖的表单)。

  • 针对非浏览器用户代理(例如命令行工具)启用了 HTTP 基本认证。

如果开发人员未提供授权详细信息,则此方法提供了合理的安全姿态。下一步是提供更具体的信息,从而覆盖此行为。

我使用 IntelliJ for Mac 的CTRL+O键盘快捷键或单击右鼠标按钮,然后生成以打开生成菜单,然后选择“Override methods…”选项来显示可重写/可实现的方法。选择具有签名configure(http:HttpSecurity):void的方法将生成以下方法:

@Override
protected void configure(HttpSecurity http) throws Exception {
    super.configure(http);
}

然后,我用以下代码替换了对超类方法的调用:

// User authorization
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .mvcMatchers("/aircraftadmin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .and()
            .httpBasic();
}

configure(HttpSecurity http)方法的实现执行以下操作:

  • 使用String模式匹配器,将请求路径与/aircraftadmin及其以下所有路径进行比较。

  • 如果匹配成功,且用户具有“ADMIN”角色/权限,则授权用户发出请求。

  • 对于任何已认证用户,均可完成其他请求

  • 提供简单的登录和注销表单(由开发人员创建的可重写表单)。

  • 启用非浏览器用户代理(命令行工具等)的 HTTP 基本身份验证。

这种最小授权机制将两个过滤器放置在安全过滤器链中:一个用于检查路径匹配和管理员权限,另一个用于所有其他路径和已认证用户。分层方法允许捕获相当简单、易于理解的复杂场景逻辑。

(用于基于表单的安全性的)SecurityConfig类的最终版本如下:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration
    .EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration
    .WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final PasswordEncoder pwEncoder =
            PasswordEncoderFactories.createDelegatingPasswordEncoder();

    @Bean
    UserDetailsService authentication() {
        UserDetails peter = User.builder()
                .username("peter")
                .password(pwEncoder.encode("ppassword"))
                .roles("USER")
                .build();

        UserDetails jodie = User.builder()
                .username("jodie")
                .password(pwEncoder.encode("jpassword"))
                .roles("USER", "ADMIN")
                .build();

        System.out.println("   >>> Peter's password: " + peter.getPassword());
        System.out.println("   >>> Jodie's password: " + jodie.getPassword());

        return new InMemoryUserDetailsManager(peter, jodie);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/aircraftadmin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .httpBasic();
    }
}

现在确认所有操作按预期进行。我重新启动Aircraft Positions应用程序,并作为 Jodie 从命令行访问/aircraftadmin端点(由于篇幅原因省略了第一个端点访问确认;部分标题和结果也因简洁起见而省略):

mheckler-a01 :: ~ » http :8080/aircraftadmin --auth jodie:jpassword
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 36000,
        "barometer": 1012.8,
        "bds40_seen_time": "2020-10-13T19:16:10Z",
        "callsign": "UPS2806",
        "category": "A5",
        "flightno": "5X2806",
        "heading": 289,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-13T19:16:14Z",
        "lat": 38.791122,
        "lon": -90.21286,
        "polar_bearing": 189.515723,
        "polar_distance": 9.855602,
        "pos_update_time": "2020-10-13T19:16:12Z",
        "reg": "N331UP",
        "route": "SDF-DEN",
        "selected_altitude": 36000,
        "speed": 374,
        "squawk": "6652",
        "type": "B763",
        "vert_rate": 0
    },
    {
        "altitude": 25100,
        "barometer": 1012.8,
        "bds40_seen_time": "2020-10-13T19:16:13Z",
        "callsign": "ASH5937",
        "category": "A3",
        "flightno": "AA5937",
        "heading": 44,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-13T19:16:13Z",
        "lat": 39.564148,
        "lon": -90.102459,
        "polar_bearing": 5.201331,
        "polar_distance": 36.841422,
        "pos_update_time": "2020-10-13T19:16:13Z",
        "reg": "N905J",
        "route": "DFW-BMI-DFW",
        "selected_altitude": 11008,
        "speed": 476,
        "squawk": "6270",
        "type": "CRJ9",
        "vert_rate": -2624
    }
]

由于具有“ADMIN”角色,Jodie 可以按预期访问/aircraftadmin端点。接下来,我尝试使用 Peter 的登录。请注意,由于篇幅原因省略了第一个端点访问确认;为简洁起见,某些标题也已省略:

mheckler-a01 :: ~ » http :8080/aircraftadmin --auth peter:ppassword
HTTP/1.1 403
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

{
    "error": "Forbidden",
    "message": "",
    "path": "/aircraftadmin",
    "status": 403,
    "timestamp": "2020-10-13T19:18:10.961+00:00"
}

这正是应该发生的事情,因为 Peter 只有“USER”角色,而不是“ADMIN”。系统正常运行。

代码检出检查

请从代码仓库的分支chapter10forms检出一个完整的基于表单的示例。

实施 OpenID Connect 和 OAuth2 进行身份验证和授权

尽管基于表单的身份验证和内部授权对于许多应用程序非常有用,但在许多情况下,“您所知道的”身份验证方法可能不够理想,甚至不足以实现所需的安全级别。一些例子包括但不限于以下情况:

  • 需要身份验证但不需要了解用户任何信息(或因法律或其他原因不想了解用户信息的)的免费服务

  • 在不认为单因素身份验证足够安全的情况下,需要和/或需要多因素身份验证(MFA)支持的情况

  • 关于创建和维护用于管理密码、角色/权限和其他必要机制的安全软件基础设施的关注

  • 对于妥协事件的责任问题的关注

对于这些问题或目标,没有简单的答案,但是一些公司已经构建并维护了强大且安全的基础设施资产,用于验证用户和验证权限,并提供给广大用户以低廉或零成本的一般使用。像 Okta 这样的领先安全供应商以及其他需要经过验证用户和权限验证的企业:Facebook、GitHub 和 Google 等等。Spring Security 支持所有这些选项,以及通过 OpenID Connect 和 OAuth2 提供更多选项。

OAuth2 是为第三方授权用户访问指定资源(如基于云的服务、共享存储和应用程序)提供手段而创建的。OpenID Connect 在 OAuth2 的基础上添加了一致的、标准化的身份验证,使用以下一种或多种因素之一:

  • 你知道的东西,例如密码

  • 你拥有的东西,比如硬件密钥

  • 你是某个人,比如生物特征识别器

Spring Boot 和 Spring Security 支持由 Facebook、GitHub、Google 和 Okta 提供的 OpenID Connect 和 OAuth2 实现的开箱即用的自动配置,由于 OpenID Connect 和 OAuth2 的公布标准以及 Spring Security 的可扩展架构,额外的提供者可以很容易地进行配置。我在接下来的示例中使用了 Okta 的库和身份验证+授权机制,但是提供者之间的差异基本上是主题的变化。请随意使用最适合您需求的安全提供程序。

在这个例子中,我重构飞机位置以作为 OpenID Connect 和 OAuth2 客户端应用程序,利用 Okta 的能力来验证用户并获取用户访问由资源服务器公开的资源的权限。然后,我重构 PlaneFinder 以根据从飞机位置(客户端)应用程序的请求中提供的凭据提供其资源——作为 OAuth2 资源服务器。

飞机位置客户端应用程序

我通常从堆栈中最后的应用程序开始,但在这种情况下,由于与用户获得(或被拒绝)访问资源相关的流程,我认为相反的方法更有价值。

用户访问使用某种机制对其进行身份验证的客户端应用程序。一旦经过身份验证,用户对资源的请求将被转发到所谓的资源服务器,该服务器保存和管理所述资源。这是大多数人反复遵循并感到非常熟悉的逻辑流程。通过按照相同顺序启用安全性——先客户端,然后是资源服务器——它与我们自己的预期流程完美对齐。

将 OpenID Connect 和 OAuth2 依赖项添加到飞机位置

与基于表单的安全性一样,当在 Spring Initializr 中创建一个新的 Spring Boot 客户端项目以启动 OpenID Connect 和 OAuth2 在绿地客户端应用中时,可以通过 Spring Initializr 简单地添加额外的依赖项,如图 10-2 所示。

sbur 1002

图 10-2. 在 Spring Initializr 中使用 Okta 配置 OpenID Connect 和 OAuth2 客户端应用所需的依赖项。

更新现有的应用程序只需要更多的努力。因为我正在替换当前的基于表单的安全性,所以首先删除我在上一节中添加的 Spring Security 的现有依赖项。然后,我添加两个与 Initializr 添加的相同的依赖项,一个是用于 OAuth2 客户端(包括 OpenID Connect 认证部分和其他必要组件),另一个是用于 Okta 的依赖项,因为我们将使用他们的基础设施来认证和管理权限,到 Aircraft Positionspom.xml Maven 构建文件中:

<!--	Comment out or remove this 	-->
<!--<dependency>-->
<!--	<groupId>org.springframework.boot</groupId>-->
<!--	<artifactId>spring-boot-starter-security</artifactId>-->
<!--</dependency>-->

<!--	Add these  	    		    -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>com.okta.spring</groupId>
    <artifactId>okta-spring-boot-starter</artifactId>
    <version>1.4.0</version>
</dependency>
注意

当前包含的 Okta 的 Spring Boot Starter 库版本为 1.4.0. 这是经过测试并与当前版本的 Spring Boot 良好配合的版本。当开发人员手动向构建文件添加依赖项时,一个好的实践习惯是访问 Spring Initializr,选择当前版本(当时的)的 Boot,添加 Okta(或其他具体版本)依赖项,并 探索 项目以确认当前推荐的版本号。

一旦刷新构建,就是重构代码的时候了,使 Aircraft Positions 能够与 Okta 进行身份验证并获取用户权限。

重构 Aircraft Positions 以进行身份验证和授权

配置当前 Aircraft Positions 作为 OAuth2 客户端应用程序实际上有三件事需要做:

  • 删除基于表单的安全配置。

  • 在用于访问 PlaneFinder 端点的创建的 WebClient 中添加 OAuth2 配置。

  • 指定 OpenID Connect+OAuth2 注册的客户端凭据和安全提供者的 URI(在本例中为 Okta)。

我首先一起处理前两者,首先完全删除 SecurityConfig 类的主体。如果仍希望或需要通过本地提供的 Aircraft Positions 访问控制资源,则 SecurityConfig 当然可以保留原样或进行一些微小修改;但是,对于本示例,PlaneFinder 扮演资源服务器的角色,因此应控制或拒绝对请求资源的访问价值。 Aircraft Positions 只是一个用户客户端,与安全基础设施合作,使用户能够进行身份验证,然后将资源请求传递给资源服务器。

我将@EnableWebSecurity注解替换为@Configuration,因为不再需要本地认证的自动配置。此外,从类头部删除了extends WebSecurityConfigurerAdapter,因为此版本的Aircraft Positions应用程序不再限制对其端点的请求,而是通过请求将用户的权限传递给 PlaneFinder,使其可以将这些权限与每个资源允许的权限进行比较,并据此采取行动。

接下来,在SecurityConfig类中创建了一个WebClient bean,以在整个Aircraft Positions应用程序中使用。目前这不是硬性要求,因为我可以将 OAuth2 配置直接整合到分配给PositionRetriever成员变量的WebClient的创建中,而且这样做确实有其合理的理由。尽管如此,PositionRetriever需要访问一个WebClient,但是配置WebClient来处理 OpenID Connect 和 OAuth2 配置远超出了PositionRetriever的核心任务:检索飞机位置。

为身份验证和授权创建和配置WebClient非常适合名为SecurityConfig的类的范围内:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.registration
    .ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web
    .OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.reactive.function.client
    .ServletOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class SecurityConfig {
    @Bean
    WebClient client(ClientRegistrationRepository regRepo,
                     OAuth2AuthorizedClientRepository cliRepo) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction filter =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction
                    (regRepo, cliRepo);

        filter.setDefaultOAuth2AuthorizedClient(true);

        return WebClient.builder()
                .baseUrl("http://localhost:7634/")
                .apply(filter.oauth2Configuration())
                .build();
    }
}

client() bean 创建方法中,自动装配了两个 bean:

  • ClientRegistrationRepository,一个 OAuth2 客户端列表,由应用程序指定使用,通常在类似application.yml的属性文件中配置。

  • OAuth2AuthorizedClientRepository,一个 OAuth2 客户端列表,表示已认证用户并管理该用户的OAuth2AccessToken

在创建和配置WebClient bean 的方法内部,我执行以下操作:

  1. 我使用两个注入的存储库初始化了一个过滤器函数。

  2. 我确认应使用默认的授权客户端。这通常是情况——毕竟,已认证用户通常是希望访问资源的资源所有者——但是可以选择为涉及委派访问的用例使用不同的授权客户端。我指定 URL 并将配置为 OAuth2 的过滤器应用于WebClient构建器,并构建WebClient,将其作为 Spring bean 返回并添加到ApplicationContext。现在,启用了 OAuth2 的WebClient可以在整个Aircraft Positions应用程序中使用。

由于WebClient bean 现在通过一个 bean 创建方法由应用程序创建,我现在移除了在PositionRetriever类中创建和直接分配WebClient对象的语句,并将其替换为一个简单的成员变量声明。使用 Lombok 的@AllArgsConstructor注解在类上,Lombok 自动为该类生成的“所有参数构造函数”添加了一个WebClient参数。由于ApplicationContext中有一个WebClient bean,Spring Boot 会自动将其注入到PositionRetriever中,并自动分配给WebClient成员变量。重新构造后的PositionRetriever类现在如下所示:

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@AllArgsConstructor
@Component
public class PositionRetriever {
    private final AircraftRepository repository;
    private final WebClient client;

    Iterable<Aircraft> retrieveAircraftPositions() {
        repository.deleteAll();

        client.get()
                .uri("/aircraft")
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(ac -> !ac.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        return repository.findAll();
    }
}

在本节的早些时候,我提到了ClientRegistrationRepository的使用,这是一个指定应用程序使用的 OAuth2 客户端列表。有多种方法可以填充此存储库,但通常是将条目指定为应用程序属性。在这个例子中,我将以下信息添加到Aircraft Positionapplication.yml文件中(这里显示了虚拟值):

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: <your_assigned_client_id_here>
            client-secret: <your_assigned_client_secret_here>
        provider:
          okta:
            issuer-uri: https://<your_assigned_subdomain_here>
                            .oktapreview.com/oauth2/default

有了这些信息,Aircraft Positions应用程序的ClientRegistrationRepository将有一个单独的 Okta 条目,在用户尝试访问该应用程序时将自动使用它。

提示

如果定义了多个条目,将在第一次请求时呈现一个网页,提示用户选择一个提供程序。

我对Aircraft Positions做了另一个小改动(以及对PositionRetriever的一个小的下游改动),只是为了更好地演示成功和失败的用户授权。我复制了当前在PositionController类中定义的唯一端点,将其重命名,并分配一个映射,暗示“仅管理员”访问:

import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@AllArgsConstructor
@RestController
public class PositionController {
    private final PositionRetriever retriever;

    @GetMapping("/aircraft")
    public Iterable<Aircraft> getCurrentAircraftPositions() {
        return retriever.retrieveAircraftPositions("aircraft");
    }

    @GetMapping("/aircraftadmin")
    public Iterable<Aircraft> getCurrentAircraftPositionsAdminPrivs() {
        return retriever.retrieveAircraftPositions("aircraftadmin");
    }
}

为了适应使用单个方法访问 PlaneFinder 两个端点的需求,在PositionRetriever中,我将其retrieveAircraftPositions()方法修改为接受一个动态路径参数String endpoint,并在构建客户端请求时使用它。更新后的PositionRetriever类如下所示:

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@AllArgsConstructor
@Component
public class PositionRetriever {
    private final AircraftRepository repository;
    private final WebClient client;

    Iterable<Aircraft> retrieveAircraftPositions(String endpoint) {
        repository.deleteAll();

        client.get()
                .uri((null != endpoint) ? endpoint : "")
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(ac -> !ac.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        return repository.findAll();
    }
}

现在,Aircraft Positions已经是一个完全配置的 OpenID Connect 和 OAuth2 客户端应用程序。接下来,我将重构 PlaneFinder,使其成为一个 OAuth2 资源服务器,在用户授权时提供资源。

PlaneFinder 资源服务器

在任何涉及更改依赖项的重构中,开始的地方是构建文件。

将 OpenID Connect 和 OAuth2 依赖项添加到 Aircraft Positions

正如之前提到的,在创建新的 Spring Boot OAuth2 资源服务器时,可以通过 Spring Initializr 简单地添加另一个或两个依赖项到绿地客户端应用程序,如图 10-3 所示。

sbur 1003

图 10-3. 使用 Okta 在 Spring Initializr 中的 OAuth2 资源服务器的依赖项

更新现有的 PlaneFinder 应用程序非常简单。我在 PlaneFinder 的pom.xml Maven 构建文件中添加了与 Initializr 添加的 OAuth2 资源服务器和 Okta 相同的两个依赖项,因为我们将使用它们的基础设施来验证权限。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>com.okta.spring</groupId>
    <artifactId>okta-spring-boot-starter</artifactId>
    <version>1.4.0</version>
</dependency>

一旦我刷新了构建,现在是时候重构代码,使 PlaneFinder 能够验证与入站请求提供的用户权限相匹配的用户权限,并授予(或拒绝)对 PlaneFinder 资源的访问。

为资源授权重构 PlaneFinder

到此为止,使用 Okta 为我们的分布式系统启用 OpenID Connect 和 OAuth2 身份验证和授权的大部分工作已经完成。正确重构 PlaneFinder 以执行 OAuth2 资源服务器的职责需要很少的工作:

  • 整合 JWT(JSON Web Token)支持

  • 将 JWT 中传递的权限与指定资源的访问所需权限进行比较

这两个任务可以通过创建一个名为SecurityWebFilterChain的单一 bean 来完成,Spring Security 将使用该 bean 来检索、验证和比较入站请求的 JWT 内容与所需权限。

再次,我创建一个SecurityConfig类,并使用@Configuration对其进行注释,以提供一个独立的位置用于 bean 创建方法。接下来,我创建一个名为securityWebFilterChain()的方法,如下所示:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http
                .authorizeExchange()
                .pathMatchers("/aircraft/**").hasAuthority("SCOPE_closedid")
                .pathMatchers("/aircraftadmin/**").hasAuthority("SCOPE_openid")
                .and().oauth2ResourceServer().jwt();

        return http.build();
    }
}

为了创建过滤器链,我自动装配了 Spring Boot 安全自动配置提供的现有ServerHttpSecurity bean。当spring-boot-starter-webflux在类路径中时,此 bean 用于支持 WebFlux 的应用程序。

注意

如果类路径中没有 WebFlux,则应用程序将使用HttpSecurity bean 及其相应的方法,就像本章早些时候在基于表单的身份验证示例中所做的那样。

然后,我配置ServerHttpSecurity bean 的安全标准,指定如何处理请求。为此,我提供了两个资源路径以匹配请求及其所需的用户权限;我还启用了使用 JWT 作为 OAuth2 资源服务器支持的配置。

注意

JWT 有时被称为bearer tokens,因为它们携带用户对资源的授权。

最后,我从ServerHttpSecurity bean 构建SecurityWebFilterChain,并将其返回,使其在整个 PlaneFinder 应用程序中作为一个 bean 可用。

当请求到达时,过滤器链将请求的资源路径与链中指定的路径进行比较,直到找到匹配项。一旦匹配成功,应用程序将使用 OAuth2 提供者(在此例中为 Okta)验证令牌的有效性,然后比较包含的权限与访问映射资源所需的权限。如果匹配成功,则授予访问权限;如果不匹配,则应用程序返回403 Forbidden状态码。

你可能已经注意到第二个pathMatcher指定了一个在 PlaneFinder 中尚不存在的资源路径。我将此路径添加到PlaneController类中,仅仅是为了能够提供成功和失败权限检查的示例。

OAuth2 提供程序可能包括几个默认权限,包括openidemailprofile等。在示例过滤器链中,我检查一个不存在的权限closedid(对于我的提供程序和 OAuth2 权限配置而言),因此任何请求资源路径以/aircraft开头的请求将失败。按目前的编写方式,对于路径以/aircraftadmin开头并携带有效令牌的任何入站请求将成功。

注意

Spring Security 在 OAuth2 提供的权限之前添加“SCOPE_”,将 Spring Security 内部的作用域概念与 OAuth2 权限一对一映射。对于使用 Spring Security 与 OAuth2 的开发者来说,这一点很重要,但实际上没有实际的区别。

为了完成代码重构,我现在在 PlaneFinder 的PlaneController类中添加了前面路径匹配引用的/aircraftadmin端点映射,简单地复制现有的/aircraft端点的功能,以演示具有不同访问条件的两个端点:

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;

import java.io.IOException;
import java.time.Duration;

@Controller
public class PlaneController {
    private final PlaneFinderService pfService;

    public PlaneController(PlaneFinderService pfService) {
        this.pfService = pfService;
    }

    @ResponseBody
    @GetMapping("/aircraft")
    public Flux<Aircraft> getCurrentAircraft() throws IOException {
        return pfService.getAircraft();
    }

    @ResponseBody
    @GetMapping("/aircraftadmin")
    public Flux<Aircraft> getCurrentAircraftByAdmin() throws IOException {
        return pfService.getAircraft();
    }

    @MessageMapping("acstream")
    public Flux<Aircraft> getCurrentACStream() throws IOException {
        return pfService.getAircraft().concatWith(
                Flux.interval(Duration.ofSeconds(1))
                        .flatMap(l -> pfService.getAircraft()));
    }
}

最后,我必须指示应用程序去哪里访问 OAuth2 提供程序,以验证传入的 JWT。关于这个的操作可能会有所不同,因为 OAuth2 提供程序端点的规范具有一定的灵活性,但是 Okta 贴心地实现了一个发行者 URI,作为一个配置的中心 URI,从中可以获得其他必要的 URI。这减少了应用开发者添加单个属性的负担。

我已将application.properties文件从键值对格式转换为application.yml,允许属性的结构化树,稍微减少了重复。请注意,这是可选的,但在属性键中出现重复时非常有用:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://<your_assigned_subdomain_here>.oktapreview.com/
              oauth2/default
  rsocket:
    server:
      port: 7635

server:
  port: 7634

现在所有元素都已就位,我重新启动 PlaneFinder OAuth2 资源服务器和Aircraft Positions OpenID Connect + OAuth2 客户端应用程序来验证结果。在浏览器中加载Aircraft Positions/aircraftadmin API 端点(http://localhost:8080/aircraftadmin),我被重定向到 Okta 进行身份验证,如图 10-4 所示。

sbur 1004

图 10-4. OpenID Connect 提供程序提供的登录提示(Okta)

一旦我提供有效的用户凭据,Okta 将经过身份验证的用户(我)重定向到客户端应用程序飞机位置。我请求的端点进而从 PlaneFinder 请求飞机位置,并传递由 Okta 提供的 JWT。一旦 PlaneFinder 将请求的路径匹配到资源路径并验证 JWT 及其包含的权限后,它将当前的飞机位置响应给飞机位置客户端应用程序,后者再将其提供给我,如图 10-5 所示。

sbur 1005

图 10-5. 成功返回当前飞机位置

如果我请求一个没有授权的资源会发生什么?为了看到一个授权失败的例子,我试图访问飞机位置/aircraft端点,网址为http://localhost:8080/aircraft,结果如图 10-6 所示。注意,由于我已经经过身份验证,因此无需重新认证即可继续访问飞机位置应用程序。

sbur 1006

图 10-6. 授权失败的结果

注意,响应未提供有关无法检索结果的详细信息。通常认为,避免泄露可能为潜在的恶意行为者提供有助于最终妥协的信息是一个良好的安全实践。但是,访问飞机位置的日志,我看到了以下额外的信息:

Forbidden: 403 Forbidden from GET http://localhost:7634/aircraft with root cause

这恰好是预期的响应,因为 PlaneFinder 的过滤器匹配请求资源路径在或者低于/aircraft时预期的未定义权限closedid未提供。

这些例子被精简到最大限度,但它们代表了使用受尊敬的第三方安全提供者进行 OpenID Connect 认证和 OAuth2 授权的关键方面。其他所有定制和扩展此类认证和授权的方法都建立在这些基本原则和步骤之上,适用于 Spring Boot 应用程序。

代码检查

欲获取完整的章节代码,请查看代码库的chapter10end分支。

概要

理解认证和授权的概念对于构建安全应用程序至关重要,这为用户验证和访问控制奠定了基础。Spring Security 将认证和授权选项与 HTTP 防火墙、过滤器链、广泛使用的 IETF 和 W3C 标准以及交换选项等机制结合起来,帮助确保应用程序的安全性。采用安全开箱即用的理念,Spring Security 利用 Boot 强大的自动配置来评估开发者的输入和可用依赖项,以尽量少的工作量提供 Spring Boot 应用程序的最大安全性。

本章讨论了安全的几个核心方面以及它们如何适用于应用程序。我演示了多种将 Spring Security 整合到 Spring Boot 应用程序中的方法,以加强应用程序的安全姿态,填补覆盖范围中的危险漏洞,并减少攻击面。

下一章将探讨部署 Spring Boot 应用程序到各种目标位置的方法,并讨论它们的相对优点。我还将演示如何创建这些部署工件,提供它们的最佳执行选项,并展示如何验证它们的组件和来源。

第十一章:部署您的 Spring Boot 应用程序

在软件开发中,部署是将应用程序推向生产的入口。

无论应用程序向最终用户承诺了多少功能,直到这些用户真正能够使用该应用程序,它都仅仅是一种学术性的假设。从比喻和实际上来看,部署是应用程序的回报。

参考 Spring Initializr,许多开发人员知道 Spring Boot 应用程序可以创建为 WAR 文件或 JAR 文件。大多数开发人员也知道有很多很好的理由(本书前面提到的几个)不选择 WAR 选项,而选择创建可执行的 JAR 文件,反之则没有几个好理由。许多开发人员可能没有意识到的是,即使构建 Spring Boot 可执行 JAR,也有许多部署选项可以满足各种需求和用例。

在本章中,我将探讨部署 Spring Boot 应用程序的不同目标位置的有用选项,并讨论它们的相对优点。然后,我将演示如何创建这些部署工件,解释实现最佳执行的选项,并展示如何验证它们的组件和来源。您几乎可以肯定,您有比您意识到的更多和更好的工具来部署您的 Spring Boot 应用程序。

代码检出检查

请从代码库中检查分支 chapter11begin 开始。

重新审视 Spring Boot 可执行 JAR

正如在第一章中讨论的那样,Spring Boot 的可执行 JAR 提供了单一、自包含、可测试和可部署单元的最大效用和多样性。创建和迭代速度快,动态自配置以适应环境变化,并且非常简单地分发和维护。

每个云服务提供商都提供了一个应用程序托管选项,广泛用于从原型到生产部署,大多数这些应用平台都期望一个基本上是自包含的可部署应用程序,只提供最基本的环境要求。Spring Boot JAR 在这些干净的环境中非常自然地适应,只需有 JDK 存在即可无摩擦地执行;一些平台甚至因其与应用托管的完美匹配而具体指定使用 Spring Boot。通过带有 HTTP 交换、消息传递等外部交互机制,Spring Boot 应用程序可以消除应用服务器或其他外部依赖的安装、配置和维护。这极大地减少了开发工作量和应用平台的开销。

由于 Spring Boot 应用程序完全控制依赖库,因此它消除了对外部依赖变更的恐惧。多年来,对于依赖于底层应用平台维护的外部组件的应用程序,计划更新应用服务器、servlet 引擎、数据库或消息传递库等诸多关键组件时,导致了无数非 Boot 应用程序的崩溃。在这些应用程序中,开发人员必须高度警惕,以防因单个依赖库的点发布变更而导致不计其数的未计划停机。激动人心的时刻。

对于 Spring Boot 应用程序,无论是核心 Spring 库还是第二(或第三、第四等)层依赖关系的升级,都不再那么痛苦和紧张。应用程序开发人员升级并测试应用程序,并在满意一切正常时部署更新(通常使用 blue-green 部署)。由于依赖项不再是应用程序外部的,而是与之捆绑在一起,开发人员可以完全控制依赖项版本和升级时机。

Spring Boot JAR 还有一个有用的技巧,感谢 Spring Boot Maven 和 Gradle 插件:能够创建所谓的“完全可执行” JAR。引号是有意的,并且也在官方文档中出现,因为应用程序仍然需要 JDK 才能正常运行。那么,“完全可执行”的 Spring Boot 应用程序是什么意思,如何创建它呢?

让我们从“如何”开始。

创建“完全可执行”的 Spring Boot JAR

我将使用 PlaneFinder 作为示例。为了比较,我使用 mvn clean package 命令在不进行任何更改的情况下从命令行构建项目。这导致在项目的 target 目录中创建了以下 JAR 文件(结果进行了修整以适应页面):

» ls -lb target/*.jar

-rw-r--r--  1 markheckler  staff  27085204 target/planefinder-0.0.1-SNAPSHOT.jar

这个 Spring Boot JAR 被称为“可执行 JAR”,因为它包含了整个应用程序,无需外部依赖;要执行它,只需安装 JDK 并提供 JVM。以当前状态运行该应用程序看起来像这样(结果进行了修整以适应页面):

» java -jar target/planefinder-0.0.1-SNAPSHOT.jar

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting PlanefinderApplication v0.0.1-SNAPSHOT
: No active profile set, falling back to default profiles: default
: Bootstrapping Spring Data R2DBC repositories in DEFAULT mode.
: Finished Spring Data repository scanning in 132 ms. Found 1 R2DBC
  repository interfaces.
: Netty started on port(s): 7634
: Netty RSocket started on port(s): 7635
: Started PlanefinderApplication in 2.75 seconds (JVM running for 3.106)

当然,这符合预期,并且它作为接下来的基准。现在我重新访问 PlaneFinder 的 pom.xml,以在现有的 spring-boot-maven-plug-in 部分中添加所示的 XML 片段,如 Figure 11-1 中所示。

sbur 1101

Figure 11-1. PlaneFinder pom.xml 文件的插件部分

回到终端后,我再次使用 mvn clean package 命令从命令行构建项目。这次,在项目的 target 目录中创建的 JAR 文件有明显的不同,如下输出所示(结果进行了修整以适应页面):

» ls -lb target/*.jar

-rwxr--r--  1 markheckler  staff  27094314 target/planefinder-0.0.1-SNAPSHOT.jar

它比 Boot 的标准可执行 JAR 稍大一点,大约是 9,110 字节,或者稍少于 9 KB。这带来了什么好处呢?

Java JAR 文件是从结尾向开头读取的——是的,您没有看错——直到找到文件结束标记。当创建所谓的“完全可执行 JAR”时,Spring Boot Maven 插件巧妙地在通常的 Spring Boot 可执行 JAR 的开头添加了一个脚本,使其能够在类 Unix 或 Linux 系统上像任何其他可执行二进制文件一样运行(假设存在 JDK),包括在init.dsystemd中注册。在编辑器中检查 PlaneFinder 的 JAR 文件结果如下(为简洁起见,仅显示了脚本头的部分内容;它非常广泛):

#!/bin/bash
#
#    .   ____          _            __ _ _
#   /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
#  ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
#   \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
#    '  |____| .__|_| |_|_| |_\__, | / / / /
#   =========|_|==============|___/=/_/_/_/
#   :: Spring Boot Startup Script ::
#

### BEGIN INIT INFO
# Provides:          planefinder
# Required-Start:    $remote_fs $syslog $network
# Required-Stop:     $remote_fs $syslog $network
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: planefinder
# Description:       Data feed for SBUR
# chkconfig:         2345 99 01
### END INIT INFO

...

# Action functions
start() {
  if [[ -f "$pid_file" ]]; then
    pid=$(cat "$pid_file")
    isRunning "$pid" && { echoYellow "Already running [$pid]"; return 0; }
  fi
  do_start "$@"
}

do_start() {
  working_dir=$(dirname "$jarfile")
  pushd "$working_dir" > /dev/null
  if [[ ! -e "$PID_FOLDER" ]]; then
    mkdir -p "$PID_FOLDER" &> /dev/null
    if [[ -n "$run_user" ]]; then
      chown "$run_user" "$PID_FOLDER"
    fi
  fi
  if [[ ! -e "$log_file" ]]; then
    touch "$log_file" &> /dev/null
    if [[ -n "$run_user" ]]; then
      chown "$run_user" "$log_file"
    fi
  fi
  if [[ -n "$run_user" ]]; then
    checkPermissions || return $?
    if [ $USE_START_STOP_DAEMON = true ] && type start-stop-daemon >
        /dev/null 2>&1; then
      start-stop-daemon --start --quiet \
        --chuid "$run_user" \
        --name "$identity" \
        --make-pidfile --pidfile "$pid_file" \
        --background --no-close \
        --startas "$javaexe" \
        --chdir "$working_dir" \
       —"${arguments[@]}" \
        >> "$log_file" 2>&1
      await_file "$pid_file"
    else
      su -s /bin/sh -c "$javaexe $(printf "\"%s\" " "${arguments[@]}") >>
        \"$log_file\" 2>&1 & echo \$!" "$run_user" > "$pid_file"
    fi
    pid=$(cat "$pid_file")
  else
    checkPermissions || return $?
    "$javaexe" "${arguments[@]}" >> "$log_file" 2>&1 &
    pid=$!
    disown $pid
    echo "$pid" > "$pid_file"
  fi
  [[ -z $pid ]] && { echoRed "Failed to start"; return 1; }
  echoGreen "Started [$pid]"
}

stop() {
  working_dir=$(dirname "$jarfile")
  pushd "$working_dir" > /dev/null
  [[ -f $pid_file ]] ||
    { echoYellow "Not running (pidfile not found)"; return 0; }
  pid=$(cat "$pid_file")
  isRunning "$pid" || { echoYellow "Not running (process ${pid}).
    Removing stale pid file."; rm -f "$pid_file"; return 0; }
  do_stop "$pid" "$pid_file"
}

do_stop() {
  kill "$1" &> /dev/null || { echoRed "Unable to kill process $1"; return 1; }
  for ((i = 1; i <= STOP_WAIT_TIME; i++)); do
    isRunning "$1" || { echoGreen "Stopped [$1]"; rm -f "$2"; return 0; }
    [[ $i -eq STOP_WAIT_TIME/2 ]] && kill "$1" &> /dev/null
    sleep 1
  done
  echoRed "Unable to kill process $1";
  return 1;
}

force_stop() {
  [[ -f $pid_file ]] ||
    { echoYellow "Not running (pidfile not found)"; return 0; }
  pid=$(cat "$pid_file")
  isRunning "$pid" ||
    { echoYellow "Not running (process ${pid}). Removing stale pid file.";
    rm -f "$pid_file"; return 0; }
  do_force_stop "$pid" "$pid_file"
}

do_force_stop() {
  kill -9 "$1" &> /dev/null ||
      { echoRed "Unable to kill process $1"; return 1; }
  for ((i = 1; i <= STOP_WAIT_TIME; i++)); do
    isRunning "$1" || { echoGreen "Stopped [$1]"; rm -f "$2"; return 0; }
    [[ $i -eq STOP_WAIT_TIME/2 ]] && kill -9 "$1" &> /dev/null
    sleep 1
  done
  echoRed "Unable to kill process $1";
  return 1;
}

restart() {
  stop && start
}

force_reload() {
  working_dir=$(dirname "$jarfile")
  pushd "$working_dir" > /dev/null
  [[ -f $pid_file ]] || { echoRed "Not running (pidfile not found)";
      return 7; }
  pid=$(cat "$pid_file")
  rm -f "$pid_file"
  isRunning "$pid" || { echoRed "Not running (process ${pid} not found)";
      return 7; }
  do_stop "$pid" "$pid_file"
  do_start
}

status() {
  working_dir=$(dirname "$jarfile")
  pushd "$working_dir" > /dev/null
  [[ -f "$pid_file" ]] || { echoRed "Not running"; return 3; }
  pid=$(cat "$pid_file")
  isRunning "$pid" || { echoRed "Not running (process ${pid} not found)";
      return 1; }
  echoGreen "Running [$pid]"
  return 0
}

run() {
  pushd "$(dirname "$jarfile")" > /dev/null
  "$javaexe" "${arguments[@]}"
  result=$?
  popd > /dev/null
  return "$result"
}

# Call the appropriate action function
case "$action" in
start)
  start "$@"; exit $?;;
stop)
  stop "$@"; exit $?;;
force-stop)
  force_stop "$@"; exit $?;;
restart)
  restart "$@"; exit $?;;
force-reload)
  force_reload "$@"; exit $?;;
status)
  status "$@"; exit $?;;
run)
  run "$@"; exit $?;;
*)
  echo "Usage: $0 {start|stop|force-stop|restart|force-reload|status|run}";
    exit 1;
esac

exit 0
<binary portion omitted>

Spring Boot Maven(或选择作为构建系统的 Gradle)插件还会为输出 JAR 设置文件所有者权限以读取、写入和执行(rwx)。这样做使其能够按前述方式执行,并允许头脚本定位 JDK,准备应用程序以及运行它,如此演示(结果已经修整和编辑以适应页面):

» target/planefinder-0.0.1-SNAPSHOT.jar

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting PlanefinderApplication v0.0.1-SNAPSHOT
: No active profile set, falling back to default profiles: default
: Bootstrapping Spring Data R2DBC repositories in DEFAULT mode.
: Finished Spring Data repository scanning in 185 ms.
  Found 1 R2DBC repository interfaces.
: Netty started on port(s): 7634
: Netty RSocket started on port(s): 7635
: Started PlanefinderApplication in 2.938 seconds (JVM running for 3.335)

现在我已经演示了如何操作,是时候讨论此选项为我们带来了什么。

这是什么意思?

创建 Spring Boot“完全可执行”JAR 的能力并不是解决所有问题的方法,但在必要时它确实提供了与底层 Unix 和 Linux 系统更深层次集成的独特能力。由于嵌入的启动脚本和执行权限,添加 Spring Boot 应用程序以提供启动功能变得非常简单。

如果您的当前应用环境中不需要或无法利用该功能,您应继续简单地创建典型的 Spring Boot 可执行 JAR 输出,利用java -jar。这只是您工具箱中的另一个工具,无需额外成本并且几乎不需要您投入精力去实施,当您发现需要时即可使用。

解压缩的 JAR

Spring Boot 创新的方法将依赖的 JAR 文件完整保留在 Boot 可执行 JAR 文件中,未经更改,非常适合后续操作,如提取。反转添加到 Spring Boot 可执行 JAR 文件中的过程会产生组件工件的原始、未更改状态。听起来很简单,因为确实如此

有很多原因使您希望将 Spring Boot 可执行 JAR 文件重新解压为其各个独立部分:

  • 提取的 Boot 应用程序执行速度略有提升。尽管这很少是重新解压的理由,但这是一个不错的附加优势。

  • 提取的依赖是可以轻松替换的独立单元。应用程序更新可以更快速地进行,或者带宽更低,因为只需重新部署更改的文件。

  • 许多云平台,如 Heroku 和任何构建或基于 Cloud Foundry 的品牌/衍生品,都会在应用部署过程中执行此操作。尽可能地将本地和远程环境镜像化可以帮助确保一致性,并在必要时诊断任何问题。

标准的 Spring Boot 可执行 JAR 和“完全可执行”JAR 都可以通过以下方式重新生成,使用jar -xvf <spring_boot_jar>(为简洁起见,大多数文件条目已删除):

» mkdir expanded
» cd expanded
» jar -xvf ../target/planefinder-0.0.1-SNAPSHOT.jar
  created: META-INF/
 inflated: META-INF/MANIFEST.MF
  created: org/
  created: org/springframework/
  created: org/springframework/boot/
  created: org/springframework/boot/loader/
  created: org/springframework/boot/loader/archive/
  created: org/springframework/boot/loader/data/
  created: org/springframework/boot/loader/jar/
  created: org/springframework/boot/loader/jarmode/
  created: org/springframework/boot/loader/util/
  created: BOOT-INF/
  created: BOOT-INF/classes/
  created: BOOT-INF/classes/com/
  created: BOOT-INF/classes/com/thehecklers/
  created: BOOT-INF/classes/com/thehecklers/planefinder/
  created: META-INF/maven/
  created: META-INF/maven/com.thehecklers/
  created: META-INF/maven/com.thehecklers/planefinder/
 inflated: BOOT-INF/classes/schema.sql
 inflated: BOOT-INF/classes/application.properties
 inflated: META-INF/maven/com.thehecklers/planefinder/pom.xml
 inflated: META-INF/maven/com.thehecklers/planefinder/pom.properties
  created: BOOT-INF/lib/
 inflated: BOOT-INF/classpath.idx
 inflated: BOOT-INF/layers.idx
»

一旦文件被解压缩,我发现使用*nix tree命令更加直观地查看结构是很有用的:

» tree
.
├── BOOT-INF
│   ├── classes
│   │   ├── application.properties
│   │   ├── com
│   │   │   └── thehecklers
│   │   │       └── planefinder
│   │   │           ├── Aircraft.class
│   │   │           ├── DbConxInit.class
│   │   │           ├── PlaneController.class
│   │   │           ├── PlaneFinderService.class
│   │   │           ├── PlaneRepository.class
│   │   │           ├── PlanefinderApplication.class
│   │   │           └── User.class
│   │   └── schema.sql
│   ├── classpath.idx
│   ├── layers.idx
│   └── lib
│       ├── h2-1.4.200.jar
│       ├── jackson-annotations-2.11.3.jar
│       ├── jackson-core-2.11.3.jar
│       ├── jackson-databind-2.11.3.jar
│       ├── jackson-dataformat-cbor-2.11.3.jar
│       ├── jackson-datatype-jdk8-2.11.3.jar
│       ├── jackson-datatype-jsr310-2.11.3.jar
│       ├── jackson-module-parameter-names-2.11.3.jar
│       ├── jakarta.annotation-api-1.3.5.jar
│       ├── jul-to-slf4j-1.7.30.jar
│       ├── log4j-api-2.13.3.jar
│       ├── log4j-to-slf4j-2.13.3.jar
│       ├── logback-classic-1.2.3.jar
│       ├── logback-core-1.2.3.jar
│       ├── lombok-1.18.16.jar
│       ├── netty-buffer-4.1.54.Final.jar
│       ├── netty-codec-4.1.54.Final.jar
│       ├── netty-codec-dns-4.1.54.Final.jar
│       ├── netty-codec-http-4.1.54.Final.jar
│       ├── netty-codec-http2-4.1.54.Final.jar
│       ├── netty-codec-socks-4.1.54.Final.jar
│       ├── netty-common-4.1.54.Final.jar
│       ├── netty-handler-4.1.54.Final.jar
│       ├── netty-handler-proxy-4.1.54.Final.jar
│       ├── netty-resolver-4.1.54.Final.jar
│       ├── netty-resolver-dns-4.1.54.Final.jar
│       ├── netty-transport-4.1.54.Final.jar
│       ├── netty-transport-native-epoll-4.1.54.Final-linux-x86_64.jar
│       ├── netty-transport-native-unix-common-4.1.54.Final.jar
│       ├── r2dbc-h2-0.8.4.RELEASE.jar
│       ├── r2dbc-pool-0.8.5.RELEASE.jar
│       ├── r2dbc-spi-0.8.3.RELEASE.jar
│       ├── reactive-streams-1.0.3.jar
│       ├── reactor-core-3.4.0.jar
│       ├── reactor-netty-core-1.0.1.jar
│       ├── reactor-netty-http-1.0.1.jar
│       ├── reactor-pool-0.2.0.jar
│       ├── rsocket-core-1.1.0.jar
│       ├── rsocket-transport-netty-1.1.0.jar
│       ├── slf4j-api-1.7.30.jar
│       ├── snakeyaml-1.27.jar
│       ├── spring-aop-5.3.1.jar
│       ├── spring-beans-5.3.1.jar
│       ├── spring-boot-2.4.0.jar
│       ├── spring-boot-autoconfigure-2.4.0.jar
│       ├── spring-boot-jarmode-layertools-2.4.0.jar
│       ├── spring-context-5.3.1.jar
│       ├── spring-core-5.3.1.jar
│       ├── spring-data-commons-2.4.1.jar
│       ├── spring-data-r2dbc-1.2.1.jar
│       ├── spring-data-relational-2.1.1.jar
│       ├── spring-expression-5.3.1.jar
│       ├── spring-jcl-5.3.1.jar
│       ├── spring-messaging-5.3.1.jar
│       ├── spring-r2dbc-5.3.1.jar
│       ├── spring-tx-5.3.1.jar
│       ├── spring-web-5.3.1.jar
│       └── spring-webflux-5.3.1.jar
├── META-INF
│   ├── MANIFEST.MF
│   └── maven
│       └── com.thehecklers
│           └── planefinder
│               ├── pom.properties
│               └── pom.xml
└── org
    └── springframework
        └── boot
            └── loader
                ├── ClassPathIndexFile.class
                ├── ExecutableArchiveLauncher.class
                ├── JarLauncher.class
                ├── LaunchedURLClassLoader$DefinePackageCallType.class
                ├── LaunchedURLClassLoader
                    $UseFastConnectionExceptionsEnumeration.class
                ├── LaunchedURLClassLoader.class
                ├── Launcher.class
                ├── MainMethodRunner.class
                ├── PropertiesLauncher$1.class
                ├── PropertiesLauncher$ArchiveEntryFilter.class
                ├── PropertiesLauncher$ClassPathArchives.class
                ├── PropertiesLauncher$PrefixMatchingArchiveFilter.class
                ├── PropertiesLauncher.class
                ├── WarLauncher.class
                ├── archive
                │   ├── Archive$Entry.class
                │   ├── Archive$EntryFilter.class
                │   ├── Archive.class
                │   ├── ExplodedArchive$AbstractIterator.class
                │   ├── ExplodedArchive$ArchiveIterator.class
                │   ├── ExplodedArchive$EntryIterator.class
                │   ├── ExplodedArchive$FileEntry.class
                │   ├── ExplodedArchive$SimpleJarFileArchive.class
                │   ├── ExplodedArchive.class
                │   ├── JarFileArchive$AbstractIterator.class
                │   ├── JarFileArchive$EntryIterator.class
                │   ├── JarFileArchive$JarFileEntry.class
                │   ├── JarFileArchive$NestedArchiveIterator.class
                │   └── JarFileArchive.class
                ├── data
                │   ├── RandomAccessData.class
                │   ├── RandomAccessDataFile$1.class
                │   ├── RandomAccessDataFile$DataInputStream.class
                │   ├── RandomAccessDataFile$FileAccess.class
                │   └── RandomAccessDataFile.class
                ├── jar
                │   ├── AbstractJarFile$JarFileType.class
                │   ├── AbstractJarFile.class
                │   ├── AsciiBytes.class
                │   ├── Bytes.class
                │   ├── CentralDirectoryEndRecord$1.class
                │   ├── CentralDirectoryEndRecord$Zip64End.class
                │   ├── CentralDirectoryEndRecord$Zip64Locator.class
                │   ├── CentralDirectoryEndRecord.class
                │   ├── CentralDirectoryFileHeader.class
                │   ├── CentralDirectoryParser.class
                │   ├── CentralDirectoryVisitor.class
                │   ├── FileHeader.class
                │   ├── Handler.class
                │   ├── JarEntry.class
                │   ├── JarEntryCertification.class
                │   ├── JarEntryFilter.class
                │   ├── JarFile$1.class
                │   ├── JarFile$JarEntryEnumeration.class
                │   ├── JarFile.class
                │   ├── JarFileEntries$1.class
                │   ├── JarFileEntries$EntryIterator.class
                │   ├── JarFileEntries.class
                │   ├── JarFileWrapper.class
                │   ├── JarURLConnection$1.class
                │   ├── JarURLConnection$JarEntryName.class
                │   ├── JarURLConnection.class
                │   ├── StringSequence.class
                │   └── ZipInflaterInputStream.class
                ├── jarmode
                │   ├── JarMode.class
                │   ├── JarModeLauncher.class
                │   └── TestJarMode.class
                └── util
                    └── SystemPropertyUtils.class

19 directories, 137 files
»

使用tree查看 JAR 内容提供了应用程序构成的良好分层显示。它还显示出多个依赖项的组合,为该应用程序提供所选择的能力。在BOOT-INF/lib下列出的文件确认了组件库在构建 Spring Boot JAR 并提取其内容后保持不变,甚至可以到达原始组件 JAR 的时间戳,如下所示(为简洁起见,大多数条目已删除):

» ls -l BOOT-INF/lib
total 52880
-rw-r--r--  1 markheckler  staff  2303679 Oct 14  2019 h2-1.4.200.jar
-rw-r--r--  1 markheckler  staff    68215 Oct  1 22:20 jackson-annotations-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff   351495 Oct  1 22:25 jackson-core-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff  1421699 Oct  1 22:38 jackson-databind-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff    58679 Oct  2 00:17 jackson-dataformat-cbor-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff    34335 Oct  2 00:25 jackson-datatype-jdk8-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff   111008 Oct  2 00:25 jackson-datatype-jsr310-
 2.11.3.jar
-rw-r--r--  1 markheckler  staff     9267 Oct  2 00:25 jackson-module-parameter-
 names-2.11.3.jar
 ...
-rw-r--r--  1 markheckler  staff   374303 Nov 10 09:01 spring-aop-5.3.1.jar
-rw-r--r--  1 markheckler  staff   695851 Nov 10 09:01 spring-beans-5.3.1.jar
-rw-r--r--  1 markheckler  staff  1299025 Nov 12 13:56 spring-boot-2.4.0.jar
-rw-r--r--  1 markheckler  staff  1537971 Nov 12 13:55 spring-boot-
 autoconfigure-2.4.0.jar
-rw-r--r--  1 markheckler  staff    32912 Feb  1  1980 spring-boot-jarmode-
 layertools-2.4.0.jar
-rw-r--r--  1 markheckler  staff  1241939 Nov 10 09:01 spring-context-5.3.1.jar
-rw-r--r--  1 markheckler  staff  1464734 Feb  1  1980 spring-core-5.3.1.jar
-rw-r--r--  1 markheckler  staff  1238966 Nov 11 12:03 spring-data-commons-
 2.4.1.jar
-rw-r--r--  1 markheckler  staff   433079 Nov 11 12:08 spring-data-r2dbc-
 1.2.1.jar
-rw-r--r--  1 markheckler  staff   339745 Nov 11 12:05 spring-data-relational-
 2.1.1.jar
-rw-r--r--  1 markheckler  staff   282565 Nov 10 09:01 spring-expression-
 5.3.1.jar
-rw-r--r--  1 markheckler  staff    23943 Nov 10 09:01 spring-jcl-5.3.1.jar
-rw-r--r--  1 markheckler  staff   552895 Nov 10 09:01 spring-messaging-
 5.3.1.jar
-rw-r--r--  1 markheckler  staff   133156 Nov 10 09:01 spring-r2dbc-5.3.1.jar
-rw-r--r--  1 markheckler  staff   327956 Nov 10 09:01 spring-tx-5.3.1.jar
-rw-r--r--  1 markheckler  staff  1546053 Nov 10 09:01 spring-web-5.3.1.jar
-rw-r--r--  1 markheckler  staff   901591 Nov 10 09:01 spring-webflux-5.3.1.jar
»

从 Spring Boot JAR 中提取所有文件后,有几种方法可以运行应用程序。推荐的方法是使用JarLauncher,它在执行过程中保持一致的类加载顺序,如下所示(结果已修剪和编辑以适合页面):

» java org.springframework.boot.loader.JarLauncher

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting PlanefinderApplication v0.0.1-SNAPSHOT
: No active profile set, falling back to default profiles: default
: Bootstrapping Spring Data R2DBC repositories in DEFAULT mode.
: Finished Spring Data repository scanning in 95 ms. Found 1 R2DBC
  repository interfaces.
: Netty started on port(s): 7634
: Netty RSocket started on port(s): 7635
: Started PlanefinderApplication in 1.935 seconds (JVM running for 2.213)

在这种情况下,PlaneFinder 的启动速度比 Spring Boot“完全可执行”JAR 中展开的速度快了整整一秒以上。单独这一点可能或可能不足以抵消单一、完全自包含的可部署单元的优势;但结合仅在少量文件更改时推送增量以及(如适用)本地和远程环境更好的对齐能力,运行展开的 Spring Boot 应用程序的能力可能是一个非常有用的选择。

将 Spring Boot 应用程序部署到容器中

正如前面提到的,一些云平台——包括本地/私有和公共云——接收可部署的应用程序,并代表开发者创建一个容器映像,使用应用程序开发者提供的广泛优化的默认设置。然后根据应用程序的复制设置和利用率使用这些映像创建(和销毁)带有运行中应用程序的容器。像 Heroku 和众多 Cloud Foundry 版本一样,允许开发者推送 Spring Boot 可执行 JAR,并提供任何所需的配置设置(或简单接受默认设置),其余由平台处理。其他平台如 VMware 的 Tanzu Application Service for Kubernetes 也包括此功能,并且功能列表在范围和流动执行方面都在增加。

许多平台和部署目标不支持这种无摩擦的开发者启用级别。无论您或您的组织是否致力于其他提供方之一,还是如果您有其他要求指导您朝不同方向发展,Spring Boot 都为您提供了保障。

尽管您可以为 Spring Boot 应用程序手工制作自己的容器映像,但这并不是最佳选择;这样做对应用程序本身没有任何价值,通常被认为是从开发到生产的一种必要之恶(充其量)。不再如此。

利用之前提到的平台使用的许多相同工具,智能地容器化应用程序,Spring Boot 在其 Maven 和 Gradle 插件中集成了能够无痛无摩擦地构建符合 Open Container Initiative (OCI) 标准的映像的能力,这些映像可供 Docker、Kubernetes 和每个主要的容器引擎/机制使用。基于业界领先的 Cloud Native BuildpacksPaketo 构建包倡议,Spring Boot 构建插件提供了使用本地安装和本地运行的 Docker 守护程序创建 OCI 映像并将其推送到本地或指定的远程映像存储库的选项。

使用 Spring Boot 插件从您的应用程序创建映像也是基于一种最佳实践,利用概念上的“自动配置”来通过分层图像内容优化图像创建,根据每个代码单元预期的变更频率分离代码/库。遵循 Spring Boot 自动配置和最佳实践背后的理念,Boot 还提供了一种覆盖和指导分层过程的方式,如果需要自定义配置,则很少需要或甚至不可取,但如果您的需求属于这些罕见的、特殊的情况之一,则可以轻松实现。

从 Spring Boot 2.4.0 Milestone 2 版本开始,默认设置为所有版本生成以下层:

dependencies

包括定期发布的依赖项,即 GA 版本

spring-boot-loader

包括在 org/springframework/boot/loader 下找到的所有文件

snapshot-dependencies

尚未被视为 GA 的前瞻性发布

application

应用程序类及相关资源(模板、属性文件、脚本等)

代码的易变性或其变更倾向和频率通常随着从上到下浏览此层列表而增加。通过创建单独的层来放置类似易变的代码,随后的映像创建效率更高,因此完成速度更快。这显著减少了在应用程序生命周期内重建可部署构件所需的时间和资源。

从 IDE 创建容器映像

使用 IntelliJ 作为示例,从 Spring Boot 应用程序创建分层容器映像非常简单,但几乎所有主要的 IDE 都具有类似的功能。

注意

必须在本地运行 Docker 版本——在我的情况下是 Docker Desktop for Mac ——才能创建图像。

要创建该图像,我通过展开 IntelliJ 右边缘标签为Maven的选项,然后展开Plugins,选择并展开spring-boot插件,双击spring-boot:build-image选项以执行目标,如图 11-2 所示。

sbur 1102

图 11-2. 从 IntelliJ 的 Maven 面板构建 Spring Boot 应用程序容器镜像

创建图像会生成一份相当冗长的操作日志。特别值得关注的是以下条目:

[INFO]     [creator]     Paketo Executable JAR Buildpack 3.1.3
[INFO]     [creator]       https://github.com/paketo-buildpacks/executable-jar
[INFO]     [creator]         Writing env.launch/CLASSPATH.delim
[INFO]     [creator]         Writing env.launch/CLASSPATH.prepend
[INFO]     [creator]       Process types:
[INFO]     [creator]         executable-jar: java org.springframework.boot.
    loader.JarLauncher
[INFO]     [creator]         task:           java org.springframework.boot.
    loader.JarLauncher
[INFO]     [creator]         web:            java org.springframework.boot.
    loader.JarLauncher
[INFO]     [creator]
[INFO]     [creator]     Paketo Spring Boot Buildpack 3.5.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/spring-boot
[INFO]     [creator]       Creating slices from layers index
[INFO]     [creator]         dependencies
[INFO]     [creator]         spring-boot-loader
[INFO]     [creator]         snapshot-dependencies
[INFO]     [creator]         application
[INFO]     [creator]       Launch Helper: Contributing to layer
[INFO]     [creator]         Creating /layers/paketo-buildpacks_spring-boot/
    helper/exec.d/spring-cloud-bindings
[INFO]     [creator]         Writing profile.d/helper
[INFO]     [creator]       Web Application Type: Contributing to layer
[INFO]     [creator]         Reactive web application detected
[INFO]     [creator]         Writing env.launch/BPL_JVM_THREAD_COUNT.default
[INFO]     [creator]       Spring Cloud Bindings 1.7.0: Contributing to layer
[INFO]     [creator]         Downloading from
    https://repo.spring.io/release/org/springframework/cloud/
    spring-cloud-bindings/1.7.0/spring-cloud-bindings-1.7.0.jar
[INFO]     [creator]         Verifying checksum
[INFO]     [creator]         Copying to
    /layers/paketo-buildpacks_spring-boot/spring-cloud-bindings
[INFO]     [creator]       4 application slices

正如前文所述,图像层(在前述列表中称为slices)及其内容可根据需要进行修改以应对特定情况。

一旦图像创建完成,类似以下所示的结果将完成日志。

[INFO] Successfully built image 'docker.io/library/aircraft-positions:
       0.0.1-SNAPSHOT'
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  25.851 s
[INFO] Finished at: 2020-11-28T20:09:48-06:00
[INFO] ------------------------------------------------------------------------

从命令行创建容器镜像

当然,也可以——而且很简单——从命令行创建相同的容器镜像。在此之前,我确实希望对生成的镜像的命名设置进行一些小改动。

为了方便起见,我更喜欢创建与我的Docker Hub帐户和命名约定相符的图像,你选择的图像仓库可能有类似的特定约定。Spring Boot 的构建插件接受部分的详细信息,以简化将图像推送到仓库/目录的步骤。我向Aircraft Positionpom.xml文件的部分添加了一行正确标记的代码,以匹配我的需求/偏好:

<build>
  <plug-ins>
    <plug-in>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plug-in</artifactId>
      <configuration>
        <image>
          <name>hecklerm/${project.artifactId}</name>
        </image>
      </configuration>
    </plug-in>
  </plug-ins>
</build>

接下来,我从项目目录中的终端窗口发出以下命令以重新创建应用程序容器镜像,并很快收到如下结果:

» mvn spring-boot:build-image

... (Intermediate logged results omitted for brevity)

[INFO] Successfully built image 'docker.io/hecklerm/aircraft-positions:latest'
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  13.257 s
[INFO] Finished at: 2020-11-28T20:23:40-06:00
[INFO] ------------------------------------------------------------------------

注意,输出的图像不再是在 IDE 中使用默认设置构建时的docker.io/library/aircraft-positions:0.0.1-SNAPSHOT。新的图像坐标与我在pom.xml中指定的相匹配:docker.io/hecklerm/aircraft-positions:latest

验证图像存在

为验证前两节中创建的图像是否已加载到本地仓库,我从终端窗口运行以下命令,并按名称进行过滤以获得如下结果(并已剪裁以适应页面):

» docker images | grep -in aircraft-positions
aircraft-positions           0.0.1-SNAPSHOT   a7ed39a3d52e    277MB
hecklerm/aircraft-positions  latest           924893a0f1a9    277MB

推送上述输出中最后显示的图像——因为它现在与预期和期望的帐户及命名约定一致——到 Docker Hub 的步骤如下,并获得以下结果:

» docker push hecklerm/aircraft-positions
The push refers to repository [docker.io/hecklerm/aircraft-positions]
1dc94a70dbaa: Pushed
4672559507f8: Pushed
e3e9839150af: Pushed
5f70bf18a086: Layer already exists
a3abfb734aa5: Pushed
3c14fe2f1177: Pushed
4cc7b4eb8637: Pushed
fcc507beb4cc: Pushed
c2e9ddddd4ef: Pushed
108b6855c4a6: Pushed
ab39aa8fd003: Layer already exists
0b18b1f120f4: Layer already exists
cf6b3a71f979: Pushed
ec0381c8f321: Layer already exists
7b0fc1578394: Pushed
eb0f7cd0acf8: Pushed
1e5c1d306847: Mounted from paketobuildpacks/run
23c4345364c2: Mounted from paketobuildpacks/run
a1efa53a237c: Mounted from paketobuildpacks/run
fe6d8881187d: Mounted from paketobuildpacks/run
23135df75b44: Mounted from paketobuildpacks/run
b43408d5f11b: Mounted from paketobuildpacks/run
latest: digest:
  sha256:a7e5d536a7426d6244401787b153ebf43277fbadc9f43a789f6c4f0aff6d5011
    size: 5122
»

访问 Docker Hub 可以确认图像已成功公开部署,如图 11-3 所示。

sbur 1103

图 11-3. Docker Hub 中的 Spring Boot 应用程序容器镜像

在将 Spring Boot 容器化应用程序部署到 Docker Hub 或任何其他可以从本地计算机外部访问的容器映像存储库之前,是更广泛(并希望是生产)部署的最后一步。

运行容器化应用程序

要运行该应用程序,我使用docker run命令。您的组织可能有一个部署流水线,从容器镜像(从镜像存储库检索)中移动应用程序到运行的容器化应用程序,但执行的步骤可能是相同的,尽管有更多的自动化和较少的输入。

由于我已经有了映像的本地副本,因此不需要进行远程检索;否则,需要通过守护程序从映像存储库检索远程映像和/或层,在基于指定映像启动容器之前在本地重构它。

要运行容器化的 Aircraft Positions 应用程序,我执行以下命令并看到以下结果(修剪和编辑以适应页面):

» docker run --name myaircraftpositions -p8080:8080
  hecklerm/aircraft-positions:latest
Setting Active Processor Count to 6
WARNING: Container memory limit unset. Configuring JVM for 1G container.
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx636688K
  -XX:MaxMetaspaceSize=104687K -XX:ReservedCodeCacheSize=240M -Xss1M
  (Total Memory: 1G, Thread Count: 50, Loaded Class Count: 16069, Headroom: 0%)
Adding 138 container CA certificates to JVM truststore
Spring Cloud Bindings Enabled
Picked up JAVA_TOOL_OPTIONS:
-Djava.security.properties=/layers/paketo-buildpacks_bellsoft-liberica/
    java-security-properties/java-security.properties
  -agentpath:/layers/paketo-buildpacks_bellsoft-liberica/jvmkill/
    jvmkill-1.16.0-RELEASE.so=printHeapHistogram=1
  -XX:ActiveProcessorCount=6
  -XX:MaxDirectMemorySize=10M
  -Xmx636688K
  -XX:MaxMetaspaceSize=104687K
  -XX:ReservedCodeCacheSize=240M
  -Xss1M
  -Dorg.springframework.cloud.bindings.boot.enable=true

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting AircraftPositionsApplication v0.0.1-SNAPSHOT
: Netty started on port(s): 8080
: Started AircraftPositionsApplication in 10.7 seconds (JVM running for 11.202)

现在快速查看一个由 Spring Boot 插件创建的映像内部。

用于检查 Spring Boot 应用程序容器映像的实用程序

存在许多用于处理容器映像的实用程序,它们的功能大部分超出了本书的范围。我想简要提到两个在某些情况下我发现很有用的工具:packdive

Pack

要检查使用 Cloud Native(Paketo)Buildpacks 创建 Spring Boot 应用程序容器映像的材料及其 buildpacks 本身,可以使用pack实用程序。 pack是指定用于使用 Cloud Native Buildpacks 构建应用程序的 CLI,并且可以通过各种方式获取。我在我的 Mac 上使用homebrew来检索并安装它,只需简单的brew install pack命令。

运行pack对先前创建的映像的影响如下:

» pack inspect-image hecklerm/aircraft-positions
Inspecting image: hecklerm/aircraft-positions

REMOTE:

Stack: io.buildpacks.stacks.bionic

Base Image:
  Reference: f5caea10feb38ae882a9447b521fd1ea1ee93384438395c7ace2d8cfaf808e3d
  Top Layer: sha256:1e5c1d306847275caa0d1d367382dfdcfd4d62b634b237f1d7a2e
             746372922cd

Run Images:
  index.docker.io/paketobuildpacks/run:base-cnb
  gcr.io/paketo-buildpacks/run:base-cnb

Buildpacks:
  ID                                         VERSION
  paketo-buildpacks/ca-certificates          1.0.1
  paketo-buildpacks/bellsoft-liberica        5.2.1
  paketo-buildpacks/executable-jar           3.1.3
  paketo-buildpacks/dist-zip                 2.2.2
  paketo-buildpacks/spring-boot              3.5.0

Processes:
  TYPE           SHELL  COMMAND  ARGS
  web (default)  bash   java     org.springframework.boot.loader.JarLauncher
  executable-jar bash   java     org.springframework.boot.loader.JarLauncher
  task           bash   java     org.springframework.boot.loader.JarLauncher

LOCAL:

Stack: io.buildpacks.stacks.bionic

Base Image:
  Reference: f5caea10feb38ae882a9447b521fd1ea1ee93384438395c7ace2d8cfaf808e3d
  Top Layer: sha256:1e5c1d306847275caa0d1d367382dfdcfd4d62b634b237f1d7a2e
             746372922cd

Run Images:
  index.docker.io/paketobuildpacks/run:base-cnb
  gcr.io/paketo-buildpacks/run:base-cnb

Buildpacks:
  ID                                         VERSION
  paketo-buildpacks/ca-certificates          1.0.1
  paketo-buildpacks/bellsoft-liberica        5.2.1
  paketo-buildpacks/executable-jar           3.1.3
  paketo-buildpacks/dist-zip                 2.2.2
  paketo-buildpacks/spring-boot              3.5.0

Processes:
  TYPE           SHELL  COMMAND  ARGS
  web (default)  bash   java     org.springframework.boot.loader.JarLauncher
  executable-jar bash   java     org.springframework.boot.loader.JarLauncher
  task           bash   java     org.springframework.boot.loader.JarLauncher

使用pack实用程序的inspect-image命令提供有关映像的一些关键信息,特别是以下信息:

  • 使用哪个 Docker 基础映像/ Linux 版本(bionic)作为此映像的基础

  • 使用的哪些 buildpacks 来填充映像(列出了五个 Paketo buildpacks)

  • 将通过什么方式运行进程(由外壳执行的 Java 命令)

请注意,将针对指定映像轮询本地和远程连接的存储库,并为两者提供详细信息。这在诊断由于某个位置的过时容器映像引起的问题时尤为有帮助。

Dive

dive实用程序由 Alex Goodman 创建,作为“潜入”容器映像的一种方法,查看非常细粒度的 OCI 映像层和整个映像文件系统的树结构。

dive深入到 Spring Boot 层次结构的应用程序级别以下,并进入操作系统层面。我认为它不如pack有用,因为它更专注于操作系统而不是应用程序,但在验证特定文件的存在或缺失、文件权限和其他重要的低级问题时,它是理想的工具。这是一个很少使用的工具,但在需要这种详细级别的细节和控制时是必不可少的。

代码检出检查

要获取完整的章节代码,请从代码仓库中检出chapter11end分支。

总结

直到应用程序的用户能够真正使用该应用程序,它仅仅是一种假设的实践。在象征性和往往非常字面的意义上,部署是回报。

许多开发人员知道 Spring Boot 应用程序可以创建为 WAR 文件或 JAR 文件。大多数开发人员也知道有许多理由可以跳过 WAR 选项并创建可执行的 JAR 文件,而很少有理由反其道而行。但许多开发人员可能没有意识到,即使在构建 Spring Boot 可执行 JAR 文件时,也有许多部署选项可以满足各种需求和用例。

在本章中,我探讨了几种部署 Spring Boot 应用程序的方式,这些方式适用于不同的目标环境,并讨论了它们的相对优劣。然后我演示了如何创建这些部署工件,解释了最佳执行选项,并展示了如何验证它们的组件和来源。目标包括标准的 Spring Boot 可执行 JAR 文件,“全面可执行”的 Spring Boot JAR 文件,解压/展开的 JAR 文件,以及使用 Cloud Native(Paketo)Buildpacks 构建的容器镜像,这些镜像可以在 Docker、Kubernetes 以及所有主要的容器引擎/机制上运行。Spring Boot 为您提供了多种无摩擦的部署选项,将您的开发超能力延伸到部署超能力领域。

在下一章,也是最后一章中,我会深入探讨两个略微更深入的主题,来为这本书和旅程画上句号。如果您想了解更多有关测试和调试响应式应用程序的内容,千万不要错过。

第十二章:深入探讨响应式编程

正如之前讨论的,响应式编程为开发人员提供了一种在分布式系统中更好地利用资源的方法,甚至将强大的扩展机制扩展到应用程序边界和通信通道中。对于仅有主流 Java 开发实践经验的开发人员(通常被称为命令式Java,因其显式和顺序逻辑,与响应式编程中通常使用的更声明式方法相对),这些响应式能力可能带来一些不希望的成本。除了预期的学习曲线外,Spring 通过并行和互补的 WebMVC 和 WebFlux 实现大大降低了这种学习曲线,还存在工具、成熟度和针对关键活动(如测试、故障排除和调试)的已建立实践的相对限制。

尽管相对于其命令式表亲而言,响应式 Java 开发确实还处于起步阶段,但它们同属一个大家庭的事实,已经允许了更快的工具和流程的发展和成熟。如前所述,Spring 在其开发和社区中建立的成熟命令式专业知识基础上,已经将数十年的演变压缩成现在可用的生产就绪组件。

本章介绍并解释了测试和诊断/调试问题的最新技术,您可能在部署响应式 Spring Boot 应用程序时会遇到,并展示了如何在生产之前,并帮助您进行生产之前,使 WebFlux/Reactor 发挥作用。

代码检查检查完成

请查看代码仓库中的chapter12begin分支开始。

何时使用响应式?

响应式编程,特别是那些专注于响应式流的应用程序,使得系统范围的扩展难以用其他现有手段匹敌。然而,并非所有应用程序都需要在端到端可扩展性的极端要求下运行,或者它们可能已经表现得非常出色,可以在可预见的时间范围内处理相对可预测的负载。命令式应用程序长期以来一直满足全球组织的生产需求,它们不会仅仅因为有了新选项就停止工作。

尽管响应式编程在其提供的可能性方面毫无疑问地令人兴奋,Spring 团队明确表示,响应式代码在可预见的未来,甚至可能永远也不会取代所有命令式代码。正如在Spring WebFlux 的参考文档中所述

如果你有一个庞大的团队,请记住在转向非阻塞、函数式和声明式编程时的陡峭学习曲线。一个实际的开始方法,而不是完全转变,是使用响应式的 WebClient。除此之外,从小处开始,并测量收益。我们预期,在广泛应用的情况下,这种转变是不必要的。如果你不确定要寻找什么样的好处,首先了解非阻塞 I/O 的工作方式(例如,单线程 Node.js 上的并发)及其影响是个不错的开始。

Spring 框架参考文档

简而言之,采用响应式编程和 Spring WebFlux 是一个选择——这是一个极好的选择,可能是实现某些需求的最佳方式——但在仔细考虑所涉及系统的相关需求和要求后再做出的选择。无论是响应式还是非响应式,Spring Boot 都提供了无与伦比的选项来开发处理所有生产工作负载的关键业务软件。

测试响应式应用程序

为了更好地专注于测试响应式 Spring Boot 应用程序的关键概念,我采取了几个步骤来缩小考虑范围的代码范围。就像放大你希望拍摄的主题一样,其他项目代码仍然存在,但不在本节信息的关键路径上。

对于这一部分,我将专注于专门测试那些公开响应式流发布者的 API,如FluxMonoPublisher类型,这些类型可以是FluxMono,而不是典型的阻塞IterableObject类型。我首先从提供外部 API 的Aircraft Positions类内部开始,即PositionController

提示

如果你还没有检查过第十二章的代码,请立即去看看。

但首先,重构

虽然PositionController内部的代码确实有效,但有点混乱。首要任务是提供更清晰的关注点分离,我开始通过将创建RSocketRequester对象的代码移动到一个@Configuration类中,使其作为 Spring bean 创建,可以在应用程序的任何地方访问:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.rsocket.RSocketRequester;

@Configuration
public class RSocketRequesterConfig {
    @Bean
    RSocketRequester requester(RSocketRequester.Builder builder) {
        return builder.tcp("localhost", 7635);
    }
}

这简化了PositionController的构造函数,将创建RSocketRequester的工作放在了适当的位置,并且远离控制器类。要在PositionController中使用RSocketRequester bean,我只需使用 Spring Boot 的构造函数注入自动装配它:

public PositionController(AircraftRepository repository,
                          RSocketRequester requester) {
    this.repository = repository;
    this.requester = requester;
}
注意

测试 RSocket 连接需要进行集成测试。虽然本节重点是单元测试而不是集成测试,但仍然需要将RSocketRequester的构造与PositionController分离开来,以便隔离和正确地单元测试PositionController

另外一个逻辑来源并不完全属于控制器功能,这次涉及使用AircraftRepository bean 获取、存储和检索飞行器位置。通常,当与特定类无关的复杂逻辑进入该类时,最好将其提取出来,就像我为RSocketRequester bean 所做的那样。为了将这段有些复杂且不相关的代码从PositionController中移出,我创建了一个PositionService类,并将其定义为一个在整个应用程序中可用的@Service bean。@Service注解只是对常用的@Component注解的更为具体的视觉描述:

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public class PositionService {
    private final AircraftRepository repo;
    private WebClient client = WebClient.create(
        "http://localhost:7634/aircraft");

    public PositionService(AircraftRepository repo) {
        this.repo = repo;
    }

    public Flux<Aircraft> getAllAircraft() {
        return repo.deleteAll()
                .thenMany(client.get()
                        .retrieve()
                        .bodyToFlux(Aircraft.class)
                        .filter(plane -> !plane.getReg().isEmpty()))
                .flatMap(repo::save)
                .thenMany(repo.findAll());
    }

    public Mono<Aircraft> getAircraftById(Long id) {
        return repo.findById(id);
    }

    public Flux<Aircraft> getAircraftByReg(String reg) {
        return repo.findAircraftByReg(reg);
    }
}
注意

当前在AircraftRepository中没有定义findAircraftByReg()方法。在创建测试之前,我解决了这个问题。

尽管可以做更多的工作(特别是关于WebClient成员变量的工作),但现在将PositionService::getAllAircraft中显示的复杂逻辑从其以前的位置移除,并将PositionService bean 注入到控制器中供其使用已足够,这导致控制器类更加干净和专注:

import org.springframework.http.MediaType;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;

@Controller
public class PositionController {
    private final PositionService service;
    private final RSocketRequester requester;

    public PositionController(PositionService service,
            RSocketRequester requester) {
        this.service = service;
        this.requester = requester;
    }

    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        model.addAttribute("currentPositions", service.getAllAircraft());

        return "positions";
    }

    @ResponseBody
    @GetMapping(value = "/acstream", produces =
        MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<Aircraft> getCurrentACPositionsStream() {
        return requester.route("acstream")
                .data("Requesting aircraft positions")
                .retrieveFlux(Aircraft.class);
    }
}

回顾现有的PositionController端点显示它们供给了一个 Thymeleaf 模板(public String getCurrentAircraftPositions(Model model))或者需要一个外部的RSocket连接(public Flux<Aircraft> getCurrentACPositionsStream())。为了隔离和测试飞行器位置应用程序提供外部 API 的能力,我需要扩展当前定义的端点。我添加了两个额外的端点映射到/acpos/acpos/search,以创建一个基本但灵活的 API,利用我在PositionService内部创建的方法。

我首先创建了一个方法,以 JSON 格式检索并返回目前位于我们的 PlaneFinder 服务启用设备范围内的所有飞行器的位置。getCurrentACPositions()方法调用PositionService::getAllAircraft,就像它的对应方法getCurrentAircraftPositions(Model model)一样,但它返回 JSON 对象值,而不是将它们添加到领域对象模型并重定向到模板引擎以显示 HTML 页面。

接下来,我创建了一种方法,通过唯一的位置记录标识符和飞行器注册号来搜索当前飞行器位置。记录(在技术上是文档,因为这个版本的Aircraft Positions使用 MongoDB)标识符是数据库中存储的最后从 PlaneFinder 检索到的位置中的唯一 ID。它对于检索特定位置记录很有用;但从飞行器的角度来看,根据飞行器的唯一注册号进行搜索更有用。

有趣的是,PlaneFinder 在查询时可能会报告单个飞机发送的少量位置。这是由于正在飞行的飞机几乎不间断地发送位置报告。对于我们来说,这意味着当根据飞机的唯一注册号在当前报告的位置中进行搜索时,实际上我们可能会检索到该航班的 1+位置报告。

有多种方法可以编写具有灵活性的搜索机制,以接受不同类型的不同搜索条件,并返回不同数量的潜在结果,但我选择将所有选项合并到单个方法中:

@ResponseBody
@GetMapping("/acpos/search")
public Publisher<Aircraft>
        searchForACPosition(@RequestParam Map<String, String> searchParams) {

    if (!searchParams.isEmpty()) {
        Map.Entry<String, String> setToSearch =
                searchParams.entrySet().iterator().next();

        if (setToSearch.getKey().equalsIgnoreCase("id")) {
            return service.getAircraftById(Long.valueOf(setToSearch.getValue()));
        } else {
            return service.getAircraftByReg(setToSearch.getValue());
        }
    } else {
        return Mono.empty();
    }
}

最终(暂时)版本的PositionController类应该如下所示:

import org.reactivestreams.Publisher;
import org.springframework.http.MediaType;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.Map;

@Controller
public class PositionController {
    private final PositionService service;
    private final RSocketRequester requester;

    public PositionController(PositionService service,
            RSocketRequester requester) {
        this.service = service;
        this.requester = requester;
    }

    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        model.addAttribute("currentPositions", service.getAllAircraft());

        return "positions";
    }

    @ResponseBody
    @GetMapping("/acpos")
    public Flux<Aircraft> getCurrentACPositions() {
        return service.getAllAircraft();
    }

    @ResponseBody
    @GetMapping("/acpos/search")
    public Publisher<Aircraft> searchForACPosition(@RequestParam Map<String,
            String> searchParams) {

        if (!searchParams.isEmpty()) {
            Map.Entry<String, String> setToSearch =
                searchParams.entrySet().iterator().next();

            if (setToSearch.getKey().equalsIgnoreCase("id")) {
                return service.getAircraftById(Long.valueOf
                    (setToSearch.getValue()));
            } else {
                return service.getAircraftByReg(setToSearch.getValue());
            }
        } else {
            return Mono.empty();
        }
    }

    @ResponseBody
    @GetMapping(value = "/acstream", produces =
            MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<Aircraft> getCurrentACPositionsStream() {
        return requester.route("acstream")
                .data("Requesting aircraft positions")
                .retrieveFlux(Aircraft.class);
    }
}

接下来,我回到PositionService类。正如前面提到的,它的public Flux<Aircraft> getAircraftByReg(String reg)方法引用了AircraftRepository中当前未定义的方法。为了解决这个问题,我在AircraftRepository接口定义中添加了一个Flux<Aircraft> findAircraftByReg(String reg)方法:

import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;

public interface AircraftRepository extends
        ReactiveCrudRepository<Aircraft, Long> {
    Flux<Aircraft> findAircraftByReg(String reg);
}

这段有趣的代码,这个单一的方法签名,展示了使用一组广泛适用的约定的强大 Spring Data 概念:像findsearchget这样的操作符,存储/检索/管理的对象类型(在本例中为Aircraft),以及成员变量名称如reg。通过使用参数+类型和返回类型声明方法签名,并使用提到的方法命名约定,Spring Data 可以为您构建方法实现。

如果您希望或需要提供更多具体或提示,也可以注释方法签名,并提供所需的细节。对于这种情况并不需要,因为声明我们希望通过注册号搜索飞机位置,并在响应式流Flux中返回 0+值就足够 Spring Data 创建实现。

回到PositionService,IDE 现在高兴地报告repo.findAircraftByReg(reg)是一个有效的方法调用。

注意

我为这个示例做出的另一个设计决策是让getAircraftByXxx方法都查询当前位置文档。这可能被认为假定数据库中存在一些位置文档,或者用户对如果数据库中尚未包含任何位置不感兴趣的情况。您的需求可能推动做出不同的选择,例如在搜索之前验证某些位置是否存在,并且如果不存在,则执行一个使用getAllAircraft调用进行新的检索。

现在,进行测试

在早期的测试章节中,使用了标准的Object类型来测试预期结果。我确实使用了WebClientWebTestClient,但只作为与所有基于 HTTP 的端点交互的首选工具,无论它们是否返回响应式流发布者类型。现在,是时候正确测试这些响应式流语义了。

我将现有的PositionControllerTest类作为起点,重新调整以适应其对应类PositionController公开的新的响应式端点。以下是类级别的细节:

@WebFluxTest(controllers = {PositionController.class})
class PositionControllerTest {
    @Autowired
    private WebTestClient client;

    @MockBean
    private PositionService service;
    @MockBean
    private RSocketRequester requester;

    private Aircraft ac1, ac2, ac3;

    ...

}

首先,我使用类级别的注解@WebFluxTest(controllers = {PositionController.class})。我仍然使用响应式的WebTestClient,并希望将此测试类的范围限制在 WebFlux 功能范围内,因此加载完整的 Spring Boot 应用程序上下文是不必要且浪费时间和资源的。

其次,我自动装配了一个WebTestClient bean。在早期关于测试的章节中,我直接将WebTestClient bean 注入到单个测试方法中,但由于现在它将在多个方法中需要使用,因此创建一个成员变量来引用它更加合理。

第三步,我使用 Mockito 的@MockBean注解创建了模拟 Bean。我简单地模拟了RSocketRequester bean,因为PositionController需要一个RSocketRequester bean,无论是真实的还是模拟的。我模拟了PositionService bean,以便在这个类的测试中模拟并使用其行为。模拟PositionService允许我确保其正确行为,同时测试其输出(PositionController),并将实际结果与预期结果进行比较。

最后,我创建了三个Aircraft实例用于包含的测试中。

在执行 JUnit 的@Test方法之前,会运行一个使用@BeforeEach注解的方法来配置场景和预期结果。这是我在每个测试方法之前使用的setUp()方法,用于准备测试环境:

@BeforeEach
void setUp(ApplicationContext context) {
    // Spring Airlines flight 001 en route, flying STL to SFO,
    // at 30000' currently over Kansas City
    ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
            "STL-SFO", "LJ", "ct",
            30000, 280, 440, 0, 0,
            39.2979849, -94.71921, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    // Spring Airlines flight 002 en route, flying SFO to STL,
    // at 40000' currently over Denver
    ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
            "SFO-STL", "LJ", "ct",
            40000, 65, 440, 0, 0,
            39.8560963, -104.6759263, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    // Spring Airlines flight 002 en route, flying SFO to STL,
    // at 40000' currently just past DEN
    ac3 = new Aircraft(3L, "SAL002", "sqwk", "N54321", "SAL002",
            "SFO-STL", "LJ", "ct",
            40000, 65, 440, 0, 0,
            39.8412964, -105.0048267, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    Mockito.when(service.getAllAircraft()).thenReturn(Flux.just(ac1, ac2, ac3));
    Mockito.when(service.getAircraftById(1L)).thenReturn(Mono.just(ac1));
    Mockito.when(service.getAircraftById(2L)).thenReturn(Mono.just(ac2));
    Mockito.when(service.getAircraftById(3L)).thenReturn(Mono.just(ac3));
    Mockito.when(service.getAircraftByReg("N12345"))
        .thenReturn(Flux.just(ac1));
    Mockito.when(service.getAircraftByReg("N54321"))
        .thenReturn(Flux.just(ac2, ac3));
}

我为注册号为 N12345 的飞机分配了飞机位置给ac1成员变量。对于ac2ac3,我为同一架飞机 N54321 分配了非常接近的位置,模拟了从 PlaneFinder 接收到频繁更新的位置报告的常见情况。

setUp()方法的最后几行定义了PositionService模拟 Bean 在不同方式调用其方法时将提供的行为。与早期关于测试的方法模拟类似,唯一的重要区别在于返回值的类型;因为实际的PositionService方法返回 Reactor 的Publisher类型的FluxMono,所以模拟方法也必须如此。

测试以检索所有飞机位置为目的。

最后,我创建了一个方法来测试PositionController的方法getCurrentACPositions()

@Test
void getCurrentACPositions() {
    StepVerifier.create(client.get()
            .uri("/acpos")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentType(MediaType.APPLICATION_JSON)
            .returnResult(Aircraft.class)
            .getResponseBody())
        .expectNext(ac1)
        .expectNext(ac2)
        .expectNext(ac3)
        .verifyComplete();
}

测试响应式流应用程序可能带来多种挑战,通常被认为是设置预期结果、获取实际结果并比较二者以确定测试成功或失败的一个相当乏味(如果容易遗漏)的努力。尽管可以有效地即时获取多个结果,就像阻塞类型的Iterable一样,响应式流Publishers并不等待完整的结果集再返回为一个单一单元。从机器的角度来看,这就是一次性接收一组五个结果(例如)或非常快速地接收五个结果,但是单独接收的差异。

Reactor 测试工具的核心是StepVerifier及其实用方法。StepVerifier订阅Publisher,正如其名称所示,使开发人员能够将获得的结果视为离散值并逐个验证。在对getCurrentACPositions的测试中,我执行以下操作:

  • 创建一个StepVerifier

  • 提供由以下步骤产生的Flux

    • 使用WebTestClient bean。

    • 访问映射到/acpos端点的PositionController::getCurrentACPositions方法。

    • 初始化exchange()

    • 验证200 OK的响应状态。

    • 验证响应头具有“application/json”的内容类型。

    • Aircraft类的实例形式返回结果项。

    • 获取响应。

  • 评估实际的第一个值与预期的第一个值ac1

  • 评估实际的第二个值与预期的第二个值ac2

  • 评估实际的第三个值与预期的第三个值ac3

  • 验证所有操作并接收Publisher完成信号。

这是对预期行为的相当详尽评估,包括条件和返回值。运行测试的结果输出类似于以下内容(已修剪以适应页面):

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

: Starting PositionControllerTest on mheckler-a01.vmware.com with PID 21211
: No active profile set, falling back to default profiles: default
: Started PositionControllerTest in 2.19 seconds (JVM running for 2.879)

Process finished with exit code 0

从 IDE 运行,结果将类似于在图 12-1 中显示的内容。

sbur 1201

图 12-1. 成功的测试

测试飞机位置搜索功能

PositionController::searchForACPosition内测试搜索功能至少需要进行两个单独的测试,因为能够通过数据库文档 ID 和飞机注册号处理飞机位置搜索。

为了测试通过数据库文档标识符搜索,我创建了以下单元测试:

@Test
void searchForACPositionById() {
    StepVerifier.create(client.get()
            .uri("/acpos/search?id=1")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentType(MediaType.APPLICATION_JSON)
            .returnResult(Aircraft.class)
            .getResponseBody())
        .expectNext(ac1)
        .verifyComplete();
}

这与所有飞机位置的单元测试类似。有两个显著的例外:

  • 指定的 URI 引用搜索端点,并包括搜索参数id=1以检索ac1

  • 预期结果仅为ac1,如expectNext(ac1)链式操作中所示。

为了测试通过飞机注册号搜索飞机位置,我创建了以下单元测试,使用我模拟的注册号,包括两个对应的位置文档:

@Test
void searchForACPositionByReg() {
    StepVerifier.create(client.get()
            .uri("/acpos/search?reg=N54321")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentType(MediaType.APPLICATION_JSON)
            .returnResult(Aircraft.class)
            .getResponseBody())
        .expectNext(ac2)
        .expectNext(ac3)
        .verifyComplete();
}

这个测试与前一个测试之间的差异很小:

  • URI 包含搜索参数 reg=N54321,应返回ac2ac3,它们都包含了注册编号为 N54321 的飞机的报告位置。

  • 预期结果被验证为ac2ac3,使用 expectNext(ac2)expectNext(ac3) 连接操作。

下面的清单展示了PositionControllerTest类的最终状态:

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.time.Instant;

@WebFluxTest(controllers = {PositionController.class})
class PositionControllerTest {
    @Autowired
    private WebTestClient client;

    @MockBean
    private PositionService service;
    @MockBean
    private RSocketRequester requester;

    private Aircraft ac1, ac2, ac3;

    @BeforeEach
    void setUp() {
        // Spring Airlines flight 001 en route, flying STL to SFO, at 30000'
        // currently over Kansas City
        ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
                "STL-SFO", "LJ", "ct",
                30000, 280, 440, 0, 0,
                39.2979849, -94.71921, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        // Spring Airlines flight 002 en route, flying SFO to STL, at 40000'
        // currently over Denver
        ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
                "SFO-STL", "LJ", "ct",
                40000, 65, 440, 0, 0,
                39.8560963, -104.6759263, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        // Spring Airlines flight 002 en route, flying SFO to STL, at 40000'
        // currently just past DEN
        ac3 = new Aircraft(3L, "SAL002", "sqwk", "N54321", "SAL002",
                "SFO-STL", "LJ", "ct",
                40000, 65, 440, 0, 0,
                39.8412964, -105.0048267, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        Mockito.when(service.getAllAircraft())
                .thenReturn(Flux.just(ac1, ac2, ac3));
        Mockito.when(service.getAircraftById(1L))
                .thenReturn(Mono.just(ac1));
        Mockito.when(service.getAircraftById(2L))
                .thenReturn(Mono.just(ac2));
        Mockito.when(service.getAircraftById(3L))
                .thenReturn(Mono.just(ac3));
        Mockito.when(service.getAircraftByReg("N12345"))
                .thenReturn(Flux.just(ac1));
        Mockito.when(service.getAircraftByReg("N54321"))
                .thenReturn(Flux.just(ac2, ac3));
    }

    @AfterEach
    void tearDown() {
    }

    @Test
    void getCurrentACPositions() {
        StepVerifier.create(client.get()
                .uri("/acpos")
                .exchange()
                .expectStatus().isOk()
                .expectHeader().contentType(MediaType.APPLICATION_JSON)
                .returnResult(Aircraft.class)
                .getResponseBody())
            .expectNext(ac1)
            .expectNext(ac2)
            .expectNext(ac3)
            .verifyComplete();
    }

    @Test
    void searchForACPositionById() {
        StepVerifier.create(client.get()
                .uri("/acpos/search?id=1")
                .exchange()
                .expectStatus().isOk()
                .expectHeader().contentType(MediaType.APPLICATION_JSON)
                .returnResult(Aircraft.class)
                .getResponseBody())
            .expectNext(ac1)
            .verifyComplete();
    }

    @Test
    void searchForACPositionByReg() {
        StepVerifier.create(client.get()
                .uri("/acpos/search?reg=N54321")
                .exchange()
                .expectStatus().isOk()
                .expectHeader().contentType(MediaType.APPLICATION_JSON)
                .returnResult(Aircraft.class)
                .getResponseBody())
            .expectNext(ac2)
            .expectNext(ac3)
            .verifyComplete();
    }
}

PositionControllerTest类中执行所有测试会得到令人满意的结果,如图 12-2 所示。

sbur 1202

图 12-2. 所有单元测试成功执行
注意

StepVerifier 提供了更多的测试可能性,本节已经示意了其中一部分。特别值得关注的是 StepVerifier::withVirtualTime 方法,它可以压缩偶尔发出值的发布者的测试,使得通常会在很长时间内分布的结果立即呈现。StepVerifier::withVirtualTime 接受一个 Supplier<Publisher> 而不是直接的 Publisher,但其使用机制相当类似。

这些是测试响应式 Spring Boot 应用的基本要素。但是,当您在生产环境中遇到问题时会发生什么?当您的应用程序上线时,Reactor 提供了哪些工具来识别和解决问题?

诊断和调试响应式应用

当传统的 Java 应用程序出现问题时,通常会有一个堆栈跟踪。命令式代码可以出于多种原因生成有用的(有时冗长的)堆栈跟踪,但在高层次上,有两个因素使得可以收集和显示这些有用信息:

  • 代码的顺序执行通常决定了如何执行某些操作(命令式)

  • 该顺序代码的执行发生在单个线程内

规则总有例外,但一般来说,这是允许捕获到发生错误的时间点之前顺序执行的步骤的常见组合:所有操作都在单一的泳道中一步步地执行。它可能不会有效地利用系统资源,通常情况下确实不会,但这使得隔离和解决问题变得更加简单。

进入响应式流。Project Reactor 和其他响应式流实现使用调度程序来管理和使用其他线程。通常会保持空闲或低效的资源可以被利用起来,以使得响应式应用能够远远超越其阻塞对应物而扩展。关于如何控制和调整 Schedulers 的选项以及它们可以如何使用的更多详细信息,我建议您查阅Reactor Core 文档,但目前可以简单地说,Reactor 在绝大多数情况下都可以很好地自动处理调度。

然而,这确实突显了为响应式 Spring Boot(或任何响应式)应用程序生成有意义的执行跟踪的一个挑战。人们不能指望简单地跟随单个线程的活动并生成有意义的顺序代码执行列表。

由于这种线程跳转优化特性,追踪执行的难度增加了,这使得响应式编程将代码 组装 与代码 执行 分开。正如在 第八章 中提到的,在大多数情况下对于大多数 Publisher 类型,直到 订阅 之前什么也不会发生。

简而言之,几乎不太可能看到生产故障指向您在声明式组装 Publisher(无论是 Flux 还是 Mono)操作流水线的代码的问题。故障几乎普遍发生在流水线变得活跃时:产生、处理和传递值给 Subscriber

由于代码组装与执行之间的距离以及 Reactor 利用多线程完成操作链的能力,需要更好的工具来有效地排查运行时出现的错误。幸运的是,Reactor 提供了几个优秀的选择。

Hooks.onOperatorDebug()

这并不意味着使用现有堆栈跟踪结果无法解决反应性应用程序的故障排除问题,只是可以显著改进。就像大多数事物一样,证据在于代码——或者在这种情况下,记录的失败输出。

为了模拟反应式 Publisher 操作链的故障,我重新访问了 PositionControllerTest 类,并在每次测试执行前运行的 setUp() 方法中更改了一行代码:

Mockito.when(service.getAllAircraft()).thenReturn(Flux.just(ac1, ac2, ac3));

我用包含结果流中错误的方式替换了由模拟 getAllAircraft() 方法生成的正常运行的 Flux

Mockito.when(service.getAllAircraft()).thenReturn(
        Flux.just(ac1, ac2, ac3)
                .concatWith(Flux.error(new Throwable("Bad position report")))
);

接下来,我执行 getCurrentACPositions() 的测试,以查看我们故意对 Flux 进行破坏的结果(包装以适应页面):

500 Server Error for HTTP GET "/acpos"

java.lang.Throwable: Bad position report
	at com.thehecklers.aircraftpositions.PositionControllerTest
        .setUp(PositionControllerTest.java:59) ~[test-classes/:na]
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
	|_ checkpoint ⇢ Handler com.thehecklers.aircraftpositions
        .PositionController
        #getCurrentACPositions() [DispatcherHandler]
	|_ checkpoint ⇢ HTTP GET "/acpos" [ExceptionHandlingWebHandler]
Stack trace:
		at com.thehecklers.aircraftpositions.PositionControllerTest
        .setUp(PositionControllerTest.java:59) ~[test-classes/:na]
		at java.base/jdk.internal.reflect.NativeMethodAccessorImpl
        .invoke0(Native Method) ~[na:na]
		at java.base/jdk.internal.reflect.NativeMethodAccessorImpl
        .invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
		at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl
        .invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
		at java.base/java.lang.reflect.Method
        .invoke(Method.java:564) ~[na:na]
		at org.junit.platform.commons.util.ReflectionUtils
        .invokeMethod(ReflectionUtils.java:686)
        ~[junit-platform-commons-1.6.2.jar:1.6.2]
		at org.junit.jupiter.engine.execution.MethodInvocation
        .proceed(MethodInvocation.java:60)
                ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        $ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
        ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.extension.TimeoutExtension
        .intercept(TimeoutExtension.java:149)
        ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.extension.TimeoutExtension
        .interceptLifecycleMethod(TimeoutExtension.java:126)
        ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.extension.TimeoutExtension
        .interceptBeforeEachMethod(TimeoutExtension.java:76)
        ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution
        .ExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod
          $0(ExecutableInvoker.java:115)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.ExecutableInvoker
        .lambda$invoke$0(ExecutableInvoker.java:105)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        $InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        .proceed(InvocationInterceptorChain.java:64)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        .chainAndInvoke(InvocationInterceptorChain.java:45)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.InvocationInterceptorChain
        .invoke(InvocationInterceptorChain.java:37)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.ExecutableInvoker
        .invoke(ExecutableInvoker.java:104)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.execution.ExecutableInvoker
        .invoke(ExecutableInvoker.java:98)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor
        .invokeMethodInExtensionContext(ClassBasedTestDescriptor.java:481)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor
        .lambda$synthesizeBeforeEachMethodAdapter
          $18(ClassBasedTestDescriptor.java:466)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .lambda$invokeBeforeEachMethods$2(TestMethodTestDescriptor.java:169)
          ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .lambda$invokeBeforeMethodsOrCallbacksUntilExceptionOccurs
            $5(TestMethodTestDescriptor.java:197)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .invokeBeforeMethodsOrCallbacksUntilExceptionOccurs
            (TestMethodTestDescriptor.java:197)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .invokeBeforeEachMethods(TestMethodTestDescriptor.java:166)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .execute(TestMethodTestDescriptor.java:133)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
        .execute(TestMethodTestDescriptor.java:71)
            ~[junit-jupiter-engine-5.6.2.jar:5.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$5(NodeTestTask.java:135)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$7(NodeTestTask.java:125)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.Node
        .around(Node.java:135) ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$8(NodeTestTask.java:123)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .executeRecursively(NodeTestTask.java:122)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .execute(NodeTestTask.java:80)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at java.base/java.util.ArrayList.forEach(ArrayList.java:1510) ~[na:na]
		at org.junit.platform.engine.support.hierarchical
        .SameThreadHierarchicalTestExecutorService
            .invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
                ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$5(NodeTestTask.java:139)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$7(NodeTestTask.java:125)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.Node
        .around(Node.java:135) ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$8(NodeTestTask.java:123)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .executeRecursively(NodeTestTask.java:122)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .execute(NodeTestTask.java:80)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at java.base/java.util.ArrayList.forEach(ArrayList.java:1510) ~[na:na]
		at org.junit.platform.engine.support.hierarchical
        .SameThreadHierarchicalTestExecutorService
            .invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
                ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$5(NodeTestTask.java:139)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$7(NodeTestTask.java:125)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.Node
        .around(Node.java:135) ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .lambda$executeRecursively$8(NodeTestTask.java:123)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.ThrowableCollector
        .execute(ThrowableCollector.java:73)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .executeRecursively(NodeTestTask.java:122)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical.NodeTestTask
        .execute(NodeTestTask.java:80)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical
        .SameThreadHierarchicalTestExecutorService
            .submit(SameThreadHierarchicalTestExecutorService.java:32)
                ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical
        .HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.engine.support.hierarchical
        .HierarchicalTestEngine.execute(HierarchicalTestEngine.java:51)
            ~[junit-platform-engine-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .execute(DefaultLauncher.java:248)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .lambda$execute$5(DefaultLauncher.java:211)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .withInterceptedStreams(DefaultLauncher.java:226)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .execute(DefaultLauncher.java:199)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at org.junit.platform.launcher.core.DefaultLauncher
        .execute(DefaultLauncher.java:132)
            ~[junit-platform-launcher-1.6.2.jar:1.6.2]
		at com.intellij.junit5.JUnit5IdeaTestRunner
        .startRunnerWithArgs(JUnit5IdeaTestRunner.java:69)
            ~[junit5-rt.jar:na]
		at com.intellij.rt.junit.IdeaTestRunner$Repeater
        .startRunnerWithArgs(IdeaTestRunner.java:33)
            ~[junit-rt.jar:na]
		at com.intellij.rt.junit.JUnitStarter
        .prepareStreamsAndStart(JUnitStarter.java:230)
            ~[junit-rt.jar:na]
		at com.intellij.rt.junit.JUnitStarter
        .main(JUnitStarter.java:58) ~[junit-rt.jar:na]

java.lang.AssertionError: Status expected:<200 OK>
    but was:<500 INTERNAL_SERVER_ERROR>

> GET /acpos
> WebTestClient-Request-Id: [1]

No content

< 500 INTERNAL_SERVER_ERROR Internal Server Error
< Content-Type: [application/json]
< Content-Length: [142]

{"timestamp":"2020-11-09T15:41:12.516+00:00","path":"/acpos","status":500,
        "error":"Internal Server Error","message":"","requestId":"699a523c"}

	at org.springframework.test.web.reactive.server.ExchangeResult
    .assertWithDiagnostics(ExchangeResult.java:209)
	at org.springframework.test.web.reactive.server.StatusAssertions
    .assertStatusAndReturn(StatusAssertions.java:227)
	at org.springframework.test.web.reactive.server.StatusAssertions
    .isOk(StatusAssertions.java:67)
	at com.thehecklers.aircraftpositions.PositionControllerTest
    .getCurrentACPositions(PositionControllerTest.java:90)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl
    .invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl
    .invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl
    .invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:564)
	at org.junit.platform.commons.util.ReflectionUtils
    .invokeMethod(ReflectionUtils.java:686)
	at org.junit.jupiter.engine.execution.MethodInvocation
    .proceed(MethodInvocation.java:60)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    $ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
	at org.junit.jupiter.engine.extension.TimeoutExtension
    .intercept(TimeoutExtension.java:149)
	at org.junit.jupiter.engine.extension.TimeoutExtension
    .interceptTestableMethod(TimeoutExtension.java:140)
	at org.junit.jupiter.engine.extension.TimeoutExtension
    .interceptTestMethod(TimeoutExtension.java:84)
	at org.junit.jupiter.engine.execution.ExecutableInvoker
    $ReflectiveInterceptorCall
        .lambda$ofVoidMethod$0(ExecutableInvoker.java:115)
	at org.junit.jupiter.engine.execution.ExecutableInvoker
    .lambda$invoke$0(ExecutableInvoker.java:105)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    $InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    .proceed(InvocationInterceptorChain.java:64)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    .chainAndInvoke(InvocationInterceptorChain.java:45)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain
    .invoke(InvocationInterceptorChain.java:37)
	at org.junit.jupiter.engine.execution.ExecutableInvoker
    .invoke(ExecutableInvoker.java:104)
	at org.junit.jupiter.engine.execution.ExecutableInvoker
    .invoke(ExecutableInvoker.java:98)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
    .lambda$invokeTestMethod$6(TestMethodTestDescriptor.java:212)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
    .invokeTestMethod(TestMethodTestDescriptor.java:208)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
    .execute(TestMethodTestDescriptor.java:137)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor
    .execute(TestMethodTestDescriptor.java:71)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$5(NodeTestTask.java:135)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$7(NodeTestTask.java:125)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$8(NodeTestTask.java:123)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .executeRecursively(NodeTestTask.java:122)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .execute(NodeTestTask.java:80)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1510)
	at org.junit.platform.engine.support.hierarchical
    .SameThreadHierarchicalTestExecutorService
        .invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$5(NodeTestTask.java:139)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$7(NodeTestTask.java:125)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$8(NodeTestTask.java:123)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .executeRecursively(NodeTestTask.java:122)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .execute(NodeTestTask.java:80)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1510)
	at org.junit.platform.engine.support.hierarchical
    .SameThreadHierarchicalTestExecutorService
        .invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$5(NodeTestTask.java:139)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$7(NodeTestTask.java:125)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .lambda$executeRecursively$8(NodeTestTask.java:123)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector
    .execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .executeRecursively(NodeTestTask.java:122)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask
    .execute(NodeTestTask.java:80)
	at org.junit.platform.engine.support.hierarchical
    .SameThreadHierarchicalTestExecutorService
        .submit(SameThreadHierarchicalTestExecutorService.java:32)
	at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor
    .execute(HierarchicalTestExecutor.java:57)
	at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine
    .execute(HierarchicalTestEngine.java:51)
	at org.junit.platform.launcher.core.DefaultLauncher
    .execute(DefaultLauncher.java:248)
	at org.junit.platform.launcher.core.DefaultLauncher
    .lambda$execute$5(DefaultLauncher.java:211)
	at org.junit.platform.launcher.core.DefaultLauncher
    .withInterceptedStreams(DefaultLauncher.java:226)
	at org.junit.platform.launcher.core.DefaultLauncher
    .execute(DefaultLauncher.java:199)
	at org.junit.platform.launcher.core.DefaultLauncher
    .execute(DefaultLauncher.java:132)
	at com.intellij.junit5.JUnit5IdeaTestRunner
    .startRunnerWithArgs(JUnit5IdeaTestRunner.java:69)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater
    .startRunnerWithArgs(IdeaTestRunner.java:33)
	at com.intellij.rt.junit.JUnitStarter
    .prepareStreamsAndStart(JUnitStarter.java:230)
	at com.intellij.rt.junit.JUnitStarter
    .main(JUnitStarter.java:58)
Caused by: java.lang.AssertionError: Status expected:<200 OK>
        but was:<500 INTERNAL_SERVER_ERROR>
	at org.springframework.test.util.AssertionErrors
    .fail(AssertionErrors.java:59)
	at org.springframework.test.util.AssertionErrors
    .assertEquals(AssertionErrors.java:122)
	at org.springframework.test.web.reactive.server.StatusAssertions
    .lambda$assertStatusAndReturn$4(StatusAssertions.java:227)
	at org.springframework.test.web.reactive.server.ExchangeResult
    .assertWithDiagnostics(ExchangeResult.java:206)
	... 66 more

如您所见,单个错误值的信息量相当难以消化。存在有用的信息,但它被过多、不太有用的数据所淹没。

注意

我勉强但有意地包含了上述 Flux 错误产生的完整输出,以显示当 Publisher 遇到错误时导航通常输出的困难,以及如何通过有效的工具显著降低噪音并增强关键信息的信号。找到问题的核心不仅减少了开发中的挫折感,而且在生产中故障排除业务关键应用程序时绝对至关重要。

Project Reactor 包含可通过其 Hooks 类调用的可配置生命周期回调 hooks。其中一个特别有用的操作符,在事情出错时提高信噪比的是 onOperatorDebug()

在实例化失败的Publisher之前调用Hooks.onOperatorDebug()使得所有后续Publisher类型(及其子类型)的汇编时间仪表化成为可能。为了确保在必要的时间捕获必要的信息,通常将调用放置在应用程序的主方法中,如下所示:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import reactor.core.publisher.Hooks;

@SpringBootApplication
public class AircraftPositionsApplication {

	public static void main(String[] args) {
		Hooks.onOperatorDebug();
		SpringApplication.run(AircraftPositionsApplication.class, args);
	}

}

由于我是从测试类演示此功能,我在故意失败的Publisher组装之前的一行插入了Hooks.onOperatorDebug();

Hooks.onOperatorDebug();
Mockito.when(service.getAllAircraft()).thenReturn(
        Flux.just(ac1, ac2, ac3)
                .concatWith(Flux.error(new Throwable("Bad position report")))
);

这个单独的添加并没有消除有点冗长的堆栈跟踪—尽管还有偶尔提供任何额外数据的情况下可能有所帮助—但是对于大多数情况,由onOperatorDebug()添加到日志的树摘要结果使得更快地识别和解决问题。为了保留完整的细节和格式,我在getCurrentACPositions()测试中引入的相同错误的回溯摘要显示在图 12-3 中。

sbur 1203

图 12-3. 调试回溯

在树的顶部是证据:在PositionControllerTest.java的第 68 行使用concatWith引入了Flux错误。由于Hooks.onOperatorDebug()的帮助,识别此问题及其具体位置所花费的时间从几分钟(甚至更多)减少到几秒钟。

为了所有后续Publisher出现的所有汇编指令仪表化,这种单个添加并不是没有成本的;然而,使用钩子来仪表化您的代码在运行时相对昂贵,因为调试模式是全局的,并且在启用后会影响每个响应流Publisher的每个链接操作符。让我们考虑另一种选择。

检查点

与其填充每个可能的Publisher的每个可能的回溯,不如在关键运算符附近设置检查点以协助故障排除。将checkpoint()运算符插入链中的工作方式类似于启用钩子,但仅适用于该操作符链的该段。

有三种检查点变体:

  • 包括回溯的标准检查点

  • 接受描述性String参数并且不包括回溯的轻量检查点

  • 包括回溯的标准检查点,也接受描述性String参数

让我们看看它们的实际表现。

首先,在PositionControllerTest中的setUp()方法中为PositionService::getAllAircraft的模拟方法之前,我删除了Hooks.onOperatorDebug()语句:

//Hooks.onOperatorDebug();      Comment out or remove
Mockito.when(service.getAllAircraft()).thenReturn(
    Flux.just(ac1, ac2, ac3)
        .checkpoint()
        .concatWith(Flux.error(new Throwable("Bad position report")))
        .checkpoint()
);

重新运行getCurrentACPositions()的测试会生成图 12-4 中显示的结果。

sbur 1204

图 12-4. 标准检查点输出

列表顶部的检查点指导我们找到了问题运算符:即触发检查点之前的那个。请注意,仍在收集回溯信息,因为检查点反映了我在PositionControllerTest类的第 64 行插入的实际源代码文件和特定行号。

切换到轻量级检查点将回溯信息收集替换为开发者指定的有用的String描述。虽然标准检查点的回溯收集范围有限,但仍需要比简单存储String更多的资源。如果以足够详细的方式完成,轻量级检查点可以提供定位问题运算符的同样实用性。更新代码以利用轻量级检查点是一件简单的事情:

//Hooks.onOperatorDebug();      Comment out or remove
Mockito.when(service.getAllAircraft()).thenReturn(
    Flux.just(ac1, ac2, ac3)
        .checkpoint("All Aircraft: after all good positions reported")
        .concatWith(Flux.error(new Throwable("Bad position report")))
        .checkpoint("All Aircraft: after appending bad position report")
);

重新运行getCurrentACPositions()测试将产生如图 12-5 所示的结果。

sbur 1205

图 12-5. 轻量级检查点输出

虽然文件和行号坐标不再出现在列表中排名第一的检查点中,但其清晰的描述使得在Flux组装中找到问题运算符变得容易。

偶尔会需要使用一系列极其复杂的运算符来构建一个Publisher。在这些情况下,包含故障排除的描述和完整的回溯信息可能会很有用。为了演示一个非常有限的例子,我再次重构了用于PositionService::getAllAircraft的模拟方法如下:

//Hooks.onOperatorDebug();      Comment out or remove
Mockito.when(service.getAllAircraft()).thenReturn(
    Flux.just(ac1, ac2, ac3)
        .checkpoint("All Aircraft: after all good positions reported", true)
        .concatWith(Flux.error(new Throwable("Bad position report")))
        .checkpoint("All Aircraft: after appending bad position report", true)
);

再次运行getCurrentACPositions()测试将导致输出如图 12-6 所示。

sbur 1206

图 12-6. 带有描述输出的标准检查点

ReactorDebugAgent.init()

有一种方法可以实现在应用程序中为所有Publishers获得完整回溯的好处,就像使用钩子生成的那样,而无需启用调试功能所带来的性能损失。

在 Reactor 项目中有一个名为reactor-tools的库,其中包括一个单独的 Java 代理用于为包含应用程序的代码添加调试信息。 reactor-tools向应用程序添加调试信息,并连接到运行中的应用程序(它是一个依赖项),以跟踪每个后续的Publisher的执行,提供几乎零性能影响的详细回溯信息,类似于使用钩子。因此,在生产环境中启用ReactorDebugAgent后,几乎没有什么坏处,而且有很多好处。

作为一个独立的库,reactor-tools必须手动添加到应用程序的构建文件中。对于飞行器位置应用程序的 Maven pom.xml,我添加了以下条目:

<dependency>
    <groupId>io.projectreactor</groupId>
    <artifactId>reactor-tools</artifactId>
</dependency>

在保存更新的pom.xml后,我刷新/重新导入依赖项,以便在项目中使用ReactorDebugAgent

类似于Hooks.onOperatorDebug()ReactorDebugAgent通常在应用程序的主方法中初始化,然后再运行应用程序。由于我将在一个不加载完整应用程序上下文的测试中演示这一点,我会像之前使用Hooks.onOperatorDebug()一样在构建用于演示运行时执行错误的Flux之前立即插入初始化调用。我还删除了现在不再需要的checkpoint()调用:

//Hooks.onOperatorDebug();
ReactorDebugAgent.init();       // Add this line
Mockito.when(service.getAllAircraft()).thenReturn(
        Flux.just(ac1, ac2, ac3)
                .concatWith(Flux.error(new Throwable("Bad position report")))
);

再次回到getCurrentACPositions()测试,我运行它并得到了类似于由Hooks.onOperatorDebug()提供的摘要树输出的总结,但没有运行时惩罚:

sbur 1207

图 12-7. 由Flux错误导致的 ReactorDebugAgent 输出

还有其他工具可用,虽然它们不直接帮助测试或调试响应式应用程序,但它们可以帮助提高应用程序质量。一个例子是BlockHound,尽管超出了本章的范围,但它可以成为确定应用程序代码或其依赖项中是否隐藏了阻塞调用的有用工具。当然,这些和其他工具正在快速演变和成熟,提供多种方式来提升您的响应式应用程序和系统。

代码检查

对于完整的章节代码,请查看代码库中的chapter12end分支。

总结

响应式编程为开发人员提供了一种在分布式系统中更好地利用资源的方式,甚至将强大的扩展机制延伸到应用程序边界和通信渠道中。对于那些仅具有主流 Java 开发实践经验的开发人员——通常称为命令式Java,因为它采用显式和顺序逻辑,而不是响应式编程中通常使用的更声明式的方法——这些响应式能力可能带来一些不希望的成本。除了预期的学习曲线,Spring 通过并行和互补的 WebMVC 和 WebFlux 实现大大降低了这些成本。此外,工具、成熟度和针对测试、故障排除和调试等基本活动的建立实践也存在相对限制。

尽管响应式 Java 开发相对于其命令式兄弟处于起步阶段,但它们同属一个家族使得有可能更快地开发和成熟出有用的工具和流程。正如前文所述,Spring 同样依赖于其在开发和社区中建立的成熟命令式专业知识,将数十年的演变凝结为现在可用的生产就绪组件。

在本章中,我介绍并详细阐述了测试、诊断和调试问题的当前技术状态,这些问题可能会在您开始部署响应式 Spring Boot 应用程序时遇到。我还演示了如何在生产环境中使用 WebFlux/Reactor 来为您工作,以多种方式测试和排查响应式应用程序,展示了每种可用选项的相对优势。即使现在,您已经有很多工具可供使用,而且前景只会变得更好。

在这本书中,我不得不选择无数个“最佳部分”中的哪些来覆盖,以便以我希望的最佳方式入门并运行 Spring Boot。还有很多内容,我只希望能将书的范围扩展一倍(或三倍)来涵盖更多内容。感谢您在这段旅程中的陪伴;我希望未来能分享更多。祝您在继续使用 Spring Boot 时一切顺利。

posted @ 2024-06-15 12:23  绝不原创的飞龙  阅读(24)  评论(0编辑  收藏  举报