精通-SpringMVC4-全-
精通 SpringMVC4(全)
原文:
zh.annas-archive.org/md5/cc25428c0d0afda326c59aa81d555968译者:飞龙
前言
作为一名 Web 开发者,我喜欢创造新事物,快速上线,然后转向我的下一个想法。
在一个所有应用程序都相互连接的世界里,我们需要与社交媒体互动来推广我们的产品,提供复杂的系统,为我们的用户提供巨大的价值。
直到最近,所有这些对于 Java 开发者来说都是一个遥远且复杂的世界。随着 Spring Boot 的诞生和云平台的民主化,我们现在可以以创纪录的时间创建令人惊叹的应用程序,并将它们提供给每个人,而不需要花费一分钱。
在本书中,我们将从头开始构建一个有用的 Web 应用程序。一个具有许多精美功能的应用程序,例如国际化、表单验证、分布式会话和缓存、社交登录、多线程编程等等。
此外,我们还将对其进行全面测试。
到本书结束时,我们将发布我们的小型应用程序并将其发布到网络上。
如果这听起来很有趣,那我们就不要浪费时间,动手编写代码吧!
本书涵盖内容
第一章, 快速搭建 Spring Web 应用,使我们能够快速开始使用 Spring Boot。它涵盖了将使我们更高效的工具,例如 Spring Tool Suite 和 Git。它还将帮助我们快速搭建应用程序框架,并揭示 Spring Boot 背后的魔法。
第二章, 精通 MVC 架构,引导我们创建一个小型 Twitter 搜索引擎。在这个过程中,它涵盖了 Spring MVC 的基础和 Web 架构的原则。
第三章, 处理表单和复杂的 URL 映射,帮助您了解如何创建用户资料表单。它涵盖了如何在服务器端以及客户端验证我们的数据,并使我们的应用程序支持不同的语言。
第四章, 文件上传和错误处理,引导您将文件上传添加到您的个人资料表单中。它演示了在 Spring MVC 中正确处理错误并显示自定义错误页面。
第五章, 构建 RESTful 应用程序,解释了 RESTful 架构的原则。它还帮助我们创建一个可通过 HTTP 调用访问的用户管理 API,了解哪些工具可以帮助我们设计这个 API,并讨论如何轻松地对其进行文档化。
第六章, 保护您的应用程序,引导我们保护我们的应用程序。它涵盖了如何使用基本的 HTTP 身份验证来保护我们的 RESTful API,以及如何在登录页面之后保护我们的网页。它演示了如何通过 Twitter 启用登录并将我们的会话存储在 Redis 服务器上,以允许我们的应用程序进行扩展。
第七章,别把希望寄托在运气上 – 单元测试和验收测试,帮助我们测试我们的应用程序。它讨论了测试和 TDD,并涵盖了如何对控制器进行单元测试以及如何使用现代库设计端到端测试。它以 Groovy 如何提高我们的生产力和测试的可读性结束。
第八章,优化你的请求,带我们了解如何优化我们的应用程序。它涵盖了如何使用缓存控制和 Gzipping。本章教你如何在内存中和 Redis 中缓存我们的 Twitter 搜索结果,并展示了如何多线程搜索。作为额外奖励,实现 Etags 和使用 WebSockets 也包含在内。
第九章,将你的 Web 应用程序部署到云端,指导我们如何发布我们的应用程序。它展示了如何比较不同的 PaaS 解决方案。然后,它演示了如何在 Cloud Foundry 和 Heroku 上部署应用程序。
第十章,超越 Spring Web,讨论了 Spring 生态系统,现代 Web 应用程序由什么组成,以及从这里可以走向何方。
你需要为这本书准备的东西
虽然我们将构建一个前沿的 Web 应用程序,但我们不需要你安装很多东西。
我们将要构建的应用程序需要 Java 8。
你不是必须这么做,但你绝对应该使用 Git 来版本控制你的项目。如果你想在 Heroku 上部署你的应用程序,这将是有必要的。此外,你将能够轻松地备份你的工作,并查看代码通过差异和历史记录的演变。本书的第一章提供了几个开始使用 Git 的资源。
我还推荐你使用一个好的 IDE。我们将看到如何快速开始使用 Spring Tool Suite(免费)和 IntelliJ Idea(你可以获得一个月的试用版)。
如果你有一台 Mac,你应该检查 Homebrew (brew.sh)). 使用这个包管理器,你可以安装本书中使用的任何工具。
这本书是为谁而写的
这本书非常适合熟悉 Spring 编程基础且渴望扩展他们的 Web 开发技能的开发者。建议具备 Spring 框架的先验知识。
习惯用法
在这本书中,你会找到许多不同风格的文本,用于区分不同类型的信息。以下是一些这些风格的示例,以及它们含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"你将在build/libs目录中找到 JAR 文件"。
代码块设置如下:
public class ProfileForm {
private String twitterHandle;
private String email;
private LocalDate birthDate;
private List<String> tastes = new ArrayList<>();
// getters and setters
}
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将被设置为粗体:
public class ProfileForm {
private String twitterHandle;
private String email;
private LocalDate birthDate;
private List<String> tastes = new ArrayList<>();
// getters and setters
}
任何命令行输入或输出都应如下所示:
$ curl https://start.spring.io
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“转到新项目菜单并选择Spring Initializr项目类型”。
注意
警告或重要注意事项以这种方式出现在一个框中。
小贴士
小贴士和技巧看起来像这样。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大价值的书籍非常重要。
要发送给我们一般反馈,只需发送电子邮件到<feedback@packtpub.com>,并在您的邮件主题中提及书籍标题。
如果您在某个主题领域有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 书籍的骄傲拥有者了,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您也可以从github.com/Mastering-Spring-MVC-4/mastering-spring-mvc4下载这本书的示例代码。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进此书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站,或添加到该标题的现有错误清单中,在“错误清单”部分下。任何现有错误清单都可以通过从www.packtpub.com/support选择您的标题来查看。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过链接发送至 <copyright@packtpub.com> 与我们联系,以提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者以及为我们提供有价值内容方面的帮助。
问题和建议
如果你在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 与我们联系,我们将尽力解决。
第一章. 快速设置 Spring Web 应用程序
在本章中,我们将直接进入代码并设置一个 Web 应用程序,我们将在这本书的剩余部分继续工作。
我们将利用 Spring Boot 的自动配置功能来构建一个无需样板代码或配置文件的程序。
我将概述 Spring Boot 的工作原理以及如何配置它。有四种方式开始使用 Spring:
-
使用 Spring Tool Suite 生成启动代码
-
使用 IntelliJ IDEA 14.1,它现在对 Spring Boot 有很好的支持
-
使用 Spring 的网站
start.Spring.io下载一个可配置的 zip 文件 -
使用 curl 命令行访问
start.Spring.io并实现相同的结果
在本书中,我们将使用 Gradle 和 Java 8,但不要害怕。即使您仍在使用 Maven 和 Java 的早期版本,我相信您会发现这些技术很容易使用。
许多官方 Spring 教程都有 Gradle 构建和 Maven 构建,所以如果您决定坚持使用 Maven,您将很容易找到示例。Spring 4 完全兼容 Java 8,所以不利用 lambda 来简化我们的代码库将是一件遗憾的事情。
我还将向您展示一些 Git 命令。我认为在您处于稳定状态时跟踪进度并提交是一个好主意。这也会使您更容易将您的工作与本书提供的源代码进行比较。
由于我们将在第九章 Chapter 9 中部署我们的应用程序到 Heroku,我建议您从一开始就使用 Git 进行版本控制。我将在本章后面为您提供一些关于如何开始使用 Git 的建议。
开始使用 Spring Tool Suite
开始使用 Spring 并发现 Spring 社区提供的众多教程和启动项目之一是下载Spring Tool Suite(STS)。STS 是为与各种 Spring 项目、Groovy 和 Gradle 一起工作而设计的 eclipse 的定制版本。即使像我一样,您有另一个您更愿意使用的 IDE,我也强烈建议您尝试一下 STS,因为它让您有机会通过“入门”项目在几分钟内探索 Spring 的庞大生态系统。
因此,让我们访问Spring.io/tools/sts/all并下载 STS 的最新版本。在我们生成第一个 Spring Boot 项目之前,我们需要为 STS 安装 Gradle 支持。您可以在仪表板上找到一个管理 IDE 扩展按钮。然后您需要在语言和框架工具部分下载Gradle 支持软件。
我还建议安装Groovy Eclipse插件以及Groovy 2.4 编译器,如图所示。这些将在本书后面的内容中设置 geb 接受测试时需要。

我们现在有两个主要选项开始使用。
提示
下载示例代码
您可以从您在 www.packtpub.com 的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以直接将文件通过电子邮件发送给您。
您也可以从 github.com/Mastering-Spring-MVC-4/mastering-spring-mvc4 下载本书的示例代码。
第一个选项是导航到文件 | 新建 | Spring Starter 项目,如图所示。这将为您提供与 start.Spring.io 相同的选项,嵌入到您的 IDE 中:

您还可以访问 spring.io 上提供的所有教程,在顶部栏中选择文件 | 新建 | 导入入门内容。您可以选择使用 Gradle 或 Maven,如图所示:
注意
您还可以查看入门代码以跟随教程,或直接获取完整代码。

在入门内容中有许多非常有趣的内容可供探索,我鼓励您自己探索。它将展示 Spring 与您可能感兴趣的各种技术的集成。
目前,我们将生成一个如图所示的网络项目。它将是一个 Gradle 应用程序,生成一个 JAR 文件并使用 Java 8。
这里是我们想要使用的配置:
| 属性 | 值 |
|---|---|
| 名称 | masterSpringMvc |
| 类型 | Gradle 项目 |
| 打包 | Jar |
| Java 版本 | 1.8 |
| 语言 | Java |
| 组 | masterSpringMvc |
| 生成物 | masterSpringMvc |
| 版本 | 0.0.1-SNAPSHOT |
| 描述 | 发挥创意! |
| 包 | masterSpringMvc |
在第二屏,您将需要选择您想要使用的 Spring Boot 版本以及应该添加到项目中的依赖项。
在撰写本文时,Spring Boot 的最新版本是 1.2.5。请确保您始终检查最新版本。
到你阅读这篇文档的时候,Spring Boot 的最新快照版本也将可用。如果那时 Spring Boot 1.3 还没有发布,你可能可以试一试。它的一大特点是出色的开发者工具。有关更多详细信息,请参阅spring.io/blog/2015/06/17/devtools-in-spring-boot-1-3。
在配置窗口的底部,你会看到代表各种 boot 启动库的多个复选框。这些是可以附加到你的构建文件中的依赖项。它们为各种 Spring 项目提供自动配置。
目前我们只对 Spring MVC 感兴趣,所以我们将只勾选 Web 复选框。
小贴士
一个 Web 应用程序的 JAR 文件?有些人可能觉得将 Web 应用程序打包成 JAR 文件很奇怪。虽然仍然可以使用 WAR 文件进行打包,但这并不是推荐的做法。默认情况下,Spring Boot 将创建一个胖 JAR,它将包含所有应用程序的依赖项,并提供使用 Java -jar 启动 Web 服务器的一种方便方式。
我们的应用程序将被打包成一个 JAR 文件。如果你想创建 WAR 文件,请参阅spring.io/guides/gs/convert-jar-to-war/。
你已经点击了完成了吗?如果你已经点击了,你应该会得到以下项目结构:

我们可以看到我们的主类MasterSpringMvcApplication及其测试套件MasterSpringMvcApplicationTests。还有两个空文件夹,static和templates,我们将在这里放置我们的静态 Web 资源(图像、样式等)和显然是我们的模板(jsp、freemarker、Thymeleaf)。最后一个文件是一个空的application.properties文件,这是 Spring Boot 的默认配置文件。这是一个非常方便的文件,我们将在本章中看到 Spring Boot 如何使用它。
build.gradle文件,我们将在稍后详细说明的构建文件。
如果你准备好了,运行应用程序的主方法。这将为我们启动一个 Web 服务器。
要这样做,请转到应用程序的主方法,并在工具栏中通过右键单击类或单击工具栏中的绿色播放按钮导航到运行方式|Spring 应用程序。
这样做并导航到http://localhost:8080将产生一个错误。别担心,继续阅读。
我将向你展示如何在不使用 STS 的情况下生成相同的项目,然后我们将回到所有这些文件。
IntelliJ 入门
IntelliJ IDEA 是 Java 开发者中非常受欢迎的工具。在过去几年里,我很高兴为这个出色的编辑器支付 Jetbrains 的年度费用。
IntelliJ 还有快速创建 Spring Boot 项目的方法。
前往新项目菜单,并选择Spring Initializr项目类型:

这将给我们提供与 STS 相同的选项,因此请参考上一章的详细配置。
小贴士
你需要将 Gradle 项目导入 IntelliJ。我建议首先生成 Gradle 包装器(参考以下 Gradle 构建 部分)。
如果需要,你可以通过再次打开其 build.gradle 文件来重新导入项目。
使用 start.Spring.io 入门
前往 start.Spring.io 开始使用 start.Spring.io。这个令人瞩目的类似 Bootstrap 的网站背后的系统你应该很熟悉!当你点击之前提到的链接时,你会看到以下截图:

的确,这里可以找到与 STS 相同的选项。点击 生成项目 将下载一个包含我们的入门项目的 ZIP 文件。
使用命令行入门
对于那些沉迷于控制台的人来说,你可以使用 curl start.Spring.io。这样做将显示如何构建你的 curl 请求的说明。
例如,要生成之前相同的项目,你可以执行以下命令:
$ curl http://start.Spring.io/starter.tgz \
-d name=masterSpringMvc \
-d dependencies=web \
-d language=java \
-d JavaVersion=1.8 \
-d type=gradle-project \
-d packageName=masterSpringMvc \
-d packaging=jar \
-d baseDir=app | tar -xzvf -
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 1255 100 1119 100 136 1014 123 0:00:01 0:00:01 --:--:-- 1015
x app/
x app/src/
x app/src/main/
x app/src/main/Java/
x app/src/main/Java/com/
x app/src/main/Java/com/geowarin/
x app/src/main/resources/
x app/src/main/resources/static/
x app/src/main/resources/templates/
x app/src/test/
x app/src/test/Java/
x app/src/test/Java/com/
x app/src/test/Java/com/geowarin/
x app/build.Gradle
x app/src/main/Java/com/geowarin/AppApplication.Java
x app/src/main/resources/application.properties
x app/src/test/Java/com/geowarin/AppApplicationTests.Java
哇!你现在可以不离开控制台就开始使用 Spring 了,这是一个梦想成真。
小贴士
你可以考虑使用之前的命令创建一个别名,这将帮助你非常快速地原型化 Spring 应用程序。
让我们开始吧
现在我们的 Web 应用程序已经准备好了,让我们看看它是如何编写的。在继续之前,我们可以使用 Git 保存我们的工作。
如果你对 Git 一无所知,我推荐以下两个教程:
-
try.github.io,这是一个很好的逐步交互式教程,用于学习基本的 Git 命令 -
pcottle.github.io/learnGitBranching,这是一个出色的 Git 树状结构的交互式可视化,它将向你展示基本的以及非常高级的 Git 功能
小贴士
安装 Git
在 Windows 上,安装 Git bash,它可以在 msysgit.github.io 找到。在 Mac 上,如果你使用 homebrew,你应该已经安装了 Git。否则,使用命令 brew install git。如果有疑问,请查看 git-scm.com/book/en/v2/Getting-Started-Installing-Git 的文档。
要使用 Git 对我们的工作进行版本控制,请在控制台中输入以下命令:
$ cd app
$ git init
使用 IntelliJ 时,忽略生成的文件:.idea 和 *.iml。使用 eclipse 时,你应该提交 .classpath 和 .settings 文件夹。在任何情况下,你应该忽略 .gradle 文件夹和 build 文件夹。
创建一个包含以下文本的 .gitignore 文件:
# IntelliJ project files
.idea
*.iml
# gradle
.gradle
build
现在,我们可以将所有其他文件添加到 Git 中:
$ git add .
$ git commit -m "Generated with curl start.Spring.io"
[master (root-commit) eded363] Generated with curl start.Spring.io
4 files changed, 75 insertions(+)
create mode 100644 build.Gradle
create mode 100644 src/main/Java/com/geowarin/AppApplication.Java
create mode 100644 src/main/resources/application.properties
create mode 100644 src/test/Java/com/geowarin/AppApplicationTests.Java
Gradle 构建
如果您不熟悉 Gradle,可以将其视为 Maven 的继任者,一个现代的构建工具。像 Maven 一样,它使用诸如如何结构化 Java 应用程序之类的约定。我们的源代码仍然位于 src/main/java,我们的 webapp 位于 src/main/webapp,等等。与 Maven 类似,您可以使用 Gradle 插件来处理各种构建任务。然而,Gradle 真正的亮点在于它允许您使用 Groovy DSL 编写自己的构建任务。默认库使得操作文件、声明任务之间的依赖关系以及增量执行作业变得容易。
提示
安装 Gradle
如果您使用的是 OS X,您可以使用 brew install gradle 命令通过 brew 安装 Gradle。在任何 *NIX 系统(包括 Mac)上,您可以使用 gvm (gvmtool.net/) 安装它。或者,您可以从 Gradle.org/downloads 下载二进制发行版。
在使用 Gradle 创建应用程序时,第一个好的实践是生成一个 Gradle 包装器。Gradle 包装器是一个小的脚本,您将和代码一起分享以确保构建将使用您用于构建应用程序的相同版本的 Gradle。
生成包装器的命令是 Gradle wrapper:
$ gradle wrapper
:wrapper
BUILD SUCCESSFUL
Total time: 6.699 secs
如果我们查看创建的新文件,我们可以看到两个脚本和两个目录:
$ git status -s
?? .gradle/
?? gradle/
?? gradlew
?? gradlew.bat
.gradle 目录包含 Gradle 二进制文件;您不希望将这些提交到版本控制中。
我们之前忽略了此文件以及构建目录,这样您就可以安全地 git add 其他所有内容:
$ git add .
$ git commit -m "Added Gradle wrapper"
Gradle 目录包含有关如何获取二进制文件的信息。另外两个文件是脚本:Windows 的批处理脚本(Gradlew.bat)和其他系统的 shell 脚本。
我们也可以使用 Gradle 运行我们的应用程序,而不是从 IDE 中执行应用程序:
$ ./gradlew bootrun
执行此命令将在其中包含我们的应用程序的嵌入式 tomcat 服务器上运行!
日志告诉我们服务器正在 8080 端口上运行。让我们检查一下:

我可以想象您的失望。我们的应用程序还没有准备好面向公众。
话虽如此,我们项目由两个文件完成的工作相当令人印象深刻。让我们回顾一下。
第一个是 Gradle 构建文件,build.Gradle:
buildscript {
ext {
springBootVersion = '1.2.5.RELEASE'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
classpath("io.spring.gradle:dependency-management-plugin:0.5.1.RELEASE")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'spring-boot'
apply plugin: 'io.spring.dependency-management'
jar {
baseName = 'masterSpringMvc'
version = '0.0.1-SNAPSHOT'
}
sourceCompatibility = 1.8
targetCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile("org.springframework.boot:spring-boot-starter-web")
testCompile("org.springframework.boot:spring-boot-starter-test")
}
eclipse {
classpath {
containers.remove('org.eclipse.jdt.launching.JRE_CONTAINER')
containers 'org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8'
}
}
task wrapper(type: Wrapper) {
gradleVersion = '2.3'
}
我们在这里看到了什么?
-
依赖于在 Maven 中央仓库上分发的 Spring Boot 插件。
-
我们的项目是一个 Java 项目。Gradle 可以为 IntelliJ 或 Eclipse 生成 IDE 项目文件。
-
应用程序将生成一个 JAR 文件。
-
我们的项目依赖托管在 Maven 中央仓库。
-
我们的项目类路径在生产中包含
spring-boot-starter-web,在测试中包含spring-boot-starter-test。 -
为 eclipse 配置的一些附加设置。
-
Gradle 包装器的版本是 2.3。
Spring Boot 插件将生成一个包含项目所有依赖项的胖 JAR 文件。要构建它,请输入:
./gradlew build
你可以在build/libs目录中找到 JAR 文件。这个目录将包含两个文件,一个称为masterSpringMvc-0.0.1-SNAPSHOT.jar的胖 JAR 文件,以及一个不包含任何依赖的经典 JAR 文件,masterSpringMvc-0.0.1-SNAPSHOT.jar.original。
小贴士
可运行 JAR
Spring Boot 的一个主要优点是将应用程序需要的所有内容嵌入到一个易于分发的 JAR 文件中,包括 Web 服务器。如果你运行java jar masterSpringMvc-0.0.1-SNAPSHOT.jar,tomcat 将在 8080 端口启动,就像你在开发时做的那样。这对于在生产环境或云中部署来说非常方便。
我们在这里的主要依赖是spring-boot-starter-web。Spring Boot 提供了一系列的 starters,它们会自动配置应用程序的一些方面,通过提供典型的依赖和 Spring 配置。
例如,spring-starter-web将包含tomcat-embedded和 Spring MVC 的依赖。它还将运行最常用的 Spring MVC 配置,提供一个监听"/"根路径的调度器,错误处理,例如我们之前看到的 404 页面,以及一个经典的视图解析器配置。
我们稍后会看到更多关于这个的内容。首先,让我们看看下一节。
让我看看代码!
这里是需要运行应用程序的所有代码。所有内容都在一个经典的主函数中,这是一个巨大的优势,因为你可以像运行其他任何程序一样在你的 IDE 中运行你的应用程序。你可以调试它,并且还可以无需插件就获得一些类重新加载的功能。
当你在 Eclipse 中保存文件或在 IntelliJ 中点击Make Project时,这种重新加载将在调试模式下可用。这只有在 JVM 能够切换新的编译版本和类文件的新版本时才可能;修改静态变量或触摸配置文件将迫使你重新加载应用程序。
我们的主要类看起来如下:
package masterSpringMvc;
import org.Springframework.boot.SpringApplication;
import org.Springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AppApplication {
public static void main(String[] args) {
SpringApplication.run(AppApplication.class, args);
}
}
注意@SpringBootApplication注解。如果你查看这个注解的代码,你会看到它实际上结合了三个其他的注解:@Configuration、@EnableAutoConfiguration和@ComponentScan:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Configuration
@EnableAutoConfiguration
@ComponentScan
public @interface SpringBootApplication {
/**
* Exclude specific auto-configuration classes such that they will never be applied.
*/
Class<?>[] exclude() default {};
}
如果你之前已经使用 Java 代码配置过 Spring 应用,那么@Configuration类应该对你来说很熟悉。它表示我们的类将处理 Spring 配置的经典方面:例如声明 bean。
@ComponentScan类也是一个经典。它将告诉 Spring 在哪里查找我们的 Spring 组件(服务、控制器等)。默认情况下,这个注解将扫描当前包及其所有子包。
这里的新特性是@EnableAutoConfiguration,它将指导 Spring Boot 执行其魔法。如果你移除它,你将不再从 Spring Boot 的自动配置中受益。
使用 Spring Boot 编写 MVC 应用程序的第一步通常是向我们的代码中添加一个控制器。将控制器添加到控制器子包中,以便它被@ComponentScan注解拾取:
package masterSpringMvc.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class HelloController {
@RequestMapping("/")
@ResponseBody
public String hello() {
return "Hello, world!";
}
}
这次,如果你打开你的浏览器并访问http://localhost:8080,你将看到这个可爱的Hello, world!输出:

Spring Boot 幕后
如果你之前已经设置了一个 Spring MVC 应用程序,你可能已经习惯了至少编写一小部分 XML 或一些 Java 注解配置类。
初始化步骤通常是以下这样:
-
初始化 Spring MVC 的 DispatcherServlet。
-
设置一个编码过滤器以确保客户端请求被正确编码。
-
设置视图解析器,告诉 Spring 在哪里可以找到我们的视图以及它们是用哪种方言编写的(jsp、Thymeleaf 模板等)。
-
配置静态资源位置(css、js)。
-
配置支持的地区和资源包。
-
配置一个多部分解析器,以便文件上传可以工作。
-
包括 tomcat 或 jetty,以便在我们的 Web 服务器上运行应用程序。
-
设置错误页面(例如 404)。
然而,Spring Boot 为我们处理所有这些工作。因为这种配置通常取决于你的应用程序,你可以提出无限多的组合。
从某种意义上说,Spring Boot 是一个有偏见的 Spring 项目配置器。它基于约定,并将默认强制执行这些约定到你的项目中。
分发器和多部分配置
让我们看看幕后发生了什么。
我们将使用为我们创建的默认 Spring Boot 配置文件,并将其置于调试模式。将以下行添加到src/main/resources/application.properties:
debug=true
现在,如果我们再次启动我们的应用程序,我们将看到 Spring Boot 的自动配置报告。它分为两部分:positive matches(正匹配),列出了我们应用程序使用的所有自动配置;以及negative matches(负匹配),这些是当应用程序启动时未满足要求的 Spring Boot 自动配置:
=========================
AUTO-CONFIGURATION REPORT
=========================
Positive matches:
-----------------
DispatcherServletAutoConfiguration
- @ConditionalOnClass classes found: org.Springframework.web.servlet.DispatcherServlet (OnClassCondition)
- found web application StandardServletEnvironment (OnWebApplicationCondition)
EmbeddedServletContainerAutoConfiguration
- found web application StandardServletEnvironment (OnWebApplicationCondition)
ErrorMvcAutoConfiguration
- @ConditionalOnClass classes found: javax.servlet.Servlet,org.springframework.web.servlet.DispatcherServlet (OnClassCondition)
- found web application StandardServletEnvironment (OnWebApplicationCondition)
HttpEncodingAutoConfiguration
- @ConditionalOnClass classes found: org.springframework.web.filter.CharacterEncodingFilter (OnClassCondition)
- matched (OnPropertyCondition)
<Input trimmed>
让我们更仔细地看看DispatcherServletAutoConfiguration:
/**
* {@link EnableAutoConfiguration Auto-configuration} for the Spring
* {@link DispatcherServlet}. Should work for a standalone application where an embedded
* servlet container is already present and also for a deployable application using
* {@link SpringBootServletInitializer}.
*
* @author Phillip Webb
* @author Dave Syer
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@Configuration
@ConditionalOnWebApplication
@ConditionalOnClass(DispatcherServlet.class)
@AutoConfigureAfter(EmbeddedServletContainerAutoConfiguration.class)
public class DispatcherServletAutoConfiguration {
/*
* The bean name for a DispatcherServlet that will be mapped to the root URL "/"
*/
public static final String DEFAULT_DISPATCHER_SERVLET_BEAN_NAME = "dispatcherServlet";
/*
* The bean name for a ServletRegistrationBean for the DispatcherServlet "/"
*/
public static final String DEFAULT_DISPATCHER_SERVLET_REGISTRATION_BEAN_NAME = "dispatcherServletRegistration";
@Configuration
@Conditional(DefaultDispatcherServletCondition.class)
@ConditionalOnClass(ServletRegistration.class)
protected static class DispatcherServletConfiguration {
@Autowired
private ServerProperties server;
@Autowired(required = false)
private MultipartConfigElement multipartConfig;
@Bean(name = DEFAULT_DISPATCHER_SERVLET_BEAN_NAME)
public DispatcherServlet dispatcherServlet() {
return new DispatcherServlet();
}
@Bean(name = DEFAULT_DISPATCHER_SERVLET_REGISTRATION_BEAN_NAME)
public ServletRegistrationBean dispatcherServletRegistration() {
ServletRegistrationBean registration = new ServletRegistrationBean(
dispatcherServlet(), this.server.getServletMapping());
registration.setName(DEFAULT_DISPATCHER_SERVLET_BEAN_NAME);
if (this.multipartConfig != null) {
registration.setMultipartConfig(this.multipartConfig);
}
return registration;
}
@Bean
@ConditionalOnBean(MultipartResolver.class)
@ConditionalOnMissingBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
public MultipartResolver multipartResolver(MultipartResolver resolver) {
// Detect if the user has created a MultipartResolver but named it incorrectly
return resolver;
}
}
@Order(Ordered.LOWEST_PRECEDENCE - 10)
private static class DefaultDispatcherServletCondition extends SpringBootCondition {
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context,
AnnotatedTypeMetadata metadata) {
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
ConditionOutcome outcome = checkServlets(beanFactory);
if (!outcome.isMatch()) {
return outcome;
}
return checkServletRegistrations(beanFactory);
}
}
}
这是一个典型的 Spring Boot 配置类:
-
它像任何其他 Spring 配置类一样被
@Configuration注解。 -
它通常使用
@Order注解声明其优先级级别。你可以看到DispatcherServletAutoConfiguration需要首先配置。 -
它还可以包含如
@AutoConfigureAfter或@AutoConfigureBefore之类的提示,以进一步细化配置处理的顺序。 -
它在特定条件下被启用。通过
@ConditionalOnClass(DispatcherServlet.class),这个特定的配置确保我们的类路径中包含DispatcherServlet,这是一个很好的迹象表明 Spring MVC 在类路径中,并且用户肯定希望启动它。
此文件还包含用于 Spring MVC 分发器 servlet 和多部分解析器的经典 bean 声明。整个 Spring MVC 配置被拆分为多个文件。
还值得注意的是,这些 bean 遵循某些规则来检查它们是否处于活动状态。在 @Conditional(DefaultDispatcherServletCondition.class) 条件下,ServletRegistrationBean 函数将被启用,这有点复杂,但会检查您是否已经在自己的配置中注册了分发器 servlet。
只有当条件 @ConditionalOnMissingBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME) 满足时,MultipartResolver 功能才会生效,例如,如果我们没有自己声明它。
这意味着 Spring Boot 只在根据常见用例配置应用程序时提供帮助。然而,在任何时候,您都可以覆盖这些默认设置并声明自己的配置。
因此,DispatcherServletAutoConfiguration 类解释了为什么我们有分发器 servlet 和多部分解析器。
视图解析器、静态资源和区域设置配置
另一个非常相关的配置是 WebMvcAutoConfiguration。它声明了视图解析器、区域解析器和我们的静态资源位置。视图解析器如下:
@Configuration
@Import(EnableWebMvcConfiguration.class)
@EnableConfigurationProperties({ WebMvcProperties.class, ResourceProperties.class })
public static class WebMvcAutoConfigurationAdapter extends WebMvcConfigurerAdapter {
@Value("${spring.view.prefix:}")
private String prefix = "";
@Value("${spring.view.suffix:}")
private String suffix = "";
@Bean
@ConditionalOnMissingBean(InternalResourceViewResolver.class)
public InternalResourceViewResolver defaultViewResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix(this.prefix);
resolver.setSuffix(this.suffix);
return resolver;
}
}
视图解析器配置非常典型。真正有趣的是这里使用配置属性来允许用户自定义它。
它所说的内容是“我将在用户的 application.properties 中寻找两个变量,分别称为 spring.view.prefix 和 spring.view.suffix”。这是一种非常方便的方法,只需在我们的配置中两行代码即可设置视图解析器。
请记住这一点,为下一章做准备。现在,我们只是浏览 Spring Boot 的代码。
关于静态资源,此配置包括以下行:
private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
"classpath:/META-INF/resources/", "classpath:/resources/",
"classpath:/static/", "classpath:/public/" };
private static final String[] RESOURCE_LOCATIONS;
static {
RESOURCE_LOCATIONS = new String[CLASSPATH_RESOURCE_LOCATIONS.length
+ SERVLET_RESOURCE_LOCATIONS.length];
System.arraycopy(SERVLET_RESOURCE_LOCATIONS, 0, RESOURCE_LOCATIONS, 0,
SERVLET_RESOURCE_LOCATIONS.length);
System.arraycopy(CLASSPATH_RESOURCE_LOCATIONS, 0, RESOURCE_LOCATIONS,
SERVLET_RESOURCE_LOCATIONS.length, CLASSPATH_RESOURCE_LOCATIONS.length);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
if (!this.resourceProperties.isAddMappings()) {
logger.debug("Default resource handling disabled");
return;
}
Integer cachePeriod = this.resourceProperties.getCachePeriod();
if (!registry.hasMappingForPattern("/webjars/**")) {
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/")
.setCachePeriod(cachePeriod);
}
if (!registry.hasMappingForPattern("/**")) {
registry.addResourceHandler("/**")
.addResourceLocations(RESOURCE_LOCATIONS)
.setCachePeriod(cachePeriod);
}
}
资源位置的声明有点复杂,但我们仍然可以理解两件事:
-
使用 "webjar" 前缀访问的任何资源都将解析在类路径内的类路径中。这将允许我们使用 Maven central 中的预包装 JavaScript 依赖项。
-
我们静态资源可以位于类路径
/META-INF/resources/、/resources/、/static/或/public/之后的任何位置。
小贴士
WebJars 是 Maven central 上可用的客户端 JavaScript 库的 JAR 包。它们包含一个 Maven 项目文件,允许传递依赖关系,并在所有基于 JVM 的应用程序中工作。WebJars 是 JavaScript 包管理器(如 bower 或 npm)的替代品。对于只需要少量 JavaScript 库的应用程序来说,它们非常出色。在 www.webjars.org 上可以找到可用的 WebJars 列表。
此文件还有一部分是专门用于区域管理的:
@Bean
@ConditionalOnMissingBean(LocaleResolver.class)
@ConditionalOnProperty(prefix = "spring.mvc", name = "locale")
public LocaleResolver localeResolver() {
return new FixedLocaleResolver(
StringUtils.parseLocaleString(this.mvcProperties.getLocale()));
}
此默认区域解析器只处理一个区域,并允许我们通过 spring.mvc.locale 配置属性来定义它。
错误和编码配置
记得我们第一次启动应用程序而没有添加控制器时吗?我们得到了一个有趣的Whitelabel 错误页面输出。
错误处理比看起来要复杂得多,尤其是在您没有web.xml配置文件并希望应用程序能够在不同的 Web 服务器之间移植时。好消息是 Spring Boot 为我们处理了这一点!让我们看看ErrorMvcAutoConfiguration:
ConditionalOnClass({ Servlet.class, DispatcherServlet.class })
@ConditionalOnWebApplication
// Ensure this loads before the main WebMvcAutoConfiguration so that the error View is
// available
@AutoConfigureBefore(WebMvcAutoConfiguration.class)
@Configuration
public class ErrorMvcAutoConfiguration implements EmbeddedServletContainerCustomizer,
Ordered {
@Value("${error.path:/error}")
private String errorPath = "/error";
@Autowired
private ServerProperties properties;
@Override
public int getOrder() {
return 0;
}
@Bean
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
public DefaultErrorAttributes errorAttributes() {
return new DefaultErrorAttributes();
}
@Bean
@ConditionalOnMissingBean(value = ErrorController.class, search = SearchStrategy.CURRENT)
public BasicErrorController basicErrorController(ErrorAttributes errorAttributes) {
return new BasicErrorController(errorAttributes);
}
@Override
public void customize(ConfigurableEmbeddedServletContainer container) {
container.addErrorPages(new ErrorPage(this.properties.getServletPrefix()
+ this.errorPath));
}
@Configuration
@ConditionalOnProperty(prefix = "error.whitelabel", name = "enabled", matchIfMissing = true)
@Conditional(ErrorTemplateMissingCondition.class)
protected static class WhitelabelErrorViewConfiguration {
private final SpelView defaultErrorView = new SpelView(
"<html><body><h1>Whitelabel Error Page</h1>"
+ "<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>"
+ "<div id='created'>${timestamp}</div>"
+ "<div>There was an unexpected error (type=${error}, status=${status}).</div>"
+ "<div>${message}</div></body></html>");
@Bean(name = "error")
@ConditionalOnMissingBean(name = "error")
public View defaultErrorView() {
return this.defaultErrorView;
}
// If the user adds @EnableWebMvc then the bean name view resolver from
// WebMvcAutoConfiguration disappears, so add it back in to avoid disappointment.
@Bean
@ConditionalOnMissingBean(BeanNameViewResolver.class)
public BeanNameViewResolver beanNameViewResolver() {
BeanNameViewResolver resolver = new BeanNameViewResolver();
resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10);
return resolver;
}
}
}
这段配置做了什么?
-
它定义了一个 bean,
DefaultErrorAttributes,通过特殊属性(如状态、错误代码和关联的堆栈跟踪)公开有用的错误信息。 -
它定义了一个
BasicErrorControllerbean,这是一个负责显示我们看到的错误页面的 MVC 控制器。 -
它允许我们通过在配置文件
application.properties中将error.whitelable.enabled设置为 false 来停用 Spring Boot 的 whitelabel 错误页面。 -
我们还可以利用我们的模板引擎来提供我们自己的错误页面。例如,它将被命名为
error.html。这就是ErrorTemplateMissingCondition条件检查的内容。
我们将在本书的后面部分看到如何正确处理错误。
在编码方面,非常简单的HttpEncodingAutoConfiguration函数将通过提供 Spring 的CharacterEncodingFilter类来处理它。您可以使用spring.http.encoding.charset覆盖默认编码("UTF-8"),并通过spring.http.encoding.enabled禁用此配置。
内嵌 Servlet 容器(Tomcat)配置
默认情况下,Spring Boot 使用 Tomcat 内嵌 API 运行和打包我们的应用程序。
让我们看看EmbeddedServletContainerAutoConfiguration:
@Order(Ordered.HIGHEST_PRECEDENCE)
@Configuration
@ConditionalOnWebApplication
@Import(EmbeddedServletContainerCustomizerBeanPostProcessorRegistrar.class)
public class EmbeddedServletContainerAutoConfiguration {
/**
* Nested configuration for if Tomcat is being used.
*/
@Configuration
@ConditionalOnClass({ Servlet.class, Tomcat.class })
@ConditionalOnMissingBean(value = EmbeddedServletContainerFactory.class, search = SearchStrategy.CURRENT)
public static class EmbeddedTomcat {
@Bean
public TomcatEmbeddedServletContainerFactory tomcatEmbeddedServletContainerFactory() {
return new TomcatEmbeddedServletContainerFactory();
}
}
/**
* Nested configuration if Jetty is being used.
*/
@Configuration
@ConditionalOnClass({ Servlet.class, Server.class, Loader.class })
@ConditionalOnMissingBean(value = EmbeddedServletContainerFactory.class, search = SearchStrategy.CURRENT)
public static class EmbeddedJetty {
@Bean
public JettyEmbeddedServletContainerFactory jettyEmbeddedServletContainerFactory() {
return new JettyEmbeddedServletContainerFactory();
}
}
/**
* Nested configuration if Undertow is being used.
*/
@Configuration
@ConditionalOnClass({ Servlet.class, Undertow.class, SslClientAuthMode.class })
@ConditionalOnMissingBean(value = EmbeddedServletContainerFactory.class, search = SearchStrategy.CURRENT)
public static class EmbeddedUndertow {
@Bean
public UndertowEmbeddedServletContainerFactory undertowEmbeddedServletContainerFactory() {
return new UndertowEmbeddedServletContainerFactory();
}
}
}
上述代码非常直接。此代码包括三个不同的配置,具体将根据您的类路径上可用的内容来激活。
您可以使用 Tomcat、tc-server、Jetty 或 Undertow 与 Spring Boot 一起使用。您可以通过排除spring-boot-starter-tomcat JAR 依赖项并用其 Jetty 或 Undertow 等价物替换它来轻松替换服务器。如果您想这样做,请参阅文档。
我们 Servlet 容器(Tomcat)的所有配置都将发生在TomcatEmbeddedServletContainerFactory中。虽然您应该确实阅读它,因为它提供了非常高级的 Tomcat 内嵌配置(对于找到文档来说可能很困难),但我们不会直接查看这个类。
相反,我将向您介绍配置 Servlet 容器可用的不同选项。
HTTP 端口
您可以通过在application.properties文件中定义server.port属性或定义一个名为SERVER_PORT的环境变量来更改默认的 HTTP 端口。
您可以通过将此变量设置为-1来禁用 HTTP,或者通过将其设置为0在随机端口上启动它。这对于测试来说非常方便。
SSL 配置
配置 SSL 是一项繁琐的工作,但 Spring Boot 提供了一个简单的解决方案。您只需要几个属性来保护您的服务器:
server.port = 8443
server.ssl.key-store = classpath:keystore.jks
server.ssl.key-store-password = secret
server.ssl.key-password = another-secret
尽管如此,您将需要为上述示例生成一个密钥库文件。
我们将在第六章中更深入地了解我们的安全选项,保护您的应用程序。当然,您可以通过添加自己的EmbeddedServletContainerFactory来进一步自定义TomcatEmbeddedServletContainerFactory功能。如果您希望添加多个连接器,这可能很有用。有关更多信息,请参阅docs.spring.io/spring-boot/docs/current/reference/html/howto-embedded-servlet-containers.html#howto-configure-ssl的文档。
其他配置
您可以通过简单地将它们声明为配置中的@Bean元素来添加经典的 Java Web 元素,如Servlet、Filter和ServletContextListener。
默认情况下,Spring Boot 还为我们添加了三样东西:
-
在
JacksonAutoConfiguration中使用 Jackson 进行 JSON 序列化 -
HttpMessageConvertersAutoConfiguration中的默认HttpMessageConverters -
在
JmxAutoConfiguration中的 JMX 功能
我们将在第五章中看到更多关于 Jackson 配置的内容,构建 RESTful 应用程序。关于 JMX 配置,您可以通过在本地使用jconsole连接到应用程序来尝试它:

您可以通过将org.springframework.boot:spring-boot-starter-actuator添加到类路径中来添加更多有趣的 MBeans。您甚至可以定义自己的 MBeans,并通过 Jolokia 在 HTTP 上公开它们。另一方面,您也可以通过在配置中添加spring.jmx.enabled=false来禁用这些端点。
注意
更多详细信息,请参阅docs.spring.io/spring-boot/docs/current/reference/html/production-ready-jmx.html。
摘要
现在我们有一个非常谦逊的 Spring Web 应用程序,尽管我们没有自己配置任何东西,但它已经有一个 RESTful JSON 的“Hello world”。我们已经看到了 Spring Boot 为我们做了什么,它是如何做到的,并且希望我们已经对如何覆盖默认的自动配置有了很好的理解。
Spring Boot 的工作原理是一个单独的书籍主题。如果您想深入了解,我推荐您阅读同一系列中 Greg Turnquist 编写的优秀书籍《Learning Spring Boot》。
现在,我们已经准备好进入下一章,我们的应用程序将通过实际提供网页服务而达到一个新的阶段,您将更多地了解 Spring MVC 的哲学。
第二章 掌握 MVC 架构
在本章中,我们将讨论 MVC 架构原则,并了解 Spring MVC 如何实现这些原则。
我们将继续使用上一章中的应用程序,并构建一些更有趣的东西。我们的目标是设计一个简单的页面,用户可以在其中根据特定标准搜索推文,并将它们展示给我们的用户。
为了实现这一点,我们将使用 Spring Social Twitter 项目,该项目可在projects.spring.io/spring-social-twitter/找到。
我们将了解如何使 Spring MVC 与现代模板引擎 Thymeleaf 协同工作,并尝试理解框架的内部机制。我们将通过不同的视图引导我们的用户,最后,我们将使用 WebJars 和 Materialize (materializecss.com)为我们的应用程序提供一个出色的外观。
MVC 架构
我预计 MVC 缩写的含义对大多数人来说都很熟悉。它代表模型-视图-控制器,并且被认为是通过解耦数据和表示层来构建用户界面的非常流行的方式。

MVC 模式在从小型计算机制造商的世界中脱颖而出,并在 Ruby on Rails 框架中落地后变得非常流行。
架构模式具有三个层次:
-
模型:这包括应用程序所知道的数据的各种表示形式。
-
视图:这是由将显示给用户的几个数据表示形式组成的。
-
控制器:这是应用程序中处理用户交互的部分。它是模型和视图之间的桥梁。
MVC 背后的想法是将视图与模型解耦。模型必须是自包含的,并且对 UI 一无所知。这基本上允许相同的数据在多个视图中重用。这些视图是查看数据的不同方式。深入挖掘或使用不同的渲染器(HTML、PDF)是这一原则的良好说明。
控制器充当用户和数据之间的调解者。其角色是控制对最终用户可用的操作,并通过应用程序的不同视图进行路由。
MVC 批评和最佳实践
虽然 MVC 仍然是设计 UI 的首选方法,但随着其普及,许多批评也随之而来。大多数批评实际上是指向了模式的错误使用。
贫血型领域模型
埃里克·埃文斯的具有影响力的书籍《领域驱动设计》,也简称为DDD,定义了一套架构规则,这些规则有助于在代码内部更好地整合业务领域。
核心思想之一是利用领域对象内的面向对象范式。违反这一原则有时被称为贫血领域模型。关于这个问题的良好定义可以在 Martin Fowler 的博客上找到(www.martinfowler.com/bliki/AnemicDomainModel.html)。
贫血模型通常表现出以下症状:
-
模型由非常简单的普通 Java 对象(POJOs)构成,仅包含 getter 和 setter 方法
-
所有业务逻辑都在服务层内部处理
-
模型的验证位于模型外部,例如,在控制器中
这可能取决于您的业务领域的复杂性,可能是一种不良实践。一般来说,DDD 实践需要额外的努力来隔离领域与应用逻辑。
架构总是需要权衡。值得注意的是,典型的 Spring 应用程序设计方式可能会导致维护过程中的复杂化。
如何避免领域贫血在这里解释:
-
服务层适合于应用级别的抽象,如事务处理,而不是业务逻辑。
-
您的领域应该始终处于有效状态。使用验证器或 JSR-303 的验证注解在表单对象内部进行验证。
-
将输入转换为有意义的领域对象。
-
将您的数据层视为具有领域查询的存储库(例如,参考 Spring Data Specification)。
-
将您的领域逻辑与底层持久化框架解耦
-
尽可能使用真实对象。例如,操作
FirstName类而不是字符串。
DDD(领域驱动设计)远不止这些简单的规则:实体、值类型、通用语言、边界上下文、洋葱架构和反腐败层。我强烈建议您自己研究这些原则。就我们而言,在这本书中,我们将尝试在构建我们的 Web 应用程序时牢记前面列出的指南。随着我们在这本书中的进展,这些问题将变得越来越熟悉。
从源头学习
如果您熟悉 Spring,您可能已经访问了 Spring 的网站,spring.io。它完全是用 Spring 制作的,好消息是它是开源的。
项目的代码名称是 sagan。它具有许多有趣的功能:
-
一个 Gradle 多模块项目
-
安全集成
-
Github 集成
-
Elasticsearch 集成
-
一个 JavaScript 前端应用程序
与项目相关的 GitHub wiki 非常详细,这将帮助您轻松开始项目。
注意
如果您对 Spring 真实世界应用程序的架构感兴趣,请访问以下 URL:
Spring MVC 1-0-1
在 Spring MVC 中,模型是 Spring MVC 的Model或ModelAndView类封装的一个简单映射。它可以从数据库、文件、外部服务等来源。如何获取数据并将其放入模型取决于你。与数据层交互的推荐方式是通过 Spring Data 库:Spring Data JPA、Spring Data MongoDB 等。与 Spring Data 相关联的项目有数十个,我鼓励你查看projects.spring.io/spring-data。
Spring MVC 的控制器端通过使用@Controller注解来处理。在 Web 应用程序中,控制器的作用是对 HTTP 请求做出响应。带有@Controller注解的类将被 Spring 拾取并有机会处理即将到来的请求。
通过@RequestMapping注解,控制器根据其 HTTP 方法(例如GET或POST方法)和 URL 声明处理特定请求。然后控制器决定是直接在 Web 响应中写入内容还是将应用程序路由到视图并将属性注入到该视图中。
一个纯 RESTful 应用程序会选择第一种方法,并通过@ResponseBody注解直接在 HTTP 响应中暴露模型的 JSON 或 XML 表示。在 Web 应用程序的情况下,这种类型的架构通常与一个前端 JavaScript 框架相关联,例如 Backbone.js、AngularJS 或 React。在这种情况下,Spring 应用程序将只处理 MVC 模型中的模型层。我们将在第四章文件上传和错误处理中研究这种类型的架构。
在第二种方法中,模型被传递到视图,由模板引擎渲染,然后写入响应。
这种视图通常与模板方言相关联,这将允许在模型内部进行导航。流行的模板方言包括 JSPs、FreeMarker 或 Thymeleaf。
混合方法可以利用模板引擎与应用程序的一些方面进行交互,然后将视图层委托给前端框架。
使用 Thymeleaf
Thymeleaf 是一个模板引擎,得到了 Spring 社区的特别关注。
它的成功主要归功于其友好的语法(几乎看起来像 HTML)以及它易于扩展。
可用的各种扩展已集成到 Spring Boot 中:
| 支持 | 依赖 |
|---|---|
| 布局 | nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect |
| HTML5 data-*属性 | com.github.mxab.thymeleaf.extras:thymeleaf-extras-data-attribute |
| Internet Explorer 条件注释 | org.thymeleaf.extras:thymeleaf-extras-conditionalcomments |
| 对 Spring 安全性的支持 | org.thymeleaf.extras:thymeleaf-extras-springsecurity3 |
可以在 www.thymeleaf.org/doc/tutorials/2.1/thymeleafspring.html 找到关于 Thymeleaf 与 Spring 集成的非常好的教程。
不再拖延,让我们添加 spring-boot-starter-thymeleaf 依赖项以启动 thymeleaf 模板引擎:
buildscript {
ext {
springBootVersion = '1.2.5.RELEASE'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
classpath("io.spring.gradle:dependency-management-plugin:0.5.1.RELEASE")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'spring-boot'
apply plugin: 'io.spring.dependency-management'
jar {
baseName = 'masterSpringMvc'
version = '0.0.1-SNAPSHOT'
}
sourceCompatibility = 1.8
targetCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile 'org.springframework.boot:spring-boot-starter-web'
compile 'org.springframework.boot:spring-boot-starter-thymeleaf'
testCompile 'org.springframework.boot:spring-boot-starter-test'
}
eclipse {
classpath {
containers.remove('org.eclipse.jdt.launching.JRE_CONTAINER')
containers 'org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8'
}
}
task wrapper(type: Wrapper) {
gradleVersion = '2.3'
}
我们的第一个页面
现在我们将向我们的应用程序添加第一个页面。它将位于 src/main/resources/templates。让我们称这个文件为 resultPage.html:
<!DOCTYPE html>
<html >
<head lang="en">
<meta charset="UTF-8"/>
<title>Hello thymeleaf</title>
</head>
<body>
<span th:text="|Hello thymeleaf|">Hello html</span>
</body>
</html>
我们可以从一开始就看到 Thymeleaf 与 html 完美集成,其语法几乎感觉自然。
th:text 的值放在管道之间。这意味着文本内的所有值都将连接起来。
起初可能有些不自然,但在实践中,我们的页面中很少会硬编码文本;因此,Thymeleaf 在这里做出了有见地的设计决策。
Thymeleaf 对网页设计师来说有一个很大的优势:模板内部的所有动态内容,在没有服务器运行的情况下打开时,都可以回退到默认值。资源 URL 可以指定为相对路径,并且每个标记都可以包含占位符。在我们的上一个例子中,当视图在我们的应用程序上下文中渲染时,文本 "Hello html" 不会显示,但如果直接用网页浏览器打开文件,则会显示。
为了加快开发速度,将此属性添加到您的 application.properties 文件中:
spring.thymeleaf.cache=false
这将禁用视图缓存,并导致每次访问模板时都会重新加载。
当然,当我们进入生产环境时,需要禁用此设置。我们将在 第八章 中看到这一点,优化您的请求。
小贴士
重新加载视图
禁用缓存后,只需使用 eclipse 保存您的视图或使用 IntelliJ 中的 Build > Make Project 操作来在更改后刷新视图。
最后,我们需要修改我们的 HelloController 类。它现在必须路由到我们刚刚创建的视图,而不是显示纯文本。为了完成这个任务,我们将移除 @ResponseBody 注解。这样做并返回一个字符串将告诉 Spring MVC 将此字符串映射到视图名称,而不是直接在响应中显示特定的模型。
现在我们来看看我们的控制器现在是什么样子:
@Controller
public class HelloController {
@RequestMapping("/")
public String hello() {
return "resultPage";
}
}
在这个例子中,控制器将用户重定向到视图名称 resultPage。然后 ViewResolver 接口将此名称与我们的页面关联。
让我们再次启动我们的应用程序并转到 http://localhost:8080。
您将看到以下页面:

Spring MVC 架构
让我们从这个精彩的 "Hello World" 新功能中退一步,试图理解我们的网络应用程序内部发生了什么。为此,我们将回顾浏览器发送的 HTTP 请求和从服务器收到的响应的旅程。
DispatcherServlet
每个 Spring Web 应用程序的入口点是DispatcherServlet。以下图展示了 Dispatcher Servlet 架构:

这仍然是一个经典的HttpServlet类,它将 HTTP 请求分派到HandlerMapping。HandlerMapping是资源(URL)和控制器之间的关联。
然后在控制器上调用适当的带有@RequestMapping注解的方法。在这个方法中,控制器设置模型数据,并将视图名称返回给分派器。
然后,DispatcherServlet将查询ViewResolver接口以找到视图的相应实现。
在我们的情况下,ThymeleafAutoConfiguration类已经为我们设置了视图解析器。
你可以在ThymeleafProperties类中看到,我们视图的默认前缀是classpath:/templates/,默认后缀是.html。
这意味着,给定视图名称resultPage,视图解析器将在我们的类路径的模板目录中查找名为resultPage.html的文件。
在我们的应用程序中,我们的ViewResolver接口是静态的,但更高级的实现可以根据请求头或用户的区域设置返回不同的结果。
视图最终将被渲染,并将结果写入响应。
传递数据到视图
我们的第一页完全是静态的;它并没有真正利用 Spring MVC 的强大功能。让我们稍微加点料。如果“Hello World”字符串不是硬编码的,而是来自服务器会怎样呢?
你会说这仍然是一个蹩脚的“Hello World”吗?是的,但它将打开更多的可能性。让我们将我们的resultPage.html文件更改为显示来自模型的消息:
<!DOCTYPE html>
<html >
<head lang="en">
<meta charset="UTF-8"/>
<title>Hello thymeleaf</title>
</head>
<body>
<span th:text="${message}">Hello html</span>
</body>
</html>
然后,让我们修改我们的控制器,使其将此消息放入此模型中:
@Controller
public class HelloController {
@RequestMapping("/")
public String hello(Model model) {
model.addAttribute("message", "Hello from the controller");
return "resultPage";
}
}
我知道,这种悬念正在折磨你!让我们看看http://localhost:8080看起来像什么。

首先要注意的是,我们向控制器的方法传递了一个新的参数,并且DispatcherServlet为我们提供了正确的对象。实际上,有许多对象可以被注入到控制器的方法中,例如HttpRequest或HttpResponse,Locale,TimeZone和Principal,它们代表一个认证用户。此类对象的完整列表可在文档中找到,文档位于docs.spring.io/spring/docs/current/spring-framework-reference/html/mvc.html#mvc-ann-arguments。
Spring 表达式语言
当使用${}语法时,实际上你正在使用Spring 表达式语言(SpEL)。在野外有几种可用的 EL 变体;SpEl 是其中最强大的变体之一。
这里是其主要功能的概述:
| 功能 | 语法 | 说明 |
|---|---|---|
| 访问列表元素 | list[0] |
|
| 访问映射条目 | map[key] |
|
| 三元运算符 | condition ? 'yes' : 'no' |
|
| 爱丽丝运算符 | person ?: default |
如果 person 的值为 null,则返回 default |
| 安全导航 | person?.name |
如果 person 或她的名字为 null,则返回 null |
| 模板 | 'Your name is #{person.name}' |
将值注入到字符串中 |
| 投影 | ${persons.![name]} |
提取所有人员的姓名并将它们放入列表中 |
| 选择 | persons.?[name == 'Bob']' |
在列表中检索名为 Bob 的人 |
| 函数调用 | person.sayHello() |
注意
对于完整的参考,请查看 docs.spring.io/spring/docs/current/spring-framework-reference/html/expressions.html 中的手册。
SpEl 的使用不仅限于视图。您还可以在 Spring 框架的各个地方使用它,例如,当使用 @Value 注解在 bean 中注入属性时。
使用请求参数获取数据
我们能够在视图中显示来自服务器的数据。然而,如果我们想从用户那里获取输入呢?使用 HTTP 协议,有多种方法可以做到这一点。最简单的方法是将查询参数传递给我们的 URL。
注意
查询参数
您当然知道查询参数。它们位于 URL 中的 ? 字符之后。它们由一个由 & 符号(和号)分隔的名称和值列表组成,例如,page?var1=value1&var2=value2。
我们可以利用这项技术来请求用户的姓名。让我们再次修改我们的 HelloController 类:
@Controller
public class HelloController {
@RequestMapping("/")
public String hello(@RequestParam("name") String userName, Model model) {
model.addAttribute("message", "Hello, " + userName);
return "resultPage";
}
}
如果我们导航到 localhost:8080/?name=Geoffroy,我们可以看到以下内容:

默认情况下,请求参数是必需的。这意味着如果我们导航到 localhost:8080,我们会看到一个错误消息。
通过查看 @RequestParam 代码,我们可以看到除了值参数外,还有两个可能的属性:required 和 defaultValue。
因此,我们可以更改我们的代码并指定参数的默认值或指示它不是必需的:
@Controller
public class HelloController {
@RequestMapping("/")
public String hello(@RequestParam(defaultValue = "world") String name, Model model) {
model.addAttribute("message", "Hello, " + name);
return "resultPage";
}
}
小贴士
在 Java 8 中,可以不指定值参数。在这种情况下,注解方法参数的名称将被使用。
足够的 "Hello World",让我们获取推文!
好吧,这本书的名字并不是 "Mastering Hello Worlds"。使用 Spring,查询 Twitter 的 API 真的很简单。
注册您的应用程序
在开始之前,您必须在 Twitter 开发者控制台中注册您的应用程序。
前往 apps.twitter.com 并创建一个新的应用程序。
给它您喜欢的名字。在网站和回调 URL 部分,只需输入 http://127.0.0.1:8080。这将允许您在本地机器上测试您的开发中的应用程序。

现在,导航到密钥,访问令牌,并复制消费者密钥和消费者密钥。我们稍后会使用这些。看看下面的截图:

默认情况下,我们的应用程序只有只读权限。这对我们的应用程序来说已经足够了,但如果你愿意,可以对其进行调整。
设置 Spring Social Twitter
我们将在build.gradle文件中添加以下依赖项:
compile 'org.springframework.boot:spring-boot-starter-social-twitter'
注意
Spring Social是一组提供访问各种社交网络公共 API 的项目。Spring Boot 默认提供了与 Twitter、Facebook 和 LinkedIn 的集成。Spring Social 总共有大约 30 个项目,可以在projects.spring.io/spring-social/找到。
将以下两行添加到application.properties中:
spring.social.twitter.appId= <Consumer Key>
spring.social.twitter.appSecret= <Consumer Secret>
这些是我们刚刚创建的应用程序关联的键。
你将在第六章保护你的应用程序中了解更多关于 OAuth 的内容。现在,我们只需使用这些凭据代表我们的应用程序向 Twitter 的 API 发出请求。
访问 Twitter
现在,我们可以在控制器中使用 Twitter 了。让我们将其名称更改为TweetController,以更好地反映其新的职责:
@Controller
public class HelloController {
@Autowired
private Twitter twitter;
@RequestMapping("/")
public String hello(@RequestParam(defaultValue = "masterSpringMVC4") String search, Model model) {
SearchResults searchResults = twitter.searchOperations().search(search);
String text = searchResults.getTweets().get(0).getText();
model.addAttribute("message", text);
return "resultPage";
}
}
如你所见,代码正在搜索与请求参数匹配的推文。如果一切顺利,你将在屏幕上看到第一条推文的内容:

当然,如果搜索没有产生任何结果,我们笨拙的代码将因ArrayOutOfBoundException而失败。所以,不要犹豫,发推文解决问题!
如果我们想显示推文列表呢?让我们修改resultPage.html文件:
<!DOCTYPE html>
<html >
<head lang="en">
<meta charset="UTF-8"/>
<title>Hello twitter</title>
</head>
<body>
<ul>
<li th:each="tweet : ${tweets}" th:text="${tweet}">Some tweet</li>
</ul>
</body>
</html>
注意
th:each是 Thymeleaf 中定义的一个标签,允许它遍历集合,并在循环中将每个值分配给变量。
我们还需要更改我们的控制器:
@Controller
public class TweetController {
@Autowired
private Twitter twitter;
@RequestMapping("/")
public String hello(@RequestParam(defaultValue = "masterSpringMVC4") String search, Model model) {
SearchResults searchResults = twitter.searchOperations().search(search);
List<String> tweets =
searchResults.getTweets()
.stream()
.map(Tweet::getText)
.collect(Collectors.toList());
model.addAttribute("tweets", tweets);
return "resultPage";
}
}
注意,我们正在使用 Java 8 流来收集推文中的消息。Tweet类包含许多其他属性,如发送者、转发次数等。然而,我们现在将保持简单,如下面的截图所示:

Java 8 流和 lambda
你可能还不熟悉 lambda 表达式。在 Java 8 中,每个集合都有一个默认方法stream(),它提供了对函数式操作方式的访问。
这些操作可以是返回流的中间操作,从而允许链式调用,或者返回值的终端操作。
最著名的中间操作如下:
-
map: 将方法应用于列表中的每个元素,并返回结果列表 -
filter: 返回匹配谓词的每个元素的列表 -
reduce: 使用操作和累加器将列表投影为一个单一值
Lambda 是函数表达式的简写语法。它们可以被强制转换为只有一个抽象方法的 Single Abstract Method,一个只有一个函数的接口。
例如,你可以这样实现Comparator接口:
Comparator<Integer> c = (e1, e2) -> e1 - e2;
在 lambda 表达式中,返回关键字隐式地是其最后一个表达式。
我们之前使用的双冒号运算符是获取类上函数引用的快捷方式,
Tweet::getText
前面的内容等同于以下内容:
(Tweet t) -> t.getText()
collect方法允许我们调用一个终端操作。Collectors类是一组终端操作,将结果放入列表、集合或映射中,允许分组、连接等。
调用collect(Collectors.toList())方法将生成一个包含流中每个元素的列表;在我们的案例中,是推文名称。
使用 WebJars 的材料设计
我们的应用程序已经很好了,但在美学方面还有很多需要改进的地方。你可能听说过材料设计。这是谷歌对扁平化设计的看法。
我们将使用 Materialize(materializecss.com),一个看起来很棒的响应式 CSS 和 JavaScript 库,就像 Bootstrap 一样。

我们在第一章中谈到了 WebJars,快速设置 Spring Web 应用程序;我们现在将使用它们。将 jQuery 和 Materialize CSS 添加到我们的依赖项中:
compile 'org.webjars:materializecss:0.96.0'
compile 'org.webjars:jquery:2.1.4'
WebJar 的组织方式是完全标准化的。你将在/webjars/{lib}/{version}/*.js中找到任何库的 JS 和 CSS 文件。
例如,要将 jQuery 添加到我们的页面中,可以在网页中添加以下内容:
<script src="img/jquery.js"></script>
让我们修改我们的控制器,使其给我们一个所有推文对象的列表,而不是简单的文本:
package masterSpringMvc.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.twitter.api.SearchResults;
import org.springframework.social.twitter.api.Tweet;
import org.springframework.social.twitter.api.Twitter;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@Controller
public class TweetController {
@Autowired
private Twitter twitter;
@RequestMapping("/")
public String hello(@RequestParam(defaultValue = "masterSpringMVC4") String search, Model model) {
SearchResults searchResults = twitter.searchOperations().search(search);
List<Tweet> tweets = searchResults.getTweets();
model.addAttribute("tweets", tweets);
model.addAttribute("search", search);
return "resultPage";
}
}
让我们在视图中包含 materialize CSS:
<!DOCTYPE html>
<html >
<head lang="en">
<meta charset="UTF-8"/>
<title>Hello twitter</title>
<link href="/webjars/materializecss/0.96.0/css/materialize.css" type="text/css" rel="stylesheet" media="screen,projection"/>
</head>
<body>
<div class="row">
<h2 class="indigo-text center" th:text="|Tweet results for ${search}|">Tweets</h2>
<ul class="collection">
<li class="collection-item avatar" th:each="tweet : ${tweets}">
<img th:src="img/${tweet.user.profileImageUrl}" alt="" class="circle"/>
<span class="title" th:text="${tweet.user.name}">Username</span>
<p th:text="${tweet.text}">Tweet message</p>
</li>
</ul>
</div>
<script src="img/jquery.js"></script>
<script src="img/materialize.js"></script>
</body>
</html>
结果看起来已经好多了!

使用布局
我们最不想做的事情是将我们 UI 的可重用块放入模板中。为此,我们将使用thymeleaf-layout-dialect依赖项,它包含在我们的项目spring-boot-starter-thymeleaf依赖项中。
我们将在src/main/resources/templates/layout中创建一个名为default.html的新文件。它将包含我们将从页面到页面重复的代码:
<!DOCTYPE html>
<html
>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no"/>
<title>Default title</title>
<link href="/webjars/materializecss/0.96.0/css/materialize.css" type="text/css" rel="stylesheet" media="screen,projection"/>
</head>
<body>
<section layout:fragment="content">
<p>Page content goes here</p>
</section>
<script src="img/jquery.js"></script>
<script src="img/materialize.js"></script>
</body>
</html>
我们现在将修改resultPage.html文件,使其使用布局,这将简化其内容:
<!DOCTYPE html>
<html
layout:decorator="layout/default">
<head lang="en">
<title>Hello twitter</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h2 class="indigo-text center" th:text="|Tweet results for ${search}|">Tweets</h2>
<ul class="collection">
<li class="collection-item avatar" th:each="tweet : ${tweets}">
<img th:src="img/${tweet.user.profileImageUrl}" alt="" class="circle"/>
<span class="title" th:text="${tweet.user.name}">Username</span>
<p th:text="${tweet.text}">Tweet message</p>
</li>
</ul>
</div>
</body>
</html>
layout:decorator="layout/default"将指示我们的布局在哪里。然后我们可以将内容注入布局的不同layout:fragment部分。请注意,每个模板都是有效的 HTML 文件。你也可以很容易地覆盖标题。
导航
我们有一个很好的小推文显示应用程序,但我们的用户应该如何知道他们需要提供一个“搜索”请求参数呢?
如果我们在应用程序中添加一个小表单会很好。
让我们做点这样的事情:

首先,我们需要修改我们的TweetController以向我们的应用程序添加第二个视图。搜索页面将直接位于我们应用程序的根目录,当在search字段中按下回车键时,将显示结果页面:
@Controller
public class TweetController {
@Autowired
private Twitter twitter;
@RequestMapping("/")
public String home() {
return "searchPage";
}
@RequestMapping("/result")
public String hello(@RequestParam(defaultValue = "masterSpringMVC4") String search, Model model) {
SearchResults searchResults = twitter.searchOperations().search(search);
List<Tweet> tweets = searchResults.getTweets();
model.addAttribute("tweets", tweets);
model.addAttribute("search", search);
return "resultPage";
}
}
我们将在templates文件夹中添加另一个页面,名为searchPage.html文件。它将包含一个简单的表单,通过get方法将搜索词传递到结果页面:
<!DOCTYPE html>
<html
layout:decorator="layout/default">
<head lang="en">
<title>Search</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h4 class="indigo-text center">Please enter a search term</h4>
<form action="/result" method="get" class="col s12">
<div class="row center">
<div class="input-field col s6 offset-s3">
<i class="mdi-action-search prefix"></i>
<input id="search" name="search" type="text" class="validate"/>
<label for="search">Search</label>
</div>
</div>
</form>
</div>
</body>
</html>
这非常简单的 HTML,并且工作得很好。你现在可以尝试一下。
如果我们想要禁止某些搜索结果怎么办?比如说,我们想在用户输入struts时显示一个错误信息。
实现这一点最好的方法是将表单修改为提交数据。在控制器中,我们可以拦截所提交的内容并相应地实现这个业务规则。
首先,我们需要更改searchPage中的表单,如下所示:
<form action="/result" method="get" class="col s12">
现在,我们将表单改为如下所示:
<form action="/postSearch" method="post" class="col s12">
我们还需要在服务器上处理这个 POST 请求。向TweetController添加此方法:
@RequestMapping(value = "/postSearch", method = RequestMethod.POST)
public String postSearch(HttpServletRequest request,
RedirectAttributes redirectAttributes) {
String search = request.getParameter("search");
redirectAttributes.addAttribute("search", search);
return "redirect:result";
}
这里有几个新特性:
-
在请求映射注解中,我们指定我们想要处理的 HTTP 方法,即
POST。 -
我们直接将两个属性作为方法参数注入。它们是请求和
RedirectAttributes。 -
我们检索请求上提交的值,并将其传递给下一个视图。
-
我们不是返回视图的名称,而是将重定向到一个 URL。
RedirectAttributes是 Spring 模型,将专门用于在重定向场景中传播值。
注意
重定向/转发是 Java Web 应用程序中的经典选项。它们都会改变在用户浏览器上显示的视图。区别在于Redirect会发送一个 302 头部,这将触发浏览器内的导航,而Forward则不会导致 URL 改变。在 Spring MVC 中,你可以通过在方法返回字符串前加上redirect:或forward:来使用这两个选项中的任何一个。在两种情况下,你返回的字符串都不会像我们之前看到的那样解析为一个视图,而是会触发导航到特定的 URL。
之前的例子有点牵强,我们将在下一章看到更智能的表单处理。如果你在postSearch方法中设置断点,你会看到它会在我们的表单 POST 之后立即被调用。
那么,错误信息怎么办呢?
让我们修改postSearch方法:
@RequestMapping(value = "/postSearch", method = RequestMethod.POST)
public String postSearch(HttpServletRequest request,
RedirectAttributes redirectAttributes) {
String search = request.getParameter("search");
if (search.toLowerCase().contains("struts")) {
redirectAttributes.addFlashAttribute("error", "Try using spring instead!");
return "redirect:/";
}
redirectAttributes.addAttribute("search", search);
return "redirect:result";
}
如果用户的搜索词包含“struts”,我们将它们重定向到searchPage,并使用闪存属性添加一条小错误信息。
这些特殊的属性仅在请求期间存在,页面刷新后会消失。当我们使用POST-REDIRECT-GET模式时,这非常有用,正如我们刚才所做的那样。
我们需要在searchPage结果中显示这条信息:
<!DOCTYPE html>
<html
layout:decorator="layout/default">
<head lang="en">
<title>Search</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h4 class="indigo-text center">Please enter a search term</h4>
<div class="col s6 offset-s3">
<div id="errorMessage" class="card-panel red lighten-2" th:if="${error}">
<span class="card-title" th:text="${error}"></span>
</div>
<form action="/postSearch" method="post" class="col s12">
<div class="row center">
<div class="input-field">
<i class="mdi-action-search prefix"></i>
<input id="search" name="search" type="text" class="validate"/>
<label for="search">Search</label>
</div>
</div>
</form>
</div>
</div>
</body>
</html>
现在,如果用户尝试搜索“struts2”推文,他们将得到一个有用且适当的答案:

检查点
在本章结束时,你应该有一个控制器,即TweetController,它处理搜索以及位于src/main/java目录中的未修改的生成配置类MasterSpringMvcApplication:

在src/main/resources目录中,你应该有一个默认布局和两个使用该布局的页面。
在application.properties文件中,我们添加了 Twitter 应用程序凭据以及一个属性,告诉 Spring 不要缓存模板以简化开发:

摘要
在本章中,你学习了如何构建一个好的 MVC 架构。我们看到了 Spring MVC 的一些内部工作原理,并且使用 Spring Social Twitter 时配置非常少。现在,多亏了 WebJars,我们可以设计一个漂亮的 Web 应用程序。
在下一章中,我们将要求用户填写他们的个人资料,这样我们就可以自动获取他们可能喜欢的推文。这将为你提供学习更多关于表单、格式化、验证和国际化的机会。
第三章:处理表单和复杂 URL 映射
我们的应用程序看起来很漂亮,但我们可以从更多关于我们用户的信息中受益。
我们可以要求他们提供他们感兴趣的领域。
在这一章中,我们将构建一个个人资料页面。它将具有服务器端和客户端验证以及个人照片的上传。我们将把信息保存到用户会话中,并确保我们的受众尽可能广泛,通过将应用程序翻译成多种语言。最后,我们将显示与用户口味匹配的 Twitter 活动的摘要。
听起来不错?让我们开始吧,我们有一些工作要做。
个人资料页面 – 一个表单
表单是每个 Web 应用的基石。它们一直是获取用户输入的主要方式,自从互联网开始以来就是如此!
我们在这里的第一个任务是创建一个像这样的个人资料页面:

它将允许用户输入一些个人信息以及一系列口味。然后,这些口味将被输入到我们的搜索引擎中。
让我们在 templates/profile/profilePage.html 中创建一个新页面:
<!DOCTYPE html>
<html
layout:decorator="layout/default">
<head lang="en">
<title>Your profile</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h2 class="indigo-text center">Personal info</h2>
<form th:action="@{/profile}" method="post" class="col m8 s12 offset-m2">
<div class="row">
<div class="input-field col s6">
<input id="twitterHandle" type="text"/>
<label for="twitterHandle">Last Name</label>
</div>
<div class="input-field col s6">
<input id="email" type="text"/>
<label for="email">Email</label>
</div>
</div>
<div class="row">
<div class="input-field col s6">
<input id="birthDate" type="text"/>
<label for="birthDate">Birth Date</label>
</div>
</div>
<div class="row s12">
<button class="btn waves-effect waves-light" type="submit" name="save">Submit
<i class="mdi-content-send right"></i>
</button>
</div>
</form>
</div>
</body>
</html>
注意 {@} 语法,它将通过将服务器上下文路径(在我们的情况下,localhost:8080)添加到其参数之前来构建资源的完整路径。
我们还将在 profile 包中创建相关的控制器,命名为 ProfileController:
package masterspringmvc4.profile;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class ProfileController {
@RequestMapping("/profile")
public String displayProfile() {
return "profile/profilePage";
}
}
现在,你可以访问 http://localhost:8080 并看到一个美丽的表单,但它什么也不做。那是因为我们没有将任何操作映射到 POST URL。
让我们在与我们的控制器相同的包中创建一个数据传输对象(DTO)。我们将命名为 ProfileForm。它的作用是将我们的网页表单的字段映射并描述验证规则:
package masterSpringMvc.profile;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class ProfileForm {
private String twitterHandle;
private String email;
private LocalDate birthDate;
private List<String> tastes = new ArrayList<>();
// getters and setters
}
这是一个普通的普通旧 Java 对象(POJO)。别忘了生成获取器和设置器,没有它们我们的数据绑定将无法正常工作。注意,我们有一个口味列表,我们现在不会填充,稍后才会。
由于我们使用的是 Java 8,用户的出生日期将使用新的 Java 日期时间 API(JSR 310)。这个 API 比旧的 java.util.Date API 好得多,因为它在人类日期的所有细微差别之间做出了强烈的区分,并使用流畅的 API 和不可变的数据结构。
在我们的例子中,LocalDate 类是一个没有与时间相关联的简单日期。它可以与表示一天中的时间的 LocalTime 类区分开来,或者表示两者的 LocalDateTime 类,或者使用时区的 ZonedDateTime 类。
注意
如果你想要了解更多关于 Java 8 日期时间 API 的信息,请参考 Oracle 教程,该教程可在 docs.oracle.com/javase/tutorial/datetime/TOC.html 找到。
小贴士
好的建议是始终生成我们数据对象的 toString 方法,就像这个表单一样。这对于调试非常有用。
要指示 Spring 将我们的字段绑定到这个 DTO,我们必须在profilePage中添加一些元数据。
<!DOCTYPE html>
<html
layout:decorator="layout/default">
<head lang="en">
<title>Your profile</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h2 class="indigo-text center">Personal info</h2>
<form th:action="@{/profile}" th:object="${profileForm}" method="post" class="col m8 s12 offset-m2">
<div class="row">
<div class="input-field col s6">
<input th:field="${profileForm.twitterHandle}" id="twitterHandle" type="text"/>
<label for="twitterHandle">Last Name</label>
</div>
<div class="input-field col s6">
<input th:field="${profileForm.email}" id="email" type="text"/>
<label for="email">Email</label>
</div>
</div>
<div class="row">
<div class="input-field col s6">
<input th:field="${profileForm.birthDate}" id="birthDate" type="text"/>
<label for="birthDate">Birth Date</label>
</div>
</div>
<div class="row s12">
<button class="btn waves-effect waves-light" type="submit" name="save">Submit
<i class="mdi-content-send right"></i>
</button>
</div>
</form>
</div>
</body>
</html>
您将注意到两点:
-
表单中的
th:object属性 -
所有字段中的
th:field属性
第一个将根据类型将对象绑定到控制器。第二个将实际字段绑定到我们的表单 Bean 属性。
为了使th:object字段生效,我们需要向我们的请求映射方法添加一个ProfileForm类型的参数:
@Controller
public class ProfileController {
@RequestMapping("/profile")
public String displayProfile(ProfileForm profileForm) {
return "profile/profilePage";
}
@RequestMapping(value = "/profile", method = RequestMethod.POST)
public String saveProfile(ProfileForm profileForm) {
System.out.println("save ok" + profileForm);
return "redirect:/profile";
}
}
我们还添加了一个映射,用于处理表单提交时的POST方法。在此阶段,如果您尝试提交包含日期(例如 1980/10/10)的表单,它将完全不起作用,并给您一个错误 400,没有有用的日志信息。
小贴士
Spring Boot 中的日志记录
使用 Spring Boot,日志配置非常简单。只需将logging.level.{package} = DEBUG添加到application.properties文件中,其中{package}是您的应用程序中某个类或包的完全限定名称。当然,您可以用任何您想要的日志级别替换 debug。您还可以添加经典的日志配置。有关更多信息,请参阅docs.spring.io/spring-boot/docs/current/reference/html/howto-logging.html。
我们需要稍微调试一下我们的应用程序来了解发生了什么。将以下行添加到您的文件application.properties中:
logging.level.org.springframework.web=DEBUG
org.springframework.web包是 Spring MVC 的基础包。这将允许我们看到 Spring web 生成的调试信息。如果您再次提交表单,您将在日志中看到以下错误:
Field error in object 'profileForm' on field 'birthDate': rejected value [10/10/1980]; codes [typeMismatch.profileForm.birthDate,typeMismatch.birthDate,typeMismatch.java.time.LocalDate,typeMismatch]; … nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type java.lang.String to type java.time.LocalDate for value '10/10/1980'; nested exception is java.time.format.DateTimeParseException: Text '10/10/1980' could not be parsed, unparsed text found at index 8]
要了解发生了什么,我们需要查看 Spring 的DateTimeFormatterRegistrar类。
在这个类中,您将看到针对 JSR 310 的六七种解析器和打印器。它们都将回退到短风格日期格式,如果您在美国,则是MM/dd/yy,否则是dd/MM/yy。
这将指示 Spring Boot 在应用程序启动时创建一个DateFormatter类。
在我们的情况下,我们需要做同样的事情并创建我们自己的格式化器,因为用两位数写年份有点尴尬。
Spring 中的Formatter是一个可以同时打印和解析对象的类。它将被用来从字符串解码和打印值。
我们将在date包中创建一个非常简单的格式化器,名为USLocalDateFormatter:
public class USLocalDateFormatter implements Formatter<LocalDate> {
public static final String US_PATTERN = "MM/dd/yyyy";
public static final String NORMAL_PATTERN = "dd/MM/yyyy";
@Override public LocalDate parse(String text, Locale locale) throws ParseException {
return LocalDate.parse(text, DateTimeFormatter.ofPattern(getPattern(locale)));
}
@Override public String print(LocalDate object, Locale locale) {
return DateTimeFormatter.ofPattern(getPattern(locale)).format(object);
}
public static String getPattern(Locale locale) {
return isUnitedStates(locale) ? US_PATTERN : NORMAL_PATTERN;
}
private static boolean isUnitedStates(Locale locale) {
return Locale.US.getCountry().equals(locale.getCountry());
}
}
这个小类将允许我们根据用户的区域设置以更常见的格式(四位数的年份)解析日期。
让我们在config包中创建一个名为WebConfiguration的新类:
package masterSpringMvc.config;
import masterSpringMvc.dates.USLocalDateFormatter;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import java.time.LocalDate;
@Configuration
public class WebConfiguration extends WebMvcConfigurerAdapter {
@Override public void addFormatters(FormatterRegistry registry) {
registry.addFormatterForFieldType(LocalDate.class, new USLocalDateFormatter());
}
}
这个类扩展了WebMvcConfigurerAdapter,这是一个非常方便的类,可以自定义 Spring MVC 配置。它提供了许多常见的扩展点,您可以通过重写方法(如addFormatters()方法)来访问。
这次,提交我们的表单不会产生任何错误,除非你没有使用正确的日期格式输入日期。
目前,用户无法看到他们应该以何种格式输入出生日期,所以让我们将此信息添加到表单中。
在ProfileController中,让我们添加一个dateFormat属性:
@ModelAttribute("dateFormat")
public String localeFormat(Locale locale) {
return USLocalDateFormatter.getPattern(locale);
}
@ModelAttribute注解将允许我们向网页公开一个属性,就像我们在上一章中看到的model.addAttribute()方法一样。
现在,我们可以通过向我们的日期字段添加占位符来在我们的页面上使用这些信息:
<div class="row">
<div class="input-field col s6">
<input th:field="${profileForm.birthDate}" id="birthDate" type="text" th:placeholder="${dateFormat}"/>
<label for="birthDate">Birth Date</label>
</div>
</div>
现在,这些信息将显示给用户:

验证
我们不希望用户输入无效或空的信息,这就是为什么我们需要在我们的ProfileForm中添加一些验证逻辑。
package masterspringmvc4.profile;
import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Past;
import javax.validation.constraints.Size;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
public class ProfileForm {
@Size(min = 2)
private String twitterHandle;
@Email
@NotEmpty
private String email;
@NotNull
private Date birthDate;
@NotEmpty
private List<String> tastes = new ArrayList<>();
}
如你所见,我们添加了一些验证约束。这些注解来自 JSR-303 规范,该规范指定了 bean 验证。该规范的流行实现是hibernate-validator,它包含在 Spring Boot 中。
你可以看到我们使用了来自javax.validation.constraints包(在 API 中定义)的注解和一些来自org.hibernate.validator.constraints包(附加约束)的注解。两者都有效,我鼓励你查看validation-api和hibernate-validator jar 包中那些包中可用的内容。
你也可以查看 hibernate validator 文档中可用的约束,文档地址为docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/#section-builtin-constraints。
为了使验证工作,我们需要添加一些其他东西。首先,控制器需要表明它在表单提交时想要一个有效的模型。将javax.validation.Valid注解添加到表示表单的参数上就可以做到这一点:
@RequestMapping(value = "/profile", method = RequestMethod.POST)
public String saveProfile(@Valid ProfileForm profileForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "profile/profilePage";
}
System.out.println("save ok" + profileForm);
return "redirect:/profile";
}
注意,如果表单包含任何错误,我们不会重定向用户。这将允许我们在同一网页上显示它们。
说到这个,我们需要在网页上添加一个位置来显示那些错误。
在profilePage.html表单标签的开始处添加这些行:
<ul th:if="${#fields.hasErrors('*')}" class="errorlist">
<li th:each="err : ${#fields.errors('*')}" th:text="${err}">Input is incorrect</li>
</ul>
这将遍历表单中找到的每个错误,并以列表形式显示它们。如果你尝试提交一个空表单,你会看到一堆错误:

注意,对“口味”上的@NotEmpty检查将阻止表单提交。实际上,我们还没有提供它们的方法。
自定义验证消息
这些错误消息对我们用户来说还不够有用。我们首先需要做的是将它们正确地关联到它们各自的字段上。让我们修改profilePage.html:
<!DOCTYPE html>
<html
layout:decorator="layout/default">
<head lang="en">
<title>Your Profile</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h2 class="indigo-text center">Personal info</h2>
<form th:action="@{/profile}" th:object="${profileForm}" method="post" class="col m8 s12 offset-m2">
<div class="row">
<div class="input-field col s6">
<input th:field="${profileForm.twitterHandle}" id="twitterHandle" type="text" th:errorclass="invalid"/>
<label for="twitterHandle">Twitter handle</label>
<div th:errors="*{twitterHandle}" class="red-text">Error</div>
</div>
<div class="input-field col s6">
<input th:field="${profileForm.email}" id="email" type="text" th:errorclass="invalid"/>
<label for="email">Email</label>
<div th:errors="*{email}" class="red-text">Error</div>
</div>
</div>
<div class="row">
<div class="input-field col s6">
<input th:field="${profileForm.birthDate}" id="birthDate" type="text" th:errorclass="invalid" th:placeholder="${dateFormat}"/>
<label for="birthDate">Birth Date</label>
<div th:errors="*{birthDate}" class="red-text">Error</div>
</div>
</div>
<div class="row s12">
<button class="btn indigo waves-effect waves-light" type="submit" name="save">Submit
<i class="mdi-content-send right"></i>
</button>
</div>
</form>
</div>
</body>
</html>
你会注意到我们在表单中的每个字段下方添加了一个 th:errors 标签。我们还为每个字段添加了一个 th:errorclass 标签。如果字段包含错误,相关的 CSS 类将被添加到 DOM 中。
验证看起来已经好多了:

我们接下来需要做的是自定义错误消息,以便更好地反映我们应用程序的业务规则。
记住 Spring Boot 会为我们创建一个消息源 bean 吗?此消息源默认位置在 src/main/resources/messages.properties。
让我们创建这样一个包,并添加以下文本:
Size.profileForm.twitterHandle=Please type in your twitter user name
Email.profileForm.email=Please specify a valid email address
NotEmpty.profileForm.email=Please specify your email address
PastLocalDate.profileForm.birthDate=Please specify a real birth date
NotNull.profileForm.birthDate=Please specify your birth date
typeMismatch.birthDate = Invalid birth date format.
提示
在开发过程中,将消息源配置为始终重新加载我们的包会非常有用。请将以下属性添加到 application.properties 文件中:
spring.messages.cache-seconds=0
0 表示始终重新加载,而 -1 表示永不重新加载。
负责在 Spring 中解析错误消息的类是 DefaultMessageCodesResolver。在字段验证的情况下,该类会按照以下顺序尝试解析以下消息:
-
code + "." + object name + "." + field
-
code + "." + field
-
code + "." + field type
-
code
在前面的规则中,代码部分可以是两件事:一个注解类型,如 Size 或 Email,或者一个异常代码,如 typeMismatch。记得我们因为不正确的日期格式而引发异常吗?相关的错误代码确实是 typeMismatch。
在前面的消息中,我们选择非常具体。一个好的做法是如下定义默认消息:
Size=the {0} field must be between {2} and {1} characters long
typeMismatch.java.util.Date = Invalid date format.
注意占位符;每个验证错误都与一些参数相关联。
声明错误消息的最后一 种方式是直接在验证注解中定义错误消息,如下所示:
@Size(min = 2, message = "Please specify a valid twitter handle")
private String twitterHandle;
然而,这种方法的一个缺点是不兼容国际化。
验证的自定义注解
对于 Java 日期,有一个名为 @Past 的注解,它确保日期来自过去。
我们不希望我们的用户假装他们是来自未来的,因此我们需要验证出生日期。为此,我们将在 date 包中定义自己的注解:
package masterSpringMvc.date;
import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
import java.lang.annotation.*;
import java.time.LocalDate;
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PastLocalDate.PastValidator.class)
@Documented
public @interface PastLocalDate {
String message() default "{javax.validation.constraints.Past.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class PastValidator implements ConstraintValidator<PastLocalDate, LocalDate> {
public void initialize(PastLocalDate past) {
}
public boolean isValid(LocalDate localDate, ConstraintValidatorContext context) {
return localDate == null || localDate.isBefore(LocalDate.now());
}
}
}
简单吗?此代码将验证我们的日期确实来自过去。
我们现在可以将其添加到配置文件中的 birthDate 字段:
@NotNull
@PastLocalDate
private LocalDate birthDate;
国际化
国际化,通常缩写为 i18n,是设计一个可以翻译成各种语言的应用程序的过程。
这通常涉及将翻译放置在以目标区域设置命名的属性包中,例如,messages_en.properties、messages_en_US.properties 和 messages_fr.properties 文件。
通过首先尝试最具体的区域设置,然后回退到不太具体的区域设置,来解析正确的属性包。
对于美国英语,如果您尝试从一个名为 x 的包中获取翻译,应用程序将首先查找 x_en_US.properties 文件,然后是 x_en.properties 文件,最后是 x.properties 文件。
我们首先要做的是将我们的错误消息翻译成法语。为此,我们将现有的 messages.properties 文件重命名为 messages_en.properties。
我们还将创建一个名为 messages_fr.properties 的第二个包:
Size.profileForm.twitterHandle=Veuillez entrer votre identifiant Twitter
Email.profileForm.email=Veuillez spécifier une adresse mail valide
NotEmpty.profileForm.email=Veuillez spécifier votre adresse mail
PastLocalDate.profileForm.birthDate=Veuillez donner votre vraie date de naissance
NotNull.profileForm.birthDate=Veuillez spécifier votre date de naissance
typeMismatch.birthDate = Date de naissance invalide.
我们在第一章中看到,快速设置 Spring Web 应用程序默认情况下,Spring Boot 使用固定的 LocaleResolver 接口。LocaleResolver 是一个简单的接口,有两个方法:
public interface LocaleResolver {
Locale resolveLocale(HttpServletRequest request);
void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale);
}
Spring 提供了这个接口的多个实现,例如 FixedLocaleResolver。这个本地解析器非常简单;我们可以通过属性配置应用程序的区域设置,一旦定义就不能更改。要配置应用程序的区域设置,让我们将以下属性添加到我们的 application.properties 文件中:
spring.mvc.locale=fr
这将添加我们的验证消息的法语版本。
如果我们查看 Spring MVC 中捆绑的不同 LocaleResolver 接口,我们将看到以下内容:
-
FixedLocaleResolver:这会固定配置中定义的区域设置。一旦固定,就不能更改。 -
CookieLocaleResolver:这允许在 cookie 中检索和保存区域设置。 -
AcceptHeaderLocaleResolver:这使用用户浏览器发送的 HTTP 头找到区域设置。 -
SessionLocaleResolver:这会在 HTTP 会话中查找和存储区域设置。
这些实现涵盖了多个用例,但在更复杂的应用程序中,有人可能会直接实现 LocaleResolver 以允许更复杂的逻辑,例如从数据库中获取区域设置并回退到浏览器区域设置,例如。
更改区域设置
在我们的应用程序中,区域设置与用户相关联。我们将将其配置文件保存在会话中。
我们将允许用户通过一个小菜单更改站点的语言。这就是为什么我们将使用 SessionLocaleResolver。让我们再次编辑 WebConfiguration:
package masterSpringMvc.config;
import masterSpringMvc.date.USLocalDateFormatter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
import java.time.LocalDate;
@Configuration
public class WebConfiguration extends WebMvcConfigurerAdapter {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addFormatterForFieldType(LocalDate.class, new USLocalDateFormatter());
}
@Bean
public LocaleResolver localeResolver() {
return new SessionLocaleResolver();
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("lang");
return localeChangeInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
}
我们声明了一个 LocaleChangeInterceptor bean 作为 Spring MVC 拦截器。它将拦截对 Controller 的任何请求并检查 lang 查询参数。例如,导航到 http://localhost:8080/profile?lang=fr 将导致区域设置更改。
小贴士
Spring MVC 拦截器可以与 Web 应用程序中的 Servlet 过滤器相比较。拦截器允许自定义预处理、跳过处理器的执行以及自定义后处理。过滤器更强大,例如,它们允许交换传递给链的请求和响应对象。过滤器在 web.xml 文件中配置,而拦截器则在应用程序上下文中声明为 bean。
现在,我们可以通过输入正确的 URL 来更改区域设置,但添加一个允许用户更改语言的导航栏会更好。我们将修改默认布局(templates/layout/default.html)以添加一个下拉菜单:
<!DOCTYPE html>
<html
>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no"/>
<title>Default title</title>
<link href="/webjars/materializecss/0.96.0/css/materialize.css" type="text/css" rel="stylesheet" media="screen,projection"/>
</head>
<body>
<ul id="lang-dropdown" class="dropdown-content">
<li><a href="?lang=en_US">English</a></li>
<li><a href="?lang=fr">French</a></li>
</ul>
<nav>
<div class="nav-wrapper indigo">
<ul class="right">
<li><a class="dropdown-button" href="#!" data-activates="lang-dropdown"><i class="mdi-action-language right"></i> Lang</a></li>
</ul>
</div>
</nav>
<section layout:fragment="content">
<p>Page content goes here</p>
</section>
<script src="img/jquery.js"></script>
<script src="img/materialize.js"></script>
<script type="text/javascript">
$(".dropdown-button").dropdown();
</script>
</body>
</html>
这将使用户能够在两种支持的语言之间进行选择。

翻译应用程序文本
为了拥有一个完全双语的程序,我们最后需要翻译我们应用程序的标题和标签。为此,我们将编辑我们的网页并使用th:text属性,例如在profilePage.html中:
<!DOCTYPE html>
<html
layout:decorator="layout/default">
<head lang="en">
<title>Your profile</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h2 class="indigo-text center" th:text="#{profile.title}">Personal info</h2>
<form th:action="@{/profile}" th:object="${profileForm}" method="post" class="col m8 s12 offset-m2">
<div class="row">
<div class="input-field col s6">
<input th:field="${profileForm.twitterHandle}" id="twitterHandle" type="text" th:errorclass="invalid"/>
<label for="twitterHandle" th:text="#{twitter.handle}">Twitter handle</label>
<div th:errors="*{twitterHandle}" class="red-text">Error</div>
</div>
<div class="input-field col s6">
<input th:field="${profileForm.email}" id="email" type="text" th:errorclass="invalid"/>
<label for="email" th:text="#{email}">Email</label>
<div th:errors="*{email}" class="red-text">Error</div>
</div>
</div>
<div class="row">
<div class="input-field col s6">
<input th:field="${profileForm.birthDate}" id="birthDate" type="text" th:errorclass="invalid"/>
<label for="birthDate" th:text="#{birthdate}" th:placeholder="${dateFormat}">Birth Date</label>
<div th:errors="*{birthDate}" class="red-text">Error</div>
</div>
</div>
<div class="row s12 center">
<button class="btn indigo waves-effect waves-light" type="submit" name="save" th:text="#{submit}">Submit
<i class="mdi-content-send right"></i>
</button>
</div>
</form>
</div>
</body>
</html>
th:text属性将用表达式替换 HTML 元素的 内容。在这里,我们使用#{}语法,表示我们想要显示来自属性源(如messages.properties)的消息。
让我们在我们的英文包中添加相应的翻译:
NotEmpty.profileForm.tastes=Please enter at least one thing
profile.title=Your profile
twitter.handle=Twitter handle
email=Email
birthdate=Birth Date
tastes.legend=What do you like?
remove=Remove
taste.placeholder=Enter a keyword
add.taste=Add taste
submit=Submit
现在转到法语部分:
NotEmpty.profileForm.tastes=Veuillez saisir au moins une chose
profile.title=Votre profil
twitter.handle=Pseudo twitter
email=Email
birthdate=Date de naissance
tastes.legend=Quels sont vos goûts ?
remove=Supprimer
taste.placeholder=Entrez un mot-clé
add.taste=Ajouter un centre d'intérêt
submit=Envoyer
一些翻译尚未使用,但很快就会用到。Et voilà!法国市场准备好迎接 Twitter 搜索的洪流了。
表单中的列表
现在,我们希望用户输入一个“tastes”列表,实际上这是一个我们将用于搜索推文的关键词列表。
将显示一个按钮,允许我们的用户输入一个新的关键词并将其添加到列表中。这个列表的每一项都将是一个可编辑的输入文本,并且可以通过删除按钮进行删除:

在某些框架中处理表单中的列表数据可能是一项繁琐的工作。然而,当您理解了原理后,使用 Spring MVC 和 Thymeleaf 就相对简单了。
在profilePage.html文件中,在包含出生日期的行下方,并在提交按钮上方添加以下行:
<fieldset class="row">
<legend th:text="#{tastes.legend}">What do you like?</legend>
<button class="btn teal" type="submit" name="addTaste" th:text="#{add.taste}">Add taste
<i class="mdi-content-add left"></i>
</button>
<div th:errors="*{tastes}" class="red-text">Error</div>
<div class="row" th:each="row,rowStat : *{tastes}">
<div class="col s6">
<input type="text" th:field="*{tastes[__${rowStat.index}__]}" th:placeholder="#{taste.placeholder}"/>
</div>
<div class="col s6">
<button class="btn red" type="submit" name="removeTaste" th:value="${rowStat.index}" th:text="#{remove}">Remove
<i class="mdi-action-delete right waves-effect"></i>
</button>
</div>
</div>
</fieldset>
这个片段的目的是遍历我们的LoginForm的tastes变量。这可以通过th:each属性实现,它看起来很像 Java 中的for…in循环。
与我们之前看到的搜索结果循环相比,迭代现在存储在两个变量中,而不是一个。第一个实际上将包含数据中的每一行。rowStat变量将包含关于迭代当前状态的附加信息。
新代码中最奇怪的事情是:
th:field="*{tastes[__${rowStat.index}__]}"
这是一种相当复杂的语法。你可以自己想出一个更简单的版本,例如:
th:field="*{tastes[rowStat.index]}"
嗯,那样是不行的。${rowStat.index}变量,它代表迭代循环的当前索引,需要在表达式其余部分之前进行评估。为了实现这一点,我们需要使用预处理。
被双下划线包围的表达式将被预处理,这意味着它将在正常处理阶段之前进行处理,允许它被评估两次。
现在在我们的表单上有两个新的提交按钮。它们都有名称。我们之前有的全局提交按钮被称为save。两个新按钮分别称为addTaste和removeTaste。
在控制器端,这将使我们能够轻松区分来自我们表单的不同操作。让我们向我们的ProfileController添加两个新操作:
@Controller
public class ProfileController {
@ModelAttribute("dateFormat")
public String localeFormat(Locale locale) {
return USLocalDateFormatter.getPattern(locale);
}
@RequestMapping("/profile")
public String displayProfile(ProfileForm profileForm) {
return "profile/profilePage";
}
@RequestMapping(value = "/profile", params = {"save"}, method = RequestMethod.POST)
public String saveProfile(@Valid ProfileForm profileForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "profile/profilePage";
}
System.out.println("save ok" + profileForm);
return "redirect:/profile";
}
@RequestMapping(value = "/profile", params = {"addTaste"})
public String addRow(ProfileForm profileForm) {
profileForm.getTastes().add(null);
return "profile/profilePage";
}
@RequestMapping(value = "/profile", params = {"removeTaste"})
public String removeRow(ProfileForm profileForm, HttpServletRequest req) {
Integer rowId = Integer.valueOf(req.getParameter("removeTaste"));
profileForm.getTastes().remove(rowId.intValue());
return "profile/profilePage";
}
}
我们为每个帖子操作添加了一个param参数来区分它们。我们之前有的现在绑定到save参数。
当我们点击一个按钮时,它的名称将自动添加到浏览器发送的表单数据中。注意,我们为移除按钮指定了一个特定的值:th:value="${rowStat.index}"。这个属性将指示相关参数应该具体取哪个值。如果没有这个属性,将发送一个空白值。这意味着当我们点击移除按钮时,一个removeTaste参数将被添加到POST请求中,包含我们想要删除的行的索引。然后我们可以使用以下代码将其返回到Controller:
Integer rowId = Integer.valueOf(req.getParameter("removeTaste"));
这种方法的唯一缺点是,每次我们点击按钮时,即使它不是严格必需的,也会发送整个表单数据。由于我们的表单足够小,所以权衡是可以接受的。
就这样!表单现在完整了,可以添加一个或多个口味。
客户端验证
作为一个小奖励,随着 HTML5 表单验证规范的推出,客户端验证现在变得非常简单。如果你的目标浏览器是 Internet Explorer 10 及以上版本,添加客户端验证就像指定正确的输入类型而不是仅仅使用文本一样简单。
通过添加客户端验证,我们可以预先验证表单,避免服务器因我们知道是错误的请求而超载。有关客户端验证规范的更多信息,请参阅caniuse.com/#search=validation。
我们可以修改我们的输入以启用简单的客户端验证。以下代码显示了之前的输入:
<input th:field="${profileForm.twitterHandle}" id="twitterHandle" type="text" th:errorclass="invalid"/>
<input th:field="${profileForm.email}" id="email" type="text" th:errorclass="invalid"/>
<input th:field="${profileForm.birthDate}" id="birthDate" type="text" th:errorclass="invalid"/>
<input type="text" th:field="*{tastes[__${rowStat.index}__]}" th:placeholder="#{taste.placeholder}"/>
这变成:
<input th:field="${profileForm.twitterHandle}" id="twitterHandle" type="text" required="required" th:errorclass="invalid"/>
<input th:field="${profileForm.email}" id="email" type="email" required="required" th:errorclass="invalid"/>
<input th:field="${profileForm.birthDate}" id="birthDate" type="text" required="required" th:errorclass="invalid"/>
<input type="text" required="required" th:field="*{tastes[__${rowStat.index}__]}" th:placeholder="#{taste.placeholder}"/>
使用这种方法,您的浏览器将在表单提交时检测到并验证每个属性根据其类型。required属性强制用户输入非空白值。email类型强制对相应字段执行基本的电子邮件验证规则。

其他类型的验证器也存在。请参阅www.the-art-of-web.com/html/html5-form-validation。
这种方法的缺点是,我们的添加口味和移除口味按钮现在将触发验证。为了解决这个问题,我们需要在默认布局的底部包含一个脚本,紧接在 jQuery 声明之后。
然而,最好只将其包含在个人资料页面上。为此,我们可以在layout/default.html页面的末尾之前添加一个新的片段部分:
<script type="text/javascript" layout:fragment="script">
</script>
这将允许我们在需要时在每个页面上包含一个额外的脚本。
现在,我们可以在我们的个人资料页面中添加以下脚本,就在关闭 body 标签之前:
<script layout:fragment="script">
$('button').bind('click', function(e) {
if (e.currentTarget.name === 'save') {
$(e.currentTarget.form).removeAttr('novalidate');
} else {
$(e.currentTarget.form).attr('novalidate', 'novalidate');
}
});
</script>
当表单上存在 novalidate 属性时,表单验证不会被触发。这个小脚本会动态地移除 novalidate 属性,如果表单的动作名为 save,且输入框的名称不同,则 novalidate 属性将始终被添加。因此,验证将仅由保存按钮触发。
检查点
在进入下一章之前,让我们检查一下所有内容是否都在正确的位置。
在 Java 源代码中,你应该有以下内容:
-
一个新的控制器,
ProfileController -
两个与日期相关的新的类:一个日期格式化器和用于验证
LocalDate的注解 -
一个新的
WebConfiguration文件夹,用于自定义 Spring MVC 的配置

在资源中,你应该在配置文件目录中有一个新的模板和两个新的包:

摘要
在本章中,你学习了如何制作一个完整的表单。我们使用 Java 8 日期创建了一个模型,并学习了如何格式化来自用户的信息并相应地显示它。
我们确保表单填写了有效的信息,包括我们的验证注解。此外,我们还通过包括一些客户端验证来防止显然错误的信息甚至到达服务器。
最后,我们甚至将整个应用程序翻译成了英语和法语,包括日期格式!
在下一章中,我们将构建一个空间,用户将能够上传他们的图片,并学习更多关于 Spring MVC 应用程序中错误处理的知识。
第四章:文件上传和错误处理
在本章中,我们将使我们的用户能够上传个人资料图片。我们还将了解如何在 Spring MVC 中处理错误。
上传文件
现在,我们将使我们的用户能够上传个人资料图片。这将在稍后的个人资料页面中提供,但现在,我们将简化事情,并在模板目录下的 profile/uploadPage.html 中创建一个新的页面:
<!DOCTYPE html>
<html
layout:decorator="layout/default">
<head lang="en">
<title>Profile Picture Upload</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h2 class="indigo-text center">Upload</h2>
<form th:action="@{/upload}" method="post" enctype="multipart/form-data" class="col m8 s12 offset-m2">
<div class="input-field col s6">
<input type="file" id="file" name="file"/>
</div>
<div class="col s6 center">
<button class="btn indigo waves-effect waves-light" type="submit" name="save" th:text="#{submit}">Submit
<i class="mdi-content-send right"></i>
</button>
</div>
</form>
</div>
</body>
</html>
除了表单上的 enctype 属性外,没有太多可看的内容。文件将通过 POST 方法发送到 upload URL。现在,我们将在 profile 包中的 ProfileController 旁边创建相应的控制器:
package masterSpringMvc.profile;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@Controller
public class PictureUploadController {
public static final Resource PICTURES_DIR = new FileSystemResource("./pictures");
@RequestMapping("upload")
public String uploadPage() {
return "profile/uploadPage";
}
@RequestMapping(value = "/upload", method = RequestMethod.POST)
public String onUpload(MultipartFile file) throws IOException {
String filename = file.getOriginalFilename();
File tempFile = File.createTempFile("pic", getFileExtension(filename), PICTURES_DIR.getFile());
try (InputStream in = file.getInputStream();
OutputStream out = new FileOutputStream(tempFile)) {
IOUtils.copy(in, out);
}
return "profile/uploadPage";
}
private static String getFileExtension(String name) {
return name.substring(name.lastIndexOf("."));
}
}
这段代码首先会在 pictures 目录中创建一个临时文件,该目录位于项目的根文件夹内;因此,请确保它存在。在 Java 中,临时文件只是用于在文件系统中获取唯一文件标识符的一种商品。用户可以选择性地删除它。
在项目的根目录下创建一个图片目录,并添加一个名为 .gitkeep 的空文件,以确保您可以在 Git 中提交它。
小贴士
Git 中的空目录
Git 是基于文件的,无法提交空目录。一个常见的解决方案是在目录中提交一个空文件,例如 .gitkeep,以强制 Git 将其纳入版本控制。
用户上传的文件将被注入到我们的控制器中的 MultipartFile 接口中。该接口提供了几种方法来获取文件名、大小和内容。
我们特别感兴趣的方法是 getInputStream()。我们将使用 IOUtils.copy 方法将这个流复制到 fileOutputStream 方法中。将输入流写入输出流的代码相当无聊,所以将 Apache Utils 添加到类路径中很方便(它是 tomcat-embedded-core.jar 文件的一部分)。
我们大量使用了相当酷的 Spring 和 Java 7 NIO 功能:
-
字符串的资源类是一个实用工具类,它表示可以通过不同方式找到的资源抽象。
-
try…with块将自动关闭我们的流,即使在异常的情况下也会关闭,从而消除了编写finally块的样板代码。
在前面的代码中,用户上传的任何文件都将被复制到 pictures 目录中。
在 Spring Boot 中有一些可用的属性来定制文件上传。看看 MultipartProperties 类。
最有趣的是:
-
multipart.maxFileSize:这定义了允许上传文件的最大文件大小。尝试上传更大的文件将导致MultipartException类异常。默认值是1Mb。 -
multipart.maxRequestSize:这定义了多部分请求的最大大小。默认值是10Mb。
默认值对我们的应用程序来说已经足够好了。上传几次之后,我们的图片目录将看起来像这样:

等等!有人上传了一个 ZIP 文件!我简直不敢相信。我们最好在我们的控制器中添加一些检查,以确保上传的文件是真实的图片:
package masterSpringMvc.profile;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.io.*;
@Controller
public class PictureUploadController {
public static final Resource PICTURES_DIR = new FileSystemResource("./pictures");
@RequestMapping("upload")
public String uploadPage() {
return "profile/uploadPage";
}
@RequestMapping(value = "/upload", method = RequestMethod.POST)
public String onUpload(MultipartFile file, RedirectAttributes redirectAttrs) throws IOException {
if (file.isEmpty() || !isImage(file)) {
redirectAttrs.addFlashAttribute("error", "Incorrect file. Please upload a picture.");
return "redirect:/upload";
}
copyFileToPictures(file);
return "profile/uploadPage";
}
private Resource copyFileToPictures(MultipartFile file) throws IOException {
String fileExtension = getFileExtension(file.getOriginalFilename());
File tempFile = File.createTempFile("pic", fileExtension, PICTURES_DIR.getFile());
try (InputStream in = file.getInputStream();
OutputStream out = new FileOutputStream(tempFile)) {
IOUtils.copy(in, out);
}
return new FileSystemResource(tempFile);
}
private boolean isImage(MultipartFile file) {
return file.getContentType().startsWith("image");
}
private static String getFileExtension(String name) {
return name.substring(name.lastIndexOf("."));
}
}
很简单!getContentType()方法返回文件的多用途互联网邮件扩展(MIME)类型。它将是image/png、image/jpg等等。所以我们只需要检查 MIME 类型是否以"image"开头。
我们在表单中添加了一个错误消息,所以我们应该在我们的网页中添加一些内容来显示它。将以下代码放在uploadPage标题下方:
<div class="col s12 center red-text" th:text="${error}" th:if="${error}">
Error during upload
</div>
下次你尝试上传 ZIP 文件时,你会得到一个错误!如下截图所示:

将图片写入响应
上传的图片不是从静态目录中提供的。我们需要采取特殊措施在网页中显示它们。
让我们在表单上方添加以下行:
<div class="col m8 s12 offset-m2">
<img th:src="img/uploadedPicture}" width="100" height="100"/>
</div>
这将尝试从我们的控制器中获取图片。让我们在PictureUploadController类中添加相应的方法:
@RequestMapping(value = "/uploadedPicture")
public void getUploadedPicture(HttpServletResponse response) throws IOException {
ClassPathResource classPathResource = new ClassPathResource("/images/anonymous.png");
response.setHeader("Content-Type", URLConnection.guessContentTypeFromName(classPathResource.getFilename()));
IOUtils.copy(classPathResource.getInputStream(), response.getOutputStream());
}
这段代码将直接将src/main/resources/images/anonymous.png目录中的图片写入响应!多么令人兴奋!
如果我们再次访问我们的页面,我们会看到以下图片:

小贴士
我在 iconmonstr(iconmonstr.com/user-icon)上找到了匿名用户头像并将其下载为 128 x 128 的 PNG 文件。
管理上传属性
在这个阶段,允许通过application.properties文件配置上传目录和匿名用户图片的路径是个好主意。
让我们在新创建的config包中创建一个PicturesUploadProperties类:
package masterSpringMvc.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import java.io.IOException;
@ConfigurationProperties(prefix = "upload.pictures")
public class PictureUploadProperties {
private Resource uploadPath;
private Resource anonymousPicture;
public Resource getAnonymousPicture() {
return anonymousPicture;
}
public void setAnonymousPicture(String anonymousPicture) {
this.anonymousPicture = new DefaultResourceLoader().getResource(anonymousPicture);
}
public Resource getUploadPath() {
return uploadPath;
}
public void setUploadPath(String uploadPath) {
this.uploadPath = new DefaultResourceLoader().getResource(uploadPath);
}
}
在这个类中,我们使用了 Spring Boot 的ConfigurationProperties。这将告诉 Spring Boot 以类型安全的方式自动映射在类路径中找到的属性(默认情况下,在application.properties文件中)。
注意,我们定义了接受'String'作为参数的 setter,但允许 getters 返回任何类型是最有用的。
现在,我们需要将PicturesUploadProperties类添加到我们的配置中:
@SpringBootApplication
@EnableConfigurationProperties({PictureUploadProperties.class})
public class MasterSpringMvc4Application extends WebMvcConfigurerAdapter {
// code omitted
}
我们现在可以在application.properties文件中添加属性的值:
upload.pictures.uploadPath=file:./pictures
upload.pictures.anonymousPicture=classpath:/images/anonymous.png
由于我们使用了 Spring 的DefaultResourceLoader类,我们可以使用file:或classpath:等前缀来指定我们的资源可以在哪里找到。
这相当于创建一个FileSystemResource类或ClassPathResource类。
这种方法也有文档化的优势。我们可以很容易地看到图片目录将在应用程序根目录中找到,而匿名图片将在类路径中找到。
就这样。我们现在可以在我们的控制器中使用我们的属性。以下是PictureUploadController类的相关部分:
package masterSpringMvc.profile;
import masterSpringMvc.config.PictureUploadProperties;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLConnection;
@Controller
public class PictureUploadController {
private final Resource picturesDir;
private final Resource anonymousPicture;
@Autowired
public PictureUploadController(PictureUploadProperties uploadProperties) {
picturesDir = uploadProperties.getUploadPath();
anonymousPicture = uploadProperties.getAnonymousPicture();
}
@RequestMapping(value = "/uploadedPicture")
public void getUploadedPicture(HttpServletResponse response) throws IOException {
response.setHeader("Content-Type", URLConnection.guessContentTypeFromName(anonymousPicture.getFilename()));
IOUtils.copy(anonymousPicture.getInputStream(), response.getOutputStream());
}
private Resource copyFileToPictures(MultipartFile file) throws IOException {
String fileExtension = getFileExtension(file.getOriginalFilename());
File tempFile = File.createTempFile("pic", fileExtension, picturesDir.getFile());
try (InputStream in = file.getInputStream();
OutputStream out = new FileOutputStream(tempFile)) {
IOUtils.copy(in, out);
}
return new FileSystemResource(tempFile);
}
// The rest of the code remains the same
}
在这一点上,如果你再次启动你的应用程序,你会看到结果并没有改变。匿名图片仍然被显示,用户上传的图片仍然位于项目根目录下的pictures目录中。
显示上传的图片
现在显示用户的图片不是很好吗?为了做到这一点,我们将在PictureUploadController类中添加一个模型属性:
@ModelAttribute("picturePath")
public Resource picturePath() {
return anonymousPicture;
}
我们现在可以注入它以在服务上传的图片时检索其值:
@RequestMapping(value = "/uploadedPicture")
public void getUploadedPicture(HttpServletResponse response, @ModelAttribute("picturePath") Path picturePath) throws IOException {
response.setHeader("Content-Type", URLConnection.guessContentTypeFromName(picturePath.toString()));
Files.copy(picturePath, response.getOutputStream());
}
@ModelAttribute注解是一个创建带有注解方法的模型属性的便捷方式。然后,它们可以用相同的注解注入到控制器方法中。使用这段代码,只要我们没有重定向到另一个页面,picturePath参数就会在模型中可用。它的默认值是我们定义在属性文件中的匿名图片。
我们需要在文件上传时更新此值。更新onUpload方法:
@RequestMapping(value = "/upload", method = RequestMethod.POST)
public String onUpload(MultipartFile file, RedirectAttributes redirectAttrs, Model model) throws IOException {
if (file.isEmpty() || !isImage(file)) {
redirectAttrs.addFlashAttribute("error", "Incorrect file. Please upload a picture.");
return "redirect:/upload";
}
Resource picturePath = copyFileToPictures(file);
model.addAttribute("picturePath", picturePath);
return "profile/uploadPage";
}
通过注入模型,我们可以在上传完成后更新picturePath参数。
现在,问题是我们的两个方法,onUpload和getUploadedPicture,将在不同的请求中发生。不幸的是,模型属性将在每次之间重置。
因此,我们将picturePath参数定义为会话属性。我们可以通过向我们的控制器类添加另一个注解来实现这一点:
@Controller
@SessionAttributes("picturePath")
public class PictureUploadController {
}
哇!仅仅为了处理一个简单的会话属性,就需要这么多注解。你将得到以下输出:

这种方法使代码组合变得非常容易。此外,我们没有直接使用HttpServletRequest或HttpSession。此外,我们的对象可以轻松地进行类型化。
处理文件上传错误
一定已经让我的细心读者意识到,我们的代码可能会抛出两种类型的异常:
-
IOException:如果在将文件写入磁盘时发生错误,则会抛出此错误。 -
MultipartException:如果在上传文件时发生错误,则会抛出此错误。例如,当超过最大文件大小时。
这将给我们一个很好的机会来探讨在 Spring 中处理异常的两种方式:
-
在控制器方法中局部使用
@ExceptionHandler注解 -
使用在 Servlet 容器级别定义的全局异常处理器
让我们在PictureUploadController类中通过添加以下方法来使用@ExceptionHandler注解处理IOException:
@ExceptionHandler(IOException.class)
public ModelAndView handleIOException(IOException exception) {
ModelAndView modelAndView = new ModelAndView("profile/uploadPage");
modelAndView.addObject("error", exception.getMessage());
return modelAndView;
}
这是一个简单而强大的方法。每次在我们的控制器中抛出IOException时,都会调用此方法。
为了测试异常处理器,由于使 Java IO 代码抛出异常可能很棘手,只需在测试期间替换onUpload方法体:
@RequestMapping(value = "/upload", method = RequestMethod.POST)
public String onUpload(MultipartFile file, RedirectAttributes redirectAttrs, Model model) throws IOException {
throw new IOException("Some message");
}
在此更改之后,如果我们尝试上传图片,我们将在上传页面上看到此异常的错误消息:

现在,我们将处理MultipartException。这需要在 Servlet 容器级别(即 Tomcat 级别)发生,因为这个异常不是由我们的控制器直接抛出的。
我们需要向我们的配置中添加一个新的EmbeddedServletContainerCustomizer bean。将此方法添加到WebConfiguration类中:
@Bean
public EmbeddedServletContainerCustomizer containerCustomizer() {
EmbeddedServletContainerCustomizer
embeddedServletContainerCustomizer = new EmbeddedServletContainerCustomizer() {
@Override
public void customize(ConfigurableEmbeddedServletContainer container) {
container.addErrorPages(new ErrorPage(MultipartException.class, "/uploadError"));
}
};
return embeddedServletContainerCustomizer;
}
这有点冗长。请注意,EmbeddedServletContainerCustomizer是一个包含单个方法的接口;因此,它可以被 lambda 表达式替换:
@Bean
public EmbeddedServletContainerCustomizer containerCustomizer() {
EmbeddedServletContainerCustomizer embeddedServletContainerCustomizer
= container -> container.addErrorPages(new ErrorPage(MultipartException.class, "/uploadError"));
return embeddedServletContainerCustomizer;
}
因此,我们只需写下以下内容:
@Bean
public EmbeddedServletContainerCustomizer containerCustomizer() {
return container -> container.addErrorPages(new ErrorPage(MultipartException.class, "/uploadError"));
}
这段代码创建了一个新的错误页面,当发生MultipartException时将被调用。它也可以映射到 HTTP 状态。EmbeddedServletContainerCustomizer接口还有许多其他功能,将允许定制我们的应用程序运行在其中的 Servlet 容器。访问docs.spring.io/spring-boot/docs/current/reference/html/boot-features-developing-web-applications.html#boot-features-customizing-embedded-containers获取更多信息。
我们现在需要在我们的PictureUploadController类中处理这个uploadError URL:
@RequestMapping("uploadError")
public ModelAndView onUploadError(HttpServletRequest request) {
ModelAndView modelAndView = new ModelAndView("uploadPage");
modelAndView.addObject("error", request.getAttribute(WebUtils.ERROR_MESSAGE_ATTRIBUTE));
return modelAndView;
}
在 Servlet 环境中定义的错误页面包含了一些有趣的属性,这些属性将有助于调试错误:
| 属性 | 描述 |
|---|---|
javax.servlet.error.status_code |
这是错误的 HTTP 状态码。 |
javax.servlet.error.exception_type |
这是异常类。 |
javax.servlet.error.message |
这是抛出异常的消息。 |
javax.servlet.error.request_uri |
这是异常发生的 URI。 |
javax.servlet.error.exception |
这是实际的异常。 |
javax.servlet.error.servlet_name |
这是捕获异常的 Servlet 的名称。 |
所有这些属性都可以方便地在 Spring Web 的WebUtils类中访问。
如果有人尝试上传太大的文件,他们将收到一个非常清晰的错误消息。
您现在可以通过上传一个非常大的文件(> 1Mb)或将multipart.maxFileSize属性设置为更低的值(例如:1kb)来测试错误是否被正确处理:

翻译错误消息
对于开发者来说,看到应用程序抛出的异常是非常有用的。然而,对于我们的用户来说,它们的价值很小。因此,我们将翻译它们。为了做到这一点,我们必须在我们的控制器构造函数中注入我们的应用程序的MessageSource类:
private final MessageSource messageSource;
@Autowired
public PictureUploadController(PictureUploadProperties uploadProperties, MessageSource messageSource) {
picturesDir = uploadProperties.getUploadPath();
anonymousPicture = uploadProperties.getAnonymousPicture();
this.messageSource = messageSource;
}
现在,我们可以从我们的消息包中检索消息:
@ExceptionHandler(IOException.class)
public ModelAndView handleIOException(Locale locale) {
ModelAndView modelAndView = new ModelAndView("profile/uploadPage");
modelAndView.addObject("error", messageSource.getMessage("upload.io.exception", null, locale));
return modelAndView;
}
@RequestMapping("uploadError")
public ModelAndView onUploadError(Locale locale) {
ModelAndView modelAndView = new ModelAndView("profile/uploadPage");
modelAndView.addObject("error", messageSource.getMessage("upload.file.too.big", null, locale));
return modelAndView;
}
这里是英文消息:
upload.io.exception=An error occurred while uploading the file. Please try again.
upload.file.too.big=Your file is too big.
现在,让我们处理法语消息:
upload.io.exception=Une erreur est survenue lors de l'envoi du fichier. Veuillez réessayer.
upload.file.too.big=Votre fichier est trop gros.
将配置文件放在会话中
我们接下来想要的是将配置文件存储在会话中,这样每次我们访问配置文件页面时它就不会被重置。这可能会让一些用户感到烦恼,我们必须解决这个问题。
小贴士
HTTP 会话是存储请求之间信息的一种方式。HTTP 是一个无状态协议,这意味着无法关联来自同一用户的两个请求。大多数 Servlet 容器所做的是,它们将一个名为JSESSIONID的 cookie 与每个用户关联。这个 cookie 将在请求头中传输,并允许你在HttpSession这个抽象的 map 中存储任意对象。这样的会话通常在用户关闭或切换 Web 浏览器,或者经过预定义的不活动期后结束。
我们刚刚看到了一个使用@SessionAttributes注解将对象放入会话中的方法。这在控制器内部工作得很好,但当数据分散在多个控制器之间时,会使数据共享变得困难。我们必须依赖于一个字符串来解析属性名称,这很难重构。出于同样的原因,我们不想直接操作HttpSession。另一个会阻止直接使用会话的论点是,依赖于它的控制器进行单元测试是多么困难。
当使用 Spring 在会话中保存东西时,还有一种流行的方法:用@Scope("session")注解一个 bean。
然后,你将能够将你的会话 bean 注入到你的控制器和其他 Spring 组件中,以便设置或从中检索值。
让我们在profile包中创建一个UserProfileSession类:
package masterSpringMvc.profile;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserProfileSession implements Serializable {
private String twitterHandle;
private String email;
private LocalDate birthDate;
private List<String> tastes = new ArrayList<>();
public void saveForm(ProfileForm profileForm) {
this.twitterHandle = profileForm.getTwitterHandle();
this.email = profileForm.getEmail();
this.birthDate = profileForm.getBirthDate();
this.tastes = profileForm.getTastes();
}
public ProfileForm toForm() {
ProfileForm profileForm = new ProfileForm();
profileForm.setTwitterHandle(twitterHandle);
profileForm.setEmail(email);
profileForm.setBirthDate(birthDate);
profileForm.setTastes(tastes);
return profileForm;
}
}
我们提供了一个方便的方法来转换ProfileForm对象。这将帮助我们从ProfileController构造函数中存储和检索表单数据。我们需要在控制器构造函数中注入我们的UserProfileSession变量并将其存储为一个字段。我们还需要将ProfileForm作为模型属性暴露,这将消除在displayProfile方法中注入它的需要。最后,一旦验证通过,我们就可以保存配置文件:
@Controller
public class ProfileController {
private UserProfileSession userProfileSession;
@Autowired
public ProfileController(UserProfileSession userProfileSession) {
this.userProfileSession = userProfileSession;
}
@ModelAttribute
public ProfileForm getProfileForm() {
return userProfileSession.toForm();
}
@RequestMapping(value = "/profile", params = {"save"}, method = RequestMethod.POST)
public String saveProfile(@Valid ProfileForm profileForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "profile/profilePage";
}
userProfileSession.saveForm(profileForm);
return "redirect:/profile";
}
// the rest of the code is unchanged
}
这就是使用 Spring MVC 在会话中保存数据所需的所有步骤。
现在,如果你完成配置文件表单并刷新页面,数据将在请求之间持久化。
在进入下一章之前,我想详细说明我们刚刚使用的一些概念。
第一种是通过构造函数进行注入。ProfileController构造函数被注解为@Autowired,这意味着 Spring 将在实例化 bean 之前从应用程序上下文中解析构造函数参数。另一种稍微不那么冗长的方法是使用字段注入:
@Controller
public class ProfileController {
@Autowired
private UserProfileSession userProfileSession;
}
构造函数注入可以说是更好的,因为它使得如果我们从spring-test框架中移除,我们的控制器单元测试更容易,并且使我们的 bean 的依赖关系更加明确。
关于字段注入和构造函数注入的详细讨论,请参阅 Oliver Gierke 在olivergierke.de/2013/11/why-field-injection-is-evil/的优秀博客文章。
另一个可能需要澄清的是Scope注解上的proxyMode参数:
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
在 Spring 中,如果我们不计默认的参数,有三个proxyMode参数可用:
-
TARGET_CLASS:这使用 CGI 代理 -
INTERFACES:这会创建一个 JDK 代理 -
NO:这不会创建任何代理
代理的优点通常在将某些东西注入到长生命周期的组件(如单例)时体现出来。因为注入只发生在 bean 创建时,所以后续对注入 bean 的调用可能不会反映其实际状态。
在我们的例子中,会话 bean 的实际状态存储在会话中,而不是直接在 bean 上。这就解释了为什么 Spring 必须创建一个代理:它需要拦截对 bean 方法的调用并监听其变化。这样,bean 的状态就可以透明地存储和从底层的 HTTP 会话中检索。
对于会话 bean,我们被迫使用代理模式。CGI 代理会对你的字节码进行操作,并适用于任何类,而 JDK 方法可能更轻量级,但需要你实现一个接口。
最后,我们让UserProfileSessionbean 实现了Serializable接口。这并不是严格必要的,因为 HTTP 会话可以在内存中存储任意对象,但将最终存储在会话中的对象序列化确实是一个好习惯。
事实上,我们可能会改变会话持久化的方式。实际上,我们将在第八章优化您的请求中存储会话到 Redis 数据库,其中 Redis 必须与Serializable对象一起工作。始终将通用数据存储的会话视为最佳做法。我们必须提供一种方法来从这个存储系统中写入和读取对象。
为了确保我们的 bean 能够正确地进行序列化,我们还需要确保它的每个字段都是可序列化的。在我们的例子中,字符串和日期是可序列化的,所以我们一切准备就绪。
自定义错误页面
Spring Boot 允许你定义自己的错误视图,而不是我们之前看到的 Whitelabel 错误页面。它必须命名为error,其目的是处理所有异常。默认的BasicErrorController类将公开许多有用的模型属性,你可以在页面上显示这些属性。
让我们在src/main/resources/templates中创建一个自定义错误页面。让我们称它为error.html:
<!DOCTYPE html>
<html >
<head lang="en">
<meta charset="UTF-8"/>
<title th:text="${status}">404</title>
<link href="/webjars/materializecss/0.96.0/css/materialize.css" type="text/css" rel="stylesheet"
media="screen,projection"/>
</head>
<body>
<div class="row">
<h1 class="indigo-text center" th:text="${error}">Not found</h1>
<p class="col s12 center" th:text="${message}">
This page is not available
</p>
</div>
</body>
</html>
现在,如果我们导航到一个我们的应用程序未处理的 URL,我们会看到我们的自定义错误页面:

处理错误的一个更高级的选项是定义自己的ErrorController类实现,这是一个负责在全局级别处理所有异常的控制器。查看ErrorMvcAutoConfiguration类和默认实现BasicErrorController类。
带有矩阵变量的 URL 映射
我们现在知道用户感兴趣的内容。改进我们的推文控制器,使其能够从关键字列表中进行搜索,将是一个好主意。
在 URL 中传递键值对的一个有趣方式是使用矩阵变量。它与请求参数非常相似。考虑以下代码:
someUrl/param?var1=value1&var2=value2
与前面的参数不同,矩阵变量理解以下内容:
someUrl/param;var1=value1;var2=value2
它们还允许每个参数都是一个列表:
someUrl/param;var1=value1,value2;var2=value3,value4
矩阵变量可以映射到控制器内部的不同对象类型:
-
Map<String, List<?>>:这处理多个变量和多个值 -
Map<String, ?>:这处理每个变量只有一个值的情况 -
List<?>:如果我们对单个变量感兴趣,其名称可以配置,则使用此
在我们的案例中,我们想要处理类似以下内容:
http://localhost:8080/search/popular;keywords=scala,java
第一个参数popular是 Twitter 搜索 API 所知的结果类型。它可以取以下值:mixed、recent或popular。
我们 URL 的其余部分是关键字列表。因此,我们将它们映射到一个简单的List<String>对象。
默认情况下,Spring MVC 会移除 URL 中分号后面的每个字符。为了在我们的应用程序中启用矩阵变量,我们首先需要关闭此行为。
让我们在WebConfiguration类中添加以下代码:
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
UrlPathHelper urlPathHelper = new UrlPathHelper();
urlPathHelper.setRemoveSemicolonContent(false);
configurer.setUrlPathHelper(urlPathHelper);
}
让我们在search包中创建一个新的控制器,我们将称之为SearchController。其作用是处理以下请求:
package masterSpringMvc.search;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.twitter.api.Tweet;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.MatrixVariable;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import java.util.List;
@Controller
public class SearchController {
private SearchService searchService;
@Autowired
public SearchController(SearchService searchService) {
this.searchService = searchService;
}
@RequestMapping("/search/{searchType}")
public ModelAndView search(@PathVariable String searchType, @MatrixVariable List<String> keywords) {
List<Tweet> tweets = searchService.search(searchType, keywords);
ModelAndView modelAndView = new ModelAndView("resultPage");
modelAndView.addObject("tweets", tweets);
modelAndView.addObject("search", String.join(",", keywords));
return modelAndView;
}
}
如您所见,我们能够重用现有的结果页面来显示推文。我们还希望将搜索委托给另一个名为SearchService的类。我们将在与SearchController相同的包中创建此服务:
package masterSpringMvc.search;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.twitter.api.Tweet;
import org.springframework.social.twitter.api.Twitter;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SearchService {
private Twitter twitter;
@Autowired
public SearchService(Twitter twitter) {
this.twitter = twitter;
}
public List<Tweet> search(String searchType, List<String> keywords) {
return null;
}
}
现在,我们需要实现search()方法。
在twitter.searchOperations().search(params)上可访问的搜索操作将searchParameters作为高级搜索的参数。此对象允许我们在十几个标准上进行搜索。我们对query、resultType和count属性感兴趣。
首先,我们需要创建一个带有searchType路径变量的ResultType构造函数。ResultType是一个枚举,因此我们可以遍历其不同的值,找到一个与输入匹配的值,忽略大小写:
private SearchParameters.ResultType getResultType(String searchType) {
for (SearchParameters.ResultType knownType : SearchParameters.ResultType.values()) {
if (knownType.name().equalsIgnoreCase(searchType)) {
return knownType;
}
}
return SearchParameters.ResultType.RECENT;
}
我们现在可以创建一个带有以下方法的SearchParameters构造函数:
private SearchParameters createSearchParam(String searchType, String taste) {
SearchParameters.ResultType resultType = getResultType(searchType);
SearchParameters searchParameters = new SearchParameters(taste);
searchParameters.resultType(resultType);
searchParameters.count(3);
return searchParameters;
}
现在,创建一个SearchParameters构造函数的列表就像执行一个映射操作(取一个关键字列表,并为每个关键字返回一个SearchParameters构造函数)一样简单:
List<SearchParameters> searches = keywords.stream()
.map(taste -> createSearchParam(searchType, taste))
.collect(Collectors.toList());
现在,我们想要获取每个SearchParameters构造函数的推文。您可能会想到以下内容:
List<Tweet> tweets = searches.stream()
.map(params -> twitter.searchOperations().search(params))
.map(searchResults -> searchResults.getTweets())
.collect(Collectors.toList());
然而,如果您这么想,这将返回一个推文列表。我们想要的却是将所有推文展平,以获得一个简单的列表。结果是调用map然后展平结果的操作被称为flatMap。因此,我们可以写出:
List<Tweet> tweets = searches.stream()
.map(params -> twitter.searchOperations().search(params))
.flatMap(searchResults -> searchResults.getTweets().stream())
.collect(Collectors.toList());
flatMap函数的语法,它接受一个流作为参数,一开始可能有点难以理解。让我展示SearchService类的整个代码,这样我们可以回顾一下:
package masterSpringMvc.search;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.twitter.api.SearchParameters;
import org.springframework.social.twitter.api.Tweet;
import org.springframework.social.twitter.api.Twitter;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class SearchService {
private Twitter twitter;
@Autowired
public SearchService(Twitter twitter) {
this.twitter = twitter;
}
public List<Tweet> search(String searchType, List<String> keywords) {
List<SearchParameters> searches = keywords.stream()
.map(taste -> createSearchParam(searchType, taste))
.collect(Collectors.toList());
List<Tweet> results = searches.stream()
.map(params -> twitter.searchOperations().search(params))
.flatMap(searchResults -> searchResults.getTweets().stream())
.collect(Collectors.toList());
return results;
}
private SearchParameters.ResultType getResultType(String searchType) {
for (SearchParameters.ResultType knownType : SearchParameters.ResultType.values()) {
if (knownType.name().equalsIgnoreCase(searchType)) {
return knownType;
}
}
return SearchParameters.ResultType.RECENT;
}
private SearchParameters createSearchParam(String searchType, String taste) {
SearchParameters.ResultType resultType = getResultType(searchType);
SearchParameters searchParameters = new SearchParameters(taste);
searchParameters.resultType(resultType);
searchParameters.count(3);
return searchParameters;
}
}
现在,如果我们导航到http://localhost:8080/search/mixed;keywords=scala,java,我们会得到预期的结果。搜索 Scala 关键字,然后是 Java:

组合起来
现在一切都独立工作,是时候将它们组合在一起了。我们将分三步完成:
-
将上传表单移至个人资料页面,并删除旧的上传页面。
-
将个人资料页面的提交按钮更改为直接触发口味搜索。
-
更改我们应用程序的首页。它应该立即显示与用户口味匹配的搜索结果。如果它们不可用,则转到个人资料页面。
我鼓励你尝试自己完成。在过程中你会遇到一些非常容易处理的问题,但你应该有足够的知识自己解决它们。我相信你。
好的,现在你已经完成了工作(你已经完成了,不是吗?),让我们看看我的解决方案。
第一步是删除旧的uploadPage标题。不要回头,只管做。
接下来,将这些行放在profilePage标题下方:
<div class="row">
<div class="col m8 s12 offset-m2">
<img th:src="img/uploadedPicture}" width="100" height="100"/>
</div>
<div class="col s12 center red-text" th:text="${error}" th:if="${error}">
Error during upload
</div>
<form th:action="@{/profile}" method="post" enctype="multipart/form-data" class="col m8 s12 offset-m2">
<div class="input-field col s6">
<input type="file" id="file" name="file"/>
</div>
<div class="col s6 center">
<button class="btn indigo waves-effect waves-light" type="submit" name="upload" th:text="#{upload}">Upload
<i class="mdi-content-send right"></i>
</button>
</div>
</form>
</div>
它与旧的uploadPage的内容非常相似。我们只是删除了标题并更改了提交按钮的标签。将相应的翻译添加到资源包中。
英语:
upload=Upload
法语:
Upload=Envoyer
我们还更改了提交按钮的名称为upload。这将帮助我们识别控制器侧的这个动作。
现在,如果我们尝试上传我们的图片,它将重定向我们到旧的上传页面。我们需要在我们的PictureUploadController类的onUpload方法中修复这个问题:
@RequestMapping(value = "/profile", params = {"upload"}, method = RequestMethod.POST)
public String onUpload(@RequestParam MultipartFile file, RedirectAttributes redirectAttrs) throws IOException {
if (file.isEmpty() || !isImage(file)) {
redirectAttrs.addFlashAttribute("error", "Incorrect file. Please upload a picture.");
return "redirect:/profile";
}
Resource picturePath = copyFileToPictures(file);
userProfileSession.setPicturePath(picturePath);
return "redirect:profile";
}
注意,我们更改了处理 POST 请求的 URL。现在它是/profile而不是/upload。当GET和POST请求有相同的 URL 时,表单处理会更简单,这将为我们节省很多麻烦,尤其是在处理异常时。这样,我们就不需要在出错后重定向用户。
我们还删除了模型属性picturePath。由于我们现在有一个在会话中代表我们的用户的 bean,即UserProfileSession,我们决定将其添加到那里。我们在UserProfileSession类中添加了picturePath属性以及相关的 getter 和 setter。
不要忘记注入UserProfileSession类,并将其作为字段在我们的PictureUploadController类中使其可用。
记住,我们会话中的会话 bean 的所有属性必须是可序列化的,与资源不同。因此,我们需要以不同的方式存储它。URL 类似乎是一个很好的选择。它是可序列化的,并且使用UrlResource类从 URL 创建资源很容易:
@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserProfileSession implements Serializable {
private URL picturePath;
public void setPicturePath(Resource picturePath) throws IOException {
this.picturePath = picturePath.getURL();
}
public Resource getPicturePath() {
return picturePath == null ? null : new UrlResource(picturePath);
}
}
我最后要做的就是在一个错误发生后使profileForm作为模型属性可用。这是因为当它被渲染时,profilePage需要它。
总结一下,以下是PictureUploadController类的最终版本:
package masterSpringMvc.profile;
import masterSpringMvc.config.PictureUploadProperties;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLConnection;
import java.util.Locale;
@Controller
public class PictureUploadController {
private final Resource picturesDir;
private final Resource anonymousPicture;
private final MessageSource messageSource;
private final UserProfileSession userProfileSession;
@Autowired
public PictureUploadController(PictureUploadProperties uploadProperties,
MessageSource messageSource,
UserProfileSession userProfileSession) {
picturesDir = uploadProperties.getUploadPath();
anonymousPicture = uploadProperties.getAnonymousPicture();
this.messageSource = messageSource;
this.userProfileSession = userProfileSession;
}
@RequestMapping(value = "/uploadedPicture")
public void getUploadedPicture(HttpServletResponse response) throws IOException {
Resource picturePath = userProfileSession.getPicturePath();
if (picturePath == null) {
picturePath = anonymousPicture;
}
response.setHeader("Content-Type", URLConnection.guessContentTypeFromName(picturePath.getFilename()));
IOUtils.copy(picturePath.getInputStream(), response.getOutputStream());
}
@RequestMapping(value = "/profile", params = {"upload"}, method = RequestMethod.POST)
public String onUpload(@RequestParam MultipartFile file, RedirectAttributes redirectAttrs) throws IOException {
if (file.isEmpty() || !isImage(file)) {
redirectAttrs.addFlashAttribute("error", "Incorrect file. Please upload a picture.");
return "redirect:/profile";
}
Resource picturePath = copyFileToPictures(file);
userProfileSession.setPicturePath(picturePath);
return "redirect:profile";
}
private Resource copyFileToPictures(MultipartFile file) throws IOException {
String fileExtension = getFileExtension(file.getOriginalFilename());
File tempFile = File.createTempFile("pic", fileExtension, picturesDir.getFile());
try (InputStream in = file.getInputStream();
OutputStream out = new FileOutputStream(tempFile)) {
IOUtils.copy(in, out);
}
return new FileSystemResource(tempFile);
}
@ExceptionHandler(IOException.class)
public ModelAndView handleIOException(Locale locale) {
ModelAndView modelAndView = new ModelAndView("profile/profilePage");
modelAndView.addObject("error", messageSource.getMessage("upload.io.exception", null, locale));
modelAndView.addObject("profileForm", userProfileSession.toForm());
return modelAndView;
}
@RequestMapping("uploadError")
public ModelAndView onUploadError(Locale locale) {
ModelAndView modelAndView = new ModelAndView("profile/profilePage");
modelAndView.addObject("error", messageSource.getMessage("upload.file.too.big", null, locale));
modelAndView.addObject("profileForm", userProfileSession.toForm());
return modelAndView;
}
private boolean isImage(MultipartFile file) {
return file.getContentType().startsWith("image");
}
private static String getFileExtension(String name) {
return name.substring(name.lastIndexOf("."));
}
}
因此,现在我们可以转到个人资料页面并上传我们的图片,以及提供个人信息,如下面的截图所示:

现在,让我们在完成个人资料后重定向我们的用户到其搜索。为此,我们需要修改ProfileController类中的saveProfile方法:
@RequestMapping(value = "/profile", params = {"save"}, method = RequestMethod.POST)
public String saveProfile(@Valid ProfileForm profileForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "profile/profilePage";
}
userProfileSession.saveForm(profileForm);
return "redirect:/search/mixed;keywords=" + String.join(",", profileForm.getTastes());
}
现在我们能够从我们的个人资料中搜索推文,我们不再需要之前制作的searchPage或TweetController。只需删除searchPage.html页面和TweetController。
最后,我们可以修改我们的主页,以便如果已经完成了我们的个人资料,它将重定向我们到一个匹配我们喜好的搜索。
让我们在控制器包中创建一个新的控制器。它负责将到达我们网站根目录的用户重定向到他们的个人资料(如果它不完整)或到resultPage(如果他们的喜好可用):
package masterSpringMvc.controller;
import masterSpringMvc.profile.UserProfileSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@Controller
public class HomeController {
private UserProfileSession userProfileSession;
@Autowired
public HomeController(UserProfileSession userProfileSession) {
this.userProfileSession = userProfileSession;
}
@RequestMapping("/")
public String home() {
List<String> tastes = userProfileSession.getTastes();
if (tastes.isEmpty()) {
return "redirect:/profile";
}
return "redirect:/search/mixed;keywords=" + String.join(",", tastes);
}
}
检查点
在本章中,我们添加了两个控制器,PictureUploadController,负责将上传的文件写入磁盘并处理上传错误,以及SearchController,它可以使用矩阵参数从关键词列表中搜索推文。
此控制器然后将搜索委托给一个新的服务,SearchService。
我们删除了旧的TweetController。
我们创建了一个会话 bean,UserProfileSession,用于存储有关我们用户的信息。
最后,我们在WebConfiguration中添加了两项内容。我们添加了我们的 Servlet 容器的错误页面以及矩阵变量的支持。

在资源方面,我们添加了一个代表匿名用户的图片和一个用于处理错误的静态页面。我们将文件上传添加到profilePage,并淘汰了旧的searchPage。

摘要
在本章中,我们讨论了文件上传和错误处理。上传文件实际上并不复杂。然而,一个重大的设计决策是关于上传文件的处理。我们本可以将图像存储在数据库中,但相反,我们选择将其写入磁盘,并在用户的会话中保存每个用户图片的位置。
我们看到了在控制器级别和 servlet 容器级别处理异常的典型方法。有关 Spring MVC 错误处理的更多信息,您可以参考spring.io/blog/2013/11/01/exception-handling-in-spring-mvc博客文章。
我们的应用程序看起来已经相当不错了,但我们不得不编写的代码量非常合理。
请期待下一章,我们将看到 Spring MVC 也是一个构建 REST 应用程序的强大框架。
第五章. 构建 RESTful 应用程序
在本章中,我们将探讨 RESTful 架构的主要原则。然后,借助非常实用的工具,我们将设计一个友好的 API,利用 Jackson 的能力将我们的模型序列化为 JSON。
我们将使用适当的错误代码和 HTTP 动词来记录我们的应用程序,并通过使用 Swagger UI 自动生成我们应用程序的整洁前端。
最后,我们将探讨其他形式的序列化,并更多地了解 Spring MVC 的内容协商机制。
什么是 REST?
REST(表征状态转移)是一种架构风格,它定义了利用 HTTP 协议功能创建可扩展 Web 服务的最佳实践。
一个 RESTful Web 服务应该自然表现出以下属性:
-
客户端-服务器:用户界面与数据存储分离
-
无状态:每个请求都包含足够的信息,使服务器能够在不维护任何状态的情况下操作
-
可缓存:服务器的响应包含足够的信息,使客户端能够就数据存储做出合理的决策
-
统一接口:URI 唯一标识资源,超链接允许 API 被发现
-
分层:API 中的每个资源都提供合理的详细程度
这种架构的优势在于它易于维护和发现。它也具有良好的可扩展性,因为不需要在服务器和客户端之间维护持久连接,这消除了负载均衡或粘性会话的需求。最后,服务效率更高,因为信息布局整齐,易于缓存。
让我们看看如何通过使用理查森成熟度模型逐步设计更好的 API。
理查森成熟度模型
伦纳德·理查森因定义了四个级别而闻名,这些级别按 0 到 3 的顺序排列,描述了 Web API 的“RESTfulness”程度。每个级别都需要对 API 进行额外的工作和投资,但也提供了额外的收益。
第 0 级 – HTTP
第 0 级非常容易达到;你只需通过 HTTP 协议在网络中将你的资源可用。你可以使用最适合你用例的数据表示(XML、JSON 等)。
第 1 级 – 资源
当人们听到 REST 这个术语时,大多数人会想到资源。资源是我们模型中元素的唯一标识符,例如用户或推文。使用 HTTP 时,资源显然与统一资源标识符 URI 相关联,如本例所示:
-
/users包含我们所有用户的列表 -
/user/42包含一个特定的用户 -
/user/42/tweets包含与该特定用户关联的所有推文的列表
也许你的 API 可以通过/user/42/tweet/3允许访问与用户相关的特定推文,或者也许每个推文都是唯一的,在这种情况下,你可能更喜欢/tweet/3。
此级别的目标是通过对多个专用资源进行暴露来处理应用程序的复杂性。
关于服务器可以返回的响应类型没有规则。当您使用/users列出所有资源时,可能只想包含少量信息,而在请求特定资源时提供更多细节。一些 API 甚至允许您在提供之前列出感兴趣的字段。
实际上,您需要根据一个简单的规则来定义您的 API 的形式:最小惊讶原则。提供用户期望的内容,您的 API 就已经处于良好状态。
第 2 级 – HTTP 动词
此级别是关于使用 HTTP 动词来识别对资源的可能操作。这是描述您可以使用 API 做什么的一个非常好的方法,因为 HTTP 动词在开发者中是一个众所周知的标准。
主要动词列表如下:
-
GET:此操作读取特定 URI 上的数据。 -
HEAD:此操作与GET相同,但没有响应体。这对于获取资源的元数据(缓存信息等)很有用。 -
DELETE:此操作用于删除资源。 -
PUT:此操作用于更新或创建资源。 -
POST:此操作用于更新或创建资源。 -
PATCH:此操作用于部分更新资源。 -
OPTIONS:此操作返回服务器在特定资源上支持的方法列表。
大多数允许创建、读取、更新、删除(CRUD)操作的应用程序仅使用三个动词:GET、DELETE和POST。您实现的动词越多,您的 API 就越丰富、语义越强。这有助于第三方通过允许他们输入几个命令并查看结果来与您的服务交互。
OPTIONS和HEAD操作很少见,因为它们在元数据级别工作,通常对任何应用程序都不是至关重要的。
初看之下,PUT和POST操作似乎做的是同一件事。主要区别在于,PUT操作被认为是幂等的,这意味着发送相同的请求多次应该导致服务器状态相同。该规则的含义基本上是PUT操作应该在给定的 URI 上操作,并包含足够的信息以确保请求成功。
例如,客户端可以使用PUT数据在/user/42上,结果将取决于实体在请求之前是否存在,要么是更新要么是创建。
另一方面,当您不确定应该写入哪个 URI 时,应使用POST。您可以在不指定请求 ID 的情况下将POST发送到/users,并期望创建用户。您也可以将POST发送到相同的/users资源,这次在请求实体中指定用户 ID,并期望服务器更新相应的用户。
如您所见,这两种方法都有效。一个常见的用例是使用POST进行创建(因为,大多数情况下,服务器应该负责 ID),并使用PUT来更新已知 ID 的资源。
服务器也可能允许部分修改资源(而不需要客户端发送资源的全部内容)。在这种情况下,它应该对PATCH方法做出响应。
在这个层面上,我也鼓励你在提供响应时使用有意义的 HTTP 状态码。我们将在稍后看到最常见的状态码。
第 3 级 – 超媒体控制
超媒体控制也被称为应用状态引擎的超文本(HATEOAS)。在这可怕的缩写背后是 RESTful 服务最重要的属性:通过使用超文本链接使其可发现。这本质上就是服务器告诉客户端它的选项,使用响应头或响应实体。
例如,在创建资源后使用PUT,服务器应该返回一个包含代码201 CREATED的响应,并发送一个包含创建资源 URI 的Location头。
没有标准定义链接到 API 其他部分的链接应该是什么样子。Spring Data REST,这是一个允许你通过最小配置创建 RESTful 后端的 Spring 项目,通常输出如下:
{
"_links" : {
"people" : {
"href" : "http://localhost:8080/users{?page,size,sort}",
"templated" : true
}
}
}
然后,转到/users:
{
"_links" : {
"self" : {
"href" : "http://localhost:8080/users{?page,size,sort}",
"templated" : true
},
"search" : {
"href" : "http://localhost:8080/users/search"
}
},
"page" : {
"size" : 20,
"totalElements" : 0,
"totalPages" : 0,
"number" : 0
}
}
这让你对可以使用 API 做什么有了很好的了解,不是吗?
API 版本控制
如果第三方客户端使用你的 API,你可以在更新应用程序时考虑对 API 进行版本控制,以避免破坏性更改。
对 API 进行版本控制通常是一个在子域名下提供一组稳定资源的问题。例如,GitLab 维护其 API 的三个版本。它们可以通过https://example/api/v3等访问。像软件中的许多架构决策一样,版本控制是一个权衡。
设计这样的 API 并识别 API 中的破坏性更改需要更多的工作。通常,添加新字段不会像删除或转换 API 实体结果或请求那样有问题。
大多数情况下,你将负责 API 和客户端,从而消除了这种复杂性的需要。
注意
请参阅这篇博客文章,了解更多关于 API 版本控制的深入讨论:
www.troyhunt.com/2014/02/your-api-versioning-is-wrong-which-is.html
有用的 HTTP 状态码
一个好的 RESTful API 的另一个重要方面是合理地使用 HTTP 状态码。HTTP 规范定义了许多标准状态码。它们应该覆盖 API 需要与用户通信的 99%。以下列表包含了最重要的状态码,每个 API 都应该使用,每个开发者都应该了解:
| 状态码 | 含义 | 用途 |
|---|---|---|
| 2xx - 成功 | 这些状态码在一切顺利时使用。 | |
200 |
一切正常 | 请求成功。 |
201 |
资源已创建 | 资源成功创建。响应应包括与创建相关联的位置列表。 |
204 |
没有内容可返回 | 服务器已成功处理请求,但没有内容可返回。 |
| 3xx - 重定向 | 这些代码用于需要客户端进一步操作以完成请求的情况。 | |
301 |
永久移动 | 资源有一个更改的 URI,其新位置在 Location 标头中指示。 |
304 |
资源未修改 | 自上次修改以来,资源没有变化。此响应必须包含日期、ETag 和缓存信息。 |
| 4xx - 客户端错误 | 请求未成功执行是因为客户端的错误。 | |
400 |
错误请求 | 客户端发送的数据无法理解。 |
403 |
禁止 | 请求已理解但未允许。可以添加描述错误的信息。 |
404 |
未找到 | 没有匹配此 URI 的内容。如果不应公开安全信息,则可以使用 403。 |
409 |
冲突 | 请求与另一个修改冲突。响应应包括解决冲突的信息。 |
| 5xx - 服务器错误 | 服务器端发生错误。 | |
500 |
内部服务器错误 | 服务器意外失败处理请求。 |
注意
更详细的信息列表,请参阅 www.restapitutorial.com/httpstatuscodes.html。
客户端是王
我们将允许第三方客户端通过 REST API 获取搜索结果。这些结果将以 JSON 或 XML 格式提供。
我们希望处理形如 /api/search/mixed;keywords=springFramework 的请求。这实际上与我们已制作的搜索表单非常相似,只是请求路径以 api 开头。在这个命名空间中找到的每个 URI 都应返回二进制结果。
让我们在 search.api 包中创建一个新的 SearchApiController 类:
package masterSpringMvc.search.api;
import masterSpringMvc.search.SearchService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.twitter.api.Tweet;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/search")
public class SearchApiController {
private SearchService searchService;
@Autowired
public SearchApiController(SearchService searchService) {
this.searchService = searchService;
}
@RequestMapping(value = "/{searchType}", method = RequestMethod.GET)
public List<Tweet> search(@PathVariable String searchType, @MatrixVariable List<String> keywords) {
return searchService.search(searchType, keywords);
}
}
这与我们的前一个控制器非常相似,但有三个细微的区别:
-
控制器类使用
@RequestMapping注解。这将是我们的基础地址,并将作为此控制器中声明的其他每个映射的前缀。 -
我们不再将请求重定向到视图,而是在搜索方法中返回一个纯对象。
-
控制器使用
@RestController注解而不是@Controller。
RestController 是声明将每个响应作为如果它被 @ResponseBody 注解一样处理的快捷方式。它告诉 Spring 将返回类型序列化为适当的格式,默认为 JSON。
当与 REST API 一起工作时,一个好的做法是始终指定您将响应的方法。一个请求以相同的方式处理 GET 或 POST 方法的情况相当不可能。
如果您访问 http://localhost:8080/api/search/mixed;keywords=springFramework,您应该会得到一个非常大的结果,如下所示:

事实上,Spring 使用 Jackson 自动处理了整个 Tweet 类的所有属性的序列化。
调试 RESTful API
使用您的浏览器,您只能在特定的 API 上执行 GET 请求。好的工具将使您的开发变得更加简单。有很多工具可以测试 RESTful API。我只会列出我使用并喜爱的工具。
JSON 格式化扩展
通常,您只会测试 GET 方法,您的第一个反应可能是将地址复制到浏览器中检查结果。在这种情况下,您可以使用像 Chrome 的 JSON Formatter 或 Firefox 的 JSONView 这样的扩展程序来获取更多内容,而不仅仅是纯文本。
浏览器中的 RESTful 客户端
浏览器是处理 HTTP 请求的自然工具。然而,使用地址栏很少允许您详细测试您的 API。
Postman 是 Chrome 的扩展程序,RESTClient 是其 Firefox 的对应程序。它们都具有类似的功能,例如创建和共享查询集合、修改头部信息以及处理身份验证(基本、摘要和 OAuth)。在撰写本文时,只有 RESTClient 支持 OAuth2。
httpie
httpie 是一个类似于 curl 的命令行工具,但面向 REST 查询。它允许您输入如下命令:
http PUT httpbin.org/put hello=world
它比这个丑陋的版本友好得多:
curl -i -X PUT httpbin.org/put -H Content-Type:application/json -d '{"hello": "world"}'
自定义 JSON 输出
使用我们的工具,我们可以轻松地看到服务器生成的请求。它非常庞大。默认情况下,Spring Boot 使用的 JSON 序列化库 Jackson 将使用 getter 方法可访问的所有内容进行序列化。
我们希望有一种更轻量级的方法,例如:
{
"text": "original text",
"user": "some_dude",
"profileImageUrl": "url",
"lang": "en",
"date": 2015-04-15T20:18:55,
"retweetCount": 42
}
通过向我们的 bean 添加注解,可以最轻松地自定义哪些字段将被序列化。您可以在类级别使用 @JsonIgnoreProperties 注解来忽略一组属性,或者在对希望忽略的属性的 getter 上添加 @JsonIgnore。
在我们的案例中,Tweet 类不是我们自己的。它是 Spring Social Twitter 的一部分,我们没有能力对其进行注解。
直接使用模型类进行序列化很少是一个好选择。这会将您的模型绑定到您的序列化库,而序列化库应该保持为实现细节。
当处理不可修改的代码时,Jackson 提供了两种选项:
-
创建一个专门用于序列化的新类。
-
使用混合类,这些是简单的类,将被链接到您的模型。这些将在您的代码中声明,并可以使用任何 Jackson 注解进行注解。
由于我们只需要对我们的模型字段执行一些简单的转换(很多隐藏和一点重命名),我们可以选择使用混入。
这是一个很好的、非侵入性的方法,可以通过简单的类或接口即时重命名和排除字段。
另一个指定应用程序不同部分使用的字段子集的选项是使用 @JsonView 注解来注释它们。这在本章中不会涉及,但我鼓励你查看这篇优秀的博客文章 spring.io/blog/2014/12/02/latest-jackson-integration-improvements-in-spring。
我们希望控制我们 API 的输出,所以让我们创建一个新的类叫做 LightTweet,它可以由一条推文构建:
package masterSpringMvc.search;
import org.springframework.social.twitter.api.Tweet;
import org.springframework.social.twitter.api.TwitterProfile;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
public class LightTweet {
private String profileImageUrl;
private String user;
private String text;
private LocalDateTime date;
private String lang;
private Integer retweetCount;
public LightTweet(String text) {
this.text = text;
}
public static LightTweet ofTweet(Tweet tweet) {
LightTweet lightTweet = new LightTweet(tweet.getText());
Date createdAt = tweet.getCreatedAt();
if (createdAt != null) {
lightTweet.date = LocalDateTime.ofInstant(createdAt.toInstant(), ZoneId.systemDefault());
}
TwitterProfile tweetUser = tweet.getUser();
if (tweetUser != null) {
lightTweet.user = tweetUser.getName();
lightTweet.profileImageUrl = tweetUser.getProfileImageUrl();
}
lightTweet.lang = tweet.getLanguageCode();
lightTweet.retweetCount = tweet.getRetweetCount();
return lightTweet;
}
// don't forget to generate getters
// They are used by Jackson to serialize objects
}
现在,我们需要让我们的 SearchService 类返回 LightTweets 类而不是推文:
public List<LightTweet> search(String searchType, List<String> keywords) {
List<SearchParameters> searches = keywords.stream()
.map(taste -> createSearchParam(searchType, taste))
.collect(Collectors.toList());
List<LightTweet> results = searches.stream()
.map(params -> twitter.searchOperations().search(params))
.flatMap(searchResults -> searchResults.getTweets().stream())
.map(LightTweet::ofTweet)
.collect(Collectors.toList());
return results;
}
这将影响 SearchApiController 类的返回类型以及 SearchController 类中的 tweets 模型属性。在这两个类中做出必要的修改。
我们还需要更改 resultPage.html 文件的代码,因为一些属性已经改变(我们不再有嵌套的 user 属性):
<ul class="collection">
<li class="collection-item avatar" th:each="tweet : ${tweets}">
<img th:src="img/strong>}" alt="" class="circle"/>
<span class="title" th:text="${tweet.user}">Username</span>
<p th:text="${tweet.text}">Tweet message</p>
</li>
</ul>
我们几乎完成了。如果你重新启动你的应用程序并转到 http://localhost:8080/api/search/mixed;keywords=springFramework,你会看到日期格式不是我们预期的:

这是因为 Jackson 没有内置对 JSR-310 日期的支持。幸运的是,这很容易修复。只需将以下库添加到 build.gradle 文件的依赖项中即可:
compile 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
这确实改变了日期格式,但现在它输出的是一个数组而不是格式化的日期。
要改变这一点,我们需要了解库做了什么。它包括一个名为 JSR-310 模块的新的 Jackson 模块。Jackson 模块是一个扩展点,用于自定义序列化和反序列化。这个模块将在启动时自动由 Spring Boot 在 JacksonAutoConfiguration 类中注册,这将创建一个默认的 Jackson ObjectMapper 方法,支持已知的模块。
我们可以看到,前一个模块为 JSR-310 中定义的所有新类添加了一堆序列化和反序列化器。这将尽可能尝试将每个日期转换为 ISO 格式。请参阅 github.com/FasterXML/jackson-datatype-jsr310。
例如,如果我们仔细看看 LocalDateTimeSerializer,我们可以看到它实际上有两种模式,并且可以通过一个名为 WRITE_DATES_AS_TIMESTAMPS 的序列化功能在这两种模式之间切换。
要定义这个属性,我们需要自定义 Spring 的默认对象映射器。正如我们可以从自动配置中看到的那样,Spring MVC 提供了一个实用类来创建我们可以使用的 ObjectMapper 方法。将以下 bean 添加到你的 WebConfiguration 类中:
@Bean
@Primary
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
ObjectMapper objectMapper = builder.createXmlMapper(false).build();
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
return objectMapper;
}
这次,我们已经完成了,日期格式正确,如你所见:

用户管理 API
我们的网络搜索 API 相当不错,但让我们做一些更有趣的事情。像许多网络应用程序一样,我们需要一个用户管理模块来识别我们的用户。为此,我们将创建一个新的user包。在这个包中,我们将添加一个如下所示的模式类:
package masterSpringMvc.user;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class User {
private String twitterHandle;
private String email;
private LocalDate birthDate;
private List<String> tastes = new ArrayList<>();
// Getters and setters for all fields
}
由于我们目前不想使用数据库,我们将在同一包中创建一个UserRepository类,它由一个简单的Map支持:
package masterSpringMvc.user;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class UserRepository {
private final Map<String, User> userMap = new ConcurrentHashMap<>();
public User save(String email, User user) {
user.setEmail(email);
return userMap.put(email, user);
}
public User save(User user) {
return save(user.getEmail(), user);
}
public User findOne(String email) {
return userMap.get(email);
}
public List<User> findAll() {
return new ArrayList<>(userMap.values());
}
public void delete(String email) {
userMap.remove(email);
}
public boolean exists(String email) {
return userMap.containsKey(email);
}
}
最后,在user.api包中,我们将创建一个非常简单的控制器实现:
package masterSpringMvc.user.api;
import masterSpringMvc.user.User;
import masterSpringMvc.user.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api")
public class UserApiController {
private UserRepository userRepository;
@Autowired
public UserApiController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@RequestMapping(value = "/users", method = RequestMethod.GET)
public List<User> findAll() {
return userRepository.findAll();
}
@RequestMapping(value = "/users", method = RequestMethod.POST)
public User createUser(@RequestBody User user) {
return userRepository.save(user);
}
@RequestMapping(value = "/user/{email}", method = RequestMethod.PUT)
public User updateUser(@PathVariable String email, @RequestBody User user) {
return userRepository.save(email, user);
}
@RequestMapping(value = "/user/{email}", method = RequestMethod.DELETE)
public void deleteUser(@PathVariable String email) {
userRepository.delete(email);
}
}
我们通过使用用户的电子邮件地址作为唯一标识符,使用 RESTful 仓库实现了所有经典的 CRUD 操作。
在这种情况下,你将很快遇到问题,因为 Spring 会删除点号后面的内容。解决方案与我们在第四章中使用的类似,即在 URL 映射的矩阵变量部分支持分号。
在我们已经在WebConfiguration类中定义的configurePathMatch()方法中添加useRegisteredSuffixPatternMatch属性,并将其设置为 false:
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
UrlPathHelper urlPathHelper = new UrlPathHelper();
urlPathHelper.setRemoveSemicolonContent(false);
configurer.setUrlPathHelper(urlPathHelper);
configurer.setUseRegisteredSuffixPatternMatch(true);
}
现在我们已经有了我们的 API,我们可以开始与之交互。
下面是一些使用 httpie 的示例命令:
~ $ http get http://localhost:8080/api/users
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Date: Mon, 20 Apr 2015 00:01:08 GMT
Server: Apache-Coyote/1.1
Transfer-Encoding: chunked
[]
~ $ http post http://localhost:8080/api/users email=geo@springmvc.com birthDate=2011-12-12 tastes:='["spring"]'
HTTP/1.1 200 OK
Content-Length: 0
Date: Mon, 20 Apr 2015 00:02:07 GMT
Server: Apache-Coyote/1.1
~ $ http get http://localhost:8080/api/users
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Date: Mon, 20 Apr 2015 00:02:13 GMT
Server: Apache-Coyote/1.1
Transfer-Encoding: chunked
[
{
"birthDate": "2011-12-12",
"email": "geo@springmvc.com",
"tastes": [
"spring"
],
"twitterHandle": null
}
]
~ $ http delete http://localhost:8080/api/user/geo@springmvc.com
HTTP/1.1 200 OK
Content-Length: 0
Date: Mon, 20 Apr 2015 00:02:42 GMT
Server: Apache-Coyote/1.1
~ $ http get http://localhost:8080/api/users
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Date: Mon, 20 Apr 2015 00:02:46 GMT
Server: Apache-Coyote/1.1
Transfer-Encoding: chunked
[]
这很好,但还不够出色。状态码尚未处理。我们需要更多的 RESTful 来攀登理查森的阶梯。
状态码和异常处理
我们首先想做的事情是正确处理响应状态。默认情况下,Spring 自动处理一些状态:
-
500 服务器错误:这表示在处理请求时发生了异常。 -
405 方法不支持:当你在一个现有的处理程序上使用不正确的方法时出现。 -
404 未找到:当处理程序不存在时出现。 -
400 错误请求:这表示请求体或参数与服务器的期望不匹配。 -
200 正常:对于没有错误处理的任何请求都会抛出。
使用 Spring MVC,有两种方式来返回状态码:
-
从 REST 控制器返回
ResponseEntity类 -
抛出一个将在专用处理程序中被捕获的异常
带有 ResponseEntity 的状态码
HTTP 协议指定,在创建新用户时,我们应该返回一个201 已创建状态。在我们的 API 中,这可以通过POST方法实现。我们还需要在操作实体不存在时抛出一些 404 错误。
Spring MVC 有一个类将 HTTP 状态与响应实体关联。它被称为ResponseEntity。让我们更新我们的UserApiController类来处理错误代码:
package masterSpringMvc.user.api;
import masterSpringMvc.user.User;
import masterSpringMvc.user.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api")
public class UserApiController {
private UserRepository userRepository;
@Autowired
public UserApiController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@RequestMapping(value = "/users", method = RequestMethod.GET)
public List<User> findAll() {
return userRepository.findAll();
}
@RequestMapping(value = "/users", method = RequestMethod.POST)
public ResponseEntity<User> createUser(@RequestBody User user) {
HttpStatus status = HttpStatus.OK;
if (!userRepository.exists(user.getEmail())) {
status = HttpStatus.CREATED;
}
User saved = userRepository.save(user);
return new ResponseEntity<>(saved, status);
}
@RequestMapping(value = "/user/{email}", method = RequestMethod.PUT)
public ResponseEntity<User> updateUser(@PathVariable String email, @RequestBody User user) {
if (!userRepository.exists(user.getEmail())) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
User saved = userRepository.save(email, user);
return new ResponseEntity<>(saved, HttpStatus.CREATED);
}
@RequestMapping(value = "/user/{email}", method = RequestMethod.DELETE)
public ResponseEntity<User> deleteUser(@PathVariable String email) {
if (!userRepository.exists(email)) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
userRepository.delete(email);
return new ResponseEntity<>(HttpStatus.OK);
}
}
你可以看到我们正在向 RESTful 的第一级进化,但其中涉及了很多样板代码。
带有异常的状态码
在我们的 API 中处理错误的另一种方式是抛出异常。使用 Spring MVC 映射异常有两种方式:
-
在类级别使用
@ExceptionHandler,就像我们在第四章中为IOException在上传控制器中做的那样,第四章:文件上传和错误处理 -
使用
@ControllerAdvice来捕获所有控制器或控制器子集抛出的全局异常
这两个选项帮助你做出一些面向业务的决定,并在你的应用程序中定义一系列实践。
为了将这些处理器与 HTTP 状态码关联起来,我们可以在注解方法中注入响应并使用 HttpServletResponse.sendError() 方法,或者只是注解方法为 @ResponseStatus 注解。
我们将定义自己的异常,EntityNotFoundException。我们的业务仓库在用户正在工作的实体找不到时将抛出这个异常。这将有助于减轻 API 代码的负担。
这里是异常的代码。我们可以将它放在一个名为 error 的新包中:
package masterSpringMvc.error;
public class EntityNotFoundException extends Exception {
public EntityNotFoundException(String message) {
super(message);
}
public EntityNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
我们的数据仓库现在将在多个位置抛出异常。我们还将区分保存和更新用户:
package masterSpringMvc.user;
import masterSpringMvc.error.EntityNotFoundException;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class UserRepository {
private final Map<String, User> userMap = new ConcurrentHashMap<>();
public User update(String email, User user) throws EntityNotFoundException {
if (!exists(email)) {
throw new EntityNotFoundException("User " + email + " cannot be found");
}
user.setEmail(email);
return userMap.put(email, user);
}
public User save(User user) {
return userMap.put(user.getEmail(), user);
}
public User findOne(String email) throws EntityNotFoundException {
if (!exists(email)) {
throw new EntityNotFoundException("User " + email + " cannot be found");
}
return userMap.get(email);
}
public List<User> findAll() {
return new ArrayList<>(userMap.values());
}
public void delete(String email) throws EntityNotFoundException {
if (!exists(email)) {
throw new EntityNotFoundException("User " + email + " cannot be found");
}
userMap.remove(email);
}
public boolean exists(String email) {
return userMap.containsKey(email);
}
}
由于我们的控制器不需要处理 404 状态,它变得更加简单。我们现在从我们的控制器方法中抛出 EntityNotFound 异常:
package masterSpringMvc.user.api;
import masterSpringMvc.error.EntityNotFoundException;
import masterSpringMvc.user.User;
import masterSpringMvc.user.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api")
public class UserApiController {
private UserRepository userRepository;
@Autowired
public UserApiController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@RequestMapping(value = "/users", method = RequestMethod.GET)
public List<User> findAll() {
return userRepository.findAll();
}
@RequestMapping(value = "/users", method = RequestMethod.POST)
public ResponseEntity<User> createUser(@RequestBody User user) {
HttpStatus status = HttpStatus.OK;
if (!userRepository.exists(user.getEmail())) {
status = HttpStatus.CREATED;
}
User saved = userRepository.save(user);
return new ResponseEntity<>(saved, status);
}
@RequestMapping(value = "/user/{email}", method = RequestMethod.PUT)
public ResponseEntity<User> updateUser(@PathVariable String email, @RequestBody User user) throws EntityNotFoundException {
User saved = userRepository.update(email, user);
return new ResponseEntity<>(saved, HttpStatus.CREATED);
}
@RequestMapping(value = "/user/{email}", method = RequestMethod.DELETE)
public ResponseEntity<User> deleteUser(@PathVariable String email) throws EntityNotFoundException {
userRepository.delete(email);
return new ResponseEntity<>(HttpStatus.OK);
}
}
如果我们不处理这个异常,Spring 默认会抛出一个 500 错误。为了处理它,我们将在错误包中创建一个小的类,紧挨着我们的 EntityNotFoundException 类。它将被命名为 EntityNotFoundMapper 类,并负责处理这个异常:
package masterSpringMvc.error;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
@ControllerAdvice
public class EntityNotFoundMapper {
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "Entity could not be found")
public void handleNotFound() {
}
}
@ControllerAdvice 注解允许我们通过注解一个 Bean 来向一组控制器添加一些行为。这些控制器建议可以处理异常,也可以使用 @ModelAttribute 声明模型属性或使用 @InitBinder 声明验证策略。
通过我们刚刚编写的代码,我们在一个地方处理了由我们的控制器抛出的所有 EntityNotFoundException 类,并将其与 404 状态关联起来。这样,我们可以抽象这个概念,并确保我们的应用程序在所有控制器中一致地处理它。
在我们的级别,我们不会处理 API 中的超链接。相反,我鼓励你查看 Spring HATEOAS 和 Spring Data REST,它们提供了非常优雅的解决方案,使你的资源更容易被发现。
使用 Swagger 进行文档化
Swagger 是一个非常棒的项目,它允许你在 HTML5 网页内对 API 进行文档化和交互。以下截图展示了 API 文档:

Swagger 以前很大(用 Scala 编写)并且与 Spring 配置相对复杂。从 2.0 版本开始,库已经被重写,一个名为 spring-fox 的非常棒的项目将允许轻松集成。
注意
spring-fox,之前被称为swagger-springmvc,已经存在了三年多,并且仍然是一个非常活跃的项目。
将以下依赖项添加到您的构建文件中:
compile 'io.springfox:springfox-swagger2:2.1.2'
compile 'io.springfox:springfox-swagger-ui:2.1.2'
第一个将提供一个注释来启用您的应用程序中的 Swagger,以及一个 API,使用注释来描述您的资源。然后 Swagger 将生成 API 的 JSON 表示。
第二个是一个 WebJar,它包含通过 Web 客户端消耗生成的 JSON 的静态资源。
现在您需要做的唯一一件事是将@EnableSwagger2注释添加到您的WebConfiguration类中:
@Configuration
@EnableSwagger2
public class WebConfiguration extends WebMvcConfigurerAdapter {
}
我们刚刚添加的swagger-ui.jar文件包含一个位于META-INF/resources的 HTML 文件。
当您访问http://localhost:8080/swagger-ui.html时,它将自动由 Spring Boot 提供服务。
默认情况下,Springfox 将扫描您的整个类路径,并显示您应用程序中声明的所有请求映射。
在我们的案例中,我们只想公开 API:
@Bean
public Docket userApi() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.paths(path -> path.startsWith("/api/"))
.build();
}
Springfox 与一组Dockets 协同工作,您需要在配置类中将它们定义为 bean。它们是 RESTful 资源的逻辑分组。一个应用程序可以有多个这样的分组。
查看文档(springfox.github.io/springfox)以了解所有可用的不同设置。
生成 XML
RESTful API 有时会以不同的媒体类型(JSON、XML 等)返回响应。负责选择正确媒体类型的机制称为 Spring 中的内容协商。
默认情况下,在 Spring MVC 中,ContentNegotiatingViewResolver bean 将负责根据您应用程序中定义的内容协商策略解析正确的内容。
您可以查看ContentNegotiationManagerFactoryBean以了解这些策略如何在 Spring MVC 中应用。
内容类型可以通过以下策略解决:
-
根据客户端发送的
Accept头 -
使用参数例如
?format=json -
使用路径扩展名,例如
/myResource.json或/myResource.xml
您可以通过覆盖WebMvcConfigurerAdapter类的configureContentNegotiation()方法来自定义 Spring 配置中的这些策略。
默认情况下,Spring 将使用Accept头和路径扩展名。
要启用 Spring Boot 的 XML 序列化,您可以将以下依赖项添加到类路径中:
compile 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml'
如果您使用浏览器探索 API 并访问http://localhost:8080/api/users,您将看到以下结果作为 XML:

这是因为您的浏览器通常不会请求 JSON,但 XML 是 HTML 之后的第二选择。这在上面的屏幕截图中有显示:

要获取 JSON,您可以访问http://localhost:8080/api/users.json,或者使用 Postman 或 httpie 发送适当的Accept头。
检查点
在本章中,我们添加了一个搜索ApiController类。因为 Twitter API 返回的推文没有适应我们的使用,我们引入了一个LightTweet类来将它们转换成更友好的格式。
我们还开发了一个用户 API。User类是模型。用户通过UserRepository类存储和检索,UserApiController类公开 HTTP 端点以对用户执行 CRUD 操作。我们还添加了一个通用的异常和一个映射器,将异常关联到 HTTP 状态。
在配置中,我们添加了一个由 Swagger 文档化的 bean,并自定义了我们的 JSR-310 日期的序列化。我们的代码库应该看起来像以下这样:

摘要
在本章中,我们看到了如何使用 Spring MVC 创建 RESTful API。这种后端在性能和维护方面提供了巨大的好处,并且当与 Backbone、Angular JS 或 React.js 等 JavaScript MVC 框架结合使用时,可以产生神奇的效果。
我们看到了如何正确处理错误和异常,并学习了如何利用 HTTP 状态来制作更好的 API。
最后,我们添加了自动文档(Swagger),并增加了生成 XML 和 JSON 的能力。
在下一章中,我们将学习如何确保我们的应用程序的安全,以及如何使用 Twitter API 来注册我们的用户。
第六章。保护您的应用程序
在本章中,我们将学习如何保护我们的 Web 应用程序,以及如何应对现代、分布式 Web 应用程序的安全挑战。
本章将分为五个部分:
-
首先,我们将在几分钟内设置基本的 HTTP 认证
-
然后,我们将为网页设计基于表单的认证,同时保留 RESTful API 的基本认证
-
我们将允许用户通过 Twitter OAuth API 进行注册
-
然后,我们将利用 Spring Session 确保我们的应用程序可以通过分布式会话机制进行扩展
-
最后,我们将配置 Tomcat 通过 SSL 使用安全连接
基本认证
最简单的认证机制是基本认证(en.wikipedia.org/wiki/Basic_access_authentication)。简而言之,没有用户名和密码,我们的页面将不可用。
我们的服务器将通过发送401 未授权HTTP 状态码和生成WWW-Authenticate头来指示我们的资源受到保护。
为了成功通过安全检查,客户端必须发送包含Basic值后跟user:password字符串的 base 64 编码的Authorization头。浏览器窗口将提示用户输入用户名和密码,如果认证成功,将授予他们访问受保护页面的权限。
让我们向我们的依赖项中添加 Spring Security:
compile 'org.springframework.boot:spring-boot-starter-security'
重新启动您的应用程序并导航到应用程序中的任何 URL。您将被提示输入用户名和密码:

如果您未能通过认证,您将看到抛出一个401错误。默认用户名是user。每次应用程序启动时,正确的密码都会随机生成,并将在服务器日志中显示:
Using default security password: 13212bb6-8583-4080-b790-103408c93115
默认情况下,Spring Security 保护了除了一些经典路由(如/css/、/js/、/images/和**/favicon.ico)之外的所有资源。
如果您想配置默认凭据,您可以将以下属性添加到application.properties文件中:
security.user.name=admin
security.user.password=secret
授权用户
在我们的应用程序中只有一个用户不允许进行细粒度安全控制。如果我们想要对用户凭据有更多控制,我们可以在config包中添加以下SecurityConfiguration类:
package masterSpringMvc.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void configureAuth(AuthenticationManagerBuilder auth)
throws Exception {
auth.inMemoryAuthentication()
.withUser("user").password("user").roles("USER").and()
.withUser("admin").password("admin").roles("USER", "ADMIN");
}
}
此代码片段将设置一个包含我们应用程序用户及其角色的内存系统。它将覆盖应用程序属性中先前定义的安全名称和密码。
@EnableGlobalMethodSecurity注解将允许我们注释应用程序的方法和类来定义它们的权限级别。
例如,假设只有我们应用程序的管理员可以访问用户 API。在这种情况下,我们只需在我们的资源上添加@Secured注解,以允许只有 ADMIN 角色访问:
@RestController
@RequestMapping("/api")
@Secured("ROLE_ADMIN")
public class UserApiController {
// ... code omitted
}
我们可以使用 httpie 轻松测试这一点,通过使用-a开关来使用基本认证,以及使用-p=h开关,这将只显示响应头。
让我们尝试使用没有管理员配置文件的用户:
> http GET 'http://localhost:8080/api/users' -a user:user -p=h
HTTP/1.1 403 Forbidden
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
Date: Sat, 23 May 2015 17:40:09 GMT
Expires: 0
Pragma: no-cache
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=2D4761C092EDE9A4DB91FA1CAA16C59B; Path=/; HttpOnly
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
现在,作为管理员:
> http GET 'http://localhost:8080/api/users' -a admin:admin -p=h
HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
Date: Sat, 23 May 2015 17:42:58 GMT
Expires: 0
Pragma: no-cache
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=CE7A9BF903A25A7A8BAD7D4C30E59360; Path=/; HttpOnly
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
你还会注意到 Spring Security 自动添加了一些常见的安全头:
-
Cache Control:这阻止用户缓存受保护资源 -
X-XSS-Protection:这告诉浏览器阻止看起来像 CSS 的内容 -
X-Frame-Options:这禁止我们的网站被嵌入在 IFrame 中 -
X-Content-Type-Options:这阻止浏览器猜测用于伪造 XSS 攻击的恶意资源的 MIME 类型
注意
这些头的完整列表可在docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#headers找到。
授权 URL
注解我们的控制器非常简单,但并不总是最可行的选项。有时,我们只想完全控制我们的授权。
移除@Secured注解;我们将想出更好的方法。
让我们看看通过修改SecurityConfiguration类,Spring Security 将允许我们做什么:
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void configureAuth(AuthenticationManagerBuilder auth)
throws Exception {
auth.inMemoryAuthentication()
.withUser("user").password("user").roles("USER").and()
.withUser("admin").password("admin").roles("USER", "ADMIN");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic()
.and()
.csrf().disable()
.authorizeRequests()
.antMatchers("/login", "/logout").permitAll()
.antMatchers(HttpMethod.GET, "/api/**").hasRole("USER")
.antMatchers(HttpMethod.POST, "/api/**").hasRole("ADMIN")
.antMatchers(HttpMethod.PUT, "/api/**").hasRole("ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
.anyRequest().authenticated();
}
}
在前面的代码示例中,我们使用 Spring Security 的流畅 API 配置了应用程序的安全策略。
此 API 允许我们通过调用与不同安全相关的方法并使用and()方法进行链式调用,全局配置 Spring Security。
我们刚才定义的是基本认证,没有 CSRF 保护。对/login和/logout的请求将允许所有用户。API 上的GET请求仅允许具有USER角色的用户,而POST、PUT和DELETE请求仅允许具有 ADMIN 角色的用户访问。最后,其他所有请求都需要任何角色的认证。
CSRF 代表跨站请求伪造,指的是恶意网站在其网站上显示表单并在你的网站上提交表单数据的一种攻击。如果你的网站用户未注销,POST请求将保留用户 cookies,因此会被授权。
CSRF 保护将生成短生命周期的令牌,这些令牌将随表单数据一起发布。我们将在下一节中看到如何正确启用它;现在,让我们先禁用它。有关更多详细信息,请参阅docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#csrf。
注意
要了解更多关于授权请求 API 的信息,请查看docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#authorize-requests。
Thymeleaf 安全标签
有时,您需要显示来自认证层的数据,例如用户的名称和角色,或者根据用户的权限隐藏和显示网页的一部分。thymeleaf-extras-springsecurity模块将允许我们这样做。
在您的build.gradle文件中添加以下依赖项:
compile 'org.thymeleaf.extras:thymeleaf-extras-springsecurity3'
使用这个库,我们可以在layout/default.html中的导航栏下方添加一个小块来显示登录用户:
<!DOCTYPE html>
<html
>
<head>
<!-- content trimmed -->
</head>
<body>
<!-- content trimmed -->
<nav>
<div class="nav-wrapper indigo">
<ul class="right">
<!-- content trimmed -->
</ul>
</div>
</nav>
<div>
You are logged as <b sec:authentication="name" /> with roles <span sec:authentication="authorities" />
-
<form th:action="@{/logout}" method="post" style="display: inline-block">
<input type="submit" value="Sign Out" />
</form>
<hr/>
</div>
<section layout:fragment="content">
<p>Page content goes here</p>
</section>
<!-- content trimmed -->
</body>
</html>
注意 HTML 声明中的新命名空间和sec:authentication属性。它允许访问代表当前登录用户的org.springframework.security.core.Authentication对象的属性,如下面的截图所示:

不要点击登出链接,因为它与基本身份验证不兼容。我们将在下一部分让它工作。
lib标签还有一些其他标签,例如用于检查用户授权的标签:
<div sec:authorize="hasRole('ROLE_ADMIN')">
You are an administrator
</div>
注意
请参阅github.com/thymeleaf/thymeleaf-extras-springsecurity上的文档,了解更多关于该库的信息。
登录表单
基本身份验证对我们来说的 RESTful API 很好,但我们更希望我们的团队能够精心设计一个登录页面,以改善网络体验。
Spring Security 允许我们定义我们需要的WebSecurityConfigurerAdapter类。我们将把SecurityConfiguration类分成两部分:
-
ApiSecurityConfiguration:这将首先进行配置。这将使用基本身份验证来保护 RESTful 端点。 -
WebSecurityConfiguration:这将配置我们应用程序其余部分的登录表单。
您可以删除或重命名SecurityConfiguration并创建ApiSecurityConfiguration:
@Configuration
@Order(1)
public class ApiSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void configureAuth(AuthenticationManagerBuilder auth)
throws Exception {
auth.inMemoryAuthentication()
.withUser("user").password("user").roles("USER").and()
.withUser("admin").password("admin").roles("USER", "ADMIN");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/api/**")
.httpBasic().and()
.csrf().disable()
.authorizeRequests()
.antMatchers(HttpMethod.GET).hasRole("USER")
.antMatchers(HttpMethod.POST).hasRole("ADMIN")
.antMatchers(HttpMethod.PUT).hasRole("ADMIN")
.antMatchers(HttpMethod.DELETE).hasRole("ADMIN")
.anyRequest().authenticated();
}
}
注意@Order(1)注解,它将确保此配置在另一个配置之前执行。然后,创建一个名为WebSecurityConfiguration的第二个网络配置:
package masterSpringMvc.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.defaultSuccessUrl("/profile")
.and()
.logout().logoutSuccessUrl("/login")
.and()
.authorizeRequests()
.antMatchers("/webjars/**", "/login").permitAll()
.anyRequest().authenticated();
}
}
这段代码的结果是,匹配/api/**的所有内容都将使用基本身份验证进行保护,没有 CSRF 保护。然后,将加载第二个配置。它将保护其他所有内容。应用程序的这一部分的所有内容都需要客户端进行认证,除了 WebJars 和登录页面的请求(这将避免重定向循环)。
如果未经认证的用户尝试访问受保护的资源,他们将被自动重定向到登录页面。
默认情况下,登录 URL 是GET /login。默认登录将通过一个包含三个值的POST /login请求进行提交:用户名(username)、密码(password)和 CSRF 令牌(_csrf)。如果登录失败,用户将被重定向到/login?error。默认登出页面是一个带有 CSRF 令牌的POST /logout请求。
现在,如果您尝试在应用程序中导航,此表单将自动生成!
如果您之前已经登录,请关闭浏览器;这将清除会话。

我们现在可以登录和注销我们的应用程序了!
这很棒,但我们只需付出很少的努力就可以做得更好。首先,我们将在 WebSecurityConfiguration 类中定义 /login 的登录页面:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login") // <= custom login page
.defaultSuccessUrl("/profile")
// the rest of the configuration stays the same
}
这将允许我们创建自己的登录页面。为此,我们需要一个非常简单的控制器来处理 GET login 请求。您可以在 authentication 包中创建一个:
package masterSpringMvc.authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class LoginController {
@RequestMapping("/login")
public String authenticate() {
return "login";
}
}
这将触发显示位于模板目录中的 login.html 页面。让我们创建它:
<!DOCTYPE HTML>
<html
layout:decorator="layout/default">
<head>
<title>Login</title>
</head>
<body>
<div class="section no-pad-bot" layout:fragment="content">
<div class="container">
<h2 class="header center orange-text">Login</h2>
<div class="row">
<div id="errorMessage" class="card-panel red lighten-2" th:if="${param.error}">
<span class="card-title">Invalid user name or password</span>
</div>
<form class="col s12" action="/login" method="post">
<div class="row">
<div class="input-field col s12">
<input id="username" name="username" type="text" class="validate"/>
<label for="username">Username</label>
</div>
</div>
<div class="row">
<div class="input-field col s12">
<input id="password" name="password" type="password" class="validate"/>
<label for="password">Password</label>
</div>
</div>
<div class="row center">
<button class="btn waves-effect waves-light" type="submit" name="action">Submit
<i class="mdi-content-send right"></i>
</button>
</div>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
</form>
</div>
</div>
</div>
</body>
</html>
注意我们处理了错误消息,并发布了 CSRF 令牌。我们还使用了默认的用户名和密码输入名称,但如果需要,这些是可配置的。结果已经看起来好多了!

你可以立即看到,Spring Security 默认将匿名凭证分配给所有未认证用户。
我们不应该向匿名用户显示注销按钮,因此我们可以将相应的 HTML 部分包裹在 sec:authorize="isAuthenticated()" 中,仅对认证用户显示,如下所示:
<div sec:authorize="isAuthenticated()">
You are logged as <b sec:authentication="name"/> with roles <span sec:authentication="authorities"/>
-
<form th:action="@{/logout}" method="post" style="display: inline-block">
<input type="submit" value="Sign Out"/>
</form>
<hr/>
</div>
Twitter 认证
我们的应用程序与 Twitter 强烈集成,因此允许通过 Twitter 进行认证似乎是合乎逻辑的。
在继续之前,请确保您已在 Twitter 上启用了您的应用的 Twitter 登录(apps.twitter.com):

设置社交认证
Spring social 通过 OAuth 提供商(如 Twitter)通过登录/注册场景启用认证。它将拦截 /signin/twitter 上的 POST 请求。如果用户不为 UsersConnectionRepository 接口所知,将调用 signup 端点。这将允许我们采取必要的措施在我们的系统中注册用户,并可能要求他们提供额外的详细信息。
让我们开始工作。我们首先需要做的是将 signin/** 和 /signup URL 添加为公开资源。让我们修改我们的 WebSecurityConfiguration 类,更改 permitAll 行:
.antMatchers("/webjars/**", "/login", "/signin/**", "/signup").permitAll()
要启用登录/注册场景,我们还需要一个 SignInAdapter 接口,一个简单的监听器,当已知用户再次登录时将被调用。
我们可以在 LoginController 旁边创建一个 AuthenticatingSignInAdapter 类:
package masterSpringMvc.authentication;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.social.connect.Connection;
import org.springframework.social.connect.UserProfile;
import org.springframework.social.connect.web.SignInAdapter;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.NativeWebRequest;
@Component
public class AuthenticatingSignInAdapter implements SignInAdapter {
public static void authenticate(Connection<?> connection) {
UserProfile userProfile = connection.fetchUserProfile();
String username = userProfile.getUsername();
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
System.out.println(String.format("User %s %s connected.", userProfile.getFirstName(), userProfile.getLastName()));
}
@Override
public String signIn(String userId, Connection<?> connection, NativeWebRequest request) {
authenticate(connection);
return null;
}
}
如您所见,此处理程序在允许用户使用 Spring Security 进行认证的完美时机被调用。我们稍后会回到这一点。现在,我们需要在同一个包中定义我们的 SignupController 类,负责首次访问的用户:
package masterSpringMvc.authentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.UsersConnectionRepository;
import org.springframework.social.connect.web.ProviderSignInUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.WebRequest;
@Controller
public class SignupController {
private final ProviderSignInUtils signInUtils;
@Autowired
public SignupController(ConnectionFactoryLocator connectionFactoryLocator, UsersConnectionRepository connectionRepository) {
signInUtils = new ProviderSignInUtils(connectionFactoryLocator, connectionRepository);
}
@RequestMapping(value = "/signup")
public String signup(WebRequest request) {
Connection<?> connection = signInUtils.getConnectionFromSession(request);
if (connection != null) {
AuthenticatingSignInAdapter.authenticate(connection);
signInUtils.doPostSignUp(connection.getDisplayName(), request);
}
return "redirect:/profile";
}
}
首先,这个控制器从会话中检索当前连接。然后,它通过之前相同的方法验证用户。最后,它将触发doPostSignUp事件,这将允许 Spring Social 存储与我们用户相关的信息在之前提到的UsersConnectionRepository接口中。
我们最后需要做的是在我们的登录页面上添加一个胜利的“使用 Twitter 登录”按钮,位于之前的表单下方:
<form th:action="@{/signin/twitter}" method="POST" class="center">
<div class="row">
<button class="btn indigo" name="twitterSignin" type="submit">Connect with Twitter
<i class="mdi-social-group-add left"></i>
</button>
</div>
</form>

当用户点击连接到 Twitter按钮时,他们将被重定向到一个 Twitter 登录页面:

说明
代码不多,但理解所有部分有点棘手。了解正在发生什么的第一个步骤是查看 Spring Boot 的SocialWebAutoConfiguration类。
在这个类中声明的SocialAutoConfigurationAdapter类包含以下 bean:
@Bean
@ConditionalOnBean(SignInAdapter.class)
@ConditionalOnMissingBean(ProviderSignInController.class)
public ProviderSignInController signInController(
ConnectionFactoryLocator factoryLocator,
UsersConnectionRepository usersRepository, SignInAdapter signInAdapter) {
ProviderSignInController controller = new ProviderSignInController(
factoryLocator, usersRepository, signInAdapter);
if (!CollectionUtils.isEmpty(this.signInInterceptors)) {
controller.setSignInInterceptors(this.signInInterceptors);
}
return controller;
}
如果在我们的配置中检测到一个ProviderSignInController类,ProviderSignInController类将自动设置。这个控制器是登录过程的基础。看看它做了什么(我将只总结重要部分):
-
它将处理来自我们连接按钮的
POST /signin/{providerId} -
它将用户重定向到我们身份提供者的适当登录 URL
-
它将通过身份提供者的
GET /signin/{providerId}通知 OAuth 令牌 -
然后,它将处理登录
-
如果在
UsersConnectionRepository接口中找不到用户,它将使用SessionStrategy接口来存储挂起的登录请求,然后重定向到signupUrl页面 -
如果找到用户,将调用
SignInAdapter接口,并将用户重定向到postSignupUrl页面
这个身份验证的两个重要组件是负责从某种存储中存储和检索用户的UsersConnectionRepository接口,以及将用户连接临时存储以便可以从SignupController类中检索的SessionStrategy接口。
默认情况下,Spring Boot 为每个认证提供者创建一个InMemoryUsersConnectionRepository接口,这意味着我们的用户连接数据将存储在内存中。如果我们重启服务器,用户将变得未知,并将再次经历注册过程。
ProviderSignInController类默认使用HttpSessionSessionStrategy,这将把连接存储在 HTTP 会话中。我们在SignupController类中使用的ProviderSignInUtils类也默认使用这种策略。如果我们把我们的应用程序部署在多个服务器上,这可能会成问题,因为会话可能不会在每个服务器上可用。
通过为ProviderSignInController和ProviderSignInUtils类提供自定义的SessionStrategy接口,将数据存储在 HTTP 会话之外,可以很容易地覆盖这些默认设置。
同样,我们可以通过提供UsersConnectionRepository接口的另一个实现来为我们的用户连接数据使用另一种存储方式。
Spring Social 提供了一个JdbcUsersConnectionRepository接口,它将自动将认证用户保存到数据库中的UserConnection表中。本书不会对此进行详细说明,但您应该能够通过添加以下 bean 到您的配置中轻松配置它:
@Bean
@Primary
public UsersConnectionRepository getUsersConnectionRepository(
DataSource dataSource, ConnectionFactoryLocator connectionFactoryLocator) {
return new JdbcUsersConnectionRepository(
dataSource, connectionFactoryLocator, Encryptors.noOpText());
}
注意
欲了解更多详情,请查看我的博客上的这篇文章 geowarin.github.io/spring/2015/08/02/social-login-with-spring.html。
分布式会话
正如我们在前一节中看到的,Spring Social 在几个时刻会将东西存储在 HTTP 会话中。我们的用户配置文件也存储在会话中。这是一种将东西保持在内存中的经典方法,只要用户在导航网站。
然而,如果我们想要扩展应用程序并将负载分配到多个后端服务器,这可能会变得麻烦。我们现在已经进入了云时代,第八章,优化您的请求将介绍如何将我们的应用程序部署到云上。
为了使我们的会话在分布式环境中工作,我们有几种选择:
-
我们可以使用粘性会话。这将确保特定用户始终被重定向到同一服务器并保持其会话。这需要额外的部署配置,并且不是一个特别优雅的方法。
-
重构我们的代码,将数据存入数据库而不是会话中。如果我们将其与客户端每次请求发送的 cookie 或 token 关联,就可以从数据库中加载用户数据。
-
使用 Spring Session 项目,可以透明地使用如 Redis 这样的分布式数据库作为底层会话提供者。
在本章中,我们将看到如何设置第三种方法。它设置起来非常简单,并且提供了令人惊叹的好处,即可以关闭它而不会影响我们应用程序的功能。
我们需要做的第一件事是安装 Redis。要在 Mac 上安装它,请使用brew命令:
brew install redis
对于其他平台,请遵循redis.io/download上的说明。
您可以使用以下命令启动服务器:
redis-server
将以下依赖项添加到您的build.gradle文件中:
compile 'org.springframework.boot:spring-boot-starter-redis'
compile 'org.springframework.session:spring-session:1.0.1.RELEASE'
在application.properties旁边创建一个新的配置文件,命名为application-redis.properties:
spring.redis.host=localhost
spring.redis.port=6379
Spring Boot 提供了一种方便的方法来将配置文件与配置文件关联。在这种情况下,application-redis.properties文件只有在 Redis 配置文件激活时才会被加载。
然后,在config包中创建一个RedisConfig类:
package masterSpringMvc.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@Configuration
@Profile("redis")
@EnableRedisHttpSession
public class RedisConfig {
}
如你所见,此配置只有在redis配置文件开启时才会生效。
我们完成了!现在我们可以使用以下标志启动我们的应用程序:
-Dspring.profiles.active=redis
你也可以使用gradlew build生成 JAR 文件,并使用以下命令启动它:
java -Dserver.port=$PORT -Dspring.profiles.active=redis -jar app.jar
或者,你可以在 Bash 中使用 Gradle 启动它,如下所示:
SPRING_PROFILES_ACTIVE=redis ./gradlew bootRun
你也可以简单地将其设置为 IDE 运行配置中的 JVM 选项。
就这样!你现在有一个服务器,用于存储登录用户的详细信息。这意味着我们可以扩展并拥有多个服务器来处理我们的网络资源,而用户不会注意到。而且我们不需要在我们的端上编写任何代码。
这也意味着即使你重启服务器,你也会保持会话。
为了查看它是否工作,使用redis-cli命令连接到 Redis。一开始,它不会包含任何键:
> redis-cli
127.0.0.1:6379> KEYS *
(empty list or set)
导航到你的应用并开始将东西放入会话中:
127.0.0.1:6379> KEYS *
1) "spring:session:expirations:1432487760000"
2) "spring:session:sessions:1768a55b-081a-4673-8535-7449e5729af5"
127.0.0.1:6379> HKEYS spring:session:sessions:1768a55b-081a-4673-8535-7449e5729af5
1) "sessionAttr:SPRING_SECURITY_CONTEXT"
2) "sessionAttr:org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN"
3) "lastAccessedTime"
4) "maxInactiveInterval"
5) "creationTime"
注意
你可以在redis.io/commands上查看可用的命令列表。
SSL
安全套接字层(SSL)是一种安全协议,其中数据通过证书加密并发送到一个受信任的方。在本部分,我将向你展示使用 Spring Boot 创建安全连接的不同方法。完成这些步骤不是启动下一章的强制性要求。它们包括为了完整性,如果你急于部署应用程序到云中,可以自由跳过它们。
在第九章,“将你的 Web 应用程序部署到云中”,我们将看到大多数云平台已经处理了 SSL,所以我们不需要在我们的端上配置它。
生成自签名证书
通常,X.509 证书由证书颁发机构提供。他们通常为你提供服务收费,所以为了测试目的,我们可以创建我们自己的自签名密钥库文件。
JDK 附带一个名为 keytool 的二进制文件,用于管理证书。使用它,你可以创建密钥库并将证书导入到现有的密钥库中。你可以在项目根目录内执行以下命令来创建一个:
$ keytool -genkey -alias masterspringmvc -keyalg RSA -keystore src/main/resources/tomcat.keystore
Enter keystore password: password
Re-enter new password: password
What is your first and last name?
[Unknown]: Master Spring MVC
What is the name of your organizational unit?
[Unknown]: Packt
What is the name of your organization?
[Unknown]: Packt
What is the name of your City or Locality?
[Unknown]: Paris
What is the name of your State or Province?
[Unknown]: France
What is the two-letter country code for this unit?
[Unknown]: FR
Is CN=Master Spring MVC, OU=Packt, O=Packt, L=Paris, ST=France, C=FR correct?
[no]: yes
Enter key password for <masterspringmvc>
(RETURN if same as keystore password): password2
Re-enter new password: password2
这将生成一个名为masterspringmvc的密钥库,使用 RSA 算法,并将其存储在src/main/resources目录下的密钥库中。
小贴士
不要将密钥库推送到你的仓库。它可能被暴力破解,这将使你网站的安保失效。你还应该使用强随机生成的密码来生成密钥库。
简单方式
如果你只关心有一个安全的 https 通道而没有 http 通道,那就简单得不能再简单了:
server.port = 8443
server.ssl.key-store = classpath:tomcat.keystore
server.ssl.key-store-password = password
server.ssl.key-password = password2
小贴士
不要将你的密码推送到你的仓库。使用${}符号来导入环境变量。
双向方式
如果你想在你的应用程序中同时拥有 http 和 https 通道,你应该在你的应用程序中添加此类配置:
@Configuration
public class SslConfig {
@Bean
public EmbeddedServletContainerFactory servletContainer() throws IOException {
TomcatEmbeddedServletContainerFactory tomcat = new TomcatEmbeddedServletContainerFactory();
tomcat.addAdditionalTomcatConnectors(createSslConnector());
return tomcat;
}
private Connector createSslConnector() throws IOException {
Connector connector = new Connector(Http11NioProtocol.class.getName());
Http11NioProtocol protocol =
(Http11NioProtocol) connector.getProtocolHandler();
connector.setPort(8443);
connector.setSecure(true);
connector.setScheme("https");
protocol.setSSLEnabled(true);
protocol.setKeyAlias("masterspringmvc");
protocol.setKeystorePass("password");
protocol.setKeyPass("password2");
protocol.setKeystoreFile(new ClassPathResource("tomcat.keystore").getFile().getAbsolutePath());
protocol.setSslProtocol("TLS");
return connector;
}
}
这将加载先前生成的 keystore,在端口 8080 之外,在端口 8443 上创建一个额外的通道。
您可以使用 Spring Security 通过以下配置自动将连接从http重定向到https:
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.requiresChannel().anyRequest().requiresSecure()
.and()
/* rest of the configuration */;
}
}
在受保护的服务器后面
使用 SSL 保护您的应用程序最方便的方式通常是将其放在一个启用了 SSL 的 Web 服务器后面,如 Apache 或 CloudFlare。这些服务器通常会使用既定的头信息来指示连接之前已经通过 SSL 发起。
如果您在application.properties文件中告诉 Spring 正确的头信息,Spring Boot 可以理解此协议:
server.tomcat.remote_ip_header=x-forwarded-for
server.tomcat.protocol_header=x-forwarded-proto
注意
请参阅以下文档以获取更多详细信息:docs.spring.io/spring-boot/docs/current/reference/html/howto-embedded-servlet-containers.html#howto-use-tomcat-behind-a-proxy-server.
检查点
在本章中,我们添加了三段配置:ApiSecurityConfiguration,它配置我们的 REST API 使用基本 HTTP 认证;WebSecurityConfiguration,它为我们的网络用户设置登录表单,用户可以使用账户或 Twitter 登录;以及RedisConfig,它允许我们的会话在 Redis 服务器上存储和检索。
在认证包中,我们添加了一个LoginController类,它将重定向到我们的登录页面,一个SignupController类,它将在用户第一次使用 Twitter 注册时被调用,以及一个AuthenticatingSignInAdapater类,它将在每次使用 Twitter 登录时被调用:

摘要
使用 Spring 保护我们的 Web 应用程序非常简单。可能性是无限的,并且高级配置,如社交登录,都在您的指尖。分发会话和扩展也只需几分钟。
在下一章中,我们将了解如何测试我们的应用程序并确保它不会退化。
第七章。别把希望寄托在运气上——单元测试和验收测试
在本章中,我们将了解为什么以及如何对我们的应用程序进行测试。我们将了解单元测试和验收测试之间的区别,并学习如何进行这两种测试。
本章分为两部分。在第一部分,我们将学习不同的测试方法的同时,用 Java 编写测试。在第二部分,这部分较短,我们将用 Groovy 编写完全相同的测试,并看看我们如何利用这种出色的语言提高代码的可读性。
如果你完成本章的所有内容,你将面临双重测试,所以请随意保留对你来说最易读的测试。
为什么我应该测试我的代码?
在 Java 世界中工作使许多开发者意识到了测试的重要性。一套好的测试可以尽早捕捉回归,并使我们发布产品时更有信心。
现在很多人已经熟悉了持续集成(www.thoughtworks.com/continuous-integration)的概念。这是一种实践,其中服务器负责在源代码控制系统上每次更改时构建应用程序。
构建应该尽可能快,并且能够自我测试。这种实践的主要思想是获得快速的反馈循环;你应该在系统中的某个部分出错时立即获得有关出错详情。
你为什么要关心?毕竟,测试你的应用程序是一种额外的成本;设计和维护测试所花费的时间必然会占用一些开发时间。
实际上,发现的错误越晚,成本越高。如果你这么想,即使是由你的 QA 团队发现的错误,其成本也开始超过你自己发现的错误。它迫使你回到编写代码时的上下文:我为什么要写这一行?那个函数的潜在业务规则是什么?
如果你早期编写测试,并且能够在几秒钟内启动它们,那么解决代码中潜在错误的成本肯定会更低。
测试的另一个好处是它们充当了代码的活文档。虽然编写广泛的文档,甚至代码注释,可能会证明是无效的,因为它们很容易过时,但养成编写良好测试以限制情况或意外行为的习惯将作为未来的安全网。
这行代码是做什么用的?你是否曾发现自己提出过这类问题?好吧,如果你有一套好的单元测试,你只需移除它并看看什么会出错!测试给了我们对代码和重构能力的空前信心。软件非常脆弱。如果你不再关心,它将慢慢腐烂并死亡。
要负责任——不要让你的代码死亡!
我应该如何测试我的代码?
我们可以在软件上执行不同类型的测试,例如安全测试、性能测试等。作为开发者,我们将专注于我们可以自动化并且有助于改进我们代码的测试。
测试分为两大类:单元测试和验收测试。测试金字塔(martinfowler.com/bliki/TestPyramid.html)显示了这些测试应该以何种比例编写:

在金字塔的底部,是单元测试(启动快且相对容易维护),在顶部是 UI 测试(成本更高且执行速度更慢)。集成测试位于中间:它们可以被视为大型单元测试,具有单元之间的复杂交互。
金字塔的理念是提醒你将重点放在你影响最大和获得最佳反馈循环的地方。
测试驱动开发
许多开发者养成了健康的习惯,即测试驱动开发(TDD)。这种做法,源自极限编程(XP),是将每个开发阶段拆分成小步骤,并为每个步骤编写一个失败的测试。你进行必要的修改,以便测试再次通过(测试为绿色)。只要测试保持绿色,你就可以重构代码。以下图示说明了 TDD 周期:

你可以迭代,直到功能完成,具有非常短的反馈循环,没有回归的保险,以及保证你写的所有代码从一开始就被测试。
TDD 受到了一些批评。其中最有趣的是这些:
-
编写测试所需的时间比实际实现的时间要多
-
这可能导致设计不良的应用程序
事实上,成为一名优秀的 TDD 实践者需要时间。一旦你掌握了应该测试什么的感觉,并且足够熟悉你的工具,你将不会浪费太多时间。
使用 TDD(或任何其他方法)构建具有适当设计的应用程序也需要经验丰富的开发者。如果你陷入“小步骤”的咒语而忘记了看大局,设计不良可能是 TDD 的副作用。诚然,TDD 不会神奇地导致优秀应用程序的设计,所以请小心,并在完成每个功能后记得退一步思考。
从本书开始,我们的代码中只有一个自动生成的单元测试。这是不好的!我们没有遵循良好的实践。本章就是为了解决这个问题而存在的。
单元测试
我们可以编写的较低级别的测试被称为单元测试。它们应该测试一小部分代码,因此有“单元”这一概念。你如何定义一个单元取决于你;它可以是类或是一组紧密相关的类。定义这一概念将决定什么将被模拟(用假对象替换)。你打算用轻量级替代品替换数据库吗?你打算替换与外部服务的交互吗?你打算模拟与测试上下文无关的行为不相关的紧密相关对象吗?
我的建议是保持平衡的方法。保持你的测试干净和快速,其他一切都会随之而来。
我很少完全模拟数据层。我倾向于使用嵌入式数据库进行测试。它们提供了一种在测试时轻松加载数据的方法。
通常,我总是出于两个原因模拟与外部服务的协作:
-
测试的速度以及在没有连接到网络的情况下运行测试的可能性
-
为了能够在与这些服务通信时测试错误情况
此外,模拟和存根之间存在微妙的区别。我们将尝试使用这两种方法来了解它们之间的关系。
适合工作的正确工具
测试新手面临的第一道障碍是缺乏编写相关和可维护测试的良好工具和库的知识。
我将在这里列出一些。这个列表绝对不是详尽的,但它包含了我们将要使用且与 Spring 兼容的工具:
| JUnit | 最广泛采用的 Java 测试运行器。默认由所有构建工具启动。 |
|---|---|
| AssertJ | 一个流畅的断言库。它比 Hamcrest 更容易使用。 |
| Mockito | 一个易于使用的模拟框架。 |
| DbUnit | 用于使用 XML 数据集模拟和断言你的数据库内容。 |
| Spock | 一个优雅的 Groovy DSL,用于以行为驱动开发(BDD)风格(Given/When/Then)编写测试。 |
Groovy 在我的测试工具集中有特殊的位置。即使你还没有准备好将 Groovy 代码投入生产,你也可以轻松地在测试中使用该语言的便利性。使用 Gradle,这非常容易做到,但我们将稍后看到。
验收测试
在 Web 应用程序的上下文中,“验收测试”通常指的是浏览器内、端到端测试。在 Java 世界中,Selenium 显然是最可靠和成熟的库之一。
在 JavaScript 世界中,我们可以找到其他替代品,例如 PhantomJS 或 Protractor。PhantomJS 在我们的案例中非常相关,因为有一个 WebDriver 可以在无头浏览器中运行 Selenium 测试,这将提高启动时间,并且不需要模拟 X 服务器或启动单独的 Selenium 服务器:
| Selenium 2 | 这提供了用于自动化测试的浏览器驱动程序。 |
|---|---|
| PhantomJS | 一个无头浏览器(没有 GUI)。可能是最快的浏览器。 |
| FluentLenium | 一个用于引导 Selenium 测试的流畅库。 |
| Geb | 一个用于执行 Selenium 测试的 Groovy 库。 |
我们的第一单元测试
现在是时候编写我们的第一个单元测试了。
我们将专注于编写控制器级别的测试,因为我们几乎没有业务代码或服务。编写 Spring MVC 测试的关键是我们类路径中的org.springframework.boot:spring-boot-starter-test依赖项。它将添加一些非常实用的库,例如这些:
-
hamcrest:这是 JUnit 的断言库 -
mockito:这是一个模拟库 -
spring-test:这是 Spring 测试库
我们将测试当用户还没有创建他们的个人资料时,重定向到个人资料页面的情况。
我们已经有一个自动生成的测试叫做MasterSpringMvc4ApplicationTests。这是可以用 Spring 测试框架编写的最基本类型的测试:它什么也不做,如果上下文无法加载,就会崩溃:
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = MasterSpringMvc4Application.class)
@WebAppConfiguration
public class MasterSpringMvc4ApplicationTests {
@Test
public void contextLoads() {
}
}
我们可以删除这个测试,创建一个新的测试来确保没有个人资料的用户新建默认重定向到个人资料页面。实际上,这将测试HomeController类的代码,所以让我们称它为HomeControllerTest类,并将其放在与HomeController相同的包中,在src/test/java。所有 IDE 都有从类创建 JUnit 测试用例的快捷方式。现在就找出如何使用你的 IDE 来做这件事吧!
这里是测试:
package masterSpringMvc.controller;
import masterSpringMvc.MasterSpringMvcApplication;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = MasterSpringMvcApplication.class)
@WebAppConfiguration
public class HomeControllerTest {
@Autowired
private WebApplicationContext wac;
private MockMvc mockMvc;
@Before
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
}
@Test
public void should_redirect_to_profile() throws Exception {
this.mockMvc.perform(get("/"))
.andDo(print())
.andExpect(status().isFound())
.andExpect(redirectedUrl("/profile"));
}
}
我们使用MockMvc来模拟与 Spring 控制器的交互,而不需要 Servlet 容器的实际开销。
我们还使用了一些 Spring 提供的匹配器来断言我们的结果。它们实际上实现了 Hamcrest 匹配器。
.andDo(print())语句将为测试场景的请求和响应生成整洁的调试输出。如果你觉得它太冗长,可以将其注释掉。
就这些了!一开始语法有点棘手,但一个具有良好补全功能的 IDE 将能够帮助你。
现在我们想测试如果用户已经填写了他们的个人资料测试部分,我们能否将他们重定向到正确的搜索。为此,我们需要使用MockHttpSession类存根会话:
import org.springframework.mock.web.MockHttpSession;
import masterSpringMvc.profile.UserProfileSession;
// put this test below the other one
@Test
public void should_redirect_to_tastes() throws Exception {
MockHttpSession session = new MockHttpSession();
UserProfileSession sessionBean = new UserProfileSession();
sessionBean.setTastes(Arrays.asList("spring", "groovy"));
session.setAttribute("scopedTarget.userProfileSession", sessionBean);
this.mockMvc.perform(get("/").session(session))
.andExpect(status().isFound())
.andExpect(redirectedUrl("/search/mixed;keywords=spring,groovy"));
}
为了使测试工作,你必须将setTastes()设置器添加到UserProfileSessionbean 中。
org.springframework.mock.web包中有许多用于 Servlet 环境的模拟工具。
注意,代表我们的 bean 在会话中的属性以scopedTarget为前缀。这是因为会话 bean 被 Spring 代理。因此,在 Spring 上下文中实际上有两个对象,我们定义的实际 bean 及其最终将放入会话的代理。
模拟会话是一个整洁的类,但我们可以通过一个构建器重构测试,该构建器将隐藏实现细节,并且可以在以后重用:
@Test
public void should_redirect_to_tastes() throws Exception {
MockHttpSession session = new SessionBuilder().userTastes("spring", "groovy").build();
this.mockMvc.perform(get("/")
.session(session))
.andExpect(status().isFound())
.andExpect(redirectedUrl("/search/mixed;keywords=spring,groovy"));
}
构建器的代码如下:
public class SessionBuilder {
private final MockHttpSession session;
UserProfileSession sessionBean;
public SessionBuilder() {
session = new MockHttpSession();
sessionBean = new UserProfileSession();
session.setAttribute("scopedTarget.userProfileSession", sessionBean);
}
public SessionBuilder userTastes(String... tastes) {
sessionBean.setTastes(Arrays.asList(tastes));
return this;
}
public MockHttpSession build() {
return session;
}
}
在这次重构之后,当然你的测试应该总是通过。
模拟和存根
如果我们想测试由SearchController类处理的搜索请求,我们当然会想模拟SearchService。
有两种方法来做这件事:使用模拟或使用存根。
使用 Mockito 进行模拟
首先,我们可以使用 Mockito 创建一个模拟对象:
package masterSpringMvc.search;
import masterSpringMvc.MasterSpringMvcApplication;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Arrays;
import static org.hamcrest.Matchers.*;
import static org.mockito.Matchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = MasterSpringMvcApplication.class)
@WebAppConfiguration
public class SearchControllerMockTest {
@Mock
private SearchService searchService;
@InjectMocks
private SearchController searchController;
private MockMvc mockMvc;
@Before
public void setup() {
MockitoAnnotations.initMocks(this);
this.mockMvc = MockMvcBuilders
.standaloneSetup(searchController)
.setRemoveSemicolonContent(false)
.build();
}
@Test
public void should_search() throws Exception {
when(searchService.search(anyString(), anyListOf(String.class)))
.thenReturn(Arrays.asList(
new LightTweet("tweetText")
));
this.mockMvc.perform(get("/search/mixed;keywords=spring"))
.andExpect(status().isOk())
.andExpect(view().name("resultPage"))
.andExpect(model().attribute("tweets", everyItem(
hasProperty("text", is("tweetText"))
)));
verify(searchService, times(1)).search(anyString(), anyListOf(String.class));
}
}
你可以看到,我们不是使用带有 Web 应用程序上下文的MockMvc来设置,而是创建了一个独立上下文。这个上下文将只包含我们的控制器。这意味着我们对控制器及其依赖的实例化和初始化有完全的控制权。这将使我们能够轻松地在控制器内部注入模拟对象。
缺点是,我们必须重新声明我们配置的一部分,比如说我们不想在分号之后删除 URL 字符。
我们使用几个 Hamcrest 匹配器来断言最终会出现在视图模型中的属性。
模拟方法有其优点,例如能够验证与模拟对象的交互并在运行时创建期望。
这也将使你的测试与对象的实际实现相关联。例如,如果你在控制器中更改了获取推文的方式,你可能会破坏与此控制器相关的测试,因为它们仍然试图模拟我们不再依赖的服务。
测试时存根我们的豆
另一种方法是替换我们的SearchService类在测试中的实现。
我们一开始有点懒惰,没有为SearchService定义接口。始终面向接口编程,而不是面向实现。这句谚语背后的智慧是“四人帮”最重要的教训之一。
控制反转的一个好处是允许我们在测试或真实系统中轻松替换我们的实现。为了使这成为可能,我们必须修改所有使用SearchService的地方,使用新的接口。一个好的 IDE 有一个名为“提取接口”的重构,它将做到这一点。这应该会创建一个包含我们SearchService类公共方法search()的接口:
public interface TwitterSearch {
List<LightTweet> search(String searchType, List<String> keywords);
}
当然,我们的两个控制器SearchController和SearchApiController现在必须使用接口而不是实现。
现在,我们有能力为TwitterSearch类创建一个专门针对测试用例的测试双胞胎。为了使这成为可能,我们需要声明一个新的 Spring 配置,名为StubTwitterSearchConfig,它将包含TwitterSearch的另一个实现。我将它放在了搜索包中,紧挨着SearchControllerMockTest:
package masterSpringMvc.search;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import java.util.Arrays;
@Configuration
public class StubTwitterSearchConfig {
@Primary @Bean
public TwitterSearch twitterSearch() {
return (searchType, keywords) -> Arrays.asList(
new LightTweet("tweetText"),
new LightTweet("secondTweet")
);
}
}
在这个配置类中,我们重新声明了带有@Primary注解的TwitterSearch豆,这将告诉 Spring 如果类路径中找到其他实现,则优先使用这个实现。
由于TwitterSearch接口只包含一个方法,我们可以使用 lambda 表达式来实现它。
这里是使用我们的StubConfiguration类以及带有SpringApplicationConfiguration注解的主要配置的完整测试:
package masterSpringMvc.search;
import masterSpringMvc.MasterSpringMvcApplication;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = {
MasterSpringMvcApplication.class,
StubTwitterSearchConfig.class
})
@WebAppConfiguration
public class SearchControllerTest {
@Autowired
private WebApplicationContext wac;
private MockMvc mockMvc;
@Before
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
}
@Test
public void should_search() throws Exception {
this.mockMvc.perform(get("/search/mixed;keywords=spring"))
.andExpect(status().isOk())
.andExpect(view().name("resultPage"))
.andExpect(model().attribute("tweets", hasSize(2)))
.andExpect(model().attribute("tweets",
hasItems(
hasProperty("text", is("tweetText")),
hasProperty("text", is("secondTweet"))
))
);
}
}
我应该使用模拟还是存根?
这两种方法都有其优点。对于详细解释,请查看 Martin Fowler 的这篇优秀文章:martinfowler.com/articles/mocksArentStubs.html。
我的测试例程更多的是编写存根,因为我更喜欢测试对象的输出而不是它们的内部工作。但那取决于你。Spring 作为其核心的依赖注入框架意味着你可以轻松地选择你喜欢的任何方法。
单元测试 REST 控制器
我们刚刚测试了一个传统的控制器重定向到视图。在原则上测试 REST 控制器非常相似,但有一些细微差别。
由于我们打算测试我们控制器的 JSON 输出,我们需要一个 JSON 断言库。将以下依赖项添加到您的 build.gradle 文件中:
testCompile 'com.jayway.jsonpath:json-path'
让我们为允许搜索推文并返回 JSON 或 XML 结果的 SearchApiController 类编写一个测试,该控制器:
package masterSpringMvc.search.api;
import masterSpringMvc.MasterSpringMvcApplication;
import masterSpringMvc.search.StubTwitterSearchConfig;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = {
MasterSpringMvcApplication.class,
StubTwitterSearchConfig.class
})
@WebAppConfiguration
public class SearchApiControllerTest {
@Autowired
private WebApplicationContext wac;
private MockMvc mockMvc;
@Before
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
}
@Test
public void should_search() throws Exception {
this.mockMvc.perform(
get("/api/search/mixed;keywords=spring")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].text", is("tweetText")))
.andExpect(jsonPath("$[1].text", is("secondTweet")));
}
}
注意对 JSON 输出的简单而优雅的断言。测试我们的用户控制器将需要更多的工作。
首先,让我们将 assertj 添加到类路径中;它将帮助我们编写更干净的测试:
testCompile 'org.assertj:assertj-core:3.0.0'
然后,为了简化测试,向我们的 UserRepository 类添加一个 reset() 方法,这将帮助我们进行测试:
void reset(User... users) {
userMap.clear();
for (User user : users) {
save(user);
}
}
在现实生活中,我们可能需要提取一个接口并为测试创建一个存根。我将把这个作为你的练习。
这里是第一个获取用户列表的测试:
package masterSpringMvc.user.api;
import masterSpringMvc.MasterSpringMvcApplication;
import masterSpringMvc.user.User;
import masterSpringMvc.user.UserRepository;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = MasterSpringMvcApplication.class)
@WebAppConfiguration
public class UserApiControllerTest {
@Autowired
private WebApplicationContext wac;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@Before
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
userRepository.reset(new User("bob@spring.io"));
}
@Test
public void should_list_users() throws Exception {
this.mockMvc.perform(
get("/api/users")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].email", is("bob@spring.io")));
}
}
为了使这生效,向 User 类添加一个构造函数,该构造函数接受电子邮件属性作为参数。请注意:您还需要为 Jackson 提供一个默认构造函数。
测试与之前的测试非常相似,只是增加了 UserRepository 的设置。
现在让我们测试创建用户的 POST 方法:
import static org.assertj.core.api.Assertions.assertThat;
// Insert this test below the previous one
@Test
public void should_create_new_user() throws Exception {
User user = new User("john@spring.io");
this.mockMvc.perform(
post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonUtil.toJson(user))
)
.andExpect(status().isCreated());
assertThat(userRepository.findAll())
.extracting(User::getEmail)
.containsOnly("bob@spring.io", "john@spring.io");
}
有两点需要注意。第一点是使用 AssertJ 在测试后断言存储库的内容。为此,您需要以下静态导入:
import static org.assertj.core.api.Assertions.assertThat;
第二点是我们在发送到控制器之前使用一个实用方法将我们的对象转换为 JSON。为此目的,我在 utils 包中创建了一个简单的实用类,如下所示:
package masterSpringMvc.utils;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
public class JsonUtil {
public static byte[] toJson(Object object) throws IOException {
ObjectMapper mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
return mapper.writeValueAsBytes(object);
}
}
DELETE 方法的测试如下:
@Test
public void should_delete_user() throws Exception {
this.mockMvc.perform(
delete("/api/user/bob@spring.io")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk());
assertThat(userRepository.findAll()).hasSize(0);
}
@Test
public void should_return_not_found_when_deleting_unknown_user() throws Exception {
this.mockMvc.perform(
delete("/api/user/non-existing@mail.com")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isNotFound());
}
最后,这是测试 PUT 方法的测试,该方法更新用户:
@Test
public void put_should_update_existing_user() throws Exception {
User user = new User("ignored@spring.io");
this.mockMvc.perform(
put("/api/user/bob@spring.io")
.content(JsonUtil.toJson(user))
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk());
assertThat(userRepository.findAll())
.extracting(User::getEmail)
.containsOnly("bob@spring.io");
}
哎呀!最后一个测试没有通过!通过检查 UserApiController 的实现,我们可以很容易地看出原因:
@RequestMapping(value = "/user/{email}", method = RequestMethod.PUT)
public ResponseEntity<User> updateUser(@PathVariable String email, @RequestBody User user) throws EntityNotFoundException {
User saved = userRepository.update(email, user);
return new ResponseEntity<>(saved, HttpStatus.CREATED);
}
我们在控制器中返回了错误的状态!将其更改为 HttpStatus.OK,测试应该再次变绿。
使用 Spring,可以轻松地使用我们应用程序的相同配置编写控制器测试,但我们也可以有效地覆盖或更改我们的测试设置中的某些元素。
在运行所有测试时,您会发现的一个有趣的事情是应用程序上下文只加载一次,这意味着开销实际上非常小。
我们的应用程序也很小,所以我们没有努力将配置拆分成可重用的块。实际上,不将整个应用程序上下文加载到每个测试中可能是一个非常好的实践。你实际上可以使用@ComponentScan注解将扫描的组件拆分成不同的单元。
这个注解有几个属性,允许你使用includeFilter和excludeFilter(例如仅加载控制器)定义过滤器,并使用basePackageClasses和basePackages注解扫描特定的包。
你也可以将你的配置拆分成多个@Configuration类。一个很好的例子是将我们应用程序的用户和推文部分的代码拆分成两个独立的部分。
我们现在将探讨验收测试,它们是一种非常不同的生物。
测试认证
如果你希望在 MockMvc 测试中设置 Spring Security,你可以将此测试写在我们的上一个测试旁边:
package masterSpringMvc.user.api;
import masterSpringMvc.MasterSpringMvcApplication;
import masterSpringMvc.user.User;
import masterSpringMvc.user.UserRepository;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.http.MediaType;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import java.util.Base64;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = MasterSpringMvcApplication.class)
@WebAppConfiguration
public class UserApiControllerAuthTest {
@Autowired
private FilterChainProxy springSecurityFilter;
@Autowired
private WebApplicationContext wac;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@Before
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).addFilter(springSecurityFilter).build();
userRepository.reset(new User("bob@spring.io"));
}
@Test
public void unauthenticated_cannot_list_users() throws Exception {
this.mockMvc.perform(
get("/api/users")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isUnauthorized());
}
@Test
public void admin_can_list_users() throws Exception {
this.mockMvc.perform(
get("/api/users")
.accept(MediaType.APPLICATION_JSON)
.header("Authorization", basicAuth("admin", "admin"))
)
.andExpect(status().isOk());
}
private String basicAuth(String login, String password) {
byte[] auth = (login + ":" + password).getBytes();
return "Basic " + Base64.getEncoder().encodeToString(auth);
}
}
在前面的示例中,我们向我们的配置中添加了SpringSecurityFilter。这将激活 Spring Security 检查。为了测试认证是否工作,我们只需在我们要执行的请求中发送正确的头信息。
基本认证的优势在于它非常简单易模拟。在更复杂的设置中,你将不得不在认证端点上执行模拟请求。
在撰写本文时,Spring Boot 处于版本 1.2.3,依赖于 Spring Security 3。
几周后,Spring Boot 1.3.0 将可用,它将更新 Spring Security 并使用版本 4。
这是一个好消息,因为 Spring Security 4 包括一个使用简单注解的非常简单的认证设置。有关更多详细信息,请参阅docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test。
编写验收测试
单元测试只能覆盖我们应用程序组件之间不同交互的一部分。为了更进一步,我们需要设置验收测试,这些测试将实际启动完整的应用程序并允许我们与其界面交互。
Gradle 配置
当我们将集成测试添加到项目时,我们首先想要做的是将它们放在与单元测试不同的位置。
原因基本上是,验收测试比单元测试慢。它们可以是不同集成作业的一部分,例如夜间构建,我们希望开发者能够轻松地从他们的 IDE 中启动不同类型的测试。为了使用 Gradle 做到这一点,我们必须添加一个新的配置,称为integrationTest。对于 Gradle 来说,一个配置是一组工件及其依赖项。我们已经在项目中有了几个配置:compile、testCompile等等。
你可以通过在项目根目录下输入./gradlew properties来查看你项目的配置,以及更多内容。
在build.gradle文件末尾添加一个新的配置:
configurations {
integrationTestCompile.extendsFrom testCompile
integrationTestRuntime.extendsFrom testRuntime
}
这将允许你为integrationTestCompile和integrationTestRuntime声明依赖项。更重要的是,通过继承测试配置,我们可以访问它们的依赖项。
小贴士
我不建议将你的集成测试依赖项声明为integrationTestCompile。它对 Gradle 来说会正常工作,但在 IDE 中的支持不存在。我通常会将我的集成测试依赖项声明为testCompile依赖项。这只是一个小的不便。
现在我们有了新的配置,我们必须为它们创建一个与之关联的sourceSet类。sourceSet类代表一组逻辑上的 Java 源代码和资源。自然地,它们也必须继承自测试和主类;请参见以下代码:
sourceSets {
integrationTest {
compileClasspath += main.output + test.output
runtimeClasspath += main.output + test.output
}
}
最后,我们需要添加一个任务来从我们的构建中运行它们,如下所示:
task integrationTest(type: Test) {
testClassesDir = sourceSets.integrationTest.output.classesDir
classpath = sourceSets.integrationTest.runtimeClasspath
reports.html.destination = file("${reporting.baseDir}/integrationTests")
}
要运行我们的测试,我们可以输入./gradlew integrationTest。除了配置我们的类路径和测试类的位置外,我们还定义了一个测试报告将被生成的目录。
此配置允许我们在src/integrationTest/java或src/integrationTest/groovy中编写我们的测试,这将使它们更容易被识别,并且可以单独从我们的单元测试中运行它们。
默认情况下,它们将被生成在build/reports/tests目录下。如果我们不覆盖它们,如果我们使用gradle clean test integrationTest启动测试和集成测试,它们将相互覆盖。
值得注意的是,Gradle 生态系统中的一个年轻插件旨在简化声明新的测试配置,有关详细信息,请访问plugins.gradle.org/plugin/org.unbroken-dome.test-sets。
我们的第一条 FluentLenium 测试
FluentLenium 是一个用于执行 Selenium 测试的出色库。让我们在我们的构建脚本中添加一些依赖项:
testCompile 'org.fluentlenium:fluentlenium-assertj:0.10.3'
testCompile 'com.codeborne:phantomjsdriver:1.2.1'
testCompile 'org.seleniumhq.selenium:selenium-java:2.45.0'
默认情况下,fluentlenium附带selenium-java。我们重新声明它只是为了明确要求可用的最新版本。我们还添加了对PhantomJS驱动程序的依赖项,该驱动程序不是由 Selenium 官方支持的。selenium-java库的问题在于它捆绑了所有受支持的 Web 驱动程序。
你可以通过输入gradle dependencies来查看你项目的依赖树。在底部,你会看到类似以下的内容:
+--- org.fluentlenium:fluentlenium-assertj:0.10.3
| +--- org.fluentlenium:fluentlenium-core:0.10.3
| | \--- org.seleniumhq.selenium:selenium-java:2.44.0 -> 2.45.0
| | +--- org.seleniumhq.selenium:selenium-chrome-driver:2.45.0
| | +--- org.seleniumhq.selenium:selenium-htmlunit-driver:2.45.0
| | +--- org.seleniumhq.selenium:selenium-firefox-driver:2.45.0
| | +--- org.seleniumhq.selenium:selenium-ie-driver:2.45.0
| | +--- org.seleniumhq.selenium:selenium-safari-driver:2.45.0
| | +--- org.webbitserver:webbit:0.4.14 (*)
| | \--- org.seleniumhq.selenium:selenium-leg-rc:2.45.0
| | \--- org.seleniumhq.selenium:selenium-remote-driver:2.45.0 (*)
| \--- org.assertj:assertj-core:1.6.1 -> 3.0.0
由于我们只会使用PhantomJS驱动程序,所以将这些依赖项全部放在类路径中是非常不必要的。为了排除我们不需要的依赖项,我们可以在我们的 buildscript 中添加以下部分,在依赖项声明之前:
configurations {
testCompile {
exclude module: 'selenium-safari-driver'
exclude module: 'selenium-ie-driver'
//exclude module: 'selenium-firefox-driver'
exclude module: 'selenium-htmlunit-driver'
exclude module: 'selenium-chrome-driver'
}
}
我们只需保留firefox驱动器。PhantomJS驱动器是一个无头浏览器,因此理解没有 GUI 发生的事情可能会很棘手。切换到 Firefox 来调试复杂的测试可能是个不错的选择。
在正确配置了 classpath 之后,我们现在可以编写我们的第一个集成测试。Spring Boot 有一个非常方便的注解来支持这种测试:
import masterSpringMvc.MasterSpringMvcApplication;
import masterSpringMvc.search.StubTwitterSearchConfig;
import org.fluentlenium.adapter.FluentTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.phantomjs.PhantomJSDriver;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.boot.test.WebIntegrationTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = {
MasterSpringMvcApplication.class,
StubTwitterSearchConfig.class
})
@WebIntegrationTest(randomPort = true)
public class FluentIntegrationTest extends FluentTest {
@Value("${local.server.port}")
private int serverPort;
@Override
public WebDriver getDefaultDriver() {
return new PhantomJSDriver();
}
public String getDefaultBaseUrl() {
return "http://localhost:" + serverPort;
}
@Test
public void hasPageTitle() {
goTo("/");
assertThat(findFirst("h2").getText()).isEqualTo("Login");
}
}
注意,FluentLenium 有一个用于请求 DOM 元素的整洁 API。然后,我们可以使用 AssertJ 在页面内容上编写易于阅读的断言。
注意
请参阅github.com/FluentLenium/FluentLenium上的文档以获取更多信息。
使用@WebIntegrationTest注解,Spring 实际上会创建嵌入的 Servlet 容器(Tomcat)并在随机端口上启动我们的 Web 应用程序!我们需要在运行时检索这个端口号。这将允许我们为测试提供一个基本 URL,这个 URL 将是我们在测试中进行的所有导航的前缀。
如果你在这个阶段尝试运行测试,你将看到以下错误信息:
java.lang.IllegalStateException: The path to the driver executable must be set by the phantomjs.binary.path capability/system property/PATH variable; for more information, see https://github.com/ariya/phantomjs/wiki. The latest version can be downloaded from http://phantomjs.org/download.html
事实上,PhantomJS 需要安装在你的机器上才能正确工作。在 Mac 上,只需使用brew install phantomjs。对于其他平台,请参阅phantomjs.org/download.html上的文档。
如果你不想在机器上安装新的二进制文件,将new PhantomJSDriver()替换为new FirefoxDriver()。你的测试会稍微慢一些,但你将有一个 GUI。
我们的第一测试是登录到配置文件页面,对吧?我们现在需要找到一种登录的方法。
关于使用存根伪造登录怎么办?
将此类放入测试源代码(src/test/java):
package masterSpringMvc.auth;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.UsersConnectionRepository;
import org.springframework.social.connect.web.ProviderSignInController;
import org.springframework.social.connect.web.SignInAdapter;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.servlet.view.RedirectView;
@Configuration
public class StubSocialSigninConfig {
@Bean
@Primary
@Autowired
public ProviderSignInController signInController(ConnectionFactoryLocator factoryLocator,
UsersConnectionRepository usersRepository,
SignInAdapter signInAdapter) {
return new FakeSigninController(factoryLocator, usersRepository, signInAdapter);
}
public class FakeSigninController extends ProviderSignInController {
public FakeSigninController(ConnectionFactoryLocator connectionFactoryLocator,
UsersConnectionRepository usersConnectionRepository,
SignInAdapter signInAdapter) {
super(connectionFactoryLocator, usersConnectionRepository, signInAdapter);
}
@Override
public RedirectView signIn(String providerId, NativeWebRequest request) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken("geowarin", null, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
return new RedirectView("/");
}
}
}
这将验证任何点击 Twitter 登录按钮的用户为 geowarin。
我们将编写第二个测试,该测试将填写配置文件表单并断言搜索结果已显示:
import masterSpringMvc.MasterSpringMvcApplication;
import masterSpringMvc.auth.StubSocialSigninConfig;
import masterSpringMvc.search.StubTwitterSearchConfig;
import org.fluentlenium.adapter.FluentTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.phantomjs.PhantomJSDriver;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.boot.test.WebIntegrationTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import static org.assertj.core.api.Assertions.assertThat;
import static org.fluentlenium.core.filter.FilterConstructor.withName;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = {
MasterSpringMvcApplication.class,
StubTwitterSearchConfig.class,
StubSocialSigninConfig.class
})
@WebIntegrationTest(randomPort = true)
public class FluentIntegrationTest extends FluentTest {
@Value("${local.server.port}")
private int serverPort;
@Override
public WebDriver getDefaultDriver() {
return new PhantomJSDriver();
}
public String getDefaultBaseUrl() {
return "http://localhost:" + serverPort;
}
@Test
public void hasPageTitle() {
goTo("/");
assertThat(findFirst("h2").getText()).isEqualTo("Login");
}
@Test
public void should_be_redirected_after_filling_form() {
goTo("/");
assertThat(findFirst("h2").getText()).isEqualTo("Login");
find("button", withName("twitterSignin")).click();
assertThat(findFirst("h2").getText()).isEqualTo("Your profile");
fill("#twitterHandle").with("geowarin");
fill("#email").with("geowarin@mymail.com");
fill("#birthDate").with("03/19/1987");
find("button", withName("addTaste")).click();
fill("#tastes0").with("spring");
find("button", withName("save")).click();
takeScreenShot();
assertThat(findFirst("h2").getText()).isEqualTo("Tweet results for spring");
assertThat(findFirst("ul.collection").find("li")).hasSize(2);
}
}
注意,我们可以轻松地让我们的 WebDriver 截取当前用于测试的浏览器的屏幕截图。这将产生以下输出:

使用 FluentLenium 的页面对象
之前的测试有点混乱。我们在测试中硬编码了所有的选择器。当我们使用相同的元素编写大量测试时,这可能会变得非常危险,因为每次我们更改页面布局,所有测试都会失败。此外,测试的阅读性有点困难。
为了解决这个问题,一个常见的做法是使用一个页面对象来表示我们应用程序中的特定网页。使用 FluentLenium,页面对象必须继承FluentPage类。
我们将创建三个页面,一个用于 GUI 的每个元素。第一个将是登录页面,可以选择点击twitterSignin按钮,第二个将是配置文件页面,其中包含填充配置文件表单的便利方法,最后一个将是结果页面,我们可以断言显示的结果。
让我们立即创建登录页面。我将所有三个页面放在一个 pages 包中:
package pages;
import org.fluentlenium.core.FluentPage;
import org.fluentlenium.core.domain.FluentWebElement;
import org.openqa.selenium.support.FindBy;
import static org.assertj.core.api.Assertions.assertThat;
public class LoginPage extends FluentPage {
@FindBy(name = "twitterSignin")
FluentWebElement signinButton;
public String getUrl() {
return "/login";
}
public void isAt() {
assertThat(findFirst("h2").getText()).isEqualTo("Login");
}
public void login() {
signinButton.click();
}
}
让我们为我们的个人资料页面创建一个页面:
package pages;
import org.fluentlenium.core.FluentPage;
import org.fluentlenium.core.domain.FluentWebElement;
import org.openqa.selenium.support.FindBy;
import static org.assertj.core.api.Assertions.assertThat;
public class ProfilePage extends FluentPage {
@FindBy(name = "addTaste")
FluentWebElement addTasteButton;
@FindBy(name = "save")
FluentWebElement saveButton;
public String getUrl() {
return "/profile";
}
public void isAt() {
assertThat(findFirst("h2").getText()).isEqualTo("Your profile");
}
public void fillInfos(String twitterHandle, String email, String birthDate) {
fill("#twitterHandle").with(twitterHandle);
fill("#email").with(email);
fill("#birthDate").with(birthDate);
}
public void addTaste(String taste) {
addTasteButton.click();
fill("#tastes0").with(taste);
}
public void saveProfile() {
saveButton.click();
}
}
让我们再为搜索结果页面创建一个页面:
package pages;
import com.google.common.base.Joiner;
import org.fluentlenium.core.FluentPage;
import org.fluentlenium.core.domain.FluentWebElement;
import org.openqa.selenium.support.FindBy;
import static org.assertj.core.api.Assertions.assertThat;
public class SearchResultPage extends FluentPage {
@FindBy(css = "ul.collection")
FluentWebElement resultList;
public void isAt(String... keywords) {
assertThat(findFirst("h2").getText())
.isEqualTo("Tweet results for " + Joiner.on(",").join(keywords));
}
public int getNumberOfResults() {
return resultList.find("li").size();
}
}
我们现在可以使用这些页面对象重构测试:
@Page
private LoginPage loginPage;
@Page
private ProfilePage profilePage;
@Page
private SearchResultPage searchResultPage;
@Test
public void should_be_redirected_after_filling_form() {
goTo("/");
loginPage.isAt();
loginPage.login();
profilePage.isAt();
profilePage.fillInfos("geowarin", "geowarin@mymail.com", "03/19/1987");
profilePage.addTaste("spring");
profilePage.saveProfile();
takeScreenShot();
searchResultPage.isAt();
assertThat(searchResultPage.getNumberOfResults()).isEqualTo(2);
}
难道不是更易于阅读吗?
使我们的测试更具 Groovy 风格
如果你不知道 Groovy,可以将其视为 Java 的一个近亲,但没有那么冗长。Groovy 是一种动态语言,具有可选类型。这意味着当需要时,你可以拥有类型系统的保证,当你知道自己在做什么时,可以拥有鸭子类型的多功能性。
使用这种语言,你可以编写没有 getters、setters、equals 和 hashcode 方法的 POJO。所有这些都会为你处理。
使用 == 实际上会调用 equals 方法。运算符可以重载,这允许使用小箭头等简洁的语法,例如 << 将文本写入文件。这也意味着你可以将整数添加到 BigIntegers 中并获得正确的结果。
Groovy 开发工具包(GDK)还为经典 Java 对象添加了几个非常有趣的方法。它还将正则表达式和闭包视为一等公民。
注意
如果你想对 Groovy 有一个坚实的介绍,请查看 www.groovy-lang.org/style-guide.html 上的 Groovy 风格指南。
你还可以观看 Peter Ledbrook 在 www.infoq.com/presentations/groovy-for-java 上做的这个精彩演讲。
至少对我来说,我总是试图在我工作的应用程序的测试方面推广 Groovy。这确实提高了代码的可读性和开发者的生产力。
使用 Spock 的单元测试
为了能够在我们的项目中编写 Groovy 测试,我们需要使用 Groovy 插件而不是 Java 插件。
这是你构建脚本中的内容:
apply plugin: 'java'
更改为以下内容:
apply plugin: 'groovy'
这种修改完全无害。Groovy 插件扩展了 Java 插件,所以它所做的唯一区别是它提供了在 src/main/groovy、src/test/groovy 和 src/integrationTest/groovy 中添加 Groovy 源代码的能力。
显然,我们还需要将 Groovy 添加到类路径中。我们还将通过 spock-spring 依赖项添加 Spock,这是最受欢迎的 Groovy 测试库,这将使它与 Spring 兼容:
testCompile 'org.codehaus.groovy:groovy-all:2.4.4:indy'
testCompile 'org.spockframework:spock-spring'
我们现在可以用不同的方法重写 HomeControllerTest。让我们在 src/test/groovy 中创建一个 HomeControllerSpec 类。我将其添加到 masterSpringMvc.controller 包中,就像我们的第一个 HomeControllerTest 实例一样:
package masterSpringMvc.controller
import masterSpringMvc.MasterSpringMvcApplication
import masterSpringMvc.search.StubTwitterSearchConfig
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.SpringApplicationContextLoader
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.web.WebAppConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
import spock.lang.Specification
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@ContextConfiguration(loader = SpringApplicationContextLoader,
classes = [MasterSpringMvcApplication, StubTwitterSearchConfig])
@WebAppConfiguration
class HomeControllerSpec extends Specification {
@Autowired
WebApplicationContext wac;
MockMvc mockMvc;
def setup() {
mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
}
def "User is redirected to its profile on his first visit"() {
when: "I navigate to the home page"
def response = this.mockMvc.perform(get("/"))
then: "I am redirected to the profile page"
response
.andExpect(status().isFound())
.andExpect(redirectedUrl("/profile"))
}
}
使用字符串作为方法名称和 Spock 提供的 BDD DSL(领域特定语言)的能力,我们的测试立即变得更加易于阅读。这里没有直接显示,但 then 块内的每个语句都将隐式地成为一个断言。
在撰写本文时,由于 Spock 不读取元注解,因此不能使用@SpringApplicationConfiguration注解,所以我们将其替换为@ContextConfiguration(loader = SpringApplicationContextLoader),这本质上是一样的。
现在我们有了同一测试的两个版本,一个是 Java 版本,另一个是 Groovy 版本。选择最适合您编码风格的版本,并删除另一个版本。如果您决定坚持使用 Groovy,您将不得不将should_redirect_to_tastes()测试重写为 Groovy。这应该不难。
Spock 也提供了强大的 mock 支持。我们可以将之前的SearchControllerMockTest类稍作修改:
package masterSpringMvc.search
import masterSpringMvc.MasterSpringMvcApplication
import org.springframework.boot.test.SpringApplicationContextLoader
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.web.WebAppConfiguration
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import spock.lang.Specification
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@ContextConfiguration(loader = SpringApplicationContextLoader,
classes = [MasterSpringMvcApplication])
@WebAppConfiguration
class SearchControllerMockSpec extends Specification {
def twitterSearch = Mock(TwitterSearch)
def searchController = new SearchController(twitterSearch)
def mockMvc = MockMvcBuilders.standaloneSetup(searchController)
.setRemoveSemicolonContent(false)
.build()
def "searching for the spring keyword should display the search page"() {
when: "I search for spring"
def response = mockMvc.perform(get("/search/mixed;keywords=spring"))
then: "The search service is called once"
1 * twitterSearch.search(_, _) >> [new LightTweet('tweetText')]
and: "The result page is shown"
response
.andExpect(status().isOk())
.andExpect(view().name("resultPage"))
and: "The model contains the result tweets"
response
.andExpect(model().attribute("tweets", everyItem(
hasProperty("text", is("tweetText"))
)))
}
}
Mockito 的所有冗余现在都消失了。then块实际上断言twitterSearch方法被调用一次(1 *)并且带有任何参数(_, _)。就像 mockito 一样,我们本可以期望特定的参数。
双箭头>>语法用于从 mocked 方法返回一个对象。在我们的例子中,它是一个只包含一个元素的列表。
仅通过在我们的类路径中添加少量依赖项,我们就已经编写了更易于阅读的测试,但我们还没有完成。我们还将重构我们的验收测试以使用 Geb,这是一个引导 Selenium 测试的 Groovy 库。
Geb 的集成测试
Geb 是 Grails 框架中编写测试的事实上的库。尽管其版本为 0.12.0,但它非常稳定,并且非常易于使用。
它提供了一个类似于 jQuery 的选择器 API,这使得测试易于编写,即使是对于前端开发者来说也是如此。Groovy 也是一种受到 JavaScript 影响的编程语言,这也将吸引他们。
让我们添加 Geb 和 Spock 规范支持的类路径:
testCompile 'org.gebish:geb-spock:0.12.0'
Geb 可以通过位于src/integrationTest/groovy根目录下的 Groovy 脚本进行配置,该脚本名为GebConfig.groovy:
import org.openqa.selenium.Dimension
import org.openqa.selenium.firefox.FirefoxDriver
import org.openqa.selenium.phantomjs.PhantomJSDriver
reportsDir = new File('./build/geb-reports')
driver = {
def driver = new FirefoxDriver()
// def driver = new PhantomJSDriver()
driver.manage().window().setSize(new Dimension(1024, 768))
return driver
}
在此配置中,我们指定了 Geb 将生成报告的位置以及要使用的驱动器。Geb 的报告是屏幕截图的增强版本,它还包含当前页面的 HTML。可以在任何时刻通过在 Geb 测试中调用report函数来触发报告的生成。
让我们用 Geb 重写我们的第一个集成测试:
import geb.Configuration
import geb.spock.GebSpec
import masterSpringMvc.MasterSpringMvcApplication
import masterSpringMvc.search.StubTwitterSearchConfig
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.test.SpringApplicationContextLoader
import org.springframework.boot.test.WebIntegrationTest
import org.springframework.test.context.ContextConfiguration
@ContextConfiguration(loader = SpringApplicationContextLoader,
classes = [MasterSpringMvcApplication, StubTwitterSearchConfig])
@WebIntegrationTest(randomPort = true)
class IntegrationSpec extends GebSpec {
@Value('${local.server.port}')
int port
Configuration createConf() {
def configuration = super.createConf()
configuration.baseUrl = "http://localhost:$port"
configuration
}
def "User is redirected to the login page when not logged"() {
when: "I navigate to the home page"
go '/'
// report 'navigation-redirection'
then: "I am redirected to the profile page"
$('h2', 0).text() == 'Login'
}
}
目前,它与 FluentLenium 非常相似。我们已经可以看到$函数,它将允许我们通过其选择器获取 DOM 元素。在这里,我们还声明我们想要页面的第一个h2,通过给出0索引。
Geb 的页面对象
使用 Geb 的页面对象非常易于工作。我们将创建与之前相同的页面对象,以便您能够欣赏到差异。
使用 Geb 时,页面对象必须继承自geb.Page类。首先,让我们创建LoginPage。我建议不要将其放在与之前相同的包中。我创建了一个名为geb.pages的包:
package geb.pages
import geb.Page
class LoginPage extends Page {
static url = '/login'
static at = { $('h2', 0).text() == 'Login' }
static content = {
twitterSignin { $('button', name: 'twitterSignin') }
}
void loginWithTwitter() {
twitterSignin.click()
}
}
然后,我们可以创建ProfilePage:
package geb.pages
import geb.Page
class ProfilePage extends Page {
static url = '/profile'
static at = { $('h2', 0).text() == 'Your profile' }
static content = {
addTasteButton { $('button', name: 'addTaste') }
saveButton { $('button', name: 'save') }
}
void fillInfos(String twitterHandle, String email, String birthDate) {
$("#twitterHandle") << twitterHandle
$("#email") << email
$("#birthDate") << birthDate
}
void addTaste(String taste) {
addTasteButton.click()
$("#tastes0") << taste
}
void saveProfile() {
saveButton.click();
}
}
这基本上与之前的页面相同。注意小 << 用于将值分配给输入元素。您也可以调用 setText。
at 方法完全是框架的一部分,当您导航到相应的页面时,Geb 会自动断言这些内容。
让我们创建 SearchResultPage:
package geb.pages
import geb.Page
class SearchResultPage extends Page {
static url = '/search'
static at = { $('h2', 0).text().startsWith('Tweet results for') }
static content = {
resultList { $('ul.collection') }
results { resultList.find('li') }
}
}
由于可以重用先前定义的内容来生成结果,所以它稍微短一些。
在没有设置页面对象的情况下,我们可以这样编写测试:
import geb.Configuration
import geb.pages.LoginPage
import geb.pages.ProfilePage
import geb.pages.SearchResultPage
import geb.spock.GebSpec
import masterSpringMvc.MasterSpringMvcApplication
import masterSpringMvc.auth.StubSocialSigninConfig
import masterSpringMvc.search.StubTwitterSearchConfig
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.test.SpringApplicationContextLoader
import org.springframework.boot.test.WebIntegrationTest
import org.springframework.test.context.ContextConfiguration
@ContextConfiguration(loader = SpringApplicationContextLoader,
classes = [MasterSpringMvcApplication, StubTwitterSearchConfig, StubSocialSigninConfig])
@WebIntegrationTest(randomPort = true)
class IntegrationSpec extends GebSpec {
@Value('${local.server.port}')
int port
Configuration createConf() {
def configuration = super.createConf()
configuration.baseUrl = "http://localhost:$port"
configuration
}
def "User is redirected to the login page when not logged"() {
when: "I navigate to the home page"
go '/'
then: "I am redirected to the login page"
$('h2').text() == 'Login'
}
def "User is redirected to its profile on his first visit"() {
when: 'I am connected'
to LoginPage
loginWithTwitter()
and: "I navigate to the home page"
go '/'
then: "I am redirected to the profile page"
$('h2').text() == 'Your profile'
}
def "After filling his profile, the user is taken to result matching his tastes"() {
given: 'I am connected'
to LoginPage
loginWithTwitter()
and: 'I am on my profile'
to ProfilePage
when: 'I fill my profile'
fillInfos("geowarin", "geowarin@mymail.com", "03/19/1987");
addTaste("spring")
and: 'I save it'
saveProfile()
then: 'I am taken to the search result page'
at SearchResultPage
page.results.size() == 2
}
}
哇,真美!您当然可以直接用 Geb 编写用户故事!
通过我们简单的测试,我们只是触及了 Geb 的表面。还有很多功能可用,我鼓励您阅读《Geb 之书》,这是一份非常好的文档,可在www.gebish.org/manual/current/找到。
检查点
在本章中,我们在 src/test/java 中添加了大量测试。我选择使用 Groovy,因此我删除了重复的测试:

在 src/test/groovy 目录中,我已经重构了两个测试,如下所示:

在 src/integrationTest/groovy 中,我们用 Geb 编写了一个集成测试:

最后,我们在 Gradle 构建中添加了一个 integrationTest 任务。运行 gradle clean test 和 gradle clean integrationTest 以确保所有测试都通过。
如果构建成功,我们就准备好进入下一章了。
摘要
在本章中,我们研究了单元测试和集成测试之间的区别。
我们看到了测试是如何成为一种健康的习惯,它将给我们对我们所构建和所发布的信心。从长远来看,它将节省我们的金钱并避免一些头痛。
Spring 与用 Java 编写的经典 JUnit 测试配合得很好,并且它对集成测试有第一级支持。但我们也容易使用其他语言,例如 Groovy,使测试更易于阅读和编写。
测试无疑是 Spring 框架最强大的特点之一,也是最初使用依赖注入的主要原因。
请期待下一章,我们将优化我们的应用程序,使其准备好在云中部署!
第八章.优化您的请求
在本章中,我们将探讨不同的技术来提高我们应用程序的性能。
我们将实现优化 Web 应用程序的经典方法:缓存控制头、Gzipping、应用程序缓存和 ETags,以及更反应性的内容,例如异步方法调用和 WebSockets。
生产配置文件
在上一章中,我们看到了如何定义一个应用程序属性文件,该文件仅在以特定配置文件启动应用程序时读取。我们将使用相同的方法,并在src/main/resources中创建一个application-prod.properties文件,紧挨着现有的application.properties文件。这样,我们将能够使用优化设置配置生产环境。
我们将在这个文件中放入一些属性以开始。在第三章中,处理表单和复杂 URL 映射,我们停用了 Thymeleaf 缓存并强制翻译捆绑包在每次访问时重新加载。
这对于开发来说很棒,但在生产中却毫无用处且浪费时间。所以让我们来解决这个问题:
spring.thymeleaf.cache=true
spring.messages.cache-seconds=-1
-1 的缓存周期意味着永久缓存捆绑包。
现在,如果我们使用“prod”配置文件启动我们的应用程序,模板和捆绑包应该会永久缓存。
来自“prod”配置文件的性质确实会覆盖我们在application.properties文件中声明的那些。
Gzipping
Gzipping 是一种被浏览器广泛理解的压缩算法。您的服务器将提供压缩响应,这将会消耗更多的 CPU 周期,但可以节省带宽。
客户端浏览器将负责解压缩资源并向用户显示它们。
要利用 Tomcat 的 Gzipping 压缩能力,只需将以下行添加到application-prod.properties文件中:
server.tomcat.compression=on
server.tomcat.compressableMimeTypes=text/html,text/xml,text/css,text/plain,\
application/json,application/xml,application/javascript
这将启用 Tomcat 在服务任何匹配列表中指定的 MIME 类型且长度大于 2048 字节的文件时的 Gzipping 压缩。您可以将server.tomcat.compression设置为force以强制压缩,或者将其设置为数值以更改 Gzipped 资产的最小长度值。
如果您想要对压缩有更多的控制,比如说对压缩级别进行控制,或者想要排除压缩的用户代理,您可以通过将org.eclipse.jetty:jetty-servlets依赖项添加到项目中,在 Jetty 中使用GzipFilter类。
这将自动触发GzipFilterAutoConfiguration类,可以通过以spring.http.gzip为前缀的一组属性进行配置。查看GzipFilterProperties以了解其可定制的程度。
注意
缓存控制
缓存控制是一组由服务器发送的 HTTP 头,用于控制用户的浏览器允许缓存资源的方式。
在上一章中,我们看到了 Spring Security 自动禁用受保护资源的缓存。
如果我们想从缓存控制中受益,我们首先必须禁用该功能:
security.headers.cache=false
# Cache resources for 3 days
spring.resources.cache-period=259200
现在,启动应用程序,转到主页,并检查 Chrome 开发者控制台。你将看到我们的 JavaScript 文件被 Gzipped 并缓存,如下面的截图所示:

如果你想对自己的缓存有更多控制,你可以在配置中添加自己资源的处理器:
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// This is just an example
registry.addResourceHandler("/img/**")
.addResourceLocations("classpath:/static/images/")
.setCachePeriod(12);
}
我们也可以覆盖 Spring Security 的默认设置。如果我们想为我们的 API 禁用“无缓存控制”策略,我们可以像这样更改ApiSecurityConfiguration类:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/api/**")
// This is just an example – not required in our case
.headers().cacheControl().disable()
.httpBasic().and()
.csrf().disable()
.authorizeRequests()
.antMatchers(HttpMethod.GET).hasRole("USER")
.antMatchers(HttpMethod.POST).hasRole("ADMIN")
.antMatchers(HttpMethod.PUT).hasRole("ADMIN")
.antMatchers(HttpMethod.DELETE).hasRole("ADMIN")
.anyRequest().authenticated();
}
应用程序缓存
现在我们已经压缩并缓存了 Web 请求,下一步我们可以采取以减少服务器负载的是将昂贵操作的输出放入缓存。Twitter 搜索需要一些时间,并将消耗我们的应用程序请求比率在 Twitter API 上。使用 Spring,我们可以轻松缓存搜索,并在每次使用相同参数调用搜索时返回相同的结果。
我们需要做的第一件事是使用@EnableCache注解激活 Spring 缓存。我们还需要创建一个CacheManager来解析我们的缓存。让我们在config包中创建一个CacheConfiguration类:
package masterSpringMvc.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Arrays;
@Configuration
@EnableCaching
public class CacheConfiguration {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager simpleCacheManager = new SimpleCacheManager();
simpleCacheManager.setCaches(Arrays.asList(
new ConcurrentMapCache("searches")
));
return simpleCacheManager;
}
}
在前面的例子中,我们使用了最简单的缓存抽象。其他实现也可用,例如EhCacheCacheManager或GuavaCacheManager,我们将在稍后使用。
现在我们已经配置了缓存,我们可以在我们的方法上使用@Cacheable注解。当我们这样做时,Spring 将自动缓存方法的结果,并将其与当前参数关联以供检索。
Spring 需要在缓存的 bean 周围创建代理。这通常意味着在同一个 bean 内部调用缓存的函数不会失败,并且可以使用 Spring 的缓存。
在我们的案例中,在SearchService类中,调用搜索操作的部分将极大地受益于缓存。
作为初步步骤,将负责创建SearchParameters类的代码放入一个名为SearchParamsBuilder的专用对象中会很好:
package masterSpringMvc.search;
import org.springframework.social.twitter.api.SearchParameters;
import java.util.List;
import java.util.stream.Collectors;
public class SearchParamsBuilder {
public static SearchParameters createSearchParam(String searchType, String taste) {
SearchParameters.ResultType resultType = getResultType(searchType);
SearchParameters searchParameters = new SearchParameters(taste);
searchParameters.resultType(resultType);
searchParameters.count(3);
return searchParameters;
}
private static SearchParameters.ResultType getResultType(String searchType) {
for (SearchParameters.ResultType knownType : SearchParameters.ResultType.values()) {
if (knownType.name().equalsIgnoreCase(searchType)) {
return knownType;
}
}
return SearchParameters.ResultType.RECENT;
}
}
这将帮助我们在我们的服务中创建搜索参数。
现在我们想要为我们的搜索结果创建一个缓存。我们希望 Twitter API 的每次调用都被缓存。Spring 缓存注解依赖于代理来对@Cacheable方法进行检测。因此,我们需要一个新的类,其中包含一个用@Cacheable注解的方法。
当你使用 Spring 抽象 API 时,你不知道缓存的底层实现。许多实现将需要缓存的返回类型和参数类型都是可序列化的。
SearchParameters不是可序列化的,这就是为什么我们将在缓存方法中传递搜索类型和关键字(都是字符串)。
由于我们想要将LightTweets对象放入缓存,我们希望它们是Serializable的;这将确保它们可以从任何缓存抽象中始终被写入和读取:
public class LightTweet implements Serializable {
// the rest of the code remains unchanged
}
让我们创建一个SearchCache类并将其放入search.cache包中:
package masterSpringMvc.search.cache;
import masterSpringMvc.search.LightTweet;
import masterSpringMvc.search.SearchParamsBuilder;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.social.TwitterProperties;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.social.twitter.api.SearchParameters;
import org.springframework.social.twitter.api.Twitter;
import org.springframework.social.twitter.api.impl.TwitterTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class SearchCache {
protected final Log logger = LogFactory.getLog(getClass());
private Twitter twitter;
@Autowired
public SearchCache(TwitterProperties twitterProperties) {
this.twitter = new TwitterTemplate(twitterProperties.getAppId(), twitterProperties.getAppSecret());
}
@Cacheable("searches")
public List<LightTweet> fetch(String searchType, String keyword) {
logger.info("Cache miss for " + keyword);
SearchParameters searchParam = SearchParamsBuilder.createSearchParam(searchType, keyword);
return twitter.searchOperations()
.search(searchParam)
.getTweets().stream()
.map(LightTweet::ofTweet)
.collect(Collectors.toList());
}
}
实际上没有比这更简单的方法了。我们使用了@Cacheable注解来指定将要使用的缓存名称。不同的缓存可能有不同的策略。
注意,我们手动创建了一个新的TwitterTemplate方法,而不是像以前那样注入它。这是因为我们稍后需要从其他线程访问缓存。在 Spring Boot 的TwitterAutoConfiguration类中,Twitterbean 绑定到请求作用域,因此它不能在 Servlet 线程之外使用。
使用这两个新对象,我们的SearchService类的代码就简单成这样:
package masterSpringMvc.search;
import masterSpringMvc.search.cache.SearchCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Profile("!async")
public class SearchService implements TwitterSearch {
private SearchCache searchCache;
@Autowired
public SearchService(SearchCache searchCache) {
this.searchCache = searchCache;
}
@Override
public List<LightTweet> search(String searchType, List<String> keywords) {
return keywords.stream()
.flatMap(keyword -> searchCache.fetch(searchType, keyword).stream())
.collect(Collectors.toList());
}
}
注意,我们使用@Profile("!async")注解了服务。这意味着只有当async配置没有被激活时,我们才会创建这个 bean。
之后,我们将创建TwitterSearch类的另一个实现,以便能够在两者之间切换。
真棒!假设我们重启我们的应用程序并尝试一个大的请求,如下所示:
http://localhost:8080/search/mixed;keywords=docker,spring,spring%20boot,spring%20mvc,groovy,grails
首先,可能需要一点时间,但之后我们的控制台将显示以下日志:
2015-08-03 16:04:01.958 INFO 38259 --- [nio-8080-exec-8] m.search.cache.SearchCache : Cache miss for docker
2015-08-03 16:04:02.437 INFO 38259 --- [nio-8080-exec-8] m.search.cache.SearchCache : Cache miss for spring
2015-08-03 16:04:02.728 INFO 38259 --- [nio-8080-exec-8] m.search.cache.SearchCache : Cache miss for spring boot
2015-08-03 16:04:03.098 INFO 38259 --- [nio-8080-exec-8] m.search.cache.SearchCache : Cache miss for spring mvc
2015-08-03 16:04:03.383 INFO 38259 --- [nio-8080-exec-8] m.search.cache.SearchCache : Cache miss for groovy
2015-08-03 16:04:03.967 INFO 38259 --- [nio-8080-exec-8] m.search.cache.SearchCache : Cache miss for grails
之后,如果我们刷新页面,结果将立即显示,并且控制台不会看到缓存未命中。
这就是我们的缓存功能,但缓存 API 还有很多其他功能。你可以使用以下注解来注解方法:
-
@CachEvict:这将从缓存中删除一个条目 -
@CachePut:这将把方法的结果放入缓存,而不会干扰方法本身 -
@Caching:这重新组合了缓存注解 -
@CacheConfig:这指向不同的缓存配置
@Cacheable注解也可以配置为在特定条件下缓存结果。
注意
关于 Spring 缓存的更多信息,请参阅以下文档:
docs.spring.io/spring/docs/current/spring-framework-reference/html/cache.html
缓存失效
目前,搜索结果将永久缓存。使用默认的简单缓存管理器给我们提供的选项不多。我们还可以做一件事来提高我们的应用程序缓存。由于我们的类路径中有 Guava,我们可以用以下代码替换缓存配置中现有的缓存管理器:
package masterSpringMvc.config;
import com.google.common.cache.CacheBuilder;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.guava.GuavaCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
@Configuration
@EnableCaching
public class CacheConfiguration {
@Bean
public CacheManager cacheManager() {
GuavaCacheManager cacheManager = new GuavaCacheManager("searches");
cacheManager
.setCacheBuilder(
CacheBuilder.newBuilder()
.softValues()
.expireAfterWrite(10, TimeUnit.MINUTES)
);
return cacheManager;
}
}
这将构建一个 10 分钟后过期的缓存,并使用软值,这意味着如果 JVM 内存不足,条目将被清理。
尝试调整 Guava 的缓存构建器。你可以为你的测试指定更小的时间单位,甚至指定不同的缓存策略。
注意
请参阅code.google.com/p/guava-libraries/wiki/CachesExplained中的文档。
分布式缓存
我们已经有了 Redis 配置文件。如果 Redis 可用,我们也可以将其用作我们的缓存提供者。这将允许我们在多个服务器之间分布缓存。让我们更改RedisConfig类:
package masterSpringMvc.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import java.util.Arrays;
@Configuration
@Profile("redis")
@EnableRedisHttpSession
public class RedisConfig {
@Bean(name = "objectRedisTemplate")
public RedisTemplate objectRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
@Primary @Bean
public CacheManager cacheManager(@Qualifier("objectRedisTemplate") RedisTemplate template) {
RedisCacheManager cacheManager = new RedisCacheManager(template);
cacheManager.setCacheNames(Arrays.asList("searches"));
cacheManager.setDefaultExpiration(36_000);
return cacheManager;
}
}
使用此配置,如果我们以“Redis”配置文件运行我们的应用程序,由于它被标注为@Primary,将使用 Redis 缓存管理器而不是CacheConfig类中定义的缓存管理器。
这将允许缓存在我们在多个服务器上进行扩展时进行分布式。Redis 模板用于序列化缓存返回值和参数,并且需要对象实现Serializable接口。
异步方法
我们的应用程序中仍然存在瓶颈;当用户搜索十个关键词时,每个搜索将依次执行。我们可以通过使用不同的线程同时启动所有搜索来轻松提高我们应用程序的速度。
要启用 Spring 的异步功能,必须使用@EnableAsync注解。这将透明地执行任何标注了@Async的方法,使用java.util.concurrent.Executor。
通过实现AsyncConfigurer接口,可以自定义默认的执行器。让我们在config包中创建一个新的配置类,称为AsyncConfig。
package masterSpringMvc.config;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
@Configuration
@EnableAsync
public class AsyncConfiguration implements AsyncConfigurer {
protected final Log logger = LogFactory.getLog(getClass());
@Override
public Executor getAsyncExecutor() {
return Executors.newFixedThreadPool(10);
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> logger.error("Uncaught async error", ex);
}
}
使用此配置,我们确保整个应用程序中分配给处理异步任务的最大线程数不超过 10 个。在一个 Web 应用程序中,每个客户端都有一个专用的线程,这非常重要。你使用的线程越多,它们阻塞的时间越长,你能够处理的客户端请求就越少。
让我们标注我们的搜索方法并使其异步。我们需要使其返回Future的子类型,这是一个表示异步结果的 Java 并发类。
我们将创建一个新的TwitterSearch类实现,该实现将在不同的线程中查询搜索 API。实现有点复杂,所以我将其分解成几个小部分。
首先,我们需要使用 @Async 注解来注释将要查询 API 的方法,以告诉 Spring 使用我们的执行器来安排任务。同样,Spring 将使用代理来完成其魔法,因此此方法必须在与调用它的服务不同的类中。如果这个组件也能使用我们的缓存那就更好了。这将导致我们创建这个组件:
@Component
private static class AsyncSearch {
protected final Log logger = LogFactory.getLog(getClass());
private SearchCache searchCache;
@Autowired
public AsyncSearch(SearchCache searchCache) {
this.searchCache = searchCache;
}
@Async
public ListenableFuture<List<LightTweet>> asyncFetch(String searchType, String keyword) {
logger.info(Thread.currentThread().getName() + " - Searching for " + keyword);
return new AsyncResult<>(searchCache.fetch(searchType, keyword));
}
}
还不要创建这个类。让我们先看看我们的服务需要什么。
ListenableFuture 抽象允许我们在未来完成之后添加回调,无论是正确结果还是发生异常的情况。
等待一组异步任务的算法看起来像这样:
@Override
public List<LightTweet> search(String searchType, List<String> keywords) {
CountDownLatch latch = new CountDownLatch(keywords.size());
List<LightTweet> allTweets = Collections.synchronizedList(new ArrayList<>());
keywords
.stream()
.forEach(keyword -> asyncFetch(latch, allTweets, searchType, keyword));
await(latch);
return allTweets;
}
如果你不知道 CountDownLatch 方法,它只是一个简单的阻塞计数器。
await() 方法将等待直到闩锁达到 0 以解锁线程。
在前面的代码中显示的 asyncFetch 方法将为我们的每个 asynFetch 方法附加一个回调。回调将结果添加到 allTweets 列表并减少闩锁。一旦每个回调都被调用,该方法将返回所有推文。
明白了?以下是最终的代码:
package masterSpringMvc.search;
import masterSpringMvc.search.cache.SearchCache;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.AsyncResult;
import org.springframework.social.twitter.api.SearchParameters;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
@Service
@Profile("async")
public class ParallelSearchService implements TwitterSearch {
private final AsyncSearch asyncSearch;
@Autowired
public ParallelSearchService(AsyncSearch asyncSearch) {
this.asyncSearch = asyncSearch;
}
@Override
public List<LightTweet> search(String searchType, List<String> keywords) {
CountDownLatch latch = new CountDownLatch(keywords.size());
List<LightTweet> allTweets = Collections.synchronizedList(new ArrayList<>());
keywords
.stream()
.forEach(keyword -> asyncFetch(latch, allTweets, searchType, keyword));
await(latch);
return allTweets;
}
private void asyncFetch(CountDownLatch latch, List<LightTweet> allTweets, String searchType, String keyword) {
asyncSearch.asyncFetch(searchType, keyword)
.addCallback(
tweets -> onSuccess(allTweets, latch, tweets),
ex -> onError(latch, ex));
}
private void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
private static void onSuccess(List<LightTweet> results, CountDownLatch latch, List<LightTweet> tweets) {
results.addAll(tweets);
latch.countDown();
}
private static void onError(CountDownLatch latch, Throwable ex) {
ex.printStackTrace();
latch.countDown();
}
@Component
private static class AsyncSearch {
protected final Log logger = LogFactory.getLog(getClass());
private SearchCache searchCache;
@Autowired
public AsyncSearch(SearchCache searchCache) {
this.searchCache = searchCache;
}
@Async
public ListenableFuture<List<LightTweet>> asyncFetch(String searchType, String keyword) {
logger.info(Thread.currentThread().getName() + " - Searching for " + keyword);
return new AsyncResult<>(searchCache.fetch(searchType, keyword));
}
}
}
现在,为了使用这个实现,我们需要使用 async 配置文件来运行应用程序。
我们可以通过用逗号分隔来同时运行具有多个活动配置文件,如下所示:
--spring.profiles.active=redis,async
如果我们在多个术语上启动搜索,我们可以看到类似这样的内容:
pool-1-thread-3 - Searching groovy
pool-1-thread-1 - Searching spring
pool-1-thread-2 - Searching java
这表明不同的搜索是并行进行的。
Java 8 实际上引入了一种名为 CompletableFuture 的新类型,这是一个更好的 API,用于操作未来。可完成未来的主要问题是没有任何执行器可以在不写一些代码的情况下与它们一起工作。这超出了本文的范围,但你可以查看我的博客上关于这个主题的文章:geowarin.github.io/spring/2015/06/12/completable-futures-with-spring-async.html。
注意
免责声明
以下部分包含大量的 JavaScript。显然,我认为你应该看看代码,特别是如果你不是 JavaScript 的粉丝。是时候学习了。话虽如此,即使 WebSocket 非常酷,它也不是必需的。你可以安全地跳到最后一个章节,并立即部署你的应用程序。
ETags
我们的 Twitter 结果被整洁地缓存,所以用户刷新结果页面不会触发对 Twitter API 的额外搜索。然而,即使结果没有变化,响应也会多次发送给这个用户,这会浪费带宽。
ETag 是网页响应数据的哈希,作为头部发送。客户端可以记住资源的 ETag 并通过 If-None-Match 头部将最后已知版本发送到服务器。这允许服务器在请求在此期间没有变化的情况下回答 304 Not Modified。
Spring 有一个特殊的 Servlet 过滤器,称为ShallowEtagHeaderFilter,用于处理 ETag。只需将其作为 bean 添加到MasterSpringMvc4Application配置类中:
@Bean
public Filter etagFilter() {
return new ShallowEtagHeaderFilter();
}
这将自动为你的响应生成 ETag,只要响应没有缓存控制头。
现在如果我们查询我们的 RESTful API,我们可以看到 ETag 随服务器响应一起发送:
> http GET 'http://localhost:8080/api/search/mixed;keywords=spring' -a admin:admin
HTTP/1.1 200 OK
Content-Length: 1276
Content-Type: application/json;charset=UTF-8
Date: Mon, 01 Jun 2015 11:29:51 GMT
ETag: "00a66d6dd835b6c7c60638eab976c4dd7"
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=662848E4F927EE9A1BA2006686ECFE4C; Path=/; HttpOnly
现在如果我们再次请求相同的资源,指定我们在If-None-Match头中知道的上一个 ETag,服务器将自动响应304 Not Modified状态:
> http GET 'http://localhost:8080/api/search/mixed;keywords=spring' If-None-Match:'"00a66d6dd835b6c7c60638eab976c4dd7"' -a admin:admin
HTTP/1.1 304 Not Modified
Date: Mon, 01 Jun 2015 11:34:21 GMT
ETag: "00a66d6dd835b6c7c60638eab976c4dd7"
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=CA956010CF268056C241B0674C6C5AB2; Path=/; HttpOnly
提示
由于我们的搜索具有并行性,针对不同关键词获取的推文可能会以不同的顺序到达,这将导致 ETag 发生变化。如果你想让这种技术适用于多个搜索,请在将搜索结果发送给客户端之前考虑对搜索结果进行排序。
如果我们要利用这一点,显然我们需要重写我们的客户端代码来处理它们。我们将使用 jQuery 的简单解决方案来实现这一点,利用浏览器的本地存储来保存用户的最新查询。
首先,从我们的模型中移除tweets变量;我们不会再从服务器进行搜索。你可能需要修改一个或两个测试来反映这一变化。
在继续之前,让我们将 lodash 添加到我们的 JavaScript 库中。如果你不知道 lodash,可以说它是 JavaScript 的 Apache Utils。你可以像这样将 lodash 添加到你的项目依赖中:
compile 'org.webjars.bower:lodash:3.9.3'
将其添加到default.html布局中,位于 materialize 的 JavaScript 之下:
<script src="img/lodash.js"></script>
我们将修改resultPage.html文件,并将显示推文的区域留空:
<!DOCTYPE html>
<html
layout:decorator="layout/default">
<head lang="en">
<title>Hello twitter</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h2 class="indigo-text center" th:text="|Tweet results for ${search}|">Tweets</h2>
<ul id="tweets" class="collection">
</ul>
</div>
</body>
</html>
然后,我们在页面的底部添加一个脚本元素,就在关闭 body 之前:
<script layout:fragment="script" th:inline="javascript">
/*<![CDATA[*/
var baseUrl = /*[[@{/api/search}]]*/ "/";
var currentLocation = window.location.href;
var search = currentLocation.substr(currentLocation.lastIndexOf('/'));
var url = baseUrl + search;
/*]]>*/
</script>
之前的脚本将负责构建我们的请求的 URL。我们将通过发出一个简单的 jQuery AJAX 调用使用它:
$.ajax({
url: url,
type: "GET",
beforeSend: setEtag,
success: onResponse
});
我们将使用beforeSend回调在调用之前修改请求头:
function getLastQuery() {
return JSON.parse(localStorage.getItem('lastQuery')) || {};
}
function storeQuery(query) {
localStorage.setItem('lastQuery', JSON.stringify(query));
}
function setEtag(xhr) {
xhr.setRequestHeader('If-None-Match', getLastQuery().etag)
}
如你所见,我们可以轻松地从本地存储中读取和写入。这里的难点是本地存储只支持字符串,因此我们必须解析和序列化查询对象为 JSON。
如果 HTTP 状态是304 Not Modified,我们可以通过从本地存储检索内容来处理响应:
function onResponse(tweets, status, xhr) {
if (xhr.status == 304) {
console.log('Response has not changed');
tweets = getLastQuery().tweets
}
var etag = xhr.getResponseHeader('Etag');
storeQuery({tweets: tweets, etag: etag});
displayTweets(tweets);
}
function displayTweets(tweets) {
$('#tweets').empty();
$.each(tweets, function (index, tweet) {
addTweet(tweet);
})
}
对于你将要看到的addTweet函数,我使用 lodash,一个非常有用的 JavaScript 实用库,来生成模板。向页面添加推文的函数可以写成如下:
function addTweet(tweet) {
var template = _.template('<li class="collection-item avatar">' +
'<img class="circle" src="img/${tweet.profileImageUrl}" />' +
'<span class="title">${tweet.user}</span>' +
'<p>${tweet.text}</p>' +
'</li>');
$('#tweets').append(template({tweet: tweet}));
}
这就是大量的 JavaScript!在单页应用中使用像 Backbone.js 这样的库来泛化这个模式更有意义。不过,希望这能作为一个简单的例子,说明如何在你的应用程序中实现 ETag。
如果你尝试多次刷新搜索页面,你会看到内容没有变化,并且会立即显示:

ETags 还有其他用途,例如用于事务的乐观锁定(它能让您知道客户端在任何时候应该基于对象的哪个版本进行工作)。在发送数据之前在服务器端对数据进行哈希处理也是额外的工作,但它将节省带宽。
WebSocket
我们还可以考虑的一种优化是将数据作为它对服务器可用时发送给客户端。由于我们在多个线程中检索搜索结果,数据将分多个块到来。我们可以分块发送它们,而不是等待所有结果。
Spring 对 WebSocket 协议提供了出色的支持,该协议允许客户端与服务器保持长时间运行的连接。数据可以在连接的两端通过 WebSocket 推送,并且消费者将实时获取数据。
我们将使用一个名为 SockJS 的 JavaScript 库来确保与所有浏览器的兼容性。如果用户使用的是过时的浏览器,SockJS 将透明地回退到另一种策略。
我们还将使用 StompJS 连接到我们的消息代理。
将以下库添加到您的构建中:
compile 'org.springframework.boot:spring-boot-starter-websocket'
compile 'org.springframework:spring-messaging'
compile 'org.webjars:sockjs-client:1.0.0'
compile 'org.webjars:stomp-websocket:2.3.3'
将 WebJars 添加到我们的默认 Thymeleaf 模板中:
<script src="img/sockjs.js"></script>
<script src="img/stomp.js"></script>
要在我们的应用程序中配置 WebSocket,我们需要添加一些配置:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/ws");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/twitterSearch").withSockJS();
}
}
这将配置我们应用程序中可用的不同频道。SockJS 客户端将连接到twitterSearch端点,并将数据推送到服务器的/ws/频道,并且能够监听/topic/以获取变化。
这将允许我们在新的控制器中注入SimpMessagingTemplate,以便在/topic/searchResult频道中向客户端推送数据,如下所示:
@Controller
public class SearchSocketController {
private CachedSearchService searchService;
private SimpMessagingTemplate webSocket;
@Autowired
public SearchSocketController(CachedSearchService searchService, SimpMessagingTemplate webSocket) {
this.searchService = searchService;
this.webSocket = webSocket;
}
@MessageMapping("/search")
public void search(@RequestParam List<String> keywords) throws Exception {
Consumer<List<LightTweet>> callback = tweet -> webSocket.convertAndSend("/topic/searchResults", tweet);
twitterSearch(SearchParameters.ResultType.POPULAR, keywords, callback);
}
public void twitterSearch(SearchParameters.ResultType resultType, List<String> keywords, Consumer<List<LightTweet>> callback) {
keywords.stream()
.forEach(keyword -> {
searchService.search(resultType, keyword)
.addCallback(callback::accept, Throwable::printStackTrace);
});
}
}
在我们的resultPage中,JavaScript 代码非常简单:
var currentLocation = window.location.href;
var search = currentLocation.substr(currentLocation.lastIndexOf('=') + 1);
function connect() {
var socket = new SockJS('/hello');
stompClient = Stomp.over(socket);
// stompClient.debug = null;
stompClient.connect({}, function (frame) {
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/searchResults', function (result) {
displayTweets(JSON.parse(result.body));
});
stompClient.send("/app/search", {}, JSON.stringify(search.split(',')));
});
}
displayTweets函数与之前基本相同:
function displayTweets(tweets) {
$.each(tweets, function (index, tweet) {
addTweet(tweet);
})
}
function addTweet(tweet) {
var template = _.template('<li class="collection-item avatar">' +
'<img class="circle" src="img/${tweet.profileImageUrl}" />' +
'<span class="title">${tweet.userName}</span>' +
'<p>${tweet.text}</p>' +
'</li>');
$('#tweets').append(template({tweet: tweet}));
}
现在您知道了!客户端现在将接收到应用程序中所有搜索的结果——实时!
在将此推送到生产环境之前,还需要做更多的工作。以下是一些想法:
-
为客户端创建子频道以私密地监听变化
-
当客户端完成使用频道时关闭频道
-
为新推文添加 CSS 过渡效果,让用户感觉到它是实时的
-
使用真实的代理,如 RabbitMQ,以允许后端随着连接的增多而扩展
WebSocket 远不止这个简单的例子。别忘了查看docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html文档以获取更多信息。
检查点
在本章中,我们创建了两个新的配置:AsyncConfiguration,它将允许我们使用@Async注解将任务提交给执行器,以及CacheConfiguration,它将创建一个CacheManager接口并允许我们使用@Cacheable注解。由于我们可以使用 Redis 作为缓存管理器,我们还修改了RedisConfig类。
我们创建了一个SearchCache类,其中包含了一个推文的缓存,现在我们有两种TwitterSearch实现可供选择:传统的SearchService,它将同步获取每个结果,以及ParallelSearchService,它将在不同的线程中发出每个查询:

摘要
在本章中,我们看到了两种与性能改进相关的不同理念。一开始,我们试图通过缓存数据和尽可能少地与服务器建立连接来减少客户端使用的带宽。
然而,在第二部分,我们开始做一些更高级的操作,允许并行运行搜索,并且每个客户端通过 WebSocket 与服务器保持持久的连接同步。这将允许客户端实时接收更新,我们的应用程序将感觉更加反应灵敏,但会消耗更多的线程。
我强烈建议我们在进入下一章并部署我们的应用程序之前,先对结果进行润色!
第九章。将您的 Web 应用程序部署到云端
在本章中,我们将游览不同的云服务提供商,了解分布式架构的挑战和优势,并了解如何将您的 Web 应用程序部署到 Pivotal Web 服务和 Heroku。
选择您的托管服务
云托管有多种形式。对于开发者来说,选择将主要在平台即服务(PaaS)和基础设施即服务(IaaS)之间。
使用最新版本,您通常会有一个裸机,您可以对其进行管理,并在其上安装应用程序所需的所有服务。
如果我们排除 Docker 等技术(它绝对令人惊叹,您绝对应该尝试一下),这实际上与传统托管非常相似,您的运维团队将不得不设置和维护一个应用程序可以运行的环境。
另一方面,PaaS 使得在开发过程中通过简单的推送部署工作流程轻松部署您的应用程序。
最知名的服务提供商是:
-
由 Pivotal 支持的 Cloud Foundry
-
Red Hat 的 OpenShift
-
Salesforce 在 2010 年收购了 Heroku
这三个提供商中的每一个都有不同的优缺点。我将尝试为您概述这些。
Cloud Foundry
由 Spring 背后的 Pivotal 支持的 Pivotal Web 服务在 Cloud Foundry 上运行,这是一个由基金会维护的开源 PaaS,并附带一个有趣的套餐。
他们提供 60 天的免费试用,并且他们的定价是您实例分配的内存和您拥有的实例数量的函数。
它们的价格从每月 2.70 美元的最小(128 Mb)实例到每月 43.20 美元的 2 GB 实例不等。
如果您想尝试一下,免费试用不需要信用卡。他们有一个市场,可以轻松安装服务,如 Redis 或 PostgreSQL,但免费选项相当有限。他们有一个很好的命令行实用程序,可以从您的控制台管理应用程序。您可以使用构建包或直接推送 JAR 进行部署。
小贴士
构建包将尝试猜测您正在使用的堆栈,并以最标准的方式构建您的应用程序(例如,对于 Maven 使用 mvn package,对于 Gradle 使用 ./gradlew stage 等)。
注意
请参阅以下 URL 上的教程,以将您的应用程序部署到 Cloud Foundry:
docs.cloudfoundry.org/buildpacks/java/gsg-spring.html
OpenShift
OpenShift 由 Red Hat 维护,并由 OpenShift Origin 驱动,这是一个开源设施,在 Google 的 Kubernetes 上运行 Docker 容器。
它的价格合理,提供了很多自由度,因为它既是 PaaS 也是 IaaS。其定价基于齿轮,即运行应用程序的容器,或像 Jenkins 这样的服务,或数据库。
OpenShift 提供一个免费计划,提供三个小型齿轮。除非您输入您的账单信息,否则您的应用程序每月必须空闲 24 小时。
额外或更大的齿轮按每月约 $15(最小)和 $72(最大)计费。
要在 OpenShift 上部署 Spring Boot 应用程序,你必须使用 DIY 卡带。它比其他基于构建包的 PaaS 工作量稍大,但配置起来也更简单。
请查看有关 OpenShift 上 Spring Boot 教程的博客文章,该文章可在 blog.codeleak.pl/2015/02/openshift-diy-build-spring-boot.html 找到。
Heroku
Heroku 是一个广为人知的 PaaS,拥有丰富的文档和基于构建包的以代码为中心的方法。它可以连接到许多称为附加组件的服务,但使用它们需要你的账单信息。
对于免费项目来说,这真的很吸引人,并且启动非常快。缺点是,如果你想要扩展,直接成本将超过每月 $25。免费实例在 30 分钟无活动后将进入睡眠模式,这意味着免费的 Heroku 应用程序始终需要 30 秒来加载。
Heroku 拥有出色的管理仪表板和命令行工具。对于本章,我选择了 Heroku,因为它非常直观。在这里你将掌握的概念适用于大多数 PaaS。
只要你不使用 Redis 附加组件,你就可以遵循本章的大部分内容并部署你的应用程序,而无需提供信用卡信息。如果你选择免费计划,你将不会收费。
将你的 Web 应用程序部署到 Pivotal Web Services
如果你想要将你的应用程序部署到 Pivotal Web Services(PWS),请遵循本节。
安装 Cloud Foundry CLI 工具
我们创建 Cloud Foundry 应用程序的第一件事是在 PWS 上设置一个账户。这已在 docs.run.pivotal.io/starting/ 中记录。
你将被要求创建一个组织,并且每个新的组织将在组织内创建一个默认空间(开发)。如下截图所示:

在左侧导航栏中,你会看到一个链接到 工具,你可以从这里下载 CLI。它也可以从开发者控制台获取。选择适合你操作系统的适当包:

组装应用程序
我们的应用程序只需组装即可部署。
PWS 的好处是,你不需要推送你的源代码来部署。你可以生成 JAR,推送它,一切都会自动检测。
我们可以使用以下命令对此进行打包以进行部署:
./gradlew assemble
这将在 build/libs 目录中创建一个 jar 文件。此时,你可以执行以下命令。以下命令将你的部署目标指向 PWS(run.pivotal.io)内的空间:
$ cf login -a api.run.pivotal.io -u <account email> -p <password> -o <organization> -s development
API endpoint: api.run.pivotal.io
Authenticating...
OK
Targeted org <account org>
Targeted space development
API endpoint: https://api.run.pivotal.io (API version: 2.33.0)
User: <account email>
Org: <account organization>
Space: <account space>
一旦你成功登录,你可以使用以下命令推送你的 jar 文件。你需要想出一个可用的名字:
$ cf push your-app-name -p build/libs/masterSpringMvc-0.0.1-SNAPSHOT.jar
Creating app msmvc4 in org Northwest / space development as wlund@pivotal.io...
OK
Creating route msmvc4.cfapps.io...
OK
Binding msmvc4.cfapps.io to msmvc4...
OK
Uploading msmvc4...
Uploading app files from: build/libs/masterSpringMvc-0.0.1-SNAPSHOT.jar
Uploading 690.8K, 108 files
Done uploading
OK
Starting app msmvc4 in org <Organization> / space development as <account email>
-----> Downloaded app package (15M)
-----> Java Buildpack Version: v3.1 | https://github.com/cloudfoundry/java-buildpack.git#7a538fb
-----> Downloading Open Jdk JRE 1.8.0_51 from https://download.run.pivotal.io/openjdk/trusty/x86_64/openjdk-1.8.0_51.tar.gz (1.5s)
Expanding Open Jdk JRE to .java-buildpack/open_jdk_jre (1.4s)
-----> Downloading Open JDK Like Memory Calculator 1.1.1_RELEASE from https://download.run.pivotal.io/memory-calculator/trusty/x86_64/memory-calculator-1.1.1_RELEASE (0.1s)
Memory Settings: -Xmx768M -Xms768M -XX:MaxMetaspaceSize=104857K -XX:MetaspaceSize=104857K -Xss1M
-----> Downloading Spring Auto Reconfiguration 1.7.0_RELEASE from https://download.run.pivotal.io/auto-reconfiguration/auto-reconfiguration-1.7.0_RELEASE.jar (0.0s)
-----> Uploading droplet (59M)
0 of 1 instances running, 1 starting
1 of 1 instances running
App started
OK
App msmvc4 was started using this command `CALCULATED_MEMORY=$($PWD/.java-buildpack/open_jdk_jre/bin/java-buildpack-memory-calculator-1.1.1_RELEASE -memorySizes=metaspace:64m.. -memoryWeights=heap:75,metaspace:10,stack:5,native:10 -totMemory=$MEMORY_LIMIT) && SERVER_PORT=$PORT $PWD/.java-buildpack/open_jdk_jre/bin/java -cp $PWD/.:$PWD/.java-buildpack/spring_auto_reconfiguration/spring_auto_reconfiguration-1.7.0_RELEASE.jar -Djava.io.tmpdir=$TMPDIR -XX:OnOutOfMemoryError=$PWD/.java-buildpack/open_jdk_jre/bin/killjava.sh $CALCULATED_MEMORY org.springframework.boot.loader.JarLauncher`
Showing health and status for app msmvc4 in org <Organization> / space development as <Account Email>
OK
requested state: started
instances: 1/1
usage: 1G x 1 instances
urls: msmvc4.cfapps.io
last uploaded: Tue Jul 28 22:04:08 UTC 2015
stack: cflinuxfs2
buildpack: java-buildpack=v3.1-https://github.com/cloudfoundry/java-buildpack.git#7a538fb java-main open-jdk-like-jre=1.8.0_51 open-jdk-like-memory-calculator=1.1.1_RELEASE spring-auto-reconfiguration=1.7.0_RELEASE
state since cpu memory disk details
#0 running 2015-07-28 03:05:04 PM 0.0% 450.9M of 1G 137M of 1G
平台代表你做了很多事情。它为你配置一个容器并检测所需的构建包,在这种情况下,是 Java。
它随后安装所需的 JDK 并上传我们指向的应用程序。它为应用程序创建一个路由,并向我们报告,然后为我们启动应用程序。
现在,你可以在开发控制台中查看应用程序:

在选择高亮的路由后,应用程序将可供使用。访问msmvc4.cfapps.io,然后你会看到以下截图:

太棒了!
唯一不能工作的是文件上传。然而,我们将在一分钟内解决这个问题。
激活 Redis
在你的应用程序服务中,你可以选择许多服务之一。其中之一是 Redis Cloud,它有一个包含 30MB 存储空间的免费计划。请选择这个计划。
在表单中,选择你喜欢的任何名字并将服务绑定到你的应用程序上。默认情况下,Cloud Foundry 将在你的环境中注入一些与该服务相关的属性:
-
cloud.services.redis.connection.host -
cloud.services.redis.connection.port -
cloud.services.redis.connection.password -
cloud.services.redis.connection.uri
这些属性将始终遵循相同的约定,因此当你添加更多服务时,跟踪你的服务将会很容易。
默认情况下,Cloud Foundry 启动 Spring 应用程序并激活 Cloud 配置文件。
我们可以利用这一点,在src/main/resources中创建一个application-cloud.properties文件,当我们的应用程序在 PWS 上运行时将使用此文件:
spring.profiles.active=prod,redis
spring.redis.host=${cloud.services.redis.connection.host}
spring.redis.port=${cloud.services.redis.connection.port}
spring.redis.password=${cloud.services.redis.connection.password}
upload.pictures.uploadPath=file:/tmp
这将把我们的 Redis 实例绑定到我们的应用程序上,并激活两个额外的配置文件:prod和redis。
我们还更改了上传图片将到达的路径。请注意,在云上使用文件系统遵循不同的规则。有关更多详细信息,请参阅以下链接:
docs.run.pivotal.io/devguide/deploy-apps/prepare-to-deploy.html#filesystem
我们最后需要做的就是禁用 Spring Session 的一个功能,该功能在我们的托管实例上不可用:
@Bean
@Profile({"cloud", "heroku"})
public static ConfigureRedisAction configureRedisAction() {
return ConfigureRedisAction.NO_OP;
}
注意
你会看到这个配置也将应用于 Heroku。
就这样。你可以重新组装你的 Web 应用程序并再次推送。现在,你的会话和应用程序缓存将存储在 Redis 上!
您可能想探索市场中的其他可用功能,例如绑定到数据或消息服务、扩展应用程序以及管理超出本介绍范围的应用程序的健康状况。
享受乐趣并享受平台提供的生产力!
在 Heroku 上部署您的 Web 应用程序
在本节中,我们将免费将您的应用程序部署到 Heroku。我们甚至将使用免费的 Redis 实例来存储我们的会话和缓存。
安装工具
创建 Heroku 应用程序的第一步是下载位于toolbelt.heroku.com的命令行工具。
在 Mac 上,您也可以使用brew命令安装它:
> brew install heroku-toolbelt
在 Heroku 上创建一个账户,并使用heroku login将工具包链接到您的账户:
> heroku login
Enter your Heroku credentials.
Email: geowarin@mail.com
Password (typing will be hidden):
Authentication successful.
然后,转到您的应用程序根目录,并输入heroku create appName --region eu。将appName替换为您选择的名称。如果您不提供名称,它将自动生成:
> heroku create appname --region eu
Creating appname... done, region is eu
https://appname.herokuapp.com/ | https://git.heroku.com/appname.git
Git remote heroku added
如果您已经创建了一个带有 UI 的应用程序,请转到您的应用程序根目录,并简单地添加远程heroku git:remote -a yourapp。
这些命令的作用是为我们的 Git 仓库添加一个名为heroku的 Git 远程。在 Heroku 上部署的过程只是将您的其中一个分支推送到 Heroku。远程安装的 Git 钩子将处理其余部分。
如果您输入git remote -v命令,您应该看到heroku版本:
> git remote -v
heroku https://git.heroku.com/appname.git (fetch)
heroku https://git.heroku.com/appname.git (push)
origin https://github.com/Mastering-Spring-MVC-4/mastering-spring-mvc4-code.git (fetch)
origin https://github.com/Mastering-Spring-MVC-4/mastering-spring-mvc4-code.git (push)
设置应用程序
运行 Gradle 应用程序与 Heroku 需要两个要素:一个名为stage的构建文件中的任务,以及一个包含运行我们应用程序的命令的微小文件,称为ProcFile。
Gradle
Gradle 构建包将自动尝试在您的应用程序根目录运行./gradlew stage命令。
注意
您可以在github.com/heroku/heroku-buildpack-gradle上获取有关 Gradle 构建包的更多信息。
我们还没有“stage”任务。将以下代码添加到您的build.gradle文件中:
task stage(type: Copy, dependsOn: [clean, build]) {
from jar.archivePath
into project.rootDir
rename {
'app.jar'
}
}
stage.mustRunAfter(clean)
clean << {
project.file('app.jar').delete()
}
这将定义一个名为stage的任务,该任务将复制 Spring Boot 在应用程序根目录生成的 jar 文件,并将其命名为app.jar。
这样找罐子会容易得多。stage任务依赖于clean任务和build任务,这意味着这两个任务都会在stage任务开始之前执行。
默认情况下,Gradle 会尝试优化任务依赖图。因此,我们必须提供提示并强制clean任务在stage之前运行。
最后,我们在已存在的clean任务中添加一个新的指令,即删除生成的app.jar文件。
现在,如果您运行./gradlew stage,它应该会运行测试并将打包的应用程序放在项目的根目录下。
Procfile
当 Heroku 检测到 Gradle 应用程序时,它将自动运行一个安装了 Java 8 的容器。因此,我们几乎没有配置要处理。
我们需要一个包含运行我们应用程序所使用的 shell 命令的文件。在你的应用程序根目录下创建一个名为 Procfile 的文件:
web: java -Dserver.port=$PORT -Dspring.profiles.active=heroku,prod -jar app.jar
这里有几个需要注意的事项。首先,我们将我们的应用程序声明为一个网络应用程序。我们还使用环境变量重新定义了应用程序将运行的端口。这非常重要,因为你的应用程序将与许多其他应用程序共存,并且每个应用程序只分配一个端口。
最后,你可以看到我们的应用程序将使用两个配置文件运行。第一个是我们在上一章中创建的 prod 配置文件,用于优化性能,以及我们即将创建的新 heroku 配置文件。
一个 Heroku 配置文件
我们不想将敏感信息,例如我们的 Twitter 应用密钥,放入源代码控制。因此,我们必须创建一些属性,这些属性将从应用程序环境中读取:
spring.social.twitter.appId=${twitterAppId}
spring.social.twitter.appSecret=${twitterAppSecret}
为了使这生效,你必须在 Heroku 上配置我们之前讨论的两个环境变量。你可以使用工具带完成此操作:
> heroku config:set twitterAppId=appId
或者,你可以转到你的仪表板,并在设置选项卡中配置环境:

注意
访问 devcenter.heroku.com/articles/config-vars 获取更多信息。
运行你的应用程序
现在是时候在 Heroku 上运行我们的应用程序了!
如果你还没有这样做,请将所有更改提交到你的 master 分支。现在,只需使用 git push heroku master 将你的 master 分支推送到 heroku 远程。这将下载所有依赖项并从头开始构建你的应用程序,所以这可能需要一点时间:
> git push heroku master
Counting objects: 1176, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (513/513), done.
Writing objects: 100% (1176/1176), 645.63 KiB | 0 bytes/s, done.
Total 1176 (delta 485), reused 1176 (delta 485)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Gradle app detected
remote: -----> Installing OpenJDK 1.8... done
remote: -----> Building Gradle app...
remote: WARNING: The Gradle buildpack is currently in Beta.
remote: -----> executing ./gradlew stage
remote: Downloading https://services.gradle.org/distributions/gradle-2.3-all.zip
...
remote: :check
remote: :build
remote: :stage
remote:
remote: BUILD SUCCESSFUL
remote:
remote: Total time: 2 mins 36.215 secs
remote: -----> Discovering process types
remote: Procfile declares types -> web
remote:
remote: -----> Compressing... done, 130.1MB
remote: -----> Launching... done, v4
remote: https://appname.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy.... done.
To https://git.heroku.com/appname.git
* [new branch] master -> master
一旦应用程序构建完成,它将自动运行。输入 heroku logs 查看最新日志或 heroku logs -t 来跟踪它们。
你可以在控制台中看到你的应用程序正在运行,如果一切按计划进行,你将能够连接到 yourapp.herokuapp.com。如下截图所示:

我们已经上线了!是时候告诉你的朋友了!
激活 Redis
要在我们的应用程序中激活 Redis,我们可以选择几种替代方案。Heroku Redis 扩展是测试版。它完全免费,提供 20 MB 的存储、分析和日志。
注意
访问 elements.heroku.com/addons/heroku-redis 获取更多详情。
在这个阶段,你必须提供你的信用卡详细信息才能继续。
要为你的应用程序安装 Redis 扩展,请输入以下命令:
heroku addons:create heroku-redis:test
现在,我们已经激活了扩展,当我们的应用程序在 Heroku 上运行时,将有一个名为 REDIS_URL 的环境变量可用。
你可以使用 heroku config 命令检查变量是否已定义:
> heroku config
=== masterspringmvc Config Vars
JAVA_OPTS: -Xmx384m -Xss512k -XX:+UseCompressedOops
REDIS_URL: redis://x:xxx@ec2-xxx-xx-xxx-xxx.eu-west-1.compute.amazonaws.com:6439
由于 RedisConnectionFactory 类不理解 URI,我们需要对其进行一点调整:
@Configuration
@Profile("redis")
@EnableRedisHttpSession
public class RedisConfig {
@Bean
@Profile("heroku")
public RedisConnectionFactory redisConnectionFactory() throws URISyntaxException {
JedisConnectionFactory redis = new JedisConnectionFactory();
String redisUrl = System.getenv("REDIS_URL");
URI redisUri = new URI(redisUrl);
redis.setHostName(redisUri.getHost());
redis.setPort(redisUri.getPort());
redis.setPassword(redisUri.getUserInfo().split(":", 2)[1]);
return redis;
}
@Bean
@Profile({"cloud", "heroku"})
public static ConfigureRedisAction configureRedisAction() {
return ConfigureRedisAction.NO_OP;
}
}
我们现在在RedisConfig类中有两个针对 Heroku 特定的 bean。这些 bean 只有在redis和heroku配置文件都激活的情况下才会生效。
注意,我们还禁用了某些 Spring Session 配置。
Spring Session 通常会通过 Redis Pub/Sub 接口监听与销毁会话密钥相关的事件。
它将自动尝试在启动时配置 Redis 环境以激活监听器。在我们的这种安全环境中,除非你有管理员权限,否则不允许添加监听器。
在我们的情况下,这些 redis 监听器并不重要,因此我们可以安全地禁用此行为。更多信息,请访问docs.spring.io/spring-session/docs/current/reference/html5/#api-redisoperationssessionrepository-sessiondestroyedevent。
我们需要修改我们的Procfile文件,以便 Heroku 使用redis配置文件运行我们的应用程序:
web: java -Dserver.port=$PORT -Dspring.profiles.active=heroku,redis,prod -jar app.jar
提交你的更改并将代码推送到 Heroku。
提高你的应用程序
我们已经在线部署了一个相当不错的应用程序,但除非你让它变得如此,否则它既不实用也不原创。
努力让它变得更好、更个性化。一旦你为你的成就感到自豪,就在 Twitter 上使用#masterspringmvc标签分享你的应用程序 URL。
努力推送最好的应用程序。我们还有很多事情没有做。以下是一些想法:
-
删除用户的旧照片以避免保留未使用的照片
-
使用 Twitter 认证信息填写用户资料
-
与用户的账户互动
-
通过 WebSocket 通道查看你应用上的实时搜索
让你的想象力飞扬!
我的版本的应用程序部署在masterspringmvc.herokuapp.com。我将改进一些细节,使应用程序变得更加响应式。试着找出差异!
摘要
由于 Spring Boot,将我们的应用程序部署到云服务提供商上非常简单,因为它是一个可运行的 jar 文件。如今,云部署非常经济实惠,部署 Java 应用程序几乎变得太容易。
通过 Redis 支持的会话,我们为可扩展的应用程序奠定了基础。确实,我们可以轻松地添加多个服务器在负载均衡器后面,并按需吸收高流量。
唯一不可扩展的是我们的 WebSocket,它需要在消息代理(如 Rabbit MQ)上运行,需要进行额外的工作。
我确实记得一个时期,找到运行 Tomcat 的主机既罕见又昂贵。那些日子已经一去不复返了,未来属于网络开发者,所以让它发生吧!
在下一章中,我们将看到我们可以做什么来使我们的应用程序变得更好,讨论我们没有涉及的技术,一般性地讨论 Spring 生态系统,以及现代 Web 应用程序的挑战。
第十章。Spring Web 之外
在本章中,我们将看到我们已经走了多远,解决了哪些问题,以及哪些问题有待解决。
我们将一般性地讨论 Spring 生态系统,并特别讨论持久化、部署和单页应用程序。
Spring 生态系统
从 Web 到数据,Spring 是一个旨在以模块化方式解决各种问题的全面生态系统:

查看 Spring IO 平台spring.io/platform。
核心内容
在 Spring 框架的核心中,显然有一个依赖注入机制。
我们只是触及了安全特性和框架与 Groovy 的出色集成的表面。
执行
我们详细了解了 Spring Boot 的内容——为庞大的子项目网络带来简单性和凝聚力。
它允许您专注于真正重要的事情,即您的业务代码。
Spring XD 项目也非常有趣。其目标是提供处理、分析和转换或导出您数据的工具,并明确关注大数据。更多信息,请访问projects.spring.io/spring-xd。
数据
在开发我们的应用程序时,我们还没有探讨过如何在数据库中存储数据的问题。在 Pivotal 的参考架构中,有一个层级既用于关系型数据也用于非关系型(NoSQL)数据。
Spring 生态系统在标签spring-data下提供了许多有趣的解决方案,可以在projects.spring.io/spring-data/找到。
当我们构建缓存时,我们简要地了解了 Spring Data Redis,但 Spring Data 还有更多内容。
所有 Spring Data 项目都共享基本概念,例如模板 API,它是一个从持久化系统中检索和存储对象的抽象。
Spring Data JPA (projects.spring.io/spring-data-jpa/) 和 Spring Data Mongo (projects.spring.io/spring-data-mongodb/) 是一些最知名的 Spring Data 项目。它们允许您通过仓库操作实体,仓库是提供创建查询、持久化对象等设施的简单接口。
Petri Kainulainen (www.petrikainulainen.net/spring-data-jpa-tutorial/) 在 Spring Data 上有许多详尽的示例。它不使用 Spring Boot 提供的设施,但您应该能够通过指南轻松开始,例如在spring.io/guides/gs/accessing-data-jpa/可用的指南。
Spring Data REST 也是一个神奇的项目,它将通过 RESTful API 半自动地暴露你的实体。访问 spring.io/guides/gs/accessing-data-rest/ 获取详细的教程。
其他值得注意的项目
Spring Integration (projects.spring.io/spring-integration) 和 Spring Reactor (projectreactor.io) 也是我最喜欢的 Spring 项目之二。
Spring Reactor 是 Pivotal 对反应式流的实现,其理念是在服务器端提供完全非阻塞的 IO。
另一方面,Spring Integration 关注企业集成模式,并允许你设计通道来加载和转换来自异构系统的数据。
一个很好的、简单的例子,展示了你可以通过通道完成的事情,可以在这里看到:lmivan.github.io/contest/#_spring_boot_application。
如果你与异构和/或复杂的子系统进行通信,这些子系统绝对值得一看。
在 Spring 生态系统中的最后一个项目是我们还没有的 Spring Batch,这是一个用于处理企业系统日常运营中大量数据的非常有用的抽象。
部署
Spring Boot 提供了将你的 Spring 应用程序作为简单的 JAR 运行和分发的功能,在这方面取得了巨大的成功。
毫无疑问,这是一步正确的方向,但有时你的 Web 应用程序并不是你想要部署的唯一东西。
当处理一个具有多个服务器和数据源的复杂系统时,运维团队的工作可能会变得相当头疼。
Docker
谁没有听说过 Docker?它是容器世界中的新潮儿,并因其充满活力的社区而取得了巨大的成功。
Docker 背后的想法并不新鲜,它利用 LinuX 容器(LXC)和 cgroups 为应用程序提供一个完全隔离的环境来运行。
你可以在 Spring 网站上找到一个教程,它将指导你使用 Docker 的第一步。spring.io/guides/gs/spring-boot-docker。
Pivotal Cloud Foundry 多年来一直在他们的容器管理器 Warden 中使用容器技术。他们最近转向了 Garden,这是一个不仅支持 Linux 容器,还支持 Windows 容器的抽象。
Garden 是 Cloud Foundry 最新版本(称为 Diego)的一部分,它还允许将 Docker 镜像作为部署单元。
Cloud Foundry 的开发者版本也以 Lattice 的名字发布,可以在 spring.io/blog/2015/04/06/lattice-and-spring-cloud-resilient-sub-structure-for-your-cloud-native-spring-applications 找到。
如果你想要在没有命令行烦恼的情况下测试容器,我推荐你看看 Kitematic。有了这个工具,你可以运行 Jenkins 容器或 MongoDB,而无需在你的系统上安装二进制文件。更多信息请访问kitematic.com/。
Docker 生态系统中的另一个值得提及的工具是 Docker Compose。它允许你通过单个配置文件运行和链接多个容器。
参考以下链接以了解 Spring Boot 应用程序的示例,该应用程序由两个 Web 服务器、一个用于存储用户会话的 Redis 和一个用于负载均衡的 Nginx 实例组成。java.dzone.com/articles/spring-session-demonstration。当然,关于 Docker Swarm 还有更多要学习的内容,它将允许你通过简单的命令扩展你的应用程序,以及 Docker Machine,它将在任何机器上为你创建 Docker 主机,包括云服务提供商。
Google Kubernetes 和 Apache Mesos 也是从 Docker 容器中受益极大的分布式系统的优秀例子。
单页应用程序
大多数今天的 Web 应用程序都是用 JavaScript 编写的。Java 被降级到后端,并承担着处理数据和业务规则的重要角色。然而,现在大部分的 GUI 操作都在客户端进行。
这在响应速度和用户体验方面有很好的理由,但那些应用程序增加了额外的复杂性。
开发者现在必须精通 Java 和 JavaScript,而且框架的数量可能会在最初让人感到有些压倒。
参与者
如果你想要深入了解 JavaScript,我强烈推荐 Dave Syer 的 Spring 和 AngularJS 教程,该教程可在spring.io/guides/tutorials/spring-security-and-angular-js找到。
选择一个 JavaScript MVC 框架也可能有点困难。AngularJS 多年来一直受到 Java 社区的青睐,但人们似乎正在远离它。更多信息,请访问gist.github.com/tdd/5ba48ba5a2a179f2d0fa。
其他替代方案包括以下:
-
BackboneJS:这是一个非常简单的 MVC 框架,它建立在 Underscore 和 jQuery 之上。
-
Ember:这是一个提供更多与数据交互功能的综合性系统。
-
React:这是 Facebook 的最新项目。它处理视图的新颖且非常有趣的哲学。它的学习曲线相当陡峭,但它是设计 GUI 框架时一个非常有意思的系统。
React 是目前我最喜欢的项目。它让你专注于视图及其单向数据流使得推理应用程序的状态变得容易。然而,它仍然处于 0.13 版本。这使得它既非常有趣,因为充满活力的社区总是能提出新的解决方案和想法,又有些令人不安,因为即使经过超过 2 年的开源开发,前方的道路仍然似乎很长。有关“通往 1.0 之路”的信息,请访问facebook.github.io/react/blog/2014/03/28/the-road-to-1.0.html。
未来
我看到很多 Java 开发者抱怨 JavaScript 的宽容性,并且很难接受它不是一个强类型语言的事实。
有其他替代方案,例如TypeScript(www.typescriptlang.org/),这些方案非常有趣,为我们这些 Java 开发者提供了我们一直用来简化生活的东西:接口、类、IDE 中的有用支持以及自动完成。
许多人对 Angular 的下一个版本(2.0)下注,这个版本将非常著名地打破一切。我认为这是最好的。他们与微软团队的合作,使得 TypeScript 变得非常独特。
当听到 ECMAScript 的一个大新特性,允许开发这个新框架,是装饰器,一种注释机制时,大多数 JEE 开发者都会微笑:
注意
要了解注释和装饰器之间的区别,请访问blog.thoughtram.io/angular/2015/05/03/the-difference-between-annotations-and-decorators.html。
JavaScript 正在快速发展,ECMAScript 6 有很多有趣的功能,使其成为一个真正先进和复杂化的语言。不要错过这个机会;在太晚之前,看看github.com/lukehoban/es6features!
网页组件规范也是一个变革者。其目标是提供可重用的 UI 组件,React 团队和 Angular 2 团队都有计划与之接口。谷歌在网页组件之上开发了一个有趣的项目,名为 Polymer,现在已升级到 1.0 版本。
注意
参考文章ng-learn.org/2014/12/Polymer/了解更多关于这些项目状态的信息。
转向无状态
当处理 JavaScript 客户端时,依赖于会话 cookie 并不是最佳选择。大多数应用程序选择完全无状态,并用令牌来识别客户端。
如果您想继续使用 Spring Session,请查看HeaderHttpSessionStrategy类。它有一个实现,可以通过 HTTP 头发送和检索会话。一个例子可以在drissamri.be/blog/2015/05/21/spr找到。
摘要
Spring 生态系统非常广泛,为现代网络应用开发者提供了很多资源。
很难找到一个没有被 Spring 项目解决的问题。
是时候说再见了!希望您喜欢我们与 Spring MVC 的这次小旅程,并且它将帮助您愉快地开发,并创造出令人惊叹的项目,无论是在工作中还是在您的业余时间。


浙公网安备 33010602011771号