SpringBoot2-基础知识-全-
SpringBoot2 基础知识(全)
原文:
zh.annas-archive.org/md5/f68e33b69ce45f75289f3e293b634f8b译者:飞龙
前言
Spring 框架是一个成熟、企业级和开源的应用程序开发框架,它提供了一个灵活且现代的替代方案,用于官方 Java EE 标准。
这本书面向的对象
这本书旨在帮助那些处于职业生涯的初级或早期中级阶段的开发者,他们希望能够轻松地使用 Java 平台创建健壮的 Web 应用程序或 RESTful 服务。你应该至少具备基本的 Java 知识,并且知道如何使用 Maven 编译带有给定 POM 文件的程序。假设你处于初级或早期中级阶段。你不需要成为 HTML 专家,但应该了解 HTML 的工作原理以及如何保持文件 XML/XHTML 的合规性。
如果你想要使用 Java 创建现代 Web 应用程序或 RESTful 服务,你应该参加这门课程。你还将学习很多关于 Spring 框架本身的知识,这将使你能够在之后独立创建完整的应用程序。
这本书涵盖的内容
第一章, Spring 项目和框架,介绍了 Spring 框架及其原则。然后我们第一次尝试构建和运行项目。在关注 Spring 的主要构建块之后,本章最后展示了如何使用 Project Lombok 库。
第二章, 构建 Spring 应用程序,带我们了解 Spring Bean 的配置类以及它们的各种依赖关系,然后展示如何创建和配置不同的环境。
第三章, 测试 Spring 应用程序,涵盖了创建和分析两种不同类型的测试,即单元测试和集成测试。
第四章, MVC 模式,讨论了上述模式,详细解释了模型、视图和控制器的概念。本章简要介绍了应用程序的开发和不同控制器的实现。
第五章, 使用网页显示信息,训练学生使用模板引擎 Thymleaf,它的语法;以及元素;然后如何使用 Thymleaf 构建网页。
第六章, 在视图和控制之间传递数据,教我们如何创建表单以及在网页浏览器中输入信息时使用的不同输入字段。
第七章, RESTful API,涵盖了 REST 的不同方面,如何使用 Postman,以及编写 RESTful API 的过程。
第八章, Web 应用程序安全,讨论了 Web 应用程序安全方面的内容,以及 Spring 中的不同安全选项。
第九章,使用数据库持久化数据,涵盖选择数据库管理系统以及与关系数据库和 SQL 一起工作;理解使用 JDBC 和 JdbcTemplate 进行数据库访问,以及使用 Spring 进一步的数据实现。
要充分利用本书
要成功完成本书,您将需要至少配备 Intel Core i5 处理器或等效处理器、8 GB RAM 和 5 GB 可用存储空间的计算机系统。此外,您还需要以下软件:
-
操作系统:Windows 7 或更高版本
-
浏览器:安装了最新更新的 Google Chrome 或 Mozilla Firefox
-
IntelliJ Community Edition(或 Ultimate)-最新版本
-
JDK 8+
-
Maven 3.3+
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择支持选项卡。
-
点击代码下载和勘误表。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本解压缩或提取文件夹:
-
适用于 Windows 的 WinRAR/7-Zip
-
适用于 Mac 的 Zipeg/iZip/UnRarX
-
适用于 Linux 的 7-Zip/PeaZip
书籍的代码包也托管在 GitHub 上,地址为github.com/TrainingByPackt/Spring-Boot-2-Fundamentals。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有来自我们丰富的书籍和视频目录中的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称的显示方式如下:“常规作用域在ConfigurableBeanFactory类中定义,而特定于 Web 应用程序的作用域在WebApplicationContext中定义。”
代码块设置为如下:
@Repository
public class ExampleBean {
@Autowired
private DataSource dataSource;
...
}
粗体:新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“默认消息应该是 INPUT+ 问候世界。”
警告或重要说明如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书籍标题,并通过customercare@packtpub.com给我们发邮件。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法副本,无论形式如何,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问 packt.com.
第一章:Spring 项目和框架
Spring 框架是一个成熟、企业级和开源的应用程序开发框架,它提供了一个灵活且现代的替代方案,用于官方的 Java EE 标准。
Spring Boot 是一种技术,它允许开发者基于 Spring 框架编写应用程序,同时通过提供约定优于配置和 Gradle 及 Maven 的依赖管理来依靠最佳实践,确保它们拥有所有库的兼容版本。这消除了在编写企业应用程序或服务时所需的大量任务。Spring Boot 还通过提供与其他框架的强大集成来解决重复和管道代码的问题。同时,当需要时,开发者可以偏离其精心设计的约定。
到本章结束时,你将能够:
-
描述 Spring 框架
-
构建并运行一个简单的项目
-
利用 Spring 的应用程序上下文
-
使用 Project Lombok 库
Spring 框架简介
Spring 框架是一个开源的应用程序框架,可以被任何 Java 应用程序使用。它为 Java 平台提供了一个控制反转(IoC)容器。在本节中,我们将查看 Spring 项目的概述,并介绍 Spring 应用程序的基本构建块。你将了解 Spring 框架的历史、Spring 重点关注的关键原则、依赖注入和控制反转。最后,我们将探讨 Spring 生态系统。
简短的历史
Spring 框架是由 Rod Johnson 在 2003 年创建的,作为重型且缓慢的 J2EE 1.4 堆栈的替代品。该项目的当前负责人是 Pivotal Software,该公司雇佣了核心开发者并指导项目的开发。
框架本身是源代码,有大量的贡献者帮助开发各种模块。但我们会很快谈到这一点。
由于其专注于生产力,Spring 获得了大量的市场份额。Zeroturnaround 公司,一家创建 Java 开发工具的公司,在 2016 年进行了一项调查,其中 36% 的参与者希望将他们的项目转换为 Spring,而只有 14% 的人希望转换为 JEE。很难找到 JEE 与 Spring 的具体市场份额数据,但在 Java 开发者调查中,Spring 通常远超过 50%,而 JEE 则远低于这个比例。这一点也反映在职位列表中,Spring 多年来一直胜过 JEE。
Spring 被全球所有规模的公司使用。例如,Netflix 对他们使用 Spring 非常公开,他们甚至提供了 Spring Cloud 栈的大部分内容。
想要了解更多关于 Spring 框架版本的背景信息,你可以阅读github.com/spring-projects/spring-framework/wiki/Spring-Framework-Versions上的条目。
要了解 Spring 的使用情况,你还可以查看 stackshare.io/spring/in-stacks。
Spring 核心原则
Spring 框架的开发基于几个原则,这些原则自其诞生以来一直是一致的:
-
控制反转和依赖注入构成了整个框架的核心。
-
模块化允许你插入不同的实现,例如日志框架模板引擎和内部代码,这为面向方面的编程创建了代理。
-
可测试性使整个项目成为可能,并旨在创建可测试的应用程序代码。
-
在许多方面为开发者提供了便利,使开发者无需过多干扰就能提高生产力。
J2EE 痛点
在过去两年中,官方 J2EE 标准受到了开发者社区的很多批评。主要问题如下:
-
标准是由一个委员会制定的,这导致了许多难以使用的功能。
-
委员会缓慢地采用新技术,这导致许多项目在 J2EE 范围之外开发,以满足这些需求。
-
应用服务器非常昂贵,并且相对较慢地采用新标准。
-
在 J2EE 1.0 - 1.4 时代,需要编写的代码量非常高。
-
配置应用程序需要大量的 XML 文档。这要么导致错误,要么需要 IDE,以帮助保持一切同步。
-
由于“先规范”的方法,许多功能使用起来非常复杂,这导致了错误或让人们完全放弃某些功能。
Spring 作为 J2EE 的替代品
Rod Johnson 想要改变 J2EE 强迫人们采取的编码方式,因此在许多方面故意采取了相反的做法:
-
与象牙塔方法相反,Spring 采用“先编码”的方式,这一方法后来也被 Java 社区和委员会所采纳。
-
Spring 非常模块化和可扩展,因此通常使用模块添加新技术,这些模块只是额外的 JAR 文件。
-
Spring 不想等待应用服务器添加功能,因此 Spring 应用包含了应用服务器或 servlet 容器未提供的一切。
-
与 J2EE 相比,Spring 总是具有更少的样板代码来实现相同的功能。这种优势现在不再那么相关,因为当前版本的 JavaEE 受 Spring 启发很大,并复制了许多其概念。
-
在最初,Spring 也使用 XML 进行其配置,因为那时被认为是最高水平。然而,当 Java 1.5 可用时,他们开始转向注解和基于 Java 的配置。
-
虽然获取一些 JavaEE 库的源代码仍然困难,但 Spring 总是提供了源 JAR 文件,并允许任何人在必要时查看精心设计的框架。
控制反转和依赖注入
控制反转(IoC)是一种定义应用程序在非常低级别上如何编写的范例。它基本上翻转了控制流,您的应用程序代码对由应用程序框架触发的事件做出反应。
这与依赖倒置(DI)模式重叠,它是依赖注入的更广泛版本。当您的应用程序使用依赖注入时,您不会自己创建重要类的实例;您让容器创建实例或提供现有的实例。特别是当使用接口时,您能够解耦应用程序的组件。这意味着您有部分之间没有直接依赖关系。
您的应用程序做出反应而不是严格控制一切的事实有助于解耦应用程序的各个部分。现在,您有依赖于框架而不是其其他部分的代码。这导致耦合度降低,因此代码更易于维护和测试。IoC 也被称为好莱坞原则,口号是“别叫我们,我们会叫你”,这完美地描述了它是什么。
想要了解更多关于 Spring 框架的背景信息,您可以阅读"TheServerSide"网站上的文章,这是一个与 Java 和软件开发相关的大型网站,链接为www.theserverside.com/news/1321158/A-beginners-guide-to-Dependency-Injection。
控制反转和依赖注入概述
请查看以下表格,它突出了主要概念:

想要了解更多关于上帝对象问题的背景信息,您可以阅读www.c-sharpcorner.com/article/godobject-a-code-smell/上的条目。
Spring 框架
典型 Spring 应用程序的构建块看起来像这样:
-
核心容器控制应用程序的生命周期,并包含应用程序上下文,其中包含由 Spring 管理的类的实例。
-
然后,有多个模块决定了您的应用程序中的控制流如何被触发。
-
例如:Spring MVC,它负责将请求分发到您通常编写的特殊类,这些类处理 HTTP 请求。
-
Spring 消息传递用于在系统之间处理和发送消息。
调度可以根据时间相关的事件执行方法。例如;您必须每天午夜执行清理任务。
-
Spring 集成能够通过文件传输查询或获取其他系统的数据以进行处理。
-
您的代码将从这些模块中被调用以实现其功能。
在您的代码执行期间或之后,您通常会准备需要存储在数据库中或返回给用户或调用者的数据。因此,Spring 应用程序的其他部分如下:
-
视图渲染器,它将准备好的数据渲染为 HTML 页面,例如。
-
对象(非)序列化是将传入或传出的数据转换为另一种形式的过程。这通常是转换为 XML 或 JSON,但也可能转换为其他格式。
-
根据你编写的应用程序类型,Spring 还提供了其他模块,以使软件开发更加容易。
Spring 生态系统
Spring 的模块化在过去 15+年里导致了众多模块的创建。其中一些已经退役,但官方主页列出了 23 个主要项目,其中一些甚至有 5 到 10 个子项目。
这意味着,对于你想做的每一件事,有很大可能性存在一个 Spring 模块可以帮助你。因此,你可以专注于业务目标,而不是编写第 100 个消息传递框架或其他基础方面。当然,这也意味着很容易迷失在需要添加和配置的项目和依赖关系的海洋中。
这是在几年前人们谈论 Spring 时的一大担忧。它需要大量的配置,并且直到一切按预期工作,设置新项目需要很长时间。
Spring Boot 特性
这就是 Spring Boot 冲进来拯救世界的时候。Spring Boot 是一个常规的 Spring 项目,旨在使开发体验更加愉快。这是通过大量依赖以下内容来实现的:
-
契约优于配置
-
自动检测功能
-
依赖管理以最小化寻找所有库兼容版本时的痛苦
重点在于提供生产就绪功能,而不进行任何代码生成。这使得你能够在几分钟内编写可以暴露给互联网的简单应用程序。
引导启动
在本节中,我们将通过使用名为Spring Initializr的 Spring 项目生成器来创建第一个 Spring Boot 项目。然后,我们将查看生成的代码并首次启动应用程序。
Spring Initializr
Spring Initializr 项目始于 2013 年。如今,它支持 Java、Kotlin 或 Groovy 编写的 Maven 和 Gradle 项目。目前的目标平台是 JDK 7 至 9。生成器能够创建 Spring Boot 1.x 或 2.x 项目。
当你在 IDE 中使用 Spring Boot 项目的项目创建向导时,它很可能会使用start.spring.io,这是 Spring Initializr 的网站。IntelliJ 和 Eclipse 都内置了对生成器的支持。
创建第一个 Spring Initializr 项目
目标是使用 Spring Initializr 创建一个项目。在完成这部分内容后,你将生成一个简单的 Spring Boot 应用程序,可以作为进一步开发的起点。完成步骤如下:
-
打开
start.spring.io。 -
创建一个简单的Maven 项目,使用Java和 Spring Boot 2.0.5。
-
输入以下详细信息:
-
-
组:
com.packt.springboot -
艺术品:
blogmania
-
看看下面的截图:

- 下载并解压项目。
目前不要添加依赖项。但是,您可以点击页面底部的“切换到完整版本”链接以显示可用的依赖项数量。如果您喜欢,可以在完整版本中填写名称(BlogMania)和描述(Self-hosted blogging made easy)。
在 IDE 中检查项目
目标是按照顺序打开 IDE 并检查生成的项目的重要部分。通过本节,您将已导入并检查了新创建的 Spring Boot 应用程序。完成步骤如下:
- 使用 IntelliJ IDE 打开项目。
-
-
打开 IntelliJ。
-
使用菜单或启动屏幕以新项目的方式打开
pom.xml。
-
- 在导入后,在左侧的项目面板中打开
pom.xml。查看文件中的以下片段:
-
-
<packaging>jar</packaging>: 应用程序将被构建为 JAR 文件。Spring Boot 构建插件将创建剩余的资产以使其可执行。 -
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> … </parent>: Spring Boot 应用程序通常将 spring-boot-starter-parent 配置为其父 POM,它为项目的构建过程提供了大量的预配置依赖项。 -
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId></dependency>: 唯一的真正依赖项是这个启动器,它将导入 Spring 框架和更多与 Spring Boot 相关的依赖项(总共 37 个,包括测试依赖项)。
-
-
-
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin>: 此构建插件负责创建可执行的 JAR 文件并将所有依赖项嵌入到这个单一文件中。
-
-
打开
src/main/java/BlogManiaApplication.java。查看文件中的以下片段:-
@SpringBootApplication: 这个注解将项目标记为 Spring Boot 应用程序,并告诉 Spring 这将是应用程序的主类。Spring Boot 会扫描类路径以查找 Beans 和其他感兴趣的类,默认情况下,它会扫描主应用程序类以下的每个包。 -
public static void main(String[] args){ SpringApplication.run(BlogManiaApplication.class, args); }:这是当应用程序启动时 JVM 调用的主方法。SpringApplication.run(...)初始化应用程序,并且一旦 Spring 上下文启动,方法调用就会终止。这意味着您可以在调用之后有代码。应用程序本身在 Spring 上下文关闭或由操作系统从外部终止时终止。访问bit.ly/2x9gEUz以获取BlogManiaApplication.java文件的代码。
-
使用不同选项运行项目
目标是检查运行项目的各种选项。通过本小节,您将在 IntelliJ 或使用 Maven 中启动新创建的 Spring Boot 应用程序。完成步骤如下:
运行应用程序有多种选择:
-
在终端中:
-
转到项目目录。
-
执行以下命令:
-
mvnw spring-boot:run
看看下面的截图:

-
- 执行以下命令:
-
在支持 Maven 的 IDE 中:
-
转到 Maven 工具窗口。
-
打开
BlogMania/Plugins/spring-boot。 -
点击
spring-boot:run。
-
看看下面的截图:

-
在使用标准 Java 功能的 IDE 中:
-
打开
BlogManiaApplication.java。 -
右键单击主方法并选择运行。
-
看看下面的截图:

-
在 IntelliJ Ultimate 版本中,您还有一个 Spring 运行仪表板,它提供了许多在 IDE 中启动和监控 Spring 应用程序的好功能。
- 这将启动应用程序,您将看到 Spring Boot 标签和一些在应用程序终止前的框架附加输出。
应用程序几乎立即终止,因为没有要执行的操作。"纯 Spring" 没有防止应用程序终止的主循环。例如,一旦添加了 Spring MVC 依赖项,应用程序将启动并等待请求,而不是立即停止。
访问 bit.ly/2x9gEUz 以获取 BlogManiaApplication.java 文件的代码。
应用程序上下文
在本节中,我们将探讨 Spring 的构建块,它们是如何被 Spring 发现的,以及如果需要,我们如何手动定义它们。
Spring Bean
Spring Bean 本身只是一个简单的 Java 对象,但它是 Spring 应用程序中的核心构建块。其生命周期由 Spring IoC 容器管理。这意味着在其生命周期内,它由容器创建和控制。容器的主体接口称为 BeanFactory,它由 ApplicationContext 实现。
你可以通过将类声明为 Spring Bean 来使其成为 Spring Bean。这通常是通过向类中添加注解(如@Component)来完成的。Spring 将找到该类,创建实例,并根据你的配置进行配置。
Spring Bean 类型
你可以使用许多注解来标记一个类为 Spring Bean。它们用于传达类的特殊含义。默认情况下,一些特殊类型可用,如@Component、@Service、@Repository和@Controller。
当你将这些注解放在一个类上时,它将被视为 Spring Bean,并在应用程序启动时创建一个实例。它的依赖关系将根据你的配置设置。
通常,你使用@Service或@Component注解在类上没有关系。这纯粹是装饰性的,有助于你更好地理解类应该做什么,因为它们之间存在语义差异。让我们回顾一下这些注解:
-
@Controller标记在 Web 应用程序上下文中的类,表示处理请求。这将在后面的章节中介绍。 -
@Service标记被认为是服务的类,这意味着它们通常为其他服务或控制器提供业务功能。 -
@Repository将一个类标记为一种数据提供者。例如,一个仓库通过从数据库、外部 Web 服务或类似的地方获取数据来提供数据访问。 -
@Component通常标记那些不适合其他类别的辅助类。
这些类型是多层架构的默认构建块。
探索 Spring Beans
目标是通过向其中添加一些 Beans 来增强你的第一个 Spring Boot 应用程序。通过本节,你已经看到了 Spring 上下文的作用,并看到了如何从主方法中访问 Spring 上下文。完成步骤如下:
-
显示现有的 Beans。
-
使用前面小节中导入的项目打开 IDE。
-
打开
BlogmaniaApplication.java文件。 -
将
SpringApplication.run的结果分配给一个名为 context 的变量。 -
在下一行中,通过上下文对象的
getBeanDefinitionNames()方法遍历所有定义的 Bean 名称,你可以访问这些 Bean 名称。 -
通过执行主方法来运行应用程序。
前往bit.ly/2NaOkvJ访问BlogManiaApplication.java文件的代码。
你可以看到默认创建的许多 Beans。其中之一是blogmaniaApplication。默认情况下,Beans 的命名与类名相同,以小写字母开头。
这就是它的样子:
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.
run(BlogmaniaApplication.class, args);
for (String s : context.getBeanDefinitionNames()) {
System.out.println(s);
}
}
-
向上下文中添加新的 Beans。
-
在与
BlogmaniaApplication.java相同的包中创建一个名为BlogService的公共类。 -
在与
BlogmaniaApplication.java相同的包中创建一个名为BlogRepository的公共类。 -
在
BlogRepository.java的public class …之前添加@Repository。 -
再次运行应用程序。
你会注意到有一个名为 blogRepository 的新 Bean,但没有名为 blogService 的 Bean,因为我们还没有将类标记为 Spring Bean。
配置类
声明 Spring Bean 的另一种方式是通过配置类。这些用于更复杂的 Bean,需要运行代码来初始化 Bean。例如,从某处读取配置或进行复杂的设置以满足依赖项。这也用于从不在你控制之下的类创建 Bean,因此你不能向它们添加注解。
你可以通过在配置类中有一个被 @Bean 注解的公共方法并返回你的类的一个实例来创建一个 Bean。
默认情况下,Bean 的名称将与方法的名称相同,而注解的类将默认生成一个以类名开头字母小写的 Bean 名称:
@Configuration
class MyConfiguration{
@Bean
public Instant date(){
return Instant.now();
}
}
Spring 将确保无论你调用配置类中 @Bean 注解的方法多少次,它都会返回正确的 Bean 实例(在幕后,结果只计算一次)。
你也可以在任何 Java 类中使用 @Bean 定义一些内容,并将其放入上下文中。请注意,这些是“轻量级 Bean”,许多机制在这些 Bean 上可能不会工作。
类路径扫描
当人们说 Spring 做了魔法,他们通常指的是这个机制。默认情况下,Spring Boot 根据应用程序类所在的包来扫描类路径,或者更确切地说,是带有 @SpringBootApplication 注解的类。
例如,它会搜索已知的 stereotypes(注解)如 component、service 或 repository,或者搜索 factory 方法如 @Bean 方法。搜索覆盖了类路径,因此也会扫描添加到项目中的依赖项!
在这个例子中,BlogmaniaApplication 位于 com.packt.springboot.blogmania 包的文件夹中,因此会扫描该包及其所有子包。com.packt.springboot.that 及其以下的所有内容都不会被扫描。如果需要,你可以通过添加带有正确参数的 @ComponentScan 来修改默认行为,但大多数情况下,这通常是不必要的。
看看下面的截图:

如果你愿意,你可以创建一个配置类来定义一个 Bean,并对其进行操作。我们将在第二章:构建 Spring 应用程序中提供更多关于配置类的实际内容。
额外工具 – Lombok 项目
在本节中,你将认识到一些 Java 中的样板代码,并定义Lombok 项目如何帮助你。这是一个 Java 库,通过自动集成到你的编辑器中,以构建工具来改进你的 Java。
样板代码是为了实现目标而必须编写的代码。这种代码几乎总是相同,或者非常相似,以至于你希望不必编写它,因为它很明显。
Java Bean
一个 Java Bean 必须遵循一定的模式才能被识别为 Java Bean。例如,所有属性都应该有 Getters 和 Setters(返回私有成员变量的值),尽管您可能省略 Setter,例如,当属性为只读时。这意味着对于只有 10 个字段的 data 类,您还必须编写 20 个方法,这些方法遵循正确的命名约定,仅存储或传递属性值。这是一个典型的样板代码示例。
Java 类
我们已经看到 Java Beans 在其结构周围有一些仪式,但普通 Java 类也是如此。例如,equals()、hashCode() 和 compareTo(...) 方法需要遵循一组规则。
当一个类覆盖其中之一时,它需要确保其他两个在 Java 对象契约方面按预期工作。
当实例 A 和实例 B 相等时,它们的 hashCode 需要相同。当您违反此契约时,在使用 Sets、HashMaps 等时,在运行时会出现奇怪的错误。另外,有一个真正打印出您类内容的 toString() 方法,在调试和日志记录中非常有帮助。
示例类
一个示例类看起来像这样:
public class BlogEntry {
private int id;
private String title;
private List<String> tags = new ArrayList<>();
private String text;
private boolean visible = true;
//[...]@Override
public void setVisible(boolean visible) {
this.visible = visible;
}
}
您可以看到,对于五个简单的数据字段,这需要很多代码。整个类定义大约有 44 行代码。如果您喜欢,您可以在 IDE 中生成很多这样的代码,但最好根本不使用这些代码!
前往 bit.ly/2QrH8cT 访问示例类文件的完整代码。
Project Lombok 来拯救
有一个名为 Project Lombok 的库,它根据注解生成代码。这使得开发 Java 软件变得更加容易。
Project Lombok 通过以下方式提供帮助:
-
Getters/Setters
-
equals()/hashCode() -
构造器
-
静态日志记录器
-
构造器类
要将 Lombok 添加到您的项目中,您需要添加以下依赖项(版本由 Spring Boot 管理):
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
如果您将 Project Lombok 添加到您的项目中,您可能需要在您的 IDE 中启用称为 注解处理 的功能,并安装 Lombok 插件。这在插件目录中可用。
Project Lombok 增强类
这里有一些具有相同功能的代码:
@Data
public class BlogEntry {
private int id;
private String title;
private List<String> tags = new ArrayList<>();
private String text;
private boolean visible = true;
}
@Data 注解组合了以下注解:
@ToString:
生成一个打印所有字段的 toString() 方法
@EqualsAndHashCode:
根据所有字段生成 equals() 和 hashCode()
@Getter:
为所有字段生成 Getter 方法
@Setter:
为所有非 final 或非 transient 字段生成 Setter 方法
@RequiredArgsConstructor:
生成所有字段作为构造器的构造器,这些字段需要值(未初始化的 final 字段、非 null 字段等)
活动:Project Lombok 在行动
目标
要创建一个使用 Lombok 的类。您将把类命令和 Bean 的名称存储在您自己的数据结构中。
场景
您应该使用 Beans 项目重用 Blogmania 应用程序,并在 IDE 中打开它。
完成步骤
- 在包含
Application类的包中创建一个BeanData类,包含所需的私有 String 字段和注解。
看一下下面的截图:

- 用每个 Bean 对应的相应数据填充数据结构和列表。
你可以使用getBean方法通过 Bean 名称从上下文中获取 Bean,例如。
- 打印
BeanData列表内容以获得所需的结果。
根据结果,你现在已经创建了一个只包含字段以及两个类级别注解的类,并且使用了由它生成的构造函数和获取器。现在,你再也不需要自己生成无聊的获取器、设置器等等了。
看一下下面的输出截图:

要了解详细步骤,请参阅本书末尾第 249 页的解决方案部分。
摘要
在本章中,你了解了 Spring 项目的历史以及为什么它被创建。你现在知道如何使用start.spring.io创建 Spring Boot 项目,以及如何启动它。还介绍了基本构建块和应用程序上下文。最后但同样重要的是,你看到了 Lombok 如何通过让你摆脱重复创建或编写相同代码来使你的生活变得更加轻松。
在下一章中,你将最终看到 Spring Beans 如何交互以及如何配置应用程序。
第二章:构建 Spring 应用程序
在本章中,你将学习如何与 Spring Beans 交互以及如何配置你的 Spring 上下文。这是每个 Spring 应用程序构建的基础。
在上一章中,你学习了如何创建 Spring Boot 项目,介绍了基本构建块和应用程序上下文。最后,你看到了如何利用 Lombok 来让你摆脱重复创建或编写相同代码的烦恼。
到本章结束时,你将能够:
-
组织 Spring Beans 之间的依赖关系
-
创建配置类以手动定义 Beans
-
组织配置属性
-
创建强类型属性类
Spring 中的依赖注入
在本节中,你将看到 Spring Beans 如何相互依赖以提供功能。Spring 容器使用上下文中的 Beans 将依赖项注入到其他管理实例中。这使得你可以编写简洁的类,只需表达它们的依赖项,Spring 负责其余部分。
存在一个单例,它使用HashMap和专用工厂来创建和连接实例。此外,还有基类可以检查类并利用某种单例来查找其他实例。
面向切面编程(用于拦截缺失的依赖项),classpath扫描(用于查找可用依赖项和注入点),以及控制反转(以控制应用程序生命周期)。
自动装配
依赖注入,这是一种引入控制反转以解决依赖项的模式的 Spring 中的术语,称为自动装配。当 Bean-A 获得对 Bean-B 的注入引用时,它就是自动装配的。自动装配仅适用于 Spring Beans,因此两者都需要被Spring 应用程序上下文所知晓。
看看下面的示例代码:
@Repository
public class ExampleBean {
@Autowired
private DataSource dataSource;
...
}
此示例代码定义了一个需要 JDBC 数据源来查询数据库的 Bean。正如你所见,字段不需要是公共的——甚至可以是私有的。Spring 通过使用反射来实现这一点,这是 Java 的一个标准特性。它允许你创建具有非常有限的公共接口的类,这使得用代码传达类的意图变得更容易。
你可能知道 JavaEE 注解,如@Inject。@Autowired是 Spring 中的对应注解。Spring 旨在对开发者友好,所以@Inject在 Spring 应用程序中也适用。然而,参数不同,所以如果没有特殊需要使用 JavaEE 版本,你应该坚持使用普通的 Spring 注解。
注入类型
在 Spring 中,你有几种方法可以访问上下文中的 Beans。你已经看到了字段注入,它被用来自动装配DataSource,但还有更多方法可以注入 Beans。
你可以在以下方面使用自动装配:
-
字段
-
属性
-
配置方法
-
构造函数
当 Spring 完全解析了一个 Bean 的依赖关系后,它会寻找一个注解了 PostConstruct 的方法并执行它。在那里,您可以执行一些最终的初始化,这将利用所有定义的依赖项。
字段注入
字段注入是获取另一个 Bean 的最简单方式。只需在字段上注解 @Autowired,Bean 创建后实例就会存在。
TestBase 和 TestClass 位于 bit.ly/2RSCHrf,在 blogmaniaexercise-solution 项目文件夹中。当您启动测试时,您会看到 MyConfiguration 的实例信息被打印了两次,尽管其中一个是在抽象基类中定义的。这有助于在基类中分组共享功能。
前往 bit.ly/2Mp9kcZ 访问 TestBase.java 文件的代码。前往 bit.ly/2CNj6WG 访问 TestClass.java 文件的代码。
属性注入
您也可以为设置器添加 @Autowired。这将触发所谓的 配置方法 机制。Spring 会在构造函数调用后,依赖项可用时调用该方法。
属性通常意味着您有一个遵循 Java Bean 标准的字段。例如,一个名为 foo 的 String 类型的属性将具有 setFoo(String …) 和 String getFoo() 方法。也存在只读和只写属性,其中之一可能缺失。boolean 字段有一个以 is 开头的 Getter,所以在这种情况下 isFoo() 将是正确的名称。
配置方法注入
Spring 能够在实例创建后、构造函数执行后调用一个方法。该方法可以有任何数量的参数,这些参数应可由 Spring 上下文中的 Bean 解析。该方法应返回 void;名称是任意的,方法不必是公共的。
您可以有多个这样的方法,并且它们将以未定义的顺序执行。
如您所见,这完全涵盖了属性注入。
构造函数注入
构造函数注入是 Spring 框架团队首选的注入 Bean 的方式。您只需创建一个带有所有必需 Bean 参数的构造函数,然后根据需要将这些值分配给您的字段。您也可能对提供的依赖项做些其他事情,例如调用它上的方法来获取一些数据,例如。因此,如果您在构造函数完成后不需要存储值,则不需要将其存储在某个地方。如果您有多个具有参数的构造函数,则需要通过添加 @Autowired 注解来指定其中一个构造函数。否则,这可以省略。
如你所回忆,我们使用 Project Lombok 来避免不必要的代码。这也可以在这里使用。只需将@AllArgsConstructor或@RequiredArgsConstructor添加到你的类中,Spring 将自动使用这个生成的构造函数。你不需要自己创建一个,甚至不需要在任何地方添加@Autowired。
循环依赖
当你有两个或更多 Spring Bean 形成依赖循环时,如果你在所有地方都使用构造函数注入,Spring 可能会在创建 Spring 上下文时遇到问题。
示例依赖关系:
Bean-A => Bean-B => Bean-C => Bean-A
当所有 Bean 都使用构造函数注入时,Spring 没有机会解决这个问题,因为实例不能在没有其他实例的情况下实例化。
有一些方法可以解决这个问题,但首先你应该重新考虑这真的是正确的解决方案;大多数循环依赖都是设计不良的症状。如果无法解决这个问题,你可以通过以下方式配置 Spring 来解决这个问题:
-
创建动态代理:
-
为了做到这一点,你需要至少更改其中一个构造函数(它不能由 Lombok 生成)。
-
然后,你可以通过在类型上添加
@Lazy来标记构造函数参数,这告诉 Spring 在构建 Spring 上下文时创建类时不需要这个依赖。 -
当第一次访问时,Spring 会在从上下文中获取的真实实例前面创建一个代理实例,该实例作为门面工作。
-
-
对于第一个类使用字段/配置方法注入:当你使用这些注入风格时,Spring 可以在满足其他依赖关系之前延迟解析 Bean。你可以在 Spring 在启动时打印出的错误信息中看到第一个类。
要将依赖标记为“懒加载”,你只需在类型前或后放置@Lazy 注解。
看一下以下示例代码:
public BlogRepository(@Lazy List<BlogEntry> db) {...
限定 Bean
如果你有多于一个相同类型的 Bean,那么 Spring 将无法检测出应该分配哪个实例。在这种情况下,应用程序将拒绝启动并显示一个错误信息,解释出了什么问题。
默认情况下,Spring 会尝试将参数/字段的名称与相应类型的 Bean 的名称匹配,但如果你需要一个特定的 Bean 且名称不匹配,你只需添加一个注解来手动解决。
例如,你可以使用以下代码从上下文中获取myDate Bean:
@Qualifier("theDate")
private LocalDateTime contextStartTime;
如果需要,你可以直接访问应用程序上下文并从那里通过类型或名称获取 Bean。你可以实现ApplicationContextAware接口,Spring 将为你提供应用程序上下文的引用。这应该只在极少数情况下使用,因为在大多数情况下,“静态绑定”应该足够了。
Bean 作用域
到目前为止,你已经看到了如何定义 Spring Bean,你也看到了通常在应用程序启动时创建一个实例。但还有其他用例,这种选择并不合适。
要指定作用域,只需将@Scope注解添加到 Bean 定义中:
@Repository @Scope(SCOPE_SINGLETON)
public class BlogRepository {
Bean 的默认作用域是singleton,这意味着容器中有一个 Bean 的实例,并且这个实例是由 Spring 上下文返回的。作用域引用本身只是一个字符串。
第二个标准作用域是prototype,每次从上下文中请求具有该名称的 Bean 时都会返回一个新的实例。因此,如果你使用原型作用域定义了 Bean-A,而 Bean-C 和 Bean-D 都有一个自动装配的字段为 Bean-A,那么它们都将获得自己的实例。当 Bean 具有某种不应与其他 Bean 共享的状态时,这很有用,例如缓存等。
还有一些其他作用域,如request和session,这些是 Spring WebMVC 特有的,将在后面介绍。你甚至可以定义自己的作用域,但这是一个高级章节,我们不会在本书中介绍。
Spring 在几个地方提供了作用域的静态常量。常规作用域在ConfigurableBeanFactory类中定义,而特定于 Web 应用程序的作用域在WebApplicationContext中定义。
配置类
你已经看到了如何通过注解类定义来声明 Spring Bean。这是最常见的用例,但有时在创建 Bean 的过程中需要更多的控制。这就是配置类发挥作用的地方。它们基本上是具有 Bean 工厂方法的类。这可以用来创建代码库外定义的类的 Bean,例如,根据某些配置值返回接口的特定实现。
配置类也是 Spring Bean,因此你可以在其中使用自动装配的依赖项,但应避免构造函数注入。
这就是配置类的样子:
@Configuration
public class MyConfiguration {
@Bean
public Date theDate(){
return new Date();
}
…
}
本节提供的源代码中的MyConfiguration类包含更多具有相应 JavaDoc 的 Bean。
配置类的默认行为是从 Spring 上下文中返回正确的实例。因此,当你在一个配置类中自动装配并调用一个带有@Bean注解的方法时,该方法可能不会执行,而是返回 Spring 上下文的结果。即使在同一个配置中调用@Bean方法也是如此。将配置类作为“常规”Bean 的依赖项在技术上可行,但并不常见。大多数项目使用专门的 Factory Bean 来处理单个类型或一组类型。
利用 Bean 依赖关系
目标是利用 Bean 依赖关系将数据传递到仓库。
完成步骤如下:
-
打开 IDE 和本节的
BlogMania应用程序(bit.ly/2QpUDd1)。 -
将
BlogService和BlogRepository移动到com.packt.springboot.blogmania.blogentries.service包中。
看看下面的截图:

右键单击 blogentries 包,并从那里创建新的包。然后,将类拖放到包中。
您现在已经创建了自己的 Spring Bean,并在业务场景中使用它来解决任务并将数据保存在内存中。请查看下面的输出截图,如下所示:

前往 bit.ly/2OeUPtQ 访问 BlogmaniaApplicationTests.java 文件的代码。
Spring Boot 应用程序的配置
在上一节中,您了解了如何连接 Spring Beans 以及可以使用哪种机制来实现这一点。
当您编写应用程序时,您将遇到需要可配置性的应用程序方面。如果您使用数据库,您将拥有不同的数据库配置;也许某些功能是启用或禁用的,或者类似的情况。
在 Spring Boot 应用程序中,您不需要自己处理这个问题。Spring Boot 有一个非常复杂的系统,可以轻松地配置您的应用程序,即使在复杂场景下也是如此。
在本节中,您将创建针对不同环境的配置文件,并使用属性和 YAML(YAML 不是标记语言)文件配置应用程序。您还将看到属性搜索顺序的实际操作,您可以使用它以多种方式配置您的 Spring Beans。
配置文件
Spring 应用程序有一个简单的机制来告诉您的应用程序它在不同的环境或模式下运行。应用程序有“活动配置文件”,它可以影响应用程序的配置或行为。配置文件是一系列单词的有序列表。这些单词中的每一个都是一个配置文件。如果没有给出任何内容,则默认为默认配置文件。
例如,您可以使用额外的命令行参数 --spring.profiles.active=peter,dev,postgres 来启动应用程序。如您所见,每个配置文件之间由逗号分隔。
这意味着配置文件 peter、dev 和 postgres 都是激活的。在接下来的幻灯片中,您将看到您可以使用这些配置文件做什么。
条件 Beans
当您需要在配置文件激活时启用或禁用某些 Beans 时,您可以在 Bean 定义中使用一个简单的注解。这可以是配置类、注解的类定义或 @Bean 注解的方法。
为了做到这一点,您只需要在类或方法定义中添加 @Profile 注解。以下代码片段是从 MyConfiguration 类中复制的(bit.ly/2oYUl05),并配置了 theConfiguredDate Bean,这是一个在开发模式下的固定时间点(可能是用于测试)以及当未设置开发配置文件时的当前日期和时间。由于我们不能有两个方法具有相同的名称,我们使用了 @Bean 注解的一个特性来用 theConfiguredDate 覆盖 Bean 的默认名称:
@Profile("dev")
@Bean("theConfiguredDate")
public Instant theConfiguredFixedDate(){
return Instant.ofEpochMilli(1527854742);
}
@Profile("!dev")
@Bean
public Instant theConfiguredDate(){
return Instant.now();
}
@Profile 注解也可以接受一个配置文件列表,在这种情况下,任何足够的给定配置文件都可以触发 Bean 的创建。这意味着配置文件是以隐式的 or(或)方式评估的,而不是 and(与)方式。
示例
@Profile("dev", "profile2", "profile3")
在 Bean 定义上还有一个 @Primary 注解,可以用来标记一个 Bean 作为给定类型的默认 Bean。当有多个 Bean 匹配该类型时使用。要选择其他 Bean 中的一个,你需要添加带有正确 Bean 名称的 @Qualifier 注解。
利用条件 Bean 进行各种实现
目标是利用条件 Spring Bean,这可以用来在例如不同实现之间切换(或选择)。在开始之前,请重用现有项目。前往 bit.ly/2oYUl05 访问 blogmania 目录的代码。
完成的步骤如下:
- 创建一个名为 Randomizer 的接口,其中包含一个返回 double 类型且没有参数的方法。
查看以下截图:

-
创建一个实现,使用
Random类的nextDouble()方法返回一个随机数,并将其作为一个 Bean。 -
创建一个始终返回,例如,
3的实现。使其成为在测试配置文件活动时激活的 Bean。它应该替换或覆盖其他 Bean。
你已经看到了如何覆盖测试中的 Bean,这有助于你编写简洁和可靠的测试。 查看下面的输出截图,如下所示:

前往 bit.ly/2x8v1s5 访问 BlogmaniaApplication.java 文件的代码。
Spring 配置文件
Spring Boot 应用程序默认由 Spring Initializr 创建,在资源文件夹中有一个空的 application.properties 文件。这是默认配置文件。你可以在属性文件格式中写入所有默认配置,我们将在稍后更详细地介绍。
在 application.properties 文件旁边,你可以放置更多基于活动配置文件加载的配置文件。
你可以在那里创建一个 application-dev.properties 文件,并且它只有在 dev 配置文件活动时才会被加载。我们将在专门的章节中很快介绍属性值应用的顺序。
属性文件
属性文件在 Java 生态系统中非常常见,甚至比现在普遍不喜欢的 XML 文件还要古老。
格式很简单:
-
值以
Key=value的形式存储,其中键通常由小写点分隔的单词组成,而=后的值可能包含空格。多行值需要在下一行的末尾加上一个\以表示行继续。 -
注释行以
#开头,并被解析器忽略。 -
你可以有空白行。
使用基于配置文件的机制,你可以设置或覆盖值。
一个示例配置看起来像这样:
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost/test
server.port=9000
属性文件应该始终使用 ISO-8859-1 编码,否则你可能会得到奇怪的结果。在测试中可能工作,但当你启动应用程序或反之亦然时可能会出错。
YAML 文件
YAML(YAML Ain't Markup Language)文件是创建 Spring Boot 配置文件的另一种语法。
格式基本上由以下定义:
-
值以
key: value的形式存储。 -
YAML 支持
子键,这些子键只是简单地缩进。同一级别的键会组合在一起,直到下一个键的缩进更少。 -
列表通过使用一个子组来支持,其中每一行都以短横线开头。
-
注释以
#开头。
属性示例在 YAML 中看起来像这样:
spring.datasource:
driverClassName: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost/test
server.port: 9000
你可以看到点号仍然作为组分隔符使用,但如果你有多个具有相同父组的键,你可以使用这个较短的版本。这对于 Spring 配置值的深层键结构来说特别方便。
多配置文件 YAML 文件
使用 YAML 文件,你可以使用一种特殊格式来定义仅在给定配置文件中激活的值。
要这样做,你使用三个短横线后跟spring.profile键以及此块应该激活的配置文件。在这里,你甚至可以使用否定语法来设置当配置文件未设置时的值:
server:
port: 9001
my.value: dev
---
spring:
profiles: dev
my.value: dev
---
spring:
profiles: production
server:
port: 0
这种高级机制在 YAML 标准中指定,但某些 YAML 验证器可能会将其标记为不再有效的 YAML 文件。解析器在短横线处截断 YAML 文件,并在内部将它们作为不同的(虚拟)文件处理。如果你在源代码上有静态代码检查器,它们可能会对此语法提出异议。
代码检查器是一个检查文件中的语法错误或常见错误的程序。许多文件类型和编程语言都有可用的代码检查器。
外部配置位置
我们讨论的文件位于类路径中,但你可能需要一些主机或环境特定的配置,你不想将其提交到版本控制中。例如,如果你的安全部门不高兴,当生产数据库凭据托管在广泛可访问的 Git 仓库中时。
Spring 文档列出了 17 种不同的设置或覆盖配置值的方法。为了简洁起见,我们将省略一些高级方法,但如果你想查看所有方法,请访问docs.spring.io/spring-boot/docs/current/reference/html/boot-features-external-config.html。
当 Spring 查找配置值时,它会搜索这个列表以找到该值。一旦找到值,搜索就会停止。
默认情况下,以下位于 JAR 文件之外的文件将在当前工作目录中搜索:
-
命令行参数
-
来自
SPRING_APPLICATION_JSON的属性(嵌入在环境变量或系统属性中的内联 JSON) -
Java 系统属性(使用
-Dmy.config.key=value设置) -
操作系统环境变量(通常与 Docker 容器一起使用)
-
在你打包的 JAR 文件外的特定配置文件的应用程序属性(
application-{profile}.properties和YAML变体) -
包含在你 JAR 文件内的特定配置文件的应用属性(
application-{profile}.properties和YAML变体) -
在你打包的 JAR 文件外的应用程序属性(
application.properties和YAML变体) -
包含在你 JAR 文件内的应用程序属性(
application.properties和YAML变体) -
在你的
@Configuration类上的@PropertySource注解(用于加载额外的属性文件,但不包括 YAML!)
意外地是,通过 @PropertySource 加载的文件是最后被评估的,但其余的部分非常合理,并允许你将默认值打包到你的应用程序中,并覆盖在主机或容器上运行的特定实例。
配置文件中的占位符
在配置文件中,你可以使用一个非常简单的变量替换机制,该机制在所有配置值加载时被评估,因此即使替换引用的是同一文件中定义的值,它仍然可以被覆盖,例如,由配置文件或系统属性。该机制适用于属性和 YAML 文件:
server.port: 9000
spring:
application:
name: MyApplication
info:
description: ${spring.application.name} is so nice
这将设置应用程序的描述为 spring.application.name 设置的内容加上一些很棒的东西。语法看起来很像 SpEL(Spring 表达式语言),但它仅限于简单的变量替换。SpEL 将很快被简要介绍。
访问环境值
我们已经看到了许多定义配置值的方法,但我们还没有使用它们。Spring 提供了多种访问这些值的方式。最老的方式是让 Spring 自动装配一个具有 Environment 类型的实例到你的 Bean 中。这提供了使用代码访问配置值的方法。
查看下面的示例代码:(来自 BlogService.java 的摘录):
@Autowired
public void init(Environment env) {
log.info("my.config.value={}",
env.getProperty("my.config.value",
Integer.class, 42));
}
log.info(...) 是由 Project Lombok 的 @Slf4j 注解提供的,它为你创建了一个静态的配置了名为 log 的类的日志记录器。
注解字段
直接使用 Environment 实例有点繁琐,因此 Spring 开发者创建了一个名为 @Value 的另一个注解,它能够从环境中访问数据并将其分配给一个字段。该字段可以是任何开箱即用的类型,如原始类型或其他默认的 Java 类,或者你可以提供一个 ConversionService 或 PropertyEditors,这些是 Spring 用于将数据转换为字符串以及从字符串转换回数据的机制。能够创建自己的转换器超出了本书的范围。
查看下面的示例代码:(来自 BlogService.java 的摘录):
@Value("${my.config.feature.flag:false}")
boolean featureFlag;
这使用键my.config.feature.flag加载值并将其转换为boolean值。当配置键未设置时,默认值为 false。因此,语法是@Value("${KEY:DEFAULT}"),其中包含默认值,以及没有默认值的@Value("${KEY}")。默认值是配置文件中期望的默认值的字符串表示形式。
列表是使用方括号和逗号分隔的值设置的,例如[1,3,5,7,9],这是一个奇数整数的列表。
Spring 表达式语言
Spring 表达式语言(SpEL)与 2006 年为 Java 服务器页面(JSP)创建的统一 EL 类似。它支持查询和操作 Spring 上下文。你可以将其视为一种轻量级脚本语言。如果你的代码使用了SpelExpressionParser,则可以使用 SpEL。
SpEL 非常强大,其中一些特别有用的特性如下:
-
文字表达式
-
访问 Spring Bean 方法和属性
-
布尔运算
-
调用静态方法
-
过滤集合
让我们看看一个示例,该示例在blogService Spring Bean 上调用getTimeMessage()方法。结果字符串随后将被转换为大写:
@Value("#{blogService.timeMessage.toUpperCase()}")
String message;
getTimeMessage()方法符合 Bean 标准,将timeMessage属性表示为只读。这允许我们省略 get 前缀。
配置属性类
随着你的应用程序的增长,你也将拥有越来越多的配置选项。使用@Value注解,你可以访问这些选项,但将它们散布在应用程序的各个地方可能会导致大量的重复,甚至可能产生工作,例如当类型、键或默认值更改时。然后,你必须更新所有出现的地方,这并不是首选的。
为了使 IDE 支持属性,你需要添加这个依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
现在,你可以使用@ConfigurationProperties注解创建一个配置属性类。该类本身应该有属性来存储数据,如果你愿意,可以在这里定义默认值:
@Data
@ConfigurationProperties("blogmania")
public class MyProperties {
String message;
int myInt=4;
SubProperties subs = new SubProperties();
@Data
public static class SubProperties{
boolean iLikePizza=true;
}
}
要创建 getter 和 setter,我们使用 Lombok。正如你所见,你也可以有深度嵌套的类来存储和分离你的数据。@ConfigurationProperties注解应该有一个参数,它是属性的前缀路径。有了这个,你就有如下属性:
blogmania.message
blogmania.my-int
blogmania.subs.i-like-pizza
为了使这生效,你必须做两件事。首先,你必须在一个配置类定义中注册带有@ConfigurationProperties注解的类,如下所示:
@EnableConfigurationProperties({MyProperties.class})
通过在@ConfigurationProperties旁边添加@Component或将一个带有@Bean注解的方法添加到配置类中,使配置类成为一个 Bean。
你可能已经注意到,iLikePizza 属性现在在配置文件中是 kebab-case 格式。这样做的原因是,这是推荐编写属性的方式。Spring 支持 ConfigurationProperty 类的宽松绑定,因此你也可以使用 camelCase 或 snake_case,但这是不推荐的,因为在某些情况下,这可能会导致问题。
活动:检查自动装配和配置
目标
为了练习自动装配和应用配置,创建几个类。
场景
你应该重复使用之前小节中使用的项目,并在 IDE 中打开它。这是一个简化版的常见任务,其中你从配置中读取内容并将其应用于输入。
你将创建一个问候服务,该服务使用配置的后缀和提供的输入来创建问候,例如 Packt 向世界问候。
完成步骤
- 创建一个类来读取后缀的配置值。
请查看以下截图:

-
创建问候服务。
-
在服务中注入所需的类。
-
创建一个公共方法来创建问候。
-
在 Application 类中,添加一个执行问候方法并打印结果的 config 方法。
结果
默认消息应该是 INPUT+ 向世界问候(INPUT 是问候方法中输入的问候者的名字),但位置(世界)应该是可定制的,因此你也应该能够创建,例如,INPUT+ 向柏林问候。位置应该使用 GreetingProperties 读取。要触发此代码,请使用 Application 类,在此处自动装配服务,并使用示例数据执行它。
请查看下面的输出截图:

要参考详细步骤,请参阅本书末尾的 解决方案 部分,第 250 页。
摘要
在本章中,你看到了如何将 Spring Beans 注入其他 Beans,以及如何在注入器无法唯一标识的情况下帮助 Spring。你还看到了如何使用配置文件和配置文件配置你的应用程序。
在下一章中,我们最终将开始探讨我们可以测试 Spring Boot 应用程序的方法。
第三章:测试 Spring 应用程序
在本章中,我们将学习测试 Spring 应用程序的不同方法。本节将重点介绍单元测试以及它们与其他测试类型的不同之处。在研究了一些理论知识后,你可以继续阅读实践部分,在那里你应该开始独立编写简单的单元测试。
到本章结束时,你将能够:
-
为 Spring 应用程序创建单元测试
-
创建内部启动应用程序部分的集成测试
-
利用丰富的 Spring 工具集进行测试
-
分析不同的测试类型
应用单元测试
在前面的章节中,你看到了如何创建 Spring 应用程序以及如何在应用程序内部添加组件之间的依赖关系。
在本节中,你将了解如何将应用程序的类作为常规类或使用一些 Spring 支持的依赖项连接方式来测试。自己编写这些测试非常重要,这样你才能知道你所编写的代码是否有效。UI 测试有时由专门的 QA 部门编写。
通过使用这些虚假依赖,你可以模拟其他类的行为或验证依赖是否以正确的方式被调用。有关更多信息,特别是关于虚假依赖和模拟的内容,你可以查看马丁·福勒的文章,链接为martinfowler.com/articles/mocksArentStubs.html#TheDifferenceBetweenMocksAndStubs。
单元测试
在软件开发中,你可以编写多个测试层。单元测试是最基本的测试,它测试软件的小部分,通常运行非常快。它们对于验证你所编写的功能在基本层面上是否有效非常重要。
例如,考虑一个用于某些账簿应用程序中添加两个数字的方法。你想要确保这个方法能够处理各种错误和输入。这可以通过使用单元测试轻松完成。实际上没有设置,它们可以在短时间内测试很多内容。你甚至可以测试非常详细的事情,比如当你添加两个非常大的数字时整数溢出。
测试金字塔,突出了大多数测试应由单元测试组成:

测试类型 – 并列
测试术语没有固定的定义,但除了单元测试之外,还有两种常见的测试类型:集成测试和 UI 测试。根据项目不同,在这三种类型之间还有其他测试类型:
看一下以下表格,它突出了主要概念:

在编写测试时,请确保它们是可靠的。尽量避开随机数、基于时间的断言等。在可能或必要时,尽量提供一组固定的数字或您自己的“时钟”,以便您可以可靠地测试您的代码。没有什么比由于边缘情况(例如,日期变化或不可预见的随机数)而偶尔失败的测试更糟糕了。有一些输入生成器可以提供符合提供的一组要求的随机数据,但总的来说,我建议坚持使用可重复的、众所周知的(并且经过深思熟虑的)输入。
使用 Plain JUnits 编写单元测试
Spring Initializr 已经添加了测试依赖项,并为您创建了一个空白的测试类。我们将通过基于Spring Boot 应用程序配置的代码测试BlogRepository来向您展示如何编写单元测试。编写测试的最基本方式是仅使用 JUnit 并测试类本身。
要设置一系列博客条目,我们必须向BlogRepository类添加一个 setter,或者我们可以创建一个可以在测试中使用的构造函数。或者,我们可以简单地通过向BlogRepository添加@AllArgsConstructor注解来实现构造函数注入。当然,这个类非常简单,通常您不会使用内存中的列表作为数据源,所以这个例子有点人为地制造,以展示单元测试:
public class BlogRepositoryJUnitTest {
@Test
public void testAdd(){
ArrayList<BlogEntry> db = new ArrayList<>();
BlogRepository repository = new BlogRepository(db);
BlogEntry entry = new BlogEntry();
repository.add(entry);
assertThat(db).contains(entry);
}
}
此测试创建了一个由我们控制的ArrayList实例的BlogRepository。然后,我们可以添加BlogEntry并验证它已被存储。我们正在使用来自AssertJ的assertThat(),这是一个非常棒的断言框架,它已经被 Spring 作为依赖项添加。
使用 Mockito 支持编写单元测试
默认情况下,Mockito,一个模拟框架,也包含在依赖项中,并且它还有一个很好的 Spring 集成。
您只需使用@RunWith注解添加运行器,然后可以将您想要测试的类定义为字段,并用@InjectMocks注解标记它。所有您想要使用的依赖项都可以通过@Mock注解添加。它们将在每个测试中重新创建,并包含类的模拟版本。在您的测试中,您可以使用Mockito.when(...)定义调用行为。您还可以验证例如,某些调用是否已使用给定的参数进行。这是通过使用Mockito.verify(...)方法完成的。注入的工作方式与 Spring 相同,因此当 Spring 能够自动装配依赖项时,Mockito 很可能也能做到:
@RunWith(MockitoJUnitRunner.class)
public class BlogRepositorySpringTest {
@Mock
List<BlogEntry> db;
@InjectMocks
BlogRepository repository;
@Test
public void testAdd(){
BlogEntry entry = new BlogEntry();
repository.add(entry);
Mockito.verify(db).add(eq(entry));
}
}
有关 Mockito 的详细信息,请访问其网站site.mockito.org/。
创建单元测试
目标是为类创建小的单元测试。现在你将为你自己的 BlogService 类编写单元测试。编写测试对于确保你的代码不仅现在能工作,而且在项目发展时也能继续工作非常重要。因此,在专业软件开发中,编写单元测试和其他测试类型非常重要。完成步骤如下:
- 打开 IDE 和
BlogMania应用程序进行本节。
看看这个截图:

-
打开
BlogService类,将光标放在类名上,然后按 Shift-CTRL-T,这将打开一个菜单以创建测试类。 -
选择 JUnit 作为
test-library,在底部的框中选择保存方法,然后按 OK。IntelliJ 将创建并打开文件。 -
为服务和其依赖项添加 Mockito 注解和字段。
-
为
BlogService的公共方法创建一个简单的测试。例如,检查条目是否已存储,某些方法是否在依赖项上被调用,或者输出是否以给定的字符串开头。
所有数据都存在,并且已经在 BlogRepository 上调用了 add 方法。博客的标题缺失,你期望抛出 IllegalArgumentException。你可以在测试方法中添加 try-catch 块,或者检查 @Test 注解,因为它有相应的属性。
前往 bit.ly/2p4Wc3C 访问 BlogServiceTest.java 文件的代码。
前往 bit.ly/2NDu8SQ 访问 BlogmaniaApplicationTests.java 文件的代码。
- 添加另一个测试,检查在保存条目之前未设置日期时,是否已添加日期。
现在,你已经为 Spring 类创建了第一个简单的单元测试。
看看你的结果截图:

集成测试
在上一节中,你看到了如何使用单元测试测试 Spring 应用程序。
虽然这是测试应用程序的一种非常重要的方式,但应该用更多应用程序的基础设施和可能存在的周围服务来测试一些事情。你可能想测试你发送到数据库的 SQL 是否工作,或者当所有 Spring 的机制都就绪并激活时,你的 REST API 是否生成正确的 JSON 格式。
在本节中,你将创建不同类型的集成测试,并初步了解 Spring 的测试支持。
JUnit 有一个测试运行器的概念,它处理这个特定类中测试的执行方式。Mockito 使用它来在测试执行之前创建模拟实例。
要为 Spring 集成测试编写测试,你只需要将 SpringRunner 添加为 JUnit 测试运行器,并将 @SpringBootTest 注解添加到测试类中。这使 Spring 能够在测试中启动应用程序,并使其对你可用。
@SpringBootTest 注解
看看这个示例代码:
@RunWith(SpringRunner.class)
@SpringBootTest
public class BlogmaniaApplicationTests {
@Test
public void contextLoads() {
}
}
这个测试启动并仅用于检查你是否在配置 Spring 时犯了错误。原因是这只有在 Spring 上下文无法启动时才会失败,例如,由于缺少类或 Bean 定义。
集成测试通常比单元测试慢得多。尽量减少集成测试的数量。不要试图使用 ITs 测试每个方面。负测试应作为单元测试进行。尽量编写主要是“快乐路径”测试,以验证基本功能。当所有测试运行时间过长时,它们在开发过程中的反馈机制价值就会降低。
测试 Bean
当我们重新访问 BlogRepositoryTest 并将其重新创建为一个集成测试时,它看起来如下所示:
@RunWith(SpringRunner.class)
@SpringBootTest
public class BlogRepositorySpringIntegrationTest {
@Autowired
List<BlogEntry> db;
@Autowired
BlogRepository repository;
@Test
public void testAdd() {
BlogEntry entry = new BlogEntry();
repository.add(entry);
assertThat(db).contains(entry);
}
}
如你所见,我们现在必须在 db-bean 中验证测试的结果,因为这样我们就无法验证 BlogRepository 上的调用。
这意味着我们必须手动检查调用是否产生了预期的效果。
然而,优势在于 Spring 提供的所有机制都到位了。因此,当有代理在 Bean 附近或 Bean 有特殊作用域时,例如,你可以使用这些测试来测试它。你也可以使用这些类型的测试来测试你的 Web 应用程序,但由于时间限制,这将在本书中不涉及。
Mockito 已经被 Spring 添加,这是有原因的。我们可以在测试中简单地模拟 Bean,并验证它们的调用或配置 Bean 调用的效果:
@RunWith(SpringRunner.class)
@SpringBootTest
public class BlogRepositorySpringIntegrationMockTest {
@MockBean
List<BlogEntry> db;
@Autowired
BlogRepository repository;
@Test
public void testAdd() {
BlogEntry entry = new BlogEntry();
repository.add(entry);
Mockito.verify(db).add(eq(entry));
}
}
通过使用 @MockBean 标记列表,我们利用了 Spring 的 Mockito 支持。我们现在得到了完整的 Spring 上下文,但这个 Bean 已经被替换为模拟版本。我们可以根据测试需要对其进行配置。这是一个非常方便的功能,使我们能够轻松编写测试,否则这将非常复杂。
还有其他一些注解,如 @SpyBean,它将代理包装在原始 Bean 上,以便你可以验证是否执行了某些调用,同时保持旧功能完整。
配置测试上下文
当你只使用 @SpringBootTest 注解时,你会得到完整的 Spring 上下文。这并不总是你测试所需要的。在这种情况下,你可以为你的测试配置不同的 Spring:
@RunWith(SpringRunner.class)
@SpringBootTest(
properties = {"my.property=test","spring.application.
name=systemUnderTest"},
classes = {BlogRepositorySpringIntegrationCfgTest.
TestConfigClass.class})
// […]
public List<BlogEntry> db(){
return new LinkedList<>();
}
}
}
前往 bit.ly/2NGKolQ 访问 配置测试上下文代码示例 的完整代码。
在这个例子中,我们正在为 Spring 上下文配置两个属性,并且阻止整个上下文启动。我们将 TestConfigClass 提供给 @SpringBootTest 注解,该注解将用于启动应用程序上下文。在这种情况下,我们只从服务包加载 Bean,并提供了我们自己的 db-bean,在这种情况下使用了一个 LinkedList。如果 ComponentScan 也添加了一个 db-bean,那么我们的本地 Bean 将覆盖扫描中的 Bean。正如你所看到的,这为你提供了很多修改和监控应用程序代码在测试时行为的方法。
活动:编写集成测试
目标
为了编写应用程序的集成测试。
场景
你应该重用本章中使用的项目,并在 IDE 中打开它。
完成步骤
- 在自己的基础上创建一个新的集成测试类。
查看这个截图:

-
为服务的方法创建各种测试。
-
创建一个愉快的路径测试。
-
为有趣的参数组合创建测试。
-
修复服务代码。
对于那些高级用户:
创建以下类型的测试,查看它们,并分析它们的优缺点(省略你在 步骤 2 中创建的类型):
-
没有使用 Mockito 的单元测试
-
使用 Mockito 的单元测试
-
没有模拟的集成测试
-
使用
MockBeans的集成测试 -
使用
SpyBeans的集成测试
前往 bit.ly/2MqhUZ4 访问 BlogService 测试文件的代码。要参考详细的步骤,请查看本书末尾第 251 页的 解决方案 部分。
结果
通过访问 bit.ly/2MqhUZ4 并转到源代码的 com/packt/springboot/blogmania/blogentries/service/activity 包,可以得到生成的代码。查看这个截图:

摘要
在本章中,你看到了如何测试 Spring 应用程序。你体验了单元测试的简单性和速度以及集成测试的表达力和强大功能。
在下一章中,我们将开始探讨 Web 应用程序开发,这样你就可以创建真正重要的应用程序。
第四章:MVC 模式
今天,我们将学习如何在 Spring 中构建一个基于 Web 的应用程序,该应用程序使用网页与用户交互。作为一个例子,我们将使用一个简单的博客应用程序。为此,我们首先将查看模型-视图-控制器(MVC)设计模式以及我们如何从中受益。
MVC 设计模式是一个非常常用的应用程序设计模型。该模型将应用程序分解为三个相互关联的部分。这样做是为了减少创建具有用户界面的面向对象应用程序所需的时间。该模型允许不同模型、视图和控制器之间的解耦,从而促进代码的重用和应用程序不同部分的并行开发。
到本章结束时,你将能够:
-
定义模型-视图-控制器(MVC)模式及其优点
-
解释模型、视图和控制器的作用
-
区分基于请求和基于组件的 MVC
-
构建你的第一个真实生活 Spring Web MVC 应用程序
介绍 MVC 模式
当使用 用户界面(UI)构建 Web 应用程序时,如果涉及多个开发者,开发可能会变得繁琐。此外,如果太多关注点混合在代码的某些部分,维护也可能变得困难。
一个名为 MVC 的设计模式解决了这个问题。通过分离渲染和操作应用程序数据的关注点,它允许团队中的多个程序员并行地工作在不同的应用程序方面。当一位开发者专注于视图时,另一位开发者能够实现业务逻辑。
还有模式可以解决常见的软件开发问题。在 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著的《设计模式:可复用面向对象软件元素》一书中,介绍了一套流行的这些模式。
如果你感兴趣 MVC 模式的历史,你仍然可以在 Trygve M H Reenskaug 的页面上找到 MVC 模式的原始描述:heim.ifi.uio.no/~trygver/themes/mvc/mvc-index.html。此外,原始的 MVC 报告可在 folk.uio.no/trygver/2007/MVC_Originals.pdf 找到。
MVC 组件
定义了以下三个组件:
-
模型:应用程序状态或数据。
-
视图:渲染应用程序数据。
-
控制器:在模型、视图和外部世界之间进行调解。
比较模型、视图和控制器
请看以下表格,它突出了主要概念:

交互
现在我们已经学习了 MVC 组件,让我们看看它们是如何相互作用的:

典型的交互包括以下步骤:
-
客户端向控制器发送请求。
-
控制器操作模型。
-
模型更新视图。
-
视图被渲染并发送到客户端。
这些步骤说明了关注点的分离,因为每个组件都有一个非常明确的任务要完成。
MVC 使用另一种设计模式,称为观察者模式。它描述了主题(模型)如何更新多个观察者(视图或其部分)关于已进行的更改。
如果你想了解更多关于观察者模式的信息,你可以在我们之前提到的书中找到更多信息,即《设计模式:可重用面向对象软件元素》,另一个好的起点是关于此模式的条目springframework.guru/gang-of-four-designpatterns/observer-pattern/。
MVC 模式的优势和劣势
MVC 模式的优势包括以下内容:
-
同时开发:接口定义良好,并利用具有不同技能的开发者。
-
高内聚:将属于一起的代码分组,有助于维护、改进和重构。
-
多个视图:为不同的媒体实现单独的视图,并保持业务逻辑的单个实现。
-
松散耦合:组件之间共享的知识量很小,并且更改可以限制在实现的孤立部分。
MVC 模式的劣势包括以下内容:
-
导航源代码:源代码可能会变得非常分散。
-
多种实现:开发者必须跟踪多种实现。
-
理解模式:开发者必须理解该模式。
基于请求与基于组件的 MVC 对比
有不同的方法来实现 MVC 模式。
在基于请求的 MVC 方法中,开发者必须自己处理传入的请求。这意味着数据必须手动转换和验证。
另一方面,在基于组件的 MVC 应用程序中,框架将负责处理并构建与视图渲染的部分相似的组件:

虽然基于请求的 MVC 架构需要大量的样板代码,但它让你对整个过程和输出有很好的控制。当你对视图有非常复杂的要求时,这种方法可能是正确的选择。
另一方面,基于请求的方法引入了更多的复杂性,而基于组件的方法增加了开发者对组件定制的负担。
Spring Web MVC 基础知识
现在我们已经了解了模型-视图-控制器模式,我们将现在看看 Spring Web MVC 如何利用这个模式来启用 Web 应用程序的开发。
当我们谈论 Web 应用时,我们通常是指视图在浏览器中渲染。但在深入细节之前,我们将简要讨论两种不同的前端实现方式。
Single-Page Applications Versus Multi-Page Applications
实现 Web 应用的客户端(也称为“前端”)有不同方式。今天最常用的架构是单页应用(SPA)。请求只更改视图的部分。
相比之下,多页应用(MPA)为每个请求渲染一个新的页面。
在 MPA 中,视图的 HTML 在服务器上渲染,然后发送回浏览器。用户的每个操作都会导致向服务器发出请求,服务器会响应另一个完整页面或视图。
单页应用(SPA)与多页应用(MPA)
看一下以下表格,它突出了主要概念:


在本章中,我们将关注如何构建多页应用。接下来,你将了解如何设计和实现一个可以构建 SPA 后端 API 的架构。
Java Servlet API
我们现在将揭示 Java Servlet API,它为 Java 生态系统中的任何类型的 Web 应用奠定了重要基础。
根据知识水平,你可以选择专注于这个子节或继续到下一个节。
Java Servlet API 是一种旨在处理针对所谓容器发出的 HTTP 请求的架构。它是在 Java Community Process(JCP)下开发的,目前可用版本为 4.0。
遵循此规范的流行容器包括 Apache Tomcat 和 Jetty。
Servlet API 组件
以下块图显示了各种组件之间的典型关系:

看一下以下表格,它突出了主要概念:

Spring Web MVC
正如我们一直在查看构建和了解一个工作 Web 应用所需的所有基本模式和技术的,我们现在将看看所有这些如何应用于 Spring Boot 应用。
Spring Boot 使用“启动依赖”来为应用添加各种功能和能力。在我们的案例中,我们想要实现一个需要 Web 服务器来运行的网络应用。我们还需要支持 MVC 模式。Spring 框架包括构建基于 MVC 设计模式的丰富和现代 Web 应用所需的所有内容。
要利用这些功能,只需在我们的 Maven pom.xml文件中包含 spring-boot-starter-web 依赖项即可。
利用 Spring Web MVC 启动器
目标是使用 Spring Web MVC 启动器在网页上显示输出。完成步骤如下:
-
打开一个 CMD 窗口并导航到
bit.ly/2DmTaQA。 -
确保你的
JAVA_HOME路径设置正确,并使用mvnw spring-boot:run命令启动项目。
看一下下面的截图:

注意应用程序在启动后立即停止。
- 使用浏览器导航到
http://localhost:8080/hello.html。
看一下下面的截图:

浏览器无法连接到服务器,因此显示了一个错误页面。
- 将以下依赖项添加到你的
pom.xml文件中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
-
再次启动项目。
-
导航到
http://localhost:8080/hello.html以查看问候页面。 -
按下 Ctrl+C 停止程序。你可以安全地忽略这里的错误消息。
你已使用 Spring Web MVC Starter 获得了预期的问候页面。
看一下下面的输出截图:

前往bit.ly/2OfiTwW访问WebstarterExerciseApplication.java文件的代码。
嵌入式 Web 服务器
感谢 Spring Boot 的自动配置功能,你只需要在你的 Maven 构建文件中添加 spring-boot-starter-web 依赖项。默认情况下,这将向你的构建添加一个嵌入式的 Apache Tomcat 服务器。它还将构建一个可执行的 JAR 文件,该文件将启动服务器并将你的应用程序部署到它上面。
如果你希望使用不同的 Web 服务器,你可以选择排除 spring-boot-starter-tomcat 依赖项,并添加以下依赖项之一:

Spring DispatcherServlet
Spring Web MVC 通过提供一个名为DispatcherServlet的 servlet 来处理传入请求。它也被称为前端控制器,因为它是处理传入请求并将它们分派到其他控制器实现的第一部分控制器。
默认情况下,DispatcherServlet将对以/开头的每个请求进行调用。你可以通过在application.properties文件中设置server.servlet.contextPath属性来更改这个所谓的上下文路径:
server.servlet.contextPath=/my-app
在前面的示例中,DispatcherServlet以及你的应用程序将只响应以/my-app开头的 URL。
DispatcherServlet 交互
以下块图显示了各种组件之间的典型关系:

当处理传入请求时,DispatcherServlet做所有繁重的工作:
-
根据请求 URI 确定要调用的控制器。为此,它使用一个能够检索控制器的
HandlerMapping。 -
调用控制器方法,可选地传递模型
-
当控制器完成时,它返回一个视图的名称
-
根据控制器返回的名称确定视图
-
解析视图
-
将视图渲染回客户端,传递要渲染的模型
这是对传入请求发生的事情的非常简单的看法。我们将在查看这些组件的一些内容时进一步详细介绍。
控制器作为组件
虽然DispatcherServlet是应用程序的前端控制器,响应每个传入请求,但业务逻辑在其自己的方法中实现。
正如我们在上一节中讨论的那样,HandlerMapper用于映射请求。
默认情况下,安装了一个RequestMappingHandlerMapping,它查找在带有@Controller注解的 Spring 组件中注有@RequestMapping的方法。我们将在后面的章节中了解更多关于此类映射的内容。
使用 HTML 和资源提供静态视图
每个 Web 应用程序都将需要某种类型的静态视图或资源。以“关于”页面为例。然而,由于您无法将模型中的数据嵌入此类页面,您可能不会对它们有太多用途。另一方面,您将需要提供一些静态资源,例如 CSS 文件、JavaScript 或图像。Spring Web MVC 能够提供放置在名为static的文件夹中的此类内容。如果您使用的是 Maven 或 Gradle 等构建工具,正如我们所做的那样,完整路径将从项目根目录的/src/main/resources/static。
通过 WebJars 添加客户端 Web 库
除了使用静态文件夹提供静态资源外,还有一种称为 WebJars 的机制。
WebJar是一种打包为 Java-Archive 的客户端 Web 库。这些库的例子包括用于网页常见 JavaScript 任务的 JQuery,或者Bootstrap,一个用于构建响应式 Web 设计的库。它可以通过使用 Maven 等构建工具轻松下载和部署作为依赖项。此外,传递依赖项也将自动下载和提供。
WebJar 存档的内容结构是标准化的,包括文件夹结构和需要存在的某些文件。
如果类路径上存在 WebJar,Spring Boot 将配置您的应用程序将 HTTP 请求映射到/webjars到/META-INF/resources/webjars文件夹。
如果您在类路径中包含多个 WebJars,它们都将位于相同的/webjars URI下。
例如,您可以通过在pom.xml文件中添加以下依赖项来包含 Bootstrap 的 WebJar:
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>4.0.0-2</version>
</dependency>
要访问此库的主要 CSS 文件,请在您的 HTML 文件中包含以下行:
<link rel='stylesheet' href='/webjars/bootstrap/4.0.0-2/css/
bootstrap.min.css'>
如您所见,该库通过其名称(bootstrap)在/webjars文件夹下引用。之后,添加版本号,然后是所需资源的路径,这取决于库。
如果您不想将库版本添加到 URI 中,您可以将webjar-locator依赖项添加到您的pom.xml文件中:
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-core</artifactId>
</dependency>
在此依赖项就绪后,您可以将 HTML 文件中的链接标签更改为以下内容:
<link rel='stylesheet' href='/webjars/bootstrap/css/bootstrap.min.
css'>
请注意,在添加 webjars-locator 后,/static文件夹中静态文件的映射将不再工作。
如果你想了解更多关于 WebJar 标准的信息,请访问www.webjars.org,以找到包括搜索引擎和代码片段生成器在内的库的详尽列表。此外,该网站上还有关于该标准和其应用的更多文档信息。
使用 Bootstrap 进行样式设计
目标是使用 Bootstrap 和 WebJar 来设计页面样式。完成步骤如下:
-
前往
bit.ly/2z8QQd6[.],获取文件夹位置。 -
使用
mvnw spring-boot:run启动应用程序。 -
将浏览器导航到
http://localhost:8080/hello.html。
注意页面是无样式的。
- 将浏览器导航到
http://localhost:8080/webjars/bootstrap/4.0.0-2/css/bootstrap.css。
浏览器将显示一个错误页面,因为资源缺失(errorCode=404)。
- 添加以下依赖项以包含 Bootstrap WebJar:
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>4.0.0-2</version>
</dependency>
-
重新启动应用程序。
-
再次将浏览器导航到
http://localhost:8080/welcome.html。
页面现在已应用了一些样式。
- 将浏览器导航到
http://localhost:8080/webjars/bootstrap/4.0.0-2/css/bootstrap.css。
取代错误页面,你现在应该看到一些 CSS 样式。
你会注意到你现在已经使用 Bootstrap 和 WebJar 为网页添加了样式。
前往bit.ly/2Obb4Il访问WebstarterExerciseApplication.java文件的代码。
模板引擎的转换
以下块图显示了各种组件之间的典型关系:

在 Web 应用程序中,我们期望视图被渲染为 HTML 页面。由于 MVC 模式鼓励我们将模型与视图分离,因此必须有一个实体将我们的模型数据转换成最终的表示形式。
这就是模板引擎发挥作用的地方。正如其名所示,模板引擎将接受一个包含 HTML 和来自模型的占位符数据的模板。然后,它将渲染最终发送给客户端的 HTML。
Thymeleaf 代码片段
以下块图显示了各种组件之间的典型关系:

例如,想象以下代码片段:
<p th:text="${hello}">Text will be replaced</p>
如果我们的模型中包含一个名为 hello 的属性,其内容为 Good Morning!,模板引擎将渲染以下 HTML 代码:
<p>Good Morning!</p>
与其他框架一样,Spring Boot 提供了一个简单的方法将模板引擎添加到你的应用程序中。
模型数据传递
正如我们所见,一个名为model的元素被用来在控制器和视图之间传递数据。Spring Web MVC 自动在这些组件之间传递模型,并提供了许多访问和绑定属性的机制。此外,许多基本任务,如转换和验证,都由框架完成。
模型绑定到当前请求,并由 org.springframework.ui.Model 类的实例表示。任何对象都可以绑定到模型。在注解请求处理器时,还可以将模型的单个属性绑定到方法参数。
当我们在 Web 页面上显示动态信息时,我们将看到如何在 第五章:使用 Web 页面显示信息 中使用模型。
Spring Web MVC 控制器
现在我们将看看 Spring Web MVC 包含了哪些内容,使我们能够实现应用程序的控制器。该框架处理传入的请求,因此我们可以专注于业务逻辑。
@RequestMapping 注解
我们已经看到 DispatcherServlet 使用 HandlerMapping 来确定如何处理传入的请求。
默认情况下,Spring Web MVC 将安装并使用 RequestMappingHandlerMapping,这允许我们使用注解来确定要使用哪个控制器和方法。
以下块图显示了各种组件之间的典型关系:

任何组件都将检查 @RequestMapping 注解。如果存在此注解,将根据路径属性创建映射。此外,方法注解指定映射的 HTTP 方法。为了更明显地表明给定的 Bean 旨在作为控制器,可以使用特殊的 @Controller 注解在类上。
自 Spring 4.3 以来,为每个 HTTP 方法都提供了便利注解。这些注解被称为 @GetMapping、@PostMapping 等。
如果 @RequestMapping 注解位于类级别,它将用作所有标注了 @RequestMapping 或任何方法特定注解的方法的前缀:
@Controller
@RequestMapping("/posts")
public class RequestMappingPostController {
@RequestMapping(path = "/newest", method = RequestMethod.GET)
// […]
// public String addPost(@RequestBody Post post) {
// This method will be mapped to
// POST requests with the path /posts
}
}
访问 bit.ly/2MsHOvc 以获取 @RequestMapping 注解示例的完整代码。
处理方法额外的注解和参数
可以应用于处理方法的额外注解。我们将在这里查看最重要的参数。
@RequestParam
发送到应用程序的请求可以包含查询中的任意数量的参数。这些参数通过问号(?)与路径分隔。这些参数会自动解析,并且可以通过使用 @RequestParam 注解将它们传递给处理方法。如果需要,值将被转换:
@GetMapping("/form")
public String setupForm(@RequestParam("formId") int formId) {
// When requesting /form?formId=1
// formId will get the value 1
}
@RequestHeader
@RequestHeader 注解允许你将一个或多个 HTTP 头注入到方法参数中。这是通过在方法参数上标注 @RequestHeader 来实现的:
Host localhost:8080
Accept text/html,application/
xhtml+xml,application/xml;q=0.9
Accept-Language fr,en-gb;q=0.7,en;q=0.3
Accept-Encoding gzip,deflate
Accept-Charset ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive 300
@GetMapping("/form")
public String setupForm(@RequestHeader("Keep-Alive") long
keepAlive) {
// keepAlive will get the value 300
}
@CookieValue
此注解允许你检索 cookie 的内容:
JSESSIONID=2388923038849
@GetMapping("/form")
public String setupForm(@CookieValue("JSESSIONID") String
jSessionId) {
// jSessionId will get the value 2388923038849
}
@PathVariable
有可能使用 URL 路径的一部分作为参数传递给处理方法。这可以通过使用@PathVariable注解来实现。如果需要,值将被转换:
@GetMapping("/form/{id}")
public String setupForm(@PathVariable long id) {
// When requesting /form/1
// formId will get the value 1
}
@ModelAttribute
当在请求处理方法的一个参数上使用@ModelAttribute注解时,您可以注入一个绑定到模型的属性。
我们将在未来的章节中详细介绍如何使用此模型:
@PostMapping("/posts")
public String addPost(@ModelAttribute("post") Post post) {
// post will get the model attribute named post
}
@RequestBody
有时,您需要访问请求的主体。通过将@RequestBody与一个方法参数结合使用,您可以注入请求主体。Spring 框架将尝试将主体转换为给定的类型。如果您指定了一个字符串,您将能够访问原始主体:
@PostMapping("/posts")
public String addPost(@RequestBody Post post) {
// post will get the content of the body that is deserialized
// into an object of type Post
}
@ResponseBody
通常,请求处理方法将返回要渲染的视图的名称。如果您想返回渲染后的响应体,您可以给方法添加@ResponseBody注解。在这种情况下,返回的值将被发送作为响应。如果返回值的类型不是字符串,它将在发送之前被转换:
@GetMapping("/welcome")
@ResponseBody
public String showWelcomeMessage() {
return "<html><head></head><body>Hello</body></html>";
}
额外内容 - 配置 Web MVC 的属性
Spring Boot 最大的优势之一是其使用预定义值进行自动配置。然而,在某些情况下,您需要更改配置的部分。例如,servlet 容器默认将监听 8080 端口。这将允许您在同一台机器上安装一个 HTTP 服务器。如果您不需要专用的 Web 服务器,并希望您的应用程序监听 80 端口,您可以通过在application.properties文件中设置server.port=80来配置 Spring Boot 嵌入的 Web 容器。
下表列出了您可以更改以满足您需求的常见配置属性:

您可以在 Spring 文档中找到所有可用配置属性的详尽列表,网址为docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#common-application-properties。
过滤器
我们在关于 Servlet API 的先前部分学习了过滤器。过滤器被组织成链,并在实际 servlet 被调用之前由容器调用。最后,它们可以以相反的顺序处理响应。
Spring Boot 使安装过滤器变得非常简单。在 Spring Boot 中,通常有多种方法可以实现这一点。首先,您必须实现一个实现javax.servlet.filter接口的 Bean。您不需要在web.xml中添加配置,任何实现过滤器接口的 Spring Bean 都将自动安装。
看看以下代码:
@WebFilter
@Component
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws
ServletException {
}
// […]
@Override
public void destroy() {
}
}
前往bit.ly/2xeD5b4访问 Filter 代码示例的完整代码。
配置 Spring Web MVC
编写和配置 Web 应用程序始终是一项综合任务。幸运的是,Spring Boot 再次承担了繁重的工作。但在许多情况下,您需要修改或更重要的是扩展配置。这包括额外的映射和安全配置。
所有可用的配置方法都由 WebMvcConfigurer 接口提供。通常,配置方法会传递一个配置器对象,然后可以使用该对象来修改某个方面。
在 Spring 框架的先前版本中,您必须实现一个扩展抽象类 WebConfigurerAdapter 的 Bean。适配器类负责提供默认实现,这样您就可以专注于您想要定制的那些方法。
自从版本 5 以来,这不再需要。相反,现在您可以为只想覆盖的方法提供 WebMvcConfigurer 接口的实现:
@Configuration
public class WebMVConfig implements WebMvcConfigurer {
public void addViewControllers(
ViewControllerRegistry registry) {
registry.addViewController("/")
.setViewName("index");
}
}
活动:创建您的第一个 Web MVC 控制器
目标
要设置一个 Spring Web MVC 项目,添加一个静态欢迎页面,并创建一个指向视图的控制器。
场景
您需要设置一个显示静态欢迎页面的 Web 应用程序。
完成步骤
-
访问
start.spring.io并在以下屏幕上输入所需的依赖项:组:
com.packt.springboot工件:
blogmania
看看这个截图:

-
下载包含初始项目文件的 ZIP 文件。
-
将下载的文件解压到项目文件夹中。
-
添加 Bootstrap Webjars 存档的依赖项。
-
创建
welcome.html文件。 -
添加一个控制器以渲染视图。
-
现在通过 Maven 命令启动应用程序。
-
使用您的浏览器导航到
http://localhost:8080以查看输出。
结果
生成的 Spring Web MVC 项目和帖子在网页上可见。
看看这个截图:

访问 bit.ly/2QpmQR9 以访问 BlogManiaApplicationTests.java 文件的代码。要参考详细步骤,请参阅本书末尾的 解决方案 部分的第 252 页。
摘要
在本章中,我们学习了关于模型-视图-控制器(MVC)模式的内容。它将 Web 应用程序的主要关注点分为模型、视图和控制器。
我们区分了基于请求和基于组件的 MVC,它们在框架如何处理传入请求方面有所不同,要么负责转换、验证等,要么让开发者处理这些方面,从而产生不同的代码重用性。
最后,我们实现了我们的第一个 Spring Web MVC 应用程序,以处理传入的请求并显示简单的页面输出。
在下一章中,我们将应用 MVC 模式到 Spring Boot。
第五章:使用网页显示信息
在本章中,我们将学习模板引擎以及它们如何在 Spring Boot 2 中集成和使用。之后,我们将介绍 Thymeleaf,这是一个非常常用的模板引擎,它被用来构建显示动态数据的网页。
应用程序。
模板引擎是一个组合框架,它允许我们集成动态信息和构建块,使开发者能够独立创建页面片段。这些片段在应用程序运行或页面渲染时被组装在一起。Spring 与 Thymeleaf、Groovy Templates、Freemarker 和 Mustache 等模板引擎很好地集成。然而,我们将专注于 Thymeleaf,因为它被广泛使用,并允许我们舒适地处理用于 Web 应用程序的 HTML 页面。
到本章结束时,你将能够:
-
识别 Thymeleaf 模板引擎的工作原理
-
解释模板的基本语法
-
定义最重要的元素
-
解释如何遍历列表并条件性地显示页面的一部分
-
使用 Thymeleaf 构建网页以显示应用程序的动态数据
使用 Thymeleaf 进行 HTML 模板化
我们已经在上一章中讨论了模板引擎。现在我们将更详细地了解它们是如何工作的。
Thymeleaf 是一个服务器端模板引擎,它因 Spring Web MVC 应用程序而变得非常流行。它目前处于第三个主要版本。但 Thymeleaf 的应用范围远超常规的 Web 应用程序。它还被用来生成其他格式,如 PDF 或 XML。甚至可以将模板引擎集成到应用程序中,使其不仅被开发者使用,还可以被应用程序的最终用户使用。
Thymeleaf 之所以特别,在于它能够编写自然的 HTML 模板,这些模板可以在网页浏览器中显示,而无需运行应用程序。这允许在设计应用程序的用户界面时拥有非常短的开发周期。但缺点是,你可能必须添加一些额外的 HTML 属性,仅为了在设计时显示模板。然而,你可以决定何时需要这些额外的属性,何时可以省略它们。
其他流行的模板引擎是Java 服务器页面(JSP)和FreeMarker。然而,这些替代品在没有运行引擎的情况下,无法轻松地进行模板开发。
网页浏览器使用 HTML 来渲染页面。它主要由角括号内的标签组成(例如 <div>)。每个标签都有一个开始标签和一个结束标签(<div>...</div>)。标签还可以包含额外的属性(例如 div class="important">,其中 class 是属性)。
模板引擎
以下图表显示了各种组件之间典型的关系:

在 Thymeleaf 的情况下,模板是用常规的 XML、XHTML 或 HTML5 文件编写的。这些文件可以在浏览器中预览,无需运行模板引擎。为了让 Thymeleaf 执行其工作,你需要在 HTML 中添加特殊属性,这些标签通常以 th 开头:
<p th:text="${welcome}">This will be replaced!</p>
将 Thymeleaf 集成到 Spring Boot 应用程序中
要在 Spring Boot 应用程序中集成 Thymeleaf,你需要在你的 Maven pom.xml 文件中添加以下依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
这将在你的项目中包含 Thymeleaf 版本 3。由于 Spring Boot 正在为你处理配置,它期望模板位于 src/main/resources/templates 文件夹中。当你从控制器方法返回视图名称时,Spring 会在这个文件夹中查找一个具有视图名称和 .html 扩展名的文件。例如,名为 welcome 的视图应位于 src/main/resources/templates/welcome.html 文件中。
一个基本的 Thymeleaf 模板
以下代码展示了一个简单的 Thymeleaf 模板:
<!DOCTYPE html>
<html >
<head>
<title>Welcome to BlogMania</title>
</head>
<body>
<p th:text="${welcome}">This will be replaced!</p>
</body>
</html>
文件的第二部分是使其成为模板的部分:
<html >
这定义了 Thymeleaf 的命名空间,并允许使用 th: 作为前缀来标记特定于 Thymeleaf 的属性。
你可以在模板中找到许多这些属性、指令和表达式。它们允许你输出模型的内容,有条件地显示视图的部分,遍历列表或执行函数。
外部化文本
在某些情况下,你可能希望在模板外有文本。这被称为 外部化文本片段。你使用 Java 属性文件来存储这些片段。在 Spring Boot 应用程序的情况下,你在 src/main/resources 文件夹中创建一个名为 messages.properties 的文件。
由于这是一个常规的属性文件,你将文本作为属性添加:
welcome.title=Welcome to BlogMania!
在 Thymeleaf 模板中,你通过使用 #{...} 表达式插入文本:
<h2 th:text="#{welcome.title}">Welcome to BlogMania!</h2>
你可以在代码仓库中找到完整的示例。
在外部化字符串中包含占位符或参数是可能的。当页面渲染时,这些消息的部分将被模型属性或其他动态值替换。有关此功能的更多信息,请参阅 Thymeleaf 文档中的www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#messages。
创建上下文感知的 URL
Java Servlet 规范包括所谓的 servlet 上下文。当配置后,servlet 上下文是 URI 的一部分,它将被添加到每个映射的前面。例如,当将 servlet 上下文设置为 /blogmania 时,映射 /blogposts 将变为 /blogmania/blogposts。
在 Spring Boot 中,你在 application.properties 文件中设置上下文路径:
server.servlet.contextPath=/blogmania
Thymeleaf 模板中的语句
除了包含外部化文本或创建 URL 的表达式之外,Thymeleaf 还提供了许多其他语句和表达式。它们可以用于任何以之前定义的命名空间 th: 为前缀的 Thymeleaf 属性。
以下表格列出了几个最有趣的声明:

以下是一个结合了此表中许多声明的示例:
'User is of kind ' + (${user.isEditor()} ? 'Editor' : (${user.
kind} ?: 'Unknown'))
使用 Thymeleaf 进行引导和模板化
目标是实现 Thymeleaf 的引导并首次接触 Thymeleaf 模板化。完成步骤如下:
-
在
bit.ly/2Og1oMZ中打开项目文件夹。 -
使用
mvnw spring-boot:run命令启动应用程序。 -
将浏览器导航到
http://localhost:8080以查看包含标题的页面。 -
在您的 IDE 或文本编辑器中,将以下行添加到文件中:
src/main/resources/templates/welcome.html<p th:text="#{welcome.message}">信息如下!</p>。 -
在文件中添加另一行:
3 + 4 = <span th:text="3 + 4">计算结果</span>。 -
停止应用程序并使用之前的命令重新启动它。
-
重新加载页面以查看以下输出:

通过本节,您已在 Thymeleaf 中实现了引导和模板化过程。
前往 bit.ly/2Og1oMZ 访问 exercise-first-thymeleaf/ 文件夹。
前往 bit.ly/2CP8dnd 访问 ExternalizedTextApplicationTests.java 文件的代码。
使用模型和数据绑定显示数据
数据绑定处理将来自两个不同数据源的信息绑定在一起,并保持它们同步。数据模型实例使用所需的方法从服务器设置或检索数据,并反映两个数据源之间的更改。
Spring Web MVC 如何处理模型
我们刚刚查看的 Thymeleaf 引擎负责处理 MVC 模式的视图部分。模型是此模式的另一个方面,由 Spring MVC 框架处理。这包括创建和初始化模型属性,并在视图和控制器之间传递它。这与例如 Java Server Faces 不同,在 Java Server Faces 中没有单独的模型,但渲染视图的组件必须访问会话或请求的属性。
模型本身由一个或多个命名属性组成。在 Spring MVC 应用程序中常用的一种类是 org.springframework.ui.Model 类。它类似于一个映射,这意味着它是您将属性与其名称关联的地方。名称随后用于在渲染视图时引用模型中的属性。
例如,可能有一个名为 blogPost 的属性引用一个 BlogPost 类的实例,该实例本身包含有关应通过视图显示的帖子的信息:

模型-视图交互
以下图表显示了各种组件之间的典型关系:

将模型作为参数传递给处理方法
要将任意对象添加到模型中,你可以在处理方法中声明一个类型为 org.springframework.ui.Model 的方法参数。当 Spring MVC 处理请求时,将创建一个实例并将其提供给方法。
看看以下代码片段,它来自你可以在代码文件中找到的类 bit.ly/2OkI7ci:
@GetMapping("/blogposts")
public String renderBlogPostList(Model model) {
List<BlogPost> blogPosts =
blogPostService.findAllBlogPosts();
model.addAttribute("blogPosts", blogPosts);
return "blogposts/list";
}
@GetMapping 注解将此方法映射到 /blogpost URL。
将 org.springframework.ui.Model 类的实例作为参数传递给方法。
所有博客文章都是使用 findAllBlogPosts() 方法检索的。
将博客文章列表添加到模型中,并与其名称 blogPosts 关联。
返回要渲染的视图的名称。在这种情况下,视图名为 blogposts/list。
使用 @ModelAttribute 将模型属性作为方法参数传递。
在某些情况下,你可能只需要在处理方法中几个模型属性。在这种情况下,Spring 允许你使用包含属性名称的 @ModelAttribute 注解来注解方法的参数。然后 Spring 将尝试从模型中获取具有给定名称的现有属性。如果找不到属性,它将被创建。
每当属性初始化并添加到模型中时,相同的实例将在整个请求的其余部分中对该名称可访问。这意味着在渲染视图时,属性也存在于模板内部:
@GetMapping("/blogposts/new")
public String initNewBlogPost(
@ModelAttribute("blogPost") BlogPost blogPost
) {
blogPost.setPublicationDate(LocalDateTime.now());
return "blogposts/edit";
}
在这个示例中,Spring 确保一个名为 blogPost 的属性与模型相关联,然后将 blogPost 传递给处理方法,在那里它将被操作。最后,渲染名为 blogposts/edit 的视图。
从控制器方法返回模型属性
将属性绑定到模型的一种方法是通过从控制器方法返回它们。任何类型的对象都可以返回。然而,其中一些具有特殊含义,并将被适当处理。
当返回 java.util.Map 或 org.springframework.ui.Model 的实例时,它们的所有属性都将添加到现有模型中。
任何非 String 类型的任意对象都将添加到模型中。名称由返回类型的名称或(如果存在)@ModelAttribute 注解中给出的名称确定。
使用 @ModelAttribute 初始化模型属性
在控制器类中,任何带有 @ModelAttribute 注解的方法都在同一控制器中的实际请求处理方法之前调用。它可以访问作为参数传入的模型实例,返回前面描述的模型属性,甚至结合这两种方法。
查看以下代码示例:
@ModelAttribute
public void addDefaultAttributes(Model model) {
int allPostsCount = blogPostService.numberOfBlogPosts();
model.addAttribute("allPostsCount", allPostsCount);
}
此方法将为控制器类处理的任何请求添加 numBlogPosts() 方法报告的所有帖子的数量。
如果需要访问此类值,您甚至可以使用 @PathParam 和类似的指令。例如,这可以用于查询数据库。
使用注解参数允许您仅获取在其他地方可能已初始化的某些属性。它还使处理方法能够清楚地表达其目的。最后,控制器方法不需要与整个模型纠缠。
有一个注解方法允许您使用通常用于所有视图且与当前要执行的任务无关的属性初始化模型。这表达了关注点分离的原则。
Spring 框架如何选择视图
Spring 提供了多种渲染模型信息的方法。我们将特别查看 HTML 页面。
Thymeleaf 模板引擎使用视图名称来确定它应该使用哪个模板。
Thymeleaf 使用视图名称在 src/main/resources/templates 文件夹中搜索模板文件。文件名必须与视图名称完全相同,前面加上 .html 扩展名。
以下表格列出了一些示例:

通过返回其名称选择视图模板
查看以下代码示例:
@GetMapping("/blogposts/new")
public String initNewBlogPost(
@ModelAttribute("blogPost") BlogPost blogPost
) {
blogPost.setPublicationDate(LocalDateTime.now());
return "blogposts/edit";
}
此示例将渲染位于 src/main/resources/templates/blogPosts/list.html 文件中的模板。
直接渲染视图而不是重定向到 URL
要重定向到 URL,您需要在处理方法返回的视图名称前加上 redirect: 前缀。这可以是相对于 servlet 上下文的 URL 或绝对 URL。模型中的任何属性都将作为查询参数添加到 URL 中。
这里有一个例子:

另一种重定向的方法是返回一个包含要重定向到的 URL 的 org.springframework.web.servlet.view.RedirectView 实例。这允许我们将额外的属性传递给重定向后执行的处理方法。
另一种类型的属性,称为闪存属性,在重定向后也将可用。
看看以下从仓库中基本 Thymeleaf 源代码的 BlogPostController 中提取的示例:
@GetMapping("/blogposts/random")
public RedirectView displayRandomBlogPost(RedirectAttributes
attributes) {
attributes.addFlashAttribute("flashMessage", "Enjoy this
post");
attributes.addAttribute("extraMessage", "This message appears
in the query");
BlogPost blogPost = blogPostService.randomBlogPost();
return new RedirectView("/blogposts/" + blogPost.getSlug(),
true);
}
在 RedirectView 实例的构造函数中,指定重定向的 URL。第二个参数告诉 Spring 相对于当前应用程序的上下文路径进行重定向。在此处理方法返回后,客户端将被重定向到类似 /blogPosts/slug-of-random-post?extraMessage=extraMessage=This+message+appears+in+the+query 的 URL。处理此请求的方法能够访问 flashMessage:
<html lang="en"
>
<head>
<title th:text="${blogPost.title}">Blog Post Title</title>
</head>
<body>
<div th:text="${param.extraMessage}">The extraMessage
parameter</div>
<div th:text="${flashMessage}">The flashMessage attribute</div>
</body>
</html>
相比之下,请求参数可以发送到任何应用程序或网站,因为它是编码在 URL 中的。
同时选择视图并返回模型
通过返回 org.springframework.web.servlet.ModelAndView 的实例,可以同时指定视图名称、添加模型属性或设置 HTTP 响应状态。
在本章代码库提供的 BlogPostController 中可以找到一个示例:
@GetMapping("/blogpost/{slug}")
public ModelAndView displayBlogPostBySlug(@PathVariable String
slug)
throws BlogPostNotFoundException {
BlogPost blogPost = blogPostService.findBlogPostBySlug(slug)
.orElseThrow(() -> new BlogPostNotFoundException("Blog
post with slug " + slug + " could not be found"));
return new ModelAndView("/blogposts/details", "blogPost",
blogPost);
}
此方法返回以下信息:
视图名称 /blogposts/details。
一个名为 blogPost 的模型属性,包含实际的博客文章。
在 Thymeleaf 模板中处理模型
一旦处理方法提供了模型和视图名称,框架将同时传递这两个名称给 Thymeleaf 模板引擎。模型中添加的所有内容都将通过其属性名称在模板中可用。
将模型数据绑定到 Thymeleaf 模板
在 Thymeleaf 通过添加命名空间启用后,可以使用指令添加动态内容。这将通过使用 th 前缀来实现。例如,假设您已向模型添加了一个名为 message 的属性:
<h1 th:text="${message}">This will be replaced!</h1>
而不是显示文本 This will be replaced!,将显示名为 message 的模型属性的内容。
要访问模型属性,使用 ${...} 来引用模型中的属性。
添加动态数据
目标是在网页上显示动态数据。完成步骤如下:
-
在您的 IDE 中打开
exercise-basic-thymeleaf项目。 -
打开
BlogPostController类。 -
添加以下方法,该方法将简单的博客文章添加到模型中:
@GetMapping("/sample-post")
public ModelAndView displaySampleBlogPost() {
BlogPost blogPost = new BlogPost();
blogPost.setTitle("A sample blog post");
blogPost.setPublicationDate(LocalDateTime.now());
blogPost.setContent("Writing blog posts is fun!");
return new ModelAndView("blogposts/details",
"blogPost", blogPost);
}
-
打开
src/main/resources/templates/blogposts/details.html文件 -
在此标记的位置添加以下代码:
<!-- Insert details view here -->
<div class="card">
<div class="card-body">
<h5 class="card-title" th:text="${blogPost.
title}">Title</h5>
<h6 class="card-subtitle" th:text="${#temporals.
format(blogPost.publicationDate, 'MMMM d,
YYYY')}">Publication Date</h6>
<p class="card-text" th:text="${blogPost.
content}">Main Content</p>
</div>
</div>
- 导航您的浏览器到
http://localhost:8080/sample-post。
您应该看到博客文章的详细信息:

通过这个子部分,您已经使用了动态数据并将其作为网页上的输出显示。
前往 bit.ly/2OcOLSH 访问 exercise-basic-thymeleaf/ 文件夹。
前往 bit.ly/2MtdW1X 访问 BasicThymeleafApplication.java 文件的代码。
遍历和显示列表中的项目
当您想要显示项目列表时,您需要一种遍历该列表的方法。Thymeleaf 可以使用 th:each 指令遍历列表,例如数组或 Java 集合:
<div th:each="currentBlogPost : ${blogPosts}">
<h2 class="blog-post-title"
th:text="${currentBlogPost.title}">Title</h2>
<p th:text="${currentBlogPost.content}">
Main Content</p>
</div>
这是您在 bit.ly/2Rqpkyn 文件中找到的内容的简化版本。
此代码片段将为集合中具有 blogPosts 属性名的每个博客文章创建一个 <div>。在此 <div> 元素内部,可以通过使用名称 currentBlogPost 访问当前的博客文章对象。如您所见,标题和内容是通过 th:text 属性插入的。
处理列表
目标是在博客文章中显示列表。完成步骤如下:
-
从前一节中打开
src/main/resources/templates/index.html欢迎页面文件。 -
在标记为
<div>的位置添加以下代码:
<!-- Insert list here -->
<div th:each="currentBlogPost : ${blogPosts}" class="card">
<div class="card-body">
<h5 class="card-title" th:text="${currentBlogPost.
title}">Title</h5>
<h6 class="card-subtitle" th:text="${#temporals.
format(currentBlogPost.publicationDate, 'MMMM d,
YYYY')}">Publication Date</h6>
<p class="card-text" th:text="${currentBlogPost.
content}">Main Content</p>
</div>
</div>
-
打开终端并转到
exercise-basic-thymeleaf文件夹。 -
输入
mvnw spring-boot:run以启动应用程序。 -
将您的浏览器导航到
http://localhost:8080以查看列表的实际效果。 -
点击“添加”按钮,以便列表应显示一个新条目:

在您通过遍历项目列表在网站上显示多个博客文章之后。
前一节子部分说明了使用 Thymeleaf 内置函数格式化时间属性(如用于博客文章发布日期的 LocalDateTime)的用法。
th:text="${#temporals.format(currentBlogPost.publicationDate, 'MMMM d, YYYY')}" 表达式从当前博客文章中获取发布日期并将其格式化为给定的模式。首先打印月份,然后是月份中的日期,接着是冒号和年份(2018 年 1 月 1 日)。
使用条件显示视图的一部分
除了遍历列表之外,Thymeleaf 还允许您根据状态模型属性隐藏或显示模板的某些部分。您还可以评估包含逻辑运算或方法调用的表达式。
为了有条件地显示页面元素,Thymeleaf 提供了 th:if 指令:
<div th:if="${#lists.isEmpty(blogPosts)}">
There are no blog posts
</div>
这是您在 bit.ly/2CRBV9A 文件中找到的内容的简化版本。
在这种情况下,当名为 blogPosts 的模型属性为空时,不会显示任何博客文章。
有条件地显示文本
目标是根据某些条件在屏幕上显示文本。完成步骤如下:
-
从前一节中打开欢迎页面。
-
在标记为
<div>的位置的上面的列表上方添加以下代码:
<!-- Insert conditional here -->
<div th:if="${#lists.isEmpty(blogPosts)}" class="alert
alert-primary">
There are no blog posts
</div>
-
打开终端并转到
exercise-basic-thymeleaf文件夹。 -
输入
mvnw spring-boot:run以启动应用程序。 -
将您的浏览器导航到
http://localhost:8080以查看列表的实际效果。 -
点击“删除所有”按钮以清除博客文章列表:

在本节中,您根据应用条件在网站上显示了文本数据。
您已经看到了多种向模型添加属性的方法。模型属性可以是任何通过名称引用的任意对象。名称是在将其添加到模型时由开发者定义的。这可以是单独的或映射处理方法。
活动:显示博客文章的详细信息
目标
实现处理方法和相应的视图模板。
场景
blogmania 应用程序已设置。有一个方法可以通过 slug 属性从内部存储检索博客文章。现在您想添加一个视图,当用户输入 /blogposts/{slug} URI 时显示博客文章。
完成步骤
-
创建一个注解类并注入
BlogPostService。 -
向
BlogPostController添加处理方法,并使用路径变量 slug 查找博客文章。当找不到博客文章时抛出异常。 -
从
BlogPostService检索博客文章。 -
从所需方法返回实例。将视图名称设置为 blogpost 并添加从服务返回的博客文章。
-
创建一个视图模板文件。
-
使用 maven 命令启动应用程序。
-
打开
http://localhost:8080/blogposts/my-first-postURL 以查看输出屏幕:

结果
最终的处理方法和相应的视图模板在网页上可见。
前往bit.ly/2NGgDS4访问BasicThymeleafApplication.java文件的代码。要参考详细步骤,请参阅本书末尾第 254 页的“解决方案”部分。
摘要
在本章中,我们学习了关于 Thymeleaf 模板引擎的内容。我们探讨了如何将所需的依赖项添加到我们的项目中,以及编写视图模板的基本语法。
我们接着探讨了 Spring MVC 如何处理模型以及如何向其添加属性。添加和访问属性类似于 Java Map。
最后,我们探讨了 Thymeleaf 用于插入模型数据、遍历列表和条件显示页面部分的表达式和控制语句。
在本章中,我们学习了如何在视图中显示从控制器作为模型属性提供的已提供信息。在下一章中,我们将探讨如何将视图的输入传递给控制器。
第六章:在视图和控制器之间传递数据
在本章中,我们将学习创建 HTML 表单以获取数据。一旦获取数据,我们将检查它。最后,我们将检查用于存储数据的字段类型。
HTML 表单或 Web 表单是一个旨在从网页收集数据以便存储或发送到服务器进行处理的文档。这些表单使用不同类型的元素,如复选框、单选按钮和文本字段,以接受各种类型的数值或文本数据。它们还可以包括按钮等交互式元素。
到本章结束时,你将能够:
-
创建 HTML 表单以获取用户输入
-
在你的 Spring 控制器中解释用户输入
-
检查表单中输入的数据
-
解释在浏览器中输入信息的不同类型的字段
表单处理
当使用 Web 应用程序时,通常有方法可以输入数据。例如,一个地址簿应用程序会允许用户将联系信息输入到表单中,并将输入存储在数据库中。输入到表单中的数据通常由相互关联的信息片段组成。例如,你可以想到个人信息、地址或银行账户。HTML 提供了一个名为表单的构造,以便允许更新或输入数据。
HTML 表单
下面的摘录显示了一个非常简单的 HTML 表单:
<html>
<head>
<title>Simple Form Page
</title>
</head>
<body>
<form method="post">
<p>Please enter your given name:
<input type="text" name="givenname"/>
</p>
<p>
<input type="Submit" value="Send"/>
</p>
</form>
</body>
</html>
处理 POST 数据
当数据输入到网页时,必须以某种方式将其传输到服务器进行进一步处理。为此,用于浏览器和服务器之间通信的 HTTP 协议实现了一个方法来完成这项任务。
当表单提交时,浏览器将生成一个所谓的请求,其中包含所有输入数据作为请求正文中的键值对。
例如,如果title的值为 My First Blog Post,而content设置为Hello World,则请求包含以下正文:
title=My+First+Blog+Post&content=Hello+World
创建表单模板
由于我们正在使用 Thymeleaf 来渲染 HTML 页面,我们需要一种方法在视图渲染时显示模型中的数据。另一方面,提交的值必须成为控制器处理的模型的一部分。
简单输入字段
假设你想要求一个博客文章的给定标题。在这种情况下,你将定义你的输入字段如下,这是文件中摘录的一部分,文件位于bit.ly/2D6fkpQ:
<input type="text"
id="title"
name="title"
th:value="${title}">
这将设置名为 title 的模型属性为输入字段的值。提交时,也将传递一个名为 title 的请求参数到控制器。
在这种情况下,你使用th:value或th:text,具体取决于你使用的输入字段类型。例如,<input>字段期望值存在于值属性中,而<textarea>需要预定义的值作为其内容。
控制器现在能够作为请求参数访问提交的值:
@PostMapping("create")
public void createFromRequestParam(
@RequestParam String title) {
log.info("The title is " + title);
}
将required = false添加到参数引用中,可以避免在请求中缺少参数时抛出错误。
您可以在bit.ly/2Fx6rbI文件中找到完整类的源代码。
由于您必须手动添加大量属性并多次重复字段名称,因此这是一个非常繁琐且容易出错的办法。
幸运的是,有一种更简单的方法可以在控制器和视图之间双向传递模型或其属性,我们很快就会看到。
实现 Thymeleaf 表单语法
目标是在网站上实现 Thymeleaf 表单语法。
完成步骤如下:
-
打开位于
bit.ly/2p38GIR的项目。 -
打开
BlogPostController类,并插入以下处理方法:
@PostMapping("create-multiple-values")
public ModelAndView createBlogPostFromMultipleValues(
@RequestParam(name = "title")
String title,
@RequestParam(name = "slug")
String slug,
@RequestParam(name = "content")
String content,
@RequestParam(name = "visible", defaultValue = "visible")
boolean visible) {
BlogPost createdBlogPost = createBlogPost(title,
slug,
content,
visible);
return new ModelAndView(
"blogposts/show",
"blogPost",
createdBlogPost);
}
- 打开
src/main/resource/templates/form-multiple-values.html文件,并插入以下表单定义:
<form action="#" th:action="@{/blogposts/create-multiple
values}" method="post">
<div class="form-group">
<label for="title">Title</label>
<input type="text"
// […]
<button type="submit" class="btn btn-primary">Submit</
button>
</form>
访问bit.ly/2xec8E6以访问表单定义代码示例的完整代码。
-
使用
mvnw spring-boot:run命令启动应用程序。 -
使用您的浏览器打开 URL
http://localhost:8080/blogposts/new-multiple-values以查看以下输出:

-
现在,输入详细信息并点击提交。
-
您现在应该看到以下页面,总结您输入的值:

通过这种方式,您已经在网站上利用了 Thymeleaf 表单语法来指示博客文章的状态。
访问bit.ly/2x6VwPp以访问formmultiple-values.html文件的代码。
访问bit.ly/2CPfkfp以访问FormhandlingIntroApplication.java文件的代码。
访问bit.ly/2p38GIR以访问form-handling/文件夹。
表单后端 Bean
在使用网页操作数据的 Web 应用程序中,您想通过使用可以在视图和控制器之间传递的对象或表单后端 Bean 来紧密耦合信息。这种类型的 Bean 提供了获取器和设置器来访问表单中每个字段的值。在 Spring MVC 中,这种类型的对象被称为命令。
以下代码片段显示了一个示例命令类,它支持表单以显示和修改博客文章。请注意 Project Lombok 的使用,以避免构造函数、获取器和设置器的样板代码:
@Data
public class BlogPost {
private LocalDateTime publicationDate;
private String slug;
private String title;
private String content;
}
如您所注意到的,这看起来像是一个常规的 Java Bean;事实上,Java Bean 和 Spring MVC 命令类之间没有区别。
为编辑填充表单 Bean
在之前,我们曾将模型的单个值绑定到表单的字段。当表单 Bean 有很多属性时,为每个字段重复模型属性名称可能会变得相当繁琐。
由于从表单后端 Bean 访问值是一个非常常见的任务,Thymeleaf 在<form>标签中提供了th:object属性来指定将在整个表单中访问的命令对象:
<form action="#" th:action="@{/blogposts/create}"
th:object="${blogPostCommand}" method="post">
<input type="text" id="title" th:field="*{title}">
<input type="text" id="slug" th:field="*{slug}">
<button type="submit">Submit</button>
</form>
在为表单设置对象之后,Thymeleaf 表达式*{...}允许你访问该对象的字段,而无需反复引用模型属性。
请注意,在使用此属性时存在限制。必须使用变量表达式(${...})指定模型属性名称,而不进行任何属性导航。这意味着${blogPostCommand}是完全可以接受的,而${pageData.blogPostCommand}会导致错误。此外,表单内不允许有其他th:object属性。
从表单数据处理开始
目标是使用表单数据处理来创建一个带有后端 Bean 的博客文章。完成步骤如下:
-
打开位于
bit.ly/2p38GIR的项目。 -
打开
CreateBlogPostCommand类,并按以下方式完成类:
@Data
public class CreateBlogPostCommand {
private String title;
private String slug;
private String content;
private boolean visible;
}
- 打开
BlogPostController类,并插入以下处理方法:
@GetMapping("new-backing-bean")
public ModelAndView renderFormViewForBackingBean() {
CreateBlogPostCommand createBlogPostCommand =
new CreateBlogPostCommand();
createBlogPostCommand.setTitle("Default Title");
return new ModelAndView("blogposts/form-backing-bean",
"createBlogPostCommand",
createBlogPostCommand);
}
} @PostMapping("create-backing-bean")
public ModelAndView createBlogPostFromBackingBean(@
ModelAttribute
CreateBlogPostCommand createBlogPostCommand) {
BlogPost createdBlogPost = createBlogPost(
createBlogPostCommand.getTitle(),
createBlogPostCommand.getSlug(),
createBlogPostCommand.getContent(),
createBlogPostCommand.isVisible());
return new ModelAndView("blogposts/show",
"blogPost",
createdBlogPost);
}
- 打开
src/main/resource/templates/form-backing-bean.html文件,并插入以下表单定义:
<form action="#" th:action="@{/blogposts/create-backing-
bean}" th:object="${createBlogPostCommand}" method="post">
<div class="form-group">
<label for="title">Title</label>
<input type="text"
class="form-control"
// […]
</div>
<button type="submit" class="btn btn-primary">Submit</
button>
</form>
前往bit.ly/2NJ5IqO以访问表单定义代码示例的完整代码。
-
通过使用
mvnw spring-boot:run命令启动应用程序。 -
使用你的浏览器打开 URL
http://localhost:8080/blogposts/new-backing-bean来查看输出:

-
现在输入详细信息并点击提交。
-
查看以下截图,总结你输入的值:

使用这种方式,你已经利用表单数据处理创建了一个带有后端 Bean 的博客文章。
前往bit.ly/2D3aziP以访问FormhandlingIntroApplication.java示例的完整代码。
前往bit.ly/2p38GIR以访问form-handling/文件夹。
Bean 验证
当涉及到获取数据时,通常会有关于值有效性的约束。这可能是一个技术约束(它必须是一个数字)或业务约束(21 岁或以上)。对于这种验证,有一个名为 Bean Validation 的框架,适用于 Java 等 JVM 语言。
如果你想了解更多关于 Java Bean 验证框架的信息,你可以访问项目的首页
beanvalidation.org/,这是一个非常好的起点。
覆盖 Bean Validation 框架超出了本书的范围。然而,基本概念是使用注解定义 Java Bean 中属性的约束。然后使用验证器执行实际的验证。
以下代码片段展示了可以验证的 Java Bean 的示例:
@Data
@NoArgsConstructor
public class CreateValidatedBlogPostCommand {
@NotBlank
@Size(max = 140)
// title is not allowed to be empty
// or longer than 140 characters
private String title;
@Size(min = 3, max = 60)
// slug must be between 3 and 60 characters long
private String slug;
@NotBlank
// content must not be empty
private String content;
private boolean visible;
}
除了将注解应用于模型类之外,验证模型几乎不需要做任何事情。大部分繁重的工作,如配置验证框架和调用验证器实现,都是由 Spring 完成的。为了最终执行实际的验证,在控制器方法中使用@Validated注解:
@PostMapping("create-validated-bean")
public String createBlogPostFromValidatedBean(
@Validated @ModelAttribute CreateValidatedBlogPostCommand
createValidatedBlogPostCommand,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
return "blogposts/form-validated-bean";
}
//...
return "blogposts/show";
}
与 Spring Boot 采用的大多数框架一样,您可以使用 Spring 注解(@Validated)或原始项目的注解(@Valid)。
除了@Validated注解外,此代码还展示了BindingResult类的使用,该类也作为参数传递。bindingResult参数用于确定在先前的请求中是否有任何验证约束被违反。在先前的示例中,如果发生验证错误,将渲染另一个视图,而不是数据输入正确的情况。通常,在这些情况下将使用包含原始表单的视图。
通常,将BindingResult添加到控制器方法中是可选的。然而,如果它缺失或没有紧跟在要验证的模型属性之后,将显示一个通用的错误页面。原因是 Spring MVC 确定控制器方法无法处理验证错误,因此转向通用错误处理器以处理不良请求。
将验证消息添加到模板中
当用户输入无效信息时,通知用户这一点是一个好的做法。通常,这是通过显示应用程序期望的额外信息来完成的。甚至可以修改受影响值的输入字段的样式,例如将其变为红色。
对于这种情况,Thymeleaf 提供了多种工具来显示这些类型的错误。以下列出了一些名为 title 的模型属性或字段的示例:

如果违反了约束并且应该显示消息,Bean Validation 框架提供了一个默认的错误消息。
利用 Spring 的验证功能
目标是使用 Spring 和 Thymeleaf 验证数据。完成步骤如下:
-
打开位于
bit.ly/2p38GIR的项目。 -
打开
CreateValidatedBlogPostCommand类,并按以下方式完成该类:
@Data
public class CreateValidatedBlogPostCommand {
@NotBlank
@Size(max = 140)
private String title;
@Size(min = 3, max = 60, message = "{slug.size}")
private String slug;
@NotBlank
private String content;
private boolean visible;
}
- 打开
BlogPostController类,并插入以下处理方法:
@GetMapping("new-validated-bean")
public ModelAndView renderFormViewForValidatedBean() {
CreateValidatedBlogPostCommand
createValidatedBlogPostCommand =
new CreateValidatedBlogPostCommand();
createValidatedBlogPostCommand.setTitle("Default Title");
return new ModelAndView("blogposts/form-validated-bean",
"createValidatedBlogPostCommand",
createValidatedBlogPostCommand);
}
@PostMapping("create-validated-bean")
public String createBlogPostFromValidatedBean(
@Validated @ModelAttribute
CreateValidatedBlogPostCommand
createValidatedBlogPostCommand,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
return "blogposts/form-validated-bean";
}
BlogPost createdBlogPost =
createBlogPost(
createValidatedBlogPostCommand.getTitle(),
createValidatedBlogPostCommand.getSlug(),
createValidatedBlogPostCommand.getContent(),
createValidatedBlogPostCommand.isVisible());
model.addAttribute("blogPost", createdBlogPost);
return "blogposts/show";
}
- 打开
src/main/resource/templates/form-validated-bean.html文件,并插入以下表单:
<form action="#"
th:action="@{/blogposts/create-validated-bean}"
th:object="${createValidatedBlogPostCommand}"
// […]
</div>
</div>
<button type="submit"
class="btn btn-primary">Submit</button>
</form>
前往bit.ly/2Ofv2Su以访问表单的代码。
-
使用
mvnw spring-boot:run命令启动应用程序。 -
使用您的浏览器打开 URL
http://localhost:8080/blogposts/new-validated-bean以获取以下输出页面:

-
现在输入详细信息并点击提交。
-
您现在应该看到以下页面,总结您输入的值:

通过这种方式,您已经利用了 Spring 的内置验证功能来测试数据。
前往bit.ly/2p4Sgjo访问FormhandlingIntroApplication.java文件的完整代码。
自定义验证消息
默认情况下,Bean 验证框架为最常见的验证约束违规提供了消息。然而,在许多情况下,您可能想要自定义这些消息或提供多语言的消息。
每个验证注解还包括一个消息属性,可以用来设置自定义消息。这可以通过直接编码到源代码中的静态消息来完成。
例如,@Size(min = 3, max = 60, message = "The size is incorrect")将显示消息。如果相应的字段不满足此要求,则大小不正确。
与将消息添加到应用程序的源代码相比,将文本外部化是一种更好的做法。为了实现这一点,首先需要在src/main/resources文件夹中创建一个ValidationMessages.properties文件。一旦创建,消息就会添加到这个文件中,并使用一个唯一的消息键。遵循命名约定是个好主意。以下是一个消息属性的示例:
slug.size=Size has to be between 3 and 60!
在注释中,可以通过将键放在花括号中来引用此消息:
@Size(min = 3, max = 60, message = "{slug.size}")
private String slug;
验证框架将自动从ValidationMessages.properties文件中获取消息。
为验证器设置新的默认消息
另一种自定义验证错误消息的方法是为验证器提供一个新的默认错误消息。这也在ValidationMessages.properties文件中完成。在这种情况下,使用全类名后跟.message 作为属性键:
javax.validation.constraints.NotBlank.message=This field must not
be blank!
在这种情况下,所有使用@NotBlank注解注解的属性在违反规则的情况下将使用提供的信息,前提是没有使用注解的消息属性设置隐式消息。
为 Bean 属性设置消息
设置自定义消息的第三种方式是在模型中指定属性的名称。与 Validation Framework 提供的先前机制相比,这一机制由 Spring MVC 处理。
这次,消息存储在src/main/resources/messages.properties文件中。以下示例演示了如何构建属性键:
NotBlank.createValidatedBlogPostCommand.title=The title must not
be blank!
首先,使用用于验证的注解名称(例如,@NotBlank)。然后是模型属性名称(在这种情况下,它是createValidatedBlogPostCommand)。最后,将模型类中属性的路径附加到后面(标题属性)。
在前面的例子中,如果createValidatedBlogPostCommand模型属性的标题属性为空,则会显示消息“标题不能为空!”。对于任何其他带有@NotBlank注解的字段,消息将根据之前描述的机制确定。
为消息提供翻译
可以应用于消息的最终改进是在多种语言中提供自定义验证消息。
除了默认属性文件外,具有相同名称的messages.properties和ValidationMessages.properties文件后面跟着语言代码。例如,messages_de.properties和ValidationMessages_de.properties将提供所有自定义消息的德语翻译。
修改验证消息
目标是修改和翻译验证消息。
-
打开位于
bit.ly/2p38GIR的项目。 -
打开
src/main/resources/messages.properties文件并添加以下行:
NotBlank.createValidatedBlogPostCommand.title=The title
must not be blank!
- 打开
src/main/resources/messages_de.properties文件并添加以下行:
NotBlank.createValidatedBlogPostCommand.title=Der Titel
darf nicht leer sein!
-
使用
mvnw spring-boot:run命令启动应用程序。 -
使用您的浏览器打开 URL
http://localhost:8080/blogposts/new-validated-bean。 -
清除标题字段并提交表单以查看输出:

通过这种方式,您已自定义了验证消息,并为另一种语言提供了翻译。
前往bit.ly/2p4Sgjo访问FormhandlingIntroApplication.java文件的完整代码。
表单输入类型和值绑定
在查看数据如何在视图和控制器之间传递之后,本节将使我们能够使用 HTML 模板引擎 Thymeleaf 中的不同类型的输入字段。这些字段的值需要绑定到控制器要处理的模型属性。
使用 HTML 表单的一个重要方面是文本、数字或其他类型值之间没有区别。然而,Java 基于强类型系统,需要显式类型。因此,值必须从或转换为文本表示。这项任务由可用的转换器类完成,这些类适用于最常见的 Java 类型,如数字、枚举或布尔值。Spring 将自动找到适当的转换器实现,以将值从或转换为文本表示。
更多关于类型转换以及如何实现自定义转换器的信息可以在 Spring 框架文档中找到,具体位置为docs.spring.io/spring/docs/current/spring-frameworkreference/core.html#core-convert。
在本章的剩余部分,我们将专注于在 Spring MVC Web 应用程序中使用 Thymeleaf 收集用户输入的常用表单元素。
你可以在配套的仓库中找到所有示例代码,具体位置为bit.ly/2QIcXxv。你可以通过运行mvnw spring-boot:run或使用 IDE 来启动应用程序。
输入文本或数字的元素(文本、隐藏、密码)
下表列出了输入字段最常见的使用案例:

.../src/main/resources/templates/inputform.html文件展示了在 Thymeleaf 中如何使用<input>标签。需要注意的是,根据你需要如何引用模型属性,重要的属性是th:value或th:field:
<div>
<label for="textValue">Text Value:</label>
<input type="text" id="textValue" th:field="*{textValue}">
</div>
<div>
<label for="numberValue">Number Value</label>
<input type="number" id="numberValue"
th:field="*{numberValue}">
</div>
<div>
<label for="passwordValue">Password Value</label>
<input type="password" id="passwordValue"
th:field="*{passwordValue}">
</div>
<div>
<label for="hiddenValue">Hidden Value</label>
<input type="hidden" id="hiddenValue"
th:field="*{hiddenValue}">
</div>
输入字段的类型远不止这里讨论的这些。然而,将要传输的值总是某种文本表示。关于这些类型及其使用的完整列表,可以在developer.mozilla.org/en/docs/Web/HTML/Element/Input或html.spec.whatwg.org/multipage/forms.html的 Mozilla 开发者文档(MDN)中找到。
输入选择元素(复选框、单选按钮)
我们现在将讨论的另一组输入字段是那些通过旋转、选择或取消选择来选择单个选项,或者从选项列表中选择一个元素的输入字段。
复选框:通常在启用或禁用选项时使用布尔值。
单选按钮:通常在从选项列表中选择一个值时使用纯文本或数字。
当涉及到单选按钮元素时,所有属于同一组的输入字段都应该引用我们模型的相同字段或属性。因此,每个输入元素都将附加相同的th:field属性:
<input type="radio"
id="radioVal1"
value="Radio Value 1"
th:field="*{radioValue}">
<label for="radioVal1">Radio Value1</label>
<input type="radio"
id="radioVal2"
value="Radio Value 2"
th:field="*{radioValue}">
<label for="radioVal2">Radio Value 2</label>
在.../src/main/resources/templates/inputform.html文件的前一个示例中,你可以看到两个单选字段都引用了名为radioValue的字段。由于 Thymeleaf 将处理字段命名,因此可以省略 HTML 的 name 属性。
编写长列表的单选选项可能会变得相当复杂。因此,Thymeleaf 提供了一套工具来简化这项任务:
<div
th:each="dynamicRadioOption : ${dynamicRadioOptions}">
<input type="radio"
th:field="*{dynamicRadioValue}"
th:value="${dynamicRadioOption}"/>
<label th:for="${#ids.prev('dynamicRadioValue')}"
th:text="#{${'radioValue.' + dynamicRadioOption}}">
Value
</label>
</div>
在这个例子中,取自同一文件,模型属性dynamicRadioOptions提供了一个选项列表。对于这个列表的每个元素,都会生成一个单选类型的输入字段和一个标签。
注意 Thymeleaf #ids.prev() 函数的使用,它指的是前一个输入字段的 ID。
用户交互的输入元素(提交)
最后也许是最重要的输入元素是提交类型的输入元素。它渲染一个按钮,该按钮将提交它所在的表单:

<input type="submit" value="Submit">
选择(单选,多选)
在 HTML 表单中使用的另一个重要输入元素是 <select> 元素。它允许您通过显示下拉列表或内联列表来获取用户输入:
<div>
<label th:for="${#ids.next('singleSelectValue')}">
Single Select Value</label>
<select th:field="*{singleSelectValue}">
<option th:each="selectOption : ${allSelectOptions}"
th:value="${selectOption}"
th:text="#{${'selectOption.' + selectOption}}">
selectOption
</option>
</select>
</div>
前面的示例将渲染一个下拉菜单,而下面的示例,我们只添加了大小属性,渲染一个内联列表:
<div>
<label th:for="${#ids.next('singleListValue')}">
Single List Value
</label>
<select th:field="*{singleListValue}" size="5">
<option th:each="selectOption : ${allSelectOptions}"
th:value="${selectOption}"
th:text="#{${'selectOption.' + selectOption}}">
selectOption
</option>
</select>
</div>
这两个示例都可以在 .../src/main/resources/templates/selectform.html 文件中找到。
此示例还演示了确定元素 ID 的另一种方法。在这种情况下,${#ids.next()} 函数将返回下一个字段元素的 ID。
<select> 标签将具有与 <input> 元素相同类型的值。这意味着模型属性将被转换为或从其文本表示形式转换。如果需要,将执行转换。如前例所示,选项的渲染类似于单选输入元素。
选择多个值
以下代码片段说明了如何允许从列表中选择多个值:
<div>
<label th:for="${#ids.next('multipleListValue')}">
Single List Value
</label>
<select th:field="*{multipleListValue}"
size="5"
multiple="multiple">
<option th:each="selectOption : ${allSelectOptions}"
th:value="${selectOption}"
th:text="#{${'selectOption.' + selectOption}}">
selectOption
</option>
</select>
</div>
这个示例可以在与之前示例相同的文件中找到。
文本区域(常规,不安全内容)
最后要查看的元素是 <textarea> 元素。它用于输入长文本。其用法与常规文本 <input> 字段没有太大区别。要将模型值绑定到 textarea 元素,使用相同的 th:value 或 th:field 属性,就像之前一样:
<textarea th:field="*{textareaValue}"
th:rows="${textareaRows}"
cols="40"></textarea>
此示例可以在 .../src/main/resources/templates/textareaform.html 文件中找到。
重要的是要注意,Thymeleaf 还提供了 textarea 的行和列属性的属性。如前例所示,行数由名为 textareaRows 的模型属性提供。
在使用 textarea 可以最好地展示收集文本输入的一个方面,但无论何时允许自由文本输入,这一点都很重要。
安全/不安全文本
为了防止代码注入,用于将文本渲染到页面上的 th:text 属性将编码任何文本,使其在 HTML 页面中可见。例如,如果值包含文本 <input>,它将被编码并输出为 <input>,以确保正确显示。然而,可能存在需要显示未修改值的用例。在这种情况下,您使用 th:utext 属性,它告诉 Thymeleaf 输出内容而不进行任何修改。
如果你使用th:utext显示用户生成的内容,你将容易受到XSS(跨站脚本攻击)的影响,所以请确保你只使用它来生成或以其他方式清理的 HTML 内容。
以下代码片段来自.../src/main/resources/templates/textareadisplay.html文件,展示了这一概念:
<div>
<span th:text="*{safeTextValue}">safeTextValue</span>
</div>
<h2>Text Area for Unsafe Text</h2>
<div>
<span th:utext="*{unsafeTextValue}">unsafeTextValue</span>
</div>
利用 Thymeleaf 中的复选框
目标是添加复选框并在 Thymeleaf 中条件性地显示内容。完成步骤如下:
-
打开位于
bit.ly/2QIcXxv的项目。 -
打开
BlogPost类并添加以下属性:
private boolean slugVisible;
- 打开
BlogPostController类,并用以下实现替换现有的initBlogPost()方法:
private BlogPost initBlogPost() {
BlogPost blogPost = new BlogPost();
blogPost.setSlugVisible(true);
return blogPost;
}
- 打开
src/main/resources/templates/blogpostform.html文件,并在标记的位置插入以下代码:
<div class="form-group">
<label for="title">Slug <input type="checkbox"
id="slugVisible"
th:field="*{slugVisible}"></label>
<input type="text"
th:if="*{slugVisible}"
class="form-control"
id="slug"
th:field="*{slug}"
placeholder="Blog Post Slug">
</div>
- 启动应用程序,并在浏览器中打开
http://localhost:8080:

- 现在禁用Slug标签后面的复选框,并点击提交,你将看到以下表单:

通过这种方式,你可以根据应用程序状态显示或隐藏表单的部分。
前往bit.ly/2p6hIol访问InputTypesAndValueBindingApplication.java文件的代码。
活动:创建一个输入新博客文章类别的页面
目标
为了实现类别和博客文章的模型视图控制器,并扩展博客文章以选择一个类别。
场景
Blogmania 应用程序能够显示和捕获博客文章。现在,你想要向现有应用程序添加设置类别的能力。
完成步骤
-
打开位于
bit.ly/2Ft1iBQ的项目。 -
从包文件夹中打开空的类别模型类。
-
向模型类添加一个名为
name的字符串类型属性,并使用 Lombok 注解生成数据类的所有方法。 -
在与类别类相同的包中打开控制器类。
-
添加一个类型为
list<Category>的字段,用于存储所有可用的类别。 -
添加一个方法来初始化一个新的空类别并将其作为属性添加到模型中。
-
为
/categories添加一个带有 POST 请求映射的方法。 -
实现当前空的方法以返回所有类别的列表。
-
打开将包含类别表单的文件。
-
添加一个输入类别名称的表单。
-
打开用于编辑博客文章的表单文件。
-
向表单中添加一个下拉字段以生成所有选项。
-
启动应用程序,并在浏览器中打开
http://localhost:8080以查看输出。看看以下截图:

- 现在点击添加类别:

-
输入类别标题并点击保存。
-
添加尽可能多的类别。
-
现在点击右上角的加号(+):

-
输入一个博客文章并查看分类列表。
-
点击保存后,所选分类应出现在博客文章标题下方:

结果
实现了分类的模型视图和控制器,并将博客文章扩展为选择一个分类。
前往bit.ly/2MwgE6Q访问BlogmaniaApplication.java文件的完整代码。要参考详细步骤,请参阅本书末尾的解决方案部分,第 255 页。
摘要
在本章中,我们探讨了如何使用 HTTP 请求将数据从浏览器发送到 Web 应用。之后,我们讨论了多个参数的使用与表单后端 Bean 之间的区别。接着,我们发现了如何验证 Beans 以及自定义默认错误信息。然后,我们讨论了各种表单输入字段,如文本输入、下拉菜单和复选框。完成本章后,你现在能够基于 Spring Boot、Spring Web MVC 和 Thymeleaf 模板引擎构建 Web 应用。
在下一章中,我们将探讨 RESTful API,与 HTML 视图和表单不同,这些 API 旨在服务或机器之间的通信。
第七章:RESTful APIs
在本章中,我们将学习 REST 的基础知识以及如何使用 Postman 和 Spring 访问和编写 REST API。最后,我们将构建一个具有 REST 接口的应用程序。
Postman 是一个通过构建请求和读取响应与 HTTP API 交互的图形界面应用程序。
到本章结束时,你将能够:
-
解释 REST 的基本原理
-
使用 Postman 访问公共和个人 REST API
-
使用 Spring 编写 REST API
-
基于 blogmania 应用程序构建具有 REST 接口的应用程序
什么是 RESTful API?
应用程序编程接口(API)是面向机器(或其他软件产品)而不是人的软件的访问点。最近它成了一种流行语,尽管这个概念已经存在了几十年。
API,尤其是(和最近)REST API,是通信服务的骨干。它们是现代分布式云应用程序工作的基础。它们也是现代浏览器用户界面与其后端服务通信的方式。
对于通信服务,存在不同的 API 风格。你能想到哪些?以下是一些例子:
-
RPC
-
SOAP
-
REST
REST – 正式定义
缩写 REST 代表 REpresentational State Transfer。这个术语是在 Roy Fielding 的具有影响力的作品《Architectural Styles and the Design of Network-Based Software Architectures》(加州大学欧文分校,2000 年)中提出的。由于这是他的博士论文,实际上很少有人真正阅读过它,而且它远远超出了 Spring Boot 书籍的范围。
Fielding 的原始作品可在网上查看:www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm
供您参考。
为了获得更实际的观点,类似于本书中我们将要采取的方法,你可以访问 searchmicroservices.techtarget.com/definition/REST-representational-state-transfer。
RESTful API – 实践者定义
对于程序员来说,一套稍微宽松的规则就足够日常工作了。另一方面,我们在 REST 架构允许不同选择的地方使用一些固定的默认值:
-
使用所有动词的 HTTP 调用——RESTful API 中的调用是通过众所周知的 HTTP 协议完成的。这使我们能够使用所有网络基础设施,如负载均衡器或缓存。除了 GET 和 POST 调用外,RESTful API 还使用
PUT和DELETE。 -
在 URL 或头信息中传递选项——通常将真实数据放在请求体中,但只有影响搜索或响应请求表示的选项会放在 URL 参数或甚至 HTTP 头中。
-
响应状态作为 HTTP 状态码——HTTP 提供了广泛的各种响应码。在 WWW 上,你通常只看到 200(OK)和 404(未找到),但有一些代码表示请求格式不正确或创建了新的实体。
-
请求和响应有效载荷以 JSON 格式——JSON 现在是事实上的标准。当然,这排除了像图像这样的二进制数据,它们以原样传输。
在 Postman 中展示 Yes/No API
当第一次启动 Postman 时,你必须通过两个对话框:第一个提示你注册,但有一个链接可以带你去应用而无需注册。第二个创建一个查询集合,Postman 至少需要一个这样的集合,即使你根本不打算保存你的查询。
一旦进入主窗口,将yesno.wtf/api输入到 URL 字段中。在这个字段前面有一个 HTTP 方法的下拉菜单;保持它为 GET。按下 Enter 键(或发送按钮)后,请求将被执行,你可以看到响应状态(应该是 200 OK)和以 JSON 格式显示的响应体。
响应体包含一个名为answer的字段,其值为是或否。为了好玩,你可以点击并跟随链接到图片:

REST 指导原则
现在你已经看到了 REST 调用的实际操作,让我们更详细地回顾一下一个好的 REST API 应该遵循的一些指导原则。
网络浏览器可以导航到任何类型的网站。就像这样,REST 客户端应该能够导航到 REST API。(当然,在实践中,这受限于机器的智能。)这意味着,理想情况下,你需要的只是一个 API 的起点,然后所有导航都只遵循先前响应中嵌入的链接。这个原则被称为超文本或超媒体——是的,HTML(超文本标记语言)和HTTP(超文本传输协议)中的HT部分都是这个意思。
第二个原则是正确使用 HTTP 动词。它们有很多,其中一些相当技术性,只与协议本身有关,而另一些则用于非常特殊的目的。
REST 资源示例
看一下以下表格,它突出了主要方法:

第三个原则是使用资源而不是远程过程调用。当涉及到端点的命名时,这一点变得非常明显:端点不是动词(毕竟,HTTP 动词就是为此而设计的),而是名词。
为了可视化资源的生命周期,请看以下图表:

利用 SWAPI
目标是查看一个良好的 REST API 在实际中的应用。我们将探索或测试一个现有的 API。不幸的是,一开始,这将是一个只读 API,所以我们只会看到 GET 请求。我们在这里使用的 API 是SWAPI,星球大战 API。这是一个允许你浏览星球大战宇宙中的行星、宇宙飞船、车辆、人物、电影和物种的 REST API。这个 API 是公开的,不受访问控制。
在开始之前,你应该打开 Postman。如果你的 Postman 应用是新安装的,你可以告诉它你不想在第一次提示时注册。在第二次,你必须创建一个集合(任何名字都可以,尽管默认可能是一个不错的选择)。你会看到主窗口。根据窗口的大小,请求历史可能显示或不显示。
完成步骤如下:
-
前往 SWAPI 的入口 URL:
swapi.co/api/. -
你会看到一个指向其他资源的链接集合:一个用于人物的资源,一个用于行星的资源,等等。跟随人物的资源。
在步骤 2中,你得到的不只是一个人物的答案,因为你访问了一个集合资源。这里有两点需要注意:首先,它不是一个包含所有人物的列表,而只有前十个,还有一个链接指向下一个十个(如果适用,你可能会得到一个指向前十个的链接)。其次,每个条目都有一个指向自身的链接,而不仅仅是一个可以用来构造这种链接的 ID。
- 点击 Luke 的链接。
然而,步骤 3的结果是一个单独的条目:一个以 ID 结尾的资源:

- 在响应的头部区域查看,找到Content-Type头部以确认服务器发送了 JSON:

在“Body”区域,你可以选择查看服务器的原始答案,而不是 Postman 向我们展示的格式化版本。
- 现在,做一些研究:Luke 的家乡在哪里?
你可能会发现 Luke 的家乡是塔图因,但这里要注意的重要事情是,你可以像在浏览器中的传统网页上一样跟随链接和探索数据。
本章的所有源代码,包括一些可以导入到 Postman 中的 JSON 格式的 Postman 集合,可以在配套的 GitHub 仓库中找到:bit.ly/2QxGni4。
看一下下面的 Postman 截图:

Spring 中的 REST 控制器
既然我们已经回顾了 REST API 是什么,你可能想知道如何使用 Spring 来操作它们。你会发现你已经知道大部分你需要的东西了。关键是 Spring 提供的那些小增强功能。
什么使 REST 控制器与众不同?
你在前面的章节中已经看到了许多控制器函数。Spring 在控制器函数的签名方面非常灵活,无论是它们接受的参数还是返回的结果;但在大多数情况下,这种控制器函数的返回值并不是实际发送到浏览器的响应,而是一个视图名称,然后这个名称会被解析为静态内容或由模板引擎渲染的视图。
对于 REST 控制器来说,这有所不同——我们希望返回数据。我们可以直接这样做,或者使用隐式映射器,正如你将要看到的。你已经在 第四章:MVC 模式,在处理器的附加注解和参数] 中看到了。
方法 部分如何从控制器返回数据而不是视图名称——控制器需要使用 @ResponseBody 注解。
实际上,这就是你需要了解的关于 REST 控制器的一切。
本节的其余部分将向你展示如何实现我们之前介绍的所有 REST 指导原则。这些高级元素并非 REST 特有的,有时在正常的 Spring MVC 上下文中也可能很有用,但它们对于实现 REST 控制器是必不可少的。
“响应体”详细说明
在本节中,我们将通过逐步展示代码片段来介绍主要概念。更完整的代码,作为前一章博客应用的扩展(将在稍后提供)。
让我们考虑以下代码片段,这是一个完整的 Spring 控制器类(仅缺少导入部分):
@Controller
public class HelloWorldController {
@RequestMapping("/api/greeting/string")
@ResponseBody
public String string() {
return "Greeting";
}
}
记住,@Controller 注解也将这个类标记为 Spring 组件,它将在 classpath 扫描中找到。@RequestMapping 注解将一个方法标记为映射到特定路径的方法,这里以它的参数给出。最后,@ResponseBody 注解让 Spring 返回方法的返回值的实际数据,而不是使用视图名称进行映射。
现在,结果会是什么?你实际上可以在你的网络浏览器或 Postman 中跟随那个链接,但为了这份文档,我们将使用一种类似于网络上的 HTTP 通信的符号,只是省略了一些细节。如果你感兴趣,你可以通过使用 telnet 自己查看这些内容,但通常这非常方便地展示了客户端的请求和服务器端的响应:
GET /api/greeting HTTP/1.1
Content-Length: 8
Content-Type: text/plain;charset=UTF-8
Greeting
正如你所见,我们方法返回的字符串值也是服务器发送出的数据。Spring 自动填充了一些响应头——它将 Content-Length 设置为 8(这是以字节为单位的),并将响应的 Content-Type(也称为 MIME 类型或媒体类型)设置为 UTF-8 编码的纯文本。(在许多情况下,编码是一个误命名的 charset。)
当然,输出文本而不是 HTML 是好的,但如今 REST 控制器中数据交换的标准格式是 JSON,所以让我们让控制器返回这个格式。
手动返回 JSON
让我们首先尝试直接的方法。看看以下代码:
@Controller
public class HelloWorldController {
@RequestMapping("/api/greeting/fakeJson")
@ResponseBody
public String fakeJson() {
return "{\"message\":\"Hello world\"}";
}
}
这里唯一改变的是返回的字符串。它现在包含 JSON,其中包含用于在 Java 字符串中编码必要双引号的反斜杠日志。因此,请求的结果如下:
GET /api/greeting HTTP/1.1
Content-Length: 25
Content-Type: text/plain;charset=UTF-8
{"message":"Hello world"}
这里有一些可以批评的地方。首先,JSON 的编写很繁琐。Java 使得这一点非常困难,因为嵌入的双引号需要转义。JSON 可能是一个相对容易的格式,但仍然,尤其是对于比这里返回的大得多的数据结构,手动创建它是容易出错的。
其次,更重要的是,内容类型仍然设置为纯文本!一些客户端可能无论如何都会忽略内容类型(Content-Type)头,并期望返回 JSON,这些客户端将能够与我们的有缺陷的服务器一起工作。然而,行为良好的客户端将不会将其识别为 JSON。他们可能会将其接受为文本(尽管是奇怪的文本)或者完全拒绝它。
例如,Postman 会尊重内容类型(Content-Type)头。默认情况下,它将显示对控制器调用的输出为纯文本。然而,你可以手动选择 JSON,从而覆盖检测到的内容类型。
在罕见的情况下,这可能是为了满足某些接口要求而快速修补的一种方法,但肯定我们可以做得更好!
手动 JSON,正确的内容类型
前面代码的主要问题在于它为我们打算的 HTTP 响应创建了一个不正确的响应:
@Controller
public class HelloWorldController {
@RequestMapping(path = "/api/greeting/manualJson",
produces = "application/json")
@ResponseBody
public String manualJson() {
return "{\"message\":\"Hello world\"}";
}
}
在这段代码中发生变化的只有 @RequestMapping 行。我们添加了一个第二个参数来生成注解,然后它给出了内容类型 HTTP 头的值。实际上,它不是字面意义上的头,正如你一会儿会看到的;此外,重复这样的值可能会导致微妙的错误,你将在稍后看到更好的替代方案。路径参数与之前我们只有一个参数时使用的隐式值参数相同。
这个控制器的结果有了很大的改进:
GET /api/greeting HTTP/1.1
Content-Length: 25
Content-Type: application/json;charset=UTF-8
{"message":"Hello world"}
在这里我们得到了相同的内容长度,实际上相同的有效载荷,但内容类型(Content-Type)头现在指向了 JSON,正如我们想要的!注意 Spring 是如何自动添加编码的。现在,Postman 默认会将其显示为 JSON。
再次,我们将通过 Postman 访问 REST 资源进行演示,但这次访问的是我们自己的应用程序而不是互联网上的东西:
-
使用 GitHub 上提供的 "rest-intro" 项目,并在其中启动
RestIntroApplication类:导航到该类,然后按 Ctrl-Shift-F10 或点击编辑器侧边栏中的任何绿色三角形。 -
启动 Postman。你应该导入 GitHub 上可用的 Postman 集合,这样你就可以轻松地使用预定义的查询。 "REST-Intro" 集合包含了这里需要的请求。执行查询
greeting/string、greeting/fakeJson和greeting/manualJson: -
显示主体和标题窗格。第一个查询将返回一个合适的字符串,第二个返回一个混合体(看起来像 JSON 的字符串),第三个将返回一个合适的 JSON。
-
另一个缺点是:手动编写的 JSON 可能不是我们想要的。虽然在这些玩具示例中稍微复杂一些,但以下章节将向你展示如何在现实生活中处理 JSON。
将数据映射到 JSON
为了总结本节关于简单 REST 控制器的内容,这两个方法将返回合适的 JSON,但你不会在下面的代码中看到任何东西:
@Controller
public class HelloWorldController {
/** Produce JSON from a map as return value. Can also be nested.
*/
@RequestMapping("/api/greeting/mapJson")
@ResponseBody
public Map<String, Object> mapJson() {
Map<String, Object> result = new HashMap<>();
result.put("message", "Hello from map");
return result;
}
/** The data wrapper that maps to JSON */
@Data
@AllArgsConstructor
static class Message {
private String message;
}
/** Produce JSON from an object as return value. */
@RequestMapping("/api/greeting/objectJson")
@ResponseBody
public Message objectJson() {
return new Message("Hello from object");
}
}
哇!这很简单。
这两种变体产生的是完全相同的结果(除了文本本身)。这也与前面的代码相同,只是对于非字符串返回值,Spring 不会自动添加 Content-Length 头。这有点不幸,但鉴于大多数客户端很少使用它,通常是可以接受的。
这里发生了什么?当发现一个复杂的返回类型时,Spring 会在应用上下文中查找HttpMessageConverter bean 并将其处理任务转交给它。一个在 Java 对象和 JSON 之间转换的非常好的库是FasterXML的Jackson(你可以在github.com/FasterXML/jackson找到项目主页)。Spring 自带一个特定的HttpMessageConverter实现,称为MappingJackson2HttpMessageConverter,它使用 Jackson 来完成这项工作。
使用这个实现,你什么都不用做。我们用于这个 Spring MVC 项目的spring-boot-starter-web依赖项已经依赖于spring-boot-starter-json,而它反过来又依赖于 Jackson。现在,Spring 的魔法开始发挥作用:当启动时在 Java classpath中找到 Jackson,那么所有必要的 Beans 都会自动创建。这样,返回 JSON 就自然而然地成功了!
剩下的唯一问题是,你更愿意为所有不同类型的 JSON 有效负载创建专门的 Java 类,还是更愿意选择(嵌套)映射。一个是更类型安全的;另一个更灵活;选择权在你。在这本书中,我们更倾向于使用 Project Lombok 创建的 Java 部分,这是在第一章:Spring 项目和框架中引入的。
Spring 中的 REST 控制器
总结一下,请记住,大多数提到的注解不仅可以放在方法上,还可以放在类级别。其语义是,这些注解适用于类中的所有方法,
一些注解作为所有方法的默认值。如果它们没有被方法覆盖,它们就有效。一个例子是@RequestBody,它将类中所有也注解为映射到 REST 端点的所有方法转换为方法。
一些注解是可添加的。@RequestMapping 注解就是其中之一。它不会将每个方法转换为端点,但它为所有也是端点的方法提供了一个公共路径前缀。在极端情况下,如果这些方法仅在产生的 MIME 类型或 HTTP 方法上有所不同,则它们不需要指定任何路径。
@Controller 和类级别的 @RequestBody 注解的组合对于 REST 控制器来说非常常见且非常有用,因此有一个专门的注解专门用于这种情况,它并不令人意外地被称为 @RestController。这本书将使用这个注解。
实现 REST 端点
目标是通过返回 JSON 实现你的第一个 REST 端点。你有一个现有的应用程序需要另一个端点。项目已经完全设置,因此添加一个类就足够了。在开始之前,请打开 IntelliJ 中的 rest-intro 项目,并确保你可以启动 RestIntroApplication。完成步骤如下:
-
添加一个新类——你可以称它为
DateTimeController。 -
使用
@RestController注解它。使用 IntelliJ 的自动完成或 Ctrl+Space 完成注解并添加导入。 -
添加一个名为
DateTime的新内部静态类,其中包含一些字符串字段,如日期和时间。添加适当的获取器和构造函数。Lombok 在这里将非常有帮助。 -
添加一个返回新数据类实例的新方法。它不需要任何参数。
-
使用
@GetMapping注解方法,使其响应某些路径,例如/api/datetime。 -
提取当前时间和日期,将它们放入一个新的
DateTime实例中,并返回该实例。你可以使用以下代码片段:
ZonedDateTime now = ZonedDateTime.now();
String date = now.format(DateTimeFormatter.ISO_LOCAL_DATE);
String time = now.format(DateTimeFormatter.ISO_LOCAL_TIME);
- 重新运行程序,并使用 Postman 访问您的新资源。
答案当然将取决于你的本地时间和日期,但它将是 JSON(application/json)格式,大致如下:
{"date":"1989-11-09","time":"19:01:30.123","zone":"Central European Time (CET)","zoneId":"Europe/Berlin","zoneOffset":"+01:00"}
你可以通过访问 bit.ly/2qABGIY 并进入 GitHub 仓库,找到返回时区的一个可能的解决方案。走
到 bit.ly/2xaeSlT 访问 RestIntroApplicationTests.java 文件的完整代码。
内容类型
我们已经在前面的响应中看到了内容类型。
所有内容都有某种类型(或多种,混合)。人类擅长通过观察内容本身来阅读和理解内容,但计算机如果事先被告知预期内容,则工作效果最佳。为此目的,并且最初作为互联网邮件标准的扩展,RFC 2046 规定了媒体类型(以前称为 MIME 类型)。一组协议的媒体类型由 IANA 协调。
请参考以下资源以获取标准:
RFC 2046: tools.ietf.org/html/rfc2046.
IANA: www.iana.org/assignments/media-types/mediatypes.xhtml。
在 Web 应用程序上下文中最有趣的内容类型如下:
-
当将 HTML 页面返回到浏览器时使用 text/html
-
text/css、text/javascript 和 image/jpeg 用于网页中链接的内容
-
text/plain 作为纯文本的回退
-
默认格式为 application/x-www-form-urlencoded,其中浏览器将表单中输入的数据发送到浏览器
-
AJAX 调用使用 application/json
HTTP 协议有两个处理内容类型的标题:
-
在 request 中使用
Accept来指定接受的结果。这可以是一个接受类型的列表 ( -
或者标题可以重复),并且它可以包含通配符和权重来指定偏好。
此标题有如 Accept-Charset 和 Accept-Language 等伴随者,它们不太重要,这意味着您通常不会关心它们。
-
在 response 中使用
Content-Type来指定实际的结果是什么。此标题具有与 Accept 标题相似的伴随者。
生成不同的内容类型
您已经看到,当您从控制器返回不同内容时,产生的内容类型可能会改变,并且您也可以手动影响内容类型。
REST 的重要原则之一是我们充分利用 HTTP 协议,使用正确的 HTTP 方法(或动词),同时也将内容协商和缓存留给协议和中间节点。因此,如果您需要在两种不同格式中具有相同的逻辑资源,则不应有两个名为 /api/customers.json 和 /api/customers.xml 的资源。
顺便说一句,Spring 允许这样做,如果您这样配置它,但这里故意不展示。它曾是旧版本的默认设置,但现在已弃用。
以下代码似乎指定了同一资源两次:
@RestController
@RequestMapping(path = "/api/greeting")
public class ContentTypeController {
@Data
@AllArgsConstructor
private static class SimpleMessage {
private String message;
}
/** GET a greeting as text/plain content-type */
@GetMapping(produces = MediaType.TEXT_PLAIN_VALUE)
public String greetText() {
return "Hello with plain text";
}
/** GET a greeting as application/json content-type */
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public SimpleMessage greetJson() {
return new SimpleMessage("Hello with JSON");
}
}
注意资源路径仅在类级别指定。这两个函数仅提供 GET 端点,并且它们确实不同——其中一个返回一个将被发送到客户端的纯文本字符串,而另一个返回一个将被映射器转换为 JSON 的复杂对象。您已经看到了 produces 选项;它仍然是 String 类型,但这次我们使用 MediaType 类中的预定义常量来避免重复。
这两个端点将对不同的请求做出反应:
GET /api/greeting HTTP/1.1
Accept: text/plain
Content-Type: text/plain;charset=UTF-8
Hello with plain text
GET /api/greeting HTTP/1.1
Accept: application/json
Content-Type: application/json;charset=UTF-8
{"message":"Hello with JSON"}
两个请求都指向同一资源,但具有不同的 Accept 标题。如您所见,Spring 默认添加了字符集,在这种情况下是 UTF-8,到响应中。这样,客户端就能理解响应体的编码。目前,如果未提供 Accept 标题,Spring 控制器将返回 JSON,但您不应依赖于这种行为。
所需的只是不同的产品选项。Spring 会忽略方法名称,它们的不同只是为了避免名称冲突,并且也是为了记录我们的意图。
内容协商以及后续将请求分派到我们的函数之一是由DispatcherServlet完成的,您已经在第四章:MVC 模式中听说过。如果没有方法可以提供请求的(或接受的)Content-Type,则向客户端发出 HTTP 状态406 不可接受。
对 Rest-intro 应用程序响应 XML
目标是展示对 rest-intro 应用程序响应 XML。完成步骤如下:
-
在 rest-intro 项目中启动
RestIntroApplication。 -
使用 Postman 调用资源,并展示它们确实分别返回文本和 JSON。
您可以使用提供的 Postman 集合Content-Types进行操作。此外,尝试请求 XML:Postman 将显示 HTTP 响应状态406 不可接受。这是预期的,因为我们没有提供该表示形式的端点。
注意,在默认设置下,应用程序的日志对此相当安静。此外,注意您还可以请求许多其他不受支持的类型,但如果您请求 text/html,那么除了 406 状态码之外,您实际上还会得到一些显示错误的 HTML,您也可以在预览窗格中查看它。
- 现在,向资源添加返回 XML 的能力!您需要向
ContentTypeController添加一个新方法,这基本上是greetJson()的副本:
@GetMapping(produces = MediaType.APPLICATION_XML_VALUE)
public SimpleMessage greetXml() {
return new SimpleMessage("Hello with XML");
}
如果您以调试日志运行应用程序,您会看到当您请求 XML 时,Spring 会尝试使用这个新方法,但后来失败了,因为没有消息转换器。要添加一个,我们只需要在类路径上添加 Jackson XML,其余的将自动连接。将以下依赖项添加到 POM 中:
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
- 现在,运行应用程序并展示我们也响应 XML。
与您所知的 JSON 数据结构类似,答案现在将是这样的:
<SimpleMessage>
<message>Hello with XML</message>
</SimpleMessage>
前往bit.ly/2Abj4Vb访问RestIntroApplication.java文件的完整代码。
工作辅助
您也可以仅仅向现有的greetJson()方法添加第二种媒体类型,但当然,包含的文本将是相同的。不过,通常情况下,您不希望在请求不同的Content-Type时发送不同的数据。您只想发送不同的表示形式。
您可以向处理程序方法添加任意多的媒体类型。
您也可以选择完全不添加任何媒体类型,因为 Spring 会尝试找到可以生成请求的Content-Type的最佳匹配处理程序方法。
消费不同的内容类型
JSON 是目前 REST 服务中最常见的数据交换格式。然而,它曾经是 XML。接受不同格式的灵活性可能意味着更多的客户端能够连接到您的服务。
另一个需要考虑的事情是,Web 应用程序只能借助 JavaScript 发送 JSON。然而,简单的 HTML 表单也可以发出 POST 请求,但不能发送 JSON。默认格式具有 Content-Type application/x-www-form-urlencoded,这基本上是一系列键/值对。如果我们的服务器可以接受这种格式,它可以对表单操作做出反应(你在这个章节的早期已经看到了)。这种格式在其他上下文中可能甚至更可取,因为它不需要预检请求。
关于跨源资源共享的讨论,请参阅 developer.mozilla.org/en-US/docs/Web/HTTP/CORS。特别是要注意,“简单请求”不需要预检,这是一个客户端会发出以确定允许方法的额外请求。这样的简单请求可能只有 Content-Type of application/xwww-form-urlencoded,而不是 JSON。避免预检请求可能会在延迟较高的网络上显著加快客户端应用程序的速度。
让我们通过代码来看一下:
@RestController
@RequestMapping(path = "/api/greeting")
public class ContentTypeController {
@Data
// […]
@RequestBody SpecificMessage input) {
return new SpecificMessage(input.addressee,
"Re: " + input.message);
}
}
访问完整代码,请前往 bit.ly/2pVBBz7。
映射使用与之前相同的端点,但现在使用 POST 方法,这使得分发成为可能。调用这些方法的结果如下:
POST /api/greeting HTTP/1.1
Content-Type: application/x-www-form-urlencoded
addressee=Peter
{"addressee":"Peter","message":"Hello Peter"}
POST /api/greeting HTTP/1.1
Content-Type: application/json
{
"addressee": "Paul",
"message": "Answer me"
}
{"addressee":"Paul","message":"Re: Answer me"}
这(几乎)与在映射注解上放置 consumes 选项一样简单。然而,由于历史原因,表单值与 JSON 的处理方式略有不同。对于 JSON 和其他具有映射器(例如 XML)的格式,我们在 Java 方法的形式参数上使用 @RequestBody 注解。这很好地说明了实际参数的值应从请求体中获取。
对于表单编码,每个预期的表单值都单独传递给一个带有 @RequestParameter 注解的参数(如果没有覆盖,则通过形式参数的名称映射)。请注意,这与我们用于提取 URL 参数的相同注解。实际上,一个没有在映射上设置 consumes 选项但具有 @RequestParameter 注解的方法将能够从请求体的表单编码值和 URL 参数中获取值。
对于 GET 请求,携带请求体是不可移植的。HTTP 规范不要求对 GET 进行处理,并且中间代理和客户端可能会在发送时删除体。因此,GET 映射不能消费任何内容。
向 GET 请求传递值的唯一可靠方式是通过 URL 参数,例如 /api/greeting?addressee=John。这是通过使用 @RequestParam 注解来完成的,在代码中看起来与接受 POST 表单相同。
HTTP 状态码和重要头信息
到目前为止,我们只关心响应体(以及内容类型,因为它与之紧密相关)。然而,HTTP 还有两个非常重要的渠道将信息传达回客户端:这些就是状态码和一系列进一步的 HTTP 标头。
每个响应都伴随着一个状态码。这是一个快速判断查询成功与否的方法,甚至在查看内容之前。状态码分为五类,在我们这个环境中重要的是以下这些:
-
2xx 范围:一切顺利。
-
4xx 范围:请求有误。
-
5xx 范围:服务器出现了不是客户端责任的问题。
对于返回码的完整列表,请参考外部资源,如 RFC 7231。当然,没有必要使用它们全部,但明智地选择可以增强你 API 的意义。
你已经看到了一些状态码,我们将在整本书中看到更多:

标头太多,甚至无法触及表面,但这里你会看到以下内容:
-
Content-Type
-
位置(使用 201 代码(见前表)或 3xx 代码)指向实际位置。
-
允许(使用 405 代码(见前表)和
OPTION请求)列出允许的请求方法。 -
Cache-Control 和 ETag(以及其他)用于控制内容应该如何缓存以及何时更新缓存中的值。
-
最后,以 X-…开头的标头通常用于各种特定于应用程序的目的。
你可以在tools.ietf.org/html/rfc7231了解更多关于 RFC 7231 的信息。
简而言之,HTTP 状态码如下:
-
1XX – 等一下
-
2XX – 这里是
-
3XX – 去别处看看
-
4XX – 你搞砸了
-
5XX – 我搞砸了
声明式状态码
控制响应状态码的最简单方法就是简单地使用@ResponseStatus注解声明它:
@RestController
@RequestMapping(path = "/api/motd")
public class MotdController {
@PutMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
public void storeMotd(@RequestBody Message message) {
// set the message of the day
// …
}
}
从 PUT 请求中返回没有有用的信息,因此该方法返回 void 并带有返回204 No Content的注释。
这是最容易的情况,特别有助于你开始。当你的需求增长时,你可能需要更精细的控制,并必须程序化地影响响应。
程序化状态码
如果状态码始终相同,无条件地设置它有什么意义呢?大多数时候,返回值以某种方式依赖于输入或整个系统的状态。如果是这种情况,我们只能在检查了一些条件之后才能决定发送哪个答案。
关键是利用响应实体,即包装返回数据和 HTTP 响应所有元数据的数据对象。因此,Spring 提供了一个参数化类型 ResponseEntity<T>,其中 T 是我们想要以 JSON 格式发送的数据。这个类提供了一系列静态函数,每个函数都创建一个响应实体构建器,逐步添加更多信息。
考虑以下代码片段:
@RestController
@RequestMapping("/api/mottos")
public class MottoController {
// […]
motto.add(message);
return ResponseEntity.ok()
.body(new Message("Accepted #" + motto.size()));
}
}
}
前往 bit.ly/2xeqODe 访问本节完整的代码。
资源 /api/mottos 是一个 REST 集合资源。使用 POST,可以将新消息发布到格言列表中。我们不希望格言重复出现,因此该方法检查唯一性,并有两个不同的路径来创建返回值。
如果格言已经存在,将使用 ResponseEntity 类的 static status() 方法创建一个新的构建器(实际上称为 BodyBuilder,这个名字可能会让你微微一笑,尽管它完全合理)。它只接受一个参数,即所需响应状态。为了实际构建具有空体的响应,调用 build()。
否则,使用 ok() 方法创建构建器;这是一个非常常见状态码的快捷方式,也可以使用 status()。这次,还发送了正确类型的正文。body() 函数最多只能使用一次,并将立即构建结果。
uniqueAppend() 方法返回类型为 ResponseEntity<Message>。但如果你仔细观察,这几乎像是作弊,因为消息只在一组两个分支中的一个被发送。碰巧的是,空体与每个泛型类型都是兼容的。
Spring 在接受处理映射函数的签名方面非常灵活(请转到 Spring 文档中的 docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc-ann-methods 以验证这一说法)。分发是在运行时完成的。
在这里我们遇到了 Java 的限制。我们不能从同一个函数中返回消息或特定的错误对象,同时保持在类型系统中。你将看到一种绕过这个限制的方法(适用于大多数错误处理应用)。如果你确实必须从一个方法中返回不同的类型,你可以将其声明为返回 ResponseEntity<?>(静态代码检查器可能会批评这种做法)或甚至 Object。你将失去编译器的类型检查,所以请确保编写适当的单元测试。
全局错误处理
每个方法中的错误处理可能非常相似且重复。对于所有方法来说,这通常是相同的。它也可能隐藏了方法的主要意图。
这些跨领域关注点通常由方面(aspects)来处理,Spring 使用了相当多的这些方面。然而,在这里,全局错误处理是通过不同的机制提供的,这被称为异常处理器。
一个用@ExceptionHandler注解的方法可以处理同一控制器(或,使用@ControllerAdvice,更全局地,这将在稍后解释)内的多个处理器映射的异常。
JavaScript 评估器
让我们实现一个控制器,它有多个可能的问题原因,并且在不使业务逻辑混乱的情况下实现它:
@RestController
public class JavaScriptController {
@Value
private static class Message {
private String message;
}
// […]
Object eval = javaScript.eval(expression);
return new Message("Evaluation of " + expression +
" yields " + eval);
}
}
前往bit.ly/2x9HZWM访问本节完整的代码。
控制器接受一个 JavaScript 表达式作为参数,并将结果作为 JSON 封装的消息返回。在将参数直接输入内置的 JavaScript 解释器之前,它包含一些参数检查。请注意,由于安全影响,你不想在受保护的环境之外做这件事,但作为一个例子,这是可以的。
现在,可能会出什么问题呢?
一方面是我们自己抛出的IllegalArgumentException,另一方面是 Nashorn 可能抛出的已检查的ScriptException和另一个未检查的ParserException。我们现在实施的错误处理只将一个异常替换为一个更具有信息量的异常。异常将逃逸出我们的处理器映射。Spring 将如何处理这个问题?答案是,默认情况下,它将生成一个相当有用的错误对象和一个响应状态码为500 内部服务器错误。
这并不完全令人满意,因为错误实际上是在客户端,我们应该正确地向客户端发出信号。为了处理这两个异常,我们在类中添加了两个错误处理器:
@RestController
public class JavaScriptController {
// handle the explicitly thrown runtime exception
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public String handleArgument(IllegalArgumentException e) {
return e.getMessage();
}
// handle the parser's exceptions
@ExceptionHandler({ParserException.class, ScriptException.
class})
@ResponseStatus(HttpStatus.NOT_ACCEPTABLE)
public String handleParse(Exception e) {
return e.getMessage();
}
}
handleArgument()方法处理IllegalArgumentException,而handleParse()处理两个 Nashorn 异常。异常参数完全是可选的,可以具有任何兼容类型。再次强调,签名非常灵活,我们可以选择返回一个ResponseEntity<>以获取一个专门的错误对象。
同时建议多个控制器
建议(Advices),在 Spring MVC 中,是声明处理器函数某些方面的方法。Spring 中有三种这样的建议,其中两种超出了这本入门书的范围(但你已经在第四章中简要地看到了第二种):
-
@InitBinder— 用于配置从原始参数到自定义域对象的自动转换。 -
@ModelAttribute— 用于控制控制器模型中的常见属性(如数据)。 -
@ExceptionHandler— 用于将抛出的异常转换为适当的响应。
所有这些都是在它们被定义的控制器范围内。
有时,我们希望采用更全局的方法。为此,我们需要定义一个带有 @ControllerAdvice 注解的 Spring Bean。当我们不需要在异常处理器上进行视图解析,而是需要在响应体上进行消息转换时,我们可以在适当的位置放置 @ResponseBody,或者简单地在建议类上使用 @RestControllerAdvice 注解。
默认情况下,此类控制器建议注解对所有控制器都是全局的。为了缩小范围,有几种选项可以限制注解的范围到某些包,甚至到特定的注解。一个非常有用的例子如下:
// Target all controllers annotated with @RestController
@RestControllerAdvice(annotations = RestController.class)
public class AdviceForAllRestControllers {
// …
}
控制头部
为了使响应更加完美,我们可能想要控制响应头部。我们从 ResponseEntity 中的静态函数获得的构建器实际上是 HeaderBuilder 的扩展,因此我们可以用它来构建头部和正文。
在最基本的形式中,我们可以使用此构建器上的 header() 方法。对于一些常见的头部,存在专门的函数。看看以下示例:
// good GET answer that controls how long the client shall cache
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS))
.eTag(Integer.toHexString(message.hashCode()))
.body(message);
// good POST answer that contains Location header
return ResponseEntity
.created(URI.create("/api/mottos/" + motto.size()))
.header("X-Copyright", "Packt 2018")
.build();
GET 请求可以从缓存中受益很大。为了使其工作,服务器需要知道缓存值的时长。或者,有时客户端也可能在请求中发送一些数据,服务器可以根据这些数据确定内容在此期间是否已更改。不深入细节,前面的代码添加了 Cache-Control 和 ETag 头部。
与规范性的 RFC 相比,当您想了解更多关于 HTTP 的信息时,您可能想阅读 Mozilla 开发网络上的优秀文档(例如,关于 ETag 的 developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)。
如前所述,在向列表资源发送请求时,新实体将分配一个新的 ID,客户端无法知道。为了将此信息传达给客户端,我们设置 Location 头部,其中包含指向新创建实体的 URL。Spring 通过 created() 函数的参数支持这一点。
最后,作为一个例子,我们设置了 X-Copyright 头部。以 X-… 开头的头部没有标准化,可以用于特定应用程序的目的。
返回不同的状态码
代码审查要求您不要抛出可以轻松避免的异常,因此您应该修改 retrieveById() 的代码以检查其参数。(这是一个虚构的场景,其中您在控制器中找到的风格相当可接受,但仍然是一个品味问题。)
目标是使用不同的策略来返回不同的状态码。正如我们在开始之前所做的那样,打开 rest-intro 项目。导航到 MottoController 并注意,实际上它比脚本中之前展示的更有用:它还有一个使用 @ExceptionHandler 返回 404 Not Found 消息的 GET 实现方式。完成步骤如下:
-
将
retrieveById()的返回值更改为ResponseEntity<Message>。 -
添加一个条件来检查 ID 是否在接受的索引范围内(使用
id < 1 || id > motto.size())。 -
对于超出范围的用例,返回一个带有 HTTP 状态404 Not Found的
ResponseEntity。 -
对于在范围内的用例,返回一个带有 HTTP 状态 200 OK 和正确格言的
ResponseEntity。 -
最后,您可以移除现在多余的
@ExceptionHandler。 -
使用 Postman 检查端点是否仍然以相同的方式反应。
程序观察到的行为没有改变,但我们正在使用一种替代方式来实现结果。
您可以使用ResponseEntity.ok()来创建响应实体构建器,然后添加正文,或者直接将正文放入ok()调用中作为快捷方式。
您可以使用ResponseEntity.notFound()或ResponseEntity.status(HttpStatus.NOT_FOUND)来创建响应实体构建器。然而,虽然第二个选择返回一个适当的BodyBuilder,但第一个选择只返回一个HeaderBuilder。这意味着您不能添加正文。Spring 决定这样做是不幸的。前往bit.ly/2CZSx0S访问MottoController.java文件的完整代码。您必须首先使用例如mottos #1的 POST 请求,否则任何请求都会返回404,因为列表为空。
您可以在 GitHub 仓库中找到一个可能的解决方案,或者您可以看到这里显示的摘录:
@Data
@NoArgsConstructor
@AllArgsConstructor
private static class Message {
private String message;
}
@GetMapping("/{id}")
public ResponseEntity<Message> retrieveById(
@PathVariable int id) {
if (id < 1 || id > motto.size()) {
return ResponseEntity.notFound().build();
} else {
return ResponseEntity.ok(motto.get(id - 1));
}
}
使用 Spring Boot 的超媒体
超媒体是超文本的扩展,允许处理声音和视频。它是 REST 的关键方面,因为它能够创建将客户端和服务器解耦的服务,从而允许它们独立开发。
HATEOAS 简介
想象一下有人给你提供了一个网站的链接。通常,这是一个指向网站顶级页面的链接,甚至是唯一的页面;通常,您只有这个链接。当您查看页面时,您开始阅读和探索——您跟随链接到其他页面,继续阅读,并跟随其他链接。您只需通过跟随链接就可以发现整个网站。
REST API 难道不应该是一样的吗?通过遵循嵌入在答案中的链接,它可以被完全发现,所以您需要的只是起始 URL。
这通常被称为超媒体或超文本。它是 Fielding 原始工作中主要 REST 原则的一部分,同时也是许多 REST API 中最常违反的性质。然而,它不仅仅是一个学术提议;有一些 API 非常遵循这个原则。记得本章的开头,当我们从单个条目链接中发现 SWAPI 星球大战 API 吗?
这个原则被称为超媒体作为应用状态引擎,或者称为难以驾驭且难以发音的缩写HATEOAS。正确使用时,它为服务器和客户端提供了更多的灵活性。
考虑以下用例:
一篇博客条目有一个作者。你如何在 REST 响应中表示这一点?我们可以简单地嵌入作者,但会失去反向关系。我们可以返回作者的 ID,但客户端如何知道使用哪个 URL 来使用这个 ID?最后,我们可以直接返回一个指向作者的链接,解决这两个问题。
-
REST 响应可能包含针对客户端不同区域的多个链接。这提供了一个单一的入口点,并结合基于位置的负载均衡。
-
当更改端点时,我们会失去向后兼容性。然而,如果客户端不应该访问我们应用程序中埋藏的任意 URL,而是遵循链接,我们只需更改入口点的链接列表。
实现原则目标有许多方法。主要挑战是生成所有链接,尤其是在允许维护软件的方式下。Spring HATEOAS 子项目简化了 HATEOAS 资源创建和消费的过程,并为链接创建提供了便捷的功能。在这本书中,我们只会触及表面,并添加一个链接回到实体本身。
考虑 ContentTypeController 和其端点 /api/greeting,它可以为 URL 中请求的人生成问候语。
当使用 /api/greeting?addressee=John 查询端点时,答案是以下内容:
{
"addressee": "John",
"message": "Hello John"
}
收件人在请求中给出,我们也可以在消息中看到它。现在,为什么我们要返回收件人?为了使重新创建我们用来获取资源的链接更容易。然而,这意味着我们必须使用我们对 API 的了解从参数中构造 URL。让我们将其转换为以下答案:
{
"message": "Hello John",
"_links": {
"self": {
"href": "http://localhost:8080/api/greeting?addressee=John"
}
}
}
这次,我们有一个与数据一起存储的原始链接引用。
使用 Spring HATEOAS 扩展应用程序
在 Spring HATEOAS 中,具有通过 URL 链接链接到其他对象的数据对象被称为资源(这个术语有许多过载的含义)。将你的数据转换为这种资源的一种方法是通过让它扩展 ResourceSupport 类。这个类提供了必要的映射到 JSON 以及一个 add() 方法来添加链接。假设消息是这样的对象,可以这样创建一个指向自身的链接:
message.add(
linkTo(methodOn(ContentTypeController.class)
.greetFromPath(addressee))
.withSelfRel());
这需要一些解释。linkTo() 和 methodOn() 都是 ControllerLinkBuilder 中的静态辅助函数。
外部表达式 linkTo(…).withSelfRel() 将创建链接本身,并给它一个名为 self 的关系。除了显示的 JSON 链接样式之外,还存在其他链接样式。例如,你可能见过带有 rel 属性的 HTML 锚点。
内部表达式类似于在模拟框架中可以看到的内容。看起来是对实际控制器函数的调用实际上是对代理对象的调用。这种和相当多的反射魔法的效果是,Spring HATEOAS 能够评估引用方法上存在的注解。
创建 HATEOAS 资源
现在您已经开始构建您的 REST API,并面临使其符合 HATEOAS 要求的需求。您继续使用 Spring HATEOAS 来增强 API。目标是创建一个展示自身链接的 HATEOAS 资源。
在开始之前,打开 hateoas-intro 项目。这是 rest-intro 项目的简化版本。只剩下 ContentTypeController 和一个处理方法。完成步骤如下:
- 您需要在一个 POM 中添加一个额外的依赖项。通常情况下,不需要指定版本,因为 Spring Boot 父 POM 已经包含了版本:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
-
在
ContentTypeController中,使Message类继承 Spring 的ResourceSupport。删除不再需要的addressee字段。 -
修复
greetFromPath()方法中的代码,并引入一个变量message而不是立即返回构造函数调用的结果。(您可以使用快捷键 Ctrl-Alt-V 提取变量。) -
现在,在返回消息之前,添加您已经看到的代码片段中的链接:
message.add(linkTo(methodOn(ContentTypeController.class)
.greetFromPath(addressee)).withSelfRel());
- 现在应用已完成,请在 Postman 中测试该应用。
生成的 JSON 现在包含一个 additional _links 字段,其中包含对资源的引用:
{
"message": "Hello John",
"_links": {
"self": {
"href": "http://localhost:8080/api/
greeting?addressee=John"
}
}
}
参考放置在 bit.ly/2PHxyoD 的完整代码。
前往 bit.ly/2MsadRU 访问 HateoasIntroApplicationTests.java 文件的完整代码。您甚至可以在 exercises/hateoas-intro-after 文件夹中找到解决方案。
活动:将博客文章列表作为 REST 资源创建
先决条件
我们在这个活动上构建的是我们在前几章中构建的 blogmania 应用程序。它已经有一个面向人类的前端。您可以在 bit.ly/2OxaeoF 找到源代码。它与我们在上一个活动中留下的代码相同,因此您可以重用该项目或创建一个新的项目,根据您的意愿。
目标
创建一个当前博客文章列表,该列表将作为 REST 资源,可通过 Postman 进行查询。
场景
您已经有一个面向人类的工作应用,现在需要为它添加另一个接口,使其适合其他程序作为客户端。您决定采用 RESTful 方式来实现。
完成步骤
-
拿
blogmania应用程序并找到BlogPostController。 -
编写一个 REST 控制器,在 URL
/api/blogposts下提供一个所有文章的列表。 -
为每个方法添加完整路径。
-
在类中添加一个或两个依赖项。
-
添加一个映射函数,通过 REST 获取所有博客文章。
-
启动应用程序,并使用 Postman 访问
localhost:8080/api/blogposts。我们这样做是为了查看作为 JSON 列表返回的博客文章列表:

结果
当前博客文章的列表将作为 REST 资源,可通过 Postman 进行查询。
前往bit.ly/2xezb1A访问BlogPostController.java文件的代码。前往bit.ly/2NFjris访问BlogPostRestController.java文件的代码。
前往bit.ly/2QrByao访问activity/文件夹。要参考详细步骤,请参阅本书末尾第 260 页的“解决方案”部分。
摘要
本章直接基于前几章的概念构建,扩展了你对 Spring MVC 控制器不同响应的掌握。这足够灵活,可以返回任何类型的数据,但特别适合于提供 REST API。你已经学习了 REST 原则以及如何使用 Spring 来实现这些原则。这包括返回最合适的 HTTP 状态码和多种有用的 HTTP 头信息。
目前,任何人都可以在我们的博客应用中发布文章。我们需要限制这种访问,因此我们将注意力转向下一章的安全问题。
第八章:Web 应用程序安全
在本章中,你将了解 Web 应用程序中安全性的重要性。我们将调查程序员在添加安全措施时的责任。最后,我们将扩展 blogmania 应用程序的功能。
到本章结束时,你将能够:
-
认识到 Web 应用程序中安全性的价值
-
确定程序员在 Web 应用程序安全中的作用
-
评估 Spring 在哪些领域提供安全解决方案
-
通过访问控制扩展 blogmania 应用程序
保护你的 Web 应用程序
在软件开发中,安全性至关重要。这不仅适用于在线银行或你的个人健康数据——甚至适用于非常简单的应用程序。为什么会这样呢?
想象一下,你的 Web 应用程序迄今为止取得了巨大的成功。许多人正在使用它,有些人可能在其中存储他们的数据。然后,灾难降临:通过你软件中的一个漏洞,攻击者能够劫持运行你应用程序的机器,并滥用该机器进行一些邪恶的目的。或者,结果发现人们可以阅读所有用户的日记条目。这可能会给你带来高昂的费用;这可能会破坏客户对你软件的信任!
此外,其他与安全相关的问题,如 Ashley Madison 约会网站客户数据的泄露、数百万信用卡数据的盗窃,甚至计算机病毒,可能成为值得讨论的相关章节。
设计和操作安全的软件必须从一开始就是一个主要目标。有许多安全方面很难在之后添加。让我们看看一些可能的威胁。
软件安全威胁
软件安全面临着许多威胁。整个部分很容易填满一本书。在本章中,我们只能希望获得一个广泛的概述。我们将探讨的威胁可以粗略地分为以下几类:
-
解释不受信任的数据
-
允许外国客户端访问
-
允许访问非自身拥有的资源
-
日志记录和监控不足
Web 应用程序安全信息的一个很好的来源是OWASP(开放 Web 应用程序安全项目)。请特别注意他们列出的“十大最关键 Web 应用程序安全风险”:www.owasp.org/index.php/Category:OWASP_Top_Ten_Project
解释不受信任的数据
每当应用程序从另一个应用程序接收数据时,都会有一个问题,即是否信任这些数据。有时,在一个封闭系统中,信任他人是处理数据的正确方式,但在大多数情况下,需要谨慎行事。
谨慎行事。
这是一个故意很宽泛的分类。OWASP 列出了许多可以包含在内的子问题。在这里的一般建议是仔细检查数据。没有任何一种库能够帮助解决这个问题。为了让你了解一些可能的问题,让我们看看一个非详尽的列表。
注入
注入意味着允许用户将他们的代码注入到你的代码中。这很少是一个好主意,通常也不是故意的。两个非常突出的代表是XSS(使用 JavaScript 的跨站脚本)和 SQL 注入。通常,客户端的一些文本被接受,例如,在一个网页表单的简单文本字段中,并且随后未经进一步处理就被使用。现在,想象一下表单中输入的文本如下:
<script>alert('Hello World')</script>
如果这段文本被简单地嵌入到你的网页中,文本将不会显示,而是执行这段 JavaScript!为了避免这种情况,你可以非常保守地接受内容,或者在每个输出中转义文本。前者更简单,但可能对用户造成问题;如果你不允许小于号(<),那么用户就不能使用它,即使是合法的文本。后者要求你在每个地方转义文本;例如,在 Thymeleaf 中使用<th:text>,正如我们在第五章:使用网页显示信息中学到的。
SQL 注入是另一种问题攻击,尤其是对于过去使用字符串连接来创建 SQL 的老代码来说。解决办法相当简单:为你的查询使用占位符。当我们讨论数据库时,我们将在第九章:使用数据库持久化数据中看到更多这方面的内容。
不安全的反序列化
不安全的反序列化最近已成为一个真正的问题。场景是某些数据打算在服务之间(非人类客户端)传递。有许多格式可以将这些数据放在线上。在前一章中,你已经学习了 JSON 作为这样的格式之一,但两种广泛使用的格式是 XML 和 Java 二进制序列化。
现在,XML 是一个非常灵活的格式。然而,强大的力量伴随着巨大的责任。在不受信任的环境中,实体功能最令人烦恼,建议使用不扩展实体的库,或者在解析之前以某种方式清理 XML。
Java 序列化在反序列化过程中对传入数据的操作很敏感。许多流行的库都包含针对这种攻击的修复。我们所能做的就是确保始终使用最新的软件。
Java 序列化和反序列化在 JMS 和 RMI 中广泛使用。它们使用内置机制工作;你可能之前见过可序列化接口。这是一种将内存中的对象转换为二进制表示,通过网络传输,并将二进制表示转换回另一侧对象的不错方法。然而,如果发送者创建了一个被操纵的字节流,另一侧的反序列化程序可能会被诱骗执行意外的操作。
允许外部客户端访问
允许访问意味着授予 Web 应用程序中每个客户端的访问权限。这是 HTTP 工作方式的结果,通常对于操作是必需的。一个后果是,来自外部站点的 JavaScript 可能能够访问我们服务器上的资源。这通常不是我们想要的。
一种可能的攻击被称为CSRF,或跨站请求伪造。在其最简单的形式中,想象一个恶意网站伪装成你的银行网站,甚至模仿布局。在你输入凭证后,恶意网站可以保存它们以备后用,同时登录你的真实银行网站。你甚至都不会注意到!Spring Security 提供帮助以避免 CSRF 攻击,我们将在稍后看到如何做到这一点。
浏览器通过实现同源策略(SOP)来避免许多这些问题。然而,这是一个相当严格的政策,可能会阻止您自己控制的多个服务器之间合法的资源共享。跨源资源共享(CORS)是为了绕过 SOP 而采取的一种方法。我们将在稍后与 Spring Security 的支持一起配置 CORS。
就像与 HTTP 和网页相关的事情一样,Mozilla 开发者网络是获取更多关于 SOP、CORS 和 CRSF 信息的优秀资源:developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy。
允许访问非自己拥有的资源
用户可能能够访问他不应该访问的东西。例如,普通用户应该只能看到他自己的私人信息,而不是他人的,而且他不应该能够访问管理界面。访问控制的关键是通过使用认证和授权。Spring 安全提供了很好的支持,实际上这是本章的主要内容。请看以下表格:

日志和监控不足
这本身不是一种威胁,而是未能检测到运行时的问题。设置防火墙;这更多的是操作部门的任务,而不是软件开发者的任务。适当的日志和监控可以检测攻击的开始,并允许快速采取对策。
认证和授权
认证和授权这两个术语经常被混淆,但了解它们的准确含义及其区别非常有帮助。
这里的最终目标是确定坐在显示器前的人是否被允许执行某个任务。首先,我们必须确认这个人的身份,然后我们才能检查这个人是否有必要的权限。
认证
认证是确认客户端身份的过程。用户有多种可能的认证方式。
Spring Security 将为我们管理经典的登录变体,我们将在下一节中转向这一点。它还支持许多不同的分布式登录,例如OAuth和OAuth2,但这本书的范围之外。
用户名密码认证需要解决以下挑战:
-
我们需要某种类型的用户数据库。这可以是一个内存中的硬编码列表,或者它可以从外部数据库系统中获取。
-
除了用户名外,我们还需要存储密码,但要以安全的方式。普遍的观点是根本不保存密码,而是保存一个不可逆的哈希值。在我们数据库不幸泄露的情况下,攻击者将无法获取密码;这很重要,因为用户往往在不同的地方使用相同的(或类似的)密码。
在 Web 应用中,我们还需要考虑认证是如何从浏览器发送到服务器的。一个非常重要的方面是原始密码必须通过网络(至少一次)传输,因此我们必须确保我们正在使用一个安全的通道(通过 HTTPS 进行通信)。
授权
在我们确认了用户的身份之后,我们必须检查他允许做什么。这被称为授权,意味着用户被授权做某事。
在 Web 应用中,处理两个不同的方面很重要。想象一个为每个用户都有一个私有区域和行政界面的 Web 应用。很明显,我们必须防止非特权用户访问行政界面。这可以通过限制应用程序中可访问的路径并授予访问权限到相关路径来实现。你将看到如何使用 Spring Security 的WebSecurityConfigurerAdapter来完成这一点。
然而,有些 URL 对所有用户都是可访问的,但需要显示不同的内容。你不想让其他人看到你的私人数据!某些资源可能会根据用户的不同而有所不同:一篇文章可能对普通用户限制为 1,000 个字符,但对高级用户限制为 4,000 个字符。
为了处理这个问题,你需要为用户分配访问权限,换句话说,授予权限。一个这样的模型是为用户分配角色(例如USER或ADMIN)并限制对某些角色的资源访问。如果你需要非常细粒度的控制,你也可以使用更强大(但遗憾的是,更难处理)的访问控制列表(ACLs)。然而,只有在最复杂的场景中,你才需要手动检查这一点。Spring Security 为你提供了一些简单的注解来限制 Spring Bean 中的方法访问。
检查婴儿步安全(1)
目标是分析未加密和加密的 Web 应用之间的差异。假设你想看到应用在未加密和加密版本中并排展示,以便进行比较。
在开始之前,你需要加载并启动位于 bit.ly/2REovBW 的 Security-Intro 应用程序。启动应用程序。在浏览器中打开主页,并在 Postman 中对 /api/messages.json 进行 REST 调用,以查看它们都很容易访问。(如果你需要,你还可以在章节文件夹中找到 Postman 的配置)。完成步骤如下:
- 定位到 POM 并在其
<dependencies>部分添加以下依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
-
让 IntelliJ 重新导入 POM 并重新启动应用程序。
-
导航到网页版本并再次进行 REST 调用。
你将观察到以下两点:
- 网页版本现在显示登录页面。
查看以下截图:

- REST 调用导致 401 未授权。
前往 bit.ly/2ylvMzt 访问 SecurityIntroApplicationTests.java 文件的完整代码。
Spring Security
在简要概述了安全的不同方面之后,让我们再次转向 Spring,并看看它是如何解决之前提到的一些问题的。请注意,许多安全问题实际上源于应用程序的错误行为,而不仅仅是使用库就能解决的。
最后两点将只简要介绍,因为关于它们没有太多可说的。因此,本节的大部分内容将关于身份验证和授权。
即使我们小心翼翼地区分这两个词,由于一个的效果只有通过另一个才能显现,因此这些概念无法孤立地展示。让我们首先将 Spring Security 添加到项目中,并观察其效果。
Spring Security 是 Spring 平台的一个子项目,就像 Spring Web MVC 一样,它在第四章:MVC 模式中介绍过。它有自己的发布周期。当使用 Spring Boot 时,你不必关心这一点,因为 Spring Boot 依赖关系管理将解析为经过测试和验证的版本。只有在极少数情况下,你可能需要手动包含所需的依赖关系并指定它们的版本号。
自动配置 Spring Security 的魔法
将 Spring Security 添加到 Spring Boot 项目中非常简单。它附带一个默认配置,非常安全,绝对不适合任何实际用途。你所要做的就是添加相应的启动 POM:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
启动 POM 对所有必要的工件有传递依赖。现在,在启动时,Spring Boot 应用程序将打印出如下一行:
Using generated security password: f76defbe-62d2-4711-9189-
aa8926ad03eb
任何资源,无论是通过网页浏览器还是 REST 客户端访问,都将受到保护。
紧密的安全默认设置
在幕后发生了很多事情。以下列表并不完全详尽,但它包含了你通常需要知道的所有信息:
-
安装了安全过滤器链
-
处理所有请求
-
一些回退用户存储库和登录页面
魔法是 Spring Boot 的一部分,它将为使用 Spring Security 创建所有必要的 Beans。这就是您已经了解的自动配置功能,它可以在类路径上找到必要的类时立即创建 Beans,并启动构造后设置例程来连接它们。
首先,它安装了一个 servlet 过滤器(如第四章:MVC 模式)中所述)。这个过滤器拦截每个请求并添加必要的安全性。这是一个类型为DelegatingFilterProxyRegistrationBean、名称为springSecurityFilterChain的 Spring Bean。正如名称所暗示的,这些不是简单的过滤器,而是过滤器链,每个过滤器都增加了一部分功能。这些链是自动构建的。
默认情况下,过滤器被配置为通过相同的级别保护所有请求。有一个值得注意的例外:当你尝试访问应用程序的 Web 部分时,你会遇到的登录页面。
应用程序的用户存储库保存在内存中,实际上系统中只有一个用户。它的名字是user,密码在应用程序启动时随机选择;它以 UUID 的形式存在。这是安全的,但在实践中有点难以操作。选择这个默认设置是为了确保添加依赖项实际上会有所不同,并迫使用户选择自己的配置,而不是让系统像以前一样不安全。
这些措施的结果对于 REST 和 Web 版本相当不同,所以我们将逐一查看。
使用基本认证保护 REST
在将 Spring Security 引入项目后,访问 REST 资源开始导致401 未授权。这是当没有提供身份验证手段时安全过滤器将产生的结果。如果您在疑惑,是的,您没有权限访问此资源,但这主要是因为您无法认证自己到系统中。
除了响应之外,服务器还发送了一个头信息,WWW-Authenticate: Basic realm="Realm",以告诉客户端如何处理这种情况。答案告诉我们应该使用一个称为基本认证的程序,并使用域域进行认证。
基本认证方案是认证的最简单形式。它不需要 cookies 或服务器上的存储。客户端在每次请求的授权头中发送凭据。密码没有被散列或加密,只是 Base64 编码;因此,建议使用安全连接。
可以使用一个域来使用不同的凭据访问服务器的不同区域。这很少使用;大多数服务器只有一个域。
对于REST 客户端,这是一种简单而有效的方法进行认证。Postman 允许你轻松设置凭据,命令行工具(如cURL)也是如此。客户端和服务器都不需要存储任何令牌,因此整个过程是无状态的。
对于网络浏览器,此方案可行,但不够舒适。浏览器会向用户显示一个对话框来输入凭据,但这属于浏览器,并且不会与网页的外观和感觉相匹配。此外,没有再次注销的方法。
基本认证要求对每个请求验证密码。现代密码散列故意设计得较慢,以提高安全性。因此,建议即使对于 REST 调用,也用一些短期凭证(如会话)来交换密码。
在会话中保护网页浏览
在项目引入 Spring Security 之后,访问受保护的网页开始返回带有位置头的302 Found状态码,浏览器将我们重定向到指定的页面。我们看到的非常基本的登录表单是 Spring Security 内置的回退。在那里输入凭据会将它们 POST 到另一个内置端点,然后检查凭据并将它们添加到会话中。为了在后续请求中识别这个会话,会设置一个名为 JSESSIONID 的 cookie。
会话由所有 servlet 容器(如 Tomcat)支持,是一个为返回用户存储一些临时数据的地方。在今天的环境中,会话主要用于识别用户,没有更多用途。如果没有中央会话存储,存储真实数据可能会在可扩展性方面带来问题。
此方案更灵活:网页可以使用自己的登录表单或将登录表单嵌入到着陆页中。此外,用户可以注销。然而,它需要在服务器和客户端都存储会话及其标识符,因此不是 100%无状态的。
检查逐步安全(2)
目标是利用受保护的 Web 应用。假设你有一个受保护的 Web 应用并想登录。在开始之前,从上一个子节开始(或重新启动)应用程序,并导航到http://localhost:8080/。
- 复制密码,它以 UUID 格式呈现。
在 IntelliJ 的日志视图中,有一行(因为周围有空行而突出)以使用生成的安全密码开头。
- 在浏览器中,输入
用户名作为用户名,并复制密码作为密码。
你现在已登录,可以查看主页。(如果你在中间忘记重新加载表单,第一次登录可能会失败。在这种情况下,只需再次尝试即可。)
-
在 Postman 中,在授权面板中,从下拉菜单中选择基本认证,或从 Postman 收藏中加载已认证的版本。
-
然后,像之前一样输入凭据。
-
当你点击预览请求时,可以在头部面板中显示头部信息。
-
当你发送请求时,数据将再次以以下截图所示的方式返回:
-

您可以看到数据被返回,因为已正确设置了授权头。
明确配置
这一次内容很多。为了自定义行为,让我们首先手动重现 Spring Security 的默认配置。如果我们的需求不同,那么将很明显需要更改什么。为此,我们需要以下内容:
-
一个处理安全方面的配置类
-
包含 HTTP 安全的代码
-
一个登录表单和一个映射函数来显示它
-
一个具有虚拟用户的用户仓库
安全配置入口点
在 Spring Boot 中配置 Spring Security 的推荐方法是扩展WebSecurityConfigurerAdapter类,它提供了插件点以访问各种方面。让我们看看:
@Configuration
@EnableWebSecurity
public class SecurityConfiguration
extends WebSecurityConfigurerAdapter {
}
如您所见,我们还将此类标记为 Spring Boot 的@Configuration类,并通过使用@EnableWebSecurity启用安全方案。
您可能会想知道,如果启用安全是默认的,为什么@EnableWebSecurity是必要的。您是对的——它不是。尽管如此,通常还是要明确指出。
如果出于某种原因,您想禁用 Spring Boot 的所有自动安全配置,即使您的项目中包含了 Spring Security,您也必须在主应用程序类中显式禁用它,使用@SpringBootApplication(exclude = SecurityAutoConfiguration.class)。
添加硬编码的用户
要管理认证源,我们使用AuthenticationManagerBuilder,它是configure()方法的一个参数。我们将从内存认证开始,也就是说,用户列表不是来自外部源,而是预先加载的。请看以下代码示例:
public class SecurityConfiguration … {
@Override
public void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth.inMemoryAuthentication()
.withUser(User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER"));
}
}
注入构建器 auth 为我们提供了与 IDE 中的自动补全功能相协调的函数。inMemoryAuthentication()创建内存存储,而withUser()则将一个用户添加到系统中,使用与之前相同的名称,但密码稍容易一些,以便进行试验。
密码是明确给出的。因此,它在代码中以明文形式存在。这不是一个安全的配置,并且仅在数据库不存在的情况下用于启动。
现在密码被设置为比不断变化的 UUID 更容易记住的东西。此用户具有 USER 角色;这用于授权资源,正如我们很快将看到的。
当您现在启动应用程序时,它仍然会打印出一个生成的密码。这不是现在使用的用户和密码。这仅仅意味着UserDetailsServiceAutoConfiguration仍然创建了一个UserDetailsService Bean,然而这个 Bean 却是未使用的。稍后我们将用我们自己的实现替换这个 Bean。
使用基本认证锁定路径
为了锁定并允许访问 HTTP 资源(网页或 API 端点),我们使用一个 HttpSecurity 对象,它是作为另一个 configure() 方法的参数获得的(它被一些其他较少使用的配置对象重载)。首先,我们希望将所有 URL 锁定给具有 USER 角色的用户,就像我们的预定义用户一样:
public class SecurityConfiguration … {
@Override
protected void configure(HttpSecurity http)
throws Exception {
http.authorizeRequests()
.antMatchers("/**").hasRole("USER")
.and()
.httpBasic().realmName("blogmania");
}
}
使用 authorizeRequests(),我们引入了路径匹配器部分。antMatchers() 是一个函数,它接受任意数量的 Ant 风格的路径,最后,hasRole() 指定了用户条件。在这种情况下,路径具有特定的角色。
Apache Ant (ant.apache.org/) 是一个广泛流行的 Java 构建系统,是 Maven 和 Gradle 的前身。它引入了一种指定路径、子路径和文件的方式,这与 shell 中星号的使用不同。它既简单又非常灵活;这里的模式 "/**" 表示“从根开始,匹配所有内容。”
Spring Security 中的角色是简单的 Java 字符串。如果你拼写错误,可能会造成麻烦,因此,对于实际应用,你应该创建一个枚举并使用其字符串值。
我们现在已经锁定了一切,但没有实际认证的手段。这可以通过简单的 httpBasic() 调用来添加。
我们的应用程序现在使用基本认证进行了保护并可以访问。
添加登录
现在保护应用程序的基本认证工作得很好,但用户体验较差。用户期望一个登录表单是应用程序的一部分,并且可能还有注销的手段。我们现在将添加以下细节:
-
登录表单
-
默认:GET /login
-
可以有任意多个
-
用户名 & 密码
-
一个神秘的自定义
_csrf字段 -
发送到登录控制器
-
默认:
POST /login
Spring 所需的最小登录表单如下:
<form action="/login" method="POST">
<input name="username" value="" type="text">
<input name="password" type="password">
<input name="submit" value="Login" type="submit">
<input name="_csrf" value="246a60ad-3059-4e4b-9409
49eddd66efdb" type="hidden">
</form>
除了神秘的自定义 _csrf 字段,它将在下一节中解释,这是一个非常基本的表单,只有两个参数(用户名和密码),它将被 POST 到 /login URL。
我们可以拥有任意数量的这些表单(例如,将它们嵌入到其他页面中),只要它们将输入 POST 到正确的接收器。然而,许多应用程序还有一个额外的独立登录页面。如果用户尝试查看他们无法访问的页面,他们将被重定向到这个页面。
这可能是因为他们的会话已过期,或者他们可能在第一次尝试时输入了错误的密码。然后页面将被添加一个 ?error 参数调用。在 Thymeleaf 模板中,我们可以评估它。考虑以下 Thymeleaf HTML:
<form th:action="@{/login}" method="post">
<label for="username">Username</label>
<input type="text" id="username" name="username">
<label for="password">Password</label>
<input type="password" id="password" name="password">
<button type="submit">Log in</button>
<div th:if="${loginError}" class="alert alert-primary"
role="alert">
<p class="error">Wrong user or password</p>
</div>
</form>
包含错误消息的第一个 <div> 只会在 loginError 设置时显示。完整的文件(包括更多标记以改进表示)可以在 login.html 文件中找到,与其他章节的所有代码一起,在 bit.ly/2DGVgvE。
我们刚刚创建的登录页面尚未映射,因此我们需要在某个控制器类中添加此方法,如下所示:
@GetMapping("/login")
public String login(Model model,
@RequestParam Optional<String> error) {
if (error.isPresent()) {
log.info("Incorrect login, warning the user");
model.addAttribute("loginError", "true");
}
return "login";
}
如您所见,如果没有添加一些错误处理,这个方法将是微不足道的。默认情况下,如果发生错误,Spring 将使用/login?error调用登录页面,因此我们评估这个可选参数并将一个属性放入模型中;这个属性用于在表单中显示错误消息。您可以在HomePageController类中找到这个源代码,在源代码分发中。
剩下的就是告诉 Spring Security 实际使用我们的登录页面。为此,我们需要将基于表单的身份验证添加到 HTTP 安全中。另外,还需要考虑登录表单本身必须对所有用户开放,否则将无法登录。完整的配置方法如下所示:
public class SecurityConfiguration … {
@Override
protected void configure(HttpSecurity http)
throws Exception {
http.authorizeRequests()
.antMatchers("/css/**", "/webjars/**", "/login").
permitAll()
.antMatchers("/**").hasRole("USER")
.and()
.formLogin().loginPage("/login")
.and()
.httpBasic().realmName("securityintro");
}
}
如果不允许未经身份验证的用户访问登录页面及其所有所需资源,无论是 CSS、JavaScript 还是图像,将导致应用程序无法正常工作。浏览器将会有无限次的重定向,希望浏览器会在某个时刻中断这些重定向。
第一条新行允许所有客户端访问一系列新的 Ant 模式。除了登录页面本身,我们还授予了对用于更美观显示的静态资源的访问权限。
另一条新行配置了一个表单登录,除了现有的基本身份验证登录外,还指定了我们使用的路径作为登录页面。(实际上这是默认设置,但在这里明确指出更好。)
添加注销功能
用户需要从应用程序中注销,无论是出于安全原因还是为了以其他用户身份登录。这可以通过安全介绍应用程序(index.html)中的以下片段轻松完成:
<form th:action="@{/logout}" method="post">
<button type="submit">Log out</button>
</form>
这段代码的要点是我们需要向/logout URL 发出 POST 请求,这是此处的可配置默认值。
探索网络安全
考虑到您有一个已经配置了网络安全的 Web 应用程序。您希望更改可以自由访问的页面,以及只能通过身份验证访问的页面。您应该尽量避免陷阱。这里的目的是探索使用HttpSecurity对象的效果。
在开始之前,从文件夹中加载并启动 Blogmania 应用程序bit.ly/2PmyyPF。请注意,您必须登录才能访问它,因为您会被立即重定向到登录页面。登录后,您可以进入起始页面。您可以再次注销。
-
在
SecurityConfiguration类中,找到configure(HttpSecurity http)方法。 -
首先,尝试通过移除看起来(可疑地)允许任何人进入的条目来加强安全性。现在主体应该如下所示:
http.authorizeRequests()
.antMatchers("/**").hasRole("USER")
.and()
.formLogin().loginPage("/login")
.and()
.httpBasic().realmName("blogmania");
- 启动应用程序并重新加载。
这根本不起作用。如果你打开了浏览器的开发者工具,你会看到它一直在重定向,直到它决定打破循环。发生了什么?哦,我们禁止了对登录页面的访问!
看看下面的截图:

-
尝试相反的操作。重新添加该行(使用
撤销),然后,移除对特定角色的要求。 -
通过按Ctrl + 空格键,IntelliJ 会显示所有可能的完整列表。只需选择允许所有,结果如下所示:
http.authorizeRequests()
.antMatchers("/css/**", "/webjars/**", "/login").
permitAll()
.antMatchers("/**").permitAll()
.and()
.formLogin().loginPage("/login")
.and()
.httpBasic().realmName("blogmania");
- 启动应用程序并重新加载。
注意,现在,你可以不登录就查看所有页面。你仍然可以导航到/login并执行登录。然而,将不会有任何明显的区别。(幕后只有区别。)
方法级安全
在我们之前的配置中,我们使用了 Web 安全;也就是说,我们将安全配置应用于 URL。由于 Spring Security 的可插拔架构不仅限于 Web 应用程序,因此必须存在另一种控制访问的方法:方法级安全。
尽管方法级安全可以在 XML 中配置,但它更广为人知的是基于注解的安全。这是因为它的广泛应用伴随着 Spring 的@Secured注解。如今,有三组不同的注解,我们首先来看看最容易使用的注解。
允许的角色
第一个注解来自 JSR-250 (jcp.org/en/jsr/detail?id=250),称为@RolesAllowed。它使用起来非常简单,如下所示:
@RolesAllowed("USER")
public void performForRole() {
log.info("Only called when authorized as USER");
}
这个注解接受一个角色(或角色列表)的名称,并将对performSecure方法的调用限制为具有 USER 角色的认证用户。如果调用该方法且不满足 USER 角色的约束,将抛出一个运行时异常AccessDeniedException。
启用@RolesAllowed
Spring Security 默认没有启用方法级安全。你必须在一个配置类中修改它,最好是你的安全配置,如下所示:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true)
public class SecurityConfiguration
extends WebSecurityConfigurerAdapter {
…
}
这个注解是最简单的,但也是使用注解进行访问控制的最有限形式。如果你的需求超出了@RolesAllowed的能力,Spring Security 提供了一些额外的安全特性。
保护控制器
你可以使用注解来保护 Spring Bean 中的所有公共方法。所以,假设我们控制了 Spring MVC 控制器中映射方法的访问,这与使用HttpSecurity配置有什么不同?
实际上,并没有。这主要是一个风格问题。看看下面的表格:

原始的@Secured 方法
Spring Security 用于方法级安全性的原始注解称为@Secured。必须使用@EnableGlobalMethodSecurity (securedEnabled = true)启用它才能使用。注解的参数传递给AccessDecisionManager以做出最终决定。你可以允许所有经过身份验证的权限访问,或者只是允许具有给定角色的权限访问,如下所示:
@Secured("IS_AUTHENTICATED_ANONYMOUSLY")
public void performSecureWithAny() {
log.info("Only called when anonymous");
}
@Secured("ROLE_USER")
public void performSecureWithRole() {
log.info("Only called when authorized as USER");
}
你可能会遇到这个注解,但你应该避免在新代码中使用它,因为它不如@RolesAllowed简单,也不如@PreAuthorize和其他注解强大,我们将在下一节中看到。
你可能会在一些材料中找到关于 Spring 安全中角色和权限之间差异的讨论。你可能会发现所有角色名称都应该以前缀ROLE_(可以配置)开始的规则。
通常,你今天不必担心这个问题,直到你需要在你应用程序中进行细粒度访问控制。如果 Spring Security 发现没有前缀的角色名称,它将自动在所有上下文中添加它们——完全在后台。如果一个用户有 USER 角色,那么以下所有角色都将允许访问:
@RolesAllowed("USER")
@RolesAllowed("ROLE_USER")
@Secured("ROLE_USER")
@PreAuthorize("hasRole('USER')")
@PreAuthorize("hasRole('ROLE_USER')")
@PreAuthorize("hasAuthority('ROLE_USER')")
这里的两个例外是 @Secured("USER") 因为这个注解使用了一种非常特殊的语法,以及 @PreAuthorize("hasAuthority('USER')") 因为 Spring 自动从角色创建的所有权限都有前缀。
基于表达式的安全
方法级安全最强大的方法是使用允许基于SpEL访问的预注解和后注解。要启用此组注解,必须将prePostEnabled选项设置为以下内容:
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration … {
…
}
这将同时启用以下四个注解:
@PreAuthorize
@PostAuthorize
@PreFilter
@PostFilter
这些名字确实很贴切。每个注解都接受一个SpEL表达式。这可以是任意复杂的。其中存在一定的危险——你必须确保你实际上对注解的效果进行了单元测试,就像你会对正常的 Java 代码进行测试一样。经验表明,这往往会被遗忘,因为测试注解要复杂一些。
简单表达式
第一个注解是@PreAuthorize,它具有非常简单的语义:如果SpEL表达式评估为真,则允许访问该方法,否则拒绝。它可以被视为@RolesAllowed的一个更强大的版本,如下例所示:
@PreAuthorize
@PostAuthorize
@PreFilter
@PostFilter
@PreAuthorize("hasRole('USER')")
public void readMyArticles() {
log.info("Only called when authorized as USER");
}
到目前为止,这仅仅更加冗长;让我们看看一个稍微好一点的例子:
@PreAuthorize("hasAnyRole('USER', 'GUEST')")
public void readPublicArticles() {
log.info("Only called when authorized as USER or GUEST");
}
强大的表达式
当表达式访问它们所保护的方法的参数时,真正的力量才开始显现。例如,假设我们已经从数据库中检索了一条记录,然后,在处理链的更下游,我们想在服务中将其删除,如下所示:
@PreAuthorize("#blogPost.author.name == authentication.name")
public void deletePost(BlogPost blogPost) {
log.info("Only called when actually the author of the post");
}
这是一个 Spring Security 给我们提供的新工具,功能非常强大。
确保在基于用户提供的数据进行权限检查时,你的代码不会被欺骗。在这个例子中,在比较用户的名字之前,从数据库中读取作者的名字是至关重要的。
实际上想要在事后授权的情况要少见得多,可能依赖于返回值。使用内置的returnObject表达式,我们可以在@PostAuthorize注解中的SpEL表达式中访问返回值。
基于表达式的访问控制也可以与HttpSecurity一起使用,而不是这个注解。你真的应该使用
使用你最喜欢的 IDE 的自动完成功能来探索指定限制的所有不同方式,但作为一个起点,这个链式方法被称为access()。
我们在这里只能触及表面。请参阅基于表达式的安全性的文档,链接为docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#el-access。
超越访问 – 过滤
@PreFilter和@PostFilter注解使 Spring 能够分别过滤输入参数和返回值。过滤是通过SpEL实现的。考虑以下示例:
@PreFilter("filterObject.content.length() < 240 or
hasRole('ADMIN')")
@PostFilter("filterObject.author.name == authentication.name")
public List<BlogPost> saveAndReturnAll(List<BlogPost> posts) {
…
}
此方法本应接受一系列博客文章,保存它们,然后返回所有博客文章,包括新旧文章。然而,注解对此施加了两个限制。
-
只有长度小于 240 个字符的博客文章允许,除非你有管理员权限。
-
只有你的博客文章会被返回,除了管理员,他们可以获取所有文章。
过滤只适用于集合。集合必须是可变的。在评估期间,表达式filterObject指的是正在审查的列表元素。这个特性超出了访问控制,我们应该仔细考虑这部分业务逻辑是否更适合在 Java 代码中处理。
总结一下,请看以下表格:

测试安全方面
记得上一章中的performForRole()方法吗?我们如何确保注解能以我们期望的方式限制访问?
要测试 Spring Security,我们需要在我们的 POM 中添加一个额外的依赖项,如下所示:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
与往常一样,版本已经被 Spring Boot 锁定。现在,测试相当简单(假设包含要测试方法的服务的名称为SecuredService):
@RunWith(SpringRunner.class)
@SpringBootTest
public class SecuredServiceTest {
@Autowired
private SecuredService securedService;
@Test
@WithMockUser
public void accessWithUserRole() {
securedService.performForRole();
}
@Test
@WithAnonymousUser
public void accessWithoutAnyRole() {
try {
securedService.performForRole();
fail();
} catch (AccessDeniedException e) {
// succeed
}
}
}
整个测试中唯一的新事物是 Spring Security 测试注解@WithMockUser和@WithAnonymousUser。后者可以测试未经授权的访问;前者提供了一个默认名为user且具有USER角色的可配置用户。
对于许多配置,这已经足够了。对于更复杂的访问方案,还有高级测试注解,例如@WithUserDetails,甚至@WithSecurityContext。
安全上下文
有时,你需要关于当前登录用户的信息,而不仅仅是允许或拒绝访问。这些信息存储在安全上下文中(该类实际上是SecurityContext)。现在的问题是,如何获取这个上下文。
安全上下文默认由 Spring Security 通过线程局部变量维护。要访问它,我们调用持有对象的静态方法,如下所示:
Object principal = SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();
String username = principal instanceof UserDetails
? ((UserDetails) principal).getUsername()
: principal.toString();
这些行尝试找到当前登录用户的名称。这段代码可能需要一些解释。
-
SecurityContextHolder.getContext()将返回当前活动的安全上下文。 -
这个上下文实际上只包含一件事:当前的认证,我们通过
getAuthentication()获取它。 -
这个认证对象包含一些详细信息,例如凭证和授予的权限,以及主体,它是用户的一个表示。
下面的代码非常具有防御性——主体只有 Object 类型,因为 Spring Security 在这里非常灵活。然而,大多数时候,主体是UserDetails的一个实现,我们可以从中提取用户名。
你可以在示例应用程序的HomePageController的homePage()方法中找到一个安全上下文的应用。我们将在下一节中更详细地查看UserDetails和授予的权限。
真实用户仓库
我们到目前为止所使用的内存认证实际上是一个非常可行的实现。关键点是初始如何加载用户,因为我们像之前那样硬编码它们是不安全的。
然而,在许多情况下,我们可能希望使用另一种实现。我们只需提供一个 Spring Security 的UserDetailsService接口的实现。表面上看起来很简单,但实际上将其应用到应用程序中可能有些复杂。让我们看看一个接受所有用户名均为小写的用户实现的例子:
@Slf4j
@RequiredArgsConstructor
public class LowercaseUserDetailsService
implements UserDetailsService {
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) {
if (username == null || username.trim().isEmpty() ||
!username.toLowerCase().equals(username)) {
log.info("Reject {}, accept only lowercase", username);
throw new UsernameNotFoundException("Accept only
lowercase");
}
String password = passwordEncoder.encode("password");
log.info("Accepting {} / {}", username, password);
return new User(username, password,
singleton(new SimpleGrantedAuthority("ROLE_USER")));
}
}
需要注意的主要一点是我们只需要实现一个方法:loadByUsername()。需要注意的是,这个例行程序实际上并不执行认证,而是一个查找用户并返回相应详情的 DAO,包括编码后的密码。实际的检查在其他地方进行。正如相应的 JavaDoc 中所述,此方法不得返回 null,并且当用户根本不存在时,应抛出UsernameNotFoundException。
Spring 对用户的所在地没有任何假设。在我们的示例中,我们动态生成用户,以表明没有任何限制。在现实世界中,我们更愿意查询我们的数据库来填充UserDetails对象。
这里返回的UserDetails对象就是我们之前看到的,作为安全上下文中的主体。如果我们只有一个UserDetails对象的来源,这是非常正常的,那么我们可以盲目地将主体转换为它。
然而,当我们正在通过未认证的代码移动时,主体不是我们的UserDetails之一。它是一个值为anonymousUser的字符串。让我们暂时跳过密码行,如下所示:
return new User(username, password,
singleton(new SimpleGrantedAuthority("ROLE_USER")));
loadByUsername()方法应该返回UserDetails,这是一个接口,并且该接口需要向 Spring Security 系统提供一个GrantedAuthority列表,另一个接口。这非常灵活,但为了帮助我们,有两个这些接口的标准实现,对于许多目的来说都是足够的:User和SimpleGrantedAuthority。我们利用这两个实现来构建一个新的用户,该用户具有之前给出的用户名,并为每个USER分配一个单一的角色。
是的,这就是你需要区分角色和权限的地方。权限是一个更通用的概念,而角色是这些的一个简单子情况。一个名为ROLE_USER的权限将被解释为 USER 角色。
密码编码器
密码绝对不能以明文形式存储。相反,你需要以不可逆的格式存储它们在你的数据库中,通常称为哈希。在一个正常的UserDetailsService中,我们不需要担心这一点,只需将数据库中的哈希密码传递给 Spring Security,以检查用户给出的密码。
在我们的例子中,我们需要使用与 Spring Security 将用于检查相同的哈希函数来哈希明文密码password,如下所示:
String password = passwordEncoder.encode("password");
我们使用全局密码编码器,它被自动注入到我们的服务中,来编码密码。在一个更复杂的应用中,这样的行会出现在不同的地方——就在用户输入了新密码之后,以及我们将它放入数据库之前。
连接起来
在我们的SecurityConfiguration类中,我们现在将用以下三个代码片段替换认证管理器构建配置方法:
@Override
public void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth.userDetailsService(userDetailsService())
.passwordEncoder(passwordEncoder());
}
@Bean
@Override
public UserDetailsService userDetailsService() {
return new LowercaseUserDetailsService(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.
createDelegatingPasswordEncoder();
}
代码现在几乎是自我解释的。我们用我们自己的实现替换了内存中的认证。需要注意的是,我们应该使用@Bean方法来创建服务,而不是用@Service注解它;这样我们就可以覆盖WebSecurityConfigurerAdapter中已经存在的方法。
我们可以从广泛的密码编码器中选择,但最灵活的是默认提供的委托密码编码器。它将始终使用一个非常强大、标准的算法来编码密码(默认情况下,现在通常是BCrypt),但它可以检查许多其他更旧的算法。当你想要迁移到新算法时,这将非常有帮助。
加密后的密码将包含算法,看起来像 {bcrypt}$2a$10$2.eeR1LFYuJjicT.SyTEpEgX7mgJvH902rS,以便 Spring Security 委派到适当的密码编码器。数据库中的一个古老条目可能是 {MD5}{3A1yJJ/pQ5zMYv77050bccaccda0e573339a,并要求用户更改密码以迁移到 BCrypt。
实际仓库
在实际应用中,用户从哪里来?非常常见的情况是已经存在一个用户数据库,一个简单的方法是让我们的用户实现 UserDetails 接口。在安全介绍应用中,这是 Author 类。
Spring Security 默认提供了两个 UserDetailsService 的实现。这两个实现甚至实现了 UserDetailsManager,以便创建新用户和更改现有用户。您已经看到了这两个之一:InMemoryUserDetailsManager 将所有可能的用户存储在内存中。对于有限数量的用户,这是高效的。为了填充用户,我们可能在启动时从文件中读取它们。
另一种实现是 JdbcUserDetailsManager,它通过 JDBC 提供数据库访问。数据库访问将在下一章中介绍,但为了使用这个类,唯一的前提是在数据库中创建所需的表。此实现提供的用户属于之前提到的 User 类。该类既不包含全名也不包含电子邮件地址,这可能会使其对许多应用不太合适。
跨站请求伪造 (CSRF)
为了结束本章,让我们简要讨论两个重要的四字母缩写词,CSRF 和 CORS。
跨站请求伪造是指网站 A 向网站 B 发送请求,但让用户认为正在发生其他事情。HTTP 的无状态特性意味着网站 B 无法确定调用是从哪里发起的。浏览器会将所有必要的 Cookie 添加到请求中,因此如果用户当前在网站 B 上登录,可能在另一个标签页中,那么网站 A 就可以操纵数据。
防止此类攻击的关键是要求在所有操纵数据的请求中发送同步令牌;也就是说,POST、PUT 和 DELETE。此令牌通常作为隐藏字段发送到浏览器,并且必须在请求中存在。攻击网站通常无法访问该令牌。
如果您只接受来自受信任来源的调用,则可以禁用 CSRF 保护。这个决定不应轻率做出。
我们需要做什么来使用这个功能?如果我们依赖于表单和 Spring Security 对 Thymeleaf(以及 JSP 和其他模板引擎)的支持,几乎不需要做什么。我们在 Thymeleaf 模板中创建的每个表单都将有一个额外的隐藏字段,称为_csrf,在调用接受时将被检查。如果你回到登录表单的解释,你会看到这个字段。当在<form>标签上使用th:action属性时,表单会被 Thymeleaf 扩展。
如果你使用 REST 调用,你无法在 JSON 中传递另一个参数,并且你一开始就不会有一个表单来给客户端提供 CSRF 令牌。在这种情况下,我们需要另一个配置,如下所示:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()
.csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse());
}
现在,Spring Security 将发送一个XSRF-TOKENcookie,我们可以使用这个 cookie 来在请求中发送一个X-XSRF-TOKEN头部。我们不会在本书中探讨这个稍微高级的技术。
跨源资源共享(Cross-Origin Resource Sharing, CORS)
跨源资源共享(CORS)是处理今天浏览器 SOP(同源策略,Same-Origin Policy)的一种方式。Spring 提供了两种不同的方式来实现相同的目标。我们不会深入细节,但我们会查看 Spring 文档中的两个示例配置。
第一个来自 Spring MVC 支持,如下所示:
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/greeting-javaconfig")
.allowedOrigins("http://localhost:9000");
}
};
}
这将放宽 SOP(标准操作程序)以允许从localhost:port 9000访问/greeting-javaconfig。此配置还支持基于注解的配置。当你没有 Spring Security 时,这就是配置 CORS 的方式。
然而,如果你有 Spring Security,那么安全和 CORS 必须协同工作,并且有必要扩展安全配置,如下所示:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors() …
}
现在,如果 Spring MVC 在classpath上,并且没有提供CorsConfigurationSource,Spring Security 将使用之前定义的 Spring MVC 提供的 CORS 配置。相反,你可以决定使用以下代码来配置 CORS:
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(
Arrays.asList("http://localhost:9000"));
configuration.setAllowedMethods(
Arrays.asList("GET","POST"));
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
活动:探索安全注解
先决条件
从源存档加载并启动安全介绍应用程序。这是一个短消息服务,它具有一些可能有些牵强的功能,但它们使得在不使用真实应用程序的开销的情况下演示 Spring Security 的一些功能变得更容易。即使作为匿名用户,你也可以查看所有页面。
目标
探索安全注解对方法的影响。
场景
你有一个配置了网络安全的 Web 应用程序。然而,你不想仅仅依赖于 URL,而是直接通过注解来保护代码。
完成步骤
-
导航到 HomePageController 以找到
homePage()方法,并添加一个注解来限制对具有USER角色的用户的访问。 -
重新启动应用程序并在浏览器中打开页面。
注意我们立即被重定向到登录页面。使用任何预定义的用户登录;例如,peter/quinn或cate/sakai。
-
注意索引页面已更改以反映你的用户:
看看以下显示名称和分配角色的截图:


-
尝试注销。
-
找到
ShortMessageService及其findAll()方法。添加一个注释来限制显示文章。 -
重新启动应用程序并在浏览器中打开页面。
结果
登录后,你将只能看到自己的文章。再次从步骤 1中移除@RolesAllowed注释,并重新启动。然后你将能够查看自己的文章。
你现在可以看到完整的注释在行动中的样子:当注销(在 Spring Security 中,这被称为匿名认证)时,你可以看到所有文章,但当你登录时,你只能看到自己的。(诚然,这不是最直接的行为。)
你是否注意到注释映射函数与我们在HttpSecurity对象上的先前配置具有相同的效果?你甚至可以尝试锁定那里的路径,而不是使用注释。(它已经在代码中,但被注释掉了。)前往bit.ly/2x8Fyoa访问HomePageController.java文件的完整代码。
要参考详细步骤,请参阅本书末尾的解决方案部分,第 261 页。
摘要
在本章中,我们探讨了安全的许多方面,特别是作为软件开发者,你应该将其列入议程的方面。Spring Security 项目在 Spring MVC 部分提供了紧密集成,这允许你锁定 URL 和 Spring Beans,这限制了某些方法的访问。你学习了如何将不同的部分连接起来;如何配置身份验证(例如在表单中)将用户加载到系统中(包括他们的访问权限和密码);以及如何让他们访问系统的某些部分。
我们现在限制博客狂热应用只能由已知用户撰写新文章。然而,博客文章仍然只保存在内存中。我们需要一种方法来持久化它们,因此我们将注意力转向本书的下一章(也是最后一章)中的数据库。
第九章:使用数据库持久化数据
在本章中,我们将探讨选择数据库管理系统。系统的选择取决于各种因素,例如可视化与报告、安全性、可扩展性和成本,仅举几例。还有各种类型的数据库,其中之一就是关系型数据库。这种数据库的设计方式使其能够识别存储信息中的关系。在大多数情况下,SQL 用于查询和维护关系型数据库系统。此外,我们还将学习如何在数据库中开发与你的应用程序相关的数据,并使用 Spring 实现数据访问。
到本章结束时,你将能够:
-
选择数据库管理系统
-
在数据库中开发与你的应用程序相关的数据
-
在 Spring 的帮助下实现数据访问
关系型数据库和 SQL
本书最后一章关于持久化,换句话说,就是使用数据库。
有些软件不需要存储任何状态。有些软件只是为了执行特定任务而启动;执行任务并返回结果;有些则位于其他组件之间,来回传递消息。然而,许多软件系统需要存储一些状态;它们被称为持久化状态。数据存储有不同的形式,但我们将关注在商业应用中最普遍的形式。
可能会有许多不同的答案。硬盘上的保存游戏文件一开始可能听起来不像是持久化,但它是一个非常有效的答案。重要的是要理解,大多数软件都需要一定量的存储,并且存储的形式差异很大。
关系型数据库管理系统(RDBMS)
关系型数据库管理系统(RDBMS),简称RDBMS,是能够存储关系型数据库中的数据的系统。严格来说,数据库仅指由系统管理的那些数据。在日常生活中,我们通常不区分这一点,而将这个管理系统称为关系型数据库,甚至只是数据库。
在不深入数据库理论的情况下,这里的“关系型”指的是 1970 年由爱德华·F·科德提出的关系代数。简而言之,这意味着我们以表格的形式存储数据,预先定义表格的列集(包括名称和类型),每个实体占据表格中的一行。这类数据库几乎总是使用查询语言 SQL 进行描述和查询;因此,它们有时被称为SQL 数据库。
关系代数的坚实基础语义是 RDBMS 中存储数据和查询此类数据的基础。数据库理论的奠基性工作可以在爱德华·F·科德 1970 年的作品《大型共享数据银行的数据关系模型》,《ACM 通讯》,第 13 卷(6):377–387 中找到(doi.org/10.1145%2F362384.362685)。
关于这个话题,有针对不同专业水平的许多优秀文章,可以在维基百科或大学网站上找到。即使你已经熟悉 SQL 数据库,阅读这些文章也是值得的。
关系型数据库是行业标准,拥有如 PostgreSQL 和 MySQL 等流行的开源软件,以及如 Oracle 和 MS SQL Server 等昂贵的企业级解决方案。它们是许多挑战的最佳解决方案,对于大多数其他情况来说,仍然是一个非常不错的解决方案,这也使得它们变得普遍。然而,对于许多特殊情况,它们被认为过于不灵活或过于缓慢。这导致了大量替代解决方案的出现,这些解决方案通常统称为NoSQL,以区别于更标准的 SQL 数据库。虽然速度或灵活性的提升伴随着一些成本,但它们并不是一个通用的替代品。尽管 Spring 对这些中的某些提供了支持,但本书的范围并不包括这些内容。
相反,我们将专注于 SQL 数据库,以及 Spring 在这些方面的出色支持。我们将看到如何连接到 RDBMS(甚至如何集成一个),如何随着我们的代码一起演进数据库,以及如何访问数据。特别是最后一个方面,我们将很快看到它是如何被简化的。但首先,我们需要一个 RDBMS 来连接。
H2 嵌入式数据库
许多数据库系统都是重量级的,可以存储数 TB 的数据(或者对于某些系统来说,这就是它们占用的内存,它们可以存储 PB 级别的数据),它们内置了故障转移和备份策略,以及一个复杂的安全概念。有时,所有这些功能都过于复杂。
H2数据库特别之处在于它体积小,是用 Java 编写的,可以嵌入到你的程序中,并且可以将所有数据存储在内存中。是的,没错——它将全部在一个地方,并且会直接工作。如果需要,H2 可以用于生产系统,因为它速度快,支持大多数 SQL 功能。然而,通常它只会在开发期间使用。
让我们直接进入并在我们的项目中使用它!
利用 H2 控制台
这里的目的是嵌入内存中的 H2 数据库。你想要给你的应用程序添加持久性。由于你正在开发新应用程序,你需要一个游乐场,因此你决定不访问公司的企业数据库,而是从内存中的解决方案开始,当需要时可以替换为外部数据库。
在开始之前,从bit.ly/2qIrUEE提供的文件夹中找到 blogmania 应用程序。完成步骤如下:
- 在 POM 中,在
<dependencies>元素的一个方便位置,添加以下依赖项:
<!-- Database access -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
在启动时,Spring Boot 会自动发现数据库,并使用注入的 DataSource beans 让你能够访问一个预配置的数据库实例。现在你需要的一切都在那里了!
然而,目前还没有太多可以看的东西。让我们转向另一个在开发过程中非常有用的功能:H2 控制台。这随 H2 数据库一起提供,并在 Spring Boot Devtools 可用时(在我们的应用程序中它们是可用的)启用。访问 bit.ly/2QpSiP0 以获取 BlogmaniaApplication.java 文件的完整代码。
- 现在开始应用程序。将您的浏览器指向
http://localhost:8080/h2-console,然后——哇!——您现在看到的是 H2 控制台登录屏幕:

-
这个控制台可以用来访问任何 SQL 数据库。字段已预先填充了正确的值,以便进入嵌入的内存测试数据库,所以只需点击连接按钮。
-
下一个屏幕一开始可能会让人感到有些不知所措。为了帮助您开始,请在屏幕中间找到“示例 SQL 脚本”部分;当您点击它时,脚本将被复制到顶部的 SQL 语句窗口中。按下运行按钮以实际运行脚本:

- 您现在已创建了一个表,向其中添加了一些数据,并对其进行了更新!新表 TEST 将现在出现在左侧的树中。您可能想探索控制台,以便熟悉它。
正如您所见证的,现在有一个数据库可用于调试和查看。然而,对这个的深入了解超出了本书的范围。
不幸的是,控制台与 Spring Security 不兼容。因此,如果我们启用了它,我们就必须稍微放松一下访问权限。在安全配置中,您必须在 WebSecurity 方法中执行一个更改,以将 H2 控制台从安全循环中移除。该方法应如下所示:
@Override
public void configure(WebSecurity web) {
web
.ignoring()
.requestMatchers(PathRequest.toH2Console());
}
在进入生产之前,撤销这些更改是个好主意。
SQL 的要点
SQL 是一种数据库语言,用于定义、创建、读取、更新和删除数据。这个缩写没有官方含义。然而,它通常被认为是 结构化查询语言(Structured Query Language)的缩写,有时发音为“sequel”,但这实际上是其前身的一个遗迹。
SQL 在表中处理数据。这些表有定义的列数,每列都有特定的类型。数据库系统是强类型的,甚至可以检查表中的值约束。SQL 中被称为 数据定义语言(DDL)的一部分负责声明和创建这些表。
语言的一部分,数据操纵语言(DML),负责处理数据。这两种子语言在逻辑上是分开的;它们在语法上有很多相似之处。
DDL 表创建
我们在上一个练习中已经看到了该语言的实际应用。让我们更仔细地看看:
CREATE TABLE test(id INT PRIMARY KEY, name VARCHAR(255));
这段 DDL 定义了一个名为 test 的表,包含两列:一列名为id,另一列名为name。id列的类型为 INT,可以存储整数值。另一方面,name列可以存储可变数量的字符串,最大长度为 255。
通常,我们会将所有 SQL 关键字写成大写。然而,这只是一个约定,SQL 对大小写不敏感。
虽然存在一些争议,但使用小写字母作为标识符的约定仍然很普遍。SQL 在这里也不区分大小写。
id列的目的是存储行的标识符。这也在数据库中被称为key。标识符应在表中是唯一的,添加 PRIMARY KEY 关键字将使数据库强制执行此约束。
大多数数据库可以强制使用指定的大小写来使用标识符。这可能会带来麻烦。
SQL 有很多标准化,但不幸的是,每个数据库都有自己的怪癖或扩展。例如,在 Oracle 中,用于存储字符字符串的正常类型称为 VARCHAR2,它不能存储空字符串,因为空字符串被处理得与特殊的 NULL 值相同。
DML 数据操纵
相比之下,接下来的几行是 DML(数据操纵语言)并操作数据:
INSERT INTO test VALUES(1, 'Hello');
INSERT INTO test VALUES(2, 'World');
SELECT * FROM test ORDER BY id;
UPDATE test SET name = 'Hi' WHERE id = 1;
DELETE FROM test WHERE id = 2;
这些行相当直观。请注意,SELECT 语句也可以有 WHERE 子句。这段简短的 SQL 足以让我们完成本章的剩余部分。
使用 JDBC 和 JdbcTemplate 从 Java 访问数据库
在我们实际访问数据之前,拥有一些可以操作的数据是很有用的。通常,数据是持久的,但在我们当前的设置中,每次应用程序启动时 H2 都会为空。在这种情况下,Spring Boot 提供在应用程序启动时执行某些 SQL 脚本的功能。
导入初始数据
第一份脚本称为schema.sql,包含用于我们应用程序中的 SQL 语句(DDL,数据定义语言),用于创建表(通常是 CREATE TABLE 语句)。第二份是data.sql,包含用于创建一些数据的 SQL 语句(DML,数据操纵语言)(通常是 INSERT 语句)。作为资源文件,它们位于src/main/resources文件夹中。本章的示例文件可以在bit.ly/2Dzb03G找到。
简单的 JDBC
JDBC 是使用 Java 访问关系数据库的标准接口。其缩写意为 Java Database Connectivity。该标准提供了一致的 API 来访问不同供应商的 RDBMS。包含实际低级实现的数据库驱动程序由供应商提供,而接口和常用类是 Java 运行时环境的一部分。
简单的 JDBC 示例
API 中大部分内容都很容易理解,并且有很好的文档。然而,使用起来相当繁琐。用户需要仔细查看数据库资源,如连接,如果未能正确关闭这些资源,可能会耗尽数据库配置的连接限制。所有这些都因使用了检查异常而变得复杂,即使在应用程序程序员无法处理失败的地方也是如此。考虑以下摘录(所有示例的完整代码可以在JdbcDemonstratingRepository类中找到):
Connection connection = null;
try {
connection = dataSource.getConnection();
Statement statement = null;
try {
// […] }
} catch (SQLException e) {
log.error("Some SQL problem while getting connection", e);
} finally {
if (connection != null) {
// close connection and handle exception while closing
}
}
前往bit.ly/2Qm2tnM访问JdbcDemonstratingRepository.java文件的完整代码。这绝对不是我们想要编写代码的方式。它所做的只是检索一个数字!
Java 7 以来的纯 JDBC
这段代码的大部分内容都与错误处理有关。复杂的查询需要对结果集进行更多的工作,但错误处理的量是相同的,因此比率会提高,但对于小查询,开销是无法容忍的。大多数人决定不在尽可能小的范围内捕获异常,从而牺牲了一部分错误报告的准确性。幸运的是,所有提到的类都实现了AutoClosable接口,这使得我们可以编写如下代码:
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(SQL_QUERY)) {
resultSet.next();
int result = resultSet.getInt(1);
log.info("plainJdbcTryWithResources success {}", result);
} catch (SQLException e) {
log.error("Some SQL problem, somewhere", e);
}
这对于简单的查询来说真的非常好,可以说是最好的了。try-with-resources 机制确保无论可能发生什么错误,所有资源都将被关闭。
关于可能出现的复杂性的讨论,您可以参考 StackOverflow 上的这个问题:stackoverflow.com/questions/8066501/how-should-i-use-try-with-resources-with-jdbc。
需要处理的 JDBC 资源
这些 JDBC 资源如下:
-
数据库连接。所有操作都在这个上下文中运行,并且建立连接时会产生网络流量(对于远程数据库)。如果需要,事务和回滚在连接级别上执行。
-
封装单个 SQL 语句的语句。
-
操作结果集的一组结果。通过网络传输此结果可能是在块和批次中进行的;结果可能很大(可能像你的整个数据库一样大),因此请确保正确处理。
Spring 拯救——JdbcTemplate
JdbcTemplate类是 Spring JDBC 包中的核心类。它简化了 JDBC 的使用,并有助于避免常见错误。它可以为简单用例完成所有工作,并为更高级用例提供回调接口的扩展点。此外,它执行异常转换,其有用性将在本章后面讨论。
类名中包含单词template,通常理解得不是很好。它指的是模板方法设计模式,这是书中描述的 23 个著名模式之一(Gamma, Helm, Johnson, Vlissides;Addison-Wesley 1994;ISBN 0-201-63361-2)。
简而言之,JDBC(获取连接、创建语句……、关闭语句和返回连接)的完整工作流程是在JdbcTemplate中执行的。为了使行为更灵活,这个工作流程在JdbcTemplate用户可以选择提供的地方调用回调函数。
让我们看看数据库访问可以有多简单:
int result = jdbcTemplate.queryForObject(SQL_QUERY, Integer.
class);
log.info("jdbcTemplateExample success {}", result);
是的,这确实是一行代码!
当然,这个例子被稍微剪裁了一下,以增强效果。尽管如此,即使是复杂场景,使用JdbcTemplate也比使用纯 JDBC 更容易处理。
详细说明:创建 JdbcTemplate
我们省略了如何获取JdbcTemplate实例以及可能出现的副作用。现在让我们来补充这一点。以下又是从同一个类中摘录的内容:
@Slf4j
@Repository
@RequiredArgsConstructor
public class JdbcDemonstratingRepository {
private static final String SQL_QUERY = "select 42 from dual";
private final DataSource dataSource;
public void jdbcTemplateExample() {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
Integer result = jdbcTemplate.queryForObject(
SQL_QUERY, Integer.class);
log.info("jdbcTemplateExample success {}", result);
}
}
@Repository注解是一个 Spring 注解,它本身被@Component注解,并将这个类标记为 Spring Bean(我们已经在第一章:Spring 项目和框架)中见过)。这只是更语义化的版本,就像@Service注解一样。它将为使用 JPA 时的异常转换准备整个代码,但这超出了本书的范围。
Spring 会为我们连接一个javax.sql.DataSource实例。这不是一个 Spring 特定的类,但 Spring Boot 会自动为我们创建它,并在我们的简单配置中连接到我们的数据库,嵌入式 H2 实例。这样的DataSource只有一个目的:获取数据库连接。
然后,这个数据源被用来创建JdbcTemplate的一个实例。通常,我们会在构造函数中创建这个实例,并在整个类中重用它。它是线程安全的。可能需要进一步配置模板,但当默认值足够时,还有一个更简单的方法:只需自动装配全局的JdbcTemplate Bean(这是示例应用程序中其他仓库类所采取的方法)。
现在,我们已经有了 Spring JDBC 支持的中央工作马力的实例,可以执行简单的查询(稍后将会解释)。这是使用JdbcTemplate的最简单方法;对于更高级的需求,我们将很快探索大量的选项。但是,首先,让我们将注意力转向一个出现失败的情况。
异常转换
通常情况下,JDBC 在出现问题时会抛出 SQLException 层次结构中的检查型异常。关于检查型异常是否曾是 Java 中的好主意,一直存在很大的争议。如今,大多数人倾向于选择未检查的(或运行时)异常。Spring 的 JdbcTemplate 将这些转换为 DataAccessException 层次结构中的异常,这些异常是 Spring 特有的且未检查的。
作为额外的优势,这些异常与数据库技术无关。你也会为 JPA 或 NoSQL 数据库访问得到相同的一组异常。
DataAccessException 层次结构中的异常比 SQLException 层次结构中的异常更详细。以下表格列出了其中一些最重要的异常:

高级查询
在探索 JdbcTemplate 类时,我们会遇到许多方法,其中大多数都是重载的。一开始可能会感到不知所措,但其中确实存在某种多维度的顺序。我们已经看到了其最简单的 queryForObject() 方法,现在我们将查看一些重载版本。
首先,重要的是要注意,queryForObject() 在所有变体中都应该返回一个精确的对象。这意味着 SQL 必须返回一行,在基本版本中,我们已经看到了,只有一列。否则,它将抛出 IncorrectResultSizeDataAccessException。除非查询返回 SQL NULL,否则它不会返回 null。
到目前为止的示例都是基于一个简单的 SQL 查询,你可以在 JdbcDemonstratingRepository: SELECT 42 FROM dual 中找到这个查询。这个查询不包含任何变量部分,它将返回一个正好有一列整型的单行。首先,让我们通过引入变量来增加一些变化。
什么是双重?标准的 SQL SELECT 查询不仅需要值或列名(在 SELECT 后面直接),还需要一个表表达式来提取数据。许多数据库在这里非常宽容,你可以在选择常量值时省略 FROM 部分。
然而,其他 RDBMS,尤其是 Oracle,则非常严格。你必须在这里提供一个表,并注意常量将针对表中的每一行返回一次。为此,Oracle 提供了一个预定义的表,DUAL。它有一个名为 DUMMY 的列,定义为 VARCHAR2(1),并包含一个值为 X 的行。其他 RDBMS 也采用了这种方法,正如你在 H2 中所看到的。
查询中的变量部分用一个问号标记,它代替一个 SQL 表达式。我们可以这样更改 SQL 查询:
SELECT 42 + ? FROM dual
目意明确,但我们如何传递一个值给它?queryForObject()方法为此目的重载了一个Object... args参数,所以最简单的事情就是只需在调用末尾添加额外的值!(还有一个重载版本,它接受一个Object[] args参数,这源于在 Java 中引入可变参数之前。我们将忽略这个版本。)
Integer result = jdbcTemplate.queryForObject(
"SELECT 42 + ? FROM dual", Integer.class, 23);
其他结果类型
我们到目前为止的所有查询都包含一个Integer.class参数,用于将数据库结果映射到 Java 类型。大多数 SQL 类型映射到 Java 类型相当自然,并且有很大的灵活性;我们可以使用BigDecimal.class或甚至double.class。为了演示这一点,让我们查询数据库的当前时间:
log.info("{}", jdbcTemplate.queryForObject(
"SELECT now() FROM dual", Timestamp.class));
log.info("{}", jdbcTemplate.queryForObject(
"SELECT now() FROM dual", LocalDateTime.class));
这很好地说明了我们可以使用java.sql.Timestamp,这是一个为了与 SQL 数据库驱动程序可能返回的值紧密匹配而创建的低级别类型,或者使用新的Java 8 DateTime-API 类型 LocalDateTime。这些转换由 Spring 方便地为我们执行!
返回多个值
如果结果集中有多个值,或者可能为零,最好请求一个结果列表,JdbcTemplate正好提供了这样的方法:
log.info("{}", jdbcTemplate.queryForList(
"SELECT fullname FROM author", String.class));
这将为我们获取数据库中所有作者的全名列表(记住它们是在data.sql startup脚本中导入的):[管理员, 彼得·奎恩, 保罗·尼普科, 凯瑟琳·萨卡伊]。
剩下的限制是查询只能返回一个列转换为给定类型。下一步是同时查询多个列。
返回结构化数据
返回多个列的查询提出了如何在 Java 中表示这些列的问题。有两种方法可以解决这个问题——将数据作为映射返回或使用回调函数。对于一次性查询,映射方法非常方便:
log.info("{}", jdbcTemplate.queryForMap(
"SELECT username, fullname FROM author WHERE id = 1"));
结果是一个Map<String, Object>,在这个例子中包含{USERNAME=admin, FULLNAME=管理员}。(注意,H2 将列名全部返回为大写,即使它在查询中接受小写。)
获取列表的相应调用如下:
log.info("{}", jdbcTemplate.queryForList(
"SELECT username, fullname FROM author"));
注意,这里有一点不对称;要获取映射而不是单个对象,我们必须将queryForObject()改为queryForMap(),而要获取映射列表而不是单个对象列表,我们使用相同的queryForList()调用,但省略了预期的类型。
映射行
使用映射很简单,但我们更希望我们的应用程序使用适当的对象而不是这些映射。Spring 允许我们通过使用RowMapper<T>接口来参与将结果集中的每一行映射到列表元素的流程。此接口是泛型的;其类型参数决定了查询方法返回值的类型:
RowMapper<Author> authorRowMapper = new RowMapper<Author>() {
@Override
public Author mapRow(ResultSet rs, int rowNum) throws
SQLException {
return Author.builder()
.username(rs.getString("username"))
.fullName(rs.getString("fullname"))
.build();
}
};
log.info("{}", jdbcTemplate.query(
"SELECT username, fullname FROM author", authorRowMapper));
映射行(简短)
幸运的是,RowMapper<T>始终是一个只有一个抽象方法的接口,这使得我们可以在 Java 8 中将其用作函数式接口。因此,前面的代码可以缩短为以下内容:
log.info("{}", jdbcTemplate.query(
"SELECT username, fullname FROM author",
(rs, rowNum) -> Author.builder()
.username(rs.getString("username"))
.fullName(rs.getString("fullname"))
.build()));
当然,在扩展版本中仅仅提及RowMapper<Author>也充当了一种文档;第二个版本既没有提及类型,也没有提及实际的映射。由于query()方法允许提供两个额外的回调:RowCallbackHandler和ResultSetExtractor<T>,这使得情况变得更糟。因此,请注意rs和rowNum这两个参数,以找到RowMapper<T>或将映射器存储在变量中。通常,以下可能是最易读的折衷方案:
RowMapper<Author> authorRowMapper =
(rs, rowNum) -> Author.builder()
.username(rs.getString("username"))
.fullName(rs.getString("fullname"))
.build();
log.info("{}", jdbcTemplate.query(
"SELECT username, fullname FROM author",
authorRowMapper));
另一个需要注意的非常重要的事情是,RowMapper<T>应该直接将一行映射到一个对象。特别是,作者不应该在给定的 SQL ResultSet上调用next()。在许多情况下,这正是我们想要的。
高级映射:行跨
有时,ResultSet的多行将被映射到我们应用程序中的一个实体。这种技术在数据库入门应用程序的AuthorRepository中已被使用。我们为什么想要这样做呢?
作者可以拥有多个角色,将它们映射到关系数据库管理系统(RDBMS)中的表的方法是将实体拆分为两个表:一个用于作者本身,另一个用于角色。要按名称检索作者,有两种方法。
首先,通过名称从作者表中检索作者的行并查找 ID。然后,使用该 ID 从角色表中检索角色。这非常直接,但需要两次数据库访问。
我们可以使用 SQL JOIN 操作同时从作者和角色表中检索数据。对于一个只有一个角色的作者来说,这同样简单,但当作者有多个角色时,操作将多次返回作者数据。因此,我们需要前进结果集并去重。
第二种方法中由于数据重复而产生的开销通常小于两次往返数据库的开销。当然,对于我们的小型应用程序来说,所有这些优化都是不必要的,甚至对于大型应用程序来说也是如此。
在我们的仓库中,我们使用ResultSetExtractor<Author>来创建作者实例。与RowMapper<T>中的情况不同,extractData(ResultSet rs)方法只有一个参数,即结果集,并且可以按其希望的任何方式处理它。这更接近于手写的 JDBC 代码;特别是这次我们需要在ResultSet上调用next()来移动游标。
注意,在RowMapper<T>和ResultSetExtractor<T>中,我们不必担心异常,因为JdbcTemplate将接管异常转换。
CRUD 操作
缩写CRUD让我们想起了我们通常执行的数据库操作类型。这四个字母代表创建(Create)、读取(Read)、更新(Update)和删除(Delete)。它们或多或少对应于 SQL 关键字INSERT、SELECT、UPDATE和DELETE。到目前为止,我们只覆盖了读取,所以我们现在转向其他三个。
读取与其他操作不同,因为它返回数据,而其他三个操作最多返回一个更新计数。从更广泛的角度来看,创建行和删除行也是对数据库的更新,因此JdbcTemplate中只有一个update()方法来满足所有这些需求——update()的使用非常直接:
int updateCount = jdbcTemplate.update(
"INSERT INTO role(author_id, role) VALUES(4, 'ADMIN')");
log.info("{} rows updated", updateCount);
这个语句将通过在数据库中插入具有该值的行来添加一个用户角色。调用返回更新的行数,正如预期的那样,是 1 行。
UPDATE和DELETE的工作方式相同。
模式更新
只有短期运行的应用程序不需要更改。如果您有一个长期运行的应用程序,需求会随着时间的推移而变化。随着您需求的变化,您的应用程序也会变化。最终,您会发现您的数据模型也需要进化。
在这个后者的例子中,添加新表是不够的;我们还需要将现有数据从旧列迁移到新表,然后删除现在无用的旧列。
您需要确保您的代码和数据库保持兼容。使用我们迄今为止看到的方法来做这件事是困难的,并且容易出错。我们希望将模式(schema)和代码保持紧密,在同一个代码库中,并且不要告诉运维部门在部署期间执行某些步骤。对于非常简单的情况,我们只能使用JdbcTemplate上的execute()方法来执行模式更改。
有工具和库支持我们在这个任务中。有Flyway,我们稍后会看到,还有Liquibase。两者都可作为 Java 库和命令行工具使用。Liquibase 有很多功能,但正如经常发生的那样,它也相当复杂。许多用户发现他们不需要所有高级功能。一般的建议是坚持使用 Flyway,并将 Liquibase 放在心中,以防万一您发现您实际上需要更多。
使用 Flyway 进行数据库迁移
Flyway 通过将两者都保存在同一个地方,即您的源代码库,来帮助保持您的代码和数据库模式同步。我们的入门级应用程序有一种类似的东西,但相当简单——包含创建数据库模式的 SQL 的schema.sql文件是源代码的一部分。然而,这假设数据库是空的。对于与应用程序一起启动的内存数据库,这已经足够好了。
在大多数应用中,数据库中的数据将远远超出应用的运行时间;这实际上就是最初使用数据库管理系统(DBMS)的整个目的,假设一个空的数据库是不够的。当我们发布软件的新版本时,我们必须相应地更改数据库以匹配这个版本。实现这一点的非常有效的方法是在新应用实际读取任何数据之前,在第一次启动时运行这个更改。这种模式和数据的更改称为迁移。
看看 Java 和 SQL 是如何并肩发展的:

当然,我们需要跟踪哪些更改已经存在于数据库中。这就是 Flyway 介入的地方。每个必要的迁移,以保持数据库与应用同步,都与应用存储在一起,Flyway 会记住已经运行过的迁移,并且只会运行一次。
利用 Flyway
目标是利用 Flyway 进行模式迁移。你希望在应用中添加迁移你的模式。你想要这样做
为了在启动时自动执行,所以你决定使用 Flyway 和 Spring Boot 对它的出色支持。
在开始之前,定位到数据库简介应用bit.ly/2zeKkl7。完成步骤如下:
- 在 POM 中,在
<dependencies>元素内的一个方便位置(在其他两个数据库依赖项之后会很好),添加以下依赖项:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
- 在
src/main/resources文件夹中,创建一个新的文件夹,db。
这最简单的方法就是在 IntelliJ IDEA 中直接操作:在树中右键点击resources,然后选择新建目录。
在这个新创建的db文件夹中,创建一个新的文件夹,migration。这是 Flyway 默认期望数据库迁移的地方。
-
将
schema.sql和data.sql文件从resources文件夹移动到新的db/migration文件夹。 -
将
schema.sql重命名为V01__initial.sql,将data.sql重命名为V02__data.sql。
看看下面的截图:

可以通过按Shift + F6在 IntelliJ IDEA 中重命名。在两种情况下,重要的是两个下划线之前的前缀(以及.sql后缀),而名称的其余部分是信息性的。
- 启动应用。它的行为和以前一样。
在日志中,你会找到以下行:
Successfully validated 2 migrations (execution time 00:00.027s)
Creating Schema History table: "PUBLIC"."flyway_schema_history"
Current version of schema "PUBLIC": << Empty Schema >>
Migrating schema "PUBLIC" to version 01 - initial
Migrating schema "PUBLIC" to version 02 - data
Successfully applied 2 migrations to schema "PUBLIC" (…)
前往bit.ly/2NEFqG9访问DatabaseIntroApplication.java文件的代码。
Flyway – 背后场景
应用启动时的日志行讲述了整个故事。Flyway 将所有迁移记录在一个名为flyway_schema_history的数据库表中。
如果你想查看模式历史表,可以在 H2 控制台中查看。请注意,实际上表的名字是flyway_schema_history,全部小写。要查看表中的所有内容,可以使用以下 SQL 语句:
SELECT * FROM "flyway_schema_history"
对于所有列也是同样的道理:
SELECT "installed_rank", "version", "description",
"type", "script", "checksum", "success"
FROM "flyway_schema_history"
结果将是以下内容:

如果表尚不存在,Flyway 将动态创建它,这就是我们使用仅内存数据库的每次启动会发生的情况。在这种情况下,当前模式的版本也将为空。
Flyway 在我们的类路径下找到所有位于db.migration;的迁移,在我们的例子中,有两个文件。所有迁移文件名都以版本号开头——一个大写字母 V 后跟一个数字,该数字可能被单个下划线或点分隔。双下划线将版本号与任意名称分隔开;这是可读的部分。Flyway 根据版本号的数值顺序排列迁移。前导零在之前的名称中仅用于在文件视图中进行正确的词法排序。
然后,Flyway 将执行所有尚未运行的迁移(在初始情况下是所有迁移)。Flyway 还会计算所有文件的总校验和。请记住,一旦应用了迁移,它将不会再次执行。更改它不会有任何效果,很可能是错误,因此如果任何校验和不匹配,Flyway 将拒绝继续。
非平凡迁移
到目前为止看到的迁移允许您在应用程序的生命周期内与 Java 源代码一起演变数据库模式。Flyway 有两个扩展允许我们处理异常情况。
有时,我们可能希望支持我们的代码库中的不同数据库系统,例如,用于测试的 H2 数据库和用于生产的 Oracle 数据库。大多数情况下我们可以使用相同的 SQL,但并不总是这样。在这种情况下,我们可以使用特定于供应商的 SQL。要使用它,我们需要配置迁移路径以包含供应商。在application.properties中的可能设置之一可以是这个:
spring.flyway.locations=db/migration/{vendor},db/migration
占位符{vendor}将被实际的数据库驱动程序替换。例如,当使用 MySQL 时,Flyway 将首先在db/migration/mysql中搜索,然后是db/migration。
另一种情况可能是迁移包括复杂的计算,这些计算仅使用 SQL 无法完成,或者使用 Java 更容易完成。对于这些情况,Flyway 也支持 Java 迁移。Spring Boot 使得使用这些迁移变得非常简单。我们只需创建扩展BaseFlywayCallback的 bean;可以使用@Order注解指定顺序。在 bean 内部,我们可以注入Datasource并使用JdbcTemplate,正如我们之前所看到的。
Outlook – 高级数据库支持
为了总结,让我们最后看一下一些高级章节,这些章节在其他方面超出了本书的范围。
外部数据库
到目前为止,我们使用了内存数据库。Spring Boot 在幕后为我们做了很多工作。数据库管理系统被自动检测、启动,并且数据库连接自动建立。
这种设置非常适合探索数据库技术。它也可以用于生产环境。H2 是轻量级且快速的。它还可以配置为将所有数据实际保存到磁盘上。然后它将是持久的,只要那个文件存在。如果你只有一个客户端(你的应用程序),并且数据适合你的内存,H2 是一个可以考虑的选项。
生产就绪的数据库提供了大量其他功能。它们可以存储更多数据,它们可以分布式部署,具有故障转移和备份策略,安全性,多租户,等等。这些通常运行在自己的主机上。这让我们想到了如何从 Spring Boot 访问它们的问题。解决方案非常简单。我们只需要设置三个属性:URL、用户名和密码。以下是一个示例:
spring.datasource.url=jdbc:mysql://localhost/test
spring.datasource.username=dbuser
spring.datasource.password=dbpass
连接池
对于大多数数据库来说,获取 JDBC 连接是一个相当慢的操作。它可能比查询本身还要长。为每个 JDBC 语句获取一个新的连接并不高效。然而,为许多语句使用一个连接需要非常仔细的资源管理。
解决这个挑战的标准技术是使用连接池,其中连接被存储在存储中以便稍后重用。存在许多这样的池实现。要使用一个,我们实际上什么都不用做!
当 Spring Boot 在类路径上时,它会自动使用 HikariCP 作为其连接池。由于 spring-boot-starter-jdbc 依赖于 HikariCP,它已经找到并使用合理的默认值进行了配置。
如果由于某种原因 HikariCP 不在类路径上,第一个回退将是 Tomcat 池的 Datasource,你也会在 Web 项目中找到它。实际上,在 Spring Boot 中不使用连接池是很困难的。
其他数据库技术 – JPA 和 Spring Data
JPA 是一个 Java 标准,用于访问数据库,它提供了一定程度的抽象。它自动或通过注解将 Java 类映射到数据库表,因此也被称为对象关系映射器,或 ORM。JPA 最著名的实现是 Hibernate,这两个术语有时可以互换使用(即使是不正确的)。
Spring Boot 通过 spring-boot-starter-data-jpa POM 支持这一点。除了正常的 JPA 功能外,这还包括 Spring Data,这是一个 Spring 子项目,允许我们通过仅创建接口来编写 DAO(或存储库)。这样的存储库可能看起来就像这样:
public interface CityRepository extends Repository<City, Long> {
List<City> findAll();
City findByNameAndCountryAllIgnoringCase(
String name, String country);
}
这个接口的实现将在启动时动态创建。JPA 并不是每个项目的最佳选择。
其他数据库技术 – jOOQ
使用 JDBC 访问数据库有一个严重的缺点——通过使用 SQL,我们将另一种语言嵌入到我们的 Java 程序中。这些语句在编译时不会被检查,并且它们不是类型安全的。
解决这个问题有几种方法。IntelliJ IDEA 在终极版中为嵌入式语言提供了很好的支持,但仅依赖 IDE 并不是一个好的方法。另一种选择是拥有一个查询数据库并从中生成反映数据库中表的 Java 类的工具。
jOOQ 就是这样一种产品,它直接由 Spring Boot 支持。它提供了一个始终以DSLContext类的对象开始的流畅 API。如果 jOOQ 在类路径上,Spring Boot 将自动创建这样的 DSLContext,并将其连接到您的全局Datasource作为 Spring Bean。我们只需将其连接到我们的一个 Bean,就可以这样使用:
List<LocalDate> = dslContext.selectFrom(AUTHOR)
.where(AUTHOR.DATE_OF_BIRTH.greaterThan(
LocalDate.of(1980, Month.JANUARY, 1)))
.fetch(AUTHOR.DATE_OF_BIRTH);
活动:创建一个显示多个作者的短信应用程序
目标
创建一个简短的消息列表,可以显示单篇文章的多个作者。
先决条件
我们在这个活动上建立了一个简单的消息应用程序,该应用程序在上一章中使用;为了简单起见,它去掉了登录功能。它已经有一个面向人类的前端。您可以在bit.ly/2BcfVW1找到源代码。
场景
您有一个需要发展的工作应用程序;现在消息可以有多位作者。我们需要对模式、现有数据、数据库访问和视图进行更改——我们将一次性涵盖很多内容!
完成步骤
-
首先,在
ShortMessage类中更改作者字段,并在创建消息时更改存储库。 -
将 Thymeleaf 的
index.html视图更改为接受多个作者。 -
现在启动应用程序以验证一切是否如之前一样显示。
-
添加一个新的 Flyway 迁移,您需要一个新表,因此从消息中复制数据,最后删除 ID 列。
-
在
retrieveAll方法的开头添加所需的代码,以使存储库与数据库兼容。 -
使用从文章 ID 到作者的映射,更改第二个
jdbcTemplate执行的查询和实现。 -
现在启动应用程序以验证一切是否如之前一样显示。
-
在另一个浏览器标签中使用 H2 控制台添加一些合著者,并在原始标签中重新加载后立即看到结果。
-
创建一个新的迁移,
V04__coauthors.sql,包含消息-作者链接。当您再次启动应用程序时,这些链接将可见,如本截图所示:

简短的消息列表可以显示单篇文章的多个作者。
前往bit.ly/2Mnhjaw访问DatabaseIntroApplicationTests.java文件的代码。
前往bit.ly/2OiSCh4访问DatabaseIntroApplication.java文件的完整代码解决方案。
要参考详细步骤,请前往本书末尾的解决方案部分,第 263 页。
摘要
在 Spring Boot 书的最后一章中,我们的重点是应用程序处理的数据,特别是它们持久化的数据。对于许多类型的应用程序来说,将数据存储在数据库中至关重要。我们简要介绍了关系数据库,这是行业的动力源泉。Spring 使得访问这些数据库变得容易。你连接了一个轻量级的内存数据库,并看到了如何连接到生产数据库。然后,你使用 SQL 和 Spring 的支持类 JdbcTemplate 从该数据库查询数据。为了与应用程序代码一起演进数据库,你随后使用了 Flyway 迁移。
这本书到此结束。你已经学会了如何开发 Spring Boot 应用程序,如何开发由 Spring 应用程序支持的网页或向客户提供 REST API,以及如何与数据库交互。Spring Boot 和 Spring 项目还有更多可以提供的内容,而这个世界现在就在你的指尖。
书籍摘要
在这本书中,你了解了 Spring 应用程序的基本构建块,Spring 应用程序的创建,以及测试 Spring 应用程序。然后,我们分析了 MVC 模式,使用 Thymeleaf 显示和编辑数据,以及 RESTful API。最后,我们实现了基于数据库的 Web 应用程序安全性和数据持久化的基本原理。
感谢!
这里有一些智慧的结束语:
-
如果有疑问,请检查 StackOverflow.com!
-
阅读你所使用的框架或库的 Javadoc 或甚至源代码,这真的很有帮助!
-
使用开源框架,贡献补丁和错误报告,或者只是报告文档错误。
第十章:解决方案
本节包含每个章节末尾活动的解答。
第一章:Spring 项目和框架
以下是本章的解决方案。
活动:Lombok 实战
完成步骤
-
在包含
Application类的包中创建一个BeanData类:-
添加一个名为
Bean的私有 final String 字段。 -
添加一个名为
BeanClass的私有 final String 字段。 -
在类中插入
@Data和@RequiredArgs注解。看一下下面的截图:
-

-
填充数据结构:
-
打开你之前用 Beans 的循环增强的主应用程序文件。
-
为
BeanData实例添加一个列表。 -
用每个 Bean 的对应数据填充列表。
-
你可以使用 getBean 方法通过 Bean 名称从上下文中获取 Bean,例如。
-
打印
BeanData列表内容:-
在 Bean 列表填充完毕后,在之后立即添加另一个循环,该循环遍历
BeanData列表。 -
使用
System.out.println通过生成的Getters输出 Bean 的名称和类。
-
第二章:构建 Spring 应用程序
以下是本章的解决方案。
活动:自动装配和配置
完成步骤
-
创建一个名为
ConfigurationProperty的类GreetingProperties,用于读取后缀的配置值,并将属性值添加到配置文件中。 -
创建问候服务
-
在服务中注入该类
-
创建一个公共方法,该方法使用问候者的名字作为参数来创建问候语。
-
在
Application类中添加一个配置方法,该方法执行问候方法并打印结果 -
高级:
-
在
Application类中添加一个自动装配的字段 -
当使用不同的机制设置
Application类的依赖项时执行问候方法。
-
第三章:测试 Spring 应用程序
以下是本章的解决方案。
活动:编写集成测试
完成步骤
-
为
BlogService类创建一个新的集成测试类。 -
为
BlogService的retrievePagedBlogEntries方法创建各种测试。 -
创建一个快乐路径测试(使用简单的测试数据,一切按预期工作)。
-
为有趣的参数组合创建测试(例如,使用你期望的行为的值,如 0、-1 等)。
-
修复服务代码以使其符合你的预期。
高级:创建不同类型的测试,查看它们并分析其优缺点。(省略你在 步骤 2 中创建的类型)
-
不使用 mockito 的单元测试
-
使用 mockito 的单元测试
-
无任何模拟的集成测试
-
使用
MockBeans进行集成测试 -
使用
SpyBeans进行集成测试
前往 bit.ly/2MqhUZ4 访问 BlogService 测试文件的代码。
第四章:MVC 模式
以下是本章的解决方案。
活动:创建你的第一个 Web MVC 控制器
完成步骤
-
前往
start.spring.io并输入以下值:组:
com.packt.springboot工件:
blogmania -
在“搜索依赖项”字段中输入以下依赖项,每个依赖项后按回车键:
Web, Thymeleaf, Devtools
-
您应该看到以下屏幕:

-
点击生成项目以下载包含初始项目文件的 ZIP 文件。
-
现在解压下载的文件,该文件名为
blog.zip,到您的项目文件夹中。 -
在
pom.xml文件的<dependencies>部分添加对 Bootstrap webjars 存档的依赖项。
前往bit.ly/2QoVEBX访问pom.xml文件的代码。
- 在
src/main/resource/templates文件夹中创建一个名为welcome.html的文件。
前往bit.ly/2x6w90k访问welcome.html文件的代码。
- 添加一个控制器来渲染视图。在名为
com.packt.springboot.blogmania.controller的包中创建WelcomeController类。
前往bit.ly/2OhxZlo访问WelcomeController.java文件的代码。
- 现在通过 Maven 启动应用程序:
mvnw spring-boot:run
- 使用浏览器导航到
http://localhost:8080:

第五章:使用网页显示信息
以下是该章节的解决方案。
活动:显示博客文章的详细信息
完成步骤
-
创建一个带有
@Controller注解的类。将类命名为BlogPostController。按照您在上一章中学到的知识注入BlogPostService。 -
向
BlogPostController添加一个名为displayBlogPostBySlug()的处理方法,该方法从查询中获取要检索的文章的 slug。设置映射为"/{slug}",并使用路径变量 slug 查找博客文章。当找不到具有给定 slug 的博客文章时,抛出BlogPostNotFoundException。 -
使用
findBySlug()方法从BlogPostService检索博客文章。 -
从
displayBlogPostBySlug()方法返回一个ModelAndView实例。设置视图名为blogpost,并将从BlogPostService返回的博客文章作为名为blogPost的属性添加到模型中。
前往bit.ly/2xaKvfc访问BlogPostController.java文件的代码。
- 在
src/main/resources/templates/blogposts/details.html创建一个视图模板文件。您可以使用样本目录中的empty.html文件作为页面布局代码。
前往bit.ly/2Qrh4OL访问details.html文件的代码。
-
使用
mvnw spring-boot:run命令启动应用程序 -
打开 URL
http://localhost:8080/blogposts/my-first-post -
获得以下输出屏幕:

第六章:在视图和控制器之间传递数据
以下是该章节的解决方案。
活动:创建一个页面以输入新的博客文章
完成步骤
-
打开位于
bit.ly/2Ft1iBQ的项目。 -
打开位于
package com.packt.springboot.blogmania.category中的尚为空的模型类Category。
前往bit.ly/2xadDDh访问Category.java文件的完整代码。
-
在模型类中添加一个名为
name的字符串类型的属性,并使用 Lombok 注解生成数据类的所有方法。 -
打开与类别类位于同一包中的控制器类
CategoryController。你将找到一个空的 Spring 控制器。
前往bit.ly/2x6YMKG访问CategoryController.java文件的完整代码。
-
添加一个名为
allCategories的字段,类型为List<Category>,将包含所有可用的类别。别忘了初始化列表(使用ArrayList<>)。 -
添加一个名为
renderCategoryForm()的方法以初始化一个新的空类别并将其添加到模型中作为名为category的属性。此方法应映射到带有 URI "/categories/new"的 GET 请求。渲染名为"/categories/form"的视图。 -
为"
/categories"添加一个addCategory()方法,用于处理 POST 请求映射。此方法将接收一个类别作为参数并将其添加到所有类别的列表中。返回视图名称为"
redirect:/"。 -
实现目前为空的
retrieveAllCategories()方法以返回所有类别的列表。你可能想要返回列表的副本以防止客户端更改原始列表。 -
打开包含类别表单的文件
src/main/resources/categories/form.html。
前往bit.ly/2NHbtFw访问form.html文件的完整代码。
-
添加一个表单以输入类别名称。在表单中使用
@{/categories}动作。请随意使用 Bootstrap 来美化输入元素。 -
打开包含表单以编辑博客文章的文件
src/main/resources/blogposts/form.html。
前往bit.ly/2NHbtFw访问form.html文件的完整代码。
-
在表单中添加一个下拉字段,使用
th:field="*{categoryName}"生成所有选项,这些选项使用模型中名为categories的属性中可用的所有类别列表。 -
启动应用程序并在浏览器中打开
http://localhost:8080。

- 现在点击添加类别。

-
输入类别标题并点击保存。
-
添加尽可能多的类别。
-
现在点击右上角的加号(+)。

-
输入一个博客文章并查看类别列表。
-
点击保存后,所选类别应出现在博客文章标题下方。

第七章:RESTful API
以下为本章节的解决方案。
活动:创建一个作为 REST 资源的博客文章列表
完成步骤
-
以 blogmania 应用程序为例,找到
BlogPostController。在这里,你可以看到单个博客文章是如何为 Web 前端交付的。另一个控制器HomePageController将所有文章添加到模型中。这两个可以作为下一步的示例。 -
编写另一个控制器,这次是一个 REST 控制器,它提供 URL
/api/blogposts下所有文章的列表。为此,创建一个新的类(最好在 blogpost 包中),命名为BlogPostRestController,并使其成为一个 Spring REST 控制器。 -
虽然你可以为每个方法添加完整的路径,但我们知道
/api/blogposts对于这个控制器中可能出现的所有方法都将相同,所以请在类级别添加这个路径。 -
这个类需要一个或两个依赖项——将它们添加到类中。
-
添加一个映射函数,通过 REST 获取所有博客文章。REST 约定表示这个列表资源将正好位于我们在类级别给出的路径下。
-
启动应用程序并使用 Postman 访问
localhost:8080/api/blogposts——你现在应该看到作为 JSON 列表返回的博客文章列表。

第八章:Web 应用程序安全
以下是该章节的解决方案。
活动:探索安全注解
完成步骤
- 导航到
HomePageController并找到homePage()方法。添加一个注解以限制对具有角色 USER 的用户访问,如下所示:
@GetMapping("/")
@RolesAllowed("USER")
public String homePage(Model model) { … }
- 重新启动应用程序并在浏览器中打开页面。
注意,我们立即被重定向到登录页面。使用任何预定义的用户登录;例如,peter/quinn或cate/sakai。
-
注意,索引页面已更改以反映您的用户。
看看以下截图,显示了分配的名称和角色:


-
尝试注销。
-
查找
ShortMessageService及其findAll()方法。添加一个注解以限制显示用户自己撰写的文章:
@PostFilter("isAnonymous() || " +
"filterObject.author.username == authentication.name")
public List<ShortMessage> findAll() { … }
- 重新启动应用程序并在浏览器中打开页面。
第九章:使用数据库持久化数据
以下是该章节的解决方案。
活动:创建一个显示多个作者的短信应用程序
完成步骤
-
首先,在
ShortMessage类中,将作者字段更改为:Listauthors。为了使 Java 代码能够编译,需要在创建 ShortMessage时在ShortMessageRepository中进行更改:在第一步中,只需使用Collections.singletonList()包装作者。 -
在下一步中,将 Thymeleaf 视图
index.html修改为接受多个作者。请注意,属性现在称为msg.authors。创建一个以逗号分隔的列表需要一点工作,但你可以从上面几行显示多个权限的方式中复制。为了完整,这需要做两次:一次是为了fullName,一次是为了用户名。 -
你现在可能想启动应用程序来验证一切是否如之前一样显示。这样的小步骤是软件发展的方式。
-
现在添加一个新的 Flyway 迁移
V03__authors.sql– 你需要一个新表message_authors,从short_message复制数据,最后删除author_id列。为了参考,请再次查看本章第三部分。 -
现在,
ShortMessageRepository与数据库不再兼容。有许多方法可以解决这个问题。一种相当简单的方法是在retrieveAll方法的开始处添加以下内容:
LinkedMultiValueMap<Integer, Author> authorsMap =
new LinkedMultiValueMap<>();
RowCallbackHandler addAuthor = rs -> authorsMap.add(
rs.getInt("message_id"),
authorService.retrieveAuthor(rs.getInt("author_id")));
jdbcTemplate.query(
"SELECT message_id, author_id FROM message_authors",
addAuthor);
-
使用从文章 ID 到作者的映射,更改查询和第二个
jdbcTemplate执行的实现。 -
你现在可能想启动应用程序来验证一切是否如之前一样显示。请注意,你现在得到的是不同数据模型和数据库中不同数据的相同显示!
-
要真正看到一些变化,你可以在另一个浏览器标签中使用 H2 控制台添加一些合著者,并在原始标签中重新加载后立即看到结果。
-
创建一个新的迁移
V04__coauthors.sql,包含一些消息-作者链接。当你再次启动应用程序时,这些链接将如以下截图所示:

解决方案摘要
希望这一节能帮助你解决在尝试这些活动时遇到的任何障碍。现在你应该能够解决其他类似的问题。
解题愉快!


浙公网安备 33010602011771号