Spring 5 的主要特点是对响应式编程的支持,包括 Spring WebFlux,这是一个全新的响应式 web 框架,它借鉴了 Spring MVC 的编程模型,允许开发人员创建可更好地扩展和使用更少线程的 web 应用程序。
第一部分 Spring 基础
本书的第一部分将帮助你开始编写 Spring 应用程序,学习 Spring 的基础。
在第一章中,将快速概述 Spring 和 Spring Boot 的要点,并展示如何在 Taco Cloud 上初始化第一个 Spring 项目。在第二章中,你将深入研究 Spring MVC,并了解如何在浏览器中呈现模型数据以及如何处理并验证表单输入,还将获得选择视图模板库的一些提示。在第三章中,将在 Taco Cloud 应用程序中添加数据持久性,我们将介绍使用 Spring 的 JDBC 模板,如何插入数据以及如何使用 Spring Data 声明 JPA 存储库。第四章介绍了 Spring 应用程序的安全性,包括自动配置 Spring Security,定义自定义用户存储,自定义登录页面并防止跨站点请求伪造(CSRF)攻击。在第一部分中,我们将在第五章中介绍配置属性,将学习如何微调自动配置的 bean 和应用配置属性到应用程序组件,并与 Spring 的 profile 文件一起使用。
第 1 章 Spring 入门
本章内容:
- Spring 和 Spring Boot 概述
- 初始化一个 Spring 项目
- 纵览 Spring
尽管希腊哲学家赫拉克利特(Heraclitus)并非以软件开发人员而闻名,他在这个问题上似乎掌握得很好。有人引用他的话说:“唯一不变的就是变化。” 这句话体现了软件开发的实质。
当 Rod Johnson 在书《Expert One-on-One J2EE Design and Development》(Wrox,2002,http://mng.bz/oVjy)中介绍了 Spring 框架的最初形式后,我们今天开发应用程序的方式与一年前、五年前、十年前,甚至15年前都不一样了。
当时,开发的最常见的应用程序类型是基于浏览器的 web 应用程序,由关系数据库支持。虽然这种类型的开发仍然是相关的,而且 Spring 已经为这种类型的应用程序做好了很好的准备,但是我们现在还对开发由面向云的微服务组成的应用程序感兴趣,这些服务将数据持久化到各种数据库中。而对响应式编程的新兴趣在于通过非阻塞操作提供更大的可伸缩性和更好的性能。
随着软件开发的发展,Spring 框架也发生了变化,以解决现代开发问题,包括微服务和响应式编程。Spring 还通过引入 Spring Boot 来简化自己的开发模型。
无论您是开发简单的数据库支持的 web 应用程序,还是构建基于微服务的现代应用程序,Spring 都是帮助您实现目标的框架。本章是您使用 Spring 进行现代应用程序开发的第一步。
什么是 Spring?
我知道你可能很想开始编写 Spring 应用程序,我向你保证,在本章结束之前,你将开发一个简单的应用程序。但是首先,我得介绍一些 Spring 的基本概念,以帮助你了解 Spring 的变化。
任何不平凡的应用程序都由许多组件组成,每个组件负责自己的在整体应用程序中的那部分功能,并与其他应用程序元素协调以完成工作。在运行应用程序时,需要以某种方式创建这些组件并相互引用。
Spring 的核心是一个 容器,通常称为 Spring 应用程序上下文,用于创建和管理应用程序组件。这些组件(或 bean)在 Spring 应用程序上下文中连接在一起以构成一个完整的应用程序,就像将砖、灰浆、木材、钉子、管道和电线绑在一起以组成房屋。
将 bean 连接在一起的行为是基于一种称为 依赖注入(DI)的模式。依赖项注入的应用程序不是由组件自身创建和维护它们依赖的其他 bean 的生命周期,而是依赖于单独的实体(容器)来创建和维护所有组件,并将这些组件注入需要它们的 bean。通常通过构造函数参数或属性访问器方法完成此操作。
例如,假设在应用程序的许多组件中,要处理两个组件:inventory service(用于获取库存级别)和 product service(用于提供基本产品信息)。product service 取决于 inventory service,以便能够提供有关产品的完整信息。图 1.1 说明了这些 bean 与 Spring 应用程序上下文之间的关系。
除了其核心容器之外,Spring 和完整的相关库产品组合还提供 Web 框架、各种数据持久性选项、安全框架与其他系统的集成、运行时监视、微服务支持、响应式编程模型以及许多其他功能,应用于现代应用程序开发。
从历史上看,引导 Spring 应用程序上下文将 bean 连接在一起的方式是使用一个或多个 XML 文件,这些文件描述了组件及其与其他组件的关系。例如,以下 XML 声明两个 bean,一个 InventoryService bean 和一个 ProductService bean,然后通过构造函数参数将 InventoryService bean 注入到 ProductService 中:

1 | <bean id="inventoryService" class="com.example.InventoryService" /> |
但是,在最新版本的 Spring 中,基于 Java 的配置更为常见。以下基于 Java 的配置类等效于 XML 配置:
1 |
|
@Configuration 注释向 Spring 表明这是一个配置类,它将为 Spring 应用程序上下文提供 beans。 配置的类方法带有 @Bean 注释,指示它们返回的对象应作为 beans 添加到应用程序上下文中(默认情况下,它们各自的 bean IDs 将与定义它们的方法的名称相同)。
与基于 XML 的配置相比,基于 Java 的配置具有多个优点,包括更高的类型安全性和改进的可重构性。即使这样,仅当 Spring 无法自动配置组件时,才需要使用 Java 或 XML 进行显式配置。
自动配置起源于 Spring 技术,即 自动装配 和 组件扫描。借助组件扫描,Spring 可以自动从应用程序的类路径中发现组件,并将其创建为 Spring 应用程序上下文中的 bean。通过自动装配,Spring 会自动将组件与它们依赖的其他 bean 一起注入。
最近,随着 Spring Boot 的推出,自动配置的优势已经远远超出了组件扫描和自动装配。Spring Boot 是 Spring 框架的扩展,它提供了多项生产力增强功能。这些增强功能中最著名的就是 自动配置,在这种配置中,Spring Boot 可以根据类路径中的条目、环境变量和其他因素,合理地猜测需要配置哪些组件,并将它们连接在一起。
这里想要展示一些演示自动配置的示例代码,但是并没有这样的代码,自动配置就如同风一样,可以看到它的效果,但是没有代码可以展示。我可以说 “看!这是自动配置的示例!” 事情发生、组件启用并且提供了功能,而无需编写代码。缺少代码是自动配置必不可少的要素,这使它如此出色。
Spring Boot 自动配置大大减少了构建应用程序所需的显式配置(无论是 XML 还是 Java)的数量。实际上,当完成本章中的示例时,将拥有一个正在运行的 Spring 应用程序,该应用程序仅包含一行 Spring 配置代码!
Spring Boot 极大地增强了 Spring 开发的能力,很难想象没有它如何开发 Spring 应用程序。因此,本书将 Spring 和 Spring Boot 视为一模一样。我们将尽可能使用 Spring Boot,并仅在必要时使用显式配置。而且,由于 Spring XML 配置是使用 Spring 的老派方式,因此我们将主要关注基于 Java 的 Spring 配置。
但是有这些功能就足够了,本书的标题包括 实战 这个词语,因此让我们动起来,立马开始使用 Spring 编写第一个应用程序。
初始化 Spring 应用程序
在本书的学习过程中,将创建 Taco Cloud,这是一个在线应用程序,用于订购由真人制作的最美味的食物 - 玉米饼。 当然,将使用 Spring、Spring Boot 以及各种相关的库和框架来实现此目标。
初始化 Spring 应用程序的有多个选择。尽管我可以指导你逐步完成手动创建项目目录结构和定义构建规范的步骤,但这却浪费了时间,最好花费更多时间编写应用程序代码。因此,将依靠 Spring Initializr 来引导应用程序的创建。
Spring Initializr 既是一个基于浏览器的 Web 应用程序,又是一个 REST API,它们可以生成一个基本的 Spring 项目结构,可以使用所需的任何功能充实自己。 使用 Spring Initializr 的几种方法如下:
- 从 Web 应用程序 http://start.spring.io 创建
- 使用
curl命令从命令行创建 - 使用 Spring Boot 命令行接口从命令行创建
- 使用 Spring Tool Suite 创建一个新项目的时候
- 使用 IntelliJ IDEA 创建一个新项目的时候
- 使用 NetBeans 创建一个新项目的时候
我没有在本章中花费数页来讨论这些选项中的每一个,而是在附录中收集了这些详细信息。在本章以及整本书中,将展示如何使用 Spring Tool Suite 中对 Spring Initializr 的支持来创建一个新项目。
顾名思义,Spring Tool Suite 是一个绝佳的 Spring 开发环境。但是它还提供了一个方便的 Spring Boot Dashboard 功能(至少在撰写本文时)其他任何 IDE 选项中均不提供。
如果你不是 Spring Tool Suite 用户,很好,我们是朋友了。跳至附录,用最适合你的 Initializr 选项代替以下各节中的说明。但是要知道,在本书中,我偶尔会引用特定于 Spring Tool Suite 的功能,例如 Spring Boot Dashboard。如果你不使用 Spring Tool Suite,则需要调整这些说明以适合你的 IDE。
使用 Spring Tool Suite 初始化 Spring 项目
要开始使用 Spring Tool Suite 中的新建 Spring 项目,请转到 “文件” 菜单并选择 “新建”,然后选择 “Spring Starter Project”。图 1.2 显示了要查找的菜单结构。

一旦选择了 Spring Starter Project,就会出现创建新的项目向导对话框(图1.3)。向导的第一页要求提供一些常规项目信息,例如项目名称、描述和其他基本信息。如果您熟悉Maven pom.xml 文件的内容,则可以将大多数字段识别为以 Maven 构建规范结尾的项目。对于 Taco Cloud 应用程序,填写如图 1.3 所示的对话框,然后单击 “下一步”。

向导的下一页使您可以选择要添加到项目中的依赖项(请参见图 1.4)。注意,在对话框顶部附近,您可以选择要作为项目基础的 Spring Boot 版本。默认为最新可用版本。通常,最好保持原样,除非您需要定位其他版本。
至于依赖项本身,您可以展开各个部分并手动查找所需的依赖项,或者在 “可用” 列表顶部的搜索框中搜索它们。对于 Taco Cloud 应用程序,选择图 1.4 中所示的依赖项。
此时,可以单击完成以生成项目并将其添加到工作区。但是,如果感到有点危险,请再次单击 “下一步”,以查看新的 starter 项目向导的最后一页,如图 1.5 所示。

默认情况下,新项目向导在 http://start.spring.io 上调用 Spring Initializr 以生成项目。通常,不需要覆盖此默认值,这就是为什么可以在向导第二页上单击 “完成” 的原因。但是,如果由于某种原因要托管自己的 Initializr 克隆版本(也许是自己计算机上的本地副本,或者是在公司防火墙内运行的自定义克隆版本),那么将需要更改 Base Url 字段以指向 Initializr 实例,然后单击完成。
单击完成后,将从 Initializr 下载该项目并将其加载到工作区中。稍等片刻,使其加载和构建,然后就可以开始开发应用程序功能了。但是首先,让我们看一下 Initializr 所带来的好处。
检查 Spring 项目结构
在 IDE 中加载项目后,将其展开以查看其中包含的内容。图 1.6 显示了 Spring Tool Suite 中扩展的 Taco Cloud 项目。

你可能会认为这是典型的 Maven 或 Gradle 项目结构,其中应用程序源代码位于src/main/java 下,测试代码位于 src/test/java 下,非 Java 资源位于 src/main/resources 下 。在该项目结构中,需要注意以下事项:
mvnw和mvnw.cmd—— 这些是 Maven 包装器脚本。即使你的计算机上没有安装 Maven,也可以使用这些脚本构建项目。pom.xml—— 这是 Maven 构建规范,一会儿我们将对此进行更深入的研究。TacoCloudApplication.java—— 这是引导项目的 Spring Boot 主类。稍后,我们将在这节详细介绍。application.properties—— 该文件最初为空,但提供了一个可以指定配置属性的地方。我们将在本章中对此文件进行一些修改,但在第 5 章中将详细介绍配置属性。static—— 在此文件夹中,可以放置要提供给浏览器的任何静态内容(图像、样式表、JavaScript 等),最初为空。templates—— 在此文件夹中,放置用于向浏览器呈现内容的模板文件。最初为空,但很快会添加 Thymeleaf 模板。TacoCloudApplicationTests.java—— 这是一个简单的测试类,可确保成功加载 Spring 应用程序上下文。开发应用程序时,将添加更多的测试。
随着 Taco Cloud 应用程序的增长,将使用 Java 代码、图像、样式表、测试以及其他可帮助完成项目的附带材料来填充此准系统的项目结构。但是与此同时,让我们更深入地研究 Spring Initializr 提供的一些选项。
探索构建规范
填写 Initializr 表单时,指定应使用 Maven 构建项目。因此,Spring Initializr 给了你一个 pom.xml 文件,该文件已经填充了你所做的选择。程序清单 1.1 显示了 Initializr 提供的整个 pom.xml 文件。
1 |
|
xml 文件中第一个值得注意的项是 <packaging> 元素。你选择将应用程序构建为可执行的 JAR 文件,而不是 WAR 文件。这可能是你所做的最奇怪的选择之一,特别是对于 web 应用程序。毕竟,传统的 Java web 应用程序被打包为 WAR 文件,而 JAR 文件是库和偶尔使用的桌面 UI 应用程序的首选打包方式。
选择 JAR 打包是一种不切实际的选择。虽然 WAR 文件非常适合部署到传统的 Java 应用服务器,但是它们并不适合大多数云平台。尽管一些云平台(如 Cloud Foundry)能够部署和运行 WAR 文件,但是所有的 Java 云平台都能够运行可执行的 JAR 文件。因此,Spring Initializr 默认为 JAR 打包,除非你不让它这样做。
如果打算将应用程序部署到传统的 Java 应用服务器,那么需要选择 WAR 打包并包含 web 初始化类。我们将在第 2 章中更详细地讨论如何构建 WAR 文件。
接下来,请注意 <parent> 元素,更具体地说,注意它的 <version> 子元素。这指定您的项目将 spring-boot-starter-parent 作为它的父 POM。除此之外,这个父 POM 还为 Spring 项目中常用的几个库提供依赖项管理。对于父 POM 覆盖的那些库,不必指定版本,因为它是从父 POM 继承的。2.0.4.RELEASE 版本,表示你正在使用 Spring Boot 2.0.4,这样项目将使用继承自 Spring Boot 版本中定义的依赖项管理。
在讨论依赖项时,请注意在 <dependencies> 元素下声明了三个依赖项。前两个看起来应该比较熟悉。它们直接对应于在单击 Spring Tool Suite 新建项目向导中的 Finish 按钮之前选择的 Web 和 Thymeleaf 依赖项。第三个依赖项提供了许多有用的测试功能,你不必选中包含它的方框,因为 Spring Initializr 假定(希望是正确的)你将编写测试。
你可能还会注意到,所有这三个依赖项的 artifact ID 中都有 starter 这个词。Spring Boot starter 依赖项的特殊之处在于,它们本身通常没有任何库代码,而是间接地引入其他库。这些 starter 依赖提供了三个主要的好处:
- 构建的文件将会小得多,也更容易管理,因为不需要对每一个可能需要的库都声明一个依赖项。
- 可以根据它们提供的功能来考虑需要的依赖关系,而不是根据库名来考虑。如果正在开发一个 web 应用程序,那么将添加 web starter 依赖项,而不是一个编写 web 应用程序的各个库的清单。
- 不用担心 library 版本问题。可以相信的是,对于给定版本的 Spring Boot,可间接地引入的库的版本将是兼容的,只需要考虑使用的是哪个版本的 Spring Boot。
最后,构建规范以 Spring Boot 插件结束。这个插件执行一些重要的功能:
- 提供了一个 Maven 编译目标,让你能够使用 Maven 运行应用程序。这将在第 1.3.4 节中尝试实现这个目标。
- 确保所有的依赖库都包含在可执行的 JAR 文件中,并且在运行时类路径中可用。
- 在 JAR 文件中生成一个 manifest 文件,表示引导类(在本书例子中是
TacoCloudApplication)是可执行 JAR 的主类。
说到引导类,让我们打开它,仔细看看。
引导应用程序
因为将从一个可执行的 JAR 运行应用程序,所以在运行 JAR 文件时,有一个主类来执行是很重要的。还需要至少一个最小的 Spring 配置文件来引导应用程序。这就是将在 TacoCloudApplication 类中找到的内容,如下程序清单 1.2 所示。
1 | package tacos; |
虽然 TacoCloudApplication 中只有很少的代码,但是其中包含了相当丰富的内容。最强大的代码行之一也是最短的代码行之一。@SpringBootApplication 注释清楚地表明这是一个 Spring 引导应用程序。但是 @SpringBootApplication 中有更多的东西。@SpringBootApplication 是一个组合了其他三个注释的复合应用程序:
@SpringBootConfiguration—— 指定这个类为配置类。尽管这个类中还没有太多配置,但是如果需要,可以将 Javabased Spring Framework 配置添加到这个类中。实际上,这个注释是@Configuration注释的一种特殊形式。@EnableAutoConfiguration—— 启用 Spring 自动配置。稍后我们将详细讨论自动配置。现在,要知道这个注释告诉 Spring Boot 自动配置它认为需要的任何组件。@ComponentScan—— 启用组件扫描。这允许你声明其他带有@Component、@Controller、@Service等注释的类,以便让 Spring 自动发现它们并将它们注册为 Spring 应用程序上下文中的组件。
TacoCloudApplication 的另一个重要部分是 main() 方法。这个方法将在执行 JAR 文件时运行。在大多数情况下,这种方法是样板代码;编写的每个 Spring 引导应用程序都有一个类似或相同的方法(尽管类名不同)。
main() 方法调用 SpringApplication 类上的静态 run() 方法,该方法执行应用程序的实际引导,创建Spring 应用程序上下文。传递给 run() 方法的两个参数是一个配置类和命令行参数。虽然传递给 run() 的配置类不必与引导类相同,但这是最方便、最典型的选择。
你可能不需要更改引导类中的任何内容。对于简单的应用程序,你可能会发现在引导类中配置一两个其他组件很方便,但是对于大多数应用程序,最好为任何没有自动配置的东西创建一个单独的配置类。你将在本书的整个过程中定义几个配置类,因此请注意这些细节。
测试应用程序
测试是软件开发的一个重要部分。认识到这一点后,Spring Initializr 提供了一个测试类。程序清单 1.3 显示了基准测试类。
1 | package tacos; |
在 TacoCloudApplicationTests 中没有太多东西:类中的一个测试方法是空的。尽管如此,这个测试类确实执行了必要的检查,以确保 Spring 应用程序上下文能够成功加载。如果做了任何阻止创建 Spring 应用程序上下文的更改,则此测试将失败,这样你就可以通过解决问题来应对。
还要注意用 @RunWith(SpringRunner.class) 注释的类。@RunWith 是一个 JUnit 注释,提供了一个测试运行器来引导 JUnit 运行测试用例。请将清单 1.3 看作是对它的基准应用程序测试,即将插件应用到 JUnit 以提供自定义测试行为。在本例中,JUnit 被赋予了 SpringRunner,这是一个由 Spring 提供的测试运行程序,它提供了创建一个 Spring 应用程序上下文的功能,以供测试运行。
其他名字的测试运行器
如果你已经熟悉编写 Spring 测试,或者正在查看一些现有的基于 Spring 的测试类,那么你可能已经看到了一个名为 SpringJUnit4ClassRunner 的测试运行器。SpringRunner 是 SpringJUnit4ClassRunner 的别名,它是在 Spring 4.3 中引入的,用于删除与特定版本的 JUnit (例如,JUnit4)的关联。毫无疑问,别名更易于阅读和输入。
@SpringBootTest 告诉 JUnit 使用 Spring 引导功能引导测试。现在,把它看作是在 main() 方法中调用 SpringApplication.run() 的测试类就足够了。在本书的过程中,将多次看到 @SpringBootTest,我们将揭示它的一些功能。
最后,还有测试方法本身。尽管 @RunWith(SpringRunner.class) 和 @SpringBootTest 的任务是加载用于测试的 Spring 应用程序上下文,但是如果没有任何测试方法,它们将没有任何事情要做。即使没有任何断言或任何类型的代码,这个空的测试方法也会调用两个注释完成它们的工作,并加载 Spring 应用程序上下文。如果运行过程中有任何问题,测试就会失败。
至此,我们已经完成了对 Spring Initializr 提供的代码的回顾。看到了一些用于开发 Spring 应用程序的样板基础,但是仍然没有编写任何代码。现在,启动 IDE,掸掉键盘上的灰尘,并向 Taco Cloud 应用程序添加一些定制代码。
编写 Spring 应用程序
因为才刚刚开始,所以我们将从对 Taco Cloud 应用程序的一个相对较小的更改开始,但是这个更改将展示 Spring 的很多优点。在刚刚开始的时候,添加到 Taco Cloud 应用程序的第一个功能是主页,这似乎是合适的。当你添加主页,你将创建两个代码构件:
- 一个处理主页请求的控制器类
- 一个视图模板,定义了主页的外观
因为测试很重要,所以还将编写一个简单的测试类来测试主页。但首先…我们来写这个控制器。
处理 web 请求
Spring 附带了一个强大的 web 框架,称为 Spring MVC。Spring MVC 的核心是控制器的概念,这是一个处理请求并使用某种信息进行响应的类。对于面向浏览器的应用程序,控制器的响应方式是可选地填充模型数据并将请求传递给视图,以生成返回给浏览器的 HTML。
你将在第 2 章学到很多关于 Spring MVC 的知识。但是现在,将编写一个简单的控制器类来处理根路径的请求(例如 /),并将这些请求转发到主页视图,而不填充任何模型数据。程序清单 1.4 显示了简单的控制器类。
1 | package tacos; |
可以看到,这个类是用 @Controller 注释的。@Controller 本身并没有做多少事情。它的主要目的是将该类识别为组件扫描的组件。由于 HomeController 是用 @Controller 注释的,因此 Spring 的组件扫描会自动发现它,并在 Spring 应用程序上下文中创建一个 HomeController 实例作为 bean。
实际上,其他一些注释(包括 @Component、@Service 和 @Repository)的用途与 @Controller 类似。你可以用任何其他的注解来有效地注释 HomeController,它仍然可以工作。但是,选择 @Controller 更能描述该组件在应用程序中的角色。
home() 方法与控制器方法一样简单。它使用 @GetMapping 进行注释,以指示如果接收到根路径 / 的 HTTP GET 请求,则此方法应该处理该请求。除了返回 home 的 String 值外,它什么也不做。
此值被解释为视图的逻辑名称。如何实现该视图取决于几个因素,但是因为 Thymeleaf 在类路径中,所以可以使用 Thymeleaf 定义该模板。
为什么是 Thymeleaf?
你可能想知道为什么选择 Thymeleaf 作为模板引擎。为什么不是 JSP?为什么不是 FreeMarker?为什么不是其他几个选项中的一个呢?
简单地说,我必须选择一些东西,我喜欢 Thymeleaf,相比其他选项更喜欢。尽管 JSP 看起来是一个不错的选择,但是在使用 JSP 进行 Spring 引导时仍然存在一些需要克服的挑战。我不想在第 1 章中掉进那个陷阱。不要紧,我们将在第 2 章中讨论其他模板选项,包括 JSP。
模板名称由逻辑视图名称派生而来,它的前缀是 /templates/,后缀是 .html。模板的结果路径是 /templates/home.html。因此,需要将模板放在项目的 /src/main/resources/templates/home.html 中。现在让我们创建该模板。
定义视图
为了保持你的主页简洁,它应该做的只是欢迎用户访问网站。程序清单 1.5 显示了定义 Taco Cloud 主页的基本 Thymeleaf 模板。
1 |
|
关于这个模板没有太多要讨论的。唯一值得注意的代码行是显示 Taco Cloud 标志的 <img> 标记。它使用一个 Thymeleaf 的 th:src 属性和一个 @{…} 表达式引用具有上下文相对路径的图片。除去这些,它只是一个 Hello World 页面。
但是让我们再多讨论一下这个图片。我将把它留给你来定义一个你喜欢的 Taco Cloud 标志。你需要将它放在项目中的恰当位置。
该图片是通过上下文相对路径 /images/TacoCloud.png 进行引用的。从我们对项目结构的回顾中可以想起,像图片这样的静态内容保存在 /src/main/resources/static 文件夹中。这意味着 Taco Cloud 标志图片也必须驻留在项目的 /src/main/resources/static/images/TacoCloud.png 中。
现在已经有了处理主页请求的控制器和呈现主页的视图模板,几乎已经准备好启动应用程序并看到它的实际运行效果了。但首先,让我们看看如何针对控制器编写测试。
测试控制器
在对 HTML 页面的内容进行断言时,测试 web 应用程序可能比较棘手。幸运的是,Spring 提供了一些强大的测试支持,使测试 web 应用程序变得很容易。
就主页而言,你将编写一个与主页本身复杂度相当的测试。你的测试将对根路径 / 执行一个 HTTP GET 请求并期望得到一个成功的结果,其中视图名称为 home,结果内容包含短语 “Welcome to…”。程序清单 1.6 应该可以达到目的。
1 | package tacos; |
关于这个测试,你可能注意到的第一件事是,它与 TacoCloudApplicationTests 类在应用到它的注释方面略有不同。HomeControllerTest 使用 @WebMvcTest 注释,而不是 @SpringBootTest 标记。这是 Spring Boot 提供的一个特殊测试注释,它安排测试在 Spring MVC 应用程序的上下文中运行。更具体地说,在本例中,它安排 HomeController 在 Spring MVC 中注册,这样你就可以对它进行请求。
@WebMvcTest 还为测试 Spring MVC 提供了 Spring 支持。虽然可以让它启动服务器,但模拟 Spring MVC 的机制就足以满足你的目的了。测试类被注入了一个 MockMvc 对象中,以此用来测试来驱动模型。
testHomePage() 方法定义了要对主页执行的测试。它从 MockMvc 对象开始,执行针对 /(根路径)的 HTTP GET 请求。该请求规定了下列期望值:
- 响应应该有一个HTTP 200(OK)状态。
- 视图应该有一个合理主页名称。
- 呈现的视图应该包含 “Welcome to…”
如果在 MockMvc 对象执行请求之后,这些期望中的任何一个都没有满足,那么测试就会失败。但是控制器和视图模板是为了满足这些期望而编写的,所以测试应该能够通过,或者至少能够通过一些表示测试通过的绿色提示。
控制器写好了,视图模板创建好了,测试通过了。看来你已经成功地实现了主页。但是,即使测试通过了,在浏览器中查看结果也会稍微让人更满意一些。毕竟,Taco Cloud 的客户也将这样看待它。让我们构建应用程序并运行它。
构建并运行应用程序
正如有多种方法可以初始化 Spring 应用程序一样,也有多种方法可以运行 Spring 应用程序。如果愿意,可以翻到附录部分,阅读一些更常见的运行 Spring 引导应用程序的方法。
因为选择使用 Spring Tool Suite 来初始化和处理项目,所以有一个称为 Spring Boot Dashboard 的便利功能可以帮助你在 IDE 中运行应用程序。Spring Boot Dashboard 显示为一个选项卡,通常位于 IDE 窗口的左下方。图 1.7 显示了 Spring Boot Dashboard 的注释截图。
虽然图 1.7 包含了一些最有用的细节,但我不想花太多时间来检查 Spring Boot Dashboard 所做的一切。现在需要知道的重要事情是如何使用它来运行 Taco Cloud 应用程序。确保 taco-cloud 应用程序在项目列表中突出显示(这是图 1.7 中显示的惟一应用程序),然后单击 start 按钮(最左边的按钮,其中有绿色三角形和红色正方形),应用程序应该会立即启动。

当应用程序启动时,将在控制台中看到一些 Spring ASCII 图飞过,然后看到一些日志条目描述应用程序启动时的步骤。在停止日志记录之前,将看到一个日志条目,其中说 Tomcat 在 port(s): 8080 (http) 上启动,这意味着已经准备好将 web 浏览器指向主页,以查看结果。
等一下,Tomcat 启动?何时将应用程序部署到 Tomcat?
Spring Boot 应用程序倾向于裹挟所有需要的东西,而不需要部署到某个应用服务器。你从未将应用程序部署到 Tomcat… 其实 Tomcat 是应用程序的一部分!(将在 1.3.6 小节中详细描述 Tomcat 如何成为应用程序的一部分的。)
现在应用程序已经启动,将 web 浏览器指向 http://localhost:8080(或单击 Spring Boot Dashboard 中地球仪样子的按钮),应该会看到类似图 1.8 所示的内容。如果你设计了自己的图标,那么结果可能不同,但是它与在图 1.8 中看到的应该相差不大。

它可能没什么好看的。但这并不是一本关于平面设计的书。主页的简陋外观现在已经足够了。它为你了解 Spring 提供了一个坚实的开端。
到目前为止,忽略了 DevTools。在初始化项目时将其作为依赖项进行选择。它作为一个依赖项出现在生成的 pom.xml 文件中。Spring Boot Dashboard 甚至显示项目已经启用了 DevTools。但是什么是 DevTools,它能为您做什么?让我们快速浏览一下 DevTools 的几个最有用的特性。
了解 Spring Boot DevTools
顾名思义,DevTools 为 Spring 开发人员提供了一些方便的开发同步工具。这些是:
- 当代码更改时自动重启应用程序
- 当以浏览器为目标的资源(如模板、JavaScript、样式表等)发生变化时,浏览器会自动刷新
- 自动禁用模板缓存
- 如果 H2 数据库正在使用,则在 H2 控制台中构建
理解 DevTools 不是 IDE 插件是很重要的,它也不要求您使用特定的 IDE。它在 Spring Tool Suite、IntelliJ IDEA 和 NetBeans 中工作得同样好。此外,由于它仅用于开发目的,所以在部署生产环境时禁用它本身是非常明智的。(我们将在第 19 章中讨论如何部署应用程序。)现在,让我们关注一下 Spring Boot DevTools 最有用的特性,首先是自动重启应用程序。
自动重启应用程序
使用 DevTools 作为项目的一部分,将能够对项目中的 Java 代码和属性文件进行更改,并在短时间内查看这些更改的应用。DevTools 监视更改,当它看到某些内容发生更改时,它会自动重新启动应用程序。
更准确地说,当 DevTools 起作用时,应用程序被加载到 Java 虚拟机(JVM)中的两个单独的类加载器中。一个类装入器装入 Java 代码、属性文件以及项目的 src/main/path 中的几乎所有东西。这些项目可能会频繁更改。另一个类加载器加载了依赖库,它们不太可能经常更改。
当检测到更改时,DevTools 只重新加载包含项目代码的类加载器,并重新启动 Spring 应用程序上下文,但不影响其他类加载器和 JVM。尽管这一策略很微妙,但它可以略微减少启动应用程序所需的时间。
这种策略的缺点是对依赖项的更改在自动重新启动时不可用。这是因为类装入器包含依赖项库 不是自动重新加载。这意味着,每当在构建规范中添加、更改或删除依赖项时,都需要重新启动应用程序才能使这些更改生效。
自动刷新浏览器和禁用模板缓存
默认情况下,模板选项(如 Thymeleaf 和 FreeMarker)被配置为缓存模板解析的结果,这样模板就不需要对它们所服务的每个请求进行修复。这在生产中非常有用,因为它可以带来一些性能上的好处。
但是,缓存的模板在开发时不是很好。缓存的模板使它不可能在应用程序运行时更改模板,并在刷新浏览器后查看结果。即使做了更改,缓存的模板仍将继续使用,直到重新启动应用程序。
DevTools 通过自动禁用所有模板缓存来解决这个问题。对模板进行尽可能多的修改,并且要知道只有浏览器刷新才能看到结果。
但如果像我一样,甚至不想被点击浏览器的刷新按钮所累,如果能够立即在浏览器中进行更改并查看结果,那就更好了。幸运的是,DevTools 为我们这些懒得点击刷新按钮的人提供了一些特别的功能。
当 DevTools 起作用时,它会自动启用 LiveReload (http://livereload.com/)服务器和应用程序。就其本身而言,LiveReload 服务器并不是很有用。但是,当与相应的 LiveReload 浏览器插件相结合时,它会使得浏览器在对模板、图像、样式表、JavaScript 等进行更改时自动刷新 —— 实际上,几乎所有最终提供给浏览器的更改都会自动刷新。
LiveReload 有针对 Google Chrome、Safari 和 Firefox 浏览器的插件。(对不起,ie 和 Edge 的粉丝们。)请访问 http://livereload.com/extensions/,了解如何为浏览器安装 LiveReload。
在 H2 控制台中构建
虽然项目还没有使用数据库,但这将在第 3 章中进行更改。如果选择使用 H2 数据库进行开发,DevTools 还将自动启用一个 H2 控制台,你可以从 web 浏览器访问该控制台。只需将 web 浏览器指向 http://localhost:8080/h2-console,就可以深入了解应用程序正在处理的数据。
至此,已经编写了一个完整但简单的 Spring 应用程序。你将在本书的整个过程中扩展它。但是现在是回顾已经完成的工作以及 Spring 如何发挥作用的好时机。
回顾
回想一下是如何走到这一步的。简而言之,以下是构建基于 Spring 的 Taco Cloud 应用程序的步骤:
- 使用 Spring Initializr 创建了一个初始项目结构。
- 写了一个控制器类来处理主页请求。
- 定义了一个视图模板来呈现主页。
- 写了一个简单的测试类来检验上诉工作。
看起来很简单,不是吗?除了启动项目的第一步之外,所采取的每一个行动都是为了实现创建主页的目标。
事实上,编写的几乎每一行代码都是针对这个目标的。不计算 Java import 语句,只计算控制器类中的两行代码,而视图模板中没有 Spring 的特定代码。尽管测试类的大部分都使用了 Spring 的测试支持,但是在测试上下文中,它的侵入性似乎要小一些。
这是使用 Spring 开发的一个重要好处。可以关注于满足应用程序需求的代码,而不是满足框架的需求。尽管确实需要不时地编写一些特定于框架的代码,但这通常只是代码库的一小部分。如前所述,Spring (通过 Spring Boot)可以被认为是 无框架的框架。
这到底是怎么回事?Spring 在幕后做了什么来确保您的应用程序需求得到满足?为了理解 Spring 在做什么,让我们从构建规范开始。
在 pom.xml 文件中,声明了对 Web 和 Thymeleaf 启动器的依赖。这两个依赖关系带来了一些其他的依赖关系,包括:
- Spring MVC 框架
- 嵌入式 Tomcat
- Thymeleaf 和 Thymeleaf 布局方言
它还带来了 Spring Boot 的自动配置库。当应用程序启动时,Spring Boot 自动配置自动检测这些库并自动执行:
- 在 Spring 应用程序上下文中配置 bean 以启用 Spring MVC
- 将嵌入式 Tomcat 服务器配置在 Spring 应用程序上下文中
- 为使用 Thymeleaf 模板呈现 Spring MV C视图,配置了一个 Thymeleaf 视图解析器
简而言之,自动配置完成了所有繁重的工作,让你专注于编写实现应用程序功能的代码。如果你问我这样好不好,我会说这是一个很好的安排!
你的 Spring 之旅才刚刚开始。Taco Cloud 应用程序只涉及 Spring 提供的一小部分内容。在你开始下一步之前,让我们来俯瞰 Spring 的风景线,看看你在旅途中会遇到什么地标。
俯瞰 Spring 风景线
要了解 Spring 的风景线,只需查看完整版 Spring Initializr web 表单上的大量复选框列表即可。它列出了 100 多个依赖项选择,所以我不会在这里全部列出或者提供一个屏幕截图。但我鼓励你们去看看。与此同时,我将提到一些亮点。
Spring 核心框架
正如你所期望的,Spring 核心框架是 Spring 领域中其他一切的基础。它提供了核心容器和依赖注入框架。但它也提供了一些其他的基本特性。
其中包括 Spring MVC 和 Spring web 框架。已经了解了如何使用 Spring MVC 编写控制器类来处理 web 请求。但是,您还没有看到的是,Spring MVC 也可以用于创建产生非 HTML 输出的 REST API。我们将在第 2 章深入研究 Spring MVC,然后在第 6 章中讨论如何使用它来创建 REST API。
Spring 核心框架还提供了一些基本数据持久性支持,特别是基于模板的 JDBC 支持。将在第 3 章中看到如何使用 JdbcTemplate。
在 Spring 的最新版本(5.0.8)中,添加了对响应式编程的支持,包括一个新的响应式 web 框架 —— Spring WebFlux,它大量借鉴了 Spring MVC。将在第 3 部分中看到 Spring 的响应式编程模型,并在第 10 章中看到 Spring WebFlux。
Spring Boot
我们已经看到了 Spring Boot 的许多好处,包括启动依赖项和自动配置。在本书中我们确实会尽可能多地使用 Spring Boot,并避免任何形式的显式配置,除非绝对必要。但除了启动依赖和自动配置,Spring Boot 还提供了一些其他有用的特性:
- Actuator 提供了对应用程序内部工作方式的运行时监控,包括端点、线程 dump 信息、应用程序健康状况和应用程序可用的环境属性。
- 灵活的环境属性规范。
- 在核心框架的测试辅助之外,还有额外的测试支持。
此外,Spring Boot 提供了一种基于 Groovy 脚本的替代编程模型,称为 Spring Boot CLI(命令行界面)。使用 Spring Boot CLI,可以将整个应用程序编写为 Groovy 脚本的集合,并从命令行运行它们。我们不会在 Spring Boot CLI 上花太多时间,但是当它适合我们的需要时,我们会接触它。
Spring Boot 已经成为 Spring 开发中不可或缺的一部分;我无法想象开发一个没有它的 Spring 应用程序。因此,本书采用了以 Spring Boot 为中心的观点,当我提到 Spring Boot 正在做的事情时,你可能会发现我在使用 Spring 这个词。
Spring Data
尽管 Spring 核心框架提供了基本的数据持久性支持,但 Spring Data 提供了一些非常惊人的功能:将应用程序的数据存储库抽象为简单的 Java 接口,同时当定义方法用于如何驱动数据进行存储和检索的问题时,对方法使用了命名约定。
更重要的是,Spring Data 能够处理几种不同类型的数据库,包括关系型(JPA)、文档型(Mongo)、图型(Neo4j)等。在第 3 章中,将使用 Spring Data 来帮助创建 Taco Cloud 应用程序的存储库。
Spring Security
应用程序安全性一直是一个重要的主题,而且似乎一天比一天重要。幸运的是,Spring 在 Spring security 中有一个健壮的安全框架。
Spring Security 解决了广泛的应用程序安全性需求,包括身份验证、授权和 API 安全性。尽管 Spring Security 的范围太大,本书无法恰当地涵盖,但我们将在第 4 章和第 12 章中讨论一些最常见的用例。
Spring Integration 和 Spring Batch
在某种程度上,大多数应用程序将需要与其他应用程序集成,甚至需要与同一应用程序的其他组件集成。为了满足这些需求,出现了几种应用程序集成模式。Spring Integration 和 Spring Batch 为基于 Spring 的应用程序提供了这些模式的实现。
Spring Integration 解决了实时集成,即数据在可用时进行处理。相反,Spring Batch 解决了批量集成的问题,允许在一段时间内收集数据,直到某个触发器(可能是一个时间触发器)发出信号,表示该处理一批数据了。将在第 9 章中研究 Spring Batch 和 Spring Integration。
Spring Cloud
在我写这篇文章的时候,应用程序开发领域正在进入一个新时代,在这个时代中,我们不再将应用程序作为单个部署单元来开发,而是将由几个称为 微服务 的单个部署单元组成应用程序。
微服务是一个热门话题,解决了几个实际的开发和运行时问题。然而,在这样做的同时,他们也带来了自己的挑战。这些挑战都将由 Spring Cloud 直接面对,Spring Cloud 是一组用 Spring 开发云本地应用程序的项目。
Spring Cloud 覆盖了很多地方,这本书不可能涵盖所有的地方。我们将在第 13、14 和 15 章中查看 Spring Cloud 的一些最常见的组件。关于 Spring Cloud 的更完整的讨论,我建议看看 John Carnell 的 Spring Microservices in Action(Manning, 2017, www.manning.com/books/spring-microservices-in-action)。
小结
- Spring 的目标是让开发人员轻松应对挑战,比如创建 web 应用程序、使用数据库、保护应用程序和使用微服务。
- Spring Boot 构建在 Spring 之上,简化了依赖管理、自动配置和运行时监控,让 Spring 变得更加简单。
- Spring 应用程序可以使用 Spring Initializr 进行初始化,它是基于 web 的,并且在大多数 Java 开发环境中都支持它。
- 在 Spring 应用程序上下文中,组件(通常称为 bean)可以用 Java 或 XML 显式地声明,可以通过组件扫描进行发现,也可以用 Spring Boot 进行自动配置。
第 2 章 开发 Web 应用程序
本章内容:
- 在浏览器中展示模型数据
- 处理和验证表单输入
- 选择视图模板库
第一印象很重要:好的房屋门面能够让购房者进入房子之前被吸引;一辆车的喷漆工作将会比引擎盖下的东西吸引更多的人;文学作品中充满了一见钟情的故事。内在的东西很重要,但外在的 —— 先看到的 —— 才是重要的。
使用 Spring 构建的应用程序将执行各种操作,包括处理数据、从数据库中读取信息以及与其他应用程序进行交互。但是应用程序用户得到的第一印象来自于用户界面。在许多应用程序中,UI 界面是在浏览器中显示的 web 应用程序。
在第 1 章中,创建了第一个 Spring MVC 控制器来显示应用程序主页。但是 Spring MVC 能做的远不止简单地显示静态内容。在本章中,将开发 Taco Cloud 应用程序的第一个主要功能 —— 设计自定义 Taco 的能力。在此过程中,将深入研究 Spring MVC,并了解如何显示模型数据和处理表单输入。
展示信息
从根本上说,Taco Cloud 是一个可以在线订购玉米饼的地方。但除此之外,Taco Cloud 还希望让顾客能够表达自己的创意,从丰富的配料中设计定制的玉米饼。
因此,Taco Cloud web应用程序需要一个页面来显示玉米饼制作艺术家可以从中选择的配料。选择的原料可能随时改变,所以不应该硬编码到 HTML 页面中。相反,应该从数据库中获取可用配料的列表,并将其提交给页面以显示给客户。
在 Spring web 应用程序中,获取和处理数据是控制器的工作。视图的工作是将数据渲染成 HTML 并显示在浏览器中。将创建以下组件来支持 Taco 创建页面:
- 一个定义玉米卷成分特性的领域类
- 一个 Spring MVC 控制器类,它获取成分信息并将其传递给视图
- 一个视图模板,在用户的浏览器中呈现一个成分列表
这些组件之间的关系如图 2.1 所示。

由于本章主要讨论 Spring 的 web 框架,所以我们将把数据库的内容推迟到第 3 章。现在,控制器将单独负责向视图提供组件。在第 3 章中,将重写控制器,使其与从数据库中获取配料数据的存储库进行协作。
在编写控制器和视图之前,让我们先确定表示配料的域类型。这将为开发 web 组件奠定基础。
建立域
应用程序的域是它所处理的主题领域 —— 影响应用程序理解的思想和概念。在 Taco Cloud 应用程序中,领域包括 Taco 设计、组成这些设计的成分、客户和客户下的 Taco 订单等对象。首先,我们将关注玉米饼配料。
在领域中,玉米饼配料是相当简单的对象。每一种都有一个名称和一个类型,这样就可以在视觉上对其进行分类(蛋白质、奶酪、酱汁等)。每一个都有一个 ID,通过这个 ID 可以轻松、明确地引用它。下面的成分类定义了需要的域对象。
1 | package tacos; |
这是一个普通的 Java 域类,定义了描述一个成分所需的三个属性。对于程序清单 2.1 中定义的 Ingredient 类,最不寻常的事情可能是它似乎缺少一组常用的 getter 和 setter 方法,更不用说像 equals()、hashCode()、toString() 等有用的方法。
在清单中看不到它们,部分原因是为了节省空间,但也因为使用了一个名为 Lombok 的出色库,它会在运行时自动生成这些方法。实际上,类级别的 @Data 注释是由 Lombok 提供的,它告诉 Lombok 生成所有缺少的方法,以及接受所有final属性作为参数的构造函数。通过使用 Lombok,可以让 Ingredient 的代码保持整洁。
Lombok 不是一个 Spring 库,但是它非常有用,没有它我很难开发。当我需要在一本书中保持代码示例简短明了时,它就成了我的救星。
要使用 Lombok,需要将其作为依赖项添加到项目中。如果正在使用 Spring Tool Suite,只需右键单击 pom.xml 文件并从 Spring 上下文菜单选项中选择 Edit Starters 即可。在第 1 章(图 1.4)中给出的依赖项的相同选择将出现,这样就有机会添加或更改所选的依赖项。找到 Lombok 选项,确保选中,然后单击 OK;Spring Tool Suite 将自动将其添加到构建规范中。
或者,可以使用 pom.xml 中的以下条目手动添加它:
1 | <dependency> |
此依赖项将在开发时提供 Lombok 注释(如 @Data),并在运行时提供自动方法生成。但是还需要在 IDE 中添加 Lombok 作为扩展,否则 IDE 将会报错缺少方法和没有设置的最终属性。请访问 https://projectlombok.org/,以了解如何在 IDE 中安装 Lombok。
你会发现 Lombok 非常有用,但它是可选的。如果不希望使用它,或是不需要它来开发 Spring 应用程序,那么请随意手动编写那些缺少的方法。继续……我将等待。完成后,将添加一些控制器来处理应用程序中的 web 请求。
创建控制器类
控制器是 Spring MVC 框架的主要参与者。它们的主要工作是处理 HTTP 请求,或者将请求传递给视图以呈现 HTML(浏览器显示),或者直接将数据写入响应体(RESTful)。在本章中,我们将重点讨论使用视图为 web 浏览器生成内容的控制器的类型。在第 6 章中,我们将讨论如何在 REST API 中编写处理请求的控制器。
对于 Taco Cloud 应用程序,需要一个简单的控制器来执行以下操作:
- 处理请求路径为
/design的 HTTP GET 请求 - 构建成分列表
- 将请求和成分数据提交给视图模板,以 HTML 的形式呈现并发送给请求的 web 浏览器
下面的 DesignTacoController 类处理这些需求。
1 | package tacos.web; |
关于 DesignTacoController,首先要注意的是在类级应用的一组注释。第一个是 @Slf4j,它是 Lombok 提供的注释,在运行时将自动生成类中的 SLF4J(Java 的简单日志门面,https://www.slf4j.org/)记录器。这个适当的注释具有与显式地在类中添加以下行相同的效果:
1 | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(DesignTacoController.class); |
稍后您将使用这个 Logger。
下一个应用到 DesignTacoController 的注释是 @Controller。此注释用于将该类标识为控制器并将其标记为组件扫描的候选对象,以便 Spring 将发现该类并在 Spring 应用程序上下文中自动创建 DesignTacoController 实例作为 bean。
DesignTacoController 也用 @RequestMapping 注释。@RequestMapping 注释在类级应用时,指定该控制器处理的请求的类型。在本例中,它指定 DesignTacoController 将处理路径以 /design 开头的请求。
处理 GET 请求
类级别的 @RequestMapping 注释用于 showDesignForm() 方法时,可以用 @GetMapping 注释进行改进。@GetMapping 与类级别的 @RequestMapping 配对使用,指定何时接收 /design 的 HTTP GET 请求,showDesignForm() 将用来处理请求。
@GetMapping 是一个相对较新的注释,是在 Spring 4.3 中引入的。在 Spring 4.3 之前,可能使用了一个方法级别的 @RequestMapping 注释:
| 注释 | 描述 |
|---|---|
| @RequestMapping | 通用请求处理 |
| @GetMapping | 处理 HTTP GET 请求 |
| @PostMapping | 处理 HTTP POST 请求 |
| @PutMapping | 处理 HTTP PUT 请求 |
| @DeleteMapping | 处理 HTTP DELETE 请求 |
| @PatchMapping | 处理 HTTP PATCH 请求 |
让正确的事情变得简单
在控制器方法上声明请求映射时,尽可能具体总是一个好主意。至少,这意味着声明一个路径(或者从类级 @RequestMapping 继承一个路径)和它将处理哪个 HTTP 方法。
长度更长的 @RequestMapping(method=RequestMethod.GET) 使我们很容易采取惰性的方式,同时去掉方法属性。由于 Spring 4.3 的新映射注释,正确的做法也很容易做到 —— 只需较少的输入。
新的请求映射注释具有与 @RequestMapping 相同的所有属性,因此可以在使用 @RequestMapping 的任何地方使用它们。
通常,我倾向于只在类级别上使用 @RequestMapping 来指定基本路径。我在每个处理程序方法上使用更具体的 @GetMapping、@PostMapping 等。
现在已经知道 showDesignForm() 方法将处理请求,让我们来看看方法体,看看它是如何工作的。该方法的大部分构造了一个成份对象列表。这个列表现在是硬编码的。当我们讲到第 3 章的时候,你会从数据库中找到玉米饼的原料列表。
一旦准备好了原料列表,接下来的几行 showDesignForm() 将根据原料类型过滤该列表。然后将成分类型列表作为属性添加到传递到 showDesignForm() 的 Model 对象。Model 是一个对象,它在控制器和负责呈现数据的视图之间传输数据。最后,放置在 Model 类属性中的数据被复制到 servlet 响应属性中,视图可以在其中找到它们。showDesignForm() 方法最后返回 “design”,这是将用于向浏览器呈现 Model 的视图的逻辑名称。
DesignTacoController 真的开始成形了。如果您现在运行应用程序并将您的浏览器指向 /design 路径,DesignTacoController 的 showDesignForm() 将被占用,它从存储库中获取数据并将其放在 Model 中,然后将请求传递给视图。但是因为还没有定义视图,所以请求会发生可怕的转变,导致 HTTP 404(Not Found)错误。为了解决这个问题,让我们将注意力转移到视图上,其中的数据将用 HTML 进行修饰,并在用户的 web 浏览器中显示。
设计视图
控制器创建完成后,就该开始设计视图了。Spring 为定义视图提供了几个很好的选项,包括 JavaServer Pages(JSP)、Thymeleaf、FreeMarker、Mustache 和基于 Groovy 的模板。现在,我们将使用 Thymeleaf,这是我们在第 1 章开始项目时所做的选择。我们将在 2.5 节中考虑其他一些选项。
为了使用 Thymeleaf,需要在构建项目时添加另一个依赖项。下面的
1 | <dependency> |
在运行时,Spring Boot 自动配置将看到 Thymeleaf 位于类路径中,并将自动创建支持 Spring MVC 的 Thymeleaf 视图的 bean。
像 Thymeleaf 这样的视图库被设计成与任何特定的 web 框架解耦。因此,他们不知道 Spring 的模型抽象,并且无法处理控制器放置在模型中的数据。但是它们可以处理 servlet 请求属性。因此,在 Spring 将请求提交给视图之前,它将模型数据复制到请求属性中,而 Thymeleaf 和其他视图模板选项可以随时访问这些属性。
Thymeleaf 模板只是 HTML 与一些额外的元素属性,指导模板在渲染请求数据。例如,如果有一个请求属性,它的键是 “message”,你希望它被 Thymeleaf 渲染成一个 HTML <p> 标签,你可以在你的 Thymeleaf 模板中写以下内容:
1 | <p th:text="${message}">placeholder message</p> |
当模板被呈现为 HTML 时,<p> 元素的主体将被 servlet 请求属性的值替换,其键值为 “message”。th:text 是一个 Thymeleaf 的命名空间属性,用于需要执行替换的地方。${} 操作符告诉它使用请求属性的值(在本例中为 “message”)。
Thymeleaf 还提供了另一个属性 th:each,它遍历元素集合,为集合中的每个项目呈现一次 HTML。当设计视图列出模型中的玉米饼配料时,这将非常方便。例如,要呈现 “wrap” 配料列表,可以使用以下 HTML 片段:
1 | <h3>Designate your wrap:</h3> |
在这里,我们在 <div> 标签中填充 th:each 属性,用来对发现于 wrap 请求属性中的集合中的每一个项目进行重复呈现。在每次迭代中,成分项都绑定到一个名为 ingredient 的 Thymeleaf 变量中。
在 <div> 元素内部,有一个复选框 <input> 元素和一个 <span> 元素,用于为复选框提供标签。复选框使用 Thymeleaf 的 th:value 元素,它将把 <iuput> 元素的 value 属性呈现为在成分 id 属性中找到的值。<span> 元素使用 th:text 属性把 “INGREDIENT” 占位符替换为成分 name 属性的值。
当使用实际的模型数据呈现时,这个 <div> 循环迭代一次可能是这样的:
1 | <div> |
最后,前面的 Thymeleaf 片段只是一个更大的 HTML 表单的一部分,通过它,玉米饼艺术家用户将提交他们美味的作品。完整的 Thymeleaf 模板(包括所有成分类型和表单)如下所示。
1 |
|
可以看到,对于每种类型的配料,都要重复 <div> 片段。还包括一个提交按钮和一个字段,用户可以在其中命名他们的创建。
值得注意的是,完整的模板包括 Taco Cloud 图标图片和一个指向样式表的 <link> 引用。在这两种情况下,Thymeleaf 的 @{} 操作符被用来产生一个上下文相关路径的静态工件,它们正在引用。正如在第 1 章中了解到的,Spring 启动应用程序中的静态内容是从类路径根目录的 /static 目录提供的。
现在控制器和视图已经完成,可以启动应用程序了。运行 Spring Boot 应用程序有许多方法。在第 1 章中,展示了如何运行这个应用程序,首先将它构建到一个可执行的 JAR 文件中,然后使用 java -jar 运行这个 JAR。展示了如何使用 mvn spring-boot:run 从构建中直接运行应用程序。
无论如何启动 Taco Cloud 应用程序,一旦启动,使用浏览器访问 http://localhost:8080/design。应该看到类似图 2.2 的页面。

它看起来真不错!访问这个玉米饼艺术家呈现形式的网站,包含一个调色板的玉米饼成分,从中他们可以创建自己的杰作。但是当他们点击 Submit Your Taco 按钮时会发生什么呢?
DesignTacoController 还没有准备好接受玉米饼创作的请求。如果提交了设计表单,用户将看到一个错误。(具体来说,它将是一个 HTTP 405 错误:请求方法 “POST” 不受支持。)让我们通过编写更多处理表单提交的控制器代码来解决这个问题。
处理表单提交
如果在视图中查看 <form> 标签,可以看到它的 method 属性被设置为 POST。而且,<form> 没有声明 action 属性。这意味着在提交表单时,浏览器将收集表单中的所有数据,并通过 HTTP POST 请求将其发送到服务器,发送到显示表单的 GET 请求的同一路径 —— /design 路径。
因此,需要在该 POST 请求的接收端上有一个控制器处理程序方法。需要在 DesignTacoController 中编写一个新的处理程序方法来处理 /design 接口的 POST 请求。
在程序清单 2.2 中,使用 @GetMapping 注释指定 showDesignForm() 方法应该处理 HTTP GET 请求 /design。与 @GetMapping 处理 GET 请求一样,可以使用 @PostMapping 处理 POST 请求。为了处理玉米饼艺术家提交的设计,将以下程序清单中的 processDesign() 方法添加到 DesignTacoController 中。
程序清单 2.4 使用 @PostMapping 处理 POST 请求
1 |
|
当应用到 processDesign() 方法时,@PostMapping 与类级别 @RequestMapping 相协调,以表明 processDesign() 应该处理 /design 接口的 POST 请求。这正是需要处理的一个玉米饼艺术家提交的作品。
提交表单时,表单中的字段被绑定到 Taco 对象的属性(其类在下一个程序清单中显示),该对象作为参数传递给 processDesign()。从这里开始,processDesign() 方法可以对 Taco 对象做任何它想做的事情。
1 | package tacos; |
Taco 是一个简单的 Java 域对象,具有两个属性。与 Ingredient 类似,Taco 类也使用 @Data 进行注释,以便在运行时自动生成基本的 JavaBean 方法。
如果查看程序清单 2.3 中的表单,将看到几个 checkbox 元素,它们都带有 ingredients 名称和一个名为 name 的文本输入元素。表单中的这些字段直接对应于 Taco 类的 ingredients 和 name 属性。
表单上的 Name 字段只需要捕获一个简单的文本值。因此 Taco 的 name 属性的类型是 String。配料复选框也有文本值,但是因为可能选择了零个或多个配料,所以它们绑定到的 ingredients 属性是一个 List<String>,它将捕获每个选择的配料。
目前,processDesign() 方法对 Taco 对象没有任何作用。事实上,它什么都做不了。没关系。在第 3 章中,将添加一些持久性逻辑,将提交的 Taco 保存到数据库中。
与 showDesignForm() 方法一样,processDesign() 通过返回一个 String 结束。与 showDesignForm() 类似,返回的值指示将显示给用户的视图。但是不同的是,从 processDesign() 返回的值的前缀是 “redirect:”,表示这是一个重定向视图。更具体地说,它表明在 processDesign() 完成之后,用户的浏览器应该被重定向到相对路径 /order/current。
这样做的想法源于,在创建了一个玉米饼之后,用户将被重定向到一个订单表单,他们可以从该表单下订单,以交付他们的玉米饼。但是还没有一个控制器来处理 /orders/current 请求。
根据现在对 @Controller、@RequestMapping 和 @GetMapping 的了解,可以轻松地创建这样的控制器。它可能类似于下面的清单。
1 | package tacos.web; |
同样,可以使用 Lombok 的 @Slf4j 注释在运行时创建一个 SLF4J Logger 对象。稍后,将使用这个 Logger 来记录提交的订单的详细信息。
类级别的 @RequestMapping 指定该控制器中的任何请求处理方法都将处理路径以 /orders 开头的请求。当与方法级 @GetMapping 结合使用时,它指定 orderForm() 方法将处理 /orders/current 的 HTTP GET 请求。
至于 orderForm() 方法本身,它非常简单,只返回 orderForm 的逻辑视图名。在第 3 章中,一旦有了把创建的 taco 持久化到数据库的方法,将重新访问该方法并修改它,以使用 taco 对象的列表填充模型,这些对象将按顺序放置。
orderForm 视图由一个名为 orderForm.html 的 Thymeleaf 模板提供,如下面显示的。
1 |
|
在大多数情况下,orderForm.html 视图是典型的 HTML/Thymeleaf 内容,没有什么值得注意的。但是注意,这里的
因此,需要添加另外一个方法到 OrderController 类中,去处理 /orders 接口的 POST 请求。在进行到下一章之前,还没有办法将订单持久化,因此在这里简化它 —— 类似于在下一个程序清单中看到的内容。一章之前,还没有办法将订单持久化,因此在这里简化它 —— 类似于在下一个程序清单中看到的内容。
1 |
|
当调用 processOrder() 方法来处理提交的订单时,它将获得一个 order 对象,其属性绑定到提交的表单字段。Order 非常像 Taco,是一个相当简单的类,它携带订单信息。
1 | package tacos; |
现在已经开发了一个 OrderController 和 order 表单视图,可以开始尝试运行了。打开浏览器访问 http://localhost:8080/design,为你的玉米饼选择一些原料,然后点击 Submit Your Taco 按钮。应该会看到类似于图 2.3 所示的表单。

在表单中填写一些字段,然后按 Submit Order 按钮。与此同时,请密切关注应用程序日志,以查看订单信息。当我尝试它,日志条目看起来像这样(重新格式化以适应这个页面的宽度):
1 | Order submitted: Order(name=Craig Walls,street1=1234 7th Street, |
如果仔细查看来自测试订单的日志条目,可以看到,虽然 processOrder() 方法完成了它的工作并处理了表单提交,但是它让一些错误的信息进来了。表单中的大多数字段包含的数据可能是不正确的。需要添加一些验证,以确保提供的数据至少与所需的信息类型相似。
验证表单输入
当设计一个新的 taco 产品时,如果用户没有选择任何食材或者没有为他们的产品指定名称,该怎么办?当提交订单时,如果他们没有填写所需的地址字段,该怎么办?或者,如果他们在信用卡字段中输入的值甚至不是有效的信用卡号,该怎么办?
按照目前的情况,没有什么能阻止用户创建一个没有任何配料或空空如也的送货地址的玉米饼,甚至提交他们最喜欢的歌曲的歌词作为信用卡号码。这是因为还没有指定应该如何验证这些字段。
执行表单验证的一种方法是在 processDesign() 和 processOrder() 方法中加入一堆 if/then 块,检查每个字段以确保它满足适当的验证规则。但是这样做会很麻烦,并且难于阅读和调试。
幸运的是,Spring 支持 Java’s Bean Validation API(也称为 JSR-303;https://jcp.org/en/jsr/detail?id=303)。这使得声明验证规则比在应用程序代码中显式地编写声明逻辑更容易。使用 Spring Boot,不需要做任何特殊的事情来将验证库添加到项目中,因为 Validation API 和 Validation API 的 Hibernate 实现作为Spring Boot web 启动程序的临时依赖项自动添加到了项目中。
要在 Spring MVC 中应用验证,需要这样做:
- 对要验证的类声明验证规则:特别是 Taco 类。
- 指定验证应该在需要验证的控制器方法中执行,具体来说就是:DesignTacoController 的 processDesign() 方法和 OrderController 的 processOrder() 方法。
- 修改表单视图以显示验证错误。
Validation API 提供了几个可以放在域对象属性上声明验证规则的注释。Hibernate 的 Validation API 实现甚至添加了更多的验证注释。让我们看看如何应用这些注释来验证提交的 Taco 或 Order。
声明验证规则
对于 Taco 类,希望确保 name 属性不是空的或 null 的,并且所选配料列表中至少有一项。下面的程序清单显示了一个更新后的 Taco 类,它使用 @NotNull 和 @Size 来声明这些验证规则。
1 | package tacos; |
你会发现,除了要求 name 属性不为 null,同时你声明它应该有一个值是至少 5 个字符的长度。
当涉及到对提交玉米饼订单进行验证声明时,必须对 Order 类应用注解。对于地址的属性,只需要确保用户没有留下任何空白字段。对于这一点,将使用 Hibernate Validator 的 @NotBlank 注解。
支付领域的验证是一个比较奇特的存在。你不仅需要确保 ccNumber 属性不为空,还要确保它包含的是一个有效的信用卡号码的值。该 ccExpiration 属性必须符合 MM/YY(两位数的年/月)格式。而 ccCVV 属性必须是一个三位的数字。为了实现这种验证,需要使用一些其他的 Java Bean Validation API 注释,同时需要从 Hibernate Validator 集合中借用一些验证注解。下面程序清单列出了验证 Order 类所需要的改变。
1 | package tacos; |
可以看到,ccNumber 属性用 @CreditCardNumber 进行了注释。该注释声明属性的值必须是通过 Luhn 算法(https://en.wikipedia.org/wiki/Luhn_algorithm)检查过的有效信用卡号。这可以防止用户出错的数据和故意错误的数据,但不能保证信用卡号码实际上被分配到一个帐户,或该帐户可以用于交易。
不幸的是,没有现成的注释来验证 ccExpiration 属性的 MM/YY 格式。我已经应用了 @Pattern 注释,为它提供了一个正则表达式,以确保属性值符合所需的格式。如果想知道如何破译正则表达式,我建议查看许多在线正则表达式指南,包括 http://www.regularexpressions.info/。正则表达式语法是一门黑暗的艺术,当然也超出了本书的范围。
最后,用 @Digits 注释 ccCVV 属性,以确保值恰好包含三个数字。
所有的验证注释都包含一个消息属性,该属性定义了如果用户输入的信息不符合声明的验证规则的要求时将显示给用户的消息。
在表单绑定时执行验证
既然已经声明了应该如何验证 Taco 和 Order,那么我们需要重新访问每个控制器,并指定应该在将表单提交到各自的处理程序方法时执行验证。
要验证提交的 Taco,需要将 Java Bean Validation API 的 @Valid 注释添加到 DesignTacoController 的 processDesign() 方法的 Taco 参数中。
1 |
|
@Valid 注释告诉 Spring MVC 在提交的 Taco 对象绑定到提交的表单数据之后,以及调用 processDesign() 方法之前,对提交的 Taco 对象执行验证。如果存在任何验证错误,这些错误的详细信息将在传递到 processDesign() 的错误对象中捕获。processDesign() 的前几行查询 Errors 对象,询问它的 hasErrors() 方法是否存在任何验证错误。如果有,该方法结束时不处理 Taco,并返回 “design” 视图名,以便重新显示表单。
要对提交的 Order 对象执行验证,还需要对 OrderController 的 processOrder() 方法进行类似的更改。
1 |
|
在这两种情况下,如果没有验证错误,则允许该方法处理提交的数据。如果存在验证错误,则请求将被转发到表单视图,以便用户有机会纠正其错误。
但是用户如何知道哪些错误需要改正呢?除非调出表单上的错误,否则用户将只能猜测如何成功提交表单。
显示验证错误
Thymeleaf 通过 fields 属性及其 th:errors 属性提供了对 Errors 对象的便捷访问。例如,要在信用卡号字段上显示验证错误,可以添加一个 元素,该元素将这些错误引用用于订单模板,如下所示。
1 | <label for="ccNumber">Credit Card #: </label> |
除了可以用来设置错误样式以引起用户注意的 class 属性外, 元素还使用 th:if 属性来决定是否显示 。fields 属性的 hasErrors() 方法检查 ccNumber 字段中是否有任何错误。如果有错误, 将被渲染。
th:errors 属性引用 ccNumber 字段,并且假设该字段存在错误,它将用验证消息替换 元素的占位符内容。
如果在其他字段的订单表单周围使用类似的 标记,则在提交无效信息时可能会看到类似图 2.4 的表单。这些错误表明姓名、城市和邮政编码字段被留空,所有的支付字段都不符合验证标准。

现在 Taco Cloud 控制器不仅可以显示和捕获输入,还可以验证信息是否符合一些基本的验证规则。让我们后退一步,重新考虑第 1 章中的 HomeController,看看另一种实现。
使用视图控制器
到目前为止,已经为 Taco Cloud 应用程序编写了三个控制器。尽管每个控制器在应用程序的功能上都有不同的用途,但它们几乎都遵循相同的编程模型:
- 它们都用 @Controller 进行了注释,以表明它们是控制器类,应该由 Spring 组件扫描自动发现,并在 Spring 应用程序上下文中作为 bean 进行实例化。
- 除了 HomeController 之外,所有的控制器都在类级别上使用 @RequestMapping 进行注释,以定义控制器将处理的基本请求模式。
- 它们都有一个或多个方法,这些方法都用 @GetMapping 或 @PostMapping 进行了注释,以提供关于哪些方法应该处理哪些请求的细节。
即将编写的大多数控制器都将遵循这种模式。但是,如果一个控制器足够简单,不填充模型或流程输入(就像 HomeController 一样),那么还有另一种定义控制器的方法。请查看下一个程序清单,了解如何声明视图控制器 —— 一个只将请求转发给视图的控制器。
1 | package tacos.web; |
关于 @WebConfig 最值得注意的是它实现了 WebMvcConfigurer 接口。WebMvcConfigurer 定义了几个配置 Spring MVC 的方法。尽管它是一个接口,但它提供了所有方法的默认实现,因此只需覆盖所需的方法。在本例中,覆盖了 addViewControllers() 方法。
addViewControllers() 方法提供了一个 ViewControllerRegistry,可以使用它来注册一个或多个视图控制器。在这里,在注册表上调用 addViewController(),传入 “/”,这是视图控制器处理 GET 请求的路径。该方法返回一个 ViewControllerRegistration 对象,在该对象上立即调用 setViewName() 来指定 home 作为应该转发 “/” 请求的视图。
就像这样,已经能够用配置类中的几行代码替换 HomeController。现在可以删除 HomeController,应用程序的行为应该与以前一样。惟一需要做的其他更改是重新访问第 1 章中的 HomeControllerTest,从 @WebMvcTest 注释中删除对 HomeController 的引用,这样测试类就可以无错误地编译了。
这里,已经创建了一个新的 WebConfig 配置类来存放视图控制器声明。但是任何配置类都可以实现 WebMvcConfigurer 并覆盖 addViewController() 方法。例如,可以将相同的视图控制器声明添加到引导 TacoCloudApplication 类中,如下所示:
1 |
|
通过扩展现有的配置类,可以避免创建新的配置类,从而降低项目工件数量。但是我倾向于为每种配置(web、数据、安全性等等)创建一个新的配置类,保持应用程序引导配置的简洁。
说到视图控制器,更一般地说,是控制器将请求转发给的视图,到目前为止,已经为所有视图使用了 Thymeleaf。我非常喜欢 Thymeleaf,但也许你更喜欢应用程序视图的不同模板模型。让我们看看 Spring 支持的许多视图选项。
选择视图模板库
在大多数情况下,对视图模板库的选择取决于个人喜好。Spring 非常灵活,支持许多常见的模板选项。除了一些小的例外,所选择的模板库本身甚至不知道它是在 Spring 中工作的。
表 2.2 列出了 Spring Boot 自动配置支持的模板选项。
| 模板 | Spring Boot starter 依赖 |
|---|---|
| FreeMarker | spring-boot-starter-freemarker |
| Groovy Templates | spring-boot-starter-groovy-templates |
| JavaServer Page(JSP) | None (provided by Tomcat or Jetty) |
| Mustache | spring-boot-starter-mustache |
| Thymeleaf | spring-boot-starter-thymeleaf |
一般来说,可以选择想要的视图模板库,将其作为依赖项添加到构建中,然后开始在 /templates 目录中(在 Maven 或 Gradl 构建项目的 src/main/resources 目录下)编写模板。Spring Boot 将检测选择的模板库,并自动配置所需的组件来为 Spring MVC 控制器提供视图。
已经在 Taco Cloud 应用程序中用 Thymeleaf 实现了这一点。在第 1 章中,在初始化项目时选择了 Thymeleaf 复选框。这导致 Spring Boot 的 Thymeleaf starter 被包含在 pom.xml 文件中。当应用程序启动时,Spring Boot 自动配置会检测到 Thymeleaf 的存在,并自动配置 Thymeleaf bean。现在要做的就是开始在 /templates 中编写模板。
如果希望使用不同的模板库,只需在项目初始化时选择它,或者编辑现有的项目构建以包含新选择的模板库。
例如,假设想使用 Mustache 而不是 Thymeleaf。没有问题。只需访问项目 pom.xml 文件,将:
1 | <dependency> |
替换为:
1 | <dependency> |
当然,需要确保使用 Mustache 语法而不是 Thymeleaf 标签来编写所有模板。Mustache 的使用细节(或选择的任何模板语言)不在这本书的范围之内,但为了让你知道会发生什么,这里有一个从 Mustache 模板摘录过来的片段,这个片段渲染了玉米饼设计表单的成分列表中的一个:
1 | <h3>Designate your wrap:</h3> |
这是 Mustache 与第 2.1.3 节中的 Thymeleaf 片段的等价替换。{{#wrap}} 块(以 {{/wrap}} 结尾)迭代 request 属性中的一个集合,该集合的键为 wrap,并为每个项目呈现嵌入的 HTML。{{id}} 和 {{name}} 标记引用项目的 id 和 name 属性(应该是一个 Ingredient)。
在表 2.2 中请注意,JSP 在构建中不需要任何特殊的依赖关系。这是因为 servlet 容器本身(默认情况下是 Tomcat)实现了 JSP 规范,因此不需要进一步的依赖关系。
但是如果选择使用 JSP,就会遇到一个问题。事实证明,Java servlet 容器 —— 包括嵌入式 Tomcat 和 Jetty 容器 —— 通常在 /WEB-INF 下寻找 jsp。但是如果将应用程序构建为一个可执行的 JAR 文件,就没有办法满足这个需求。因此,如果将应用程序构建为 WAR 文件并将其部署在传统的 servlet 容器中,那么 JSP 只是一个选项。如果正在构建一个可执行的 JAR 文件,必须选择 Thymeleaf、FreeMarker 或表 2.2 中的其他选项之一。
缓存模板
默认情况下,模板在第一次使用时只解析一次,解析的结果被缓存以供后续使用。对于生产环境来说,这是一个很好的特性,因为它可以防止对每个请求进行冗余的模板解析,从而提高性能。
但是,在开发时,这个特性并不那么好。假设启动了应用程序并点击了玉米饼设计页面,并决定对其进行一些更改。当刷新 web 浏览器时,仍然会显示原始版本。查看更改的惟一方法是重新启动应用程序,这非常不方便。
幸运的是,有一种方法可以禁用缓存。只需将 templateappropriate 高速缓存属性设置为 false。表 2.3 列出了每个支持的模板库的缓存属性。
| 模板 | 缓存使能属性 |
|---|---|
| Freemarker | spring.freemarker.cache |
| Groovy Templates | spring.groovy.template.cache |
| Mustache | spring.mustache.cache |
| Thymeleaf | spring.thymeleaf.cache |
默认情况下,所有这些属性都设置为 true 以启用缓存。可以通过将其缓存属性设置为 false 来禁用所选模板引擎的缓存。例如,要禁用 Thymeleaf 缓存,请在 application.properties 中添加以下行:
1 | = false |
惟一的问题是,在将应用程序部署到生产环境之前,一定要删除这一行(或将其设置为 true)。一种选择是在 profile 文件中设置属性。(我们将在第 5 章讨论 profiles 文件。)
一个更简单的选择是使用 Spring Boot 的 DevTools,就像我们在第 1 章中选择的那样。在 DevTools 提供的许多有用的开发时帮助中,它将禁用所有模板库的缓存,但在部署应用程序时将禁用自身(从而重新启用模板缓存)。
小结
- Spring 提供了一个强大的 web 框架,称为 Spring MVC,可以用于开发 Spring 应用程序的 web 前端。
- Spring MVC 是基于注解的,可以使用 @RequestMapping、@GetMapping 和 @PostMapping 等注解来声明请求处理方法。
- 大多数请求处理方法通过返回视图的逻辑名称来结束,例如一个 Thymeleaf 模板,请求(以及任何模型数据)被转发到该模板。
- Spring MVC 通过 Java Bean Validation API 和 Hibernate Validator 等验证 API 的实现来支持验证。
- 视图控制器可以用来处理不需要模型数据或处理的 HTTP GET 请求。
- 除了 Thymeleaf,Spring 还支持多种视图选项,包括 FreeMarker、Groovy Templates 和 Mustache。
第 3 章 处理数据
本章内容:
- 使用 Spring JdbcTemplate
- 使用 SimpleJdbcInsert 插入数据
- 使用 Spring Data 声明 JPA repositories
大多数应用程序提供的不仅仅是一张漂亮的脸。虽然用户界面可能提供与应用程序的交互,但它所呈现和存储的数据将应用程序与静态网站区分开来。
在 Taco Cloud 应用程序中,需要能够维护关于 ingredients、tacos 和 orders 的信息。如果没有一个数据库来存储这些信息,应用程序将无法比在第 2 章中开发的应用程序取得更大的进展。
在本章中,将向 Taco Cloud 应用程序添加数据持久化操作。首先使用 Spring 对 JDBC(Java Database Connectivity)的支持来消除样板代码。然后,将重新使用 JPA(Java Persistence API)处理数据存储库,从而消除更多代码。
使用 JDBC 读写数据
几十年来,关系数据库和 SQL 一直是数据持久化的首选。尽管近年来出现了许多替代数据库类型,但关系数据库仍然是通用数据存储的首选,而且不太可能很快被取代。
在处理关系数据时,Java 开发人员有多个选择。两个最常见的选择是 JDBC 和 JPA。Spring 通过抽象支持这两种方式,这使得使用 JDBC 或 JPA 比不使用 Spring 更容易。在本节中,我们将重点讨论 Spring 是如何支持 JDBC 的,然后在第 3.2 节中讨论 Spring 对 JPA 的支持。
Spring JDBC 支持起源于 JdbcTemplate 类。JdbcTemplate 提供了一种方法,通过这种方法,开发人员可以对关系数据库执行 SQL 操作,与通常使用 JDBC 不同的是,这里不需要满足所有的条件和样板代码。
为了更好地理解 JdbcTemplate 的作用,我们首先来看一个示例,看看如何在没有 JdbcTemplate 的情况下用 Java 执行一个简单的查询。
1 |
|
在程序清单 3.1 的某个地方,有几行代码用于查询数据库中的 ingredients。但是很难在 JDBC 的混乱代码中找到查询指针。它被创建连接、创建语句和通过关闭连接、语句和结果集来清理的代码所包围。
更糟糕的是,在创建连接或语句或执行查询时,可能会出现许多问题。这要求捕获一个 SQLException,这可能有助于(也可能无助于)找出问题出在哪里或如何解决问题。
SQLException 是一个被检查的异常,它需要在 catch 块中进行处理。但是最常见的问题,如未能创建到数据库的连接或输入错误的查询,不可能在 catch 块中得到解决,可能会重新向上抛出以求处理。相反,要是考虑使用 JdbcTemplate 的方法。
1 | private JdbcTemplate jdbc; |
程序清单 3.2 中的代码显然比程序清单 3.1 中的原始 JDBC 示例简单得多;没有创建任何语句或连接。而且,在方法完成之后,不会对那些对象进行任何清理。最后,这样做不会存在任何在 catch 块中不能处理的异常。剩下的代码只专注于执行查询(调用 JdbcTemplate 的 queryForObject() 方法)并将结果映射到 Ingredient 对象(在 mapRowToIngredient() 方法中)。
程序清单 3.2 中的代码是使用 JdbcTemplate 在 Taco Cloud 应用程序中持久化和读取数据所需要做的工作的一个片段。让我们采取下一步必要的步骤来为应用程序配备 JDBC 持久化。我们将首先对域对象进行一些调整。
为域适配持久化
在将对象持久化到数据库时,通常最好有一个惟一标识对象的字段。Ingredient 类已经有一个 id 字段,但是需要向 Taco 和 Order 添加 id 字段。
此外,了解何时创建 Taco 以及何时放置 Order 可能很有用。还需要向每个对象添加一个字段,以捕获保存对象的日期和时间。下面的程序清单显示了 Taco 类中需要的新 id 和 createdAt 字段。
1 |
|
因为使用 Lombok 在运行时自动生成访问器方法,所以除了声明 id 和 createdAt 属性外,不需要做任何事情。它们将在运行时根据需要生成适当的 getter 和 setter 方法。Order 类也需要做类似的修改,如下所示:
1 |
|
同样,Lombok 会自动生成访问字段的方法,因此只需要按顺序进行这些更改。(如果由于某种原因选择不使用 Lombok,那么需要自己编写这些方法。)
域类现在已经为持久化做好了准备。让我们看看如何使用 JdbcTemplate 在数据中对它们进行读写。
使用 JdbcTemplate
在开始使用 JdbcTemplate 之前,需要将它添加到项目类路径中。这很容易通过添加 Spring Boot 的 JDBC starter 依赖来实现:
1 | <dependency> |
还需要一个存储数据的数据库。出于开发目的,嵌入式数据库也可以。我喜欢 H2 嵌入式数据库,所以我添加了以下依赖进行构建:
1 | <dependency> |
稍后,将看到如何配置应用程序来使用外部数据库。但是现在,让我们继续编写一个获取和保存 Ingredient 数据的存储库。
定义 JDBC 存储库
Ingredient repository 需要执行以下操作:
- 查询所有的 Ingredient 使之变成一个 Ingredient 的集合对象
- 通过它的 id 查询单个 Ingredient
- 保存一个 Ingredient 对象
以下 IngredientRepository 接口将这三种操作定义为方法声明:
1 | package tacos.data; |
尽管该接口体现了需要 Ingredient repository 做的事情的本质,但是仍然需要编写一个使用 JdbcTemplate 来查询数据库的 IngredientRepository 的实现。下面的程序清单是编写实现的第一步。
1 | package tacos.data; |
JdbcIngredientRepository 使用 @Repository 进行了注解。这个注解是 Spring 定义的少数几个原型注解之一,包括 @Controller 和 @Component。通过使用 @Repository 对 JdbcIngredientRepository 进行注解,这样它就会由 Spring 组件在扫描时自动发现,并在 Spring 应用程序上下文中生成 bean 实例。
当 Spring 创建 JdbcIngredientRepository bean 时,通过 @Autowired 注解将 JdbcTemplate 注入到 bean 中。构造函数将 JdbcTemplate 分配给一个实例变量,该变量将在其他方法中用于查询和插入数据库。谈到那些其他方法,让我们来看看 findAll() 和 findById() 的实现。
1 |
|
findAll() 和 findById() 都以类似的方式使用 JdbcTemplate。期望返回对象集合的 findAll() 方法使用了 JdbcTemplate 的 query() 方法。query() 方法接受查询的 SQL 以及 Spring 的 RowMapper 实现,以便将结果集中的每一行映射到一个对象。findAll() 还接受查询中所需的所有参数的列表作为它的最后一个参数。但是,在本例中,没有任何必需的参数。
findById() 方法只期望返回单个成分对象,因此它使用 JdbcTemplate 的 queryForObject() 方法而不是 query()。queryForObject() 的工作原理与 query() 非常相似,只是它返回的是单个对象,而不是对象列表。在本例中,它给出了要执行的查询、一个 RowMapper 和要获取的 Ingredient 的 id,后者用于代替查询 SQL 中 的 ?。
如程序清单 3.5 所示,findAll() 和 findById() 的 RowMapper 参数作为 mapRowToIngredient() 方法的方法引用。当使用 JdbcTemplate 作为显式 RowMapper 实现的替代方案时,使用 Java 8 的方法引用和 lambda 非常方便。但是,如果出于某种原因,想要或是需要一个显式的 RowMapper,那么 findAll() 的以下实现将展示如何做到这一点:
1 |
|
从数据库读取数据只是问题的一部分。在某些情况下,必须将数据写入数据库以便能够读取。因此,让我们来看看如何实现 save() 方法。
插入一行
JdbcTemplate 的 update() 方法可用于在数据库中写入或更新数据的任何查询。并且,如下面的程序清单所示,它可以用来将数据插入数据库。
1 |
|
因为没有必要将 ResultSet 数据映射到对象,所以 update() 方法要比 query() 或 queryForObject() 简单得多。它只需要一个包含 SQL 的字符串来执行,以及为任何查询参数赋值。在本例中,查询有三个参数,它们对应于 save() 方法的最后三个参数,提供了 Ingredient 的 id、name 和 type。
完成了 JdbcIngredientRepository后,现在可以将其注入到 DesignTacoController 中,并使用它来提供一个 Ingredient 对象列表,而不是使用硬编码的值(正如第 2 章中所做的那样)。DesignTacoController 的变化如下所示。
1 |
|
请注意,showDesignForm() 方法的第 2 行现在调用了注入的 IngredientRepository 的 findAll() 方法。findAll() 方法从数据库中提取所有 Ingredient,然后将它们对应到到模型的不同类型中。
几乎已经准备好启动应用程序并尝试这些更改了。但是在开始从查询中引用的 Ingredient 表读取数据之前,可能应该创建这个表并写一些 Ingredient 数据进去。
定义模式并预加载数据
除了 Ingredient 表之外,还需要一些保存订单和设计信息的表。图 3.1 说明了需要的表以及这些表之间的关系。

图 3.1中的表有以下用途:
- Ingredient - 保存着原料信息
- Taco - 保存着关于 taco 设计的重要信息
- Taco_Ingredient - 包含 Taco 表中每一行的一个或多行数据,将 Taco 映射到该 Taco 的 Ingredient
- Taco_Order - 保存着重要的订单细节
- Taco_Order_Tacos - 包含 Taco_Order 表中的每一行的一个或多行数据,将 Order 映射到 Order 中的Tacos
下一个程序清单显示了创建表的 SQL 语句。
1 | create table if not exists Ingredient ( |
最大的问题是把这个模式定义放在哪里。事实证明,Spring Boot 回答了这个问题。
如果有一个名为 schema.sql 的文件。在应用程序的类路径根目录下执行 sql,然后在应用程序启动时对数据库执行该文件中的 SQL。因此,应该将程序清单 3.8 的内容写入一个名为 schema.sql 的文件中,然后放在项目的 src/main/resources 文件夹下。
还需要用一些 Ingredient 数据来预加载数据库。幸运的是,Spring Boot 还将执行一个名为 data.sql 的文件,这个文件位于根路径下。因此,可以使用 src/main/resources/data.sql 中的下面程序清单中的 insert 语句来加载包含 Ingredient 数据的数据库。
1 | delete from Taco_Order_Tacos; |
即使只开发了 Ingredient 数据的存储库,也可以启动 Taco Cloud 应用程序并访问设计页面,查看JdbcIngredientRepository 的运行情况。继续……试试吧。当回到代码中时,可以继续编写用于持久化 Taco、Order 的存储库和相应的数据。
插入数据
到此,已经了解了如何使用 JdbcTemplate 向数据库写入数据。JdbcIngredientRepository 中的 save() 方法使用 JdbcTemplate 的 update() 方法将 Ingredient 对象保存到数据库中。
虽然这是第一个很好的例子,但是它可能有点太简单了。保存数据可能比 JdbcIngredientRepository 所需要的更复杂。使用 JdbcTemplate 保存数据的两种方法包括:
- 直接使用 update() 方法
- 使用 SimpleJdbcInsert 包装类
让我们首先看看,当持久话需求比保存一个 Ingredient 所需要的更复杂时,如何使用 update() 方法。
使用 JdbcTemplate 保存数据
目前,Taco 和 Order 存储库需要做的惟一一件事是保存它们各自的对象。为了保存 Taco 对象,TacoRepository 声明了一个 save() 方法,如下所示:
1 | package tacos.data; |
类似地,OrderRepository 也声明了一个 save() 方法:
1 | package tacos.data; |
看起来很简单,对吧?没那么快。保存一个 Taco 设计需要将与该 Taco 关联的 Ingredient 保存到 Taco_Ingredient 表中。同样,保存 Order 也需要将与 Order 关联的 Taco 保存到 Taco_Order_Tacos 表中。这使得保存 Taco 和 Order 比 保存 Ingredient 更有挑战性。
要实现 TacoRepository,需要一个 save() 方法,该方法首先保存基本的 Taco 设计细节(例如,名称和创建时间),然后为 Taco 对象中的每个 Ingredient 在 Taco_Ingredients 中插入一行。下面的程序清单显示了完整的 JdbcTacoRepository 类。
1 | package tacos.data; |
save() 方法首先调用私有的 saveTacoInfo() 方法,然后使用该方法返回的 Taco id 调用 saveIngredientToTaco(),它保存每个成分。关键在于 saveTacoInfo() 的细节。
在 Taco 中插入一行时,需要知道数据库生成的 id,以便在每个 Ingredient 中引用它。保存 Ingredient 数据时使用的 update() 方法不能获得生成的 id,因此这里需要一个不同的 update() 方法。
需要的 update() 方法接受 PreparedStatementCreator 和 KeyHolder。KeyHolder 将提供生成的 Taco id,但是为了使用它,还必须创建一个 PreparedStatementCreator。
如程序清单 3.10 所示,创建 PreparedStatementCreator 非常重要。首先创建一个 PreparedStatementCreatorFactory,为它提供想要执行的 SQL,以及每个查询参数的类型。然后在该工厂上调用 newPreparedStatementCreator(),在查询参数中传递所需的值以生成 PreparedStatementCreator。
通过使用 PreparedStatementCreator,可以调用 update(),传入 PreparedStatementCreator 和 KeyHolder(在本例中是 GeneratedKeyHolder 实例)。update() 完成后,可以通过返回 keyHolder.getKey().longValue() 来返回 Taco id。
回到 save() 方法,循环遍历 Taco 中的每个成分,调用 saveIngredientToTaco() 方法。saveIngredientToTaco() 方法使用更简单的 update() 形式来保存对 Taco_Ingredient 表引用。
TacoRepository 剩下所要做的就是将它注入到 DesignTacoController 中,并在保存 Taco 时使用它。下面的程序清单显示了注入存储库所需的改变。
1 |
|
构造函数包含一个 IngredientRepository 和一个TacoRepository。它将这两个变量都赋值给实例变量,以便它们可以在 showDesignForm() 和 processDesign() 方法中使用。
说到 processDesign() 方法,它的更改比 showDesignForm() 所做的更改要广泛一些。下一个程序清单显示了新的 processDesign() 方法。
1 |
|
关于程序清单 3.12 中的代码,首先注意到的是 DesignTacoController 现在使用 @SessionAttributes(“order”) 进行了注解,并且在 order() 方法上有一个新的注解 @ModelAttribute。与 taco() 方法一样,order() 方法上的 @ModelAttribute 注解确保在模型中能够创建 Order 对象。但是与 session 中的 Taco 对象不同,这里需要在多个请求间显示订单,因此可以创建多个 Taco 并将它们添加到订单中。类级别的 @SessionAttributes 注解指定了任何模型对象,比如应该保存在会话中的 order 属性,并且可以跨多个请求使用。
taco 设计的实际处理发生在 processDesign() 方法中,除了 Taco 和 Errors 对象外,该方法现在还接受 Order 对象作为参数。Order 参数使用 @ModelAttribute 进行注解,以指示其值应该来自模型,而 Spring MVC 不应该试图给它绑定请求参数。
在检查验证错误之后,processDesign() 使用注入的 TacoRepository 来保存 Taco。然后,它将 Taco 对象添加到保存于 session 中 Order 对象中。
实际上,Order 对象仍然保留在 session 中,直到用户完成并提交 Order 表单才会保存到数据库中。此时,OrderController 需要调用 OrderRepository 的实现来保存订单。我们来写一下这个实现。
使用 SimpleJdbcInsert 插入数据
保存一个 taco 不仅要将 taco 的名称和创建时间保存到 Taco 表中,还要将与 taco 相关的配料的引用保存到 Taco_Ingredient 表中。对于这个操作还需要知道 Taco 的 id,这是使用 KeyHolder 和 PreparedStatementCreator 来获得的。
在保存订单方面,也存在类似的情况。不仅必须将订单数据保存到 Taco_Order 表中,还必须引用 Taco_Order_Tacos 表中的每个 taco。但是不是使用繁琐的 PreparedStatementCreator, 而是使用SimpleJdbcInsert, SimpleJdbcInsert 是一个包装了 JdbcTemplate 的对象,它让向表插入数据的操作变得更容易。
首先创建一个 JdbcOrderRepository,它是 OrderRepository 的一个实现。但是在编写 save() 方法实现之前,让我们先关注构造函数,在构造函数中,将创建两个 SimpleJdbcInsert 实例,用于将值插入 Taco_Order 和 Taco_Order_Tacos 表中。下面的程序清单显示了 JdbcOrderRepository(没有 save() 方法)。
1 | package tacos.data; |
与 JdbcTacoRepository 一样,JdbcOrderRepository 也通过其构造函数注入了 JdbcTemplate。但是,构造函数并没有将 JdbcTemplate 直接分配给一个实例变量,而是使用它来构造两个 SimpleJdbcInsert 实例。
第一个实例被分配给 orderInserter 实例变量,它被配置为使用 Taco_Order 表,并假定 id 属性将由数据库提供或生成。分配给 orderTacoInserter 的第二个实例被配置为使用 Taco_Order_Tacos 表,但是没有声明如何在该表中生成任何 id。
构造函数还创建 ObjectMapper 实例,并将其分配给实例变量。尽管 Jackson 用于 JSON 处理,但稍后将看到如何重新使用它来帮助保存订单及其关联的 tacos。
现在让我们看看 save() 方法如何使用 SimpleJdbcInsert 实例。下一个程序清单显示了 save() 方法,以及几个用于实际工作的 save() 委托的私有方法。
1 |
|
save() 方法实际上并不保存任何东西。它定义了保存订单及其关联 Taco 对象的流,并将持久性工作委托给 saveOrderDetails() 和 saveTacoToOrder()。
SimpleJdbcInsert 有两个执行插入的有用方法:execute() 和 executeAndReturnKey()。两者都接受 Map<String, Object>,其中 Map 键对应于数据插入的表中的列名,映射的值被插入到这些列中。
通过将 Order 中的值复制到 Map 的条目中,很容易创建这样的 Map。但是 Order 有几个属性,这些属性和它们要进入的列有相同的名字。因此,在 saveOrderDetails() 中,我决定使用 Jackson 的 ObjectMapper 及其 convertValue() 方法将 Order 转换为 Map。这是必要的,否则 ObjectMapper 会将 Date 属性转换为 long,这与 Taco_Order 表中的 placedAt 字段不兼容。
随着 Map 中填充完成订单数据,我们可以在 orderInserter 上调用 executeAndReturnKey() 方法了。这会将订单信息保存到 Taco_Order 表中,并将数据库生成的 id 作为一个 Number 对象返回,调用 longValue() 方法将其转换为从方法返回的 long 值。
saveTacoToOrder() 方法要简单得多。不是使用 ObjectMapper 将对象转换为 Map,而是创建 Map 并设置适当的值。同样,映射键对应于表中的列名。对 orderTacoInserter 的 execute() 方法的简单调用就能执行插入操作。
现在可以将 OrderRepository 注入到 OrderController 中并开始使用它。下面的程序清单显示了完整的 OrderController,包括因使用注入的 OrderRepository 而做的更改。
1 | package tacos.web; |
除了将 OrderRepository 注入控制器之外,OrderController 中惟一重要的更改是 processOrder() 方法。在这里,表单中提交的 Order 对象(恰好也是在 session 中维护的 Order 对象)通过注入的 OrderRepository 上的 save() 方法保存。
一旦订单被保存,就不再需要它存在于 session 中了。事实上,如果不清除它,订单将保持在 session 中,包括其关联的 tacos,下一个订单将从旧订单中包含的任何 tacos 开始。因此需要 processOrder() 方法请求 SessionStatus 参数并调用其 setComplete() 方法来重置会话。
所有的 JDBC 持久化代码都准备好了。现在,可以启动 Taco Cloud 应用程序并进行测试。你想要多少 tacos 和多少 orders 都可以。
可能还会发现在数据库中进行挖掘是很有帮助的。因为使用 H2 作为嵌入式数据库,而且 Spring Boot DevTools 已经就位,所以应该能够用浏览器访问 http://localhost:8080/h2-console 来查看 H2 控制台。虽然需要确保 JDBC URL 字段被设置为 JDBC:h2:mem:testdb,但是默认的凭证应该可以让你进入。登录后,应该能够对 Taco Cloud 模式中的表发起查询。
Spring 的 JdbcTemplate 和 SimpleJdbcInsert 使得使用关系数据库比普通 JDBC 简单得多。但是可能会发现 JPA 使它更加简单。让我们回顾一下之前的工作,看看如何使用 Spring 数据使数据持久化更加容易。
使用 Spring Data JPA 持久化数据
Spring Data 项目是一个相当大的伞形项目,几个子项目组成,其中大多数子项目关注于具有各种不同数据库类型的数据持久化。一些最流行的 Spring 数据项目包括:
- Spring Data JPA - 针对关系数据库的持久化
- Spring Data Mongo - 针对 Mongo 文档数据库的持久化
- Spring Data Neo4j - 针对 Neo4j 图形数据库的持久化
- Spring Data Redis - 针对 Redis 键值存储的持久化
- Spring Data Cassandra - 针对 Cassandra 数据库的持久化
Spring Data 为所有这些项目提供的最有意思和最有用的特性之一是能够基于存储库规范接口自动创建存储库。
为了了解 Spring Data 是如何工作的,需要将本章前面介绍的基于 jdbc 的存储库替换为 Spring Data JPA 创建的存储库。但是首先,需要将 Spring Data JPA 添加到项目构建中。
添加 Spring Data JPA 到数据库中
Spring Data JPA 可用于具有 JPA starter 的 Spring Boot 应用程序。这个 starter 依赖不仅带来了 Spring Data JPA,还包括 Hibernate 作为 JPA 的实现:
1 | <dependency> |
如果想使用不同的 JPA 实现,那么至少需要排除 Hibernate 依赖,并包含所选择的 JPA 库。例如,要使用 EclipseLink 而不是 Hibernate,需要按如下方式更改构建:
1 | <dependency> |
请注意,根据对 JPA 实现的选择,可能需要进行其他更改。详细信息请参阅选择的 JPA 实现的文档。现在,让我们重新查看域对象并对它们进行注解以实现 JPA 持久化。
注解域作为实体
很快就会看到,在创建存储库方面,Spring Data 做了一些惊人的事情。但不幸的是,在使用 JPA 映射注解注解域对象时,它并没有太大的帮助。需要打开 Ingredient、Taco 和 Order 类,并添加一些注解。首先是 Ingredient 类。
1 | package tacos; |
为了将其声明为 JPA 实体,必须使用 @Entity 注解。它的 id 属性必须使用 @Id 进行注解,以便将其指定为惟一标识数据库中实体的属性。
除了特定于 JPA 的注解之外,还在类级别上添加了 @NoArgsConstructor 注解。JPA 要求实体有一个无参构造函数,所以 Lombok 的 @NoArgsConstructor 实现了这一点。但是要是不希望使用它,可以通过将 access 属性设置为 AccessLevel.PRIVATE 来将其设置为私有。因为必须设置 final 属性,所以还要将 force 属性设置为 true,这将导致 Lombok 生成的构造函数将它们设置为 null。
还添加了一个 @RequiredArgsConstructor。@Data 隐式地添加了一个必需的有参构造函数,但是当使用 @NoArgsConstructor 时,该构造函数将被删除。显式的 @RequiredArgsConstructor 确保除了私有无参数构造函数外,仍然有一个必需有参构造函数。
现在让我们转到 Taco 类,看看如何将其注解为 JPA 实体。
1 | package tacos; |
与 Ingredient 一样,Taco 类现在使用 @Entity 注解,其 id 属性使用 @Id 注解。因为依赖于数据库自动生成 id 值,所以还使用 @GeneratedValue 注解 id 属性,指定自动策略。
要声明 Taco 及其相关 Ingredient 列表之间的关系,可以使用 @ManyToMany 注解 ingredient 属性。一个 Taco 可以有很多 Ingredient,一个 Ingredient 可以是很多 Taco 的一部分。
还有一个新方法 createdAt(),它用 @PrePersist 注解。将使用它将 createdAt 属性设置为保存 Taco 之前的当前日期和时间。最后,让我们将 Order 对象注解为一个实体。下一个程序清单展示了新的 Order 类。
1 | package tacos; |
对 Order 的更改与对 Taco 的更改非常相似。但是在类级别有一个新的注解:@Table。这指定订单实体应该持久化到数据库中名为 Taco_Order 的表中。
尽管可以在任何实体上使用这个注解,但它对于 Order 是必需的。没有它,JPA 将默认将实体持久化到一个名为 Order 的表中,但是 Order 在 SQL 中是一个保留字,会导致问题。现在实体已经得到了正确的注解,该编写 repository 了。
声明 JPA repository
在存储库的 JDBC 版本中,显式地声明了希望 repository 提供的方法。但是使用 Spring Data,扩展 CrudRepository 接口。例如,这是一个新的 IngredientRepository 接口:
1 | package tacos.data; |
CrudRepository 为 CRUD(创建、读取、更新、删除)操作声明了十几个方法。注意,它是参数化的,第一个参数是存储库要持久化的实体类型,第二个参数是实体 id 属性的类型。对于 IngredientRepository,参数应该是 Ingredient 和 String 类型。
也可以这样定义 TacoRepository:
1 | package tacos.data; |
IngredientRepository 和 TacoRepository 之间唯一显著的区别是对于 CrudRepository 的参数不同。在这里,它们被设置为 Taco 和 Long 去指定 Taco 实体(及其 id 类型)作为这个 respository 接口的持久化单元。最后,同样的更改可以应用到 OrderRepository:
1 | package tacos.data; |
现在有了这三个 repository,可能认为需要为这三个 repository 编写实现,还包括每种实现的十几个方法。但这就是 Spring Data JPA 优秀的地方 —— 不需要编写实现!当应用程序启动时,Spring Data JPA 会动态地自动生成一个实现。这意味着 repository 可以从一开始就使用。只需将它们注入到控制器中,就像在基于 JDBC 的实现中所做的那样。
CrudRepository 提供的方法非常适合用于实体的通用持久化。但是如果有一些基本持久化之外的需求呢?让我们看看如何自定义 repository 来执行域特有的查询。
自定义 JPA repository
想象一下,除了 CrudRepository 提供的基本 CRUD 操作之外,还需要获取投递给指定邮政编码的所有订单。事实证明,通过在 OrderRepository 中添加以下方法声明可以很容易地解决这个问题:
1 | List<Order> findByDeliveryZip(String deliveryZip); |
在生成 repository 实现时,Spring Data 检查存储库接口中的任何方法,解析方法名称,并尝试在持久化对象的上下文中理解方法的用途(在本例中是 Order)。本质上,Spring Data 定义了一种小型的领域特定语言(DSL),其中持久化细节用 repository 中的方法签名表示。
Spring Data 知道这个方法是用来查找订单的,因为已经用 Order 参数化了 CrudRepository。方法名 findByDeliveryZip() 表明,该方法应该通过将其 deliveryZip 属性与作为参数,传递给匹配的方法来查找所有订单实体。
findByDeliveryZip() 方法非常简单,但是 Spring Data 也可以处理更有趣的方法名。repository 的方法由一个动词、一个可选的主语、单词 by 和一个谓词组成。在 findByDeliveryZip() 中,动词是 find,谓词是 DeliveryZip,主语没有指定,暗示是一个 Order。
让我们考虑另一个更复杂的例子。假设需要查询在给定日期范围内投递给指定邮政编码的所有订单。在这种情况下,当添加到 OrderRepository 时,下面的方法可能会被证明是有用的:
1 | List<Order> readOrdersByDeliveryZipAndPlacedAtBetween(String deliveryZip, Date startDate, Date endDate); |
图 3.2 说明了在生成 respository 实现时,Spring Data 如何解析和理解 readOrdersByDeliveryZipAndPlacedAtBetween() 方法。可以看到,readOrdersByDeliveryZipAndPlacedAtBetween() 中的动词是 read。Spring Data 还将 find、read 和 get 理解为获取一个或多个实体的同义词。另外,如果只希望方法返回一个带有匹配实体计数的 int,也可以使用 count 作为动词。

图 3.2 Spring Data 解析 repository 方法特征来确定如何运行查询语句
尽管该方法的主语是可选的,但在这里它表示 Order。Spring Data 会忽略主题中的大多数单词,因此可以将方法命名为 readPuppiesBy…它仍然可以找到 Order 实体,因为这是 CrudRepository 参数化的类型。
谓词跟在方法名中的 By 后面,是方法签名中最有趣的部分。在本例中,谓词引用两个 Order属性:deliveryZip 和 placedAt。deliveryZip 属性必须与传递给方法的第一个参数的值一致。Between 关键字表示 deliveryZip 的值必须位于传入方法最后两个参数的值之间。
除了一个隐式的 Equals 操作和 Between 操作外,Spring Data 方法签名还可以包括以下任何操作:
- IsAfter, After, IsGreaterThan, GreaterThan
- IsGreaterThanEqual, GreaterThanEqual
- IsBefore, Before, IsLessThan, LessThan
- IsLessThanEqual, LessThanEqual
- IsBetween, Between
- IsNull, Null
- IsNotNull, NotNull
- IsIn, In
- IsNotIn, NotIn
- IsStartingWith, StartingWith, StartsWith
- IsEndingWith, EndingWith, EndsWith
- IsContaining, Containing, Contains
- IsLike, Like
- IsNotLike, NotLike
- IsTrue, True
- IsFalse, False
- Is, Equals
- IsNot, Not
- IgnoringCase, IgnoresCase
作为 IgnoringCase 和 IgnoresCase 的替代方法,可以在方法上放置 AllIgnoringCase 或 AllIgnoresCase 来忽略所有 String 比较的大小写。例如,考虑以下方法:
1 | List<Order> findByDeliveryToAndDeliveryCityAllIgnoresCase(String deliveryTo, String deliveryCity); |
最后,还可以将 OrderBy 放在方法名的末尾,以便根据指定的列对结果进行排序。例如,通过 deliveryTo 属性来订购:
1 | List<Order> findByDeliveryCityOrderByDeliveryTo(String city); |
虽然命名约定对于相对简单的查询很有用,但是对于更复杂的查询,不需要太多的想象就可以看出方法名称可能会失控。在这种情况下,可以随意将方法命名为任何想要的名称,并使用 @Query 对其进行注解,以显式地指定调用方法时要执行的查询,如下例所示:
1 |
|
在这个 @Query 的简单用法中,请求在西雅图交付的所有订单。但是也可以使用 @Query 来执行几乎任何想要的查询,即使通过遵循命名约定来实现查询很困难或不可能。
小结
- JdbcTemplate 大大简化了 JDBC 的工作。
- 当需要知道数据库生成的 id 时,可以同时使用 PreparedStatementCreator 和 KeyHolder。
- 为了方便执行数据插入,使用 SimpleJdbcInsert。
- Spring Data JPA 使得 JPA 持久化就像编写存储库接口一样简单。
第 4 章 Spring 安全
本章内容:
- 自动配置 Spring Security
- 自定义用户存储
- 自定义登录页面
- 防御 CSRF 攻击
- 了解你的用户
你有没有注意到电视情景喜剧里的大多数人都不锁门?在《Leave it to Beaver》的时代,人们不锁门并不是什么稀罕事。但是,在我们关心隐私和安全的今天,我们却看到电视上的人物能够畅通无阻地进入他们的公寓和家中,这似乎很疯狂。
信息可能是我们现在拥有的最有价值的东西;骗子们正在寻找通过潜入不安全的应用程序来窃取我们的数据和身份的方法。作为软件开发人员,我们必须采取措施保护应用程序中的信息。无论是用用户名与密码保护的电子邮件帐户,还是用交易密码保护的经济帐户,安全性都是大多数应用程序的一个重要方面。
启用 Spring Security
保护 Spring 应用程序的第一步是将 Spring Boot security starter 依赖项添加到构建中。在项目的 pom.xml 文件中,添加以下 <dependency> 内容:
1 | <dependency> |
如果正在使用 Spring Tool Suite,这甚至更简单。右键单击 pom.xml 文件并从 Spring 上下文菜单中选择 编辑 Starters。将出现 “启动依赖项” 对话框。检查核心类别下的安全条目,如图 4.1 所示。

上面的依赖项是保护应用程序所需的唯一的东西。当应用程序启动时,自动配置将检测类路径中的 Spring Security,并设置一些基本的安全性配置。
如果想尝试一下,启动应用程序并访问主页(或任何页面)。将提示使用 HTTP 基本身份验证对话框进行身份验证。要想通过认证,需要提供用户名和密码。用户名是 user。至于密码,它是随机生成并写入了应用程序日志文件。日志条目应该是这样的:
1 | Using default security password: 087cfc6a-027d-44bc-95d7-cbb3a798a1ea |
假设正确地输入了用户名和密码,将被授予对应用程序的访问权。
保护 Spring 应用程序似乎非常简单。Taco Cloud 应用程序的已经被保护了,我想我现在可以结束这一章,进入下一个主题了。但是在我们开始之前,让我们考虑一下自动配置提供了什么样的安全性。
只需要在项目构建中添加 security starter,就可以获得以下安全特性:
- 所有的 HTTP 请求路径都需要认证。
- 不需要特定的角色或权限。
- 没有登录页面。
- 身份验证由 HTTP 基本身份验证提供。
- 只有一个用户;用户名是 user。
这是一个良好的开端,但我认为大多数应用程序(包括 Taco Cloud)的安全需求将与这些基本的安全特性有很大的不同。
如果要正确地保护 Taco Cloud 应用程序,还有更多的工作要做。至少需要配置 Spring Security 来完成以下工作:
- 提示使用登录页面进行身份验证,而不是使用 HTTP 基本对话框。
- 为多个用户提供注册页面,让新的 Taco Cloud 用户可以注册。
- 为不同的请求路径应用不同的安全规则。例如,主页和注册页面根本不需要身份验证。
为了满足对 Taco Cloud 的安全需求,必须编写一些显式的配置,覆盖自动配置提供的内容。首先需要配置一个合适的用户存储,这样就可以有多个用户。
配置 Spring Security
多年来,有几种配置 Spring Security 的方法,包括冗长的基于 xml 的配置。幸运的是,Spring Security 的几个最新版本都支持基于 Java 的配置,这种配置更容易读写。
在本章结束之前,已经在基于 Java 的 Spring Security 配置中配置了所有 Taco Cloud 安全需求。但是在开始之前,可以通过编写下面清单中所示的基本配置类来简化它。
1 | package tacos.security; |
这个基本的安全配置做了什么?嗯,不是很多,但是它确实离需要的安全功能更近了一步。如果再次尝试访问 Taco Cloud 主页,仍然会提示需要登录。但是,将看到一个类似于图 4.2 的登录表单,而不是一个 HTTP 基本身份验证对话框提示。

提示:你可能会发现,在手动测试安全性时,将浏览器设置为 private 或 incognito 模式是很有用的。这将确保每次打开私人/隐身窗口时都有一个新的会话。必须每次都登录到应用程序,但是可以放心,你在安全性方面所做的任何更改都将被应用,并且旧 session 的任何残余都不会阻止你查看你的更改。
这是一个小小的改进 —— 使用 web 页面进行登录的提示(即使它在外观上相当简单)总是比 HTTP 基本对话框更友好。将在 4.3.2 节中自定义登录页面。然而,当前的任务是配置一个能够处理多个用户的用户存储。
事实证明,Spring Security 为配置用户存储提供了几个选项,包括:
- 一个内存用户存储
- 基于 JDBC 的用户存储
- 由 LDAP 支持的用户存储
- 定制用户详细信息服务
无论选择哪个用户存储,都可以通过重写 WebSecurityConfigurerAdapter 配置基类中定义的 configure() 方法来配置它。首先,你需要在 SecurityConfig 类中添加以下方法:
1 |
|
现在,只需要使用使用给定 AuthenticationManagerBuilder 的代码来替换这些省略号,以指定在身份验证期间如何查找用户。首先,将尝试内存用户存储。
内存用户存储
用户信息可以保存在内存中。假设只有少数几个用户,这些用户都不可能改变。在这种情况下,将这些用户定义为安全配置的一部分可能非常简单。
例如,下一个清单显示了如何在内存用户存储中配置两个用户 “buzz” 和 “woody”。
1 |
|
正如你所看到的,AuthenticationManagerBuilder 使用构造器风格的 API 来配置身份验证细节。在这种情况下,对 inMemoryAuthentication() 方法的调用,可以直接在安全配置本身中指定用户信息。
对 withUser() 的每个调用都会启动用户的配置。给 withUser() 的值是用户名,而密码和授予的权限是用 password() 和 authority() 方法指定的。如程序清单 4.2 所示,两个用户都被授予 ROLE_USER 权限。用户 “buzz” 的密码被配置为 “infinity“。同样,”woody” 的密码是 “bullseye“。
内存中的用户存储应用于测试或非常简单的应用程序时非常方便,但是它不允许对用户进行简单的编辑。如果需要添加、删除或更改用户,则必须进行必要的更改,然后重新构建、部署应用程序。
对于 Taco Cloud 应用程序,由于内存中用户存储的闲置,因此希望客户能够注册应用程序并管理自己的用户帐户,这不能够实现。因此让我们看看另一个允许使用数据库支持的用户存储的选项。
基于 JDBC 的用户存储
用户信息通常在关系数据库中维护,基于 JDBC 的用户存储似乎比较合适。下面的程序清单显示了如何配置 Spring Security,并将用户信息通过 JDBC 保存在关系型数据库中,来进行身份认证。
1 |
|
configure() 的这个实现在给定的 AuthenticationManagerBuilder 上调用 jdbcAuthentication()。然后,必须设置 DataSource,以便它知道如何访问数据库。这里使用的数据源是由自动装配提供的。
重写默认用户查询
虽然这个最小配置可以工作,但它对数据库模式做了一些假设。它期望已经存在某些表,用户数据将保存在这些表中。更具体地说,以下来自 Spring Security 内部的代码片段显示了在查找用户详细信息时将执行的 SQL 查询:
1 | public static final String DEF_USERS_BY_USERNAME_QUERY = |
第一个查询检索用户的用户名、密码以及是否启用它们,此信息用于对用户进行身份验证;下一个查询查询用户授予的权限,以进行授权;最后一个查询查询作为组的成员授予用户的权限。
如果可以在数据库中定义和填充满足这些查询的表,那么就没有什么其他要做的了。但是,数据库很可能不是这样的,需要对查询进行更多的控制。在这种情况下,可以配置自己的查询。
1 |
|
在本例中,仅重写了身份验证和基本授权查询,也可以通过使用自定义查询调用 groupAuthoritiesByUsername() 来重写组权限查询。
在将默认 SQL 查询替换为自己设计的查询时,一定要遵守查询的基本约定。它们都以用户名作为唯一参数。身份验证查询选择用户名、密码和启用状态;授权查询选择包含用户名和授予的权限的零个或多个行的数据;组权限查询选择零个或多个行数据,每个行有一个 group id、一个组名和一个权限。
使用编码密码
以身份验证查询为重点,可以看到用户密码应该存储在数据库中。唯一的问题是,如果密码以纯文本形式存储,就会受到黑客的窥探。但是如果在数据库中对密码进行编码,身份验证将失败,因为它与用户提交的明文密码不匹配。
为了解决这个问题,你需要通过调用 passwordEncoder() 方法指定一个密码编码器
1 |
|
passwordEncoder() 方法接受 Spring Security 的 passwordEncoder 接口的任何实现。Spring Security 的加密模块包括几个这样的实现:
- BCryptPasswordEncoder —— 采用 bcrypt 强哈希加密
- NoOpPasswordEncoder —— 不应用任何编码
- Pbkdf2PasswordEncoder —— 应用 PBKDF2 加密
- SCryptPasswordEncoder —— 应用了 scrypt 散列加密
- StandardPasswordEncoder —— 应用 SHA-256 散列加密
上述代码使用了 StandardPasswordEncoder。但是,如果没有现成的实现满足你的需求,你可以选择任何其他实现,甚至可以提供你自己的自定义实现。PasswordEncoder 接口相当简单:
1 | public interface PasswordEncoder { |
无论使用哪种密码编码器,重要的是要理解数据库中的密码永远不会被解码。相反,用户在登录时输入的密码使用相同的算法进行编码,然后将其与数据库中编码的密码进行比较。比较是在 PasswordEncoder 的 matches() 方法中执行的。
最后,将在数据库中维护 Taco Cloud 用户数据。但是,我没有使用 jdbcAuthentication(),而是想到了另一个身份验证选项。但在此之前,让我们先看看如何配置 Spring Security 以依赖于另一个常见的用户数据源:使用 LDAP(轻量级目录访问协议)接入的用户存储。
LDAP 支持的用户存储
要为基于 LDAP 的身份验证配置 Spring Security,可以使用 ldapAuthentication() 方法。这个方法与 jdbcAuthentication() 类似。下面的 configure() 方法演示了用于 LDAP 身份验证的简单配置:
1 |
|
userSearchFilter() 和 groupSearchFilter() 方法用于为基本 LDAP 查询提供过滤器,这些查询用于搜索用户和组。默认情况下,用户和组的基本查询都是空的,这表示将从 LDAP 层次结构的根目录进行搜索。但你可以通过指定一个查询基数来改变这种情况:
1 |
|
userSearchBase() 方法提供了查找用户的基本查询。同样,groupSearchBase() 方法指定查找组的基本查询。这个示例不是从根目录进行搜索,而是指定要搜索用户所在的组织单元是 people,组应该搜索组织单元所在的 group。
配置密码比较
针对 LDAP 进行身份验证的默认策略是执行绑定操作,将用户通过 LDAP 服务器直接进行验证。另一种选择是执行比较操作,这包括将输入的密码发送到 LDAP 目录,并要求服务器将密码与用户的密码属性进行比较。因为比较是在 LDAP 服务器中进行的,所以实际的密码是保密的。
如果希望通过密码比较进行身份验证,可以使用 passwordCompare() 方法进行声明:
1 |
|
默认情况下,登录表单中给出的密码将与用户 LDAP 条目中的 userPassword 属性值进行比较。如果密码保存在不同的属性中,可以使用 passwordAttribute() 指定密码属性的名称:
1 |
|
在本例中,指定密码属性应该与给定的密码进行比较。此外,还可以指定密码编码器,在进行服务器端密码比较时,最好在服务器端对实际密码加密。但是尝试的密码仍然会通过网络传递到 LDAP 服务器,并且可能被黑客截获。为了防止这种情况,可以通过调用 passwordEncoder() 方法来指定加密策略。
在前面的示例中,使用 bcrypt 密码散列函数对密码进行加密,这里的前提是密码在 LDAP 服务器中也是使用 bcrypt 加密的。
引用远程 LDAP 服务器
到目前为止,我们忽略了 LDAP 服务器和数据实际驻留的位置,虽然已经将 Spring 配置为根据 LDAP 服务器进行身份验证,但是该服务器在哪里呢?
默认情况下,Spring Security 的 LDAP 身份验证假设 LDAP 服务器正在本地主机上监听端口 33389。但是,如果 LDAP 服务器位于另一台机器上,则可以使用 contextSource() 方法来配置位置:
1 |
|
contextSource() 方法返回 ContextSourceBuilder,其中提供了 url() 方法,它允许指定 LDAP 服务器的位置。
配置嵌入式 LDAP 服务器
如果没有 LDAP 服务器去做身份验证,Spring Security 可提供一个嵌入式 LDAP 服务器。可以通过 root() 方法为嵌入式服务器指定根后缀,而不是将 URL 设置为远程 LDAP 服务器:
1 |
|
当 LDAP 服务器启动时,它将尝试从类路径中找到的任何 LDIF 文件进行数据加载。LDIF(LDAP 数据交换格式)是在纯文本文件中表示 LDAP 数据的标准方法,每个记录由一个或多个行组成,每个行包含一个 name:value 对,记录之间用空行分隔。
如果不希望 Spring 在类路径中寻找它能找到的 LDIF 文件,可以通过调用 ldif() 方法来更明确地知道加载的是哪个 LDIF 文件:
1 |
|
这里,特别要求 LDAP 服务器从位于根路径下的 users.ldif 文件中加载数据。如果你感兴趣,这里有一个LDIF 文件,你可以使用它来加载内嵌 LDAP 服务器的用户数据:
1 | dn: ou=groups,dc=tacocloud,dc=com |
Spring Security 的内置用户存储非常方便,涵盖了一些常见的用例。但是 Taco Cloud 应用程序需要一些特殊的东西。当开箱即用的用户存储不能满足需求时,需要创建并配置一个定制的用户详细信息服务。
自定义用户身份验证
在上一章中,决定了使用 Spring Data JPA 作为所有 taco、配料和订单数据的持久化选项。因此,以同样的方式持久化用户数据是有意义的,这样做的话,数据最终将驻留在关系型数据库中,因此可以使用基于 JDBC 的身份验证。但是更好的方法是利用 Spring Data 存储库来存储用户。
不过,还是要先做重要的事情,让我们创建表示和持久存储用户信息的域对象和存储库接口。
当 Taco Cloud 用户注册应用程序时,他们需要提供的不仅仅是用户名和密码。他们还会告诉你,他们的全名、地址和电话号码,这些信息可以用于各种目的,不限于重新填充订单(更不用说潜在的营销机会)。
为了捕获所有这些信息,将创建一个 User 类,如下所示。程序清单 4.5 定义用户实体
1 | package tacos; |
毫无疑问,你已经注意到 User 类比第 3 章中定义的任何其他实体都更加复杂。除了定义一些属性外,User 还实现了来自 Spring Security 的 UserDetails 接口。
UserDetails 的实现将向框架提供一些基本的用户信息,比如授予用户什么权限以及用户的帐户是否启用。
getAuthorities() 方法应该返回授予用户的权限集合。各种 isXXXexpired() 方法返回一个布尔值,指示用户的帐户是否已启用或过期。
对于 User 实体,getAuthorities() 方法仅返回一个集合,该集合指示所有用户将被授予 ROLE_USER 权限。而且,至少现在,Taco Cloud 还不需要禁用用户,所以所有的 isXXXexpired() 方法都返回 true 来表示用户处于活动状态。
定义了 User 实体后,现在可以定义存储库接口:
1 | package tacos.data; |
除了通过扩展 CrudRepository 提供的 CRUD 操作之外,UserRepository 还定义了一个 findByUsername() 方法,将在用户详细信息服务中使用该方法根据用户名查找 User。
如第 3 章所述,Spring Data JPA 将在运行时自动生成该接口的实现。因此,现在可以编写使用此存储库的自定义用户详细信息服务了。
创建用户详细信息服务
Spring Security 的 UserDetailsService 是一个相当简单的接口:
1 | public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;} |
这个接口的实现是给定一个用户的用户名,期望返回一个 UserDetails 对象,如果给定的用户名没有显示任何结果,则抛出一个 UsernameNotFoundException。
由于 User 类实现了 UserDetails,同时 UserRepository 提供了一个 findByUsername() 方法,因此它们非常适合在自定义 UserDetailsService 实现中使用。下面的程序清单显示了将在 Taco Cloud 应用程序中使用的用户详细信息服务。
1 | package tacos.security;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.core.userdetails.UsernameNotFoundException;import org.springframework.stereotype.Service;import tacos.User;import tacos.data.UserRepository; class UserRepositoryUserDetailsService implements UserDetailsService { private UserRepository userRepo; public UserRepositoryUserDetailsService(UserRepository userRepo) { this.userRepo = userRepo; } public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepo.findByUsername(username); if (user != null) { return user; } throw new UsernameNotFoundException("User '" + username + "' not found"); }} |
UserRepositoryUserDetailsService 通过 UserRepository 实例的构造器进行注入。然后,在它的 loadByUsername() 方法中,它调用 UserRepository 中的 findByUsername() 方法去查找 User;
loadByUsername() 方法只有一个简单的规则:不允许返回 null。因此如果调用 findByUsername() 返回 null,loadByUsername() 将会抛出一个 UsernameNotFoundExcepition。除此之外,被找到的 User 将会被返回。
你会注意到 UserRepositoryUserDetailsService 上有 @Service 注解。这是 Spring 的另一种构造型注释,它将该类标记为包含在 Spring 的组件扫描中,因此不需要显式地将该类声明为 bean。Spring 将自动发现它并将其实例化为 bean。
但是,仍然需要使用 Spring Security 配置自定义用户详细信息服务。因此,将再次返回到 configure() 方法:
1 | UserDetailsService userDetailsService; void configure(AuthenticationManagerBuilder auth) throws Exception { auth .userDetailsService(userDetailsService);} |
这次,只需调用 userDetailsService() 方法,将自动生成的 userDetailsService 实例传递给 SecurityConfig。
与基于 JDBC 的身份验证一样,也可以(而且应该)配置密码编码器,以便可以在数据库中对密码进行编码。为此,首先声明一个 PasswordEncoder 类型的bean,然后通过调用 PasswordEncoder() 将其注入到用户详细信息服务配置中:
1 |
|
我们必须讨论 configure() 方法中的最后一行,它出现了调用 encoder() 方法并将其返回值传递给 passwordEncoder()。但实际上,因为 encoder() 方法是用 @Bean 注释的,所以它将被用于在 Spring 应用程序上下文中声明一个 PasswordEncoder bean,然后拦截对 encoder() 的任何调用,以从应用程序上下文中返回 bean 实例。
既然已经有了一个通过 JPA 存储库读取用户信息的自定义用户详细信息服务,那么首先需要的就是一种让用户进入数据库的方法。需要为 Taco Cloud 用户创建一个注册页面,以便注册该应用程序。
用户注册
尽管 Spring Security 处理安全性的很多方面,但它实际上并不直接涉及用户注册过程,因此将依赖于 Spring MVC 来处理该任务。下面程序清单中的 RegistrationController 类展示并处理注册表单。
1 | package tacos.security; |
与任何典型的 Spring MVC 控制器一样,RegistrationController 使用 @Controller 进行注解,以将其指定为控制器,并将其标记为组件扫描。它还使用 @RequestMapping 进行注解,以便处理路径为 /register 的请求。
更具体地说,registerForm() 方法将处理 /register 的 GET 请求,它只返回注册的逻辑视图名。下面的程序清单显示了定义注册视图的 Thymeleaf 模板。
1 |
|
提交表单时,HTTP POST 请求将由 processRegistration() 方法处理。processRegistration() 的 RegistrationForm 对象绑定到请求数据,并使用以下类定义:
1 | package tacos.security; |
在大多数情况下,RegistrationForm 只是一个支持 Lombok 的基本类,只有少量属性。但是 toUser() 方法使用这些属性创建一个新的 User 对象,processRegistration() 将使用注入的 UserRepository 保存这个对象。
毫无疑问,RegistrationController 被注入了一个密码编码器。这与之前声明的 PasswordEncoder bean 完全相同。在处理表单提交时,RegistrationController 将其传递给 toUser() 方法,该方法使用它对密码进行编码,然后将其保存到数据库。通过这种方式,提交的密码以编码的形式写入,用户详细信息服务将能够根据编码的密码进行身份验证。
现在 Taco Cloud 应用程序拥有完整的用户注册和身份验证支持。但是如果在此时启动它,你会注意到,如果不是提示你登录,你甚至无法进入注册页面。这是因为,默认情况下,所有请求都需要身份验证。让我们看看 web 请求是如何被拦截和保护的,以便可以修复这种奇怪的先有鸡还是先有蛋的情况。
保护 web 请求
Taco Cloud 的安全需求应该要求用户在设计 tacos 或下订单之前进行身份验证。但是主页、登录页面和注册页面应该对未经身份验证的用户可用。
要配置这些安全规则,需要介绍一下 WebSecurityConfigurerAdapter 的另一个 configure() 方法:
1 |
|
这个 configure() 方法接受 HttpSecurity 对象,可以使用该对象来配置如何在 web 级别处理安全性。可以配置 HttpSecurity 的属性包括:
- 在允许服务请求之前,需要满足特定的安全条件
- 配置自定义登录页面
- 使用户能够退出应用程序
- 配置跨站请求伪造保护
拦截请求以确保用户拥有适当的权限是配置 HttpSecurity 要做的最常见的事情之一。让我们确保 Taco Cloud 的客户满足这些要求。
保护请求
需要确保 /design 和 /orders 的请求仅对经过身份验证的用户可用;应该允许所有用户发出所有其他请求。下面的 configure() 实现就是这样做的:
1 |
|
对 authorizeRequests() 的调用返回一个对象(ExpressionInterceptUrlRegistry),可以在该对象上指定 URL 路径和模式以及这些路径的安全需求。在这种情况下,指定两个安全规则:
- 对于 /design 和 /orders 的请求应该是授予 ROLE_USER 权限的用户的请求。
- 所有的请求都应该被允许给所有的用户。
这些规则的顺序很重要。首先声明的安全规则优先于较低级别声明的安全规则。如果交换这两个安全规则的顺序,所有请求都将应用 permitAll(),那么关于 /design 和 /orders 请求的规则将不起作用。
hasRole() 和 permitAll() 方法只是声明请求路径安全需求的两个方法。表 4.1 描述了所有可用的方法。
| 方法 | 做了什么 |
|---|---|
| access(String) | 如果 SpEL 表达式的值为 true,则允许访问 |
| anonymous() | 默认用户允许访问 |
| authenticated() | 认证用户允许访问 |
| denyAll() | 无条件拒绝所有访问 |
| fullyAuthenticated() | 如果用户是完全授权的(不是记住用户),则允许访问 |
| hasAnyAuthority(String…) | 如果用户有任意给定的权限,则允许访问 |
| hasAnyRole(String…) | 如果用户有任意给定的角色,则允许访问 |
| hasAuthority(String) | 如果用户有给定的权限,则允许访问 |
| hasIpAddress(String) | 来自给定 IP 地址的请求允许访问 |
| hasRole(String) | 如果用户有给定的角色,则允许访问 |
| not() | 拒绝任何其他访问方法 |
| permitAll() | 无条件允许访问 |
| rememberMe() | 允许认证了的同时标记了记住我的用户访问 |
表 4.1 中的大多数方法为请求处理提供了基本的安全规则,但是它们是自我限制的,只支持那些方法定义的安全规则。或者,可以使用 access() 方法提供 SpEL 表达式来声明更丰富的安全规则。Spring Security 扩展了 SpEL,包括几个特定于安全性的值和函数,如表 4.2 所示。
| Security 表达式 | 意指什么 |
|---|---|
| authentication | 用户认证对象 |
| denyAll | 通常值为 false |
| hasAnyRole(list of roles) | 如果用户有任何给定的角色,则为 true |
| hasRole(role) | 如果用户有给定的角色,则为 true |
| hasIpAddress(IP Address) | 如果请求来自给定 IP 地址,则为 true |
| isAnonymous() | 如果用户是默认用户,则为 true |
| isAuthenticated() | 如果用户是认证了的,则为 true |
| isFullyAuthenticated() | 如果用户被完全认证了的(不是使用记住我进行认证),则为 true |
| isRememberMe() | 如果用户被标记为记住我后认证了,则为 true |
| permitAll() | 通常值为 true |
| principal | 用户 pricipal 对象 |
表 4.2 中的大多数安全表达式扩展对应于表 4.1 中的类似方法。实际上,使用 access() 方法以及 hasRole() 和 permitAll 表达式,可以按如下方式重写 configure()。
1 |
|
乍一看,这似乎没什么大不了的。毕竟,这些表达式只反映了已经对方法调用所做的工作。但是表达式可以灵活得多。例如,假设(出于某种疯狂的原因)只想允许具有 ROLE_USER 权限的用户在周二(例如,在周二)创建新的 Taco;你可以重写表达式如下:
1 |
|
使用基于 SpEL 的安全约束,这种可能性实际上是无限的。我敢打赌,你已经在构思基于 SpEL 的有趣的安全约束了。
只需使用 access() 和程序清单 4.9 中的 SpEL 表达式,就可以满足 Taco Cloud 应用程序的授权需求。现在,让我们来看看如何定制登录页面来适应 Taco Cloud 应用程序的外观。
创建用户登录页面
默认的登录页面比你开始时使用的笨拙的 HTTP 基本对话框要好得多,但它仍然相当简单,不太适合 Taco Cloud 应用程序的其余部分。
要替换内置的登录页面,首先需要告诉 Spring Security 自定义登录页面的路径。这可以通过调用传递给 configure() 的 HttpSecurity 对象上的 formLogin() 来实现:
1 |
|
请注意,在调用 formLogin() 之前,需要使用对 and() 的调用来连接这一部分的配置和前面的部分。and() 方法表示已经完成了授权配置,并准备应用一些额外的 HTTP 配置。在开始新的配置部分时,将多次使用 and()。
连接之后,调用 formLogin() 开始配置自定义登录表单。之后对 loginPage() 的调用指定了将提供自定义登录页面的路径。当 Spring Security 确定用户未经身份验证并且需要登录时,它将把用户重定向到此路径。
现在需要提供一个控制器来处理该路径上的请求。因为你的登录页面非常简单 —— 除了一个视图什么都没有 —— 在 WebConfig 中声明它为一个视图控制器是很容易的。下面的 addViewControllers() 方法在将 “/” 映射到主控制器的视图控制器旁边设置登录页面视图控制器:
1 |
|
最后,需要定义 login 页面视图本身,因为使用 Thymeleaf 作为模板引擎,下面的 Thymeleaf 模板应该做得很好:
1 |
|
关于这个登录页面需要注意的关键事情是,它发布到的路径以及用户名和密码字段的名称。默认情况下,Spring Security 在 /login 监听登录请求,并期望用户名和密码字段命名为 username 和 password。但是,这是可配置的。例如,以下配置自定义路径和字段名:
1 | .and() |
这里,指定 Spring Security 应该监听请求 /authenticate 请求以处理登录提交。此外,用户名和密码字段现在应该命名为 user 和 pwd。
默认情况下,当 Spring Security 确定用户需要登录时,成功的登录将直接将用户带到他们所导航到的页面。如果用户要直接导航到登录页面,成功的登录将把他们带到根路径(例如,主页)。但你可以通过指定一个默认的成功页面来改变:
1 | .and() |
按照这里的配置,如果用户在直接进入登录页面后成功登录,那么他们将被引导到 /design 页面。
另外,可以强制用户在登录后进入设计页面,即使他们在登录之前已经在其他地方导航,方法是将 true 作为第二个参数传递给 defaultSuccessUrl:
1 | .and() |
现在已经处理了自定义登录页面,让我们来看看身份验证的另一面 —— 如何让用户登出。
登出
与登录应用程序同样重要的是登出。要启用登出功能,只需调用 HttpSecurity 对象上的 logout:
1 | .and() |
这将设置一个安全筛选器来拦截发送到 /logout 的请求。因此,要提供登出功能,只需在应用程序的视图中添加登出表单和按钮:
1 | <form method="POST" th:action="@{/logout}"> |
当用户单击按钮时,他们的 session 将被清除,他们将退出应用程序。默认情况下,它们将被重定向到登录页面,在那里它们可以再次登录。但是,如果希望它们被发送到另一个页面,可以调用 logoutSucessFilter() 来指定一个不同的登出后的登录页面:
1 | .and() |
在这个例子中,用户在登出后将被跳转到主页。
阻止跨站请求伪造攻击
跨站请求伪造(CSRF)是一种常见的安全攻击。它涉及到让用户在一个恶意设计的 web 页面上编写代码,这个页面会自动(通常是秘密地)代表经常遭受攻击的用户向另一个应用程序提交一个表单。例如,在攻击者的网站上,可能会向用户显示一个表单,该表单会自动向用户银行网站上的一个 URL 发送消息(该网站的设计可能很糟糕,很容易受到这种攻击),以转移资金。用户甚至可能不知道攻击发生了,直到他们注意到他们的帐户中少了钱。
为了防止此类攻击,应用程序可以在显示表单时生成 CSRF token,将该 token 放在隐藏字段中,然后将其存储在服务器上供以后使用。提交表单时,token 将与其他表单数据一起发送回服务器。然后服务器拦截请求,并与最初生成的 token 进行比较。如果 token 匹配,则允许继续执行请求。否则,表单一定是由一个不知道服务器生成的 token的恶意网站呈现的。
幸运的是,Spring Security 有内置的 CSRF 保护。更幸运的是,它是默认启用的,不需要显式地配置它。只需确保应用程序提交的任何表单都包含一个名为 _csrf 的字段,该字段包含 CSRF token。
Spring Security 甚至可以通过将 CSRF token 放在名为 _csrf 的请求属性中来简化这一过程。因此,可以使用以下代码,在 Thymeleaf 模板的一个隐藏字段中呈现 CSRF token:
1 | <input type="hidden" name="_csrf" th:value="${_csrf.token}"/> |
如果使用 Spring MVC 的 JSP 标签库或带有 Spring 安全方言的 Thymeleaf,那么甚至不需要显式地包含一个隐藏字段,隐藏字段将自动呈现。
在 Thymeleaf 中,只需确保 <form> 元素的一个属性被前缀为 Thymeleaf 属性。因为让 Thymeleaf 将路径呈现为上下文相关是很常见的,所以这通常不是问题。例如,Thymeleaf 渲染隐藏字段所需要的仅仅是 th:action 属性:
1 | <form method="POST" th:action="@{/login}" id="loginForm"> |
当然也可以禁用 CSRF 支持,但我不太愿意展示如何禁用。CSRF 保护很重要,而且在表单中很容易处理,所以没有理由禁用它,但如果你坚持禁用它,你可以这样调用 disable():
1 | .and() |
我再次提醒你不要禁用 CSRF 保护,特别是对于生产环境中的应用程序。
所有 web 层安全性现在都配置到 Taco Cloud 了。除此之外,现在有了一个自定义登录页面,并且能够根据 JPA 支持的用户存储库对用户进行身份验证。现在让我们看看如何获取有关登录用户的信息。
了解你的用户
通常,仅仅知道用户已经登录是不够的。通常重要的是要知道他们是谁,这样才能调整他们的体验。
例如,在 OrderController 中,当最初创建绑定到订单表单的订单对象时,如果能够用用户名和地址预先填充订单就更好了,这样他们就不必为每个订单重新输入它。也许更重要的是,在保存订单时,应该将订单实体与创建订单的用户关联起来。
为了在 Order 实体和 User 实体之间实现所需的连接,需要向 Order 类添加一个新属性:
1 |
|
此属性上的 @ManyToOne 注解表明一个订单属于单个用户,相反,一个用户可能有多个订单。(因为使用的是 Lombok,所以不需要显式地定义属性的访问方法。)
在 OrderController 中,processOrder() 方法负责保存订单。需要对其进行修改,以确定经过身份验证的用户是谁,并调用 Order 对象上的 setUser() 以将 Order 与该用户连接起来。
有几种方法可以确定用户是谁。以下是一些最常见的方法:
- 将主体对象注入控制器方法
- 将身份验证对象注入控制器方法
- 使用 SecurityContext 获取安全上下文
- 使用 @AuthenticationPrincipal 注解的方法
例如,可以修改 processOrder() 来接受 java.security.Principal 作为参数。然后可以使用主体名从 UserRepository 查找用户:
1 |
|
这可以很好地工作,但是它会将与安全性无关的代码与安全代码一起丢弃。可以通过修改 processOrder() 来减少一些特定于安全的代码,以接受 Authentication 对象作为参数而不是 Principal:
1 |
|
有了身份验证,可以调用 getPrincipal() 来获取主体对象,在本例中,该对象是一个用户。注意,getPrincipal() 返回一个 java.util.Object,因此需要将其转换为 User。
然而,也许最干净的解决方案是简单地接受 processOrder() 中的用户对象,但是使用 @AuthenticationPrincipal 对其进行注解,以便它成为身份验证的主体:
1 |
|
@AuthenticationPrincipal 的优点在于它不需要强制转换(与身份验证一样),并且将特定于安全性的代码限制为注释本身。当在 processOrder() 中获得 User 对象时,它已经准备好被使用并分配给订单了。
还有一种方法可以识别通过身份验证的用户是谁,尽管这种方法有点麻烦,因为它包含了大量与安全相关的代码。你可以从安全上下文获取一个认证对象,然后像这样请求它的主体:
1 | Authentication authentication = |
尽管这个代码段充满了与安全相关的代码,但是它与所描述的其他方法相比有一个优点:它可以在应用程序的任何地方使用,而不仅仅是在控制器的处理程序方法中,这使得它适合在较低级别的代码中使用。
小结
- Spring Security 自动配置是一种很好的开始学习安全的方式,但大多数应用程序需要明确地配置安全,以满足其独特的安全需求。
- 用户细节可以在关系数据库、LDAP 或完全自定义实现支持的用户存储中进行管理。
- Spring Security 自动防御 CSRF 攻击。
- 通过 SecurityContext 对象(从 SecurityContextHolder. getcontext() 中返回)或使用 @AuthenticationPrincipal 注入控制器中,可以获得认证用户的信息。
第 5 章 使用配置属性
本章内容:
- 微调自动配置 bean
- 将配置属性应用于应用程序组件
- 使用 Spring 配置文件
你还记得 iPhone 第一次出现的时候吗?一块由金属和玻璃制成的小平板几乎不符合人们对电话的认知。然而,它开创了现代智能手机时代,改变了我们交流的一切方式。尽管在很多方面,触控手机都比上一代的翻盖手机更简单、功能更强大,但在 iPhone 第一次发布时,很难想象一个只有一个按钮的设备怎么能用来打电话。
在某些方面,Spring Boot 自动配置是这样的。自动配置大大简化了 Spring 应用程序的开发。但是,在使用 Spring XML 配置中设置属性值和调用 bean 实例上 setter 方法十年之后,如何在没有显式配置的 bean 上设置属性并不是很明显。
幸运的是,Spring Boot 提供了一种配置属性的方法。配置属性不过是 Spring 应用程序上下文中 bean 上的属性,可以从几个属性源(包括 JVM 系统属性、命令行参数和环境变量)之一进行设置。
在本章中,将从实现 Taco Cloud 应用程序中的新功能中后退一步,以研究配置属性。在接下来的章节中,你学到的东西无疑会对你以后的学习很有帮助。我们将首先了解如何使用配置属性来微调 Spring Boot 自动配置的内容。
微调自动配置
在我们深入研究配置属性之前,有必要确定在 Spring 中有两种不同(但相关)的配置
- Bean wiring —— 它声明应用程序组件将在 Spring 应用程序上下文中作为 bean 创建,以及它们应该如何相互注入。
- Property injection —— 在 Spring 应用程序上下文中设置 bean 的值。
在 Spring 的 XML 和基于 Java 的配置中,这两种类型的配置通常在同一个地方显式地声明。在 Java 配置中,@Bean 注解的方法可能实例化一个 bean,然后设置其属性的值。例如,考虑下面的 @Bean 方法,它为嵌入式 H2 数据库声明了一个数据源:
1 |
|
这里的 addScript() 和 addScripts() 方法设置了一些带有 SQL 脚本名称的字符串属性,这些 SQL 脚本应该在数据源准备好后应用到数据库中。如果不使用 Spring Boot,那么这就是配置 DataSource bean 的方式,而自动配置使此方法完全没有必要。
如果 H2 依赖项在运行时类路径中可用,那么 Spring Boot 将在 Spring 应用程序上下文中自动创建适当的数据源 bean。bean 应用于 schema.sql 和 data.sql 脚本的读取。
但是,如果希望将 SQL 脚本命名为其他名称呢?或者,如果需要指定两个以上的 SQL 脚本怎么办?这就是配置属性的用武之地。但是在开始使用配置属性之前,需要了解这些属性的来源。
理解 Spring 环境抽象
Spring 环境抽象是任何可配置属性的一站式商店。它抽象了属性的起源,以便需要这些属性的 bean 可以从 Spring 本身使用它们。Spring 环境来自几个属性源,包括:
- JVM 系统属性
- 操作系统环境变量
- 命令行参数
- 应用程序属性配置文件
然后,它将这些属性聚合到单一的源中,从这个源中可以注入 Spring bean。图 5.1 演示了来自属性源的属性是如何通过 Spring 环境抽象流到 Spring bean 中的。

通过 Spring Boot 自动配置的 bean 都可以通过从 Spring 环境中提取的属性进行配置。作为一个简单的例子,假设希望应用程序的底层 servlet 容器侦听某些端口上的请求,而不是默认端口 8080。为此,通过在 src/main/resources/application.properties 文件中的 server.port 属性来指定一个不同的接口,如下所示:
1 | =9090 |
就我个人而言,我更喜欢在设置配置属性时使用 YAML。因此,我可能设置在 /src/main/resources/application.yml 文件中的 server.port 的值,而不是使用 application.properties 文件,如下所示:
1 | server: |
如果希望在外部配置该属性,还可以在启动应用程序时使用命令行参数指定端口:
1 | $ java -jar tacocloud-0.0.5-SNAPSHOT.jar --server.port=9090 |
如果想让应用程序总是在一个特定的端口上启动,可以把它设置为一个操作系统环境变量:
1 | $ export SERVER_PORT=9090 |
注意,在将属性设置为环境变量时,命名风格略有不同,以适应操作系统对环境变量名称的限制。Spring 能够将其分类并将 SERVER_PORT 转译为 server.port。
正如我所说的,有几种设置配置属性的方法。当我们讲到第 14 章的时候,你会看到在一个集中的配置服务器中设置配置属性的另一种方法。实际上,可以使用几百个配置属性来调整 Spring bean 的行为。你已经看到了一些:本章中的 server.port 和前一章的 security.user.name 和 security.user.password。
在本章中不可能测试所有可用的配置属性。尽管如此,让我们来看看一些可能经常遇到的最有用的配置属性。我们将从几个属性开始,这些属性允许你调整自动配置的数据源。
配置数据源
此时,Taco Cloud 应用程序仍未完成,但是在准备部署应用程序之前,还有几个章节要处理一些问题。因此,作为数据源使用的嵌入式 H2 数据库非常适合目前为止需要的一切。但是,一旦将应用程序投入生产,可能需要考虑一个更持久的数据库解决方案。
虽然可以显式地配置 DataSource bean,但这通常是不必要的。相反,通过配置属性为数据库配置 URL 和凭据更简单。例如,如果打算开始使用 MySQL 数据库,可以将以下配置属性添加到 application.yml:
1 | spring: |
虽然需要将适当的 JDBC 驱动程序添加到构建中,但通常不需要指定 JDBC 驱动程序类;Spring Boot 可以从数据库 URL 的结构中找到它。但如果有问题,可以试着设置 spring.datasource.schema 和 spring.datasource.data 属性:
1 | spring: |
可能显式数据源配置不是你的风格。相反,你可能更喜欢在 JNDI 中配置数据源,并让 Spring 从那里查找它。在这种情况下,通过配置 spring.datasource.jndi-name 来设置数据源:
1 | spring: |
如果设置了 spring.datasource.jndi-name 属性,那么其他数据源的连接属性(如果设置了)会被忽略。
配置嵌入式服务器
已经看到如何通过设置 server.port 来设置 servlet 容器。还没有让你看到的是,如果把 server.port 设置为 0 会发生什么:
1 | server: |
尽管正在显式地设置 server.port 为 0,但是服务器不会在端口 0 上启动。相反,它将从随机选择的可用端口启动。这在运行自动化集成测试以确保任何并发运行的测试不会在硬编码端口号上发生冲突时非常有用。在第 13 章中将看到,当不关心应用程序启动于哪个端口时,它也很有用,因为它是一个将从服务注册表中查找的微服务。
但是底层服务器不仅仅是一个端口。需要对底层容器做的最常见的事情之一是将其设置为处理 HTTPS 请求。要做到这一点,你必须做的第一件事是通过使用 JDK 的 keytool 命令行工具创建一个密钥存储:
1 | $ keytool -keystore mykeys.jks -genkey -alias tomcat -keyalg RSA |
你将会被问到几个关于你的名字和公司的问题,这些问题大部分都是无关紧要的。但当被要求输入密码时,记住你的密码。对于本例,我选择 letmein 作为密码。
接下来,需要设置一些属性,用于在嵌入式服务器中启用 HTTPS。可以在命令行中指定它们,但是那样会非常不方便。相反,可能会在 application.properties 或 application.yml 文件中设置它们。在 application.yml 中,属性可能是这样的:
1 | server: |
在这里 server.port 属性设置为 8443,这是开发 HTTPS 服务器的常用选择。server.ssl.key-store 属性设置为创建密钥存储库文件的路径。这里显示了一个 file:// URL 来从文件系统加载它,但是如果将它打包到应用程序 JAR 文件中,将使用一个 classpath: URL来引用它。同时 server.ssl.key-store-password 和 server.ssl.key-password 属性都被设置为创建密钥存储时指定的密码值。
有了这些属性,应用程序应该侦听端口 8443 上的 HTTPS 请求。根据使用的浏览器,可能会遇到服务器无法验证其身份的警告。在开发期间从本地主机提供服务时,这没有什么可担心的。
配置日志
大多数应用程序都提供某种形式的日志记录。即使应用程序没有直接记录任何内容,应用程序使用的库也肯定会记录它们的活动。
默认情况下,Spring Boot 通过 Logback 配置日志,默认为 INFO 级别,然后写入控制台。在运行应用程序和其他示例时,可能已经在应用程序日志中看到了大量的 INFO 级别的日志条目。
要完全控制日志配置,可以在类路径的根目录(在 src/main/resources 中)创建 log .xml 文件。下面是一个简单的 log .xml 文件的例子:
1 | <configuration> |
除了用于日志的模式外,Logback 配置或多或少与没有 log .xml 文件时得到的默认配置相同。但是通过编辑 logback.xml,可以完全控制应用程序的日志文件。
注意:logback.xml 中包含的具体内容超出了本书的范围。有关更多信息,请参阅 Logback 的文档。
对日志配置最常见的更改是更改日志级别,可能还会指定应该写入日志的文件。使用 Spring Boot 配置属性,可以在不创建 log .xml 文件的情况下进行这些更改。
要设置日志记录级别,需要创建以 logging.level 为前缀的属性,后面接上要为其设置日志级别的日志记录器的名称。例如,假设想将 root 日志级别设置为 WARN,但是将 Spring 安全日志设置为 DEBUG 级别。可以像下面这样设置:
1 | logging: |
另外,可以将 Spring Security 包的名称折叠成一行,以便于阅读:
1 | logging: |
现在,假设希望将日志条目写入位于 /var/logs/ 文件夹下的 TacoCloud.log 文件。loggin.path 和 logging.file 属性可以帮助实现这一点:
1 | logging: |
假设应用程序对 /var/logs/ 文件夹有写权限,那么日志将被写到 /var/logs/TacoCloud.log 文件中。默认情况下,日志文件在大小达到 10 MB 时就会进行循环写入。
使用特殊的属性值
在设置属性时,不限于将它们的值声明为硬编码的字符串和数值。相反,可以从其他配置属性派生它们的值。
例如,假设(不管出于什么原因)想要设置一个名为 greeting.welcome 的属性,用于返回另一个名为 spring.application.name 的属性的值。为此,在设置 greeting.welcome 时可以使用 ${} 占位符标记:
1 | greeting: |
你甚至可以把这个占位符嵌入到其他文本中:
1 | greeting: |
正如你所看到的,使用配置属性配置 Spring 自己的组件可以很容易地将值注入这些组件的属性并调整自动配置。配置属性并不专属于 Spring 创建的 bean。只需稍加努力,就可以利用你自己的 bean 中的配置属性。接下来让我们来看看怎么做。
创建自己的配置属性
正如前面提到的,配置属性只不过是指定来接受 Spring 环境抽象配置的 bean 的属性。没有提到的是如何指定这些 bean 来使用这些配置。
为了支持配置属性的属性注入,Spring Boot 提供了@ConfigurationProperties 注释。当放置在任何 Spring bean 上时,它指定可以从 Spring 环境中的属性注入到该 bean 的属性。
为了演示 @ConfigurationProperties 是如何工作的,假设已经将以下方法添加到 OrderController 中,以列出经过身份验证的用户之前的订单:
1 |
|
除此之外,还需要向 OrderRepository 添加了必要的 findByUser() 方法:
1 | List<Order> findByUserOrderByPlaceAtDesc(User user); |
请注意,此存储库方法是用 OrderByPlacedAtDesc 子句命名的。OrderBy 部分指定一个属性,通过该属性对结果排序 —— 在本例中是 placedAt 属性。最后的 Desc 让排序按降序进行。因此,返回的订单列表将按时间倒序排序。
如前所述,在用户下了一些订单之后,这个控制器方法可能会很有用。但对于最狂热的 taco 鉴赏家来说,它可能会变得有点笨拙。在浏览器中显示的一些命令是有用的;一长串没完没了的订单只是噪音。假设希望将显示的订单数量限制为最近的 20 个订单,可以更改 ordersForUser():
1 |
|
随着这个改变,OrderRepository 跟着需要变为:
1 | List<Order> findByUserOrderByPlaceAtDesc(User user, Pageable pageable); |
这里,已经更改了 findByUserOrderByPlacedAtDesc() 方法的签名,以接受可分页的参数。可分页是 Spring Data 通过页码和页面大小选择结果子集的方式。在 ordersForUser() 控制器方法中,构建了一个 PageRequest 对象,该对象实现了 Pageable 来请求第一个页面(page zero),页面大小为 20,以便为用户获得最多 20 个最近下的订单。
虽然这工作得非常好,但它让我感到有点不安,因为已经硬编码了页面大小。如果后来发现 20 个订单太多,而决定将其更改为 10 个订单,该怎么办?因为它是硬编码的,所以必须重新构建和重新部署应用程序。
可以使用自定义配置属性来设置页面大小,而不是硬编码页面大小。首先,需要向 OrderController 添加一个名为 pageSize 的新属性,然后在 OrderController 上使用 @ConfigurationProperties 注解 ,如下面的程序清单所示。
1 |
|
程序清单 5.1 中最重要的变化是增加了 @ConfigurationProperties 注解。其 prefix 属性设置为 taco。这意味着在设置 pageSize 属性时,需要使用一个名为 taco.orders.pageSize 的配置属性。
新的 pageSize 属性默认为 20。但是可以通过设置 taco.orders.pageSize 属性轻松地将其更改为想要的任何值。例如,可以在 application.yml 中设置此属性:
1 | taco: |
或者,如果需要在生产环境中进行快速更改,可以通过设置 taco.orders.pageSize 属性作为环境变量来重新构建和重新部署应用程序:
1 | $ export TACO_ORDERS_PAGESIZE=10 |
可以设置配置属性的任何方法,都可以用来调整最近订单页面的大小。接下来,我们将研究如何在属性持有者中设置配置数据。
定义配置属性持有者
这里没有说 @ConfigurationProperties 必须设置在控制器或任何其他特定类型的 bean 上,@ConfigurationProperties 实际上经常放在 bean 上。在应用程序中,这些 bean 的惟一目的是作为配置数据的持有者,这使控制器和其他应用程序类不涉及特定于配置的细节,它还使得在几个可能使用该信息的 bean 之间共享公共配置属性变得很容易。
对于 OrderController 中的 pageSize 属性,可以将其提取到一个单独的类中。下面的程序清单以这种方式使用了 OrderProps 类。
1 | package tacos.web; |
正如在 OrderController 中所做的,pageSize 属性默认为 20,同时 OrderProps 使用 @ConfigurationProperties 进行注解,以具有 taco.orders 前缀。
它还带有 @Component 注解,因此 Spring 组件扫描时将自动发现它并在 Spring 应用程序上下文中将其创建为 bean。这很重要,因为下一步是将 OrderProps bean 注入到 OrderController 中。
关于配置属性持有者,没有什么特别的。它们是从 Spring 环境中注入属性的 bean。它们可以被注入到任何需要这些属性的其他 bean 中。对于 OrderController,这意味着从 OrderController 中删除 pageSize 属性,而不是注入并使用 OrderProps bean:
1 |
|
现在 OrderController 不再负责处理它自己的配置属性。这使得 OrderController 中的代码稍微整洁一些,并允许在任何其他需要它们的 bean 中重用 OrderProps 中的属性。此外,正在收集与一个地方的订单相关的配置属性:OrderProps 类。如果需要添加、删除、重命名或以其他方式更改其中的属性,只需要在 OrderProps 中应用这些更改。
例如,假设在其他几个 bean 中使用 pageSize 属性,这时最好对该属性应用一些验证,以将其值限制为不小于 5 和不大于 25。如果没有持有者 bean,将不得不对 OrderController、pageSize 属性以及使用该属性的所有其他类应用验证注解。但是因为已经将 pageSize 提取到 OrderProps 中,所以只需要更改 OrderProps:
1 | package tacos.web; |
尽管可以很容易地将 @Validated、@Min 和 @Max 注解应用到 OrderController(以及可以注入 OrderProps 的任何其他 bean),但这只会使 OrderController 更加混乱。通过使用配置属性持有者 bean,就在在一个地方收集了配置属性的细节,使得需要这些属性的类相对干净。
声明配置属性元数据
根据 IDE 的情况,你可能已经注意到 application.yml(或是 appication.properties)中的 taco.orders.pageSize 属性有一个警告,说类似未知属性 ‘taco’ 之类的东西。出现此警告是因为缺少关于刚刚创建的配置属性的元数据。图 5.2 显示了我将鼠标悬停在 Spring Tool Suite 中 taco 属性时的效果。

配置属性元数据是完全可选的,并不会阻止配置属性的工作。但是元数据对于提供有关配置属性的最小文档非常有用,特别是在 IDE 中。
例如,当我将鼠标悬停在 security.user.password 属性上时,如图5.3所示,虽然悬停帮助你获得的是最小的,但它足以帮助你了解属性的用途以及如何使用它。

为了帮助那些可能使用你定义的配置属性(甚至可能是你自己定义的)的人,通常最好是围绕这些属性创建一些元数据,至少它消除了 IDE 中那些恼人的黄色警告。
要为自定义配置属性创建元数据,需要在 META-INF(例如,在项目下的 src/main/resources/META-INF 中)中创建一个名为 addition-spring-configuration-metadata.json 的文件。
快速修复缺失的元数据。
如果正在使用 Spring Tool Suite,则有一个用于创建丢失的属性元数据的快速修复选项。将光标放在缺少元数据警告的行上,然后按下 Mac 上的 CMD-1 或 Windows 和 Linux 上的 Ctrl-1 弹出的快速修复(参见图 5.4)。

然后选择 Create Metadata for… 选项来为属性添加一些元数据(在 additional-spring-configuration-metadata 中)。如果该文件不存在,则创建该文件。
对于 taco.orders.pageSize 属性,可以用以下 JSON 设置元数据:
1 | { |
注意,元数据中引用的属性名是 taco.orders.pagesize。Spring Boot 灵活的属性命名允许属性名的变化,比如 taco.orders.page-size 相当于 taco.orders.pageSize。
有了这些元数据,警告就应该消失了。更重要的是,如果你悬停在 taco.orders.pageSize 属性,你将看到如图 5.5 所示的描述。

另外,如图 5.6 所示,可以从 IDE 获得自动完成帮助,就像 Springprovided 的配置属性一样。

配置属性对于调整自动配置的组件和注入到应用程序 bean 中的细节非常有用。但是,如果需要为不同的部署环境配置不同的属性呢?让我们看看如何使用 Spring 配置文件来设置特定于环境的配置。
使用 profile 文件进行配置
当应用程序部署到不同的运行时环境时,通常会有一些配置细节不同。例如,数据库连接的细节在开发环境中可能与在 QA 环境中不一样,在生产环境中可能还不一样。在一个环境中唯一配置属性的一种方法是使用环境变量来指定配置属性,而不是在 application.properties 或 application.yml 中定义它们。
例如,在开发期间,可以依赖于自动配置的嵌入式 H2 数据库。但在生产中,可以将数据库配置属性设置为环境变量,如下所示:
1 | export SPRING_DATASOURCE_URL=jdbc:mysql://localhost/tacocloud |
尽管这样做是可行的,但是将一两个以上的配置属性指定为环境变量就会变得有点麻烦。此外,没有跟踪环境变量更改的好方法,也没有在出现错误时轻松回滚更改的好方法。
相反,我更喜欢利用 Spring profile 文件。profile 文件是一种条件配置类型,其中根据运行时激活的 profile 文件应用或忽略不同的 bean、配置类和配置属性。
例如,假设出于开发和调试的目的,希望使用嵌入式 H2 数据库,并且希望将 Taco Cloud 代码的日志级别设置为 DEBUG。但是在生产中,需要使用一个外部 MySQL 数据库,并将日志记录级别设置为 WARN。在开发环境中,很容易不设置任何数据源属性并获得自动配置的 H2 数据库。至于 DEBUG 级别的日志记录,可以在 application.yml 中设置 logging.level.tacos 属性。
1 | logging: |
这正是开发目的所需要的。但是,如果要将此应用程序部署到生产环境中,而不需要对 application.yml 进行进一步更改,仍然可以获得对于 tacos 包的调试日志和嵌入式 H2 数据库。需要的是定义一个具有适合生产的属性的 profile 文件。
定义特定 profile 的属性
定义特定 profile 文件的属性的一种方法是创建另一个仅包含用于生产的属性的 YAML 或属性文件。文件的名称应该遵循这个约定:application-{profile 名称}.yml 或 application-{profile 名称}.properties。然后可以指定适合该配置文件的配置属性。例如,可以创建一个名为 application-prod.yml 的新文件,包含以下属性:
1 | spring: |
另一种指定特定 profile 文件的属性的方法只适用于 YAML 配置。它涉及在应用程序中将特定 profile 的属性与非 profile 的属性一起放在 application.yml 中,由三个连字符分隔。将生产属性应用于 application.yml 时,整个 application.yml 应该是这样的:
1 | logging: |
这个 application.yml 文件由一组三重连字符(—)分成两个部分。第二部分为 spring.profiles 指定一个值,这个值指示了随后应用于 prod 配置文件的属性。另一方面,第一部分没有为 spring.profiles 指定值。因此,它的属性对于所有 profile 文件都是通用的,或者如果指定的 profile 文件没有设置其他属性,它就是默认的。
无论应用程序运行时哪个配置文件处于活动状态,tacos 包的日志级别都将通过默认配置文件中的属性设置为 DEBUG。但是,如果名为 prod 的配置文件是活动的,那么 logging.level.tacos 属性将会被重写为 WARN。同样,如果 prod 配置文件是活动的,那么数据源属性将设置为使用外部 MySQL 数据库。
通过创建使用 application-{profile 名称}.yml 或 application-{profile 名称}.properties 这种模式命名的其他 YAML 或 properties 文件,可以为任意数量的 profile 文件定义属性。或者在 application.yml 中再输入三个破折号通过 spring.profiles 来指定配置文件名称。然后添加需要的所有特定 profile 文件的属性。
激活 profile 文件
设置特定 profile 属性没有什么意思,除非这些 profile 处于活动状态。但是要如何激活一个 profile 文件呢?让一个 profile 文件处于激活状态需要做的只是将 spring.profiles.active 属性的值指定为需要激活的 profile 的名称。例如,可以像下面这样设置 application.yml 中的这个属性:
1 | spring: |
但是这可能是设定一个活动 profile 最糟糕的方式了。如果在 application.yml 中设置了激活的 profile,然后那个 profile 文件就变成了默认 profile 文件,那么就没有达到生产环境特定属性与开发环境特定属性分离的目的。相反,我推荐使用环境变量设置激活的 profile。在生产环境,像下面这样设置 SPRING_PROFILES_ACTIVE:
1 | % export SPRING_PROFILES_ACTIVE=prod |
这样设置完成后,部署于那台机器的任何应用程序将会使用 prod profile,同时相应的配置属性将优先于默认配置文件中的属性。
如果使用可执行的 JAR 文件来运行应用程序,你可能也可以通过命令行设置激活的 profile 文件,如下所示:
1 | % java -jar taco-cloud.jar --spring.profiles.active=prod |
请注意 spring.profiles.active 属性名包含的是复数单词 profiles。这意味着可以指定多个活动 profiles 文件。通常,这是一个逗号分隔的列表,当它设置一个环境变量:
1 | % export SPRING_PROFILES_ACTIVE=prod,audit,ha |
但是在 YAML 中,需要像下面这样指定它:
1 | spring: |
同样值得注意的是,如果将 Spring 应用程序部署到 Cloud Foundry 中,一个名为 cloud 的配置文件会自动激活。如果 Cloud Foundry 是生产环境,那么需要确保在 cloud profile 文件中指定了特定于生产环境的属性。
事实证明,配置文件只对在 Spring 应用程序中有条件地设置配置属性有用。让我们看看如何声明特定活动 profile 文件的 bean。
有条件地使用 profile 文件创建 bean
有时候,为不同的配置文件提供一组惟一的 bean 是很有用的。通常,不管哪个 profile 文件是活动的,Java 配置类中声明的任何 bean 都会被创建。但是,假设只有在某个配置文件处于活动状态时才需要创建一些 bean,在这种情况下,@Profile 注解可以将 bean 指定为只适用于给定的 profile 文件。
例如,在 TacoCloudApplication 中声明了一个 CommandLineRunner bean,用于在应用程序启动时加载嵌入式数据库中的成分数据。这对于开发来说很好,但是在生产应用程序中是不必要的(也是不受欢迎的)。为了防止在每次应用程序在生产部署中启动时加载成分数据,可以使用 @Profile 像下面这样注解 CommandLineRunner bean 方法:
1 |
|
或是假设需要在 dev profile 或是 qa profile 被激活时创建 CommandLineRunner,在这种情况下,可以列出需要的 profile:
1 |
|
这样成分数据只会在 dev 或是 qa profile 文件被激活时才会被加载。这就意味着需要在开发环境运行应用程序时,将 dev profile 激活。如果这个 CommandLineRunner bean 总是被创建,除非 prod 配置文件是活动的,那就更方便了。在这种情况下,你可以像这样应用 @Profile:
1 |
|
在这里,感叹号 !否定了配置文件名称。实际上,它声明如果 prod 配置文件不是活动的,就会创建 CommandLineRunner bean。
也可以在整个 @Configuration 注解的类上使用 @Profile。例如,假设要将 CommandLineRunner bean 提取到一个名为 DevelopmentConfig 的单独配置类中。然后你可以用 @Profile 来注解 DevelopmentConfig:
1 |
|
在这里,CommandLineRunner bean(以及在 DevelopmentConfig 中定义的任何其他 bean)仅在 prod 和 qa 配置文件都不活动的情况下才会被创建。
小结
- 可以使用 @ConfigurationProperties 注解 Spring bean,以支持从几个属性源之一注入值。
- 可以在命令行参数、环境变量、JVM 系统属性、属性文件或 YAML 文件等选项中设置配置属性。
- 配置属性可用于覆盖自动配置设置,包括指定数据源 URL 和日志级别的能力。
- Spring profile 文件可以与属性源一起使用,根据活动配置文件有条件地设置配置属性。
第二部分 集成 Spring
第 2 部分的章节介绍了一些有助于将 Spring 应用程序与其他应用程序集成在一起的主题。
第 6 章通过讨论如何在 Spring 中编写 REST api 来扩展第 2 章中开始的关于 Spring MVC 的讨论。我们将了解如何在 Spring MVC 中定义 REST 端点,启用超链接 REST 资源,以及如何使用 Spring Data REST 自动生成基于存储库的 REST 端点。第 7 章切换透视图,以展示 Spring 应用程序如何使用 REST API。在第 8 章中,我们将讨论如何使用异步通信使 Spring 应用程序能够使用 Java 消息服务(JMS)、RabbitMQ 和 Kafka 发送和接收消息。最后,第 9 章讨论了使用 Spring Integration 项目的声明性应用程序集成。我们将介绍实时处理数据、定义集成流以及与外部系统(如电子邮件和文件系统)的集成。
第 6 章 创建 REST 服务
本章内容:
- 在 Spring MVC 中定义 REST 端点
- 启用超链接 REST 资源
- 自动生成基于存储库的 REST 端点
“网络浏览器挂掉了,现在怎么办?”
大约十多年前,我听到有人说,web 浏览器正接近濒死状态,可能会被其他东西取代。但这是怎么回事呢?什么有可能取代几乎无处不在的 web 浏览器呢?如果不使用 web 浏览器,我们将如何消费越来越多的网站和在线服务?这的确是一个疯子的胡言乱语!
快进到今天,很明显,web 浏览器并没有消失,但它不再是上网的主要方式。移动设备、平板电脑、智能手表和语音设备现在都很常见,甚至许多基于浏览器的应用程序实际上都在运行 JavaScript 应用程序,而不是让浏览器成为服务器呈现内容的无声终端。
有了如此多的客户端选项,许多应用程序采用了一种常见的设计:将用户界面推近客户端,而服务器公开一个 API,通过该 API,所有类型的客户端都可以与后端进行交互。
在本章中,将使用 Spring 为 Taco Cloud 应用程序提供 REST API。将使用在第 2 章中所学到的关于 Spring MVC 的知识,使用 Spring MVC 控制器创建 RESTful 端点。还将自动公开第 4 章中定义的 Spring Data 存储库的 REST 端点。最后,我们将研究测试和保护这些端点的方法。
但首先,将从编写一些新的 Spring MVC 控制器开始,这些控制器公开带有 REST 端点的后端功能,以供丰富的 web 前端使用。
编写 RESTful 控制器
当你翻页并阅读本章的介绍时,Taco Cloud 的用户界面已经被重新设计了。一直在做的事情在开始的时候是可以的,但是在美学方面却有欠缺。
图 6.1 只是新的 Taco Cloud 的一个示例,很时髦的,不是吗?

在对 Taco Cloud 外观进行改进的同时,我还决定使用流行的 Angular 框架将前端构建为一个单页应用程序。最终,这个新的浏览器 UI 将取代在第 2 章中创建的服务器渲染页面。但要实现这一点,需要创建一个 REST API,基于 Angular 的 UI 将与之通信,以保存和获取 taco 数据。
用 SPA 还是不用?
在第 2 章中,使用 Spring MVC 开发了一个传统的多页面应用程序(MPA),现在将用一个基于 Angular 的单页面应用程序(SPA)取代它,但并不总是说 SPA 是比 MPA 更好的选择。
由于呈现在很大程度上与 SPA 中的后端处理解耦,因此可以为相同的后端功能开发多个用户界面(例如本机移动应用程序)。它还提供了与其他可以使用 API 的应用程序集成的机会。但并不是所有的应用程序都需要这种灵活性,如果只需要在 web 页面上显示信息,那么 MPA 是一种更简单的设计。
这不是一本关于 Angular 的书,所以这一章的代码主要着重于后端的 Spring 代码。我将展示足够多的 Angular 代码,让你了解客户端是如何工作的。请放心,完整的代码集,包括 Angular 前端,都可以作为可下载代码的一部分,在 https://github.com/habuma/springing-inaction-5-samples 中找到。你可能还会对 Jeremy Wilken(2018 年传)的《Angular 实战》以及 Yakov Fain 和 Anton Moiseev(2018 年出版)合著的《基于 TypeScript 的 Angular 开发(第二版)》感兴趣。
简而言之,Angular 客户端代码将通过 HTTP 请求的方式与本章中创建的 API 进行通信。在第 2 章中,使用 @GetMapping 和 @PostMapping 注解来获取和发送数据到服务器。在定义 REST API 时,这些相同的注释仍然很有用。此外,Spring MVC 还为各种类型的 HTTP 请求支持少量其他注解,如表 6.1 所示。
| 注解 | HTTP 方法 | 典型用法 |
|---|---|---|
| @GetMapping | HTTP GET 请求 | 读取资源数据 |
| @PostMapping | HTTP POST 请求 | 创建资源 |
| @PutMapping | HTTP PUT 请求 | 更新资源 |
| @PatchMapping | HTTP PATCH 请求 | 更新资源 |
| @DeleteMapping | HTTP DELETE 请求 | 删除资源 |
| @RequestMapping | 通用请求处理 |
要查看这些注释的实际效果,将首先创建一个简单的 REST 端点,该端点获取一些最近创建的 taco。
从服务器获取数据
Taco Cloud 最酷的事情之一是它允许 Taco 狂热者设计他们自己的 Taco 作品,并与他们的 Taco 爱好者分享。为此,Taco Cloud 需要能够在单击最新设计链接时显示最近创建的 Taco 的列表。
在 Angular 代码中,我定义了一个 RecentTacosComponent,它将显示最近创建的 tacos。RecentTacosComponent 的完整 TypeScript 代码在下面程序清单中。
1 | import { Component, OnInit, Injectable } from '@angular/core'; |
请注意 ngOnInit() 方法,在该方法中,RecentTacosComponent 使用注入的 Http 模块执行对 http://localhost:8080/design/recent 的 Http GET 请求,期望响应将包含 taco 设计的列表,该列表将放在 recentTacos 模型变量中。视图(在 recents.component.HTML 中)将模型数据以 HTML 的形式呈现在浏览器中。在创建了三个 tacos 之后,最终结果可能类似于图 6.2。

这个版面中缺失的部分是一个端点,它处理 /design/recent 接口的 GET 请求 ,并使用一个最新设计的 taco 列表进行响应。后面将创建一个新的控制器来处理这样的请求,下面的程序清单显示了怎么去做的。
1 | package tacos.web.api; |
你可能认为这个控制器的名字听起来很熟悉。在第 2 章中,创建了一个处理类似类型请求的 DesignTacoController。但是这个控制器是用于多页面 Taco Cloud 应用程序的,正如 @RestController 注解所示,这个新的 DesignTacoController 是一个 REST 控制器。
@RestController 注解有两个用途。首先,它是一个像 @Controller 和 @Service 这样的原型注解,它通过组件扫描来标记一个类。但是与 REST 的讨论最相关的是,@RestController 注解告诉 Spring,控制器中的所有处理程序方法都应该将它们的返回值直接写入响应体,而不是在模型中被带到视图中进行呈现。
或者,可以使用 @Controller 来注解 DesignTacoController,就像使用任何 Spring MVC 控制器一样。但是,还需要使用 @ResponseBody 注解所有处理程序方法,以获得相同的结果。另一个选项是返回一个 ResponseEntity 对象,我们稍后将讨论它。
类级别的 @RequestMapping 注解与 recentTacos() 方法上的 @GetMapping 注解一起工作,以指定 recentTacos() 方法负责处理 /design/recent 接口的 GET 请求(这正是 Angular 代码所需要的)。
注意,@RequestMapping 注解还设置了一个 produces 属性。这指定了 DesignTacoController 中的任何处理程序方法只在请求的 Accept 头包含 “application/json” 时才处理请求。这不仅限制了 API 只生成 JSON 结果,还允许另一个控制器(可能是第 2 章中的 DesignTacoController)处理具有相同路径的请求,只要这些请求不需要 JSON 输出。尽管这将 API 限制为基于 JSON 的,但是欢迎将 produces 设置为多个内容类型的字符串数组。例如,为了允许 XML 输出,可以向 produces 属性添加 “text/html”:
1 |
在程序清单 6.2 中可能注意到的另一件事是,该类是用 @CrossOrigin 注解了的。由于应用程序的 Angular 部分将运行在独立于 API 的主机或端口上(至少目前是这样),web 浏览器将阻止 Angular 客户端使用 API。这个限制可以通过在服务器响应中包含 CORS(跨源资源共享)头来克服。Spring 使得使用 @CrossOrigin 注解应用 CORS 变得很容易。正如这里所应用的,@CrossOrigin 允许来自任何域的客户端使用 API。
recentTacos() 方法中的逻辑相当简单。它构造了一个 PageRequest 对象,该对象指定只想要包含 12 个结果的第一个(第 0 个)页面,结果按照 taco 的创建日期降序排序。简而言之就是你想要一打最新设计的 tacos。PageRequest 被传递到 TacoRepository 的 findAll() 方法的调用中,结果页面的内容被返回给客户机(如程序清单 6.1 所示,它将作为模型数据显示给用户)。
现在,假设需要提供一个端点,该端点通过其 ID 获取单个 taco。通过在处理程序方法的路径中使用占位符变量并接受 path 变量的方法,可以捕获该 ID 并使用它通过存储库查找 taco 对象:
1 |
|
因为控制器的基本路径是 /design,所以这个控制器方法处理 /design/{id} 的 GET 请求,其中路径的 {id} 部分是占位符。请求中的实际值指定给 id 参数,该参数通过 @PathVariable 映射到 {id}占位符。
在 tacoById() 内部,将 id 参数传递给存储库的 findById() 方法来获取 Taco。findById() 返回一个可选的
如果 ID 不匹配任何已知的 taco,则返回 null,然而,这并不理想。通过返回 null,客户端接收到一个空体响应和一个 HTTP 状态码为 200(OK)的响应。客户端会收到一个不能使用的响应,但是状态代码表明一切正常。更好的方法是返回一个带有 HTTP 404(NOT FOUND)状态的响应。
正如它目前所写的,没有简单的方法可以从 tacoById() 返回 404 状态代码。但如果你做一些小的调整,你可以设置适当的状态代码:
1 |
|
现在,tacoById() 不返回 Taco 对象,而是返回一个 ResponseEntity
现在已经开始为 Angular 客户端或任何其他类型的客户端创建 Taco Cloud API 了。出于开发测试的目的,可能还希望使用 curl 或 HTTPie(https://httpie.org/)等命令行实用程序来了解 API。例如,下面的命令行显示了如何使用 curl 获取最近创建的 taco:
1 | $ curl localhost:8080/design/recent |
如果更喜欢 HTTPie,可以用下面这种方式:
1 | $ http :8080/design/recent |
但是,定义返回信息的端点只是开始。如果 API 需要从客户端接收数据呢?让我们看看如何编写处理请求输入的控制器方法。
向服务器发送数据
到目前为止,API 能够返回 12 个最近创建的 tacos。但是这些 tacos 是如何产生的呢?
还没有从第 2 章中删除任何代码,所以仍然拥有原始的 DesignTacoController,它显示一个 taco 设计表单并处理表单提交。这是获得一些测试数据以测试创建的 API 的好方法。但是,如果要将 Taco Cloud 转换为单页面应用程序,则需要创建 Angular 组件和相应的端点来替代第 2 章中的 Taco 设计表单。
已经通过定义一个名为 DesignComponent 的新 Angular 组件(在一个名为 design.component.ts 的文件中)处理了 taco 设计表单的客户端代码。与处理表单提交相关,DesignComponent 有一个 onSubmit() 方法,如下所示:
1 | onSubmit() { |
在 onSubmit() 方法中,调用 HttpClient 的 post() 方法,而不是 get()。这意味着不是从 API 获取数据,而是将数据发送到 API。具体地说,使用 HTTP POST 请求将模型变量中包含的 taco 设计发送到 API 的 /design 端点。
这意味着需要在 DesignTacoController 中编写一个方法来处理该请求并保存设计。通过将以下 postTaco() 方法添加到 DesignTacoController 中,可以让控制器做到这一点:
1 |
|
因为 postTaco() 将处理 HTTP POST 请求,所以它使用 @PostMapping 而不是 @GetMapping 进行注解。这里没有指定 path 属性,所以 postTaco() 方法将处理 DesignTacoController 上的类级 @RequestMapping 中指定的 /design 请求。
但是,确实设置了 consumer 属性。consumer 属性用于处理输入,那么 produces 就用于处理输出。这里使用 consumer 属性,表示该方法只处理 Content-type 与 application/json 匹配的请求。
方法的 Taco 参数添加了 @RequestBody 注解,以指示请求体应该转换为 Taco 对象并绑定到参数。这个注解很重要 —— 如果没有它,Spring MVC 会假设将请求参数(查询参数或表单参数)绑定到 Taco 对象。但是 @RequestBody 注解确保将请求体中的 JSON 绑定到 Taco 对象。
postTaco() 接收到 Taco 对象后,将其传递给 TacoRepository 上的 save() 方法。
这里在 postTaco() 方法上使用了 @ResponseStatus(HttpStatus.CREATED) 注解。在正常情况下(当没有抛出异常时),所有响应的 HTTP 状态码为 200(OK),表示请求成功。尽管 HTTP 200 响应总是好的,但它并不总是具有足够的描述性。对于 POST 请求,HTTP 状态 201(CREATED)更具描述性,它告诉客户机,请求不仅成功了,而且还创建了一个资源。在适当的地方使用 @ResponseStatus 将最具描述性和最准确的 HTTP 状态代码传递给客户端总是一个好想法。
虽然已经使用 @PostMapping 创建了一个新的 Taco 资源,但是 POST 请求也可以用于更新资源。即便如此,POST 请求通常用于创建资源,PUT 和 PATCH 请求用于更新资源。让我们看看如何使用 @PutMapping 和 @PatchMapping 更新数据。
更新服务器上的资源
在编写任何处理 HTTP PUT 或 PATCH 命令的控制器代码之前,应该花点时间考虑一下这个问题:为什么有两种不同的 HTTP 方法来更新资源呢?
虽然 PUT 经常用于更新资源数据,但它实际上是 GET 语义的对立面。GET 请求用于将数据从服务器传输到客户机,而 PUT 请求用于将数据从客户机发送到服务器。
从这个意义上说,PUT 实际上是用于执行大规模替换操作,而不是更新操作。相反,HTTP PATCH 的目的是执行补丁或部分更新资源数据。
例如,假设希望能够更改订单上的地址,我们可以通过 REST API 实现这一点,可以用以下这种方式处理 PUT 请求:
1 |
|
可能行得通,但它要求客户端在 PUT 请求中提交完整的订单数据。从语义上讲,PUT 的意思是“把这个数据放到这个 URL 上”,本质上是替换任何已经存在的数据。如果订单的任何属性被省略,该属性的值将被 null 覆盖。甚至订单中的 taco 也需要与订单数据一起设置,否则它们将从订单中删除。
如果 PUT 完全替换了资源数据,那么应该如何处理只进行部分更新的请求?这就是 HTTP PATCH 请求和 Spring 的 @PatchMapping 的好处。可以这样写一个控制器方法来处理一个订单的 PATCH 请求:
1 |
|
这里要注意的第一件事是,patchOrder() 方法是用 @PatchMapping 而不是 @PutMapping 来注解的,这表明它应该处理 HTTP PATCH 请求而不是 PUT 请求。
但是 patchOrder() 方法比 putOrder() 方法更复杂一些。这是因为 Spring MVC 的映射注解(包括 @PatchMapping 和 @PutMapping)只指定了方法应该处理哪些类型的请求。这些注解没有规定如何处理请求。尽管 PATCH 在语义上暗示了部分更新,但是可以在处理程序方法中编写实际执行这种更新的代码。
对于 putOrder() 方法,接受订单的完整数据并保存它,这符合 HTTP PUT 的语义。但是为了使 patchMapping() 坚持 HTTP PATCH 的语义,该方法的主体需要更多语句。它不是用发送进来的新数据完全替换订单,而是检查传入订单对象的每个字段,并将任何非空值应用于现有订单。这种方法允许客户机只发送应该更改的属性,并允许服务器为客户机未指定的任何属性保留现有数据。
使用 PATCH 的方法不止一种
PATCH 方式应用于 patchOrder() 方法时,有两个限制:
如果传递的是 null 值,意味着没有变化,那么客户端如何指示字段应该设置为 null?
没有办法从一个集合中移除或添加一个子集。如果客户端想要从集合中添加或删除一条数据,它必须发送完整的修改后的集合。
对于应该如何处理 PATCH 请求或传入的数据应该是什么样子,确实没有硬性规定。客户端可以发送应用于特定 PATCH 请求的描述,这个描述包含着需要被应用于数据的更改,而不是发送实际的域数据。当然,必须编写请求处理程序来处理 PATCH 指令,而不是域数据。
在 @PutMapping 和 @PatchMapping 中,请注意请求路径引用了将要更改的资源。这与 @GetMappingannotated 方法处理路径的方式相同。
现在已经了解了如何使用 @GetMapping 和 @PostMapping 来获取和发布资源。已经看到了使用 @PutMapping 和 @PatchMapping 更新资源的两种不同方法,剩下的工作就是处理删除资源的请求。
从服务器删除数据
有时数据根本就不再需要了。在这些情况下,客户端需要发起 HTTP DELETE 请求删除资源。
Spring MVC 的 @DeleteMapping 可以方便地声明处理 DELETE 请求的方法。例如,假设需要 API 允许删除订单资源,下面的控制器方法应该可以做到这一点:
1 |
|
至此,另一个映射注解的思想对你来说应该已经过时了。你已经看到了 @GetMapping、@PostMapping、@PutMapping 和 @PatchMapping —— 每一个都指定了一个方法应该处理对应的 HTTP 方法的请求。@DeleteMapping 用于 deleteOrder() 方法负责处理 /orders/{orderId} 的删除请求。
该方法中的代码实际用于执行删除订单操作。在本例中,它接受作为 URL 中的路径变量提供的订单 ID,并将其传递给存储库的 deleteById() 方法。如果调用该方法时订单存在,则将删除它。如果订单不存在,将抛出一个 EmptyResultDataAccessException 异常。
我选择捕获 EmptyResultDataAccessException 而不做任何事情。我的想法是,如果试图删除一个不存在的资源,其结果与在删除之前它确实存在的结果是一样的,也就是说,资源将不存在。它以前是否存在无关紧要。或者,我也可以编写 deleteOrder() 来返回一个 ResponseEntity,将 body 设置为 null,将 HTTP 状态代码设置为 NOT FOUND。
在 deleteOrder() 方法中需要注意的惟一一点是,它使用 @ResponseStatus 进行了注解,以确保响应的 HTTP 状态是 204(NO CONTENT)。对于不再存在的资源,不需要将任何资源数据发送回客户机,因此对删除请求的响应通常没有正文,因此应该发送一个 HTTP 状态代码,让客户机知道不需要任何内容。
Taco Cloud API 已经开始成形了,客户端代码现在可以轻松地使用这个 API 来显示配料、接受订单和显示最近创建的 tacos。但是还可以做一些事情来让客户端更容易地使用这个 API。接下来,让我们看看如何将超媒体添加到 Taco Cloud API 中。
启用超媒体
到目前为止,创建的 API 是相当基本的,但是只要使用它的客户机知道 API 的 URL 模式,它就可以工作。例如,客户端可能被硬编码,知道它可以向 /design/recent 接口发出 GET 请求来获得最近创建的 tacos 列表。同样地,它可能是硬编码的,以知道它可以将该列表中的任何 taco 的 ID 附加到 /design 接口中,以获得特定 taco 资源的 URL。
使用硬编码的 URL 模式和字符串操作在 API 客户机代码中很常见。但是请想象一下,如果 API 的 URL 模式改变了,会发生什么。硬编码的客户端代码相对于 API 已经过时了,因此会被破坏。硬编码 API url 并在其上使用字符串操作会使客户端代码变得兼容性弱。
超媒体作为应用程序状态的引擎(HATEOAS),是一种创建自描述 API 的方法,其中从 API 返回的资源包含到相关资源的链接。这使客户机能够在对 API 的 url 了解最少的情况下引导 API。相反,它理解 API 提供的资源之间的关系,并在遍历这些关系时使用对这些关系的理解来发现 API 的 url。
例如,假设一个客户端请求一个最近设计的 tacos 列表。在它的原始形式,没有超链接,最近的 tacos 列表将在客户端以 JSON 的形式接收,看起来像这样(为了简洁起见,除了列表中的第一个taco 外,其他都被剪掉了):
1 | [ |
如果客户端希望在 taco 本身上获取或执行其他 HTTP 操作,则需要(通过硬编码)知道可以将 id 属性的值附加到路径为 /design 的 URL。同样,如果它希望对其中一个成分执行 HTTP 操作,它需要知道它可以将成分的 id 附加到路径为 /ingredients 的 URL。在这两种情况下,都需要在路径前面加上 http:// 或 https:// 和 API 的主机名。
相反,如果使用超媒体启用了 API,则该 API 将描述自己的 url,从而使客户端无需进行硬编码。如果嵌入了超链接,那么最近创建的 tacos 列表可能与下面的列表类似。
1 | { |
这种特殊风格的 HATEOAS 被称为 HAL(超文本应用语言)这是一种简单且常用的格式,用于在 JSON 响应中嵌入超链接。
虽然这个列表不像以前那样简洁,但它确实提供了一些有用的信息。这个新的 tacos 列表中的每个元素都包含一个名为 _links 的属性,该属性包含用于客户端引导的 API 超链接。在本例中,tacos 和 ingredients 都有引用这些资源的自链接,整个列表都有一个引用自身的 recents 链接。
如果客户端应用程序需要对列表中的 taco 执行 HTTP 请求,则不需要了解 taco 资源的 URL 是什么样子的。相反,它知道请求自链接,该链接映射到 http://localhost:8080/design/4。如果客户想要处理特定的成分,它只需要遵循该成分的自链接。
Spring HATEOAS 项目为 Spring 提供了超链接支持。它提供了一组类和资源汇编器,可用于在从 Spring MVC 控制器返回资源之前向资源添加链接。
要在 Taco Cloud API 中启用超媒体,需要将 Spring HATEOAS starter 依赖项添加到构建中:
1 | <dependency> |
这个启动程序不仅将 Spring HATEOAS 添加到项目的类路径中,还提供了自动配置来启用 Spring HATEOAS。需要做的就是重新设计控制器以返回资源类型,而不是域类型。首先,将超级媒体链接添加到 /design/recent 的 GET 请求中,用于返回的最近的 tacos 列表。
添加超链接
Spring HATEOAS 提供了两种表示超链接资源的主要类型:Resource 和 Resources。Resource 类型表示单个资源,而 Resources 是资源的集合。这两种类型都能够携带其他资源的链接。当从 Spring MVC REST 控制器方法返回时,它们携带的链接将包含在客户端接收到的 JSON(或 XML)中。
要将超链接添加到最近创建的 tacos 列表,需要重新访问程序清单 6.2 中显示的 recentTacos() 方法。最初的实现返回了一个 List
1 |
|
在这个新版本的 recentTacos() 中,不再直接返回 tacos 列表。而是使用 Resources.wrap() 将 tacos 列表包装为 Resources<Resource
1 | "_links": { |
这是一个好的开始,但你仍有一些工作要做。此时,添加的惟一链接就是整个列表;没有链接添加到 taco 资源本身或每个 taco 的成分,很快就会加上的。但首先,需要处理为 recents 链接提供的硬编码 URL。
像这样硬编码一个 URL 是非常糟糕的主意。除非 Taco Cloud 仅限于在自己的开发机器上运行应用程序,否则需要一种方法来避免在 URL 中硬编码 localhost:8080。幸运的是,Spring HATEOAS 以链接构建器的形式提供了帮助。
Spring HATEOAS 链接生成器中最有用的是 ControllerLinkBuilder。这个链接生成器非常聪明,无需硬编码就可以知道主机名是什么。它还提供了一个方便的连贯的 API,帮助你构建相对于任何控制器的基本 URL 的链接。
使用 ControllerLinkBuilder,可以重写硬编码的链接在 recentTacos() 中创建的 Link,如下所示:
1 | Resources<Resource<Taco>> recentResources = Resources.wrap(tacos); |
不仅不再需要硬编码主机名,还不必指定 /design 路径。相反,需要一个指向 DesignTacoController 的链接,它的基本路径是 /design。ControllerLinkBuilder 使用控制器的基本路径作为正在创建的链接对象的基础。
接下来是对任何 Spring 项目中我最喜欢的方法之一的调用:slash()。我喜欢这个方法因为它简洁地描述了它要做的事情。它确实在 URL 后面附加了一个斜杠 / 和给定的值,因此,URL 的路径是 /design/recent。
最后,为链接指定一个关系名。在本例中,关系被命名为 recents。
尽管我非常喜欢 slash() 方法,ControllerLinkBuilder 有另一个方法可以帮助消除与链接 url 相关的硬编码。可以通过给予它在控制器上的方法来调用 linkTo(),而不是调用 slash(),并让 ControllerLinkBuilder 从控制器基础路径和方法映射路径中派生出基础 URL。下面的代码以这种方式使用了 linkTo() 方法:
1 | Resources<Resource<Taco>> recentResources = Resources.wrap(tacos); |
这里我决定静态地引用 linkTo() 和 methodOn() 方法(都来自 ControllerLinkBuilder),以使代码更易于阅读。methodOn() 方法获取控制器类并允许调用 recentTacos() 方法,该方法被 ControllerLinkBuilder 拦截,不仅用于确定控制器的基本路径,还用于确定映射到 recentTacos() 的路径。现在,整个 URL 都是从控制器的映射中派生出来的,而且绝对没有硬编码的部分,非常好~
创建资源装配器
现在需要向列表中包含的 taco 资源添加链接。一种选择是循环遍历 Resources 对象中携带的每个 Resource
我们需要一个不同的策略。
将定义一个实用工具类,将 taco 对象转换为新的 TacoResource 对象,而不是让 Resources.wrap() 为列表中的每个 taco 创建一个资源对象。TacoResource 对象看起来很像 Taco,但是它也能够携带链接。下面程序清单显示了 TacoResource 的样子。
1 | package tacos.web.api; |
在很多方面,TacoResource 与 Taco 域类型并没有太大的不同。它们都有 name、createAt 和 ingredients 属性。但是 TacoResource 扩展了 ResourceSupport 以继承链接对象列表和管理链接列表的方法。
另外,TacoResource 不包含 Taco 的 id 属性。这是因为不需要在 API 中公开任何特定于数据库的 id。从 API 客户机的角度来看,资源的自链接将作为资源的标识符。
注意:
域和资源:分开还是放一起?一些 Spring 开发人员可能会选择通过扩展他们的域类型 ResourceSupport,来将他们的域类型和资源类型组合成单个类型,正确的方法没有对错之分。我选择创建一个单独的资源类型,这样 Taco 就不会在不需要链接的情况下不必要地与资源链接混杂在一起。另外,通过创建一个单独的资源类型,我可以很容易地去掉 id 属性,这样就不会在 API 中暴露它。
TacoResource 只有一个构造函数,它接受一个 Taco 并将相关属性从 Taco 复制到自己的属性。这使得将单个 Taco 对象转换为 TacoResource 变得很容易。但是,如果到此为止,仍然需要循环才能将 Taco 对象列表转换为 Resources
为了帮助将 Taco 对象转换为 TacoResource 对象,还需要创建一个资源装配器,如下程序清单所示。
1 | package tacos.web.api; |
TacoResourceAssembler 有一个默认构造函数,它通知超类(ResourceAssemblySupport),在创建 TacoResource 时,它将使用 DesignTacoController 来确定它创建的链接中的任何 url 的基本路径。
重写 instantiateResource() 方法来实例化给定 Taco 的 TacoResource。如果 TacoResource 有一个默认的构造函数,那么这个方法是可选的。但是,在本例中,TacoResource 需要使用 Taco 进行构造,因此需要覆盖它。
最后,toResource() 方法是继承 ResourceAssemblySupport 时唯一严格要求的方法。这里,它从 Taco 创建一个 TacoResource 对象,并自动给它一个自链接,该链接的 URL 来自 Taco 对象的 id 属性。
从表面上看,toResource() 似乎具有与 instantiateResource() 类似的用途,但它们的用途略有不同。虽然 instantiateResource() 仅用于实例化资源对象,但 toResource() 不仅用于创建资源对象,还用于用链接填充它。在背后,toResource() 将调用 instantiateResource()。
现在调整 recentTacos() 方法来使用 TacoResourceAssembler:
1 |
|
recentTacos() 现在不是返回一个 Resources<Resource
通过 TacoResource 列表,可以创建一个 Resources
此时,对 /design/recent 接口的 GET 请求将生成一个 taco 列表,其中每个 taco 都有一个自链接和一个 recents 链接,但这些成分之间仍然没有联系。为了解决这个问题,你需要为原料创建一个新的资源装配器:
1 | package tacos.web.api; |
如你所见,IngredientResourceAssembler 很像 TacoResourceAssembler,但它使用的是 Ingredient 和 IngredientResource 对象,而不是 Taco 和 TacoResource 对象。
说到 IngredientResource,它是这样的:
1 | package tacos.web.api; |
与 TacoResource 一样,IngredientResource 继承了 ResourceSupport 并将相关属性从域类型复制到它自己的属性集中(不包括 id 属性)。
剩下要做的就是对 TacoResource 做一些轻微的修改,这样它就会携带一个 IngredientResource 对象,而不是 Ingredient 对象:
1 | package tacos.web.api; |
这个新版本的 TacoResource 创建了一个 IngredientResourceAssembly 的静态实例,并使用它的 toResource() 方法将给定 Taco 对象的 Ingredient 列表转换为 IngredientResouce 列表。
最近的 tacos 列表现在完全嵌套了超链接,不仅是为它自己(recents 链接),而且为它所有的 tacos 数据和那些taco 的 ingredient 数据。响应应该类似于程序清单 6.3 中的 JSON。你可以在这里停下来,然后继续下一个话题。但首先我要解决程序清单 6.3 中一些令人困扰的问题。
嵌套命名关系
如果仔细看看程序清单 6.3,会发现顶级元素像这样:
1 | { |
最值得注意的是 tacoResourceList 这个名称,它源于 List
@Relation 注解可以帮助打破 JSON 字段名与 Java 中定义的资源类型类名之间的耦合。通过在 TacoResource 上使用 @Relationip 注解,可以指定 Spring HATEOAS 应该如何在 JSON 结果中字段的命名:
1 |
|
在这里,已经指定当资源对象中使用 TacoResource 对象列表时,应该将其命名为 tacos。虽然在我们的 API 中没有使用它,但是一个 TacoResource 对象应该在 JSON 中被称为 taco。
因此,从 /design/recent 返回的 JSON 现在看起来是这样的(无论在 TacoResource 上执行或不执行什么重构):
1 | { |
Spring HATEOAS 使向 API 添加链接变得非常简单明了。尽管如此,它确实添加了几行不需要的代码。因此,一些开发人员可能会选择不在他们的 API 中使用 HATEOAS,即使这意味着如果 API 的 URL 模式发生变化,客户端代码可能会被破坏。
如果在存储库中使用 Spring Data,可能会有一个双赢的方案。让我们看看 Spring Data REST 如何根据第 3 章中使用 Spring Data 创建的数据存储库自动创建 API。
启用以数据为中心的服务
正如在第 3 章中看到的,Spring Data 拥有一种特殊的魔力,它根据在代码中定义的接口自动创建存储库的实现。但是 Spring Data 还有另一个技巧,可以为应用程序定义 API。
Spring Data REST 是 Spring Data 家族中的另一个成员,它为 Spring Data 创建的存储库自动创建REST API。只需将 Spring Data REST 添加到构建中,就可以获得一个 API,其中包含所定义的每个存储库接口的操作。
要开始使用 Spring Data REST,需要在构建中添加以下依赖项:
1 | <dependency> |
信不信由你,这就是在一个已经将 Spring Data 用于自动存储库的项目中公开 REST API 所需要的全部内容。通过在构建中简单地使用 Spring Data REST starter,应用程序可以自动配置,从而为 Spring Data 创建的任何存储库(包括 Spring Data JPA、Spring Data Mongo 等)自动创建 REST API。
Spring Data REST 创建的 REST 端点至少与自己创建的端点一样好(甚至可能更好)。因此,在这一点上,可以做一些拆卸工作,并在继续之前删除到目前为止创建的任何 @RestController 注解的类。
要尝试 Spring Data REST 提供的端点,可以启动应用程序并开始查看一些 url。基于已经为 Taco Cloud 定义的存储库集,应该能够执行针对 Taco、Ingredient、Order 和 User 的 GET 请求。
例如,可以通过向 /ingredients 接口发出 GET 请求来获得所有 Ingredient 的列表。使用 curl,可能会得到这样的结果(经过删节,只显示第一个 Ingredient):
1 | $ curl localhost:8080/ingredients |
哇!通过向构建中添加一个依赖项,不仅获得了 Ingredient 的端点,而且返回的资源也包含超链接!假装是这个 API 的客户端,也可以使用 curl 来跟踪特定入口的自链接:
1 | $ curl localhost:8080/ingredients/FLTO |
为了避免过于分散注意力,在本书中我们不会浪费太多时间来深入研究 Spring Data REST 创建的每个端点和选项。但是应该知道,它还支持其创建的端点的 POST、PUT 和 DELETE 方法。没错:可以通过向 /ingredients 接口发送 POST 请求创建一个新的 Ingredient,然后通过向 /indegredient/FLTO 接口发送 DELETE 请求来从菜单上移除面粉玉米饼。
可能想要做的一件事是为 API 设置一个基本路径,这样它的端点是不同的,并且不会与编写的任何控制器发生冲突。(事实上,如果不删除先前创建的 IngredientController,它将干扰 Spring Data REST 提供的 /ingredients 端点。)要调整 API 的基本路径,请设置 spring.data.rest 基本路径属性:
1 | spring: |
这将设置 Spring Data REST 端点的基本路径为 /api。因此,Ingredient 端点现在是 /api/ingredients。现在,通过请求一个 tacos 列表来使用这个新的基本路径:
1 | $ curl http://localhost:8080/api/tacos |
噢?这并没有达到预期的效果。有一个 Ingredient 实体和一个 IngredintRepository 接口,其中 Spring Data REST 暴露 /api/ingredients 端点。因此,如果有一个 Taco 实体和一个 TacoRepository 接口,为什么 Spring Data REST 不能提供 /api/tacos 端点呢?
调整资源路径和关系名称
实际上,Spring Data REST 提供了处理 tacos 的端点。但是,尽管 Spring Data REST 非常智能,但它在暴露 tacos 端点方面的表现却稍微逊色一些。
在为 Spring Data 存储库创建端点时,Spring Data REST 尝试使关联多元化的实体类。对于 Ingredient 实体,端点是 /ingredients。对于 Order 和 User 实体,它是 /orders 和 /users。到目前为止,一切顺利。
但有时,比如 “taco”,它会在一个字母上出错,这样复数形式就不太正确了。事实证明,Spring Data REST 将复数形式 “taco” 表示为 “tacoes”,因此,要想对 tacos 发出请求,你必须请求 /api/tacoes:
1 | % curl localhost:8080/api/tacoes |
你可能想知道我怎么知道 “taco” 会被误拼成 “tacoes”。事实证明,Spring Data REST 还公开了一个 home 资源,其中包含所有公开端点的链接。只需向 API 基础路径发出 GET 请求即可获得:
1 | $ curl localhost:8080/api |
可以看到,home 资源显示了所有实体的链接。除了 tacoes 链接之外,一切看起来都很好,其中关系名称和 URL 都有 “taco” 的单数复数形式。
好消息是,不必接受 Spring Data REST 的这个小怪癖。通过向 Taco 类添加一个简单的注解,可以调整关系名称和路径:
1 |
|
@RestResource 注解让你可以给定任何你想要的的名称和路径的关系,在这个例子中,把它们都设定为了 “tacos”。现在当请求 home 资源的时候,将会看到 tacos 链接正确的复数形式:
1 | "tacos": { |
这还可以对端点的路径进行排序,这样就可以针对 /api/tacos 接口发起请求来使用 taco 资源了。
说到排序,让我们看看如何对 Spring Data REST 端点的结果进行排序。
分页和排序
你可能注意到了在 home 资源的链接中,全部都有 page、size 和 sort 参数。默认情况下,像是对 /api/tacos 这种集合资源请求的接口来说,将会从第一页返回每页 20 个数据项。但是可以根据请求的要求,通过指定特定的 page 和 size 参数来调整页面大小和哪一页。
举个例子,要请求 tacos 的页面大小为 5 的第一页,可以发起以下 GET 请求(使用 curl):
1 | $ curl "localhost:8080/api/tacos?size=5" |
假设有多于 5 条 tacos 数据,可以通过添加 page 参数请求 tacos 数据的第二页:
1 | curl "localhost:8080/api/tacos?size5&page=1" |
注意 page 参数是从 0 开始的,意思是请求第 1 页实际上是请求的第 2 页。(还会注意到许多 shell 命令行在请求中的 & 符号上出错,这就是为什么我在前面的 curl 命令中引用整个 URL 的原因。)
可以使用字符串操作将这些参数添加到 URL 中,但是 HATEOAS 提供了响应中第一个、最后一个、下一个和前一个页面的链接:
1 | "_links" : { |
有了这些链接,API 的客户端就不需要跟踪它所在的页面并将参数连接到 URL。相反,它必须知道如何根据这些页面导航链接的名称查找其中一个链接并跟踪它。
sort 参数允许根据实体的任何属性对结果列表进行排序。例如,需要一种方法来获取最近创建的 12个 tacos,以便 UI 显示,可以通过指定以下分页和排序参数组合来做到这一点:
1 | curl "localhost:8080/api/tacos?sort=createAt,desc?page=0&size=12" |
这里,sort 参数指定了应该根据 createdDate 属性进行排序,并按降序排序(以便最新的 tacos 排在前面)。页面和大小参数的指定确定了应该在第一个页面上看到 12 个 tacos。
这正是 UI 为了显示最近创建的 tacos 所需要的。它与在本章前面的 DesignTacoController 中定义的 /design/recent 端点大致相同。
不过有个小问题,需要对 UI 代码进行硬编码,以请求包含这些参数的 tacos 列表。但是,通过使客户端对如何构造 API 请求了解得太多而增加了客户端的一些弱兼容性。如果客户端可以从链接列表中查找 URL,那就太好了。如果 URL 更简洁,就像以前的 /design/recent 端点一样,那就更好了。
添加用户端点
Spring Data REST 非常擅长创建针对 Spring Data 存储库执行 CRUD 操作的端点。但是有时需要脱离默认的 CRUD API,并创建一个能够解决核心问题的端点。
没有任何东西可以阻止你在 @RestController 注解的 bean 中实现任何想要的端点,来补充 Spring Data REST 自动生成的内容。实际上,可以重新使用本章前面的 DesignTacoController,它仍然可以与 Spring Data REST 提供的端点一起工作。
但是,当你编写自己的 API 控制器时,它们的端点似乎以以下两种方式与 Spring Data REST 端点分离:
- 自己的控制器端点没有映射到 Spring Data REST 的基本路径下。可以强制它们的映射以任何想要的基本路径作为前缀,包括 Spring Data REST 基本路径,但是如果基本路径要更改,需要编辑控制器的映射来匹配。
- 在自己的控制器中定义的任何端点都不会自动作为超链接包含在 Spring Data REST 端点返回的资源中。这意味着客户端将无法发现具有关系名称的自定义端点。
让我们首先解决关于基本路径的问题。Spring Data REST 包括 @RepositoryRestController,这是一个用于控制器类的新注解,其映射应该采用与为 Spring Data REST 端点配置的基本路径相同的基本路径。简单地说,@RepositoryRestController 注解的控制器中的所有映射的路径都将以 spring.data.rest.base-path 的值为前缀(已配置为 /api)。
将创建一个只包含 recentTacos() 方法的新控制器,而不是重新启用 DesignTacoController,它有几个不需要的处理程序方法。下一个程序清单中的 RecentTacosController 使用 @RepositoryRestController 进行注解,以采用 Spring Data REST 的基本路径进行其请求映射。
1 | package tacos.web.api; |
尽管 @GetMapping 映射到路径 /tacos/recent,但是类级别的 @RepositoryRestController 注解将确保它以 Spring Data REST 的基本路径作为前缀。正如所配置的,recentTacos() 方法将处理 /api/tacos/recent 的 GET 请求。
需要注意的一件重要事情是,尽管 @RepositoryRestController 的名称与 @RestController 类似,但它的语义与 @RestController 不同。具体来说,它不确保从处理程序方法返回的值被自动写入响应体。因此,需要使用 @ResponseBody 对方法进行注解,或者返回一个包装响应数据的 ResponseEntity。
使用 RecentTacosController,对 /api/tacos/recent 的请求将返回最多 15 个最近创建的 tacos,而不需要在 URL 中对参数进行分页和排序。但是,当请求 /api/tacos 时,它仍然不会出现在超链接列表中。让我们解决这个问题。
向 Spring Data 端点添加用户超链接
如果最近的 tacos 端点不在 /api/tacos 返回的超链接中,客户端如何知道如何获取最近的 tacos?它要么猜测,要么使用分页和排序参数。无论哪种方式,它都将在客户端代码中硬编码,这并不理想。
不过,通过声明资源处理器 bean,可以将链接添加到 Spring Data REST 自动包含的链接列表中。Spring Data HATEOAS 提供了 ResourceProcessor,这是一个在通过 API 返回资源之前操作资源的接口。出于需要自动包含链接列表的目的,需要对 ResourceProcessor 进行实现,该实现将一个最近链接添加到类型为 PagedResources<Resource /api/tacos 端点返回的类型)。下一个程序清单显示了定义这样一个 ResourceProcessor 的 bean 方法声明。程序清单 6.8 向 Spring Data REST 端点添加用户链接
1 |
|
程序清单 6.8 中显示的 ResourceProcessor 被定义为一个匿名内部类,并声明为一个将在 Spring 应用程序上下文中创建的 bean。Spring HATEOAS 将自动发现这个 bean(以及 ResourceProcessor 类型的任何其他 bean),并将它们应用于适当的资源。在这种情况下,如果从控制器返回 PagedResources<Resource/api/tacos 请求的响应。
小结
- Spring MVC 可以创建端点,控制器遵循与以浏览器为目标的控制器相同的编程模型。
- 控制器处理程序方法可以使用 @ResponseBody 进行注解,也可以返回 ResponseEntity 对象,从而绕过模型,直接将数据写入响应体。
- @RestController 注解简化了 REST 控制器,无需在处理程序方法上使用 @ResponseBody。
- Spring HATEOAS 启用了能够从 Spring MVC 控制器返回的资源的超链接。
- Spring Data 存储仓库可以使用 Spring Data REST 自动公开为 REST API。
第 7 章 调用 REST 服务
本章内容:
- 使用 RestTemplate 调用 REST API
- 使用 Traverson 引导超媒体 API
你是否曾经去看电影,当电影开始的时候,你发现只有你一个人在电影院?从本质上说,这是一次私人观影的美妙经历。你可以选择任何你想要的座位,和屏幕上的人物交谈,甚至可以打开你的手机发推特谈论它,而不会有人因为破坏了他们的观影体验而生气。最棒的是,也没有其他人会为你毁了这部电影!
这种情况在我身上并不常见。但当它出现的时候,我在想如果我没有出现会发生什么。他们还会放映这部电影吗?英雄还会拯救世界吗?电影结束后,工作人员还会打扫影院吗?
没有观众的电影就像没有客户端的 API。它已经准备好接受和提供数据了,但是如果 API 从未被调用过,那么它真的是一个 API 吗?就像薛定谔的猫一样,在我们向它发出请求之前,我们无法知道 API 是活动的还是返回 HTTP 404 响应。
在前一章中,重点介绍了如何定义 REST 端点,以供应用程序外部的一些客户端使用。尽管开发这样一个 API 的驱动力是一个用作 Taco Cloud 网站的单页面 Angular 应用程序,但事实是客户端可以是任何语言的任何应用程序 —— 甚至是另一个 Java 应用程序。
Spring 应用程序既提供 API,又向另一个应用程序的 API 发出请求,这种情况并不少见。事实上,在微服务的世界里,这正变得越来越普遍。因此,花点时间看看如何使用 Spring 与 REST API 交互是值得的。
Spring 应用程序可以通过以下方式使用 REST API:
- RestTemplate —— 一个由 Spring 核心框架提供的简单、同步 REST 客户端。
- Traverson —— 可感知超链接的同步 REST 客户端,由 Spring HATEOAS 提供,灵感来自同名的 JavaScript 库。
- WebClient —— 一个在 Spring 5 中引入的响应式、异步 REST 客户端。
我将推迟到第 11 章讨论 Spring 的响应式 web 框架的时候再讨论 WebClient。现在,我们将主要关注另外两个 REST 客户端,首先是 RestTemplate。
使用 RestTemplate 调用 REST 端点
从客户的角度来看,与 REST 资源进行交互需要做很多工作 —— 主要是单调乏味的样板文件。使用低级 HTTP 库,客户端需要创建一个客户端实例和一个请求对象,执行请求,解释响应,将响应映射到域对象,并处理过程中可能抛出的任何异常。不管发送什么 HTTP 请求,所有这些样板文件都会重复。
为了避免这样的样板代码,Spring 提供了 RestTemplate。正如 JDBCTemplate 处理使用 JDBC 糟糕的那部分一样,RestTemplate 使你不必为调用 REST 资源而做单调的工作。
RestTemplate 提供了 41 个与 REST 资源交互的方法。与其检查它提供的所有方法,不如只考虑 12 个惟一的操作,每个操作都有重载,以形成 41 个方法的完整集合。表 7.1 描述了 12 种操作。
| 方法 | 描述 |
|---|---|
| delete(…) | 对指定 URL 上的资源执行 HTTP DELETE请求 |
| exchange(…) | 对 URL 执行指定的 HTTP 方法,返回一个 ResponseEntity,其中包含从响应体映射的对象 |
| execute(…) | 对 URL 执行指定的 HTTP 方法,返回一个映射到响应体的对象 |
| getForEntity(…) | 发送 HTTP GET 请求,返回一个 ResponseEntity,其中包含从响应体映射的对象 |
| getForObject(…) | 发送 HTTP GET 请求,返回一个映射到响应体的对象 |
| headForHeaders(…) | 发送 HTTP HEAD 请求,返回指定资源 URL 的 HTTP 请求头 |
| optionsForAllow(…) | 发送 HTTP OPTIONS 请求,返回指定 URL 的 Allow 头信息 |
| patchForObject(…) | 发送 HTTP PATCH 请求,返回从响应主体映射的结果对象 |
| postForEntity(…) | 将数据 POST 到一个 URL,返回一个 ResponseEntity,其中包含从响应体映射而来的对象 |
| postForLocation(…) | 将数据 POST 到一个 URL,返回新创建资源的 URL |
| postForObject(…) | 将数据 POST 到一个 URL,返回从响应主体映射的对象 |
| put(…) | 将资源数据 PUT 到指定的URL |
除了 TRACE 之外,RestTemplate 对于每个标准 HTTP 方式至少有一个方法。此外,execute() 和 exchange() 为使用任何 HTTP 方式发送请求提供了低层的通用方法。表 7.1 中的大多数方法都被重载为三种方法形式:
- 一种是接受一个 String 作为 URL 规范,在一个变量参数列表中指定 URL 参数。
- 一种是接受一个 String 作为 URL 规范,其中的 URL 参数在 Map<String, String> 中指定。
- 一种是接受 java.net.URI 作为 URL 规范,不支持参数化 URL。
一旦了解了 RestTemplate 提供的 12 个操作以及每种变体的工作方式,就可以很好地编写调用资源的 REST 客户端了。
要使用 RestTemplate,需要创建一个实例:
1 | RestTemplate rest = new RestTemplate(); |
或是将它声明为一个 bean,在需要它的时候将其注入:
1 |
|
让我们通过查看支持四种主要 HTTP 方法(GET、PUT、DELETE 和 POST)的操作来探寻 RestTemplate 的操作。我们将从 getForObject() 和 getForEntity() —— GET 方法开始。
请求 GET 资源
假设想从 Taco Cloud API 获取一个 Ingredient 数据。假设 API 没有启用 HATEOAS,需要使用 getForObject() 来获取 Ingredient。例如,下面的代码使用 RestTemplate 获取一个 Ingredient 对象的 ID:
1 | public Ingredient getIngredientById(String ingredientId) { |
这里使用的是 getForObject() 变量,它接受一个字符串 URL 并为 URL 变量使用一个变量列表。传递给 getForObject() 的 ingredientId 参数用于填充给定 URL 中的 {id} 占位符。虽然在本例中只有一个 URL 变量,但重要的是要知道变量参数是按给定的顺序分配给占位符的。
getForObject() 的第二个参数是响应应该绑定的类型。在这种情况下,应该将响应数据(可能是 JSON 格式)反序列化为将要返回的 Ingredient 对象。
或者,可以使用映射来指定 URL 变量:
1 | public Ingredient getIngredientById(String ingredientId) { |
在这个例子中,ingredientId 的值被映射到 id 键上,当发出请求时,{id} 占位符被键为 id 的映射条目替换。
使用 URI 参数稍微复杂一些,需要在调用 getForObject() 之前构造一个 URI 对象,它类似于其他两中形式:
1 | public Ingredient getIngredientById(String ingredientId) { |
这里的 URI 对象是根据字符串规范定义的,其占位符是根据映射中的条目填充的,这与前面的 getForObject() 形式非常相似。getForObject() 方法是获取资源的一种有效方法。但是,如果客户端需要的不仅仅是有效负载,可能需要考虑使用 getForEntity()。
getForEntity() 的工作方式与 getForObject() 非常相似,但它返回的不是表示响应有效负载的域对象,而是包装该域对象的 ResponseEntity 对象。ResponseEntity 允许访问附加的响应细节,比如响应头。
例如,假设除了 Ingredient 数据之外,还希望检查响应中的 Date 头信息,有了 getForEntity(),事情就简单多了:
1 | public Ingredient getIngredientById(String ingredientId) { |
getForEntity() 方法使用与 getForObject() 相同的重载参数,因此可以将 URL 变量作为变量列表参数,或者使用 URI 对象调用 getForEntity()。
请求 PUT 资源
对于发送 HTTP PUT 请求,RestTemplate 提供 put() 方法。put() 的所有三个重载方法都接受一个将被序列化并发送到给定 URL 的对象。至于 URL 本身,可以将其指定为 URI 对象或 String。与 getForObject() 和 getForEntity() 类似,URL 变量可以作为变量参数列表或 Map 提供。
假设想要用来自一个新的 Ingredient 对象的数据来替换配料资源。下面的代码应该可以做到这一点:
1 | public void updateIngredient(Ingredient ingredient) { |
这里 URL 以 String 的形式给出,并有一个占位符,该占位符由给定的 Ingredient 对象的 id 属性替换。要发送的数据是 Ingredient 对象本身。put() 方法返回 void,因此不需要处理返回值。
请求 DELETE 资源
假设 Taco Cloud 不再提供一种配料,并希望将其作为一种选项完全删除。要做到这一点,可以从 RestTemplate 中调用 delete() 方法:
1 | public void deleteIngredient(Ingredient ingredient) { |
在本例中,仅将 URL(指定为 String)和 URL 变量值赋给 delete()。但是,与其他 RestTemplate 方法一样,可以将 URL 指定为 URI 对象,或者将 URL 参数指定为 Map。
请求 POST 资源
现在,假设向 Taco Cloud 菜单添加了一种新 Ingredient。向 .../ingredients 端点发起 HTTP POST 请求就能实现添加,这个请求的请求体重需要包含 Ingredient 数据。RestTemplate 有三种发送 POST 请求的方法,每种方法都有相同的重载变量来指定 URL。如果想在 POST 请求后收到新创建的 Ingredient 资源,可以像这样使用 postForObject():
1 | public Ingredient createIngredient(Ingredient ingredient) { |
postForObject() 方法的这种形式采用 String 作为 URL 规范,要发送到服务器的对象以及响应主体应该绑定到的域类型。虽然在本例中没有利用它,但第四个参数可以是 URL 变量值的 Map 或要替换到 URL 中的参数的变量列表。
如果客户对新创建的资源的位置有更多的需求,那么可以调用 postForLocation():
1 | public URI createIngredient(Ingredient ingredient) { |
注意,postForLocation() 的工作方式与 postForObject() 非常相似,只是它返回的是新创建资源的 URI,而不是资源对象本身。返回的 URI 派生自响应的 Location 头信息。如果同时需要位置和响应负载,可以调用 postForEntity():
1 | public Ingredient createIngredient(Ingredient ingredient) { |
虽然 RestTemplate 方法的用途不同,但是它们的使用方式非常相似。这使得你很容易精通 RestTemplate 并在客户端代码中使用它。
另一方面,如果使用的 API 在其响应中包含超链接,那么 RestTemplate 就没有那么有用了。当然可以使用 RestTemplate 获取更详细的资源数据,并处理其中包含的内容和链接,但是这样做并不简单。在使用 RestTemplate 调用超媒体 API 时,与其挣扎,不如将注意力转移到为这类事情创建的客户端库 —— Traverson。
使用 Traverson 引导 REST API
Traverson 附带了 Spring Data HATEOAS,作为在 Spring 应用程序中使用超媒体 API 的开箱即用解决方案。这个基于 Java 的库的灵感来自于同名的类似的 JavaScript 库(https://github.com/traverson)。
你可能已经注意到 Traverson 的名字听起来有点像 “traverse on”,这是描述它用法的好方式。在本节中,将通过遍历关系名称上的 API 来调用 API。
要使用 Traverson,首先需要实例化一个 Traverson 对象和一个 API 的基础 URI:
1 | Traverson traverson = new Traverson(URI.create("http://localhost:8080/api"), MediaType.HAL_JSON); |
这里将 Traverson 指向 Taco Cloud 的基本 URL(在本地运行),这是需要给 Traverson 的唯一 URL。从这里开始,将通过链接关系名称来引导 API。还将指定 API 将生成带有 HAL 风格的超链接的 JSON 响应,以便 Traverson 知道如何解析传入的资源数据。与 RestTemplate 一样,可以选择在使用 Traverson 对象之前实例化它,或者将它声明为一个 bean,以便在需要的地方注入它。
有了 Traverson 对象,可以通过以下链接开始使用 API。例如,假设想检索所有 Ingredient 的列表。从第 6.3.1 节了解到,Ingredient 链接有一个链接到配料资源的 href 属性,需要点击这个链接:
1 | ParameterizedTypeReference<Resources<Ingredient>> ingredientType = new ParameterizedTypeReference<Resources<Ingredient>>() {}; |
通过调用 Traverson 对象上的 follow() 方法,可以引导到链接关系名称为 ingredients 的资源。现在客户端已经引导到 ingredients,需要通过调用 toObject() 来提取该资源的内容。
toObject() 方法要求你告诉它要将数据读入哪种对象。考虑到需要将其作为 Resources
打个比方,假设这不是一个 REST API,而是一个网站的主页。设想这是在浏览器中查看的主页,而不是 REST 客户端代码。在页面上看到一个链接,上面写着 Ingredient,然后点击这个链接。当到达下一页时,将读取该页,这类似于 Traverson 以 Resources
现在让我们考虑一个更有趣的用例,假设想获取最近创建的 tacos,从 home 资源开始,可以引导到最近的 tacos 资源,像这样:
1 | ParameterizeTypeReference<Resources<Taco>> tacoType = new ParameterizedTypeReference<Resources<Taco>>() {}; |
在这里可以点击 Tacos 链接,然后点击 Recents 链接。这会将你带到你所感兴趣的资源,因此使用适当的 ParameterizedTypeReference 调用 toObject() 可以得到想要的结果。调用 follow() 方法可以通过列出跟随的关系名称来简化:
1 | Resources<Taco> tacoRes = traverson.follow("tacos", "recents").toObject(tacoType); |
Traverson 可以轻松地引导启用了 HATEOAS 的 API 并调用其资源。但有一件事它没有提供任何方法来编写或删除这些 API。相比之下,RestTemplate 可以编写和删除资源,但不便于引导 API。
当需要同时引导 API 和更新或删除资源时,需要同时使用 RestTemplate 和 Traverson。Traverson 仍然可以用于引导到将创建新资源的链接。然后可以给 RestTemplate 一个链接来执行 POST、PUT、DELETE 或任何其他 HTTP 请求。
例如,假设想要向 Taco Cloud 菜单添加新的 Ingredient。下面的 addIngredient() 方法将 Traverson 和 RestTemplate 组合起来,向 API POST 一个新 Ingredient:
1 | private Ingredient addIngredient(Ingredient ingredient) { |
在 follow Ingredient 链接之后,通过调用 asLink() 请求链接本身。在该链接中,通过调用 getHref() 请求链接的 URL。有了 URL,就有了在 RestTemplate 实例上调用 postForObject() 并保存新 Ingredient 所需的一切。
小结
客户端可以使用 RestTemplate 针对 REST API 发出 HTTP 请求。
Traverson 可以通过使用在响应中嵌入的超链接来让客户端引导 API。
第 8 章 发送异步消息
本章内容:
- 异步消息
- 使用 JMS、RabbitMQ 和 Kafka 发送消息
- 从 Broker 拉取消息
- 监听消息
现在是星期五下午 4 点 55 分。你还有几分钟就要开始期待已久的假期了。你有足够的时间开车去机场赶飞机。但是在你打包好行李准备出去的时候,你需要确保你的老板和同事们知道你一直在做的工作的状态,这样周一他们可以从你断掉的地方接着做。不幸的是,你的一些同事已经跳过了周末,并且你的老板一直在开会。你该做些什么?
这是最实用的办法就是,向老板和同事发一封简短的电子邮件,详细说明你的进展情况,并承诺寄张明信片,这样做既能传达你的所处的工作做到的地步,又能赶上飞机。你不知道他们在哪里,也不知道他们什么时候会读这封邮件,但你知道他们最终会回到自己的办公桌上读这封邮件。与此同时,你正在去机场的路上。
同步 通信有它的地位,这是我们在 REST 中所看到的。但这并不是开发人员可以使用的惟一应用程序间通信方式。异步消息传递是一种间接地将消息从一个应用程序发送到另一个应用程序而无需等待响应的方式。这种间接方式提供了通信应用程序之间更松散的耦合和更大的可伸缩性。
在本章中,将使用异步消息传递将订单从 Taco Cloud 网站发送到 Taco Cloud 厨房中的一个独立应用程序,在那里将准备 tacos。我们将考虑 Spring 为异步消息传递提供的三个选项:Java 消息服务(JMS)、RabbitMQ 和高级消息队列协议(AMQP)以及 Apache Kafka。除了基本的消息发送和接收之外,我们还将了解 Spring 对消息驱动 POJO 的支持:一种类似于 EJB 的消息驱动 bean(MDB)的消息接收方式。
使用 JMS 发送消息
JMS 是一个 Java 标准,它定义了一个用于使用消息代理的公共 API。自 2001 年首次引入以来,JMS 一直是 Java 中异步消息传递的首选方法。在 JMS 之前,每个消息代理都有一个专用 API,这使得应用程序的消息代码在代理之间的可移植性更差。但是有了 JMS,所有兼容的实现都可以通过公共接口进行处理,这与 JDBC 为关系数据库操作提供公共接口的方式非常相似。
Spring 通过称为 JmsTemplate 的基于模板的抽象来支持 JMS。使用 JmsTemplate,很容易从生产者端跨队列和主题发送消息,并在消费者端接收这些消息。Spring 还支持消息驱动 POJO 的概念:简单的 Java 对象以异步方式对队列或主题上到达的消息做出响应。
我们将探讨 Spring 的 JMS 支持,包括 JmsTemplate 和消息驱动 POJO。但是在可以发送和接收消息之前,需要一个消息代理,它可以在生产者和消费者之间传递这些消息。让我们通过在 Spring 中设置消息代理来开始对 Spring JMS 的探索。
接收 JMS 消息
在消费消息时,可以选择 拉模型(代码请求消息并等待消息到达)或 推模型(消息可用时将消息传递给代码)。
JmsTemplate 提供了几种接收消息的方法,但它们都使用拉模型。调用其中一个方法来请求消息,线程会发生阻塞,直到消息可用为止(可能是立即可用,也可能需要一段时间)。
另一方面,还可以选择使用推模型,在该模型中,定义了一个消息监听器,它在消息可用时被调用。
这两个选项都适用于各种用例。人们普遍认为推模型是最佳选择,因为它不会阻塞线程。但是在某些用例中,如果消息到达得太快,侦听器可能会负担过重。拉模型允许使用者声明他们已经准备好处理新消息。
让我们看看接收消息的两种方式。我们将从 JmsTemplate 提供的拉模型开始。
使用 JmsTemplate 接收
JmsTemplate 提供多个用于拉模式的方法,包括以下这些:
1 | Message receive() throws JmsException; |
可以看到,这 6 个方法是 JmsTemplate 中的 send() 和 convertAndSend() 方法的镜像。receive() 方法接收原始消息,而 receiveAndConvert() 方法使用配置的消息转换器将消息转换为域类型。对于其中的每一个,可以指定 Destination 或包含目的地名称的 String,也可以从缺省目的地获取一条消息。
要查看这些操作,需要编写一些代码来从 tacocloud.order.queue 的目的地拉取 Order。下面的程序清单显示了 OrderReceiver,这是一个使用 JmsTemplate.receive() 接收 Order 数据的服务组件。
1 | package tacos.kitchen.messaging.jms; |
这里,使用了一个 String 来指定从何处拉取订单。receive() 方法返回一个未转换的 Message。但是真正需要的是 Message 中的 Order,所以接下来要做的就是使用注入的消息转换器来转换消息。消息中的类型 ID 属性将指导转换器将其转换为 Order,但是它是作为一个 Object 返回的,在返回它之前需要进行转换。
接收原始 Message 对象在某些需要检查消息属性和标题的情况下可能很有用,但是通常只需要有效载荷。将有效负载转换为域类型需要两个步骤,需要将消息转换器注入组件。当只关心消息的有效负载时,receiveAndConvert() 要简单得多。下面的程序清单显示了如何修改 JmsOrderReceiver 来使用 receiveAndConvert() 而不是 receive()。
1 | package tacos.kitchen.messaging.jms; |
这个新版本的 JmsOrderReceiver 有一个 receieveOrder() 方法,该方法已经减少到只有一行。不再需要注入 MessageConverter,因为所有的消息转换都将在 receiveAndConvert() 中完成。
在继续之前,让我们考虑一下如何在 Taco Cloud 厨房应用程序中使用 receiveOrder()。在 Taco Cloud 的一个厨房里,一名食品加工人员可能会按下一个按钮或采取一些行动,表示他们已经准备好开始制作 tacos 了。
此时,receiveOrder() 将被调用,而对 receive() 或 receiveAndConvert() 的调用将被阻塞。在订单消息准备好之前,不会发生任何其他事情。一旦订单到达,它将从 receiveOrder() 中返回,其信息用于显示订单的详细信息,以便食品加工人员开始工作。这似乎是拉模型的自然选择。
现在,让我们通过声明 JMS 监听器来了解推模型是如何工作的。
声明消息监听器
在拉模型中,接收消息需要显式调用 receive() 或 receiveAndConvert() 方法,与拉模型不同,消息监听器是一个被动组件,在消息到达之前是空闲的。
要创建对 JMS 消息作出响应的消息监听器,只需使用 @JmsListener 对组件中的方法进行注解。下面程序清单显示了一个新的 OrderListener 组件,它被动地监听消息,而不是主动地请求消息。
1 | package tacos.kitchen.messaging.jms.listener; |
receiveOrder() 方法由 JmsListener 注解,以监听 tacocloud.order.queue 目的地的消息。它不处理 JmsTemplate,也不被应用程序代码显式地调用。相反,Spring 中的框架代码将等待消息到达指定的目的地,当消息到达时,receiveOrder() 方法将自动调用,并将消息的 Order 有效负载作为参数。
在许多方面,@JmsListener 注解类似于 Spring MVC 的请求映射注释之一,比如 @GetMapping 或 @PostMapping。在 Spring MVC 中,用一个请求映射方法注解的方法对指定路径的请求做出响应。类似地,使用 @JmsListener 注解的方法对到达目的地的消息做出响应。
消息监听器通常被吹捧为最佳的选择,因为它们不会阻塞,并且能够快速处理多个消息。然而,在 Taco Cloud 应用程序的上下文中,它们可能不是最佳选择。食品加工是系统中的一个重要瓶颈,可能无法在接到订单时快速准备 taco。当一个新的订单显示在屏幕上时,食品加工者可能已经完成了一半的订单。厨房用户界面需要在订单到达时对其进行缓冲,以避免给厨房员工带来过重的负担。
这并不是说消息监听器不好。相反,当消息可以快速处理时,它们是完美的选择。但是,当消息处理程序需要能够根据自己的时间请求更多消息时,JmsTemplate 提供的拉模型似乎更合适。
因为 JMS 是由标准 Java 规范定义的,并且受到许多消息 Broker 的支持,所以它是 Java 中消息传递的常用选择。但是 JMS 有一些缺点,尤其是作为 Java 规范,它的使用仅限于 Java 应用程序。RabbitMQ 和 Kafka 等较新的消息传递选项解决了这些缺点,并且适用于 JVM 之外的其他语言和平台。让我们把 JMS 放在一边,看看如何使用 RabbitMQ 进行 taco 订单消息传递。
使用 JmsTemplate 发送消息
在构建中有 JMS starter 依赖(无论 Artemis 还是 ActiveMQ),Spring Boot 将会自动配置 JmsTemplate,这样就可以将其注入并使用它发送和接收消息了。
JmsTemplate 是 Spring JMS 集成支持的核心。与 Spring 的其他面向模板的组件非常相似,JmsTemplate 消除了大量与 JMS 协同工作所需的样板代码。如果没有 JmsTemplate,将需要编写代码来创建与消息代理的连接和会话,并编写更多代码来处理在发送消息过程中可能抛出的任何异常。JmsTemplate 专注于真正想做的事情:发送消息。
JmsTemplate 有几个发送消息的有用方法,包括:
1 | // 发送原始消息 |
实际上只有两个方法,send() 和 convertAndSend(),每个方法都被重载以支持不同的参数。如果仔细观察,会发现 convertAndSend() 的各种形式可以分为两个子类。在试图理解所有这些方法的作用时,请考虑以下细分:
- send() 方法需要一个 MessageCreator 来制造一个 Message 对象。
- convertAndSend() 方法接受一个 Object,并在后台自动将该 Object 转换为一条 Message。
- 三种 convertAndSend() 方法会自动将一个 Object 转换成一条 Message,但也会接受一个 MessagePostProcessor,以便在 Message 发送前对其进行定制。
此外,这三个方法类别中的每一个都由三个重载的方法组成,它们是通过指定 JMS 目的地(队列或主题)的方式来区分的:
- 一个方法不接受目的地参数,并将消息发送到默认目的地。
- 一个方法接受指定消息目的地的目标对象。
- 一个方法接受一个 String,该 String 通过名称指定消息的目的地。
要使这些方法工作起来,请考虑下面程序清单中的 JmsOrderMessagingService,它使用 send() 方法的最基本形式。
1 | package tacos.messaging; |
sendOrder() 方法调用 jms.send(),传递 MessageCreator 的匿名内部类实现。该实现重写 createMessage() 以从给定的 Order 对象创建新的对象消息。
我认为程序清单 8.1 中的代码虽然简单,但是有点笨拙。声明匿名内部类所涉及的过程会使简单的方法调用变得复杂。意识到 MessageCreator 是一个功能接口,这时可以用一个 lambda 表达式稍微调整一下 sendOrder() 方法:
1 |
|
但是请注意,对 jms.send() 的调用没有指定目的地。为了实现这一点,还必须使用 spring.jms.template.default-destination 属性指定一个默认的目的地名称。例如,可以在 application.yml 中设置属性
1 | spring: |
在许多情况下,使用缺省目的地是最简单的选择。它让你指定一次目的地名称,允许代码只关心发送消息,而不关心消息被发送到哪里。但是,如果需要将消息发送到缺省目的地之外的目的地,则需要将该目的地指定为 send() 方法的参数。
一种方法是传递目标对象作为 send() 的第一个参数。最简单的方法是声明一个 Destination bean,然后将其注入执行消息传递的 bean。例如,下面的 bean 声明了 Taco Cloud 订单队列 Destination:
1 | public Destination orderQueue() { |
需要注意的是,这里使用的 ActiveMQQueue 实际上来自于 Artemis(来自 org.apache.activemq.artemis.jms.client 包)。如果正在使用 ActiveMQ(而不是 Artemis),那么还有一个名为 ActiveMQQueue 的类(来自 org.apache.activemq.command 包)。
如果这个 Destination bean 被注入到 JmsOrderMessagingService 中,那么可以在调用 send() 时使用它来指定目的地:
1 | private Destination orderQueue; |
使用类似这样的 Destination 对象指定目的地,使你有机会配置 Destination,而不仅仅是目的地的名称。但是在实践中,几乎只指定了目的地名称,将名称作为 send() 的第一个参数通常更简单:
1 |
|
虽然 send() 方法并不是特别难以使用(特别是当 MessageCreator 以 lambda 形式给出时),但是提供 MessageCreator 还是会增加一些复杂性。如果只需要指定要发送的对象(以及可选的目的地),不是会更简单吗?这简要地描述了 convertAndSend() 的工作方式,让我们来看看。
在发送前转换消息
JmsTemplates 的 convertAndSend() 方法不需要提供 MessageCreator,从而简化了消息发布。相反,将要直接发送的对象传递给 convertAndSend(),在发送之前会将该对象转换为消息。
例如,sendOrder() 的以下重新实现使用 convertAndSend() 将 Order 发送到指定的目的地:
1 |
|
与 send() 方法一样,convertAndSend() 将接受 Destination 或 String 值来指定目的地,或者可以完全忽略目的地来将消息发送到默认目的地。
无论选择哪种形式的 convertAndSend(),传递给 convertAndSend() 的 Order 都会在发送之前转换为消息。实际上,这是通过 MessageConverter 实现的,它完成了将对象转换为消息的复杂工作。
配置消息转换器
MessageConverter 是 Spring 定义的接口,它只有两个用于实现的方法:
1 | public interface MessageConverter { |
这个接口的实现很简单,都不需要创建自定义实现。Spring 已经提供了一些有用的实现,就像表 8.3 中描述的那样。
| 消息转换器 | 做了什么 |
|---|---|
| MappingJackson2MessageConverter | 使用 Jackson 2 JSON 库对消息进行与 JSON 的转换 |
| MarshallingMessageConverter | 使用 JAXB 对消息进行与 XML 的转换 |
| MessagingMessageConverter | 使用底层 MessageConverter(用于有效负载)和JmsHeaderMapper(用于将 Jms 信息头映射到标准消息标头)将 Message 从消息传递抽象转换为 Message,并从 Message 转换为 Message |
| SimpleMessageConverter | 将 String 转换为 TextMessage,将字节数组转换为 BytesMessage,将 Map 转换为 MapMessage,将Serializable 转换为 ObjectMessage |
SimpleMessageConverter 是默认的消息转换器,但是它要求发送的对象实现 Serializable 接口。这样要求可能还不错,但是可能更喜欢使用其他的消息转换器,如 MappingJackson2MessageConverter,来避免上述限制。
为了应用不同的消息转换器,需要做的是将选择的转换器声明为一个 bean。例如,下面这个 bean 声明将会使用 MappingJackson2MessageConverter 而不是 SimpleMessageConverter:
1 |
|
注意一下,你在返回 MappingJackson2MessageConverter 之前调用了 setTypeIdPropertyName()。这是非常重要的,因为它使接收者知道要将传入消息转换成什么类型。默认情况下,它将包含被转换类型的完全限定类名。但这有点不灵活,要求接收方也具有相同的类型,具有相同的完全限定类名。
为了实现更大的灵活性,可以通过调用消息转换器上的 setTypeIdMappings() 将合成类型名称映射到实际类型。例如,对消息转换器 bean 方法的以下更改将合成订单类型 ID 映射到 Order 类:
1 |
|
与在消息的 _typeId 属性中发送完全限定的类名不同,将发送值 order。在接收应用程序中,将配置类似的消息转换器,将 order 映射到它自己对 order 的理解。订单的实现可能在不同的包中,有不同的名称,甚至有发送者 Order 属性的一个子集。
后期处理消息
让我们假设,除了利润丰厚的网络业务,Taco Cloud 还决定开几家实体 Taco 连锁店。考虑到他们的任何一家餐馆也可以成为 web 业务的执行中心,他们需要一种方法来将订单的来源传达给餐馆的厨房。这将使厨房工作人员能够对商店订单采用与网络订单不同的流程。
在 Order 对象中添加一个新的 source 属性来携带此信息是合理的,可以用 WEB 来填充在线订单,用 STORE 来填充商店中的订单。但这将需要更改网站的 Order 类和厨房应用程序的 Order 类,而实际上,这些信息只需要为 taco 准备人员提供。
更简单的解决方案是在消息中添加一个自定义头信息,以承载订单的源。如果正在使用 send() 方法发送 taco 订单,这可以通过调用消息对象上的 setStringProperty() 轻松实现:
1 | jms.send("tacocloud.order.queue", |
这里的问题是没有使用 send()。通过选择使用 convertAndSend(),Message 对象是在幕后创建的,并且不能访问它。
幸运的是,有一种方法可以在发送消息之前调整在幕后创建的 Message。通过将 MessagePostProcessor 作为最后一个参数传递给 convertAndSend(),可以在消息创建之后对其进行任何操作。下面的代码仍然使用 convertAndSend(),但是它也使用 MessagePostProcessor 在消息发送之前添加 X_ORDER_SOURCE 头信息:
1 | jms.convertAndSend("tacocloud.order.queue", order, |
可能注意到了 MessagePostProcessor 是一个函数接口,这意味着可以使用 lambda 将其简化为匿名内部类:
1 | jms.convertAndSend("tacocloud.order.queue", order, |
尽管只需要这个特定的 MessagePostProcessor 来处理对 convertAndSend() 的调用,但是可能会发现自己使用同一个 MessagePostProcessor 来处理对 convertAndSend() 的几个不同调用。在这些情况下,也许方法引用是比 lambda 更好的选择,避免了不必要的代码重复:
1 |
|
已经看到了几种发送消息的方法。但是,如果没有人收到信息,就没有什么用处。让我们看看如何使用 Spring 和 JMS 接收消息。
设置 JMS
在使用 JMS 之前,必须将 JMS 客户端添加到项目的构建中。使用 Spring Boot,这个过程简单的不能再简单了,需要做的仅仅是将 starter 依赖添加到构建中。但是,首先必须决定是使用 Apache ActiveMQ,还是使用较新的 Apache ActiveMQ Artemis Broker。
如果使用 ActiveMQ,需要添加以下依赖到项目的 pom.xml 文件中:
1 | <dependency> |
如果选择 ActiveMQ Artemis,starter 如下所示:
1 | <dependency> |
Artemis 是 ActiveMQ 的下一代重新实现,实际上这让 ActiveMQ 成为一个遗留选项。因此,对于 Taco Cloud,将选择 Artemis。但是,这种选择最终对如何编写发送和接收消息的代码几乎没有影响。唯一显著的区别在于如何配置 Spring 来创建与 Broker 的连接。
默认情况下,Spring 假设 Artemis Broker 正在监听 localhost 的 61616 端口。对于开发目的,这是可以的,但是一旦准备好将应用程序发送到生产环境中,就需要设置一些属性来告诉 Spring 如何访问代理。表 8.1 列出了最有用的属性。
| 属性 | 描述 |
|---|---|
| spring.artemis.host | broker 主机 |
| spring.artemis.port | broker 端口 |
| spring.artemis.user | 用于访问 broker 的用户(可选) |
| spring.artemis.password | 用于访问 broker 的密码(可选) |
例如,考虑应用程序中的以下条目。可能用于非开发设置的 yml 文件:
1 | spring: |
这将设置 Spring,以创建到监听 artemis.tacocloud.com(端口 61617)的 Artemis Broker 的 broker 连接。它还设置将与该 broker 交互的应用程序的凭据,凭据是可选的,但建议用于生产部署。
如果要使用 ActiveMQ 而不是 Artemis,则需要使用表 8.2 中列出的 ActiveMQ 特定的属性。
| 属性 | 描述 |
|---|---|
| spring.activemq.broker-url | Broker 的 URL |
| spring.activemq.user | 用于访问 Broker 的用户(可选) |
| spring.activemq.password | 用于访问 Broker 的密码(可选) |
| spring.activemq.in-memory | 是否启动内存 Broker(默认:true) |
请注意,不是为 Broker 的主机名和端口提供单独的属性,而是使用单个属性 spring.activemq.broker-url 指定 ActiveMQ Broker 的地址。URL 应该是 tcp:// URL,如下面的 YAML 片段所示:
1 | spring: |
无论选择 Artemis 还是ActiveMQ,当 Broker 在本地运行时,都不需要为开发环境配置这些属性。
但是,如果使用 ActiveMQ,则需要设置 spring.activemq.in-memory 属性为 false,以防止 Spring 启动内存中的 Broker。内存中的 Broker可能看起来很有用,但它只在发布和消费同一个应用的消息时有用(这一点用处有限)。
在继续之前,将希望安装并启动一个 Artemis(或 ActiveMQ)Broker,而不是使用嵌入式 Broker。与其在这里重复安装说明,我建议你参考 Broker 文档了解详细信息:
- Artemis —— https://activemq.apache.org/artemis/docs/latest/using-server.html
- ActiveMQ —— http://activemq.apache.org/getting-started.html#GettingStarted-PreInstallationRequirements
有了构建中的 JMS starter 和等待将消息从一个应用程序传递到另一个应用程序的 Broker,就可以开始发送消息了。
使用 RabbitMQ 和 AMQP
RabbitMQ 可以说是 AMQP 最优秀的实现,它提供了比 JMS 更高级的消息路由策略。JMS 消息使用接收方将从中检索它们的目的地的名称来寻址,而 AMQP 消息使用交换器的名称和路由键来寻址,它们与接收方正在监听的队列解耦。交换器和队列之间的这种关系如图 8.1 所示。

当消息到达 RabbitMQ broker 时,它将转到它所寻址的交换器。交换器负责将其路由到一个或多个队列,具体取决于交换器的类型、交换器与队列之间的绑定以及消息的路由键的值。
有几种不同的交换方式,包括以下几种:
- Default —— 一种特殊的交换器,通过 broker 自动创建。它将消息路由到与消息的路由键的值同名的队列中。所有的队列将会自动地与交换器绑定。
- Direct —— 路由消息到消息路由键的值与绑定值相同的队列。
- Topic —— 将消息路由到一个或多个队列,其中绑定键(可能包含通配符)与消息的路由键匹配。
- Fanout —— 将消息路由到所有绑定队列,而不考虑绑定键或路由键。
- Headers —— 与 topic 交换器类似,只是路由基于消息头值而不是路由键。
- Dead letter —— 对无法交付的消息(意味着它们不匹配任何已定义的交换器与队列的绑定)的全部捕获。
最简单的交换形式是 Default 和 Fanout,因为它们大致对应于 JMS 队列和主题。但是其他交换允许定义更灵活的路由方案。
需要理解的最重要的一点是,消息是用路由键发送到交换器的,它们是从队列中使用的。它们如何从一个交换到一个队列取决于绑定定义以及什么最适合相应的情况。
使用哪种交换类型以及如何定义从交换到队列的绑定与 Spring 应用程序中消息的发送和接收方式关系不大。因此,我们将重点讨论如何编写使用 RabbitMQ 发送和接收消息的代码。
注意
有关如何最好地将队列绑定到交换器的更详细讨论,请参见 Alvaro Videla 和 Jason J.W. Williams(Manning, 2012)的《RabbitMQ 实战》。
添加 RabbitMQ 到 Spring 中
在开始使用 Spring 发送和接收 RabbitMQ 消息之前,需要将 Spring Boot 的 AMQP starter 依赖项添加到构建中,以取代在前一节中添加的 Artemis 或 ActiveMQ starter:
1 | <dependency> |
将 AMQP starter 添加到构建中将触发自动配置,该配置将创建 AMQP 连接工厂和 RabbitTemplate bean,以及其他支持组件。只需添加此依赖项,就可以开始使用 Spring 从 RabbitMQ broker 发送和接收消息,表 8.4 中列出了一些有用的属性。
| 属性 | 描述 |
|---|---|
| spring.rabbitmq.addresses | 一个逗号分隔的 RabbitMQ Broker 地址列表 |
| spring.rabbitmq.host | Broker 主机(默认为 localhost) |
| spring.rabbitmq.port | Broker 端口(默认为 5672) |
| spring.rabbitmq.username | 访问 Broker 的用户名(可选) |
| spring.rabbitmq.password | 访问 Broker 的密码(可选) |
出于开发目的,可能有一个 RabbitMQ Broker,它不需要在本地机器上运行身份验证,监听端口 5672。当还在开发阶段时,这些属性可能不会有太大的用处,但是当应用程序进入生产环境时,它们无疑会很有用。
例如,假设在进入生产环境时,RabbitMQ Broker 位于一个名为 rabbit.tacocloud.com 的服务器上,监听端口 5673,并需要凭据。在这种情况下,应用程序中的以下配置。当 prod 配置文件处于活动状态时,yml 文件将设置这些属性:
1 | spring: |
现在 RabbitMQ 被配置到了应用程序中了,是时候使用 RabbitTemplate 发送消息了。
使用 RabbitTemplate 发送消息
Spring 对于 RabbitMQ 消息支持的核心就是 RabbitTemplate。RabbitTemplate 提供一套与 JmsTemplate 类似的方法。但是对于 RabbitMQ,在工作方式上还是有一些细微的差别。
关于使用 RabbitTemplate 发送消息,send() 和 convertAndSend() 方法与来自 JmsTemplate 的同名方法并行。但是不同于 JmsTemplate 方法,它只将消息路由到给定的队列或主题,RabbitTemplate 方法根据交换和路由键发送消息。下面是一些用 RabbitTemplate 发送消息的最有用的方法:
1 | // 发送原始消息 |
这些方法与 JmsTemplate 中的孪生方法遵循类似的模式。前三个 send() 方法都发送一个原始消息对象。接下来的三个 convertAndSend() 方法接受一个对象,该对象将在发送之前在后台转换为消息。最后三个 convertAndSend() 方法与前三个方法类似,但是它们接受一个 MessagePostProcessor,可以在消息对象发送到代理之前使用它来操作消息对象。
这些方法与对应的 JmsTemplate 方法不同,它们接受 String 值来指定交换和路由键,而不是目的地名称(或 Destination 对象)。不接受交换的方法将把它们的消息发送到默认交换。同样,不接受路由键的方法将使用默认路由键路由其消息。
让我们用 RabbitTemplate 发送 taco 订单。一种方法是使用 send() 方法,如程序清单 8.5 所示。但是在调用 send() 之前,需要将 Order 对象转换为消息。如果不是因为 RabbitTemplate 使用 getMessageConverter() 方法使其消息转换器可用,这可能是一项乏味的工作。
1 | package tacos.messaging; |
有了 MessageConverter 之后,将 Order 转换为消息就很简单了。必须使用 MessageProperties 提供任何消息属性,但是如果不需要设置任何此类属性,则可以使用 MessageProperties 的缺省实例。然后,剩下的就是调用 send(),将交换键和路由键(两者都是可选的)与消息一起传递。在本例中,只指定了与消息一起的路由键:tacocloud.order,因此将使用缺省交换。
说到默认交换,默认交换名称是 “”(一个空 String ),它对应于 RabbitMQ Broker 自动创建的默认交换。同样,默认的路由键是 “”(其路由取决于所涉及的交换和绑定)。可以通过设置 spring.rabbitmq.template.exchange 和 spring.rabbitmq.template.routing-key 属性来覆盖这些缺省值:
1 | spring: |
在这种情况下,所有发送的消息都将自动发送到名为 tacocloud.orders 的交换器。如果在 send() 或 convertAndSend() 调用中也未指定路由键,则消息将有一个 kitchens.central 的路由键。
从消息转换器创建消息对象非常简单,但是使用 convertAndSend() 让 RabbitTemplate 处理所有的转换工作就更容易了:
1 | public void sendOrder(Order order) { |
配置消息转换器
默认情况下,使用 SimpleMessageConverter 执行消息转换,SimpleMessageConverter 能够将简单类型(如 String)和可序列化对象转换为消息对象。但是 Spring 为 RabbitTemplate 提供了几个消息转换器,包括以下内容:
- Jackson2JsonMessageConverter —— 使用Jackson 2 JSON处理器将对象与 JSON 进行转换
- MarshallingMessageConverter —— 使用 Spring 的序列化和反序列化抽象转换 String 和任何类型的本地对象
- SimpleMessageConverter —— 转换 String、字节数组和序列化类型
- ContentTypeDelegatingMessageConverter —— 基于 contentType 头信息将对象委托给另一个 MessageConverter
- MessagingMessageConverter —— 将消息转换委托给底层 MessageConverter,将消息头委托给 AmqpHeaderConverter
如果需要修改消息转换器,需要做的是配置 MessageConverter bean,例如,对于基于 JSON 的消息对话,可以像下面这样配置 Jackson2JsonMessageConverter:
1 |
|
Spring Boot 的自动配置将会发现这个 bean 并 RabbitTemplate 的缺省的消息转换器那里。
设置消息属性
与 JMS 一样,可能需要在发送的消息中设置一些标题。例如,假设需要为通过 Taco Cloud 网站提交的所有订单发送一个 X_ORDER_SOURCE。在创建 Message 对象时,可以通过提供给消息转换器的 MessageProperties 实例设置消息头。
重新访问程序清单 8.5 中的 sendOrder() 方法,只需要添加一行代码来设置标题:
1 | public void sendOrder(Order order) { |
但是,在使用 convertAndSend() 时,不能快速访问 MessageProperties 对象。不过,MessagePostProcessor 可以做到这一点:
1 |
|
这里,在 convertAndSend() 中使用 MessagePostProcessor 的匿名内部类进行实现 。在 postProcessMessage() 方法中,首先从消息中获取 MessageProperties,然后调用 setHeader() 来设置 X_ORDER_SOURCE 头信息。
现在已经了解了如何使用 RabbitTemplate 发送消息,接下来让我们将注意力转移到从 RabbitMQ 队列接收消息的代码上。
从 RabbitMQ 接收消息
使用 RabbitTemplate 发送消息与使用 JmsTemplate 发送消息差别不大。事实证明,从 RabbitMQ 队列接收消息与从 JMS 接收消息并没有太大的不同。
与 JMS 一样,有两个选择:
- 使用 RabbitTemplate 从队列中拉取消息
- 获取被推送到 @RabbitListener 注解的方法中的消息
让我们从基于拉模型的 RabbitTemplate.receive() 方法开始。
使用 RabbitTemplate 接收消息
RabbitTemplate 有多个从队列中拉取消息的方法,一部分最有用的方法如下所示:
1 | // 接收消息 |
这些方法是前面描述的 send() 和 convertAndSend() 方法的镜像。send() 用于发送原始 Message 对象,而 receive() 从队列接收原始 Message 对象。同样地,receiveAndConvert() 接收消息,并在返回消息之前使用消息转换器将其转换为域对象。
但是在方法签名方面有一些明显的区别。首先,这些方法都不以交换键或路由键作为参数。这是因为交换和路由键用于将消息路由到队列,但是一旦消息在队列中,它们的下一个目的地就是将消息从队列中取出的使用者。使用应用程序不需要关心交换或路由键,队列是在消费应用程序是仅仅需要知道一个东西。
许多方法接受一个 long 参数来表示接收消息的超时。默认情况下,接收超时为 0 毫秒。也就是说,对 receive() 的调用将立即返回,如果没有可用的消息,则可能返回空值。这与 receive() 方法在 JmsTemplate 中的行为有明显的不同。通过传入超时值,可以让 receive() 和 receiveAndConvert() 方法阻塞,直到消息到达或超时过期。但是,即使使用非零超时,代码也要准备好处理返回的 null 值。
让我们看看如何将其付诸实践。下面程序清单显示了 OrderReceiver 的一个新的基于 Rabbit 的实现,它使用 RabbitTemplate 来接收订单。
1 | package tacos.kitchen.messaging.rabbit; |
receiveOrder() 方法是所有操作发生的地方。它调用所注入的 RabbitTemplate 上的 receive() 方法来从 tacocloud.queue 中获取订单。它不提供超时值,因此只能假设调用立即返回 Message 或 null。如果返回一条 Message,则使用 RabbitTemplate 中的 MessageConverter 将 Message 转换为 Order。另一方面,如果 receive() 返回 null,则返回 null。
根据实际情况的不同,可能容忍一个小的延迟。例如,在 Taco Cloud 厨房项目的头顶显示器中,如果没有订单信息出现,可以等待一下,可以决定等 30 秒后再放弃。然后,可以将 receiveOrder() 方法更改为传递一个 30,000 毫秒的延迟后再调用 receive():
1 | public Order receiveOrder() { |
如果你和我一样,看到这样一个硬编码的数字会让你有点不舒服。那么创建一个带 @ConfigurationProperties 注解的类是个好想法,这样就可以使用 Spring Boot 的配置属性来配置超时。如果不是 Spring Boot 已经提供了这样的配置属性,我也会觉得硬编码的数字很不舒服。如果希望通过配置设置超时,只需删除 receive() 调用中的超时值,并在配置中使用 spring.rabbitmq.template.receive-timeout 属性设置它:
1 | spring: |
回到 receiveOrder() 方法,请注意,必须使用 RabbitTemplate 中的消息转换器来将传入 Message 对象转换为 Order 对象。但是如果 RabbitTemplate 携带了一个消息转换器,为什么它不能进行转换呢?这正是 receiveAndConvert() 方法的用途。使用 receiveAndConvert(),可以像这样重写 receiveOrder():
1 | public Oreder receiveOrder() { |
那就简单多了,不是吗?所看到的唯一麻烦的事情就是从 Object 到 Order 的转换。不过,除了演员阵容,还有另一种选择。相反,你可以传递一个 ParameterizedTypeReference 来直接接收一个 Order 对象:
1 | public Order receiveOrder() { |
这是否比类型转换更好还值得商榷,但它是一种比类型转换更安全的方法。使用 receiveAndConvert() 的 ParameterizedTypeReference 的惟一要求是消息转换器必须是 SmartMessageConverter 的实现;Jackson2JsonMessageConverter 是唯一可以选择的开箱即用的实现。
JmsTemplate 提供的拉模型适用于许多用例,但通常最好有监听消息并在消息到达时调用的代码。让我们看看如何编写响应 RabbitMQ 消息的消息驱动 bean。
使用监听器处理 RabbitMQ 消息
对于消息驱动的 RabbitMQ bean,Spring 提供了 RabbitListener,相当于 RabbitMQ 中的 JmsListener。要指定当消息到达 RabbitMQ 队列时应该调用某个方法,请在相应的 bean 方法上使用 @RabbitTemplate 进行注解 。
例如,下面的程序清单显示了 OrderReceiver 的 RabbitMQ 实现,它被注解为监听订单消息,而不是使用 RabbitTemplate 来轮询订单消息。
1 | package tacos.kitchen.messaging.rabbit.listener; |
这与程序清单 8.4 中的代码非常相似。实际上,唯一改变的是监听器注解—从 @JmsListener 变为了 @RabbitListener。尽管 @RabbitListener 非常棒,但这种近乎复制的代码让我对 @RabbitListener 没什么可说的,而我之前还没有对 @JmsListener 说过。它们都非常适合编写从各自的 broker 推送给它们的消息的代码 —— JMS broker 用于 @JmsListener,RabbitMQ broker 用于 @RabbitListener。
虽然在前面的段落中可能感觉到了 @RabbitListener 不是那么让人兴奋。事实上,@RabbitListener 与 @JmsListener 的工作方式非常相似,这一点非常令人兴奋!这意味着在使用 RabbitMQ 与 Artemis 或 ActiveMQ 时,不需要学习完全不同的编程模型。同样令人兴奋的是 RabbitTemplate 和 JmsTemplate 之间的相似性。
在结束本章时,让我们继续关注 Spring 支持的另一个消息传递中间件:Apache Kafka。
使用 Kafka 发送消息
Apache Kafka 是我们在本章中研究的最新消息传递选项。乍一看,Kafka 是一个消息代理,就像ActiveMQ、Artemis 或 Rabbit 一样。但是 Kafka 有一些独特的技巧。
Kafka 被设计为在集群中运行,提供了巨大的可伸缩性。通过将其 topic 划分到集群中的所有实例中,它具有很强的弹性。RabbitMQ 主要处理 exchange 中的队列,而 Kafka 仅利用 topic 来提供消息的发布/订阅。
Kafka topic 被复制到集群中的所有 broker 中。集群中的每个节点充当一个或多个 topic 的 leader,负责该 topic 的数据并将其复制到集群中的其他节点。
更进一步说,每个 topic 可以分成多个分区。在这种情况下,集群中的每个节点都是一个 topic 的一个或多个分区的 leader,但不是整个 topic 的 leader。该 topic 的职责由所有节点分担。图 8.2 说明了这是如何工作的。

由于 Kafka 独特的构建风格,我鼓励你在迪伦·斯科特(Dylan Scott,2017)的《Kafka 实战》中阅读更多关于它的内容。出于我们的目的,我们将重点讨论如何使用 Spring 向 Kafka 发送和接收消息。
在 Spring 中设置 Kafka
要开始使用 Kafka 进行消息传递,需要将适当的依赖项添加到构建中。但是,与 JMS 和 RabbitMQ 不同,Kafka 没有 Spring Boot starter。不过还是只需要一个依赖:
1 | <dependency> |
这个依赖项将 Kafka 所需的一切都带到项目中。更重要的是,它的存在将触发 Kafka 的 Spring Boot 自动配置,它将在 Spring 应用程序上下文中生成一个 KafkaTemplate。你所需要做的就是注入 KafkaTemplate 并开始发送和接收消息。
然而,在开始发送和接收消息之前,应该了解一些在使用 Kafka 时会派上用场的属性。具体来说就是,KafkaTemplate 默认在 localhost 上运行 Kafka broker,并监听 9092 端口。在开发应用程序时,在本地启动 Kafka broker 是可以的,但是在进入生产环境时,需要配置不同的主机和端口。
spring.kafka.bootstrap-servers 属性设置一个或多个 Kafka 服务器的位置,用于建立到 Kafka 集群的初始连接。例如,如果集群中的 Kafka 服务器之一运行在 Kafka .tacocloud.com 上,并监听 9092 端口,那么可以在 YAML 中像这样配置它的位置:
1 | spring: |
但是注意 spring.kafka.bootstrap-servers 属性是复数形式,它接受一个列表。因此,可以在集群中为它提供多个 Kafka 服务器:
1 | spring: |
在项目中设置了 Kafka 之后,就可以发送和接收消息了。首先来看看 KafkaTemplate 将 Order 对象发送给 Kafka。
使用 KafkaTemplate 发送消息
在许多方面,KafkaTemplate 与 JMS 和 RabbitMQ 类似。与此同时,它也是不同的,尤其是在我们考虑它发送消息的方法时:
1 | ListenableFuture<SendResult<K, V>> send(String topic, V data); |
注意到的第一件事是没有 convertAndSend() 方法。这是因为 KafkaTemplate 是用的泛型,同时能够在发送消息时直接处理域类型。在某种程度上,所有的 send() 方法都在做 convertAndSend() 的工作。
再者 send() 和 sendDefault() 的参数,它们与 JMS 和 Rabbit 中使用的参数完全不同。当使用 Kafka 发送消息时,可以指定以下参数来指导如何发送消息:
- 发送消息的 topic(send() 方法必要的参数)
- 写入 topic 的分区(可选)
- 发送记录的键(可选)
- 时间戳(可选;默认为 System.currentTimeMillis())
- payload(必须)
topic 和 payload 是两个最重要的参数。分区和键对如何使用 KafkaTemplate 几乎没有影响,除了作为 send() 和 sendDefault() 的参数用于提供额外信息。出于我们的目的,我们将把重点放在将消息有效负载发送到给定主题上,而不考虑分区和键。
对于 send() 方法,还可以选择发送一个 ProducerRecord,它与在单个对象中捕获所有上述参数的类型差不多。也可以发送 Message 对象,但是这样做需要将域对象转换为 Message。通常,使用其他方法比创建和发送 ProducerRecord 或 Message 对象更容易。
使用 KafkaTemplate 及其 send() 方法,可以编写一个基于 kafka 的 OrderMessagingService 实现。下面的程序清单显示了这样一个实现。
1 | package tacos.messaging; |
在 OrderMessagingService 的这个实现中,sendOrder() 方法使用注入的 KafkaTemplate 的 send() 方法向名为tacocloud.orders.topic 的主题发送 Order。代码中除了使用 “Kafka” 这个名称外,这与为 JMS 和 Rabbit 编写的代码没有太大的不同。
如果设置了默认主题,可以稍微简化 sendOrder() 方法。首先,通过设置 spring.kafka.template.default-topic 属性,将默认主题设置为 tacocloud.orders.topic:
1 | spring: |
然后,在 sendOrder() 方法中,可以调用 sendDefault() 而不是 send(),并且不指定主题名称:
1 |
|
现在已经编写了消息发送代码了,让我们将注意力转向编写从 Kafka 接收这些消息的代码。
编写 Kafka 监听器
除了 send() 和 sendDefault() 的惟一方法签名之外,KafkaTemplate 与 JmsTemplate 和 RabbitTemplate 的不同之处在于它不提供任何接收消息的方法。这意味着使用 Spring 消费来自 Kafka 主题的消息的唯一方法是编写消息监听器。
对于 Kafka,消息监听器被定义为被 @KafkaListener 注解的方法。@KafkaListener 注解大致类似于 @JmsListener 和 @RabbitListener,其使用方式大致相同。下面程序清单显示了为 Kafka 编写的基于 listener 的订单接收程序。
1 | package tacos.kitchen.messaging.kafka.listener; |
handle() 方法由 @KafkaListener 注解,表示当消息到达名为 tacocloud.orders.topic 的主题时应该调用它。正如程序清单 8.9 中所写的,只为 handle() 方法提供了一个 Order(payload)参数 。但是,如果需要来自消息的其他元数据,它也可以接受一个 ConsumerRecord 或 Message 对象。
例如,handle() 的以下实现接受一个 ConsumerRecord,这样就可以记录消息的分区和时间戳:
1 |
|
类似地,可以使用 Message 而不是 ConsumerRecord,并达到同样的效果:
1 |
|
值得注意的是,消息有效负载也可以通过 ConsumerRecord.value() 或 Message.getPayload() 获得。这意味着可以通过这些对象请求 Order,而不是直接将其作为 handle() 的参数。
小结
- 异步消息传递在通信的应用程序之间提供了一个间接层,允许更松散的耦合和更大的可伸缩性。
- Spring 支持 JMS、RabbitMQ 或 Apache Kafka 的异步消息传递。
- 应用程序可以使用基于模板的客户端(JmsTemplate、RabbitTemplate 或 KafkaTemplate)通过消息 broker 发送消息。
- 接收应用程序可以使用相同的基于模板的客户端,在基于 pull 的模型中使用消息。
- 也可以通过向 bean 方法应用消息监听器注解(@JmsListener、@RabbitListener 或 @KafkaListener)将消息推送给消费者。
第 9 章 集成 Spring
本章内容:
- 实时数据处理
- 定义集成流程
- 使用 Spring Integration 的 Java DSL 定义
- 集成电子邮件、文件系统和其他外部系统
我在旅行中遇到的最令人沮丧的事情之一就是在长途飞行中,飞机上的互联网连接很差或者根本不存在。我喜欢利用我的飞行时间完成一些工作,包括写这本书。如果没有网络连接,那么在需要获取库或查找 Java 文档时,我就处于不利地位了,也无法完成大量工作。我学会了在这种场合下带本书来读。
正如我们需要连接到互联网来提高生产力一样,许多应用程序也必须连接到外部系统来执行它们的工作。应用程序可能需要读取或发送电子邮件、与外部 API 交互或对写入数据库的数据作出响应。而且,由于数据是从这些外部系统获取或写入的,应用程序可能需要以某种方式处理数据,以便将其转换到或从应用程序自己的域。
在本章中,将看到如何使用 Spring Integration 的通用集成模式。Spring Integration 是由 Gregor Hohpe 和 Bobby Woolf 在《企业级集成模式》一书中编目的许多集成模式的实现。每个模式都被实现为一个组件,消息通过该组件传输管道中的数据。使用 Spring 配置,可以将这些组件组装到数据流经的管道中。让我们从定义一个简单的集成流开始,它引入了 Spring Integration 的许多特性。
声明简单的集成流
一般来说,Spring Integration 支持创建集成流,应用程序可以通过这些集成流接收或发送数据到应用程序本身的外部资源。应用程序可以与之集成的一种资源是文件系统。因此,在 Spring Integration 的众多组件中,有用于读写文件的通道适配器。
为了熟悉 Spring Integration,将创建一个向文件系统写入数据的集成流。首先,需要将 Spring Integration 添加到项目构建中。对于 Maven,必要的依赖关系如下:
1 | <dependency> |
第一个依赖项是 Spring Integration 的 Spring Boot starter。无论 Spring Integration 流可能与什么集成,这种依赖关系都是开发 Spring Integration 流所必需的。与所有 Spring Boot starter 依赖项一样,它也存在于 Initializr 的复选框表单中。
第二个依赖项是 Spring Integration 的文件端点模块。此模块是用于与外部系统集成的二十多个端点模块之一。我们将在第 9.2.9 节中更多地讨论端点模块。但是,就目前而言,要知道文件端点模块提供了将文件从文件系统提取到集成流或将数据从流写入文件系统的能力。
接下来,需要为应用程序创建一种将数据发送到集成流的方法,以便将数据写入文件。为此,将创建一个网关接口,如下面所示。
1 | package sia5; |
尽管它是一个简单的 Java 接口,但是关于 FileWriterGateway 还有很多要说的。首先会注意到它是由 @MessagingGateway 注解的。这个注解告诉 Spring Integration 在运行时生成这个接口的实现 —— 类似于 Spring Data 如何自动生成存储库接口的实现。当需要编写文件时,代码的其他部分将使用这个接口。
@MessagingGateway 的 defaultRequestChannel 属性表示,对接口方法的调用产生的任何消息都应该发送到给定的消息通道。在本例中,声明从 writeToFile() 调用产生的任何消息都应该发送到名为 textInChannel 的通道。
对于 writeToFile() 方法,它接受一个文件名作为字符串,另一个字符串包含应该写入文件的文本。关于此方法签名,值得注意的是 filename 参数使用 @Header 进行了注解。在本例中,@Header 注解指示传递给 filename 的值应该放在消息头中(指定为 FileHeaders),解析为 file_name 的文件名,而不是在消息有效负载中。另一方面,数据参数值则包含在消息有效负载中。
现在已经有了一个消息网关,还需要配置集成流。尽管添加到构建中的 Spring Integration starter 依赖项支持 Spring Integration 的基本自动配置,但仍然需要编写额外的配置来定义满足应用程序需求的流。声明集成流的三个配置选项包括:
- XML 配置
- Java 配置
- 使用 DSL 进行 Java 配置
我们将对 Spring Integration 的这三种风格的配置进行讲解,从最原始的 XML 配置开始。
使用 XML 定义集成流
尽管在本书中我避免使用 XML 配置,但 Spring Integration 在 XML 中定义的集成流方面有着悠久的历史。因此,我认为值得至少展示一个 XML 定义的集成流示例。下面的程序清单显示了如何在 XML 中配置流。
1 |
|
分析一下程序清单 9.2 中的 XML:
- 配置了一个名为 textInChannel 的通道,这与为 FileWriterGateway 设置的请求通道是相同的。当在 FileWriterGateway 上调用 writeToFile() 方法时,结果消息被发布到这个通道。
- 配置了一个转换器来接收来自 textInChannel 的消息。它使用 Spring Expression Language(SpEL)表达式在消息有效负载上调用 toUpperCase()。然后将大写操作的结果发布到 fileWriterChannel 中。
- 配置了一个名为 fileWriterChannel 的通道,此通道用作连接转换器和外部通道适配器的管道。
- 最后,使用 int-file 命名空间配置了一个外部通道适配器。这个 XML 命名空间由 Spring Integration 的文件模块提供,用于编写文件。按照配置,它将接收来自 fileWriterChannel 的消息,并将消息有效负载写到一个文件中,该文件的名称在消息的 file_name 头中指定,该文件位于 directory 属性中指定的目录中。如果文件已经存在,则将用换行来追加文件,而不是覆盖它。
如果希望在 Spring Boot 应用程序中使用 XML 配置,则需要将 XML 作为资源导入 Spring 应用程序。最简单的方法是在应用程序的 Java 配置类上使用 Spring 的 @ImportResource 注解:
1 |
|
尽管基于 XML 的配置很好地服务于 Spring Integration,但大多数开发人员对使用 XML 越来越谨慎。(正如我所说的,我在本书中避免使用 XML 配置)让我们把这些尖括号放在一边,将注意力转向 Spring Integration 的 Java 配置风格。
在 Java 中配置集成流
大多数现代 Spring 应用程序都避开了 XML 配置,而采用了 Java 配置。实际上,在 Spring Boot 应用程序中,Java 配置是自动配置的自然补充。因此,如果要将集成流添加到 Spring Boot 应用程序中,那么在 Java 中定义该流是很有意义的。
作为如何使用 Java 配置编写集成流的示例,请查看下面的程序清单。这显示了与以前相同的文件编写集成流,但这次是用 Java 编写的。
1 | package sia5; |
使用 Java 配置,可以声明两个 bean:一个转换器和一个文件写入消息处理程序。这里转换器是 GenericTransformer。因为 GenericTransformer 是一个函数接口,所以能够以 lambda 的形式提供在消息文本上调用 toUpperCase() 的实现。转换器的 bean使用 @Transformer 进行注解,并将其指定为集成流中的转换器,该转换器接收名为 textInChannel 的通道上的消息,并将消息写入名为 fileWriterChannel 的通道。
至于文件写入 bean,它使用 @ServiceActivator 进行了注解,以指示它将接受来自 fileWriterChannel 的消息,并将这些消息传递给由 FileWritingMessageHandler 实例定义的服务。FileWritingMessageHandler 是一个消息处理程序,它使用消息的 file_name 头中指定的文件名将消息有效负载写入指定目录中的文件。与 XML 示例一样,将 FileWritingMessageHandler 配置为用换行符附加到文件中。
FileWritingMessageHandler bean 配置的一个独特之处是调用 setExpectReply(false) 来指示服务激活器不应该期望应答通道(通过该通道可以将值返回到流中的上游组件)。如果不调用 setExpectReply(),则文件写入 bean 默认为 true,尽管管道仍按预期工作,但将看到记录了一些错误,说明没有配置应答通道。
还会看到不需要显式地声明通道。如果不存在具有这些名称的 bean,就会自动创建 textInChannel 和 fileWriterChannel 通道。但是,如果希望对通道的配置方式有更多的控制,可以像这样显式地将它们构造为 bean:
1 |
|
可以说,Java 配置选项更易于阅读,也更简洁,而且与我在本书中所追求的纯 Java 配置完全一致。但是,通过 Spring Integration 的 Java DSL(领域特定语言)配置风格,它可以变得更加精简。
使用 Spring Integration 的 DSL 配置
让我们进一步尝试定义文件编写集成流。这一次,仍然使用 Java 定义它,但是将使用 Spring Integration 的 Java DSL。不是为流中的每个组件声明一个单独的 bean,而是声明一个定义整个流的 bean。
1 | package sia5; |
这个新配置尽可能简洁,用一个 bean 方法捕获整个流。IntegrationFlows 类初始化了这个构建者 API,可以从该 API 声明流。
在程序清单 9.4 中,首先从名为 textInChannel 的通道接收消息,然后该通道转到一个转换器,使消息有效负载大写。在转换器之后,消息由出站通道适配器处理,该适配器是根据 Spring Integration 的文件模块中提供的文件类型创建的。最后,调用 get() 构建要返回的 IntegrationFlow。简而言之,这个 bean 方法定义了与 XML 和 Java 配置示例相同的集成流。
注意,与 Java 配置示例一样,不需要显式地声明通道 bean。虽然引用了 textInChannel,但它是由 Spring Integration 自动创建的,因为没有使用该名称的现有通道 bean。但是如果需要,可以显式地声明通道 bean。
至于连接转换器和外部通道适配器的通道,甚至不需要通过名称引用它。如果需要显式配置通道,可以在流定义中通过调用 channel() 的名称引用:
1 |
|
在使用 Spring Integration 的 Java DSL(与任何流式 API 一样)时要记住的一件事是,必须巧妙地使用空白来保持可读性。在这里给出的示例中,我小心地缩进了行以表示相关代码块。对于更长、更复杂的流,甚至可以考虑将流的一部分提取到单独的方法或子流中,以获得更好的可读性。
现在已经看到了使用三种不同配置风格定义的简单流,让我们回过头来看看 Spring Integration 的全貌。
探索 Spring Integration
Spring Integration 涵盖了许多集成场景。试图将所有这些内容都包含在一个章节中,就像试图将大象装进一个信封一样。我将展示一张 Spring Integration 大象的照片,而不是对 Spring Integration 进行全面的讨论,以便让你了解它是如何工作的。然后,将创建一个向 Taco Cloud 应用程序添加功能的集成流。
集成流由以下一个或多个组件组成。在编写更多代码之前,我们将简要地了解一下这些组件在集成流中所扮演的角色:
- Channels —— 将信息从一个元素传递到另一个元素。
- Filters —— 有条件地允许基于某些标准的消息通过流。
- Transformers —— 更改消息值或将消息有效负载从一种类型转换为另一种类型。
- Routers —— 直接将信息发送到几个渠道之一,通常是基于消息头。
- Splitters —— 将收到的信息分成两条或多条,每条都发送到不同的渠道。
- Aggregators —— 与分离器相反,它将来自不同渠道的多条信息组合成一条信息。
- Service activators —— 将消息传递给某个 Java 方法进行处理,然后在输出通道上发布返回值。
- Channel adapters —— 将通道连接到某些外部系统或传输。可以接受输入,也可以向外部系统写入。
- Gateways —— 通过接口将数据传递到集成流。
在定义文件写入集成流时,你已经看到了其中的一些组件。FileWriterGateway 接口是将应用程序提交的文本写入文件的网关。还定义了一个转换器来将给定的文本转换为大写;然后声明一个服务网关,它执行将文本写入文件的任务。这个流有两个通道:textInChannel 和 fileWriterChannel,它们将其他组件相互连接起来。现在,按照承诺快速浏览一下集成流组件。
消息通道
消息通道意指消息移动的集成管道移动。它们是连接 Spring Integration 所有其他部分的管道。
Spring Integration 提供了多个管道的实现,包括以下这些:
- PublishSubscribeChannel —— 消息被发布到 PublishSubscribeChannel 后又被传递给一个或多个消费者。如果有多个消费者,他们都将会收到消息。
- QueueChannel —— 消息被发布到 QueueChannel 后被存储到一个队列中,直到消息被消费者以先进先出(FIFO)的方式拉取。如果有多个消费者,他们中只有一个能收到消息。
- PriorityChannel —— 与 QueueChannel 类似,但是与 FIFO 方式不同,消息被冠以 priority 的消费者拉取。
- RendezvousChannel —— 与 QueueChannel 期望发送者阻塞通道,直到消费者接收这个消息类似,这种方式有效的同步了发送者与消费者。
- DirectChannel —— 与 PublishSubscribeChannel 类似,但是是通过在与发送方相同的线程中调用消费者来将消息发送给单个消费者,此通道类型允许事务跨越通道。
- ExecutorChannel —— 与 DirectChannel 类似,但是消息分派是通过 TaskExecutor 进行的,在与发送方不同的线程中进行,此通道类型不支持事务跨通道。
- FluxMessageChannel —— Reactive Streams Publisher 基于 Project Reactor Flux 的消息通道。(我们将会在第 10 章讨论 Reactive Streams、Reactor 和 Flux)
在 Java 配置和 Java DSL 样式中,输入通道都是自动创建的,默认是 DirectChannel。但是,如果希望使用不同的通道实现,则需要显式地将通道声明为 bean 并在集成流中引用它。例如,要声明 PublishSubscribeChannel,需要声明以下 @Bean 方法:
1 |
|
然后在集成流定义中通过名称引用这个通道。例如,如果一个服务 activator bean 正在使用这个通道,那么可以在 @ServiceActivator 的 inputChannel 属性中引用它:
1 |
或者,如果使用 Java DSL 配置方式,需要通过调用 channel() 方法引用它:
1 |
|
需要注意的是,如果使用 QueueChannel,则必须为使用者配置一个轮询器。例如,假设已经声明了一个这样的 QueueChannel bean:
1 |
|
需要确保将使用者配置为轮询消息通道。在服务激活器的情况下,@ServiceActivator 注解可能是这样的:
1 |
在本例中,服务激活器每秒(或 1,000 ms)从名为 orderChannel 的通道轮询一次。
过滤器
过滤器可以放置在集成管道的中间,以允许或不允许消息进入流中的下一个步骤。
例如,假设包含整数值的消息通过名为 numberChannel 的通道发布,但是只希望偶数传递到名为 evenNumberChannel 的通道。在这种情况下,可以使用 @Filter 注解声明一个过滤器,如下所示:
1 |
|
在本例中,使用 lambda 表达式实现过滤器。但是,事实上,filter() 方法是接收一个 GenericSelector 作为参数。这意味着可以实现 GenericSelector 接口,而不是引入一个简略的 lambda 表达式实现过滤。
转换器
转换器对消息执行一些操作,通常会产生不同的消息,并且可能会产生不同的负载类型。转换可以是一些简单的事情,例如对数字执行数学运算或操作 String 字符串值;转换也会很复杂,例如使用表示 ISBN 的 String 字符串值来查找并返回相应书籍的详细信息。
例如,假设正在一个名为 numberChannel 的通道上发布整数值,并且希望将这些数字转换为包含等效罗马数字的 String 字符串。在这种情况下,可以声明一个 GenericTransformer 类型的 bean,并添加 @Transformer 注解,如下所示:
1 |
|
通过 @Transformer 注解将 bean 指定为 transformer bean,它从名为 numberChannel 的通道接收整数值,并使用 oRoman() (toRoman() 方法是在一个名为 RomanNumbers 的类中静态定义的,并在这里通过方法引用进行引)的静态方法进行转换,得到的结果被发布到名为 romanNumberChannel 的通道中。
在 Java DSL 配置风格中,调用 transform() 甚至更简单,将方法引用传递给 toRoman() 方法即可:
1 |
|
虽然在两个 transformer 代码示例中都使用了方法引用,但是要知道 transformer 也可以使用 lambda 表达式。或者,如果 transformer 比较复杂,需要单独的成为一个 Java 类,可以将它作为 bean 注入流配置,并将引用传递给 transform() 方法:
1 |
|
在这里,声明了一个 RomanNumberTransformer 类型的 bean,它本身是 Spring Integration 的 Transformer 或 GenericTransformer 接口的实现。bean 被注入到 transformerFlow() 方法,并在定义集成流时传递给 transform() 方法。
路由
基于某些路由标准的路由器允许在集成流中进行分支,将消息定向到不同的通道。
例如,假设有一个名为 numberChannel 的通道,整数值通过它流动。假设希望将所有偶数消息定向到一个名为 evenChannel 的通道,而将奇数消息定向到一个名为 oddChannel 的通道。要在集成流中创建这样的路由,可以声明一个 AbstractMessageRouter 类型的 bean,并使用 @Router 注解该 bean:
1 |
|
这里声明的 AbstractMessageRouter bean 接受来自名为 numberChannel 的输入通道的消息。定义为匿名内部类的实现检查消息有效负载,如果它是偶数,则返回名为 evenChannel 的通道(在路由器 bean 之后声明为 bean)。否则,通道有效载荷中的数字必须为奇数;在这种情况下,将返回名为 oddChannel 的通道(也在 bean 声明方法中声明)。
在 Java DSL 形式中,路由器是通过在流定义过程中调用 route() 来声明的,如下所示:
1 |
|
虽然仍然可以声明 AbstractMessageRouter 并将其传递给 route(),但是本例使用 lambda 表达式来确定消息有效负载是奇数还是偶数。
如果是偶数,则返回一个偶数的字符串值。如果是奇数,则返回奇数。然后使用这些值来确定哪个子映射将处理消息。
分割器
有时,在集成流中,将消息拆分为多个独立处理的消息可能很有用。Splitter 将为分割并处理这些消息。
Splitter 在很多情况下都很有用,但是有两个基本用例可以使用 Splitter:
- 消息有效载荷,包含单个消息有效载荷相同类型的项的集合。例如,携带产品列表的消息可能被分成多个消息,每个消息的有效负载是一个产品。
- 信息有效载荷,携带的信息虽然相关,但可以分为两种或两种以上不同类型的信息。例如,购买订单可能包含交付、帐单和行项目信息。交付细节可能由一个子流程处理,账单由另一个子流程处理,每一项则由另一个子流程处理。在这个用例中,Splitter 后面通常跟着一个路由器,它根据有效负载类型路由消息,以确保正确的子流处理数据。
当将消息有效负载拆分为两个或多个不同类型的消息时,通常只需定义一个 POJO 即可,该 POJO 提取传入的有效负载的各个部分,并将它们作为集合的元素返回。
例如,假设希望将携带购买订单的消息拆分为两条消息:一条携带账单信息,另一条携带项目列表。下面的 OrderSplitter 将完成这项工作:
1 | public class OrderSplitter { |
然后,可以使用 @Splitter 注解将 OrderSplitter bean 声明为集成流的一部分,如下所示:
1 |
|
在这里,购买订单到达名为 poChannel 的通道,并被 OrderSplitter 分割。然后,将返回集合中的每个项作为集成流中的单独消息发布到名为 splitOrderChannel 的通道。在流的这一点上,可以声明一个 PayloadTypeRouter 来将账单信息和项目,并路由到它们自己的子流:
1 |
|
顾名思义,PayloadTypeRouter 根据消息的有效负载类型将消息路由到不同的通道。按照这里的配置,将有效负载为类型为 BillingInfo 的消息路由到一个名为 billingInfoChannel 的通道进行进一步处理。至于项目信息,它们在 java.util.List 集合包中;因此,可以将 List 类型的有效负载映射到名为 lineItemsChannel 的通道中。
按照目前的情况,流分为两个子流:一个是 BillingInfo 对象流,另一个是 List
1 |
|
当携带 List
如果你想使用 Java DSL 来声明相同的 Splitter/Router 配置,你可以调用 split() 和 route():
1 | return IntegrationFlows |
流定义的 DSL 形式当然更简洁,如果不是更难于理解的话。它使用与 Java 配置示例相同的 OrderSplitter 来分割订单。在订单被分割之后,它被其类型路由到两个单独的子流。
服务激活器
服务激活器从输入信道接收消息并发送这些消息给的 MessageHandler。Spring 集成提供了多种的 MessageHandler 实现开箱即用(PayloadTypeRouter 就是 MessageHandler 的实现),但你会经常需要提供一些定制实现充当服务激活。作为一个例子,下面的代码说明了如何声明的 MessageHandler bean,构成为一个服务激活器:
1 |
|
通过 @ServiceActivator 注解 bean,将其指定为一个服务激活器,从所述信道处理消息命名 someChannel。至于 MessageHandler 的本身,它是通过一个 lambda 实现。虽然这是一个简单的 MessageHandler,给定的消息时,它发出其有效载荷的标准输出流。
另外,可以声明一个服务激活器,用于在返回一个新的有效载荷之前处理传入的消息。在这种情况下,这个 bean 应该是一个 GenericHandler 而非的 MessageHandler:
1 |
|
在这种情况下,服务激活器是一个 GenericHandler,其中的有效载荷为 Order 类型。当订单到达,它是通过 repository 进行保存;保存 Order 后产生的结果被发送到名称为 completeChannel 的输出通道。
注意,GenericHandler 不仅给出了有效载荷,还有消息头(即使该示例不使用任何形式的头信息)。同时也可以通过传递了 MessageHandler 或 GenericHandler 去调用在流定义中的 handler() 方法,来使用在 Java DSL 配置式中的服务激活器:
1 | public IntegrationFlow someFlow() { |
在这种情况下,MessageHandler 是作为一个 lambda,但也可以将它作为一个参考方法甚至是一个类,它实现了 MessageHandler 接口。如果给它一个 lambda 或方法引用,要知道,它是接受一个消息作为参数。
类似地,如果服务激活器不是流的结束,handler() 可以写成接受 GenericHandler 参数。从之前应用订单存储服务激活器来看,可以使用 Java DSL 对流程进行配置:
1 | public IntegrationFlow orderFlow(OrderRepository orderRepo) { |
当利用 GenericHandler 时,lambda 表达式或方法参考接受该消息的有效载荷和报头作为参数。另外,如果选择在一个流程的结束使用 GenericHandler,需要返回 null,否则会得到这表明有没有指定输出通道的错误。
网关
网关是通过一个应用程序可以将数据提交到一个集成信息流和接收这是该流的结果的响应的装置。通过 Spring Integration 实现的,网关是实现为应用程序可以调用将消息发送到集成信息流的接口。
你已经见过 FileWriterGateway 消息网关的例子。FileWriterGateway 是单向网关,它的方法接受 String 作为参数,将其写入到文件中,返回 void。同样,编写一个双向网关也很容易。当写网关接口时,确保该方法返回某个值发布到集成流程即可。
作为一个例子,假设一个网关处理接受一个 String 的简单集成信息流,并把特定的 String 转成大写。网关接口可能是这个样子:
1 | package com.example.demo; |
令人惊叹的是,没有必要实现这个接口。Spring Integration 自动提供运行时实现,这个实现会使用特定的通道进行数据的发送与接收。
当 uppercase() 被调用时,给定的 String 被发布到名为 inChannel 的集成流通道中。而且,不管流是如何定义的或是它是做什么的,在当数据到达名为 outChannel 通道时,它从 uppercase() 方法中返回。
至于 uppercase 集成流,它只有一个单一的步骤,把 String 转换为大写一个简单的集成流。以下是使用 Java DSL 配置:
1 |
|
正如这里所定义的,流程启动于名为 inChannel 的通道获得数据输入的时候。然后消息的有效负载通过转换器去执行变成大写字母的操作,这里的操作都使用 lambda 表达式进行定义。消息的处理结果被发布到名为 outChannel 的通道中,这个通道就是已经被声明为 UpperCaseGateway 接口的答复通道。
通道适配器
通道适配器代表集成信息流的入口点和出口点。数据通过入站信道适配器的方式进入到集成流中,通过出站信道适配器的方式离开集成流。
入站信道的适配器可以采取多种形式,这取决于它们引入到流的数据源。例如,声明一个入站通道适配器,它采用从 AtomicInteger 到流递增的数字。使用 Java 配置,它可能是这样的:
1 |
|
此 @Bean 方法声明了一个入站信道适配器 bean,后面跟随着 @InboundChannelAdapter 注解,它们每 1 秒(1000 ms)从注入的 AtomicInteger 提交一个数字到名 numberChannel 的通道中。
当使用 Java 配置时,@InboundChannelAdapter 意味着是一个入站通道适配器,from() 方法就是使用 Java DSL 来定义流的时候,表明它是怎么处理的。下面对于流定义的一个片段展示了在 Java DSL 配置中类似的输入通道适配器:
1 |
|
通常情况下,通道适配器通过的 Spring Integration 的多端点模块之一进行提供。举个例子,假设需要一个入站通道适配器,用它来监视指定的目录,同时将任何写入到那个目录中的文件作为消息,提交到名为 file-channel 的通道中。下面的 Java 配置使用 FileReadingMessageSource 从 Spring Integration 的文件端点模块来实现这一目标:
1 |
|
当在 Java DSL 中写入同样的 file-reading 入站通道适配器时,来自 Files 类的 inboundAdapter() 方法达到的同样的目的。出站通道适配器位于集成信息流的最后位置,将最终消息扇出到应用程序或是其他系统中:
1 |
|
服务激活器(作为消息处理的实现)往往是为出站通道适配器而存在的,特别是当数据需要被扇出到应用程序本身的时候。
值得一提的,Spring Integration 的端点模块为几种常见的用例提供了有用的消息处理程序。如在程序清单 9.3 中所看到的 FileWritingMessageHandler 出站通道适配器,这就是一个很好的例子。说到 Spring Integration 端点模块,让我们快速浏览一下准备使用的集成端点模块。
端点模块
Spring Integration 可以让你创建自己的通道适配器,这是很棒的。但是,更棒的是 Spring Integration 提供了包含通道超过两打的端点模块适配器,包括入站和出站,用于与各种常用外部系统进行集成,如表 9.1 所示。
| 模块 | 依赖的 Artifact ID |
|---|---|
| AMQP | spring-integration-amqp |
| Spring application events | spring-integration-event |
| RSS and Atom | spring-integration-feed |
| Filesystem | spring-integration-file |
| FTP/FTPS | spring-integration-ftp |
| GemFire | spring-integration-gemfire |
| HTTP | spring-integration-http |
| JDBC | spring-integration-jdbc |
| JPA | spring-integration-jpa |
| JMS | spring-integration-jms |
| spring-integration-mail | |
| MongoDB | spring-integration-mongodb |
| MQTT | spring-integration-mqtt |
| Redis | spring-integration-redis |
| RMI | spring-integration-rmi |
| SFTP | spring-integration-sftp |
| STOMP | spring-integration-stomp |
| Stream | spring-integration-stream |
| Syslog | spring-integration-syslog |
| TCP/UDP | spring-integration-ip |
| spring-integration-twitter | |
| Web | Services spring-integration-ws |
| WebFlux | spring-integration-webflux |
| WebSocket | spring-integration-websocket |
| XMPP | spring-integration-xmpp |
| ZooKeeper | spring-integration-zookeeper |
从表 9.1 可以清楚的看出 Spring Integration 提供了一套广泛的组件,以满足众多集成的需求。大多数应用程序一点都不会用到 Spring Integration 提供的功能。但是,如果你需要它们,很好,Spring Integration 几乎都能覆盖到。
更重要的是,本章在表 9.1 中列出模块,不可能涵盖提供的所有通道适配器。你已经看到,使用文件系统模块写入到文件系统的例子。而你很快就要使用电子邮件模块读取电子邮件。
每个端点模块提供通道适配器,当使用 Java 配置时,可以被声明为 bean,当时应 Java DSL 配置时,可以通过静态方法进行引用。鼓励你去探索你最感兴趣的任何端点模块。你会发现它们的使用方法相当一致。但现在,让我们把关注点转向电子邮件端点模块,看看在 Taco Cloud 应用程序中如何使用它。
创建 Email 集成流
Taco Cloud 应该能让它的用户通过 email 提交他们的 taco 设计和放置订单。你在报纸上通过发送传单和放置外卖广告,邀请大家通过电子邮件发送 taco 订单。这样做很成功!不幸的是,它有点太过于”成功”了。有这么多的电子邮件中,你必须雇用临时工做一些,无非就是阅读完所有的信件,并提交订单的详细信息到订购系统来的工作。
在本节中,将实现一个集成信息流,用于轮询 Taco Cloud 收件箱中的 taco 订单电子邮件,并解析邮件订单的详细信息,然后提交订单到 Taco Cloud 进行处理。总之,你将从邮箱端点模块中使用入站通道适配器,用于把 Taco Cloud 收件箱中的邮件提取到集成中。
下一步,在集成信息流中,电子邮件将被解析为订单对象,接着被扇出到另外一个向 Taco Cloud 的 REST API 提交订单的处理器中,在那里,它们将如同其他订单一样被处理。首先,让我们定义一个简单的配置属性的类,来捕获如何处理 Taco Cloud 电子邮件的细节:
1 |
|
正如你所看到的,EmailProperties 使用 get() 方法来产生一个 IMAP URL。流就使用这个 URL 连接到 Taco Cloud 的电子邮件服务器,然后轮询电子邮件。所捕获的属性中包括,用户名、密码、IMAP服务器的主机名、轮询的邮箱和该邮箱被轮询频率(默认为 30 秒轮询一次)。
该 EmailProperties 类是在类的级别使用了 @ConfigurationProperties 注解,注解中 prefix 被设置为 tacocloud.email。这意味着,可以在 application.yml 配置文件中详细配置 email 的信息:
1 | tacocloud: |
现在让我们使用 EmailProperties 去配置集成流。
定义这个流程时有两种选择:
- 定义在 Taco Cloud 应用程序本身里面 – 在流结束的位置,服务激活器将调用定义了的存储库来创建 taco 订单。
- 定义在一个单独的应用程序中 – 在流结束的位置,服务激活器将发送 POST 请求到 Taco Cloud API 来提交 taco 订单。
无论选择那种服务激活器的实现,对流本身没有什么影响。但是,因为你会需要一些类型来代表的 tao、order 和 ingredient,这些需要与你在 Taco Cloud 应用程序中定义的那些有一些不一样。因此在一个单独的应用程序中集成信息流,可以避免与现有的域类型混淆进行。
还可以选择使用 XML 配置、Java 配置或 Java DSL 来定义流。我比较喜欢 Java DSL 的优雅,哈哈,这就是你会用到的。随意啦,你如果对一点点额外的挑战有兴趣,可以使用其他配置风格来书写这个流。现在,让我们来看看在 Java DSL 配置下的 taco 订单电子邮件流。
1 | package tacos.email; |
taco 订单电子邮件流(在 tacoOrderEmailFlow() 方法中的定义)是由三个不同的部分组成:
- IMAP 电子邮件入站信道适配器 —— 根据 EmailProperties 的 getImapUrl() 方法返回的 IMP URL 来创建通道适配器,根据 pollRate属性来设定轮询延时。进来的电子邮件被移交到它连接到转换器的通道。
- 一种将电子邮件转换为订单对象的转换器 —— 在 EmailToOrderTransformer 中实现的转换器,其被注入到 tacoOrderEmailFlow() 方法中。从转换中所产生的订单通过另外一个通道扇出到最终组件中。
- 处理程序(作为出站通道适配器)—— 处理程序接收一个订单对象,并将其提交到 Taco Cloud 的 REST API。
可以通过将 Email 端点模块作为项目构建的依赖项,就可以对 Mail.imapInboundAdapter() 进行调用。Maven 的依赖关系如下所示:
1 | <dependency> |
EmailToOrderTransformer 这个类,通过继承 AbstractMailMessageTransformer 的方式,实现了 Spring Integration 中的 Transformer 接口,如程序清单 9.6 所示。
1 |
|
AbstractMailMessageTransformer 是处理其有效载荷为电子邮件消息的基类,其着重于,从到来的消息中将邮件信息提取到,通过 doTransform() 方法传入的 Message 对象中。
在 doTransform() 方法中,把 Message 传递到名 processPayload() 的 private 方法中,在其中将电子邮件解析为 Order 对象。这里的 Order 对象与 Taco Cloud 主应用程序中的 Order 对象虽然有些相似,但是还是不同的,它稍微简单一些:
1 | package tacos.email; |
与用于在客户的整个交付和账单信息不同,这个 Order 类只携带了客户的电子邮件。
将电子邮件解析为 taco 订单是一个有意义的事情。事实上,即使是一个简单的实现,都会涉及到几十行代码。而这几十行的代码对 Spring Integration 和如何实现转换器的讨论是没有更多的帮助的。因此,为了节省空间,准备放下对 processPayload() 方法的详细实现。
该 EmailToOrderTransformer 做的最后一件事就是返回包含 Order 对象有效载荷的 MessageBuilder。由 MessageBuilder 产生的消息,被发送到集成信息流的最后一个部分:消息处理器,推送订单到 Taco Cloud API。OrderSubmitMessageHandler,如下所示,实现了 Spring Integration 的 GenericHandler 接口,用于处理携带 Order 有效载荷的消息。
1 | package tacos.email; |
为了满足 GenericHandler 接口的要求,OrderSubmitMessageHandler 重写 handle() 方法。这个方法接收传入 Order 对象,并使用注入的 RestTemplate 通过 POST 请求中注入 ApiProperties 对象捕获的 URL 提交订单。最后,handler() 方法返回 null 以指示流处理结束。
ApiProperties 是为了避免在调用 postForObject() 方法时对 URL 进行硬编码。这是一个配置属性文件,看起来像这样:
1 |
|
在 application.yml,Taco Cloud API 的 URL 可能会像这样被配置:
1 | tacocloud: |
为了使 RestTemplate 在项目中可用,它被注入到 OrderSubmitMessageHandler 中,需要将 Spring Boot web starter 中添加到项目构建中:
1 | <dependency> |
这使得 RestTemplate 在 classpath 中可用,这也触发了 Spring MVC 的自动配置。作为一个独立的 Spring Integration 流,应用程序并不需要 Spring MVC,或是嵌入的Tomcat。因此,你应该在 application.yml 中禁用 Spring MVC 的自动配置:
1 | spring: |
spring.main.web-application-type 属性可以被设置为 servlet、reactive 或是 none,当 Spring MVC 在 classpath 中时,自动配置将这个值设置为 servlet。但是这里需要将其重写为 none,这样 Spring MVC 和 Tomcat 就不会自动配置了。(我们将在 11 章讨论将其作为一个响应式 web 应用程序的意义)。
总结
- Spring Integration 允许定义数据在进入或离开应用程序时可以通过的流。
- Integration 流可以以 XML、Java 或 Java DSL 配置的风格进行定义。
- 消息网关和通道适配器充当集成信息流的入口和出口。
- 消息可以被转化,分割,聚集,路由和由服务活化器在流动的过程中进行处理。
- 消息通道连接集成流的组件。