SpringMVC-WebFlux-高级教程-全-
SpringMVC WebFlux 高级教程(全)
一、建立本地开发环境
Spring 于 2002 年 10 月发布,是一个使用 Java 开发的开源框架和控制反转(IoC)容器,它是为 Java 平台而构建的。它已经从一个小型的库集合转变为一个大型的成熟项目集合,旨在简化开发,即使解决方案很复杂。
这本书从打包成 jar 并部署到应用服务器的经典 web 应用,到由一组易于部署在云环境中的微服务组成的应用,每个微服务都在自己的 VM 或容器上。
这一切都始于开发人员在编写和运行代码之前需要安装的一组工具。
如果你知道如何使用 SDKMAN, 1 你可以跳过下两节解释如何安装 Java SDK 和 Gradle。如果你不知道如何使用 SDKMAN 或者从来不知道它的存在,试试看;它是一个管理多个 SDK 并行版本的工具。如果您有其他项目在本地使用不同版本的 Java 和 Gradle,这个工具可以帮助您在它们之间轻松切换。
安装 Java SDK
由于 Spring 是一个用来编写和运行 Spring 应用的 Java 框架,所以您需要安装 Java SDK。这个项目是用 JDK 14 编写建造的。要安装 JDK 14,请从 www.oracle.com/java/ 下载与您的操作系统匹配的 JDK 并安装。因此,如果您正在构建一个应用,并打算使用它来获取经济利益,您可能需要考虑 Oracle 许可或使用开源 JDK。 2

图 1-1
Java 标志 3
我们建议您将JAVA_HOME环境变量设置为指向 Java 14 的安装目录(JDK 解压缩的目录),并将$JAVA_HOME/bin (%JAVA_HOME%\bin添加到系统的常规路径中(Windows 用户使用))。这背后的原因是为了确保用 Java 编写的任何其他开发应用都使用这个版本的 Java,并防止开发过程中出现奇怪的不兼容错误。如果您想从终端运行构建,您肯定使用了预期的 Java 版本。
重新启动终端,并通过打开终端(Windows 中的命令提示符或 macOS 和 Linux 上安装的任何类型的终端)并键入以下命令,验证操作系统看到的 Java 版本是否是您安装的版本。
> java -version # to check the runtime
然后是下面。
> javac -version # to check the compiler
您应该会看到类似下面的输出。
> java -version
java version "14.0.2" 2020-07-14
Java(TM) SE Runtime Environment (build 14.0.2+12-46)
Java HotSpot(TM) 64-Bit Server VM (build 14.0.2+12-46, mixed mode, sharing)
> javac -version
javac 14.0.2
安装 Gradle
Gradle 是一个开放源码的构建自动化工具,它足够灵活,可以构建几乎任何类型的软件。它在配置文件中使用 Groovy,这使得它是可定制的。本书附带的项目是用 Gradle 6.x 成功搭建的。

图 1-2
Gradle logo 4
本书附带的源代码可以使用 Gradle Wrapper 编译和执行,Gradle Wrapper 是 Windows 上的批处理脚本和其他操作系统上的 shell 脚本。
当您通过包装器启动 Gradle 构建时,Gradle 会自动下载到您的项目中来运行构建;因此,您不需要在您的系统上显式安装它。接下来介绍的推荐开发编辑器知道如何使用 Gradle Wrapper 构建代码。在 www.gradle.org/docs/current/userguide/gradle_wrapper.html 的公开文档中有关于如何使用 Gradle 包装器的说明。
推荐的做法是将代码和构建工具分开保存。如果你决定在你的系统上安装 Gradle,你可以从 www.gradle.org 下载二进制文件,解压并将内容复制到硬盘上。(或者,如果您有兴趣,可以下载包含二进制文件、源代码和文档的完整包。)创建一个GRADLE_HOME环境变量,并将其指向解包 Gradle 的位置。此外,将$GRADLE_HOME/bin(对于 Windows 用户为%GRADLE_HOME%\bin)添加到系统的常规路径中,以便您可以在终端中构建项目。
Gradle 被选为本书源代码的构建工具,因为它设置简单,配置文件小,定义执行任务灵活,Spring 团队目前使用它来构建所有的 Spring 项目。
要验证操作系统是否看到您刚刚安装的 Gradle 版本,请打开一个终端(Windows 中的命令提示符,以及安装在 macOS 和 Linux 上的任何类型的终端)并键入
gradle -version
您应该会看到类似下面的内容。
gradle -version
------------------------------------------------------------
Gradle 6.7
------------------------------------------------------------
Build time: 2020-08-04 22:01:06 UTC
Revision: 00a2948da9ea69c523b6b094331593e6be6d92bc
Kotlin: 1.3.72
Groovy: 2.5.12
Ant: Apache Ant(TM) version 1.10.8 compiled on May 10 2020
JVM: 14.0.2 (Oracle Corporation 14.0.2+12-46)
OS: Mac OS X 10.15.6 x86_64
运行这个命令还可以验证 Gradle 使用的是预期的 JDK 版本。
安装 Apache Tomcat
Web 应用应该由应用服务器托管,除非它们是使用 Spring Boot 构建的,在这种情况下,依赖嵌入式服务器更实际。Apache Tomcat 5 是 Java Servlet、JavaServer Pages、Java Expression Language 和 WebSocket 技术的开源实现。

图 1-3
阿帕奇雄猫标志 6
本书的 Spring MVC 项目是在 Apache Tomcat 9.x 中测试的,要安装 Apache Tomcat,去官方网站获取与你的操作系统匹配的版本。在熟悉的地方打开包装。在基于 Unix 的系统上,您可以使用软件包管理器来安装它。如果你手动安装,记得转到bin目录,并使所有文件可执行。
推荐的 IDE
我们建议您将 IntelliJ IDEA 用于本书中的代码。它是最智能的 Java IDE。

图 1-4
IntelliJ IDEA logo7
IntelliJ IDEA 为 Java EE 提供了优秀的特定于框架的编码帮助和生产力提升特性,Spring 也包含了对 Gradle 的良好支持。它是帮助您专注于学习 Spring(而不是如何使用 IDE)的完美选择。可以从 JetBrains 官方网站( www.jetbrains.com/idea/ )下载。它在你的操作系统上也很轻,并且易于使用。
IntelliJ IDEA 还可以与 Apache Tomcat 很好地集成,这允许您部署项目以从编辑器启动和停止服务器。
既然已经讨论了工具,我们来谈谈项目。
书店项目
包含本书源代码的项目被组织成一个多模块的梯度项目。每一章都有一个或多个相应的项目,您可以很容易地识别它们,因为它们以章节号为前缀。表 1-1 列出了这些项目,并对每个项目进行了简短描述。
表 1-1
书店项目模块
|回
|
项目名
|
描述
|
| --- | --- | --- |
| – | 书店-MVC-共享 | Spring MVC 项目使用的实体和实用程序类 |
| – | 书店-共享 | Spring Boot 项目使用的实体和公用事业分类 |
| one | 第一章-书店 | 一个简单的 Spring Boot Web MVC 项目,具有典型的 Web 结构(静态资源在webapp目录中) |
| one | 第一章-MVC-书店 | 一个简单的 Spring MVC 项目。 |
| Two | 第二章-书店 | 一个简单的 Spring Boot Web MVC 项目,具有典型的引导结构(resources/static目录中的静态资源) |
| Two | 第二章-样本 | 一个简单的项目与第二章的非网页样本 |
| five | 第五章-书店 | Spring Boot 书店 MVC 项目,使用百里香叶视图 |
| six | 第六章-书店 | 书店 Spring Boot MVC 项目,使用 Apache Tiles 视图 |
| seven | 第七章-书店 | 支持上传文件的 Spring Boot 书店 MVC 项目 |
| eight | 第八章-书店 | 支持各种视图类型的 Spring Boot 书店 MVC 项目 |
| nine | 第 9-1 章-书店-禁止开机 | 部署在 Apache Tomcat 上的书店 Spring WebFlux 项目(使用反应式控制器) |
| nine | 第 9-2 章-书店 | 书店 Spring Boot WebFlux 项目(使用反应式控制器) |
| nine | 第 9-3 章-书店 | 书店 Spring Boot WebFlux 项目(使用功能端点) |
| Ten | 第 10-1 章-书店 | 书店 Spring Boot WebFlux 项目通过 web 过滤器支持不区分大小写的 URIs 和国际化(最优雅的解决方案) |
| Ten | 第 10-2 章-书店 | 书店 Spring Boot WebFlux 项目支持验证 |
| Ten | 第 10-3 章-书店 | 书店 Spring Boot WebFlux 项目通过LocaleContextResolver支持不区分大小写的 URIs 和国际化 |
| Eleven | 第 11-1 章-书店 | 使用 WebSocket 聊天的 Spring Boot 书店 MVC 项目 |
| Eleven | 第 11-2 章-书店 | 书店 Spring Boot WebFlux 项目,通过 WebSocket 上的反应流发布科技新闻 |
| Eleven | 第 11-3 章-客户-书店 | 火箭客户项目 |
| Eleven | 第 11-3 章服务器-书店 | RSocket 服务器项目 |
| Eleven | 第 11-4 章-服务器-书店 | 书店 Spring Boot WebFlux 项目使用反应式安全 |
| Twelve | 第十二章-书店 | 使用 Spring Security 的 Spring Boot 书店 MVC 项目 |
| Twelve | 第十二章-MVC-书店 | 使用 Spring Security 的书店 Spring MVC 项目 |
| Thirteen | 第十三章-账户服务 | 微服务提供反应式账户 API |
| Thirteen | 第十三章-图书服务 | 提供反应式图书 API 的微服务 |
| Thirteen | 第十三章-发现-服务 | 微服务发现并注册其他微服务 |
| Thirteen | 第十三章-新发布-服务 | 微服务提供单个反应端点,随机发出Book个实例 |
| Thirteen | 第十三章-展示-服务 | 具有与其他界面交互的百里香网络界面的微服务 |
| Thirteen | 第十三章-技术新闻-服务 | 微服务提供单一反应端点,随机发出代表技术新闻的String实例 |
名称中包含-mvc-和chapter9-1-bookstore-no-boot的项目被编译并打包成一个*.war,可以在 Apache Tomcat 中运行。除了chapter2-sample,之外,所有其他项目都是使用 Spring Boot 构建的,并且可以通过执行它们的主类来运行。chapter2-sample project有多个主类,您可以运行它们来测试特定的场景。
构建项目
一旦安装了推荐的工具,下一步就是从 GitHub 获取项目源代码。
GitHub 项目页面位于 https://github.com/Apress/spring-mvc-and-webflux 。
您可以下载 repo 页面源代码,使用 IntelliJ IDEA 克隆项目,或者在终端中使用Git克隆项目。您可以使用 HTTPS 或 Git 协议——任何感觉熟悉和简单的协议。
您可以使用 IntelliJ IDEA 构建该项目,但是如果您是第一次打开它,则需要一段时间来弄清楚项目结构并对文件进行索引。我们建议您打开一个终端,通过执行清单 1-1 中的命令来构建项目。输出应该类似于这个,而且它肯定包含 BUILD SUCCESSFUL。
> gradle clean build
...
BUILD SUCCESSFUL in 3m 1s
150 actionable tasks: 148 executed, 2 up-to-date
Listing 1-1Building the Project for This Book
一旦在终端中构建了项目,您就可以验证您拥有正确的项目和正确的工具。现在是时候在 IntelliJ IDEA 中打开它了。
你注意到的第一件事是 IntelliJ IDEA 正试图决定 Gradle 和 JDK 版本。而且它并不总是有效的,特别是当您的系统上有每一个的多个版本时。在右上角,您可能会看到如图 1-5 所示的通知。

图 1-5
IntelliJ 想法试图推断格雷尔和 JDK 版本
要解决这个问题,您必须执行以下操作。
-
First, if you want to use Gradle Wrapper, skip this step. Otherwise, go to the Gradle view, click the little wrench button (the one labeled Build Tool Settings), and a window appears to allow you to choose the Gradle version. If you have Gradle installed on your system, and the
GRADLE_HOMEenvironment variable is set up, IntelliJ IDEA finds it. Still, it does not use it if the project contains a Gradle Wrapper configuration. To use Gradle on your system, choose Specified location in the section of the window marked in Figure 1-6.![img/300017_2_En_1_Fig6_HTML.jpg]()
图 1-6
IntelliJ IDEA Gradle 和 Gradle JVM 设置
同时,确保 Gradle JVM 也设置为 JDK 14。
-
In the IntelliJ IDEA main menu, select File > Project structure…. The Project Structure window allows you to configure the project SDK and the project language level. Make sure it is JDK 14 for both, as depicted in Figure 1-7.
![img/300017_2_En_1_Fig7_HTML.jpg]()
图 1-7
IntelliJ IDEA 项目 JDK 设置
如果一切顺利,IntelliJ IDEA 使用格雷和 JDK 来构建你的项目并执行测试。如果您想在 IntelliJ IDEA 中构建项目,请使用 Gradle 视图。当项目被正确加载时,所有的模块应该和一组按目的分组的分级任务一起列出。在构建组下,一个名为构建的任务相当于清单 1-1 中的 Gradle 命令。图 1-8 显示了 IntelliJ IDEA 中一次成功的 Gradle 构建运行。

图 1-8
IntelliJ IDEA 成功的分级构建
运行项目
不使用 Spring Boot 构建的项目需要部署到 Apache Tomcat 服务器上。在成功的 Gradle 构建之后,应该已经为所有项目生成了工件。要在本地 Apache 服务器上部署项目,您必须执行以下操作。
-
单击右上角的项目启动程序列表。
-
选择编辑配置… 。
-
在“编辑配置”窗口中,选择要创建的启动器类型。
-
In the upper-left corner, click the
+button. In the list of launcher types, select Tomcat Server > Local (see Figure 1-9).![img/300017_2_En_1_Fig9_HTML.jpg]()
图 1-9
IntelliJ IDEA 启动器选项
-
在运行/调试配置窗口中,需要用 Apache 服务器的位置和要部署的项目填充一个表单。首先,命名配置。选择与您的项目相关的名称。
-
点击配置按钮。
-
选择您的 Apache Tomcat 服务器目录。
-
点击确定按钮。
-
Click the Fix button. You are warned that you must select something to deploy (see Figure 1-10).
![img/300017_2_En_1_Fig10_HTML.jpg]()
图 1-10
用于配置要部署的 Apache Tomcat 服务器和工件的 IntelliJ IDEA 启动器选项
-
在列表中,选择要部署的项目。
-
接下来,在 Deployment 选项卡中,您可以编辑上下文路径,因为自动生成的路径很奇怪。
-
Click the OK button, and you are done (see Figure 1-11).
![img/300017_2_En_1_Fig11_HTML.jpg]()
图 1-11
用于配置要部署的工件的 IntelliJ IDEA 启动器选项
现在,您的启动器的名称出现在第一步中提到的列表中。您可以通过单击 Run 按钮(启动器列表旁边的绿色三角形)来启动 Apache Tomcat。如果一切顺利,IntelliJ 会打开一个浏览器选项卡,进入项目的主页。
IntelliJ IDEA 中 Apache Tomcat 的日志控制台可以在部署失败时提供更多信息。图 1-12 显示了 chapter1-mvc-bookstore 项目(在成功部署之后)和 Apache Tomcat 日志控制台的页面。

图 1-12
Apache Tomcat 日志控制台
运行 Spring Boot 项目甚至更容易。找到主类,右击它,选择运行。如果项目构建成功,应用应该启动并出现在服务视图中,如图 1-13 所示。

图 1-13
IntelliJ IDEA Spring Boot 主应用类和服务视图
IntelliJ IDEA 似乎在 Gradle 多模块项目上遇到了一些困难,因为对于 Spring Boot Web 应用,它无法检测工作目录,这意味着它无法正确构建应用上下文。要解决这个问题,请打开为 Spring Boot 应用生成的项目启动器,并选择您想要运行的项目的目录作为工作目录选项的值,如图 1-14 所示。

图 1-14
带有显式填充的工作目录的 IntelliJ IDEA Spring Boot 启动器
摘要
希望本章的说明足以帮助你开始。如果有任何遗漏或不清楚的地方,请随时首先询问 Google。如果这不起作用,在 GitHub 上创建一个问题。
编码快乐!
二、Spring 框架基础
Spring 框架是由 Rod Johnson (Wrox,2002)为专家一对一 J2EE 设计和开发编写的代码发展而来的。 1 该框架结合了业界 Java 企业版(JEE)开发的最佳实践,并与同类最佳的第三方框架相集成。如果您需要一个尚不存在的集成,它还提供了简单的扩展点来编写您自己的集成。该框架的设计考虑到了开发人员的生产力,它使得使用现有的、有时很麻烦的 Java 和 JEE API 变得更容易。
Spring Boot 于 2014 年 4 月发布,旨在简化云时代的应用开发。Spring Boot 使得创建独立的、生产级的基于 Spring 的应用变得容易。这些应用可以独立运行,也可以部署到传统的 Servlet 容器或 JEE 服务器上。
Spring Boot 坚持认为 Spring 平台是一个整体,并支持第三方库。它让您不费吹灰之力就能开始,但如果您想要更复杂的配置或让配置对您来说更简单,它就不会碍事。
在开始我们的 Spring MVC 和 Spring WebFlux 之旅之前,我们先快速回顾一下 Spring(也称为 Spring Framework )。Spring 是 Java 企业软件开发的事实上的标准。它介绍了依赖注入、面向方面编程 (AOP),以及用plain-old-Java-objects(POJO)编程。
在这一章中,我们将讨论依赖注入和 AOP。具体来说,我们将介绍 Spring 如何帮助我们实现依赖注入,以及如何利用编程为我们带来优势。为了做这里提到的事情,我们探索控制反转(IoC)容器;应用上下文。
这里我们只涉及 Spring 框架的必要基础。如果想要更深入的了解它,我们建议优秀的 Spring 框架文档 2 或书籍如 Pro Spring 5 (Apress,2017) 3 或 Spring 5 Recipes,4thEdition(Apress,2017) 4 。
除了 Spring 框架复习之外,我们还将触及 Spring Boot 的基础知识。关于 Spring Boot 更深入的信息,我们建议优秀的 Spring Boot 参考指南 5 或 Spring Boot 2 食谱 (Apress,2018) 6 。
让我们从快速浏览一下 Spring 框架和组成它的模块开始。
你可以在 chapter2-samples 项目中找到本章的示例代码。示例的不同部分包含一个带有main方法的类,您可以运行该方法来执行代码。
Spring 框架
在介绍中,我们提到了 Spring 框架是由 Rod Johnson 为《一对一 J2EE 设计和开发专家》一书编写的代码演变而来的。这本书旨在解释 JEE 的一些复杂情况以及如何克服它们。虽然 JEE 的许多复杂性和问题已经在新的 JEE 规范中解决了(特别是从 JEE 6 开始),但是 Spring 已经变得非常流行,因为它简单(不是简单化!)构建应用的方法。它还为不同的技术提供了一致的编程模型,无论是数据访问还是消息传递基础设施。该框架允许开发人员针对离散的问题,专门为它们构建解决方案。
该框架由几个模块组成(见图 2-1 ),这些模块协同工作并相互构建。我们几乎可以精挑细选我们想要使用的模块。

图 2-1
Spring 框架概述
图 2-1 中的所有模块代表 jar 文件,如果我们需要特定的技术,我们可以将它们包含在类路径中。表 2-1 列出了 Spring 5.2 附带的所有模块,包括每个模块内容的简要描述和用于依赖管理的任何工件名称。实际 jar 文件的名称可能不同,这取决于您获取模块的方式。
表 2-1
Spring 框架模块概述
|组件
|
假象
|
描述
|
| --- | --- | --- |
| 面向切面编程 | Spring-aop | Spring 的基于代理的 AOP 框架 |
| 方面 | Spring 方面 | Spring 基于 AspectJ 的方面 |
| 豆子 | 春豆 | Spring 的核心 bean 工厂支持 |
| 语境 | Spring 的背景 | 应用上下文运行时实现;还包含调度和远程处理支持类 |
| 语境 | spring 上下文索引器 | 支持提供应用中使用的 beans 的静态索引;提高启动性能 |
| 语境 | spring 上下文支持 | 将第三方库与 Spring Integration 的支持类 |
| 核心 | Spring 芯 | 核心实用程序 |
| 表达语言 | Spring 的表情 | Spring 表达式语言(SpEL)的类 |
| 使用仪器 | Spring 乐器 | 与 Java 代理一起使用的检测类 |
| 作业控制语言 | Spring-jcl | Spring 专用于 commons-logging 的替代品 |
| 数据库编程 | spring-jdbc | JDBC 支持包,包括数据源设置类和 JDBC 访问支持 |
| (同 JavaMessageService)Java 消息服务 | spring-jms | JMS 支持包,包括同步 JMS 访问和消息监听器容器 |
| 对象关系映射(Object Relation Mapping) | Spring 形状 | ORM 支持包,包括对 Hibernate 5+和 JPA 的支持 |
| 信息发送 | Spring 短信 | Spring 消息传递抽象;由 JMS 和 WebSocket 使用 |
| 泌酸调节素 | Spring-oxm | XML 支持包,包括对对象到 XML 映射的支持;还包括对 JAXB、JiBX、XStream 和 Castor 的支持 |
| 试验 | Spring 试验 | 测试支持类 |
| 处理 | Spring-tx | 交易基础设施类别;包括 JCA 集成和 DAO 支持类 |
| 网 | Spring 网 | 适用于任何 web 环境的核心 web 包 |
| webflux | spring web lux | Spring WebFlux 支持包包括对 Netty 和 Undertow 等几种反应式运行时的支持 |
| 小型应用 | spring web VC(Spring web 控制台) | 在 Servlet 环境中使用的 Spring MVC 支持包包括对通用视图技术的支持 |
| WebSocket | Spring 腹板插座 | Spring WebSocket 支持包包括对 WebSocket 协议通信的支持 |
大多数模块都依赖于 Spring 框架中的其他模块。核心模块是这一规则的例外。图 2-2 给出了常用模块及其对其他模块的依赖性的概述。请注意,图中缺少了仪器、方面和测试模块;这是因为它们的依赖依赖于项目和使用的其他模块。其他依赖项根据项目的需要而有所不同。

图 2-2
Spring 框架模块依赖关系
依赖注入
在依赖注入(DI)中,对象在构造时被赋予依赖关系。它是一个 Spring 框架基础。你可能听说过控制反转 (IoC)。 7 国际奥委会是一个更宽泛、更通用的概念,可以用不同的方式来称呼。IoC 允许开发人员分离并专注于对企业应用的给定部分重要的事情,而不用考虑系统的其他部分做什么。接口编程是考虑解耦的一种方式。
几乎每个企业应用都由需要协同工作的多个组件组成。在 Java 企业开发的早期,我们简单地将构造那些对象(以及它们需要的对象)的所有逻辑放在构造器中(参见清单 2-1 )。乍一看,这种方法没有错;然而,随着时间的推移,对象构造变得很慢,对象拥有了很多本不应该拥有的知识(参见单责任原则)。 8 这些类变得难以维护,并且它们也难以进行单元和/或集成测试。
package com.apress.prospringmvc.moneytransfer.simple;
import java.math.BigDecimal;
import com.apress.prospringmvc.moneytransfer.domain.Account;
import com.apress.prospringmvc.moneytransfer.domain.MoneyTransferTransaction;
import com.apress.prospringmvc.moneytransfer.domain.Transaction;
import com.apress.prospringmvc.moneytransfer.repository.AccountRepository;
import com.apress.prospringmvc.moneytransfer.repository.MapBasedAccountRepository;
import com.apress.prospringmvc.moneytransfer.repository.MapBasedTransactionRepository;
import com.apress.prospringmvc.moneytransfer.repository.TransactionRepository;
import com.apress.prospringmvc.moneytransfer.service.MoneyTransferService;
public class SimpleMoneyTransferServiceImpl implements MoneyTransferService {
private AccountRepository accountRepository = new MapBasedAccountRepository();
private TransactionRepository transactionRepository = new MapBasedTransactionRepository();
@Override
public Transaction transfer(String source, String target, BigDecimal amount) {
Account src = this.accountRepository.find(source);
Account dst = this.accountRepository.find(target);
src.credit(amount);
dst.debit(amount);
MoneyTransferTransaction transaction = new MoneyTransferTransaction(src, dst, amount);
this.transactionRepository.store(transaction);
return transaction;
}
}
Listing 2-1A MoneyTransferService Implementation with Hardcoded Dependencies
从清单 2-1 程序到接口的类,但是它仍然需要知道接口的具体实现,只是为了进行对象构造。通过解耦构造逻辑(协作对象)来应用 IoC 使得应用更易于维护,并增加了可测试性。有七种方法可以分离这种依赖关系构造逻辑。
-
工厂模式
-
服务定位器模式
-
依赖注入
-
基于构造函数
-
基于 Setter
-
基于现场
-
-
情境化查找
当使用工厂模式、服务定位器模式或上下文化查找时,需要依赖关系的类仍然具有一些关于获取依赖关系的知识。这可以使事情更容易维护,但仍然很难测试。清单 2-2 显示了来自 JNDI (Java 命名和目录接口)的上下文化查找。构造函数代码需要知道如何查找和处理异常。
package com.apress.prospringmvc.moneytransfer.jndi;
import javax.naming.InitialContext;
import javax.naming.NamingException;
//other import statements omitted.
public class JndiMoneyTransferServiceImpl implements MoneyTransferService {
private AccountRepository accountRepository;
private TransactionRepository transactionRepository;
public JndiMoneyTransferServiceImpl() {
try {
InitialContext context = new InitialContext();
this.accountRepository = (AccountRepository) context.lookup("accountRepository");
this.transactionRepository = (TransactionRepository) context.lookup("transactionRepository");
} catch (NamingException e) {
throw new IllegalStateException(e);
}
}
//transfer method omitted, same as listing 2-1
}
Listing 2-2MoneyTransferService Implementation with Contextualized Lookup
前面的代码不是特别干净;例如,想象来自不同上下文的多个依赖项。代码会很快变得混乱,越来越难以进行单元测试。
为了解决对象构造器中的构造/查找逻辑,我们可以使用依赖注入。我们只是传递给对象完成工作所需的依赖关系。这使得我们的代码干净、解耦,并且易于测试(参见清单 2-3 )。依赖注入是一个过程,其中对象指定它们所使用的依赖。IoC 容器使用该规范;当它构造一个对象时,它也注入它的依赖项。这样,我们的代码更干净,我们不再用构造逻辑来增加类的负担。维护更容易,进行单元和/或集成测试也更容易。测试更容易,因为我们可以注入一个存根或模拟对象来验证我们的对象的行为。
package com.apress.prospringmvc.moneytransfer.constructor;
// import statements ommitted
public class MoneyTransferServiceImpl implements MoneyTransferService {
private final AccountRepository accountRepository;
private final TransactionRepository transactionRepository;
public MoneyTransferServiceImpl(AccountRepository accountRepo,
TransactionRepository transactionRepo) {
this.accountRepository = accountRepo;
this.transactionRepository = transactionRepo;
}
//transfer method omitted, same as listing 2-1
}
Listing 2-3A MoneyTransferService Implementation with Constructor-Based Dependency Injection
顾名思义,基于构造函数的依赖注入使用构造函数来注入对象中的依赖。清单 2-3 使用基于构造函数的依赖注入。它有一个接受两个对象作为参数的构造函数:com.apress.prospringmvc.moneytransfer.repository.AccountRepository和com.apress.prospringmvc.moneytransfer.repository.TransactionRepository。当我们构造一个com.apress.prospringmvc.moneytransfer.constructor.MoneyTransferServiceImpl的实例时,我们需要给它所需的依赖项。
基于 Setter 的依赖注入使用一个 setter 方法来注入依赖。JavaBeans 规范定义了 setter 和 getter 方法。如果我们有一个名为setAccountService的方法,我们设置一个名为accountService的属性。属性名是使用方法名创建的,减去“set”,第一个字母小写(完整规范在 JavaBeans 规范中)?? 9。清单 2-4 展示了一个基于 setter 的依赖注入的例子。一个属性不一定要同时有 getter 和 setter。属性可以是只读的(只定义了一个 getter 方法)或只写的(只定义了一个 setter 方法)。清单 2-4 只显示了 setter 方法,因为我们只需要写属性;在内部,我们可以直接引用字段。
package com.apress.prospringmvc.moneytransfer.setter;
// imports ommitted
public class MoneyTransferServiceImpl implements MoneyTransferService {
private AccountRepository accountRepository;
private TransactionRepository transactionRepository;
public void setAccountRepository(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
public void setTransactionRepository(TransactionRepository transactionRepo) {
this.transactionRepository = transactionRepository;
}
//transfer method omitted, same as listing 2-1
}
Listing 2-4A MoneyTransferService Implementation with Setter-Based Dependency
Injection
最后,还有使用注释的基于字段的依赖注入(参见清单 2-5 )。我们不需要指定构造函数参数或 setter 方法来设置依赖关系。我们首先定义一个可以保存依赖关系的类级字段。接下来,我们在该字段上添加了一个注释,以表达我们将该依赖项注入到对象中的意图。Spring 接受几种不同的注释:@Autowired、@Resource和@Inject。所有这些注释或多或少都以相同的方式工作。深入解释这些注释之间的差异不在本书的范围内,所以如果你想了解更多,我们建议使用 Spring Boot 参考指南或 Pro Spring 5 (Apress,2017)。主要区别在于@Autowired注释来自 Spring 框架,而@Resource和@Inject是 Java 标准注释。
package com.apress.prospringmvc.moneytransfer.annotation;
import org.springframework.beans.factory.annotation.Autowired;
//other imports omitted
public class MoneyTransferServiceImpl implements MoneyTransferService {
@Autowired
private AccountRepository accountRepository;
@Autowired
private TransactionRepository transactionRepository;
//transfer method omitted, same as listing 2.1
}
Listing 2-5A MoneyTransferService Implementation with Field-Based Dependency Injection

@Autowired@Inject可以放在方法和构造函数上表示依赖注入配置,即使有多个实参!当对象只有一个构造函数时,可以省略注释。**
*综上所述,我们出于以下原因想要使用依赖注入。
-
清除器代码
-
去耦代码
-
更简单的代码测试
前两个原因使我们的代码更容易维护。代码更容易测试的事实应该允许我们编写单元测试来验证我们的对象的行为——以及我们的应用。
应用上下文
为了在 Spring 中进行依赖注入,你需要一个应用上下文。在 Spring 中,这是一个org.springframework.context.ApplicationContext接口的实例。应用上下文负责管理其中定义的 beans。它还支持更复杂的事情,比如将 AOP 应用于其中定义的 beans。
Spring 提供了几种不同的ApplicationContext实现(参见图 2-3 )。这些实现中的每一个都提供了相同的特性,但是在加载应用上下文配置的方式上有所不同。图 2-3 也向我们展示了org.springframework.web.context.WebApplicationContext界面,它是在网络环境中使用的ApplicationContext界面的特殊版本。

图 2-3
各种ApplicationContext实现(简化)
如前所述,不同的实现具有不同的配置机制(即 XML 或 Java)。表 2-2 显示了默认配置选项,并指出资源加载位置。
表 2-2
应用上下文概述
|履行
|
位置
|
文件类型
|
| --- | --- | --- |
| ClassPathXmlApplicationContext | 类路径 | 可扩展置标语言 |
| FileSystemXmlApplicationContext | 文件系统 | 可扩展置标语言 |
| AnnotationConfigApplicationContext | 类路径 | 爪哇 |
| XmlWebApplicationContext | Web 应用根 | 可扩展置标语言 |
| AnnotationConfigWebApplicationContext | Web 应用类路径 | 爪哇 |
让我们来看一个基于 Java 的配置文件——com.apress.prospringmvc.moneytransfer.annotation.ApplicationContextConfiguration类(参见清单 2-6 )。该类中使用了两个注释:org.springframework.context.annotation.Configuration和org.springframework.context.annotation.Bean。第一个将我们的类构造为一个配置文件,而第二个表示该方法的结果被用作创建 bean 的工厂。默认情况下,bean 的名称是方法名称。
在清单 2-6 中,有三个 beans。它们被命名为accountRepository、transactionRepository和moneyTransferService。我们还可以通过在@Bean注释上设置name属性来显式指定一个 bean 名称。
package com.apress.prospringmvc.moneytransfer.annotation;
import com.apress.prospringmvc.moneytransfer.repository.AccountRepository;
import com.apress.prospringmvc.moneytransfer.repository.MapBasedAccountRepository;
import com.apress.prospringmvc.moneytransfer.repository.MapBasedTransactionRepository;
import com.apress.prospringmvc.moneytransfer.repository.TransactionRepository;
import com.apress.prospringmvc.moneytransfer.service.MoneyTransferService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ApplicationContextConfiguration {
@Bean
public AccountRepository accountRepository() {
return new MapBasedAccountRepository();
}
@Bean
public TransactionRepository transactionRepository() {
return new MapBasedTransactionRepository();
}
@Bean
public MoneyTransferService moneyTransferService() {
return new MoneyTransferServiceImpl();
}
}
Listing 2-6The ApplicationContextConfiguration Configuration File
配置类可以是abstract;但是,他们不可能是final。为了解析这个类,Spring 可能会创建一个 configuration 类的动态子类。
拥有一个只有@Configuration注释的类是不够的。我们还需要一些东西来引导我们的应用上下文。我们用它来启动我们的应用。在示例项目中,这是MoneyTransferSpring类的责任(参见清单 2-7 )。这个类通过创建一个org.springframework.context.annotation.AnnotationConfigApplicationContext的实例来引导我们的配置,并将包含我们配置的类传递给它(参见清单 2-6 )。
package com.apress.prospringmvc.moneytransfer.annotation;
import com.apress.prospringmvc.ApplicationContextLogger;
import com.apress.prospringmvc.moneytransfer.domain.Transaction;
import com.apress.prospringmvc.moneytransfer.service.MoneyTransferService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.math.BigDecimal;
public class MoneyTransferSpring {
private static final Logger logger =
LoggerFactory.getLogger(MoneyTransferSpring.class);
/**
* @param args
*/
public static void main(String[] args) {
ApplicationContext ctx =
new AnnotationConfigApplicationContext(ApplicationContextConfiguration.class);
transfer(ctx);
ApplicationContextLogger.log(ctx);
}
private static void transfer(ApplicationContext ctx) {
MoneyTransferService service =
ctx.getBean("moneyTransferService", MoneyTransferService.class);
Transaction transaction = service.transfer("123456", "654321", new BigDecimal("250.00"));
logger.info("Money Transfered: {}", transaction);
}
}
Listing 2-7The MoneyTransferSpring Class
最后,请注意,应用上下文可以在一个层次结构中。我们可以有一个应用上下文作为另一个上下文的父上下文(见图 2-4 )。一个应用上下文只能有一个父级,但可以有多个子级。子上下文可以访问父上下文中定义的 beans 但是,父 bean 不能访问子上下文中的 bean。例如,如果我们在父上下文中启用事务,这将不适用于子上下文(请参阅本章后面的“启用功能”一节)。

图 2-4
应用上下文层次结构
这个特性允许我们将应用 bean(例如,服务、存储库和基础设施)与 web beans(例如,请求处理程序和视图)分开。这种分离是有用的。例如,假设多个 servlets 需要重用相同的应用 beans。我们可以简单地重用已经存在的实例,而不是为每个 servlet 重新创建它们。当一个 servlet 处理 web UI,另一个 servlet 处理 web 服务时,就会出现这种情况。
资源加载
表 2-2 提供了不同ApplicationContext实现和默认资源加载机制的概述。然而,这并不意味着您只能从默认位置加载资源。您还可以通过包含适当的前缀从特定位置加载资源(参见表 2-3 )。
表 2-3
前缀概述
|前缀
|
位置
|
| --- | --- |
| classpath: | 类路径的根 |
| file: | 文件系统 |
| http: | Web 应用根 |
除了能够指定从哪里加载文件之外,还可以使用 ant 样式的正则表达式来指定加载哪些文件。ant 样式的正则表达式是包含和/或*字符的资源位置。**一个字符表示“在当前级别”或“单个级别”,而多个字符表示“这个和所有子级别”
表 2-4 显示了一些例子。这种技术只在处理类路径或文件系统上的文件资源时有效;它不适用于 web 资源或包名。
表 2-4
蚂蚁风格的正则表达式
|表示
|
描述
|
| --- | --- |
| classpath:/META-INF/spring/*.xml | 从 META-INF/spring 目录中的类路径加载所有带有 XML 文件扩展名的文件 |
| file:/var/conf/*/.properties | 从/var/conf 目录和所有子目录中加载具有属性文件扩展名的所有文件 |
组件扫描
Spring 还有一个叫的东西,组件扫描。简而言之,这个特性使 Spring 能够扫描您的类路径,寻找用org.springframework.stereotype.Component(或者像@Service, @Repository, @Controller或org.springframework.context.annotation.Configuration这样的专用注释)注释的类。如果我们想要启用组件扫描,我们需要指示应用上下文这样做。org.springframework.context.annotation.ComponentScan注释使我们能够做到这一点。这个注释需要放在我们的配置类中,以启用组件扫描。清单 2-8 显示了修改后的配置类。
package com.apress.prospringmvc.moneytransfer.scanning;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
/**
* @author Marten Deinum
*/
@Configuration
@ComponentScan(basePackages = {
"com.apress.prospringmvc.moneytransfer.scanning",
"com.apress.prospringmvc.moneytransfer.repository" })
public class ApplicationContextConfiguration {}
Listing 2-8Implementing Component Scanning with ApplicationContextConfiguration
看一下清单 2-8 就会发现这个类没有更多内容。只有两个注解。一个注释表示该类用于配置,而另一个注释启用组件扫描。组件扫描注释配置有要扫描的包。
不指定一个包来扫描整个类路径或者使用太宽的包(像com.apress)被认为是不好的做法。这可能导致扫描大多数或所有类,从而严重影响应用的启动时间。
领域
默认情况下,Spring 应用上下文中的所有 beans 都是单态的。顾名思义,bean 只有一个实例,它用于整个应用。这通常不会造成问题,因为我们的服务和存储库不保存状态;它们只是执行某个操作并(可选地)返回值。
然而,如果我们想将状态保存在 bean 中,那么单例就有问题了。我们正在开发一个 web 应用,希望能吸引成千上万的用户。如果一个 bean 只有一个实例,并且所有用户都在同一个实例中操作,那么用户可以看到和修改彼此的数据或者来自几个用户组合的数据。这不是我们想要的。幸运的是,Spring 为 beans 提供了几个我们可以利用的范围(见表 2-5 )。
表 2-5
范围概述
|前缀
|
描述
|
| --- | --- |
| singleton | 默认范围。创建一个 bean 实例,并在整个应用中共享。 |
| prototype | 每次需要某个 bean 时,都会返回该 bean 的一个新实例。 |
| thread | bean 在需要时创建,并绑定到当前执行的线程。如果线程死了,bean 就被破坏了。 |
| request | bean 在需要时创建,并绑定到传入的javax.servlet.ServletRequest的生命周期。如果请求结束,bean 实例被销毁。 |
| session | bean 在需要时创建并存储在javax.servlet.HttpSession中。当会话被销毁时,bean 实例也被销毁。 |
| globalSession | bean 在需要时创建,并存储在全局可用的会话中(在 Portlet 环境中可用)。如果没有这样的会话可用,则作用域恢复到会话作用域功能。 |
| application | 这个作用域非常类似于单例作用域;但是,这个作用域的 beans 也在javax.servlet.ServletContext中注册。 |
轮廓
Spring 在 3.1 版本中引入了概要文件。概要文件使得为不同的环境创建不同的应用配置变得容易。例如,我们可以为本地环境、测试和部署到 CloudFoundry 创建单独的概要文件。这些环境中的每一个都需要一些特定于环境的配置或 beans。您可以考虑数据库配置、消息传递解决方案和测试环境,以及某些 beans 的存根。
为了启用概要文件,我们需要告诉应用上下文哪些概要文件是活动的。为了激活某些概要文件,我们需要设置一个名为spring.profiles.active的系统属性(在 web 环境中,这可以是 servlet 初始化参数或 web 上下文参数)。这是一个逗号分隔的字符串,包含活动配置文件的名称。如果我们现在添加一些带有org.springframework.context.annotation.Configuration和org.springframework.context.annotation.Profile注释的(在本例中是静态内部)类(参见清单 2-9 ,那么只有匹配其中一个活动概要的类才会被处理。所有其他类都被忽略。
package com.apress.prospringmvc.moneytransfer.annotation.profiles;
import com.apress.prospringmvc.moneytransfer.annotation.MoneyTransferServiceImpl;
import com.apress.prospringmvc.moneytransfer.repository.AccountRepository;
import com.apress.prospringmvc.moneytransfer.repository.MapBasedAccountRepository;
import com.apress.prospringmvc.moneytransfer.repository.MapBasedTransactionRepository;
import com.apress.prospringmvc.moneytransfer.repository.TransactionRepository;
import com.apress.prospringmvc.moneytransfer.service.MoneyTransferService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
public class ApplicationContextConfiguration {
@Bean
public AccountRepository accountRepository() {
return new MapBasedAccountRepository();
}
@Bean
public MoneyTransferService moneyTransferService() {
return new MoneyTransferServiceImpl();
}
@Configuration
@Profile(value = "test")
public static class TestContextConfiguration {
@Bean
public TransactionRepository transactionRepository() {
return new StubTransactionRepository();
}
}
@Configuration
@Profile(value = "local")
public static class LocalContextConfiguration {
@Bean
public TransactionRepository transactionRepository() {
return new MapBasedTransactionRepository();
}
}
}
Listing 2-9ApplicationContextConfiguration with Profiles
清单 2-10 显示了一些示例引导代码。一般来说,我们不会从我们的引导代码中设置活动概要文件。相反,我们使用系统变量的组合来设置我们的环境。这使我们能够保持我们的应用不变,但仍然有改变我们的运行时配置的灵活性。
package com.apress.prospringmvc.moneytransfer.annotation.profiles;
import com.apress.prospringmvc.ApplicationContextLogger;
import com.apress.prospringmvc.moneytransfer.domain.Transaction;
import com.apress.prospringmvc.moneytransfer.service.MoneyTransferService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.math.BigDecimal;
/**
* @author Marten Deinum
*/
public class MoneyTransferSpring {
private static final Logger logger = LoggerFactory.getLogger(MoneyTransferSpring.class);
/**
* @param args
*/
public static void main(String[] args) {
System.setProperty("spring.profiles.active", "test");
AnnotationConfigApplicationContext ctx1 =
new AnnotationConfigApplicationContext(ApplicationContextConfiguration.class);
transfer(ctx1);
ApplicationContextLogger.log(ctx1);
System.setProperty("spring.profiles.active", "local");
AnnotationConfigApplicationContext ctx2 =
new AnnotationConfigApplicationContext(ApplicationContextConfiguration.class);
transfer(ctx2);
ApplicationContextLogger.log(ctx2);
}
private static void transfer(ApplicationContext ctx) {
MoneyTransferService service = ctx.getBean("moneyTransferService", MoneyTransferService.class);
Transaction transaction = service.transfer("123456", "654321", new BigDecimal("250.00"));
logger.info("Money Transfered: {}", transaction);
}
}
Listing 2-10MoneyTransferSpring with Profiles
你可能想知道为什么我们应该使用概要文件。一个原因是它允许灵活的配置。这意味着我们的整个配置都在版本控制之下,并且在相同的源代码中,而不是分散在不同的服务器、工作站等等上。当然,我们仍然可以加载包含一些属性(如用户名和密码)的附加文件。如果公司的安全策略不允许我们将这些属性置于版本控制之下,那么这可能是有用的。当我们讨论测试和部署到云时,我们广泛地使用概要文件,因为这两个任务需要不同的数据源配置。
启用功能
Spring 框架比依赖注入提供了更多的灵活性;它还提供了许多我们可以启用的不同功能。我们可以使用注释来启用这些功能(参见表 2-6 )。注意,我们不会使用表 2-6 中的所有注释;然而,我们的示例应用使用了事务,并且我们使用了一些 AOP。这本书最大的部分是关于由org.springframework.web.servlet.config.annotation.EnableWebMvc和org.springframework.web.reactive.config.EnableWebFlux注释提供的特性。
Spring Boot 自动启用其中一些功能;这取决于在类路径上检测到的类。
表 2-6
注释支持的功能概述
|注释
|
描述
|
被 Spring Boot 探测到
|
| --- | --- | --- |
| org.springframework.context.annotation.EnableAspectJAutoProxy | 支持处理构造为 org . AspectJ . lang . annotation . aspect 的 beans。 | 是 |
| org.springframework.scheduling.annotation.EnableAsync | 启用对使用org.springframework.scheduling.annotation.Async或javax.ejb.Asynchronous注释处理 bean 方法的支持。 | 不 |
| org.springframework.cache.annotation.EnableCaching | 启用对带有 org . spring framework . cache . annotation . cache able 批注的 bean 方法的支持。 | 是 |
| org.springframework.context.annotation.EnableLoadTimeWeaving | 启用对加载时编织的支持。默认情况下,Spring 使用基于代理的 AOP 方法;然而,这个注释使我们能够切换到加载时编织。一些 JPA 提供商需要它。 | 不 |
| org.springframework.scheduling.annotation.EnableScheduling | 启用对注释驱动的调度的支持,使我们能够解析用 org . spring framework . scheduling . annotation . scheduled 注释注释的 bean 方法。 | 不 |
| org.springframework.beans.factory.aspectj.EnableSpringConfigured | 支持对非 Spring 管理的 beans 应用依赖注入。一般来说,这样的 beans 用org.springframework.beans.factory.annotation.Configurable注释进行注释。这个特性需要加载时或编译时编织,因为它需要修改类文件。 | 不 |
| org.springframework.transaction.annotation.EnableTransactionManagement | 启用注释驱动的事务支持,使用org.springframework.transaction.annotation.Transactional或javax.ejb.TransactionAttribute来驱动事务。 | 是 |
| org.springframework.web.servlet.config.annotation.EnableWebMvc | 通过请求处理方法支持强大而灵活的注释驱动控制器。该特性检测带有org.springframework.stereotype.Controller注释的 beans,并将带有org.springframework.web.bind.annotation.RequestMapping注释的方法绑定到 URL。 | 是 |
| org.springframework.web.reactive.config.EnableWebFlux | 使用 Spring web MVC 中众所周知的概念来支持强大而灵活的反应式 Web 实现,并在可能的情况下对其进行扩展。 | 是 |
关于这些特性的更多信息,我们建议您查看 Java 文档中不同的注释和专门的参考指南章节。
面向方面编程
为了启用表 2-4 中列出的特性,Spring 使用了面向方面编程(AOP)。AOP 是思考软件结构的另一种方式。它使您能够将诸如事务管理或性能日志之类的事情模块化,这些特性跨越多种类型和对象(横切关注点)。在 AOP 中,有几个重要的概念需要记住(见表 2-7 )。
表 2-7
核心 AOP 概念
|概念
|
描述
|
| --- | --- |
| 方面 | 横切关注点的模块化。一般来说,这是一个带有org.aspectj.lang.annotation.Aspect注释的 Java 类。 |
| 连接点 | 程序执行过程中的一个点。这可以是方法的执行、字段的赋值或异常的处理。在 Spring 中,连接点总是一个方法的执行! |
| 建议 | 某个方面在特定连接点采取的特定动作。建议有几种类型:前的、后的、后的、后的、前后的。在 Spring 中,一个通知被称为拦截器,因为我们正在拦截方法调用。 |
| 切入点 | 匹配连接点的谓词。通知与一个切入点表达式相关联,并在任何匹配切入点的连接点上运行。Spring 默认使用 AspectJ 表达式语言。可以使用org.aspectj.lang.annotation.Pointcut注释编写连接点。 |
现在让我们看看事务管理,以及 Spring 如何使用 AOP 来围绕方法应用事务。交易通知或拦截器是org.springframework.transaction.interceptor.TransactionInterceptor。这个建议放在带有org.springframework.transaction.annotation.Transactional注释的方法周围。为此,Spring 在实际对象周围创建了一个包装器,称为代理(见图 2-5 )。代理的行为类似于封闭对象,但它允许添加(动态)行为(在本例中,是方法的事务性)。

图 2-5
代理方法调用
org.springframework.transaction.annotation.EnableTransactionManagement注释注册包含切入点的 beans(作用于org.springframework.transaction.annotation.Transactional注释)。此时,拦截器就可以使用了。用于启用特性的其他注释的工作方式类似;他们注册 beans 来启用期望的特性,包括大多数特性的 AOP(以及代理创建)。
网络应用
那么如何将所有这些技术应用到 web 应用中呢?例如,应用上下文如何发挥作用?那么提到的其他事情呢?
在开发 web 应用时,存在实际的业务逻辑(例如,服务、存储库和基础设施信息),并且存在基于 web 的 beans。这些东西应该是分开的,所以我们需要有多个应用上下文和关系。
我们还需要引导应用的代码,否则什么都不会发生。在本章的例子中,我们使用了一个带有 main 方法的MoneyTransferSpring类来启动应用上下文。这不是我们在网络环境中能做到的。Spring 附带了两个可以引导应用的组件:org.springframework.web.servlet.DispatcherServlet和org.springframework.web.context.ContextLoaderListener。这两个组件引导并配置应用上下文。
我们来看看配置DispatcherServlet的类。这是com.apress.prospringmvc.bookstore.web.BookstoreWebApplicationInitializer级(见清单 2-11 )。我们的 Servlet 3.0+容器检测到这个类,它初始化我们的应用(关于这个主题的更多信息,参见第三章)。我们创建DispatcherServlet并传递它org.springframework.web.context.support.AnnotationConfigWebApplicationContext。接下来,我们将 servlet 映射到所有内容(“/”),并告诉它在启动时加载。
package com.apress.prospringmvc.bookstore.web;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRegistration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.WebApplicationInitializer;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
import com.apress.prospringmvc.bookstore.web.config.WebMvcContextConfiguration;
public class BookstoreWebApplicationInitializer implements WebApplicationInitializer {
@Override
public void onStartup(final ServletContext servletContext) throws ServletException {
registerDispatcherServlet(servletContext);
}
private void registerDispatcherServlet(final ServletContext servletContext) {
WebApplicationContext dispatcherContext =
createContext(WebMvcContextConfiguration.class);
DispatcherServlet dispatcherServlet =
new DispatcherServlet(dispatcherContext);
ServletRegistration.Dynamic dispatcher =
servletContext.addServlet("dispatcher", dispatcherServlet);
dispatcher.setLoadOnStartup(1);
dispatcher.addMapping("*.htm");
}
private WebApplicationContext createContext(final Class<?>... annotatedClasses) {
AnnotationConfigWebApplicationContext context =
new AnnotationConfigWebApplicationContext();
context.register(annotatedClasses);
return context;
}
}
Listing 2-11The BookstoreWebApplicationInitializer Class
让我们通过添加一个ContextLoaderListener类来让事情变得有趣一点,这样我们可以有一个父上下文和一个子上下文(参见清单 2-12 )。新注册的监听器使用com.apress.prospringmvc.bookstore.config.InfrastructureContextConfiguration(参见清单 2-13 )来决定加载哪些 beans。已经配置好的DispatcherServlet自动检测ContextLoaderListener加载的应用上下文。
package com.apress.prospringmvc.bookstore.web;
import org.springframework.web.context.ContextLoaderListener;
import com.apress.prospringmvc.bookstore.config.InfrastructureContextConfiguration;
// other imports omitted, see listing 2-11
public class BookstoreWebApplicationInitializer implements WebApplicationInitializer {
@Override
public void onStartup(final ServletContext servletContext) throws ServletException {
registerListener(servletContext);
registerDispatcherServlet(servletContext);
}
// registerDispatcherServlet method ommitted see Listing 2-11
// createContext method omitted see Listing 2-11
private void registerListener(final ServletContext servletContext) {
AnnotationConfigWebApplicationContext rootContext =
createContext(InfrastructureContextConfiguration.class);
servletContext.addListener(new ContextLoaderListener(rootContext));
}
}
Listing 2-12The Modifcation
for the BookstoreWebApplicationInitializer Class
清单 2-13 是我们的主要应用上下文。它包含我们的服务和存储库的配置。这个清单还展示了我们的 JPA 实体管理器,包括其基于注释的事务支持。
package com.apress.prospringmvc.bookstore.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages = {
"com.apress.prospringmvc.bookstore.service",
"com.apress.prospringmvc.bookstore.repository"})
public class InfrastructureContextConfiguration {
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean emfb = new LocalContainerEntityManagerFactoryBean();
emfb.setDataSource(dataSource);
emfb.setJpaVendorAdapter(jpaVendorAdapter());
return emfb;
}
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
return new HibernateJpaVendorAdapter();
}
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
@Bean
public DataSource dataSource() {
return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2).build();
}
}
Listing 2-13The InfrastructureContextConfiguration Source File
Spring Boot
本章前面提到的所有内容也适用于 Spring Boot。Spring Boot 构建并扩展了 Spring 框架的特性。然而,这确实让事情变得简单多了。默认情况下,Spring Boot 会自动配置它在类路径中找到的特性。当 Spring Boot 检测到 Spring MVC 类时,它启动 Spring MVC。当它找到一个DataSource实现时,它就引导它。
可以通过向application.properties或application.yml文件添加属性来进行定制。您可以通过它来配置数据源、视图处理和服务器端口等。另一种选择是手动配置,就像在常规的 Spring 应用中一样。当 Spring Boot 检测到某个功能的预配置部分时,它通常不会自动配置该功能。
前面章节中的应用可以通过 Spring Boot 进行简化(参见清单 2-14 和清单 2-15 )。
package com.apress.prospringmvc.bookstore;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
@SpringBootApplication
public class BookstoreApplication extends SpringBootServletInitializer {
public static void main(String[] args) {
SpringApplication.run(BookstoreApplication.class);
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(BookstoreApplication.class);
}
}
Listing 2-14The BookstoreApplication
BookstoreApplication类有@SpringBootApplication,,可以自动配置检测到的特性和第三方库。在这种情况下,它扩展了SpringBootServletInitializer,因为应用被打包成一个 WAR 并部署到一个容器中。Spring Boot 没有编写我们自己的WebApplicationInitializer,而是提供了一个现成的。它在一个经典容器中实现了大多数 Spring Boot 功能。
配置属性可以在一个application.properties或application.yml文件中给出(见清单 2-15 ),以配置缺省值不适用时所需的特性。有关最常见功能的列表,请查看 Spring Boot 参考指南的附录 A10。
server.port=8080 # 8080 is also the default servlet port
spring.application.name=bookstore
Listing 2-15application.properties
Spring Boot 的一个很好的特性是,当在不同的环境下运行时,我们可以使用概要文件来加载不同的/额外的配置文件。例如,当启用local概要文件时,Spring Boot 也会加载一个application-local.properties或application-local.yml。当在基于云的环境中运行时,属性也可以从 Git 存储库或 Docker 环境中获得。
摘要
本章介绍了 Spring Core 的基本知识。我们回顾了依赖注入,并简要介绍了依赖注入的三个不同版本。我们还讨论了基于构造函数、基于设置器和基于注释的依赖注入。
接下来,我们进入了 Spring 世界,检查了org.springframework.context.ApplicationContext s,包括它们在我们的应用中扮演的角色。我们还解释了不同的应用上下文(例如,基于 XML 或 Java 的)以及每个上下文中的资源加载。在我们的 web 环境中,我们在org.springframework.web.context.WebApplicationContext接口的实现中使用应用上下文的专门版本。我们还介绍了应用上下文中的 beans 在默认情况下是如何限定单例范围的。幸运的是,Spring 为我们提供了额外的范围,比如request、session、globalSession、prototype、application、thread。
为了在不同的环境中使用不同的配置,Spring 还包含了概要文件。我们简要地解释了如何启用概要文件以及如何使用它们。当我们测试样例应用并将其部署到 Cloud Foundry 时,我们在样例应用中使用概要文件。
我们还深入研究了 Spring 需要几个启用注释来启用某些特性的方式。这些注释在应用上下文中注册了支持所需特性的附加 beans。这些特性中的大部分依赖于 AOP 来启用(例如,声明式事务管理)。Spring 创建代理来将 AOP 应用于在我们的应用上下文中注册的 beans。
最后,我们快速浏览了一下 Spring Boot,以及它是如何让我们作为软件开发人员的生活变得轻松的。Spring Boot 使用自动配置来配置在类路径上检测到的功能。它构建并扩展了 Spring 框架。
下一章着眼于 MVC web 应用的架构,不同的层,以及它们在我们的应用中的角色。
*三、Web 应用架构
在我们开始 Spring MVC 内部的旅程之前,我们首先需要理解 web 应用的不同层。我们将从简单介绍 MVC 模式开始讨论,包括它是什么以及为什么我们应该使用它。我们还将介绍 Spring 框架提供的一些接口和类,以表达 MVC 模式的不同部分。
在回顾了 MVC 模式之后,我们将浏览 web 应用中的不同层,看看每一层在应用中扮演什么角色。我们还探索了 Spring 框架如何在不同的层中帮助我们,并利用它们为我们服务。
MVC 模式
模型视图控制器模式(MVC 模式)最初是由 Trygve Reenskaug 在 Xerox 从事 Smalltalk 工作时描述的。当时,该模式针对的是桌面应用。这种模式将表示层分成不同种类的组件。每个组件都有自己的职责。视图使用模型来呈现自己。基于用户操作,视图触发控制器,控制器反过来更新模型。然后模型通知视图(重新)呈现自己(见图 3-1 )。

图 3-1
MVC 模式
MVC 模式完全是关于关注点的分离。每个组件都有自己的角色(见表 3-1 )。关注点的分离在表示层中很重要,因为它有助于我们保持不同组件的整洁。这样,我们就不会给实际视图增加业务逻辑、导航逻辑和模型数据的负担。遵循这种方法可以很好地将一切分开,这使得维护和测试我们的应用更加容易。
表 3-1
简言之,MVC
|组件
|
描述
|
| --- | --- |
| 模型 | 模型是视图需要的数据,这样它就可以被渲染。它可能是用户下的订单或请求的图书列表。 |
| 视角 | 视图是实际的实现,它使用模型在 web 应用中呈现自己。这可能是 JSP 或 JSF 页面,但也可能是资源的 PDF、XML 或 JSON 表示。 |
| 控制器 | 控制器是负责响应用户动作的组件,比如表单提交或单击链接。控制器更新模型并采取其他所需的行动,比如调用服务方法来下订单。 |
MVC 模式的经典实现(如图 3-1 所示)包括用户触发一个动作。这将提示控制器更新模型,从而将更改推回到视图中。然后视图用来自模型的更新数据更新自己。这是 MVC 模式的理想实现,例如,它在基于 Swing 的桌面应用中工作得非常好。然而,由于 HTTP 协议的性质,这种方法在 web 环境中是不可行的。对于 web 应用,用户通常通过发出请求来启动操作。这将提示应用更新和呈现视图,并将其发送回用户。这意味着在 web 环境中我们需要一个稍微不同的方法。我们需要从服务器中提取更改,而不是将更改推送到视图中。
这种方法似乎可行,但是在 web 应用中应用起来并不像人们想象的那样简单。Web(或 HTTP)在设计上是无状态的,所以保持一个模型是很困难的。对于 Web,MVC 模式被实现为模型 2 架构(见图 3-2 )。 1 原始模式(模型 1 如图 3-1 所示)与修改后的模式的区别在于,它加入了一个前端控制器,将传入的请求分派给其他控制器。这些控制器处理传入的请求,返回模型,并选择视图。

图 3-2
模型 2 MVC 模式
前端控制器是处理传入请求的组件。首先,它将请求委托给合适的控制器。当该控制器完成处理和更新模型时,前端控制器根据结果确定渲染哪个视图。在大多数情况下,这个前端控制器被实现为一个javax.servlet.Servlet servlet(例如,JSF 的FacesServlet)。在 Spring MVC 中,这个前端控制器是org.springframework.web.servlet.DispatcherServlet。
应用分层
在简介中,我们提到了一个应用由几层组成(见图 3-3 )。我们喜欢把层看作是应用关注的领域。因此,我们也使用分层来实现关注点的分离。例如,视图不应该负担业务或数据访问逻辑,因为这些都是不同的关注点,通常位于不同的层。

图 3-3
典型应用分层
层应该被认为是概念上的边界,但是它们不必彼此物理隔离(在另一个虚拟机中)。对于 web 应用,这些层通常运行在同一个虚拟机中。Rod Johnson 的书,专家一对一的 J2EE 设计和开发 (Wrox,2002),对应用的分布和扩展进行了很好的讨论。
图 3-3 是应用各层的高度概括视图。数据访问在应用的底部,表示在顶部,服务(实际的业务逻辑)在中间。这一章着眼于这个架构,以及一切是如何组织的。表 3-2 提供了不同层的简要描述。
表 3-2
层的简要概述
|层
|
描述
|
| --- | --- |
| 陈述 | 这很可能是一个基于网络的解决方案。表示层应该尽可能薄。还应该有可能提供替代的表示层,如 web 前端或 web 服务外观。这些都应该在设计良好的服务层上运行。 |
| 服务 | 包含业务逻辑的实际系统的入口点。它提供了一个粗粒度的接口,支持系统的使用。这一层也应该是系统的事务边界(可能也是安全边界)。这一层不应该知道任何关于持久性或所使用的视图技术的事情(或者知道得越少越好)。 |
| 数据存取 | 基于接口的层提供了对底层数据访问技术的访问,而无需将其暴露给上层。这一层抽象了实际的持久性框架(例如,JDBC、JPA 或类似 MongoDB 的东西)。注意,这一层不应该包含业务逻辑。 |
各层之间的交流是自上而下的。服务层可以访问数据访问层,但数据访问层不能访问服务层。如果您看到这种循环依赖悄悄进入您的应用,请后退几步,重新考虑您的设计。循环依赖(或自下而上的依赖)几乎总是糟糕设计的标志,并导致复杂性增加和更难维护的应用。
Note
有时候,你会遇到术语,层。许多人交替使用 tier 和 layer 然而,将它们分开有助于讨论应用架构或其部署。我们喜欢使用层来表示应用中的概念层,而层表示部署时不同机器上的层的物理分离。在层中思考有助于软件开发人员,而在层中思考有助于系统管理员。
尽管图 3-3 给出了一个 web 应用各层的概述,我们可以进一步细分。在一个典型的 web 应用中,我们可以识别五个概念层(见图 3-4 )。我们可以将表示层分为 web 和用户界面层,但是应用还包括一个域层(参见本章后面的“Spring MVC 应用层”一节)。通常,领域层跨越所有层,因为从数据访问层到用户界面,它无处不在。

图 3-4
Web MVC 应用层
Note
分层架构并不是唯一的应用架构;然而,它是 web 应用最常遇到的架构。
如果你看一下样例应用,图 3-4 中显示的架构在包结构中变得清晰。这些包可以在书店共享项目中找到(见图 3-5 )。主要包包括以下内容。
-
com.apress.prospringmvc.bookstore.domain:畴层 -
com.apress.prospringmvc.bookstore.service:服务层 -
com.apress.prospringmvc.bookstore.repository:数据访问层
其他包是 web 层的支持包,com.apress.prospringmvc.bookstore.config包包含根应用上下文的配置类。我们在本书的过程中构建的用户界面和 web 层,这些层在用户界面所需的com.apress.prospringmvc.bookstore.web包和百里香 2 模板中。

图 3-5
书店包装概述
关注点分离
正如在第二章中提到的,清楚地分离关注点是很重要的。如果你看图 3-4 中的架构,关注点的分离出现在层中。将关注点分成不同的层有助于我们实现清晰的设计和灵活且可测试的应用。
创建或检测图层可能很困难。一个经验法则是,如果一个层对其他层有太多的依赖,您可能希望引入另一个层来合并所有的依赖。另一方面,如果您在不同的层中看到一个单独的层,您可能想要重新考虑这个层,并使它成为应用的一个方面。在这种情况下,我们可以使用 Spring 框架的 AOP 功能在运行时应用这些方面(参见第二章)。
耦合层——例如,服务层需要与数据访问层对话——是通过定义清晰的接口来实现的。定义接口和接口编程减少了与具体实现的实际耦合。耦合性和复杂性的降低使得应用更易于测试和维护。使用接口的另一个好处是,Spring 可以使用 JDK 动态代理 3 来创建代理并应用 AOP。Spring 还可以使用字节码生成库(cglib)在基于类的代理上应用 AOP,该库以重新打包的形式随 Spring 框架一起提供。
要点是:应用中的分层导致更易维护和测试的应用。关注点的清晰分离也导致良好的应用架构。
Spring MVC 应用层
您可能想知道所有的层如何适应 Spring MVC 应用,以及所有不同的层如何帮助我们构建 Spring MVC 应用。本节着眼于图 3-4 中描绘的五层。我们特别关注不同层所扮演的角色以及每一层中应该包含的内容。
领域层
领域是应用中最重要的一层。它是我们正在解决的业务问题的代码表示,并且包含我们领域的业务规则。这些规则可能会检查我们是否有足够的资金从我们的帐户转账,或者确保字段是唯一的(例如,我们系统中的用户名)。
确定领域模型的一个流行技术是使用用例描述中的名词作为领域对象(例如,Account或Transaction)。这些对象包含状态(例如,Account的用户名)和行为(例如,Account上的credit方法)。这些方法通常比服务层中的方法更细粒度。例如,在第二章的货币转移示例中,com.apress.prospringmvc.moneytransfer.domain.Account对象有一个debit和credit方法。credit 方法包含一些业务逻辑,用于检查我们的帐户中是否有足够的资金来转账。
在第二章中,com.apress.prospringmvc.moneytransfer.service.MoneyTransferService的实现使用这些支持方法来实现一个用例(在这个例子中,它将钱从一个账户转移到另一个账户)。这不要与贫血的域模型 4 相混淆,在这种模型中,我们的域对象只有状态,没有行为。
一般来说,你的领域模型不需要依赖注入;但是,这样做还是有可能的。例如,可以使用 Spring 框架和 AspectJ 在我们的域对象中实现依赖注入。在这种情况下,我们会给我们的域类加上org.springframework.beans.factory.annotation.Configurable注释。接下来,我们需要设置加载时编织或编译时编织,并注入我们的依赖关系。关于这个主题的更多信息,请参阅 Spring 框架文档。 5
用户界面层
用户界面层将应用呈现给用户。该层将服务器生成的响应呈现为用户客户端请求的类型。例如,web 浏览器可能会请求 HTML 文档,web 服务可能需要 XML 文档,而另一个客户端可能会请求 PDF 或 Excel 文档。
我们将表示层分为用户界面层和 web 层,因为尽管有各种不同的视图技术,我们还是希望尽可能多地重用代码。我们的目标是只重新实现用户界面。有许多不同的视图技术,包括 JSF、JSP(X)、FreeMarker 和百里香叶等等。在理想的情况下,我们可以在不改变应用后端的情况下切换用户界面。
Spring MVC 帮助我们将用户界面与系统的其他部分隔离开来。在 Spring 中,视图由一个界面表示:org.springframework.web.servlet.View。这个接口负责将来自用户的动作结果(模型)转换成用户请求的响应类型。View接口是通用的,它不依赖于特定的视图技术。Spring 框架或视图技术为每种支持的视图技术提供了一个实现。开箱即用,Spring 支持以下视图技术。
-
JSP
-
便携文档格式
-
超过
-
FreeMarker
-
胸腺泡
-
瓷砖 3
-
XML(封送处理、XSLT 或普通)
-
JSON(使用 Jackson 或 GSON)
-
Groovy 标记
-
脚本视图(车把、ERB、科特林脚本模板)
通常,用户界面依赖于领域层。有时候,直接暴露和呈现领域模型是很方便的。当我们开始在应用中使用表单时,这尤其有用。例如,这将让我们直接处理域对象,而不是额外的间接层。一些人认为这在层之间产生了不必要的或不想要的耦合。然而,仅仅为了从视图中分离域而创建另一层会导致不必要的复杂性和重复。在任何情况下,重要的是要记住 Spring MVC 不要求我们直接向视图公开域模型——我们是否这样做完全取决于我们自己。
Web 层
web 层有两个职责。第一个责任是引导用户通过 web 应用。二是做服务层和 HTTP 之间的集成层。
在网站中导航用户可以像将 URL 映射到视图或像 Spring Web Flow 这样的成熟页面流解决方案一样简单。导航通常只绑定到 web 层,在域或服务层中没有任何导航逻辑。
作为集成层,web 层应该尽可能的薄。应该是这个层将传入的 HTTP 请求转换为服务层可以处理的内容,然后将来自服务器的结果(如果有)转换为用户界面的响应。web 层不应该包含任何业务逻辑,这是服务层的唯一目的。
web 层也由 cookies、HTTP 头和可能的 HTTP 会话组成。一致和透明地管理所有这些事情是 web 层的责任。不同的 HTTP 元素不应该渗入我们的服务层。如果他们这样做,整个服务层(以及我们的应用)就会与 web 环境联系在一起。这样做会增加维护和测试应用的难度。保持服务层的整洁还允许我们为不同的通道重用相同的服务。例如,它使我们能够添加 web 服务或 JMS 驱动的解决方案。web 层应该被视为连接到服务层并向最终用户公开的客户端或代理。
在 Java web 开发的早期,servlets 或 JavaServer Pages 主要实现这一层。servlets 负责处理请求并将其转换成服务层可以理解的内容。通常情况下,servlets 会将所需的 HTML 直接写回客户机。这种实现很快变得难以维护和测试。几年后,Model 2 MVC 模式出现了,我们最终拥有了高级的 Web MVC 功能。
像 Spring MVC、Struts、JSF 和 Tapestry 这样的框架为这种模式提供了不同的实现,它们都以不同的方式工作。然而,我们可以确定两种主要类型的 web 层实现:请求/响应框架(例如,struts 和 Spring MVC)和基于组件的框架(例如,JSF 和 Tapestry)。请求/响应框架对javax.servlet.ServletRequest和javax.servlet.ServletResponse对象进行操作。因此,他们在 Servlet API 上操作的事实并没有真正对用户隐藏。基于组件的框架提供了一个完全不同的编程模型。他们试图对程序员隐藏 Servlet API,并提供基于组件的编程模型。使用基于组件的框架感觉很像使用 Swing 桌面应用。
这两种方法各有利弊。Spring MVC 功能强大,在两者之间取得了很好的平衡。它可以隐藏使用 Servlet API 的事实;但是,访问该 API 很容易(尤其是)。
web 层依赖于领域层和服务层。在大多数情况下,您希望将传入的请求转换成一个域对象,并调用服务层上的方法来处理该域对象(例如,更新客户或创建订单)。Spring MVC 使得将传入的请求映射到对象变得很容易,我们可以使用依赖注入来访问服务层。
在 Spring MVC 中,web 层由带有org.springframework.stereotype.Controller注释的org.springframework.web.servlet.mvc.Controller接口或类表示。基于接口的方法是有历史的,从一开始它就是 Spring 框架的一部分;然而,它现在被认为是过时的。不管怎样,它对于简单的用例仍然有用,Spring 提供了一些现成的方便的实现。新的基于注释的方法比原来的基于接口的方法更加强大和灵活。本书的重点是基于注释的方法。
在执行一个控制器后,基础设施(参见第四章了解更多关于这个主题的信息)期待一个org.springframework.web.servlet.ModelAndView类的实例。这个类包含了模型(以org.springframework.ui.ModelMap的形式)和要呈现的视图。这个视图可以是一个实际的org.springframework.web.servlet.View实现或者一个视图的名称。
Caution
不要在带有Controller接口的类上使用Controller注释。这些是以不同的方式处理的,混合使用这两种策略会导致令人惊讶和不希望的结果!
服务层
服务层在应用的架构中非常重要。它被认为是我们应用的核心,因为它向用户公开了系统的功能(用例)。它通过提供一个粗粒度的 API 来做到这一点(如表 3-2 中所述)。清单 3-1 描述了一个粗粒度的服务接口。
package com.apress.prospringmvc.bookstore.service;
import com.apress.prospringmvc.bookstore.domain.Account;
public interface AccountService {
Account save(Account account);
Account login(String username, String password) throws AuthenticationException;
Account getAccount(String username);
}
Listing 3-1A Coarse-Grained Service Interface
这个清单被认为是粗粒度的,因为它需要从客户端调用一个简单的方法来完成一个用例。这与清单 3-2 (细粒度服务方法)中的代码形成对比,后者需要几次调用来执行一个用例。
package com.apress.prospringmvc.bookstore.service;
import com.apress.prospringmvc.bookstore.domain.Account;
public interface AccountService {
Account save(Account account);
Account getAccount(String username);
void checkPassword(Account account, String password);
void updateLastLogin(Account account);
}
Listing 3-2A Fine-Grained Service Interface
如果可能的话,我们不应该调用一系列方法来执行一个系统函数。我们应该尽可能地屏蔽用户的数据访问和 POJO 交互。在理想情况下,粗粒度函数应该代表一个成功或失败的工作单元。用户可以使用不同的客户端(例如,网络应用、网络服务或桌面应用);然而,这些客户端应该执行相同的业务逻辑。因此,服务层应该是我们实际系统(即业务逻辑)的单一入口点。
在服务层使用单一入口点和粗粒度方法的额外好处是,我们可以在这一层简单地应用事务和安全性。我们不必让应用的不同客户端承担安全和事务性需求。它现在是系统核心的一部分,一般通过 AOP 来应用。
在基于 web 的环境中,我们可能有多个用户同时操作服务。服务必须是无状态的,因此将服务设为单例是一个好的做法。在领域模型中,应该尽可能地保留状态。保持服务层的无状态提供了一个额外的好处:它还使得服务层是线程安全的。
将服务层保持在单个入口点,保持层的无状态,并在该层上应用事务和安全性,这使得 Spring 框架的其他特性能够将服务层公开给不同的客户端。例如,我们可以使用配置轻松地通过 RMI 或 JMS 公开我们的服务层。有关 Spring Framework 远程支持的更多信息,我们建议使用 Pro Spring 5 (Apress,2017)或在线 Spring Framework 文档。 6
在我们的书店示例应用中,com.apress.prospringmvc.bookstore.service.BookstoreService接口(参见清单 3-3 )充当我们的服务层的接口(还有几个其他接口,但这是最重要的一个)。这个接口包含几个粗粒度的方法。在大多数情况下,执行一个用例需要一个方法调用(例如,createOrder)。
package com.apress.prospringmvc.bookstore.service;
import java.util.List;
import com.apress.prospringmvc.bookstore.domain.Account;
import com.apress.prospringmvc.bookstore.domain.Book;
import com.apress.prospringmvc.bookstore.domain.BookSearchCriteria;
import com.apress.prospringmvc.bookstore.domain.Cart;
import com.apress.prospringmvc.bookstore.domain.Category;
import com.apress.prospringmvc.bookstore.domain.Order;
public interface BookstoreService {
List<Book> findBooksByCategory(Category category);
Book findBook(long id);
Order findOrder(long id);
List<Book> findRandomBooks();
List<Order> findOrdersForAccount(Account account);
Order store(Order order);
List<Book> findBooks(BookSearchCriteria bookSearchCriteria);
Order createOrder(Cart cart, Account account);
List<Category> findAllCategories();
}
Listing 3-3The BookstoreService Interface
如清单 3-3 所示,服务层依赖于领域层来执行业务逻辑。然而,它也依赖于数据访问层来存储和检索底层数据存储中的数据。服务层可以作为一个或多个域对象之间的绑定器来执行业务功能。服务层应该协调它需要哪些域对象,以及它们如何相互作用。
Spring 框架没有帮助我们实现服务层的接口;然而,这并不奇怪。服务层是我们的应用的基础;事实上,它是专门为我们的应用。然而,Spring 框架可以帮助我们构建架构和编程模型。我们可以使用依赖注入和应用方面来驱动我们的事务。所有这些对我们的编程模型都有积极的影响。
数据访问层
数据访问层负责与底层的持久性机制进行交互。这一层知道如何在数据存储中存储和检索对象。它这样做是因为服务层不知道使用了哪个底层数据存储。(数据存储可以是数据库,但也可以由文件系统上的平面文件组成。)
创建单独的数据访问层有几个原因。首先,我们不想让服务层知道我们使用的数据存储类型;我们希望透明地处理持久性。在我们的示例应用中,我们使用内存数据库和 JPA (Java 持久性 API)来存储数据。现在想象一下,我们的 com . a press . prospring MVC . book store . domain . account 不是来自数据库,而是来自活动目录服务。我们可以简单地创建一个新的接口实现,它知道如何处理 Active Directory——而不需要改变我们的服务层。理论上,我们可以很容易地交换实现;例如,我们可以在不改变服务层的情况下从 JDBC 切换到 Hibernate。不太可能出现这种情况,但是有这种能力还是不错的。
这种方法最重要的原因是它简化了应用的测试。一般来说,数据访问很慢,所以我们必须尽可能快地运行我们的测试。一个单独的数据访问层使得创建我们的数据访问层的存根或模拟实现变得容易。
Spring 对数据访问层有很好的支持。例如,它提供了一种一致且透明的方式来处理各种数据访问框架(例如,JDBC、JPA 和 Hibernate)。对于这些技术中的每一项,Spring 都提供了对以下能力的广泛支持。
-
事务管理
-
资源处理
-
异常翻译
事务管理在其支持的每种技术中都是透明的。事务管理器处理事务,它支持 JTA (Java Transaction API ),支持分布式或全局事务(跨越多个资源的事务,如数据库和 JMS 代理)。这种出色的事务支持意味着事务管理器也可以为您管理资源。我们不再担心数据库连接或文件句柄被关闭;这都是为你处理的。支持的实现可以在org.springframework.jdbc和org.springframework.orm包中找到。
Tip
Spring Data 项目 7 提供了与几种技术的更深层次的集成。在一些用例中,它消除了编写我们自己的数据访问对象(DAO)或存储库的实现的需要。
Spring 框架包含了另一个强大的特性,作为其数据访问支持的一部分:异常翻译。Spring 为其支持的所有技术提供了广泛的异常翻译支持。这个特性将特定于技术的异常转换成org.springframework.dao.DataAccessException的子类。对于数据库驱动技术,它考虑数据库供应商、版本和从数据库接收的错误代码。异常层次从java.lang.RuntimeException开始扩展;因此,它不必被捕获,因为它不是一个检查过的异常。有关数据访问支持的更多信息,请参见 Pro Spring 5 (Apress,2017)或在线 Spring 框架文档。
清单 3-4 展示了数据访问对象或存储库的外观。注意,该接口没有引用或提及我们使用的任何数据访问技术(我们在示例应用中使用 JPA)。此外,服务层不关心数据是如何持久存储的,也不关心数据在哪里持久存储;它只是想知道如何存储或检索它。
package com.apress.prospringmvc.bookstore.repository;
import com.apress.prospringmvc.bookstore.domain.Account;
public interface AccountRepository extends CrudRepository<Account, Long> {
Account findByUsername(String username);
}
Listing 3-4A Sample AccountRepository using Spring Data
更多通往罗马的道路
这里讨论的架构并不是唯一的应用架构。哪种架构最适合给定的应用取决于应用的大小、开发团队的经验以及应用的生命周期。团队越大或者应用存在的时间越长,具有独立层的干净架构就变得越重要。
从单个静态页面开始的 web 应用可能不需要任何架构。然而,随着应用的增长,越来越重要的是,我们不要试图把所有东西都放在一个页面上,因为这将使维护或理解应用变得非常困难,更不用说测试了。
随着应用的规模和年龄的增长,我们需要重构它的设计,并记住每一层或每一个组件都应该有一个单独的职责。如果我们发现一些关注点应该在不同的层或者涉及多个组件,我们应该把它转换成应用的一个方面(横切关注点),并使用 AOP 把它应用到代码中。
当决定如何构建我们的层时,我们应该尝试为我们的系统确定一个清晰的 API(通过 Java 接口公开)。为我们的系统考虑一个 API 让我们考虑我们的设计和一个有用的和可用的 API。一般来说,如果一个 API 很难使用,它也很难测试和维护。因此,干净的 API 非常重要。此外,使用不同层之间的接口允许单独的层被独立地构建和测试。这在较大的开发团队(或者由多个较小的团队组成的团队)中是一个很大的优势。它允许我们专注于我们正在处理的功能,而不是底层或更高级别的组件。
在设计和构建应用时,使用良好的面向对象实践和模式来解决问题也很重要。例如,我们应该利用多态和继承,我们应该使用 AOP 来应用系统范围的关注点。Spring 框架还可以帮助我们在运行时将应用连接在一起。总的来说,本章描述的特性和方法可以帮助我们保持代码的整洁,并为我们的应用实现最佳的架构。
摘要
在这一章中,我们讨论了 MVC 模式,包括它的起源和它解决的问题。我们还简要讨论了 MVC 模式的三个组成部分:模型、视图和控制器。接下来,我们讨论了 Model 2 MVC 模式,以及使用前端控制器如何将其与 Model 1 MVC 模式区别开来。在 Spring MVC 中,这个前端控制器是org.springframework.web.servlet.DispatcherServlet。
接下来,我们简要介绍了一般的 web 应用架构。我们确定了 web 应用中通常可用的五个不同的层:域、用户界面、web、服务和数据访问。这些层在我们的应用中扮演着重要的角色,我们讨论了这些角色是什么以及它们是如何组合在一起的。我们还介绍了 Spring 如何在应用的不同层帮助我们。
本章的主要内容是 MVC 模式中的各种层和组件可以分离不同的关注点。每一层都应该有一个单一的职责,无论是业务逻辑还是 HTTP 世界和服务层之间的绑定器。关注点的分离有助于我们实现一个干净的架构和创建可维护的代码。最后,清晰的分层使得测试我们的应用更加容易。
下一章将深入探讨 Spring MVC。具体来说,它探索了DispatcherServlet servlet,包括它如何工作以及如何配置它。它还进一步研究了本章中描述的不同组件在 Spring MVC 应用中是如何工作的。
四、Spring MVC 架构
本章深入 Spring MVC 的内部,仔细观察org.springframework.web.servlet.DispatcherServlet。首先,学习 servlet 如何处理传入的请求,并确定哪些组件在请求处理中起作用。在确定了这些组件之后,我们将更深入地研究它们的角色、功能和实现。您还将学习如何配置org.springframework.web.servlet.DispatcherServlet,部分是通过检查 Spring Boot 的默认配置和扩展配置。
DispatcherServlet 请求处理工作流
在前一章中,你学习了前端控制器在 Model 2 MVC 模式中扮演的重要角色。前端控制器负责将传入的请求分派给正确的处理程序,并准备将响应呈现为用户希望看到的内容。Spring MVC 中前端控制器的角色由org.springframework.web.servlet. DispatcherServlet扮演。这个 servlet 使用几个组件来完成它的角色。所有这些组件都表示为接口,对于这些接口,有一个或多个实现是可用的。下一节将探讨这些组件在请求处理工作流中扮演的一般角色。下一节将介绍接口的不同实现。
我们特意使用了处理者这个术语。DispatcherServlet 非常灵活且可定制,它可以处理比org.springframework.web.servlet.mvc.Controller实现或org.springframework.stereotype.Controller注释类更多类型的处理程序。
工作流程
图 4-1 显示了请求处理工作流程的高级概述。

图 4-1
请求处理工作流
在前面的章节中,您学习了关注点分离的重要性。在 Spring 框架中,应用了相同的规则。考虑到可扩展性和关注点的分离,许多支持组件被设计为接口。虽然图 4-1 中的高层概述是正确的,但幕后发生的更多。图 4-2 显示了请求处理工作流程的完整视图。

图 4-2
请求处理工作流
图 4-2 提供了DispatcherServlet内部请求处理工作流程的全局概览。以下部分将详细介绍这个流程中的不同步骤。
准备请求
在DispatcherServlet开始分派和处理请求之前,它准备并预处理请求。servlet 通过使用org.springframework.web.servlet.LocaleResolver确定和公开当前请求的当前java.util.Locale来启动。接下来,它在org.springframework.web.context.request.RequestContextHolder中准备并公开当前请求。这使得框架代码很容易访问当前请求,而不是传递它。
接下来,servlet 构造了org.springframework.web.servlet.FlashMap implementation。它通过调用试图解析输入FlashMap的org.springframework.web.servlet.FlashMapManager来做到这一点。这个映射包含在前一个请求中显式存储的属性。一般来说,这在重定向到下一页时使用。这个主题将在第五章中进行深入讨论。
接下来,检查传入的请求以确定它是否是一个多部分 HTTP 请求(这在进行文件上传时使用)。如果是这样,请求通过一个org.springframework.web.multipart.MultipartResolver组件被包装在org.springframework.web.multipart.MultipartHttpServletRequest中。在此之后,请求准备好被分派给正确的处理程序。图 4-3 显示了请求处理工作流程第一部分的流程图。

图 4-3
请求处理流程的开始
确定处理程序执行链
几个组件参与分派请求(见图 4-4 )。当请求准备好分派时,DispatcherServlet咨询一个或多个org.springframework.web.servlet.HandlerMapping实现来确定哪个处理程序可以处理该请求。如果没有找到处理程序,HTTP 404 响应将被发送回客户端。HandlerMapping 返回org.springframework.web.servlet.HandlerExecutionChain(您将在下一节了解更多)。当处理程序确定后,servlet 试图找到org.springframework.web.servlet.HandlerAdapter来执行找到的处理程序。如果找不到合适的HandlerAdapter,则抛出javax.servlet.ServletException。

图 4-4
分派请求
执行处理程序执行链
为了处理请求,DispatcherServlet使用HandlerExecutionChain class来决定执行什么。该类包含对需要调用的实际处理程序的引用;然而,它也(可选地)引用在处理程序执行之前(preHandle方法)和之后(postHandle方法)执行的org.springframework.web.servlet.HandlerInterceptor实现。这些拦截器可以应用横切功能(参见第六章了解更多关于这个主题的信息)。如果代码执行成功,拦截器会以相反的顺序再次被调用;最后,当需要时,视图被渲染(见图 4-5 )。

图 4-5
处理请求
处理程序的执行被委托给在上一步中确定的选定的HandlerAdapter。它知道如何执行选定的处理程序,并将响应翻译成org.springframework.web.servlet.ModelAndView。
如果返回的model and view中没有视图,则根据传入的请求查询org.springframework.web.servlet.RequestToViewNameTranslator以生成视图名称。
处理程序异常
当在处理请求的过程中抛出异常时,DispatcherServlet咨询已配置的org.springframework.web.servlet.HandlerExceptionResolver实例来处理抛出的异常。解析器可以将异常转换成视图向用户显示。例如,如果有一个与数据库错误相关的异常,您可以显示一个页面,指示数据库关闭。如果异常没有得到解决,它将被重新抛出并由 servlet 容器处理,这通常会导致 HTTP 500 响应代码(内部服务器错误)。图 4-6 显示了请求处理工作流程的这一部分。

图 4-6
异常处理
渲染视图
如果在请求处理工作流程中选择了一个视图,DispatcherServlet首先检查它是否是一个视图引用(如果视图是java.lang.String就是这种情况)。如果是这样的话,那么将参考已配置的org.springframework.web.servlet.ViewResolverbean 来解析对实际org.springframework.web.servlet.View实现的视图引用。如果没有观点和一个不能解决,javax.servlet.ServletException被抛出。图 4-7 显示了视图渲染过程。

图 4-7
视图渲染过程
完成加工
每个传入的请求都经过请求处理流程的这一步,不管是否有异常。如果一个handler execution chain可用,拦截器的afterCompletion方法被调用。只有成功调用了preHandle方法的拦截器才会调用它们的afterCompletion方法。接下来,这些拦截器以调用它们的preHandle方法的相反顺序执行。这模拟了 servlet 过滤器中的行为,其中第一个被调用的过滤器也是最后一个被调用的过滤器。
最后,DispatcherServlet使用 Spring 框架中的事件机制来触发org.springframework.web.context.support.RequestHandledEvent(见图 4-8 )。您可以创建并配置org.springframework.context.ApplicationListener来接收和记录这些事件。

图 4-8
完成加工
请求处理摘要
DispatcherServlet是使用 Spring MVC 处理请求的关键组件。它也是高度灵活和可配置的。这种灵活性来自于这样一个事实,即 servlet 使用许多不同的组件来完成它的角色,并且这些组件被表示为接口。表 4-1 给出了请求处理工作流中涉及的所有主要组件类型的概述。
表 4-1
请求处理工作流中使用的 DispatcherServlet 组件
|组件类型
|
描述
|
| --- | --- |
| org.springframework.web.multipart.MultipartResolver | 处理多部分表单处理的策略接口 |
| org.springframework.web.servlet.LocaleResolver | 区域解析和修改策略 |
| org.springframework.web.servlet.ThemeResolver | 主题解析和修改的策略 |
| org.springframework.web.servlet.HandlerMapping | 将传入请求映射到处理程序对象的策略 |
| org.springframework.web.servlet.HandlerAdapter | 处理程序对象类型执行处理程序的策略 |
| org.springframework.web.servlet.HandlerExceptionResolver | 处理处理程序执行期间引发的异常的策略 |
| org.springframework.web.servlet.RequestToViewNameTranslator | 处理程序返回 none 时确定视图名称的策略 |
| org.springframework.web.servlet.ViewResolver | 将视图名称转换为实际视图实现的策略 |
| org.springframework.web.servlet.FlashMapManager | 模拟 flash 范围的策略 |
在接下来的章节中,您将看到如何配置DispatcherServlet。您还将进一步了解各种组件的不同实现。
前端控制器
像任何 servlet 一样,org.springframework.web.servlet.DispatcherServlet需要进行配置,以便 web 容器可以引导和映射 servlet。这样,它可以处理请求。配置DispatcherServlet是一个双向过程。首先,您需要告诉容器加载一个 servlet,并将其映射到一个或多个 URL 模式。
在引导之后,servlet 使用创建的org.springframework.web.context.WebApplicationContext来配置自己。servlet 试图从这个应用上下文中检测所需的组件,如果没有找到,它将使用默认值(在大多数情况下)。
引导调度程序 Servlet
servlet 规范(从版本 3.0 开始)有几个配置和注册 servlet 的选项。
-
选项 1:使用一个
web.xml文件(参见清单 4-1 )。 -
选项 2:使用一个
web-fragment.xml文件(参见清单 4-2 )。 -
选项 3:使用
javax.servlet.ServletContainerInitializer(见清单 4-3 )。 -
选项 4:示例应用使用 Spring 5.2,因此您可以通过实现
org.springframework.web.WebApplicationInitializer接口获得第四个选项。 -
选项 5:使用 Spring Boot 自动配置
DispatcherServlet.
dispatcher servlet 需要一个web application context,它应该包含使 dispatcher servlet 能够配置自身的所有 beans。默认情况下,dispatcher servlet 创建org.springframework.web.context.support.XmlWebApplicationContext。
接下来的部分中的所有样本加载org.springframework.web.servlet.DispatcherServlet并将其映射到所有传入的请求(/)。所有这些配置都导致 servlet 的相同运行时设置。只是你做这件事的机制不同。本书的其余部分使用选项 4 来配置示例应用。
org.springframework.web.context.WebApplicationContext是org.springframework.context.ApplicationContext的专门扩展,在网络环境中是需要的(更多信息见第二章)。
您在本书中构建的示例应用尽可能多地使用选项 5 来配置环境和应用。然而,您将学习配置 servlet 的所有四个选项的基本设置。
使用 web.xml
自从 servlet 规范出现以来,web.xml文件就一直存在。它是一个 XML 文件,包含引导 servlet、监听器和/或过滤器所需的所有配置。清单 4-1 显示了引导DispatcherServlet所需的最小 web.xml 配置。web.xml文件必须在 web 应用的WEB-INF目录中(这由 servlet 规范决定)。
<web-app xmlns:="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0" metadata-complete="true">
<servlet>
<servlet-name>bookstore</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>bookstore</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>
Listing 4-1The web.xml Configuration (Servlet 4.0)
默认情况下,dispatcher servlet 从WEB-INF目录加载一个名为[servletname]-servlet.xml的文件。
web-app 元素中的metadata-complete属性指示 servlet 容器不要扫描javax.servlet.ServletContainerInitializer实现的类路径;它也不扫描web-fragment.xml文件。将这个属性添加到您的web.xml中会大大增加启动时间,因为它会扫描类路径,这在大型应用中需要时间。
使用 web-fragment.xml
web-fragment.xml 特性从 servlet 规范的 3.0 版本开始就可用了,它允许对 web 应用进行更加模块化的配置。web-fragment.xml必须在 jar 文件的META-INF目录中。它不会在 web 应用的META-INF中被检测到;它必须在一个 jar 文件中。web-fragment.xml可以包含与web.xml相同的元素(参见清单 4-2 )。
这种方法的好处是打包成 jar 文件的每个模块都有助于 web 应用的配置。这也被认为是一个缺点,因为现在你已经将你的配置分散到你的代码库,这在更大的项目中可能是麻烦的。
<web-fragment xmlns:="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-fragment_4_0.xsd"
version="4.0" metadata-complete="true">
<servlet>
<servlet-name>bookstore</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>bookstore</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
</web-fragment>
Listing 4-2The web-fragment.xml Configuration (Servlet 4.0)
使用 ServletContainerInitializer
servlet 规范的 3.0 版本引入了使用基于 Java 的方法来配置 web 环境的选项(参见清单 4-3 )。Servlet 3.0+兼容容器扫描类路径,寻找实现javax.servlet.ServletContainerInitializer接口的类,并调用这些类的onStartup方法。通过在这些类上添加一个javax.servlet.annotation.HandlesTypes注释,您还可以得到进一步配置 web 应用所需的类(这是允许第四个选项使用org.springframework.web.WebApplicationInitializer的机制)。
像 web 片段一样,ServletContainerInitializer允许 web 应用的模块化配置,但是现在是以基于 Java 的方式。使用 Java 给你带来了使用 Java 语言代替 XML 的所有好处。此时,您有了强类型,可以影响 servlet 的构造,并且有了配置 servlet 的更简单的方法(在 XML 文件中,这是通过在 XML 文件中添加 init-param 和/或 context-param 元素来完成的)。
package com.apress.prospringmvc.bookstore.web;
import java.util.Set;
// javax.servlet imports omitted.
import org.springframework.web.servlet.DispatcherServlet;
public class BookstoreServletContainerInitializer
implements ServletContainerInitializer {
@Override
public void onStartup(Set<Class<?>> classes, ServletContext servletContext)
throws ServletException {
ServletRegistration.Dynamic registration;
registration = servletContext.addServlet("ds", DispatcherServlet.class);
registration.setLoadOnStartup(1);
registration.addMapping("/");
}
}
Listing 4-3A Java-based Configuration
使用 WebApplicationInitializer
现在是时候看看在使用 Spring 时配置应用的选项 4 了。Spring 提供了一个ServletContainerInitializer实现(org.springframework.web.SpringServletContainerInitializer),让生活变得更简单一些(参见清单 4-4 )。Spring 框架提供的实现检测并实例化所有实例of org.springframework.web. WebApplicationInitializer and calls the onStartup这些实例的方法。
package com.apress.prospringmvc.bookstore.web;
// javax.servlet imports omitted
import org.springframework.web.WebApplicationInitializer;
import org.springframework.web.servlet.DispatcherServlet;
public class BookstoreWebApplicationInitializer
implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext)
throws ServletException {
ServletRegistration.Dynamic registration
registration = servletContext.addServlet("dispatcher", DispatcherServlet.class);
registration.addMapping("/");
registration.setLoadOnStartup(1);
}
}
Listing 4-4The WebApplicationInitializer Configuration
使用这个特性会影响应用的启动时间!首先,servlet 容器需要扫描所有javax.servlet.ServletContainerInitializer实现的类路径。其次,扫描类路径中的org.springframework.web.WebApplicationInitializer实现。在大型应用中,这种扫描可能需要一些时间。
不要直接实现WebApplicationInitializer,,而是使用 Spring 的一个类。
使用 Spring Boot
使用 Spring Boot 时,不需要手动配置DispatcherServlet。Spring Boot 根据检测到的配置自动进行配置。表 4-2 中提到的属性大多可以通过spring.mvc名称空间中的属性进行配置。基本样品见清单 4-5 。
package com.apress.prospringmvc.bookstore;
@SpringBootApplication
public class BookstoreApplication {
public static void main(String[] args) {
SpringApplication.run(BookstoreApplication.class, args);
}
}
Listing 4-5BookstoreApplication Using Spring Boot
在经典的战争应用中使用 Spring Boot 时,需要一个专门的WebApplicationInitializer。Spring Boot 为此提供了SpringBootServletInitializer。样本见清单 4-6 。
package com.apress.prospringmvc.bookstore;
@SpringBootApplication
public class BookstoreApplication extends SpringBootServletInitializer {
public static void main(String[] args) {
SpringApplication.run(BookstoreApplication.class, args);
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(BookstoreApplication.class);
}
}
Listing 4-6BookstoreApplication Using Spring Boot in a WAR
配置 DispatcherServlet
配置org.springframework.web.servlet.DispatcherServlet是一个两步过程。第一步是通过直接在 dispatcher servlet(声明)上设置属性来配置 servlet 的行为。第二步是在应用上下文中配置组件(初始化)。
dispatcher servlet 附带了许多组件的默认设置。这使您不必为基本行为做大量的配置,并且您可以根据需要覆盖和扩展配置。除了 dispatcher servlet 的默认配置之外,Spring MVC 也有一个默认配置。这可以通过使用org.springframework.web.servlet.config.annotation.EnableWebMvc注释来启用(参见第二章中的“启用功能”一节)。
使用 Spring Boot 时,不需要添加@EnableWebMvc,因为当 Spring Boot 在类路径上检测到 Spring MVC 时,它默认是启用的。
DispatcherServlet 属性
dispatcher servlet 有几个可以设置的属性。所有这些属性都有一个 setter 方法,并且都可以通过编程或包含 servlet 初始化参数来设置。表 4-2 列出并描述了 dispatcher servlet 上可用的属性。
表 4-2
DispatcherServlet 的属性
|财产
|
默认
|
描述
|
| --- | --- | --- |
| cleanupAfterInclude | 真实的 | 指示是否在包含请求后清除请求属性。通常,缺省值就足够了,只有在特殊情况下才应该将该属性设置为 false。 |
| contextAttribute | 空 | 存储此 servlet 的应用上下文。如果应用上下文是通过 servlet 本身之外的某种方式创建的,这将非常有用。 |
| contextClass | org.springframework.web.context.support.XmlWebApplicationContext | 配置 servlet 要构造的org.springframework.web.context.WebApplicationContext的类型(它需要一个默认的构造函数)。使用给定的contextConfigLocation进行配置。如果使用构造函数传入应用上下文,则不需要它。 |
| contextConfigLocation | [servlet-name]-servlet.xml | 指示指定应用上下文类的配置文件的位置。 |
| contextId | 空 | 提供应用上下文 ID。例如,这在上下文被记录或发送到System.out时使用。 |
| contextInitializerscontextInitializerClasses | Null | 使用可选的org.springframework.context.ApplicationContextInitializer类为应用上下文执行一些初始化逻辑,比如激活某个概要文件。 |
| detectAllHandlerAdapters | True | 从应用上下文中检测所有的org.springframework.web.servlet.HandlerAdapter实例。当设置为false时,使用特殊名称handlerAdapter检测单个信号。 |
| detectAllHandlerExceptionResolvers | True | 从应用上下文中检测所有的org.springframework.web.servlet.HandlerExceptionResolver实例。当设置为false时,使用特殊名称handlerExceptionResolver检测单个信号。 |
| detectAllHandlerMappings | True | 从应用上下文中检测所有的org.springframework.web.servlet.HandlerMappingbean。当设置为false时,使用特殊名称handlerMapping检测单个信号。 |
| detectAllViewResolvers | True | 从应用上下文中检测所有的org.springframework.web.servlet.ViewResolverbean。当设置为false时,使用特殊名称viewResolver检测单个信号。 |
| dispatchOptionsRequest | False | 指示是否处理 HTTP 选项请求。默认为false;当设置为true时,还可以处理 HTTP OPTIONS 请求。 |
| dispatchTraceRequest | False | 指示是否处理 HTTP 跟踪请求。默认值为 false 当设置为true时,还可以处理 HTTP 跟踪请求。 |
| environment | org.springframework.web.context.support.StandardServletEnvironment | 为这个 servlet 配置org.springframework.core.env.Environment。环境指定哪个配置文件是活动的,并且可以保存特定于该环境的属性。 |
| 命名空间 | [servletname]-servlet | 使用此命名空间来配置应用上下文。 |
| publishContext | True | 指示 servlet 的应用上下文是否被发布到javax.servlet.ServletContext。对于生产,我们建议您将此设置为false。 |
| publishEvents | True | 指示请求处理后是否触发org.springframework.web.context.support.ServletRequestHandledEvent。您可以使用org.springframework.context.ApplicationListener来接收这些事件。 |
| threadContextInheritable | False | 指示是否向从请求处理线程创建的子线程公开LocaleContext和RequestAttributes。 |
应用上下文
org.springframework.web.servlet.DispatcherServlet需要org.springframework.web.context.WebApplicationContext用需要的组件来配置自己。您可以让 servlet 自己构造一个,或者使用构造函数来传递应用上下文。在基于 XML 的配置文件中,使用第一个选项(因为无法构造应用上下文)。在基于 Java 的配置中,使用第二个选项。
在示例应用中,com.apress.prospringmvc.bookstore.web.BookstoreWebApplicationInitializer class引导应用。要启用基于 Java 的配置,您需要指示 servlet 使用基于 Java 的应用上下文(默认情况下是基于 XML 的上下文),并向它传递配置类。您使用org.springframework.web.context.support.AnnotationConfigWebApplicationContext类来设置应用和配置 servlet。清单 4-7 中的变更以粗体突出显示。
package com.apress.prospringmvc.bookstore.web;
// javax.servlet imports omitted.
import org.springframework.web.WebApplicationInitializer;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
import com.apress.prospringmvc.bookstore.web.config.WebMvcContextConfiguration;
public class BookstoreWebApplicationInitializer implements WebApplicationInitializer {
@Override
public void onStartup(final ServletContext servletContext) throws ServletException {
registerDispatcherServlet(servletContext);
}
private void registerDispatcherServlet(final ServletContext servletContext) {
WebApplicationContext dispatcherContext = createContext(WebMvcContextConfiguration.class);
DispatcherServlet dispatcherServlet = new DispatcherServlet(dispatcherContext);
ServletRegistration.Dynamic dispatcher;
dispatcher = servletContext.addServlet("dispatcher", dispatcherServlet);
dispatcher.setLoadOnStartup(1);
dispatcher.addMapping("/");
}
private WebApplicationContext createContext(final Class<?>... annotatedClasses) {
AnnotationConfigWebApplicationContext
context = new AnnotationConfigWebApplicationContext();
context.register(annotatedClasses);
return context;
}
}
Listing 4-7The BookstoreWebApplicationInitializer with ApplicationContext
清单 4-7 展示了如何构造org.springframework.web.servlet.DispatcherServlet并传递给它一个应用上下文。这是配置 servlet 的最基本的方式。
第二章封面人物简介。要选择一个概要文件,您可以包含一个 servlet 初始化参数(参见第二章);然而,为了更加动态,您可以使用org.springframework.context.ApplicationContextInitializer。这种初始化器在加载所有 beans 之前初始化应用上下文。
当您想要配置或设置想要使用的配置文件时,这在 web 应用中非常有用(更多信息,请参见第二章)。例如,您可能需要设置一个自定义系统属性。或者,您可以通过读取文件系统上的某个文件或选择基于操作系统的配置文件来检测配置文件。你有几乎无限多的选择。
packag* org.cloudfoundry.reconfiguration.spring;
// Other imports omitted
import org.cloudfoundry.runtime.env.CloudEnvironment;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.PropertySource;
public final class CloudApplicationContextInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered {
private static final Log logger = LogFactory.getLog(CloudApplicationContextInitializer.class);
private static final int DEFAULT_ORDER = 0;
private ConfigurableEnvironment springEnvironment;
private CloudEnvironment cloudFoundryEnvironment;
public CloudApplicationContextInitializer() {
cloudFoundryEnvironment = new CloudEnvironment();
}
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
if (!cloudFoundryEnvironment.isCloudFoundry()) {
logger.info("Not running on Cloud Foundry.");
return;
}
try {
logger.info("Initializing Spring Environment for Cloud Foundry");
springEnvironment = applicationContext.getEnvironment();
addPropertySource(buildPropertySource());
addActiveProfile("cloud");
} catch(Throwable t) {
// be safe
logger.error("Unexpected exception on initialization: " + t.getMessage**(), t);
}
}
// Other methods omitted
}
Listing 4-8The CloudApplicationContextInitializer
组件分辨率
当 servlet 被配置时,它从 servlet 容器接收一个初始化请求。当 servlet 初始化时,它使用逻辑来检测所需的组件(参见图 4-9 )。

图 4-9
DispatcherServlet 的组件发现
有些组件是通过类型来检测的,而有些是通过名称来检测的。对于类型可检测的组件,您可以指定(见表 4-2 )您不想这样做。在这种情况下,组件由一个众所周知的名称来检测。表 4-3 列出了请求处理中涉及的不同组件以及用于检测它的 bean 名称。该表还指示 dispatcher servlet 是否自动检测多个实例(如果可以禁用 yes,则按照表中指定的名称检测单个 bean)。
表 4-3
组件及其名称
|成分
|
默认 Bean 名称
|
检测多个
|
| --- | --- | --- |
| org.springframework.web.multipart.MultipartResolver | multipartResolver | 不 |
| org.springframework.web.servlet.LocaleResolver | localeResolver | 不 |
| org.springframework.web.servlet.ThemeResolver | themeResolver | 不 |
| org.springframework.web.servlet.HandlerMapping | handlerMapping | 是 |
| org.springframework.web.servlet.HandlerAdapter | handlerAdapter | 是 |
| org.springframework.web.servlet.HandlerExceptionResolver | handlerExceptionResolver | 是 |
| org.springframework.web.servlet.RequestToViewNameTranslator | requestToViewNameTranslator | 不 |
| org.springframework.web.servlet.ViewResolver | viewResolver | 是 |
| org.springframework.web.servlet.FlashMapManager | flashMapManager | 不 |
DispatcherServlet 的默认配置
您可能会对处理请求所涉及的所有组件感到有点不知所措。您甚至可能想知道是否需要显式地配置它们。幸运的是,Spring MVC 有一些合理的缺省值,在很多情况下,这些缺省值足够了——或者至少足够开始使用了。正如您在表 4-4 中看到的,dispatcher servlet 有一些默认设置。您可以在下一节找到关于不同实现的更多信息。
表 4-4
DispatcherServlet 的默认组件
|成分
|
默认实施
|
| --- | --- |
| MultipartResolver | 不需要默认的显式配置 |
| LocaleResolver | org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver |
| ThemeResolver | org.springframework.web.servlet.theme.FixedThemeResolver |
| HandlerMapping | org.springframework.web.servlet.handler.BeanNameUrlHandlerMapping、org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping、org.springframework.web.servlet.function.support.RouterFunctionMapping |
| HandlerAdapter | org.springframework.web.servlet.mvc.HttpRequestHandlerAdapter、org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter、org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter、org.springframework.web.servlet.function.support.HandlerFunctionAdapter |
| HandlerExceptionResolver | org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver、org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver、org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver |
| RequestToViewNameTranslator | org.springframework.web.servlet.view.DefaultRequestToViewNameTranslator |
| ViewResolver | org.springframework.web.servlet.view.InternalResourceViewResolver |
| FlashMapManager | org.springframework.web.servlet.support.SessionFlashMapManager |
Spring Boot 违约
Spring Boot 继承了上一节提到的大部分默认配置。然而,它在某些部分确实有所不同。
Spring Boot 默认使能org.springframework.web.multipart.support.StandardServletMultipartResolver。这可以通过声明自己的MultipartResolver或将spring.servlet.multipart.enabled属性设置为false来禁用。spring.servlet.multipart名称空间中的其他属性可以配置文件上传。
接下来,它向列表中添加了两个ViewResolver。它增加了org.springframework.web.servlet.view.BeanNameViewResolver和org.springframework.web.servlet.view.ContentNegotiatingViewResolver。它仍然有InternalResourceViewResolver,可以通过使用spring.mvc.view.prefix和spring.mvc.view.suffix属性对其进行部分配置。
Spring MVC 组件
在前面的章节中,您了解了请求处理工作流以及其中使用的组件。您还学习了如何配置org.springframework.web.servlet.DispatcherServlet。在本节中,您将仔细查看请求处理工作流中涉及的所有组件。例如,您探索不同组件的 API,并查看 Spring 框架附带了哪些实现。
的配置
Handler mapping决定将传入的请求分派给哪个处理程序。可以用来映射传入请求的标准是 URL 然而,实现(见图 4-10 )可以自由选择使用什么标准来确定映射。
org.springframework.web.servlet. HandlerMapping的 API 由一个方法组成(参见清单 4-9 )。这个方法被DispatcherServlet调用来确定org.springframework.web.servlet.HandlerExecutionChain。可以配置多个处理程序映射。servlet 依次调用不同的处理程序映射,直到其中一个不返回 null。
package org.springframework.web.servlet;
import javax.servlet.http.HttpServletRequest;
public interface HandlerMapping {
HandlerExecutionChain getHandler(HttpServletRequest request)
throws Exception;
}
Listing 4-9The HandlerMapping API

图 4-10
HandlerMapping实施
开箱即用,Spring MVC 提供了四种不同的实现。大多数都是基于 URL 映射的。其中一个实现提供了更复杂的映射策略,稍后您将了解到这一点。然而,在查看不同的实现之前,请仔细查看 URL,看看哪些部分是重要的。
请求 URL 由几个部分组成。我们来解剖一下 http://www.example.org/bookstore/app/home 这个网址。一个 URL 由四部分组成(见图 4-11 )。

图 4-11
URL 映射
-
服务器的主机名,由协议+
://+主机名或域名+:+端口组成 -
应用的名称(如果是根应用,则为 none)
-
servlet 映射的名称(在示例应用中,它被映射到/)
-
servlet 内部的路径
默认情况下,所有提供的处理程序映射实现都使用 servlet 内部相对于 servlet 上下文的路径(servlet 上下文相对路径)来解析处理程序。将alwaysUseFullPath属性设置为 true 可以改变这种行为。然后包含 servlet 映射,这(对于手边的例子)导致 /app/home 解析请求处理程序;否则,使用 /home 。
所有实现共有的最后一个特性是可以配置默认的处理程序。这是通过设置defaultHandler属性来完成的。当找不到传入请求的处理程序时,它总是被映射到默认处理程序。这是可选的,应该谨慎使用,尤其是在链接多个处理程序映射时。只有最后一个处理程序映射应该指定一个默认的处理程序,否则链会断开。
beannomeler 映射
org.springframework.web.servlet.handler.BeanNameUrlHandlerMapping实现是 dispatcher servlet 使用的默认策略之一。该实现将任何名称以/开头的 bean 视为潜在的请求处理程序。一个 bean 可以有多个名称,名称也可以包含一个通配符,用*表示。
这个实现使用 ant 样式的正则表达式将传入请求的 URL 与 bean 的名称进行匹配。它遵循这个算法。
-
尝试精确匹配;如果找到,退出。
-
在所有注册的路径中搜索匹配项;最具体的获胜。
-
如果没有找到匹配项,则返回映射到/*或默认处理程序(如果已配置)的处理程序。
bean 的名称不同于 ID。过去,它是由 XML 规范定义的,不能包含特殊字符,如/。这意味着您需要使用 bean 的名称。您可以通过在org.springframework.context.annotation.Bean注释上设置 name 属性来提供 bean 的名称。一个 bean 可以有多个名字,名字可以写成 ant 风格的正则表达式。
清单 4-10 展示了如何使用 bean 名称并将其映射到/index.htm URL。在示例应用中,您现在可以使用 http://localhost:8080/chapter 4-book store/index . htm 来调用这个控制器。
package com.apress.prospringmvc.bookstore.web.config;
import java.util.Properties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.apress.prospringmvc.bookstore.web.IndexController;
@Configuration
public class WebMvcContextConfiguration {
@Bean(name = { "/index.htm" })
public IndexController indexController() {
return new IndexController();
}
}
Listing 4-10The BeanNameUrlHandlerMapping
sample Configuration
SimpleUrlHandlerMapping
与org.springframework.web.servlet.handler.BeanNameUrlHandlerMapping相反,这种实现需要显式配置,并且它不会自动检测映射。清单 4-11 显示了一个示例配置。同样,将控制器映射到/index.htm。
package com.apress.prospringmvc.bookstore.web.config;
// Other imports omitted see Listing 4-10
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping;
@Configuration
public class WebMvcContextConfiguration {
@Bean
public IndexController indexController() {
return new IndexController();
}
@Bean
public HandlerMapping simpleUrlHandlerMapping() {
var mappings = new Properties();
mappings.put("/index.htm", "indexController");
var urlMapping = new SimpleUrlHandlerMapping();
urlMapping.setMappings(mappings);
return urlMapping;
}
}
Listing 4-11The SimpleUrlHandlerMapping
Sample Configuration
您需要显式配置SimpleUrlHandlerMapping并向其传递映射(参见粗体代码)。您将/index.htm URL 映射到名为 indexController 的控制器。如果您有很多控制器,这种配置会大大增加。这种方法的优点是所有的映射都在一个位置。
RequestMappingHandlerMapping
RequestMappingHandlerMapping的实现更加复杂。它使用注释来配置映射。注释可以在类和/或方法级别。为了将com.apress.prospringmvc.bookstore.web.IndexController映射到/index.htm,您需要添加@RequestMapping注释。清单 4-12 是控制器,清单 4-13 显示了示例配置。
package com.apress.prospringmvc.bookstore.web.config;
// Other imports omitted see Listing 4-10
@Configuration
public class WebMvcContextConfiguration {
@Bean
public IndexController indexController() {
return new IndexController();
}
}
Listing 4-13An annotation-based sample Configuration
package com.apress.prospringmvc.bookstore.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class IndexController {
@RequestMapping(value = "/index.htm")
public ModelAndView indexPage() {
return new ModelAndView("/WEB-INF/views/index.jsp");
}
}
Listing 4-12The IndexController with RequestMapping
RouterFunctionMapping
org.springframework.web.servlet.function.support.HandlerFunctionAdapter实现是定义处理程序的函数方式。清单 4-14 展示了编写处理程序来呈现索引页面的函数风格。
package com.apress.prospringmvc.bookstore.web.config;
// Other imports omitted see Listing 4-10
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
@Configuration
public class WebMvcContextConfiguration {
@Bean
public RouterFunction<ServerResponse> routes() {
return route()
.GET("/", response -> ok().render("index"))
.build();
}
}
Listing 4-14A Functional-Style Sample Configuration
处理器适配器
org.springframework.web.servlet. HandlerAdapter是 dispatcher servlet 和所选 handler 之间的绑定器。它从 dispatcher servlet 中删除了实际的执行逻辑,这使得 dispatcher servlet 具有无限的可扩展性。将该组件视为 servlet 和实际处理程序实现之间的绑定器。清单 4-15 显示了HandlerAdapter API。
package org.springframework.web.servlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface HandlerAdapter {
boolean supports(Object handler);
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
long getLastModified(HttpServletRequest request, Object handler);
}
Listing 4-15The HandlerAdapter API
如清单 4-15 所示,API 由三个方法组成。dispatcher servlet 在上下文中的每个处理程序上调用supports方法;这样做是为了确定哪个HandlerAdapter可以执行所选的处理程序。如果处理程序适配器可以执行该处理程序,则调用handle方法来执行所选的处理程序。处理程序的执行会导致org.springframework.web.servlet.ModelAndView被返回。然而,一些实现总是返回null,表明响应已经发送到客户端。
如果传入的请求是 GET 或 HEAD 请求,则调用getLastModified方法来确定底层资源最后一次被修改的时间(–1 表示总是重新生成内容)。结果作为Last-Modified请求头发送回客户端,并与If-Modified-Since请求头进行比较。如果有修改,内容会重新生成并重新发送给客户端;否则,HTTP 响应代码 304(未修改)被发送回客户端。这在 dispatcher servlet 提供静态资源时特别有用,这样可以节省带宽。
开箱即用,Spring MVC 提供了 HandlerAdapter 的五个实现(见图 4-12 )。

图 4-12
HandlerAdapter 实现
HttpRequestHandlerAdapter
org.springframework.web.servlet.mvc. HttpRequestHandlerAdapter知道如何执行org.springframework.web.HttpRequestHandler实例。Spring Remoting 主要使用这个处理程序适配器来支持一些 HTTP remoting 选项。然而,您也可以使用org.springframework.web.HttpRequestHandler接口的两个实现。一个服务静态资源,另一个将传入的请求转发给 servlet 容器的默认 servlet(更多信息见第五章)。
SimpleControllerHandlerAdapter
org.springframework.web.servlet.mvc. SimpleControllerHandlerAdapter知道如何执行org.springframework.web.servlet.mvc.Controller实现。它从控制器实例的handleRequest方法中返回org.springframework.web.servlet.ModelAndView。
simplieservlethandleradapter
在应用上下文中配置javax.servlet.Servlet实例并把它们放在 dispatcher servlet 后面会很方便。要执行这些 servlets,您需要org.springframework.web.servlet.handler. SimpleServletHandlerAdapter。它知道如何执行javax.servlet.Servlet,并且总是返回null,因为它期望 servlet 自己处理响应。
HandlerFunctionAdapter
org.springframework.web.servlet.function.support. HandlerFunctionAdapter知道如何执行org.springframework.web.servlet.function.HandlerFunction实例。它根据 h andler function的org.springframework.web.servlet.function.ServerResponse返回org.springframework.web.servlet.ModelAndView。
requestmappingchandleradapter
org.springframework.web.servlet.mvc.method.annotation. RequestMappingHandlerAdapter执行用org.springframework.web.bind.annotation.RequestMapping标注的方法。它转换方法参数并提供对请求参数的简单访问。方法的返回值被转换或添加到这个处理程序适配器内部创建的org.springframework.web.servlet.ModelAndView实现中。整个绑定和转换过程是可配置的、灵活的;在第 5 和 6 章节中解释了这些可能性。
多重解析器
org.springframework.web.multipart. MultipartResolver策略接口确定传入请求是否是多部分文件请求(用于文件上传),如果是,它将传入请求包装在org.springframework.web.multipart.MultipartHttpServletRequest中。包装后的请求可以轻松地从表单访问底层的多部分文件。文件上传在第七章中说明。清单 4-16 显示了MultipartResolver API。
package org.springframework.web.multipart;
import javax.servlet.http.HttpServletRequest;
public interface MultipartResolver {
boolean isMultipart(HttpServletRequest request);
MultipartHttpServletRequest resolveMultipart(HttpServletRequest request)
throws MultipartException;
void cleanupMultipart(MultipartHttpServletRequest request);
}
Listing 4-16The MultipartResolver API
在准备和清理请求的过程中,会调用org.springframework.web.multipart.MultipartResolver组件’s的三个方法。调用isMultipart方法来确定一个传入的请求是否是一个多部分请求。如果是,那么调用resolveMultipart方法,将原始请求包装在MultipartHttpServletRequest中。最后,当请求被处理后,调用cleanupMultipart方法来清理所有被使用的资源。图 4-13 显示了MultipartResolver的两种现成实现。

图 4-13
多解析器实现
CommonsMultipartResolver
org.springframework.web.multipart.commons. CommonsMultipartResolver使用 Commons FileUpload 库 1 来处理多部分文件。它可以轻松配置 Commons FileUpload 库的几个方面。
StandardServletMultipartResolver
Servlet 3.0 规范引入了处理多部分表单的标准方式。org.springframework.web.multipart.support. StandardServletMultipartResolver仅仅作为这个标准方法的包装器,所以它是透明公开的。
LocaleResolver
org.springframework.web.servlet. LocaleResolver策略接口决定哪个java.util.Locale渲染页面。在大多数情况下,它解析应用中的验证消息或标签。不同的实现如图 4-14 所示,并在以下小节中描述。

图 4-14
LocaleResolver 实现
清单 4-17 显示了 org . spring framework . web . servlet . locale solver 的 API。
package org.springframework.web.servlet;
import java.util.Locale;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface LocaleResolver {
Locale resolveLocale(HttpServletRequest request);
void setLocale(HttpServletRequest request,HttpServletResponse response,
Locale locale);
}
Listing 4-17The LocaleResolver API
API 由两个方法组成,每个方法都在存储和检索当前的java.util.Locale中发挥作用。当您想要更改当前的语言环境时,会调用setLocale方法。如果实现不支持这一点,就会抛出java.lang.UnsupportedOperationException。Spring Framework uses the resolveLocale method——通常在内部——解析当前的语言环境。
AcceptHeaderLocaleResolver
org.springframework.web.servlet.i18n. AcceptHeaderLocaleResolver 实现简单地委托给当前javax.servlet.HttpServletRequest的getLocale方法。它使用Accept-Language HTTP 头来确定语言。客户端设置此头值;此解析程序不支持更改区域设置。
库克埃勒索尔弗
org.springframework.web.servlet.i18n.CookieLocaleResolver实现使用javax.servlet.http.Cookie来存储要使用的语言环境。这在您希望应用尽可能无状态的情况下特别有用。实际值存储在客户端,并在每次请求时发送给您。这个解析器允许更改区域设置(你可以在第六章找到更多信息)。这个解析器还允许您配置 cookie 的名称和要使用的默认区域设置。如果不能为当前请求确定任何值(即,既没有 cookie 也没有默认的区域设置),这个解析器就退回到请求的区域设置(见AcceptHeaderLocaleResolver)。
FixedLocaleResolver
org.springframework.web.servlet.i18n. FixedLocaleResolver是org.springframework.web.servlet.LocaleResolver的最基本实现。它允许您配置在整个应用中使用的区域设置。这种配置是固定的;因此,这是无法改变的。
SessionLocaleResolver
org.springframework.web.servlet.i18n.SessionLocaleResolver实现使用javax.servlet.http.HttpSession来存储区域设置的值。可以配置属性的名称以及默认的语言环境。如果不能为当前请求确定任何值(即,既没有值存储在会话中,也没有默认的区域设置),那么它将返回到请求的区域设置(见AcceptHeaderLocaleResolver)。这个解析器还允许你改变区域设置(更多信息见第六章)。
主题解析器
org.springframework.web.servlet. ThemeResolver策略界面决定页面呈现哪个主题。有几种实现方式;这些如图 4-15 所示,并在以下小节中解释。如何应用主题在第八章中有解释。如果没有主题名称可以解析,那么这个解析器使用硬编码的默认主题。

图 4-15
ThemeResolver实施
清单 4-18 显示了org.springframework.web.servlet.ThemeResolver的 API,它类似于org.springframework.web.servlet.LocaleResolver API。
package org.springframework.web.servlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface ThemeResolver {
String resolveThemeName(HttpServletRequest request);
void setThemeName(HttpServletRequest request, HttpServletResponse response,
String themeName);
}
Listing 4-18The ThemeResolver API
当你想改变当前的主题时,调用setThemeName方法。如果不支持改变主题,它抛出java.lang.UnsupportedOperationException。Spring 框架在需要解析当前主题时会调用resolveThemeName方法。这主要是通过使用主题 JSP 标签来完成的。
CookieThemeResolver
org.springframework.web.servlet.theme. CookieThemeResolver使用javax.servlet.http.Cookie来存储要使用的主题。这在您希望应用尽可能无状态的情况下特别有用。实际值存储在客户端,并在每次请求时发送给您。此解析程序允许更改主题;你可以在第 6 和 8 章节中找到更多相关信息。这个解析器还允许您配置 cookie 的名称和要使用的主题区域设置。
FixedThemeResolver
org.springframework.web.servlet.theme. FixedThemeResolver是org.springframework.web.servlet.ThemeResolver的最基本实现。它允许你配置一个在整个应用中使用的主题。这种配置是固定的;因此,这是无法改变的。
SessionThemeResolver
org.springframework.web.servlet.theme. SessionThemeResolver使用javax.servlet.http.HttpSession存储主题的值。可以配置属性的名称和默认主题。
处理器异常解析器
在大多数情况下,您希望控制如何处理请求处理过程中发生的异常。您可以为此使用一个HandlerExceptionResolver。API(参见清单 4-19 )由一个方法组成,这个方法在由 dispatcher servlet 检测到的org.springframework.web.servlet. HandlerExceptionResolvers上被调用。解析器可以选择自己处理异常,或者返回一个包含要呈现的视图和模型的org.springframework.web.servlet.ModelAndView implementation(通常包含抛出的异常)。
package org.springframework.web.servlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface HandlerExceptionResolver {
ModelAndView resolveException(HttpServletRequest request,
HttpServletResponse response, Object handler,
Exception ex);
}
Listing 4-19The HandlerExceptionResolver API
图 4-16 显示了 Spring 框架提供的不同实现。每个都以稍微不同的方式工作,就像每个都有不同的配置一样(更多信息见第六章)。

图 4-16
HandlerExceptionResolver 实现
org.springframework.web.servlet.handler.HandlerExceptionResolverComposite实现由 Spring MVC 内部使用。它将几个org.springframework.web.servlet.HandlerExceptionResolver实现链接在一起。此解析程序不提供实际的实现或附加功能;相反,它仅仅充当多个实现的包装器(当配置了多个实现时)。
RequestToViewNameTranslator
当处理程序没有返回视图实现或视图名称,并且没有向客户端发送响应本身时,那么org.springframework.web.servlet. RequestToViewNameTranslator试图从传入的请求中确定视图名称。默认的实现(见图 4-17)org.springframework.web.servlet.view.DefaultRequestToViewNameTranslator只是简单地获取 URL,去掉后缀和上下文路径,然后使用剩余部分作为视图名(即http://localhost:8080/bookstore/admin/index.html变成了admin/index)。你可以在第八章找到更多关于视图的信息。

图 4-17
RequstToViewNameTranslator 层次结构
清单 4-20 中显示了RequestToViewNameTranslator API。
package org.springframework.web.servlet;
import javax.servlet.http.HttpServletRequest;
public interface RequestToViewNameTranslator {
String getViewName(HttpServletRequest request) throws Exception;
}
Listing 4-20The RequestToViewNameTranslator API
视图解析器
Spring MVC 提供了非常灵活的视图解析机制。它只是获取从处理程序返回的视图名称,并尝试将其解析为实际的视图实现(如果没有返回具体的org.springframework.web.servlet.View)。实际的实现可以是 JSP,但也可以是 Excel 电子表格或 PDF 文件。有关视图解析的更多信息,请参阅第八章。
这个 API(参见清单 4-21 )非常简单,由一个方法组成。该方法采用视图名称和当前选择的区域设置(参见LocaleResolver)。这可以解析一个实际的视图实现。当配置了多个org.springframework.web.servlet.ViewResolvers时,dispatcher servlet 依次调用它们,直到其中一个返回一个视图进行渲染。
package org.springframework.web.servlet;
import java.util.Locale;
public interface ViewResolver {
View resolveViewName(String viewName, Locale locale) throws Exception;
}
Listing 4-21The ViewResolver API
ViewResolver的实现如图 4-18 所示。开箱即用,Spring 提供了几个实现(更多信息参见第八章)。

图 4-18
ViewResolver 实现
FlashMapManager
org.springframework.web.servlet. FlashMapManager在 Spring MVC 应用中启用 flash“作用域”。您可以使用这种机制将属性放在一个 flash map 中,然后在重定向后检索这些属性(flash map 在请求/响应周期后仍然存在)。渲染视图后,会清除 flash 贴图。Spring 提供了一个单一的实现,org.springframework.web.servlet.support.SessionFlashMapManager(参见图 4-19 )。

图 4-19
FlashMapManager 层次结构
清单 4-22 显示了FlashMapManager API。
package org.springframework.web.servlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface FlashMapManager {
FlashMap retrieveAndUpdate(HttpServletRequest request,
HttpServletResponse response);
void saveOutputFlashMap(FlashMap flashMap, HttpServletRequest request,
HttpServletResponse response);
}
Listing 4-22The FlashMapManager API
摘要
本章从查看请求处理工作流开始,确定哪些组件起作用。可以认为DispatcherServlet是 Spring MVC 中的主要组件。它扮演着最重要的角色——前端控制器。Spring MVC 中的 MVC 模式是显式的;您有一个模型、一个视图和一个控制器(处理程序)。控制器处理请求,填充模型,并选择要呈现的视图。
在处理请求时,DispatcherServlet使用许多不同的组件来扮演它的角色。最重要的部件是HandlerMapping和HandlerAdapter;这些组件分别是用于映射和处理请求的核心组件。要应用横切关注点,可以使用HandlerInterceptor。处理完请求后,需要呈现一个视图。一个处理程序可以返回一个View或者一个要渲染的视图的名称。在后一种情况下,这个名称被传递给一个ViewResolver来解析一个实际的视图实现。
还有对 flash 范围的变量的基本支持。要让这成为可能,就有FlashMapManager。有时,请求处理不会按照您希望的方式进行。例如,您可能会遇到异常。要处理这些,您可以使用HandlerExceptionResolver。最后起作用的组件是LocaleResolver和ThemeResolver。总之,这些支持应用中的国际化和主题化。
接下来的章节将解释如何构建控制器来处理请求,并进一步研究如何通过 Spring Boot 来配置 Spring MVC。
五、实现控制器
控制器在 web 应用中起着至关重要的作用:它们执行实际的请求,准备模型,并选择要呈现的视图。结合 dispatcher servlet,控制器在请求处理工作流中也起着至关重要的作用。控制器是核心应用和应用的 web 界面之间的绑定器。本章着眼于两种不同的控制器方法,并涵盖了由 Spring 框架提供并由 Spring Boot 配置的开箱即用的实现。
本章还介绍了请求处理的支持组件。例如,我们将介绍表单提交以及如何应用国际化(I18N)。
在本章中,我们使用百里香 HTML 模板。使用 Apache Tiles 的 Spring MVC 支持 JSP 视图,但是我们不建议您在使用 Spring Boot 和嵌入式容器时使用 JSP 视图。 1
控制器简介
控制器是负责响应用户动作的组件。该操作可以是提交表单、单击链接或简单地访问页面。控制器选择或更新视图所需的数据。它还选择要渲染的视图的名称,或者可以自己渲染视图。使用 Spring MVC,我们在编写控制器时有两种选择。我们既可以实现一个接口,也可以在类上添加一个注释。界面是org.springframework.web.servlet.mvc.Controller,标注是org.springframework.stereotype.Controller。这本书的主要焦点是编写控制器的基于注释的方法。但是,我们觉得还是需要提一下基于接口的方法。
虽然这两种方法都是为了实现控制器,但是它们之间有两个主要的区别。第一个区别是灵活性,第二个区别是将 URL 映射到控制器。基于注释的控制器允许非常灵活的方法签名,而基于接口的方法在接口上有一个我们必须实现的预定义方法。接触其他有趣的合作者更加困难(但不是不可能!).
对于基于接口的方法,我们必须进行 URL 到这些控制器的显式外部映射;一般来说,这种方法与org.springframework.web.servlet.handler.SimpleUrlHandlerMapping相结合,这样所有的 URL 都在一个位置。将所有的 URL 放在一个位置是基于接口的方法优于基于注释的方法的一个优点。基于注释的方法将其映射分散在整个代码库中,这使得很难看出哪个 URL 映射到哪个请求处理方法。基于注释的控制器的优势在于,当您打开控制器时,您可以看到它映射到了哪些 URL。
本节展示了如何编写这两种类型的控制器,以及如何配置基本的视图控制器。
基于接口的控制器
要编写基于接口的控制器,我们需要创建一个实现org.springframework.web.servlet.mvc.Controller接口的类。清单 5-1 显示了该接口的 API。在实现这个接口时,我们必须实现handleRequest方法。当控制器自己处理响应时,该方法需要返回一个org.springframework.web.servlet.ModelAndView对象或null。
package org.springframework.web.servlet.mvc;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.ModelAndView;
@FunctionalInterface
public interface Controller {
ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
}
Listing 5-1The Controller Interface
我们来看一个小样本。如果我们用com.apress.prospringmvc.bookstore.web.IndexController创建一个基于接口的控制器,它看起来会像清单 5-2 中的样子。我们实现了handleRequest方法并返回了一个带有视图名的ModelAndView实例。
package com.apress.prospringmvc.bookstore.web;
// javax.servlet imports omitted
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;
public class IndexController implements Controller {
@Override
public ModelAndView handleRequest(HttpServletRequest request,
HttpServletResponse response)
throws Exception {
return new ModelAndView("index");
}
}
Listing 5-2An Interface-based IndexController
除了编写这个控制器,我们还需要配置一个org.springframework.web.servlet.HandlerMapping的实例来将/index.htm映射到这个控制器上(更多信息参见第三章)。我们还需要确保org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter被注册来执行基于接口的控制器(这是默认注册的)。
这里给出的例子很简单。现在想象一个有一些页面流的控制器。在这种情况下,我们需要检查请求是 GET 还是 POST 请求;基于此,我们需要执行不同的控制器逻辑。对于大型控制器,这可能会变得很麻烦。
表 5-1 展示了框架附带的Controller实现。
表 5-1
现有控制器实现的列表
|控制器实现
|
描述
|
| --- | --- |
| UrlFilenameViewController | 一个控制器实现,它接受 URL 的路径并将其转换为视图名。它可以配置为在视图名称后添加前缀和/或后缀。 |
| ParameterizableViewController | 返回已配置视图名称的控制器。 |
| ServletForwardingController | 一个控制器实现,将请求转发给一个命名的 servlet,它可以是一个没有任何映射的 servlet。如果您想使用 Spring MVC 基础设施来分派请求和应用拦截器,这是非常有用的。 |
| ServletWrappingController | 包装和管理 servlet 实现的控制器实现。如果您想使用 Spring MVC 基础设施来分派请求和应用拦截器,这是非常有用的。 |
表 5-1 中列出的所有控制器都位于org.springframework.web.servlet.mvc包中。
基于注释的控制器
要编写基于注释的控制器,我们需要编写一个类,并将org.springframework.stereotype.Controller注释放在该类上。此外,我们需要给类和/或方法添加一个org.springframework.web.bind.annotation.RequestMapping注释。清单 5-3 展示了我们的IndexController基于注释的方法。
package com.apress.prospringmvc.bookstore.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class IndexController {
@RequestMapping(value = "/index.htm")
public ModelAndView indexPage() {
return new ModelAndView("index");
}
}
Listing 5-3An Annotation-based IndexController
控制器包含一个带有@RequestMapping注释的方法,它指定它应该被映射到/index.htm URL,这是请求处理方法。方法没有必需的参数,我们可以返回任何我们想要的;现在,我们想还一个ModelAndView。
映射在控制器定义中,我们需要一个org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping的实例来解释这些映射(默认注册)。
配置视图控制器
到目前为止,我们编写的两个控制器样本被称为视图控制器。他们不选择数据;相反,他们只选择要渲染的视图名称。如果我们有一个包含更多这些视图的大型应用,维护和编写这些视图会变得很麻烦。Spring MVC 可以在这里帮助我们。使我们能够简单地将org.springframework.web.servlet.mvc.ParameterizableViewController添加到我们的配置中,并对其进行相应的配置。我们需要配置一个实例来返回作为视图名称的index,并将其映射到/index.htm URL。清单 5-4 显示了需要添加什么来使其工作。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.web.servlet.mvc.ParameterizableViewController;
// Other imports omitted
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
// Other methods omitted
@Bean(name = "/index.htm")
public Controller index() {
ParameterizableViewController index = new ParameterizableViewController();
index.setViewName("index");
return index;
}
}
Listing 5-4A ParameterizableViewController Configuration
那么它是如何工作的呢?我们创建控制器,将视图名称设置为 return,然后显式地将其命名为/index.htm(参见突出显示的部分)。显式命名使得org.springframework.web.servlet.handler.BeanNameUrlHandlerMapping能够获取我们的控制器并将其映射到 URL。然而,如果它变得非常大,那么我们就需要创建一些这样的方法。同样,Spring MVC 是来帮助我们的。我们可以覆盖addViewControllers方法(org.springframework.web.servlet.config.annotation.WebMvcConfigurer的方法之一),简单地将我们的视图名称注册到某些 URL。清单 5-5 展示了如何做到这一点。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
// Other imports omitted
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
// Other methods omitted
@Override
public void addViewControllers(final ViewControllerRegistry registry) {
registry.addViewController("/index.htm").setViewName("index");
}
}
Listing 5-5A ViewController Configuration
结果是一样的。ParameterizableViewController被创建并映射到/index.htm URL(见图 5-1 )。然而,第二种方法比第一种方法更容易使用,也不那么麻烦。

图 5-1
索引页
请求处理方法
编写请求处理方法可能是一项挑战。例如,一个方法应该如何映射到一个传入的请求?这里可能有几个因素,包括 URL、使用的方法(例如 GET 或 POST)、 2 参数或 HTTP 头的可用性、 3 甚至请求内容类型或要生成的内容类型(例如 XML、JSON 或 HTML)。这些以及更多因素会影响选择哪种方法来处理请求。
编写请求处理方法的第一步是在方法上放置一个org.springframework.web.bind.annotation.RequestMapping注释。这个映射由org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping检测,以创建传入 URL 到正确方法的映射(参见第四章中的“Spring MVC 组件”一节以获得更多关于处理程序映射的信息)。接下来,我们需要指定我们希望哪个 web 请求执行指定的处理程序。
注释可以放在类型(控制器)和方法级别。我们可以使用类型级别的注释进行粗粒度映射(例如 URL),然后使用方法级别的注释进一步指定何时执行方法(例如 GET 或 POST 请求)。
表 5-2 显示了可以在 RequestMapping 注释上设置的属性,以及它们如何影响映射。
表 5-2
RequestMapping属性
属性
|
描述
| |
| --- | --- | --- |
| name | 用于此映射的名称。该名称可与MvcUriComponentsBuilder一起使用,以生成动态链接。 | |
| value或path | 指定控制器对哪个或哪些 URL 做出反应,例如/order.htm。我们也可以使用 ant 风格的表达式来指定 URL。 | |
| method | 将方法绑定到特定的 HTTP 方法。支持的方法包括 GET、POST、PUT、DELETE、HEAD、OPTIONS 和 TRACE。默认情况下,选项和跟踪由org.springframework.web.servlet.DispatcherServlet处理。为了将这些方法传递给控制器,我们需要在 servlet 上分别将dispatchOptionsRequest和/或dispatchTraceRequest设置为true。 | |
| params | 缩小请求参数存在与否的范围。支持的表达式包括: | |
| | param-name=param-value | 指定的参数必须有特定的值 |
| | param-name!=param-value | 指定的参数不能有特定值。 |
| | !param-name | 请求中必须没有指定的参数。 |
| headers | 缩小 HTTP 请求头存在与否的范围。 4 支持的表达式包括以下几种: | |
| | header-name=header-value | 指定的标头必须有特定的值。 |
| | header-name!=header-value | 指定的头不能有特定的值。 |
| | !header-name | 请求标头中必须没有指定的标头。 |
| | 表达式中的值还可以在 Content-Type 或 Accept 头中包含通配符()(即 content-type="text/ "将匹配所有基于文本的内容类型)。 | |
| consumes | 指定映射请求的可消费媒体类型。我们用这个来缩小主映射的范围。例如,text/xml映射所有对 XML 内容类型的请求,但是我们也可以指定text/*来匹配所有文本内容类型。我们也可以否定它:!text/xml匹配除此之外的所有内容类型。该参数优于使用 headers 参数来指定一个Content-Type头,因为它更明确。 | |
| produces | 指定此请求处理方法接受的可生成的媒体类型。它缩小了主要映射的范围。适用于 consumes 参数的相同规则也适用于此参数。此参数比使用 headers 参数指定 Accept 标头更可取,因为它更显式。 | |
在表 5-3 中,有几个示例映射也显示了类级和方法级匹配的效果。如上所述,类上的 RequestMapping 注释适用于控制器中的所有方法。这种机制可以在类级别进行粗粒度映射,在方法级别进行细粒度映射。
表 5-3
示例映射
|班级
|
方法
|
描述
|
| --- | --- | --- |
| | @RequestMapping(value="/order.htm") | 映射到 order.htm URL 上的所有请求 |
| @RequestMapping("/order.htm") | @RequestMapping(method=RequestMethod.GET) | 映射到 order.html URL 的所有 GET 请求 |
| @RequestMapping("/order.*") | @RequestMapping(method={RequestMethod.PUT, RequestMethod.POST}) | 映射到订单的所有 PUT 和 POST 请求。网址。表示任何后缀或扩展名,例如。htm,。医生。xls 等等 |
| @RequestMapping(value="/customer.htm", consumes="application/json") | @RequestMapping(produces="application/xml") | 映射到所有发送 JSON 并接受 XML 作为响应的请求 |
| @RequestMapping(value="/order.htm") | @RequestMapping(params="add-line", method=RequestMethod.POST) | 映射到所有包含 add-line 参数的 order.htm URL 的 POST 请求 |
| @RequestMapping(value="/order.htm") | @RequestMapping(headers="!VIA") | 映射到不包含 VIA HTTP 头的 order.htm URL 的所有请求 |
支持的方法参数类型
请求处理方法可以有各种方法参数和返回值。表 5-4 中提到的大多数参数可以任意顺序使用。然而,该规则有一个例外:org.springframework.validation.BindingResult参数。该参数必须遵循我们用来绑定请求参数的模型对象。
表 5-4
支持的方法参数类型
|参数类型
|
描述
|
| --- | --- |
| javax.servlet.ServletRequest | 触发此方法的请求对象。 |
| javax.servlet.http.HttpServletRequest | 触发此方法的 HTTP 请求对象。 |
| org.springframework.web.multipart.MultipartRequest | 触发此方法的请求对象仅适用于多部分请求。该包装器允许轻松访问上传的文件。仅公开用于多部分文件访问的方法。 |
| org.springframework.web.multipart.MultipartHttpServletRequest | MultipartHttpServletRequest公开了HttpServletRequest和MultipartRequest方法。 |
| javax.servlet.ServletResponse | 与请求相关联的响应。如果我们需要自己编写响应,这是很有用的。 |
| javax.servlet.http.HttpServletResponse | 与请求相关联的响应。如果我们需要自己编写响应,这是很有用的。 |
| javax.servlet.http.HttpSession | 基础 http 会话。如果不存在会话,则启动一个会话。因此,这个论点永远不会null。 |
| org.springframework.web.context.request.WebRequest | 允许对请求和会话属性进行更通用的访问,而不依赖于底层的本机 API(例如,Servlet 或 JSF)。 |
| org.springframework.web.context.request.NativeWebRequest | WebRequest 扩展,具有用于基础请求和响应的访问器方法。 |
| java.util.Locale | 由配置的org.springframework.web.servlet.LocaleResolver确定的当前选择的区域设置。 |
| java.io.InputStream | 由ServletRequest上的getInputStream方法公开的流 |
| java.io.Reader | 由ServletRequest上的getReader方法公开的读取器。 |
| java.io.OutputStream | 由ServletResponse上的getOutputStream方法公开的响应流。它可以直接给用户写一个响应。 |
| java.io.Writer | 由ServletResponse上的getWriter方法公开的响应编写器。它可以直接给用户写一个响应。 |
| javax.security.Principal | 当前认证的用户(可以是null)。 |
| java.util.Map | 属于该控制器/请求的隐式模型。 |
| org.springframework.ui.Model | 属于该控制器/请求的隐式模型。模型实现有向模型添加对象的方法,以增加便利性。添加对象时允许方法链接,因为每个方法都返回模型。 |
| org.springframework.ui.ModelMap | 属于该控制器/请求的隐式模型。ModelMap 是一个 Map 实现,它包括一些向模型添加对象的方法,以增加便利性。 |
| org.springframework.web.multipart.MultipartFile | 将上传的文件绑定到方法参数(只有 Spring 的多部分支持才支持多个文件)。只有当请求是多部分表单提交时,它才起作用。要使用的请求属性的名称要么取自可选的org.springframework.web.bind.annotation.RequestPart注释,要么来自参数的名称(后者只有在该信息在类中可用时才有效)。 |
| javax.servlet.http.Part | 将上传的文件绑定到方法参数(只有 Spring 的多部分支持才支持多个文件)。只有当请求是多部分表单提交时,它才起作用。要使用的请求属性的名称要么取自可选的org.springframework.web.bind.annotation.RequestPart注释,要么来自参数的名称(后者只有在该信息在类中可用时才有效)。 |
| org.springframework.web.servlet.mvc.support.RedirectAttributes | 如果您想发出重定向,可以指定属性的确切列表。它还可以添加 flash 属性。此参数用于替代重定向中的隐式模型。 |
| org.springframework.validation.Errors | 在模型对象之前的的绑定和验证结果。 |
| org.springframework.validation.BindingResult | 在模型对象之前的的绑定和验证结果。具有模型的访问器方法和用于类型转换的基础结构。(对于大多数用例来说,这是不需要的,而是使用错误)。 |
| org.springframework.web.bind.support.SessionStatus | 用于将处理标记为完成的处理程序,它触发对由org.springframework.web.bind.annotation.SessionAttributes指示的会话属性的清除。有关详细信息,请参阅本章后面的“使用会话属性”一节。 |
| org.springframework.web.util.UriComponentsBuilder | URI 生成器,用于准备相对于当前请求 URL 的 URL。 |
| org.springframework.http.HttpEntity<?> | 表示 HTTP 请求或响应实体。它由请求或响应的头部和主体组成。 |
| 表单对象 | 使用类型转换将请求参数绑定到 bean 属性。这些对象作为模型属性公开。可以选择用org.springframework.web.bind.annotation.ModelAttribute进行注释。 |
| 请求正文对象 | 使用消息转换将请求体绑定到 bean 属性。这些对象需要用org.springframework.web.bind.annotation.RequestBody.进行注释 |
重定向属性
与表 5-4 中显示的内容相比,org.springframework.web.servlet.mvc.support. RedirectAttributes需要更多的解释。使用RedirectAttributes,可以准确地声明重定向需要哪些属性。默认情况下,在进行重定向时,所有模型属性都是公开的。因为重定向总是导致 GET 请求,所以所有的原语模型属性(或者原语的集合/数组)都被编码为请求参数。然而,使用带注释的控制器,模型中有一些对象(比如路径变量和其他隐式值)不需要公开,并且在我们的控制之外。
这里的RedirectAttributes可以帮助我们。如果这是一个方法参数,并且发出了重定向,则只有添加到 RedirectAttributes 实例的属性才会添加到 URL。
除了指定编码在 URL 中的属性之外,还可以指定 flash 属性,这些属性在重定向之前被存储,在重定向之后被检索并作为模型属性可用。这是通过使用已配置的org.springframework.web.servlet.FlashMapManager来完成的。Flash 属性对于无法编码的对象(非基本对象)或保持 URL 干净非常有用。
UriComponentsBuilder
UriComponentsBuilder提供了一种构建和编码 URIs 的机制。它可以采用 URL 模式,替换或扩展变量。这可以针对相对或绝对 URL 来完成。这种机制在创建 URL 时特别有用,而不是在我们需要考虑编码参数或自己进行字符串连接的情况下。这个组件始终为我们处理这些事情。清单 5-6 中的代码创建了/book/detail/42 URL。
UriComponentsBuilder
.fromPath("/book/detail/{bookId}")
.buildAndExpand("42")
.encode();
Listing 5-6The UriComponentsBuilder Sample Code
给出的例子很简单;但是,可以指定更多的变量(例如,bookId)并替换它们(例如,指定端口或主机)。还有ServletUriComponentsBuilder子类,我们可以用它来操作当前请求。例如,我们不仅可以用它来替换路径变量,还可以替换请求参数。
支持的方法参数注释
除了显式支持的类型(如前一节所述),我们还可以使用一些注释来注释我们的方法参数(见表 5-5 )。其中一些也可用于表 5-4 中提到的方法参数类型。在这种情况下,它们指定请求、cookie、头或响应中属性的名称,以及该参数是否是必需的。
表 5-5
支持的方法参数注释
|参数类型
|
描述
|
| --- | --- |
| RequestParam | 将参数绑定到单个请求参数或所有请求参数。 |
| RequestHeader | 将参数绑定到单个请求头或所有请求头。 5 |
| RequestBody | 获取带有此批注的参数的请求正文。该值使用 org . spring framework . http . converter . http message converter 进行转换。 |
| RequestPart | 将参数绑定到多部分表单提交的一部分。 |
| ModelAttribute | 使用此批注绑定和验证参数。来自传入请求的参数被绑定到给定的对象。 |
| PathVariable | 将方法参数绑定到 URL 映射中指定的路径变量(RequestMapping注释的 value 属性)。 |
| CookieValue | 将方法参数绑定到一个javax.servlet.http.Cookie。 |
| SessionAttribute | 将方法参数绑定到会话属性。 |
| RequestAttribute | 将方法参数绑定到请求属性(不要与请求参数混淆)。 |
| MatrixVariable | 将方法参数绑定到路径段内的名称-值对。 |
通过使用类型转换,所有参数值都被转换为参数类型。类型转换系统使用org.springframework.core.convert.converter.Converter或java.beans.PropertyEditor将String类型转换为实际类型。
所有的注释都存在于org.springframework.web.bind.annotation包中。
所有这些不同的方法参数类型和注释允许我们编写非常灵活的请求处理方法。然而,我们可以通过扩展框架来扩展这个机制。解析这些方法参数类型是由各种org.springframework.web.method.support.HandlerMethodArgumentResolver实现完成的。清单 5-7 显示了那个界面。如果需要,我们可以创建自己的接口实现,并将其注册到框架中。你可以在第七章中找到更多相关信息。
package org.springframework.web.method.support;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
public interface HandlerMethodArgumentResolver {
boolean supportsParameter(MethodParameter parameter);
Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory)
throws Exception;
}
Listing 5-7The HandlerMethodArgumentResolver Interface
让我们仔细看看我们可以使用的所有不同的注释类型。所有这些注释都有一些我们可以设置的属性,这些属性有默认值或者可能是必需的。
表 5-5 中的所有注释都有一个值属性。这个值属性指的是要使用的对象的名称(它适用于什么取决于注释)。如果没有填充这个值,那么回退将使用方法参数的名称。仅当使用参数信息编译类时,这种回退才可用。 6 使用ModelAttribute注释时,此规则会出现一个例外。它不使用方法参数的名称,而是使用简单的类名作为参数名称,从参数的类型中推断出名称。如果类型是数组或集合,它会通过添加 List 使其成为复数。如果我们用我们的com.apress.prospringmvc.bookstore.domain.Book作为一个参数,名字将是book;如果是数组或者集合,就会变成bookList。
他们要求停止
@RequestParam注释可以放在请求处理方法中的任何参数上。如果存在,它从请求中检索一个参数。当放上一个map时,有一些特殊的处理,这取决于是否设置了 name 属性。如果设置了名称,则检索值并转换成一个map。对于转换(参见“数据绑定”部分和表 5-6 了解更多信息),如果没有给出名称,所有请求参数都作为键/值对添加到映射中。
表 5-6
RequestParam 属性
|属性
|
缺省值
|
描述
|
| --- | --- | --- |
| required | true | 指示参数是否是必需的。如果它是必需的并且参数丢失,那么抛出org.springframework.web.bind.MissingServletRequestParameterException。 |
| defaultValue | null | 指示当请求中缺少参数时要使用的默认值。设置默认值就是隐式地将 required 设置为 false。该值可以是硬编码值,也可以是 SpEL 表达式。 |
| value或name | 空字符串 | 指示要从请求中查找的参数的名称。如果没有指定名称,则该名称是从方法参数名称派生的。如果找不到名字,抛出java.lang.IllegalArgumentException。 |
请求标题
@RequestHeader注释可以放在任何方法参数上。它将方法参数绑定到请求头。当放在map上时,所有可用的请求头都作为键/值对放在 map 上。如果将该值放在另一种类型的参数上,则使用org.springframework.core.convert.converter.Converter或PropertyEditor将该值转换为该类型(有关更多信息,请参见“数据绑定”一节和表 5-7 )。
表 5-7
请求标头属性
|属性
|
缺省值
|
描述
|
| --- | --- | --- |
| required | 真实的 | 指示参数是否是必需的。如果它是必需的并且缺少参数,则抛出org.springframework.web.bind.ServletRequestBindingException。设置为false时,null作为数值;或者,在指定时使用defaultValue。 |
| defaultValue | Null | 指示当请求中缺少参数时要使用的默认值。设置默认值就是隐式地将 required 设置为 false。该值可以是硬编码值,也可以是 SpEL 表达式。 |
| value或name | 空字符串 | 指示要绑定到的请求标头的名称。如果没有指定名称,则该名称是从方法参数名称派生的。如果找不到名字,抛出java.lang.IllegalArgumentException。 |
主体数据
@RequestBody注释标记了我们想要绑定到 web 请求主体的方法参数。通过定位和调用org.springframework.http.converter.HttpMessageConverter将主体转换为方法参数类型。这个转换器是根据请求内容类型选择的。如果没有找到转换器,抛出org.springframework.web.HttpMediaTypeNotSupportedException。默认情况下,这会导致向客户端发送代码为 415 ( SC_UNSUPPORTED_MEDIA_TYPE)的响应。
或者,也可以用 javax.validation.Valid 或 org . spring framework . validation . annotation . validated 对方法参数进行注释,以便对创建的对象进行验证。您可以在本章后面的“模型属性的验证”一节中找到更多关于验证的信息。
请求部分
当@RequestPart注释被放在类型为javax.servlet.http.Part、org.springframework.web.multipart.MultipartFile的方法参数上时(或者放在后者的集合或数组上),我们将注入该文件(或文件组)的内容。如果将其放在任何其他参数类型上,内容将通过在文件上检测到的内容类型的org.springframework.http.converter.HttpMessageConverter进行传递。如果没有找到合适的转换器,则抛出org.springframework.web.HttpMediaTypeNotSupportedException。(更多信息参见“数据绑定”部分和表 5-8 )。
表 5-8
RequestPart 属性
|属性
|
缺省值
|
描述
|
| --- | --- | --- |
| required | true | 指示参数是否是必需的。如果它是必需的并且缺少参数,则抛出org.springframework.web.bind.ServletRequestBindingException。当设置为false时,null作为一个值;或者,指定时使用defaultValue。 |
| value或name | 空字符串 | 要绑定到的请求标头的名称。如果没有指定名称,该名称将从方法参数名称中派生。如果没有找到名字,抛出java.lang.IllegalArgumentException。 |
模型属性
@ModelAttribute注释可以放在方法参数上,也可以放在方法上。当放置在方法参数上时,它将该参数绑定到模型对象。当放在一个方法上时,该方法构造一个模型对象,并且在调用任何请求处理方法之前调用该方法。这些方法可以创建要在表单中编辑的对象,或者提供表单呈现自身所需的数据。(更多信息参见“数据绑定”部分和表 5-9 )。表 9
表 5-9
模型属性
|属性
|
缺省值
|
描述
|
| --- | --- | --- |
| value或name | 空字符串 | 要绑定到的模型属性的名称。如果没有指定名称,则该名称是从方法参数类型派生的。 |
路径变量
@PathVariable注释可以和路径变量一起使用。可以在 URL 模式中使用路径变量将 URL 绑定到变量。在我们的 URL 映射中,路径变量被表示为{name}。如果我们使用/book/{isbn}/image的 URL 映射,ISBN将作为路径变量可用。(更多信息参见“数据绑定”部分和表 5-10 )。
烹饪价值
这个@CookieValue注释可以放在请求处理方法中的任何参数上。当存在时,它检索一个 cookie。当放在类型为javax.servlet.http.Cookie的参数上时,我们得到完整的 cookie 否则,cookie 的值将被转换为参数类型。(更多信息参见“数据绑定”部分和表 5-11 )。
会话属性
这个@SessionAttribute注释可以放在请求处理方法中的任何参数上。当存在时,它从HttpSession中检索一个属性。(更多信息参见“数据绑定”部分和表 5-12 )。
请求属性
这个@RequestAttribtue注释可以放在请求处理方法中的任何参数上。当存在时,它从HttpServletRequest中检索一个属性。属性是使用请求的getAttribute方法获得的,不应该与参数混淆。对于后者,使用@RequestParam注释。(更多信息参见“数据绑定”部分和表 5-13 )。
支持的方法返回值
除了所有不同的方法参数类型之外,一个请求处理方法还可以有几个不同的返回值。表 5-14 列出了请求处理方法的默认支持和处理方法返回值。
当一个任意的对象被返回,并且没有ModelAttribute注释存在时,框架试图为模型中的对象确定一个名称。它采用简单的类名(没有包的类名),首字母小写——例如,我们的 com . a press . prospring MVC . book . domain . book 的名称变成了book。当返回类型是集合或数组时,它变成类的简单名称,后缀是 List。因此,Book对象的集合变成了bookList。
当我们使用一个Model或ModelMap来添加没有明确名称的对象时,同样的逻辑也适用。这也有使用特定对象的优点,而不是简单的Map来获得对底层隐式模型的访问。
虽然支持的返回值列表已经很广泛了,但是我们可以利用框架的灵活性和可扩展性来创建我们自己的处理程序。该方法的返回值由org.springframework.web.method.support.HandlerMethodReturnValueHandler接口的实现来处理(参见清单 5-8 )。
表 5-10
PathVariable 属性
|属性
|
缺省值
|
描述
|
| --- | --- | --- |
| value或name | 空字符串 | 要绑定到的路径变量的名称。如果没有指定名称,则该名称是从方法参数名称派生的。如果没有找到名字,抛出java.lang.IllegalArgumentException。 |
package org.springframework.web.method.support;
import org.springframework.core.MethodParameter;
import org.springframework.web.context.request.NativeWebRequest;
public interface HandlerMethodReturnValueHandler {
boolean supportsReturnType(MethodParameter returnType);
void handleReturnValue(Object returnValue,
MethodParameter returnType,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest)
throws Exception;
}
Listing 5-8The HandlerMethodReturnValueHandler Interface
编写基于注释的控制器
让我们把目前为止我们开发的一些理论应用到我们的控制器上。例如,我们页面上的所有菜单选项都会导致 404 错误,这表明无法找到该页面。
在本节中,我们将向应用添加一些控制器和视图。我们首先创建一个简单的登录控制器,使用请求和请求参数进行操作。接下来,我们添加一个使用对象的图书搜索页面。最后,我们通过构建一个控制器来检索和显示一本书的细节。
一个简单的登录控制器
在我们开始编写控制器之前,我们需要有一个登录页面。在WEB-INF/views目录中,我们创建一个名为login.html的文件。最终的结构应该如图 5-2 所示。

图 5-2
添加login.html后的目录结构
表 5-11
CookieValue 属性
|属性
|
缺省值
|
描述
|
| --- | --- | --- |
| required | true | 指示参数是否是必需的。如果它是必需的并且缺少参数,则抛出org.springframework.web.bind.ServletRequestBindingException。当设置为false时,null作为一个值;或者,指定时使用defaultValue。 |
| defaultValue | null | 指示当请求中缺少参数时要使用的默认值。设置默认值就是隐式地将 required 设置为 false。该值可以是硬编码值,也可以是 SpEL 表达式。 |
| value或name | 空字符串 | 指示要绑定到的 cookie 的名称。如果没有指定名称,该名称将从方法参数名称中派生。如果没有找到名字,抛出java.lang.IllegalArgumentException。 |
登录页面需要一些内容。站点所有页面共有的内容在template/layout.html模板中声明。声明login.html继承了一些内容,并使用特殊的百里香结构(如th:fragment和th:replace)替换了一些内容。login.html页面最重要的部分是登录表单,如清单 5-9 所示。
表 5-12
会话属性属性
|属性
|
缺省值
|
描述
|
| --- | --- | --- |
| required | true | 指示参数是否是必需的。如果它是必需的并且缺少参数,则抛出org.springframework.web.bind.ServletRequestBindingException。当设置为false时,null用作一个值。 |
| value或name | 空字符串 | 指示要绑定到的 cookie 的名称。如果没有指定名称,该名称将从方法参数名称中派生。如果没有找到名字,抛出java.lang.IllegalArgumentException。 |
<form action="#" th:action="@{/login}" method="POST" id="loginForm">
<fieldset>
<legend>Login</legend>
<table>
<tr>
<td>Username</td>
<td>
<input type="text" id="username" name="username"
placeholder="Username"/>
</td>
</tr>
<tr>
<td>Password</td>
<td>
<input type="password" id="password" name="password"
placeholder="Password"/>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<button id="login">Login</button>
</td>
</tr>
</table>
</fieldset>
</form>
Listing 5-9The Login Page, login.html
除了页面,我们还需要有一个控制器,并将其映射到/login。让我们创建com.apress.prospringmvc.bookstore.web.controller.LoginController并让它呈现我们的页面(参见清单 5-10 )。
表 5-24
LocaleResolver 概述
|班级
|
描述
|
| --- | --- |
| FixedLocaleResolver | 总是解析为固定的区域设置。我们网站的所有用户都使用相同的区域设置,因此不支持更改区域设置。 |
| SessionLocaleResolver | 解析(并存储)用户的HttpSession中的区域设置。属性存储可以配置的区域设置,以及在没有区域设置时使用的默认区域设置。这样做的缺点是,区域设置不会在两次访问之间存储,因此必须在用户的会话中至少设置一次。 |
| AcceptHeaderLocaleResolver | 使用 HTTP accept 标头解析区域设置。通常,这是用户操作系统的区域设置,因此不支持更改区域设置。它也是DispatcherServlet使用的默认LocalResolver。 |
| CookieLocaleResolver | 使用 cookie 存储用户的区域设置。这个解析器的优点是区域设置保存在客户端的机器上,因此在以后访问网站时可以使用。可以配置 cookie 名称和超时,以及默认的区域设置。 |
表 5-23
消息源概述
|班级
|
描述
|
| --- | --- |
| ResourceBundleMessageSource | 使用 JVM 上可用的ResourceBundle工具。它只能从类路径加载资源。 |
| ReloadableResourceBundleMessageSource | 工作方式与ResourceBundleMessageSource类似,但是增加了重载和缓存功能。它允许资源在文件系统的任何地方;它使用 Spring 中的资源加载机制。 |
表 5-22
JSR-303 注释的字段错误的错误代码
|模式
|
例子
|
| --- | --- |
| 注释名称+对象名称+字段 | NotEmpty.newOrder.name |
| 注释名称+字段 | NotEmpty.name |
| 注释名称+字段类型 | NotEmpty.java.lang.String |
| 注释名称 | NotEmpty |
表 5-21
字段错误的错误代码
|模式
|
例子
|
| --- | --- |
| 代码+对象名称+字段 | required.newOrder.name |
| 代码+字段 | required.name |
| 代码+字段类型 | required.java.lang.String |
| 密码 | required |
表 5-20
Spring MVC 使用的 Spring 默认属性编辑器
|班级
|
说明
|
| --- | --- |
| ByteArrayPropertyEditor | 一个用于字节数组的编辑器,可以将字符串转换成相应的字节表示形式。 |
| CharterEditor | 用于Character或char字段的编辑器。它将(Unicode)字符串转换为字符。如果不止一个字符被解析,抛出java.lang.IllegalArgumentException。默认注册。 |
| CharsetEditor | 一个用于java.nio.charset.Charset的编辑器,它需要与java.nio.charset.Charset的命名方法相同的命名语法。 |
| ClassEditorClassArrayEditor | 将表示类的字符串解析成实际类的编辑器,反之亦然。当找不到类时,抛出java.lang.IllegalArgumentException。 |
| CurrencyEditor | 将货币代码翻译成Currency对象的货币编辑器。它还将货币代码公开为一个Currency对象的文本表示。 |
| CustomBooleanEditor | 用于Boolean属性的可定制属性编辑器。 |
| CustomCollectionEditor | 一个用于集合的属性编辑器,可以将任何源Collection转换成给定的目标集合类型。 |
| CustomMapEditor | 将任何源Map转换成给定目标Map类型的Map属性编辑器。 |
| CustomDateEditor | 支持定制DateFormat的java.util.Date的可定制属性编辑器。默认情况下是而不是注册的,用户必须根据需要以适当的格式注册。 |
| CustomNumberEditor | 任何Number子类的可定制属性编辑器,如Integer、Long、Float等等。默认情况下,它是注册的,但可以通过将其自定义实例注册为自定义编辑器来重写。 |
| FileEditor | 能够将字符串解析为java.io.File对象的编辑器。它是默认注册的。 |
| InputStreamEditor | 一个单向属性编辑器,能够接受文本字符串并产生java.io.InputStream,因此InputStream属性可以直接设置为字符串。注意,默认用法不会为你关闭InputStream!它是默认注册的。 |
| LocaleEditor | 能够解析到Locale对象的编辑器,反之亦然(字符串格式为【国家】【变量】,这与Locale的toString()方法提供的行为相同)。它是默认注册的。 |
| PatternEditor | 一个能够将字符串解析为 JDK 1.5 Pattern对象的编辑器,反之亦然。 |
| PropertiesEditor | 一个能够将字符串(使用 Javadoc 中为 Properties 类定义的格式进行格式化)转换为Properties对象的编辑器。它是默认注册的。 |
| StringTrimmerEditor | 修剪字符串的属性编辑器。可选地,它允许将空字符串转换为空值。默认注册的是不是;它必须由用户根据需要进行注册。 |
| TimeZoneEditor | 将时区 id 转换成时区对象的编辑器。注意,它不公开 TimeZone 对象的文本表示。 |
| URIEditor | 一个用于java.net.URI的编辑器,它直接填充 URI 属性,而不是使用字符串属性作为桥梁。默认情况下,该编辑器将字符串编码为 URIs。 |
| URLEditor | 能够将 URL 的字符串表示解析为实际的java.net.URL对象的编辑器。它是默认注册的。 |
| UUIDEditor | 默认情况下,将字符串转换为java.util.UUID,反之亦然。 |
| ZoneId | 默认情况下,将字符串转换为java.time.ZoneId,反之亦然。 |
表 5-19
书店项目中使用的百里香表达式。
|表示
|
描述
|
| --- | --- |
| <head th:replace="~{template/layout :: head('Search books')}"></head> | 可参数化的片段表达式:当前模板中的<head../>元素被替换为来自template/layout.html的元素,参数值被注入到这个<head../>元素声明中需要的地方。 |
| <div id="header" th:replace="~{template/layout :: header}" ></div> | 片段表达式:这个 div 元素被模板template/layout.html中具有相同 ID 的元素替换。 |
| <h1 id="pagetitle" th:text="#{book.search.title}">SEARCH TITLE</h1> | 消息表达式:<h1>元素的值被替换为与book.search.title消息键匹配的消息值。产生的 HTML 元素,对于和 EN 区域设置是<h1 id="pagetitle">Search Books</h1>。 |
| th:action="@{/book/search}" | 链接表达式:通过在作为参数提供的值前面添加协议、域名和应用上下文来生成完整的 URL(例如,http://localhost:8080/chapter5-bookstore/book/search)。它为表单提供了一个 POST 端点。 |
| th:object="${bookSearchCriteria}" | 变量表达式:声明用于收集表单数据的模型对象。 |
| <th:block th:each="book : ${bookList}"> </th:block> | 变量表达式–迭代:块中的内容对于bookList中的每个元素都是重复的 |
| <td><input type="text" th:field="{title}"/><span th:if="${#fields.hasErrors('title')}" class="error" th:errors="{title}"></span></td> | 变量表达式–验证:在bean-backed form,中,辅助元素可以显示验证错误。一般来说,如果用户输入的标题违反了@Valid约束,它会返回到这个页面并显示错误消息。 |
表 5-18
百里香叶标准表达类型。
|建造
|
表达式类型
|
评论
|
| --- | --- | --- |
| ${... } | 变量表达式。 | OGNL 表达式或 Spring EL 在模型属性上执行。 |
| *{... } | 选择表达式。 | 对先前用变量表达式选择的对象执行的 OGNL 表达式或 Spring EL。 |
| #{... } | 消息(i18n)表达式。 | 也称为国际化表达式以允许检索特定于地区的消息。 |
| @{... } | 链接(URL)表达式。 | 构建 URL 并添加有用的上下文和会话信息。 |
| ~{... } | 片段表达式。 | 表示标记片段并在模板中移动它们的简单方法。可以指定模板元素的继承或重写。 |
表 5-17
会话属性属性
|参数名称
|
描述
|
| --- | --- |
| value | 应存储在会话中的模型属性的名称。 |
| types | 模型属性的完全限定类名(类型)应该存储在会话中。这种类型的模型中的所有属性都存储在会话中,不管它们的名称如何。 |
表 5-16
WebDataBinder属性。
属性
|
描述
|
| --- | --- |
| allowedFields | 指定允许绑定的字段。是白名单;只有包含在此列表中的字段用于绑定。字段名还可以包含一个星号(*),用于将字段名与特定模式相匹配。默认情况下,允许所有字段。有关从绑定中排除字段的信息,请参见disallowedFields属性。 |
| autoGrowCollectionLimit | 设置绑定时自动增大集合的最大大小。此设置可以防止在绑定大型集合时出现内存不足的错误。默认情况下,它设置为 256。 |
| autoGrowNestedPaths | 如果设置为true,用默认对象值填充包含null值的路径,而不是引发异常。这个默认的对象值用于表达式的进一步遍历。当访问越界索引时,该属性还控制集合的自动增长。默认情况下,它被设置为true。 |
| bindEmptyMultipartFiles | 默认情况下,如果用户在没有选择不同文件的情况下重新提交多部分表单,会用空的多部分文件夹替换已绑定的多部分文件。如果这不是你想要的,而你想要的是null,那么关闭这个属性。 |
| bindingErrorProcessor | 设置org.springframework.validation.BindingErrorProcessor执行。Spring 提供了org.springframework.validation.DefaultBindingErrorProcessor作为默认实现。 |
| conversionService | 设置 org . spring framework . core . convert . conversion service。 |
| disallowedFields | 指定不允许绑定的字段。这是绑定期间要忽略的请求参数名称的黑名单。一般来说,将 ID 和版本字段放在那里是明智的。和allowedFields一样,这个属性可以包含一个来匹配具有特定模式的字段名。 |
| extractOldValueForEditor | 指定是否提取编辑器和转换器的旧值。默认情况下,旧值保留在绑定结果中。如果不希望这样,将该属性设置为false。如果您有带副作用的 getters(例如,它们设置其他属性或默认值),将这个设置为false也是有用的。 |
| fieldDefaultPrefix | 指定前缀以标识包含空字段默认值的参数。默认值为!。 |
| fieldMarkerPrefix | 指定前缀以标识标记未提交字段的参数。通常,这对于复选框很有用。未选中的复选框不会作为请求的一部分提交。注意,这个机制仍然让我们接收一个值。默认标记是 _(下划线)。 |
| ignoreInvalidFields | 如果设置为true,忽略无效字段。我们应该忽略模型对象中对应字段的绑定参数,但是这些参数是不可访问的吗?一般来说,当嵌套路径的一部分解析为null时,就会发生这种情况。默认值为 false(即而不是*忽略这些字段)。 |
| ignoreUnknownFields | 指示是否忽略未在模型对象上表示为参数的参数。当设置为false时,提交的所有参数必须在我们的模型对象上表示。默认为true。 |
| messageCodesResolver | 设置org.springframework.validation.MessageCodesResolver。Spring 提供了org.springframework.validation.DefaultMessageCodesResolver作为默认实现。 |
| requiredFields | 设置必填字段。如果未设置必填字段,这将导致绑定错误。 |
| validator | 设置org.springframework.validation.Validator执行。 |
表 5-15
configurablewebbindininitializer 属性
|属性
|
描述
|
| --- | --- |
| autoGrowNestedPaths | 如果设置为 true,则包含空值的路径将由默认对象值填充,而不会导致异常。此外,这个默认值用于表达式的进一步遍历。当访问越界索引时,该属性还控制集合的自动增长。默认值为 true。 |
| bindingErrorProcessor | 设置 org . spring framework . validation . bindingerrorprocessor 实现。 |
| conversionService | 设置org.springframework.core.convert.ConversionService的实例。 |
| directFieldAccess | 当设置为true时,我们不需要编写 getter/setter 来访问字段。默认为false。 |
| messageCodesResolver | sets org . springframework . validation . message codes resolve。 |
| propertyEditorRegistrar propertyEditorRegistrars | 注册一个或多个org.springframework.beans.PropertyEditorRegistrars。当我们想要使用旧式的PropertyEditors进行类型转换时,这是很有用的。 |
| Validator | 设置org.springframework.validation.Validator执行。 |
表 5-14
受支持的方法返回值
|参数类型
|
描述
|
| --- | --- |
| org.springframework.web.servlet.ModelAndView | 当ModelAndView返回时,按原样使用。它应该包含要使用的完整模型和要渲染的视图(或View)的名称(后者是可选的)。 |
| org.springframework.ui.Model | 指示此方法返回了一个模型。该模型中的对象被添加到控制器的隐式模型中,并可用于视图渲染。视图的名称由org.springframework.web.servlet.RequestToViewNameTranslator决定。 |
| java.util.Maporg.springframework.ui.ModelMap | 地图元素被添加到控制器的隐式模型中,并可用于视图渲染。视图的名称由org.springframework.web.servlet.RequestToViewNameTranslator决定。 |
| org.springframework.web.servlet.View | 要呈现的视图。 |
| java.lang.CharSequence(一般为java.lang.String) | 要呈现的视图的名称。如果用@ModelAttribute标注,它将被添加到模型中。 |
| java.lang.Void | 控制器已经准备好模型,视图的名称由org.springframework.web.servlet.RequestToViewNameTranslator.决定 |
| org.springframework.http.HttpEntity<?>org.springframework.http.ResponseEntity<?> | 指定返回给用户的标题和实体主体。实体体通过org.springframework.http.converter.HttpMessageConverter被转换并发送给响应流。可选地,HttpEntity也可以设置一个状态码发送给用户。 |
| org.springframework.http.HttpHeaders | 指定要返回给用户的标头。 |
| org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody | 将结果异步写入客户端。需要启用异步处理(默认情况下打开)。 |
| org.springframework.web.context.request.async.DeferredResultorg.springframework.util.concurrent.ListenableFuturejava.util.concurrent.CompletionStagejava.util.concurrent.Callable | 异步处理环境中使用的异步结果类型。需要启用异步处理(默认情况下打开)。 |
| 任何其他返回类型 | 所有其他返回类型都用作模型属性。该名称源自返回类型或在org.springframework.web.bind.annotation.ModelAttribute中指定的名称。除非方法返回值用@ResponseBody标注,否则它会使用HttpMessageConverter写入客户端,而不是用作模型属性。更多细节,请参见关于休息控制器的第七章。 |
表 5-13
RequestAttribtue属性
属性
|
缺省值
|
描述
|
| --- | --- | --- |
| required | true | 指示参数是否是必需的。如果它是必需的并且缺少参数,则抛出org.springframework.web.bind.ServletRequestBindingException。当设置为false时,null用作一个值。 |
| value或name | 空字符串 | 指示要绑定到的 cookie 的名称。如果没有指定名称,该名称将从方法参数名称中派生。如果没有找到名字,抛出java.lang.IllegalArgumentException。 |
package com.apress.prospringmvc.bookstore.web.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
@RequestMapping("/login")
public class LoginController {
@GetMapping
public String login() {
return "login";
}
}
Listing 5-10The Initial LoginController
重启应用后,我们点击登录按钮,我们应该会看到如图 5-3 所示的页面。

图 5-3
登录页面
如果我们现在输入用户名和密码(jd/secret)并按下 Login 按钮,我们会看到一个错误页面(错误代码 405 ),指出不支持该方法(POST)。这是正确的,因为我们的控制器还没有处理 POST 请求的方法。因此,让我们在控制器中添加一个方法来处理我们的登录。清单 5-11 显示了修改后的控制器。
package com.apress.prospringmvc.bookstore.web.controller;
import com.apress.prospringmvc.bookstore.domain.Account;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.servlet.http.HttpSession;
@Controller
@RequestMapping("/login")
public class LoginController {
private static final String ACCOUNT_ATTRIBUTE = "account";
private final AccountService accountService;
public LoginController(AccountService accountService) {
this.accountService = accountService;
}
@GetMapping
public String login() {
return "login";
}
@PostMapping
public String handleLogin(HttpServletRequest request, HttpSession session) {
try {
var username = request.getParameter("username");
var password = request.getParameter("password");
var account = this.accountService.login(username, password);
session.setAttribute(ACCOUNT_ATTRIBUTE, account);
return "redirect:/index.htm";
} catch (AuthenticationException ae) {
request.setAttribute("exception", ae);
return "login";
}
}
}
Listing 5-11The Modified LoginController
在我们继续之前,让我们深入了解一下handleLogin方法是如何工作的。从请求中检索username和password参数;他们把AccountService上的login方法叫做。如果提供了正确的凭证,我们将获得用户的一个Account实例(存储在会话中),然后重定向到索引页面。如果凭证不正确,服务抛出一个authentication exception,现在由控制器处理。异常存储为请求属性,我们将用户返回到登录页面。
尽管当前控制器完成了它的工作,我们仍然直接在HttpServletRequest上操作。这是一种麻烦(但有时是必要的)的方法;然而,我们通常希望避免这种情况,并使用灵活的方法签名来简化我们的控制器。记住这一点,让我们修改控制器,限制直接访问请求的使用(参见清单 5-12 )。
package com.apress.prospringmvc.bookstore.web.controller;
import org.springframework.web.bind.annotation.RequestParam;
// Other imports omitted, see Listing 5-11
@Controller
@RequestMapping(value = "/login")
public class LoginController {
// Other methods omitted
@PostMapping
public String handleLogin(@RequestParam String username, @RequestParam String password,
HttpServletRequest request, HttpSession session)
throws AuthenticationException {
try {
var account = this.accountService.login(username, password);
session.setAttribute(_ACCOUNT_ATTRIBUTE_, account);
return "redirect:/index.htm";
} catch (AuthenticationException ae) {
request.setAttribute("exception", ae);
return "login";
}
}
}
Listing 5-12The LoginController with RequestParam
使用@RequestParam注释简化了我们的控制器。然而,我们的异常处理规定我们仍然需要访问请求。这将在下一章我们实现异常处理时改变。
这种方法有一个缺点:在浏览器中缺乏对后退按钮的支持。如果我们返回一页,我们会得到一个漂亮的弹出窗口,询问我们是否要重新提交表单。常见的方法是在发出 7 请求后进行重定向;这样,我们可以解决双重提交的问题。在 Spring 中,我们可以通过使用RedirectAttributes来解决这个问题。清单 5-13 用粗体突出显示了对我们控制器的最终修改。
package com.apress.prospringmvc.bookstore.web.controller;
// Other imports omitted, see Listing 5-11
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
@RequestMapping(value = "/login")
public class LoginController {
// Other methods omitted
@PostMapping
public String handleLogin(@RequestParam String username,
@RequestParam String password,
RedirectAttributes redirect,
HttpSession session)
throws AuthenticationException {
try {
var account = this.accountService.login(username, password);
session.setAttribute(ACCOUNT_ATTRIBUTE, account);
return "redirect:/index.htm";
} catch (AuthenticationException ae) {
redirect.addFlashAttribute("exception", ae);
return "redirect:/login";
}
}
}
Listing 5-13The LoginController with RedirectAttributes
当应用被重新部署,并且我们登录时,输入错误的用户名/密码组合会引发一条错误消息;但是,当我们按下 Back 按钮时,弹出的表单提交请求消失了。
直到现在,我们所做的一切都是低水平的。我们的解决方案包括直接处理请求和/或响应,或者通过使用org.springframework.web.bind.annotation.RequestParam进行一点抽象。然而,我们使用面向对象的编程语言,在可能的情况下,我们希望使用对象。我们将在下一节探讨这一点。
图书搜索页面
我们有一家书店,我们想卖书。然而,目前我们的 web 应用中还没有允许用户搜索甚至查看图书列表的功能。让我们通过创建一个图书搜索页面来解决这个问题,以便我们的应用用户可以搜索图书。
首先,我们在/WEB-INF/views目录中创建一个目录book。在这个目录中,我们创建了一个名为search.html的文件。这个文件是我们的搜索表单,它也显示搜索的结果。搜索表单和结果表的代码可以在清单 5-14 中看到。
<form action="#" th:action="@{/book/search}"
method="GET" id="bookSearchForm">
<fieldset>
<legend>Search Criteria</legend>
<table>
<tr>
<td><label>Title</label></td>
<td><input type="text"/></td>
</tr>
</table>
</fieldset>
<button id="search"">Search</button>
</form>
<!-- other HTML and JavaScript code omitted -->
<table id="bookSearchResults">
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>Price</th>
</tr>
</thead>
<tbody>
<th:block th:each="book : ${bookList}">
<tr>
<td th:text="${book.title}"></td>
<td th:text="${book.description}"></td>
<td th:text="${book.price}"></td>
</tr>
</th:block>
</tbody>
</table>
Listing 5-14The Search Page Form
该页面包含一个表单,其中有一个字段用于填写搜索书籍的(部分)标题。当有结果时,我们向用户显示一个包含结果的表格。现在我们有了一个页面,我们还需要一个控制器来处理请求。清单 5-15 显示初始com.apress.prospringmvc.bookstore.web.controller.BookSearchController。
package com.apress.prospringmvc.bookstore.web.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import com.apress.prospringmvc.bookstore.domain.BookSearchCriteria;
import com.apress.prospringmvc.bookstore.service.BookstoreService;
import javax.servlet.http.HttpServletRequest;
@Controller
public class BookSearchController {
private final BookstoreService bookstoreService;
public BookSearchController(BookstoreService bookstoreService) {
this.bookstoreService=bookstoreService;
}
@GetMapping("/book/search")
public String list(Model model, HttpServletRequest request) {
var criteria = new BookSearchCriteria();
criteria.setTitle(request.getParameter("title"));
model.addAttribute(this.bookstoreService.findBooks(criteria));
return "book/search";
}
}
Listing 5-15The BookSearchController with Search
控制器对 URL 做出反应,从请求中检索 title 参数(这是我们页面中字段的名称,如清单 5-13 所示),最后继续搜索。搜索的结果被放入模型中。最初,它显示所有的书;然而,一旦输入了标题,它就会限制基于该标题的结果(参见图 5-4 )。

图 5-4
显示结果的图书搜索页面
如前所述,在大多数情况下,直接使用HttpServletRequest是不必要的。让我们把com.apress.prospringmvc.bookstore.domain.BookSearchCriteria放在方法参数列表中,让我们的搜索方法简单一点(参见清单 5-16 )。
package com.apress.prospringmvc.bookstore.web.controller;
import org.springframework.web.bind.annotation.RequestParam;
// Other imports omitted, see listing 5-15
@Controller
public class BookSearchController {
@GetMapping("/book/search")
public String list(Model model, BookSearchCriteria criteria) {
model.addAttribute(this.bookstoreService.findBooks(criteria));
return "book/search";
}
}
Listing 5-16The BookSearchController with BookSearchCriteria as a Method Argument
在 Spring MVC 中,这被称为数据绑定。为了启用数据绑定,我们需要修改com.apress.prospring.bookstore.web.controller.BookSearchController,,使其使用方法参数,而不是直接处理请求(参见清单 5-14 )。或者,它可以使用RequestParam来检索参数并在对象上设置它们。这迫使 Spring 在 criteria 方法参数上使用数据绑定。这样做将所有与我们的对象属性同名的请求参数映射到该对象(即,请求参数title被映射到属性title)。使用数据绑定简化了我们的控制器(您可以在本章的“数据绑定”一节中找到更深入的信息)。
我们可以做得更好!我们可以返回别的东西,而不是返回一个String。例如,让我们修改控制器来返回一组书籍。这个集合以名称bookList添加到模型中,如本章前面所解释的。清单 5-16 显示了这个控制器,但是我们在哪里选择视图来渲染呢?它没有被明确地指定。在第四章中,我们提到如果没有明确提到的视图需要渲染,那么org.springframework.web.servlet.RequestToViewNameTranslator就会起作用。我们看到这种机制在这里发挥作用。它取 URL(http://[server]:[port]/chapter5-bookstore/book/search);去掉服务器、端口和应用名称。删除后缀(如果有);然后使用剩下的book/search作为视图名称进行渲染(这正是我们一直在返回的)。
package com.apress.prospringmvc.bookstore.web.controller;
// Other imports omitted, see listing 5-15
@Controller
public class BookSearchController {
@GetMapping(value = "/book/search")
public Collection<Book> list(BookSearchCriteria criteria ) {
return this.bookstoreService.findBooks(criteria);
}
}
Listing 5-17The BookSearchController Alternate Version
图书详细信息页面
现在让我们在搜索页面中加入更多的功能。例如,让我们将一本书的标题设置为一个链接,该链接可以导航到一本书的详细信息页面,该页面显示了该书的图片和一些信息。我们首先修改我们的search.html并添加链接(参见清单 5-18 )。
<form action="#" th:action="@{/book/search}"
method="GET" id="bookSearchForm">
<fieldset>
<legend >Search Criteria</legend>
<table>
<tr>
<td><label for="title">Title</label></td>
<td><input type="text" th:field="*{title}"/></td>
</tr>
</table>
</fieldset>
<button id="search">Search</button>
</form>
<!-- other HTML and JavaScript code omitted -->
<table id="bookSearchResults">
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>Price</th>
</tr>
</thead>
<tbody>
<th:block th:each="book : ${bookList}">
<tr>
<td><a th:href="@{/book/detail/} + ${book.id}"
th:text="${book.title}"></a></td>
<td th:text="${book.description}"></td>
<td th:text="${book.price}"></td>
</tr>
</th:block>
</tbody>
</table>
Listing 5-18The Modified Search Page
突出显示的行是我们需要对该页面进行的唯一更改。此时,我们已经基于书的 ID 生成了一个 URL,所以我们应该得到一个类似于/book/detail/4的 URL,它向我们显示了 ID 为 4 的书的详细信息。让我们创建一个控制器来对这个 URL 做出反应,并从 URL 中提取 ID(参见清单 5-19 )。
package com.apress.prospringmvc.bookstore.web.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import com.apress.prospringmvc.bookstore.domain.Book;
import com.apress.prospringmvc.bookstore.service.BookstoreService;
@Controller
public class BookDetailController {
@GetMapping(value = "/book/detail/{bookId}")
public String details(@PathVariable("bookId") long bookId, Model model) {
var book = this.bookstoreService.findBook(bookId);
model.addAttribute(book);
return "book/detail";
}
}
Listing 5-19The BookDetailController
突出显示的代码使得提取 ID 成为可能。这是org.springframework.web.bind.annotation.PathVariable在起作用。URL 映射包含{bookId}部分,它告诉 Spring MVC 将 URL 的这一部分绑定到一个名为bookId的路径变量。然后,我们可以使用注释再次检索路径变量。除了控制器,我们还需要一个 HTML 页面来显示细节。清单 5-20 中的代码在图书目录中创建了一个detail.html。
<img th:src="@{'/resourcimg/' + ${book.isbn} + '/book_front_cover.png'}"
align="left" alt="${book.title}" width="250"/>
<table>
<tr>
<td>Title</td>
<td th:text="${book.title}"></td>
</tr>
<tr>
<td >Description</td>
<td th:text="${book.description}"></td>
</tr>
<tr>
<td>Author</td>
<td th:text="${book.author}"></td>
</tr>
<tr>
<td>Year</td>
<td th:text="${book.year}"></td>
</tr>
<tr>
<td>ISBN</td>
<td th:text="${book.isbn}"></td>
</tr>
<tr>
<td >Price</td>
<td th:text="${book.price}"></td>
</tr>
</table>
Listing 5-20The Book’s detail.html Page
如果我们在重新部署后点击搜索页面中的一个链接,我们应该会看到一个显示图书图片和一些信息的详细页面(见图 5-5 )。

图 5-5
图书的详细信息页面
数据绑定
本节探讨使用数据绑定的好处,包括如何配置和扩展它。然而,我们从解释数据绑定的基础开始。清单 5-21 显示了我们的com.apress.prospringmvc.bookstore.domain.BookSearchCriteria类。它是一个简单的对象,有两个属性:title和category。
package com.apress.prospringmvc.bookstore.domain;
public class BookSearchCriteria {
private String title;
private Category category;
public String getTitle() {
return this.title;
}
public void setTitle(String title) {
this.title = title;
}
public void setCategory(Category category) {
this.category = category;
}
public Category getCategory() {
return this.category;
}
}
Listing 5-21The BookSearchCriteria JavaBean
假设我们收到以下请求:http://localhost:8080/chapter5-bookstore/book/search?title=Agile。在这种情况下,“标题”属性的值为“Agile”。在幕后,Spring 调用 JavaBean 上的setTitle方法,我们在控制器的 list 方法中将它指定为方法参数。如果请求中有一个名为 category 的参数,Spring 将调用 setCategory 方法;然而,它会首先尝试将参数(总是一个String)转换成com.apress.prospring.bookstore.domain.Category JavaBean。
然而,数据绑定并不局限于简单的 setter 方法。我们还可以绑定到嵌套属性,甚至是索引集合,比如映射、数组和列表。当参数名包含一个点时,就会发生嵌套绑定。);例如,address.street=Somewhere导致getAddress().setStreet("Somewhere")。
为了绑定到索引集合,我们必须使用带方括号的符号将索引括起来。使用地图时,该索引不必是数字。例如,list[2].name将在列表的第三个元素上绑定一个 name 属性。类似地,map['foo'].name会将 name 属性绑定到 map 中 foo 键下的值。
自定义数据绑定
我们有两个选项来定制数据绑定的行为:全局或每个控制器。我们可以通过执行全局设置来混合这两种策略,然后对每个控制器进行微调。
全球定制
为了全局定制数据绑定,我们需要创建一个实现org.springframework.web.bind.support.WebBindingInitializer接口的类。Spring MVC 提供了这个接口的可配置实现,org.springframework.web.bind.support.ConfigurableWebBindingInitializer。接口的实例必须向要使用的处理程序映射实现注册。在创建了一个org.springframework.web.bind.WebDataBinder的实例后,调用org.springframework.web.bind.support.WebBindingInitializer的initBinder方法。
所提供的实现允许我们设置几个属性。当属性未设置时,它使用由org.springframework.web.bind.WebDataBinder指定的默认值。如果我们想要指定更多的属性,很容易扩展默认实现并添加所需的行为。这里可以设置与控制器相同的属性(见表 5-15 )。
Spring Boot 让配置和重用ConfigurableWebBindingInitializer变得更加容易。它检测特定类型的 bean,并使用它来进一步配置处理程序适配器。
@Bean
public ConfigurableWebBindingInitializer configurableWebBindingInitializer(
Validator mvcValidator,
FormattingConversionService conversionService) {
var initializer = new ConfigurableWebBindingInitializer();
initializer.setDirectFieldAccess(true);
initializer.setValidator(mvcValidator);
initializer.setConversionService(conversionService);
return initializer;
}
Listing 5-22Configure the ConfigurableWebBindingInitializer
注意方法签名中的验证器和 FormattingConversionService 依赖关系。这些是启用从传入字符串参数到实际所需类型的验证和类型转换所必需的。
每个控制器定制
对于每控制器选项,我们必须在控制器中实现一个方法,并将org.springframework.web.bind.annotation.InitBinder注释放在该方法上。该方法必须没有返回值(void)并且至少有org.springframework.web.bind.WebDataBinder作为方法参数。方法可以和请求处理方法有相同的参数。但是,它不能有带org.springframework.web.bind.annotation.ModelAttribute注释的方法参数。这是因为模型在绑定后是可用的,在这个方法中,我们配置我们绑定的方式。
org.springframework.web.bind.annotation.InitBinder注释有一个名为 value 的属性,它可以接受这个 init-binder 方法所应用的模型属性名或请求参数名。默认情况下,应用于所有模型属性和请求参数。
为了定制绑定,我们需要配置我们的org.springframework.web.bind.WebDataBinder。这个对象有几个我们可以使用的配置选项(setter 方法),如表 5-16 所示。
除了设置这些属性,我们还可以告诉org.springframework.web.bind.WebDataBinder使用 bean 属性访问(默认)或直接字段访问。这可以通过调用initBeanPropertyAccess或initDirectFieldAccess方法来分别设置属性访问或直接字段访问来实现。直接字段访问的优点是,我们不必为每个要用于绑定的字段编写 getter/setter。清单 5-23 展示了一个 init-binder 方法的例子。
package com.apress.prospringmvc.bookstore.web.controller;
//Imports omitted
@Controller
@RequestMapping("/customer")
public class RegistrationController {
// Other methods omitted
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.initDirectFieldAccess();
binder.setDisallowedFields("id");
binder.setRequiredFields("username", "password", "emailAddress");
}
}
Listing 5-23An Example init-binder Method
模型属性
为了充分利用数据绑定,我们必须使用模型属性。此外,我们应该使用这些模型属性之一作为表单字段绑定的对象。在我们的com.apress.prospringmvc.bookstore.web.controller.BookSearchController中,我们添加了一个对象作为方法参数,Spring 使用它作为绑定请求参数的对象。然而,对我们的对象和我们如何创建对象有更多的控制是可能的。为此,我们可以使用org.springframework.web.bind.annotation.ModelAttribute注释。这种注释既可以放在方法上,也可以放在方法参数上。
在方法上使用 ModelAttribute
我们可以在方法上使用@ModelAttribute注释来创建一个要在表单中使用的对象(例如,在编辑或更新时)或者获取引用数据(例如,需要将表单呈现为类别列表的数据)。让我们修改我们的控制器,向模型添加一个类别列表和一个com.apress.prospring.bookstore.domain.BookSearchCriteria对象的实例(参见清单 5-24 )。
当一个@ModelAttribute注释放在一个方法上时,这个方法在请求处理方法被调用之前被调用!
package com.apress.prospringmvc.bookstore.web.controller;
// Other imports omitted.
import org.springframework.web.bind.annotation.ModelAttribute;
@Controller
public class BookSearchController {
private final BookstoreService bookstoreService;
@ModelAttribute
public BookSearchCriteria criteria() {
return new BookSearchCriteria();
}
@ModelAttribute("categories")
public List<Category> getCategories() {
return this.bookstoreService.findAllCategories();
}
@GetMapping("/book/search")
public Collection<Book> list(BookSearchCriteria criteria) {
return this.bookstoreService.findBooks(criteria);
}
}
Listing 5-24The BookSearchController with ModelAttribute Methods
用@ModelAttribute标注的方法在方法参数类型上与请求处理方法具有相同的灵活性。当然,它们不应该对响应进行操作,也不能有@ModelAttribute注释方法参数。我们也可以让方法返回void;然而,我们需要包含org.springframework.ui.Model、org.springframework.ui.ModelMap,或java.util.Map作为方法参数,并显式地将其值添加到模型中。
该注释也可以放在请求处理方法上,表明该方法的返回值是一个模型属性。然后视图的名称从使用已配置的org.springframework.web.servlet.RequestToViewNameTranslator的请求中派生出来。
在方法参数上使用 ModelAttribute
当在方法参数上使用注释时,从模型中查找参数。如果没有找到,则使用默认构造函数创建该参数类型的实例。
清单 5-25 显示了带有注释的com.apress.prospring.bookstore.web.controller.BookSearchController。
package com.apress.prospringmvc.bookstore.web.controller;
// Imports omitted see listing 5-22
@Controller
public class BookSearchController {
// Methods omitted see listing 5-22
@GetMapping("/book/search")
public Collection<Book> list(@ModelAttribute("bookSearchCriteria")
BookSearchCriteria criteria) {
return this.bookstoreService.findBooks(criteria);
}
}
Listing 5-25The BookSearchController with ModelAttribute Annotation on a Method Argument
使用会话属性
在请求之间的会话中存储模型属性可能是有益的。例如,假设我们需要编辑一个客户记录。第一个请求从数据库中获取客户。然后在应用中对其进行编辑,并将更改提交给客户。如果我们不在会话中存储客户,那么必须从数据库中检索客户记录。这可能不方便。
在 Spring MVC 中,您可以告诉框架在会话中存储某些模型属性。为此,可以使用org.springframework.web.bind.annotation. SessionAttributes注释(见表 5-17 )。您应该使用这个注释来存储会话中的模型属性,以便在多个 HTTP 请求中存活。但是,您不应该使用这个注释在会话中存储一些东西,然后使用javax.servlet.http.HttpSession来检索它。会话属性也只能在同一个控制器中使用,因此您不应该将它们用作在控制器之间移动对象的传输工具。如果你需要的话,我们建议你直接进入HttpSession。
当使用org.springframework.web.bind.annotation.SessionAttributes注释在会话中存储模型属性时,我们还需要告诉框架何时删除这些属性。为此,我们需要使用org.springframework.web.bind.support.SessionStatus接口(参见清单 5-26 )。当我们使用完属性后,我们需要在接口上调用setComplete方法。要访问该接口,我们可以简单地将其作为方法参数(见表 5-4 )。
package org.springframework.web.bind.support;
public interface SessionStatus {
void setComplete();
boolean isComplete();
}
Listing 5-26The SessionStatus Interface
胸腺泡表达
为了使用框架提供的所有数据绑定特性,我们依赖于视图技术,在本例中是百里香。百里香叶解析search.html模板并评估各种模板表达式来呈现表单。Spring 框架可以用于 JSP 视图,在这种情况下,Spring 标记库可以编写表单并将表单元素绑定到 Spring 对象。
使用百里香叶视图时,无论模板类型(HTML、文本等。),您可以使用五种类型的标准表达式(或构造)。它们在表 5-18 中列出。
本章的例子中使用了许多这样的表达方式。表 5-19 列出了其中的一些,并对每一个做了简短的解释。
给定所有关于百里香表达式的信息,如果我们加载搜索页面并发出搜索,我们会看到我们的标题字段保持先前输入的值(见图 5-6 )。这是因为我们结合百里香表达式使用了数据绑定。

图 5-6
“标题”字段保持填充状态
现在是时候通过添加一个下拉框(一个 HTML select)来选择除标题之外要搜索的类别,让事情变得有趣一点了。首先,我们的模型中已经有了类别(参见清单 5-27 )。
package com.apress.prospringmvc.bookstore.web.controller;
// other import statements omitted
@Controller
public class BookSearchController {
@ModelAttribute("categories")
public Iterable<Category> getCategories() {
return this.bookstoreService.findAllCategories();
}
// other code omitted
}
Listing 5-27Adding Categories to the search.html Model
我们只想添加一个下拉菜单,并将其绑定到category’s ID 字段(参见清单 5-28 )。我们添加一个 select 标记,并告诉它哪个模型属性包含要呈现的项目。我们还为每个项目指定要显示的值和标签。该值绑定到用于表单的模型属性。
<form action="#" th:action="@{/book/search}"
th:object="${bookSearchCriteria}"
method="GET" id="bookSearchForm">
<fieldset>
<legend>Search Criteria</legend>
<table>
<tr>
<td>
<label for="title">Title</label>
</td>
<td><input type="text" th:field="*{title}"/></td>
</tr>
<tr>
<td>
<label for="category">Category</label>
</td>
<td>
<select th:field="*{category}">
<option th:each="c : ${categories}"
th:value="${c.id}" th:text="${c.name}"
th:selected="${c.id==1}">
</option>
</select>
</td>
</tr>
</table>
</fieldset>
<button id="search">Search</button>
</form>
// Result table omitted
Listing 5-28The Search Page with a Category Drop-down Element
类型变换
数据绑定的一个重要部分是类型转换。当我们收到请求时,我们唯一拥有的就是字符串实例。然而,在现实世界中,我们使用许多不同的对象类型,不仅仅是文本表示。因此,我们希望将那些字符串实例转换成我们可以使用的东西,这就是类型转换的用武之地。使用 Spring,有三种方法可以进行类型转换。
-
属性编辑器
-
转换器
-
格式化程序
属性编辑器是旧式的类型转换,而转换器和格式化程序是新的方式。转换器和格式化程序更加灵活;因此,它们也比属性编辑器更强大。此外,依赖属性编辑器会拉入整个java.beans包,包括它的所有支持类,这在 web 环境中是不需要的。
属性编辑器
从一开始,对属性编辑器的支持就是 Spring 框架的一部分。为了使用这种类型转换,我们创建了一个 PropertyEditor 实现(通常通过子类化PropertyEditorSupport)。属性编辑器获取一个String并将其转换为强类型对象,反之亦然。Spring 提供了几个现成的实现来完成这个任务(见表 5-20 )。
所有的属性编辑器都在 org . spring framework . beans . property editors 包中。
转换器
Spring 中的转换器 API 是一个通用的类型转换系统。在 Spring 容器中,这个系统作为属性编辑器的替代,将 bean 属性值字符串转换成所需的属性类型。每当我们需要进行类型转换时,我们也可以在应用中使用这个 API。转换器系统是一个强类型转换系统,并使用泛型来实现这一点。
四个不同的接口可以实现一个转换器,它们都在 org . spring framework . core . convert . converter 包中。
-
Converter -
ConverterFactory -
GenericConverter -
ConditionalGenericConverter
让我们来探索四种不同的 API。
清单 5-29 显示了Converter API,非常简单。它只有一个convert方法,接受源参数并将其转换成目标参数。源和目标类型由S和T通用类型参数表示。
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
Listing 5-29The Converter API
清单 5-30 展示了ConverterFactory API,当你需要一个完整的类层次结构的转换逻辑时,这很有用。为此,我们可以将S参数化为我们要转换的类型(源),并将R参数化为我们要转换的基本类型。然后,我们可以在该工厂的实现中创建适当的转换器。
package org.springframework.core.convert.converter;
public interface ConverterFactory<S, R> {
<T extends R> Converter<S, T> getConverter(Class<T> targetType);
}
Listing 5-30The ConverterFactory API
当我们需要更复杂的转换逻辑时,我们可以使用org.springframework.core .convert.converter.GenericConverter(参见清单 5-31 )。与以前的转换器类型相比,它更灵活,但类型化程度较低。它支持多个源和目标类型之间的转换。在转换过程中,我们可以访问源和目标类型描述,这对复杂的转换逻辑很有用。这也允许类型转换由注释驱动(即,我们可以在运行时解析注释以确定需要做什么)。
package org.springframework.core.convert.converter;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.util.Assert;
import java.util.Set;
public interface GenericConverter {
Set<ConvertiblePair> getConvertibleTypes();
Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType);
}
Listing 5-31The GenericConverter API
这种类型的转换逻辑的一个例子是从数组转换为集合的转换器。转换器首先检查要转换的元素的类型,以便对不同的元素应用附加的转换逻辑。
清单 5-32 显示了GenericConverter的一个特殊版本,它允许我们为它何时执行指定一个条件。例如,我们可以创建一个转换器,它使用 BigDecimals valueOf方法之一来转换一个值,但这只有在我们可以用给定的source type调用该方法时才有用。
package org.springframework.core.convert.converter;
import org.springframework.core.convert.TypeDescriptor;
boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType);
}
Listing 5-32The ConditionalGenericConverter API
转换器在org.springframework.core.convert.ConversionService接口后执行(见清单 5-33);该接口的典型实现还实现了org.springframework.core.convert.converter.ConverterRegistry接口,该接口可以轻松注册额外的转换器。当使用 Spring MVC 时,有一个预配置的org.springframework.format.support.DefaultFormattingConversionService实例(它也允许执行和注册格式化程序)。
package org.springframework.core.convert;
public interface ConversionService {
boolean canConvert(Class<?> sourceType, Class<?> targetType);
boolean canConvert(TypeDescriptor sourceType, TypeDescriptor targetType);
<T> T convert(Object source, Class<T> targetType);
Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType);
}
Listing 5-33The ConversionService API
格式化程序
转换器 API 是一个通用的类型转换系统。它是强类型的,可以从任何对象类型转换为另一种对象类型(如果有转换器可用的话)。然而,这不是我们在 web 环境中需要的东西,因为我们只处理字符串对象。另一方面,我们可能希望将对象以字符串的形式呈现给客户端,甚至可能希望以本地化的方式来实现。这就是格式化程序 API 的用武之地(参见清单 5-34 )。它提供了一种简单而可靠的机制来将字符串转换为强类型对象。它是属性编辑器的替代品,但也更轻便(例如,它不依赖于java.beans包)和更灵活(例如,它可以访问本地化内容的语言环境)。
package org.springframework.format;
public interface Formatter<T> extends Printer<T>, Parser<T> {
}
import java.util.Locale
public interface Printer<T> {
String print(T object, Locale locale);
}
import java.util.Locale
import java.text.ParseException;
public interface Parser<T> {
T parse(String text, Locale locale) throws ParseException;
}
Listing 5-34The Formatter API
为了创建一个格式化程序,我们需要实现org.springframework.format.Formatter接口并将类型T指定为我们想要转换的类型。例如,假设我们有一个可以将java.util.Date实例转换成文本的格式化程序,反之亦然。我们将指定T为Date,并使用Locale来确定执行转换的具体日期格式(参见清单 5-35 )。
package com.apress.prospringmvc.bookstore.formatter;
// java.text and java.util imports omitted
import org.springframework.format.Formatter;
import org.springframework.util.StringUtils;
public class DateFormatter implements Formatter<Date> {
private String format;
@Override
public String print(Date object, Locale locale) {
return getDateFormat(locale).format(object);
}
@Override
public Date parse(String text, Locale locale) throws ParseException {
return getDateFormat(locale).parse(text);
}
private DateFormat getDateFormat(Locale locale) {
if (StringUtils.hasText(this.format)) {
return new SimpleDateFormat(this.format, locale);
} else {
return SimpleDateFormat.getDateInstance(SimpleDateFormat.MEDIUM, locale);
}
}
public void setFormat(String format) {
this.format = format;
}
}
Listing 5-35The Sample DateFormatter
格式化程序也可以由注释驱动,而不是由字段类型驱动。如果我们想将一个格式化程序绑定到一个注释,我们必须实现org.springframework.format.AnnotationFormatterFactory(参见清单 5-36 )。
package org.springframework.format;
public interface AnnotationFormatterFactory<A extends Annotation> {
Set<Class<?>> getFieldTypes();
Printer<?> getPrinter(A annotation, Class<?> fieldType);
Parser<?> getParser(A annotation, Class<?> fieldType);
}
Listing 5-36The AnnotationFormatterFactory
Interface
我们需要用我们想要关联的注释类型来参数化A。getPrinter和getParser方法应该分别返回org.springframework.format.Printer和org.springframework.format.Parser。然后,我们可以使用它们在注释类型之间进行转换。假设我们有一个com.apress.prospringmvc.bookstore.formatter.DateFormat注释,可以用来设置日期字段的格式。然后我们可以实现清单 5-37 中所示的工厂。
package com.apress.prospringmvc.bookstore.formatter;
import java.util.Date;
import java.util.Set;
import org.springframework.format.AnnotationFormatterFactory;
import org.springframework.format.Parser;
import org.springframework.format.Printer;
public class DateFormatAnnotationFormatterFactory
implements AnnotationFormatterFactory<DateFormat> {
@Override
public Set<Class<?>> getFieldTypes() {
return Set.of(Date.class);
}
@Override
public Printer<?> getPrinter(DateFormat annotation, Class<?> fieldType) {
return createFormatter(annotation);
}
@Override
public Parser<?> getParser(DateFormat annotation, Class<?> fieldType) {
return createFormatter(annotation);
}
private DateFormatter createFormatter(DateFormat annotation) {
var formatter = new DateFormatter();
formatter.setFormat(annotation.format());
return formatter;
}
}
Listing 5-37The DateFormatAnnotationFormatterFactory Class
配置类型转换
如果我们想在 Spring MVC 中使用org.springframework.core.convert.converter.Converter或者org.springframework.format.Formatter,那么我们需要增加一些配置。
org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter对此有办法。可以覆盖addFormatters方法来注册额外的转换器和/或格式化程序。这个方法有org.springframework.format.FormatterRegistry(见清单 5-38 )作为参数,它可以注册额外的转换器和/或格式化程序。(FormatterRegistry扩展了org.springframework.core.convert.converter.ConverterRegistry,为Converter实现提供了相同的功能)。
package org.springframework.format;
import java.lang.annotation.Annotation;
import org.springframework.core.convert.converter.ConverterRegistry;
public interface FormatterRegistry extends ConverterRegistry {
void addFormatter(Formatter<?> formatter);
void addFormatterForFieldType(Class<?> fieldType, Formatter<?> formatter);
void addFormatterForFieldType(Class<?> fieldType, Printer<?> printer, Parser<?> parser);
void addFormatterForFieldAnnotation(AnnotationFormatterFactory
<? extends Annotation> annotationFormatterFactory);
}
Listing 5-38The FormatterRegistry Interface
为了从一个String转换到一个com.apress.prospringmvc.bookstore.domain.Category,我们实现了org.springframework.core.convert.converter.GenericConverter(参见清单 5-39 )并在我们的配置中注册它(参见清单 5-40 )。com.apress.prospringmvc.bookstore.converter.StringToEntityConverter将一个String作为其来源,并将其转换成一个可配置的实体类型。然后使用一个javax.persistence.EntityManager从数据库中加载记录。
package com.apress.prospringmvc.bookstore.web.config;
// other imports omitted
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
// other code omitted
@Bean
public StringToEntityConverter categoryConverter() {
return new StringToEntityConverter(Category.class);
}
@Override
public void addFormatters(final FormatterRegistry registry) {
registry.addConverter(categoryConverter());
registry.addFormatter(new DateFormatter("dd-MM-yyyy"));
}
}
Listing 5-40The CategoryConverter Configuration
package com.apress.prospringmvc.bookstore.converter;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.convert.converter.GenericConverter;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.Set;
public class StringToEntityConverter implements GenericConverter {
private static final String ID_FIELD = "id";
private final Class<?> clazz;
@PersistenceContext
private EntityManager em;
public StringToEntityConverter(Class<?> clazz) {
super();
this.clazz = clazz;
}
@Override
public Set<ConvertiblePair> getConvertibleTypes() {
return Set.of(
new ConvertiblePair(String.class, this.clazz),
new ConvertiblePair(this.clazz, String.class));
}
@Override
public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
if (String.class.equals(sourceType.getType())) {
if (!StringUtils.hasText((String) source)) {
return null;
}
var id = Long.parseLong((String) source);
return this.em.find(this.clazz, id);
} else if (this.clazz.equals(sourceType.getType())) {
if (source == null) {
return "";
} else {
var field = ReflectionUtils.findField(source.getClass(), ID_FIELD);
if (field != null) {
ReflectionUtils.makeAccessible(field);
return ReflectionUtils.getField(field, source);
}
}
}
throw new IllegalArgumentException("Cannot convert " + source + " into a suitable type!");
}
}
Listing 5-39The StringToEntityConverter
除了类别转换,我们还需要进行日期转换。因此,清单 5-38 也包含带有日期转换模式的org.springframework.format.datetime.DateFormatter。
使用类型转换
现在我们已经讨论了类型转换,让我们看看它的实际应用。我们创建了用户注册页面,允许我们输入com.apress.prospringmvc.bookstore.domain.Account对象的详细信息。首先,我们需要一个WEB-INF/views下的网页。接下来,我们需要创建一个客户目录,并在其中放置一个register.html文件。清单 5-41 中包含的内容已经被简化,因为在这个页面上有很多不同字段的重复内容。
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{template/layout :: head('Register')}"></head>
<!-- other HTML elements omitted -->
<form action="#" th:action="@{/customer/register}"
th:object="${account}" method="POST" id="registerForm">
<fieldset>
<legend >Personal</legend>
<table>
<tr>
<td><label for="firstName">Firstname</label></td>
<td><input type="text" th:field="*{firstName}"/>
<span th:if="${#fields.hasErrors('firstName')}"
class="error" th:errors="*{firstName}"></span>
</td>
</tr>
<tr>
<td><label for="lastName" >Lastname</label></td>
<td><input type="text" th:field="*{lastName}"/>
<span th:if="${#fields.hasErrors('lastName')}"
class="error" th:errors="*{lastName}"></span>
</td>
</tr>
<tr>
<td><label for="title" >date of Birth</label></td>
<td><input type="date" th:field="*{dateOfBirth}"/>
<span th:if="${#fields.hasErrors('dateOfBirth')}"
class="error" th:errors="*{dateOfBirth}"></span>
</td>
</tr>
</table>
<button id="search" >Save</button>
<!-- other form elements omitted -->
</fieldset>
</form>
Listing 5-41The Registration Page
为此我们还需要一个控制器,所以我们创建了com.apress.prospringmvc.bookstore.web.controller.RegistrationController。在这个控制器中,我们使用了一些数据绑定特性。首先,我们不允许提交一个ID字段(以防止有人编辑另一个用户)。然后,我们根据当前的地区预先选择用户的国家。清单 5-42 显示了我们的控制器。
package com.apress.prospringmvc.bookstore.web.controller;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import com.apress.prospringmvc.bookstore.domain.Account;
@Controller
@RequestMapping("/customer/register")
public class RegistrationController {
private final AccountService accountService;
public RegistrationController(AccountService accountService) {
this.accountService = accountService;
}
@ModelAttribute("countries")
public Map<String, String> countries(Locale currentLocale) {
var countries = new TreeMap<String,String>();
for (var locale : Locale.getAvailableLocales()) {
countries.put(locale.getCountry(), locale.getDisplayCountry(currentLocale));
}
return countries;
}
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.setDisallowedFields("id");
binder.setRequiredFields("username","password","emailAddress");
}
@GetMapping
@ModelAttribute
public Account register(Locale currentLocale) {
var account = new Account();
account.getAddress().setCountry(currentLocale.getCountry());
return account;
}
@RequestMapping(method = { RequestMethod.POST, RequestMethod.PUT })
public String handleRegistration(@ModelAttribute Account account, BindingResult result) {
if (result.hasErrors()) {
return "customer/register";
}
this.accountService.save(account);
return "redirect:/customer/account/" + account.getId();
}
}
Listing 5-42The RegistrationController
控制器有很多事情要做。例如,initBinder方法配置我们的绑定。它不允许设置ID属性,并设置了一些必填字段。我们还有一种方法,通过将 JDK 中所有可用的国家添加到模型中来准备我们的模型。最后,我们有两个请求处理方法,一个用于 GET 请求(进入页面时的初始请求),一个用于提交表单时的 POST/PUT 请求。注意模型属性旁边的org.springframework.validation.BindingResult属性。这就是我们可以用来检测错误,并在此基础上,我们可以重新显示原始页面。另外,请记住,百里香模板中的错误表达式显示了字段或对象的错误消息(这将在接下来的章节中介绍)。当应用被重新部署时,您点击注册链接,您应该看到如图 5-7 所示的页面。

图 5-7
帐户注册页面
如果我们现在输入一个无效日期,将用户名、密码和电子邮件地址字段留空,然后提交表单,同一页面会重新显示一些错误消息(参见图 5-8 )。

图 5-8
帐户注册页面显示一些错误
错误消息是由 Spring MVC 中的数据绑定工具创建的。在本章的后面,你会看到我们如何影响显示的信息。现在,让我们让它们保持完整。如果我们填写正确的信息并单击 Save,我们将被重定向到一个帐户页面(我们已经提供了基本的控制器和实现)。
验证模型属性
我们已经多次提到验证。我们也多次提到了org.springframework.validation包。通过 Spring 框架中的验证抽象,验证我们的模型属性很容易完成。验证不绑定到 web 它是关于验证对象的。因此,验证也可以在 web 层之外使用;其实在哪里都可以用。
验证的主要抽象是org.springframework.validation.Validator接口。这个接口有两个回调方法。supports方法确定验证器实例是否可以验证对象。validate方法验证对象(参见清单 5-43 )。
package org.springframework.validation;
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
Listing 5-43The Validator Interface
调用 supports 方法来查看验证器是否可以验证当前的对象类型。如果返回 true,框架用要验证的对象和一个org.springframework.validation.Errors接口实现的实例调用 validate 方法。绑定时,这是org.springframework.validation.BindingResult的一个实现。在进行验证时,最好包含一个Errors或BindingResult(后者扩展了错误)方法属性。这样,我们可以处理绑定或验证错误的情况。如果不是这样,org.springframework.validation.BindException就扔了。
当使用 Spring MVC 时,我们有两个触发验证的选项。第一种是将验证器注入到我们的控制器中,并在验证器上调用 validate 方法。第二个是向我们的方法属性添加javax.validation.Valid (JSR-303)或org.springframework.validation.annotation.Validated注释。来自 Spring 框架的注释比来自 javax.validation 包的注释更强大。Spring 注释使我们能够指定提示;当与 JSR-303 验证器(例如 hibernate-validation)结合使用时,我们可以指定验证组。
验证和绑定错误会导致向Errors实例注册消息代码。一般来说,简单地向用户显示错误代码并不能提供太多信息,所以代码必须被解析为消息。这就是org.springframework.context.MessageSource发挥作用的地方。错误代码作为消息代码传递给已配置的消息源,并检索消息。如果我们不配置消息源,我们会得到一个很好的堆栈跟踪,表明找不到错误代码的消息。因此,在我们继续之前,让我们配置清单 5-44 中所示的MessageSource。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.context.support.ResourceBundleMessageSource;
// Other imports omitted
@Configuration
public class WebMvcContextConfiguration extends WebMvcConfigurer {
@Bean
public MessageSource messageSource() {
var messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setUseCodeAsDefaultMessage(true);
return messageSource;
}
// Other methods omitted
}
Listing 5-44The MessageSource Configuration
Bean
我们配置一个消息源,然后将它配置为加载一个包含 basename 消息的资源包(在本章后面的“国际化”一节中,您将了解到更多相关内容)。当找不到消息时,我们将代码作为消息返回。这在开发过程中特别有用,因为我们可以很快看到资源包中缺少哪些消息代码。
让我们为我们的com.apress.prospringmvc.bookstore.domain.Account类实现验证。我们想要验证帐户是否有效,为此,我们需要用户名、密码和有效的电子邮件地址。为了处理运输,我们还需要一个地址、城市和国家。没有这些信息,帐户无效。现在让我们看看如何利用验证框架。
实现我们的验证器
我们从实现我们自己的验证器开始。在这种情况下,我们创建一个com.apress.prospringmvc.bookstore.validation.AccountValidator(参见清单 5-45 )并使用 init-binder 方法来配置它。
package com.apress.prospringmvc.bookstore.validation;
import java.util.regex.Pattern;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
import com.apress.prospringmvc.bookstore.domain.Account;
public class AccountValidator implements Validator {
private static final String EMAIL_PATTERN = "^[_A-Za-z0-9-]+(\\.[_A-Za-z0-9-]+)*@"
+"[A-Za-z0-9]+(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]\{2,})$";
@Override
public boolean supports(Class<?> clazz) {
return Account.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
ValidationUtils.rejectIfEmpty(errors, "username","required", new Object[] {"Username"});
ValidationUtils.rejectIfEmpty(errors, "password","required", new Object[] {"Password"});
ValidationUtils.rejectIfEmpty(errors, "emailAddress","required", new Object[] {"Emailaddress"});
ValidationUtils.rejectIfEmpty(errors, "address.street","required", new Object[] {"Street"});
ValidationUtils.rejectIfEmpty(errors, "address.city","required", new Object[] {"City"});
ValidationUtils.rejectIfEmpty(errors, "address.country","required", new Object[] {"Country"});
if (!errors.hasFieldErrors("emailAddress")) {
var account = (Account) target;
var email = account.getEmailAddress();
if (!emai.matches(EMAIL_PATTERN)) {
errors.rejectValue("emailAddress", "invalid");
}
}
}
}
Listing 5-45The AccountValidator Implementation
在org.springframework.web.bind.WebDataBinder上指定requiredFields将导致与ValidationUtils.rejectIfEmptyOrWhiteSpace相同的验证逻辑。然而,在我们的例子中,我们将所有的验证逻辑放在一个地方,而不是分散在两个地方。
这个验证器实现检查字段是否不为空。如果该字段为空,它会为给定的字段记录一个错误。错误是由org.springframework.validation.MessageCodesResolver实现确定的消息代码的集合。默认实现org.springframework.validation.DefaultMessageCodesResolver解析为四种不同的代码(见表 5-21 )。表中的顺序也是错误代码解析为正确消息的方式。
验证的最后一部分是我们需要配置我们的验证器,并告诉控制器在提交时验证我们的模型属性。在清单 5-46 中,我们展示了修改后的顺序控制器。我们只想在表单的最终提交时触发验证。
package com.apress.prospringmvc.bookstore.web.controller;
import com.apress.prospringmvc.bookstore.domain.AccountValidator;
import javax.validation.Valid;
// Other imports omitted
@Controller
@RequestMapping("/customer/register")
public class RegistrationController {
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.setDisallowedFields("id");
binder.setValidator(new AccountValidator());
}
@RequestMapping(method = { RequestMethod._POST_, RequestMethod._PUT_ })
public String handleRegistration(@Valid @ModelAttribute Account account, BindingResult result) {
if (result.hasErrors()) {
return "customer/register";
}
this.accountService.save(account);
return "redirect:/customer/account/" + account.getId();
}
// Other methods omitted
}
Listing 5-46The RegistrationController with Validation
如果我们在重新部署后提交非法值,我们会收到一些错误代码,如图 5-9 所示。

图 5-9
带有错误代码的注册页面
使用 JSR-303 验证
除了实现我们自己的验证器,我们还可以使用 JSR-303 注释来添加验证。为此,我们只需要用 JSR-303 注释来注释我们的com.apress.prospringmvc.bookstore.domain.Account对象(参见清单 5-47 ),然后保留javax.validation.Valid注释。当使用这些注释时,使用的错误代码与我们的自定义验证器中使用的略有不同(见表 5-22 )。但是,注册页面不需要改变,所以它仍然和以前一样。我们的 init-binder 方法不需要设置验证器,因为支持 JSR-303 的验证器会被自动检测到(示例项目使用 Hibernate 中的验证器)。
package com.apress.prospringmvc.bookstore.domain;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.persistence.*;
import javax.validation.Valid;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String firstName;
private String lastName;
private Date dateOfBirth;
@Embedded
@Valid
private Address address = new Address();
@NotEmpty
@Email
private String emailAddress;
@NotEmpty
private String username;
@NotEmpty
private String password;
// getters and setters omitted
}
Listing 5-47An Account with JSR-303 Annotations
当使用 JSR-303 注释时,如果我们提交带有无效值的表单,我们会得到如图 5-10 所示的结果。如您所见,显示的是消息而不是代码。这怎么可能?我们使用的验证器实现附带了一些默认消息。如果我们想要的话,我们可以通过在资源包中指定表 5-22 中的一个代码来覆盖这些代码(见下一节)。

图 5-10
带有错误消息的注册页面
国际化
为了实现国际化,我们需要配置不同的组件来基于用户的语言(地区)解析消息。例如,有org.springframework.context.MessageSource,它让我们根据消息代码和地区来解析消息。为了解析语言环境,我们还需要org.springframework.web.servlet.LocaleResolver。最后,为了改变区域设置,我们还需要配置org.springframework.web.servlet.i18n.LocaleChangeInterceptor(下一章将更深入地讨论拦截器)。
信息源
消息源是根据代码和地区解析消息的组件。Spring 提供了几个org.springframework.context.MessageSource接口的实现。其中两个实现是我们可以使用的实现,而另一个实现只是委托给另一个消息源。
Spring 框架提供的两个实现在org.springframework.context.support包中。表 5-23 简要描述了两者。
我们以大致相同的方式配置这两个 beans。我们需要一个名为messageSource的 bean。我们选择哪个实现并不重要。例如,我们甚至可以创建自己的实现,使用数据库来加载消息。
清单 5-48 中的配置配置org.springframework.context.support.ReloadableResourceBundleMessageSource,,它从类路径中加载一个名为 messages.properties 的文件。它还试图加载我们当前用来解析消息的locale的messages_[locale].properties。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
// Other imports omitted
@Configuration
public class WebMvcContextConfiguration extends WebMvcConfigurer {
@Bean
public MessageSource messageSource() {
var messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:/messages");
messageSource.setUseCodeAsDefaultMessage(true);
return messageSource;
}
}
Listing 5-48The MessageSource Configuration in WebMvcContext
下面的代码片段显示了加载的两个属性文件(资源包)。 messages.properties (参见清单 5-49 )文件中的消息被视为默认值,它们可以在特定语言的 messages_nl.properties 文件中被覆盖(参见清单 5-50 )。
home.title=Welkom
invalid.account.emailaddress=Ongeldig emailadres.
required=Veld {0} is verplicht.
Listing 5-50messages_nl.properties
home.title=Welcome
invalid.account.emailaddress=Invalid email address.
required=Field {0} is required.
Listing 5-49The messages.properties Snippet
LocaleResolver
为了让消息源正确地工作,我们还需要配置org.springframework.web.servlet.LocaleResolver(这可以在org.springframework.web.servlet.i18n包中找到)。Spring 附带了几个实现,可以让我们的生活更加轻松。语言环境解析器是一种检测哪个Locale的策略。不同的实现使用不同的方式来解析区域设置(见表 5-24 )。
LocaleChangeInterceptor
如果我们希望我们的用户改变区域设置,我们需要配置org.springframework.web.servlet.i18n. LocaleChangeInterceptor(参见清单 5-51 )。这个拦截器检查当前传入的请求,并检查请求中是否有一个名为 locale 的参数。如果存在这种情况,拦截器将使用之前配置的语言环境解析器来更改当前用户的语言环境。可以配置参数名称。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.context.MessageSource;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
// Other imports omitted
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
@Bean
public HandlerInterceptor localeChangeInterceptor() {
var localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("lang");
return localeChangeInterceptor;
}
@Bean
public LocaleResolver localeResolver() {
return new CookieLocaleResolver();
}
}
Listing 5-51The Full Internationalization Configuration
一般来说,让LocaleChangeInterceptor作为首批拦截者之一是个好主意。如果出现问题,我们希望用正确的语言通知用户。
如果我们重新部署我们的应用,当我们切换语言时,我们应该得到本地化的错误消息(当然,这只有在我们向资源包添加适当的错误代码时才有效)。然而,对错误消息使用MessageSource并不是它唯一的用途;我们还可以使用MessageSource从资源包中检索标签、标题、错误消息等等。我们可以用百里香的信息表达来说明这一点。清单 5-52 显示了一个修改过的图书搜索页面,它使用消息表达式来填充标签、标题和标题。如果我们切换语言,我们应该得到本地化的信息(见图 5-11 和 5-12 )。

图 5-12
荷兰语图书搜索页面

图 5-11
英文图书搜索页面
<form action="#" th:action="@{/book/search}"
th:object="${bookSearchCriteria}"
method="GET" id="bookSearchForm">
<fieldset>
<legend th:text="#{book.searchcriteria}">SEARCH CRITERIA</legend>
<table>
<tr>
<td><label for="title" th:text="#{book.title}">TITLE</label></td>
<td><input type="text" th:field="*{title}"/></td>
</tr>
<tr>
<td><label for="category" th:text="#{book.category}">CATEGORY</label></td>
<td>
<select th:field="*{category}">
<option th:each="c : ${categories}" th:value="${c.id}"
th:text="${c.name}" th:selected="${c.id==1}">
</option>
</select>
</td>
</tr>
</table>
</fieldset>
<button id="search" th:text="#{button.search}">SEARCH</button>
</form>
<table id="bookSearchResults">
<thead>
<tr>
<th th:text="#{book.title}">TITLE</th>
<th th:text="#{book.description}">DESCRIPTION</th>
<th th:text="#{book.price}">PRICE</th>
<th></th>
</tr>
</thead>
<tbody>
<th:block th:each="book : ${bookList}">
<tr>
<td><a th:href="@{/book/detail/} + ${book.id}"
th:text="${book.title}">TITLE</a></td>
<td th:text="${book.description}">DESC</td>
<td th:text="${book.price}">PRICE</td>
<td><a th:href="@{/cart/add/} + ${book.id}"
th:text="#{book.addtocart}">CART</a></td>
</tr>
</th:block>
</tbody>
</table>
Listing 5-52The Book Search Page with the Message Tag
摘要
这一章涵盖了我们需要编写控制器和处理表单的所有内容。我们首先探索了@RequestMapping注释,以及它如何将请求映射到处理请求的方法。我们还探索了灵活的方法签名,并介绍了哪些方法参数类型和返回值是开箱即用的。
接下来,我们深入其中,开始编写控制器并修改现有代码。我们还介绍了表单对象,并讲述了如何将属性绑定到字段。我们解释了数据绑定,探索了 Spring 的类型转换系统,以及它如何与某些对象相互转换。我们还编写了自己的转换器实现,将文本转换成类别对象。
除了类型转换,我们还探索了验证。有两种验证方式:我们可以创建自己的Validator接口实现,或者在我们想要验证的对象上使用 JSR-303 注释。启用验证是通过@Valid或@Validated注释完成的。
为了更容易地将某些字段绑定到表单对象的属性,Spring Form 标签库帮助我们编写 HTML 表单。这个库还帮助我们向用户显示绑定和验证错误。
最后,我们讨论了如何在我们的 web 页面上实现国际化,以及如何将验证和错误代码转换成适当的消息以显示给最终用户。
在下一章,我们将探索 Spring MVC 的一些更高级的特性。在这个过程中,您将看到如何进一步扩展和定制现有的基础设施。
六、实现控制器:高级
本章着眼于 Spring MVC 的一些更高级的部分,然后展示如何利用这个框架来扩展它以满足我们的需求。
我们从检查作用域 beans 以及如何利用它们为我们服务开始。接下来,我们探索如何将通用功能(横切关注点)添加到我们的应用中。为此,我们来看看拦截器,包括如何创建它们以及如何将它们连接到我们的应用中。
无论我们的应用是多么健壮或经过深思熟虑的,总有一天我们的应用会表现得不像预期的那样(例如,可能有人通过网络连接到我们的数据库服务器),这会导致我们的应用出现异常。一般来说,我们希望防止用户看到模糊的堆栈跟踪;为此,我们探索 Spring MVC 中的异常处理机制。
在我们涵盖了所有这些主题之后,我们深入 Spring @MVC 的内部,探索几个我们可以扩展的 APIs 然后,我们使用这些扩展的 API 来增强框架的功能。
使用作用域 Beans
在第二章中,我们提到了 Spring 框架支持的 beans 的不同范围。表 6-1 再次列出。这一节使用范围对我们有利。具体来说,我们将通过一个实际的例子来利用一个限定了作用域的 bean 来创建一个在线购物车。
表 6-1
范围概述
|前缀
|
描述
|
| --- | --- |
| singleton | 默认范围。创建一个 bean 实例,并在整个应用中共享。bean 的生命周期与构造它的应用上下文相关联。 |
| prototype | 每次需要某个 bean 时,都会返回该 bean 的一个新实例。 |
| thread | bean 在需要时创建,并绑定到当前执行的线程。如果线程死了,bean 就被破坏了。 |
| request | bean 在需要时创建,并绑定到传入的javax.servlet.ServletRequest的生命周期。如果请求结束,bean 实例被销毁。 |
| session | bean 在需要时创建并存储在javax.servlet.http.HttpSession中。当会话被销毁时,bean 实例也被销毁。 |
| application | 这个作用域非常类似于单例作用域。主要的区别在于,具有该范围的 beans 也在javax.servlet.ServletContext中注册。 |
我们已经使用了 singleton 范围——因为这是 Spring 框架中 bean 创建的默认范围。org.springframework.context.annotation.Scope注释指定了 bean 的范围;其性能列于表 6-2 中。
表 6-2
范围批注属性
|财产
|
描述
|
| --- | --- |
| value + scopeName | 要使用的范围的名称(见表 6-1 )。默认为 singleton。 |
| proxyMode | 指示是否应创建作用域代理以及由哪种代理机制创建。除非通过组件扫描标记或注释设置了另一个默认代理模式,否则此属性默认为“否”。 |
该注释可以用作类型级或方法级注释。当您使用Scope作为类型级注释时,该类型的所有 beans 都具有注释指定的范围。当您将它用作方法级注释时,由这个带注释的方法创建的 beans 具有由注释指定的范围。您必须将它放在用org.springframework.context.annotation.Bean注释标注的方法上。
向购物车中添加东西
这部分迈出了让网站访问者从我们的书店购买书籍的第一步。具体来说,我们实现了允许我们将书籍添加到购物车中的逻辑。为此,我们首先需要定义一个会话范围的购物车 bean。
清单 6-1 展示了如何用会话范围定义一个 bean(我们的购物车)。这个 bean 可以被注入到其他 bean 中,就像框架中的任何其他 bean 一样。Spring 处理管理 bean 生命周期的复杂性。bean 的生命周期取决于 bean 的范围(参见表 6-1 )。例如,单例范围的 bean(默认)与应用上下文的生命周期相关联,而会话范围的 bean 与javax.servlet.http.HttpSession对象的生命周期相关联。
package com.apress.prospringmvc.bookstore.web.config;
//Other imports omitted
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import com.apress.prospringmvc.bookstore.domain.Cart;
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
//Other methods omitted
@Bean
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public Cart cart() {
return new Cart();
}
}
Listing 6-1Cart Session Scoped Bean
在这种情况下,我们有一个带有注释的 bean 声明,并且我们使用会话范围。我们希望使用基于类的代理(com.apress.prospringmvc.bookstore.domain.Cart没有实现接口,所以我们需要基于类的代理)。我们现在可以简单地将这个 bean 注入到其他 bean 中,并像使用任何其他 bean 一样使用它。让我们创建一个使用这个 bean 的控制器:com.apress.prospringmvc.bookstore.web.controller.CartController(参见清单 6-2 )。
package com.apress.prospringmvc.bookstore.web.controller;
import com.apress.prospringmvc.bookstore.domain.Book;
import com.apress.prospringmvc.bookstore.domain.Cart;
import com.apress.prospringmvc.bookstore.service.BookstoreService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
@Controller
public class CartController {
private final Cart cart;
private final BookstoreService bookstoreService;
public CartController(Cart cart, BookstoreService bookstoreService) {
this.cart = cart;
this.bookstoreService = bookstoreService;
}
@PostMapping("/cart/add/{bookId}")
public String addToCart(@PathVariable("bookId") long bookId,
@RequestHeader("referer") String referer) {
Book book = this.bookstoreService.findBook(bookId);
this.cart.addBook(book);
return "redirect:" + referer;
}
}
Listing 6-2The CartController Bean
在这种情况下,我们简单地自动连接会话范围的 bean cart,就像我们对任何其他 bean 所做的那样。addToCart方法包含将一本书添加到购物车的逻辑。添加完图书后,我们会重定向到我们来的页面(referer 请求标题)。
这个控制器被映射到 URL,/cart/add/{bookId};然而,目前没有任何东西调用我们的控制器,因为我们没有任何东西指向那个 URL。让我们修改我们的图书搜索页面,并添加一个链接,将一本书添加到我们的购物车中(参见清单 6-3 )。粗体突出显示的部分显示了更改。
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
// Search Form Omitted
<c:if test="${not empty bookList}">
<table>
<tr>
<th><spring:message code="book.title"/></th>
<th><spring:message code="book.description"/></th>
<th><spring:message code="book.price" /></th>
<th></th>
</tr>
<c:forEach items="${bookList}" var="book">
<tr>
<td>
<a href="<c:url value="/book/detail/${book.id}"/>">${book.title}</a>
</td>
<td>${book.description}</td>
<td>${book.price}</td>
<td>
<a href="<c:url value="/cart/add/${book.id}"/>">
<spring:message code="book.addtocart" />
</a>
</td>
</tr>
</c:forEach>
</table>
</c:if>
Listing 6-3The Book Search Page with an Add to Cart Link
重启我们的应用后,我们应该在图书页面上有一个添加到购物车的链接(见图 6-1 )。如果我们点击那个链接,我们应该停留在图书页面。然而,我们确实往购物车里添加了一些东西。

图 6-1
带有添加到购物车链接的图书搜索
实现结帐
为了最终完成订购流程,我们允许客户检查他们的购物车。这在数据库中创建了一个实际的com.apress.prospringmvc.bookstore.domain.Order对象和条目。结帐是我们在前一章和前一节中讨论的许多事情的组合。控制器是com.apress.prospringmvc.bookstore.web.controller.CheckoutController(见清单 6-4 ),它包含很多逻辑。checkout.jsp文件是包含我们屏幕的 JSP 可以在/WEB-INF/views/cart中找到。
package com.apress.prospringmvc.bookstore.web.controller;
//Other imports omitted
import com.apress.prospringmvc.bookstore.validation.OrderValidator;
@Controller
@SessionAttributes(types = { Order.class })
@RequestMapping("/cart/checkout")
public class CheckoutController {
private final Cart cart;
private final BookstoreService bookstoreService;
public CheckoutController(Cart cart, BookstoreService bookstoreService) {
this.cart = cart;
this.bookstoreService = bookstoreService;
}
@ModelAttribute("countries")
public Map<String, String> countries(Locale currentLocale) {
var countries = new TreeMap<String, String>();
for (Locale locale : Locale.getAvailableLocales()) {
countries.put(locale.getCountry(),locale.getDisplayCountry(currentLocale));
}
return countries;
}
@GetMapping
public void show(HttpSession session, Model model) {
var account = (Account) session.getAttribute(LoginController.ACCOUNT_ATTRIBUTE);
var order = this.bookstoreService.createOrder(this.cart, account);
model.addAttribute(order);
}
@PostMapping(params = "order")
public String checkout(SessionStatus status,
@Validated @ModelAttribute Order order, BindingResult errors) {
if (errors.hasErrors()) {
return "cart/checkout";
} else {
this.bookstoreService.store(order);
status.setComplete(); //remove order from session
this.cart.clear(); // clear the cart
return "redirect:/index.htm";
}
}
@PostMapping(params = "update")
public String update(@ModelAttribute Order order) {
order.updateOrderDetails();
return "cart/checkout";
}
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.setValidator(new OrderValidator());
}
}
Listing 6-4The CheckoutController
当我们单击 checkout 时,在控制器上调用的第一个方法是 show 方法。它获取我们的购物车,并使用存储在会话中的account来创建订单,并将其添加到模型中。订单存储在请求之间的会话中;这要归功于SessionAttributes的使用。当这些完成后,结账页面被渲染(见图 6-2 )。

图 6-2
结账页面
填写表单时,客户可以做两件事:他可以按订购按钮或更新按钮。当按下 update 按钮时,将调用 Update 方法。这将提交表单,然后更新订单(并重新计算总价)。按下订单按钮时,订单被提交,然后由com.apress.prospringmvc.bookstore.validation.OrderValidator确认。出现错误时,页面会重新显示,并向客户显示错误消息。有趣的部分发生在没有错误的时候。首先,订单存储在数据库中。当我们完成订单时,我们需要从会话中删除它,这是通过调用org.springframework.web.bind.support.SessionStatus对象上的setComplete方法来完成的(参见第五章的“支持的方法参数类型”一节)。最后,在再次重定向到索引页面之前,我们需要清空购物车。我们这样做是为了让顾客可以在购物车中添加新书。因为我们不能简单地替换会话范围的对象,所以我们需要调用一个方法来清除它。如果我们要用一个新的实例替换 cart,我们将销毁作用域代理对象。
贯穿各领域的问题
在开发企业应用时,我们经常面临横切关注点的挑战。这些是影响许多对象和动作的关注点。横切关注点的例子包括事务管理和安全性,以及为每个传入的 web 请求公开通用数据之类的操作。
一般来说,使用传统的面向对象方法很难在我们的代码库中实现这些问题。如果我们用传统的方式实现它们,将会导致代码重复和难以维护的代码。对于我们的一般对象,我们可以使用面向方面编程(AOP)来解决这些横切关注点;然而,在将它应用于请求时,我们需要一种稍微不同的方法。
Spring MVC 给了我们两种实现横切关注点的方法。第一种方法使用拦截器来实现通用逻辑,而第二种方法依赖于异常处理。这一节将介绍在我们的 web 应用中应用横切关注点的两种技术。
截击机
拦截器对于请求处理程序就像过滤器对于 servlets 一样。根据 servlet 规范, 1 过滤器是一段可重用的代码,可以转换 HTTP 请求、响应和头信息的内容。过滤器修改或调整对资源的请求,并修改或调整来自资源的响应。过滤的例子包括认证、审计和加密。
过滤器和拦截器都实现了通用的功能(横切关注点),以应用于所有(或部分)传入的 HTTP 请求。过滤器比拦截器更强大,因为它们可以替换(或包装)传入的请求/响应,而拦截器不能做到这一点。另一方面,拦截器比过滤器有更多的生命周期方法(见表 6-3 )。
表 6-3
拦截器回调
|方法
|
描述
|
| --- | --- |
| preHandle | 在调用处理程序之前调用。 |
| postHandle | 当处理程序方法被成功调用时,在呈现视图之前调用。它可以在模型中放置共享对象。 |
| afterCompletion | 在视图呈现之后,请求处理完成时调用。这个方法总是在成功调用 preHandle 方法的拦截器上调用,即使在请求处理过程中出现错误。它可以清理资源。 |
Spring MVC 有两种拦截器策略。
-
org.springframework.web.servlet.HandlerInterceptor(见清单 6-5 ) -
org.springframework.web.context.request.WebRequestInterceptor(见清单 6-6 )
package org.springframework.web.context.request;
import org.springframework.ui.ModelMap;
public interface WebRequestInterceptor {
void preHandle(WebRequest request) throws Exception;
void postHandle(WebRequest request, ModelMap model) throws Exception;
void afterCompletion(WebRequest request, Exception ex) throws Exception;
}
Listing 6-6The WebRequestInterceptor Interface (in module spring-web)
package org.springframework.web.servlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface HandlerInterceptor {
boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception;
void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception;
void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception;
}
Listing 6-5The HandlerInterceptor Interface (in module spring-webmvc)
正如 Spring 框架中经常出现的情况,这两种策略都被表示为提供实现的接口。这两种策略的主要区别在于WebRequestInterceptor独立于底层技术。它可以在 JSF 或 Servlet 环境中使用,而无需更改实现。一个handler interceptor只能在 Servlet 环境中使用。HandlerInterceptor的一个优点是我们可以用它来防止处理程序被调用。我们通过从preHandle方法返回false来做到这一点。
配置拦截器
要使用拦截器,您需要在配置中配置它。配置拦截器包括两个步骤。
-
配置拦截器。
-
将它连接到处理程序。
将拦截器连接到我们的处理程序有两种方式。可以同时使用这两种方法,但我们不推荐这样做。首先,我们可以使用BeanPostProcessor显式地将拦截器添加到我们的处理程序映射中。第二,我们可以使用org.springframework.web.servlet.config.annotation.InterceptorRegistry来添加拦截器。
一般来说,最好使用InterceptorRegistry来添加拦截器,因为这是一种非常方便的添加方式。限制拦截器匹配的 URL 也非常容易(在关于InterceptorRegistry的部分中解释)。)
使用 BeanPostProcessor 显式配置带有拦截器的处理程序映射
为了用处理程序映射注册拦截器,我们首先需要包含处理程序映射。为此,我们需要显式地添加它们或者扩展 Spring 基类来获得对它们的引用(参见清单 6-7 )。接下来,我们简单地将所有拦截器添加到实例中。使用多个处理程序映射可能会很麻烦,尤其是如果我们只想将拦截器应用于某些 URL。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.util.List;
public class InterceptorAddingPostProcessor implements BeanPostProcessor {
private final List<HandlerInterceptor> interceptors;
public InterceptorAddingPostProcessor(List<HandlerInterceptor> interceptors) {
this.interceptors = interceptors;
}
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof RequestMappingHandlerMapping) {
RequestMappingHandlerMapping handlerMapping = (RequestMappingHandlerMapping) bean;
handlerMapping.setInterceptors(this.interceptors);
}
return bean;
}
}
Listing 6-7A Sample of Explicit HandlerMapping BeanPostProcessor for Interceptors
使用拦截注册表
一种更强大、更灵活的注册拦截器的方法是使用org.springframework.web.servlet.config.annotation.InterceptorRegistry。添加到这个注册表中的拦截器被添加到所有已配置的处理程序映射中。此外,使用这种方法很容易映射到某些 URL。为了访问注册表,我们需要在配置 web 资源的配置类上实现org.springframework.web.servlet.config.annotation.WebMvcConfigurer接口。这个接口有几个回调方法,在 Spring MVC 的配置过程中被调用。
InterceptorRegistry有两个方法(每种拦截器类型一个)可以用来添加拦截器(参见清单 6-8 )。这两个方法都返回了一个org.springframework.web.servlet.config.annotation.InterceptorRegistration的实例,我们可以用它来微调拦截器的映射。我们可以使用蚂蚁风格的路径模式 2 来为注册的拦截器配置细粒度的映射。如果我们不提供模式,拦截器将应用于所有传入的请求。
package org.springframework.web.servlet.config.annotation;
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.context.request.WebRequestInterceptor;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.WebRequestHandlerInterceptorAdapter;
public class InterceptorRegistry {
public InterceptorRegistration addInterceptor(HandlerInterceptor interceptor) { .. }
public InterceptorRegistration addWebRequestInterceptor(WebRequestInterceptor interceptor) { ..
}
}
Listing 6-8The InterceptorRegistry Interface
清单 6-9 显示了我们当前的配置。此时,我们已经配置了一个拦截器来更改区域设置,这个拦截器应用于所有传入的请求(我们没有指定匹配的 URL 模式)。接下来,我们配置拦截器并使用addInterceptor方法将其添加到注册表中。该框架负责用已配置的处理程序映射注册拦截器的附加细节。
package com.apress.prospringmvc.bookstore.web.config;
//Other imports omitted
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
@Bean
public HandlerInterceptor localeChangeInterceptor() {
var localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("lang");
return localeChangeInterceptor;
}
//... Other methods omitted
}
Listing 6-9Using the InterceptorRegistry to Add Interceptors
清单 6-10 显示了一段代码,其中我们将所有 URL 的映射改为只有以/customers开头的 URL。
package com.apress.prospringmvc.bookstore.web.config;
//Imports omitted
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
var registration = registry.addInterceptor(localeChangeInterceptor());
registation.addPathPatterns("/customers/**");
}
//Other methods omitted
}
Listing 6-10Limiting an Interceptor to Certain URLs
实现拦截器
到目前为止,我们已经介绍了不同类型的拦截器以及如何注册它们以供使用。现在让我们为我们的存储实现拦截器。我们实现了两种不同的拦截器。第一个将一些常用的数据添加到我们的模型中,以显示给用户。第二个解决了一个安全需求:我们希望只有注册用户才能访问帐户和结帐页面。
实现 WebRequestInterceptor
在本节中,我们实现org.springframework.web.context.request. WebRequestInterceptor。如果你在图 6-3 中查看我们的网页,你会看到一个随机图书区。到目前为止,我们网页上的这一部分仍然是空的。现在我们创建一个拦截器,向模型中添加一些随机书籍。为此,我们实现了后处理方法(参见清单 6-11 )。

图 6-3
没有列出随机书籍的欢迎页面
在一个真实的网上商店中,你可能会称这个部分为“新书”或“推荐书籍”
package com.apress.prospringmvc.bookstore.web.interceptor;
import com.apress.prospringmvc.bookstore.service.BookstoreService;
import org.springframework.ui.ModelMap;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.context.request.WebRequestInterceptor;
public class CommonDataInterceptor implements WebRequestInterceptor {
private final BookstoreService bookstoreService;
public CommonDataInterceptor(BookstoreService bookstoreService) {
this.bookstoreService = bookstoreService;
}
@Override
public void preHandle(WebRequest request) throws Exception {
}
@Override
public void postHandle(WebRequest request, ModelMap model) throws Exception {
if (model != null) {
model.addAttribute("randomBooks", this.bookstoreService.findRandomBooks());
}
}
@Override
public void afterCompletion(WebRequest request, Exception ex) throws Exception {
}
}
Listing 6-11The CommonDataInterceptor
postHandle方法向模型中添加一些随机的书籍,但是只有当这个模型可用时。这就是为什么我们的代码包含了一个null检查。当我们使用 AJAX 或自己编写响应时,模型可以是null。
为了将拦截器应用于传入的请求,我们需要注册它。拦截器需要为每个传入的请求调用,所以它不需要太多额外的配置(参见清单 6-12 中突出显示的行)。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.web.context.request.WebRequestInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
com.apress.prospringmvc.bookstore.web.interceptor.CommonDataInterceptor;
// Other imports omitted
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
private final BookstoreService bookstoreService;
public WebMvcContextConfiguration(BookstoreService bookstoreService) {
this.bookstoreService = bookstoreService;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
registry.addWebRequestInterceptor(commonDataInterceptor());
}
@Bean
public WebRequestInterceptor commonDataInterceptor() {
return new CommonDataInterceptor(this.bookstoreService);
}
// Other methods omitted
}
Listing 6-12The CommondDataInterceptor Configuration
现在,当我们重新部署我们的应用并访问一个页面时,我们应该看到随机书籍显示在页面的随机书籍部分(见图 6-4 )。(我们模板中用于选择随机书籍的逻辑如清单 6-13 所示。)

图 6-4
欢迎页面,标题在随机书籍部分
<div class="right_box">
<div class="title">
<span class="title_icon">
<img src="<c:url value="/resourcimg/bullet4.gif"/>" alt="" title="" />
</span>
<spring:message code="main.title.randombooks"/>
</div>
<c:forEach items="${randomBooks}" var="book">
<div class="new_prod_box">
<c:url value="/book/detail/${book.id}" var="bookUrl" />
<a href="${bookUrl}">${book.title}</a>
<div class="new_prod_img">
<c:url value="/resourcimg/${book.isbn}/book_front_cover.png" var="bookImage"/>
<a href="${bookUrl}">
<img src="${bookImage}" alt="${book.title}" title="${book.title}" class="thumb" border="0" width="100px"/>
</a>
</div>
</div>
</c:forEach>
</div>
Listing 6-13The Random Books Section from the Template
实现处理程序拦截器
目前,我们的帐户页面不安全。例如,某人可以简单地更改 URL 中的 ID 来查看另一个帐户的内容。让我们使用拦截器方法将安全性应用到我们的页面。我们将创建一个拦截器来检查我们是否已经登录(我们的帐户在 HTTP 会话中是可用的)。如果没有,它抛出com.apress.prospringmvc.bookstore.service.AuthenticationException(参见清单 6-14 )。我们还将原始 URL 存储在会话属性中;这样,我们可以在用户登录后将他重定向到他想要访问的 URL。
在为应用实现或添加安全性时,通常最好使用像 Spring Security 3 (参见第十二章)或阿帕奇·希罗 4 这样的框架,而不是推出自己的安全解决方案!
package com.apress.prospringmvc.bookstore.web.interceptor;
// javax.servlet imports omitted
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import org.springframework.web.util.WebUtils;
import com.apress.prospringmvc.bookstore.domain.Account;
import com.apress.prospringmvc.bookstore.service.AuthenticationException;
import com.apress.prospringmvc.bookstore.web.controller.LoginController;
public class SecurityHandlerInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
var account= (Account) WebUtils.getSessionAttribute(request, LoginController.ACCOUNT_ATTRIBUTE);
if (account == null) {
//Retrieve and store the original URL.
var url = request.getRequestURL().toString();
WebUtils.setSessionAttribute(request, LoginController.REQUESTED_URL, url);
throw new AuthenticationException("Authentication required.", "authentication.required");
}
return true;
}
}
Listing 6-14SecurityHandlerInterceptor
对于这个拦截器,我们的配置稍微复杂一些,因为我们想要将它映射到某些 URL(参见清单 6-15 中突出显示的部分)。
package com.apress.prospringmvc.bookstore.web.config;
import com.apress.prospringmvc.bookstore.web.interceptor.SecurityHandlerInterceptor;
//Other imports omitted
@Configuration
public class WebMvcContextConfiguration extends WebMvcConfigurerAdapter {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
registry.addWebRequestInterceptor(commonDataInterceptor());
registry.addInterceptor(new SecurityHandlerInterceptor()).
addPathPatterns("/customer/account*", "/cart/checkout");
}
// Other methods omitted
}
Listing 6-15SecurityHandlerInterceptor Configuration
最后,我们还需要对我们的com.apress.prospringmvc.bookstore.web.controller.AccountController进行修改。目前,我们希望 URL 中包含一个 ID。但是,我们不是从数据库中检索帐户,而是从会话中恢复它。清单 6-16 显示了必要的修改。
package com.apress.prospringmvc.bookstore.web.controller;
// Imports omitted
@Controller
@RequestMapping("/customer/account")
@SessionAttributes(types = Account.class)
public class AccountController {
//Fields and other methods omitted
@GetMapping
public String index(Model model, HttpSession session) {
var account = (Account) session.getAttribute(LoginController.ACCOUNT_ATTRIBUTE);
model.addAttribute(account);
model.addAttribute("orders", this.orderRepository.findByAccount(account));
return "customer/account";
}
@PostMapping
@PutMapping
public String update(@ModelAttribute Account account) {
this.accountRepository.save(account);
return "redirect:/customer/account";
}
}
Listing 6-16The AccountController
当我们重新部署应用并单击菜单栏中的 Account 时,我们会看到一个错误页面(参见图 6-5 )。我们使用默认的异常处理机制将错误代码发送回客户端,以便浏览器可以对其进行操作。在下一节中,我们将更详细地讨论异常处理。

图 6-5
单击安全链接后出现 403 错误页面
虽然我们已经保护了我们的资源,但如果能向用户显示登录页面,并提示她需要登录才能看到所请求的页面,那就更好了。这是我们在下一节要做的。
异常处理
正如第四章中提到的,当请求处理过程中发生异常时,Spring 会尝试处理该异常。为了给我们一种处理异常的通用方法,Spring 使用了另一种策略,可以通过实现org.springframework.web.servlet.HandlerExceptionResolver接口来利用这种策略。
org.springframework.web.servlet.HandlerExceptionResolver为 dispatcher servlet 提供了一个回调方法(参见清单 6-17 )。当请求处理工作流中发生异常时,将调用此方法。该方法可以返回org.springframework.web.servlet.ModelAndView,也可以选择自己处理异常。
package org.springframeowork.web.servlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface HandlerExceptionResolver {
ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex);
}
Listing 6-17The HandlerExceptionResolver Interface
默认情况下,DispatcherServlet 在类型为org.springframework.web.servlet.HandlerExceptionResolver的应用上下文中查找所有 beans(参见第四章中的“配置 dispatcher servlet”一节)。当检测到多个解析器时,dispatcher servlet 会查询它们,直到返回 viewname 或写入响应。如果异常不能被处理,那么异常被重新抛出,以便 servlet 容器可以处理它。servlet 容器使用其配置中的错误页面配置,或者简单地将异常传播给用户。(在大多数情况下,您会在屏幕上看到一个错误 500 和一个堆栈跟踪。)
Spring MVC 附带了几个org.springframework.web.servlet.HandlerExceptionResolver接口的实现,如图 6-6 所示。请注意,这些实现的工作方式各不相同。表 6-4 给出了不同实现如何工作的简要概述。
表 6-4
HandlerExceptionResolver 实现
|处理器异常解析器
|
描述
|
| --- | --- |
| ExceptionHandlerExceptionResolver | 在当前控制器中搜索用@ExceptionHandler标注的方法,并选择最佳的异常处理方法来处理异常。然后它调用选定的方法。 |
| DefaultHandlerExceptionResolver | 将众所周知的异常转换为对客户端的正确响应。返回一个空的ModelAndView并将适当的 HTTP 响应代码发送给客户机。 |
| ResponseStatusExceptionResolver | 查找异常上的org.springframework.web.bind.annotation.ResponseStatus注释,并使用它向客户端发送响应。 |
| SimpleMappingExceptionResolver | 通过异常类名或该类名的一部分(子字符串)将异常映射到视图名。这种实现既可以全局配置,也可以针对某些控制器进行配置。 |
| HandlerExceptionResolverComposite | 由 MVC 配置在内部用来链接异常解析器。只有框架可以使用它。 |

图 6-6
HandlerExceptionResolver 层次结构
如图 6-6 中的类图所示,大多数解决异常的可用实现都扩展了org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver。这是一个方便的超类,为所有实现提供了通用的特性和配置选项。表 6-5 列出并简要描述了其通用属性。
表 6-5
公共抽象处理程序 exception 解析属性
|财产
|
描述
|
| --- | --- |
| mappedHandlerClasses | 一组HandlerExceptionResolver应该处理异常的处理程序类。从不在集合中的类型的处理程序传播的异常不由这个HandlerExceptionResolver处理。 |
| mappedHandlers | 类似于mappedHandlerClasses,但是它包含实际的处理程序(在本例中是控制器),而不是类。 |
| preventResponseCaching | 使我们能够防止缓存由此HandlerExceptionResolver解析的视图。默认值是false,允许浏览器缓存错误页面。 |
| warnLogCategory | 设置用于记录异常的类别(日志级别为 WARN)。缺省值是 no category,这意味着没有日志记录。 |
所有属性都在AbstractHandlerExceptionResolver上定义。
DefaultHandlerExceptionResolver
DefaultHandlerExceptionResolver实现总是返回一个空的ModelAndView,并向客户端发送一个 HTTP 响应代码。在表 6-6 中,您可以看到 HTTP 响应代码和描述映射的异常。
表 6-6
异常 HTTP 响应代码映射
|例外
|
HTTP 代码
|
描述
|
| --- | --- | --- |
| NoSuchRequestHandlingMethodException | Four hundred and four | 未发现 |
| HttpRequestMethodNotSupportedException | Four hundred and five | 不允许的方法 |
| HttpMediaTypeNotSupportedException | Four hundred and fifteen | 不支持的媒体类型 |
| HttpMediaTypeNotAcceptableException | Four hundred and six | 不可接受 |
| ConversionNotSupportedExceptionHttpMessageNotWritableException | Five hundred | 内部服务器错误 |
| MissingServletRequestParameterExceptionServletRequestBindingExceptionTypeMismatchExceptionHttpMessageNotReadableExceptionMethodArgumentNotValidExceptionMissingServletRequestPartException | four hundred | 错误的请求 |
ResponseStatusExceptionResolver
ResponseStatusExceptionResolver检查抛出的异常是否用org.springframework.web.bind.annotation.ResponseStatus注释进行了注释(参见清单 6-18 )。如果是这种情况,它会处理异常,将来自注释的 HTTP 响应代码发送到客户端,然后返回一个空的ModelAndView,指示异常已被处理。如果该注释不存在,它只是返回null来表明异常没有被处理。
package com.apress.prospringmvc.bookstore.service;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(value = HttpStatus.FORBIDDEN)
public class AuthenticationException extends Exception {
private final String code;
public AuthenticationException(String message, String code) {
super(message);
this.code = code;
}
public String getCode() {
return this.code;
}
}
Listing 6-18Handling an AuthenticationException
当我们抛出这个异常时,org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver检测到它已经用org.springframework.web.servlet.bind.annotation.ResponseStatus进行了注释。这是我们用来让框架处理com.apress.prospringmvc.bookstore.service.AuthenticationException的机制。这个注释有两个属性可以用来指定信息(见表 6-7 )。
表 6-7
ResponseStatus属性
财产
|
描述
|
| --- | --- |
| Value | 发送要发送给客户端的 HTTP 响应代码。这是必须的。 |
| Reason | 将原因发送给客户端。这是可选的。它还提供了附加信息。 |
SimpleMappingExceptionResolver
SimpleMappingExceptionResolver可以配置为将某些异常转换为视图。例如,我们可以将(部分)异常类名映射到一个视图。我们在这里说部分是因为匹配是基于类名完成的,而不是基于它的具体类型。匹配是通过简单的子串机制完成的;不支持通配符(ant 样式的正则表达式)。
清单 6-19 显示了SimpleMappingExceptionResolver的配置。它被配置为将一个AuthenticationException映射到名为 login 的视图。我们还设置了一个 HTTP 响应代码,用 login 视图发送。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;
// Imports omitted
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
@Override
public void configureHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers {
exceptionResolvers.add(simpleMappingExceptionResolver());
}
@Bean
public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {
var mappings = new Properties();
mappings.setProperty("AuthenticationException", "login");
var statusCodes = new Properties();
mappings.setProperty("login", String.valueOf(HttpServletResponse.SC_UNAUTHORIZED));
var exceptionResolver = new SimpleMappingExceptionResolver();
exceptionResolver.setExceptionMappings(mappings);
exceptionResolver.setStatusCodes(statusCodes);
return exceptionResolver;
}
// Other methods omitted
}
Listing 6-19A SimpleMappingExceptionResolver Configuration
匹配是基于类名而不是具体类型来完成的。如果抛出的异常的类名与指定的模式匹配,则使用相应的视图名。该模式不支持通配符;它仅仅是一个匹配类名的子串。我们需要仔细选择图案。例如,Exception匹配几乎所有抛出的异常(因为大多数异常的类名中都有异常)。类似地,DataAccessException或多或少地匹配所有 Spring 的数据访问异常。
我们需要做最后的调整。即,我们需要修改our com.apress.prospringmvc.bookstore.web.controller.LoginController。此刻,控制器内部有异常处理;然而,这可以被删除,因为AuthenticationException是由我们最近配置的HandlerExceptionResolver处理的(参见清单 6-20 中改进的控制器)。
package com.apress.prospringmvc.bookstore.web.controller;
// Imports omitted
@Controller
@RequestMapping(value = "/login")
public class LoginController {
public static final String ACCOUNT_ATTRIBUTE = "account";
public static final String REQUESTED_URL = "REQUESTED_URL";
private final AccountService accountService;
@GetMapping
public void login() {}
@PostMapping
public String handleLogin(@RequestParam String username, @RequestParam String password,
HttpSession session) throws AuthenticationException {
var account = this.accountService.login(username, password);
session.setAttribute(ACCOUNT_ATTRIBUTE, account);
var url = (String) session.getAttribute(REQUESTED_URL);
session.removeAttribute(REQUESTED_URL);
if (StringUtils.hasText(url) && !url.contains("login")) {
return "redirect:" + url;
} else {
return "redirect:/index.htm";
}
}
}
Listing 6-20The Improved Login Controller
如果我们在重新部署后点击菜单栏上的 Account,我们会看到一个登录页面(见图 6-7 )。

图 6-7
登录页面
ExceptionHandlerExceptionResolver
ExceptionHandlerExceptionResolver在当前控制器或@ControllerAdvice注释类中寻找用org.springframework.web.bind.annotations.ExceptionHandler注释的方法。
异常处理方法非常像控制器方法(如第五章所解释的);它们可以使用相同的方法参数和相同的返回值。异常处理方法使用相同的底层基础结构来检测返回类型和方法参数类型。然而,除了这些方法之外,我们还可以在抛出的异常中传递一个附加的方法;也就是说,我们可以指定 Exception 类型的参数(或子类)。
清单 6-21 中的方法处理定义它的控制器中抛出的所有异常。它会将错误代码 500 连同给定的原因一起发送回客户端。这是我们可以编写的最基本的异常处理方法。如前所述,我们可以在方法签名中使用多个参数,这也适用于方法参数,因为方法返回类型不同。(有关概述,请参见上一章中的表 5-3 和表 5-4。)
@ExceptionHandler
@ResponseStatus(value=HttpStatus.INTERNAL_SERVER_ERROR, reason="Exception while handling request.")
public void handleException() {}
Listing 6-21A Basic Exception-handling Method Sample
清单 6-22 显示了一个更详细的例子。当org.springframework.dao.DataAccessException发生时,它用尽可能多的信息填充模型。之后,名为db-error的视图被渲染。
@ExceptionHandler
public ModelAndView handle(DataAccessException ex, Principal principal, WebRequest request) {
var mav = new ModelAndView("db-error");
mav.addObject("exception", ex);
mav.addObject("username", principal.getName());
mav.addAllObjects(request.getParameterMap());
for(Iterator<String> names = request.getHeaderNames(); names.hasNext(); ) {
var name = names.next();
var value = request.getHeaderValues(name);
mav.addObject(name, value);
}
return mav;
}
Listing 6-22An Advanced Exception-handling Method Sample
扩展 Spring@MVC
在前面的章节中,我们解释了 Spring MVC 是如何工作的,以及我们如何编写控制器。然而,可能会有这样的时候,即装即用的框架支持不够充分,我们希望改变或增加框架的行为。一般来说,Spring 框架由于其构建方式而具有灵活性。它使用了许多策略和委托,我们可以用它们来扩展或修改框架的行为。在这一节中,我们将深入研究请求映射、请求处理和表单呈现的内部机制。最后,我们将讨论如何扩展这些特性。
延伸RequestMappingHandlerMapping
为了将传入的请求映射到控制器方法,Spring 使用了处理程序映射。对于我们的用例,我们一直使用org.springframework.web.servlet.mvc.method.annotation. RequestMappingHandlerMapping,我们已经多次提到它的灵活性。为了将基于方法的请求与org.springframework.web.bind.annotation.RequestMapping注释相匹配,处理程序映射参考了几个org.springframework.web.servlet.mvc.condition.RequestCondition实现(参见图 6-8 )。

图 6-8
RequestCondition 类图
如图所示,每个属性都有一个实现(即,消费、头、方法、参数、产品和值;更多细节见org.springframework.web.bind.annotation.RequestMapping注释的表 5-2)。RequestConditionHolder和RequestMappingInfo是框架内部使用的两个实现。
要创建一个实现,我们需要两样东西。首先,我们需要一个接口的实现(参见清单 6-23 中的 API)。第二,我们需要扩展org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping。这个类包含两个回调方法,作为我们定制请求方法的工厂方法(参见清单 6-24 )。调用getCustomTypeCondition方法来创建匹配类型级条件的实例,而getCustomMethodCondition方法用于方法级条件。
package org.springframework.web.servlet.mvc.method.annotation;
import java.lang.reflect.Method;
import org.springframework.context.EmbeddedValueResolverAware;
//other imports omitted
public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping implements MatchableHandlerMapping, EmbeddedValueResolverAware {
// Other methods omitted.
protected RequestCondition<?> getCustomMethodCondition(Method method) {
return null;
}
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
return null;
}
}
Listing 6-24The RequestMappingHandlerMapping
package org.springframework.web.servlet.mvc.condition;
import javax.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.RequestMapping;
public interface RequestCondition<T> {
T combine(T other);
T getMatchingCondition(HttpServletRequest request);
int compareTo(T other, HttpServletRequest request);
}
Listing 6-23The RequestCondition API
扩展 RequestMappingHandlerAdapter
像RequestMappingHandlerMapping一样,RequestMappingHandlerAdapter使用几种不同的策略来完成它的工作。为了确定在方法参数中注入什么,适配器参考了几个org.springframework.web.method.support.HandlerMethodArgumentResolver实现。对于返回类型,它参考已注册的org.springframework.web.method.support.HandlerMethodReturnValueHandler实现。
HandlerMethodArgumentResolver
RequestMappingHandlerAdapter使用HandlerMethodArgumentResolver来确定方法参数的用途。每个支持的方法参数类型或注释都有一个实现(参见第五章的“支持的方法参数类型”一节)。API 很简单,如清单 6-25 所示。
package org.springframework.web.method.support;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
public interface HandlerMethodArgumentResolver {
boolean supportsParameter(MethodParameter parameter);
Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory)throws Exception;
}
Listing 6-25The HandlerMethodArgumentResolver API
在每个注册的HandlerMethodArgumentResolver上调用supportsParameter方法。返回true的函数检测或创建用于该方法参数的实际值。我们通过调用 resolveArgument 方法来实现这一点。
handletmethodreturnvaluehandler
HandlerMethodReturnValueHandler类似于HandlerMethodArgumentResolver,但是有一个重要的区别。顾名思义,HandlerMethodReturnValueHandler适用于方法返回值。每个支持的返回值或注释都有一个实现(参见第五章的“支持的返回值”一节)。这个 API 也很简单,如清单 6-26 所示。
package org.springframework.web.method.support;
import org.springframework.core.MethodParameter;
import org.springframework.web.context.request.NativeWebRequest;
public interface HandlerMethodReturnValueHandler {
boolean supportsReturnType(MethodParameter returnType);
void handleReturnValue(Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
throws Exception;
}
Listing 6-26HandlerMethodReturnValueHandler
每个注册的HandlerMethodReturnValueHandler的supportsReturnType方法用该方法的返回类型调用。返回true的函数处理返回值,这是通过调用handleReturnValue方法来完成的。
实现您自己的
我们可以利用RequestMappingHandlerAdapter使用的策略。例如,我们想要一种简单的方法来存储和检索javax.servlet.http.HttpSession中的对象。为此,我们首先需要一个注释,将方法参数或返回类型标记为我们想要检索或放入HttpSession的内容。清单 6-27 描述了我们使用的注释。
package com.apress.prospringmvc.bookstore.web.method.support;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SessionAttribute {
String value() default "";
boolean required() default true;
boolean exposeAsModelAttribute() default false;
}
Listing 6-27The SessionAttribute Annotation
然而,添加注释本身并没有多大帮助,因为我们仍然需要一个使用该注释的类。因为我们想从HttpSession中检索并存储,所以我们创建了一个实现了HandlerMethodReturnValueHandler和HandlerMethodArgumentResolver接口的类(参见清单 6-28 )。
package com.apress.prospringmvc.bookstore.web.method.support;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
import org.springframework.web.method.support.ModelAndViewContainer;
public class SessionAttributeProcessor implements HandlerMethodReturnValueHandler, HandlerMethodArgumentResolver {
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return returnType.getMethodAnnotation(SessionAttribute.class) != null;
}
@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throw Exception {
var annotation = returnType.getMethodAnnotation(SessionAttribute.class);
webRequest.setAttribute(annotation.value(), returnValue, WebRequest.SCOPE_SESSION);
exposeModelAttribute(annotation, returnValue, mavContainer);
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(SessionAttribute.class);
}
private void exposeModelAttribute(SessionAttribute annotation, Object value,
ModelAndViewContainer mavContainer) {
if (annotation.exposeAsModelAttribute()) {
mavContainer.addAttribute(annotation.value(), value);
}
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory)
throws Exception {
var annotation = parameter.getParameterAnnotation(SessionAttribute.class);
var value = webRequest.getAttribute(annotation.value(), WebRequest.SCOPE_SESSION);
if (value == null && annotation.required()) {
throw new MissingServletRequestParameterException(annotation.value(), parameter.getParameterType().getName());
}
exposeModelAttribute(annotation, value, mavContainer);
return value;
}
}
Listing 6-28The SessionAttributeProcessor
在使用处理器之前,我们需要对其进行配置。为此,我们需要修改我们的配置类。具体来说,我们需要将处理器添加为 bean,并让环境知道 bean 的存在(参见清单 6-29 )。
package com.apress.prospringmvc.bookstore.web.config;
com.apress.prospringmvc.bookstore.web.method.support.SessionAttributeProcessor;
// Other imports omitted
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
@Bean
public SessionAttributeProcessor sessionAttributeProcessor() {
return new SessionAttributeProcessor();
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(sessionAttributeProcessor());
}
@Override
public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> returnValueHandlers) {
returnValueHandlers.add(sessionAttributeProcessor());
}
// Other methods omitted
}
Listing 6-29The Modified WebMvcContextConfiguration
现在我们已经配置了处理器,我们终于可以使用它了。让我们从修改帐户的控制器开始,如清单 6-30 所示。我们不再需要直接访问会话并将帐户添加到模型中;这现在全部由处理器处理。粗体字反映了变化;它只是一个方法参数的注释。此时,我们不再需要直接访问 HTTP 会话。
package com.apress.prospringmvc.bookstore.web.controller;
// Imports omitted
@Controller
@RequestMapping("/customer/account")
@SessionAttributes(types = Account.class)
public class AccountController {
@RequestMapping(method = RequestMethod.GET)
public String index(Model model,@SessionAttribute(value = LoginController.ACCOUNT_ATTRIBUTE, exposeAsModelAttribute = true) Account account) {
model.addAttribute("orders", this.orderRepository.findByAccount(account));
return "customer/account";
}
// Other methods omitted
}
Listing 6-30The Modified AccountController
如果我们现在重新启动应用并单击 Account(登录后),我们会看到我们的帐户页面(见图 6-9 )。

图 6-9
帐户页面
使用 RequestDataValueProcessor
org.springframework.web.servlet.support.RequestDataValueProcessor组件是可选的,因为我们可以在呈现请求参数值或发出重定向之前使用它来检查或修改请求参数值。
我们可以使用这个组件作为解决方案 5 的一部分,以提供数据完整性、机密性和防止跨站点请求伪造(CSRF)。 6 我们也可以用它来自动给所有表单和 URL 添加隐藏字段。
RequestDataValueProcessor API 由四个方法组成(参见清单 6-31 )。
package org.springframework.web.servlet.support;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
public interface RequestDataValueProcessor {
String processAction(HttpServletRequest request, String action, String httpMethod);
String processFormFieldValue(HttpServletRequest request, String name, String value, String type);
Map<String, String> getExtraHiddenFields(HttpServletRequest request);
String processUrl(HttpServletRequest request, String url);
}
Listing 6-31The RequestDataValueProcessor API
我们可以用这个界面做一些有趣的事情。例如,我们可以在控制器(或拦截器)中不可编辑的字段(如 ID)上创建一个校验和,然后检查这个校验和,看是否有任何字段被篡改。
<c:/url ../>标签使用了processUrl方法。在重定向时,我们可以使用它来编码或向 URL 添加额外的参数,以保护我们的 URL(例如,我们可以添加校验和来检查参数的有效性)。
框架没有提供默认实例。因此,需要为我们的应用定制一个实现(HDIV 7 网站有一个插件来保护网站免受各种漏洞的攻击)。要配置RequestDataValueProcessor,我们需要将它添加到应用上下文中,然后用名称requestDataValueProcessor注册它,这是框架用来检测注册实例的名称。
摘要
本章讲述了构建 web 应用的一些更高级的技术。例如,我们从查看作用域 beans 开始,并利用它们为我们服务。为此,我们在示例应用中实现了一个购物车。
有时,我们发现自己需要重用代码或跨许多类或 URL 执行代码。这些横切关注点可以使用面向方面的编程来解决;然而,这并不总是很适合 web 应用。在 Spring MVC 中,我们可以使用拦截器和高级异常处理策略来解决那些横切关注点。例如,我们可以使用拦截器为许多控制器执行一段代码。在配置这些拦截器时,我们可以根据 URL 指定是映射到所有控制器还是只映射到某些控制器。
尽管我们都试图构建尽可能健壮的应用,但总有出错的可能。当事情确实出错时,我们希望优雅地处理问题。例如,当我们需要用户的凭证时,我们可能希望向用户显示一个错误页面或登录页面。为此,我们深入研究了 Spring MVC 中的异常处理策略。
随后,我们更深入地研究了 Spring MVC 的基础设施类,并研究了如何在需要时扩展框架。我们还解释了如何通过指定额外的请求条件来扩展请求匹配。接下来,我们解释(并展示)了如何编写一个处理器来处理方法参数类型和请求处理方法的返回值。
最后,我们以对请求数据值处理器的简要介绍结束了本章,介绍了如何使用它来防止 CSFR 并提供数据完整性。
七、REST 和 AJAX
到目前为止,我们一直在构建一个经典的 web 应用:我们向服务器发送一个请求,服务器处理这个请求,我们呈现结果并显示给客户端。然而,在过去的十年中,我们构建 web 应用的方式发生了很大的变化。现在我们有了 JavaScript 和 JSON/XML,它们允许基于 AJAX 的 web 应用,并将越来越多的行为推送到客户端,包括验证、渲染部分屏幕等等。
本章从 REST 1 (表述性状态转移)开始,这是一种架构风格,影响了开发人员对 web 资源的思考和处理。稍后,我们将讨论 AJAX,并将其与 REST 结合起来考虑。
本章的第二部分讲述了文件上传。您将学习如何使用 Spring 框架上传文件,并在我们的控制器中处理任务。然而,在我们进入这个之前,让我们看看休息。
表征状态转移(REST)
本节简要解释 REST 的主题,它本质上有两个部分:首先,资源和如何识别它们,以及我们如何操作或使用这些资源。REST 在 2000 年由罗伊·托马斯·菲尔丁在一篇题为“架构风格和基于网络的软件架构的设计”的论文中描述 2 它描述了如何使用 HTTP 协议和该协议提供的特性来处理资源。
识别资源
第章 4 简要讨论了一个 URL(统一资源定位符) 3 的组成部分。对于休息,这不会改变;但是,URL 很重要,因为它指向一个唯一的资源。这就是为什么当谈到 REST APIs 时,URL 被替换为 URI(统一资源标识符)。 4 表 7-1 给出了几个资源位置的例子。
表 7-1
资源定位器
|上呼吸道感染
|
描述
|
| --- | --- |
| http://www.example.com/books | 书单 |
| http://www.example.com/books/9781430241553 | 书号为 978-1-4302-4155-3 的详细资料 |
URIs 在操作中使用他们所描述的对象的复数是一种最佳做法。
在 REST 中,这完全是关于资源的一个表示,因此 URI 很重要。它给我们一个实际资源的位置(网页,网页上的图像,mp3 文件,或者其他)。我们在 web 浏览器中看到的不是实际的资源,而是该资源的表示。下一节将解释我们如何使用这个资源位置进行修改、删除等操作。)那个资源。
使用资源
HTTP 协议指定了几种方法(HTTP 方法) 5 来处理来自我们应用的信息。表 7-2 给出了这些方法的概述。
表 7-2
可用的 HTTP 方法
|方法
|
描述
|
| --- | --- |
| 得到 | 从给定位置检索资源(例如,一本书)的表示形式。 |
| 头 | 类似于 GET 但是,并不返回实际的表示形式,而只返回属于该资源的标头。有助于确定是否发生了变化以及是否需要发送 GET 请求。 |
| 放 | 在服务器上存储资源(书籍)的表示形式。通常,资源有一个唯一的标识符。当 PUT 请求的主体包含带有标识符的对象时,主体的内容会更新带有指定 ID 的现有资源。如果 PUT 请求主体没有标识符,则会创建一个新资源。如果用户多次发出相同的 PUT 请求,结果应该总是相同的。 |
| 邮政 | 类似于 PUT,但服务器控制创建资源或启动操作。帖子对于创建新资源(比如用户)或触发动作(在我们的示例中,将一本书添加到购物车)非常有用。多次发出相同的请求不会产生相同的结果(也就是说,图书会被添加两次)。这就是为什么成功的 POST 请求的结果通常是重定向到包含已创建资源的页面。 6 |
| 删除 | 删除寻址的资源(在这种情况下,删除书)。 |
| 选择 | 确定与此资源或服务器功能相关的选项。(例如,支持的 HTTP 方法、是否启用了安全性、任何版本等等)。 |
| 微量 | 沿着到目标资源的路径执行消息环回测试,提供有用的调试机制。 |
| 修补 | 旨在对现有资源进行部分更改。当带宽有限时,修补程序对于更新资源非常有用。这种方法既不安全也不幂等。 |
TRACE 和 OPTIONS 方法不在 REST 中使用,但为了完整起见在这里提到了它们。
PATCH 方法不在 REST 中使用,因为对于 API 来说,使用 PUT 方法自动应用补丁是一个很好的实践。
在“识别资源”一节中,我们提到了 URI 是如何指向资源的。如果我们将 REST 与表 7-1 中的资源结合起来,我们可以使用它们,如表 7-3 中所述。
表 7-3
应用接口
|统一资源定位器
|
方法
|
描述
|
| --- | --- | --- |
| http://www.example.com/books | 得到 | 得到一份书单。 |
| http://www.example.com/books | 放 | 更新书单。 |
| http://www.example.com/books | 邮政 | 创建新的图书列表。 |
| http://www.example.com/books | 删除 | 删除所有的书。 |
| http://www.example.com/books/9781430241553 | 得到 | 获取 ISBN 为 978-1-4302-4155-3 的图书的代表。 |
| http://www.example.com/books/9781430241553 | 放 | 用 ISBN 978-1-4302-4155-3 更新这本书。 |
| http://www.example.com/books/9781430241553 | 邮政 | 创建 ISBN 为 978-1-4302-4155-3 的图书。 |
| http://www.example.com/books/9781430241553 | 删除 | 删除 ISBN 为 978-1-4302-4155-3 的书。 |
HTTP 方法的列表比大多数 web 浏览器支持的要大。通常,它们只支持 GET 和 POST 方法,不支持其他方法。为了在传统的 web 应用中使用不同的方法,我们需要使用一种变通方法;对于这个,Spring MVC 有HiddenHttpMethodFilter。
HiddenHttpMethodFilter
org.springframework.web.filter. HiddenHttpMethodFilter组件将 POST 请求屏蔽为另一种指定类型的请求。它使用请求参数来确定对传入的请求使用哪种方法。这适用于使用 Spring 标签库和百里香叶创建的表单。默认情况下,它使用名为_method的请求参数;然而,这个名称可以通过扩展HiddenHttpMethodFilter类并覆盖setMethodParam(String)来为参数设置一个不同的名称来配置。
通过确保请求参数存在,可以将 POST 请求“转换”为 PUT 或 DELETE 然后请求被包装在HttpMethodRequestWrapper(它是HiddenHttpMethodFilter的内部类)中。GET 请求按原样处理;它不会被转换成另一种类型的请求。这是因为与其他类型不同,GET 请求将所有参数都编码在 URL 中。相比之下,POST 和 PUT 请求将它们编码在请求体中。
为我们的 web 应用启用HiddenHttpMethodFilter需要向application.properties添加一个属性(参见清单 7-1 )。
spring.mvc.hiddenmethod.filter.enabled=true
Listing 7-1Enable HiddenHttpMethodFilter
启用过滤器后,我们需要修改我们的帐户页面。打开account.jsp文件,确保有一个名为_method,和值为PUT的隐藏字段。清单 7-2 显示页面的开始;如您所见,表单的开头定义了这个隐藏字段。
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<form:form method="POST" modelAttribute="account" id="accountForm">
<!-- filter to transform POST method in PUT -->
<input type="hidden" name="_method" value="PUT" />
<fieldset>
<legend><spring:message code="account.personal"/></legend>
// Remainder of page omitted
Listing 7-2Account.jsp Heading
当提交页面时,HiddenHttpMethodFilter执行它的工作,将我们的 POST 请求转换成 PUT 请求。对于简单的应用,控制器可以使用相同的处理程序方法处理两种请求方法(参见清单 7-3 )。对于更复杂的应用,它们可以单独处理。
package com.apress.prospringmvc.bookstore.web.controller;
//Imports omitted
@Controller
@RequestMapping("/customer/account")
@SessionAttributes(types = Account.class)
public class AccountController {
@PostMapping
@PutMapping
public String update(@ModelAttribute Account account) {
this.accountRepository.save(account);
return "redirect:/customer/account";
}
// Other methods omitted
}
Listing 7-3AccountController Update Method
过滤器仍然是一种变通方法,使浏览器和普通表单的 REST 成为可能,如果我们选择对我们的网站使用渐进增强或优雅降级,这可能是有用的。渐进式增强意味着向基本页面添加丰富的行为,并首先确保我们的基本页面如我们所愿地工作。优雅的降级正好相反——我们开发一个丰富的网站,并试图确保整个网站仍然工作,即使某些功能不可用。
异步 JavaScript 和 XML (AJAX)
杰西·詹姆斯·加勒特在 2005 年创造了 AJAX 这个术语。AJAX 本身并不是一项技术。它是一系列技术的集合,共同为我们的 web 应用创造丰富的用户体验。AJAX 结合了以下技术。
-
使用 HTML 和 CSS 实现基于标准的表示
-
使用文档对象模型(DOM)进行动态显示和交互
-
数据交换和操作(使用 XML 或 JSON)
-
使用
XMLHttpRequest进行异步数据检索 -
JavaScript 将所有这些整合在一起
虽然首字母缩写代表异步 JavaScript 和 XML ,但它通常与 JavaScript 对象符号(JSON)一起使用,在客户机和服务器之间传递数据。
由于 AJAX 已经使用了几年,所以有很多 JavaScript 框架和库可以让创建丰富的用户体验变得更加容易。对于 Spring MVC,您选择哪个 JavaScript 框架或库并不重要,讨论大量的 JavaScript 框架和库也超出了本书的范围。对于我们的例子,我们使用 jQuery 7 ,因为它是使用最广泛的库之一。要使用 jQuery,我们需要加载包含这个库的 JavaScript 文件。为此,我们修改 template.jsp 文件以包含 jQuery(参见清单 7-4 )。
<!DOCTYPE HTML>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="tiles" uri="http://tiles.apache.org/tags-tiles" %>
<html>
<head>
<meta charset="utf-8">
<c:set var="titleKey">
<tiles:getAsString name="title" />
</c:set>
<title> Bookstore |
<spring:message code="${titleKey}" text="Your Home in Books"/>
</title>
<link rel="stylesheet" type="text/css"
href="<c:url value="/resources/css/style.css"/>" >
<script
src="<c:url value="/resources/jquery/jquery-3.5.1.min.js"/>">
</script>
</head>
//Body Omitted
</html>
Listing 7-4Modified template.jsp Header
如果你有一个良好和持续的互联网连接,你就不必下载jQuery;你可以直接链接公开的缩小版。
<script src="https://code.jquery.com/jquery-3.5.1.min.js"/>
这会将 jQuery JavaScript 库添加到所有页面中(如果添加到 commons 布局中);然而,就其本身而言,它并没有多大作用。我们仍然需要向页面添加逻辑,这意味着当用户与 HTML 元素交互时,调用现在可用的函数。在接下来的小节中,我们将 AJAX 行为添加到我们的示例应用中。我们从一个简单的表单提交开始,一路探索 Spring MVC 提供的用于 AJAX 的特性,以及它如何帮助我们构建 REST 应用。
向我们的应用添加 AJAX
由于 Spring MVC 的灵活性,很容易将 AJAX 行为添加到我们的应用中,并与 Spring MVC 很好地集成。在本节中,您将学习如何将表单提交更改为基于 AJAX 的表单提交(使用和不使用 JSON)。AJAX 更适合提交搜索表单,因为它可以防止页面完全重载。然而,表单提交并不是 AJAX 唯一可能的用途;它仅仅服务于我们的示例应用;还可以创建自动完成字段、自动字段/表单验证等等。
提交带有 HTML 结果 AJAX 表单
让我们看看我们的图书搜索页面,并将其转换成一个更动态的网页。我们首先将普通表单提交改为 AJAX 表单提交。打开search.jsp文件,将清单 7-5 中所示的脚本添加到表单之后或页面底部,以确保 HTML 代码被呈现并且 JS 可以操作它。
<script>
$('#bookSearchForm').submit(function(evt){
evt.preventDefault();
formData = $('#bookSearchForm').serialize();
$.ajax({
url: $('#bookSearchForm').action,
type: 'GET',
data: formData
});
});
</script>
Listing 7-5Book Search Page with AJAX Form Submit
这个脚本代替了实际的表单提交。它首先阻止实际的提交,然后构建一个 AJAX 请求,将数据传递给服务器。如果我们现在重新部署我们的应用,导航到我们的图书搜索页面,然后按 Submit,看起来什么也没发生。至少我们在屏幕上看不到任何变化。如果我们调试我们的应用,我们可以看到请求到达服务器和搜索被发出。那么为什么结果没有被渲染呢?
在本节的开始,我们提到 AJAX 是一个技术集合,其中之一是使用XMLHttpRequest的异步数据检索。这也是我们目前的问题所在。我们向服务器发送一个请求,但是我们没有包含任何处理来自服务器的响应的内容。
清单 7-6 显示了修改后的脚本(见突出显示的部分)来呈现返回的页面。
<script>
$('#bookSearchForm').submit(function(evt) {
evt.preventDefault();
formData = $('#bookSearchForm').serialize();
$.ajax({
url: $('#bookSearchForm').action,
type: 'GET',
data: formData,
success: function(html) {
resultTable = $('#bookSearchResults', html);
$('#bookSearchResults').html(resultTable);
}
});
});
</script>
Listing 7-6Book Search Page with Success Handler
我们为这个脚本添加了成功处理程序,它的作用是呈现我们从服务器收到的结果。结果是正常呈现的整个页面。我们选择带有结果的表,并用检测到的表替换屏幕上的当前表。如果重新部署应用并发出搜索命令,页面将再次工作。
提交带有 JSON 结果 AJAX 表单
上一节展示了一个基本的 AJAX 表单提交,我们从中获得了 HTML。我们将数据发送到服务器,并获得一个 HTML 页面片段页面进行渲染。另一种方法是获取我们需要在客户端渲染和处理的数据。这使 JavaScript 代码有点复杂,但是我们也需要扩展我们的服务器端。我们需要一个额外的方法将 JSON 编码的数据返回给客户端(参见清单 7-7 )。
package com.apress.prospringmvc.bookstore.web.controller;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.ResponseBody;
// Other imports omitted
@Controller
public class BookSearchController {
// Other methods omitted
@GetMapping(value = "/book/search", produces = MediaType.APPLICATION_JSON_VALUE )
public @ResponseBody Collection<Book> listJSON(
@ModelAttribute("bookSearchCriteria") BookSearchCriteria criteria) {
return this.bookstoreService.findBooks(criteria);
}
}
Listing 7-7BookSearch Controller with JSON Producing Method
该方法与同一控制器上的原始列表方法相同;然而,有两个重要的区别,这是突出的。第一个是,每当一个传入的请求指定它想要接收 JSON(通过设置Accept头,如第四章中所解释的)时,这个方法就会被调用。接下来,我们使用@ResponseBody注释来指示 Spring MVC 使用返回值作为响应的主体(参见第五章中的“支持的方法参数注释”一节)。使用org.springframework.http.converter.HttpMessageConverter<T>转换返回值。
当在类路径中找到 Jackson Java JSON 处理器 8 时,Spring MVC 会自动注册org.springframework.http.converter.json.MappingJackson2HttpMessageConverter。
除了控制器之外,我们需要修改我们的 JavaScript 来指定我们想要从服务器接收 JSON。因为我们接收 JSON,所以我们需要使用 JSON 来替换我们的结果表的内容。在清单 7-8 中,您可以看到search.jsp文件的结果。
<script>
$('#bookSearchForm').submit(function(evt){
evt.preventDefault();
formData = $('#bookSearchForm').serialize();
$.ajax({
url: $('#bookSearchForm').action,
type: 'GET',
dataType: 'json',
data: formData,
success: function(data){
var content = '';
var books = data;
var baseDetailUrl = '<c:url value="/book/detail/"/>';
var baseAddCartUrl = '<c:url value="/cart/add/" />';
for (var i = 0; i<books.length; i++) {
content += '<tr>';
content += '<td><a href="'
+ baseDetailUrl + books[i].id+'">'
+ books[i].title+'</a</td>';
content += '<td>'+books[i].description+'</td>';
content += '<td>'+books[i].price+'</td>';
content += '<td><a href="'+ baseAddCartUrl +books[i].id
+'"><spring:message code="book.addtocart"/></a></td></tr>';
}
$('#bookSearchResults tbody').html(content);
}
});
});
</script>
Listing 7-8Book Search Page with JSON Success Handler
当应用被重新部署并执行搜索时,我们的新方法被调用,JSON 被返回给客户机。客户端使用 JSON 对象创建一个新的表体,当表体创建后,它将替换当前的表体。
发送和接收 JSON
可以向服务器发送 JSON,也可以从服务器接收 JSON。发送 JSON 的优点是它比 XML 更紧凑,发送和处理(客户端和服务器端)都更快。一个缺点是,您需要一些手工编码来准备发送到服务器的 JSON,特别是在重用现有对象时(正如您在我们的示例中看到的)。
为了实现这一点,我们需要修改我们的客户端 JavaScript,并对我们的请求处理方法进行一些更改。控制器需要知道我们没有使用普通的模型属性,而是想要使用 JSON 作为我们的BookSearchCriteria。为了实现这一点,我们用@RequestBody注释我们的方法参数;它类似于@ResponseBody,但是针对传入的请求。为了明确 handler 方法需要某种类型的数据输入,可以将consumes属性添加到@PostMapping注释中。
清单 7-9 强调了需要对控制器进行的更改。
package com.apress.prospringmvc.bookstore.web.controller;
import org.springframework.web.bind.annotation.RequestBody;
// Other imports omitted
@Controller
public class BookSearchController {
// Other methods omitted
@PostMapping(value = "/book/search", produces = MediaType.APPLICATION_JSON_VALUE
,consumes = MediaType.APPLICATION_JSON_VALUE)
public @ResponseBody Collection<Book> listJSON(
@RequestBody BookSearchCriteria criteria) {
return this.bookstoreService.findBooks(criteria);
}
}
Listing 7-9BookSearchController with RequestBody Annotation
请注意从 GET 请求到 POST 请求的变化;这是必需的,因为我们使用了@RequestBody注释。注释在请求的主体上操作,但是 GET 请求通常将数据编码在 URL 中而不是主体中。
使用@RequestBody 和@ResponseBody 注释时,用于表示/构建资源的所有内容都应该是请求的一部分。Spring MVC 将请求体反序列化为在 handler 方法中处理的 Java 对象,并将返回的结果序列化为由 produces 属性指定的类型。
修改了控制器之后,我们还需要再次修改 JavaScript。我们需要将表单中的数据转换成可以发送给服务器的 JSON 字符串。清单 7-10 显示了需要更改的内容。
<script>
$('#bookSearchForm').submit(function(evt){
evt.preventDefault();
var title = $('#title').val();
var category = $('#category').val();
var json = { "title" : title, "category" : { "id" : category}};
$.ajax({
url: $('#bookSearchForm').action,
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify(json),
success: function(books) {
var content = '';
var baseDetailUrl = '<c:url value="/book/detail/"/>';
var baseAddCartUrl = '<c:url value="/cart/add/" />';
for (var i = 0; i<books.length; i++) {
content += '<tr>';
content += '<td><a href="'+ baseDetailUrl + books[i].id+'">'
+books[i].title+'</a></td>';
content += '<td>'+books[i].description+'</td>';
content += '<td>'+books[i].price+'</td>';
content += '<td><a href="'+ baseAddCartUrl +books[i].id
+'"><spring:message code="book.addtocart"/></a></td></tr>';
}
$('#bookSearchResults tbody').html(content);
}
});
});
</script>
Listing 7-10Book Search Page with JSON Form Submit
正如您所看到的,添加了contentType属性来将表单数据转换成 JSON 对象,并且请求的类型被更改为 POST。这是必要的,因为内容是请求的主体,而 GET 请求没有主体,而是将所有内容编码到 URL 中。
data属性值用于将 JSON 对象转换成 JSON 字符串,该字符串可以发送给服务器。其他一切都保持不变。
如果应用被重新部署,我们发出一个搜索,搜索结果再次显示给用户。
jQuery 有一个插件架构,有几个插件可以使表单到 JSON (Dream.js,9JsonView10)的转换更容易。我们选择不使用插件,以避免关注插件本身。
结合 AJAX 和 REST
我们简要介绍了 REST,也谈到了 AJAX,但是我们分别介绍了每个主题。但是,这两者结合起来也是非常容易的。在 REST 部分,我们将帐户更新表单更改为带有 PUT 请求的表单,但这是使用 POST 的模拟。使用我们使用的 JavaScript 库,可以创建一个真正的 PUT 请求,而不是作为 PUT 请求使用的 POST 请求。
要发出和处理 PUT 请求,必须做两件事:AJAX 必须提交表单作为 PUT 请求,我们需要准备服务器来处理 PUT 请求。POST 和 PUT 请求之间存在一些差异。一个主要的区别是 POST 请求必须有可用的表单数据(规范要求这样),但是 PUT 请求不是这样。Spring 提供了org.springframework.web.filter.FormContentFilter,在这里可以帮到我们。
当检测到内容类型为application/x-www-form-urlencoded的 PUT 请求时,过滤器开始工作。它解析传入请求的主体(委托给org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter),结果是一个可以像普通表单参数一样使用的参数映射。要在 Spring Boot 应用中启用过滤器,必须在应用配置中将spring.mvc.formcontent.filter.enabled属性设置为true(参见清单 7-11 )。
spring.mvc.formcontent.filter.enabled=true
Listing 7-11Enable the FormContentFilter
接下来,我们需要向我们的account.jsp file添加一些 JavaScript。它类似于我们第一次添加到图书搜索页面的脚本,有一个主要区别:我们现在使用 PUT 而不是 GET。参见清单 7-12 中添加在表单之后或页面末尾的 JavaScript。控制器方法(参见清单 7-3 )保持不变,因为它仍然是控制器的一个 PUT 请求。
<script>
$('#accountForm').submit(function(evt){
evt.preventDefault();
formData = $('#accountForm').serialize();
$.ajax({
url: $('#accountForm').action,
type: 'PUT',
data: formData
});
});
</script>
Listing 7-12Account Page PUT AJAX Form Submit
渐进增强
我们应用 AJAX 特性的方式是一种叫做渐进增强的技术。这意味着构建一个简单的 web 页面,然后用 JavaScript 为页面添加动态的丰富行为。
相反的方法也是可能的;这种技术被称为优雅退化,这意味着我们从一个页面开始,包含我们想要的所有行为。根据浏览器提供的功能,我们缩小了所使用的丰富行为。
现在的趋势是使用渐进增强,因为它更容易构建和维护。它还有一个优势,我们可以根据连接到我们应用的设备功能进行增强(iPhone 与安装了 Internet Explorer 9 的 Windows 7 PC 相比,具有不同的功能)。
处理文件上传
RFC 1867 中定义了 HTML 格式的 HTTP 文件上传或基于表单的文件上传。 11 在向表单添加一个带有类型文件的 HTML 输入字段并将编码设置为multipart/form-data后,浏览器可以将文本和/或二进制文件作为 POST 请求的一部分发送到服务器。
为了处理文件上传,我们首先需要注册org.springframework.web.multipart.MultipartResolver。开箱即用,Spring 提供了两种处理文件上传的方式。第一个是 Servlet API 规范中描述的多部分支持,第二个是通过使用 Apache 的 Commons file upload12项目提供的特性。
Spring 框架提供了两种实现。
-
org.springframework.web.multipart.support.StandardServletMultipartResolver -
org.springframework.web.multipart.commons.CommonsMultipartResolver
第一个实现可以在 Servlet 上启用了 multipart 的 Servlet API 环境中使用,第二个实现使用 Commons FileUpload 库。
对于文件上传的实际处理,我们需要修改控制器。这些修改大多独立于所使用的文件上传技术。Spring 提供了几个抽象来处理文件上传。
-
我们可以编写一个请求处理方法,采用类型为
org.springframework.web.multipart.MultipartFile(或Collection<MultipartFile>)的参数,或者我们可以使用org.springframework.web.multipart.MultipartHttpServletRequest并自己检索文件。 -
当我们处于 Servlet API 环境中并使用多部分解析支持时。我们也可以使用
javax.servlet.http.Part接口来获取文件。
文件上传的最后一种方式是用org.springframework.web.bind.annotation.RequestPart标注方法参数(见第四章)。当放上前面描述的任何东西时,Spring 使用类型转换系统来转换文件的内容。
我们首先讨论两种不同策略的配置。之后,我们看看如何在控制器内部处理文件上传。
配置
启用文件上传的第一步是配置我们的环境。由于 Spring 提供了两种现成的不同技术,每种技术都需要一组不同的配置项。我们看一下 Servlet API 多部分支持和 Commons FileUpload。
配置 Servlet API 文件上传
默认情况下,Spring Boot 支持 Servlet API 上传文件的方式,因为这在 Servlet 容器中总是可用的。可以通过在应用配置中将spring.servlet.multipart.enabled设置为true / false来启用或禁用它(参见清单 7-13 )。
spring.servlet.multipart.enabled=true
Listing 7-13Explicitly Enable Multipart File Upload
当使用 Spring vanilla 配置时,org.springframework.web.servlet.DispatcherServlet上的多部分解析的第一步是向 XML 配置添加一个多部分配置部分,或者在我们的org.springframework.web.WebApplicationInitializer实现中包含javax.servlet.MultipartConfigElement。
在 Spring Boot web 应用中,其他属性可以配置最大文件大小、请求大小等等(参见表 7-4 )。
表 7-4
文件上传的 Spring Boot 属性
|财产
|
描述
|
默认
|
| --- | --- | --- |
| spring.servlet.multipart.enabled | 启用或禁用文件上传 | true |
| spring.servlet.multipart.location | 上传文件的临时位置,如果未指定,将使用临时目录。 | |
| spring.servlet.multipart.max-file-size | 要上传的文件的最大大小 | 1 兆字节 |
| spring.servlet.multipart.max-request-size | 当请求包含多个文件时,上载请求的最大大小 | 10 兆 |
| spring.servlet.multipart.file-size-threshold | 在写入磁盘之前,有多少文件保留在内存中 | 0 字节 |
| spring.servlet.multipart.resolve-lazily | 文件应该立即解析/解析还是延迟到作为参数访问时再解析/解析 | false |
配置 Apache Commons 文件上传
要在 Spring Boot 使用 Commons FileUpload 支持,需要注册CommonsMultipartResolver来启用文件上传(参见清单 7-14 )。像spring.servlet.multipart.location,这样的配置中使用的参数不会自动应用于 Commons FileUpload 配置——尽管我们可以重用配置属性来进行手动配置!
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.web.multipart.MultipartResolver;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
// Other imports omitted
@Configuration
public class WebMvcContextConfiguration implements WebMvcConfigurer {
@Bean
public MultipartResolver multipartResolver(MultipartProperties multipartProperties) {
CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver();
multipartResolver.setMaxUploadSize(multipartProperties.getMaxFileSize().toBytes());
return multipartResolver;
}
}
Listing 7-14Configuration with CommonsMultipartResolver
文件上传的请求处理方法
除了配置上传,我们还需要一个表单页面来提交文件。为此,我们需要创建一个编码设置为multipart/form-data的表单(参见清单 7-15 )。
如果我们改变可用的不同技术,这种形式不会改变;只有处理上传的方式发生了变化。当使用 file 类型添加 input 元素时,给它一个名称是很重要的,特别是当我们进行单个文件上传时。从请求中检索文件也需要这个名称。
<form id="orderForm"
action="<c:url value="/order/upload"/>"
method="POST"
enctype="multipart/form-data">
<fieldset>
<legend>Upload order</legend>
<input type="file" placeholder="Select File"
id="order" name="order"/>
<button id="upload"><spring:message code="button.upload"/></button>
</fieldset>
</form>
Listing 7-15Upload Order Form for Account Page
我们将这个表单添加到已经存在的表单之后的account.jsp文件中。当我们现在渲染账户页面时,它看起来如图 7-1 。

图 7-1
带有文件上传的帐户页面
在下面的章节中,我们将探讨在控制器中处理文件上传的不同方式。大多数方法可以在两种不同的文件上传技术之间移植;然而,最后一个只有在使用 Servlet API 多部分支持时才可用。上传文件时,每种不同的请求处理方法都有相同的输出;它打印上传文件的名称和文件的大小,如图 7-2 所示。

图 7-2
样本文件上传输出
用多部分文件编写请求处理方法
当编写请求处理方法时,如果我们想上传文件并使用 Spring 的多部分文件抽象,我们需要创建一个方法,对其进行注释,并确保它将MultipartFile作为方法参数。当上传了多个同名文件时,我们也可以接收一个Collection<MultipartFile>的文件,而不是一个元素。清单 7-16 展示了一个控制器,它有一个可以使用这种技术处理文件上传的方法。
package com.apress.prospringmvc.bookstore.web.controller;
// Other imports omitted
import org.springframework.web.multipart.MultipartFile;
@Controller
public class UploadOrderController {
private Logger logger =
LoggerFactory.getLogger(UploadOrderController.class);
@PostMapping(path = "/order/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String handleUpload(final MultipartFile order) {
logFile(order.getOriginalFilename(), order.getSize());
return "redirect:/customer/account";
}
private void logFile(String name, long size) {
this.logger.info("Received order: {}, size {}", name, size);
}
}
Listing 7-16UploadOrderController with MultipartFile
使用 MultipartHttpServletRequest 处理文件上载
除了直接访问文件,也可以使用MultipartHttpServletRequest来访问多部分文件(见清单 7-17 )。访问多部分文件的方法在org.springframework.web.multipart.MultipartRequest超级接口中定义。
package com.apress.prospringmvc.bookstore.web.controller;
// Other imports omitted
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
@Controller
public class UploadOrderController {
private Logger logger =
LoggerFactory.getLogger(UploadOrderController.class);
@PostMapping("/order/upload")
public String handleUpload(final MultipartHttpServletRequest request) {
Map<String, MultipartFile> files = request.getFileMap();
for (MultipartFile file : files.values()) {
logFile(file.getOriginalFilename(), file.getSize());
}
return "redirect:/customer/account";
}
private void logFile(String name, long size) {
this.logger.info("Received order: {}, size {}", name, size);
}
}
Listing 7-17UploadOrderController with MultipartHttpServletRequest
使用表单对象处理上传
除了直接处理上传,我们还可以让它成为表单对象的一部分(模型属性)。如果上传是包含更多字段的表单的一部分,这可能会很方便(如我们的客户帐户页面,包括一张图片)。为此,我们需要创建一个可以用作表单对象的类,它的属性类型为MultipartFile(参见清单 7-18 )。
package com.apress.prospringmvc.bookstore.web;
import org.springframework.web.multipart.MultipartFile;
public class UploadOrderForm {
private MultipartFile order;
public MultipartFile getOrder() {
return this.order;
}
public void setOrder(MultipartFile order) {
this.order = order;
}
}
Listing 7-18UploadOrderForm Class
我们需要修改控制器,将表单作为方法参数(参见清单 7-19 )。
package com.apress.prospringmvc.bookstore.web.controller;
// Other imports omitted
import com.apress.prospringmvc.bookstore.web.UploadOrderForm;
@Controller
public class UploadOrderController {
private Logger logger =
LoggerFactory.getLogger(UploadOrderController.class);
@PostMapping(path = "/order/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String handleUpload(UploadOrderForm form) {
logFile(form.getOrder().getOriginalFilename(), form.getOrder().getSize());
return "redirect:/customer/account";
}
private void logFile(String name, long size) {
this.logger.info("Received order: {}, size {}", name, size);
}
}
Listing 7-19UploadOrderController with UploadOrderForm Object
使用 Servlet API 编写请求处理方法
在严格的 Servlet API 环境中,我们可以使用标准的javax.servlet.http.Part接口来访问上传的文件。我们简单地创建一个方法,将Part作为一个参数(参见清单 7-20 )。我们需要创建一个方法,注释它,并给它一个方法参数。这种技术只在 Servlet API 环境中有效(所以如果你正在使用 Netty 服务器编写一个反应式应用,这种方法是不可用的),而且无论如何都比使用MultipartFile参数具有可移植性。
package com.apress.prospringmvc.bookstore.web.controller;
// Other imports omitted
import javax.servlet.http.Part;
@Controller
public class UploadOrderController {
private Logger logger =
LoggerFactory.getLogger(UploadOrderController.class);
@PostMapping(path = "/order/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String handleUpload(final Part order) {
logFile(order.getName(), order.getSize());
return "redirect:/customer/account";
}
private void logFile(String name, long size) {
this.logger.info("Received order: {}, size {}", name, size);
}
}
Listing 7-20UploadOrderController with Part
异常处理
上传文件也可能会失败。文件可能太大而无法处理(大于配置的最大文件大小),或者我们的磁盘可能已满。失败的原因有很多。如果可能的话,我们希望处理错误并向用户显示一个漂亮的错误页面。我们可以使用异常处理(如第六章中所解释的)来处理异常并显示一个漂亮的错误页面。当异常发生时,多部分支持抛出 org。springframework.web.multipart.MultipartException,我们可以用这个异常来显示一个错误页面。
摘要
这一章讲述了罗伊·托马斯·菲尔丁解释的表述性状态转移(REST)。您了解了如何配置 Spring MVC 来促进 REST 使用的不同方法。我们讨论了HiddenHttpMethodFilter的配置和该滤波器的使用案例。
接下来,我们简要解释了异步 JavaScript 和 XML (AJAX ),以及我们如何在客户端使用它们并让控制器对这些请求做出响应。虽然 AJAX 最初是关于 XML 的,但现在是关于 JSON 的。我们通过使用@RequestBody和@ResponseBody注释探索了 Spring MVC 提供的 JSON 特性。
本章的最后一部分介绍了如何将文件上传到我们的应用中。为此,我们研究了 Servlet API 多部分支持和 Commons FileUpload 支持所需的配置。然后,我们探索了编写能够处理文件上传的控制器的不同方法。
八、解析和实现视图
到目前为止,我们主要使用 JavaServer Pages (JSP)和 HTML 模板作为我们的视图技术;然而,Spring MVC 提供了一个非常强大和灵活的机制来解析和实现视图。你已经在第四章中简单的看了一下视图解析机制。本章着眼于不同的ViewResolver实现,并展示如何创建和使用我们自己的实现。您可以看到 Spring MVC 开箱即用地支持哪些视图技术。我们创建了一些定制的实现。然而,在我们深入内部之前,让我们回顾一下视图呈现过程和 API。
视图解析器和视图
第四章讨论了 dispatcher servlet 的请求处理工作流。解析和呈现视图是该过程的一部分。图 8-1 显示了视图渲染过程(参见 4 章节中的“渲染视图”部分)。

图 8-1
查看渲染过程
控制器可以返回一个org.springframework.web.servlet.View实现或者一个视图的引用(视图名)。在后一种情况下,会参考已配置的 ViewResolvers 来将引用转换为具体的实现。当实现可用时,它被指示呈现;否则,抛出javax.servlet.ServletException。
ViewResolver(见清单 8-1 )只有一个方法来解析视图。
package org.springframework.web.servlet;
import java.util.Locale;
public interface ViewResolver {
View resolveViewName(String viewName, Locale locale) throws Exception;
}
Listing 8-1ViewResolver API
当一个视图被选中时,dispatcher servlet 调用视图实例上的 render 方法(参见清单 8-2 )。在View实例上调用getContentType()方法来确定内容的类型。该值设置响应的内容类型;它还被org.springframework.web.servlet.view.ContentNegotiatingViewResolver用来确定最佳匹配视图(更多信息见下一节)。
package org.springframework.web.servlet;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface View {
String getContentType();
void render(Map<String, ?> model,
HttpServletRequest request,
HttpServletResponse response) throws Exception;
}
Listing 8-2View API
查看解析器
第四章展示了不同 ViewResolver 实现的层次结构。让我们仔细看看通用的可用实现,它们是如何工作的,以及它们是如何配置的。图 8-2 再次显示了不同的实现。特定于特定视图技术的实现将在本章后面的“视图技术”一节中解释。

图 8-2
视图解析器层次结构
BeanNameViewResolver
默认情况下,org.springframework.web.servlet.view. BeanNameViewResolver实现是最基本的可用和配置。它获取视图的名称,并在org.springframework.context.ApplicationContext中查看是否有同名的视图。如果有,解析器返回它;否则,它返回 null。这个视图解析器在小型应用中很有用;然而,它有一个很大的缺点:每个视图都需要在应用上下文中使用 bean 进行配置。它有一个可以配置的属性,这就是它被调用的顺序(见表 8-1 )。
表 8-1
BeanNameViewResolver属性
财产
|
目的
|
| --- | --- |
| Order | 此视图解析程序在链中的调用顺序。数字越大,在链中的顺序越低。 |
清单 8-3 显示了视图解析器如何服务和解析索引页面的配置。我们还需要添加一个View实例,因为我们使用了一个支持 JSTL 的 JSP,所以我们返回了org.springframework.web.servlet.view.JstlView。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.web.servlet.view.BeanNameViewResolver;
import org.springframework.web.servlet.view.JstlView;
// Other imports omitted
@Configuration
public class ViewConfiguration {
@Bean
public ViewResolver viewResolver() {
BeanNameViewResolver viewResolver = new BeanNameViewResolver();
viewResolver.setOrder(1);
return viewResolver;
}
@Bean
public View index() {
JstlView view = new JstlView();
view.setUrl("/WEB-INF/views/index.jsp");
return view;
}
}
Listing 8-3BeanNameViewResolver Configuration
UrlBasedViewResolver
org.springframework.web.servlet.view. UrlBasedViewResolver期望视图名称直接映射到 URL。它可以通过向视图名称添加前缀和/或后缀来选择性地修改 URL。一般来说,这个类是不同视图技术的基类,比如 JSP 和基于模板的视图技术(参见本章后面的“视图技术”一节)。表 8-2 描述了这种视图解析器的属性。
表 8-2
UrlBasedViewResolver属性
财产
|
目的
|
| --- | --- |
| staticAttributes | 此视图解析程序解析的每个视图中包含的属性。属性及其值通过方法setAttributes(Properties)和setAttributeMap(Map<String,?>)作为Properties或Map<String,?>实例提供。 |
| cacheUnresolved | 是否应该缓存未解析的视图?也就是说,如果一个视图已经解析为 null,是否应该将它放入缓存中?默认值为 true。(继承自AbstractCachingViewResolver。) |
| contentType | 设置内容类型 1 (text/HTML,application/JSON 等。)对于由该视图解析器解析的所有视图,除了那些自己确定或返回内容类型并忽略该属性的视图实现(如 JSP)。 |
| exposePathVariables | 路径变量(见第五章)是否应该添加到模型中?一般来说,视图自己决定;设置此属性可以重写该行为。 |
| Order | 此视图解析程序在链中的调用顺序。数字越大,在链中的顺序越低。 |
| Prefix | 添加到视图名称以生成 URL 的前缀。 |
| redirectContextRelative | 以/开头的重定向 URL 是否应该被解释为相对于 servlet 上下文?默认值为 true。当此属性设置为 false 时,URL 相对于当前 URL 进行解析。 |
| redirectHttp10Compatible | 重定向应该与 HTTP 1.0 兼容吗?当为真时,HTTP 状态代码 302 发出重定向;否则,一个 HTTP 状态代码 303 将被重定向。默认值为true。 |
| requestContextAttribute | 为所有视图设置org.springframework.web.servlet.support.RequestContext属性的名称。默认值为 null,这意味着您没有公开 RequestContext。当使用 useBean 等标准 JSP 标签或 Velocity 等无法访问请求的技术时,公开一个RequestContext会很有用。RequestContext是特定请求状态的上下文持有者。 |
| Suffix | 添加到视图名称以生成 URL 的后缀。 |
| viewClass | 要创建的视图的类型;这需要是org.springframework.web.servlet.view.AbstractUrlBasedView的子类。该属性是必需的。 |
| viewNames | 此视图解析程序可以处理的视图的名称。名称可以包含用于匹配名称的通配符*。默认值为 null,表示解析所有视图。 |
清单 8-4 是这个视图解析器的示例配置。我们需要指定视图类(必需的)。通常,还需要添加前缀和/或后缀来生成指向实际视图实现的 URL。使用UrlBasedViewResolver的优点是,在我们的配置中,我们不需要为每个View实例准备一个 bean。我们依靠UrlBasedViewResolver使用配置的属性和一个符号视图名来创建一个View。
package com.apress.prospringmvc.bookstore.web.config;
// Other imports omitted
import org.springframework.web.servlet.view.JstlView;
import org.springframework.web.servlet.view.UrlBasedViewResolver;
@Configuration
public class ViewConfiguration {
@Bean
public ViewResolver viewResolver() {
UrlBasedViewResolver viewResolver = new UrlBasedViewResolver();
viewResolver.setOrder(1);
viewResolver.setPrefix("/WEB-INF/views/");
viewResolver.setSuffix(".jsp");
viewResolver.setViewClass(JstlView.class);
return viewResolver;
}
}
Listing 8-4UrlBasedViewResolver Configuration
InternalResourceViewResolver
这个对UrlBasedViewResolver的扩展是一个方便的子类,它将视图类预配置为org.springframework.web.servlet.view.InternalResourceView及其子类。清单 8-5 显示了org.springframework.web.servlet.view.?? 的示例配置。结果基本上与清单 8-4 中的相同。
package com.apress.prospringmvc.bookstore.web.config;
// Other imports omitted*
import org.springframework.web.servlet.view.InternalResourceViewResolver;
@Configuration
public class ViewConfiguration {
@Bean
public ViewResolver viewResolver() {
InternalResourceViewResolver viewResolver;
viewResolver = new InternalResourceViewResolver();
viewResolver.setOrder(1);
viewResolver.setPrefix("/WEB-INF/views/");
viewResolver.setSuffix(".jsp");
return viewResolver;
}
}
Listing 8-5InternalResourceViewResolver configuration
XsltViewResolver
org.springframework.web.servlet.web.view.xslt. XsltViewResolver可以将视图名称解析为 XSLT 样式表,从而将模型转换为向用户显示的内容。为了使用这个视图解析器和视图,我们需要一个 XSLT 模板来将模型转换成视图。返回的视图中,org.springframework.web.servlet.view.xslt.XsltView的一个实例检测要渲染哪个模型对象。它支持以下类型。
-
javax.xml.transform.Source -
org.w3c.dom.Document -
org.w3c.dom.Node -
java.io.Reader -
java.io.InputStream -
org.springframework.core.io.Resource
XsltView接受支持的类型并使用 XSLT 样式表转换它。尽管这种机制可能很强大,但我们认为这不是为 web 应用创建视图层的东西。一般来说,从控制器返回 XML(或 JSON)并在客户端用 JavaScript 直接处理更容易。
ContentNegotiatingViewResolver
org.springframework.web.servlet.view. ContentNegotiatingViewResolver是一个非常特殊的视图解析器;它可以通过名称和内容类型来解析视图。它首先确定所请求的内容类型。有三种方法可以做到。
表 8-3
ContentNegotiatingViewResolver 属性
|财产
|
目的
|
| --- | --- |
| contentNegotiationManager | bean 确定请求的媒体类型。 |
| cnmFactoryBean | ContentNegotiationManagerFactoryBean bean 创建一个ContentNegotiationManager实例。 |
| defaultViews | 设置要参考的默认视图。当找不到特定视图时使用。在使用封送视图或返回 JSON 时非常有用。 |
| useNotAcceptableStatusCode | 当找不到合适的视图时,我们是否应该向客户端发送 HTTP 响应代码 406?默认值为 false。 |
| viewResolvers | 要咨询的视图解析器列表。默认情况下,它检测应用上下文中的所有视图解析器。 |
| Order | 此视图解析程序在链中的调用顺序。数字越大,在链中的顺序越低。 |
-
检查文件扩展名。
-
检查
Accept割台。 -
默认勾选一个名为
format的请求参数(参数名称可配置;参见表 8-3 。
在确定了内容类型之后,解析器会咨询所有已配置的视图解析器,以便按名称收集候选视图。最后,它通过检查是否支持所请求的内容类型来选择最佳匹配的视图。表 8-3 显示了视图解析器的可配置属性。
当使用多个视图解析器时,ContentNegotiatingViewResolver必须具有最高的顺序才能正常工作。默认情况下已经设置好了,但是如果你改变了顺序,请记住这一点。
实现自己的 ViewResolver
本节解释了如何实现我们自己的视图解析器。我们创建了一个简单的实现,它从配置视图的映射中解析视图名。
实现自己的观点很容易做到;你创建一个类,让它实现ViewResolver接口(参见清单 8-1 ,并提供必要的实现。清单 8-6 显示我们的com.apress.prospringmvc.bookstore.web.view.SimpleConfigurableViewResolver。
package com.apress.prospringmvc.bookstore.web.view;
// Other imports omitted*
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.ViewResolver;
public class SimpleConfigurableViewResolver implements ViewResolver {
private Map<String, ? extends View> views = new HashMap<>();
@Override
public View resolveViewName(String viewName, Locale locale) {
return this.views.get(viewName);
}
public void setViews(Map<String, ? extends View> views) {
this.views = views;
}
}
Listing 8-6SimpleConfigurableViewResolver
我们将在下一节中使用这个实现来添加 PDF 和 Excel 的视图。
查看技术
Spring MVC 支持许多不同的技术,如果没有支持,您可以通过实现org.springframework.web.servlet.View或扩展所提供的视图类来添加它。本节讨论几种视图技术,并展示 Spring MVC 如何支持它们。对一些人来说,有广泛的支持;对其他人来说,很少。图 8-3 显示了视图类的层次结构,在这里你可以看到一些支持的视图技术。对于某些技术,我们需要指定一个特定的ViewResolver来工作;其他的与配置的视图解析器一起工作。

图 8-3
视图层次结构
本节的下一部分将简要介绍一些受支持的视图技术。它展示了支持类以及如何设置 Spring 来使用指定的技术。它没有深入介绍所有不同的受支持视图技术;这里提到的大部分技术都有其他的书籍。

TilesViewResolver在本节的大多数清单中有 2 阶,以确保它在正确的时刻执行,特别是当使用ContentNegotiatingViewResolver时,它应该在TilesViewResolver之前执行(这是默认的)。
JavaServer 页面
到目前为止,我们的应用一直使用 JavaServer Pages。Spring 对它有很好的支持,包括它自己的标签库(见章节 5 和 6 )。Spring 有支持和集成类,一般来说,它是与org.springframework.web.servlet.view.InternalResourceViewResolver一起使用的工具,以启用 JSTL 支持并与 Sun 的默认格式和函数库集成。
瓷砖
Apache Tiles 该项目现已退休,Spring 框架团队非常喜欢百里叶 3 和小胡子、 4 但 Tiles 在很长一段时间内都是 Spring 的最爱,仍然值得关注。这些页面组件可以在不同的页面布局中重用和配置。最初它被设计成一个 JSP 组合框架;但是,它也可以构成基于 FreeMarker 的视图。
配置图块
要开始使用 Tiles,我们必须为它配置和引导引擎。接下来,我们需要配置视图解析器来返回基于 tiles 的视图,最后,我们需要指定页面组成并添加不同的模板(Tiles)。
我们需要将org.springframework.web.servlet.view.tiles3.TilesConfigurer添加到我们的配置中。接下来,我们需要特殊的 org . spring framework . web . servlet . view . tiles 3 . tilesviewrolver。清单 8-7 显示了图块的最基本配置。
package com.apress.prospringmvc.bookstore.web.config;
*// Other imports omitted*
import org.springframework.web.servlet.view.tiles3.TilesConfigurer;
import org.springframework.web.servlet.view.tiles3.TilesViewResolver;
@Configuration
public class ViewConfiguration {
@Bean
public TilesConfigurer tilesConfigurer() {
return new TilesConfigurer();
}
@Bean
public TilesViewResolver tilesViewResolver() {
TilesViewResolver tilesViewResolver = new TilesViewResolver();
tilesViewResolver.setOrder(2);
return tilesViewResolver;
}
}
Listing 8-7ViewConfiguration for Tiles
默认情况下,TilesConfigurer从WEB-INF目录加载一个名为tiles.xml的文件;该文件包含页面定义。在我们查看定义文件之前,让我们看看表 8-4 中的配置器的属性。
表 8-4
TilesConfigurer属性
财产
|
目的
|
| --- | --- |
| checkRefresh | 我们应该检查图块定义的变化吗?默认为false;将其设置为 true 会影响性能,但在开发过程中会很有用。 |
| completeAutoload | 当设置为 true(默认为false)时,图块的初始化完全由图块本身完成。它使这个配置器类的其他属性变得无用。 |
| definitions | 包含定义的文件列表。默认是指/WEB-INF/tiles.xml。 |
| definitionsFactoryClass | 设置用于创建图块定义的org.apache.tiles.definition.DefinitionsFactory实现。默认情况下使用org.apache.tiles.definition.UrlDefinitionsFactory类。 |
| preparerFactoryClass | 设置要使用的org.apache.tiles.preparer.PreparerFactory实现。默认情况下使用org.apache.tiles.preparer.BasicPreparerFactory类。 |
| tilesInitializer | 设置自定义初始值设定项来初始化图块。当设置自定义实现时,初始化器应该完全初始化 Tiles,因为设置这个属性会使这个类上的其他属性变得无用。 |
| useMutableTilesContainer | 我们应该使用可变瓷砖容器吗?默认为false。 |
| validateDefinitions | 指定我们是否应该验证定义 XML 文件。默认为true。 |
TilesViewResolver 没有要设置的附加属性;它与 UrlBasedViewResolver 具有相同的属性集。它是一个方便的子类,可以自动配置要返回的正确视图类型。对于图块,我们需要创建org.springframework.web.servlet.view.tiles3.TilesView的实例。
配置和创建模板
平铺需要一个或多个文件来定义我们的页面;这些被称为定义文件。TilesConfigurer 加载的默认文件是/WEB-INF/tiles.xml(参见清单 8-8 )。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE tiles-definitions PUBLIC
"-//Apache Software Foundation//DTD Tiles Configuration 3.0//EN"
"http://tiles.apache.org/dtds/tiles-config_3_0.dtd">
<tiles-definitions>
<!-- definition 1 -->
<definition name="template" template="/WEB-INF/templates/template.jsp">
<put-attribute name="header" value="/WEB-INF/templates/header.jsp"/>
<put-attribute name="footer" value="/WEB-INF/templates/footer.jsp"/>
</definition>
<!-- definition 2 -->
<definition name="*" extends="template">
<put-attribute name="title" value="{1}.title" />
<put-attribute name="body" value="/WEB-INF/views/{1}.jsp" />
</definition>
<!-- definition 3 -->
<definition name="*/*" extends="template">
<put-attribute name="title" value="{1}.{2}/title" />
<put-attribute name="body" value="/WEB-INF/views/{1}/{2}.jsp" />
</definition>
</tiles-definitions>
Listing 8-8Tiles Definitions
我们创造了三个定义。
-
名为 template 的定义是总体布局配置。
-
其他定义扩展了这种常规布局(并且可以覆盖预定义的属性)。通过在定义名称中使用通配符(
*)来声明多个定义。{1}占位符指的是星星的值。 -
更多的定义扩展了这种常规布局,但是位于更深层次的目录中。位置层级由
/表示。占位符{1}表示第一颗星的值,{2}表示第二颗星的值。 5
为了让 Spring 选择正确的定义,我们的定义名称必须与视图匹配(或者像我们在示例中那样使用*通配符)。我们的模板页面(template.jsp)由三个瓦片(header、footer和body)组成,我们需要一个包含消息键的属性标题,这样我们就可以使用我们的消息源(参见章节 5 国际化的讨论)来解析实际的标题。清单 8-9 显示的是template.jsp,为总图。
<!DOCTYPE HTML>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ tagib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="tiles" uri="http://tiles.apache.org/tags-tiles" %>
<html>
<head>
<meta charset="utf-8">
<c:set var="titleKey">
<tiles:getAsString name="title" />
</c:set>
<title>
Bookstore | <spring:message code="${titleKey}"
text="Your Home in Books"/>
</title>
<link rel="stylesheet" type="text/css"
href="<c:url value="/resources/css/style.css"/>" >
</head>
<body>
<div id="wrap">
<tiles:insertAttribute name="header"/>
<div class="center_content">
<div class="left_content">
<h1>
<spring:message code="${titleKey}"
text="${titleKey}"/>
</h1>
<tiles:insertAttribute name="body" />
</div><!--end of left content-->
<div class="right_content">
<div class="right_box">
<div class="title">
<span class="title_icon">
<img
src="<c:url value="/resourcimg/bullet4.gif"/>"
alt="" title="" />
</span>
<spring:message code="main.title.randombooks"/>
</div>
<c:forEach items="${randomBooks}" var="book">
<div class="new_prod_box">
<c:url value="/book/${book.id}" var="bookUrl" />
<a href="${bookUrl}">${book.title}</a>
<div class="new_prod_img">
<c:url
value="/book/${book.isbn}/image" var="bookImage"/>
<a href="${bookUrl}">
<img src="${bookImage}" alt="${book.title}"
title="${book.title}" class="thumb"
border="0" width="100px"/>
</a>
</div>
</div>
</c:forEach>
</div><!--end of right box-->
</div><!--end of right content-->
<div class="clear"></div>
</div><!--end of center content-->
<tiles:insertAttribute name="footer" />
</div>
</body>
</html>
Listing 8-9template.jsp content
突出显示的代码根据来自我们的tiles.xml的title属性的内容设置了一个变量。这样,我们可以在 tiles 配置上指定一个键,并使用 Spring 消息标记来检索国际化值。清单 8-10 显示了我们的index.jsp,它被用作欢迎页面的主体。
<p>Welcome to the Book Store</p>
Listing 8-10index.jsp Used as Content
图 8-4 显示了结果页面。

图 8-4
结果欢迎页面
FreeMarker 和百里香叶
FreeMarker 6 和百里香叶都是用 Java 编写的模板框架。您可以使用它们来创建 HTML 页面的模板。它们是基于文本的模板引擎,两者都广泛用于各种模板解决方案的应用中。
Spring 使用的另一个 HTML 模板框架叫做 Velocity,但是它不再被支持了。Velocity 包在 Spring 4.3 中被弃用,并在 5.0.1 7 中被完全移除,以支持 FreeMarker。速度是相当古老的;最新版本发布于 2010 年。
FreeMarker 和 Thymeleaf 模板不像 JSP 那样编译成 Java 代码。它们在运行时由它们的模板引擎解释,这很像我们前面讨论的 XSLT 处理。您可能认为这种解释(而不是编译)会导致应用的性能下降,但这通常不是真的。两个引擎都有大量的解释模板缓存,这使得它们很快。
与 JSP 相比,使用模板方法的另一个优点是,在后一种情况下,您可能会倾向于将 Java 代码放在 JSP 中。将 Java 代码放在页面中,尽管是可能的,但不是您应该采用的方法。这通常会导致页面难以维护、调试和修改。
当使用 FreeMarker 和 Thymeleaf 模板时,需要额外的配置来设置正确的模板引擎和视图解析器。首先,我们需要配置我们选择的模板引擎。然后,我们需要为模板引擎配置视图解析。
配置模板引擎
Spring 框架广泛支持 FreeMarker 和 Thymeleaf,并且有一些助手类可以使引擎的配置更加容易。FreeMarker 有org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer(见表 8-5 )。
表 8-5
FreeMarkerConfigurer属性
财产
|
目的
|
| --- | --- |
| configLocation | 包含 FreeMarker 引擎设置的配置文件的位置。 |
| defaultEncoding | 设置 FreeMarker 配置文件的编码。默认情况下使用平台编码。 |
| freemarkerSettings | 直接设置模板引擎的属性。它可以覆盖配置文件中的属性,或者完全配置模板引擎。 |
| freemarkerVariables | 设置已知 FreeMarker 对象的贴图。这些对象作为变量传递给 FreeMarker 配置。 |
| postTemplateLoaders | 指定 freemarker . cache . template loader 来加载模板。它们是在默认模板加载器之后注册的。 |
| preferFileSystemAccess | 我们应该选择文件系统访问来加载 FreeMarker 模板吗?默认值为 true 如果您的模板不在文件系统上,例如,在 jar 文件的类路径上,则将此设置为 false。 |
| preTemplateLoaders | 指定 freemarker . cache . template loader 来加载模板。它们在默认模板加载器之前注册。 |
| templateLoaderPathtemplateLoaderPaths | 设置 FreeMarker 模板的路径。templateLoaderPaths的值可以是逗号分隔的路径列表。它可以混合不同的资源路径(参见第二章中的“资源加载”)。 |
该表中最重要的属性是设置加载模板的位置的属性:templateLoaderPath。最佳实践是让 web 客户端无法访问它们,这可以通过将它们放在WEB-INF目录中来实现。
还有org.springframework.beans.factory.FactoryBean来配置 FreeMarker 模板引擎,引导引擎使用非 web 模板,如电子邮件。
百里香发动机通过org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver和org.thymeleaf.spring5.SpringTemplateEngine两种类型与 Spring 框架轻松集成(见表 8-6 )。需要一个SpringResourceTemplateResolver bean 来支持百里香模板资源。
表 8-6
百里香叶的特性SpringResourceTemplateResolver
财产
|
目的
|
| --- | --- |
| applicationContext | 这个属性需要被设置为 Spring ApplicationContext实例,这样它就可以访问模板资源。 |
| prefix | 添加到所有模板名称的前缀,用于将模板名称转换为资源名称。 |
| suffix | 添加到所有模板名称的后缀,用于将模板名称转换为资源名称。 |
| forceSuffix | 模板上应该强制使用后缀吗?如果设置为true,无论模板名称的扩展名如何,都将应用配置的后缀。默认为false。 |
| templateMode | 应用于百里香解析器解析的模板的模板模式。默认为 HTML。 |
| forceTemplateMode | 是否应该在模板资源上强制使用模板模式?如果设置为true,则解析不在模板资源名称上,而是在配置的suffix上。默认是false。 |
| characterEncoding | 读取资源的字符编码。 |
| cacheable | 百里香解析器解析的模板应该缓存吗?默认值是true,但是在开发过程中,我们建议您将该属性设置为false。 |
| order | 此视图解析程序在链中的调用顺序。数字越大,在链中的顺序越低。默认值为 1。 |
上表中最重要的属性是定义模板资源位置的属性(suffix和prefix),以及必须访问它们的applicationContext。最佳实践是让 web 客户端无法访问它们,这可以通过将它们放在WEB-INF目录中来实现。
还有另一个特定于 Spring 的实现,由实现ServletContextAware的org.thymeleaf.templateresolver.ServletContextTemplateResolver类提供,并且依赖于 servlet 上下文。这个实现使用 Servlet 资源解析机制来解析模板,而SpringResourceTemplateResolver使用 Spring 的资源解析机制来解析模板。这些类大多是可互换的,但是推荐使用SpringResourceTemplateResolver,因为它可以自动与 Spring 的资源解析基础设施集成。
除了设置不同的引擎,我们还需要配置一个视图解析器来解析正确的视图实现。春运用org.springframework.web.servlet.view.freemarker.FreemarkerViewResolver。百里香框架为同样的目的提供了org.thymeleaf.spring5.view.ThymeleafViewResolver。不要求使用这些专门的视图解析器;配置广泛的InternalResourceViewResolver也可以。然而,使用这些专门的视图解析器使我们的生活变得更容易。清单 8-11 显示了一个 FreeMarker 配置示例。
package com.apress.prospringmvc.bookstore.web.config;
// Other imports omitted
import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer;
import org.springframework.web.servlet.view.freemarker.FreeMarkerViewResolver;
@Configuration
public class ViewConfiguration {
@Bean
public FreeMarkerConfigurer freeMarkerConfigurer() {
FreeMarkerConfigurer freeMarkerConfigurer;
freeMarkerConfigurer = new FreeMarkerConfigurer();
freeMarkerConfigurer.setTemplateLoaderPath("WEB-INF/freemarker");
return freeMarkerConfigurer;
}
@Bean
public ViewResolver freeMarkerViewResolver() {
FreeMarkerViewResolver viewResolver = new FreeMarkerViewResolver();
viewResolver.setSuffix(".ftl");
return viewResolver;
}
}
Listing 8-11FreeMarker Configuration
当控制器现在返回index作为视图名称时,对于 FreeMarker 模板,它变成了WEB-INF/freemarker/index.ftl。视图名称前有templateLoaderPath。视图解析器还允许设置额外的前缀(从AbstractTemplateViewResolver继承而来)。表 8-7 描述了视图解析器的不同属性。
表 8-7
FreeMarker 视图解析器的其他属性
|财产
|
目的
|
| --- | --- |
| allowRequestOverride | 当我们合并模型时,请求属性应该覆盖模型属性吗?当设置为 true 时,当以相同的名称存储时,请求属性可以覆盖模型属性。默认值为 false,这将在遇到同名属性时导致异常。 |
| allowSessionOverride | 当我们合并模型时,会话属性应该覆盖模型属性吗?设置为 true 时,会话属性可以覆盖以相同名称存储的模型属性。默认值为 false,这将在遇到同名属性时导致异常。 |
| exposeRequestAttributes | 所有的请求属性都应该放在模型中吗?默认值为 false。 |
| exposeSessionAttributes | 是否应该将所有会话属性都放入模型中?默认值为 false。 |
| exposeSpringMacroHelpers | 是否应该暴露宏(见表 8-8 )以便它们可用于渲染?默认值为 true。 |
清单 8-12 显示了百里香叶配置样本。
package com.apress.prospringmvc.bookstore.web.config;
// Other imports omitted
import org.thymeleaf.spring5.SpringTemplateEngine;
import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
@Configuration
public class ViewConfiguration implements ApplicationContextAware {
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
var resolver = new SpringResourceTemplateResolver();
resolver.setApplicationContext(applicationContext);
resolver.setPrefix("/WEB-INF/thymeleaf/");
resolver.setSuffix(".html");
//HTML is the default value, added here for clarity
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
return resolver;
}
@Bean
@Description("Thymeleaf Template Engine")
public SpringTemplateEngine templateEngine() {
var templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver());
return templateEngine;
}
@Bean
@Description("Thymeleaf View Resolver")
public ThymeleafViewResolver viewResolver() {
var viewResolver = new ThymeleafViewResolver();
viewResolver.setTemplateEngine(templateEngine());
return viewResolver;
}
}
Listing 8-12Thymeleaf Configuration
当控制器现在返回index作为视图名称时,对于百里香模板,它变成了WEB-INF/AbstractTemplateViewResolver/index.html。将prefix值添加到视图名称之前,并将suffix值添加到视图名称之后。
模板语言
既然我们已经配置了环境,我们还需要编写一个显示页面的模板。FreeMarker 和百里香有点相似。列表 8-13 和 8-14 分别显示了 FreeMarker 和百里香的图书搜索页面。
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{template/layout :: head('Search books')}"></head>
<body>
<div id="header" th:replace="~{template/layout :: header}" ></div>
<h1 id="pagetitle" th:text="#{book.searchcriteria}">SEARCH TITLE</h1>
<form action="#" th:action="@{/book/search}" th:object="${bookSearchCriteria}" method="GET" id="bookSearchForm">
<fieldset>
<legend th:text="#{book.searchcriteria}">SEARCH CRITERIA</legend>
<table>
<tr>
<td><label for="title" th:text="#{book.title}">TITLE</label></td>
<td><input type="text" th:field="*{title}"/></td>
</tr>
<tr>
<td><label for="category" th:text="#{book.category}">CATEGORY</label></td>
<td>
<select th:field="*{category}">
<option th:each="c : ${categories}" th:value="${c.id}" th:text="${c.name}" th:selected="${i==1}">
</option>
</select>
</td>
</tr>
</table>
</fieldset>
<button id="search" th:text="#{button.search}">SEARCH</button>
</form>
<!-- Javascript functions ommitted -->
<table id="bookSearchResults" th:if="${bookList ne null and not #lists.isEmpty(bookList)}">
<thead>
<tr>
<th th:text="#{book.title}">TITLE</th>
<th th:text="#{book.description}">DESCRIPTION</th>
<th th:text="#{book.price}">PRICE</th>
<th></th>
</tr>
</thead>
<tbody>
<th:block th:each="book : ${bookList}">
<tr>
<td><a th:href="@{/book/detail/} + ${book.id}" th:text="${book.title}">TITLE</a></td>
<td th:text="${book.description}">DESC</td>
<td th:text="${book.price}">PRICE</td>
<td><a th:href="@{/cart/add/} + ${book.id}" th:text="${book.addtocart}">CART</a></td>
</tr>
</th:block>
</tbody>
</table>
</body>
</html>
Listing 8-14books/search.html Thymeleaf Template
<#ftl>
<#import "/spring.ftl" as spring />
<!DOCTYPE HTML>
<html>
<head>
<title>Booksearch</title>
</head>
<body>
<h1><@spring.message code="book.title" /></h1>
<p>
<form method="POST">
<fieldset>
<legend><@spring.message code="book.searchcriteria" /></legend>
<table>
<tr>
<td><@spring.message code="book.title" /></td>
<td><@spring.formInput"searchCriteria.title" /></td>
</tr>
<tr>
<td><@spring.message code="book.category" /></td>
<td><@spring.formSingleSelect
"searchCriteria.category", categories, "" /></td>
</tr>
</table>
</fieldset>
<button id="search"><@spring.message code="book.search" /></button>
</form>
<!-- Javascript functions ommitted -->
<#if bookList?has_content>
<table>
<tr>
<th><@spring.message code="book.title"/></th>
<th><@spring.message
code="book.description"/></th>
<th><@spring.message code="book.price" /></th>
</tr>
<#list bookList as book>
<tr>
<td>${book.title}</td>
<td>${book.description}</td>
<td>${book.price}</td>
<td><a
href="<@spring.url "/cart/add/${book.id}"/>">
<@spring.message code="book.addtocart"/></a></td>
</tr>
</#list>
</table>
</#if>
</p>
</body>
</html>
Listing 8-13books/search.ftl FreeMarker Template.
FreeMarker 模板类似于 Apache 图块模板。FreeMarker(参见清单 8-13 )模板也有可用的标签库(在绑定到 Spring 的清单中)。这两个库为 JSP 提供了与 Spring Form 标记库相同的支持。
百里香叶不同于 FreeMarker。百里叶是一个现代的服务器端 Java 模板引擎,适用于 web 和独立环境。当 Spring 开始远离 Apache Tiles 时,它转向了百里香,因为它的创建者为 Spring 设计了这个模板框架。
Thymeleaf 支持多种模板:HTML、XML、JavaScript、CSS,甚至纯文本,但是最容易设计和使用的是 HTML 模板。百里香模板对于开发流程来说是优雅而自然的,因为它们是用 HTML 编写的,所以可以在设计阶段使用浏览器进行测试。百里香的最大优点是它可以很容易地与 Spring 控制器、本地化和验证集成。
前一个模板包含几个百里香标签,这些标签很自然地适合 HTML 内容。它们以th:为前缀,由百里香模板引擎解释生成相应的 HTML 页面。
表 8-8 提供了不同 FreeMarker 标签的概述。百里香等效结构只是丰富的 HTML 标签,因此没有必要进行比较。
表 8-8
Tage 可用于 FreeMarker 和百里香等效的 HTML 构造
|巨
|
FreeMarker
|
| --- | --- |
| 消息(根据 code 参数从资源包中输出一个字符串) | |
| messageText (根据 code 参数从资源包中输出一个字符串,返回默认参数的值) | |
| url (用应用的上下文根作为相对 url 的前缀) | |
| formInput (用于收集用户输入的标准输入字段) | |
| **formHiddenInput *** (用于提交非用户输入的隐藏输入字段,例如 CSRF 令牌) | |
| formPasswordInput *(收集密码的标准输入字段) | |
| formTextarea (用于收集长的自由格式文本输入的大文本字段) | |
| formSingleSelect (允许选择单个所需值的下拉框) | |
| formMultiSelect (允许用户选择 0 个或多个值的选项列表框) | |
| formRadioButtons (一组单选按钮,允许从可用选项中进行单项选择) | |
| 表单复选框(一组允许选择 0 个或多个值的复选框) | |
| 表单复选框(单个复选框) | |
| 显示错误 | |
列出的任何宏的参数都有一致的含义。
-
path :要绑定的字段名称(即
searchCriteria.title)。 -
选项:包含可从输入栏中选择的所有可用值的映射。映射的键表示从表单回发并绑定到命令对象的值。属于键的值被用作向用户显示的标签。通常,这种图由控制器作为参考数据提供。根据所需的行为,可以使用任何 Map 实现。
-
分隔符:当多个选项作为离散元素(单选按钮或复选框)可用时,字符序列在列表中分隔每个选项(例如,
<br/>)。 -
属性:包含在 HTML 标签本身中的任意标签或文本的附加字符串。该字符串由宏直接回显。例如,在 textarea 字段中,您可以提供属性作为
rows="5" cols="60",或者您可以传递样式信息,如style="border:1px solid silver"。 -
classOrStyle :对于 showErrors 宏,包装每个错误的 span 标签使用的 CSS 类的名称。如果没有提供任何信息(或者值为空),错误将被包含在
<b></b>标签中。
表中标记(*)的两个宏用于 FreeMarker 然而,它们不是必需的,因为您可以使用普通的formInput宏指定 hidden 或 password 作为fieldType参数的值。
使用 FreeMarker,您可以指定使用哪个库。在 FreeMarker 中,我们需要使用 import 指令来指定库(参见清单 8-13 )。
百里香不使用任何需要在模板中引用的特殊标签库。百里香模板引擎寻找th:结构并动态解析它们。最重要的百里香叶构建体用于以下目的。
-
th:fragment 声明了一个 HTML 元素,它是布局的一部分,可以被子页面继承或覆盖。片段可以接收一个参数。比如
<head th:fragment="head(title)"/>。 -
th:replace 声明了一个 HTML 元素,它替换了一个从布局继承的元素。如果片段被参数化,则需要一个参数。比如
<head th:replace="~{template/layout :: head('Search')}"/>。 -
th:text (对于带有文本值的 HTML 元素)告诉百里香叶引擎用一个动态获得的值替换这个结构的值。编写 HTML 模板时,HTML 元素的默认值通常用大写字母书写。这有助于在浏览器中打开模板,因为它描绘了正确的视图。当描述默认文本而不是动态解析的文本时,它还有助于发现引擎配置问题。
th:text结构中的值要么是模型属性<title th:text="${title}"> TITLE </title>的值,要么是国际化文本<title th:text="#{book.title}"> TITLE </title>的值。 -
th:href 为
<a/>和<link />HTML 元素设置带有上下文 URL 的href属性。元素<link rel="stylesheet" type="text/css" th:href="@{/resources/css/style.css}" >的href属性由一个 URL 填充,该 URL 指向应用上下文中的style.css文件。 -
th:if 决定页面上是否应该显示 HTML 元素或文本。例如,我们可以使用类似于
<li th:if="${session.account ne null}"><a th:href="@{/logout}" th:text="#{nav.logout}">LOGOUT</a></li>的结构,以用户会话中存在一个account实例作为注销选项的条件。
百里叶使用 Spring 表达式语言进行表达式求值,并且有这个库的扩展用于 Spring Security 支持。这种与 Spring 框架的强大集成清楚地表明了为什么这个模板框架是 Spring web 应用的完美之选。
便携文档格式
Spring 可以集成 iText 8 或者 OpenPDF 9 来支持渲染 PDF 视图。Spring 团队推荐 OpenPDF,因为它得到了积极的维护,并修复了不受信任的 PDF 内容的一个重要漏洞。
为了能够呈现 PDF 视图,我们需要编写自己的视图实现,为此,我们需要扩展org.springframework.web.servlet.view.document.AbstractPdfView。当我们扩展这个类时,我们必须实现buildPdfDocument方法。
我们创建了一个 PDF 文件,在我们的帐户页面上概述了我们的一个订单。清单 8-15 显示了视图实现。
package com.apress.prospringmvc.bookstore.web.view;
// Other imports omitted
import org.springframework.web.servlet.view.document.AbstractPdfView;
import com.lowagie.text.Document;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Table;
import com.lowagie.text.pdf.PdfWriter;
public class OrderPdfView extends AbstractPdfView {
@Override
protected void buildPdfDocument(Map<String, Object> model,
Document document,
PdfWriter writer,
HttpServletRequest request,
HttpServletResponse response)
throws Exception {
Order order = (Order) model.get("order");
document.addTitle("Order :" + order.getId());
document.add(new Paragraph("Order date: " + order.getOrderDate()));
document.add(new Paragraph("Delivery date: " + order.getDeliveryDate()));
Table orderDetails = new Table(4);
orderDetails.addCell("Title");
orderDetails.addCell("Price");
orderDetails.addCell("#");
orderDetails.addCell("Total");
for (OrderDetail detail : order.getOrderDetails()) {
orderDetails.addCell(detail.getBook().getTitle());
orderDetails.addCell(detail.getBook().getPrice().toString());
orderDetails.addCell(String.valueOf(detail.getQuantity()));
orderDetails.addCell(detail.getPrice().toString());
}
document.add(orderDetails);
}
}
Listing 8-15View Implementation to Create a PDF
接下来,我们来补充一下org.springframework.web.servlet.view。ContentNegotiatingViewResolver对我们的视图进行配置。我们这样做是为了让我们的订单页面呈现为 HTML 或 PDF 格式,我们不想改变com.apress.prospringmvc.bookstore.web.controller.OrderController,因为它已经在做我们想要做的事情了——将选中的订单添加到模型中。清单 8-16 显示了变更后的com.apress.prospringmvc.bookstore.web.config.ViewConfiguration。这也是我们开始使用自定义视图解析器的地方。
package com.apress.prospringmvc.bookstore.web.config;
// Other imports omitted
import org.springframework.web.servlet.view.ContentNegotiatingViewResolver;
import org.springframework.web.servlet.view.document.AbstractPdfView;
import com.apress.prospringmvc.bookstore.web.view.OrderPdfView;
import com.apress.prospringmvc.bookstore.web.view.SimpleConfigurableViewResolver;
@Configuration
public class ViewConfiguration {
@Bean
public ContentNegotiatingViewResolver contentNegotiatingViewResolver() {
ContentNegotiatingViewResolver viewResolver;
viewResolver = new ContentNegotiatingViewResolver();
List<ViewResolver> viewResolvers = new ArrayList<ViewResolver>();
viewResolvers.add(pdfViewResolver());
viewResolver.setViewResolvers(viewResolvers);
return viewResolver;
}
@Bean
public ViewResolver pdfViewResolver() {
SimpleConfigurableViewResolver viewResolver;
viewResolver = new SimpleConfigurableViewResolver();
Map<String, AbstractPdfView> views;
views = new HashMap<String, AbstractPdfView>();
views.put("order", new OrderPdfView());
viewResolver.setViews(views);
return viewResolver;
}
// Other methods omitted
}
Listing 8-16ViewConfiguration with ContentNegotiatingViewResolver
更改后的配置包含了我们的视图解析器,我们用它来解析com.apress.prospringmvc.bookstore.web.view.OrderPdfView。此配置还允许我们解析 Excel 文档的订单视图(参见“Excel”一节)。
经过这些更改后,我们需要重新部署我们的应用。如果我们登录并导航到我们的帐户页面,我们现在可以单击 PDF 链接并获得 PDF 而不是 HTML 版本。图 8-5 显示了点击 PDF 链接的结果。

图 8-5
生成的 PDF
虽然这种方法非常灵活,但缺点是我们需要为我们想要的每个 PDF 编码 PDF 的构造。如果我们有一些复杂的 PDF 或需要应用某种样式,这是繁琐和难以维护的。在这种情况下,可能值得考虑像 JasperReports 这样的解决方案(参见“JasperReports”一节)。
超过
Spring 有两种呈现 Excel 文档的方式。第一种是使用 JExcel 库, 10 ,另一种是使用 Apache POI 库。 11 这两种方法都需要我们实现一个视图(和 PDF 一样);为此,我们扩展了org.springframework.web.servlet.view.document.AbstractXlsView或org.springframework.web.servlet.view.document.AbstractXlsxView。它们分别适用于 XLS 和 XLSX 格式。两种实现都隐藏了设置,并允许加载和处理 XLS 模板;我们需要添加特定于视图的渲染。我们需要为此实现 buildExcelDocument 方法。清单 8-17 显示了一个使用 Apache POI 的 Excel 文档订单的View实现示例。
package com.apress.prospringmvc.bookstore.web.view;
// Other imports omitted
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.springframework.web.servlet.view.document.AbstractXlsView;
public class OrderExcelView extends AbstractXlsView {
@Override
protected void buildExcelDocument(Map<String, Object> model,
WritableWorkbook workbook,
HttpServletRequest request,
HttpServletResponse response)
throws Exception {
response.setHeader("Content-Disposition",
"attachment; filename=\"order.xls\"");
Order order = (Order) model.get("order");
Sheet sheet = workbook.createSheet();
sheet.createRow(1).createCell(0)
.setCellValue("Order: " + order.getId());
sheet.createRow(2).createCell(0)
.setCellValue("Order Date: " + order.getOrderDate());
sheet.createRow(3).createCell(0)
.setCellValue("Delivery Date: " + order.getDeliveryDate());
sheet.createRow(4).createCell(0)
.setCellValue("Order: " + order.getId());
Row header = sheet.createRow(5);
header.createCell(0).setCellValue("Quantity");
header.createCell(1).setCellValue("Title");
header.createCell(2).setCellValue("Price");
int row = 5;
for (OrderDetail detail : order.getOrderDetails()) {
row++;
Row detailRow = sheet.createRow(row);
detailRow.createCell(0).setCellValue(detail.getQuantity());
detailRow.createCell(1)
.setCellValue(detail.getBook().getTitle());
detailRow.createCell(2).setCellValue(
detail.getPrice().doubleValue() * detail.getQuantity());
}
row++;
Row footer = sheet.createRow(row);
footer.createCell(0).setCellValue("Total");
footer.createCell(1).setCellValue(
order.getTotalOrderPrice().doubleValue());
}
}
Listing 8-17OrderExcelView
在视图旁边,我们需要添加一个视图解析器。在我们的示例应用中,我们将这个(就像 PDF 视图一样)添加到我们的ViewConfiguration类中。我们添加了自定义实现的另一个实例(参见清单 8-18 )并让ContentNegotiatingViewResolver决定做什么。
package com.apress.prospringmvc.bookstore.web.config;
//Other imports omitted
import org.springframework.web.servlet.view.document.AbstractJExcelView;
import org.springframework.web.servlet.view.document.AbstractPdfView;
import com.apress.prospringmvc.bookstore.web.view.OrderExcelView;
@Configuration
public class ViewConfiguration {
@Bean
public ContentNegotiatingViewResolver contentNegotiatingViewResolver() {
ContentNegotiatingViewResolver viewResolver;
viewResolver = new ContentNegotiatingViewResolver();
List<ViewResolver> viewResolvers = new ArrayList<ViewResolver>();
viewResolvers.add(pdfViewResolver());
viewResolvers.add(xlsViewResolver());
viewResolver.setViewResolvers(viewResolvers);
return viewResolver;
}
@Bean
public ViewResolver xlsViewResolver() {
SimpleConfigurableViewResolver viewResolver;
viewResolver = new SimpleConfigurableViewResolver();
Map<String, AbstractJExcelView> views;
views = new HashMap<String, AbstractJExcelView>();
views.put("order", new OrderExcelView());
viewResolver.setViews(views);
return viewResolver;
}
// Other methods omitted
}
Listing 8-18ViewConfiguration with OrderExcelView
但是,等等,难道我们的应用不会因为我们有多个解析为订单视图名称的视图实现而崩溃吗?特殊的视图解析器ContentNegotiatingViewResolver可以在这里帮助我们。它使用Accept头确定哪个解析的视图最匹配请求的内容类型。不需要改变我们的控制器,只需添加一些配置(和视图实现),我们就可以区分哪个视图被服务。
要测试,点击XLS链接,会下载一个 Excel 文档供您查看。
XML 和 JSON
Spring MVC 有另一种方式向我们的客户提供 XML 或 JSON。我们可以利用ContentNegotiatingViewResolver成为我们的优势。Spring 有两个特殊的视图实现来将对象转换成 XML 或 JSON,分别是org.springframework.web.servlet.view.xml.MarshallingView和org.springframework.web.servlet.view.json.MappingJackson2JsonView。基于 XML 的视图使用 Spring XML 支持将我们的模型编组为 XML。JSON 视图使用 Jackson 库。 12 我们可以轻松地配置视图解析器,将 XML 和/或 JSON 公开给客户。我们可以简单地为 XML 和 JSON 添加一个默认视图(我们也可以添加额外的视图解析器,就像我们对 PDF 和 Excel 文档所做的那样)。清单 8-19 是修改后的配置(参见突出显示的部分)。
package com.apress.prospringmvc.bookstore.web.config;
// Other imports omitted
import org.springframework.oxm.Marshaller;
import org.springframework.oxm.xstream.XStreamMarshaller;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
import org.springframework.web.servlet.view.xml.MarshallingView;
import com.apress.prospringmvc.bookstore.web.view.OrderExcelView;
import com.apress.prospringmvc.bookstore.web.view.OrderPdfView;
@Configuration
public class ViewConfiguration {
@Bean
public ContentNegotiatingViewResolver contentNegotiatingViewResolver() {
ContentNegotiatingViewResolver viewResolver;
viewResolver = new ContentNegotiatingViewResolver();
List<ViewResolver> viewResolvers = new ArrayList<ViewResolver>();
viewResolvers.add(pdfViewResolver());
viewResolvers.add(xlsViewResolver());
viewResolver.setViewResolvers(viewResolvers);
List<View> defaultViews = new ArrayList<View>();
defaultViews.add(jsonOrderView());
defaultViews.add(xmlOrderView());
viewResolver.setDefaultViews(defaultViews);
return viewResolver;
}
@Bean
public MappingJackson2JsonView jsonOrderView() {
MappingJackson2JsonView jsonView = new MappingJackson2JsonView();
jsonView.setModelKey("order");
return jsonView;
}
@Bean
public MarshallingView xmlOrderView() {
MarshallingView xmlView = new MarshallingView(marshaller());
xmlView.setModelKey("order");
return xmlView;
}
@Bean
public Marshaller marshaller() {
return new XStreamMarshaller();
}
// Other methods omitted, see previous listings
}
Listing 8-19ViewConfiguration for XML and JSON
为了让 XML 工作,我们还需要配置一个org.springframework.oxm.Marshaller实现。我们在这里选择使用 XStream 13 库是因为使用起来快捷方便。要使用另一种解决方案,只需配置适当的封送拆收器。关于编组和 XML 的更多信息可以在 Spring 参考指南中找到。将modelKey属性设置为要封送的对象。如果未指定,将在模型映射中搜索受支持的值类型。
在 ORM 实现中使用这种类型的视图时(就像在我们的例子中),由于集合正在初始化,您可能会遇到延迟加载或加载一半数据库的情况。
如果我们现在将浏览器中的 URL 改为。json 或者。xml,我们得到订单的 JSON 或 XML 表示(见图 8-6 是 JSON 示例)。现在,我们有五种不同的方式来查看订单(HTML、PDF、Excel、JSON 和 XML ),无需触摸控制器,只需更改配置即可。

图 8-6
JSON 表示我们的order
摘要
本章讲述了 Spring MVC 的视图部分。我们通过介绍几种通用的 ViewResolver 实现来研究视图解析。我们还介绍了 Spring MVC 支持的几种视图技术,并解释了如何配置 Spring 来使用它们。我们从 JSP 开始,我们简要介绍了 JSF 以及如何将 Spring Integration 到 JSF 应用中。接下来,您看到了几种模板解决方案;具体来说,瓷砖,速度和自由标记。
在基于 web 的视图之后,我们研究了不同的视图技术,比如如何在不改变控制器的情况下创建 PDF 和 Excel,只是简单地添加了ContentNegotiatingViewResolver和一个适当的视图实现。
前一章介绍了 JSON,这一章介绍了另一种将模型公开为 JSON 或 XML 的方法。最后,我们看了呈现 PDF 和 Excel 视图。
从这一章学到的一件重要的事情是控制器逻辑和视图逻辑的分离(通过我们的顺序的不同表示来证明)。这显示了应用关注点分离的力量以及它给人的灵活性。
您可能永远不会在一个应用中使用所有的技术。您可能只使用两到三种不同的技术(对于我们的页面,可能会创建一个 PDF 或 Excel 文件)。但是,能够灵活地更改或简单地向我们的应用添加一个新的视图层也不错。
九、Spring WebFlux 简介
在前面的章节中,典型的 Java web 应用是在 Apache Tomcat 服务器的一个实例上构建和运行的,该实例在应用的外部或嵌入在应用中。不管是哪种情况,Spring DispatcherServlet负责将传入的 HTTP 请求定向到应用中声明的所有处理程序。但是,像我们到目前为止开发的应用能在真实的生产环境中使用吗?DispatcherServlet可以同时处理多少 HTTP 请求?这个数字还能增加吗?它们会在可接受的时间框架内得到处理吗?在与世界共享 web 应用之前,需要回答这些问题以及许多其他问题。
生产 web 应用需要处理大量的用户和大量的数据,并且在面对更多的信息进入、系统中的错误或者仅仅是系统中的速度变慢时,能够保持弹性。想想 Twitter、脸书或 YouTube,以及一天中任何时候有多少内容被上传或下载。当打开你的脸书页面时,你希望它能有响应,即使有数百万其他用户登录并和你做同样的事情:阅读消息,或发布消息,视频,图片,或玩游戏。这相当于同时处理数量惊人的请求。这些请求可能需要来自数据库或文件的数据,或者来自其他服务的数据,这引入了阻塞 I/O 操作的可能性。
如果这些应用是使用 Spring 开发的,DispatcherServlet是所有请求的入口点。DispatcherServlet在它能处理的请求数量上没有发言权。servlet 容器定义了这一点;在我们的例子中,是 Apache Tomcat 服务器。
接下来的几章将重点介绍如何使用 Spring WebFlux 来构建在 Netty、Undertow 和 Servlet 3.1+容器等非阻塞服务器上运行的反应式 web 应用。为了理解为什么反应式应用最适合处理大量的用户和数据,有必要解释一下自从互联网出现以来 HTTP 请求是如何被处理的。
HTTP 请求处理
HTTP 是超文本传输协议的首字母缩写,使用超文本链接加载网页。一个典型的 HTTP 流包括一台客户机向服务器发出一个请求,然后服务器发送一个响应消息。在 HTTP (1.0)的初始版本中,每个请求/响应对都需要一个连接。您可以看到为什么这是低效的,因为建立新的连接包括 TCP 握手过程,以将通信方相互介绍。?? 1
在 HTTP 1.1 中,引入了持久 HTTP 连接。 2 这意味着一个连接保持活动状态,并被多个 HTTP 请求重用,从而减少双方之间的通信延迟。
这对 Apache Tomcat 这样的 servlet 容器意味着什么?Apache Tomcat 是构建和维护基于 Java 软件平台的动态网站和应用的流行选择。Java Servlet API 使 web 服务器能够使用 HTTP 协议处理动态的基于 Java 的 web 内容。旧版本的 Tomcat 有一个阻塞的 HTTP 连接器,遵循的是每个连接一个线程的模型。这意味着它为正在处理的每个 HTTP 连接分配一个 Java 线程。因此,可以同时连接到应用的用户数量受到应用服务器支持的线程数量的限制。当建立 HTTP 连接时不创建线程,在 HTTP 连接关闭后销毁线程,因为这样效率很低。相反,服务器管理一个为 HTTP 连接提供线程的线程池。当一个 HTTP 连接建立后,一个线程从池中被分配给它。线程完成接收请求和提供响应的工作,当 HTTP 连接关闭时,线程被回收到池中,并准备好分配给另一个请求。
这种方法的问题是,只要 HTTP 连接是打开的,线程在不使用时就处于空闲状态。如果一个或多个用户在请求之间花费时间,或者忘记关闭连接,最终,服务器会耗尽线程并停止接受新的连接。一种解决方案是增加池中线程的数量;但是,线程池的大小受到特征(内存、CPU 等)的限制。)安装服务器的虚拟机/计算机。显而易见的解决方案是获得更强大的服务器。在软件开发中,这被称为垂直扩展,它在一定程度上起作用,受到现有硬件及其成本的限制。明智的解决方案是将应用安装在多台服务器上。如今,通过将您的应用部署到像 AWS 3 或 GCP 4 这样的云平台,并建立一个包括自动负载平衡器的云配置,可以在必要时跨越虚拟机,就可以轻松做到这一点。这种方法被称为水平缩放并且很有效,但是它也会变得非常昂贵,尤其是当你的应用变得流行的时候。
Tomcat 的新版本(在 Java 4 之后)和其他流行的 web 服务器都使用每个请求一个线程的模型,这意味着一个持久的 HTTP 连接不需要一直分配一个线程给它。这在 Java 4 之前是不可能的,因为在 JDK 中没有非阻塞 IO API。只有在处理请求时,才能将线程分配给 HTTP 连接。如果连接是空闲的,线程可以被回收,连接被放在一个集中的 NIO 5 select set 中检测新的请求,而不消耗单独的线程。这意味着处理相同数量的用户所需的线程数量更少,而且由于线程使用的资源少得多,扩展应用所需的财务投资也更少。
有几件事需要考虑。对于同时连接的大量用户,资源消耗仍然很高。对于短期请求,它具有与每连接线程模型相同的行为和性能。当在处理每个请求的过程中有长时间的暂停时,线程仍然保持空闲。唯一的优点是每请求线程模式比每连接线程模式的伸缩性稍好。
并发处理 HTTP 请求的应用还必须设计为共享资源,对于不能共享的资源,必须实现同步访问,这可能会导致阻塞。我们如何在 Java 应用中避免阻塞?通过使用异步处理。Servlet API 3.0 引入了异步 Servlet 处理支持,因此慢速任务(例如,等待一些资源变得可用)可以在一个单独的线程中处理,而不会阻塞服务器管理的线程池,并且当完成时,通知容器分配一个新的容器管理的线程,以将结果发送回客户端。
这些是针对典型 web 问题的典型解决方案,但是它们仍然需要在将响应发送给客户端之前完整地构建响应。以搜索引擎 web 应用为例。您将如何实现解决方案来响应搜索查询?考虑到互联网上可用内容的数量,让您的客户端等待直到您扫描完所有索引内容将会花费很长时间,并且响应的大小如此之大,以至于需要很长时间来传输。向你的客户发送完整的回复是不可行的。对于这种情况,流式方法更合适。您扫描一些索引资源,然后向客户端发送一个部分响应,然后扫描更多的资源,发送另一个资源,以此类推,直到没有发现其他内容。另一个可能出现的问题是,如果您发送部分结果,但您将它们发送到 fast,而客户端无法处理它们,您就有阻塞客户端的风险。因此,您的解决方案需要提供一个允许客户端调节流量的数据流。
如果软件的例子对你来说太令人费解,想象一下下面的场景。你有一个叫吉姆的朋友。你还有一桶不同颜色的球。吉姆告诉你把所有的红球都给他。你有两种方法做这件事。
-
你把所有的红球都捡起来,放到另一个桶里,然后把桶递给吉姆。这是典型的请求-完成响应模型。这是一个异步模型,如果选择红球花费的时间太长,Jim 会在你进行分类时做其他事情,当你完成时,你会通知他一桶红球准备好了。它是异步的,因为 Jim 没有被你分类球所阻挡;他做其他事情,直到他们准备好。
-
你从你的桶里一个接一个地拿出红色的球,扔向吉姆。这是你的数据流,或者说是球流。如果你找到它们并扔出去的速度比吉姆接住它们的速度快,你就有障碍。吉姆告诉你慢下来;他在控制球的流动。
这将软件转化为反应式应用。
构建反应式应用
在处理大量数据时,反应式应用是解决方案。反应式应用是以弹性、响应性和可伸缩性为优先考虑的应用。反应宣言 6 描述了反应应用的特点。Reactive Streams API 规范 7 提供了应用组件应该实现的最小接口集,因此应用可以被认为是反应式的。因此,Reactive Streams API 是一个互操作性规范,它确保了反应组件的无缝集成,并保持操作的非阻塞和异步。
描述反应式应用有四个关键术语。
-
响应迅速:以快速一致的响应时间为特点。
-
弹性:在故障期间保持响应并能够恢复。
-
弹性:在高负荷时保持反应灵敏。
-
消息驱动:通信是异步的,应用反压力是为了防止消息的生产者压倒消费者。
反应式应用应该更加灵活、松散耦合和可伸缩,但同时更容易开发、更易于改变和更能容忍失败。构建反应式应用需要遵循反应式编程范式的原则。
反应式编程简介
反应式编程是用异步数据流编程。Reactive Streams 是一个为非阻塞反压异步流处理提供标准的倡议。它们对于解决需要跨线程边界复杂协调的问题非常有用。操作符允许您将数据收集到所需的线程上,并确保线程安全操作,在大多数情况下,不需要过度使用synchronized和 volatile constructs。
在版本 8 中引入 Streams API 之后,Java 向反应式编程迈进了一步,但是反应式流直到版本 9 才可用。在需要高效处理大量数据的世界里,反应式编程变得流行起来。当 Oracle 致力于在 JDK 中实现反应流和模块时,像 RxJava、 8 Akka、 9 和 Project Reactor10这样的项目似乎为 Java 世界中缺少的反应流 API 实现提供了替代方案。
无法等待延迟六个月发布的 JDK 9,Pivotal 开源团队, 11 同一个创建 Spring 的团队,使用 Project Reactor 构建 Spring WebFlux 12 ,他们自己的反应库。Spring WebFlux 是一个框架,旨在构建反应式的、非阻塞的、基于服务器的 web 应用,这些应用需要少量的线程来解决大量的请求。
然而,在全面深入 Spring WebFlux 之前,您需要了解一下 Java 中使用反应流编程的一些皮毛。
用流编程
从命令式编程转换到反应式编程的第一步是将您的思维从变量转换到流。
假设你需要计算两个数的和。在命令式编程中,您声明这两个变量,然后执行添加它们的语句。程序一个接一个地执行语句。如果这两个变量在计算总和后被修改,保存总和的变量不会被修改。
当使用 streams 时,要添加的两个值作为一个流提供给你。您声明要对发出的值执行的操作——在本例中是加法操作。流发出的任何额外值都会影响结果。
流是按时间顺序排列的一系列事件。这些事件可以是下列事件之一:
-
发射值是要消耗的某种类型的值。
-
错误是意外的无效值,其处理方式与值不同。
-
一个完成信号是一个通知,表示不再发送更多的值。
大多数情况下,后两个可以省略,开发人员只编写函数来处理发出的值。应该处理由流发出的值或在值发出时执行某些操作的函数必须订阅该流。然后他们倾听溪流的声音。他们正在观察 13 流并等待消耗发出的值。
使用术语函数而不是方法可能看起来很奇怪,但是当涉及到反应式编程时,你很少能缺一不可。函数式编程是基于纯函数的。纯函数是不改变输入的函数,总是返回一个值,返回值完全基于输入(它们没有副作用)。因为它们的动作是原子性的,所以可以很容易地组合函数。Lambda 表达式也适用于这种类型的代码编写。因此,功能反应式编程是一种编程范例,它通过将纯功能应用于反应式流来提供解决方案。 14
总之,回到溪流。流有一个源,一个提供要发出的值的实体。您也可以将流视为从源到目的地的移动过程中的数据。这个源可以是任何东西:变量、用户输入、属性、数据结构(比如集合),甚至是另一个流。流发出值,直到源耗尽或发生错误。
订阅流的函数使用值并将结果作为新流的一部分返回。这些类型的功能有时被称为变压器、处理器或运算符。它们不修改初始流,因为流是不可变的。结束一个链的功能被称为终端操作员或最终变压器。处理多个流发射值并计算单个值的函数称为归约器,将流发射值累加到集合中的函数称为收集器。
纯函数链有时被称为管道。
在图 9-1 中,你可以看到一个糟糕的绘图,它用一桶彩球和一个偶然出现的代表错误信号的苹果描绘了流处理。

图 9-1
使用一桶球的流处理表示
这种表示并不完全准确,因为将球涂成红色应该会创建一个新的红色球,但它足够接近流如何工作以及应该如何使用的想法。
如果有帮助的话,想象一下使用流编写代码类似于设计一台 Rube Goldberg 机器。 十五
在引入 streams 之前,所有开发人员都必须管理数据集,即集合。让我们做一个简单的任务:给定一组不同颜色和不同大小的球,选择所有直径大于 3 的蓝色球,将它们涂成红色,并计算它们直径的总和。在 Java 8 之前,代码看起来类似于清单 9-1 中的片段。
List<Ball> bucket = List.of(
new Ball("BLUE",9),
new Ball("RED", 4),
// other instances omitted
);
Integer sum = 0;
for(Ball ball : bucket) {
if (ball.getColor().equals("BLUE") && ball.getDiameter() >= 3) {
ball.setColor("RED");
sum += ball.getDiameter();
}
}
System.out.println("Diameter sum is " + sum);
Listing 9-1Filtering Balls and Adding Their Diameters Before Java 8
这是命令式代码的描述。它使用一系列要执行的语句来改变球列表的状态。它描述了程序应该如何完成状态改变。
从 Java 8 开始,使用 streams 可以编写清单 9-2 中描述的相同代码。
import java.util.function.Function;
import java.util.function.Predicate;
// other imports and code omitted
Predicate<Ball> predicate = ball -> ball.getColor().equals("BLUE")
&& ball.getDiameter() >= 3;
Function<Ball, Ball> redBall =
ball -> new Ball("RED", ball.getDiameter());
Function<Ball, Integer> quantifier = Ball::getDiameter;
int sum = bucket.stream()
.filter(predicate)
.map(redBall)
.map(quantifier)
.reduce(0, Integer::sum);
System.out.println("Diameter sum is " + sum);
Listing 9-2Filtering Balls and Adding Their Diameters Starting with Java 8
清单 9-2 中的管道由以下方法调用组成。
stream()方法返回一个使用初始集合作为源的java.util.stream.Stream<Ball>实例。
*** filter(Predicate<T>) 方法使用作为参数提供的谓词来过滤流,并返回包含与谓词匹配的元素的流。谓词由测试球的组合布尔条件组成。
* `map(Function<T, R>)` **方法**是一个转换函数,它接受流的元素,应用作为参数提供的函数,并将结果作为流返回。
* `reduce(T, BinaryOperator<T>)` **方法**是一个累加器函数。它有两个参数:一个初始值和一个`java.util.function.BinaryOperator<T>`实例,声明在两个相同类型的操作数之间执行的操作,产生相同类型的结果。在前面的清单中,使用的函数是声明为方法引用的整数的典型求和函数。(查 Java 方法引用;如果你以前没用过,它们很酷。)**
**清单 9-2 中的代码以三个需要解释的语句开始。
-
predicate实例是Predicate<T>功能接口的内联实现。该接口公开了一个名为test(..)的抽象方法,该方法必须用谓词实现,以便根据提供的参数进行评估。 -
redBall实例是Function<T, R>功能接口的内联实现。该接口公开了一个名为apply(..)的抽象方法,必须用代码实现该方法才能应用于所提供的参数。在本例中,我们正在创建一个新的Ball实例。 -
quantifier实例是对来自Ball实例的名为getDiameter()的方法的引用。在 Java 中,它们被称为方法引用,因为这听起来很酷。
引入这三个实例是为了具体化应该如何修改流。对于声明式编程方法来说,它们是必需的。 16 通过在管道外声明它们,结果是一个代码片段,声明需要实现什么。如何成为一个参数,改变那些引用所指向的不会影响什么。您可以将代码管道想象成一条装配线,操作员就是工作站。改变工作站内部的东西(谓词或函数)不应该影响管道设置。
Java 8 中引入的流 API 提供了许多用于流处理的实用方法,而反应式流 API 甚至进一步扩展了该集合。根据您试图解决的问题,lambda 表达式和流的组合可能会产生可读性更好、更优雅的解决方案。
既然您已经知道了如何使用流编写代码,那么您已经准备好了锦上添花:反应式流。
反应流
反应式流为 Java 中的反应式编程提供了一个通用的 API。它由四个简单的接口组成,这四个接口为具有非阻塞背压的异步流处理提供了标准。如果您想编写一个可以与其他反应式组件集成的组件,您需要实现其中的一个。
在抽象层次上,组件以及它们之间的关系,如反应流规范中所描述的,看起来如图 9-2 所示。

图 9-2
反应流规范抽象表示
现在,详细的解释。
-
一个发布者是一个潜在的无限的数据生产者。在 Java 中,数据生产者必须实现
org.reactivestreams.Publisher<T>。 -
一个订阅者向发布者注册来消费数据。在 Java 中,数据消费者必须实现
org.reactivestreams.Subscriber<T>。 -
在订阅时,会创建一个订阅对象来表示发布者和订阅者之间的一对一关系。该对象用于向发布者请求数据,也用于取消对数据的需求。在 Java 中,订阅类必须实现
org.reactivestreams.Subscription。 -
发布者根据订阅者的要求发出值。
-
一个处理器是一个特殊的组件,具有与发布者和订阅者相同的属性。在 Java 中,数据处理器必须实现
org.reactivestreams.Processor<T,R>。处理器可以被链接以形成流处理流水线。 -
处理器从链中它前面的发布者/处理器消费数据,并发出数据供链中它后面的处理器/订户消费。
-
如果生产者/处理器不能足够快地消费数据,则订户/处理器在发送数据时施加背压以减慢生产者/处理器的速度。
这是反应流如何工作的基本思想。在基本的情况下,有一个发布者、一个订阅者和一个它们所响应的事件流。在更复杂的情况下,会涉及到处理器。
您可以在 IDE 或 GitHub 上查看代码。 17
JVM 的大多数反应式实现都是并行开发的。不同的开发团队为他们的 Reactive Streams 接口的实现选择了不同的名称。这就是为什么最初引入 streams 时,每个组件都有不止一个名称。
当 JDK 采用反应流规范时,决定将所有接口从org.reactivestreams包复制到java.util.concurrent.Flow类中。大多数库现在也支持适配器与 JDK 集成。表 9-1 显示了最常用的 Java 反应库中反应流实现的名称。
表 9-1
反应流实现名称
|反应流 API
|
RxJava
|
项目反应器
|
阿卡
|
JDK*
|
| --- | --- | --- | --- | --- |
| 出版者 | 可观察的,单一 | 流,猴子 | 来源 | 流动。出版商* |
| 订户 | 观察者 | 核心订阅服务器 | 水槽 | 流动。订户* |
| 处理器 | 科目 | 通量处理器、单处理器 | 流动 | 流动。处理器* |
| 签署 | - | - | - | - |
- JDK 反应流规范组件在表 9-1 中用*标记,因为它们不扩展反应流 API,而是复制流类中的所有组件。
用反应式流编写的代码看起来与用非反应式流编写的代码非常相似,但是幕后发生的事情是不同的。反应流是异步的,但是您不必编写处理它的逻辑。您需要声明当某个值在流上发出时必须发生什么。当流异步发出一个项目时,您正在编写的代码被调用,独立于主程序流。如果涉及到多个处理器,每个处理器都在自己的线程上执行。
**由于您的代码是异步运行的,所以您必须小心使用作为参数提供给转换器方法的函数。确保它们是纯函数,它们应该只通过它们的参数和返回值与程序交互,并且它们永远不应该修改需要同步的对象。
现在让我们看看 Project Reactor 是如何实现 Reactive Streams 规范的,以及它的类是如何编写 Reactive 代码的。
使用 Project Reactor
Project Reactor 是反应式编程的第一批库之一。它为反应式应用提供了一个无阻塞的稳定基础,并提供了高效的需求管理。它提供了一些类,使得使用反应流设计代码变得非常实用。它适用于 Java 8,但为所有 JDK9+版本提供了适配器类。Project Reactor 适合编写微服务应用,并提供了比 JDK 更多的类,旨在使反应式应用的开发更加实用。
Project Reactor 提供了两个主要的 publisher 实现。
-
reactor.core.publisher.Mono<T>是表示零个或一个元素的反应流发布器。 -
reactor.core.publisher.Flux<T>是一个反应式流发布器,表示从零到无穷大元素的异步序列。
Mono<T>和Flux<T>类似于java.util.concurrent.Future<V>。它们表示异步计算的结果。它们之间的区别在于,当您试图用get()方法获得结果时,Future<V>会阻塞当前线程,直到计算完成。Mono<T>和Flux<T>都提供了一系列block*()方法,用于检索不阻塞当前线程的异步计算的值。
在到达之前,我们先了解一下Flux<T>和Mono<T>。
图 9-3 描述了Flux<T>和Mono<T>的类层次结构,包括来自反应流规范包的根父类。

图 9-3
项目反应器堆芯组件的等级体系
CorePublisher<T>接口声明了一个要实现的方法,它自己版本的subscribe(..)方法需要一个CoreSubscriber<T>类型的参数。
CoreSubscriber<T>接口声明了一个名为currentContext()的默认方法,用于访问包含与下游或终端操作者共享的信息的反应上下文。它还声明了一个抽象方法onSubscribe(Subscription),实现者需要为其提供一个实现,以便在发出值之前初始化流状态。
Flux<T>和Mono<T>都继承了Publisher<T>,这意味着如果实现了Subscriber<T>,它们可以与任何类型的订户链接。当使用 project reactor 编写代码时,扩展BaseSubscriber<T>抽象类是很实用的。这个类对从Subscriber<T>继承的所有方法都有最小的实现,但也为它们声明了钩子方法(拦截器方法),当发出上一节提到的三个信号之一:值、错误或完成时,您可以实现这些方法来定制订阅者行为。它还包含一个用于向发布者发送订阅信号的方法的钩子,以及一个用于添加在任何终止事件之后执行的行为的 final 钩子:error、complete 或 cancel。因为所有的方法都有一个最小化的实现(一个空的主体),这允许你只重写你感兴趣的方法,并且在开发过程中有一个方法最重要:hookOnNext(T)。
Mono<T>是一种特殊类型的反应流,它只能返回 0 或 1 的单值。知道流发出的值的数量会导致编写更简单的代码来使用它们。此外,不是所有的异步进程都返回值,所以可以使用一个Mono<Void>来表示这个进程的完成。由于Mono<T>对其值的限制如此之低,与Flux<T>相比,它只提供了一小组操作符。
在继续之前,先看看清单 9-3 中的代码。它包含清单 9-2 中代码的反应流版本。
import reactor.core.publisher.BaseSubscriber;
import reactor.core.publisher.Flux;
// other imports and code omitted
Subscriber<Integer> subscriber = new BaseSubscriber<Integer>() {
@Override
protected void hookOnNext(Integer sum) {
System.out.println("Diameter sum is " + sum);
}
};
Flux.fromIterable(bucket) // Flux<Ball>
.filter(predicate)
.map(redBall)
.map(quantifier) // Flux<Integer>
.reduce(0, Integer::sum) // Mono<Integer>
.subscribe(subscriber);
Listing 9-3Filtering Balls and Adding Their Diameters Using Reactive Streams
添加了一些注释,以便在返回的流上的对象类型发生变化时更加清楚。实现中的所有方法要么返回Flux<T>要么返回Mono<T>,这就是为什么它们可以很好地链接在一起。链中的最后一个组件是订阅者,它使用由reduce(..)累加器函数返回的流中的元素。因为这个累加器返回一个Mono<T>,如果需要的话,更多的函数可以被链接。
对于这个例子,订户对象是通过扩展BaseSubscriber<T>类型并将其实例化来创建的。hookOnNext(..)方法的实现在控制台中打印出发出的值。整个流水线执行是异步的,发出值就打印出来,不会阻塞主线程。
Flux<T>或Mono<T>类提供了subscribe(..)方法的丰富版本,允许开发者定义java.util.function.Consumer<T>函数来处理错误和完成信号。假设在订阅之前操作符可能出错,这允许正确处理错误,并在流程成功完成后执行额外的操作。
因此,使用这种方法,清单 9-3 中的代码变成了清单 9-4 中的代码。
Flux.fromIterable(bucket)
.filter(predicate)
.map(redBall)
.map(quantifier)
.reduce(0, Integer::sum)
.subscribe(
sum -> System.out.println("Diameter sum is " + sum),
error -> System.err.println("Something went wrong " + error),
() -> System.out.println("Pipeline executed successfully.")
);
Listing 9-4Declaring
Consumers for a Different Kind of Signal
在幕后,创建了一个订户实例,并由适当的钩子方法调用作为参数提供的消费者。
也许这段代码看起来比前面的实现更复杂,但是代码仍然是可读的,并且是异步和非阻塞的。对于简单的用例场景,使用反应性组件是多余的。在反应式应用中,非阻塞进程的效率和可靠性超过了拥有一长串不可读的函数和代码的成本,反应式流显示了它们的真正威力。即使你认为长长的反应函数链看起来不切实际,相信我,只用 Java 并发 API 编写的相同代码更是如此。
要了解为什么反应式编程对于中等负载和中等复杂性(不像网飞或脸书 1F642)处理的应用效率低下,让我们修改前面的实现,以便每个函数打印线程 ID。通过在管道中添加对delayElements(Duration.ofSeconds(1))的调用来模拟处理值的延迟。
在清单 9-5 中,描述了修改后的redBall函数,但是对于管道中的所有其他函数,修改是相似的。管道代码不会改变,pure 函数返回的结果不受 print 语句的影响。
// other imports and code omitted
Function<Ball, Ball> redBall = ball -> {
System.out.println("[RedBall]Executing thread: "
+ Thread.currentThread().getId());
return new Ball("RED", ball.getDiameter());
};
Flux.fromIterable(bucket)
.delayElements(Duration.ofSeconds(1))
.filter(predicate)
.map(redBall)
.map(quantifier)
.reduce(0, Integer::sum)
.subscribe(
sum -> System.out.println("Diameter sum is " + sum),
error -> System.err.println("Something went wrong " + error),
() -> System.out.println("Pipeline executed successfully.")
);
Listing 9-5Pure Function Modified To Print the ID of Its Executing Thread
当我们现在执行管道时,控制台中的输出应该看起来非常接近清单 9-6 中描述的输出。
[Predicate]Executing thread: 17
[GetDiameter]Executing thread: 17
[Predicate]Executing thread: 18
[Predicate]Executing thread: 19
[Predicate]Executing thread: 20
[GetDiameter]Executing thread: 20
[RedBall]Executing thread: 20
[GetDiameter]Executing thread: 20
[GetDiameter]Executing thread: 20
[Predicate]Executing thread: 21
[Predicate]Executing thread: 22
[Predicate]Executing thread: 23
[GetDiameter]Executing thread: 23
[Predicate]Executing thread: 24
...
[Subscriber]Executing thread: 24
Listing 9-6Reactive Pipeline Output
不同的线程 id 意味着每个纯函数都由自己的线程执行。正在使用的线程数量等于处理器拥有的内核数量,并且负载分布均匀:每个内核一个线程。对于一个简单的任务,创建如此多的线程并协调它们是不值得的。
Flux<T>和Mono<T>功能强大,实用。在编写 Spring 反应式应用时,您很少需要使用其他任何东西。它们都提供了丰富的操作符列表,这些操作符可以创建、组合和控制反应流 18 ,并允许实际的流水线设计。
要记住的一点是,在编写反应式应用时,应用的每个组件都必须是反应式的,否则应用就不是真正的反应式应用,非反应式组件可能会成为瓶颈并破坏整个流程。
例如:具有典型层的三层应用:表示层、服务层、数据库层只有在这三层都是反应性的情况下才是反应性的。一个反应式 Spring WebFlux 应用必须有反应式视图、反应式控制器、反应式服务、反应式存储库和反应式数据库。并且调用应用的客户端也必须是被动的。一个应用可以使用由另一个应用被动公开的数据,在这种情况下成为一个客户端。为了使通信顺利进行,两个应用都必须是反应式的。除了视图和数据库之外,如果其余的都是用 Java 编写的,那么 API 中每个方法的输入和输出都必须是Flux<T>或Mono<T>(或者前面提到的任何其他反应流的实现)实例,这样就可以组合它们,而不需要编写额外的代码来将它们封装在Flux<T>或Mono<T>实例中。
事不宜迟,让我们看看如何从使用 Spring MVC 编写应用转移到使用 Spring WebFlux 做同样的事情。
Spring WebFlux 简介
Spring Web MVC 是围绕DispatcherServlet设计的,它是将 HTTP 请求映射到处理程序的网关,并设置了主题配置、国际化、文件上传和视图解析。Spring MVC 是为 Servlet API 和 Servlet 容器构建的。这意味着它主要使用阻塞 I/O 和每个 HTTP 请求一个线程。支持请求的异步处理是可能的,但是需要更大的线程池,这反过来需要更多的资源。而且,很难规模化。
Spring WebFlux 是在 Spring 5 中添加的一个反应式堆栈 web 框架,它是 Spring 对阻塞 I/O 架构这一新兴问题的回应。它可以在 Servlet 3.1+容器上运行,但可以适应其他本地服务器 API。首选的服务器是 Netty 19 ,它在异步、非阻塞领域已经得到很好的应用。Spring WebFlux 构建时考虑了函数式反应式编程,并允许以声明式风格编写代码。
这两个框架有一些共同点,甚至可以一起使用。图 9-4 是来自 Spring 官方参考文档的图表,展示了 Spring MVC 和 Spring WebFlux 的共同点以及它们如何相互支持。

图 9-4
Spring MVC 和 Spring web 流量图
从 Spring 5 开始,spring-web模块增加了底层基础设施和 HTTP 抽象来构建反应式 web 应用。所有公共 API 都被修改,以支持将Publisher<T>和Subscriber<T>作为参数和返回类型。该模块是spring-webflux模块的依赖项,是 Spring 反应式应用最重要的依赖项。那么,我们如何编写一个 Spring Web Flux 应用呢?很简单,我们从配置入手。
Spring WebFlux 配置:反应式控制器
前面提到过,反应式应用可以部署在 Servlet 3.1+容器上,比如 Tomcat、Jetty 或 Undertow。这里的技巧是不要使用DispatcherServlet,它是 HTTP 请求处理程序/控制器的中央调度程序。再厉害,也还是个阻断成分。Tomcat 和 Jetty 的核心是非阻塞的,所以关键是使用它们来处理 HTTP 请求,而不需要 servlet facade。
这就是新的和改进的spring-web组件通过引入org.springframework.http.server.reactive.HttpHandler来提供帮助的地方。这个接口代表了反应式 HTTP 请求处理的最低层次的契约,Spring 基于它为每个支持的服务器提供了服务器适配器。表 9-2 列出了 Spring WebFlux 支持的服务器以及适配器类的名称,这些适配器类代表了每个服务器的非阻塞 I/O 到反应流桥的核心。
表 9-2
支持的服务器
|服务器名称
|
Spring 适配器
|
使用的桥
|
| --- | --- | --- |
| 妮蒂 | ReactorHttpHandlerAdapter | 使用 Reactor Netty 库的 Netty API |
| 逆流 | UndertowHttpHandlerAdapter | spring-web逆流到激流桥 |
| 雄猫 | TomcatHttpHandlerAdapter | spring-web: Servlet 3.1 到反应流桥的无阻塞 I/O |
| 码头 | JettyHttpHandlerAdapter | spring-web: Servlet 3.1 到反应流桥的无阻塞 I/O |
HttpHandler界面非常基础。其内容如清单 9-7 所示。
package org.springframework.http.server.reactive;
import reactor.core.publisher.Mono;
// other comments omitted
public interface HttpHandler {
/**
* Handle the given request and write to the response.
* @param request current request
* @param response current response
* @return indicates completion of request handling
*/
Mono<Void> handle(ServerHttpRequest request, ServerHttpResponse response);
}
Listing 9-7HttpHandler Interface
在此基础上,Spring 提供了 WebHandler 接口,这是一个稍微高级一点的契约,描述了所有具有过滤器链样式处理和异常处理的通用服务器 API。
与经典的 Spring Web MVC 应用相比,这对 Spring 配置意味着什么?这意味着你需要的不是org.springframework.web.servlet.DispatcherServlet,而是org.springframework.web.reactive.DispatcherHandler。DispatcherHandler,作为dispatcher servlet,被设计为前端控制器。它是核心的WebHandler实现,并为可配置组件执行的请求处理提供算法。它委托给特殊的 beans 来处理请求和呈现适当的响应,并且它们的实现如预期的那样是非阻塞的。类似于 Spring MVC 生态系统,有一个HandlerMapping bean 将请求映射到一个处理程序,一个HandlerAdapter bean 调用一个处理程序,一个org.springframework.web.server.WebExceptionHandler bean 处理异常,一个HandlerResultHandler bean 从处理程序获取结果并完成响应,所有这些都声明在org.springframework.web.reactive包中。
按照 Spring 的典型方式,大多数情况下,DispatcherHandler的配置不需要直接描述它的代码。要配置一个在 Servlet 3.1+容器中运行的 Spring WebFlux 应用,您需要执行以下操作。
-
声明一个 Spring WebFlux 配置类,并用
@Configuration和@EnableWebFlux.对其进行注释 -
扩展
org.springframework.web.server.adapter.AbstractReactiveWebInitializer类,实现getConfigClasses()方法,并在其中注入您的 Spring WebFlux 配置类。
清单 9-8 描述了AppConfiguration,一个 Spring WebFlux 应用的定制配置类。
package com.apress.prospringmvc.bookstore;
import org.springframework.web.reactive.config.EnableWebFlux;
// other imports omitted
@EnableWebFlux
@Configuration
public class AppConfiguration {
}
Listing 9-8AppConfiguration Class
@EnableWebFlux注释是org.springframework.web.reactive.config包的一部分,它支持使用带注释的控制器和功能端点。当 Spring IoC 找到这个注释时,它从org.springframework.web.reactive.config.WebFluxConfigurationSupport导入所有 Spring WebFlux 配置。如果您想以任何方式定制导入的配置,您所要做的就是让带注释的类实现org.springframework.web.reactive.config.WebFluxConfigurer。该接口包含用于配置静态访问资源处理程序、格式化程序、验证程序、消息源、视图解析器等的方法。
清单 9-9 描述了WebAppInitializer,,它扩展了AbstractReactiveWebInitializer以提供与 Servlet 3.1+容器的集成。
package com.apress.prospringmvc.bookstore;
import org.springframework.web.server.adapter.AbstractReactiveWebInitializer;
public class WebAppInitializer extends AbstractReactiveWebInitializer {
@Override
protected Class<?>[] getConfigClasses() {
return new Class<?>[]{AppConfiguration.class};
}
}
Listing 9-9WebAppInitializer Class
AbstractReactiveWebInitializer实现了WebApplicationInitializer,并且是在 Servlet 容器上安装 Spring Web Reactive 应用所必需的。
为了帮助你从 Spring MVC 过渡到 Spring WebFlux,请看表 9-3 。下表显示了 Spring MVC 和 Spring WebFlux 应用之间的配置组件的对应关系。
表 9-3
Spring MVC 和 WebFlux 比较
|框架
|
spring webflux
|
| --- | --- |
| @EnableWebMvc | @EnableWebFlux |
| WebMvcConfigurer | WebFluxConfigurer |
| WebMvcConfigurationSupport | WebFluxConfigurationSupport |
| WebApplicationInitializer(界面) | AbstractReactiveWebInitializer(类) |
| DispatcherServlet | DispatcherHandler |
一旦编写了两个配置类,下一步就是编写一个反应式控制器。由于我们使用的是 Spring WebFlux,我们知道这可以通过确保方法返回Flux<T>或Mono<T >来实现。在编写 web 应用时,我经常做的一件事就是编写一个IndexController,打印出“它工作了!”清单 9-10 描述了一个简单的IndexController返回一个发出单个值的Mono<T>实例。
package com.apress.prospringmvc.bookstore;
import reactor.core.publisher.Mono;
// other imports omitted
@RestController
public class IndexController {
@ResponseStatus(HttpStatus.OK)
@GetMapping(path="/", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Mono<String> index(){
return Mono.just("It works!");
}
}
Listing 9-10Reactive IndexController Implementation
使用@RestControllers是因为反应式应用专注于流数据,但是也可以使用@Controllers,正如你在下一章中看到的。
produces属性的值是text/event-stream,它代表返回内容的类型。此内容类型描述来自源的事件流。如果您将这个应用部署到 Apache Tomcat 服务器上,并试图打开http://localhost:8080/页面,一些浏览器会敦促您将输出保存到一个文件中,因为浏览器无法呈现响应。响应是服务器发送的事件(SSE),这是一种服务器推送技术,使客户端能够通过 HTTP 连接从服务器接收更新流,HTTP 连接是 HTML5 的一部分。 20 在经典轮询模型中,客户端和服务器之间的通信需要通过 HTTP 连接进行一系列的请求/响应,因为客户端必须重复轮询数据。服务器发送的事件允许服务器在数据可用时向客户端推送数据,而无需客户端请求。因此最适合反应式 web 应用。
除了这个头,我们如何知道我们的应用是反应性的呢?这很简单——我们不是返回一个Mono<T>,而是返回一个Flux<T>,并降低发射元素的速率。
通过使用一个名为zip的操作合并两个流,可以降低值发出的速率。有三种组合操作可应用于流,如图 9-5 所示。

图 9-5
流组合操作
使用图 9-5 中描述的操作符之一,可以将两个或多个Flux<T>实例合并成一个Flux<T>。下面的列表描述了 de 运算符和结果流。
-
concat连接两个流。流的顺序很重要,因为第二个流仅在第一个流发出onComplete信号后被订阅。对于连接流,可以使用Flux.concat(..)实用程序方法。还有一个名为concatWith(Flux<T>)的方法,可以在一个Flux<T>实例上调用,将它与作为参数接收的流连接起来。清单 9-11 展示了一个流连接的例子,使用两个非常方便的流作为源:一个发出X值,另一个发出Y值。 -
merge合并两条溪流。流的顺序并不重要,因为两个流是同时订阅的。结果流随机地从任何一个源流中发出值。对于合并流,可以使用Flux.merge(..)实用程序方法。还有一个名为mergeWith(Flux<T>)的方法,可以在一个Flux<T>实例上调用它,将它与作为参数接收的流合并。清单 9-12 展示了一个流合并的例子,使用两个非常方便的流作为源:一个发出X值,另一个发出Y值。
Flux<String> y = Flux.just("Y", "Y");
Flux<String> x = Flux.just("X", "X");
Flux.concat(y,x).subscribe(str -> System.out.print(str + " "));
// or
y.concatWith(x).subscribe(str -> System.out.print(str + " "));
// expect: Y Y X X
Listing 9-11Stream Concatenation Examples
zip是一个发出值的流,这些值是通过将每个流发出的值包装在一起而创建的。此操作的结果应用于两到八个流。当一个流发出onComplete信号时,来自其他流的所有不能合并的值都被丢弃。压缩流时,可以使用Flux.zip(..)实用程序方法。这个方法有不止一个版本,它们可以压缩 2 到 8 个流。结果值属于reactor.util.function.Tuple*类型,其中*替换被组合的值的数量。还有一个名为zipWith(Flux<T>)的方法,可以在一个Flux<T>实例上调用,用作为参数接收的流压缩它。
Flux<String> y = Flux.just("Y", "Y");
Flux<String> x = Flux.just("X", "X");
Flux.merge(y,x).subscribe(str -> System.out.print(str + " "));
// or
y.mergeWith(x).subscribe(str -> System.out.print(str + " "));
// expect multiple X and Y elements being written in any order
Listing 9-12Stream Merging Examples
清单 9-13 展示了一个流压缩的例子,使用三个非常方便的流作为源:一个发出X值,一个发出Y值,一个发出Z值。
Flux<String> y = Flux.just("Y", "Y");
Flux<String> x = Flux.just("X", "X");
Flux<String> z = Flux.just("Z", "Z");
Flux.zip(y,x).subscribe(t -> System.out.print(str + " "));
// or
y.zipWith(x).subscribe(str -> System.out.print(str + " "));
// expect multiple Tuple2 instances: [Y,X]
Flux.zip(y,x,z).subscribe(t -> System.out.print(str + " "));
// expect multiple Tuple3 instances: [Y,X,Z]
Listing 9-13Zipping Merging Examples
zipWith(..)操作通过将一个流与一个反应性间隔流相结合来降低该流的发射速率。可通过调用Flux.interval(Duration)创建反应性间隔流。该方法将一个Duration实例作为参数,并创建一个Flux<Long>,它发出从 0 开始的long值,并在全局计时器上以指定的时间间隔递增。在初始延迟等于作为参数提供的持续时间之后,发出第一个元素。
如果我们压缩一个带有一秒钟Duration的反应式间隔流的反应式数据流,这会导致一个每秒钟发出一个Tuple2实例的流。然后,我们将map应用于元组流,以分离出我们感兴趣的值。
让我们把事情变得有趣一些,修改IndexController并为/debug路径添加一个处理程序方法,该方法以每秒一个的速度返回包含所有 bean 名称及其类型的流。每个发出的值都是类型Pair<S,T>,包含 bean 名称及其类型。属性produces的值将该方法返回的类型声明为MediaType.APPLICATION_STREAM_JSON_VALUE,它是一个带有application/stream+json值的常量。这意味着这个流发出的每个值都被转换成 JSON。
清单 9-14 中描述了提议的实现。
package com.apress.prospringmvc.bookstore;
import reactor.core.publisher.Flux;
import reactor.util.function.Tuple2;
import org.springframework.data.util.Pair;
// other imports omitted
@RestController
public class IndexController implements ApplicationContextAware {
// other code omitted
@ResponseStatus(HttpStatus.OK)
@GetMapping(path="/debug", produces =
MediaType.APPLICATION_STREAM_JSON_VALUE)
public Flux<Pair<String,String>> debug() {
List<Pair<String,String>> info = new ArrayList<>();
Arrays.stream(ctx.getBeanDefinitionNames())
.forEach(beanName -> info.add(Pair.of(beanName,
ctx.getBean(beanName).getClass().getName())));
return Flux.fromIterable(info)
.zipWith(Flux.interval(Duration.ofSeconds(1))).map(Tuple2::getT1);
}
}
Listing 9-14Reactive IndexController Implementation
Returning a Flux<T>
当在浏览器中访问时,如果浏览器可以解析它,您会看到这些值以一秒钟的延迟按顺序显示。如果您的浏览器不能这样做,几秒钟后(取决于数据集有多大),系统会提示您将响应保存为文件。如果您有基于 UNIX 的操作系统,您可以使用curl命令打开那个http://localhost:8080/debug。
curl -H -v "application/stream+json" http://localhost:8080/debug
这个命令打印转换成 JSON 的值。但是,这在哪里配置呢?这不是显式完成的,尽管如果需要,您可以声明自己的转换器。使用Encoder<T>和Decoder<T>bean 完成Flux<T>和Mono<T>到字节的转换,反之亦然。这两个接口是org.springframework.core.codec包的一部分,基本实现是spring-core和spring-web模块的一部分。
在 Spring WebFlux 应用中,org.springframework.http.codec.HttpMessageWriter<T> bean 被默认配置为使用现有的编码器实现来编码类型为<T>的对象流,并将它们写入数据缓冲区流以传输响应内容。默认情况下,org.springframework.http.codec.HttpMessageReader<T> bean 被配置为使用现有的解码器实现来将包含请求数据的数据缓冲区流解码为类型为<T>的对象流。
配置 Spring WebFlux 应用类似于配置 Spring MVC 应用。唯一改变的是处理程序方法的返回类型,以及在用@RequestMapping和变体注释的方法中作为参数可用的选项。
-
支持将反应类型作为参数,但是您不应该将反应类型用于不需要非阻塞 I/O 的参数(例如,对于保存书籍的 POST 请求处理程序方法,将参数类型声明为
Mono<Book>是没有意义的)。) -
org.springframework.web.server.ServerWebExchange可以用作参数来提供对 HTTP 请求、响应和其他服务器端属性的访问。在清单 9-15 中,IndexController.debug(..)方法已经被修改为接收ServerWebExchange作为参数。对请求进行分析,以检查请求的user-agent报头,如果请求是使用curl命令发出的,则在响应中添加一个 cookie。请求和响应对象都是通过ServerWebExchange参数来访问的。 -
如果一个具有
Mono<Void>返回类型的方法也有一个ServerHttpResponse,或者一个ServerWebExchange参数,或者一个@ResponseStatus注释,那么它就被认为已经完全处理了响应。 -
返回类型支持
Flux<ServerSentEvent>、Observable<ServerSentEvent>或其他反应类型。当只需要编写简单的文本时,可以省略ServerSentEvent包装类型。在这种情况下,produces属性必须设置为text/event-stream值(就像第一个版本的IndexController一样)。
package com.apress.prospringmvc.bookstore;
import org.springframework.web.server.ServerWebExchange;
// other imports omitted
@RestController
public class IndexController implements ApplicationContextAware {
// other code omitted
@ResponseStatus(HttpStatus.OK)
@GetMapping(path="/debug", produces = MediaType.APPLICATION_STREAM_JSON_VALUE)
public Flux<Pair<String,String>> debug(ServerWebExchange exchange) {
if(Objects.requireNonNull(exchange.getRequest()
.getHeaders().get("user-agent"))
.stream().anyMatch(v-> v.startsWith("curl"))){
logger.debug("Development request with id: {}", exchange.getRequest().getId());
ResponseCookie devCookie = ResponseCookie
.from("Invoking.Environment.Cookie", "dev")
.maxAge(Duration.ofMinutes(5)).build();
exchange.getResponse().addCookie(devCookie);
}
List<Pair<String,String>> info = new ArrayList<>();
Arrays.stream(ctx.getBeanDefinitionNames()).forEach(beanName ->
info.add(Pair.of(beanName, ctx.getBean(beanName).getClass().getName()))
);
return Flux.fromIterable(info).zipWith(Flux.interval(Duration.ofSeconds(1))).map(Tuple2::getT1);
}
}
Listing 9-15Reactive IndexController Using the ServerWebExchange Argument
Spring Boot 网络流量应用
当使用 Spring Boot 构建 Spring WebFlux 应用时,事情变得更加简单。使用spring-boot-starter-webflux作为依赖项和 Spring Boot 依赖注入确保应用已经默认配置了所有必要的基础设施 beans。
剩下要做的就是编写反应式控制器和它们所需的其他定制 bean,比如存储库或服务 bean。不过,有一件重要的事情需要提一下。不支持创建可部署的 war 文件。 21
因为 Spring WebFlux 并不严格依赖于 Servlet API,并且应用默认部署在嵌入式 Reactor Netty 服务器上,所以 WebFlux 应用不支持 war 部署。
这是什么意思?这意味着我们不能使用 Spring Boot 构建一个可部署的 war,我们不能在 Tomcat 服务器上部署它,这实际上并不重要。既然反应式应用最适合写微服务应用,那么无论如何,有一个嵌入式服务器,把你的应用打包成可执行文件jar更合适。
Spring WebFlux 配置:功能端点
Spring WebFlux 提供了一个功能模型,作为将请求映射到处理程序的@Controller注释类的替代方法。配置可以很好地组合,并且具有不变性的优势。
在这个模型中,请求由一个HandlerFunction<T>处理。这是一个简单的函数接口,声明了一个方法。清单 9-16 显示了这个接口的代码。
// other comments omitted
package org.springframework.web.reactive.function.server;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
@FunctionalInterface
public interface HandlerFunction<T extends ServerResponse> {
Mono<T> handle(ServerRequest request);
}
Listing 9-16HandlerFunction<T> Code
handle(..)方法接受一个ServerRequest并返回一个Mono<ServerResponse>。它相当于一个用@RequestMapping标注的@Controller方法。
ServerRequest将请求体公开为一个Flux<T>或Mono<T>实例。它还提供对请求参数、路径变量、HTTP 方法和头的访问。作为一个整体,ServerResponse接受任何Publisher<T>实现。ServerRequest和ServerResponse都是不可变的。通过调用各种静态方法(例如ok()、badRequest()等)来构建响应。)在公开一个BodyBuilder的ServerResponse类上。这个实例提供了多种方法来定制响应:设置 HTTP 响应状态代码、添加标头和提供主体。
处理函数通常被分组到特定于被处理对象类型的组件中。例如,处理特定于Book对象的请求的处理函数应该被分组到一个名为BookHandler的组件中。
清单 9-17 描述了BookHandler类。声明了这种类型的 bean,它是 Spring WebFlux 应用配置的一部分,它的方法被用作管理Book实例的请求的处理函数。
package com.apress.prospringmvc.bookstore;
// other imports omitted
@Component
public class BookHandler {
private final BookService bookService;
public HandlerFunction<ServerResponse> list;
public HandlerFunction<ServerResponse> delete;
public BookHandler(BookService bookService) {
this.bookService = bookService;
/* 1 */
list = serverRequest -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(bookService.findAll(), Book.class);
/* 2 */
delete = serverRequest -> ServerResponse.noContent()
.build(bookService.delete(serverRequest.pathVariable("id")));
}
/* 3 */
public Mono<ServerResponse> findByIsbn(ServerRequest serverRequest) {
Mono<Book> bookMono = bookService.findByIsbn(serverRequest.pathVariable("isbn"));
return bookMono
.flatMap(book -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).bodyValue(book))
.switchIfEmpty(ServerResponse.notFound().build());
}
/* 4 */
public Mono<ServerResponse> save(ServerRequest serverRequest) {
Mono<Book> bookMono = serverRequest.bodyToMono(Book.class).doOnNext(bookService::save);
return bookMono
.flatMap(book -> ServerResponse.created(URI.create("/books/" + book.getId())).contentType(MediaType.APPLICATION_JSON).bodyValue(book))
.switchIfEmpty(ServerResponse.status(HttpStatus .INTERNAL_SERVER_ERROR).build());
}
}
Listing 9-17The BookHandler Class
在清单 9-17 中,每个处理函数都标有一个数字。下面的列表讨论了每个处理函数,项目符号编号与函数编号相匹配。
-
list是一个简单的处理函数,它返回被动BookService.findAll()方法返回的所有Book实例。它被声明为类型为HandlerFunction的字段,并且是BookHandler类的成员。它不能在声明的同一行初始化,因为它依赖于bookService。要初始化该域,必须首先初始化bookService域。因为它是在构造函数中初始化的,所以list字段的初始化也是构造函数的一部分。最初的ServerResponse.ok()将 HTTP 响应状态设置为 200 (OK ),并返回对内部BodyBuilder的引用,该引用允许链接其他方法来描述请求。该链必须以返回一个Mono<ServerResponse>的body*(..)方法之一结束。 -
delete是一个简单的处理函数,删除一个 ID 与 path 变量匹配的Book实例。通过调用serverRequest.pathVariable("id")提取路径变量。“id”参数表示路径变量的名称。bookService.delete()方法返回Mono<Void>,因此Mono<ServerResponse>发出一个响应,其主体为空,状态码 204(无内容)由ServerResponse.noContent()设置。 -
findByIsbn是一个处理函数,它返回一个由ISBN路径变量标识的Book实例。通过调用返回一个Mono<Book>的bookService.findByIsbn(..)来检索实例。如果这个流发出一个值,这意味着找到了一本书与 path 变量匹配,并且创建了一个响应,其状态代码为 200,主体由 JSON 的Book实例表示。为了访问由流无阻塞发出的Book实例,使用了flatMap(..)函数。如果流没有发出一个值,这意味着没有找到具有预期的ISBN的书,因此通过调用switchIfEmpty(ServerResponse.notFound().build())创建一个状态为 404(未找到)的空响应。 -
save是一个处理函数,存储请求体中包含的一个新的Book实例。由于通过调用serverRequest.bodyToMono(Book.class)请求体被读取为Mono<Book>,所以当发出值时doOnNext(bookService::save)方法被链接以调用bookService.save(book)。该方法返回一个Mono<Book>。当一个成功的save被执行时,这个流发出一个值,当创建的资源可以被访问时,响应被填充一个指向 URL 的Location头。为了访问由流无阻塞发出的Book实例,使用了flatMap(..)函数。这是通过调用ServerResponse.created()来完成的,它将响应状态设置为 201(已创建),并声明一个 URI 作为参数。作为参数提供的值成为位置头的值。如果流没有发出值,这意味着保存操作失败。这里提供的实现返回一个空的响应体,响应状态为 500(内部服务器错误)。
现在我们有了处理函数,它们是如何映射到请求的呢?嗯,这是一个路由器 bean 的工作。
org.springframework.web.reactive.function.server.RouterFunction<T>是一个简单的函数接口,描述了一个将传入请求路由到HandlerFunction<T>实例的函数。RouterFunction<T>乘ServerRequest返回Mono<HandlerFunction<T>>。如果没有找到处理函数,它返回一个空的Mono<Void>。RouterFunction<T>与@Controller类中的@RequestMapping注释有相似的用途。
org.springframework.web.reactive.function.server.RouterFunctions是一个实用程序抽象类,它提供了构建简单和嵌套路由函数的静态方法,甚至可以将RouterFunction<T>转换为HttpHandler,这使得应用可以在 Servlet 3.1+容器中运行。
在进一步讨论路由器功能之前,我们先来看一个例子。清单 9-18 描述了BookRouterConfiguration配置类。它声明了一个类型为RouterFunction<ServerResponse>,的 bean,它是一个路由器函数,将传入的请求路由到前面介绍的BookHandler bean 中声明的处理程序函数。
package com.apress.prospringmvc.bookstore;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
// other imports omitted
@Configuration
public class BookRouterConfiguration {
private final Logger logger = LoggerFactory.getLogger(BookRouter.class);
@Bean
RouterFunction<ServerResponse> routerFunction(BookHandler bookHandler) {
return route(GET("/books"), bookHandler.list) /* 1 */
.andRoute(GET("/books/{isbn}"), bookHandler::findByIsbn) /* 2 */
.andRoute(POST("/books"), bookHandler::save) /* 3 */
.andRoute(DELETE("/books/{id}"), bookHandler.delete) /* 4 */
.filter((request, next) -> { /* 5 */
logger.info("Before handler invocation: " + request.path());
return next.handle(request);
});
}
}
Listing 9-18The BookRouterConfiguration Configuration Class
在清单 9-18 中,每个处理函数都标有一个数字。下面的列表讨论了每一行的内容,项目符号编号与函数编号相匹配。
-
route(GET("/books"), bookHandler.list)基于org.springframework.web.reactive.function.server.RequestPredicate和HandlerFunction<T>创建一个RouterFunction<T>。RequestPredicate实例代表一个函数,它根据一组属性评估ServerRequest,比如请求方法和 URL 模板。GET("/books")是来自org.springframework.web.reactive.function.server.RequestPredicates抽象实用程序类的一个静态方法,它创建一个将 GET 请求与/booksURL 相匹配的request predicate。 -
.andRoute(GET("/books/{isbn}"), bookHandler::findByIsbn)将谓词和处理函数作为参数,并创建一个路由器函数,该函数被添加到调用该方法的路由器函数中。它返回表示两者组合的路由器函数。 -
.andRoute(POST("/books"), bookHandler::save)是来自org.springframework.web.reactive.function.server.RequestPredicates实用程序类的一个静态方法,它创建一个将 POST 请求匹配到/booksURL 的request predicate。 -
.andRoute(DELETE("/books/{id}"), bookHandler.delete)是来自org.springframework.web.reactive.function.server.RequestPredicates实用程序类的一个静态方法,它创建一个将删除请求匹配到/books/{id}URL 的request predicate,其中ID是路径变量的名称。 -
.filter((request, next)→{..})是在RouterFunction中声明的一个方法,可以实现该方法来根据某些条件向过滤处理函数添加一个HandlerFilterFunction<T,S>或者添加日志记录代码,如清单 9-18 中的例子所示。HandlerFilterFunction<T,S>的功能相当于@ControllerAdvice或javax.servlet.Filter。
在 Spring WebFlux 应用中,如果您想要获取包含处理程序函数定义的 bean,必须在声明 bean 的包中启用组件扫描。
路由器功能 beans 可以在任何@Configuration注释类中声明,并通过对声明配置类的包启用组件扫描来获得。
Spring WebFlux 应用适合流数据,这就是为什么大多数应用不解析对视图的请求,而是返回数据流。首选的方法是使用 React、 22 TypeScript、 23 等技术编写一个与后端分离的 web 应用。这在升级资源和扩展应用时带来了很大的灵活性。客户端和服务器之间的首选通信方式是通过 WebSocket 协议, 24 ,它允许在 web 浏览器和服务器之间建立双向通信。
WebSocket 是反应式应用的完美协议,因为双方可以随时开始发送数据,这意味着客户端可以施加背压。但是,在接下来的章节中会有更多的介绍。
摘要
这一章是对反应式编程的一个小介绍,它只是触及了 Spring WebFlux 的表面,所以你会更熟悉接下来两章中的代码。
这里有一些你应该记住的事情。
-
流可以被视为正在移动的数据。
-
反应流是支持背压的非阻塞流。消费者调节生产者发射元素的速率。
-
纯函数是不改变输入的函数,总是返回一个值,并且返回值完全基于输入(它们没有副作用)。
-
函数式反应式编程是用反应式流和纯函数编程。
-
反应式组件的关键特征应该是可组合性和可读性。
-
使用反应式编程并根据这种范式编写代码,并不总是会产生如反应式宣言所描述的反应式系统。
-
反应式应用的所有组件都必须是反应式的;否则有堵塞的风险。
-
反应式代码是完全声明性的,在订阅者连接到发布者之前什么都不会发生。
-
Spring WebFlux 应用可以部署在 Servlet 3.1 容器上。
-
Spring WebFlux 是 Spring MVC 的反应式替代品。
-
Spring WebFlux 应用通过带注释的方法和功能端点支持请求映射。
-
Spring WebFlux 提供了一种函数式方法来将传入的请求映射到处理函数。
-
函数式反应式编程最适合实现微服务应用。Spring Boot WebFlux 是微服务应用的完美构建模块。
十、使用 Spring WebFlux 构建反应式应用
在前一章中,函数式反应式编程是出于需要而引入的。Spring WebFlux 是一个用于编写 Spring 反应式应用的功能性反应式框架,为本章中的应用编写的代码是功能性的,并且使用反应式流。
Spring WebFlux 非常适合构建需要轻松流式传输的应用。上一章介绍的例子使用反应流通过 HTTP 连接向客户机发送数据。但是客户端只接收数据,没有做任何事情来控制发射速率。本章通过将部分书店应用从 MVC 迁移到 Spring WebFlux,引入一个提供新书发布信息的web client,并为应用添加聊天功能,来探索 Spring WebFlux 的功能。
从 Spring Web MVC 到 Spring WebFlux
将应用逻辑从经典迁移到反应式时,方法处理的类型会发生变化。陈述也会改变;它们不再是命令式的,而是声明式的,它们的代码只有在发出信号时才会执行。对于多层应用,可以从较低层(数据访问)到较高层(表示层),或者从较高层到较低层进行转换。为了让您热身,让我们从底层开始。
迁移数据访问层
直到不久前,关系数据库还不支持被动访问。传统的数据库 JDBC 驱动程序不是反应式的,因此在反应式应用中,它们代表了影响整个应用行为的阻塞 I/O 组件。不过,去年推出了 R2DBC 1 项目,将反应式编程 API 引入关系数据库。目前,大多数使用的关系数据库都有 R2DBC 实现。但是,即使在三次正式发布之后,这个项目仍然不稳定。
这就只剩下一个选择:放弃关系数据库,转而使用支持反应式访问的 NoSQL 数据库。你可能会使用 Spring WebFlux 来编写微服务应用。具有强类型和列表之间刚性关系的关系数据库与微服务架构的水平可伸缩性和集群化的要求不兼容。Spring 支持一些现代 NoSQL 数据库——couch base、Redis 和 Cassandra,但是 Spring 最喜欢的是 MongoDB。 2 作为本章案例研究的反应书店应用使用 MongoDB。(本章的项目包含一个 README.adoc 文件,其中包含在本地安装 MongoDB 的说明。)
在 Spring Boot 应用中,application.yml配置文件填充了允许与 MongoDB 数据库集成的属性。但是,它们对于 web 环境来说并不重要,所以这里不做介绍。
在 Spring Boot 应用中使用 MongoDB 数据库需要对项目类路径的spring-boot-starter-data-mongodb-reactive依赖和对代码的一些修改。下面列出了这些更改。
-
实体类变成了域类,这意味着 ID 类型被限制为
String或BigDecimal。如果你尝试使用任何其他类型,就会抛出一个类型为org.springframework.dao.InvalidDataAccessApiUsageException的异常。 -
因为 NoSQL 数据库不是关系数据库,所以数据库结构改变以保持强链接的数据。
-
数据库表成为数据库集合。
-
JPA/hibernate 注释被替换为 Spring Data MongoDB 注释。
-
JpaRepository<T, ID>扩展名被替换为ReactiveMongoRepository<T, ID>或ReactiveCrudRepository<T, ID>。
清单 10-1 中显示了对实现的Book类的更改。应用中的其他类也有类似的变化。
package com.apress.prospringmvc.bookstore.document;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.math.BigDecimal;
// other imports omitted
@Document(collection="book")
public class Book {
@Id
private String id;
private String title;
private String description;
private BigDecimal price;
private Integer year;
private String author;
@Indexed(unique = true)
private String isbn;
private String category;
// getters and setters omitted
}
Listing 10-1The Book MongoDB Document Class
ReactiveCrudRepository<T, ID>接口是org.springframework.data.repository.reactive包的一部分,包含返回Publisher<T>实现的方法声明,对于 Spring,这意味着Flux<T>和Mono<T>。ReactiveSortingRepository<T, ID>是org.springframework.data.repository.reactive包的一部分,它扩展了ReactiveCrudRepository<T, ID>来提供一些额外的为 MongoDB 优化的方法模板。用于被动数据访问的BookRepository接口使用 MongoDB 查询从数据库中选择数据,如清单 10-2 所示。
package com.apress.prospringmvc.bookstore.repository;
import com.apress.prospringmvc.bookstore.document.Book;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import reactor.core.publisher.Flux;
/**
* Created by Iuliana Cosmina on 28/06/2020
*/
public interface BookRepository extends ReactiveMongoRepository<Book, String>{
@Query("{'category': { '$regex' : ?0 } }")
Flux<Book> findByCategory(String category);
@Query(value= "{}", fields ="{'id': 1, 'isbn' : 1, 'category' :1 }")
Flux<Book> findAllLight();
}
Listing 10-2The BookRepository Reactive Interface for Accessing the MongoDB Book Collection
@Query注释中的参数用于直接在存储库方法上声明查找器查询。
迁移服务层
如果应用需要一个服务层,那么它的组件也必须修改成反应性的。除此之外,实现也必须改变以适应新的数据库结构。在清单 10-3 中,描述了BookstoreServiceImpl的一些方法。findBooksByCategory(String)被修改为支持类型为String而不是Category的参数,这是因为没有Category table。结果作为Flux<Book>返回。
修改findBooks(BookSearchCriteria)来创建一个 MongoDB 查询,并将其传递给BookRepository来过滤结果。结果被返回为Flux<Book>。
findOrdersForAccountId(String accountId)被修改为从account集合中获取账户实例的订单,因为没有order集合。
package com.apress.prospringmvc.bookstore.service;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
// other imports omitted
/**
* Created by Iuliana Cosmina on 28/06/2020
*/
@Service
@Transactional(readOnly = true)
public class BookstoreServiceImpl implements BookstoreService {
@Override
public Mono<Book> findBook(String id) {
return this.bookRepository.findById(id);
}
@Override
public Flux<Book> findBooksByCategory(String category) {
return this.bookRepository.findByCategory(category);
}
@Override
public Flux<Book> findBooks(BookSearchCriteria bookSearchCriteria) {
Query query = new Query();
if (bookSearchCriteria.getTitle() != null) {
query.addCriteria(Criteria.where("title")
.is(bookSearchCriteria.getTitle()));
}
if (bookSearchCriteria.getCategory() != null) {
query.addCriteria(Criteria.where("category")
.is(bookSearchCriteria.getTitle()));
}
return bookRepository.findAll(query);
}
@Override
public Mono<List<Order>> findOrdersForAccountId(String accountId) {
return this.accountRepository
.findById(accountId).map(Account::getOrders);
}
//other code omitted
}
Listing 10-3The BookRepository Reactive Interface for Accessing the MongoDB Book Collection
一旦使用了反应式数据库,迁移数据访问和服务层就变得很容易,几乎没有什么困难。只要确保您总是返回Publisher<T>实例,就大功告成了。
迁移 Web 图层
迁移 web 图层需要进行一些更改,因为当您不知道渲染了多少数据时,渲染视图会很困难。过去,AJAX(异步 JavaScript 和 XML)解决了这个问题,但是 AJAX 使我们只能响应页面上的用户操作来更新页面。它没有解决来自服务器的更新问题。由于反应式通信涉及双向数据流,因此需要新的 web 库。做这件事的方法不止一种,所以我们开始研究吧,好吗?
反应式模板引擎的配置
让我们举一个在前面章节中使用的非常简单的例子:书店应用的index页面可以显示应用上下文中的 beans 列表。在前面的章节中,IndexController包含了一个方法,该方法使用一个包含应用上下文中所有 beans 名称的List<String>来填充模型。
要使这个控制器是可反应的,列表必须被一个Flux<String>,代替,视图也必须是可反应的。幸运的是,百里香叶可以配置为支持反应式视图。模板的语法没有改变;只有视图解析器和模板引擎必须替换为它们的反应式通信器。
在清单 10-4 中,描述了一个用于反应式百里香视图支持的 Spring 配置类。这个类有点冗长。其中设置的大多数属性值已经被声明为默认值,但是这个类是这样编写的,以便从开发的角度来看清楚什么是可定制的。
package com.apress.prospringmvc.bookstore.config;
import org.springframework.web.reactive.config.EnableWebFlux;
import org.springframework.web.reactive.config.ViewResolverRegistry;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.thymeleaf.spring5.ISpringWebFluxTemplateEngine;
import org.thymeleaf.spring5.SpringWebFluxTemplateEngine;
import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.spring5.view.reactive.ThymeleafReactiveViewResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
// other imports omitted
@Configuration
public class ReactiveThymeleafWebConfig implements
ApplicationContextAware, WebFluxConfigurer {
ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext context) {
this.context = context;
}
@Bean
public ITemplateResolver thymeleafTemplateResolver() {
var resolver = new SpringResourceTemplateResolver();
resolver.setApplicationContext(this.context);
resolver.setPrefix("classpath:templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCacheable(false);
resolver.setCheckExistence(false);
return resolver;
}
@Bean
public ISpringWebFluxTemplateEngine thymeleafTemplateEngine() {
var templateEngine = new SpringWebFluxTemplateEngine();
templateEngine.setTemplateResolver(thymeleafTemplateResolver());
return templateEngine;
}
@Bean
public ThymeleafReactiveViewResolver thymeleafReactiveViewResolver() {
var viewResolver = new ThymeleafReactiveViewResolver();
viewResolver.setTemplateEngine(thymeleafTemplateEngine());
viewResolver.setOrder(1);
viewResolver.setResponseMaxChunkSizeBytes(8192);
return viewResolver;
}
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.viewResolver(thymeleafReactiveViewResolver());
}
}
Listing 10-4Spring Configuration Class for Reactive Thymeleaf Views Support
负责解析模板的模板解析器 bean 不需要是被动的。因为模板解析器 bean 包含来自应用配置的数据,所以在使用 Spring Boot 时可以完全删除它,并通过用@EnableConfigurationProperties(ThymeleafProperties.class)注释配置类来替换它。
使用模板解析器的模板引擎是反应式的,是ISpringWebFluxTemplateEngine的一个实现。为了与 Spring MVC 类型集成而设计的SpringTemplateEngine,必须替换为SpringWebFluxTemplateEngine,它是ISpringWebFluxTemplateEngine接口的一个实现,旨在与 Spring WebFlux 集成,并以一种反应友好的方式执行模板。由于模板引擎只需要一个模板解析器,我们也可以跳过这个 bean 声明,让 Spring Boot 来配置它。支持反应视图的配置类可以简化,如清单 10-5 所示。
package com.apress.prospringmvc.bookstore.config;
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
// other imports omitted
@Configuration
@EnableConfigurationProperties(ThymeleafProperties.class)
public class ReactiveThymeleafWebConfig implements
WebFluxConfigurer {
private final ISpringWebFluxTemplateEngine thymeleafTemplateEngine;
public ReactiveThymeleafWebConfig(ISpringWebFluxTemplateEngine templateEngine) {
this.thymeleafTemplateEngine = templateEngine;
}
@Bean
public ThymeleafReactiveViewResolver thymeleafReactiveViewResolver() {
var viewResolver = new ThymeleafReactiveViewResolver();
viewResolver.setTemplateEngine(thymeleafTemplateEngine);
viewResolver.setOrder(1);
return viewResolver;
}
//other code omitted
}
Listing 10-5Simplified Spring Configuration Class for Reactive Thymeleaf Views Support
@EnableConfigurationProperties注释支持百里香配置属性。用@ConfigurationProperties(prefix = "spring.thymeleaf"),对ThymeleafProperties类进行了注释,使其成为百里香属性的配置 bean。这意味着你可以使用application.properties或application.yml来配置百里香。这些属性以spring.thymeleaf为前缀,允许您配置模板解析器 bean,而无需编写额外的代码。清单 10-6 是相当于清单 10-4 中 Java 配置的 YML 配置。
spring:
thymeleaf:
prefix: classpath:templates/
suffix: .html
mode: HTML
cache: false
check-template: false
reactive:
max-chunk-size: 8192
Listing 10-6Simplified Spring Configuration Class for Reactive Thymeleaf Views Support (snippet from the application.yml file)
有些百里香属性在ThymeleafProperties类中被赋予了默认值,所以这意味着如果你的应用使用默认值,百里香 YML 配置部分可以完全跳过,清单 10-5 中的配置仍然有效。
ThymeleafReactiveViewResolver是org.springframework.web.reactive.result.view.ViewResolver接口的实现,即 Spring WebFlux 视图解析器接口。responseMaxChunkSizeBytes是您应该感兴趣的属性,因为它定义了由百里香引擎生成并作为输出传递给服务器的输出org.springframework.core.io.buffer.DataBuffer实例的最大大小。这一点很重要,因为如果您有大量数据通过Flux<T>发送,您可能希望一点一点地呈现视图,而不是在响应完成之前保持网页处于加载状态。尤其是因为这是反应式沟通的主要思想之一。
百里香叶有三种操作模式。
-
FULL:当没有配置最大块大小,也没有数据驱动上下文变量时,模板输出在内存中作为单个块生成,然后作为响应发送。这与非反应行为非常相似。 -
CHUNKED:已建立配置的最大块大小限制,但尚未指定数据驱动程序上下文变量。模板以块的形式生成,大小大致等于配置的大小,并发送给客户端。在发送一个块后,百里香引擎停止并等待服务器请求更多的块;是的,这是背压的一种实现。 -
DATA-DRIVEN``:``data-driver变量以反应式数据流的形式包装异步对象,这意味着驱动模板的反应式友好执行。当在返回视图逻辑名称的处理程序方法中声明这种变量时,百里香引擎被设置为DATA-DRIVEN模式,这意味着解析后的视图作为数据流发送到客户端。支持数据驱动变量的模板必须包含该变量的迭代(th:each)。
重写IndexController使其反应的方法使用了一个data-driver变量。这意味着,不是向模型添加一个List<String>,而是需要一个org.thymeleaf.spring5.context.webflux.IReactiveDataDriverContextVariable的实例。这个变量包含在一个发出应用上下文 bean 名称的Flux<String>实例中。实现该接口的上下文变量以反应式数据流的形式包装Publisher<T>实例,以驱动模板的反应式友好执行。 3
为了使这种方法的结果清晰,值的发出被减慢到每 200 毫秒一次。这意味着当打开http://localhost:8080/ URL 时,您应该看到列出 bean 名称的页面部分逐渐加载。
清单 10-7 中描述了无功IndexController的实现。
package com.apress.prospringmvc.bookstore.controller;
import org.thymeleaf.spring5.context.webflux.IReactiveDataDriverContextVariable;
import org.thymeleaf.spring5.context.webflux.ReactiveDataDriverContextVariable;
import reactor.core.publisher.Flux;
// other imports omitted
@Controller
public class IndexController implements ApplicationContextAware {
private ApplicationContext ctx;
@Override
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
ctx = applicationContext;
}
@GetMapping("/")
public String index(final Model model) {
List<String> beans = Arrays.stream(ctx.getBeanDefinitionNames())
.sorted()
.collect(Collectors.toList());
Flux<String> flux = Flux.fromIterable(beans)
.delayElements(Duration.ofMillis(200));
IReactiveDataDriverContextVariable dataDriver =
new ReactiveDataDriverContextVariable( flux,10);
model.addAttribute("beans", dataDriver);
return "index";
}
}
Listing 10-7Reactive IndexController with Data-Driver Variable
ReactiveDataDriverContextVariable是IReactiveDataDriverContextVariable接口的基本实现。使用IReactiveDataDriverContextVariable,,我们将百里香设置为数据驱动模式,这意味着 HTML 项目是以一种反应友好的方式产生的。
index.html模板不需要任何改变。Thymeleaf 使用相同的 HTML 结构迭代集合和反应性数据集。流发出的值被添加到嵌套在可滚动的<div>中的<ul>列表中,以保持页面大小等于屏幕大小。${beans}变量是对百里香数据驱动变量暴露的反应流的引用。
<!-- other HTML parts omitted -->
<div class="scrollable">
<ul th:each="bean : ${beans}">
<li th:text="${bean}"> </li>
</ul>
</div>
Listing 10-8The Thymeleaf Template Snippet to Render the Bean Names (snippet from the index.html file
)
尽管如此,逐渐加载页面并不是最好的选择,因为页面设计的某些部分直到交互通信完成后才会呈现。这使用户感觉好像他们是通过 ADSL 连接浏览这个页面。让我们看看另一种方法,在页面完全加载后,使用反应式处理程序方法和 JavaScript 函数在 HTML div 中逐渐加载这些 bean 名称。
使用服务器发送的事件(SSE)
在前一章中,提到了反应式控制器。大多数反应式控制器都用@RestController注释,这是一个组合注释,标记控制器具有返回数据而不是逻辑视图名称的处理程序方法。IndexController不能被注释,因为我们仍然需要它来解析index.html视图模板。但是,我们可以将 bean flux 提取到一个用@ResponseBody标注的反应处理程序方法中。这导致了清单 10-9 中的实现。
package com.apress.prospringmvc.bookstore.controller;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;
// other imports omitted
@Controller
public class IndexController implements ApplicationContextAware {
private ApplicationContext ctx;
@Override
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
ctx = applicationContext;
}
@GetMapping(path = {"/", "index.htm"})
public String index() {
return "index";
}
@ResponseBody
@GetMapping(value = "/beans", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> getBeanNames() {
List<String> beans = Arrays.stream(ctx.getBeanDefinitionNames())
.sorted().collect(Collectors.toList());
return Flux.fromIterable(beans).delayElements(Duration.ofMillis(200));
}
}
Listing 10-9Reactive IndexController with a Method Handler Returning a Flux<T> of Data
如果您启动该应用,您可以通过运行以下命令来测试该方法是否返回了一系列 bean 名称。
curl -H "Accept:text/event-stream" http://localhost:8080/beans
Spring WebFlux 将每个 bean 名称作为服务器发送的事件发出,现在,index.html需要一些修改来处理它们并在页面上显示它们。因为新数据必须在到达时添加到页面中,所以 JavaScript 需要编写一个函数来修改最少的 beans。
因此,显示 bean 名称所需的index.html模板片段发生了变化,如清单 10-10 所示。
<!-- other HTML/JavaScript parts omitted -->
<script type="text/javascript" th:inline="javascript">
/*<![CDATA[*/
var renderBeans = {
source: new EventSource([[@{|/beans|}]]) ,
start: function () {
this.source.addEventListener("message", function (event) {
//console.log(event);
$("#beans").append('<li>'+ event.data +'</li>')
});
this.source.onerror = function () {
this.source.close();
};
},
stop: function() {
this.source.close();
}
};
$( window ).on( "load", function() {
renderBeans.start();
});
$( window ).on( "onbeforeunload", function() {
renderBeans.stop();
});
/*]]>*/
</script>
<div class="scrollable">
<ul id="beans">
</ul>
</div>
Listing 10-10Thymeleaf template snippet used to display the bean names received as Server-Sent Events (snippet from the index.html file)
[[@{|/beans|}]]是一个百里香链接表达式,用于生成相对于应用上下文的 URL。
在清单 10-10 中,jQuery库用于编写处理服务器发送事件所需的 JavaScript 代码,这是一种服务器推送技术,使客户端能够通过 HTTP 连接从服务器接收自动更新。这意味着页面得到呈现,但连接保持打开,因此服务器可以向客户端发送更多数据。EventSource API 被标准化为 HTML5 的一部分,除了 Internet Explorer,其他所有浏览器都支持它。
百里香叶产生三种类型的小分子物质。
-
头:数据以
head:或{prefix}_head为前缀;其中prefix值通过ReactiveDataDriverContextVariable构造函数设置。前缀用于包含迭代数据(如果有)之前的所有标记的通信期间的单个事件。例如,当您正在读取一个脸书线程时,当您打开页面时,数据库中存在的所有注释,在您打开该页面时的时间戳之前,应该已经呈现在页面中了。没有必要一一渲染。百里香支持这种类型的初始化事件。 -
数据消息:数据以
message:或{prefix}_message为前缀;其中prefix通过ReactiveDataDriverContextVariable构造器设置。前缀用于一系列事件,数据驱动程序产生的每个值对应一个事件。例如,当您阅读脸书帖子时,其他用户在您查看页面时发表的评论会一个接一个地出现在评论部分。来自注释的数据通过 message 类型的 SSE 发送给客户机。 -
Tail :数据以
tail:或{prefix}_tail为前缀,其中prefix值通过ReactiveDataDriverContextVariable构造函数设置。前缀用于通信期间的单个事件,包含迭代数据(如果有)之后的所有标记。例如,假设脸书有一个选项,用户可以通过它选择停止查看新评论,这种类型的事件可以用来发送数据库中所有现有的评论,其时间戳值在最后显示的评论和用户选择停止查看新评论的时间戳之间。 4
在前面的代码片段中,/beans URL 被用作 SSE 流的源。使用 URL 创建一个EventSource 5 实例,它打开一个持久连接,服务器通过该连接以text/event-stream格式发送事件。连接保持打开,直到通过调用EventSource.close()关闭。那些事件被 Spring WebFlux 标记为message事件,并在EventSource实例上设置一个EventListener 6 实例来拦截那些事件,提取数据,并将其添加到一个 HTML 页面中。
bean names 流被有意减慢,以显示连续的通信。如果使用 Chrome 或 Firefox,在加载页面时,可以在开发者控制台中看到服务器发送的事件。从EventListener实例体中删除console.log(event)语句的注释。在图 10-1 中,书店应用的主页在 Firefox 中打开,你可以在开发者控制台中看到从服务器发送的数据流。

图 10-1
服务器发送的事件显示在 Firefox 的开发人员控制台中
使用服务器发送的事件将反应数据显示到百里香模板中的另一种方法是使用一个IReactiveSSEDataDriverContextVariable上下文变量。实现该接口的上下文变量以反应数据流的形式包装Publisher<T>实例,这意味着以 SSE(服务器发送事件)模式驱动模板的反应友好执行。 7 这意味着 Spring WebFlux 不会将数据包装成 SSEs 相反,它会将它们发送到百里香引擎来执行此操作。
在 Spring WebFlux 应用使用百里香叶引擎生成视图的上下文中,这意味着发出的每个值都被映射到百里香叶模板片段,百里香叶视图的一部分。当发出一个值时,百里香引擎获取数据并将其封装在片段描述的 HTML 元素中,然后作为类型为message的 SSE 发出。然后使用 JavaScript 函数将事件数据注入 HTML 页面。清单 10-11 是百里香模板的一个片段。它显示了一个名为newBooks. N的<div/>元素,其内部是一个只有一行的表格,包含即将发行的一本书的详细信息。
<!-- other HTML parts omitted -->
<div class="releases_box">
<div class="title">
<span class="title_icon">
<img th:src="@{/statimg/release.ico}" alt="" title="" />
</span>
<th:block th:text="#{main.title.newbooks}">New Books</th:block>
</div>
<div id="newBooks">
<!-- /start/ the targeted fragment -->
<table th:each="book : ${newBooks}" class="releases_table">
<tr>
<td
th:text="${book.year} + ', ' + ${book.title} + ', by ' + ${book.author}">
</td>
</tr>
</table>
<!-- /end/ the targeted fragment -->
</div>
</div>
Listing 10-11Thymeleaf Template Snipped to Display the Bean Names Received As Server-Sent Events (snippet from the search.html file)
表定义表示每次发出值时由百里香引擎重写的片段。这是使用类似于清单 10-10 中描述的 JavaScript 函数完成的,只是这个函数用事件数据覆盖了newBooks div 的 HTML 内容。清单 10-12 中描述了包含实现所需反应行为的函数的renderBooks变量。
// other HTML/JavaScript parts omitted
var renderBooks = {
source: new EventSource([[@{|/book/new|}]]) ,
start: function () {
renderBooks.source.addEventListener("message", function (event) {
//console.log(event);
$("#newBooks").html(event.data);
});
renderBooks.source.onerror = function () {
this.close();
};
},
stop: function() {
this.source.close();
}
};
Listing 10-12The JavaScript Function That Provides the View Reactive Behavior (snippet from the search.html file)
映射到/book/new URL 的处理程序方法是BookSearchController的一部分,与用于在IndexController中提供 beans 名称流的方法没有什么不同。主要有两个区别。第一个区别是IReactiveSSEDataDriverContextVariable被用作数据驱动上下文变量的引用类型。这就是 Thymeleaf 被告知我们希望模板以 SSE(服务器发送事件)模式执行的方式。
第二个区别是方法返回的逻辑视图名,该名称必须包含百里香模板片段的标识符,该模板片段应在作为 SSE 发送之前应用于流发出的每个元素。逻辑视图名称必须遵循以下语法:templateName :: #fragmentIdentifier。清单 10-13 中的上下文变量没有声明显式前缀,这意味着事件被标记为具有message类型。
package com.apress.prospringmvc.bookstore.controller;
import org.thymeleaf.spring5.context.webflux.IReactiveSSEDataDriverContextVariable;
import org.thymeleaf.spring5.context.webflux.ReactiveDataDriverContextVariable;
//other imports omitted
@Controller
public class BookSearchController {
// Generates random books to be displayed
@GetMapping( value = "/book/new", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public String newBooks(final Model model){
Flux<Book> newReleases = Flux.interval(Duration.ofSeconds(3))
.map(delay -> BookNewReleasesUtil.randomRelease());
final IReactiveSSEDataDriverContextVariable dataDriver =
new ReactiveDataDriverContextVariable(newReleases, 1);
model.addAttribute("newBooks", dataDriver);
return "book/search :: #newBooks";
}
}
// other code omitted
Listing 10-13Handler Method in BookSearchController Declaring a IReactiveSSEDataDriverContextVariable
将所有这些组件放在一起,您会得到一个每 3 秒钟显示一本新书的页面。如果使用 Chrome 或 Firefox,在加载页面时,可以在开发者控制台中看到服务器发送的事件。从EventListener实例体中删除console.log(event)语句的注释。在图 10-2 中,书店应用的search页面在 Firefox 中打开,你可以在开发者控制台中看到服务器发送的数据流。注意,数据是一个与名为newBooks,的模板片段匹配的 HTML 片段,其中百里香叶变量被替换为发出的值。

图 10-2
服务器发送的事件显示在 Firefox 的开发人员控制台中
我们提到前缀支持 SSE。当您需要在同一个页面上有多个反应片段时,前缀非常有用。在这种情况下,前缀将 se 映射到同一模板中的不同片段。这是必需的,因为即使两个发布者在同一台服务器上发出事件,他们也是通过同一 HTTP 连接来完成的。需要两个事件侦听器来拦截不同类型的事件并相应地处理它们。
在search.html模板中,如果我们添加一个显示技术新闻的新部分,我们必须向两个上下文数据驱动变量添加前缀,以便基于它们进行过滤,并定向到适当的模板片段。newBooks用作指向newBooks div 的服务器事件的前缀,techNews用作显示技术新闻的名为techNews的 div 的前缀。两个 div 元素的 HTML 和 JavaScript 基本相同。如果你想看看发送给客户的 SSEs 是什么样的,请看图 10-3 。

图 10-3
服务器发送的带有前缀名称的事件显示在 Firefox 的开发人员控制台中
在前面的例子中,书籍和科技新闻是由一个流提供的,该流从一个固定的集合中随机发出一个条目。不过,有人提到,当来自多个服务的数据被聚合时,反应式应用是一个很好的选择。对于更接近现实的情况,newBooks div 的数据可以由书店应用外部的反应式服务提供,或者可能更多地属于图书出版商(比如 Apress)。用于techNews div 的数据可以由某个公共技术聚合器应用的反应服务提供。从那些流发出的数据的处理程序方法的实现看起来很像清单 10-13 中的代码,所以这里不再重复。
Spring WebFlux 引入了一些东西,可以毫不费力地与其他反应式服务集成,这将在下一节讨论。
介绍 WebClient 和 WebTestClient
在 Spring WebFlux 之前,使用org.springframework.web.client.RestTemplate可以发出 HTTP 请求。这个客户端是同步的,通过 HTTP 方法为常见场景提供模板。现在已经废弃的AsyncRestTemplate是后来在同一个包中引入的,以支持异步 HTTP 请求,只是被WebClient所取代。对于测试来说,org.springframework.boot.test.web.client.TestRestTemplate仍然可以用于同步 HTTP 请求。
Spring WebFlux 提供了一个反应式的、非阻塞的 HTTP 客户端,通过org.springframework.web.reactive.function.client.WebClient接口公开了一个非常实用的 API。提供了单个实现,org.springframework.web.reactive.function.client.DefaultWebClient。在幕后,它使用在类路径上找到的 HTTP 客户端,比如 Reactor Netty。这是从您的反应式应用访问其他反应式服务的实用工具。WebClient接口提供了两个静态方法来创建一个WebClient实例——都被命名为create。其中一个调用接收应用的基本 URL 作为参数,所有后续调用都可以简化,因为它们的 URL 被认为是相对于基本 URL 的。
清单 10-14 显示了一个使用静态create()方法创建的web client。然后,实例对在http://localhost:8080/randomBookNews公开的反应服务进行 GET REST 调用。这个服务只不过是同一个应用中的一个功能端点,它返回无限的Book实例流。
package com.apress.prospringmvc.bookstore.controller;
import org.springframework.web.reactive.function.client.WebClient;
//other imports omitted
@Controller
public class BookSearchController {
@GetMapping( value = "/book/new",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public String newBooks(final Model model){
// previous implementation
//Flux<Book> newReleases = Flux.interval(Duration.ofSeconds(5))
// .map(delay -> BookNewReleasesUtil.randomRelease());
WebClient webClient = WebClient.create();
Flux<Book> newReleases = webClient
.get().uri("http://localhost:8080/randomBookNews")
.retrieve()
.bodyToFlux(Book.class);
final IReactiveSSEDataDriverContextVariable dataDriver =
new ReactiveDataDriverContextVariable(newReleases, 1, "newBooks");
model.addAttribute("newBooks", dataDriver);
return "book/search :: #newBooks";
}
//other code omitted
}
Listing 10-14Using WebClient Without a Base URL Within the BookSearchController
另一种方法是使用完整的 URL 来创建WebClient实例。然后,所有使用该实例的 HTTP 请求只能使用相对于 baseURL 的 URL 的一部分。清单 10-15 显示了一个使用静态create(String)方法创建的WebClient。
WebClient webClient = WebClient.create("http://localhost:8080/randomBookNews");
Flux<Book> newReleases = webClient.get().uri("/")
.retrieve()
.bodyToFlux(Book.class);
Listing 10-15Using WebClient with a Base URL
创建WebClient的另一种方法是使用通过调用WebClient.builder()方法返回的构建器实例。这允许对WebClient实例进行更细粒度的配置。使用构建器时,您可以设置头、cookies、额外的操作符来定制返回值,甚至可以设置不同的客户端连接器。默认使用(ReactorClientHttpConnector 8 )。它是由 Spring WebFlux 提供的,但是你也可以使用一个反应式的 Apache CloseableHttpAsyncClient, 9 为例)。这意味着清单 10-15 中的WebClient声明可以像清单 10-16 中描述的那样编写。
WebClient webClient = WebClient.builder()
.baseUrl("http://localhost:8080/randomBookNews")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE)
.defaultCookie("InternalCookie", "all")
.build();
Listing 10-16Creating WebClient Using a Builder
不管是用什么方式创建的,WebClient实例都是不可变的;但是,它支持一个clone()方法,该方法返回一个构建器,该构建器可以基于原始实例创建一个新实例。
WebClient非常灵活,允许创建复杂的结构,支持使用带有路径变量和请求参数的 URL 进行 HTTP 请求。也支持 URL 生成器和 URL 编码。我们快速复习一些重要的方法。对于可用 API 的完整描述,请随意查阅官方文档,这非常好。 10
retrieve()方法获得一个 HTTP 响应。在前面的例子中,这个方法后面是对bodyToMono(Class)或bodyToFlux(Class)的调用。两者都将发出的值的类型作为参数接收。但是,这个方法后面也可以跟一个对onStatus(..),的调用,并且可以发出异常,这些异常被封装到WebClientResponseException对象中,这取决于 HTTP 状态代码。清单 10-17 描述了这样一个例子。
Flux<Book> newReleases = webClient.get()
.uri("/")
.retrieve()
.onStatus(HttpStatus::is4xxClientError, response ->
Mono.error( response.statusCode() == HttpStatus.UNAUTHORIZED
? new ServiceDeniedException("You shall not pass!")
: new ServiceDeniedException("Well.. this is unfortunate!"))
)
.onStatus(HttpStatus::is5xxServerError, response ->
Mono.error(response.statusCode() == HttpStatus.INTERNAL_SERVER_ERROR
? new ServiceDownException("This is SpartAAA!!")
: new ServiceDownException("Well.. this is a mystery!"))
)
.bodyToFlux(Book.class);
Listing 10-17Using WebClient with Customized Error Behavior, Using the onStatus(..) Method
如果响应应该有内容,那么它应该由匹配状态代码谓词的函数使用;否则,它的内容将被丢弃以释放资源。
retrieve()法可与get()、post()、put()、delete()等配合使用。还有一个exchange()方法,它提供了更细粒度的控制。例如,exchange()方法提供了对响应的访问,这允许您检查响应头、cookies,或者以任何必要的方式更改它。缺点是它不支持基于 HTTP 状态代码的定制行为。
关于WebClient的一件很酷的事情是,如果它的输出可以被正确地反序列化,它可以与使用任何其他技术开发的服务一起使用。为了证明这一点,我们使用 Node.js 实现了提供科技新闻的服务。 11 清单 10-18 描述了该服务的实现。
const http = require('http');
const sys = require('util');
const fs = require('fs');
const hostname = 'localhost';
const port = 3000;
const news = [
'Apress merged with Springer.',
// other values omitted
];
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/event-stream;charset=UTF-8');
res.setHeader('Cache-Control', 'no-cache');
// only if you want anyone to access this endpoint
res.setHeader('Access-Control-Allow-Origin', '*');
res.flushHeaders();
// Sends a SSE every 2 seconds on a single connection.
setInterval(function() {
res.write('data:'+news[Math.floor(Math.random() * news.length)] + '\n\n');
}, 2000);
});
server.listen(port, hostname, () => {
console.log(`Event stream available at http://${hostname}:${port}/techNews`);
});
Listing 10-18The tech-news.js
Service That Generates an Infinite Stream of Random Tech News
js 库是 JavaScript 函数的集合,可以用几行代码创建一个 web 服务器。作为参数提供给http.createServer的函数每 2 秒发出一个随机文本。
当用任何技术编写反应式服务时,可以用curl命令检查输出。使用-v选项获得服务发送内容的详细描述。这揭示了编写客户端所需的重要信息,例如媒体类型和编码以及发送信息的格式。
清单 10-19 展示了 curl 命令和参数,用于检查 Node.js 技术新闻服务的响应及其在终端中的输出。
$ curl http://localhost:3000/techNews -v
* Connection failed
* connect to ::1 port 3000 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /techNews HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/event-stream;charset=UTF-8
< Cache-Control: no-cache
< Access-Control-Allow-Origin: *
< Date: Thu, 30 Jul 2020 11:12:54 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
data:Amazon launches reactive API for DynamoDB.
data:Java 17 will be released in September 2021.
...
Listing 10-19Node.js Service Output Returned by the curl Command
WebClient对于检索由其他服务产生的数据是实用的,但是更实用的是它的用于编写集成测试的版本:WebTestClient。org.springframework.test.web.reactive.server.WebTestClient是org.springframework.boot.test.web.client.TestRestTemplate的无功当量。它可以测试控制器和功能端点,它本质上包装了WebClient,并为其提供了测试环境。WebTestClient提供与WebClient相同的 API,但也支持对返回响应的测试假设。
清单 10-20 描述了检查搜索书籍匹配标准的 POST 请求实现的测试方法。
package com.apress.prospringmvc.bookstore.web;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.junit.jupiter.api.Assertions.assertEquals;
// other imports omitted
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookstoreWebTest {
private static Logger logger = LoggerFactory.getLogger(BookstoreWebTest.class);
@Autowired
private WebTestClient testClient;
@Test
public void shouldReturnTwoBooks(){
BookSearchCriteria criteria = new BookSearchCriteria();
criteria.setCategory(Book.Category.JAVA);
testClient.post()
.uri("/book/search")
.accept(MediaType.APPLICATION_JSON)
.body(Mono.just(criteria), BookSearchCriteria.class)
.exchange()
.expectStatus().isOk() /* test */
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBodyList(Book.class)
.consumeWith(
result -> {
assertEquals(2, result.getResponseBody().size());
result.getResponseBody().forEach(p ->
logger.info("Response: {}",p));
});
}
}
Listing 10-20WebTestClient to Test a POST Request with Consumers
测试在测试应用上下文中运行,/book/search请求是针对在http://localhost:{mockPort}可用的模拟服务器发出的。注意方法的链接,这些方法也可以使用WebClient。WebTestClient部分在exchange()呼叫之后开始。之后的所有三种方法都测试请求的假设。
-
expectStatus().isOk()检查 HTTP 状态代码是否为 200。 -
expectHeader().contentType(MediaType.APPLICATION_JSON)检查响应的媒体类型是否为 JSON。 -
expectBodyList(Book.class)检查响应的主体是否包含一组Book实例。 -
作为一个参数,提供了一个消费者函数,它检查集合的大小是否为 2,并打印集合的每个成员。
方法允许开发者指定消费者使用他们觉得舒服的任何测试库来测试请求体。清单 10-20 中的实现可能被认为是冗长的。测试方法不需要打印结果体,这使得检查集合大小成为唯一需要的验证。在这种情况下,可以去掉consumeWith(..),用hasSize(2)代替。
使用这个实例的另一种方法值得一提。WebTestClient支持使用 JsonPath 12 表达式制作主体断言。对于包含 JSON 内容的响应,这是很实用的,我们不希望将这些内容反序列化为 Java 对象,或者在应用中没有相应的类。在清单 10-21 中,检查响应体的预期属性和预期值,而不将响应体反序列化为Book实例。
package com.apress.prospringmvc.bookstore.web;
// imports omitted
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookstoreWebTest {
private static Logger logger = LoggerFactory.getLogger(BookstoreWebTest.class);
@Autowired
private WebTestClient testClient;
@Test
public void shouldReturnBook(){
testClient.get()
.uri(uriBuilder -> uriBuilder.path("/book/isbn/{isbn}")
.build("9781484237779"))
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.title").isNotEmpty()
.jsonPath("$.author").isEqualTo("Iuliana Cosmina");
}
}
Listing 10-21WebTestClient to Test a GET Request Using JsonPath Expressions
WebTestClient可以使用bindToServer()方法测试真实的服务器。这很好,因为它可以测试使用其他技术开发的服务。
清单 10-22 描述了WebTestClient,的创建,它可以在运行于http://localhost:8080的真实应用上运行之前的测试方法。
private final WebTestClient testClient = WebTestClient
.bindToServer()
.baseUrl("http://localhost:8080")
.build();
Listing 10-22WebTestClient Suitable to Test a GET Request on a Real Server
WebTestClient测试 API 比较丰富,写测试 13 的时候要参考官方文档,因为本书不可能全部涵盖。
国际化
使用反应式应用时的一个热门话题是国际化。Spring MVC 提供了一种非常简单的配置国际化的方法。
-
创建一个实现
WebMvcConfigurer的配置类。 -
用
@EnableWebMvc注释配置类(如果使用 Spring Boot,则不要注释)。 -
创建翻译属性文件。
-
声明一个
MessageSourcebean 并用它们的位置对其进行配置。 -
声明一个
LocaleChangeInterceptorbean,根据附加到请求的lang参数的值将交换机配置到一个新的语言环境。 -
声明一个
LocaleResolverbean 来配置一个语言环境解析策略。
可能会有很多步骤,但并不是所有的步骤都是必需的,尤其是在 Spring Boot 应用中,当遵循惯例时。
由于有了WebHandler API,配置国际化支持变得更加容易。首先,让我们讨论默认方式,它依赖于Accept-Language头。
使用 Accept-Language 头的国际化支持
用@EnableWebFlux注释配置类从org.springframework.web.reactive.config.DelegatingWebFluxConfiguration导入 Spring WebFlux 配置。如果应用上下文的定制是必要的,比如国际化支持,那么可以扩展这个类,并且重写一些方法。Spring WebFlux 应用上下文被org.springframework.web.server.adapter.WebHttpHandlerBuilder用来组装一个处理链,该处理链由一个WebHandler实例组成,用一组WebFilter实例和WebExceptionHandler实例来修饰。默认情况下,WebHttpHandlerBuilder将org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver配置为支持地区上下文解析。类名(AcceptHeaderLocaleContextResolver)给出了它所描述的语言环境上下文解析策略的一个重要提示:语言环境是从 HTTP 请求的Accept-Language头中识别的。
Accept-Language请求 HTTP 头通告了客户机可以理解哪些语言以及首选哪种语言环境变量。浏览器根据他们的用户界面语言设置这个头,用户很少改变默认设置。当发出 REST 请求时,可以很容易地更改这个参数。每个响应都被翻译成请求中为这个头设置的语言值。
在 Spring WebFlux 引导应用中,添加语言资源文件,声明一个MessageSource bean,并配置它们的位置,就足以支持使用 Accept 头的国际化。
使用请求参数和 LocaleContextResolver 的自定义实现的国际化支持
大多数 web 应用支持使用请求参数的国际化。为了在 Spring WebFlux 应用中使用请求参数提供国际化支持,必须在配置中添加一个定制的LocaleContextResolver实现来替换默认的AcceptHeaderLocaleContextResolver(实现相同的接口)。这是通过扩展DelegatingWebFluxConfiguration并覆盖createLocaleContextResolver()方法来返回自定义LocaleContextResolver的实例来实现的。
清单 10-23 中描述了使用请求参数支持国际化的建议实现。
package com.apress.prospringmvc.bookstore.config.i18n;
import org.springframework.context.i18n.LocaleContext;
import org.springframework.context.i18n.SimpleLocaleContext;
import org.springframework.util.CollectionUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.i18n.LocaleContextResolver;
import java.util.List;
import java.util.Locale;
public class RequestParamLocaleResolver implements LocaleContextResolver {
private String languageParameterName;
public RequestParamLocaleResolver(final String languageParameterName) {
this.languageParameterName = languageParameterName;
}
@Override
public LocaleContext resolveLocaleContext(final ServerWebExchange exchange) {
Locale defaultLocale = Locale.getDefault();
List<String> referLang = exchange.getRequest().getQueryParams().get(languageParameterName);
if (!CollectionUtils.isEmpty(referLang) ) {
String lang = referLang.get(0);
defaultLocale = Locale.forLanguageTag(lang);
}
return new SimpleLocaleContext(defaultLocale);
}
}
Listing 10-23Custom LocaleContextResolver Resolving Locale Using a Request Parameter
为了配置 Spring WebFlux 来使用这个实现,我们需要添加扩展DelegatingWebFluxConfiguration的配置类。实现很简单,如清单 10-24 所示。
package com.apress.prospringmvc.bookstore.config;
import com.apress.prospringmvc.bookstore.config.i18n.RequestParamLocaleResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.config.DelegatingWebFluxConfiguration;
import org.springframework.web.server.i18n.LocaleContextResolver;
@Configuration
public class LocaleSupportConfig extends DelegatingWebFluxConfiguration {
@Override
protected LocaleContextResolver createLocaleContextResolver() {
return new RequestParamLocaleResolver("lang");
}
}
Listing 10-24Custom LocaleContextResolver Resolving Locale Using a Request Parameter
语言参数名称应该是可配置的,但是请记住,视图中区域设置更改的 URL 需要匹配。前面的实现的缺点是,只有在以?lang=XX为后缀的情况下,所需的语言环境才会应用于请求。这是因为区域设置不会保存在任何地方。在 Spring MVC 应用中,我们使用CookieLocaleResolver创建一个区域 cookie 并读取它来识别用户配置的区域。这允许应用使用不同于浏览器中配置的语言环境。CookieLocaleResolver是org.springframework.web.servlet.i18n包的一部分,Spring WebFlux 没有这样的解析器。
很容易修改前面的RequestParamLocaleResolver来添加 cookie 支持,因为它可以通过ServerWebExchange访问请求和响应。清单 10-25 描述了LocaleContextResolver的一个实现,它将所需的地区存储在一个 cookie 中,生存期为五分钟。
package com.apress.prospringmvc.bookstore.config.i18n;
import org.springframework.http.HttpCookie;
import org.springframework.http.ResponseCookie;
// other imports omitted
public class CookieParamLocaleResolver implements LocaleContextResolver {
public static final String LOCALE_REQUEST_ATTRIBUTE_NAME = "Bookstore.Cookie.LOCALE";
private String languageParameterName;
public CookieParamLocaleResolver(final String languageParameterName) {
this.languageParameterName = languageParameterName;
}
@Override
public LocaleContext resolveLocaleContext(final ServerWebExchange exchange) {
List<String> referLang = exchange.getRequest().getQueryParams().get(languageParameterName);
Locale defaultLocale = getLocaleFromCookie(exchange);
if (!CollectionUtils.isEmpty(referLang) ) {
String lang = referLang.get(0);
defaultLocale = Locale.forLanguageTag(lang);
setLocaleToCookie(lang, exchange);
}
return new SimpleLocaleContext(defaultLocale);
}
private void setLocaleToCookie(final String lang, final ServerWebExchange exchange) {
MultiValueMap<String, HttpCookie> cookies = exchange.getRequest().getCookies();
HttpCookie langCookie = cookies.getFirst(LOCALE_REQUEST_ATTRIBUTE_NAME);
if(langCookie == null || !lang.equals(langCookie.getValue())) {
ResponseCookie cookie = ResponseCookie.from(LOCALE_REQUEST_ATTRIBUTE_NAME, lang)
.maxAge(Duration.ofMinutes(5)).build();
exchange.getResponse().addCookie(cookie);
}
}
private Locale getLocaleFromCookie(final ServerWebExchange exchange){
MultiValueMap<String, HttpCookie> cookies = exchange.getRequest().getCookies();
HttpCookie langCookie = cookies.getFirst(LOCALE_REQUEST_ATTRIBUTE_NAME);
return langCookie != null ? Locale.forLanguageTag(langCookie.getValue()) : Locale.getDefault();
}
}
Listing 10-25Custom LocaleContextResolver Resolving Locale Using a Request Parameter
使用请求参数和自定义实现 WebFilter 的国际化支持
使用定制的web filter实现国际化支持是一个非常优雅的解决方案,因为它不需要对 WebFlux 配置进行任何显式的修改。自定义WebFilter可以简单地声明为一个 bean。作为WebHttpHandlerBuilder应用上下文,的一部分,国际化过滤器被提取并添加到应用于每个请求的 web 过滤器集合中。
这种实现的缺点是区域设置不能保存在任何地方,所以如果我们想将区域设置保存到 cookie 或用户会话中,就必须编写额外的代码。这并不困难,因为WebFilter也可以通过ServerWebExchange访问请求和响应。
清单 10-26 中的实现不完全是我的。一个叫 Jonathan Mendoza 的开发者把它贴在了 StackOverflow 上,除了添加 cookies 支持,我没有别的办法可以对它进行改进。 14 我们之前称这个实现是最优雅的,因为它使用了默认的AcceptHeaderLocaleContextResolver,它只不过是拦截请求并用Accept-Language头来修饰它。该值取自请求参数。如果区域设置 cookie 不存在,则创建它。如果没有语言请求参数,则该值取自 cookie 或应用的默认值(如果 cookie 不存在)。
为了减少本书的篇幅,清单 10-26 只描述了LanguageQueryParameterWebFilter中的核心方法。完整的实现在包含本书代码的库中。
package com.apress.prospringmvc.bookstore.util;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import org.springframework.web.server.adapter.DefaultServerWebExchange;
import org.springframework.web.server.adapter.HttpWebHandlerAdapter;
// other imports omitted
@Component
public class LanguageQueryParameterWebFilter implements WebFilter {
// other code omitted
@Override
public Mono<Void> filter(final ServerWebExchange exchange, final WebFilterChain chain) {
final ServerHttpRequest request = exchange.getRequest();
final MultiValueMap<String, String> queryParams = request.getQueryParams();
final String languageValue = queryParams.getFirst("lang");
final ServerWebExchange localizedExchange =
getServerWebExchange(languageValue, exchange);
return chain.filter(localizedExchange);
}
private ServerWebExchange getServerWebExchange(final String languageValue,
final ServerWebExchange exchange) {
return isEmpty(languageValue)
? getLocaleFromCookie(exchange)
: getLocalizedServerWebExchange(languageValue, exchange);
}
private ServerWebExchange getLocalizedServerWebExchange(final String languageValue,
final ServerWebExchange exchange) {
setLocaleToCookie(languageValue, exchange);
final ServerHttpRequest httpRequest = exchange.getRequest()
.mutate()
.headers(httpHeaders -> httpHeaders.set("Accept-Language", languageValue))
.build();
return new DefaultServerWebExchange(httpRequest, exchange.getResponse(),
httpWebHandlerAdapter.getSessionManager(),
httpWebHandlerAdapter.getCodecConfigurer(),
httpWebHandlerAdapter.getLocaleContextResolver());
}
// setLocaleToCookie & getLocaleFromCookie are pretty similar to Listing 10-25.
}
Listing 10-26Custom WebFilter Resolving Locale Using a Request Parameter
验证、类型转换和错误处理
因为 Spring WebFlux 应用可以使用反应式控制器来构建,所以它以与 Spring MVC 应用相同的方式支持验证、类型转换和错误处理。
控制器参数支持类似于@Valid(来自javax.validation包)及其 Spring 等价物@Validated(来自org.springframework.validation.annotation包)的验证。要配置全局Validator,,必须配置一个类型为org.springframework.validation.beanvalidation.LocalValidatorFactoryBean的 bean。如果缺少这样的 bean,那么默认情况下会声明一个名为webFluxValidator的类型为org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean的 bean。OptionalValidatorFactoryBean是LocalValidatorFactoryBean的子类,是一个伪验证器。它没有声明要执行的任何验证。为了通知在启动 Spring WebFlux 应用时不支持验证,清单 10-27 中的调试消息被打印在日志文件中。
DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating shared instance of singleton bean 'webFluxValidator'
DEBUG o.s.v.b.OptionalValidatorFactoryBean - Failed to set up a Bean Validation provider
javax.validation.NoProviderFoundException: Unable to create a Configuration because no Bean Validation provider could be found. Add a provider like Hibernate Validator (RI) to your classpath.
at javax.validation.Validation$GenericBootstrapImpl.configure(Validation.java:291)
Listing 10-27Debug Messages Printed in the Log File When Validation for a Spring WebFlux Application Is Not Supported
配置一个Validator bean 就像覆盖配置类正在实现的WebFluxConfigurer接口中声明的getValidator()默认方法一样简单。这个方法应该返回一个类型为Validator的 bean。这个 bean 从类路径中选择一个 bean 验证提供者,所以应该将一个具有这样一个提供者的库,比如Hibernate Validator库,添加到项目的依赖项中。
自定义转换器和格式化程序也是如此。WebFluxConfigurer接口声明了默认的addFormatters(FormatterRegistry),它可以注册定制的转换器和格式化程序。(这与WebMvcConfigurer接口为 Spring MVC 应用所做的是一样的。)清单 10-28 显示了来自ReactiveThymeleafWebConfig的一个片段,其中包括一个Validator bean 和一个日期格式化程序配置。
package com.apress.prospringmvc.bookstore.config;
import com.apress.prospringmvc.bookstore.util.formatter.DateFormatAnnotationFormatterFactory;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.format.FormatterRegistry;
// other import omitted
@Configuration
@EnableWebFlux
public class ReactiveThymeleafWebConfig implements WebFluxConfigurer {
@Bean
public Validator validator() {
final var validator = new LocalValidatorFactoryBean();
validator.setValidationMessageSource(messageSource());
return validator;
}
@Override
public Validator getValidator() {
return validator();
}
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addFormatterForFieldAnnotation(new DateFormatAnnotationFormatterFactory());
}
// other code omitted
}
Listing 10-28Validator Bean and a Date Formatter Configuration for a Spring WebFlux Application
在 Spring WebFlux 应用中,任何与控制器相关的东西都以与 Spring MVC 应用相同的方式进行配置和工作。唯一需要开发人员额外工作的情况是对功能端点进行验证。
功能端点表示将请求映射到org.springframework.web.reactive.function.server.HandlerFunction<T extends ServerResponse>的方式。A handler function将一个org.springframework.web.reactive.function.server.ServerRequest作为参数,并返回一个由Mono<org.springframework.web.reactive.function.server.ServerResponse>返回类型表示的延迟响应。一个处理函数相当于一个@RequestMapping注释方法;不幸的是,它不支持像这类方法那样用注释@Valid和@Validated标记验证的参数。由于这个小缺点,验证必须由开发人员在函数体中配置。
对于一个Book对象,应该声明一个实现org.springframework.validation.Validator的BookValidator类来测试标题、作者、ISBN 和类别是否为空。这个类在清单 10-29 中有描述。
package com.apress.prospringmvc.bookstore.util.validation;
import com.apress.prospringmvc.bookstore.document.Book;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
public class BookValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return (Book.class).isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
ValidationUtils.rejectIfEmpty
(errors, "title", "required", new Object[] { "Title" });
ValidationUtils.rejectIfEmpty
(errors, "author", "required", new Object[] { "Author" });
ValidationUtils.rejectIfEmpty
(errors, "isbn", "required", new Object[] { "Isbn" });
ValidationUtils.rejectIfEmpty(
errors, "category", "required", new Object[] { "Category" });
}
}
Listing 10-29BookValidator Class
to Validate Book Instances
处理发送一个Book对象到数据库的 PUT/POST 请求的处理函数应该首先验证Book实例,如果验证失败就抛出ServerWebInputException。对于任何包含不可接受数据的请求,都应该抛出这种类型的异常,因为它会自动将 HTTP 状态代码设置为 400(错误请求)并返回Errors对象,让用户知道问题出在哪里。清单 10-30 描述了一个BookHandler类,它包含了处理创建Book实例的 POST 请求所需的所有代码。
package com.apress.prospringmvc.bookstore.handler;
import com.apress.prospringmvc.bookstore.util.validation.BookValidator;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import org.springframework.web.server.ServerWebInputException;
import static org.springframework.web.reactive.function.server.ServerResponse.*;
import javax.validation.ValidationException;
// other imports omitted
@Component
public class BookHandler {
private BookstoreService bookstoreService;
private final Validator validator = new BookValidator();
public BookHandler(BookstoreService bookstoreService) {
this.bookstoreService = bookstoreService;
}
public Mono<ServerResponse> create(ServerRequest serverRequest) {
return serverRequest.bodyToMono(Book.class)
.flatMap(this::validate)
.flatMap(book -> bookstoreService.addBook(book))
.flatMap(book -> ServerResponse.created(URI.create("/book/isbn/" + book.getIsbn()))
.contentType(MediaType.APPLICATION_JSON).bodyValue(book))
.onErrorResume(error -> ServerResponse.badRequest().bodyValue(error));
}
private Mono<Book> validate(Book book) {
Errors errors = new BeanPropertyBindingResult(book, "book");
validator.validate(book, errors);
if (errors.hasErrors()) {
throw new ValidationException(errors.toString());
}
return Mono.just(book);
}
// other handler functions emitted
}
Listing 10-30BookHandler Class
to Validate Book Instances
这种方法有点粗糙,因为ValidationException将消息设置为全文,导致将Errors对象转换为String。ValidationException被onErrorResume(..)函数拦截,允许进一步配置响应。如果没有onErrorResume(..),由 Spring Boot 自动配置的默认错误处理程序 bean 会捕捉异常并生成默认响应。这个 bean 被命名为errorWebExceptionHandler,,它的类型是DefaultErrorWebExceptionHandler,,这是 Spring Boot 提供的默认实现。图 10-4 描述了WebExceptionHandler的层次结构。

图 10-4
WebExceptionHandler等级制度
该 bean 返回的响应是一个通用 JSON 表示对象,包含 HTTP 状态代码 400(错误请求)、URI 路径和一个字母数字请求标识符。测试验证应用的最简单的方法是使用WebTestClient编写一个阴性测试。清单 10-31 描述了一个测试,假设创建一个Book实例失败,并返回一个带有 HTTP 状态代码的响应。因为查看响应细节很有趣,所以添加了一个消费者来打印它。
package com.apress.prospringmvc.bookstore.api;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.reactive.server.WebTestClient;
//other imports omitted
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookApiTest {
private static Logger logger = LoggerFactory.getLogger(BookApiTest.class);
@Autowired
private WebTestClient testClient;
@Test
void shouldFailCreatingABook() {
// no isbn, no category
Book book = new Book();
book.setTitle("TDD for dummies");
book.setAuthor("Test User");
testClient.post().uri("/book/isbn")
.body(Mono.just(book), Book.class).exchange()
.expectStatus().isBadRequest() // 400
.expectBody()
.consumeWith(responseEntity ->
logger.debug("Response: {}", responseEntity)
);
}
}
Listing 10-31Test Method Overing a Validation Failure When a Request Is Made for Creating a Book Instance
前面的测试通过,没有创建Book实例,因为ISBN和category丢失了。响应 HTTP 代码是 400,正如控制台中打印的响应细节所证明的,您可以在清单 10-32 中看到。响应详细信息包括验证失败的对象。一些 JSON 行被删除了,因为输出太冗长,不能作为本书的一部分,但是验证细节被保留了下来。
DEBUG c.a.p.bookstore.api.BookApiTest - Response:
> POST http://localhost:51164/book/isbn
> WebTestClient-Request-Id: [1]
> Content-Type: [application/json]
> Content-Length: [132]
{
"id":null,"title":"TDD for dummies",
"description":null,
"price":null,
"year":null,
"author":"Test User",
"isbn":null
}
< 400 BAD_REQUEST Bad Request
< Vary: [Origin, Access-Control-Request-Method, Access-Control-Request-Headers]
< Content-Type: [application/json]
< Content-Length: [10831]
#response body starts here
{
# other JSON code omitted
"message": "[
Field error in object 'book' on field 'isbn':
rejected value [null];
codes [required.book.isbn,required.isbn,
required.java.lang.String,required];
arguments [Isbn];
Field error in object 'book' on field 'category':
rejected value [null];
codes [required.book.category,required.category,
required.java.lang.String,required];
arguments [Category];
}
Listing 10-32Response Details of a Failed Request to Create a Book Instance
在前面的清单中,您可以看到显式输出将您指向缺少值的必需字段。输出有些冗长,因为它是由BookValidator实例创建的Error实例的String表示。失败时返回的响应可以通过用其他东西替换Error实例来定制。
功能端点的验证处理很简单,依赖于将验证操作符添加到处理从ServerRequest实例中检索的对象的管道中。错误处理可以用同样的方式完成——在使用ServerResponse返回之前,添加一个操作符来处理管道处理对象中发出的错误。从开发的角度来看,最简单的方法是声明一个自定义错误对象或自定义异常类型,并尽可能依赖默认的错误处理程序。
对于 Spring Boot WebFlux 应用中错误处理行为的更细粒度定制,可以提供WebExceptionHandler或ErrorWebExceptionHandler的实现。然而,由于级别太低,您必须直接处理请求/响应交换,这可能会很痛苦。定制的错误处理 bean 必须用@Order(-2)进行配置和注释,以优先于WebFluxResponseStatusExceptionHandler和 Spring Boot 的ErrorWebExceptionHandler。通过扩展AbstractErrorWebExceptionHandler或DefaultErrorWebExceptionHandler可以重用现有的实现。清单 10-33 描述了一个实现WebExceptionHandler的全局错误处理程序的简单实现。必须为handle(ServerWebExchange, Throwable)方法提供一个具体的实现来定制返回的响应消息。
package com.apress.prospringmvc.bookstore.util;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebExceptionHandler;
import com.apress.prospringmvc.bookstore.util.MissingValueException;
// other imports omitted
@Component
@Order(-2)
public class MissingValuesExceptionHandler implements WebExceptionHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
DataBuffer buffer;
if (ex instanceof MissingValueException) {
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
exchange.getResponse().getHeaders().add("Content-Type", "application/json");
final String message = " {\"missing_value_for\": \""+
((MissingValueException)ex).getFieldNames() +"\"}";
buffer = exchange.getResponse().bufferFactory().wrap(message.getBytes());
} else {
exchange.getResponse().setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
exchange.getResponse().getHeaders().add("Content-Type", "application/json");
buffer = exchange.getResponse().bufferFactory().wrap("Ooops!".getBytes());
}
return exchange.getResponse().writeWith(Flux.just(buffer));
}
}
Listing 10-33Custom Global Error Handler Implementation
MissingValueException类是一个定制的异常类,它封装了失败字段的名称。为了让前面的异常处理程序完成它的工作,必须更改BookHandler处理函数,以便在验证失败时抛出MissingValueException异常,并从请求/响应交换管道中删除onErrorResume(..)调用。清单 10-34 显示了这些变化。
package com.apress.prospringmvc.bookstore.handler;
import com.apress.prospringmvc.bookstore.util.MissingValueException;
// other imports omitted
@Component
public class BookHandler {
private BookstoreService bookstoreService;
private final Validator validator = new BookValidator();
public BookHandler(BookstoreService bookstoreService) {
this.bookstoreService = bookstoreService;
}
public Mono<ServerResponse> create(ServerRequest serverRequest) {
return serverRequest.bodyToMono(Book.class)
.flatMap(this::validate)
.flatMap(book -> bookstoreService.addBook(book))
.flatMap(book -> ServerResponse.created(URI.create("/book/isbn/" + book.getIsbn()))
.contentType(MediaType.APPLICATION_JSON).bodyValue(book));
// no 'onErrorResume()' here
}
private Mono<Book> validate(Book book) {
Errors errors = new BeanPropertyBindingResult(book, "book");
validator.validate(book, errors);
if (errors.hasErrors()) {
throw MissingValueException.of(errors.getAllErrors());
}
return Mono.just(book);
}
// other handler functions emitted
}
Listing 10-34Handler Function That Throws a MissingValueException When Validation Fails
如果您现在运行清单 10-31 中的测试,它不会失败,因为 HTTP 响应代码是相同的,但是您会注意到响应体被简化为{"missing_value_for": "[Isbn, Category]"}。
很简单,对吧?在一个实际的应用中,你可能两者都需要:因为WebExceptionHandler的实现适合全局异常,而验证错误处理可以在执行验证的地方实现,在特定类型对象的处理函数中。无论你采取什么方法,确保让使用你服务的人知道他们做错了什么。
摘要
这一章让你深入了解了在构建一个反应式 Spring WebFlux 应用时什么是重要的。我们讲述了迁移多层应用的一些细节,以强调这样一个事实:只有当一个反应式应用的所有组件都是反应式的时,它才是完全反应式的。为了帮助您从 Spring Web MVC 转换到 WebFlux,我们对这两种技术的配置进行了比较。
我们研究了使用反应式控制器以多种方式呈现百里香叶动态视图:使用数据驱动上下文变量以反应式友好的方式加载视图,使用 JavaScript 来使用Flux<T>并重新生成部分呈现的 HTML 模板,以及通过发送 SSE 来重写百里香叶视图的片段。
我们看了使用WebClient消费其他反应式服务,以及使用WebTestClient和curl命令测试反应式应用。
还讨论了功能端点的国际化、验证和错误处理,因为它们在构建 web 应用时非常重要。
从这一章中可以学到一些东西。Spring WebFlux 有一些优点,比如更干净、更简洁的代码。Spring Boot 提供了许多开箱即用的组件,使开发时间更长,配置时间更少。错误处理更容易实现,代码更容易阅读。但不是所有的元件都必须是电抗的。当您只有一个简单的页面要呈现给用户时,就没有必要使用反应式组件来呈现它。
最重要的事情:永远,永远不要打block(),尽可能避开subscribe()!
十一、保护 Spring WebFlux 应用
在反应式编程的两章之后,我们仅仅触及了反应式应用的表面。反应式代码不会让你的代码更简单、更易读,但它让你的代码更健壮、更容易扩展。交换大量信息的复杂应用最适合使用反应式框架来实现。通过使用反应式编程,数据可以流动,并且可以执行操作来简单有效地转换和组合数据,而不需要编写处理同步线程的复杂性的代码。
用 Spring WebFlux 编写的反应式 web 应用也可以公开反应式视图,使用户界面响应更快。反应式视图可以显示由反应式服务发送的数据,而无需冻结页面。这些主题已经讨论过了,实现它们的代码在前面两章中已经讨论过了。
本章涵盖了与 Spring WebFlux 应用相关的两个更重要的主题:如何应用背压和保护 Spring WebFlux 应用。
反压力
前两章提到了背压。有人暗示,背压表示订阅方控制其订阅的发布方发出值的速率的操作。如果没有 WebSocket 这样的双向通信协议,解决背压问题是没有意义的。
订阅者可以通过配置的方式向发布者请求特定数量的项目。前一章使用了速度较慢的发布器,它们使用zip操作符按配置的时间间隔发布项目。
让我们从介绍允许背压发生的技术开始:WebSocket 协议。
WebSocket 协议
处理数据流的一个核心建议是永远不要阻塞。使用由流发出的数据的客户端不拥有该线程,所以它永远不应该阻塞它。为了避免发出其他项,需要将它们存储在缓冲区中。但是,缓冲区是有限的,它们可能会被填满和溢出,数据可能会丢失。所以剩下唯一可能的选择:应该允许客户端控制流发出项目的速率。然而,要做到这一点,我们需要一个双向的沟通渠道。
首先,让我们快速回顾一下图 11-1 。

图 11-1
TCP 之上的通信技术
浏览器和服务器之间的正常通信是通过 TCP 连接完成的。HTTP 协议 1 是 TCP 之上的通信协议,它包含一旦数据到达就读取和处理该数据的特定指令。客户端向服务器发出 HTTP 请求,服务器用 HTTP 响应进行应答。客户机和服务器之间的多次请求和响应交换可以通过 HTTP 持久连接(保持活动)进行。客户端和服务器之间的这种通信方式被称为轮询,因为它们定期通过 HTTP 连接向服务器请求新数据。为章节 1 到 8 编写的应用就是为这种类型的通信而设计的。
第十章中介绍的服务器发送事件(SSE)2T3,是一种服务器推送技术,使客户端能够通过 HTTP 连接从服务器接收自动更新。这是一种单向通信,类似于 JMS 中的发布/订阅模型。
WebSocket 3 是一种计算机通信协议,通过单一 TCP 连接提供全双工通信通道。WebSocket 是 HTTP 的替代方案,它允许在浏览器(客户端)和服务器之间进行双向交互通信。它支持 TCP 之上的消息流,其 API 消息可以发送到服务器,并且可以接收事件驱动的响应,而无需轮询。WebSocket 被设计成 HTTP 兼容的。HTTP 仅用于握手。这是两个协议之间连接的终点。在引擎盖下,他们是非常不同的。WebSocket 是一个底层的传输协议,第一个 URL 请求建立一个连接,之后所有的应用消息都流经同一个 TCP 连接。图 11-2 大致描绘了客户端和服务器之间使用 WebSocket 的通信。

图 11-2
使用 WebSocket 进行通信
WebSocket 上的通信从一个 HTTP 请求开始,请求使用 WebSocket 协议。该请求应该有一个名为Upgrade的头,其中填充了值WebSocket,还有一个名为Connection的头,其中填充了值Upgrade。一些 base-64 编码的随机字节头可以防止同一条消息被发送两次。清单 11-1 中描述了这个初始 HTTP 请求和响应的内容示例。
--- HTTP Request ---
GET /chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Origin: http://localhost:8080
--- HTTP Response ---
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Listing 11-1WebSocket HTTP Request and Response Handshake Example
响应应该是 HTTP 状态代码 101,这表示 WebSocket 接受通信。在这个初始握手之后,客户机(浏览器)和服务器通过 WebSocket 会话进行通信,任何一方都可以结束这个会话。
WebSocket 是需要频繁高速交换小块数据的应用的合适解决方案(例如,社交媒体、交易、视频游戏、博彩网站等)。).如果信息交换量相对较低,经典的 HTTP 轮询解决方案可能会提供有效的解决方案。在通信的数据传输部分,客户端和服务器可以同时向对方发送消息,这为向您的应用添加更健壮的实时通信功能打开了大门。
大多数现代浏览器都支持 WebSocket。
WebSocket 协议定义了一个ws://前缀来表示 WebSocket 连接。wss://前缀表示安全、加密的 WebSocket 连接用于通信。WebSocket 不知道代理服务器和防火墙,但它与 HTTP 兼容,并使用 HTTP 80 和 443 端口进行通信。未加密的 WebSocket 流量在到达 WebSocket 服务器的途中流经一个显式或透明的代理服务器。如果代理服务器没有配置为支持未加密的 WebSocket 流量,连接很可能会失败。加密的 WebSocket 流量是通过使用传输层安全性(TLS)的连接完成的。当浏览器配置为使用显式代理服务器时,会发出 HTTP CONNECT 命令。这建立了一个隧道,它通过 Web Sockets 安全客户端和 WebSocket 服务器之间的 HTTP 代理提供低级的端到端 TCP 通信。
从 4.0 版本开始,Spring 框架支持 WebSocket 风格的消息传递,并将 STOMP 作为应用级别的子协议。在框架内,对 WebSocket 的支持在spring-websocket模块中,它与 Java WebSocket API 标准(JSR-356)兼容。 4 对于一个 servlet 环境,还有一个 Spring Boot 启动器依赖:spring-boot-starter-websocket。在讨论在反应式应用中使用 WebSocket 进行通信之前,让我们先了解一下如何在非反应式应用中使用 WebSocket。
对非反应式应用使用 WebSocket API
当使用 Spring 的 WebSocket API 时,通常会实现org.springframework.web.socket.WebSocketHandler接口,或者使用方便的子类,比如用于处理二进制消息的org.springframework.web.socket.handler.BinaryWebSocketHandler,用于 SockJS 消息的org.springframework.web.socket.sockjs.transport.handler.SockJsWebSocketHandler,或者用于处理基于String的消息的org.springframework.web.socket.handler.TextWebSocketHandler。在我们的例子中,为了简单起见,我们使用一个TextWebSocketHandler来通过 WebSocket 传递字符串消息。您可以在本书的源代码中找到连接和发送消息到服务器的 JavaScript 代码。本节重点介绍实现一个非常基本的聊天功能的处理程序并对其进行配置所必需的 Spring 代码。如前所述,处理程序接收和发送文本消息,因此处理程序类必须扩展TextWebSocketHandler。代码如清单 11-2 所示。
package com.apress.prospringmvc.bookstore;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.List;
import java.util.Random;
public class ChatHandler extends TextWebSocketHandler {
@Override
public void handleTextMessage(WebSocketSession session, TextMessage textMessage) throws IOException {
if(textMessage.getPayload().toLowerCase().contains("hello")||
textMessage.getPayload().toLowerCase().contains("hi")) {
session.sendMessage(new TextMessage(BOT_ANSWERS.get(0)));
session.sendMessage(new TextMessage(BOT_ANSWERS.get(1)));
} else {
session.sendMessage(new TextMessage(randomMessages()));
}
}
private static final Random RANDOM = new Random(System.currentTimeMillis());
private static final List<String> BOT_ANSWERS = List.of(
"Hello!",
"How can I help?"
// ... more messages omitted
);
private static String randomMessages() {
return BOT_ANSWERS.get(RANDOM.nextInt(BOT_ANSWERS.size() - 2) + 2);
}
}
Listing 11-2The ChatHandler Class That Extends TextWebSocketHandler
ChatHandler实现了一个非常基本的聊天机器人,它用一个固定集合中的随机消息来回复收到的消息。这个实现处理从客户端收到的请求,这些请求是使用特定于 WebSocket 官方 API 的 JavaScript 函数发送的。 5 现在我们有了一个用于 WebSocket 通信的处理程序,让我们将它映射到一个 URL 路径,并告诉 Spring 我们正在使用 WebSocket 与客户端通信。对此有一个特殊的注解叫做@EnableWebSocket。
清单 11-3 描述了应用支持 WebSocket 所必需的 Spring 配置类。
package com.apress.prospringmvc.bookstore.web.config;
import com.apress.prospringmvc.bookstore.ChatHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
@EnableAsync
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler(), "/chatHandler").setAllowedOrigins("*");
}
@Bean
public ChatHandler chatHandler() {
return new ChatHandler();
}
}
Listing 11-3The Spring WebSocket Configuration Class
前面的配置类包含一些需要更详细解释的元素。
-
@EnableWebSocket:当放置在 Spring 配置类上时,它启用 WebSocket 请求处理。 -
WebSocketConfigurer:必须实现该接口才能访问WebSocketHandlerRegistry。应该实现一个方法registerWebSocketHandlers(WebSocketHandlerRegistry)来将 WebSocket URL 路径映射到适当的处理程序。 -
setAllowedOrigins("*"):这个方法被设置为允许从任何来源调用我们的应用。如果你熟悉 CORS(跨源资源共享),你知道出于安全原因,浏览器限制从脚本发起的跨源 HTTP 请求。默认情况下,只允许来自同一来源的呼叫。在产生的WebSocketHandlerRegistration上调用该方法可以确保您不会在本地遇到问题,尤其是在 127.0.0.1 上的浏览器中打开应用并尝试使用localhost发送 WebSocket 消息时。我在写代码的时候就遇到了这种情况,这就是为什么我认为它值得一提。在生产中,您不应该这样做,最多,您应该配置允许来源的列表。 -
一个非常有用的注释,因为它支持异步消息传递。这意味着一旦连接打开,客户端和服务器就可以并行发送消息。
在这个简单的例子中,客户机由浏览器中执行的 JavaScript 代码表示,浏览器是chat.html视图的一部分。实现很简单。它使用 jQuery for JavaScript 事件声明侦听器,以连接到服务器、发送/接收消息或关闭连接。清单 11-4 描述了它的实现和 HTML 元素。
<script th:inline="javascript">
var ping;
var websocket;
jQuery(function ($) {
function writeMessage(message) {
$('#messageOutput').append(message + '\n')
}
$('#connect')
.click(function doConnect() {
var handlerURL = 'ws://localhost:8080/chapter11-1/chatHandler';
websocket = new WebSocket(handlerURL);
websocket.addEventListener('message', function (evt) {
writeMessage('STAFF: ' + evt.data);
});
websocket.addEventListener('open', function(evt) {
writeMessage("CONNECTED");
});
websocket.addEventListener('close', function (evt) {
writeMessage(`DISCONNECTED.
Reason: code=${evt.code}, reason=${evt.reason}`);
});
websocket.onerror = function (evt) {
writeMessage('ERROR:' + evt.data);
};
});
$('#disconnect')
.click(function () {
if(typeof websocket != 'undefined') {
websocket.close();
} else {
alert("Not connected.");
}
});
$('#send')
.click(function () {
if(typeof websocket != 'undefined') {
websocket.send($('#message').val());
writeMessage('USER:' + $('#message').val());
} else {
alert("Not connected.");
}
});
});
</script>
<div class="left_content" id="left_content"
th:fragment="~{template/layout :: left_content}" >
<fieldset>
<legend th:text="#{chat.title}">CONTACT STAFF</legend>
<table>
<tr>
<td colspan="2"><button id="connect"
th:text="#{button.connect}">CONNECT</button></td>
</tr>
<tr>
<td><input id="message" value=""/></td>
<td><button id="send"
th:text="#{button.send}">SEND</button></td>
</tr>
<tr>
<td colspan="2" align="center">
<textarea readonly id="messageOutput"
rows="10" cols="50"></textarea></td>
</tr>
<tr>
<td colspan="2"><button id="disconnect"
th:text="#{button.disconnect}">Disconnect
</button></td>
</tr>
</table>
</fieldset>
</div>
<!-- other HTML code omitted -->
Listing 11-4JavaScript Functions
for Establishing a WebSocket Connection and Sending/Receiving Messages Over It
使用chat.html模板生成的 HTML 页面如图 11-3 所示。

图 11-3
聊天页面
例如,这种类型的实现非常适合与提供支持的真人聊天。现在,让我们回到反应世界。
在反应式应用中使用 WebSocket API
在前面的章节中,由 Node.js 服务产生的新闻流被 Spring WebFlux 书店应用使用。使用一个数据驱动变量,使用一个WebClient来消费该流并产生一个注入百里香模板的Flux<String>。使用 WebSocket,我们可以避免使用被动视图,而使用 JavaScript 代码通过 WebSocket 连接直接与提供数据的服务器通信。从通信中删除一个节点可能会加快传输速度,并且不再需要编写一些 Java 代码。
在本节中,我们不再使用 Node.js 服务,而是使用WebSocketHandler实现相同的功能。Spring 框架提供了一个反应式 WebSocket API,您可以使用它来编写处理 WebSocket 消息的客户端和服务器端应用。reactive org.springframework.web.reactive.socket.WebSocketHandler接口声明了一个应该由开发人员实现的用于处理 WebSocket 会话的抽象方法(参见清单 11-5 )。
package org.springframework.web.reactive.socket;
// imports and default method omitted
public interface WebSocketHandler {
Mono<Void> handle(WebSocketSession session);
}
Listing 11-5WebSocketHandler Method
Skeleton for handle(WebSocketSession)
org.springframework.web.reactive.socket.WebSocketSession接口表示一个 WebSocket 会话,它声明了一组在双方之间交换信息的方法。最重要的是send(Publisher<WebSocketMessage>)和receive()(见清单 11-6 )。
package org.springframework.web.reactive.socket;
// imports and other methods omitted
public interface WebSocketSession {
WebSocketMessage textMessage(String payload);
Mono<Void> send(Publisher<WebSocketMessage> messages);
Flux<WebSocketMessage> receive();
Mono<Void> close(CloseStatus status);
}
Listing 11-6WebSocketSession Method Skeleton for send(..) and receive()
Spring 为这个接口提供了非常有用的org.springframework.web.reactive.socket.adapter.AbstractWebSocketSession<T>实现. It为WebSocketSession接口中的所有方法提供了基本实现。在向客户端发送数据之前,必须将其转换为 WebSocket 协议能够识别的格式。任何类型对象的字符串表示都可以通过使用实用方法textMessage(String)转换成org.springframework.web.reactive.socket.WebSocketMessage。如果客户机是一个浏览器,并且使用 JavaScript 将数据呈现到浏览器中,那么最合适的文本表示就是 JSON。将WebSocketMessage实例转换为String的反向功能由WebSocketMessage类中声明的getPayloadAsText()方法提供。
一个WebSocketHandler实现必须将入站(来自客户端的消息)和出站(发送到客户端的消息)流组成一个统一的流,并返回一个Mono<Void>。前面提到过,WebSocket 通信可以被任何一方关闭。这意味着,根据应用要求,当发生以下情况时,统一流程完成。
-
入站或出站消息流完成。
-
入站流完成,连接关闭,而出站流是无限的。
-
在选定的时间点(服务器超时),通过调用
WebSocketSession的close(CloseStatus)方法。(org.springframework.web.reactive.socket.CloseStatus类包含一组常数值,代表最常见的 WebSockets 状态代码。1000是用于优雅的沟通结束的代码。) 6
在书店应用的上下文中,WebSocketHandler的实现应该接收来自客户端的消息,同时还向应用发送技术新闻。清单 11-7 描述了这样一个实现。
package com.apress.prospringmvc.bookstore.handler;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.WebSocketMessage;
import org.springframework.web.reactive.socket.WebSocketSession;
// other imports omitted
public class TechNewsHandler implements WebSocketHandler {
private final Logger logger = LoggerFactory.getLogger(NewsWebSocketHandler.class);
private final AtomicLong rate = new AtomicLong(2000);
@Override
public Mono<Void> handle(WebSocketSession session) {
Flux<String> newsFlux = Flux.fromStream(
Stream.generate(BookNewReleasesUtil::randomNews))
.delayElements(Duration.ofMillis(rate.get()));
return session.send(newsFlux.map(session::textMessage))
.and(session.receive()
.map(WebSocketMessage::getPayloadAsText)
.doOnNext(message -> logger.debug("Client says: {}", message))
);
}
}
Listing 11-7TechNewsHandler Implementation
在清单 11-7 中,入站和出站流合并成一个统一的流可能并不明显。这就是声明式编程和 lambdas 的危险。清单 11-8 中的实现清楚地表明了这两个不同的流。
package com.apress.prospringmvc.bookstore.handler;
// other imports omitted
public class TechNewsHandler implements WebSocketHandler {
private final Logger logger = LoggerFactory.getLogger(TechNewsHandler.class);
@Override
public Mono<Void> handle(WebSocketSession session) {
var inbound = session.receive()
.map(WebSocketMessage::getPayloadAsText)
.doOnNext(message -> logger.debug("Client says: {}", message))
.then();
var source = Flux.generate(
(SynchronousSink<String> synchronousSink) ->
synchronousSink.next(BookNewReleasesUtil.randomNews())
);
var outbound = session.send(source.map(session::textMessage)
.delayElements(Duration.ofSeconds(2L))); // artificial delay
return Mono.zip(inbound, outbound).then();
}
}
Listing 11-8TechNewsHandler Implementation
Making the Two Streams Obvious
send(..)方法获取发送给客户端的消息的来源;在这种情况下,无限的Flux<String>发出随机的科技新闻。receive()方法返回一个流,该流发出代表从客户端接收的消息的WebSocketMessage实例。这些信息使用getPayloadAsText()方法转换成String,并打印在控制台上。
现在我们有了一个WebSocketHandler,我们必须将它映射到一个 URL。在一个将spring-boot-starter-webflux声明为依赖项的 Spring Boot 应用中,这是通过声明一个包含 URL 路径和TechNewsHandler bean 之间对应关系的HandlerMapping bean 和声明一个WebSocketHandlerAdapter来完成的。WebSocketHandlerAdapter委托给一个WebSocketService。默认情况下,这是一个类型为HandshakeWebSocketService. I的实例。ts 的职责是对与 WebSocket 相关的 HTTP 请求执行基本检查,并从WebSession中提取属性,以将它们插入到WebSocketSession中(这在需要身份验证时非常有用)。
不需要特殊的注释。只需在一个用@Configuration标注的类中声明三个 beans,它们就被 Spring 捡起来使用了。配置所需的所有接口和类都是org.springframework.web.reactive包及其子包的一部分。清单 11-9 描述了这个非常简单的配置。
package com.apress.prospringmvc.bookstore.config;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
// other imports omitted
@Configuration
public class WebSocketConfig {
@Bean
WebSocketHandler techNewsHandler(){
return new TechNewsHandler();
}
@Bean
HandlerMapping handlerMapping(WebSocketHandler techNewsHandler) {
return new SimpleUrlHandlerMapping() {
{
setUrlMap(Collections.singletonMap("/ws/tech/news", techNewsHandler));
setOrder(-1);
}
};
}
@Bean
public WebSocketHandlerAdapter handlerAdapter() {
return new WebSocketHandlerAdapter();
}
}
Listing 11-9Spring Necessary Beans to Configure WebSocket Communication
这就是在 Spring 应用中配置 WebSocket 通信支持所需的全部内容。该处理程序的映射顺序设置为–1,以确保 WebSocket 请求在带注释的控制器之前得到处理。用于发出 WebSocket 请求的 JavaScript 代码非常简单;它在清单 11-10 中进行了描述。
<script th:inline="javascript">
$( window ).on( "load", function() {
renderNews.start();
});
$( window ).on( "onbeforeunload", function() {
renderNews.start();
});
var renderNews = {
socket : new WebSocket('ws://localhost:8080/ws/tech/news'),
fromServer: [],
start: function () {
this.socket.addEventListener('message', function (event) {
let message = event.data
$("#techNews").html(message);
renderNews.fromServer.push(event.data);
if(renderNews.fromServer.length % 10 === 0) {
renderNews.socket.send('Slow down mate!');
} else if(renderNews.fromServer.length % 15 === 0) {
renderNews.socket.send('Faster mate!');
}
});
this.socket.addEventListener('open', function(event) {
console.log('Opening connection...');
renderNews.socket.send('Give me your best shot');
});
this.socket.addEventListener('close', function(event) {
if (event.wasClean) {
console.log(`Clean closing...
code=${event.code} reason=${event.reason}`);
} else {
// event.code is usually 1006 in this case
console.log('Server closed the connection.');
}
});
this.socket.addEventListener('error', function(event) {
console.log(`Well bummer... ${error.message}`);
});
},
stop: function() {
this.socket.close();
}
};
</script>
Listing 11-10JavaScript Code Written Using Official WebSocket API in(part of the search.html template file
)
JavaScript 代码类似于清单 11-4 ,因为它使用相同的 WebSocket API 来处理连接、关闭和消息事件。
不幸的是,没有 Java 客户端来测试 WebSocket 反应式通信。而且curl在这方面也做得不好。测试它的唯一方法是运行项目并添加console.log语句来调试它。或者试试简单的 WebSocket 客户端 Chrome 插件。 7
既然我们有了一个使用 WebSocket 通信的客户机和服务器,那么是时候讨论背压这个主题了。
处理背压
背压是反应式编程的独角兽。几乎每个软件工程师都知道如何定义它,并且必须尽早处理它。术语背压借用自流体动力学,但在软件中,它表示阻止数据通过软件的力。如果你有两个应用在交换数据,而其中一个不能足够快地处理接收到的数据,这就是在阻止数据流。背压会导致阻塞和数据丢失,所以处理背压就是编写代码在服务器端调节数据流,在客户端实现一些数据保存机制。
如今,服务器和客户端应用通过网络分离,通信通过 TCP 完成。为了理解如何通过网络处理背压,有必要了解 TCP 的工作原理。在因特网上建立通信可以通过各种协议来完成,而网络协议是一个庞大的主题,已经有不止一本书写了关于它们的内容。但是,在这本书的背景下,我尽量保持简单。传输控制协议(TCP)是互联网协议簇的核心协议。通过 HTTP、WebSocket 和 TCP 之上的其他协议进行通信的应用通常对传递每个信息位(包)并不敏感,而是对传递所有信息的总持续时间敏感。这就是为什么每次包到达目的地时,都会发出一个确认信号,这确保了成功的端到端通信。
处理背压适合在哪里?应该实现某种机制来控制发送到网络或从网络接收的逻辑元素的数量。TCP 有自己的流量控制, 8 但是它适用于包。TCP 不知道你使用它上面的协议发送的内容的整体形状。因此,在 TCP 之上使用协议的应用必须将逻辑元素转换成字节。但是他们无法控制这些元素在被转换成包后是如何传输的。背压由 TCP 流量控制调节。当然,可以在应用中添加逻辑来控制元素发出的速率,但是客户端对此有什么可说的吗?
Spring Framework 5 在框架中实现了 WebSockets 支持的现代化,为这个通信通道增加了反应能力。一旦建立了 WebSocket 通信,客户端和服务器就可以独立地向对方发送在专用流上发出的消息。在非反应式应用中,客户端发送一条或多条消息,服务器也通过发送一条或多条消息做出反应。在反应式应用中,客户端和服务器可以通过 WebSocket 连接相互发送消息流。
在前面的例子中,我们已经看到消息以一定的速率发送到客户端。客户端同时发送的消息只是被打印出来,它们不会以任何方式中断或影响服务器的行为。运行清单 11-10 中描述的代码的浏览器和运行清单 11-8 中代码的服务器之间的交互的可视化表示如图 11-4 所示。

图 11-4
JavaScript 客户端和 Spring WebFlux 应用通过 WebSocket 进行通信
服务器通过inbound流接收客户端发送的消息,并使用outbound流将消息发送给客户端。客户机在浏览器的 HTML 页面中呈现消息,服务器在控制台中打印它收到的消息。没用吧?没多大作用。在 Internet 上也找不到其他的基本示例,它们实现了一种 echo 通信,在这种通信中,服务器用客户端发送的消息进行响应。应用级别的背压控制的例子很简单,只需使用定制的订户来处理块中发出的元素,从而调节流量。这假设客户端应用是使用反应流 API 实现编写的。
清单 11-11 是一个使用项目反应器BaseSubscriber<T>的实现来处理背压的简单例子。消息源与前一节中使用 WebSocket 发送消息的流相同,但是它被限制为 20 个元素,以保持执行的有限性。
@Test
void testBackpressureHandlingOne() {
var techNews = Flux.fromStream(
Stream.generate(BookNewReleasesUtil::randomNews))
.take(20).log(); // server outbound stream
// client
techNews.subscribe(new BaseSubscriber<>() {
int processed;
final int limit = 5;
@Override
protected void hookOnSubscribe(Subscription subscription) {
subscription.request(limit);
}
@Override
protected void hookOnNext(String news) {
//client logic here
if (++processed >= limit) {
processed = 0;
request(limit);
}
}
});
}
Listing 11-11Backpressure Handling Example Using Customized BaseSubscriber<T>
Project Reactor 的BaseSubscriber<T>中的hookOnSubscribe(Subscription)实现被认为是无限的,这意味着它请求的元素数量与subscription.request(..)方法的声明参数类型的上限(即long)一样多,因此默认情况下,它从流中请求Long.MAX_VALUE元素。您可以通过查看BaseSubscriber<T> Java 代码来检查这一点。 9
这并不总是好的,因为消费者处理接收到的值可能会很慢。
再打个流体力学的比方,客户控制水龙头杠杆来决定水压是很正常的吧?下面的代码相当于使用了limitRate(int)方法,当在Flux<T>实例上调用该方法时,它会将后续订阅者的请求限制在作为参数提供的数量内(这样就像一个流量杠杆)。清单 11-12 中描述了相当于清单 11-11 的代码。
@Test
void testBackpressureHandlingTwo() {
var techNews = Flux.fromStream(
Stream.generate(BookNewReleasesUtil::randomNews))
.take(20).log();
consume(techNews.limitRate(5));
}
private void consume(Flux<String> input) {
input.subscribe(/*s -> clientLogicHere(s)*/);
}
Listing 11-12Backpressure Handling Example Using Flux.limitRate(int)
当您运行前面的任何测试时,您会看到日志消息清楚地表明数据是以五条消息的形式发出的,这让客户端可以松口气。但是,大多数人期望客户机告诉服务器首选的发射速率,对吗?嗯,我这样做是因为我得到了反应流和双向连接的承诺,我想一起使用它们。我试图修改TechNewsHandler,以便当从客户端接收到一条消息时,服务器用一个根据消息以不同速率发出值的流来响应。信息慢点,伙计!使频率下降到每 5 秒发射一个值,并且更快,伙计!使速率增加到每 2 秒发射一个值。清单 11-13 中描述了一个解决方案。
package com.apress.prospringmvc.bookstore.handler;
// other imports omitted
public class TechNewsHandler implements WebSocketHandler {
private final Logger logger = LoggerFactory.getLogger(TechNewsHandler.class);
private Flux<String> getRandomNews(String message){
long rate = "Slow down mate!".equals(message) ? 5000:2000L;
return Flux.fromStream(Stream.generate(BookNewReleasesUtil::randomNews))
.delayElements(Duration.ofMillis(rate));
}
@Override
public Mono<Void> handle(WebSocketSession session) {
return session.send(session.receive()
.map(WebSocketMessage::getPayloadAsText)
.log()
.flatMap(this::getRandomNews)
.map(session::textMessage)).then();
}
}
Listing 11-13ServerController
Modified to Support Different Emission Rates for Messages
这看起来是一个非常优雅的解决方案,既漂亮又干净,但是它能像预期的那样工作吗?
最长的答案是:不尽然。
简短的回答是:没有。
反应式编程的声明式风格和对它的误解有时会导致这样的怪物。前面的handle(..)方法的实现可以总结如下:每次在这个会话中收到一条消息时,发送一个由getRandomNews()调用返回的无限消息流。该函件可归纳如下。
-
客户发送,给我你最好的照片。
-
服务器上的一个数据流每 2 秒钟就开始发送随机的科技新闻。
-
客户发送慢点,伙计!
-
服务器上的另一个流开始每 5 秒钟发送一次随机的科技新闻。
-
客户发送,快点,伙计!
-
服务器上的另一个数据流每 2 秒钟就开始发送随机的科技新闻。
等等。
没有人取消订阅这些流,结果是什么呢?所有流都会继续发出元素,直到用户关闭网页或者服务器内存不足。所有这些流都在同一个WebSocketSession将它们的消息发送到同一个客户端,所以结果会淹没浏览器。背压控制没有正确实现,因为控制流是不可能的,因为处理后续消息之间不共享状态。
在本书附带的源代码中,您可以找到 Node.js 服务器实现(文件tech-news-server.js),它允许客户端控制发射速率。这是可能的,因为使用了setInterval和clearInterval方法。对生成消息的函数的引用在同一会话中处理后续消息时共享。没有使用 JavaScript 反应流库,所以反应是模拟的。但是,它的工作是考虑客户的愿望。
使用 Spring WebFlux 无法编写等效的版本。一个ConcurrentHashMap<String、Flux<WebSocketMessage>>可以存储映射到每个WebSocketSession的出站流的引用。当接收到消息时,从连接映射中检索对出站流的引用,只需在现有流上调用.subscribe().dispose()并用另一个具有不同值发出速率的流来替换它。主要问题是反应式编程的声明式风格阻止您替换现有的流。这是因为不变性。
呈现在网页上的随机新闻不需要与客户端进行太多的交流。客户端必须打开页面,这将打开 WebSocket 连接,通过该连接传送数据,该操作还意味着订阅服务器上的技术新闻流,然后关闭页面,这将取消订阅,因为 WebSocket 连接也被关闭。仅此而已。在这种情况下,根据客户端的偏好来调节消息发送速率是没有意义的。上一次一个网站被设计成降低广告速度以避免浏览器冻结是什么时候?没人关心这个,这就是广告拦截器被发明的原因。服务器被设计成以保持信息可见和有用的频率向客户机发送消息。
来自客户端的消息影响服务器在其流上发回的内容的唯一方式是将它们保存在数据库中,向客户端发送消息的流将该数据库用作源。添加一个反应式流程,使用客户端消息作为命令来控制需要生成什么并发送给客户端,这样两者之间就有了良好的交互。这种情况如图 11-5 所示,请记住:您可以影响在出站流上发送什么,而不是发送消息的频率。

图 11-5
反应式应用,在这种应用中,客户端可以控制服务器使用共享数据库发送哪些消息
Project Reactor 是一个反应流实现,因此它的所有操作符都支持非阻塞背压。然而,这仅适用于服务器端的 Java 应用。无论是 HTTP 还是 WebSocket,通信协议都限制了无法正确处理背压。关于如何处理两个 Spring WebFlux 应用之间的背压的最详细的解释可以在 StackOverflow 10 上找到,这是 Project Reactor 的知名贡献者 Oleh Dokuka 的精彩贡献。可以得出的结论是,我们可以声明几个预取的元素,并将服务器发出的数据分割成块,以抑制需求(就像前面的示例中所做的那样)。但是,在数据开始流动后,客户端没有办法影响这一点。
Oleh Dokuka 说,要通过网络边界实现逻辑元素背压,我们需要一个合适的协议。该协议是 RSocket,将在下一节中介绍。
RSocket 协议
RSocket 11 是一种二进制应用协议,提供可用于字节流传输(如 TCP、WebSockets 和 Aeron)之上的反应流语义。它是由当今最受欢迎的流媒体平台网飞的工程师开发的。它支持在单个连接上异步交换消息,具有以下特点。
-
fire-and-forget(无响应):比如 handler 方法返回
Mono<Void>,声明一个类型为 RequestMessageType 的参数;您可以将此视为客户端和服务器之间的一对一通信。HTTP 支持这一点,但是响应的缺乏让一些浏览器感到困惑。 -
请求/响应(stream of 1) :例如 handler 方法返回
Mono<response messagetype>并声明一个类型为 RequestMessageType 或Mono<request message type>的参数;您可以将此视为客户端和服务器之间的一对一通信。HTTP 支持这一点。 -
请求/流(有限多流):例如 handler 方法返回
Flux<response messagetype>并声明一个类型为 RequestMessageType 或Mono<request message type>的参数;您可以将此视为客户端和服务器之间的一对多通信。WebSocket 支持这一点。 -
通道(双向流):比如 handler 方法返回
Flux<response messagetype>,声明一个Flux<request message type>类型的参数;您可以将此视为客户端和服务器之间的多对多通信。
RequestMessageType 和 RequestMessageType 是占位符类型,替换客户端和服务器之间通信中使用的真实类型,以创建通用模式。
RSocket 最好的一点是有 Java、JavaScript、Kotlin、.NET,Python,Go,C++。理论上,这意味着用 JavaScript 开发的应用可以使用该协议与 Java 应用交换消息,这意味着可以在逻辑元素级别应用背压。RSocket 还没有被广泛使用,但是它确实有很大的潜力。不幸的是,在书店应用中包含 RSocket 支持会带来很多麻烦,因为rsocket-js API 是作为一组 Node.js 模块提供的。所以取而代之的是,因为这本书关注的是 Spring,所以我们写了一个 Spring WebFlux RSocket 服务器和客户端。
RSocket 的 Java 实现建立在 Project Reactor 之上。正如您可能预料的那样,有一个名为org.springframework.boot:spring-boot-starter-rsocket的 RSocket 的 Spring Boot 启动器依赖项,它很容易使用。当这个库出现在类路径中时,会使用您的application.properties或application.yaml文件中的属性自动配置和定制一个org.springframework.boot.rsocket.netty.NettyRSocketServer bean。需要在应用配置文件中设置两个属性:服务器端口和进行 RSocket 通信的传输协议。没有它们,应用将无法启动。
在清单 11-14 中,您可以看到示例配置文件,这些文件配置服务器在端口 8081 上启动,通过 WebSocket 发送和接收消息。
spring:
rsocket:
server:
transport: websocket
port: 8081
mapping-path: /rsocket
Listing 11-14Spring Boot RSocket Server Application Configuration File
spring.rsocket.server.transport属性的另一个选项是TCP,这也是默认选项,假设 RSocket 协议用于 HTTP 之上。spring.rsocket.server.mapping-path是可选的,可用于设置 RSocket 应用的根上下文路径。如果未指定,则不设置上下文路径。
现在我们有了一个服务器,我们需要一个控制器或处理程序类来处理 RSocket 请求。目前仅支持控制器。你可以在一个@Controller注释的类中拥有处理程序方法,方法是用@MessageMapping(path)进行注释,来自包org.springframework.messaging.handler.annotation。还没有提供 RSocket 请求的自定义处理程序类和功能路由。我的技术评论员想要一个 GitHub 问题链接,但是没有。API 还没有出现,还没有人请求它。
前面已经介绍了 RSocket 上的四种通信方式。清单中的ServerController11-15 包含四种方法,每种方法处理一种类型的通信。当客户端和服务器交换比简单文本值更复杂的对象时,必须使用消息转换器来支持序列化和反序列化。默认情况下,使用 JSON,但是如果您正在定制通信并使用其他消息转换器,您可能需要用@Payload注释 RSocket 处理程序方法的参数。在清单 11-15 中,使用了注释,即使不是必需的,因为默认情况下,被处理的对象可以在 JSON 中序列化和反序列化。
package com.apress.prospringmvc.bookstore.controller;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Controller;
// other imports omitted
@Controller
public class ServerController {
private final Logger logger = LoggerFactory.getLogger(ServerController.class);
// fire-and-forget
@MessageMapping("introduction")
public Mono<Void> introduction(@Payload ClientMessage clientMessage){
logger.debug("{}: We have a new client --> {}" , Instant.now(), clientMessage.getName());
return Mono.empty();
}
// request/response
@MessageMapping("check-service")
public Mono<String> checkService(@Payload ClientMessage clientMessage){
logger.debug("{}: Ping request from client --> {}" , Instant.now(), clientMessage.getName());
return Mono.just(Instant.now() + ": Service online. Send command.");
}
// request/stream
@MessageMapping("show-books")
public Flux<Book> showBooks(@Payload ClientMessage clientMessage) {
logger.debug("{}: Random releases requested by client --> {}" , Instant.now(), clientMessage.getName());
return Flux.fromStream(
Stream.generate(BookNewReleasesUtil::randomRelease))
.delayElements(Duration.ofSeconds(1L));
}
//channel (bi-directional streams)
@MessageMapping("books-channel")
public Flux<Book> useChannel(@Payload Flux<ClientMessage> messages) {
return messages
.map(message-> BookNewReleasesUtil.randomForAuthor(message.getAuthor()))
.delayElements(Duration.ofSeconds(1L));
}
}
Listing 11-15RSocket Requests Handler Methods in a @Controller
每个方法接收一个ClientMessage实例,作为参数,该实例是一个简单的类,包含发出请求的客户机的名称。useChannel(Flux<ClientMessage>)方法处理客户机和服务器之间的双向通信流。它从客户端接收消息流,处理每个消息流,并使用基于从客户端接收的数据发出的数据流进行响应。在这种情况下,ClientMessage实例包含一个作者姓名,在生成随机书籍时用作标准。这是一个非常简单的类,与此无关。
如果您启动应用,您会看到服务器在 8081 端口上启动,但是我们如何测试它呢?还记得我说过这个协议很有潜力吗?我不是唯一这样想的人。这就是为什么 Pivotal/VMWare 的一名开发人员创建了一个命令行实用程序来帮助测试 RSocket 服务器。 13 简称 RSocket 客户端 CLI (RSC)或rsc。它可以测试前三种方法。测试双向是一项困难的任务,因为使用命令行提供流作为参数很麻烦。在本章的代码中,您可以找到测试所有三种方法的命令。在清单 11-16 中,您可以看到用于测试showBooks()方法的rsc命令。--debug选项得到一个详细的结果,以文本和二进制格式显示有效负载数据。
$ rsc ws://localhost:8081/rsocket --stream --route show-books --log --debug -d "{\"name\": \"Gigi\"}"
2020-08-17 21:44:17.425 INFO --- [ctor-http-nio-1] rsc : onSubscribe(FluxMap.MapSubscriber)
2020-08-17 21:44:17.425 INFO --- [ctor-http-nio-1] rsc : request(unbounded)
2020-08-17 21:44:17.433 DEBUG --- [ctor-http-nio-1] i.r.FrameLogger : sending ->
Frame => Stream ID: 1 Type: REQUEST_STREAM Flags: 0b100000000 Length: 40 InitialRequestN: 9223372036854775807
Metadata:
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-----------------------------------------------+----------------+
|00000000| 0a 73 68 6f 77 2d 62 6f 6f 6b 73 |.show-books |
+--------+-----------------------------------------------+----------------+
Data:
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-----------------------------------------------+----------------+
|00000000| 7b 22 6e 61 6d 65 22 3a 20 22 47 69 67 69 22 7d |{"name": "Gigi"}|
+--------+-----------------------------------------------+----------------+
2020-08-17 21:44:18.439 DEBUG --- [ctor-http-nio-1] i.r.FrameLogger : receiving ->
Frame => Stream ID: 1 Type: NEXT Flags: 0b100000 Length: 123
Data:
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-----------------------------------------------+----------------+
|00000000| 7b 22 74 69 74 6c 65 22 3a 22 52 65 61 63 74 69 |{"title":"Reacti|
|00000010| 76 65 20 53 70 72 69 6e 67 22 2c 22 70 72 69 63 |ve Spring","pric|
|00000020| 65 22 3a 32 35 2e 34 34 2c 22 79 65 61 72 22 3a |e":25.44,"year":|
|00000030| 32 30 32 30 2c 22 61 75 74 68 6f 72 22 3a 22 4a |2020,"author":"J|
|00000040| 6f 73 68 20 4c 6f 6e 67 22 2c 22 69 73 62 6e 22 |osh Long","isbn"|
|00000050| 3a 22 39 37 38 31 34 38 34 32 32 37 31 31 31 22 |:"9781484227111"|
|00000060| 2c 22 63 61 74 65 67 6f 72 79 22 3a 22 53 70 72 |,"category":"Spr|
|00000070| 69 6e 67 22 7d |ing"} |
+--------+-----------------------------------------------+----------------+
2020-08-17 21:44:18.439 INFO --- [ctor-http-nio-1] rsc: onNext({"title":"Reactive Spring","price":25.44,"year":2020,"author":"Josh Long","isbn":"9781484227111","category":"Spring"})
# more elements here since this stream is infinite
Listing 11-16Output of rsc Command
您可能已经注意到,请求/响应和请求/流方法没有将 Mono < ClientMessage >声明为参数。这是有意的,因为这会使它们不适合用 RSC 进行测试.
为了使请求/响应和请求/流遵守反应规则,它们的参数应该被声明为具有Mono<ClientMessage>。这改变了方法的主体,因为必须在处理管道的前端添加参数。清单 11-17 中描述了这些变化。
package com.apress.prospringmvc.bookstore.controller;
// other imports omitted
@Controller
public class ServerController {
private final Logger logger = LoggerFactory.getLogger(ServerController.class);
// request/response
// no longer testable with rsc
@MessageMapping("check-service")
public Mono<String> checkService(@Payload Mono<ClientMessage> clientMessage){
return clientMessage
.doOnNext(message -> logger.debug("{}: Ping request from client --> {}" ,
Instant.now(), message.getName()))
.map( message -> Instant.now() + ": Service online. Send command.");
}
// request/stream
// no longer testable with rsc
@MessageMapping("show-books")
public Flux<Book> showBooks(@Payload Mono<ClientMessage> clientMessage) {
return clientMessage
.doOnNext(message -> logger.debug("{}: " +
"Random releases requested by client --> {}" , Instant.now(), message.getName()))
.thenMany(Flux.fromStream(
Stream.generate(BookNewReleasesUtil::randomRelease))
.delayElements(Duration.ofSeconds(1L)));
}
// rest of the code omitted
}
Listing 11-17The ServerController Fully Reactive Request/Response and the Request/Stream Implementations
一旦服务器按预期启动并运行,正如rsc所确认的,您就可以开始编写您的客户机了。同一个 Spring Boot 依赖项提供了编写客户机所需的类,但是因为我们连接到一个 RSocket 服务器,所以应用配置文件没有填充特定于 RSocket 的属性。在应用配置文件中没有任何属性并且spring-boot-starter-webflux在类路径中org.springframework.boot.web.embedded.netty.NettyWebServer在默认端口 8080 上启动。
我们的客户机是一个简单的@RestController带注释的类,包含四个方法,每个方法对 RSocket 服务器应用上的四个相应方法之一进行 RSocket 调用。这包括不能用rsc测试的方法。为此,需要一个org.springframework.messaging.rsocket.RSocketRequester的实例。正在使用 WebSocket URL 配置此 bean,该 URL 向服务器公开的 RSocket 消息处理方法发出请求。bean 是使用RSocketRequester.Builder.创建的,它是反应式的并返回一个Mono<RSocketRequester>,所以需要一个block()调用(是的,就是我在第十章末尾告诉你要避免的那个)来提取RSocketRequester实例。
清单 11-18 描述了这个 bean 的声明。因为我们使用 RSocket over WebSocket 进行通信,所以在服务器上调用了带有前缀为ws://的典型 WebSocket URL。
如果我们使用 HTTP,那么应该调用带有 HTTP URL 的connectTcp(..)。
package com.apress.prospringmvc.bookstore;
import org.springframework.messaging.rsocket.RSocketRequester;
//other imports omitted
@SpringBootApplication
public class RSocketClientBookstoreApplication {
public static void main(String... args) {
new SpringApplication(RSocketClientBookstoreApplication.class).run(args);
}
@Bean
RSocketRequester rSocketRequester(RSocketRequester.Builder builder) {
return builder.connectWebSocket(URI.create("ws://localhost:8081/rsocket")).block();
}
}
Listing 11-18The RSocketRequester Bean Declaration in the Spring Boot Application Main Class
控制器没什么特别的,但是对RSocketRequester bean 方法调用的调用很特别。清单 11-19 描绘了ClientController,其中的每个处理程序方法都可以被认为是一个 RSocket 客户端使用同一个RSocketRequester bean 向 RSocket 服务器发出请求。
package com.apress.prospringmvc.bookstore;
import org.springframework.messaging.rsocket.RSocketRequester;
//other imports omitted
@RestController
public class ClientController {
private final RSocketRequester requester;
public ClientController(RSocketRequester requester) {
this.requester = requester;
}
@GetMapping("introduction")
public Mono<String> introduction(){
ClientMessage clientMessage = new ClientMessage().name("gigi");
requester.route("introduction").data(clientMessage).send();
return Mono.just("Introduction data was sent.");
}
@GetMapping("check-service")
public Mono<String> checkService(){
ClientMessage clientMessage = new ClientMessage().name("gigi");
return requester.route("check-service").data(clientMessage).retrieveMono(String.class);
}
@GetMapping(path = "show-books", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Book> showBooks(){
ClientMessage clientMessage = new ClientMessage().name("gigi");
return requester.route("show-books").data(clientMessage).retrieveFlux(Book.class).limitRate(20);
}
@GetMapping(value = "books-channel", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<Book> booksChannel() {
return this.requester.route("books-channel")
.data(Flux.range(0, 10).map(i -> new ClientMessage().name("gigi").author(RandomUtil.randomAuthor())))
.retrieveFlux(Book.class).limitRate(5).log();
}
}
Listing 11-19The ClientController Containing Handler Methods Making RSocket Requests
introduction()和checkService()方法不需要produces属性,因为它们返回一个Mono<String>, Spring 足够聪明,可以在没有特殊指令的情况下自行反序列化。作为单个值,没有必要将它作为 SSE 发送给客户机。它也很实用,因为这些方法可以用浏览器来测试。
RSocketRequester将每个客户机请求路由到适当的服务器方法。该方法在服务器端由使用@MessageMapping注释指定的属性来标识。introduction()处理程序方法被设计成发出一个服务器调用,然后返回一个Mono<String>,所以该方法可以通过在浏览器中打开http://localhost:8080/introduction来执行。如果不返回任何内容,访问这个 URL 会让大多数浏览器感到困惑。例如,Firefox 抱怨无法读取响应,因为它无法将其转换为文本,而 Chrome 则什么都不说。但是,在反应式服务直接相互对话(不涉及浏览器)的环境中,只发送消息而不期待响应有时是有意义的。举个例子,我们来看看网络广告。你认为将它们发送到页面的服务器会期待你的响应吗?
可以在浏览器中测试checkService()方法,从服务器收到的响应显示为网页。最后两个方法返回事件流;为了测试它们,你需要使用curl命令 14 。
在越来越多的人对使用 Spring Boot 编写微服务应用感兴趣的背景下,RSocket 目前是 Pivotal 的典型代表。它易于设置,并且完美地集成在 Spring 生态系统中。如果你想在 Spring Reactive 应用的背景下了解更多关于 RSocket 的知识,spring.io 官方博客上有一系列文章。 十五
WebFlux 安全性
WebFlux 安全是本书的最后一个反应式应用主题。任何公开的应用都需要支持多个用户并控制对敏感资源的访问。在 Spring WebFlux 应用中进行配置比在非反应式 Spring web 应用中进行配置更容易。
从第 5 版开始,Spring Security 库被修改为包含 reactive 组件,以在 Spring WebFlux reactive 应用中设置安全性。Spring Security 在书中有专门的一章。如果您不熟悉保护 Spring 应用,我们建议您先阅读相关内容。我们所说的关于 servlet 环境中 Spring Security 性的一切也适用于反应式应用。唯一的区别是一些类和注释,它们允许通过使用反应流和声明性编程在反应式应用中配置安全性。
表 11-1 包含了为 Spring WebFlux 应用和 Spring MVC 应用配置安全性所涉及的注释和类。
表 11-1
Spring MVC 和 WebFlux 安全组件比较
|spring webflux
|
框架
|
描述
|
| --- | --- | --- |
| org.springframework.security. config.annotation .web.reactive | org.springframework.security. config.annotation.web.configuration | 大多数组件所在的根包。 |
| @EnableWebFluxSecurity | @EnableWebSecurity / @EnableWebMvcSecurity | 在配置类上使用的注释,用于启用 Spring Security 性。(@EnableWebMvcSecurity已弃用,可能会在 Spring 6 中移除。它的所有功能已经是@EnableWebSecurity的一部分。) |
| @EnableRSocketSecurity | — | @EnableRSocketSecurity是org.springframework.security.config.annotation.rsocket包的一部分,增加了通过 RSocket 协议通信的应用中的 Spring Security 性。 |
| @EnableReactive MethodSecurity | @EnableGlobalMethodSecurity | 在配置类上使用的注释,用于在方法级别启用 Spring Security 性。 |
| — | SecurityConfigurer | 由安全配置类实现的接口。 |
| ServerHttpSecurity(类) | HttpSecurity(最后一课) | 允许为特定的 HTTP 请求配置基于 web 的安全性。 |
| ReactiveUserDetailsService | UserDetailsService | 实现这些接口的类的实例存储用户信息,这些信息随后被封装到Authentication对象中。ReactiveUserDetailsService是围绕UserDetailsService的反应式包装器。 |
| ServerHttpRequest | HttpServletRequest | ServerHttpRequest是 Spring Web 库的一部分,而HttpServletRequest是 Java Servlet 库的一部分。 |
| ServerHttpResponse | HttpServletResponse | ServerHttpResponse是 Spring Web 库的一部分,而HttpServletResponse是 Java Servlet 库的一部分。 |
| ServerWebExchange | — | ServerWebExchange表示 HTTP 请求-响应交互的契约。它提供对ServerHttpRequest和ServerHttpResponse的访问。 |
| SecurityWebFilterChain | SecurityFilterChain | 表示过滤器链的接口与一个ServerWebExchange / HttpServletRequest匹配,以决定它是否适用于该请求。 |
| WebFilterChainProxy | DelegatingFilterProxy | 将过滤请求的工作委托给一组SecurityWebFilterChain / SecurityFilterChain实例。 |
| WebSessionServerCsrfTokenRepository | HttpSessionCsrfTokenRepository | 一个 CSRF 令牌库实现,它将CsrfToken存储在HttpSession中。 |
当使用 Spring Boot 编写一个安全的 web 反应式应用时,项目类路径上的spring-boot-starter-security依赖项会根据一些内部类为您自动配置安全性。应用仍然会启动,但是任何 URL 都会重定向到一个默认的登录表单。
要定制 Spring Boot WebFlux 应用中的安全配置,您可以在任何配置类中声明一个类型为org.springframework.security.web.server.SecurityWebFilterChain的 bean。当应用启动时,它会选择它并启用它配置的所有访问规则。如果不使用 Spring Boot,配置类需要用@EnableWebFluxSecurity注释。这个注释是在org.springframework.security.config.annotation.web.reactive包中声明的,尽管在 Spring Boot 应用中没有必要,但大多数开发人员倾向于将所有与安全相关的 beans 聚集在一个用这个注释注释的配置类中,从而将它用作一个标记。
创建 SecurityWebFilterChain bean 的方法将 Spring 注入的一个org.springframework.security.config.web.server.ServerHttpSecurity对象作为参数。ServerHttpSecurity类公开了与它的非反应对等物HttpSecurity几乎相同的方法,允许开发人员为 URL、身份验证提供者、登录表单、注销表单、CSRF 实现等指定访问规则。第十二章详细解释了如何配置 Spring Security。
在 Spring WebFlux 应用中,配置认证的最快方法是声明一个用一个或多个UserDetails实例初始化的MapReactiveUserDetailsService bean。该 bean 为内存中的身份验证提供数据。清单 11-20 是一个被配置为向两个用户提供认证数据的MapReactiveUserDetailsService bean 的例子。您可以看到PasswordEncoder bean,它设置了密码散列以获得更好的安全性。开发环境的配置可以使用NoOpPasswordEncoder来设置,它不会以任何方式改变密码。出于安全考虑,这种方法不被认可。
package com.apress.prospringmvc.bookstore.config.security;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
//other import omitted
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
//other code omitted
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails john = User.withUsername("john")
.password(passwordEncoder().encode("doe")).roles("USER")
.build();
UserDetails admin = User.withUsername("admin")
.password(passwordEncoder().encode("admin")).roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(john, admin);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Listing 11-20The MapReactiveUserDetailsService Bean
MapReactiveUserDetailsService类实现了org.springframework.security.core.userdetails.ReactiveUserDetailsService来声明一个简单的 API,用于根据用户名检索一个Mono<UserDetails>。如果身份验证数据是由数据库或任何外部系统(例如 Google 或 Okta 这样的单点登录提供商)提供的,那么使用现有数据的最简单方法就是实现这个接口,并提供一种定制的方法来检索身份验证数据。声明一个你的类型的 bean,它会被自动拾取。因为书店应用的身份验证数据保存在 MongoDB 表中,所以实现很简单,因为反应式 MongoDB 存储库使用反应式流返回数据。清单 11-21 描述了书店应用中使用的ReactiveUserDetailsService的实现。
package com.apress.prospringmvc.bookstore.config.security;
import com.apress.prospringmvc.bookstore.repository.AccountRepository;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
// other imports omitted
@Service
public class ReactiveAuthenticationService implements ReactiveUserDetailsService {
private final AccountRepository accountRepository;
public ReactiveAuthenticationService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Override
public Mono<UserDetails> findByUsername(String username) {
return accountRepository.findByUsername(username).switchIfEmpty(
Mono.defer(() -> Mono.error(new UsernameNotFoundException("User Not Found"))
)).map(this::toUserDetails);
}
private UserDetails toUserDetails(Account account) {
String[] authorities = new String[account.getRoles().size()];
authorities = account.getRoles().toArray(authorities);
return User.withUsername(account.getUsername())
.password(account.getPassword())
.authorities(authorities)
.build();
}
}
Listing 11-21The ReactiveAuthenticationService
Bean Used in the Bookstore Application
ReactiveAuthenticationService返回的数据用于认证用户。它的角色用于决定允许用户访问哪些资源,以及允许在应用中执行哪些操作。现在我们已经设置了身份验证提供者数据,下一步是配置SecurityWebFilterChain。清单 11-22 描述了书店应用中使用的配置。
package com.apress.prospringmvc.bookstore.config.security;
import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
// other imports omitted
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfig {
@Bean
SecurityWebFilterChain authorization(ServerHttpSecurity http) {
final RedirectServerLogoutSuccessHandler logoutSuccessHandler =
new RedirectServerLogoutSuccessHandler();
logoutSuccessHandler.setLogoutSuccessUrl(URI.create("/"));
return http
.formLogin(formLogin -> formLogin.loginPage("/login"))
.logout(logoutSpec -> logoutSpec.logoutUrl("/signout")
.logoutSuccessHandler(logoutSuccessHandler))
.authorizeExchange(authorize -> authorize
.matchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.pathMatchers("/book/edit/*", "/book/create").hasRole("ADMIN")
.pathMatchers("/customer/edit/*").hasRole("ADMIN")
.matchers(ServerWebExchangeMatchers.pathMatchers(HttpMethod.DELETE,
"/book/delete/*", "/customer/delete/*", "/account/delete/*")).hasRole("ADMIN")
.anyExchange().permitAll()
)
.csrf(csrf -> csrf.csrfTokenRepository(repo()))
.build();
}
@Bean
public ServerCsrfTokenRepository repo() {
WebSessionServerCsrfTokenRepository repo = new WebSessionServerCsrfTokenRepository();
repo.setParameterName("_csrf");
repo.setHeaderName("X-CSRF-TOKEN"); // default header name
return repo;
}
@Bean
public SpringSecurityDialect securityDialect() {
return new SpringSecurityDialect();
}
}
Listing 11-22The SecurityWebFilterChain
Bean
该配置描述了以下内容。
-
.formLogin(formLogin→formLogin.loginPage("/login"))配置/loginURL 返回的视图中声明的表单,用于用户登录。要使用默认生成的表单,您可以使用.formLogin(Customizer.withDefaults()). -
。logout(logout spec→logout spec . logout URL("/sign out "))是对 URL
/signout的 POST 请求,触发注销操作。默认为/logout。 -
.authorizeExchange(..)使用Customizer<T>实例来配置授权。 -
行
.matchers(PathRequest.toStaticResources().atCommonLocations())构建了一个ServerWebExchangeMatcher,它匹配静态源所在的 Spring Boot 默认位置(/resources/static目录),而.permitall()调用将它们排除在安全性之外。 -
pathMatchers(String...)方法对 URL 使用 Ant 样式模式,并返回将 URL 映射到处理程序方法的ServerWebExchangeMatcher实例。 -
ServerWebExchangeMatchers包含一些创建ServerWebExchangeMatcher实例的实用方法。前面的清单中显示的例子使用一个 HTML 方法和一个 URL 列表来创建一个应用安全规则的ServerWebExchangeMatcher实例。 -
.csrf(csrf→csrf.csrfTokenRepository(repo()))通过引入由同一代码示例中配置的ServerCsrfTokenRepository生成的 CSRF 令牌来保护应用。 -
RedirectServerLogoutSuccessHandler注销后将用户重定向到根页面(“/”)。
通过这种配置,客户端和服务器之间的所有交换都是安全的,无论它们是使用控制器处理程序方法还是功能端点来设置的。
通过在测试端点的方法上使用@WithMockUser注释,可以非常容易地测试安全配置。这个注释是spring-security-test库的一部分,位于org.springframework.security.test.context.support包中。它是在 Spring 4 中引入的,在 Spring 5 中被扩展到了反应端点。
清单 11-23 描述了四种测试方法。清单 11-22 中的配置涵盖了每个端点。
package com.apress.prospringmvc.bookstore.api;
import org.springframework.security.test.context.support.WithMockUser;
//other imports
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookSecuredApiTest {
private static Logger logger = LoggerFactory.getLogger(BookSecuredApiTest.class);
@Autowired
private WebTestClient testClient;
@WithMockUser(roles = "USER")
@Test
void shouldFindByIsbn(){
testClient.get()
.uri(uriBuilder -> uriBuilder.path("/book/by/{isbn}").build("9781484230042"))
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody(Book.class)
.consumeWith(responseEntity -> {
logger.debug("Response: {}", responseEntity);
Book book = responseEntity.getResponseBody();
assertAll("book", () ->
{
assertNotNull(book);
assertAll("book",
() -> assertNotNull(book.getTitle()),
() -> assertEquals("Iuliana Cosmina", book.getAuthor()));
});
});
}
@Test
@WithMockUser(roles = "ADMIN")
void shouldCreateABook() {
Book book = new Book();
book.setTitle("TDD for dummies");
book.setAuthor("Test User");
book.setPrice(BigDecimal.valueOf(40.99));
book.setIsbn("12232434324");
book.setCategory("test");
testClient.post().uri("/book/create")
.body(Mono.just(book), Book.class)
.exchange()
.expectStatus().isCreated()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectHeader().exists("Location")
.expectBody(Book.class)
.consumeWith(responseEntity -> {
logger.debug("Response: {}", responseEntity);
assertAll("book", () ->
{
assertNotNull(book);
assertAll("book",
() -> assertNotNull(book.getIsbn()),
() -> assertEquals("test", book.getCategory()));
});
});
}
@WithMockUser(roles = "ADMIN")
@Test
void shouldDeleteByIsbn(){
testClient.delete()
.uri(uriBuilder -> uriBuilder.path("/book/delete/{isbn}")
.build("9781484230042"))
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isNoContent();
}
@Test
public void shouldReturnTwoBooks(){
BookSearchCriteria criteria = new BookSearchCriteria();
criteria.setCategory(Book.Category.JAVA);
testClient.post()
.uri("/book/search")
.accept(MediaType.APPLICATION_JSON)
.body(Mono.just(criteria), BookSearchCriteria.class)
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBodyList(Book.class)
.hasSize(2);
}
}
Listing 11-23Class Testing Secured Endpoints Access
当@WithMockUser被置于 Spring Security 引导测试上下文中的测试方法上时,对被测试端点的调用是使用特定于模拟用户的模拟安全上下文进行的。可以使用名称@WithMockUser("john")来指定用户,但是由于在清单 11-21 中,安全规则是使用角色来声明的,所以使用角色的注释形式更合适。
书店应用使用百里香叶作为模板引擎。为了支持视图中的安全百里香元素,必须将类型为SpringSecurityDialect的 bean 添加到配置中。
如果安全部分看起来很薄,这是因为第十二章完全致力于 Spring Security。考虑阅读该内容,然后回到本节。
摘要
在我们三部曲反应章节的最后,有几件事你应该记住。要构建反应式应用,您需要一种反应式思维,并以声明方式编写代码。Spring WebFlux 是编写在健壮的 JVM 平台上运行的反应式应用的优秀候选。Spring WebFlux 简化了线程化工作,因为它不需要与并行工作的底层组件进行交互。它提供了许多简化数据流转换的操作符。最终的代码更干净、可读性更强、也更健壮。
支持服务器发送的事件和 WebSocket 协议。RSocket(网飞开发人员的另一个创意)是一种新的消息协议,旨在解决常见的微服务通信挑战,如在 TCP 上处理逻辑元素级别的背压。您可以获得现代控制,如多路复用、背压、恢复和路由,并且您可以获得多种消息传递模式,包括一劳永逸、请求-响应和流式传输。
保护 WebFlux 应用也很容易。
反应式应用改善用户体验,现在大部分 web 和移动应用都是反应式应用。所以,如果你认为你可能会推迟学习反应式编程,也许不要。😃
也有缺点。学习曲线可能很陡,很容易错误地管理订阅,最终导致内存泄漏,影响用户体验。一般的方法是慢慢来,只对需要的组件进行反应。
当构建高负载或多用户应用时,您需要变得被动。社交网络应用是反应式应用的一个很好的例子。在线聊天应用是另一个例子。你想流媒体音乐或视频?变得被动也是正确的选择。你想建立一个具有高度互动 UI 元素的网络游戏吗?是的,你需要把它建成一个反应式的应用。
反应式编程已经存在,并且对于任何有成就的开发人员来说,很好地使用它都是一项必备技能。
十二、Spring Security
据说信息就是力量。如今,当所有的 web 应用都托管在云中,部署在别人设计的容器中,并且希望得到安全保护和审计时,这种情况就更加真实了。开发人员至少可以保证密码的安全。大多数 web 应用都有任何人都可以看到的公共页面和为经过验证的用户保留的私有页面。例如,书店应用应该有添加、编辑和删除图书条目的页面,这些页面只对具有管理角色的用户可用。在前面的章节中,Spring MVC 被用来构建一个 Spring web 应用。Spring Security 是保护 Spring web 应用的最佳框架。
Spring Security 是一个高度可定制的认证和访问控制框架。这个框架是基于 Acegi security, 1 编写的,当时 Spring 还在襁褓中。它为使用 Spring 框架构建的 Java 企业应用提供了强大而灵活的安全解决方案。Spring Security 为身份验证、授权和防范常见攻击提供了全面的支持。它还提供了与其他库的集成,以简化其使用。
这一章详细介绍了保护 Spring web 应用的不同方法。让我们从几个关键的安全术语和原则开始。
安全基础知识
访问 web 应用并能执行操作的实体被称为主体。委托人使用被称为凭证的标识密钥来标识自己。当访问 web 应用时,计算机的浏览器会创建一个小的信息包并存储在您的系统中。这被称为 cookie *。2cookie 可以存储个性化信息(如语言和主题),以便定制您的下次访问。在项目中搜索本书的CookieResolver。这是一个典型的 Spring bean 类型,旨在创建一个名为org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE的 cookie 来存储您的首选语言环境。这里提到的 cookie 名称是 Spring 设置的默认名称,但是可以在为CookieResolver bean 配置时进行定制。这些 cookies 被称为持久型,即使你关闭了浏览器,它们仍然在运行。它们作为文件存储在浏览器的一个子文件夹中,直到您手动删除它们,或者您的浏览器根据永久 cookie 文件中包含的过期时间删除它们。一些 web 应用使用这种类型的 cookies 来存储您的凭据,因此您不必在每次使用网站时都键入它们。
非永久性的 cookie 被称为会话 cookie,它们的生命周期由 HTTP 用户会话的生命周期决定。HTTP 用户会话在您登录 web 应用时开始,在您注销时结束。有些应用还会在页面关闭时结束用户的会话。存储在会话 cookie 中的信息在站点的页面之间共享。购物网站使用这种类型的 cookies 来存储您添加到虚拟购物车中的产品。
当用户登录时,服务器会在您的浏览器中设置一个临时 cookie,以记住您当前已登录,并且您的身份已得到确认。会话 cookies 也适用于存储凭证,因为它们缩短的生命周期也缩短了它们被劫持的时间间隔。 3
确认身份的过程称为认证,包括对照服务器数据库检查用户提供的凭证。最基本的身份验证类型需要用户 id 和密码,它依赖于单一的身份验证因素。它是一种的单因素认证。用户通过认证后,通常会经历一个授权的过程。需要这个过程来确定用户应该能够访问的应用部分。例如,书店应用的普通用户不应该被允许编辑或删除书籍。用户可以访问的应用部分通常由角色描述。
Spring Security 框架提供了独立配置认证和授权的可能性。由于是松散耦合的,其中一个或两个都可以被这些服务的外部提供者替换。Spring Framework 还支持 web 请求级别、服务方法和单个域对象上的授权。有了如此多的可能性,难怪 Spring Security 几乎成了保护 Spring 应用的默认选择。
最低限度的 Spring Web 安全性
Spring MVC 是一个非常强大和通用的框架,通过实现org.springframework.web.servlet.HandlerInterceptor和在 HTTP 用户会话中存储凭证,可以实现最小的限制。
可以在 Spring MVC 配置中注册org.springframework.web.servlet.HandlerInterceptor的定制实现,以便在org.springframework.web.servlet.HandlerAdapter调用处理程序之前和之后执行代码。这意味着HandlerInterceptor中的代码会阻止处理程序的正常执行。因此,使用HandlerInterceptor实现最低限度的安全性是可能的。当用户登录时,其信息和凭证存储在 HTTP 会话中。可以编写一个与清单 12-1 非常相似的HandlerInterceptor实现来寻找这个属性。
package com.apress.prospringmvc.bookstore.web.interceptor;
import org.springframework.web.servlet.HandlerInterceptor;
// Other imports omitted
public class SecurityHandlerInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
var account = (Account) WebUtils.getSessionAttribute(request, "ACCOUNT_ATTRIBUTE");
if (account == null) {
//Retrieve and store the original URL.
var url = request.getRequestURL().toString();
WebUtils.setSessionAttribute(request, "REQUESTED_URL", url);
throw new AuthenticationException("Authentication required.", "authentication.required");
}
return true;
}
}
Listing 12-1SecurityHandlerInterceptor to Control Access to Pages Requiring Authentication
当用户试图访问需要认证的页面时,SecurityHandlerInterceptor会设置REQUESTED_URL属性。这确保了在成功认证后,用户被重定向到他在认证前试图访问的页面。这种配置对于登录过程很重要,因为登录页面必须从这种行为中排除,这就是清单 12-2 中的最后一个if语句在handleLogin(..)方法中负责的事情。
package com.apress.prospringmvc.bookstore.web.controller;
// Other imports omitted
@Controller
@RequestMapping(value = "/login")
public class LoginController {
public static final String ACCOUNT_ATTRIBUTE = "account";
public static final String REQUESTED_URL = "REQUESTED_URL";
@Autowired
private AccountService accountService;
@RequestMapping(method = RequestMethod.GET)
public void login() {
}
@RequestMapping(method = RequestMethod.POST)
public String handleLogin(@RequestParam String username, @RequestParam String password, HttpSession session)
throws AuthenticationException {
var account = this.accountService.login(username, password);
session.setAttribute(ACCOUNT_ATTRIBUTE, account);
var url = (String) session.getAttribute(REQUESTED_URL);
// Remove the attribute
session.removeAttribute(REQUESTED_URL);
// Prevent loops for the login page.
if (StringUtils.hasText(url) && !url.contains("login")) {
return "redirect:" + url;
} else {
return "redirect:/index.htm";
}
}
}
Listing 12-2The LoginController Code
Snippet That Ensure No Endless Loop Is Caused When User Logs In
由于处理程序方法有多种多样的签名,当访问这些 URL 中的任何一个时,可以使用一个HttpSession object作为参数,并且可以从中提取并使用Account实例。
必须将一个SecurityHandlerInterceptor实例添加到为 Spring 应用配置的拦截器的注册表中。这是通过确保 web 配置类中被覆盖的addInterceptors(..)方法包含对它的引用来实现的。清单 12-3 描述了这个配置片段。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
// Other imports omitted
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = { "com.apress.prospringmvc.bookstore" })
public class WebMvcContextConfiguration implements WebMvcConfigurer {
// code omitted
@Override
public void addInterceptors(InterceptorRegistry registry) {
// other interceptors omitted
registry.addInterceptor(new SecurityHandlerInterceptor())
.addPathPatterns("/customer/account", "/cart/checkout");
}
}
Listing 12-3Adding a SecurityHandlerInterceptor Instance
to the List of Interceptors
这是在 Spring 应用中保护对几个页面的访问的最简单的方法。对于为教学目的构建的简单应用,这很好,因为它清楚地表明了 Spring 是多么强大,但是在实际的生产应用中很少使用这样的实现。此外,还需要编写更多的代码来支持身份验证过程。支持授权需要编写更多的代码,这就是 Spring Security 存在的原因。
使用 Spring Security 性
Spring Security 最大的一个优势就是可移植性。它不需要一个特殊的容器来运行。可以通过向应用添加所需的依赖项并配置一些 beans 来设置 Spring Security 性。另一个很大的优势是它的可扩展性:开发人员可以决定如何定义主体、凭证存储在哪里、以什么格式存储、如何做出授权决定等等。此外,由于保护资源是使用代理机制完成的,Spring Security 使得将安全逻辑从应用逻辑中分离出来变得容易,避免了代码混乱和分散。
当为 Spring web 应用配置了 Spring Security 时,在到达DispatcherServlet,之前,请求会通过安全过滤器链进行过滤。这些过滤器都是javax.servlet.Filter接口的实现。过滤器的顺序很重要,任何过滤器都可以修改请求,然后调用链中的下一个过滤器。Spring 的ApplicationContext通过其核心实现Filter : org.springframework.web.filter.DelegatingFilterProxy与 Servlet 容器的生命周期集成在一起。
通过标准的 servlet 容器机制注册它,所有的工作都可以委托给实现Filter的 Spring bean。
当在应用中配置了 Spring Security 时,DelegatingFilterProxy将过滤请求的工作委托给一个名为springSecurityFilterChain的org.springframework.security.web.FilterChainProxy类型的特殊 bean。这个 bean 允许使用它的org.springframework.security.web.SecurityFilterChain实例列表委托给许多Filter实例。每个SecurityFilterChain匹配一个 URL 片配置的应用的一部分。所有 Spring Security Filter实现的完整列表及其确切顺序可以在官方参考文档中找到。 4
因此,总的来说,这个过滤器链提供了对身份验证的支持,确保授权,在 HTTP 会话中维护org.springframework.security.core.context.SecurityContext,并在注销时管理用户会话的有效结束。如果您还记得,ApplicationContext是为应用提供配置的中央界面。同样,SecurityContext是为应用提供安全配置的中央界面。
表 12-1 按照它们在链中出现的顺序列出了最重要的网络过滤器,并简单解释了它们的职责。
表 12-1
最重要的安全过滤器springSecurityFilterChain委托给
过滤器
|
描述
|
| --- | --- |
| org.springframework.security.web.access.channel.ChannelProcessingFilter | 为使用适当的通道传递请求提供支持。安全请求通过安全通道传递。 |
| org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter | 提供异步安全请求处理支持。 |
| org.springframework.security.web.header.HeaderWriterFilter | 支持在响应中添加安全头,如X-Frame-Options、X-XSS-Protection,和X-Content-Type-Options。 |
| org.springframework.security.web.context.SecurityContextPersistenceFilter | 用特定于用户会话的信息填充SecurityContextHolder,并在请求之间维护SecurityContext。 |
| org.springframework.security.web.csrf.CsrfFilter | 为包含 CSRF 令牌的请求提供支持。 5 |
| org.springframework.security.web.authentication.logout.LogoutFilter | 通过调用一组处理程序从安全上下文中清除Authentication对象来结束用户会话。在其执行结束时,用户不再被验证;因此,它无法访问安全页面。 |
| org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter | 处理身份验证表单提交。登录表单为此过滤器提供了两个参数:用户名和密码。 |
| org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter | 如果应用不需要登录页面,这个过滤器可以为您生成一个。 |
| org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter | 如果应用不需要注销页面,这个过滤器可以为您生成一个。大多数应用并不真的需要注销页面。 |
| org.springframework.security.web.authentication. www.BasicAuthenticationFilter | 为基本身份验证提供支持。 6 这种类型的认证涉及到具有带 Base-64 编码值username:password的Authorization报头的请求。由于身份验证令牌是以明文形式传输的,因此请使用摘要式身份验证而不是基本身份验证。 |
| org.springframework.security.web.authentication. www.DigestAuthenticationFilter | 为摘要式身份验证提供支持。 7 查看前一个表格行。 |
| org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter | SecurityContextHolder是春安的核心组件。该过滤器提供了实现安全 API 方法的请求包装器。调用这些方法就是用配置、认证和授权信息填充SecurityContextHolder,从而配置应用的安全上下文。 |
| org.springframework.security.web.authentication.AnonymousAuthenticationFilter | 为匿名用户提供支持。 |
| org.springframework.security.web.session.SessionManagementFilter | 为各种与 HTTP 会话相关的功能提供支持,如会话固定保护攻击防御。 8 |
| org.springframework.security.web.access.ExceptionTranslationFilter | 为链中引发的安全异常提供支持。它表示 Java 异常和 HTTP 状态代码之间的链接,需要这些代码来维护用户界面并向用户显示适当的消息。 |
| org.springframework.security.web.access.intercept.FilterSecurityInterceptor | 为授权 HTTP 请求提供支持,并引发AccessDeniedException s。 |
| org.springframework.security.web.authentication.switchuser.SwitchUserFilter | 为用户上下文切换提供支持。具有较高权限的用户可以切换到较低权限的用户。 |
图 12-1 展示了在 web 应用中配置 Spring Security 性时请求处理工作流的高级概述。

图 12-1
Spring Security 的认证和授权过程
图 12-1 描述了 Spring web 应用如何通过 Spring Security 来处理请求。
用户试图访问应用的安全页面。由于页面是安全的,应用将用户重定向到登录页面。用户提供其用户名和密码,并提交登录请求。
登录请求被过滤,并且springSecurityFilterChain和处理认证的合适过滤器被识别。在这种情况下,它就是UsernamePasswordAuthenticationFilter。这个过滤器使用AuthenticationManager来验证凭证。最常用的AuthenticationManager实现是ProviderManager,,它声明了可用于支持认证过程的已配置AuthenticationProvider实例列表。
如果凭证得到验证,就会创建一个Authentication实例,并存储在应用的security context中。SecurityContext包含在SecurityContextHolder,中,?? 是 Spring Security 的核心。
配置身份验证
Authentication实例包含以下信息。
-
用于识别用户的主体。在基本的用户/密码认证中,它通常是一个
UserDetails实例。 -
凭证包含密码。为了避免密码被泄露,通常会在用户通过身份验证后清除密码。
-
权限代表授予用户的应用权限,也称为角色或范围。
一旦用户通过身份验证,返回的响应要么是为登录过程配置的默认页面,要么是用户在通过身份验证之前试图访问的页面(如果用户有权访问该页面)。包含在Authentication对象中的权限由AccessDecisionManager使用投票者列表进行分析,以决定用户是否可以访问所请求的资源。
Spring Security 提供了一种非常实用的方式来配置和定制大多数进程。在 Spring MVC 应用中,配置 Spring Security 配置需要三样东西:
-
将
springSecurityFilterChain与容器 servlet 环境集成。 -
为认证和授权提供安全配置,它定义了应用的
securityContext。 -
将安全上下文与 web 应用上下文集成。
为了将springSecurityFilterChain与容器 servlet 环境集成,必须在配置中添加一个扩展org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer的类。清单 12-4 描述了这样一个类。AbstractSecurityWebApplicationInitializer的子类注册DelegatingFilterProxy以在任何其他注册filter.之前使用springSecurityFilterChain这是必要的,因为应用上的每个请求都必须被拦截并分析权限。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.web.context.
AbstractSecurityWebApplicationInitializer;
class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
}
Listing 12-4Subclass of AbstractSecurityWebApplicationInitializer
必须提供用@EnableWebSecurity标注的配置类来配置securityContext。为了帮助开发人员轻松创建这个类,Spring Security 提供了org.springframework.security.config.annotation.SecurityConfigurer接口和多个实现,这些实现已经包含了开箱即用的默认实现,因此所需的定制是最少的。
为 web 应用配置 Spring Security 性最简单的方法是扩展WebSecurityConfigurerAdapter并为configure(AuthenticationManagerBuilder auth)方法提供一个实现来配置AuthenticationManagerBuilder。Spring Security 使用这个构建器创建一个对用户进行认证的org.springframework.security.authentication.AuthenticationManager。
Spring 支持多种认证机制: 9 Basic、Form、OAuth、X.509、SAML,但是对于本章,重点在于使用用户名和密码设置表单认证。
对于简单的小型应用,内存数据库足以存储用户名和凭证。即使数据库在内存中,密码也不应该以明文存储,这就是为什么 Spring Security PasswordEncoder实现之一应该对密码应用散列函数。在清单 12-5 中,org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder在将密码文本存储到数据库之前对其应用 BCrypt 强散列函数。
出于测试目的,Spring Security 仍然支持org.springframework.security.crypto.password.NoOpPasswordEncoder,这是一个不做任何事情并以明文形式保存密码的实现。但是,该类被标记为 deprecated,因此将来可能会被移除。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.config.annotation.web.configuration.
EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.
WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.authentication.
builders.AuthenticationManagerBuilder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.apress.prospringmvc.bookstore.util.ConfigurationException;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
try {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
auth.inMemoryAuthentication()
.passwordEncoder(passwordEncoder)
.withUser("john")
.password(passwordEncoder.encode("doe"))
.roles("USER")
.and().withUser("jane")
.password(passwordEncoder.encode("doe"))
.roles("USER", "ADMIN")
.and().withUser("admin")
.password(passwordEncoder.encode("admin"))
.roles("ADMIN");
} catch (Exception e) {
throw new ConfigurationException(
"In-Memory authentication was not configured.", e);
}
}
// code omitted
}
Listing 12-5Configuration of an AuthenticationManager by Extending WebSecurityConfigurerAdapter
每个用户在应用中都被分配了一个角色。角色封装了用户可以在应用中执行的操作。大多数应用至少有两个角色。对于书店应用,ADMIN角色被分配给可以添加、编辑和删除图书条目的用户。可以查看和订购书籍的用户拥有USER角色。
一个GrantedAuthority实例代表授予一个Authentication对象的权限。角色和权限之间没有显著的区别,除了语义和它们的使用方式。角色存储在配置了 Spring Security 的安全位置,通常是数据库。当声明一个内存数据库时,如清单 12-5 所示,可以使用roles(..)方法。这个方法由 Spring Security 提供的一个名为UserDetailsManagerConfigurer(位于一个包中,这个包的名字很长,与此无关)的构建器类提供,用来创建UserDetails实例,封装允许访问应用的用户的认证数据。角色不能为空,角色名不能以ROLE_开头,因为roles(..)方法是UserDetailsManagerConfigurer类中声明的authorities(..)方法的快捷方式,在角色名前面加上ROLE_。因此,清单 12-5 中的代码也可以按照清单 12-6 中的方式编写。有异曲同工之妙。
package com.apress.prospringmvc.bookstore.web.config.sec;
// Imports omitted (view Listing 12-3)
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
try {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
auth.inMemoryAuthentication()
.passwordEncoder(passwordEncoder)
.withUser("john")
.password(passwordEncoder.encode("doe"))
.authorities("ROLE_USER")
.and().withUser("jane")
.password(passwordEncoder.encode("doe"))
.authorities("ROLE_USER", "ROLE_ADMIN")
.and().withUser("admin")
.password(passwordEncoder.encode("admin"))
.authorities("ROLE_ADMIN");
} catch (Exception e) {
throw new ConfigurationException(
"In-Memory authentication was not configured.", e);
}
}
// code omitted
}
Listing 12-6Configuration of an AuthenticationManager by Setting Up Authorities
角色或权限用在授权过程中,这将在本章后面解释。
当 H2 内存数据库用于存储凭据时,可以通过访问数据库的 H2 web 客户端来检查其内容。要将此 web 客户端添加到您的应用,您必须将org.h2.server.web.WebServlet添加到应用,并将其映射到一个安全的 URL。即使密码被加密,保护对数据库的访问仍然是一件必须做的事情。这将在本章后面解释。
auth.inMemoryAuthentication()行向AuthenticationManagerBuilder添加了内存认证。withUser(String username)方法构建一个包含用户核心信息的org.springframework.security.core.userdetails.UserDetails实例。出于安全目的,Spring Security 不使用这些实例。它们保存着加载到Authentication对象中的信息。UserDetails实例由UserDetailsService管理.开发人员可以提供自己的实现来声明存储和检索认证信息的不同方式。
Spring Security 提供了基于 JDBC(数据库)的UserDetailsService。这种方法的限制是数据库必须具有 Spring Security 所要求的结构, 10 并且必须是 SQL 数据库。但是由于书店应用有自己的保存用户信息的数据库表——ACCOUNT表,所以使用该表进行认证也更合适。有两种方法可以做到这一点。第一个是实现一个定制的UserDetailsService,它从ACCOUNT表中加载用户详细信息。另一种方式是实现定制的AuthenticationProvider。
对于大多数应用来说,这种方法是首选的,因为身份验证提供者也可以从外部提供者(例如,像 Google 这样的单点登录提供者这样的外部系统)检索身份验证信息。这允许身份验证过程的外部化。
清单 12-7 描述了在SecurityConfiguration类中设置自定义authentication provider认证所需的变化。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.authentication.AuthenticationProvider;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(bookstoreAuthenticationProvider());
}
@Bean
public AuthenticationProvider bookstoreAuthenticationProvider(){
return new BookstoreAuthenticationProvider();
}
// code omitted
}
Listing 12-7Custom Implementation of AuthenticationManagerBuilder Using a Database Table
bookstoreAuthenticationProvider bean 类型是BookstoreAuthenticationProvider,,它实现了AuthenticationProvider。AuthenticationProvider是一个简单的接口,有两个方法。清单 12-8 描述了这个接口的代码。 11
package org.springframework.security.authentication;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
// some comments omitted
public interface AuthenticationProvider {
/**
* @param authentication the authentication request object.
* @return a fully authenticated object including credentials.
* @throws AuthenticationException if authentication fails.
*/
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
/**
* @param authentication
* @return <code>true</code> if the implementation can more closely evaluate the
* <code>Authentication</code> class presented
*/
boolean supports(Class<?> authentication);
}
Listing 12-8AuthenticationProvider Interface Code
authenticate(..)方法处理认证请求。在BookstoreAuthenticationProvider实现中,代码从ACCOUNT表中提取用户,验证密码,并创建Authentication对象。
supports(..)方法声明了支持的Authentication实现的类型。由于使用了使用用户名和密码的简单认证,声明支持的类型是UsernamePasswordAuthenticationToken。
清单 12-9 描述了BookstoreAuthenticationProvider的完整代码。
package com.apress.prospringmvc.bookstore.web.config.sec;
import com.apress.prospringmvc.bookstore.domain.Account;
import com.apress.prospringmvc.bookstore.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
// Other imports omitted
public class BookstoreAuthenticationProvider implements AuthenticationProvider {
private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Autowired
private AccountService accountService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
Account account = accountService.getAccount(username);
if(account == null) {
throw new BadCredentialsException(
"Authentication failed for " + username);
}
if(!passwordEncoder.matches(password, account.getPassword())) {
throw new BadCredentialsException(
"Authentication failed for " + username);
}
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
account.getRoles().forEach(role ->
grantedAuthorities.add(new SimpleGrantedAuthority(role.getRole())));
return new
UsernamePasswordAuthenticationToken(
username, password, grantedAuthorities);
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(
UsernamePasswordAuthenticationToken.class);
}
}
Listing 12-9BookstoreAuthenticationProvider Code
配置授权
需要在安全配置类中实现的另一个重要方法是configure(HttpSecurity security)。这个方法定制了 Spring Security 用来为 HTTP 请求启用 web 安全性的org.springframework.security.config.annotation.web.builders.HttpSecurity实例。
HttpSecurity安全对象是通用的,可以配置规则来拦截某些 URL,配置登录和注销表单生成,在成功或失败的身份验证请求后重定向,CSRF 保护,等等。决定用户可以发出哪些请求是基于它的权限,这就是授权的意义所在。
默认情况下,Spring Security 要求对所有请求进行身份验证。但是 Spring Security 可以通过按照优先级添加更多的规则来配置不同的规则。列出的截取的 URL 的顺序很重要。模式按照定义的顺序进行评估。这意味着,更具体的模式比不太具体的模式在列表中定义得更高。您可以为需要某种程度安全性的 URL 定义模式,并以一个模式结束列表,该模式允许访问与列表中先前条目不匹配的任何内容。或者,您可以为首先需要公共访问的 URL 定义模式,并以禁止访问与列表中先前条目不匹配的任何内容的模式结束列表。这取决于应用的目的。
清单 12-10 描述了一个简单的 Spring Security 授权,它允许未经认证的用户访问书店应用的所有页面,除了具有ADMIN角色的经过认证的用户可以访问的页面。
还有一个额外的configure(WebSecurity web)方法。这个方法接受一个类型为WebSecurity的参数,并告诉 Spring Security 忽略静态 web 请求(比如。css,js,图片等。).没有理由让这些请求通过安全过滤器,因为这只会减慢视图的呈现过程。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/customer/account", "/cart/checkout").authenticated()
.antMatchers("/book/edit", "/book/delete").hasRole("ADMIN")
.antMatchers("/customer/delete").hasRole("ADMIN")
.antMatchers("/**").permitAll();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/resources/**",img/**", "/styles/**");
}
// some code omitted
}
Listing 12-10Spring Security Authorization Configuration for Intercepting URLs Using AntPathRequestMatchers
使用清单 12-10 中的配置片段,一个未经认证的用户可以看到图 12-2 中描述的书店应用视图。

图 12-2
非验证用户的书店应用视图
authorizeRequests()返回一个HttpSecurity的实例,该实例被配置为允许使用RequestMatcher实现来限制对请求的访问。清单 12-10 描述了使用AntPathRequestMatcher实例来声明一个 URL 模板,请求 URL 应该与该模板匹配。这个类的名字清楚地表明支持 ant 风格的模式。对于每个匹配用antMatchers(..)方法声明的模式的请求 URL,应用与返回的匹配器实例相关联的限制。
只有经过身份验证的用户才能访问他们自己的帐户和结帐页面,所以下一行
.antMatchers("/customer/account", "/cart/checkout").authenticated()
只允许经过身份验证的用户根据资源的 URL 访问这些资源。
下面一行声明,只有经过身份验证的用户拥有ADMIN角色时,才应该处理带有/book/edit和/book/deleteURL 的请求。
.antMatchers("/book/edit", "/book/delete").hasRole("ADMIN")
如果没有该角色的用户试图访问这些 URL 中的一个,则会引发异常,并且用户会被重定向到错误页面。
匹配器的顺序很重要。匹配 URL 的第一个声明的模板决定应用于请求的访问规则。
这就是为什么这个配置行.antMatchers("/**").permitAll()排在最后。这表明所有剩余的请求都将被解析,而不管用户是否经过身份验证。这些也被称为公共资源。
在生产应用中通常不会这样做,在生产应用中最好使用.antMatchers("/**").denyAll()来避免暴露任何不应该暴露的内容。
在 Security 4.1.1 中,引入了MvcRequestMatcher实现。它通过支持声明模板的扩展和添加对变量的支持,扩展了对 ant 样式模式的支持。
.antMatchers("/customer/account")声明请求 URL 与“/客户/账户”字符串的精确匹配。
.mvcMatchers("/customer/account")声明更广泛的匹配。URL“/customer/account/”、“/customer/account.pdf”和“/customer/account.html”被视为与此模板匹配。
此外,正如您可能已经注意到的,在antMatchers和mvcMatchers中声明 URL 模板时支持通配符。
当 URL 模式以“*”结尾时,该模式匹配前缀加一个单个术语(例如,/h2-console/*匹配/h2-console/a和/h2-console/20)。
当以**结束一个 URL 模式时,该模式匹配前缀,以及任意数量的术语,整个目录树。所以,/h2-console/**匹配/h2-console/a,/h2-console/20,/h2-console/a/20,/h2-console/1/2/3/4,还有很多。
可以说,MvcRequestMatchers提供了更多的安全性,因为它们包含了使用相同模板的更广泛的 URL 集,并且避免了错误地公开内容。
还有一个参数化版本的authorizeRequests (Cutomizer org.springframework.security.config.Customizer<T>实例,该实例用作它的参数。这提供了更具可读性的配置。前面的配置可以进一步简化,因为所有包含 edit 和 delete 的 URL 都是为具有ADMIN角色的用户保留的,所以我们可以使用一个更通用的配置。
清单 12-10 中的配置因此可以升级到清单 12-11 中描述的配置。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.MvcRequestMatcher;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/*/edit/*", "/*/delete/*").hasRole("ADMIN")
.mvcMatchers("/customer/account", "/cart/checkout").authenticated()
.anyRequest().permitAll()
);
}
// some code omitted
}
Listing 12-11Spring Security authorization Configuration for Intercepting URLs Using MvcRequestMatchers
与匹配器相关的规则也是通用的。从 Spring Security 版本开始,Spring EL 表达式可以用作授权机制。像下面这样的构造是正确的,它将对/h2-console URL 的访问限制为具有ADMIN和DBADMIN角色的用户。
.mvcMatchers("/h2-console").access("hasRole('ADMIN') and hasRole('DBADMIN')")
前一个配置并不等同于下一个配置,后者将对/h2-console URL 的访问限制为ADMIN或DBADMIN角色的用户。
.mvcMatchers("/h2-console").hasAnyRole("ADMIN", "DBADMIN")
使用hasRole(..)方法可以限制一个角色。
.mvcMatchers("/h2-console").hasAnyRole("DBADMIN")
安全表达式在 Spring Security 标签 12 中得到支持,它们在根据角色动态地重新调整返回给用户的视图时非常有用。在 JSP 模板中使用 Spring Security 标记需要在 JSP 页面中声明以下标记库。
<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
例如,Logout菜单项不应该是视图的一部分,除非用户已经过身份验证。注销表单也不应该是视图的一部分。通过将它们封装到一个<sec:authorize access="isAuthenticated()"> .. </sec:authorize>元素中,可以对它们进行调整。
还有像Register和Login这样的菜单项,只有在没有认证用户的情况下才应该呈现。这可以通过将特定的 HTML 元素封装在类似的结构中,但是使用表达式的否定来实现。
使用清单 12-12 中描述的结构可以很容易地做到这一点。
<!-- other taglibs declarations omitted -->
<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
<div class="header">
<!-- other JSP elements omitted -->
<div class="nav">
<ul style="float: left;">
<li class="selected">
<a href="${homeUrl}">
<spring:message code="nav.home"/>
</a>
</li>
<!-- other unsecured JSP elements omitted -->
<sec:authorize access="!isAuthenticated()">
<li>
<a href="<c:url value="/customer/register"/>">
<spring:message code="nav.register"/>
</a>
</li>
<li>
<a href="<c:url value="/login"/>">
<spring:message code="nav.login"/>
</a>
</li>
</sec:authorize>
<sec:authorize access="hasRole('ADMIN')">
<li>
<a href="<c:url value="/customer/list"/>">
<spring:message code="nav.admin"/>
</a>
</li>
</sec:authorize>
<sec:authorize access="isAuthenticated()">
<li>
<a href="#" onclick="document.getElementById('logout').submit();">
<spring:message code="nav.logout"/>
</a>
</li>
<spring:url value="/logout" var="logoutUrl"/>
<form action="${logoutUrl}" id="logout" method="post">
<input type="hidden" name="${_csrf.parameterName}"
value="${_csrf.token}"/>
</form>
</sec:authorize>
</ul>
<ul style="float: right;">
<sec:authorize access="isAuthenticated()">
<li>(<em><sec:authentication property="principal" /></em>)</li>
</sec:authorize>
<!-- other unsecured JSP elements omitted -->
</ul>
</div>
</div>
Listing 12-12header.jsp Navigation Menu with Security Configurations
Spring Security 标签库还提供了访问认证数据的可能性。在清单 12-12 中,最后一个元素由经过认证的用户名填充。
此外,如果您不喜欢在视图模板中使用安全表达式,请在安全配置中为 URL 配置规则,并使用对 URL 的引用。与其写下面的内容,
<sec:authorize access="hasRole('ADMIN')">
<a href="<c:url value="/book/edit/${book.id}"/>">
<spring:message code="label.edit"/>
你可以写这个。
<sec:authorize url="/*/edit/*">
<a href="<c:url value="/book/edit/${book.id}"/>">
<spring:message code="label.edit"/>
在本章开始时,您已经了解到 Spring Security 提供了保护单个对象的方法。这很容易实现,因为安全表达式非常强大,可以访问 beans 和路径变量。因此,假设我们的书店有一本专门为 VIP 用户保留的书,可以配置一个 bean 来限制只有他们才能访问这本书。
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/book/{bookId}")
.access("bookSecurity.checkVIP(authentication,#bookId)")
...
);
access(String)方法将表示 SpEL 表达式的文本作为参数,该表达式定制类型为ExpressionUrlAuthorizationConfigurer,的安全基础设施 bean,该 bean 将基于 SpEL 表达式的基于 URL 的授权添加到应用中。
Spring Security 提供了多种方法来保护对资源的访问,但是建议注意一些细节,因为很容易暴露太多或太少。
清单 12-12 展示了 Spring Security 标签库如何使用 Apache Tiles 动态生成考虑安全设置的视图。这个标记库可以和其他基于 JSP 的模板引擎一起使用。
为了与 Spring 完全集成而设计,百里香提供了一个包含 Spring Security 方言的扩展库。Spring Security 集成模块是 Spring Security 标签库的替代品。它确实有优点,其中之一是没有 CSRF 隐藏元素可以显式地添加到表单中。CSRF 将在本章稍后讨论,但是现在,请看清单 12-13 ,它是清单 12-12 的百里香版本。
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<!-- other HTML elements omitted -->
<div class="header">
<!-- other unsecured HTML elements omitted -->
<div class="nav">
<ul style="float: left;">
<!-- other HTML elements omitted -->
<li sec:authorize="! isAuthenticated()">
<a th:href="@{/customer/register}"
th:text="#{nav.register}">REGISTER</a>
</li>
<li sec:authorize="! isAuthenticated()">
<a th:href="@{/login}"
th:text="#{nav.login}">LOGIN</a>
</li>
<li sec:authorize="isAuthenticated()">
<a th:href="@{/logout}"
th:text="#{nav.logout}">LOGOUT</a>
</li>
</ul>
<ul style="float: right;">
<li sec:authorize="isAuthenticated()">
(<em sec:authentication="name"></em>)
</li>
<!-- other unsecured HTML elements omitted -->
</ul>
</div>
</div>
Listing 12-13Thymeleaf layout.html Navigation Menu with Security Configurations
语法稍微好一点,不是吗?
配置登录和注销
为了支持简单的开发,Spring Security 自带了默认的登录表单和注销支持。当后端开发人员不想浪费时间设置一个 HTML 页面来测试他的安全设置时,这很有用。一个HttpSecurity对象中的大多数方法返回一个对它们自身的引用。这提供了通过将方法链接在一起来配置安全性的机会。类似于authorizeRequests(..),登录表单,注销支持可以使用特定于每个目的的Customizer实例来配置。清单 12-14 给出了一个非常简单的配置默认表单生成和注销支持的例子。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.config.Customizer;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/*/edit/*", "/*/delete/*")
.hasRole("ADMIN")
.anyRequest().permitAll()
).formLogin(Customizer.withDefaults())
.logout(Customizer.withDefaults());
}
// other code omitted
}
Listing 12-14Default Form Generation and Logout Support Configuration Using Spring Security
当书店应用以这种配置启动时,单击Login菜单项会打开一个页面,其中有一个非常简单的登录表单。图 12-3 是 HTML 生成的登录页面截图。

图 12-3
Spring Security 生成的注销页面
如果您使用“查看页面源代码”浏览器选项,您还可以查看 HTML 代码。以下是您应该了解/注意的关于此页面的事项列表。
-
生成的登录视图被映射到
/login。这可以通过调用带有定制实现Customizer的formLogin(..)来改变,以构建带有不同登录页面映射的FormLoginConfigurer实例。 -
生成的表单有两个名为
username和password的字段。这些是默认值。可以通过调用定制实现Customizer的formLogin(..)方法来改变它们,为username和password字段构建一个不同名称的FormLoginConfigurer实例。 -
该表单包含一个名为“CSRF”的隐藏字段。在 Spring Security 4.3 中,CSRF 保护配置成为防止跨站点请求伪造攻击的默认选项。 13 对于测试场景可以将其禁用。
-
提交用户名和密码值的 POST 请求被发送到
/login,这也是一个默认值。这可以通过使用带有不同登录处理 URL 的FormLoginConfigurer实例来定制。 -
登录后,用户被自动重定向到
/。这可以通过用不同的默认成功 URL 定制一个FormLoginConfigurer实例来改变。 -
如果用户无法通过身份验证,则失败 URL 的默认值是
/login?error,,可以通过使用不同的默认身份验证失败 URL 定制一个FormLoginConfigurer实例来更改该值。 -
至于注销支持,它也有自己的默认值。
-
注销用户的 POST 请求被映射到
/logout。这个默认值可以通过调用带有自定义实现Customizer的logout(..)方法来更改,以构建带有不同注销 URL 的LogoutConfigurer。 -
当用户成功注销后,会被重定向到
/login?logout。这个值可以通过使用不同的成功注销 URL 定制一个LogoutConfigurer实例来更改。
两种配置器类型(FormLoginConfigurer and LogoutConfigurer)上有更多的方法,允许为登录和注销配置失败或成功的身份验证处理程序、使会话无效的特殊选项、为注销清除缓存,以及更多您可能会在需要时读到的内容。在此之前,请看清单 12-15 中的代码示例。Spring Security 配置已经过修改,可以定制所有提到的属性,并增加了对 CSRF 保护的支持。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.config.annotation.web.configurers.
FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.
LogoutConfigurer;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/*/edit/*", "/*/delete/*")
.hasRole("ADMIN")
.anyRequest().permitAll()
).formLogin(
formLogin -> formLogin.loginPage("/auth")
.usernameParameter("user")
.passwordParameter("secret")
.loginProcessingUrl("/auth")
.failureUrl("/auth?auth_error=1")
.defaultSuccessUrl("/home")
)
.logout(
logout -> logout.logoutUrl("/custom-logout")
.logoutSuccessUrl("/home")
.invalidateHttpSession(true)
.clearAuthentication(true)
).csrf().csrfTokenRepository(repo());
}
@Bean
public CsrfTokenRepository repo() {
HttpSessionCsrfTokenRepository
repo = new HttpSessionCsrfTokenRepository();
repo.setParameterName("_csrf");
repo.setHeaderName("X-CSRF-TOKEN");
return repo;
}
// other code omitted
}
Listing 12-15Customized Login Form and Logout Support Configuration Using Spring Security
在 Spring Security 中,使用缺省值来命名组件是很实用的。任何定制都需要组件来支持——视图、控制器、异常处理程序等等。
清单 12-16 展示了书店应用的HttpSecurity对象的完整配置。它的简单性证明了它被设计成使用 Spring 默认配置,同时仍然能够使用与应用整体主题相匹配的定制登录表单。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.config.annotation.web.configurers.
FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.
LogoutConfigurer;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/*/edit/*", "/*/delete/*")
.hasRole("ADMIN")
.anyRequest().permitAll()
).formLogin(
formLogin -> formLogin.loginPage("/login")
.failureUrl("/login?auth_error=1")
).logout(Customizer.withDefaults())
.csrf().csrfTokenRepository(repo());
}
// other code omitted
}
Listing 12-16Customized Login Form and Logout Support Configuration Using Spring Security
即使登录页面被映射到默认的 URL,登录页面也在这里被显式地配置,以告知 Spring Security 提供了登录页面。如果不指定,Spring Security 将生成缺省页面并使用它。
同样,作为定制登录页面一部分的登录表单必须声明一个隐藏的 CSRF 字段;否则,请求将无法通过 Spring Security 过滤器链,而是返回一个带有 HTTP 状态代码 403(禁止)的错误页面。
拥有自定义登录表单的好处是,它看起来像是网站的一部分(可以包含公司徽标),但也可以是国际化的。例如,书店登录定制表单代码如清单 12-17 所示。
<form action="<c:url value="/login"/>" method="post">
<input type="hidden"
name="${_csrf.parameterName}"
value="${_csrf.token}"/>
<fieldset>
<legend>
<spring:message code="login.title" />
</legend>
<table>
<tr>
<td>
<spring:message code="account.username"/>
</td>
<td>
<input type="text" id="username"
name="username"
placeholder="<spring:message code="account.username"/>"/>
</td>
</tr>
<tr>
<td>
<spring:message code="account.password"/>
</td>
<td>
<input type="password" id="password"
name="password"
placeholder="<spring:message code="account.password"/>"/>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<button id="login">
<spring:message code="button.login"/>
</button>
</td>
</tr>
</table>
</fieldset>
</form>
Listing 12-17login.jsp Custom Login Form
当使用百里香叶作为模板引擎时,不需要为登录表单声明隐藏的 CSRF 字段,百里香叶集成模块负责将该字段注入到用th:action属性声明的每个表单中。为此,我们只需要配置模板引擎来识别和支持用sec:前缀声明的元素。这是通过调用templateEngine.addDialect(new SpringSecurityDialect())来配置对 Spring Security 方言的支持来实现的。
CSRF 参数和头名的默认名称在org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository类中声明。这种类型的 bean 可以配置为支持不同的 CSRF 参数和头名称。为本章创建的 Spring Security 配置类中配置的名称是默认的:_csrf和X-CSRF-TOKEN。以下示例使用了不同的头名称。
如果您使用 JavaScript,那么就不可能在 HTTP 参数中提交 CSRF 令牌。由于百里香没有一个表单来添加隐藏字段到页面的头部,必须用两个元条目来丰富,这两个元条目由 CSRF 头部名称和 CSRF 令牌填充。这两个元条目声明了 CSRF 参数和头名称,它们必须与 Spring Security 配置类中声明的相匹配。然后在 JavaScript 代码中提取这些值,并添加到提交的请求的 JSON 主体中。在前面的章节中,“搜索书籍”请求是使用 JavaScript 发送的。因此,必须修改“search.html”页面内容,如清单 12-18 所示,以便在安全的 web 应用中工作。
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
th:with="lang=${#locale.language}"
th:lang="${lang}"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head th:fragment="~{template/layout :: head('Search books')}">
<meta id="_csrf" name="_csrf" th:content="${_csrf.token}"/>
<meta id="_csrf_header" name="_csrf_header" th:content="${_csrf.headerName}"/>
</head>
<!-- most of HTML and Javascript code omitted-->
<script th:inline="javascript">
/*<![CDATA[*/
$('#bookSearchForm').submit(function(evt){
evt.preventDefault();
var title = $('#title').val();
var category = $('#category').val();
var json = { "title" : title, "category" : { "id" : category}};
var token = $('#_csrf').attr('content');
var header = $('#_csrf_header').attr('content');
$.ajax({
url: $('#bookSearchForm').action,
beforeSend: function(xhr) {
xhr.setRequestHeader(header, token);
},
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify(json),
success: function(responseData) {
console.log(responseData); // debugging purposes
renderBooks(responseData);
}
});
});
/*]]>*/
</script>
Listing 12-18search.html with CSRF Protection
到目前为止,在本章中,Spring Security 上下文被配置并与容器 servlet 环境集成,但是安全上下文没有与应用上下文正确集成。Spring 是智能的,它会在被配置为要扫描的包中挑选出所有标有@Configuration的文件。但是如果您有一个多层应用,那么 Spring Security beans 应该放在哪里呢?在根 web 应用上下文中还是在 dispatcher servlet 上下文中?
这个问题的答案取决于应用的请求和架构。
-
如果不需要应用上下文层次结构,则不需要特殊配置;但是要确保扫描了声明安全配置类的包。
-
如果需要应用上下文层次结构,有两种情况。
-
假设有服务层,可以直接访问吗?它需要被保护吗?那么安全配置类应该是根应用上下文的一个组件,这样服务组件也可以得到保护。
-
如果只能通过 web 应用访问安全配置,那么应该将安全配置类设置为 servlet 应用上下文的一个组件。
-
让我们假设我们需要前面列表中最后一个场景的配置。这是通过将安全配置类添加到WebApplicationInitializer实现中来实现的。对于不专门通过 web 层接收请求的复杂应用,可以将安全配置类添加为根上下文类,但是对于我们简单的书店应用,将其添加为 servlet 上下文类是合理的。清单 12-19 描述了将安全性集成到 servlet 应用上下文中的配置片段。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.config.annotation.web.configurers.
FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.
LogoutConfigurer;
// Other imports omitted
public class BookstoreWebApplicationInitializer
extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class<?>[]{
DbConfiguration.class,
JpaConfiguration.class};
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class<?>[]{
WebMvcContextConfiguration.class,
SecurityConfiguration.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
@Override
protected Filter[] getServletFilters() {
CharacterEncodingFilter cef = new CharacterEncodingFilter();
cef.setEncoding("UTF-8");
cef.setForceEncoding(true);
return new Filter[]{new HiddenHttpMethodFilter(), cef};
}
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
super.onStartup(servletContext);
ServletRegistration.Dynamic servlet = servletContext
.addServlet("h2-console", new WebServlet());
servlet.setLoadOnStartup(2);
servlet.addMapping("/h2-console/*");
}
}
Listing 12-19WebApplicationInitializer Implementation Class with Spring Security
在清单 12-19 中,有一个额外的方法需要解释。onStartup(ServletContext)将org.h2.server.web.WebServlet添加到 Spring 应用上下文,并将其映射到/h2-console/* URL。这个 servlet 是 H2 库的一部分,为应用中使用的内存数据库提供了一个 web 客户端。本章的“配置身份验证”一节中首先提到了这个 servlet。您可以使用这个 web 客户端来查询应用的所有表中的信息,但是如果您想看看加密的密码值是什么样子,尤其是ACCOUNT表。
servlet.setLoadOnStartup(2);方法设置加载这个 servlet 的优先级。任何大于或等于零的值都意味着这个 servlet 必须在容器调用了为ServletContext在其ServletContextListener.contextInitialized(… )方法中配置的所有ServletContextListener对象之后进行初始化。这意味着这个 servlet 是在加载了完整的 Spring 应用上下文之后加载的。
因为它提供了对敏感用户数据的访问,所以这个 servlet 的 URL 应该是安全的;最好只有ADMIN用户能够访问它。这里要提到的另一件事是,这个 servlet 使用表单来发送和检索数据,它不是应用的一部分。这意味着这些表单中没有 CSRF 令牌,也没有办法修改它们。这意味着必须对所有以/h2-console/开头的 URL 禁用 CSRF 保护。这是通过调用configure(HttpSecurity)方法中CsrFConfigurer的另一个方法来完成的。
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(..)
.formLogin(..).logout(Customizer.withDefaults())
.csrf()
.csrfTokenRepository(repo())
//don't apply CSRF protection to /h2-console
.ignoringAntMatchers("/h2-console/**");
}
您可能已经注意到,在清单 12-16 中,URL“/customer/account”不在被配置为安全的特定 URL 模式中,尽管它应该只对经过身份验证的用户可用。这是为了展示如何使用 Spring Security 性在方法级别应用安全性。
安全方法
URL 安全规则应用于端点,因此它们应用于匹配端点的所有控制器方法,而无需在控制器中添加任何额外的配置。保护方法需要用特殊的安全注释来注释控制器方法。在一个地方声明所有的安全性(security configuration 类)有一定的吸引力,因为它与代码的其余部分是分离的,但是它适合于小型、简单的应用。当应用增长时,将授权规则放在受保护的代码附近可能更有意义。在过去,安全方法的一个额外优势是可以使用 SpEL,但是由于 SpEL 现在可以与MvcMatchers一起使用,所以只保留了接近安全代码的优势。
保护方法是通过使用 AOP 代理在幕后完成的。标记需要由安全代理调用的方法是通过一些注释来完成的。
-
@Secured(来自org.springframework.security.access.annotation包)是一个 Spring Security 注释,它定义了方法的安全配置属性列表,比如角色。当安全配置类用@EnableGlobalMethodSecurity(secured = true)注释时,这个注释被选中。这个注释不支持 SpEL 表达式,所以现在很少使用。 -
@RolesAllowed(来自javax.annotation.security)是 JSR-250??【14】相当于@Secured的注解。当安全配置类用@EnableGlobalMethodSecurity(jsr250Enabled = true)和项目类路径中的 JSR-250 库进行注释时,这个注释被选中。 -
@PreAuthorize(从org.springframework.security.access.prepost package)指定一个方法访问控制 SpEL 表达式,对其进行评估以决定是否执行该方法。当安全配置类用@EnableGlobalMethodSecurity(prePostEnabled = true)注释时,这个注释被选中。 -
@PostAuthorize(来自org.springframework.security.access.prepost包)指定一个方法访问控制 SpEL 表达式,该表达式在方法执行后被评估。当安全配置类用@EnableGlobalMethodSecurity(prePostEnabled = true)注释时,这个注释被选中。这在实现域级安全性时非常有用。使用@PostAuthorize声明的规则测试返回对象的所有权,如果检查失败,则不返回对象。 -
@PostFilter(来自org.springframework.security.access.prepost包)指定一个方法访问控制 SpEL 表达式,该表达式在方法执行后被求值,结果是一个集合。SpEL 表达式可以修改集合,并在返回集合之前删除用户无权访问的对象。当安全配置类用@EnableGlobalMethodSecurity(prePostEnabled = true)注释时,这个注释被选中。(还有一个@PreFilter注释,它在被注释的方法操作集合之前,根据授权表达式过滤集合。)
安全注释可以放在类和方法级别,方法级别的表达式覆盖类中的表达式。当您在单个控制器中声明了所有管理功能时,在类级别使用安全性是非常有用的。
JSR-250 库包含一小组安全注释(例如,@DenyAll、@PermitAll),这些注释在 Spring Security 中没有对应项,因为使用 Spring Security 表达式可以获得相同的效果。提到 JSR-250 注释是因为你可能在混合使用 JEE 和 Spring 的项目中找到它们,所以你应该知道 Spring Security 也可以被配置为支持它们,但是本章的重点是 Spring Security 注释。
使用带有 URL“/customer/Account”的 HTTP GET 请求来检索 Account 页面的内容。清单 12-20 中描述了处理这个请求的方法。
package com.apress.prospringmvc.bookstore.web.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import java.security.Principal;
// other imports omitted
@PreAuthorize(“isAuthenticated()”)
@Controller
@RequestMapping("/customer/account")
public class AccountController {
@GetMapping
public String index(Model model, Principal activeUser) {
Account account = accountRepository.findByUsername(activeUser.getName());
model.addAttribute("account", account);
model.addAttribute("orders", this.orderRepository.findByAccount(account));
model.addAttribute("fileOrders", getAsWebFiles());
return "customer/account";
}
// other code omitted
}
Listing 12-20Handler Method for "/customer/account"
该方法声明了一个类型为Principal的参数,Spring 用当前经过身份验证的用户主体注入该参数。这是必需的,因为从数据库中提取帐户详细信息需要用户名。具有讽刺意味的是,该方法不是为未经身份验证的用户执行的。如果这个请求是由未经验证的用户发出的,Spring 就找不到主体;所以参数是null,并抛出一个NullPointerException。尽管如此,将内部异常公开并不是一个好的做法,所以保护该方法是必须的。
现在有趣的是,这种方法可以通过多种方式来保护。在接下来的例子中,假设安全配置类被配置为支持提到的注释。
-
@PreAuthorize("isAuthenticated()")只有经过认证的用户才能访问该方法。如果一个未经身份验证的用户试图访问帐户页面,就会发出请求,但是由于这个注释,它会被一个安全的代理截获,该代理检查是否有一个经过身份验证的用户,如果没有,就会抛出AccessDeniedException。配置这类异常处理的常见方式是声明将AccessDeniedException映射到login视图的SimpleMappingExceptionResolver,,以及一条用户友好的错误消息,最好是国际化的消息。这个 bean 将异常添加到视图的模型中,JSP taglib 元素的组合可以显示当前地区的消息。在清单 12-21 中,显示了一个代码片段,描述了 Spring web configuration 类中这个 bean 的声明。在清单 12-22 中,您可以看到在 HTML 登录页面中显示错误消息的 JSP 代码。 -
由于我们知道 Account 表中的所有用户都有其中一个角色,所以这个表达式也适用。有趣的事实:与 JSR-250 相当的是
@RolesAllowed({"USER", "ADMIN"})。 -
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')") -
@PreAuthorize("hasAnyRole('USER','ADMIN')")
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<c:if test="${exception ne null}">
<div class="error">
<spring:message code="authentication.required"
text="${e.getMessage}" htmlEscape="true"/>
</div>
</c:if>
<!-- rest of this template omitted -->
Listing 12-22JSP Code to Show an Error Requiring User Authentication in the login.jsp Page
package com.apress.prospringmvc.bookstore.web.config;
import
org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;
// other imports omitted
@Configuration
@EnableWebMvc
public class WebMvcContextConfiguration implements WebMvcConfigurer {
Bean
public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {
SimpleMappingExceptionResolver exceptionResolver = new SimpleMappingExceptionResolver();
Properties mappings = new Properties();
mappings.setProperty("AccessDeniedException", "login");
Properties statusCodes = new Properties();
mappings.setProperty("login", String.valueOf(HttpServletResponse.SC_UNAUTHORIZED));
exceptionResolver.setExceptionMappings(mappings);
exceptionResolver.setStatusCodes(statusCodes);
return exceptionResolver;
}
// other code omitted
}
Listing 12-21SimpleMappingExceptionResolver to Handle AccessDeniedException
@Secured是初始安全注释,不支持 SpEL 表达式。@PreAuthorize是 Spring Security 3 中新增的,功能强大。@PreAuthorize支持使用 SpEL 声明访问规则,因此您可以尽情发挥创造力。
“/客户/列表”也不安全。该页面显示 ACCOUNT 表中的所有用户,应该只有具有 ADMIN 角色的用户可以访问。这可以通过用@Secured({"ROLE_ADMIN" })或@ pre authorize(" has ROLE(' ADMIN '))".注释该方法来轻松设置
但是让我们假设我们想要确保只有名为“admin”的管理员用户可以访问该页面。唯一适合这项工作的注释是@PreAuthorize,因为它可以解释这样的表达式。
@PreAuthorize("hasRole('ADMIN') and principal == 'admin'").
保护方法为 web 应用增加了一层额外的安全性。因为支持使用 SpEL 表达安全规则,所以可以用非常具体和精细的方式控制访问。
所以,好好享受保护东西的乐趣吧!
保护 Spring Boot Web 应用
保护 Spring Boot web 应用与保护 Spring MVC 应用没有什么不同。但是有一些事情要记住。一旦 security starter 库作为依赖项被添加,对应用的公共访问就不存在了,任何 URL 都会重定向到默认的 Spring Security 登录生成的表单。如果您想允许访问任何内容,您必须提供一个配置。
没有必要将springSecurityFilterChain与容器 servlet 环境集成,因为您使用的是 Spring Boot 支持的嵌入式服务器之一。因此,不需要类扩展AbstractSecurityWebApplicationInitializer。
没有必要将您的配置类与 web 应用上下文显式集成。
这就是全部了。在 Spring Boot Web 应用中配置 Spring Security 恢复如下。
-
将
spring-boot-starter-security依赖项添加到您的配置中 -
编写一个扩展
WebSecurityConfigurerAdapter的 Spring Security 配置类(与带有经典设置的 Spring MVC 应用的方式相同;添加用于启用 web 安全性、方法安全性、配置 URL 匹配等的注释。) -
添加您的定制
AuthenticationProvider实现(如果您有) -
修改您的视图以保护敏感内容
如果您使用百里香,不要忘记向模板引擎注册 Spring Security 方言。
摘要
本章介绍了 Spring web 应用环境中 Spring Security 性的基本部分。我们研究了 Spring Security 如何与经典的 Spring MVC 应用集成。介绍了 cookies、会话、身份验证、授权、主体和权限等核心概念,并解释了它们在保护 web 应用中的作用。
我们展示了如何修改 Apache Tiles 和 Thymeleaf 视图模板来考虑安全性设置。这意味着为用户提供的服务取决于他们在应用中的角色。
简单介绍了常见类型的攻击,如会话劫持和 CSRF,以及 Spring Security 提供的防止这些攻击的方法。
还讨论了 Spring Boot web 应用上下文中的 Spring Security 性,并明确了开发人员需要提供的保护应用的最小操作集。
我们使用了一个带有密码散列的内存数据库来保存身份验证数据,然后展示了如何实现一个定制的身份验证提供程序来使用应用中的一个现有表。
十三、云中的 Spring 应用
很少有技术大学在教学开发时触及云的主题。除非大学很大,有亚马逊,微软,或者谷歌这样的云提供商,作为合作伙伴,作为学生,你很可能在没有为云编写应用或在云中部署应用的情况下毕业。这一章简单介绍了云的开发,并展示了 Spring 如何开发微服务。
如今,大多数应用都托管在云中,任何新编写的应用都可能被设计为托管在云中,因此了解这如何影响应用的架构非常重要。
让我们回到软件开发中提到云这个词之前,回顾一下应用架构。
应用架构
应用架构指的是决定应用应该具有哪些部分以及它们应该如何相互连接的过程。
介绍独石
想象一下你想创业在网上卖书(很有创意,我知道!).你可以雇佣一家公司来创建你的网站,或者如果你已经对软件开发感兴趣,你可以自己创建。您需要一个数据库来存储有关书籍、订单和用户的信息。您需要一个管理这些信息的应用。应用由更多的部分或层组成。
-
用户界面(也称为表示层):为此,你需要 HTML、CSS、JavaScript 等等。如果要从多个设备(电脑、平板电脑、手机)访问应用,您可能需要针对每个设备的特定技术(例如,针对 Android 设备的 Android SDK)。
-
前端(也称为应用层):接收来自用户界面的请求并将数据转换成链中下一层可以处理的东西的部分。对于 Spring web 应用,这由控制器和功能端点来表示。
-
服务(也称为业务层):接收来自前端的数据并对其进行处理,准备将其存储在数据库中。
-
数据访问层(也称为持久层):它从数据库中检索数据,并将其转换为服务层可以处理的数据。它还从服务层获取数据,并将其保存到数据库中。包含 Dao(数据访问对象)和管理实体的特殊类。对于 Spring 应用,这由实体/文档、类和存储库来表示。
像这样设计你的应用意味着你正在构建一个多层或多层架构或单片架构。
在开发过程中,所有层都位于一台计算机上。但是,当应用必须进入生产环境时,数据库通常被放在另一台计算机上,该计算机比其他计算机备份得更频繁,因为它包含对应用最重要的数据。如果您使用 Java,其余的层被打包到部署在应用服务器上的单个 WAR 或 EAR 中,比如 Apache Tomcat。
如果这听起来很熟悉,那是因为这是在本书中完成的。Spring Boot 应用甚至不需要应用服务器,因为它嵌入在它们之中。即使在反应性章节中,我们也经常提到它们适合构建微服务,但是架构仍然是单一的。在图 13-1 中,您可以看到这样一个应用是如何部署给公众使用的。

图 13-1
为公共使用而部署的多层应用
单体式应用易于开发、测试、部署和扩展——至少在最初是这样,因为随着越来越多的用户访问您的应用,缺点就会变得明显。
-
如果数据库的大小增加了,你用一个更大的硬盘替换它,问题就解决了——至少在一段时间内。
-
如果有太多用户访问您的应用,请增加您的计算机或 VM 的内存以及 Apache 服务器上的线程数量。这样应该可以暂时解决问题—。
** 但是,维护一个完整的代码库是困难的。随着代码库的增长,知识必须在开发人员之间分配,因为很少有一个开发人员能够很好地了解所有的部分;当人们离开公司时,知识就是这样流失的。
* 当代码库很大时,改变和升级技术变得非常危险,这就是技术债务蔓延的方式。
* 连续部署是不可能的。
* 缩放是有限且昂贵的。*
*如果您有自己的数据中心,升级硬件是可能的,但成本很高,想象一下维护数据中心的成本吧!这就是亚马逊、谷歌和其他公司看到了巨大商业潜力的地方——提供一个基础设施,在这个基础设施上,开发人员可以以更少的麻烦和成本来部署和管理应用。云就是这样诞生的。云是世界各地的许多计算机和运行在这些计算机上的软件。在作为云的一部分的远程计算机上部署和管理应用被称为云计算。通过使用云计算,用户和公司不必自己管理物理服务器或在自己的机器上运行软件应用。
现在世界上有一些云提供商。他们中的大多数都提供广泛的服务,如自动备份、自动缩放、负载平衡和数据存储,亚马逊可能在服务数量上领先。
将计算机使用的资源(CPU、RAM)重新调整为一个整体所需的大小称为垂直缩放。即使您的应用部署在云上,并且计算机被替换为虚拟机,按照 monolith 所需的方式调整它们的资源(CPU、RAM)也是麻烦和有限的。还有另一种类型的扩展,涉及到将更多的机器添加到您的资源池中,这被称为水平扩展。不幸的是,整体架构并不完全适合水平扩展,但微服务架构适合。
微服务简介
微服务架构,也叫微服务、1是面向服务架构(SOA)的一种专门化和实现方式。它构建灵活的、可独立部署的服务。微服务架构不同于整体架构,因为它不是通过使用层而是作为服务的集合来定义应用。
微服务是一种范式,它要求将服务分解为高度专业化的功能实例,并通过不可知的通信协议(例如 REST)相互连接,共同完成一个共同的业务目标。每个微服务都是一个无状态功能的微小单元,一个不关心输入来自哪里,也不知道输出去哪里的进程;它不知道大局是什么。由于这种专门化和解耦,每个问题都可以被识别,原因可以被定位和修复,实现可以被重新部署,而不会影响其他微服务。这意味着微服务系统具有高的职责内聚性和低耦合性。这些品质允许单个服务的架构通过持续的重构来发展,减少大的预先设计的必要性,并允许软件更早和持续地发布。将一个大型复杂的应用分解成更小的独立应用,可以快速、频繁、可靠地交付功能,并促进公司技术体系的发展。
由于低粒度和轻量级通信协议,微服务近年来越来越受欢迎。它们已经成为构建企业应用的首选方式。微服务模块化架构风格似乎特别适合基于云的环境。当必须支持多个平台和设备时,这种架构方法是可扩展的,并且被认为是理想的。想想现在网络上最大的玩家:脸书、Twitter、网飞、亚马逊、PayPal、SoundCloud 等等。他们拥有大型网站和应用,这些网站和应用已经从单一架构发展到微服务,因此可以从任何设备访问它们。亚马逊和谷歌目前是该行业中最大的两家,它们提供了一套云计算服务,非常适合构建由众多微服务协同工作组成的复杂应用。为银行、零售商、餐馆、小企业以及电信和技术提供软件和服务的企业别无选择,只能依靠 AWS 或 GCP 来保持其服务的随时可用。这是通过使用 Apigee 2 或亚马逊 API 网关构建他们的微服务来实现的。 3 亚马逊和谷歌为微服务提供了沟通的基础设施和无数构建它们的工具。最终,为这些服务编写代码仍然是开发人员的责任。
Spring Boot 是一个很好的工具,可以用来构建一个小型的利基应用,在一个更复杂的应用中代表一个微服务。响应式服务可以事半功倍,降低云基础架构的成本。
使用微服务的主要优势是什么?以下是 IT 界的好评。
-
粒度增加
-
增强的可扩展性
-
易于自动化的部署和测试(当然取决于上下文,因为如果涉及到事务,事情就开始变得困难了),因为微服务的特点是定义良好的接口,便于通信(JSON/ WSDL/JMS/AMQP)
-
增强解耦,因为微服务不共享服务的状态
-
增强凝聚力
-
适合持续的重构、集成和交付
-
增强模块独立性
-
专业化—围绕能力组织;每个微服务都针对一种特定的功能而设计
-
提高敏捷性和速度,因为当系统被正确分解为微服务时,每个服务都可以独立开发和部署,并与其他服务并行。
-
每个服务都是有弹性的、可复原的、可组合的、最小的和完整的
-
故障隔离的改进
-
消除对单一技术堆栈的长期承诺,因为微服务可以用不同的编程语言编写
-
更容易的知识共享,因为新开发人员可以在几个微服务上工作,而不需要了解整个系统
既然这个世界上没有十全十美,特别是在软件开发方面,就有几个缺点。
-
微服务引入了额外的复杂性,以及小心处理服务间请求的必要性。
-
像处理实体类一样处理共享资源会导致问题。
-
处理多个数据库和事务(分布式事务)可能会很痛苦。
-
测试微服务可能很麻烦,因为在测试服务之前,必须确认每个微服务依赖项都是有效的。
-
部署可能会变得复杂,需要服务之间的协调。
向云迁移:是还是不是?
在本章中,我们将书店转换为更适合部署到云的微服务。但是,在此之前,我们应该列出迁移到云的一些优势。
-
一个明显的优势—降低基础架构成本
-
由于开发人员学习配置云服务,人员成本可能会更低,因为你不再需要一个大的基础设施部门
-
灵活性—配置云服务,如负载平衡器和自动扩展组,以确保您的应用始终运行并自我修复
-
云基础架构会自动维护,因此无需担心软件更新
-
一切都在云端,所以你可以在任何地方工作
-
和许多其他人
迁移到云有什么缺点吗?当然可以。
-
主要缺点是你完全依赖你的云提供商。
-
供应商锁定—转换您的云提供商是一件痛苦的事情;对服务的控制是通过自定义 API 提供的,它们之间没有桥梁。
-
如果你的云提供商因为自然灾害遇到了技术问题,你可能会被系统淘汰。
-
如果管理资源的软件发生故障,自动伸缩和自我修复可能会受到影响。即使您正确配置了您的云基础架构,并考虑到了所有因素,本应扩展并保持云基础架构的服务仍然托管在同一个云中。停机仍然是一种可能。
-
尽管云服务提供商实施了最佳的安全标准和行业认证,但在外部服务提供商上存储数据和重要文件仍然存在一定程度的风险。黑客对云提供商来说尤其危险;例如,如果亚马逊遭到黑客攻击,其所有客户的数据都将面临风险。
-
有限的控制——大多数云提供商允许您使用 web 控制台和 SSH 连接来管理您的基础设施,但这种控制最终是最小的。
-
成本—尽管与建立和管理自己的数据中心相比,使用云计算可以降低您的成本,但在某些情况下,云服务的成本可能会增加。大多数云提供商宣传按需付费模式,为您提供灵活性和更低的硬件成本,但它们仍然不适合小规模的短期项目。此外,云资源的错误配置可能会增加您的账单。
尽管存在缺点,但许多组织受益于云服务提供的敏捷性、规模和按需付费模式。然而,与任何基础设施服务一样,云计算对于您的特定用例的适用性应该在基于风险的评估中进行评估。例如,亚马逊鼓励公司明智地规划,只使用他们需要的东西。他们有一些合作伙伴公司,以最低的成本提供设计和维护云基础架构的咨询服务。因为一个企业要有较长的寿命和利润,他们的客户也必须这样做。
尽管如此,如果你是一名职业生涯刚起步的开发人员,想要了解云,你可能没有能力支付云访问的费用。无需担心;大多数云提供商向他们的用户提供免费的受限访问账户。亚马逊为新用户提供 12 个月的免费等级访问 4 来学习使用他们的服务。GCP 提供三个月的免费试用 5 ,外加 300 美元的信用额度,可用于任何谷歌云服务。但是,如果你仍然不愿意尝试它们中的任何一个,不要担心,Spring 已经为你准备好了。Spring Cloud project collection 是根据分布式系统中最常见的模式构建微服务的工具宝库,您可以在本地运行这些服务,就像它们在云中运行一样。
介绍 SpringCloud
要开发由一组带有 Spring 组件的微服务组成的应用,需要对以下 Spring 技术有很好的了解。
-
服务注册和发现技术,如网飞的 OSS Eureka
-
像 Eureka 或 Consul 这样的 Spring 云项目
-
REST 概念(因为微服务之间的通信是使用 REST 完成的)
Spring Boot 是为开发人员设计的,通过使常见的概念——如 RESTful HTTP 和嵌入式 web 应用运行时——易于连接和使用,提高了工作效率。它很灵活,允许开发人员只挑选他们想要使用的模块,消除了大量或庞大的配置和运行时依赖。
Spring Cloud 6 是一个项目集合,旨在简化分布式应用的开发。
-
配置管理(Spring Cloud Config 提供由 Git 存储库支持的集中式外部配置)
-
服务发现(Eureka 是一个用于弹性中间层负载平衡和故障转移的服务注册中心,由 Spring Cloud 支持)
-
断路器(Spring Cloud 支持网飞的 Hystrix,这是一个库,提供了当在预定义的阈值内没有收到响应时停止调用服务的组件)
-
智能路由(Zuul 将呼叫转发和分配给服务)
-
微代理(中间层服务的客户端代理)
-
控制总线(消息传递系统可用于监控和管理框架内的组件,就像用于应用级消息传递一样)
-
一次性令牌(使用 Spring Vault 7 仅用于一次数据访问)
-
全局锁(协调、区分优先级或限制对资源的访问)
-
领导选举(指定单个流程作为分配给几个节点的某些任务的组织者的过程)
-
分布式消息传递(Spring Cloud Bus 可用于通过轻量级消息代理链接分布式系统的节点)
-
集群状态(集群状态请求被路由到主节点,以确保返回最新的集群状态)
-
客户端负载平衡
如果您对使用 Spring Cloud 构建微服务应用感兴趣,Spring 官方文档涵盖了所有基础知识。协调分布式系统并不容易,可能会导致样板代码。Spring Cloud 让开发人员更容易编写这种类型的管理代码。其结果适用于任何分布式环境,包括开发站、数据中心或托管平台,如 Cloud Foundry。
Spring Cloud 构建于 Spring Boot 之上,它具有典型的 Spring Boot 优势:开箱即用的预配置基础架构 beans,可以进一步配置或扩展以创建自定义解决方案。它遵循相同的 Spring 声明性方法,依赖于注释和属性(YAML)文件。
SpringCloud 网飞提供与网飞 OSS(网飞开源软件)的集成。GitHub 官方页面在 https://netflix.github.io/ 。它是一个开源库的集合,是开发者为解决大规模分布式系统问题而编写的。它是用 Java 编写的,在用 Java 编写微服务应用时,它几乎成了最常用的软件。
面向云的重新设计
我们在整本书中编写的书店应用是一个整体。它的各个层,尽管被分离在不同的模块中,但都被组合成了部署在 Apache Tomcat 上的一个 war,或者打包成可执行 jar 的一个 Spring Boot 应用。即使在使其具有反应性时,monolith 架构也被保留下来,因为这是一个小应用,还没有理由改变它。将它作为一个整体部署到云中是可能的,但是由于前面几节中列出的所有原因,效率很低。它需要重新设计。
在前面的章节中,应用的每个模块都包含(或多或少)特定于应用处理的所有对象的整体层的功能。例如,Book、Account和Orders的所有存储库类都负责数据库操作,因此属于 DAO 层。
微服务要求根据业务功能进行分离,因此每个服务应该有一个与单一类型的对象相关的单一角色。因此,我们可能需要一个微服务来处理对Book对象的所有操作,一个用于Account对象,以此类推。由于tech news service独立于应用,所以它可以被做成微服务。
提供随机图书发行的服务也是如此。图 13-2 描述了如何为云重新设计书店应用的建议。

图 13-2
书店整体架构和微服务架构的比较
图 13-2 有一个在所有微服务之间共享的数据库,但这不是必需的。任何提供服务的东西都可以成为微服务。您可以将安全性作为一项独立的服务,依赖于云提供商的认证服务。您可以决定为帐户建立一个单独的数据库。您也可以决定为订单建立一个单独的数据库。说到数据库,有三种模型。
-
Private-tables-per-service:每个服务拥有一组只能由该服务访问的表。
-
每服务模式:每个服务都有一个该服务专用的数据库模式。
-
每个服务一个数据库服务器:每个服务都有自己的数据库服务器。
这完全取决于您的设计要求。在本章的其余部分,您将学习如何使用 Spring Cloud 编写服务并确保它们之间的通信。
注册和发现服务器
微服务架构确保一组流程朝着一个共同的目标协同工作:为最终用户提供有能力且可靠的服务。要做到这一点,进程必须有效地通信。要相互交流,首先要找到对方。这就是网飞尤里卡注册服务器的用武之地。因为它是开源的,所以它被并入了 Spring Cloud,Spring 的简单原则现在也适用。
在本章中,书店应用分为六个项目;每个项目的名称都带有前缀chapter13,,如图 13-3 所示。

图 13-3
书店微服务项目
发现服务是核心组件。它是所有其他微服务用来注册和发现彼此的项目的中心。
项目的配置没什么特别的。这是一个简单的 Spring Boot Web 应用,在其类路径中有一个 Spring Cloud starter 项目:spring-cloud-starter-netflix-eureka-server starter。这个依赖关系为项目添加了构建网飞尤里卡服务注册中心所需的所有依赖关系。这是一种特殊类型的服务,它对其他现有服务进行编目,并支持客户端通信和负载平衡。每隔一个微服务就注册一次,这样 Eureka 就知道每个端口和 IP 地址上运行的所有应用。这意味着所有其他五个微服务都是发现服务的客户端,并且必须配置其位置以知道在哪里注册它们自己。
为了创建一个 Eureka 服务注册中心,项目的主类必须用@EnableEurekaServer进行注释(参见清单 13-1 )。
package com.apress.prospringmvc.bookstore;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
// other imports omitted
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
private static Logger logger = LoggerFactory.getLogger(DiscoveryServerApplication.class);
public static void main(String... args) throws Exception {
System.setProperty("spring.config.name", "discovery-service");
var ctx = SpringApplication.run(DiscoveryServerApplication.class, args);
assert (ctx != null);
logger.info("Started ...");
System.in.read();
ctx.close();
}
}
Listing 13-1Spring Boot Main Class of a Discovery Microservice
使用了System.in.read();调用,所以你可以通过按discovery-service.yml,并且spring.config.name环境变量被设置为让 Spring Boot 知道从中获取配置的文件的名称。
@EnableEurekaServer注释非常重要,因为它激活了与 Eureka 服务器相关的配置。这个注释负责为项目提供一个 Eureka 服务器实例。该服务器带有一个非常好的 web 界面,在这里可以监控已注册的微服务。主页可通过 http://[ip]:[port]/访问。其中IP是您的计算机的 IP 或者是localhost, 127.0.0.1、0.0.0.0,中的任何一个或者全部,如果您在 Spring Boot 配置文件中这样配置的话。该端口也取自 Spring Boot 配置文件。
discovery-service.yml包含该服务器的设置,其内容在清单 13-2 中描述。
spring:
application:
name: discovery-service
# Configure the Server
eureka:
client:
registerWithEureka: false # do not auto-register as client
fetchRegistry: false
server:
waitTimeInMsWhenSyncEmpty: 0
server:
port: 3000 # where this discovery server is accessible
address: 0.0.0.0
Listing 13-2The Eureka Discovery Server Configuration (discovery-service.yml)
之前的配置在端口 3000 上启动服务器,如果访问 web 界面,可以看到此时没有微服务注册,如图 13-4 所示。

图 13-4
尤里卡发现服务器 web 界面
主页显示了一些关于发现服务器实例的指标。网飞的 Eureka 服务器的原始版本避免在可配置的时间内回答客户,如果它从一个空的注册表开始。eureka.server.waitTimeInMsWhenSyncEmpty属性控制这种行为,它被设计成在服务器有足够的时间来构建注册表之前,客户机不会得到部分/空的注册表信息。当某些微服务必须在其依赖项启动并准备就绪后才启动时,这很有用。在清单 13-2 中,值被设置为零以尽快开始回答客户。这种配置适合于开发环境,因为它加快了速度。
如果未设置,eureka.server.waitTimeInMsWhenSyncEmpty的默认值为 5 分钟。
eureka.client.registerWithEureka属性用于注册 Eureka 客户端,通常在 Eureka discovery 服务器上设置为false。它告诉这个实例不要向它找到的 Eureka 服务器注册它自己,因为它是它自己。
如果发现服务器公开任何具有与服务的注册和发现无关的功能的端点(例如公开用于监控微服务的指标),它必须将自己注册为客户端。
现在我们有了服务器,我们可以开始编写我们的微服务了。
开发微服务
使用 Spring Boot 创建微服务非常简单。您必须选择功能,编写实现行为所需的代码,公开可以被其他微服务访问的端点,并将其配置为注册为 Eureka 客户端。
微服务是处理明确定义的需求的独立流程。在创建基于微服务的分布式应用时,每个微服务组件都应该根据其用途包装在包中。整个实现应该是非常松散耦合的,但是非常紧密。让我们从我们可以为书店应用编写的最小、最简单的微服务开始:科技新闻服务。该服务应该公开一个可以访问无限技术新闻流的单一端点。
因为它是一个没有 web 接口的反应式服务,所以这个项目唯一的依赖项是spring-boot-starter-webflux和spring-cloud-starter-netflix-eureka-client。
spring-cloud-starter-netflix-eureka-client为项目添加了所有必要的依赖项,允许您构建一个网飞尤里卡客户端。
除了这两个之外,添加spring-boot-starter-actuator. 8 也是可行的。这个依赖项将 Spring Actuator 添加到您的项目中,这将为我们的应用添加生产就绪的特性。通过几个端点,它公开了关于正在运行的应用的操作信息。
科技新闻服务非常简单。它有一个用@EnableEurekaClient注释的 Spring Boot 主配置类。这是将该应用转变为微服务的关键组件,因为它支持 Eureka 客户端发现配置。
在这个类中,声明了一个路由器 bean 来配置端点和处理程序函数之间的映射。
清单 13-3 描绘了TechNewsApplication的内容。
package com.apress.prospringmvc.technews;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
// other imports omitted
@SpringBootApplication
@EnableEurekaClient
public class TechNewsApplication {
public static void main(String... args) {
System.setProperty("spring.config.name", "technews-service");
SpringApplication springApplication = new SpringApplication(TechNewsApplication.class);
springApplication.run(args);
}
@Bean
public RouterFunction<ServerResponse> router(TechNewsHandler handler){
return RouterFunctions
.route(GET("/"), handler.main)
.andRoute(GET("/index.htm"), handler.main)
.andRoute(GET("/tech/news"), handler.data);
}
}
Listing 13-3Spring Boot Tech News Microservice Configuration Class
TechNewsHandler类包含两个处理函数实现。其内容列于清单 13-4 中。
package com.apress.prospringmvc.technews;
// other imports omitted
@Component
class TechNewsHandler {
private static final Random RANDOM = new Random(System.currentTimeMillis());
public static final List<String> TECH_NEWS = List.of(
"Apress merged with Springer."
// other values omitted
);
public static String randomNews() {
return TECH_NEWS.get(RANDOM.nextInt(TECH_NEWS.size()));
}
final HandlerFunction<ServerResponse> main = serverRequest -> ok()
.contentType(MediaType.TEXT_HTML)
.bodyValue("Tech News service up and running!");
final HandlerFunction<ServerResponse> data = serverRequest -> ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(Flux.interval(Duration.ofSeconds(5))
.map(delay -> randomNews()), String.class);
}
Listing 13-4TechNewsHandler Handler Functions
这个应用的 Spring Boot 配置文件被命名为technews-service.yml,并在清单 13-5 中描述。
# Spring Properties
spring:
application:
name: technews-service # Service registers under this name
# HTTP Server
server:
port: 4000 # HTTP (Netty) port
address: 0.0.0.0
# Discovery Server Access
eureka:
client:
registerWithEureka: true
fetchRegistry: false
serviceUrl:
defaultZone: http://localhost:3000/eureka/
healthcheck:
enabled: true
instance:
leaseRenewalIntervalInSeconds: 5
preferIpAddress: false
# Actuator endpoint configuration
info:
app:
name: technews-service
description: Spring Cloud Random Tech News Generator
version: 2.0.0-SNAPSHOT
Listing 13-5Spring Boot Configuration File for the Tech News Microservice
前面的配置包含三个部分。
-
Spring 段定义应用名为
technews-service。微服务使用这个名称向 Eureka 服务器注册。 -
服务器部分定义了监听请求的端口。在本例中,它被设置为 4000。作为一个使用嵌入式 Netty 实例的反应式应用,如果不指定,它会尝试使用 8080。因为一次只有一个进程可以监听一个端口,所以每个微服务都有一个通过配置分配的不同端口。对于这个微服务,
server.address被设置为0.0.0.0,这意味着在安装它的计算机的所有地址上都可以访问它的端点。 -
Eureka 部分使用
eureka.client.serviceUrl.defaultZone属性定义了要注册的服务器所在的 URI。eureka.client.registerWithEureka默认为true;它用于配置 Eureka 客户端的注册。在此配置中明确设置它是为了避免混淆此微服务的类型。Eureka 客户端从服务器获取注册表信息并在本地缓存。之后,客户端使用这些信息来查找其他服务。因为
technews-service不依赖于另一个已经注册的微服务,所以不需要获取注册信息。将eureka.client.fetchRegistry设置为false以防止它这样做。注册成功后,Eureka 服务器总是报告一个客户机应用处于运行状态。这种行为可以通过启用 Eureka 健康检查来改变,这会导致将应用状态传播到 Eureka。这通过将
eureka.client.healthcheck.enabled属性设置为true来完成。尤里卡的客户端需要通过发送一个名为心跳的信号来告诉服务器它们仍然活跃。默认情况下,时间间隔为 30 秒。通过自定义
eureka.instance.leaseRenewalIntervalInSeconds属性的值,可以将其设置为更小的间隔。在开发过程中,可以将其设置为较小的值,这样可以加快注册速度,但是在生产中,这会产生与服务器的额外通信,这可能会导致服务延迟。对于生产,不应修改默认值。eureka.instance.preferIpAddress告知 Eureka 服务器是否应该使用域名或注册客户端的 IP。在我们的例子中,因为所有东西都在同一台机器上工作,所以这个属性的值是不相关的。 -
在执行器部分中,在浏览器中访问
/actuator/info时显示的信息由information属性块定制。健康信息由/actuator/healthURI 访问。
这些信息和更多信息可以在网飞 GitHub 页面上找到。 9 这里只解释与我们的实现相关的部分。
现在我们有了一个作为 Eureka 客户端的微服务,下一步是启动它并检查它是否注册到发现服务器。当您在 discovery-service 应用的日志中看到以下输出时,注册应该完成。
DEBUG o.s.c.n.e.server.InstanceRegistry - register TECHNEWS-SERVICE, vip technews-service, leaseDuration 90, isReplication false
INFO c.n.e.r.AbstractInstanceRegistry - Registered instance TECHNEWS-SERVICE/192.168.0.14:technews-service:4000 with status UP (replication=false)
注册后,当访问 Eureka 服务器的 web 界面 http://localhost:3000/时,您应该看到在Instances currently registered with Eureka部分,添加了一个针对technews-service微服务的条目,如图 13-5 所示。

图 13-5
尤里卡发现服务器 web 界面
如果您点击Status栏中的链接,您会注意到它会将您带到科技新闻应用中的/actuator/info,在那里您会看到驱动部分中的信息(参见图 13-6 )。

图 13-6
TechNews 执行器信息
newreleases-service也是一个非常简单的服务,它几乎与technews-service相同,但是它返回无限的Book实例流。这是一个 Spring Boot 应用,可以通过执行它的主类来启动,就像 tech news 微服务一样,所以没有必要在这里添加更多的代码或任何文本,因为它对本章没有任何实际价值。这两个微服务提供了书店应用中图书页面的新闻部分中描述的数据。
一旦启动了technews-service和newreleases-service微服务,就可以在浏览器中打开下面的网址http://localhost:3000/eureka/apps。该端点公开了所有已注册微服务的注册表元数据,例如它们的注册时间、运行状况检查、发送心跳的时间等等,这些都是微服务的标准信息。该信息发布在服务注册表中,并且可供所有客户端使用。您的 Eureka 服务器的目的是生成和管理信息,并与所有需要它的微服务共享。数据的格式是 XML。清单 13-6 中描述了一小段代码。
<applications>
<versions__delta>1</versions__delta>
<apps__hashcode>UP_2_</apps__hashcode>
<application>
<name>TECHNEWS-SERVICE</name>
<instance>
<instanceId>192.168.0.14:technews-service:4000</instanceId>
<hostName>192.168.0.14</hostName>
<app>TECHNEWS-SERVICE</app>
<ipAddr>192.168.0.14</ipAddr>
<status>UP</status>
<homePageUrl>http://192.168.0.14:4000/</homePageUrl>
<statusPageUrl>http://192.168.0.14:4000/actuator/info</statusPageUrl>
<healthCheckUrl>http://192.168.0.14:4000/actuator/health</healthCheckUrl>
<lastUpdatedTimestamp>1598830791842</lastUpdatedTimestamp>
<lastDirtyTimestamp>1598830791711</lastDirtyTimestamp>
<actionType>ADDED</actionType>
</instance>
</application>
<!-- other output omitted -->
</applications>
Listing 13-6Eureka Server Registered Microservices Information
如果您只想查看特定服务的元数据信息,请将服务名称添加到前面提到的 URI 中。因此,要仅查看关于technews-microservice,的信息,您必须访问http://localhost:3000/eureka/apps/TECHNEWS-SERVICE。
通过定制eureka.instance.metadataMap元数据,可以将附加元数据添加到实例注册中。通常,添加额外的元数据不会以任何方式修改远程客户端的行为,除非客户端被设计为知道其含义。要了解更多信息,请查阅官方 Spring Eureka 文档。 10
在注册时,每个微服务从服务器获得一个惟一的注册标识符,您可以在前面的输出片段的<instanceId>元素中看到。如果另一个进程使用相同的 ID 注册,服务器会将其视为重新启动,因此第一个进程会被丢弃。
为了运行同一个流程的多个实例,出于负载平衡和弹性的原因,我们必须确保服务器生成不同的注册 ID。在本地,这可以通过为微服务使用不同的端口来实现。这是最简单的方法,无需对代码库或配置进行侵入性的更改。
到目前为止使用的配置的注册 ID——<instanceId>元素中的 ID——是使用下面的默认模式构建的。
${ipAddress}:${spring.application.name:${server.port}}
technews-service微服务实例的注册 ID 是
192.168.0.14:technews-service:4000
微服务名称和端口在格式模式中耦合在一起,因为它们提供了一种唯一的方法来标识微服务及其侦听请求的端口。
可以通过在 Spring Boot 配置文件中为 Eureka eureka.instance.metadataMap.instanceId属性添加不同的值来修改注册 ID 模板。清单 13-7 描述了一个修改注册 ID 模板的配置示例。
eureka:
instance:
metadataMap:
instanceId: ${spring.application.name}:${spring.application.instance_id:${server.port}}
Listing 13-7Registration ID Is Configured to Use a Different Naming Template
如果没有定义spring.application.instance_id,就退回到这个默认模板(如果对是哪个有疑问的话)。
${ipAddress}:${spring.application.name:${server.port}}
当在本地运行一个微服务应用时(我想你正在浏览这本书),微服务的 main 方法可以被参数化,将端口作为一个参数。这允许您通过提供一个不同的端口作为参数来启动任意数量的服务实例。在清单 13-8 中,端口值被读取并注入到 Spring Boot server.port环境变量中。
package com.apress.prospringmvc.newreleases;
// other imports omitted
@SpringBootApplication
@EnableEurekaClient
public class NewReleasesApplication {
public static void main(String... args) {
if (args.length == 1) {
System.setProperty("server.port", args[0]);
}
System.setProperty("spring.config.name", "newreleases-service");
SpringApplication springApplication = new SpringApplication(NewReleasesApplication.class);
springApplication.run(args);
}
}
Listing 13-8NewReleasesApplication
That Takes Port As an Argument
在图 13-7 中,您可以看到启动了三个newreleases-service实例:默认在端口 5000 上,另外两个在端口 5001 和 5002 上。

图 13-7
注册了多个新版本微服务实例
每个实例都是通过创建一个新的 IntelliJ IDEA 启动器并将端口配置为程序参数来启动的,如图 13-8 所示。

图 13-8
用于newreleases-service应用的 IntelliJ IDEA 启动器,端口作为程序参数提供
科技新闻和新发布微服务是基本的,它们不需要使用数据库,并且它们之间没有通信。下一步是开发使用数据库的微服务。
使用数据库的微服务
考虑到我们的服务是反应式的,选择的数据库是 MongoDB。由于前面章节中介绍的文档映射类Book和Account是相互分离的,book-service和account-service是独立的。每个都使用自己的 MongoDB 集合。
微服务公开了一个 REST API,用于各种图书操作:列表、创建、更新、删除、搜索、获取随机图书。在反应章节中已经描述了该服务的实现。
微服务为各种账户操作公开了一个 REST API:列表、创建、删除、更新。在反应章节中已经描述了该服务的实现。
当在微服务之间传输时,默认情况下,数据被序列化到 JSON,使用在 Spring Boot 应用中自动配置的默认org.springframework.http.codec.json.Jackson2JsonEncoder<T>。当到达目的地时,自动配置的org.springframework.http.codec.json.Jackson2JsonDecoder<T>将发出的数据转换回 Java 对象。
这两个微服务都不需要 web 接口,因为它们的 REST APIs 是由唯一一个具有 web 控制台的服务presentation-service调用的。
表 13-1 列出了account-service微服务公开的所有端点。
表 13-1
由account-service微服务公开的端点
上呼吸道感染
|
方法
|
影响
|
| --- | --- | --- |
| /,/ index.htm | 得到 | 返回“帐户服务启动并运行!”。如果不想使用执行机构,检查应用状态很有用。 |
| /account | 得到 | 返回包含对Flux<Account>的引用的响应。 |
| /account/{username} | 得到 | 返回一个响应,其主体表示对与作为路径变量提供的用户名相对应的Mono<Account>的引用。 |
| /account/{username} | 放 | 更新对应于作为路径变量提供的用户名的Account实例,并返回一个响应,其主体表示发出更新实例的Mono<Account>。 |
| /account | 邮政 | 使用来自请求主体的数据创建Account实例,并返回一个响应,其主体表示发出所创建实例的Mono<Account>。该响应的 location 标头中填充了用于访问新实例的 URI。 |
| /account | 删除 | 删除对应于作为路径变量提供的用户名的Account实例,并返回一个空响应。 |
表 13-2 列出了book-service微服务公开的所有端点。
表 13-2
由book-service微服务公开的端点
上呼吸道感染
|
方法
|
影响
|
| --- | --- | --- |
| /,/ index.htm | 得到 | 返回“预订服务启动并运行!”。 |
| /book/random | 得到 | 返回一个响应,其主体表示对包含两本书的 Flux
| /book/search | 得到 | 返回一个响应,其主体表示对发出与请求主体中提供的BookCriteria细节相匹配的Book实例的Flux<Book>的引用。 |
| /book/by/{isbn} | 得到 | 返回一个响应,其主体表示对与作为路径变量提供的 ISBN 相对应的Mono<Book>的引用。 |
| /book/{isbn} | 放 | 更新对应于作为路径变量提供的 ISBN 的Book实例,并返回一个响应,其主体表示发出更新实例的Mono<Book>。 |
| /book/create | 邮政 | 使用来自请求主体的数据创建Book实例,并返回一个响应,其主体表示发出所创建实例的Mono<Book>。该响应的 location 标头中填充了用于访问新实例的 URI。 |
| /book/delete/{isbn} | 删除 | 删除对应于作为路径变量提供的 ISBN 的Book实例,并返回一个空响应。 |
我们使用/book/by/{isbn}作为 URI 来通过 ISBN 检索图书实例,因为 ISBN 的类型是String。如果我们用 URI 模板/book/{isbn}声明一个 GET 处理程序方法,用于通过 ISBN 检索书籍,这个模板将匹配/book/random和/book/search上的 GET 请求,而两个 URI 模板的处理程序方法永远不会被调用。另一个解决方案是为{isbn}路径变量声明一个正则表达式,但是采用了最简单的方法。
必须修改每个服务的配置文件来添加一个 MongoDB 部分,因为每个服务都需要访问自己的集合。但是总体来说,实现与书中构建的任何反应式服务没有什么不同。因此,也可以使用WebTestClient来测试它们,以确保它们按预期工作。还可以使用curl测试它们,以确保它们发出由presentation service渲染的元素。
带有 Web 控制台的微服务
已经说过,微服务使用 REST 之类的不可知协议进行通信。account-service、book-service、tech、news-service和newreleases-service通过 HTTP 公开 RESTful APIs(尽管可以使用不同的通信通道,如 JMS 或 AMQP)。
presentation-service更有趣,因为它使用其他四个发出的数据(见图 13-2 )并通过 REST API 调用获取数据。该服务公开了一个最终用户可以访问数据的 web 界面。为了使用由反应式服务产生的数据,Spring 提供了我们在前面章节中已经使用过的WebClient接口。WebClient发送 HTTP 请求并获取多种格式的数据,如 XML、JSON 或数据流。
presentation-service微服务客户端使用一个平衡的WebClient来连接和请求来自其他注册的微服务的数据。平衡的WebClient不知道他们的位置和确切的 URI,因为 SpringCloud 在引擎盖下照顾这个。
presentation-service的实现略有不同,因为它配置了一个 web 接口。默认情况下,Eureka 服务器使用 FreeMarker 模板,因此如果需要不同的实现,必须通过将spring.application.freemarker.enabled属性设置为false来忽略这些模板。配置文件被命名为presentation-service.yml,,其内容如清单 13-9 所示。
spring:
application:
name: presentation-service # Service registers under this name
freemarker:
enabled: false # Ignore Eureka dashboard FreeMarker templates
thymeleaf:
cache: false
prefix: classpath:/templates/
# HTTP Server
server:
port: 7000 # HTTP (Netty) port
address: 0.0.0.0
context-path: /
compression:
enabled: true
# Discovery Server Access
eureka:
client:
registerWithEureka: true
fetchRegistry: true
serviceUrl:
defaultZone: http://localhost:3000/eureka/
instance:
leaseRenewalIntervalInSeconds: 10
preferIpAddress: false
info:
app:
name: presentation-service
description: Spring Cloud Bookstore Service accessing data from all other services
version: 2.0.0-SNAPSHOT
Listing 13-9presentation-service.yml Configuration File
由于presentation-service是一个带有国际化百里香模板的 Spring Boot WebFlux 应用,因此必须添加额外的配置,如前面的反应章节所述。这个项目使用一个用@EnableWebFlux标注的配置类和用@EnableEurekaClient标注的主 Spring Boot 类来配置,使这个应用成为一个 Eureka 客户机。
除了将freemarker.enabled属性设置为false之外,还允许我们的应用使用百里香模板;在之前的配置中,最重要的属性是为该服务设置为true的eureka.client.fetchRegistry。对于其他服务,该属性被设置为false,因为它们不关心其他哪些微服务注册到了 Eureka 服务器。它们被设计成独立的。他们不需要其他微服务提供的数据来完成工作。他们不需要向 Eureka 服务器注册来询问其他注册的服务。presentation-service微服务需要这些微服务来完成它的工作。在向 Eureka 服务器注册了自己之后,它需要知道这些服务器是否也注册了,将这个属性设置为true就可以做到这一点。
与其他微服务的通信由一个名为 Ribbon 的负载平衡器来实现。 11 自 2015 年 Spring Cloud 首次亮相以来,默认的网飞 Ribbon 支持的负载平衡策略就已经到位。Ribbon 是一个客户端负载平衡器,它提供对 HTTP 和 TCP 客户端行为的控制。Ribbon 的客户机组件提供了一组很好的配置选项,比如连接超时、重试、重试算法(指数、有界后退)等等。Ribbon 内置了一个可插拔和可定制的负载平衡组件。当然,因为我们使用的是 Spring Boot,所以没有必要对默认配置做太多调整。默认情况下,当spring-cloud-starter-netflix-eureka-client位于类路径中时,Ribbon 作为依赖项添加到项目中,并且是spring-cloud-netflix-ribbon模块的一部分。Ribbon 由一个用@LoadBalanced标注的平衡WebClient实例使用,以识别现有的微服务和直接调用。
2019 年,SpringCloud 改用自己的负载均衡器解决方案,SpringCloud 网飞 OSS 项目转入维护模式。为了避免默认使用 Ribbon,spring.cloud.loadbalancer.ribbon.enabled属性必须设置为false
由于presentation-service需要从其他四个微服务中访问数据,并且WebClient一旦创建就不可变,我们需要为每个微服务创建一个WebClient bean。我们可以这样做,在任何地方注入四个单独的WebClientbean,或者我们可以使用一个构建器 bean。构建器 bean 在需要时创建平衡的WebClient实例,然后丢弃它们,让垃圾收集器完成它的工作。
@LoadBalanced也可以放在WebClient.Builder上。在清单 13-10 中,您可以看到主 Spring Boot 配置类,其中声明了WebClient.Builder平衡 bean,以及该项目的路由函数。
package com.apress.prospringmvc.presentation;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.web.reactive.function.client.WebClient;
// other imports omitted
@EnableEurekaClient
@SpringBootApplication
public class PresentationServiceApplication {
private static Logger logger = LoggerFactory
.getLogger(PresentationServiceApplication.class);
public static void main(String... args) throws IOException {
System.setProperty("spring.config.name", "presentation-service");
var ctx = SpringApplication.run(PresentationServiceApplication.class, args);
assert (ctx != null);
logger.info("Started ...");
System.in.read();
ctx.close();
}
@LoadBalanced @Bean
WebClient.Builder webClientBuilder() {
return WebClient.builder();
}
@Bean
public RouterFunction<ServerResponse> router(PresentationHandler handler){
return RouterFunctions
.route(GET("/"), handler.main)
.andRoute(GET("/index.htm"), handler.main)
.andRoute(GET("/book/search"), handler.searchPage)
.andRoute(POST("/book/search"), handler::retrieveResults)
.andRoute(GET("/cart/checkout"), handler.checkoutPage)
.andRoute(GET("/customer/register"), handler::registerPage)
.andRoute(GET("/customer/login"), handler.loginPage)
.andRoute(GET("/book/random"), handler::randomFragment)
.andRoute(GET("/tech/news"), handler::newsFragment)
.andRoute(GET("/book/releases"), handler::releasesFragment);
}
}
Listing 13-10PresentationServiceApplication
Configuration File
PresentationHandler类是一个简单的定制类,包含许多用于处理应用请求的HandlerFunction<ServerResponse>。它使用WebClient.Builder将请求转发给其他微服务。Spring Cloud 拦截请求,并使用一个定制的org.springframework.http.client.reactive.ClientHttpRequest implementation,该定制的org.springframework.http.client.reactive.ClientHttpRequest implementation使用org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer<ServiceInstance>进行微服务查找,并促进云中的进程间通信(或者像本场景中那样在一台机器上)。
在编写代码时,如果你需要直接引用ClientHttpRequest,注意不要把它和org.springframework.http.client包中的非反应性对应物混淆了。
Spring Cloud 为ReactiveLoadBalancer<T>接口提供了现成的实现:配置循环负载平衡的org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer类。请求随机分布在任意数量的已配置实例中。如果你想插入你自己的负载平衡器,你可以通过提供你自己的类实现ReactiveLoadBalancer<T>来实现。
现在我们已经介绍了如何识别微服务,让我们通过在控制台中打印微服务 URIs 来检查它是否工作,其中的数据来自于presentation-service。因为我们有四个微服务,所以我们需要四个ReactiveLoadBalancer<ServiceInstance>,因为你知道——不变性。解决方案是使用ReactiveLoadBalancer.Factory<ServiceInstance>来创建这些反应式负载平衡器实例。为此,我们创建一个具有init方法的 bean,该方法为我们的每个服务使用工厂实例创建一个负载平衡器,获取 URI,并在控制台中打印出来。清单 13-11 中描述了ServiceUriBuilder类的代码。
package com.apress.prospringmvc.presentation;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.Response;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
//other imports committed
@Component
public class ServiceUriBuilder {
private static Logger logger = LoggerFactory.getLogger(ServiceUriBuilder.class);
final ReactiveLoadBalancer.Factory<ServiceInstance> loadBalancerfactory;
public ServiceUriBuilder(ReactiveLoadBalancer.Factory<ServiceInstance> loadBalancerfactory) {
this.loadBalancerfactory = loadBalancerfactory;
}
@PostConstruct
public void getServiceURIs(){
Flux.just("technews-service","newreleases-service","book-service","account-service")
.map(serviceId -> {
ReactiveLoadBalancer<ServiceInstance> loadBalancer = loadBalancerfactory.getInstance(serviceId);
Flux<Response<ServiceInstance>> chosen = Flux.from(loadBalancer.choose());
chosen.map(responseServiceInstance -> {
ServiceInstance server = responseServiceInstance.getServer();
var url = "http://" + server.getHost() + ':' + server.getPort();
logger.debug("--->> {} : {}", serviceId, url);
return url;
}).subscribe();
return serviceId;
}).subscribe();
}
}
Listing 13-11ServiceUriBuilder Class
如您所见,使用 Spring Boot 配置通过spring.application.name属性赋予服务的名称被用作返回ReactiveLoadBalancer<ServiceInstance>实例的loadBalancerfactory.getInstance(String)方法的参数。因为它是一个反应性组件,调用loadBalancer.choose()会返回一个Publisher<Response<ServiceInstance>>(通过将它包装在Flux.from(..)中而转换成一个Flux<T>),它会根据负载平衡算法发出所选择的服务器。ServiceInstance是从Response<T>对象中提取出来的,现在微服务 URI 才可以使用它的元数据放在一起。
如果一切正常,当通过运行 Spring Boot 可执行类来启动presentation-service应用时,你可能已经做了几百次了,你应该会看到一些 URIs 打印在控制台上,类似于清单 13-12 中所示的。
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> technews-service : http://192.168.0.14:4000
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> newreleases-service : http://192.168.0.14:5000
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> newreleases-service : http://192.168.0.14:5001
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> newreleases-service : http://192.168.0.14:5002
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> book-service : http://192.168.0.14:6001
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> account-service : http://192.168.0.14:6002
Listing 13-12Output Generated by the ServiceUriBuilder Bean Containing the Microservices URIs
不要期望所有的 URL 都被打印在一起,或者按照前面清单中的顺序打印。结果是通过调用反应函数获得的,因此 URIs 可能分散在其他日志语句中。
现在我们已经确认了presentation-service应用知道其他微服务的位置,让我们看看WebClient如何访问它们的数据。PresentationHandler类包含项目的所有处理函数,清单 13-13 中描述了它的一些内容。
package com.apress.prospringmvc.presentation;
import org.apache.commons.lang3.tuple.Pair;
// other imports omitted
@Component
public class PresentationHandler {
private final PresentationService presentationService;
public PresentationHandler(PresentationService presentationService) {
this.presentationService = presentationService;
}
final HandlerFunction<ServerResponse> main = serverRequest -> ok()
.contentType(MediaType.TEXT_HTML)
.render("index");
final HandlerFunction<ServerResponse> searchPage = serverRequest -> ok()
.contentType(MediaType.TEXT_HTML)
.render("book/search", Map.of(
"categories", List.of(WEB, SPRING, JAVA),
"bookSearchCriteria", new BookSearchCriteria()));
public Mono<ServerResponse> newsFragment(ServerRequest request) {
final IReactiveSSEDataDriverContextVariable dataDriver =
new ReactiveDataDriverContextVariable(presentationService.techNews(),
1, "techNews");
return ok().contentType(MediaType.TEXT_EVENT_STREAM)
.render("book/search :: #techNews", Map.of("techNews", dataDriver));
}
// other code omitted
}
Listing 13-13The PresentationHandler Class
当调用main处理函数时,它返回index.html视图模板的实现。
调用searchPage处理函数时,它返回需要两个模型属性的search.html视图模板的实现:categories和一个BookSearchCriteria实例。
newsFragment方法返回一个反应视图片段,其中填充了由presentationService.techNews()调用返回的Flux<String>发出的数据。
PresentationService bean 是使用平衡的WebClient.Builder来调用微服务presentation-service的 bean。清单 13-14 显示了techNews()方法的代码,该方法返回显示在search.html页面上的Flux<String>发布的随机科技新闻。
在同一个清单中,newReleases()方法检索由newreleases-service作为Flux<Book>返回的新书发布。
package com.apress.prospringmvc.presentation;
import org.springframework.web.reactive.function.client.WebClient;
// other imports omitted
@Service
public class PresentationService {
private static final String TECHNEWS_SERVICE_URI = "http://technews-service";
private static final String NEWRELEASES_SERVICE_URI = "http://newreleases-service";
private WebClient.Builder webClientBuilder;
public PresentationService(WebClient.Builder webClientBuilder) {
this.webClientBuilder = webClientBuilder;
}
public Flux<Book> newReleases() {
return webClientBuilder.baseUrl(NEWRELEASES_SERVICE_URI).build()
.get().uri("/book/releases")
.retrieve()
.bodyToFlux(Book.class).map(book -> {
logger.debug("Retrieved book: {}", book);
return book;
});
}
public Flux<String> techNews() {
return webClientBuilder.baseUrl(TECHNEWS_SERVICE_URI).build()
.get().uri("/tech/news")
.retrieve()
.bodyToFlux(String.class).map(val -> {
logger.debug("Retrieved val : {}", val);
return val;
});
}
// other code omitted
}
Listing 13-14The PresentationService Class
Spring 使用构造函数注入了WebClient.Builder。
这个类中的大多数方法看起来都相似,只要微服务启动,负载平衡器就知道将请求发送到哪里。
你可能已经注意到TECHNEWS_SERVICE_URI和NEWRELEASES_SERVICE_URI不是真正的 URIs,没有端口。它们是通过在服务名前面加上“http://”来创建的。负载均衡器拦截对这些 URL 的WebClient请求,并使用来自org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools类的reconstructURI()方法重新构建发送请求的 URI。 十二
共享课程
presentation-service处理Book和Account对象,由newreleases-service、books-service,和account-service通过反应流提供。
books-service项目包含一个名为Book的 MongoDB 文档类来表示 book 对象。
account-service项目包含一个名为Account的 MongoDB 文档类来表示 account 对象。
newreleases-service项目包含一个非常简单的Book类,有三个属性,对即将出版的书很重要:标题、作者和年份。随机的Book实例是使用无限的反应流创建和发射的。presentation-service向“/book/releases”发出 GET 请求。这些Book实例是使用一个反应流发送给它的。
presentation-service项目包含一个Book和一个Account类,这两个简单的 POJOs 专用于保存 web 界面中显示的数据。
这是一种多余的,也许是懒惰的方法,以保持项目尽可能的分离。可能有一个包含所有公共类的项目被添加到它们的类路径中。但是借助 JSON 的魔力,在不同的项目中拥有不同的类是可能的,并且序列化和反序列化仍然有效。
在所有微服务启动后,您应该能够在 Eureka web 应用中看到它们,并访问书店应用的 web 界面(参见图 13-9 )。

图 13-9
在尤里卡注册的多个微服务和书店微服务应用显示从technews-service和newreleases-service接收的数据
摘要
本章简要介绍了云开发的世界。
我们向您展示了单片和微服务架构之间的区别,并解释了为什么由多个微服务组成的应用更适合云环境。
Spring Cloud 使得在本地环境中实践云开发变得容易。通过将 Spring Eureka libs 添加到项目中,可以很容易地将 Spring Boot 应用转换为微服务。
您了解了如何将一个整体分割成多个独立的微服务,并使用发现服务器来注册它们,确保它们可以相互通信。每个 Spring Boot 微服务都可以部署到 Kubernetes 集群的私有云或容器中自己的 VM 上。只要发现服务器可以访问,服务仍然可以找到彼此。
*






bean 的名称不同于 ID。过去,它是由 XML 规范定义的,不能包含特殊字符,如/。这意味着您需要使用 bean 的名称。您可以通过在
RequestMessageType 和 RequestMessageType 是占位符类型,替换客户端和服务器之间通信中使用的真实类型,以创建通用模式。
浙公网安备 33010602011771号