SpringBoot2-秘籍-全-
SpringBoot2 秘籍(全)
原文:
zh.annas-archive.org/md5/85b33110c9d4ae1a18b28a4c4651ada1译者:飞龙
前言
Spring 框架为 Java 开发提供了极大的灵活性,这也导致了繁琐的配置工作。Spring Boot 解决了 Spring 的配置难题,使得创建独立的生产级 Spring 应用程序变得容易。
这本实用指南使现有的开发过程更加高效。《Spring Boot 烹饪书 2.0,第二版》 智能地结合了所有技能和专业知识,以高效地使用 Spring Boot 在本地和云中开发、测试、部署和监控应用程序。我们从概述您将学习的 Spring Boot 的重要功能开始,以创建一个用于 RESTful 服务的 Web 应用程序。您还将学习如何通过了解自定义路由和资产路径以及如何修改路由模式来微调 Web 应用程序的行为,同时解决复杂企业应用程序的需求,并理解自定义 Spring Boot 启动器的创建。
本书还包含了创建各种测试的示例,这些测试是在 Spring Boot 1.4 和 2.0 中引入的,以及深入了解 Spring Boot DevTools 的方法。我们将探讨 Spring Boot Cloud 模块的基础以及各种云启动器,以创建原生云应用程序并利用服务发现和断路器。
本书面向对象
本书针对对 Spring 和 Java 应用程序开发有良好知识和理解的 Java 开发者,熟悉软件开发生命周期(SDLC)的概念,并理解不同类型测试策略、通用监控和部署关注点的需求。本书将帮助您学习高效的 Spring Boot 开发技术、集成和扩展能力,以便使现有的开发过程更加高效。
本书涵盖内容
第一章,Spring Boot 入门,概述了框架中包含的重要和有用的 Spring Boot 启动器。您将学习如何使用 spring.io 资源,如何开始一个简单的项目,以及如何配置构建文件以包含所需的启动器。本章将以创建一个配置为执行一些计划任务的简单命令行应用程序结束。
第二章,配置 Web 应用程序,提供了如何创建和添加自定义的 servlet 过滤器、拦截器、转换器、格式化器和属性编辑器到 Spring Boot Web 应用程序的示例。它将从创建一个新的 Web 应用程序开始,并使用它作为基础,使用我们在本章前面讨论的组件进行定制。
第三章,Web 框架行为调整,深入探讨了调整 Web 应用程序行为。它将涵盖配置自定义路由规则和模式,添加额外的静态资源路径,以及添加和修改 Servlet 容器连接器和其他属性,例如启用 SSL。
第四章,编写自定义 Spring Boot 启动器,展示了如何创建自定义 Spring Boot 启动器以提供可能适用于复杂企业应用程序的额外行为和功能。您将了解底层自动配置机制的工作原理,以及如何使用它们有选择性地启用/禁用默认功能并条件性地加载您自己的。
第五章,应用测试,探讨了测试 Spring Boot 应用程序的不同技术。它将从介绍测试 MVC 应用程序开始,然后讨论如何使用预填充数据的内存数据库来模拟测试中的真实数据库交互的一些技巧,并以 Cucumber 和 Spock 等测试工具的 BDD 示例结束。
第六章,应用打包和部署,将涵盖配置构建以生成适用于 Linux/OSX 环境的 Docker 镜像和自执行二进制文件的示例。我们将探讨使用 Consul 进行外部应用配置的选项,并深入了解 Spring Boot 环境和配置功能的具体细节。
第七章,健康监控和数据可视化,探讨了 Spring Boot 提供的各种机制,帮助我们查看与应用程序健康相关的数据。我们将从学习如何编写和公开自定义健康指标,并使用 HTTP 端点和 JMX 查看数据开始。然后,我们将概述 SSHd 的管理命令的创建,并以使用 Micrometer 度量框架将监控数据与 Graphite 和 Dashing 集成结束。
第八章,Spring Boot DevTools,深入探讨了如何在应用开发过程中使用 Spring Boot DevTools 来简化动态代码重新编译/重启和远程代码更新的常见任务。我们将学习如何将 DevTools 添加到项目中,随后探讨 DevTools 如何通过自动重启运行中的应用来加速开发过程。
第九章,Spring Cloud,提供了 Spring Boot Cloud 模块中各种功能的示例。您将学习如何使用不同的云模块进行服务发现,例如 Consul 或 Netflix Eureka。稍后,我们将探讨如何结合 Netflix 库,如 Hystrix 断路器和基于 Feign 接口的 REST 客户端。
要充分利用本书
对于本书,您需要在您喜欢的操作系统(Linux、Windows 或 OS X)上安装 JDK 1.8。假设读者对 Java 有合理的了解,包括 JDK 1.8 添加的最新功能,以及 Spring 框架及其操作概念的基本知识,如依赖注入、控制反转和 MVC。
其余的软件,如 Gradle 构建工具、所有必要的 Java 库(如 Spring Boot、Spring 框架及其依赖项),以及 Docker、Consul、Graphite、Grafana 和 Dashing,将在整个食谱中安装。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择支持选项卡。
-
点击代码下载与勘误。
-
在搜索框中输入书名,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Spring-Boot-2.0-Cookbook-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/上找到。查看它们!
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“现在我们将为LocaleChangeInterceptor添加一个@Bean声明。”
代码块设置如下:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都按以下方式编写:
$ ./gradlew clean bootRun
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“在“搜索依赖项”下选择“Actuator”选项。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
章节
在本书中,您将找到一些频繁出现的标题(准备就绪、如何操作...、工作原理...、还有更多...和另请参阅)。
为了清楚地说明如何完成食谱,请按照以下方式使用这些部分:
准备工作
本节告诉您在食谱中可以期待什么,并描述如何设置任何软件或任何为食谱所需的初步设置。
如何做...
本节包含遵循食谱所需的步骤。
它是如何工作的...
本节通常包含对前一个章节发生事件的详细解释。
还有更多...
本节包含有关食谱的附加信息,以便您对食谱有更深入的了解。
参考信息
本节提供了对食谱有用的其他信息的链接。
联系我们
我们欢迎读者的反馈。
一般反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送电子邮件给我们。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误表提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 联系我们,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用过这本书,为什么不在此购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需更多关于 Packt 的信息,请访问 packtpub.com.
第一章:Spring Boot 入门
Spring Boot 有很多启动器,它们已经是 Spring Boot 家族的一部分。本章将为您提供一个关于 start.spring.io/ 的概述,可用的启动器模块,并还将向您展示如何使项目 Bootful,正如 Josh Long 喜欢称呼的那样。
在本章中,我们将学习以下主题:
-
使用 Spring Boot 模板和启动器
-
创建一个简单的应用程序
-
使用 Gradle 启动应用程序
-
使用命令行运行器
-
设置数据库连接
-
设置数据存储库服务
-
调度执行器
简介
在当今软件开发的快节奏世界中,应用程序创建的速度和快速原型设计的需要变得越来越重要。如果您正在使用 JVM 语言开发软件,Spring Boot 正是那种能够给您提供力量和灵活性的框架,这将使您能够以快速的速度生产高质量的软件。因此,让我们看看 Spring Boot 如何帮助您使您的应用程序 Bootful。
使用 Spring Boot 模板和启动器
Spring Boot 包含超过 40 个不同的启动器模块,为许多不同的框架提供现成的集成库,例如既支持关系型数据库也支持 NoSQL 数据库的连接、网络服务、社交网络集成、监控库、日志记录、模板渲染,等等。虽然实际上不可能涵盖所有这些组件,但我们将介绍重要且流行的组件,以了解 Spring Boot 提供的可能性和应用开发的便捷性。
如何做到这一点...
我们将首先创建一个基本的简单项目骨架,Spring Boot 将帮助我们实现这一点:
-
填写有关我们项目的详细信息简单表格
-
点击生成项目 alt + a 预制项目骨架将下载;这就是我们开始的地方
它是如何工作的...
您将看到项目依赖关系部分,我们可以选择我们的应用程序将执行的功能类型:它是否会连接到数据库?它是否会有一个网络界面?我们是否计划与任何社交网络集成以及内置操作支持?等等。通过选择所需的技术,适当的启动器库将自动添加到我们预生成的项目模板的依赖列表中。
在我们生成项目之前,让我们了解一下 Spring Boot 启动器究竟是什么以及它为我们提供了哪些好处。
Spring Boot 旨在使创建应用程序变得容易。Spring Boot 启动器是引导库,其中包含启动特定功能所需的所有相关传递依赖项。每个启动器都有一个特殊的文件,其中包含 Spring 提供的所有提供依赖项的列表。以下是一个以spring-boot-starter-test定义为例的链接查看:
在这里,我们将看到以下代码:
provides: spring-test, spring-boot, junit, mockito, hamcrest-library, jsonassert, json-path
这告诉我们,通过在我们的构建中包含spring-boot-starter-test作为依赖项,我们将自动获得spring-test、spring-boot、junit、mockito、hamcrest-library、jsonassert和json-path。这些库将为我们提供编写我们将开发的软件的应用程序测试所需的所有必要事物,而无需手动将这些依赖项单独添加到构建文件中。
提供了超过 100 个启动器,并且随着社区添加的持续增加,除非我们发现自己需要与一个相当常见或流行的框架集成,否则很可能已经有一个启动器可供我们使用。
下表展示了最显著的一些,以便您了解可用的选项:
| 启动器 | 描述 |
|---|---|
spring-boot-starter |
这是核心 Spring Boot 启动器,它为您提供了所有基础功能。它是所有其他启动器的依赖项,因此无需显式声明。 |
spring-boot-starter-actuator |
此启动器为您提供了监控、管理和审计应用程序的功能。 |
spring-boot-starter-jdbc |
此启动器为您提供了连接和使用 JDBC 数据库、连接池等功能的支持。 |
spring-boot-starter-data-jpa spring-boot-starter-data-* |
JPA 启动器为您提供了所需的库,以便您可以使用Java 持久化 API(JPA):Hibernate 以及其他库。各种data-*系列启动器为 MongoDB、Data REST 或 Solr 等数据存储提供了支持。 |
spring-boot-starter-security |
这引入了所有 Spring Security 所需的依赖项。 |
spring-boot-starter-social-* |
这允许您与 Facebook、Twitter 和 LinkedIn 集成。 |
spring-boot-starter-test |
这是一个包含spring-test和一系列测试框架(如 JUnit 和 Mockito)依赖项的启动器。 |
spring-boot-starter-web |
这为您提供了开发 Web 应用程序所需的所有依赖项。它可以与spring-boot-starter-hateoas、spring-boot-starter-websocket、spring-boot-starter-mobile或spring-boot-starter-ws以及各种模板渲染启动器(sping-boot-starter-thymeleaf或spring-boot-starter-mustache)一起增强。 |
spring-cloud-starter-* |
提供对多个框架(如 Netflix OSS、Consul 或 AWS)支持的多个cloud-*家族启动器。 |
创建一个简单的应用程序
现在我们对我们可用的启动器有了基本的了解,让我们继续创建我们的应用程序模板,请访问start.spring.io。
如何操作...
我们将要创建的应用程序是一个图书目录管理系统。它将记录已出版的书籍、作者、评论者、出版社等信息。我们将我们的项目命名为BookPub,并执行以下步骤:
-
首先,让我们通过点击下面的链接切换到完整版本 Generate Project alt + 按钮
-
在顶部选择 Gradle 项目
-
使用 Spring Boot 版本 2.0.0(SNAPSHOT)
-
使用默认建议的组名:
com.example -
在工件字段中输入
bookpub -
将
BookPub作为应用程序的名称 -
将
com.example.bookpub指定为我们的包名 -
选择 Jar 作为打包方式
-
使用 Java 版本 8
-
从“搜索依赖项”选择中选取 H2、JDBC 和 JPA 启动器,以便我们可以在
build文件中获得连接到 H2 数据库所需的工件 -
点击 Generate Project alt + 下载项目存档
它是如何工作的...
点击 Generate Project alt + 按钮将下载bookpub.zip存档,我们将从我们的工作目录中提取它。在新创建的bookpub目录中,我们将看到一个build.gradle文件,它定义了我们的构建。它已经预配置了正确的 Spring Boot 插件和库版本,甚至包括我们选择的额外启动器。以下为build.gradle文件的代码:
dependencies {
compile("org.springframework.boot:spring-boot-starter-data-jpa")
compile("org.springframework.boot:spring-boot-starter-jdbc")
runtime("com.h2database:h2")
testCompile("org.springframework.boot:spring-boot-starter-test")
}
我们已经选择了以下启动器:
-
org.springframework.boot:spring-boot-starter-data-jpa:此启动器引入了 JPA 依赖项。 -
org.springframework.boot:spring-boot-starter-jdbc:此启动器引入了 JDBC 支持库。 -
com.h2database:H2 是一种特定的数据库实现,即 H2。 -
org.springframework.boot:spring-boot-starter-test:此启动器引入了运行测试所需的所有必要依赖项。它仅在构建的测试阶段使用,并且在常规应用程序编译时间和运行时不包括在内。
如您所见,runtime("com.h2database:h2") 依赖项是一个运行时依赖项。这是因为我们实际上并不需要,甚至可能都不想知道,在编译时我们将连接到哪种确切类型的数据库。Spring Boot 将在检测到应用程序启动时类路径中存在 org.h2.Driver 类时自动配置所需的设置并创建适当的 bean。我们将在本章后面探讨这是如何以及在哪里发生的。
data-jpa 和 jdbc 是 Spring Boot 启动器工件。如果我们下载这些依赖 JAR 文件后查看,或者使用 Maven Central,我们会发现它们不包含任何实际的类,只有各种元数据。两个包含我们感兴趣的文件是 pom.xml 和 spring.provides。让我们首先查看 spring-boot-starter-jdbc JAR 工件中的 spring.provides 文件,如下所示:
provides: spring-jdbc,spring-tx,tomcat-jdbc
这告诉我们,通过将这个启动器作为我们的依赖项,我们将通过传递性获得 spring-jdbc、spring-tx 和 tomcat-jdbc 依赖库在我们的构建中。pom.xml 文件包含适当的依赖声明,这些声明将在构建时间由 Gradle 或 Maven 使用以解决所需的依赖项。这也适用于我们的第二个启动器:spring-boot-starter-data-jpa。这个启动器将传递性地为我们提供 spring-orm、hibernate-entity-manager 和 spring-data-jpa 库。
到目前为止,我们的应用程序类路径中已经有了足够的库/类,以便让 Spring Boot 了解我们正在尝试运行什么类型的应用程序以及需要由 Spring Boot 自动配置以连接在一起的设施和框架类型。
之前我们提到,org.h2.Driver 类在类路径中的存在将触发 Spring Boot 自动配置我们的应用程序的 H2 数据库连接。为了确切了解这将会如何发生,让我们首先查看我们新创建的应用程序模板,特别是 BookPubApplication.java,它位于项目根目录下的 src/main/java/com/example/bookpub 目录中。我们这样做如下:
package com.example.bookpub;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.
SpringBootApplication;
@SpringBootApplication
public class BookPubApplication {
public static void main(String[] args) {
SpringApplication.run(BookPubApplication.class, args);
}
}
这实际上是我们整个和完全可运行的应用程序。这里没有太多代码,而且肯定没有提到配置或数据库。制作魔法的关键是 @SpringBootApplication 元注解。在这里,我们将找到将指导 Spring Boot 自动设置事物的真实注解:
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan (excludeFilters = @Filter(type =
FilterType.CUSTOM,
classes = TypeExcludeFilter.class))
public @interface SpringBootApplication {...}
让我们逐一查看前面代码片段中提到的以下注解列表:
@SpringBootConfiguration: 这个注解本身是一个元注解;它告诉 Spring Boot,被注解的类包含 Spring Boot 配置定义,例如@Bean、@Component和@Service声明等。在内部,它使用@Configuration注解,这是一个 Spring 注解,而不仅仅是 Spring Boot,因为它是一个 Spring 框架核心注解,用于标记包含 Spring 配置定义的类。
需要注意的是,当使用 Spring Boot Test 框架执行测试时,使用@SpringBootConfiguration而不是@Configuration是有帮助的,因为当测试被@SpringBootTest注解时,这个配置将自动被 Test 框架加载。正如 Javadoc 中所述,应用程序应该只包含一个@SpringApplicationConfiguration,而大多数习惯性的 Spring Boot 应用程序将继承自@SpringBootApplication。
-
@ComponentScan: 这个注解告诉 Spring,我们希望从被注解的类所在的包开始扫描我们的应用程序包,作为默认的包根,以便其他可能被@Configuration、@Controller和其他适用注解注解的类。Spring 会自动将这些类包含在上下文配置中。应用的TypeExcludeFilter类提供了过滤功能,用于排除从ApplicationContext中排除的各种类。它主要被spring-boot-test用于排除仅在测试期间应使用的类;然而,你也可以添加自己的 bean,这些 bean 扩展自TypeExcludeFilter,并为其他被认为必要的类型提供过滤功能。 -
@EnableAutoConfiguration: 这个注解是 Spring Boot 注解的一部分,它本身也是一个元注解(你会发现 Spring 库非常依赖于元注解,以便它们可以组合和组合配置)。它导入了EnableAutoConfigurationImportSelector和AutoConfigurationPackages.Registrar类,这些类有效地指示 Spring 根据类路径中可用的类自动配置条件 bean。(我们将在第四章,编写自定义 Spring Boot 启动器中详细介绍自动配置的内部工作原理。)
主方法中的SpringApplication.run(BookPubApplication.class, args);代码行基本上创建了一个 Spring 应用程序上下文,该上下文读取BookPubApplication.class中的注解并实例化一个上下文,这与我们没有使用 Spring Boot 而坚持使用常规 Spring 框架时所做的操作类似。
使用 Gradle 启动应用程序
通常,创建任何应用程序的第一步是拥有一个基本的可启动骨架。由于 Spring Boot 启动器已经为我们创建了应用程序模板,我们只需要提取代码、构建并执行它。现在让我们进入控制台,使用 Gradle 启动应用程序。
如何操作...
将我们的目录位置更改为bookpub.zip存档被提取的位置,并在命令行中执行以下命令:
$ ./gradlew clean bootRun
如果你目录中没有gradlew,那么请从gradle.org/downloads下载 Gradle 的一个版本,或者通过执行brew install gradle使用 Homebrew 安装它。安装 Gradle 后,在gradle文件夹中运行wrapper以生成 Gradle wrapper文件。另一种方法是调用$gradleclean bootRun。
前一个命令的输出将如下所示:
...
. ____ _ __ _ _
/\ / ___'_ __ _ _(_)_ __ __ _
( ( )___ | '_ | '_| | '_ / _` |
\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |___, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.0.0.BUILD-SNAPSHOT)
2017-12-16 23:18:53.721 : Starting BookPubApplication on mbp with
PID 43850
2017-12-16 23:18:53.781 : Refreshing org.springframework.context.
annotation.Annotatio
2017-12-16 23:18:55.544 : Building JPA container
EntityManagerFactory for persistence
2017-12-16 23:18:55.565 : HHH000204: Processing
PersistenceUnitInfo name: default
2017-12-16 23:18:55.624 : HHH000412: Hibernate Core
{5.2.12.Final}
2017-12-16 23:18:55.625 : HHH000206: hibernate.properties not
found
2017-12-16 23:18:55.627 : HHH000021: Bytecode provider name :
javassist
2017-12-16 23:18:55.774 : HCANN000001: Hibernate Commons
Annotations {5.0.1.Final
2017-12-16 23:18:55.850 : HHH000400: Using dialect:
org.hibernate.dialect.H2Dialect
2017-12-16 23:18:55.902 : HHH000397: Using
ASTQueryTranslatorFactory
2017-12-16 23:18:56.094 : HHH000227: Running hbm2ddl schema
export
2017-12-16 23:18:56.096 : HHH000230: Schema export complete
2017-12-16 23:18:56.337 : Registering beans for JMX exposure on
startup
2017-12-16 23:18:56.345 : Started BookPubApplication in 3.024
seconds (JVM running...
2017-12-16 23:18:56.346 : Closing
org.springframework.context.annotation.AnnotationC..
2017-12-16 23:18:56.347 : Unregistering JMX-exposed beans on
shutdown
2017-12-16 23:18:56.349 : Closing JPA EntityManagerFactory for
persistence unit 'def...
2017-12-16 23:18:56.349 : HHH000227: Running hbm2ddl schema
export
2017-12-16 23:18:56.350 : HHH000230: Schema export complete
BUILD SUCCESSFUL
Total time: 52.323 secs
它是如何工作的...
如我们所见,应用程序启动得很顺利,但由于我们没有添加任何功能或配置任何服务,它立即就消失了。然而,从启动日志中,我们可以看到自动配置确实发生了。让我们看一下以下几行:
Building JPA container EntityManagerFactory for persistence unit
'default'
HHH000412: Hibernate Core {5.2.12.Final}
HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
这条信息告诉我们,由于我们添加了jdbc和data-jpa启动器,JPA 容器被创建,并将使用 Hibernate 5.2.12.Final 通过 H2Dialect 来管理持久性。这是可能的,因为我们有正确的类在类路径中。
使用命令行运行器
在我们的基本应用程序骨架准备就绪后,让我们通过让应用程序做一些事情来给它添加一些实质性的内容。
让我们先创建一个名为StartupRunner的类。这个类将实现CommandLineRunner接口,它基本上只提供了一个方法:public void run(String... args) --Spring Boot 将在应用程序启动后只调用一次这个方法。
如何操作...
- 在项目根目录下的
src/main/java/com/example/bookpub/目录中创建一个名为StartupRunner.java的文件,内容如下:
package com.example.bookpub;
import com.example.bookpub.repository.BookRepository;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.scheduling.annotation.Scheduled;
public class StartupRunner implements CommandLineRunner {
protected final Log logger = LogFactory.getLog(getClass());
@Override
public void run(String... args) throws Exception {
logger.info("Hello");
}
}
- 在我们定义了类之后,让我们通过在
BookPubApplication.java应用程序配置中将它定义为@Bean来继续,该配置文件位于我们新创建的StartupRunner.java文件相同的文件夹中,如下所示:
@Bean
public StartupRunner schedulerRunner() {
return new StartupRunner();
}
它是如何工作的...
如果我们再次运行我们的应用程序,通过执行$ ./gradlew clean bootRun,我们将得到一个类似于之前的输出。然而,我们将在日志中看到我们的Hello消息,如下所示:
2017-12-16 21:57:51.048 INFO ---
com.example.bookpub.StartupRunner : Hello
即使程序在执行过程中会被终止,但我们至少让它做了一些事情!
命令行运行器是一种有用的功能,用于执行只需启动后运行一次的各种类型的代码。有些人也将其用作启动各种执行线程的地方,但 Spring Boot 提供了更好的解决方案来完成这项任务,这将在本章末尾讨论。Spring Boot 使用命令行运行器接口扫描所有实现,并使用启动参数调用每个实例的run方法。我们还可以使用@Order注解或实现Ordered接口来定义我们希望 Spring Boot 执行它们的精确顺序。例如,Spring Batch依赖于运行器来触发作业的执行。
由于命令行运行器是在应用程序启动后实例化和执行的,我们可以利用依赖注入来连接我们需要的任何依赖项,例如数据源、服务和其他组件。这些可以在实现run时使用。
重要的是要注意,如果在run(String... args)方法中抛出任何异常,这将导致上下文关闭并使应用程序关闭。建议使用try/catch包装有风险的代码块以防止这种情况发生。
设置数据库连接
在每个应用程序中,都需要访问某些数据并对它进行一些操作。最常见的数据源是某种类型的数据存储,即数据库。Spring Boot 使得连接数据库并开始通过 JPA(等等)消费数据变得非常容易。
准备中
在我们之前的例子中,我们创建了一个基本的应用程序,该应用程序通过在日志中打印一条消息来执行命令行运行器。让我们通过添加对数据库的连接来增强这个应用程序。
之前,我们已经在build文件中添加了必要的jdbc和data-jpa启动器以及一个 H2 数据库依赖项。现在我们将配置一个 H2 数据库的内存实例。
在嵌入式数据库的情况下,如 H2、Hyper SQL Database(HSQLDB)或 Derby,除了在build文件中包含对这些数据库之一的依赖项之外,不需要进行任何实际配置。当检测到类路径中的这些数据库之一,并在代码中声明了DataSource bean 依赖项时,Spring Boot 会自动为您创建一个。
为了证明仅仅在类路径中包含 H2 依赖项,我们就会自动获得一个默认的数据库,让我们修改我们的StartupRunner.java文件,使其看起来如下:
public class StartupRunner implements CommandLineRunner {
protected final Log logger = LogFactory.getLog(getClass());
@Autowired
private DataSource ds;
@Override
public void run(String... args) throws Exception {
logger.info("DataSource: "+ds.toString());
}
}
现在,如果我们继续运行我们的应用程序,我们将在日志中看到数据源的名字,如下所示:
2017-12-16 21:46:22.067 com.example.bookpub.StartupRunner
:DataSource: org.apache.tomcat.jdbc.pool.DataSource@4... {...driverClassName=org.h2.Driver; ... }
因此,在底层,Spring Boot 识别到我们已经自动装配了一个DataSource bean 依赖项,并自动创建了一个初始化内存中的 H2 数据存储器。这很好,但可能对于早期原型设计阶段或测试目的来说并不太有用。谁会想要一个随着应用程序关闭而消失所有数据的数据库,每次重启应用程序都必须从零开始?
如何操作...
为了创建一个嵌入式的 H2 数据库,该数据库不会在内存中存储数据,而是在应用程序重启之间使用文件来持久化数据,我们可以通过以下步骤来更改默认设置:
- 打开项目根目录下的
src/main/resources目录中的名为application.properties的文件,并添加以下内容:
spring.datasource.url = jdbc:h2:~/test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.username = sa
spring.datasource.password =
-
通过命令行执行
./gradlew clean bootRun来启动应用程序 -
检查你的主目录,你应该在那里看到以下文件:
test.mv.db
用户主目录位于 Linux 的/home/<username>和 macOS X 的/Users/<username>下。
它是如何工作的...
尽管默认情况下,Spring Boot 通过检查类路径中是否存在支持的数据库驱动程序来对数据库配置做出某些假设,但它提供了易于配置的选项,通过一组在spring.datasource下分组暴露的属性来调整数据库访问。
我们可以配置的项包括url、username、password、driver-class-name等等。如果你想从 JNDI 位置消费数据源,即由外部容器创建的数据源,你可以使用spring.datasource.jndi-name属性来配置它。可能的属性集相当大,所以我们不会全部介绍。然而,我们将在[第五章“应用程序测试”中介绍更多选项,我们将讨论使用数据库模拟应用程序测试数据。
通过查看各种博客和示例,你可能注意到有些地方在属性名中使用短横线,如driver-class-name,而其他地方则使用驼峰式变体:driverClassName。在 Spring Boot 中,这两种实际上是命名同一属性的两个同等支持的方式,并且它们在内部被转换成相同的东西。
如果你想要连接到一个常规(非嵌入)数据库,除了在类路径中拥有适当的驱动库之外,我们还需要在配置中指定我们选择的驱动程序。以下代码片段展示了连接到 MySQL 的配置示例:
spring.datasource.driver-class-name: com.mysql.jdbc.Driver
spring.datasource.url:
jdbc:mysql://localhost:3306/springbootcookbook
spring.datasource.username: root
spring.datasource.password:
如果我们想让 Hibernate 根据我们的实体类自动创建模式,我们需要在配置中添加以下行:
spring.jpa.hibernate.ddl-auto=create-drop
不要在生产环境中这样做,否则在启动时,所有表模式和数据都将被删除!在需要的地方使用更新或验证值代替。
你甚至可以在抽象层更进一步,而不是自动装配一个 DataSource 对象,你可以直接使用 JdbcTemplate。这将指示 Spring Boot 自动创建一个数据源,然后创建一个包装数据源的 JdbcTemplate,从而为你提供一个更方便且安全地与数据库交互的方式。JdbcTemplate 的代码如下:
@Autowired
private JdbcTemplate jdbcTemplate;
你也可以查看 spring-boot-autoconfigure 源代码中的 org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration 文件,以查看数据源创建魔法的代码。
设置数据仓库服务
连接到数据库然后执行传统的 SQL,虽然简单直接,但并不是操作数据、将其映射到一组领域对象以及操作关系内容最方便的方式。这就是为什么出现了多个框架来帮助你将数据从表映射到对象,这通常被称为 对象关系映射(ORM)。此类框架中最著名的例子是 Hibernate。
在上一个示例中,我们介绍了如何设置数据库连接并配置用户名和密码的设置,我们还讨论了应该使用哪个驱动程序等等。在这个菜谱中,我们将通过添加一些定义数据库中数据结构的实体对象和一个 CrudRepository 接口来访问数据来增强我们的应用程序。
由于我们的应用程序是一个书籍跟踪目录,显然的领域对象将是 Book、Author、Reviewers 和 Publisher。
如何做到这一点...
-
在项目根目录下
src/main/java/com/example/bookpub目录中创建一个名为entity的新包文件夹。 -
在这个新创建的包中,创建一个名为
Book的新类,内容如下:
@Entity
public class Book {
@Id
@GeneratedValue
private Long id;
private String isbn;
private String title;
private String description;
@ManyToOne
private Author author;
@ManyToOne
private Publisher publisher;
@ManyToMany
private List<Reviewers> reviewers;
protected Book() {}
public Book(String isbn, String title, Author author,
Publisher publisher) {
this.isbn = isbn;
this.title = title;
this.author = author;
this.publisher = publisher;
}
//Skipping getters and setters to save space, but we do need them
}
- 由于任何书籍都应该有一个作者和一个出版社,理想情况下还有一些评论者,我们需要创建这些实体对象。让我们从创建一个与我们的
Book相同目录下的Author实体类开始,如下所示:
@Entity
public class Author {
@Id
@GeneratedValue
private Long id;
private String firstName;
private String lastName;
@OneToMany(mappedBy = "author")
private List<Book> books;
protected Author() {}
public Author(String firstName, String lastName) {...}
//Skipping implementation to save space, but we do need
it all
}
- 同样,我们将创建
Publisher和Reviewer类,如下面的代码所示:
@Entity
public class Publisher {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "publisher")
private List<Book> books;
protected Publisher() {}
public Publisher(String name) {...}
}
@Entity
public class Reviewer {
@Id
@GeneratedValue
private Long id;
private String firstName;
private String lastName;
protected Reviewer() {}
public Reviewer(String firstName, String lastName)
{...}
}
- 现在,我们将在
src/main/java/com/example/bookpub/repository包下通过扩展 Spring 的CrudRepository接口来创建我们的BookRepository接口,如下所示:
@Repository
public interface BookRepository
extends CrudRepository<Book, Long> {
public Book findBookByIsbn(String isbn);
}
- 最后,让我们修改我们的
StartupRunner类,以便打印我们收藏中的书籍数量,而不是一些随机的数据源字符串,通过自动装配一个新创建的BookRepository并将.count()调用的结果打印到日志中,如下所示:
public class StartupRunner implements CommandLineRunner {
@Autowired private BookRepository bookRepository;
public void run(String... args) throws Exception {
logger.info("Number of books: " +
bookRepository.count());
}
}
它是如何工作的...
如您可能已经注意到的,我们没有写一行 SQL,甚至没有提及任何关于数据库连接、构建查询或类似的事情。我们代码中处理数据库后端数据的唯一线索是类和字段注解的存在:@Entity、@Repository、@Id、@GeneratedValue 和 @ManyToOne,以及 @ManyToMany 和 @OneToMany。这些注解是 JPA 的一部分,以及 CrudRepository 接口的扩展,是我们与 Spring 沟通需要将我们的对象映射到数据库中适当的表和字段的方式,并为我们提供与这些数据交互的程序化能力。
让我们逐一介绍以下注解:
-
@Entity表示被注解的类应该映射到数据库表。表的名称将派生自类的名称,但如果需要,也可以进行配置。需要注意的是,每个实体类都应该有一个默认的protected构造函数,这对于自动实例化和 Hibernate 交互是必需的。 -
@Repository表示该接口旨在为您提供对数据库数据的访问和操作。它还作为 Spring 在组件扫描期间的指示,表明此实例应作为一个 bean 创建,该 bean 将在应用程序中使用并可注入到其他 bean 中。 -
CrudRepository接口定义了从数据存储中读取、创建、更新和删除数据的基本通用方法。我们将在BookRepository扩展中定义的额外方法,如public Book findBookByIsbn(String isbn),表明 Spring JPA 应将此方法的调用映射到选择 ISBN 字段的 SQL 查询。这是一个约定命名的映射,将方法名称转换为 SQL 查询。它可以是一个非常强大的盟友,允许您构建查询,如findByNameIgnoringCase(String name)等。 -
@Id和@GeneratedValue注解向您提供指示,即被注解的字段应映射到数据库的主键列,并且此字段的值应由系统生成,而不是明确输入。 -
@ManyToOne和@ManyToMany注解定义了关联字段关联,这些关联字段引用其他表中存储的数据。在我们的例子中,多本书属于一个作者,许多评论家评论了多本书。 -
@OneToMay注解中的mappedBy属性定义了一个反向关联映射。它指示 Hibernate,映射的真实来源定义在Book类的author或publisher字段中。
想了解更多关于 Spring Data 所有强大功能的详细信息,请访问docs.spring.io/spring-data/data-commons/docs/current/reference/html/.
安排执行器
在本章的早期部分,我们讨论了如何将命令行运行器用作启动计划执行器线程池的地方,以间隔运行工作线程。虽然这确实是一种可能性,但 Spring 为你提供了一个更简洁的配置来实现相同的目标:@EnableScheduling。
准备工作
我们将增强我们的应用程序,使其每 10 秒打印出我们存储库中的书籍数量。为了实现这一点,我们将对BookPubApplication和StartupRunner类进行必要的修改。
如何实现...
- 让我们在
BookPubApplication类中添加一个@EnableScheduling注解,如下所示:
@SpringBootApplication
@EnableScheduling
public class BookPubApplication {...}
- 由于
@Scheduled注解只能放在没有参数的方法上,让我们在StartupRunner类中添加一个新的run()方法,并用@Scheduled注解标注它,如下行所示:
@Scheduled(initialDelay = 1000, fixedRate = 10000)
public void run() {
logger.info("Number of books: " +
bookRepository.count());
}
- 通过在命令行中执行
./gradlew clean bootRun来启动应用程序,以便观察日志中每 10 秒显示的Number of books: 0信息。
它是如何工作的...
@EnableScheduling,就像我们在本书中讨论和将要讨论的许多其他注解一样,不是一个 Spring Boot;它是一个 Spring Context 模块注解。类似于@SpringBootApplication和@EnableAutoConfiguration注解,这是一个元注解,并通过@Import(SchedulingConfiguration.class)指令内部导入SchedulingConfiguration,这个指令可以在由导入的配置创建的ScheduledAnnotationBeanPostProcessor内部找到。对于每个没有参数的注解方法,将创建适当的执行器线程池。它将管理注解方法的计划调用。
第二章:配置 Web 应用程序
在上一章中,我们学习了如何创建一个起始应用程序模板,添加一些基本功能,并设置数据库连接。在这一章中,我们将继续演进我们的 BookPub 应用程序,并为其提供一个网络存在。
在这一章中,我们将学习以下主题:
-
创建基本的 RESTful 应用程序
-
创建 Spring Data REST 服务
-
配置自定义 Servlet 过滤器
-
配置自定义拦截器
-
配置自定义 HttpMessageConverters
-
配置自定义属性编辑器
-
配置自定义类型格式化程序
创建基本的 RESTful 应用程序
虽然命令行应用程序确实有其位置和用途,但今天的大多数应用程序开发都是围绕网络、REST 和数据处理服务进行的。让我们从通过提供基于网络的 API 来增强我们的BookPub应用程序开始,以便获取图书目录的访问权限。
我们将从上一章结束的地方开始,因此应该已经有一个包含实体对象和定义了仓库服务以及配置了数据库连接的应用程序骨架。
如何做...
- 我们首先需要做的是在
build.gradle中添加一个新的依赖项,使用spring-boot-starter-web启动器来获取所有必要的基于网络的库。以下代码片段将展示其外观:
dependencies {
compile("org.springframework.boot:spring-boot-starter-data-jpa")
compile("org.springframework.boot:spring-boot-starter-jdbc")
compile("org.springframework.boot:spring-boot-starter-web")
runtime("com.h2database:h2")
runtime("mysql:mysql-connector-java")
testCompile("org.springframework.boot:spring-boot-starter-test")
}
-
接下来,我们需要创建一个 Spring 控制器,用于处理我们应用程序中目录数据的 Web 请求。让我们首先创建一个新的包结构来存放我们的控制器,以便我们的代码根据其适当的目的进行分组。从我们的项目根目录开始,在
src/main/java/com/example/bookpub目录中创建一个名为controllers的包文件夹。 -
由于我们将公开图书数据,让我们在我们的新创建的包中创建一个名为
BookController的控制器类文件,其内容如下:
@RestController
@RequestMapping("/books")
public class BookController {
@Autowired
private BookRepository bookRepository;
@RequestMapping(value = "", method = RequestMethod.GET)
public Iterable<Book> getAllBooks() {
return bookRepository.findAll();
}
@RequestMapping(value = "/{isbn}", method =
RequestMethod.GET)
public Book getBook(@PathVariable String isbn) {
return bookRepository.findBookByIsbn(isbn);
}
}
-
通过运行
./gradlew clean bootRun来启动应用程序。 -
应用程序启动后,打开浏览器并转到
http://localhost:8080/books,你应该会看到一个响应:[]。
它是如何工作的...
将服务暴露给 Web 请求的关键是@RestController注解。这是另一个元注解或便利注解的例子,正如 Spring 文档有时所指出的,我们在之前的食谱中已经见过。在@RestController中定义了两个注解:@Controller和@ResponseBody。因此,我们同样可以像以下这样注解BookController:
@Controller
@ResponseBody
@RequestMapping("/books")
public class BookController {...}
让我们看一下前面代码片段中的以下注解:
-
@Controller:这是一个 Spring stereotypes 注解,类似于@Bean和@Repository,它将注解的类声明为 MVC -
@ResponseBody:这是一个 Spring MVC 注解,表示来自 web 映射方法的响应构成了整个 HTTP 响应体负载的内容,这对于 RESTful 应用程序来说是典型的。 -
@RequestMapping:这是一个 Spring MVC 注解,表示对/books/*URL 的请求将被路由到这个控制器。
创建 Spring Data REST 服务
在前面的例子中,我们通过 REST 控制器将 BookRepository 接口作为前端,以便通过 Web RESTful API 暴露其后的数据。虽然这确实是一种快速简单的方法来使数据可访问,但它确实需要我们手动创建控制器并定义所有期望的操作的映射。为了最小化样板代码,Spring 提供了一种更方便的方法:spring-boot-starter-data-rest。这允许我们只需在存储库接口上添加一个注解,Spring 就会完成其余的工作,将其暴露到 Web 上。
我们将继续进行到上一个菜谱结束的地方,因此实体模型和 BookRepository 接口应该已经存在。
如何做到这一点...
- 我们将首先在我们的
build.gradle文件中添加另一个依赖项,以便添加spring-boot-starter-data-rest仓库:
dependencies {
...
compile("org.springframework.boot:spring-boot-starter-data-rest")
...
}
- 现在,让我们在项目根目录下的
src/main/java/com/example/bookpub/repository目录中创建一个新的接口来定义AuthorRepository,内容如下:
@RepositoryRestResource
public interface AuthorRepository extends
PagingAndSortingRepository<Author, Long> {
}
- 既然我们已经开始了——考虑到代码量如此之少——让我们创建剩余实体模型
PublisherRepository和ReviewerRepository的存储库接口,将这些文件放置在与AuthorRepository相同的包目录中,内容如下:
@RepositoryRestResource
public interface PublisherRepository extends
PagingAndSortingRepository<Publisher, Long> {
}
否则,你可以使用以下代码代替前面的代码:
@RepositoryRestResource
public interface ReviewerRepository extends
PagingAndSortingRepository<Reviewer, Long> {
}
-
通过运行
./gradlew clean bootRun启动应用程序 -
应用程序启动后,打开浏览器并转到
http://localhost:8080/authors,你应该看到以下响应:![图片]()
它是如何工作的...
如浏览器视图所示,我们将获得比我们编写书籍控制器时多得多的信息。这在一定程度上是因为我们扩展的不是 CrudRepository 接口,而是一个 PagingAndSortingRepository 接口,而 PagingAndSortingRepository 又是 CrudRepository 的扩展。我们决定这样做的原因是为了获得 PagingAndSortingRepository 提供的额外好处。这将添加额外的功能,以便使用分页检索实体,并能够对它们进行排序。
@RepositoryRestResource 注解虽然不是必需的,但它为我们提供了更精细地控制将存储库作为 Web 数据服务暴露的能力。例如,如果我们想将 URL path 或 rel 值更改为 writers 而不是 authors,我们可以调整注解如下:
@RepositoryRestResource(collectionResourceRel = "writers", path = "writers")
由于我们在构建依赖中包含了spring-boot-starter-data-rest,我们还将获得spring-hateoas库的支持,它为我们提供了很好的 ALPS 元数据,例如_links对象。这在构建 API 驱动的 UI 时非常有帮助,可以从元数据中推断导航能力并适当地展示它们。
配置自定义 servlet 过滤器
在实际的 Web 应用程序中,我们几乎总是需要添加门面或包装器来处理服务请求,记录它们,过滤掉 XSS 中的恶意字符,执行身份验证等等。默认情况下,Spring Boot 自动添加了OrderedCharacterEncodingFilter和HiddenHttpMethodFilter,但我们总是可以添加更多。让我们看看 Spring Boot 是如何帮助我们完成这个任务的。
在 Spring Boot、Spring Web、Spring MVC 和其他各种组合中,已经存在大量的不同 servlet 过滤器,我们只需要在配置中定义它们作为 bean 即可。假设我们的应用程序将在负载均衡器代理后面运行,并且我们希望将用户使用的实际请求 IP 翻译成代理 IP,而不是应用程序实例接收请求时使用的代理 IP。幸运的是,Apache Tomcat 8 已经为我们提供了一个实现:RemoteIpFilter。我们唯一需要做的就是将其添加到我们的过滤器链中。
如何去做...
- 将配置分离并分组到不同的类中,以便更清晰地了解正在配置的内容是个好主意。因此,让我们在项目根目录下的
src/main/java/com/example/bookpub目录中创建一个名为WebConfiguration的单独配置类,其内容如下:
@Configuration
public class WebConfiguration {
@Bean
public RemoteIpFilter remoteIpFilter() {
return new RemoteIpFilter();
}
}
-
通过运行
./gradlew clean bootRun来启动应用程序。 -
在启动日志中,我们应该看到以下行,表明我们的过滤器已被添加:
...FilterRegistrationBean : Mapping filter: 'remoteIpFilter' to: [/*]
它是如何工作的...
这个功能背后的魔法实际上非常简单。让我们从单独的配置类开始,逐步到过滤器 bean 检测。
如果我们查看主类BookPubApplication,我们会看到它被注解了@SpringBootApplication,这反过来又是一个便利的元注解,它声明了@ComponentScan等。我们已经在之前的菜谱中详细讨论了这一点。@ComponentScan的存在指示 Spring Boot 将WebConfiguration检测为一个@Configuration类,并将其定义添加到上下文中。因此,我们在WebConfiguration中声明的任何内容都相当于直接在BookPubApplication中放置它。
@BeanpublicRemoteIpFilterremoteIpFilter() {...}声明只是为RemoteIpFilter类创建了一个 Spring Bean。当 Spring Boot 检测到所有javax.servlet.Filter的 Bean 时,它会自动将它们添加到过滤器链中。因此,如果我们想添加更多过滤器,我们只需要将它们声明为@Bean配置。例如,对于更高级的过滤器配置,如果我们想使特定的过滤器仅应用于特定的 URL 模式,我们可以创建一个FilterRegistrationBean类型的@Bean配置,并使用它来配置精确的设置。
为了使支持这个用例更容易,Spring Boot 为我们提供了配置属性,可以在使用 Tomcat servlet 容器时替代手动配置RemoteIpFilter Bean。使用server.use-forward-headers=true来指示 Spring Boot 需要自动配置对代理头部的支持,以提供适当的请求混淆。对于 Tomcat,还可以使用server.tomcat.remote_ip_header=x-forwarded-for和server.tomcat.protocol_header=x-forwarded-proto属性来配置应使用哪些特定的头部名称来检索值。
配置自定义拦截器
虽然 servlet 过滤器是 Servlet API 的一部分,并且除了在过滤器链中自动添加之外与 Spring 无关 --Spring MVC 为我们提供了另一种封装网络请求的方式:HandlerInterceptor。根据文档,HandlerInterceptor就像一个过滤器。它不是在嵌套链中封装请求,而是在不同的阶段提供切割点,例如在请求被处理之前,在请求被处理后,在视图渲染之前,以及在请求完全完成后。它不会让我们改变请求的任何内容,但如果拦截器逻辑决定这样做,它可以通过抛出异常或返回 false 来停止执行。
与使用过滤器类似,Spring MVC 自带了许多预制的HandlerInterceptors。常用的有LocaleChangeInterceptor和ThemeChangeInterceptor;但当然还有其他提供很大价值的拦截器。所以,让我们将LocaleChangeInterceptor添加到我们的应用程序中,以便了解它是如何实现的。
如何操作...
尽管你可能认为,在看过之前的食谱后,添加拦截器并不像只是声明为一个 Bean 那样简单。我们实际上需要通过WebMvcConfigurer或覆盖WebMvcConfigurationSupport来实现。让我们看看以下步骤:
- 让我们增强我们的
WebConfiguration类以实现WebMvcConfigurer:
public class WebConfiguration implements WebMvcConfigurer {...}
- 现在,我们将添加一个
LocaleChangeInterceptor的@Bean声明:
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
return new LocaleChangeInterceptor();
}
- 这实际上会创建拦截器 Spring 豆,但不会将其添加到请求处理链中。为了实现这一点,我们需要重写
addInterceptors方法并将我们的拦截器添加到提供的注册表中:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
-
通过运行
./gradlew clean bootRun来启动应用程序 -
在浏览器中,访问
http://localhost:8080/books?locale=foo -
现在,如果你查看控制台日志,你会看到一堆堆栈跟踪错误,基本上是以下内容:
Caused by: java.lang.UnsupportedOperationException: Cannot change HTTP accept header - use a different locale resolution strategy
虽然错误不是因为我们输入了一个无效的区域设置,而是因为默认的区域设置解析策略不允许重置浏览器请求的区域设置,但我们得到错误的事实表明我们的拦截器正在工作。
它是如何工作的...
当涉及到配置 Spring MVC 内部组件时,仅仅定义一系列豆类并不简单,至少不是总是这样。这是因为需要提供更精细的 MVC 组件到请求的映射。为了使事情变得简单,Spring 在 WebMvcConfigurer 接口为我们提供了一系列默认方法,我们可以扩展并覆盖我们需要的设置。
在配置拦截器的情况下,我们正在重写 addInterceptors(InterceptorRegistry registry) 方法。这是一个典型的回调方法,我们被提供一个注册表以便注册尽可能多的额外拦截器。在 MVC 自动配置阶段,Spring Boot,就像在过滤器的情况下,检测 WebMvcConfigurer 的实例,并依次调用它们的所有回调方法。这意味着如果我们想要进行一些逻辑上的分离,我们可以有多个 WebMvcConfigurer 类的实现。
配置自定义 HttpMessageConverters
当我们构建我们的 RESTful 网络数据服务时,我们定义了控制器、存储库,并在它们上放置了一些注解;但我们没有在任何地方进行从 Java 实体豆到 HTTP 数据流输出的对象转换。然而,在幕后,Spring Boot 自动配置了 HttpMessageConverters,以便将我们的实体豆转换为 JSON 表示形式,并使用 Jackson 库将其写入 HTTP 响应。当有多个转换器可用时,最适用的转换器会根据消息对象类和请求的内容类型被选中。
HttpMessageConverters的目的在于将各种对象类型转换为它们对应的 HTTP 输出格式。转换器可以支持多种数据类型或多种输出格式,或者两者的组合。例如,MappingJackson2HttpMessageConverter可以将任何 Java 对象转换为application/json,而ProtobufHttpMessageConverter只能操作com.google.protobuf.Message的实例,但可以将它们写入为application/json、application/xml、text/plain或application/x-protobuf。HttpMessageConverters不仅支持写入 HTTP 流,还可以将 HTTP 请求转换为适当的 Java 对象。
如何实现...
我们可以通过多种方式来配置转换器。这完全取决于你更喜欢哪种方式,或者你想要达到多少控制程度。
- 让我们在以下方式中将
ByteArrayHttpMessageConverter作为@Bean添加到我们的WebConfiguration类中:
@Bean
public
ByteArrayHttpMessageConverter
byteArrayHttpMessageConverter() {
return new ByteArrayHttpMessageConverter();
}
- 实现这一目标的另一种方法是覆盖
WebConfiguration类中的configureMessageConverters方法,该类扩展了WebMvcConfigurerAdapter,并定义如下方法:
@Override
public void configureMessageConverters
(List<HttpMessageConverter<?>> converters) {
converters.add(new ByteArrayHttpMessageConverter());
}
- 如果你想要有更多的控制,我们可以以以下方式覆盖
extendMessageConverters方法:
@Override
public void extendMessageConverters
(List<HttpMessageConverter<?>> converters) {
converters.clear();
converters.add(new ByteArrayHttpMessageConverter());
}
它是如何工作的...
如你所见,Spring 为我们提供了多种实现相同功能的方式,这完全取决于我们的偏好或特定实现的细节。
我们已经介绍了三种向应用程序添加HttpMessageConverter的不同方法。那么,它们之间有什么区别呢?
将HttpMessageConverter声明为@Bean是向应用程序添加自定义转换器最快、最简单的方式。这与我们在早期示例中添加 servlet 过滤器的方式类似。如果 Spring 检测到HttpMessageConverter类型的 bean,它将自动将其添加到列表中。如果没有实现WebMvcConfigurer的WebConfiguration类,这将是首选的方法。
当应用程序需要定义更精确的设置控制,如拦截器、映射等时,最好使用WebMvcConfigurer实现来配置这些设置,因为覆盖configureMessageConverters方法并将我们的转换器添加到列表中将更加一致。由于可能有多个WebMvcConfigurers实例,这些实例可能是由我们添加的,也可能是通过各种 Spring Boot 启动器的自动配置设置添加的,因此无法保证我们的方法会以任何特定的顺序被调用。
如果我们需要做更彻底的事情,比如从列表中移除所有其他转换器或清除重复的转换器,这就是覆盖 extendMessageConverters 发挥作用的地方。该方法在所有 WebMvcConfigurers 调用 configureMessageConverters 之后被调用,并且转换器列表已经完全填充。当然,完全有可能其他 WebMvcConfigurer 实例也会覆盖 extendMessageConverters;但这种情况的可能性非常低,因此你有很大的机会产生期望的影响。
配置自定义的 PropertyEditors
在前面的示例中,我们学习了如何配置 HTTP 请求和响应数据的转换器。还有其他类型的转换发生,尤其是在动态将参数转换为各种对象时,例如字符串转换为日期或整数。
当我们在控制器中声明一个映射方法时,Spring 允许我们自由定义方法签名,使用我们所需的精确对象类型。这是通过使用 PropertyEditor 实现来实现的。PropertyEditor 是 JDK 部分定义的一个默认概念,旨在允许将文本值转换为给定类型。它最初是为了构建 Java Swing / 抽象窗口工具包 (AWT) GUI 而设计的,后来证明非常适合 Spring 将网络参数转换为方法参数类型的需求。
Spring MVC 已经为大多数常见类型提供了大量的 PropertyEditor 实现,例如布尔值、货币和类。假设我们想要创建一个合适的 Isbn 类对象,并在我们的控制器中使用它而不是一个普通的字符串。
如何操作...
-
首先,我们需要从我们的
WebConfiguration类中移除extendMessageConverters方法,因为converters.clear()调用会破坏渲染,因为我们移除了所有支持的类型转换器 -
在我们的项目根目录下的
src/main/java/com/example/bookpub目录下创建一个名为model的新包 -
接下来,我们在项目根目录下创建一个名为
Isbn的新类,位于我们刚刚创建的包目录中,内容如下:
package com.example.bookpub.model;
import org.springframework.util.Assert;
public class Isbn {
private String eanPrefix;
private String registrationGroup;
private String registrant;
private String publication;
private String checkDigit;
public Isbn(String eanPrefix, String registrationGroup,
String registrant, String publication,
String checkDigit) {
this.eanPrefix = eanPrefix;
this.registrationGroup = registrationGroup;
this.registrant = registrant;
this.publication = publication;
this.checkDigit = checkDigit;
}
public String getEanPrefix() {
return eanPrefix;
}
public void setEanPrefix(String eanPrefix) {
this.eanPrefix = eanPrefix;
}
public String getRegistrationGroup() {
return registrationGroup;
}
public void setRegistrationGroup
(String registrationGroup) {
this.registrationGroup = registrationGroup;
}
public String getRegistrant() {
return registrant;
}
public void setRegistrant(String registrant) {
this.registrant = registrant;
}
public String getPublication() {
return publication;
}
public void setPublication(String publication) {
this.publication = publication;
}
public String getCheckDigit() {
return checkDigit;
}
public void setCheckDigit(String checkDigit) {
this.checkDigit = checkDigit;
}
public static Isbn parseFrom(String isbn) {
Assert.notNull(isbn);
String[] parts = isbn.split("-");
Assert.state(parts.length == 5);
Assert.noNullElements(parts);
return new Isbn(parts[0], parts[1], parts[2],
parts[3], parts[4]);
}
@Override
public String toString() {
return eanPrefix + '-'
+ registrationGroup + '-'
+ registrant + '-'
+ publication + '-'
+ checkDigit;
}
}
-
在我们的项目根目录下的
src/main/java/com/example/bookpub目录下创建一个名为editors的新包 -
在我们的项目根目录下创建一个名为
IsbnEditor的新类,位于我们刚刚创建的包目录中,内容如下:
package com.example.bookpub.editors;
import org.springframework.util.StringUtils;
import com.example.bookpub.model.Isbn;
import java.beans.PropertyEditorSupport;
public class IsbnEditor extends PropertyEditorSupport {
@Override
public void setAsText(String text) {
if (text == null) {
setValue(null);
}
else {
String value = text.trim();
if (!StringUtils.isEmpty(value)) {
setValue(Isbn.parseFrom(value));
} else {
setValue(null);
}
}
}
@Override
public String getAsText() {
Object value = getValue();
return (value != null ? value.toString() : "");
}
}
- 接下来,我们将向
BookController添加一个名为initBinder的方法,在其中我们将使用以下内容配置IsbnEditor方法:
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(Isbn.class, new
IsbnEditor());
}
- 我们在
BookController中的getBook方法也将改变,以便接受Isbn对象,如下所示:
@RequestMapping(value = "/{isbn}", method =
RequestMethod.GET)
public Book getBook(@PathVariable Isbn isbn) {
return bookRepository.findBookByIsbn(isbn.toString());
}
-
通过运行
./gradlew clean bootRun启动应用程序 -
在浏览器中,访问
http://localhost:8080/books/978-1-78528-415-1 -
虽然我们不会观察到任何明显的变化,但
IsbnEditor确实在运行,它从{isbn}参数创建一个Isbn类对象。
它是如何工作的...
Spring 自动配置了大量默认编辑器;但对于自定义类型,我们必须为每个 Web 请求显式实例化新的编辑器。这是在控制器中通过一个带有@InitBinder注解的方法完成的。这个注解会被扫描,所有检测到的方法都应该接受WebDataBinder作为参数。WebDataBinder还为我们提供了注册所需数量的自定义编辑器的能力,以便正确绑定控制器方法。
非常重要的是要知道PropertyEditor不是线程安全的!因此,我们必须为每个 Web 请求创建我们自定义编辑器的新实例,并将它们注册到WebDataBinder。
如果需要新的PropertyEditor,最好通过扩展PropertyEditorSupport并覆盖所需的方法以自定义实现来创建一个。
配置自定义类型格式化器
主要是因为其状态性和缺乏线程安全性,从版本 3 开始,Spring 添加了一个Formatter接口来替代PropertyEditor。这些格式化器旨在提供类似的功能,但以完全线程安全的方式,并专注于非常具体的任务,即解析对象类型的字符串表示并将其转换为对象。
假设我们希望我们的应用程序有一个格式化器,它可以将书籍的 ISBN 号码从字符串形式转换为书籍实体对象。这样,我们就可以在请求 URL 签名只包含 ISBN 号码或数据库 ID 时,使用Book作为参数定义控制器请求方法。
如何做到这一点...
-
首先,让我们在项目根目录
src/main/java/com/example/bookpub目录中创建一个新的包,名为formatters -
接下来,我们将创建一个名为
BookFormatter的Formatter实现,位于我们新创建的项目根目录的包目录中,内容如下:
public class BookFormatter implements Formatter<Book> {
private BookRepository repository;
public BookFormatter(BookRepository repository) {
this.repository= repository;
}
@Override
public Book parse(String bookIdentifier, Locale locale)
throws ParseException {
Book book = repository.findBookByIsbn(bookIdentifier);
return book != null ? book :
repository.findById(Long.valueOf(bookIdentifier))
.get();
}
@Override
public String print(Book book, Locale locale) {
return book.getIsbn();
}
}
- 现在我们有了我们的格式化器,我们将通过覆盖
WebConfiguration类中的addFormatters(FormatterRegistry registry)方法将其添加到注册表中:
@Autowired
private BookRepository bookRepository;
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addFormatter(new BookFormatter(bookRepository));
}
- 最后,让我们向位于项目根目录
src/main/java/com/example/bookpub/controllers目录中的BookController类添加一个新的请求方法,该方法将显示给定 ISBN 号码的书籍的审稿人:
@RequestMapping(value = "/{isbn}/reviewers", method =
RequestMethod.GET)
public List<Reviewer> getReviewers(@PathVariable("isbn")
Book book) {
return book.getReviewers();
}
- 为了让我们有一些数据来操作,让我们手动(目前)通过向
StartupRunner类添加两个额外的自动装配的仓库来手动填充我们的数据库,以添加一些测试数据:
@Autowired
private AuthorRepository authorRepository;
@Autowired
private PublisherRepository publisherRepository;
- 以下代码片段是为
StartupRunner的run(...)方法准备的:
Author author = new Author("Alex", "Antonov");
author = authorRepository.save(author);
Publisher publisher = new Publisher("Packt");
publisher = publisherRepository.save(publisher);
Book book = new Book("978-1-78528-415-1",
"Spring Boot Recipes", author, publisher);
bookRepository.save(book);
-
通过运行
./gradlew clean bootRun启动应用程序 -
在浏览器中打开
http://localhost:8080/books/978-1-78528-415-1/reviewers,你应该能看到以下结果!![图片]()
它是如何工作的...
格式化工具旨在提供与 PropertyEditors 相似的功能。通过在重写的 addFormatters 方法中将我们的格式化工具注册到 FormatterRegistry,我们指示 Spring 使用我们的格式化工具将书籍的文本表示转换为实体对象,并反向转换。由于格式化工具是无状态的,我们不需要在控制器中对每个调用进行注册;我们只需做一次,这将确保 Spring 在每个网络请求中使用它。
还记得,如果你想定义一个常见类型的转换,例如 String 或 Boolean,例如修剪文本,最好通过控制器中的 InitBinder 下的 PropertyEditors 来做这件事,因为这种改变可能不是全局需要的,而只是需要特定功能。
你可能已经注意到,我们还自动将 BookRepository 注入到 WebConfiguration 类中,因为这是创建 BookFormatter 所必需的。这是 Spring 的一个酷特性——它允许我们组合配置类,并使它们同时依赖于其他豆类。正如我们指出的,为了创建 WebConfiguration 类,我们需要 BookRepository,Spring 确保首先创建 BookRepository,然后在创建 WebConfiguration 类时自动将其注入为依赖项。WebConfiguration 实例化后,它将根据配置指令进行处理。
剩余添加的功能应该已经熟悉,因为我们已经在之前的菜谱中介绍了它们。我们将在第五章 应用测试中详细探讨如何自动用模式和数据填充数据库,我们还将讨论应用测试。
第三章:调整 Web 框架行为
在本章中,我们将学习以下主题:
-
配置路由匹配模式
-
配置自定义静态路径映射
-
通过 ServletWebServerFactory 调整 Tomcat
-
选择嵌入式 servlet 容器
-
添加自定义连接器
简介
在第二章 配置 Web 应用程序 中,我们探讨了如何在 Spring Boot 中使用自定义过滤器、拦截器等配置 Web 应用程序。我们将继续深入了解通过行为调整、配置自定义路由规则和模式、添加额外的静态资产路径、添加和修改 servlet 容器连接器以及其他属性(如启用 SSL)来增强我们的 Web 应用程序。
配置路由匹配模式
当我们构建 Web 应用程序时,并不总是默认的即插即用的映射配置适用。有时,我们希望创建包含点(.)等字符的 RESTful URL,Spring 将其视为分隔符定义格式,如path.xml;或者我们可能不想识别尾随斜杠等。方便的是,Spring 提供了一种轻松实现此功能的方法。
在第二章 配置 Web 应用程序 中,我们介绍了WebConfiguration类,它继承自WebMvcConfigurerAdapter。这种扩展允许我们覆盖旨在添加过滤器、格式化程序等的方法。它还具有可以覆盖的方法,例如配置路径匹配等。
让我们假设 ISBN 格式确实允许使用点来分隔书号和修订版,其模式看起来像[isbn-number].[revision]。
如何操作...
我们将配置应用程序不使用.*后缀模式匹配,并在解析参数时不要删除点后面的值。让我们执行以下步骤:
- 让我们在
WebConfiguration类中添加必要的配置,内容如下:
@Override
public void
configurePathMatch(PathMatchConfigurer configurer) {
configurer.setUseSuffixPatternMatch(false).
setUseTrailingSlashMatch(true);
}
-
通过运行
./gradlew clean bootRun启动应用程序。 -
让我们在浏览器中打开
http://localhost:8080/books/978-1-78528-415-1.1以查看以下结果:

- 如果我们输入正确的 ISBN,我们将看到不同的结果,如下所示:

它是如何工作的...
让我们详细看看我们做了什么。configurePathMatch(PathMatchConfigurer configurer)方法使我们能够设置自己的行为,以便 Spring 能够根据我们的需求将请求 URL 路径与控制器参数匹配:
-
configurer.setUseSuffixPatternMatch(false): 这个方法表示我们不希望使用.*后缀,因此会移除最后一个点后面的尾随字符。这意味着 Spring 将整个978-1-78528-415-1.1ISBN 解析为{isbn}参数用于BookController。因此,http://localhost:8080/books/978-1-78528-415-1.1和http://localhost:8080/books/978-1-78528-415-1将成为不同的 URL。 -
configurer.setUseTrailingSlashMatch(true): 这个方法表示我们想要将 URL 末尾的/符号用作匹配,就像它不存在一样。这实际上使得http://localhost:8080/books/978-1-78528-415-1与http://localhost:8080/books/978-1-78528-415-1/相同。
如果你想要进一步配置路径匹配的方式,你可以提供自己的 PathMatcher 和 UrlPathHelper 实现,但这些通常只在最极端和定制化的情况下需要,并且通常不推荐这样做。
配置自定义静态路径映射
在前面的配方中,我们探讨了如何调整 URL 路径映射以请求并将它们转换为控制器方法。也可以控制我们的 Web 应用程序如何处理静态资源和存在于文件系统或打包在可部署归档中的文件。
假设我们想要通过我们应用程序的静态 Web URL http://localhost:8080/internal/application.properties 暴露我们的内部 application.properties 文件。为了开始这个过程,请按照下一节中的步骤进行操作。
如何做到这一点...
- 让我们在
WebConfiguration类中添加一个新的方法addResourceHandlers,内容如下:
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/internal/**")
.addResourceLocations("classpath:/");
}
-
通过运行
./gradlew clean bootRun启动应用程序 -
让我们在浏览器中打开
http://localhost:8080/internal/application.properties来查看以下结果:

它是如何工作的...
我们重写的方法 addResourceHandlers(ResourceHandlerRegistry registry) 是来自 WebMvcConfigurer 的另一种配置方法,它赋予我们定义自定义映射以静态资源 URL 并连接到文件系统或应用程序类路径上的资源的能力。在我们的例子中,我们定义了一个映射,将所有通过 /internal URL 访问的内容映射到我们应用程序的 classpath:/ 路径上(对于生产环境,你可能不想将整个类路径作为静态资源暴露!)。
因此,让我们详细看看我们做了什么,如下所示:
-
registry.addResourceHandler("/internal/**")方法向注册表中添加一个资源处理器来处理我们的静态资源,并返回ResourceHandlerRegistration给我们,这可以用来进一步以链式方式配置映射。/internal/**是一个路径模式,它将使用PathMatcher来与请求 URL 进行匹配。我们在前面的示例中已经看到了如何配置PathMatcher,但默认情况下使用的是AntPathMatcher实现。我们可以配置多个 URL 模式以匹配特定的资源位置。 -
在新创建的
ResourceHandlerRegistration实例上调用addResourceLocations("classpath:/")方法,它定义了资源应该从哪些目录加载。这些应该是有效的文件系统或类路径目录,并且可以输入多个。如果提供了多个位置,它们将按照输入的顺序进行检查。
我们还可以使用 setCachePeriod(Integer cachePeriod) 方法为给定的资源配置一个缓存间隔。
通过 ServletWebServerFactory 调优 Tomcat
Spring Boot 通过在 application.properties 中简单地设置值来公开许多服务器属性,可以用来配置诸如 PORT、SSL 等事物。然而,如果我们需要进行更复杂的调优,Spring Boot 提供了一个 ServletWebServerFactory 接口,可以用来程序化地定义我们的配置。
尽管会话超时可以通过在 application.properties 中设置 server.session.timeout 属性为所需的秒数来轻松配置,但我们将使用 ServletWebServerFactory 来演示如何进行配置。
如何做到...
- 假设我们想让会话持续一分钟。为了实现这一点,我们将在
WebConfiguration类中添加一个ServletWebServerFactorybean,内容如下:
@Bean
public ServletWebServerFactory servletContainer() {
TomcatServletWebServerFactory tomcat =
new TomcatServletWebServerFactory();
tomcat.getSession().setTimeout(Duration.ofMinutes(1));
return tomcat;
}
- 仅为了演示目的,我们将从请求中获取会话以强制其创建。为此,我们将在
BookController类中添加一个新的请求映射,内容如下:
@RequestMapping(value = "/session", method =
RequestMethod.GET)
public String getSessionId(HttpServletRequest request) {
return request.getSession().getId();
}
-
通过运行
./gradlew clean bootRun来启动应用程序。 -
让我们在浏览器中打开
http://localhost:8080/books/session来查看以下结果:

如果我们等待超过一分钟然后重新加载这个页面,会话 ID 将会变为另一个。
它是如何工作的...
ServletWebServerFactory 接口定义了 WebServer getWebServer(ServletContextInitializer... initializers) 方法。Spring Boot 默认提供了 TomcatServletWebServerFactory、JettyServletWebServerFactory 和 UndertowServletWebServerFactory 应用服务器的具体工厂实现。由于我们在示例中使用 Tomcat,我们将使用提供的 TomcatServletWebServerFactory 类来配置会话的行为。
在应用程序启动期间,Spring Boot 自动配置检测到工厂的存在,并调用 getWebServer(...) 方法,传递一个 ServletContextInitializer bean 集合的引用。通常,这些初始化器是由 Spring Boot 内部创建和管理的,但我们可以始终创建一些自定义的 ServletContextInitializer bean 来添加应在应用程序服务器启动生命周期中执行的自定义行为。
选择嵌入式 servlet 容器
如果我们决定要使用 Jetty 作为我们的 servlet 容器,我们需要在我们的 build 文件中添加一个 Jetty starter。
如何操作...
- 由于 Tomcat 已经是 Spring Boot 的传递依赖项,我们需要通过在
build.gradle中添加以下内容来从我们的build依赖项树中排除它:
configurations {
compile.exclude module: "spring-boot-starter-tomcat"
}
- 我们还需要在我们的
build依赖项中添加一个对 Jetty 的compile依赖项:
compile("org.springframework.boot:spring-boot-starter-jetty")
-
为了修复编译错误,我们需要从我们的
WebConfiguration类中移除 Tomcat 的RemoteIpFilter的 bean 声明,因为已经移除了 Tomcat 依赖。 -
通过运行
./gradlew clean bootRun启动应用程序 -
如果我们现在查看控制台日志,我们会看到我们的应用程序正在 Jetty 中运行:
2017-12-16 --- o.eclipse.jetty.server.AbstractConnector
: Started ServerConnector...
2017-12-16 ---.o.s.b.web.embedded.jetty.JettyWebServer
: Jetty started on port(s) 8080 (http/1.1)...
它是如何工作的...
这之所以能工作,是因为 Spring Boot 的自动配置魔法。我们必须从 build 文件中移除 Tomcat 依赖,以防止 Tomcat 和 Jetty 之间的依赖冲突。Spring Boot 对类路径中的类进行条件扫描,并根据其检测到的结果确定将使用哪个 servlet 容器。
如果我们查看 ServletWebServerFactoryAutoConfiguration 类,我们会看到以下检查此条件的代码:
/**
* Nested configuration if Jetty is being used.
*/
@Configuration
@ConditionalOnClass({ Servlet.class, Server.class, Loader.class})
@ConditionalOnMissingBean(value = ServletWebServerFactory.class,
search = SearchStrategy.CURRENT)
public static class EmbeddedJetty {
@Bean
public JettyServletWebServerFactory
JettyServletWebServerFactory() {
return new JettyServletWebServerFactory();
}
}
@ConditionalOnClass 注解告诉 Spring Boot,如果 Jetty 的类(即 org.eclipse.jetty.server.Server 和 org.eclipse.jetty.util.Loader)存在于类路径中,则仅使用 EmbeddedJetty 配置。
添加自定义连接器
在企业应用程序开发和部署中,另一个非常常见的场景是使用两个独立的 HTTP 端口连接器来运行应用程序:一个用于 HTTP,另一个用于 HTTPS
准备工作
我们将首先回到使用 Tomcat;因此,对于这个配方,我们将撤销在先前的示例中实现的更改。
为了创建 HTTPS 连接器,我们需要一些东西;但最重要的是,我们需要生成用于加密和解密与浏览器 SSL 通信的证书密钥库。
如果你使用 Unix 或 macOS,你可以通过运行以下命令来实现:
$JAVA_HOME/bin/keytool -genkey -alias tomcat -keyalg RSA
在 Windows 上,可以通过以下命令实现:
"%JAVA_HOME%binkeytool" -genkey -alias tomcat -keyalg RSA
在创建密钥库的过程中,你应该输入适合你的信息,包括密码、名称等。为了本书的目的,我们将使用默认密码:changeit。一旦执行完成,一个新创建的密钥库文件将出现在你的主目录下,名称为:keystore。
你可以在 tomcat.apache.org/tomcat-8.0-doc/ssl-howto.html#Prepare_the_Certificate_Keystore 找到有关准备证书密钥库的更多信息。
如何做到这一点...
在密钥库创建完成后,我们需要创建一个单独的 properties 文件来存储我们的 HTTPS 连接器的配置,例如端口。之后,我们将创建一个配置属性绑定对象,并使用它来配置我们的新连接器。执行以下步骤:
- 首先,我们将从项目的根目录在
src/main/resources目录下创建一个名为tomcat.https.properties的新属性文件,内容如下:
custom.tomcat.https.port=8443
custom.tomcat.https.secure=true
custom.tomcat.https.scheme=https
custom.tomcat.https.ssl=true
custom.tomcat.https.keystore=${user.home}/.keystore
custom.tomcat.https.keystore-password=changeit
- 接下来,我们将在
WebConfiguration类中创建一个名为TomcatSslConnectorProperties的嵌套静态类,内容如下:
@ConfigurationProperties(prefix = "custom.tomcat.https")
public static class TomcatSslConnectorProperties {
private Integer port;
private Boolean ssl = true;
private Boolean secure = true;
private String scheme = "https";
private File keystore;
private String keystorePassword;
//Skipping getters and setters to save space, but we do need them
public void configureConnector(Connector connector) {
if (port != null)
connector.setPort(port);
if (secure != null)
connector.setSecure(secure);
if (scheme != null)
connector.setScheme(scheme);
if (ssl!= null)
connector.setProperty("SSLEnabled", ssl.toString());
if (keystore!= null &&keystore.exists()) {
connector.setProperty("keystoreFile",
keystore.getAbsolutePath());
connector.setProperty("keystorePassword",
keystorePassword);
}
}
}
- 现在,我们需要将新创建的
tomcat.http.properties文件添加为 Spring Boot 属性源,并启用TomcatSslConnectorProperties的绑定。这可以通过在WebConfiguration类的类声明之前添加以下代码来完成:
@Configuration
@PropertySource("classpath:/tomcat.https.properties")
@EnableConfigurationProperties(WebConfiguration.TomcatSslConnectorProperties.class)
public class WebConfiguration extends WebMvcConfigurerAdapter {...}
- 最后,我们需要修改一个
ServletWebServerFactorySpring bean,我们将添加我们的 HTTPS 连接器。我们将通过在WebConfiguration类中更改以下代码来实现这一点:
@Bean
public ServletWebServerFactory servletContainer
(TomcatSslConnectorProperties properties) {
TomcatServletWebServerFactory tomcat =
new TomcatServletWebServerFactory();
tomcat.addAdditionalTomcatConnectors
(createSslConnector(properties));
tomcat.getSession().setTimeout(Duration.ofMinutes(1));
return tomcat;
}
private Connector createSslConnector
(TomcatSslConnectorProperties properties) {
Connector connector = new Connector();
properties.configureConnector(connector);
return connector;
}
-
通过运行
./gradlew clean bootRun来启动应用程序。 -
让我们在浏览器中打开
https://localhost:8443/internal/tomcat.https.properties来查看以下结果:

它是如何工作的...
在这个菜谱中,我们做了一些事情;让我们一次分解一个更改。
第一个更改,忽略创建密钥库的需要,是创建 tomcat.https.properties 和 TomcatSslConnectorProperties 对象以将它们绑定到。在此之前,我们已经处理了在配置我们的数据源时对 application.properties 中各种设置的更改。然而,当时我们不需要创建任何绑定对象,因为 Spring Boot 已经定义了它们。
如我们之前所学的,Spring Boot 已经公开了许多属性来配置应用程序设置,包括 server 部分的一整套设置。这些值绑定到一个内部 Spring Boot 类:ServerProperties
常见应用程序属性的完整列表可以在 Spring Boot 参考文档中找到,网址为 docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html。
我们添加的功能只是简单地模仿 Spring Boot,并创建我们自己的配置组,其背后有一个绑定对象。我们没有使用已经存在的 server.tomcat. 前缀,而是选择 custom.tomcat,主要是因为我们需要将我们的配置值与默认值分开。由于我们正在添加第二个连接器,我们希望默认配置属性和我们的自定义属性之间有一个清晰的分离。
@ConfigurationProperties(prefix = "custom.tomcat.https") 方法是我们 TomcatSslConnectorProperties 对象的一个重要注解。它告诉 Spring Boot 自动将带有 custom.tomcat.https 前缀的属性绑定到 TomcatSslConnectorProperties 中声明的字段。为了使绑定发生——除了在类中定义字段之外——定义 getter 和 setter 也是非常重要的。还值得一提的是,在绑定过程中,Spring 会自动尝试将属性值转换为适当的数据类型。例如,custom.tomcat.https.keystore 的值会自动绑定到一个私有的文件密钥库字段对象。
我们之前学到的转换器也将用于将数据转换为自定义定义的数据类型的过程中。
下一步是告诉 Spring Boot 将 tomcat.https.properties 中定义的属性包含在属性列表中。这是通过在 WebConfiguration 类中的 @Configuration 旁边添加 @PropertySource("classpath:/tomcat.https.properties") 来实现的。
在值导入后,我们需要告诉 Spring Boot 自动为我们创建一个 TomcatSslConnectorProperties 实例。这是通过在 @Configuration 旁边添加以下注解来完成的:
@EnableConfigurationProperties(WebConfiguration.TomcatSslConnectorProperties.class)
这将指导 Spring Boot 自动创建一个类型为 TomcatSslConnectorProperties 的 bean,并将其与指定的 classpath:/tomcat.https.properties 文件中的值绑定。这个 bean 可以后来用于在不同的地方进行自动装配,例如当我们创建一个 ServletWebServerFactory bean 时。
在设置并完成所有属性支持后,我们将继续编写实际代码来创建第二个连接器。ServletWebServerFactory bean 的创建为 Spring Boot 提供了一个工厂,用于创建 WebServer。我们添加到 TomcatSslConnectorProperties 中的方便的 configureConnector(Connector connector) 方法,为我们提供了一个很好的地方来封装和整合所有配置新创建的 Connector 实例所需的设置。
第四章:编写自定义 Spring Boot 启动器
在本章中,我们将涵盖以下主题:
-
理解 Spring Boot 自动配置
-
创建自定义 Spring Boot 自动配置启动器
-
配置自定义条件性 Bean 实例化
-
使用自定义 @Enable 注解切换配置
简介
在前面的章节中,我们在开发 Spring Boot 应用程序时做了很多配置,甚至更多的自动配置。现在,是时候揭开 Spring Boot 自动配置背后的魔法,并编写一些我们自己的启动器了。
这是一种非常实用的能力,尤其是在拥有专有代码的大型软件企业中,专有代码的存在是不可避免的。能够创建内部自定义启动器,自动添加一些配置或功能到应用程序中,非常有帮助。一些可能的候选包括自定义配置系统、库以及处理连接数据库、使用自定义连接池、HTTP 客户端、服务器等配置。我们将深入了解 Spring Boot 自动配置的内部机制,查看新启动器的创建方式,探索基于各种规则的 Bean 条件初始化和连接,并看到注解可以是一个强大的工具,为启动器的消费者提供更多控制权,以决定应该使用哪些配置以及在哪里使用。
理解 Spring Boot 自动配置
当涉及到启动应用程序并配置它以精确地包含所需的所有内容时,Spring Boot 具有强大的功能,而无需我们开发者编写大量的粘合代码。这种力量的秘密实际上来自于 Spring 本身,或者更确切地说,来自于它提供的 Java 配置功能。随着我们添加更多的启动器作为依赖项,我们的类路径中将会出现越来越多的类。Spring Boot 会检测特定类的存在或不存在,并根据这些信息做出一些决策,有时这些决策相当复杂,并自动创建和连接必要的 Bean 到应用程序上下文中。
听起来很简单,对吧?
在前面的食谱中,我们添加了多个 Spring Boot 启动器,例如 spring-boot-starter-data-jpa、spring-boot-starter-web、spring-boot-starter-data-test 等。我们将使用上一章完成相同的代码,以便查看应用程序启动过程中实际发生的情况以及 Spring Boot 在连接我们的应用程序时所做的决策。
如何操作...
-
便利的是,Spring Boot 提供了一种通过简单地以
debug标志启动应用程序来获取CONDITIONS EVALUATION REPORT的能力。这可以通过环境变量DEBUG、系统属性-Ddebug或应用程序属性--debug传递给应用程序。 -
通过运行
DEBUG=true ./gradlew clean bootRun来启动应用程序。 -
现在,如果您查看控制台日志,您将看到那里打印了更多标记为
DEBUG级别的信息。在启动日志序列的末尾,我们将看到如下所示的CONDITIONS EVALUATION REPORT:
=========================
CONDITIONS EVALUATION REPORT
=========================
Positive matches:
-----------------
...
DataSourceAutoConfiguration
- @ConditionalOnClass classes found:
javax.sql.DataSource,org.springframework.jdbc.
datasource.embedded.EmbeddedDatabaseType
(OnClassCondition)
...
Negative matches:
-----------------
...
GsonAutoConfiguration
- required @ConditionalOnClass classes not found:
com.google.gson.Gson (OnClassCondition)
...
它是如何工作的...
如您所见,在调试模式下打印的信息量可能有些令人不知所措,所以我只选择了一个正匹配和一个负匹配的例子。
对于报告中的每一行,Spring Boot 都会告诉我们为什么某些配置被选中包含在内,它们在哪些方面进行了正匹配,或者对于负匹配,是什么缺失的阻止了特定配置被包含在组合中。让我们看看DataSourceAutoConfiguration的正匹配:
-
找到的
@ConditionalOnClass类告诉我们 Spring Boot 已经检测到特定类的存在,在我们的例子中是两个类:javax.sql.DataSource和org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType。 -
OnClassCondition指示使用了哪种匹配方式。这由@ConditionalOnClass和@ConditionalOnMissingClass注解支持。
虽然OnClassCondition是最常见的检测类型,但 Spring Boot 还使用了许多其他条件。例如,OnBeanCondition用于检查特定 bean 实例的存在或不存在,OnPropertyCondition用于检查属性的存在、不存在或特定值,以及可以使用@Conditional注解和Condition接口实现定义的任何数量的自定义条件。
负匹配显示 Spring Boot 评估过的配置列表,这意味着它们确实存在于类路径中,并被 Spring Boot 扫描,但未通过包含所需的条件。GsonAutoConfiguration虽然作为导入的spring-boot-autoconfigure工件的一部分存在于类路径中,但由于所需的com.google.gson.Gson类未检测到存在于类路径中,因此未能通过OnClassCondition。
GsonAutoConfiguration文件的实现如下所示:
@Configuration
@ConditionalOnClass(Gson.class)
public class GsonAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public Gson gson() {
return new Gson();
}
}
在查看代码后,很容易将条件注解与 Spring Boot 在启动时提供的信息之间的联系联系起来。
创建自定义 Spring Boot 自动配置启动器
我们对 Spring Boot 决定将哪些配置包含在应用程序上下文形成过程中的过程有一个高级的了解。现在,让我们尝试创建我们自己的 Spring Boot 启动器工件,我们可以将其作为可自动配置的依赖项包含在我们的构建中。
在 第二章,配置 Web 应用程序,你学习了如何创建数据库 Repository 对象。所以,让我们构建一个简单的起始作品,它将创建另一个 CommandLineRunner,该 CommandLineRunner 将获取所有 Repository 实例的集合并打印出每个的总条目数。
我们将首先向现有项目添加一个子 Gradle 项目,该项目将包含起始作品的代码库。我们将称之为 db-count-starter。
如何做到这一点...
-
我们将首先在项目根目录下创建一个名为
db-count-starter的新目录。 -
由于我们的项目现在已成为所谓的
multiproject构建,我们需要在项目根目录中创建一个settings.gradle配置文件,其内容如下:
include 'db-count-starter'
- 我们还应该在项目根目录下的
db-count-starter目录中为我们的子项目创建一个单独的build.gradle配置文件,其内容如下:
apply plugin: 'java'
repositories {
mavenCentral()
maven { url "https://repo.spring.io/snapshot" }
maven { url "https://repo.spring.io/milestone" }
}
dependencies {
compile("org.springframework.boot:spring-boot:2.0.0.BUILD-SNAPSHOT")
compile("org.springframework.data:spring-data-commons:2.0.2.RELEASE")
}
-
现在我们已经准备好开始编码了。所以,第一步是创建目录结构,
src/main/java/com/example/bookpubstarter/dbcount,在项目根目录下的db-count-starter目录中。 -
在新创建的目录中,让我们添加名为
DbCountRunner.java的CommandLineRunner文件实现,其内容如下:
public class DbCountRunner implements CommandLineRunner {
protected final Log logger = LogFactory.getLog(getClass());
private Collection<CrudRepository> repositories;
public DbCountRunner(Collection<CrudRepository> repositories) {
this.repositories = repositories;
}
@Override
public void run(String... args) throws Exception {
repositories.forEach(crudRepository ->
logger.info(String.format("%s has %s entries",
getRepositoryName(crudRepository.getClass()),
crudRepository.count())));
}
private static String
getRepositoryName(Class crudRepositoryClass) {
for(Class repositoryInterface :
crudRepositoryClass.getInterfaces()) {
if (repositoryInterface.getName().
startsWith("com.example.bookpub.repository")) {
return repositoryInterface.getSimpleName();
}
}
return "UnknownRepository";
}
}
- 在
DbCountRunner的实际实现到位后,我们现在需要创建一个配置对象,该对象将在配置阶段声明性地创建一个实例。所以,让我们创建一个名为DbCountAutoConfiguration.java的新类文件,其内容如下:
@Configuration
public class DbCountAutoConfiguration {
@Bean
public DbCountRunner dbCountRunner
(Collection<CrudRepository> repositories) {
return new DbCountRunner(repositories);
}
}
-
我们还需要告诉 Spring Boot,我们新创建的 JAR 软件包包含自动配置类。为此,我们需要在项目根目录下的
db-count-starter/src/main目录中创建一个resources/META-INF目录。 -
在这个新创建的目录中,我们将放置名为
spring.factories的文件,其内容如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.bookpubstarter.dbcount.DbCountAutoConfiguration
- 为了演示的目的,我们将在主项目的
build.gradle文件中添加对起始作品的依赖项,通过在依赖项部分添加以下条目:
compile project(':db-count-starter')
-
通过运行
./gradlew clean bootRun启动应用程序。 -
一旦应用程序编译并启动,我们应该在控制台日志中看到以下内容:
2017-12-16 INFO com.example.bookpub.StartupRunner : Welcome to the Book Catalog System!
2017-12-16 INFO c.e.b.dbcount.DbCountRunner : AuthorRepository has 1 entries
2017-12-16 INFO c.e.b.dbcount.DbCountRunner : PublisherRepository has 1 entries
2017-12-16 INFO c.e.b.dbcount.DbCountRunner : BookRepository has 1 entries
2017-12-16 INFO c.e.b.dbcount.DbCountRunner : ReviewerRepository has 0 entries
2017-12-16 INFO com.example.bookpub.BookPubApplication : Started BookPubApplication in 8.528 seconds (JVM running for 9.002)
2017-12-16 INFO com.example.bookpub.StartupRunner : Number of books: 1
它是如何工作的...
恭喜!你现在已经构建了自己的 Spring Boot 自动配置起始作品。
首先,让我们快速浏览一下我们对 Gradle 构建配置所做的更改,然后我们将详细检查起始设置。
由于 Spring Boot 启动器是一个独立的、独立的工件,仅仅将更多类添加到我们现有的项目源树中并不能真正展示出很多。为了使这个独立的工件,我们有几种选择:在我们的现有项目中创建一个单独的 Gradle 配置,或者完全创建一个全新的项目。然而,最理想的解决方案是将我们的构建转换为 Gradle 多项目构建,通过在根项目的build.gradle文件中添加嵌套项目目录和子项目依赖来实现。通过这样做,Gradle 实际上为我们创建了一个独立的 JAR 工件,但我们不需要将其发布到任何地方,只需将其作为编译project(':db-count-starter')依赖项包含即可。
关于 Gradle 多项目构建的更多信息,您可以查看gradle.org/docs/current/userguide/multi_project_builds.html的手册。
Spring Boot 自动配置启动器不过是一个带有@Configuration注解的常规 Spring Java 配置类,并且类路径中的META-INF目录下存在spring.factories文件,其中包含适当的配置条目。
在应用程序启动期间,Spring Boot 使用SpringFactoriesLoader(它是 Spring Core 的一部分),以获取为org.springframework.boot.autoconfigure.EnableAutoConfiguration属性键配置的 Spring Java 配置列表。在底层,这个调用收集了类路径中所有 jar 或其他条目在META-INF目录下的所有spring.factories文件,并构建一个复合列表作为应用程序上下文配置添加。除了EnableAutoConfiguration键之外,我们还可以以类似的方式声明以下自动初始化的启动实现:
-
org.springframework.context.ApplicationContextInitializer -
org.springframework.context.ApplicationListener -
org.springframework.boot.autoconfigure.AutoConfigurationImportListener -
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter -
`org.springframework.boot.autoconfigure.template.TemplateAvailabilityProvider` -
org.springframework.boot.SpringBootExceptionReporter -
org.springframework.boot.SpringApplicationRunListener -
org.springframework.boot.env.PropertySourceLoader -
org.springframework.boot.env.EnvironmentPostProcessor -
org.springframework.boot.diagnostics.FailureAnalyzer -
org.springframework.boot.diagnostics.FailureAnalysisReporter -
org.springframework.test.contex.TestExecutionListener
充满讽刺意味的是,Spring Boot Starter 不需要依赖 Spring Boot 库作为其编译时依赖项。如果我们查看DbCountAutoConfiguration类中的类导入列表,我们将不会看到来自org.springframework.boot包的任何内容。我们声明对 Spring Boot 的依赖的唯一原因是因为我们的DbCountRunner实现实现了org.springframework.boot.CommandLineRunner接口。
配置自定义条件 bean 实例化
在前面的例子中,你学习了如何启动基本的 Spring Boot Starter。在将 jar 包包含在应用程序类路径中时,DbCountRunner bean 将被自动创建并添加到应用程序上下文中。在本章的第一个菜谱中,我们也看到了 Spring Boot 具有根据一些条件进行条件配置的能力,例如类路径中存在特定类、bean 的存在以及其他一些条件。
对于这个配方,我们将通过条件检查来增强我们的启动器。这将在没有其他此类 bean 实例已被创建并添加到应用程序上下文的情况下,创建DbCountRunner的实例。
如何做到这一点...
- 在
DbCountAutoConfiguration类中,我们将向dbCountRunner(...)方法添加一个@ConditionalOnMissingBean注解,如下所示:
@Bean
@ConditionalOnMissingBean
public DbCountRunner
dbCountRunner(Collection<CrudRepository> repositories) {
return new DbCountRunner(repositories);
}
- 我们还需要将
spring-boot-autoconfigure组件的依赖项添加到db-count-starter/build.gradle文件的依赖项部分:
compile("org.springframework.boot:spring-boot-autoconfigure:2.0.0.BUILD-SNAPSHOT")
-
现在,让我们通过在终端中运行
./gradlew clean bootRun来启动应用程序,以验证我们将在控制台日志中看到与之前菜谱中相同的输出。 -
如果我们使用
DEBUG开关启动应用程序以查看自动配置报告,正如我们在本章第一道菜谱中学到的,我们将看到我们的自动配置位于 Positive Matches 组中,如下所示:
DbCountAutoConfiguration#dbCountRunner
- @ConditionalOnMissingBean (types: com.example.bookpubstarter.dbcount.DbCountRunner; SearchStrategy: all) found no beans (OnBeanCondition)
- 让我们在主
BookPubApplication配置类中显式/手动创建一个DbCountRunner的实例,并且我们还将覆盖其run(...)方法,这样我们就可以在日志中看到差异:
protected final Log logger = LogFactory.getLog(getClass());
@Bean
public DbCountRunner dbCountRunner
(Collection<CrudRepository> repositories) {
return new DbCountRunner(repositories) {
@Override
public void run(String... args) throws Exception {
logger.info("Manually Declared DbCountRunner");
}
};
}
-
通过运行
DEBUG=true ./gradlew clean bootRun来启动应用程序。 -
如果我们查看控制台日志,我们将看到两件事:自动配置报告将在 Negative Matches 组中打印我们的自动配置,并且,而不是每个存储库的计数输出,我们将看到
Manually Declared DbCountRunner文本出现:
DbCountAutoConfiguration#dbCountRunner
- @ConditionalOnMissingBean (types: com.example.bookpubstarter.dbcount.DbCountRunner; SearchStrategy: all) found the following [dbCountRunner] (OnBeanCondition)
2017-12-16 INFO com.example.bookpub.BookPubApplication$1 : Manually Declared DbCountRunner
它是如何工作的...
如我们从之前的配方中学到的,Spring Boot 将在应用程序上下文创建期间自动处理所有来自spring.factories的配置类条目。在没有额外指导的情况下,所有使用@Bean注解注解的内容都将用于创建 Spring Bean。这个功能实际上是 Java 配置的 Spring Framework 的一部分。Spring Boot 在此基础上增加的是能够条件性地控制某些@Configuration或@Bean注解何时执行以及何时最好忽略它们的能力。
在我们的案例中,我们使用了@ConditionalOnMissingBean注解来指示 Spring Boot 仅在没有任何其他匹配类类型或已声明的 bean 名称的 bean 时创建我们的DbCountRunner bean。由于我们在BookPubApplication配置中明确创建了一个@Bean条目用于DbCountRunner,这具有优先权,导致OnBeanCondition检测到 bean 的存在;从而指示 Spring Boot 在应用程序上下文设置期间不使用DbCountAutoConfiguration。
使用自定义的@Enable注解切换配置
允许 Spring Boot 自动评估类路径和检测到的配置,这使得启动一个简单应用程序变得非常快速和简单。然而,有时我们希望提供配置类,但要求启动库的消费者明确启用此类配置,而不是依赖 Spring Boot 自动决定是否包含它。
我们将修改之前的配方,使启动器通过元注解启用,而不是使用spring.factories路由。
如何操作...
- 首先,我们将注释掉位于项目根目录
db-count-starter/src/main/resources中的spring.factories文件的内容,如下所示:
#org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
#com.example.bookpubstarter.dbcount.DbCountAutoConfiguration
- 接下来,我们需要创建元注解。我们将在项目根目录下的
db-count-starter/src/main/java/com/example/bookpubstarter/dbcount目录中创建一个名为EnableDbCounting.java的新文件,其内容如下:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(DbCountAutoConfiguration.class)
@Documented
public @interface EnableDbCounting {
}
- 现在,我们将向我们的
BookPubApplication类添加@EnableDbCounting注解,并从其中删除dbCountRunner(...)方法,如下面的代码片段所示:
@SpringBootApplication
@EnableScheduling
@EnableDbCounting
public class BookPubApplication {
public static void main(String[] args) {
SpringApplication.run(BookPubApplication.class, args);
}
@Bean
public StartupRunner schedulerRunner() {
return new StartupRunner();
}
}
- 通过运行
./gradlew clean bootRun来启动应用程序。
它是如何工作的...
运行应用程序后,你可能会首先注意到打印的计数全部显示为0,尽管StartupRunner已经在控制台打印了Number of books: 1,如下所示:
c.e.b.dbcount.DbCountRunner : AuthorRepository has 0 entries
c.e.b.dbcount.DbCountRunner : BookRepository has 0 entries
c.e.b.dbcount.DbCountRunner : PublisherRepository has 0 entries
c.e.b.dbcount.DbCountRunner : ReviewerRepository has 0 entries
com.example.bookpub.StartupRunner : Welcome to the Book Catalog System!
com.example.bookpub.StartupRunner : Number of books: 1
这是因为 Spring Boot 正在随机执行 CommandLineRunners,并且由于我们更改了配置以使用 @EnableDbCounting 注解,它会在 BookPubApplication 类本身的配置之前被处理。由于数据库填充是由我们在 StartupRunner.run(...) 方法中完成的,而 DbCountRunner.run(...) 的执行发生在之前,因此数据库表没有数据,所以报告了 0 个计数。
如果我们要强制执行顺序,Spring 通过 @Order 注解提供了这种能力。让我们用 @Order(Ordered.LOWEST_PRECEDENCE - 15) 注解 StartupRunner 类。由于 LOWEST_PRECEDENCE 是默认分配的顺序,我们将通过稍微降低顺序号来确保 StartupRunner 将在 DbCountRunner 之后执行。让我们再次运行应用程序,现在我们将看到计数被正确显示。
现在我们已经解决了这个小排序问题,让我们更详细地检查我们用 @EnableDbCounting 注解做了什么。
没有包含配置的 spring.factories,Spring Boot 并不知道在应用程序上下文创建过程中应该包含 DbCountAutoConfiguration 类。默认情况下,配置组件扫描只会从 BookPubApplication 包及其以下部分开始。由于包不同——com.example.bookpub 与 com.example.bookpubstarter.dbcount——扫描器不会找到它。
这就是我们的新创建的元注解发挥作用的地方。在 @EnableDbCounting 注解中,有一个键嵌套注解 @Import(DbCountAutoConfiguration.class),这使得事情发生。这是一个由 Spring 提供的注解,可以用来注解其他注解,声明在过程中应该导入哪些配置类。通过用 @EnableDbCounting 注解我们的 BookPubApplication 类,我们递归地告诉 Spring 应该将 DbCountAutoConfiguration 作为应用程序上下文的一部分包含进来。
使用便利的元注解、spring.factories 和条件 Bean 注解,我们现在可以创建复杂且详尽的定制自动配置 Spring Boot 启动器,以满足我们企业的需求。
第五章:应用程序测试
在本章中,我们将涵盖以下主题:
-
为 MVC 控制器创建测试
-
配置数据库模式并填充数据
-
使用内存数据库创建测试
-
使用模拟对象创建测试
-
创建 JPA 组件测试
-
创建 WebMvc 组件测试
-
使用 Cucumber 编写测试
-
使用 Spock 编写测试
简介
在前面的章节中,我们做了大量的编码工作。我们从零开始创建了一个新的 Spring Boot 应用程序,向其中添加了一个 MVC 组件和一些数据库服务,对应用程序的行为进行了一些调整,甚至编写了我们自己的 Spring Boot starter。现在是时候迈出下一步,了解 Spring Boot 在测试所有这些代码时提供了哪些工具和功能,以及它与其他流行测试框架的集成情况如何。
我们将了解如何使用 Spring JUnit 集成创建单元测试。接下来,我们将探索设置数据库并使用测试数据对其进行测试的选项。然后,我们将查看 行为驱动开发(BDD)工具 Cucumber 和 Spock,了解它们如何与 Spring Boot 集成。
为 MVC 控制器创建测试
在前面的章节中,我们在逐步创建我们的应用程序方面取得了很大的进展,但我们如何知道它实际上确实做了我们想要它做的事情?更重要的是,我们如何确保六个月后,甚至一年后,它仍然会继续做我们最初期望它做的事情?这个问题最好的答案就是创建一系列测试,最好是自动化的,对我们的代码运行一系列断言。这确保了在给定的特定输入下,我们始终获得相同和预期的输出。测试给我们带来了急需的安心,我们的应用程序不仅代码优雅、外观美观,而且性能可靠,尽可能无错误。
在 第四章 编写自定义 Spring Boot Starter 中,我们停止了我们的 Web 应用程序安装了自定义编写的 Spring Boot starter。现在我们将创建一些基本的测试来测试我们的 Web 应用程序,并确保所有控制器都公开了预期的 RESTful URL,我们可以依赖这些 URL 作为服务 API。这种测试类型略高于通常所说的 单元测试,因为它测试了整个 Web 应用程序,需要应用程序上下文完全初始化,并且所有 bean 都应该连接在一起才能工作。这种测试有时被称为 集成 或 服务测试。
如何做...
- Spring Boot 通过在项目根目录的
src/test/java/com/example/bookpub目录中创建一个占位符测试文件,名为BookPubApplicationTests.java,来启动我们的项目,其内容如下:
@RunWith(SpringRunner.class)
@SpringApplicationConfiguration(classes =
BookPubApplication.class)
public class BookPubApplicationTests {
@Test
public void contextLoads() {
}
}
- 在
build.gradle中,我们还添加了对spring-boot-starter-test的测试依赖项,如下所示:
testCompile("org.springframework.boot:spring-boot-starter-test")
- 我们将继续扩展基本模板测试,包含以下代码:
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookPubApplicationTests {
@Autowired
private WebApplicationContext context;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private BookRepository repository;
@LocalServerPort
private int port;
private MockMvc mockMvc;
@Before
public void setupMockMvc() {
mockMvc = webAppContextSetup(context).build();
}
@Test
public void contextLoads() {
assertEquals(1, repository.count());
}
@Test
public void webappBookIsbnApi() {
Book book =
restTemplate.getForObject("http://localhost:" +
port + "/books/978-1-78528-415-1", Book.class);
assertNotNull(book);
assertEquals("Packt", book.getPublisher().getName());
}
@Test
public void webappPublisherApi() throws Exception {
mockMvc.perform(get("/publishers/1")).
andExpect(status().isOk()).andExpect(content().
contentType(MediaType.parseMediaType
("application/hal+json;charset=UTF-8"))).
andExpect(content().
string(containsString("Packt"))).
andExpect(jsonPath("$.name").value("Packt"));
}
}
-
通过运行
./gradlew clean test来执行测试。 -
通过查看控制台输出,我们可以知道我们的测试已经成功运行,但我们实际上并没有看到很多信息,除了以下几行(为了简洁而截断):
:compileJava
:compileTestJava
:testClasses
:test
2016-10-13 21:40:44.694 INFO 25739 --- [ Thread-4] ationConfigEmbeddedWebApplicationContext : Closing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@206f4aa6: startup date [Mon Apr 13 21:40:36 CDT 2015]; root of context hierarchy
2016-10-13 21:40:44.704 INFO 25739 --- [ Thread-4] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2016-10-13 21:40:44.705 INFO 25739 --- [ Thread-4] org.hibernate.tool.hbm2ddl.SchemaExport : HHH000227: Running hbm2ddl schema export
2016-10-13 21:40:44.780 INFO 25739 --- [ Thread-4] org.hibernate.tool.hbm2ddl.SchemaExport : HHH000230: Schema export complete
BUILD SUCCESSFUL
Total time: 24.635 secs
-
通过查看 Gradle 生成的 HTML 报告可以获得更好的洞察力,这些报告可以在浏览器中打开,并位于
build/reports/tests/index.html,如下截图所示:![]()
-
点击
com.example.bookpub.BookPubApplicationTests将带我们到单个测试用例分解,显示每个测试的状态以及执行所需的时间,如下截图所示:![]()
-
好奇心更强的人也可以点击标准输出按钮,以便在测试执行期间查看生成的运行时应用程序日志。
它是如何工作的...
现在我们已经创建了第一个测试,让我们详细检查代码。
我们将首先查看为BookPubApplicationTests类声明的以下注解:
-
@RunWith(SpringRunner.class): 这是一个标准的 JUnit 注解,我们可以配置它以使用SpringRunner,为标准的 JUnit 测试提供引导 Spring Boot 框架的功能。 -
@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT): 这是一个标记类为 Spring Boot 测试的注解。它将使用 Spring Boot 框架来配置测试类实例,提供适当的配置、自动装配等。webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT属性意味着当前测试将使用一个真实、运行的服务实例,并需要完整的上下文初始化和应用启动,就像真实的一样。RANDOM_PORT值用于告诉 Spring Boot 在随机选择的 HTTP 端口上启动 Tomcat 服务器,我们稍后将通过声明@LocalServerPortprivate int port;值字段来获取这个端口。当在 Jenkins 或其他任何 CI 服务器上运行测试时,这种选择随机 HTTP 端口的能力非常方便,因为在并行运行多个作业时可能会遇到端口冲突。
魔法类注解消失后,让我们看看类本身的内容。由于这是一个 Spring Boot 测试,我们可以在执行期间将 Spring 管理的任何对象声明为@Autowired,或者使用@Value注解将其设置为特定的环境值。在我们的测试中,我们自动装配了WebApplicationContext和BookRepository对象,以及一个TestRestTemplate实例,我们将在执行标准的 JUnit @Test注解测试用例时使用它。
在第一个测试用例contextLoads()中,我们将断言我们已经建立了BookRepository连接,并且它包含一个书籍条目。
我们的第二个测试将确保我们的 Web 应用程序能够响应一个通过ISBN查找Book的 RESTful URL - "/books/{isbn}"。为此测试,我们将使用TestRestTemplate的实例,并在随机选择的端口上对运行实例进行 RESTful 调用。Spring Boot 提供了port字段的值。
在webappBookIsbnApi测试中,我们使用了一个完整的 URL,其起始部分为"http://localhost:" + port,如果TestRestTemplate被 Spring Boot 自动装配和注入,技术上是不需要的。在这种情况下,可以使用一个相对 URL,看起来像Book book = restTemplate.getForObject("/books/978-1-78528-415-1", Book.class);,并且TestRestTemplate将自动确定运行中的测试服务器实例的端口号。
或者,我们可以通过MockMvc对象执行相同类型的测试。这是由 Spring Test 框架提供的,允许我们在不通过RestTemplate进行基于客户端的测试的情况下执行 MVC 测试,而是完全在服务器端进行,控制器请求是从与测试应用程序相同的上下文中执行的。
为了使用MockMvc,我们将使用MockMvcBuilders实用工具,通过@Autowired WebApplicationContext构建一个实例。我们将在设置方法中这样做,这样我们就不必在每个测试中明确执行它。
如果我们使用WebEnvironment.MOCK而不是RANDOM_PORT来注释我们的测试,Spring Boot 也可以自动创建一个MockMvc实例。这种配置将只使测试在模拟上下文中运行,而不会启动任何真实服务器。我们的示例展示了如何在同一个测试类中结合使用真实服务器实例和MockMVC。
MockMvc为我们提供了一套非常广泛的功能,以便对与网络请求相关的几乎所有事物执行断言。它被设计成以方法链的形式使用,允许我们将各种测试链接在一起,形成一个良好、连续的逻辑链:
-
perform(get(...)):此方法设置网络请求。在我们的特定情况下,我们执行一个 GET 请求,但MockMvcRequestBuilders类为我们提供了所有常见方法调用的静态辅助函数。 -
andExpect(...): 这个方法可以被多次调用,每次调用都代表对perform(...)调用结果的某个条件的评估。这个调用的参数是ResultMatcher接口的任何实现,以及由MockMvcResultMatchers静态实用类提供的许多内置实现。这实际上打开了进行无限多种不同检查的可能性,例如验证响应状态、内容类型、会话中存储的值、闪存作用域、验证重定向、渲染模型的内 容或头信息,等等。我们将使用第三方json-path附加库(作为spring-boot-test依赖自动引入)来测试 JSON 响应数据,以确保它包含正确的元素,并且位于正确的树结构中。andExpect(jsonPath("$.name").value("Packt"))验证了在 JSON 文档的根处有一个值为Packt的name元素。
要了解更多关于 MockMvc 中可用的各种可能性,您可以参考github.com/spring-projects/spring-mvc-showcase/tree/master/src/test/java/org/springframework/samples/mvc。
配置数据库模式和填充数据
在本书的早期部分,在第二章“配置 Web 应用程序”中,我们在StartupRunner的run(...)方法中手动添加了一些数据库条目。虽然这样做可以快速简单地启动某些内容,但从长远来看,这并不是一个好主意,尤其是当你处理大量数据时。将数据库的准备、更改和其他配置与运行应用程序的其余代码分离,即使是在设置测试用例时,也是一种良好的实践。幸运的是,Spring 为我们提供了支持,使这项任务变得相当简单和直接。
我们将继续使用之前配方中留下的应用程序状态。Spring 为我们提供了几种定义如何在数据库中填充结构和数据的方法。第一种方法依赖于 Hibernate 自动创建表结构,通过从我们定义的@Entity对象推断,并使用import.sql文件来填充数据。第二种方法是通过使用普通的 Spring JDBC 功能,这依赖于包含数据库表定义的schema.sql文件和包含数据的相应data.sql文件。
如何做到这一点...
- 首先,我们将移除在第二章“配置 Web 应用程序”中创建的程序化数据库填充。所以,让我们从
StartupRunner的run(...)方法中注释掉以下代码:
Author author = new Author("Alex", "Antonov");
author = authorRepository.save(author);
Publisher publisher = new Publisher("Packt");
publisher = publisherRepository.save(publisher);
Book book = new Book("978-1-78528-415-1", "Spring Boot Recipes", author, publisher);
bookRepository.save(book);
- 如果我们运行测试,如果缺少
test.h2.db文件,它们可能会失败,因为它们期望数据在数据库中。我们将通过在项目根目录的src/main/resources目录下创建以下内容的 Hibernateimport.sql文件来填充数据库:
INSERT INTO author (id, first_name, last_name) VALUES (1, 'Alex', 'Antonov')
INSERT INTO publisher (id, name) VALUES (1, 'Packt')
INSERT INTO book (isbn, title, author_id, publisher_id) VALUES ('978-1-78528-415-1', 'Spring Boot Recipes', 1, 1)
-
通过再次运行
./gradlew clean test来执行测试,它们神奇地启动并通过了。 -
另一种方法是使用 Spring JDBC 对
schema.sql和data.sql的支持。让我们将新创建的import.sql文件重命名为data.sql,并在同一目录下创建一个包含以下内容的schema.sql文件:
-- Create syntax for TABLE 'author'
DROP TABLE IF EXISTS `author`;
CREATE TABLE `author` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
-- Create syntax for TABLE 'publisher'
DROP TABLE IF EXISTS `publisher`;
CREATE TABLE `publisher` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
-- Create syntax for TABLE 'reviewer'
DROP TABLE IF EXISTS `reviewer`;
CREATE TABLE `reviewer` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
-- Create syntax for TABLE 'book'
DROP TABLE IF EXISTS `book`;
CREATE TABLE `book` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`description` varchar(255) DEFAULT NULL,
`isbn` varchar(255) DEFAULT NULL,
`title` varchar(255) DEFAULT NULL,
`author_id` bigint(20) DEFAULT NULL,
`publisher_id` bigint(20) DEFAULT NULL,
PRIMARY KEY (`id`),
CONSTRAINT `FK_publisher` FOREIGN KEY (`publisher_id`) REFERENCES `publisher` (`id`),
CONSTRAINT `FK_author` FOREIGN KEY (`author_id`) REFERENCES `author` (`id`)
);
-- Create syntax for TABLE 'book_reviewers'
DROP TABLE IF EXISTS `book_reviewers`;
CREATE TABLE `book_reviewers` (
`book_id` bigint(20) NOT NULL,
`reviewers_id` bigint(20) NOT NULL,
CONSTRAINT `FK_book` FOREIGN KEY (`book_id`) REFERENCES `book` (`id`),
CONSTRAINT `FK_reviewer` FOREIGN KEY (`reviewers_id`) REFERENCES `reviewer` (`id`)
);
-
由于我们现在正在手动创建数据库模式,我们需要告诉 Hibernate 映射器不要从实体中自动推导出一个,并用它来填充数据库。因此,让我们在项目根目录的
src/main/resources目录下的application.properties文件中设置spring.jpa.hibernate.ddl-auto=none属性。 -
通过运行
./gradlew clean test来执行测试,它们应该会通过。
它是如何工作的...
在这个配方中,我们实际上探索了两种实现相同目标的方法,这在 Spring 生态系统中是很常见的。根据使用的组件不同,无论是纯 Spring JDBC、Spring JPA 与 Hibernate,还是 Flyway 或 Liquibase 迁移,填充和初始化数据库的方法会有所不同,但最终结果基本上是相同的。
Flyway 和 Liquibase 都是提供增量数据库迁移功能的框架。当一个人想要以程序化、可描述的方式维护数据库变化的增量日志,并能够快速将数据库置于特定版本所需的状态时,这非常有用。虽然这些框架在提供此类支持的方法上有所不同,但它们的目的相似。更详细的信息可以在它们各自的网站上获得,flywaydb.org 和 www.liquibase.org。
在前面的例子中,我们探讨了两种不同的填充和初始化数据库的方法。
使用 Spring JPA 和 Hibernate 初始化数据库
在这种方法中,大部分工作实际上是由 Hibernate 库完成的,我们只是设置了适当的配置并创建了 Hibernate 进行工作所需的常规预期文件:
spring.jpa.hibernate.ddl-auto=create-drop设置指示 Hibernate 使用@Entity模型,并根据其结构自动推断数据库模式。在应用程序启动时,将使用计算出的模式预初始化数据库表结构;当应用程序关闭时,它将被全部销毁。即使在应用程序被强制终止或突然崩溃的情况下,在启动时,如果检测到现有表,它们将被删除并从头开始重新创建。因此,对于生产环境来说,依赖这种方式可能不是一个好主意。
如果没有明确配置spring.jpa.hibernate.ddl-auto属性,Spring Boot 默认使用 create-drop 为嵌入式数据库,如 H2,因此请小心并适当设置它。
- Hibernate 期望
import.sql文件位于类路径的根目录中。该文件用于在应用程序启动时执行声明的 SQL 语句。虽然文件中可以放入任何有效的 SQL 语句,但建议您放入数据导入语句,如INSERT或UPDATE,并避免对表结构进行修改,因为模式定义已经由 Hibernate 处理。
使用 Spring JDBC 初始化数据库
如果应用程序不使用 JPA,或者您不想明确依赖 Hibernate 功能,只要存在spring-boot-starter-jdbc依赖项,Spring 就提供了另一种设置数据库的方法。因此,让我们看看我们是如何让它工作的,如下所示列表所示:
-
spring.jpa.hibernate.ddl-auto=none设置告诉 Hibernate,如果 Hibernate 依赖项也存在(如我们案例中所示),不要对数据库进行任何自动处理。在生产环境中,这是一个好的实践,因为您可能不希望意外地清除所有数据库表。那将是一场灾难,这是肯定的! -
预期
schema.sql文件存在于类路径的根目录中。Spring 会在每次应用程序启动时,在数据库创建模式时执行该文件。然而,与 Hibernate 不同,它不会自动删除任何现有的表,因此在使用DROP TABLE IF EXISTS删除现有表并在创建新表之前是一个好主意,或者如果您只想在表不存在时创建新表,可以将CREATE TABLE IF NOT EXISTS作为表创建 SQL 的一部分。这使得声明数据库结构演变逻辑更加灵活,因此在生产环境中使用也更加安全。 -
预期
data.sql文件存在于类路径的根目录中。该文件用于执行数据填充 SQL,因此所有INSERT INTO语句都应放在这里。
由于这是一个 Spring 原生功能,我们还将获得定义模式和数据文件的能力,不仅限于全局,还可以根据特定的数据库平台。例如,我们可以有一组文件,我们可以用于 Oracle,schema-oracle.sql,以及另一组用于 MySQL,schema-mysql.sql。同样适用于data.sql变体;然而,它们不必按平台定义,因此虽然你可能有一些特定平台的模式文件,但可能有一个共享的数据文件。如果你想覆盖 Spring Boot 自动推断的值,可以显式设置spring.datasource.platform配置值。
如果有人想覆盖schema.sql和data.sql的默认名称,Spring Boot 提供了配置属性,我们可以使用这些属性来控制spring.datasource.schema和spring.datasource.data。
使用内存数据库创建测试
在之前的菜谱中,我们探讨了如何设置我们的数据库,使其包含所需的表并填充所需的数据。当涉及到测试时,一个典型的挑战是如何正确且可预测地设置环境,以便在执行测试时,我们可以安全地以确定性的方式断言行为。在一个连接到数据库的应用程序中,确保数据库包含一个确定性的数据集,以便可以对断言进行评估,这一点非常重要。对于一个详尽的测试套件,根据测试来刷新或更改该数据集也是必要的。幸运的是,Spring 提供了一些很好的功能,可以帮助你完成这项任务。
我们将从我们在之前的菜谱中留下的BookPub应用程序的状态开始。在这个阶段,我们有一个定义所有表的schema.sql文件,我们还需要一个数据库,其中包含在data.sql中定义的一些起始数据。在这个菜谱中,我们将扩展我们的测试,以使用针对特定测试套件定制的特定数据固定文件。
如何做到这一点...
-
我们的第一步是在项目的根目录下的
src/test目录中创建一个名为resources的目录。 -
在这个目录中,我们将开始放置我们的固定 SQL 数据文件。让我们在资源目录中创建一个名为
test-data.sql的新文件,内容如下:
INSERT INTO author (id, first_name, last_name) VALUES (2, 'Greg', 'Turnquist')
INSERT INTO book (isbn, title, author_id, publisher_id) VALUES ('978-1-78439-302-1', 'Learning Spring Boot', 2, 1)
- 现在,我们需要一种方法在测试运行时加载此文件。我们将按照以下方式修改我们的
BookPubApplicationTests类:
public class BookPubApplicationTests {
...
@Autowired
private BookRepository repository;
@Autowired
private RestTemplate restTemplate;
@Autowired
private DataSource ds;
@LocalServerPort
private int port;
private MockMvc mockMvc;
private static boolean loadDataFixtures = true;
@Before
public void setupMockMvc() {
...
}
@Before
public void loadDataFixtures() {
if (loadDataFixtures) {
ResourceDatabasePopulator populator =
new ResourceDatabasePopulator(
context.getResource("classpath:/test-data.sql"));
DatabasePopulatorUtils.execute(populator, ds);
loadDataFixtures = false;
}
}
@Test
public void contextLoads() {
assertEquals(2, repository.count());
}
@Test
public void webappBookIsbnApi() {
...
}
@Test
public void webappPublisherApi() throws Exception {
...
}
}
-
通过运行
./gradlew clean test来执行测试,尽管我们向数据库中添加了另一本书及其作者,但测试应该继续通过。 -
我们也可以使用之前菜谱中学到的填充数据库的方法。由于测试代码有自己的
resources目录,我们可以在其中添加另一个data.sql文件,Spring Boot 将会使用这两个文件来填充数据库。让我们继续在项目根目录下的src/test/resources目录中创建一个名为data.sql的文件,并包含以下内容:
INSERT INTO author (id, first_name, last_name) VALUES (3, 'William', 'Shakespeare')
INSERT INTO publisher (id, name) VALUES (2, 'Classical Books')
INSERT INTO book (isbn, title, author_id, publisher_id) VALUES ('978-1-23456-789-1', 'Romeo and Juliet', 3, 2)
由于 Spring Boot 收集了类路径中所有数据文件的出现,因此可以将数据文件放在 JAR 文件或不同的物理位置中,最终它们都会位于类路径的根目录。同样重要的是要记住,这些脚本的加载顺序不是确定的,如果你依赖于某些参照性 ID,最好使用选择来获取它们,而不是做出假设。
- 由于我们向数据库中添加了另一本书,现在我们有三本了,我们应该在我们的
contextLoads()测试方法中修复断言:
assertEquals(3, repository.count());
-
通过运行
./gradlew clean test来执行测试,它们应该继续通过。 -
可以公平地说,当运行单元测试时,内存数据库可能比持久性数据库更适合这个角色。让我们在项目根目录下的
src/test/resources目录中创建一个专门的application.properties文件配置实例,并包含以下内容:
spring.datasource.url = jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.jpa.hibernate.ddl-auto=update
需要注意的是,Spring Boot 只从类路径中加载一个application.properties文件。当我们创建了一个新的application.properties文件在src/test/resources中时,之前的src/main/resources中的那个就不再被加载,因此其中定义的所有属性都没有合并到环境中。因此,你应该配置所有需要的属性值。在我们的例子中,我们必须重新定义spring.jpa.hibernate.dll-auto属性,尽管它已经在src/main/resources/application.properties位置中声明过了。
- 通过运行
./gradlew clean test来执行测试,测试应该继续通过。
它是如何工作的...
在这个菜谱中,我们依赖于 Spring 提供的初始化和填充数据库的功能,以便用所需的数据填充数据库,以便运行测试并对它们进行断言。然而,我们还想能够使用一些只与特定测试套件相关的数据。为此,我们转向了ResourceDatabasePopulator和DatabasePopulatorUtils类,在测试执行之前插入所需的数据。这些正是 Spring 内部用来处理schema.sql和data.sql文件的相同类,但现在,我们明确地定义了我们想要执行的脚本文件。
因此,让我们一步一步地分解我们所做的工作,如下所示:
-
我们创建了一个名为
loadDataFixtures()的设置方法,并用@Before注解标注它,以告诉 JUnit 在每次测试之前运行它。 -
在这个方法中,我们获取了
classpath:/test-data.sql数据文件的资源句柄,该文件位于我们的应用程序类路径中,我们在这里存储测试数据并对其执行@Autowired DataSource ds。 -
由于 Spring 只能在类的实例中自动注入依赖项,并且
@Before注解的设置方法会为每个测试执行,因此我们必须稍微发挥一点创意,以避免每次测试而不是每次测试套件/类执行时重复填充我们的数据库。为了实现这一点,我们创建了一个static boolean loadDataFixtures变量,它为BookPubApplicationTests类的每个实例保留其状态,从而确保我们只执行一次DatabasePopulatorUtils。变量必须是静态的,因为每次测试方法在类中运行时都会创建一个新的测试类实例;在实例级别上拥有boolean标志将无法达到目的。
或者,我们也可以使用@Sql注解代替loadDataFixtures()方法,并将我们的BookPubApplicationTests类标记为@Transactional,以确保在运行每个测试方法之前填充test-data.sql文件。然后我们可以将数据库回滚到执行前的状态。
这使得测试设置变得稍微简单一些,事务部分允许测试在数据库中修改数据,而不必担心竞争条件,但缺点是每次测试之前都会执行 SQL 填充,这会增加一些额外的延迟。
要使这个功能工作,我们需要移除loadDataFixtures()方法,并将以下注解添加到BookPubApplicationTests类中:
@Transactional
@Sql(scripts = "classpath:/test-data.sql")
-
为了最后的润色,我们决定有一个单独的
application.properties文件用于测试目的。我们将它添加到我们的src/test/resources类路径中,使用内存数据库的测试配置,而不是使用基于文件的持久配置。 -
与
application.properties不同,其中只能从类路径加载一个文件,Spring 支持多个配置文件,这些配置文件将被合并。因此,我们不必声明一个完全独立的application.properties文件,我们可以创建一个application-test.properties文件,并在运行测试时设置一个活动配置文件为测试。
使用模拟对象创建测试
在前面的配方中,我们使用数据固定文件来填充内存数据库,以便在可预测和静态的数据集上运行我们的测试。虽然这使得测试一致且可预测,但我们仍然需要为创建数据库、用数据填充它以及初始化所有 JPA 和连接组件付费,这可以被视为测试的一个过度步骤。幸运的是,Spring Boot 提供了内部支持,能够模拟 bean 并将它们作为组件注入测试中,以便在测试设置和作为应用程序上下文中的依赖项进一步使用。
让我们来看看如何使用 Mockito 的力量,这样我们就不需要完全依赖数据库了。我们将学习如何优雅地使用 Mockito 框架和一些@MockBean注解的巧妙性来模拟Repository实例对象。
如何做到...
- 首先,我们将在项目根目录下的
src/test/java/com/example/bookpub目录中创建一个新的MockPublisherRepositoryTests测试类,内容如下:
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.reset;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
public class MockPublisherRepositoryTests {
@MockBean
private PublisherRepository repository;
@Before
public void setupPublisherRepositoryMock() {
given(repository.count()).willReturn(5L);
}
@Test
public void publishersExist() {
assertThat(repository.count()).isEqualTo(5L);
}
@After
public void resetPublisherRepositoryMock() {
reset(repository);
}
}
- 通过运行
./gradlew clean test来执行测试,并且测试应该通过
它是如何工作的...
这里发生了一些神奇的事情。让我们从我们放入MockPublisherRepositoryTests类的注解开始:
@SpringBootTest注解的webEnvironment属性被替换为WebEnvironment.NONE。这是为了通知 Spring Boot 我们不想为这个测试初始化一个完整的应用程序 Web 服务器,因为我们只会与仓库对象进行交互,而不会调用控制器或使用 WebMvc 堆栈的任何部分。我们这样做是为了节省测试启动时间,如果有人好奇想看看差异,只需简单地将它切换回WebEnvironment.RANDOM_PORT值并重新运行测试,就会显示时间几乎翻倍。(在我的 MacBook Pro 上,它从 5 秒增加到近 9 秒。)
在检查了应用程序的更改之后,现在让我们看看我们在MockPublisherRepositoryTests类本身中做了什么:
@MockBean注解指示 Spring 这个依赖项不是一个真实实例,而是一个由 Mockito 框架支持的mock对象。这有一个有趣的效果,它实际上用模拟实例替换了上下文中的PublisherRepositorybean 实例,所以,在上下文中,所有对PublisherRepository的依赖都连接到模拟版本,而不是真实的、数据库支持的版本。
现在我们已经知道了PublisherRepository的模拟实例是如何被注入到我们的测试中的,让我们来看看新创建的测试设置方法。其中两个特别感兴趣的方法是setupPublisherRepositoryMock()和resetPublisherRepositoryMock()。它们被描述如下:
-
setupPublisherRepositoryMock()方法被@Before注解标记,这告诉 JUnit 在运行类中的每个@Test方法之前执行此方法。我们将使用 Mockito 框架来配置我们模拟实例的行为。我们这样配置它,当调用repository.count()方法时,它将返回5作为结果。Mockito、JUnit 和 Hamcrest 库为我们提供了许多方便的 DLS-like 方法,我们可以使用这些方法以类似英语的、易于阅读的风格定义这样的规则。 -
resetPublisherRepositoryMock()方法被@After注解标记,这告诉 JUnit 在每个@Test方法运行后执行此方法。在每个测试结束时,我们需要重置 mock 的行为,因此我们将使用reset(...)方法调用来清除所有设置,并使 mock 为下一个测试做好准备,这个测试可以用于另一个测试套件。
理想情况下,在测试运行结束时没有必要重置mock对象,因为每个测试类都会启动自己的上下文,所以测试类之间不会共享 mock 的实例。通常认为,创建多个较小的测试而不是一个大的测试是一个好的实践。然而,有些情况下,当 mock 对象由容器注入管理时,重置 mock 是有必要的,因此我认为这一点值得提及。关于使用reset(...)的最佳实践,请参阅github.com/mockito/mockito/wiki/FAQ#can-i-reset-a-mock。
创建一个 JPA 组件测试
我们之前的多数测试示例都需要启动整个应用程序并配置所有 bean 才能执行。虽然这对我们这个代码量很少的简单应用程序来说不是大问题,但对于一些更大、更复杂的面向企业的服务来说,可能是一个昂贵且耗时的过程。考虑到良好的测试覆盖率的关键方面之一是低执行时间,我们可能希望避免启动整个应用程序来测试单个组件,或者像 Spring Boot 所说的那样,测试一个“切片”。
在这个菜谱中,我们将尝试创建一个类似于我们之前的PublisherRepository测试的测试,但不需要启动整个容器和初始化所有 bean。方便的是,Spring Boot 为我们提供了@DataJpaTest注解,我们可以将其放在我们的测试类上,它将自动配置所有必要的 JPA 功能组件,但不是整个上下文。因此,像控制器、服务之类的 bean 将会缺失。这个测试非常适合快速测试实体领域对象的绑定有效性,以确保字段名、关联等配置正确。
如何实现...
- 让我们在项目根目录下的
src/test/java/com/example/bookpub目录中创建一个新的JpaAuthorRepositoryTests测试类,其内容如下:
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@DataJpaTest
public class JpaAuthorRepositoryTests {
@Autowired
private TestEntityManager mgr;
@Autowired
private AuthorRepository repository;
@Test
public void testAuthorEntityBinding() {
Long id = mgr.persistAndGetId(createAuthor(),
Long.class);
Author author = repository.findById(id).get();
assertThat(author.getFirstName()).
isEqualTo("Mark");
assertThat(author.getLastName()).
isEqualTo("Twain");
}
private Author createAuthor() {
return new Author("Mark", "Twain");
}
}
- 通过运行
./gradlew clean test来执行测试,并且测试应该继续通过
它是如何工作的...
与我们之前的测试相比,关键的区别在于缺少 @SpringBootTest 注解,它已被 @DataJpaTest 注解所取代。测试类本身的明显简单性得益于 @DataJpaTest 注解承担了大部分的声明和负载,以配置测试环境。如果我们查看注解定义,我们会看到无数不同的内部注解配置了所有必要的组件。其中重要的是 @AutoConfigure* 注解,如 @AutoConfigureDataJpa 或 @AutoConfigureTestDatabase。这些注解本质上指导 Spring Boot 在引导测试时导入必要的组件配置。例如,在 @DataJpaTest 中,只有 Cache、DataJpa、TestDatabase 和 TestEntityManager 组件会被配置并可用,这显著减少了测试的足迹,无论是从内存角度还是启动和执行时间。然后,具体的配置类将从我们之前看到的 META-INF/spring.factories 描述符中加载,这些描述符由各种工件提供。
在初始化了正确的组件后,我们可以利用一些预配置的豆(beans),例如 TestEntityManager,它赋予我们与测试数据库实例交互的能力,预先初始化其内容所需的状态,并操作测试数据。这保证了在每个测试套件执行完毕后,我们将为下一个套件获得一个干净的起点,无需显式清理。这使得编写测试更加容易,无需担心执行顺序和测试套件之间潜在的变化越界,避免因意外的不一致状态而使测试结果不一致。
创建一个 WebMvc 组件测试
*Test 切片集合中的另一个是 @WebMvcTest,它允许我们为应用程序的 WebMvc 部分创建测试,快速测试控制器、过滤器等,同时提供使用 @MockBean 配置必要依赖项的能力,如服务、数据存储库等。
这是 Spring Boot 测试框架提供的另一个非常有用的测试切片,我们将在这个配方中探讨其使用方法,看看我们如何为我们的 BookController 文件创建一个 Mvc 层测试,使用预定义的数据集模拟 BookRepository 服务,并确保返回的 JSON 文档是我们根据该数据预期的那样。
如何做...
- 首先,我们将在项目根目录下的
src/test/java/com/example/bookpub目录中创建一个新的WebMvcBookControllerTests测试类,内容如下:
import static org.hamcrest.Matchers.containsString;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@WebMvcTest
public class WebMvcBookControllerTests {
@Autowired
private MockMvc mockMvc;
@MockBean
private BookRepository repository;
// The 2 repositories below are needed to
//successfully initialize StartupRunner
@MockBean
private AuthorRepository authorRepository;
@MockBean
private PublisherRepository publisherRepository;
@Test
public void webappBookApi() throws Exception {
given(repository.findBookByIsbn("978-1-78528-415-1"))
.willReturn(new Book("978-1-78528-415-1",
"Spring Boot Recipes",
new Author("Alex", "Antonov"),
new Publisher("Packt")));
mockMvc.perform(get("/books/978-1-78528-415-1")).
andExpect(status().isOk()).
andExpect(content().
contentType(MediaType.parseMediaType
("application/json;charset=UTF-8"))).
andExpect(content().
string(containsString("Spring Boot Recipes"))).
andExpect(jsonPath("$.isbn").
value("978-1-78528-415-1"));
}
}
- 通过运行
./gradlew clean test来执行测试,并且测试应该继续通过
它是如何工作的...
@WebMvcTest的功能与我们之前在配方中看到的@DataJpaTest注解非常相似。真正的区别仅仅在于测试引导过程中初始化的一组组件。与@DataJpaTest不同,这次没有为我们提供数据库组件,而是我们得到了WebMvc和MockMvc配置,这些配置为初始化控制器、过滤器、拦截器等提供了所有必要的基石。因此,我们必须将AuthorRepository和PublisherRepository作为模拟豆子添加到我们的测试代码中,否则测试将无法启动,因为 Spring Boot 将无法满足StartupRunner类对这两个存储库的豆依赖。
解决这个问题的另一个方案可能是从StartupRunner类中移除对这两个存储库的依赖,因为我们已经在本章的“配置数据库模式和填充它”配方中注释掉了使用它们的代码。如果那不可能,我想展示如何处理你拥有其他类中的 bean 依赖的情况,这些类与测试没有直接关系,但在初始化和执行期间会导致启动失败。
如我们所见,与我们的先前的配方测试不同,那次我们没有使用任何 bean 模拟,因为它是测试一个没有进一步依赖的底层组件,这次我们需要提供一个BookRepository模拟,它被我们的BookController类使用,我们正在测试其功能。
我们已经看到如何在MockPublisherRepositoryTests类中使用@Before注解预先配置模拟对象,所以这次我们在webappBookApi测试方法中直接进行配置,这与我们将要学习使用 Spock 框架编写测试时看到的风格相似。
在given(...)调用内部,我们预先配置了BookRepository模拟对象的行为,指示它在findBookByIsbn方法被调用并传入参数"978-1-78528-415-1"时返回一个特定的Book实例。
我们下一次调用mockMvc.perform并传入/books/978-1-78528-415-1将触发BookController的getBook方法的调用,该方法委托预先连接的模拟实例bookRepository,并使用我们预先配置的Book对象实例来运行验证逻辑。
从日志中可以看出,我们只能看到 WebMvc 层已经引导。没有初始化数据库或其他组件,这导致了运行时的显著节省,仅用 3 秒,而之前的完整应用程序引导测试需要 9 秒。
使用 Cucumber 编写测试
单元测试已经很长时间是软件开发生命周期的一个预期部分,几乎没有人会想象编写没有单元测试的代码。测试的艺术并不停滞不前,测试哲学的进步甚至将单元测试的概念进一步扩展,引入了诸如服务测试、集成测试,最后是被称为 BDD 的测试,它建议创建描述应用程序行为的测试套件,而不必深入到代码较低层次的实现细节。这样一个在 Ruby 世界首先获得大量流行,后来扩展到包括 Java 在内的其他语言的框架是 Cucumber BDD。
为了本菜谱的目的,我们将继续使用之前的示例,通过添加 Cucumber-JVM 实现来增强测试套件,这将为我们提供原始 Ruby Cucumber 框架的 Java 版本,并创建一些测试来展示与 Spring Boot 应用程序的能力和集成点。
这个菜谱绝不是为了涵盖 Cucumber 测试框架提供的所有功能,它主要关注 Cucumber 和 Spring Boot 的集成点。要了解更多关于 Cucumber-JVM 的信息,您可以访问cukes.info/docs#cucumber-implementations或github.com/cucumber/cucumber-jvm以获取详细信息。
如何做到这一点...
- 我们需要做的第一件事是将 Cucumber 库的必要依赖项添加到我们的
build.gradle文件中,如下所示:
dependencies {
compile("org.springframework.boot:spring-boot-starter-data-jpa")
compile("org.springframework.boot:spring-boot-starter-jdbc")
compile("org.springframework.boot:spring-boot-starter-web")
compile("org.springframework.boot:spring-boot-starter-data-rest")
compile project(":db-count-starter")
runtime("com.h2database:h2")
runtime("mysql:mysql-connector-java")
testCompile("org.springframework.boot:spring-boot-starter-test")
testCompile("info.cukes:cucumber-spring:1.2.5")
testCompile("info.cukes:cucumber-java8:1.2.5")
testCompile("info.cukes:cucumber-junit:1.2.5")
}
- 接下来,我们需要创建一个测试驱动类来运行 Cucumber 测试。让我们在我们的项目根目录的
src/test/java/com/example/bookpub目录下创建一个名为RunCukeTests.java的文件,并包含以下内容:
@RunWith(Cucumber.class)
@CucumberOptions(plugin={"pretty", "html:build/reports/cucumber"},
glue = {"cucumber.api.spring",
"classpath:com.example.bookpub"},
monochrome = true)
public class RunCukeTests {
}
- 驱动类创建完成后,我们就准备好开始编写 Cucumber 所说的步骤定义了。我将在本菜谱的如何工作...部分简要介绍这些内容。现在,让我们在我们的项目根目录的
src/test/java/com/example/bookpub目录下创建一个名为RepositoryStepdefs.java的文件,并包含以下内容:
@WebAppConfiguration
@ContextConfiguration(classes = BookPubApplication.class,
loader = SpringBootContextLoader.class)
public class RepositoryStepdefs {
@Autowired
private WebApplicationContext context;
@Autowired
private DataSource ds;
@Autowired
private BookRepository bookRepository;
private Book loadedBook;
@Given("^([^\"]*) fixture is loaded$")
public void data_fixture_is_loaded(String fixtureName)
throws Throwable {
ResourceDatabasePopulator populator
= new ResourceDatabasePopulator
(context.getResource("classpath:/" + fixtureName + ".sql"));
DatabasePopulatorUtils.execute(populator, ds);
}
@Given("^(\d+) books available in the catalogue$")
public void books_available_in_the_catalogue(int bookCount)
throws Throwable {
assertEquals(bookCount, bookRepository.count());
}
@When("^searching for book by isbn ([\d-]+)$")
public void searching_for_book_by_isbn(String isbn)
throws Throwable {
loadedBook = bookRepository.findBookByIsbn(isbn);
assertNotNull(loadedBook);
assertEquals(isbn, loadedBook.getIsbn());
}
@Then("^book title will be ([^"]*)$")
public void book_title_will_be(String bookTitle)
throws Throwable {
assertNotNull(loadedBook);
assertEquals(bookTitle, loadedBook.getTitle());
}
}
- 现在,我们需要在项目根目录的
src/test/resources/com/example/bookpub目录下创建一个名为repositories.feature的相应测试功能定义文件,内容如下:
@txn
Feature: Finding a book by ISBN
Background: Preload DB Mock Data
Given packt-books fixture is loaded
Scenario: Load one book
Given 3 books available in the catalogue
When searching for book by isbn 978-1-78398-478-7
Then book title will be Orchestrating Docker
- 最后,我们将在项目根目录的
src/test/resources目录下创建一个名为packt-books.sql的更多数据 SQL 文件,内容如下:
INSERT INTO author (id, first_name, last_name) VALUES (5, 'Shrikrishna', 'Holla')
INSERT INTO book (isbn, title, author_id, publisher_id) VALUES ('978-1-78398-478-7', 'Orchestrating Docker', 5, 1)
-
通过运行
./gradlew clean test来执行测试,测试应该会通过。 -
随着 Cucumber 的添加,我们也在 JUnit 报告和 Cucumber 特定的报告 HTML 文件中获得了测试结果。如果我们打开浏览器中的
build/reports/tests/index.html并点击“类”按钮,我们将在表中看到我们的场景,如下面的截图所示! -
如我们所见,描述比我们在原始基于 JUnit 的测试用例中看到的类和方法名称更友好。
-
Cucumber 还会生成自己的报告,可以通过在浏览器中打开
build/reports/cucumber/index.html来查看。 -
作为一种行为驱动测试框架,特征文件不仅允许我们定义单个条件,还可以声明整个场景概述,这使得定义多个类似数据的断言更容易。让我们在项目根目录的
src/test/resources/com/example/bookpub目录中创建另一个名为restful.feature的特征文件,其内容如下:
@txn
Feature: Finding a book via REST API
Background:
Given packt-books fixture is loaded
Scenario Outline: Using RESTful API to lookup books by ISBN
Given catalogue with books
When requesting url /books/<isbn>
Then status code will be 200
And response content contains <title>
Examples:
|isbn |title |
|978-1-78398-478-7|Orchestrating Docker|
|978-1-78528-415-1|Spring Boot Recipes |
- 我们还将在项目根目录的
src/test/java/com/example/bookpub目录中创建一个相应的RestfulStepdefs.java文件,其内容如下:
import cucumber.api.java.Before;
import cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import cucumber.api.java.en.When;
import static org.hamcrest.CoreMatchers.containsString;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
@WebAppConfiguration
@ContextConfiguration(classes = BookPubApplication.class, loader = SpringBootContextLoader.class)
public class RestfulStepdefs {
@Autowired
private WebApplicationContext context;
@Autowired
private BookRepository bookRepository;
private MockMvc mockMvc;
private ResultActions result;
@Before
public void setup() throws IOException {
mockMvc =
MockMvcBuilders.webAppContextSetup(context).build();
}
@Given("^catalogue with books$")
public void catalogue_with_books() {
assertTrue(bookRepository.count() > 0);
}
@When("^requesting url ([^"]*)$")
public void requesting_url(String url) throws Exception {
result = mockMvc.perform(get(url));
}
@Then("^status code will be ([\d]*)$")
public void status_code_will_be(int code) throws
Throwable {
assertNotNull(result);
result.andExpect(status().is(code));
}
@Then("^response content contains ([^"]*)$")
public void response_content_contains(String content)
throws Throwable {
assertNotNull(result);
result.andExpect(
content().string(containsString(content))
);
}
}
- 通过运行
./gradlew clean test来执行测试,并且测试应该继续通过。
它是如何工作的...
如果你看了所有这些代码并跟随着,但没有完全理解到底发生了什么,这里将详细说明我们所做的一切。
让我们从对步骤定义的快速概述开始。由于 Cucumber 框架使用Gherkin特征文档文件来描述要测试的业务规则,这些规则以类似英语的句子陈述形式表示,因此这些规则需要被转换成可执行代码。这就是步骤定义类的工作。定义的特征场景中的每个步骤都需要与步骤定义类中的一个方法相匹配,以便执行它。这种匹配是通过在方法上方的步骤注释中声明正则表达式来完成的。正则表达式包含 Cucumber 使用的匹配组,以便提取方法参数并将它们传递给执行方法。
在RepositoryStepdefs中,我们可以在以下方法中看到这一点:
@Given("^([^\"]*) fixture is loaded$")
public void data_fixture_is_loaded(String fixtureName) {...}
@Given 注解包含匹配 Given packt-books fixture is loaded 文本的正则表达式,该文本从 repositories.feature 文件加载,并从模式中提取 packt-books 文本,然后将其作为 fixtureName 参数传递给方法。@When 和 @Then 注解按照完全相同的原则工作。因此,实际上 Cucumber 框架所做的就是将特性文件中的类似英语的规则与执行方法的匹配模式相匹配,并将规则的部分作为参数传递给匹配的方法。
更多关于 Gherkin 及其使用方法的信息可以在 cukes.info/docs/reference#gherkin 找到。
在解释了 Cucumber 的基本概述后,让我们将注意力转向测试如何与 Spring Boot 集成以及如何进行配置。
所有这些都始于驱动程序工具类,在我们的案例中是 RunCukeTests。这个类本身不包含任何测试,但它有两个重要的注解来连接这些元素,@RunWith(Cucumber.class) 和 @CucumberOptions:
@RunWith(Cucumber.class): 这是一个 JUnit 注解,表示 JUnit 运行器应该使用 Cucumber 特性文件来执行测试。
@CucumberOptions: 这为 Cucumber 提供了额外的配置:
-
plugin={"pretty", "html:build/reports/cucumber"}: 这告诉 Cucumber 在build/reports/cucumber目录中以 HTML 格式生成其报告。 -
glue = {"cucumber.api.spring", "classpath:com.example.bookpub"}: 这是一个非常重要的设置,因为它告诉 Cucumber 在测试执行期间要加载哪些包以及从哪里加载它们。cucumber.api.spring包需要存在,以便利用cucumber-spring集成库,而com.example.bookpub包是我们步骤定义实现类的位置。 -
monochrome = true: 这告诉 Cucumber 在与 JUnit 集成时不要使用 ANSI 颜色打印输出,因为这在保存的控制台输出文件中看起来不会正确。
选项的完整列表可以在 cukes.info/docs/reference/jvm#list-all-options 找到。
现在让我们看看 RepositoryStepdefs 类。它在类级别开始于以下注解:
-
@WebAppConfiguration指示 Spring 该类需要初始化WebApplicationContext,并在执行过程中用于测试目的。 -
@ContextConfiguration(classes = BookPubApplication.class和loader = SpringBootContextLoader.class)指示 Spring 使用BookPubApplication类作为 Spring 应用程序上下文的配置,以及使用 Spring Boot 中的SpringBootContextLoader类来引导测试工具。
需要注意的是,这些注释必须与所有步骤定义类匹配,否则只有其中一个类会被@ContextConfiguration注释标注,以便将 Spring 对 Cucumber 测试的支持连接起来。
由于cucumber-spring集成不了解 Spring Boot,只知道 Spring,所以我们不能使用@SpringBootTest元注释。我们必须求助于仅使用 Spring 的注释来将事物连接起来。幸运的是,我们不必经历许多繁琐的过程,只需通过传递所需的配置类和加载器来声明SpringBootTest封装的确切注释即可。
一旦放置了适当的注释,Spring 和 Spring Boot 将接管并提供与我们的步骤定义类依赖项自动装配相同的便利性。
Cucumber 测试的一个有趣特性是为每次执行场景创建一个新的步骤定义类实例。尽管方法命名空间是全局的——这意味着我们可以使用在不同步骤定义类中声明的各种方法——但它们操作的是它们中定义的状态,并且不是直接共享的。然而,在另一个步骤定义实例中@Autowire另一个步骤定义的实例是可能的,并依赖于公共方法或字段来访问和修改数据。
由于每个场景都会创建一个新的实例,定义类是具有状态的,并且依赖于内部变量在断言之间的转换中保持状态。例如,在@When标注的方法中,设置特定的状态,而在@Then标注的方法中,对该状态的断言集将被评估。在我们的RepositoryStepdefs类示例中,我们将在其searching_for_book_by_isbn(...)方法中内部设置loadedBook类变量的状态,稍后它将被用于在book_title_will_be(...)方法中断言,以验证书籍标题的匹配。因此,如果我们在我们特征文件中混合来自不同定义类的规则,内部状态将无法在多个类之间访问。
当与 Spring 集成时,可以使用模拟对象的注入——正如我们在之前的示例中在MockPublisherRepositoryTests中看到的——并且可以使用共享的@Given标注方法来设置特定测试的模拟行为。然后我们可以使用相同的依赖实例并将其注入到另一个定义类中,以便用于评估@Then标注的断言方法。
另一种方法是我们在第二个定义类中看到的,即RestfulStepdefs,其中我们注入了BookRepository。然而,在restful.feature中,我们将使用Given packt-books fixture is loaded行为声明,这相当于从RepositoryStepdefs类调用data_fixture_is_loaded方法,该类共享相同的注入BookRepository对象实例,将其中的packt-books.sql数据插入其中。
如果我们需要在RestfulStepdefs类内部访问RepositoryStepdefs实例中的loadedBook字段值,我们可以在RestfulStepdefs内部声明@Autowired RepositoryStepdefs字段,并将loadedBook字段设置为public而不是private,以便使其对外部世界可访问。
Cucumber 和 Spring 集成的另一个巧妙功能是在特性文件中使用@txn注解。这告诉 Spring 在事务包装器中执行测试,在测试执行之间重置数据库,并保证每个测试都有一个干净的数据库状态。
由于所有步骤定义类之间的全局方法命名空间和定义特性文件的测试行为,我们可以利用 Spring 注入的力量,以便重用测试模型并为所有测试提供一个共同的设置逻辑。这使得测试的行为类似于我们的应用程序在实际生产环境中的功能。
使用 Spock 编写测试
另一个同样受欢迎的测试框架是 Spock,它是由 Peter Niederwieser 用 Groovy 编写的。作为一个基于 Groovy 的框架,它非常适合为大多数基于 JVM 的语言创建测试套件,尤其是 Java 和 Groovy 本身。Groovy 的动态语言特性使得在 Groovy 语言中编写优雅、高效和表达性的规范变得非常合适,无需翻译。这是通过 Cucumber 和 Gherkin 库实现的。基于 JUnit 之上,并通过 JUnit 的@RunWith功能与之集成,就像 Cucumber 做的那样,它是对传统单元测试的一个简单增强,并且与所有现有工具配合良好,这些工具内置了对 JUnit 的支持或集成。
在这个菜谱中,我们将从上一个菜谱留下的地方开始,并使用几个基于 Spock 的测试来增强我们的测试集合。在这些测试中,我们将看到如何使用 Spring 依赖注入和测试工具来设置 MockMVC。这些将被 Spock 测试规范用来验证我们的数据存储服务将返回预期的数据。
如何做到这一点...
- 为了将 Spock 测试添加到我们的应用程序中,我们首先需要修改我们的
build.gradle文件。由于 Spock 测试是用 Groovy 编写的,所以首先要做的是在我们的build.gradle文件中添加一个groovy插件,如下所示:
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'groovy'
apply plugin: 'spring-boot'
- 我们还需要将必要的 Spock 框架依赖项添加到
build.gradle依赖项块中:
dependencies {
...
testCompile('org.spockframework:spock-core:1.1-groovy-2.4-rc-2')
testCompile('org.spockframework:spock-spring:1.1-groovy-2.4-rc-2')
...
}
-
由于测试将使用 Groovy 编写,我们需要为文件创建一个新的源目录。让我们在项目根目录中创建
src/test/groovy/com/example/bookpub目录。 -
现在我们已经准备好编写我们的第一个测试了。在项目根目录下的
src/test/groovy/com/example/bookpub目录中创建一个名为SpockBookRepositorySpecification.groovy的文件,并包含以下内容:
package com.example.bookpub;
import com.example.bookpub.entity.Author;
import com.example.bookpub.entity.Book
import com.example.bookpub.entity.Publisher
import com.example.bookpub.repository.BookRepository
import com.example.bookpub.repository.PublisherRepository
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.jdbc.datasource.init.DatabasePopulatorUtils
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator
import org.springframework.test.web.servlet.MockMvc
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.ConfigurableWebApplicationContext
import spock.lang.Specification
import javax.sql.DataSource
import static org.hamcrest.CoreMatchers.containsString
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class SpockBookRepositorySpecification extends Specification {
@Autowired
private ConfigurableWebApplicationContext context
@Autowired
private DataSource ds;
@Autowired
private BookRepository repository;
@Autowired
private MockMvc mockMvc;
void setup() {
ResourceDatabasePopulator populator =
new ResourceDatabasePopulator(
context.getResource("classpath:/packt-books.sql"));
DatabasePopulatorUtils.execute(populator, ds);
}
@Transactional
def "Test RESTful GET"() {
when:
def result = mockMvc.perform(get("/books/${isbn}"));
then:
result.andExpect(status().isOk())
result.andExpect(
content().string(containsString(title))
);
where:
isbn | title
"978-1-78398-478-7"|"Orchestrating Docker"
"978-1-78528-415-1"|"Spring Boot Recipes"
}
@Transactional
def "Insert another book"() {
setup:
def existingBook =
repository.findBookByIsbn("978-1-78528-415-1")
def newBook = new Book("978-1-12345-678-9",
"Some Future Book", existingBook.getAuthor(),
existingBook.getPublisher()
)
expect:
repository.count() == 3
when:
def savedBook = repository.save(newBook)
then:
repository.count() == 4
savedBook.id > -1
}
}
-
通过运行
./gradlew clean test来执行测试,并且测试应该通过。 -
由于 Spock 与 JUnit 集成,我们可以看到 Spock 测试的执行报告与我们的其他测试套件一起。如果我们打开浏览器中的
build/reports/tests/index.html并点击“类”按钮,我们将在表中看到我们的规范,如下面的截图所示:

- 选择
com.example.bookpub.SpockBookRespositorySpecification链接将带我们到详细报告页面,如下所示:

- 接下来,我们将进一步扩展我们的测试,并探索数据库仓库的模拟功能。让我们以
PublisherRepository作为我们的模拟候选,并将其连接到BookController类以提供getBooksByPublisher功能。让我们将以下内容添加到项目根目录下的src/main/java/com/example/bookpub/controllers目录中的BookController类中:
@Autowired
private PublisherRepository publisherRepository;
@RequestMapping(value = "/publisher/{id}", method = RequestMethod.GET)
public List<Book> getBooksByPublisher(@PathVariable("id") Long id) {
Optional<Publisher> publisher =
publisherRepository.findById(id);
Assert.notNull(publisher);
Assert.isTrue(publisher.isPresent());
return publisher.get().getBooks();
}
- 让我们将以下内容添加到项目根目录下的
src/main/java/com/example/bookpub/entity目录中的Publisher类中:
@OneToMany(mappedBy = "publisher")
@JsonBackReference
private List<Book> books;
- 最后,让我们将获取器和设置器添加到
Publisher实体类中的书籍属性:
public List<Book> getBooks() {
return books;
}
public void setBooks(List<Book> books) {
this.books = books;
}
- 在完成所有代码添加后,我们准备向项目根目录下的
src/test/groovy/com/example/bookpub目录中的SpockBookRepositorySpecification.groovy文件添加另一个测试,内容如下:
...
class SpockBookRepositorySpecification extends Specification {
...
@MockBean
private PublisherRepository publisherRepository
@Transactional
def "Test RESTful GET books by publisher"() {
setup:
Publisher publisher =
new Publisher("Strange Books")
publisher.setId(999)
Book book = new Book("978-1-98765-432-1",
"Mystery Book",
new Author("John", "Doe"),
publisher)
publisher.setBooks([book])
Mockito.when(publisherRepository.count()).
thenReturn(1L)
Mockito.when(publisherRepository.findById(1L)).
thenReturn(Optional.of(publisher))
when:
def result =
mockMvc.perform(get("/books/publisher/1"))
then:
result.andExpect(status().isOk())
result.andExpect(content().
string(containsString("Strange Books")))
cleanup:
Mockito.reset(publisherRepository)
}
}
- 通过运行
./gradlew clean test来执行测试,并且测试应该继续通过。
它是如何工作的...
如您从本示例中看到的,编写测试可以像被测试的生产代码本身一样详细和复杂。让我们检查我们为了将 Spock 测试集成到我们的 Spring Boot 应用程序中而采取的步骤。
我们首先添加了一个 Groovy 插件,以便使我们的构建对 Groovy 友好,我们还添加了所需的 Spock 库依赖项spock-core和spock-spring,这两者都是使 Spock 与 Spring 的依赖注入和上下文一起工作所必需的。
下一步是创建 SpockBookRepositorySpecification Spock 规范,它扩展了 Spock 的规范抽象基类。扩展 Specification 类非常重要,因为这是 JUnit 知道我们的类是需要执行测试类的方式。如果我们查看 Specification 源代码,我们会看到 @RunWith(Sputnik.class) 注解,就像我们在 Cucumber 菜谱中使用的那样。除了 JUnit 引导之外,Specification 类还为我们提供了许多有用的方法和模拟支持。
关于 Spock 提供的详细功能,你可以参考可用的 Spock 文档,网址为 spockframework.github.io/spock/docs/current/index.html。
值得注意的是,我们为 SpockBookRepositorySpecification 类使用了与我们的基于 Spring Boot 的测试相同的注解,如下面的代码所示:
@SpringBootTest
@AutoConfigureMockMvc
我们之所以必须添加 @AutoConfigureMockMvc 而不是仅仅使用 @SpringBootTest,是为了添加功能,允许我们使用 @Autowire MockMvc 实例,而不是必须自己创建它。常规的 @SpringBootTest 并不会自动创建和配置 MockMvc 对象的实例,因此我们可能需要手动创建,就像我们在 BookPubApplicationTests 中所做的那样,或者添加 @AutoConfigureMockMvc 注解,这是在 @WebMvcTest 内部使用的,让 Spring 为我们处理它。好消息是我们可以始终使用与 Spring Boot 相同的注解组合,并直接注解我们的类,这正是我们所做的。
与 Cucumber 不同,Spock 将测试的所有方面结合在一个 Specification 类中,将其划分为多个块,如下所示:
-
setup: 这个块用于配置特定的测试,包括变量设置、数据填充、构建模拟对象等。 -
expect: 这个块是 Spock 定义的一个刺激块,设计用来包含简单的表达式,断言状态或条件。除了评估条件之外,我们只能在这个块中定义变量,不允许做其他任何事情。 -
when: 这个块是另一种刺激类型块,它总是与then一起使用。它可以包含任何任意代码,并设计用来定义我们试图测试的行为。 -
then: 这个块是一个响应类型块。它与expect类似,只能包含条件、异常检查、变量定义和对象交互,例如特定方法被调用的次数等。
关于交互测试的更多信息可以在 Spock 的网站上找到,网址为 spockframework.github.io/spock/docs/current/interaction_based_testing.html。
cleanup:这个块用于清理环境的状态,并可能撤销作为单个测试执行部分所做的任何更改。在我们的配方中,这是我们重置PublisherRepository模拟对象的地方。
Spock 还为我们提供了基于实例的setup()和cleanup()方法,这些方法可以用来定义适用于规范中所有测试的通用设置和清理行为。
如果我们看看我们的setup()方法,这就是我们可以配置数据库以测试数据填充的地方。一个有趣且重要的细微差别是,setup()方法在每次测试方法之前执行,而不是每个类执行一次。在做诸如填充数据库之类的事情时,这一点很重要,以避免在不适当的回滚下多次重新插入相同的数据。
帮助我们实现这一点的就是测试方法的@Transactional注解。就像 Cucumber 功能文件中的@txn标签一样,这个注解指示 Spock 以事务作用域执行注解方法及其相应的setup()和cleanup()执行,这些执行在特定测试方法完成后会被回滚。我们依赖于这种行为来为每个测试获得一个干净的数据库状态,这样我们就不需要在每次运行每个测试时在setup()方法的执行中插入重复的数据。
大多数人可能都在想,为什么我们不得不在我们的Publisher实体类中添加@JsonBackReference注解。答案与 Jackson JSON 解析器及其处理循环依赖的方式有关。在我们的模型中,我们有一本书属于一个出版社,每个出版社有多本书。当我们用Books模拟创建Publisher类并将一个出版社实例分配给一本书——后来这本书被放入出版社的书籍集合中——我们就创建了一个循环引用。在执行BookController.getBooksByPublisher(...)方法期间,Jackson 渲染器在尝试将对象模型写入 JSON 时可能会抛出StackOverflowError。通过在Publisher上添加这个注解,我们告诉 Jackson 对象是如何相互引用的,因此,Jackson 现在正确地处理它,从而避免了循环引用循环的情况。
最后需要记住的重要一点是 Spring Boot 如何处理和加工被@RepositoryRestResource注解的仓库接口。与被我们用普通的@Repository注解注解的BookRepository接口不同,我们后来明确将其声明为BookController类的自动装配依赖项,我们没有为其他仓库接口(如PublisherRepository等)创建显式的控制器来处理 RESTful 请求。这些接口会被 Spring Boot 扫描,并自动包装成映射的端点,捕获请求并将调用委托给后端的SimpleJpaRepository代理。由于这种设置,我们只能为这些被明确注入为 bean 依赖项的对象(例如我们的BookRepository示例)使用模拟对象替换方法。好消息是,在这些情况下,我们并没有明确期望 bean 进行连接,只是使用一些注解来为 Spring Boot 提供接口的元数据以执行其魔法,我们可以依赖 Spring Boot 正确地完成这项工作。我们知道它已经测试了其背后的所有功能,所以我们不需要测试它们。为了测试实际的仓库和实体功能,我们可以使用@DataJpaTest注解来进行特定的 JPA 切片测试。
第六章:应用程序打包和部署
在本章中,我们将涵盖以下主题:
-
创建 Spring Boot 可执行 JAR 文件
-
创建 Docker 镜像
-
构建自执行二进制文件
-
Spring Boot 环境配置、层次结构和优先级
-
使用 EnvironmentPostProcessor 向环境中添加自定义 PropertySource
-
使用属性文件外部化环境配置
-
使用环境变量外部化环境配置
-
使用 Java 系统属性外部化环境配置
-
使用 JSON 外部化环境配置
-
设置 Consul
-
使用 Consul 和 envconsul 外部化环境配置
简介
一个应用程序如果没有被使用,那还有什么意义?在当今这个时代——当 DevOps 成为软件开发的方式,当云成为王者,当构建微服务被认为是应该做的事情时——很多注意力都集中在应用程序如何在指定的环境中打包、分发和部署。
十二要素应用方法在定义现代 软件即服务 (SaaS) 应用程序应该如何构建和部署方面发挥了关键作用。其中一个关键原则是将环境配置定义与应用程序和存储分离。十二要素应用方法还倾向于依赖项的隔离和捆绑、开发与生产一致性、以及应用程序部署的简便性和可丢弃性,以及其他方面。
十二要素应用方法可以在 12factor.net/ 找到。
DevOps 模型还鼓励我们对自己的应用程序拥有完整的所有权,从编写和测试代码到构建和部署。如果我们想要承担这种所有权,我们需要确保维护和运营成本不会过高,并且不会占用我们开发新功能的主要任务太多时间。这可以通过拥有干净、定义良好且隔离的部署工件来实现,这些工件是自包含的、自执行的,并且可以在任何环境中部署而无需重新构建。
以下食谱将指导我们完成所有必要的步骤,以实现低努力度的部署和维护,同时保持代码的干净和优雅。
创建 Spring Boot 可执行 JAR 文件
Spring Boot 的魔力如果没有提供一种优雅的方式来打包整个应用程序,包括所有依赖项、资源等,在一个复合的可执行 JAR 文件中,那就不会完整。JAR 文件创建后,只需运行 java -jar <name>.jar 命令即可简单地启动。
我们将继续使用前几章中构建的应用程序代码,并添加必要的功能以打包它。让我们继续看看如何创建 Spring Boot Uber JAR。
Uber JAR 通常被称为一个封装在单个复合 JAR 文件中的应用程序包,该文件内部包含一个/lib目录,其中包含所有依赖的内部 JAR,以及可选的包含可执行文件的/bin目录。
如何做...
-
让我们从第五章,“应用测试”,进入代码目录,并执行
./gradlew clean build -
在构建了 Uber JAR 之后,让我们通过执行
java -jar build/libs/ch6-0.0.1-SNAPSHOT.jar来启动应用程序 -
这将导致我们的应用程序在以下控制台输出的 JAR 文件中运行:
. ____ _ __ _ _
/\ / ___'_ __ _ _(_)_ __ __ _
( ( )___ | '_ | '_| | '_ / _` |
\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |___, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.0.0.BUILD-SNAPSHOT)
...
(The rest is omitted for conciseness)
...
2017-12-17 INFO: Registering beans for JMX exposure on startup
2017-12-17 INFO: Tomcat started on port(s): 8080 (http) 8443
(https)
2017-12-17 INFO: Welcome to the Book Catalog System!
2017-12-17 INFO: BookRepository has 1 entries
2017-12-17 INFO: ReviewerRepository has 0 entries
2017-12-17 INFO: PublisherRepository has 1 entries
2017-12-17 INFO: AuthorRepository has 1 entries
2017-12-17 INFO: Started BookPubApplication in 12.156 seconds (JVM
running for 12.877)
2017-12-17 INFO: Number of books: 1
它是如何工作的...
如你所见,获取打包的可执行 JAR 文件相当简单。所有的魔法代码都已经编码并提供给我们,作为 Spring Boot Gradle 插件的组成部分。插件的添加增加了许多任务,允许我们打包 Spring Boot 应用程序,运行它,并构建 JAR、TAR、WAR 文件等。例如,我们一直在本书中使用到的bootRun任务,是由 Spring Boot Gradle 插件提供的。我们可以通过执行./gradlew tasks来查看可用的 Gradle 任务列表。当我们运行此命令时,我们将得到以下输出:
------------------------------------------------------------
All tasks runnable from root project
------------------------------------------------------------
Application tasks
-----------------
bootRun - Run the project with support for auto-detecting main
class and reloading static resources
run - Runs this project as a JVM application
Build tasks
-----------
assemble - Assembles the outputs of this project.
bootJar - Assembles an executable jar archive containing the main
classes and their dependencies.
build - Assembles and tests this project.
buildDependents - Assembles and tests this project and all projects
that depend on it.
buildNeeded - Assembles and tests this project and all projects it
depends on.
classes - Assembles classes 'main'.
clean - Deletes the build directory.
jar - Assembles a jar archive containing the main classes.
testClasses - Assembles classes 'test'.
Build Setup tasks
-----------------
init - Initializes a new Gradle build. [incubating]
Distribution tasks
------------------
assembleBootDist - Assembles the boot distributions
assembleDist - Assembles the main distributions
bootDistTar - Bundles the project as a distribution.
bootDistZip - Bundles the project as a distribution.
distTar - Bundles the project as a distribution.
distZip - Bundles the project as a distribution.
installBootDist - Installs the project as a distribution as-is.
installDist - Installs the project as a distribution as-is.
上述输出并不完整;我已经排除了非相关任务组,如 IDE、文档等,但你将在控制台上看到它们。在任务列表中,我们将看到bootRun、bootJar和其他任务。这些任务是由 Spring Boot Gradle 插件添加的,执行它们会将所需的 Spring Boot 步骤添加到构建管道中。如果你执行./gradlew tasks --all,你将看到实际的任务依赖关系,这将不仅打印出可见的任务,还包括依赖的内部任务和任务依赖关系。例如,当我们运行build任务时,以下所有依赖任务也被执行了:
build - Assembles and tests this project. [assemble, check]
assemble - Assembles the outputs of this project. [bootJar,
distTar, distZip, jar]
你可以看到,build任务会执行assemble任务,然后它会调用bootJar,实际上 Uber JAR 的创建就在这里进行。
插件还提供了一些非常实用的配置选项。虽然我不会详细介绍所有这些选项,但我将提到两个我认为非常有用的选项:
bootJar {
classifier = 'exec'
baseName = 'bookpub'
}
此配置允许我们指定可执行 JAR 文件的classifier以及 JAR 的baseName,这样常规 JAR 中只包含应用程序代码,而可执行 JAR 则带有classifier名称,例如bookpub-0.0.1-SNAPSHOT-exec.jar。
另一个有用的配置选项允许我们指定哪些依赖 JAR 需要解包,因为出于某种原因,它们不能作为嵌套内部 JAR 包含在内。当你需要某些内容在系统Classloader中可用时,例如通过启动系统属性设置自定义的SecurityManager,这会非常有用:
bootJar {
requiresUnpack = '**/some-jar-name-*.jar'
}
在这个例子中,当应用程序启动时,some-jar-name-1.0.3.jar 依赖项的内容将被解压缩到文件系统上的一个临时文件夹中。
创建 Docker 镜像
Docker,Docker,Docker!我在我参加的所有会议和科技聚会上越来越频繁地听到这个短语。Docker 的到来受到了社区的欢迎,它立刻成为了热门。Docker 生态系统正在迅速扩张,许多其他公司提供服务、支持和补充框架,如 Apache Mesos、Amazon Elastic Beanstalk、ECS 和 Kubernetes,仅举几例。甚至微软也在他们的 Azure 云服务中提供 Docker 支持,并与 Docker 合作将 Docker 带到 Windows 操作系统。
Docker 极其受欢迎的原因在于其能够以自包含容器形式打包和部署应用程序。这些容器比传统的完整虚拟机更轻量。可以在单个操作系统实例上运行多个容器,因此与传统的虚拟机相比,可以在相同的硬件上部署更多的应用程序。
在这个菜谱中,我们将探讨如何将我们的 Spring Boot 应用程序打包成 Docker 镜像,以及如何部署和运行它。
在您的开发机器上构建 Docker 镜像并运行它是可行的,但这并不像能够与世界分享那样有趣。您需要将其发布到某个地方以便部署,尤其是如果您打算在亚马逊或其他类似云环境中使用它。幸运的是,Docker 不仅为我们提供了容器解决方案,还提供了位于 hub.docker.com 的仓库服务 Docker Hub,我们可以在那里创建仓库并发布我们的 Docker 镜像。所以把它想象成 Docker 的 Maven Central。
如何做到这一点...
-
第一步是在 Docker Hub 上创建账户,这样我们就可以发布我们的镜像。访问
hub.docker.com并创建账户。如果您有 GitHub 账户,您也可以使用它登录。 -
一旦您拥有账户,我们需要创建一个名为
springbootcookbook的仓库。 -
创建了账户后,现在是构建镜像的时候了。为此,我们将使用 Gradle Docker 插件之一。我们将首先更改
build.gradle以修改buildscript块,进行以下更改:
buildscript {
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-
plugin:${springBootVersion}")
classpath("se.transmode.gradle:gradle-docker:1.2")
}
}
-
我们还需要通过在
build.gradle文件中添加apply plugin: 'docker'指令来应用此插件。 -
我们还需要明确地将
application插件添加到build.gradle中,因为它不再由 Spring Boot Gradle 插件自动包含。 -
将
apply plugin: 'application'添加到build.gradle文件中的插件列表中。 -
最后,我们还需要将以下 Docker 配置添加到
build.gradle文件中:
task distDocker(type: Docker,
overwrite: true,
dependsOn: bootDistTar) {
group = 'docker'
description = "Packs the project's JVM application
as a Docker image."
inputs.files project.bootDistTar
def installDir = "/" + project.bootDistTar.archiveName
- ".${project.bootDistTar.extension}"
doFirst {
tag "ch6"
push false
exposePort 8080
exposePort 8443
addFile file("${System.properties['user.home']}
/.keystore"), "/root/"
applicationName = project.applicationName
addFile project.bootDistTar.outputs.files.singleFile
entryPoint = ["$installDir/bin/${project.applicationName}"]
}
}
-
假设您已经在您的机器上安装了 Docker,我们可以通过执行
./gradlew clean distDocker来创建镜像。 -
关于 Docker 安装说明,请访问位于
docs.docker.com/installation/#installation的教程。如果一切顺利,您应该会看到以下输出:
> Task :distDocker
Sending build context to Docker daemon 68.22MB
Step 1/6 : FROM aglover/java8-pier
---> 3f3822d3ece5
Step 2/6 : EXPOSE 8080
---> Using cache
---> 73717aaca6f3
Step 3/6 : EXPOSE 8443
---> Using cache
---> 6ef3c0fc3d2a
Step 4/6 : ADD .keystore /root/
---> Using cache
---> 6efebb5a868b
Step 5/6 : ADD ch6-boot-0.0.1-SNAPSHOT.tar /
---> Using cache
---> 0634eace4952
Step 6/6 : ENTRYPOINT /ch6-boot-0.0.1-SNAPSHOT/bin/ch6
---> Using cache
---> 39a853b7ddbb
Successfully built 39a853b7ddbb
Successfully tagged ch6:0.0.1-SNAPSHOT
BUILD SUCCESSFUL
Total time: 1 mins 0.009 secs.
- 我们也可以执行以下 Docker 镜像命令,以便查看新创建的镜像:
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
ch6 0.0.1-SNAPSHOT 39a853b7ddbb 17 minutes ago 1.04 GB
aglover/java8-pier latest 69f4574a230e 11 months ago 1.01 GB
- 成功构建了镜像后,我们现在可以通过执行以下命令在 Docker 中启动它:
docker run -d -P ch6:0.0.1-SNAPSHOT.
- 容器启动后,我们可以通过
docker ps命令查询 Docker 仓库的端口绑定,以便我们可以访问我们服务的 HTTP 端点。这可以通过docker ps命令完成。如果容器运行成功,我们应该看到以下结果(名称和端口可能会有所不同):
CONTAINER ID IMAGE COMMAND
CREATED STATUS PORTS
NAMES
37b37e411b9e ch6:latest "/ch6-boot-0.0.1-S..."
10 minutes ago Up 10 minutes 0.0.0.0:32778-
>8080/tcp, 0.0.0.0:32779->8443/tcp drunk_carson
- 从这个输出中,我们可以看出内部端口
8080的端口映射已经设置为32778(您的端口在每次运行时可能会有所不同)。让我们在浏览器中打开http://localhost:32778/books,以查看我们的应用程序的实际运行情况,如下面的截图所示:

如果您使用 macOS X 和 boot2docker,那么您将不会在本地运行 Docker 容器。在这种情况下,您将使用 boot2docker ip 而不是本地主机来连接到应用程序。有关如何使 boot2docker 集成更简单的更多提示,请访问 viget.com/extend/how-to-use-docker-on-os-x-the-missing-guide。还可以使用由 Ian Sinnott 慷慨提供的漂亮的 Docker 外壳,它将自动启动 boot2docker 并处理环境变量。要获取包装器,请访问 gist.github.com/iansinnott/0a0c212260386bdbfafb。
它是如何工作的...
在前面的例子中,我们看到了如何轻松地将我们的 build 包在 Docker 容器中。Gradle-Docker 插件负责创建 Dockerfile、构建镜像和发布的大部分工作;我们只需要给它一些指令,说明我们希望镜像如何构建。因为 Spring Boot Gradle 插件使用 boot 分发,所以 Gradle-Docker 插件不知道它需要使用一个 bootified TAR 归档。为了帮助解决这个问题,我们覆盖了 distDocker 任务。让我们详细检查这些指令:
-
group和description属性仅有助于在执行./gradlew tasks命令时正确显示任务。 -
inputs.files project.bootDistTar指令非常重要。这是指导distDocker任务使用 Spring Boot 分发创建的 TAR 归档,而不是通用的归档。 -
def installDir = "/" + project.bootDistTar.archiveName - ".${project.bootDistTar.extension}"指令正在创建一个变量,包含解压缩的工件将被放置在 Docker 容器内的目录。 -
exposePort指令告诉插件向 Dockerfile 添加一个EXPOSE <port>指令,这样当我们的容器启动时,它将通过端口映射将这些内部端口暴露给外部。我们在运行docker ps命令时看到了这种映射。 -
addFile指令告诉插件向 Dockerfile 添加一个ADD <src> <dest>指令,这样在构建容器时,我们将从容器镜像中的文件系统复制源文件系统中的文件。在我们的例子中,我们需要复制.keystore证书文件,这是我们之前在配置 HTTPS 连接器时设置的,我们在tomcat.https.properties中指示它从${user.home}/.keystore加载。现在,我们需要它在/root/目录中,因为在容器中,我们的应用程序将在 root 用户下执行。(这可以通过更多的配置来更改。)
Gradle-Docker 插件默认使用项目名称作为镜像名称。项目名称反过来由 Gradle 从项目的目录名称推断,除非配置了显式的属性值。由于代码示例是关于 第六章,“应用程序打包和部署”,项目目录被命名为 ch6,因此镜像名称。项目名称可以通过在 gradle.properties 中添加 name='some_project_name' 来显式配置。
如果你查看生成的 Dockerfile,它可以在项目的根目录下的 build/docker/ 目录中找到,你会看到以下两个指令:
ADD ch6-boot-0.0.1-SNAPSHOT.tar /
ENTRYPOINT ["/ch6-boot-0.0.1-SNAPSHOT/bin/ch6"]
ADD 指令添加了由 bootDistTar 任务生成的 TAR 应用程序存档,其中包含我们的应用程序作为 tarball 打包。我们甚至可以通过执行 tar tvf build/distributions/ch6-boot-0.0.1-SNAPSHOT.tar 来查看生成的 tarball 的内容。在构建容器的过程中,TAR 文件的内容将被提取到容器中的 / 目录,并随后用于启动应用程序。
接着是 ENTRYPOINT 指令。这告诉 Docker 在容器启动后执行 /ch6-boot-0.0.1-SNAPSHOT/bin/ch6,这是我们作为 tarball 内容的一部分看到的,从而自动启动我们的应用程序。
Dockerfile 的第一行,即 FROM aglover/java8-pier,是使用 aglover/java8-pier 镜像的指令,该镜像包含安装了 Java 8 的 Ubuntu OS,作为我们容器的基镜像,我们将在此镜像上安装我们的应用程序。这个镜像来自 Docker Hub 仓库,并且插件会自动使用它,但可以通过配置设置来更改,如果需要的话。
如果你已在 Docker Hub 上创建了账户,我们还可以将创建的 Docker 镜像发布到注册库。作为公平警告,生成的镜像大小可能达到数百兆字节,因此上传可能需要一些时间。要发布此镜像,我们需要将标签更改为 tag "<docker hub username>/<docker hub repository name>" 并在 build.gradle 中的 distDocker 任务定义中添加 push true 设置:
task distDocker(type: Docker,
overwrite: true,
dependsOn: bootDistTar) {
...
doFirst {
tag "<docker hub username>/<docker hub repository name>"
push true
...
}
}
tag 属性设置创建的镜像标签,默认情况下,插件假定它位于 Docker Hub 仓库中。如果 push 配置设置为 true(正如我们的情况一样),它将在这里发布。
要查看所有 Gradle-Docker 插件配置选项的完整列表,请查看 github.com/Transmode/gradle-docker GitHub 项目页面。
当启动 Docker 镜像时,我们使用 -d 和 -P 命令行参数。它们的使用如下:
-
-d:此参数表示希望以分离模式运行容器,其中进程在后台启动 -
-P:此参数指示 Docker 将所有内部公开的端口发布到外部,以便我们可以访问它们
要详细了解所有可能的命令行选项,请参阅 docs.docker.com/reference/commandline/cli/。
构建自执行二进制文件
自 Spring Boot 版本 1.3 以来,Gradle 和 Maven 插件支持生成真正的可执行二进制文件的功能。这些看起来像普通的 JAR 文件,但它们的 JAR 内容与包含命令构建逻辑的启动脚本融合在一起,能够无需显式执行 java -jar file.jar 命令即可自我启动。这种功能非常有用,因为它允许轻松配置 Linux 自动启动服务,如 init.d 或 systemd,以及 macOS X 上的 launchd。
准备工作
对于这个配方,我们将使用现有的应用程序构建。我们将检查自启动的可执行 JAR 文件是如何创建的,以及如何修改默认的启动脚本以添加对自定义 JVM 启动参数的支持,例如 -D 启动系统属性、JVM 内存、垃圾回收和其他设置。
对于这个配方,请确保 build.gradle 正在使用 Spring Boot 版本 2.0.0 或更高版本。如果不是,请在 buildscript 配置块中更改以下设置:
ext {
springBootVersion = '2.0.0.BUILD-SNAPSHOT'
}
应在 db-counter-starter/build.gradle 文件中执行相同的 Spring Boot 版本升级。
如何操作...
-
构建默认的自执行 JAR 文件非常简单;实际上,一旦我们执行
./gradlew clean bootJar命令,它就会自动完成。 -
我们可以通过简单地调用
./build/libs/bookpub-0.0.1-SNAPSHOT.jar来启动创建的应用程序。 -
在企业环境中,我们很少对默认的 JVM 启动参数感到满意,因为我们经常需要调整内存设置、GC 配置,甚至传递启动系统属性,以确保我们使用的是所需的 XML 解析器版本或类加载器或安全管理器的专有实现。为了满足这些需求,我们将修改默认的
launch.script文件以添加对 JVM 选项的支持。让我们首先从项目的根目录中复制默认的launch.script文件到github.com/spring-projects/spring-boot/blob/master/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/resources/org/springframework/boot/loader/tools/launch.scriptSpring Boot GitHub 仓库。
launch.script 文件仅在 Linux 和 OS X 环境中得到支持。如果您想为 Windows 创建可执行的 JAR 文件,您需要提供自己的 launch.script 文件,该文件针对 Windows 壳命令执行进行了定制。好消息是,这仅是所需的一项特殊事项;本食谱中的所有说明和概念在 Windows 上也能正常工作,前提是使用符合规范的 launch.script 模板。
- 我们将修改复制的
launch.script文件,并在 第 142 行 标记上方添加以下内容(这里只显示脚本的相关部分,以便压缩空间):
...
# Find Java
if [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then
javaexe="$JAVA_HOME/bin/java"
elif type -p java 2>&1> /dev/null; then
javaexe=java
elif [[ -x "/usr/bin/java" ]]; then
javaexe="/usr/bin/java"
else
echo "Unable to find Java"
exit 1
fi
# Configure JVM Options
jvmopts="{{jvm_options:}}"
arguments=(-Dsun.misc.URLClassPath.disableJarChecking=true $jvmopts $JAVA_OPTS -jar $jarfile $RUN_ARGS "$@")
# Action functions
start() {
...
- 在放置了自定义的
launch.script文件后,我们需要在我们的build.gradle文件中添加选项设置,内容如下:
applicationDefaultJvmArgs = [
"-Xms128m",
"-Xmx256m"
]
bootJar {
classifier = 'exec'
baseName = 'bookpub'
launchScript {
script = file('launch.script')
properties 'jvm_options' : applicationDefaultJvmArgs.join(' ')
}
}
-
现在,我们已经准备好启动我们的应用程序。首先,让我们使用
./gradlew clean bootRun命令,然后查看 JConsole 的 VM 概述选项卡,我们会看到我们的参数确实已经传递给了 JVM,如下所示:![]()
-
我们还可以通过运行
./gradlew clean bootJar命令来构建自启动的可执行 JAR 文件,然后执行./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar以启动我们的应用程序。我们应该在 JConsole 中看到类似的结果。 -
或者,我们也可以使用
JAVA_OPTS环境变量来覆盖一些 JVM 参数。比如说,我们想将最小堆内存大小更改为 128 兆字节。我们可以使用JAVA_OPTS=-Xmx128m ./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar命令来启动我们的应用程序,并在 JConsole 中看到以下效果:

它是如何工作的...
通过对 launch.script 的小幅定制,我们能够创建一个自执行的部署应用程序,打包为一个自包含的 JAR 文件,除了其他一切之外,还可以配置为使用各种操作系统特定的自动启动框架启动。
Spring Boot Gradle 和 Maven 插件为我们提供了大量的参数自定义选项,甚至可以在 launch.script 中嵌入类似 mustache 的模板占位符,这些占位符可以在构建时替换为值。我们已经利用了这一功能,通过 launchScript{properties} 配置设置将我们的 JVM 参数注入到文件中。
在我们自定义的 launch.script 版本中,我们添加了 jvmopts="{{jvm_options:}}" 行,该行将在构建和打包时替换为 jvm_options 参数的值。此参数在我们的 build.gradle 文件中声明为 launchScript.properties 参数 : launchScript{properties 'jvm_options' : applicationDefaultJvmArgs.join(' ')} 的值。
JVM 参数可以硬编码,但保持我们的应用程序使用 bootRun 任务启动的方式和从自执行 JAR 启动时启动的方式之间的一致性要好得多。为了实现这一点,我们将使用为 bootRun 执行目的定义的相同的 applicationDefaultJvmArgs 参数集合,只是将所有不同的参数折叠成一行文本,由空格分隔。使用这种方法,我们只需要定义一次 JVM 参数,并在两种执行模式中使用它们。
重要的是要注意,这种重用也适用于使用 Gradle 的 application 插件以及 Spring Boot Gradle 的 bootDistZip 和 bootDistTar 定义的 distZip 和 distTar 任务构建的应用程序分发。
我们可以通过启动我们的自执行 JAR 文件而不是默认情况下由 distTar 任务生成的 TAR 文件内容来修改构建以创建 Docker 镜像。为此,我们需要使用以下代码更改我们的 distDocker 配置块:
task distDocker(type: Docker, overwrite: true,
dependsOn: bootJar) {
...
inputs.files project.bootJar
doFirst {
...
addFile file("${System.properties['user.home']}/.keystore"),
"/root/"
applicationName = project.applicationName
addFile project.bootJar.outputs.files.singleFile
def executableName = "/" +
project.bootJar.outputs.files.singleFile.name
entryPoint = ["$executableName"]
}
}
这将使我们的 distDocker 任务将可执行 JAR 文件放入 Docker 镜像中,而不是 TAR 存档。
Spring Boot 环境配置、层次结构和优先级
在之前的几个配方中,我们探讨了如何以各种方式打包我们的应用程序以及如何部署它。下一步合乎逻辑的步骤是需要配置应用程序,以便提供一些行为控制以及一些特定环境的配置值,这些值可能会并且很可能会根据环境的不同而不同。
这种环境配置差异的常见例子是数据库设置。我们当然不希望在我们的开发机器上运行的应用程序连接到生产环境数据库。也存在我们希望应用程序以不同的模式运行或使用不同的配置文件集的情况,正如 Spring 所称呼的那样。一个例子可能是以实时或模拟模式运行应用程序。
对于这个食谱,我们将从代码库的先前状态开始,添加对不同配置配置文件的支持,并检查如何将属性值用作其他属性中的占位符。
如何做到...
- 我们首先通过更改位于项目根目录
src/main/java/org/test/bookpub目录中的BookPubApplication.java文件中schedulerRunner(...)方法的定义,向schedulerRunner的@Bean创建中添加一个@Profile注解,内容如下:
@Bean
@Profile("logger")
public StartupRunner schedulerRunner() {
return new StartupRunner();
}
-
通过运行
./gradlew clean bootRun来启动应用程序。 -
一旦应用程序启动,我们就不再应该看到来自
StartupRunner类的先前日志输出,它看起来像这样:
2017-12-17 --- org.test.bookpub.StartupRunner : Number of books: 1
-
现在,让我们通过运行
./gradlew clean bootJar来构建应用程序,并通过运行./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar --spring.profiles.active=logger来启动它;我们将再次看到日志输出行出现。 -
由配置选择器启用的另一个功能是能够添加特定配置文件的属性文件。让我们在项目根目录
src/main/resources目录下创建一个名为application-inmemorydb.properties的文件,内容如下:
spring.datasource.url = jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
- 让我们通过运行
./gradlew clean bootJar来构建应用程序,并通过运行./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar --spring.profiles.active=logger,inmemorydb来启动它,这将使用inmemorydb配置文件配置来使用内存数据库而不是基于文件的数据库。
它是如何工作的...
在这个食谱中,我们尝试了使用配置文件并根据活动配置文件应用额外的配置设置。配置文件首次在 Spring Framework 3.2 中引入,用于根据活动配置文件有条件地配置上下文中的 bean。在 Spring Boot 中,这个功能得到了进一步的扩展,允许配置分离。
通过在我们的 StartupRunner@Bean 创建方法上放置一个 @Profile("logger") 注解,Spring 将被指示仅在日志记录器配置文件被激活时创建该 Bean。通常,这是通过在应用程序启动期间通过命令行传递 --spring.profiles.active 选项来完成的。在测试中,另一种实现方式是在 Test 类上使用 @ActiveProfiles("profile") 注解,但这不支持正常应用程序的执行。也可以否定配置文件,例如 @Profile("!production")。当使用此类注解(! 标记否定)时,只有在没有活动生产配置文件的情况下才会创建 Bean。
在启动过程中,Spring Boot 将通过命令行传递的所有选项视为应用程序属性,因此启动过程中传递的任何内容最终都会成为可以使用的属性值。这种相同的机制不仅适用于新属性,还可以用作覆盖现有属性的方法。让我们设想一个场景,即我们已经在 application.properties 文件中定义了一个活动配置文件,如下所示:spring.profiles.active=basic。通过命令行传递 --spring.profiles.active=logger 选项,我们将替换活动配置文件从 basic 更改为 logger。如果我们想包含一些配置文件而不管活动配置如何,Spring Boot 提供了一个 spring.profiles.include 选项来配置。以这种方式设置的任何配置文件都将被添加到活动配置文件列表中。
由于这些选项不过是常规的 Spring Boot 应用程序属性,它们都遵循相同的覆盖优先级层次结构。选项概述如下:
-
命令行参数:这些值覆盖列表中的所有其他属性源,你可以始终确信通过
--property.name=value传递的内容将优先于其他方式。 -
JNDI 属性:它们在优先级顺序中排在下一级。如果你使用的是提供数据的应用程序容器,并且通过 JNDI
java:comp/env命名空间提供数据,这些值将覆盖下面所有其他设置。 -
Java 系统属性:这些值是另一种将属性传递给应用程序的方式,无论是通过
-Dproperty=name命令行参数还是通过在代码中调用System.setProperty(...)。它们提供了另一种替换现有属性的方法。来自System.getProperty(...)的任何内容都将覆盖列表中的其他内容。 -
操作系统环境变量:无论是来自 Windows、Linux、OS X 还是任何其他操作系统,它们都是指定配置的常见方式,特别是对于位置和值。最著名的一个是
JAVA_HOME,它通常用来指示 JVM 在文件系统中的位置。如果前面的设置都不存在,将使用ENV变量而不是以下提到的变量来为属性值:
由于操作系统环境变量通常不支持点(.)或破折号(-),Spring Boot 提供了一个自动重映射机制,在属性评估期间将下划线(_)替换为点(.);它还处理大小写转换。因此,JAVA_HOME与java.home同义。
-
random.*:这为原始类型的随机值提供了特殊支持,可以用作配置属性中的占位符。例如,我们可以定义一个名为some.number=${random.int}的属性,其中${random.int}将被某个随机整数值替换。对于文本值,同样适用于${random.value},对于长整型则适用于${random.long}。 -
application-{profile}.properties:它们是特定配置文件的文件,只有当相应的配置文件被激活时才会应用。 -
application.properties:它们是主要属性文件,包含基本/默认应用程序配置。与特定配置文件类似,这些值可以从以下位置加载,其中最高优先级高于较低条目:-
file:config/:这是位于当前目录中的/config目录: -
file::这是当前目录 -
classpath:/config:这是类路径中的一个/config包 -
classpath::这是类路径的根
-
-
使用@PropertySource 注解的@Configuration 类:这些是在代码中使用注解配置的任何内联属性源。我们在第三章的“添加自定义连接器”配方中看到了这样的用法,Web 框架行为调整。它们在优先级链中非常低,并且只由默认属性 precede。
-
默认属性:它们通过调用
SpringApplication.setDefaultProperties(...)进行配置,很少使用,因为它感觉非常像在代码中硬编码值而不是在外部配置文件中外部化它们。
使用 EnvironmentPostProcessor 向环境添加自定义 PropertySource
在企业已经使用特定配置系统的情况下,无论是自定义编写还是现成的,Spring Boot 为我们提供了一个通过创建自定义PropertySource实现来将此集成到应用程序中的功能。
如何做到这一点...
让我们假设我们有一个现有的配置设置,它使用流行的 Apache Commons Configuration 框架,并将配置数据存储在 XML 文件中:
- 为了模拟我们假设的现有配置系统,将以下内容添加到
build.gradle文件的依赖部分:
dependencies {
...
compile project(':db-count-starter')
compile("commons-configuration:commons-
configuration:1.10")
compile("commons-codec:commons-codec:1.6")
compile("commons-jxpath:commons-jxpath:1.3")
compile("commons-collections:commons-collections:3.2.1")
runtime("com.h2database:h2")
...
}
- 接下来,在项目根目录下的
src/main/resources目录中创建一个简单的配置文件commons-config.xml,内容如下:
<?xml version="1.0" encoding="ISO-8859-1" ?>
<config>
<book>
<counter>
<delay>1000</delay>
<rate>${book.counter.delay}0</rate>
</counter>
</book>
</config>
- 然后,我们将在项目根目录下的
src/main/java/org/test/bookpub目录中创建一个名为ApacheCommonsConfigurationPropertySource.java的PropertySource实现文件,内容如下:
public class ApacheCommonsConfigurationPropertySource
extends EnumerablePropertySource<XMLConfiguration> {
private static final Log logger = LogFactory.getLog(
ApacheCommonsConfigurationPropertySource.class);
public static final String
COMMONS_CONFIG_PROPERTY_SOURCE_NAME = "commonsConfig";
public ApacheCommonsConfigurationPropertySource(
String name, XMLConfiguration source) {
super(name, source);
}
@Override
public String[] getPropertyNames() {
ArrayList<String> keys =
Lists.newArrayList(this.source.getKeys());
return keys.toArray(new String[keys.size()]);
}
@Override
public Object getProperty(String name) {
return this.source.getString(name);
}
public static void addToEnvironment(
ConfigurableEnvironment environment, XMLConfiguration
xmlConfiguration) {
environment.getPropertySources().addAfter(
StandardEnvironment.
SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, new
ApacheCommonsConfigurationPropertySource(
COMMONS_CONFIG_PROPERTY_SOURCE_NAME,
xmlConfiguration));
logger.trace("ApacheCommonsConfigurationPropertySource
add to Environment");
}
}
- 现在,我们将创建
EnvironmentPostProcessor实现类,以便在项目根目录下的src/main/java/org/test/bookpub目录中启动我们的名为ApacheCommonsConfigurationEnvironmentPostProcessor.java的PropertySource,内容如下:
package com.example.bookpub;
import org.apache.commons.configuration.ConfigurationException;
import org.apache.commons.configuration.XMLConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
public class ApacheCommonsConfigurationEnvironmentPostProcessor
implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(
ConfigurableEnvironment environment,
SpringApplication application) {
try {
ApacheCommonsConfigurationPropertySource
.addToEnvironment(environment,
new XMLConfiguration("commons-
config.xml"));
} catch (ConfigurationException e) {
throw new RuntimeException("Unable to load commons-config.xml", e);
}
}
}
- 最后,我们需要在项目根目录下的
src/main/resources目录中创建一个名为META-INF的新目录,并在其中创建一个名为spring.factories的文件,内容如下:
# Environment Post Processors
org.springframework.boot.env.EnvironmentPostProcessor=\
com.example.bookpub.ApacheCommonsConfigurationEnvironmentPostProcessor
- 设置完成后,我们现在可以开始在应用中使用我们的新属性了。让我们更改位于项目根目录下的
src/main/java/org/test/bookpub目录中的StartupRunner类的@Scheduled注解的配置,如下所示:
@Scheduled(initialDelayString = "${book.counter.delay}",
fixedRateString = "${book.counter.rate}")
- 通过运行
./gradlew clean bootJar来构建应用,并通过./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar --spring.profiles.active=logger来启动它,以确保我们的StartupRunner类仍然每十秒记录一次书籍数量,正如预期的那样。
它是如何工作的...
在这个菜谱中,我们探讨了如何添加我们自己的自定义PropertySource,这使我们能够连接 Spring Boot 环境中的现有系统。让我们看看这些组件是如何协同工作的内部机制。
在上一节中,我们学习了不同的配置定义是如何堆叠的,以及用于将它们叠加在一起的规则。这将帮助我们更好地理解使用自定义PropertySource实现桥接 Apache Commons Configuration 的工作原理。(这不应与@PropertySource注解混淆!)
在第四章《编写自定义 Spring Boot 启动器》中,我们学习了spring.factories的使用,因此我们已经知道这个文件的作用是在应用启动时自动包含应该被 Spring Boot 集成的类。这次的不同之处在于,我们不是配置EnableAutoConfiguration设置,而是配置SpringApplicationRunListener设置。
我们创建了以下两个类来满足我们的需求:
-
ApacheCommonsConfigurationPropertySource:这是EnumerablePropertySource基类的扩展,它为您提供了内部功能,以便通过getProperty(String name)实现将 XMLConfiguration 从 Apache Commons Configuration 桥接到 Spring Boot 的世界,通过提供转换来获取特定属性值,以及通过getPropertyNames()实现获取所有支持的属性名称列表。在您处理的情况中,如果不知道或计算完整的属性名称列表非常昂贵,您可以直接扩展PropertySource抽象类,而不是使用EnumerablePropertySource。 -
ApacheCommonsConfigurationEnvironmentPostProcessor:这是EnvironmentPostProcessor接口的实现,由 Spring Boot 在应用程序启动时实例化,并在初始环境初始化完成后、应用程序上下文启动之前接收通知回调。此类在spring.factories中配置,并由 Spring Boot 自动创建。
在我们的后处理器中,我们实现了postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application)方法,这使我们能够访问ConfigurableEnvironment实例。当此回调被调用时,我们将获得一个已经填充了前面层次结构中所有属性的环境实例。然而,我们将有机会在任何位置注入我们自己的PropertySource实现,我们将在ApacheCommonsConfigurationPropertySource.addToEnvironment(...)方法中成功实现这一点。
在我们的情况下,我们将选择在优先级顺序中将我们的源代码插入到systemEnvironment下方,但如果需要的话,我们可以将此顺序更改为我们想要的最高优先级。但请务必小心,不要将其放置得过高,以至于无法通过命令行参数、系统属性或环境变量覆盖您的属性。
使用属性文件外部化环境配置
上一节食谱教给我们有关应用程序属性以及它们是如何提供的。正如本章开头所提到的,在应用程序部署期间,几乎不可避免地会有一些属性值与环境相关。它们可以是数据库配置、服务拓扑,甚至是简单的功能配置,其中某些功能可能在开发中已启用,但尚未准备好投入生产。
在本食谱中,我们将学习如何使用外部存储的属性文件进行特定环境的配置,这些文件可能位于本地文件系统或互联网上的任何地方。
在这个菜谱中,我们将使用与上一个菜谱中相同的配置,使用相同的应用程序。我们将使用它来实验使用存储在本地文件系统中的外部配置属性以及来自互联网 URL(如 GitHub 或任何其他)的属性。
如何做到这一点...
- 让我们先添加一些代码来记录我们特定配置属性的值,这样我们就可以轻松地看到它在执行不同操作时的变化。在项目根目录下的
src/main/java/org/test/bookpub目录中的BookPubApplication类中添加一个@Bean方法,内容如下:
@Bean
public CommandLineRunner configValuePrinter(
@Value("${my.config.value:}") String configValue) {
return args -> LogFactory.getLog(getClass()).
info("Value of my.config.value property is: " +
configValue);
}
- 通过运行
./gradlew clean bootJar来构建应用程序,并通过运行./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar --spring.profiles.active=logger来启动它,以便查看以下日志输出:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of
my.config.value property is:
- 值为空,正如我们所预期的。接下来,我们将在主目录中创建一个名为
external.properties的文件,内容如下:
my.config.value=From Home Directory Config
- 让我们通过执行
./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar --spring.profiles.active=logger --spring.config.location=file:/home/<username>/external.properties来运行我们的应用程序,以便在日志中看到以下输出:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of my.config.value property is: From Home Directory Config
对于 macOS 用户,主目录可以在/Users/<username>文件夹中找到。
-
我们还可以将文件作为 HTTP 资源加载,而不是从本地文件系统加载。因此,将名为
external.properties的文件放置在 Web 上的某个位置,其中包含my.config.value=From HTTP Config的内容。它甚至可以检查 GitHub 或 BitBucket 存储库,只要它不需要任何身份验证即可访问。 -
让我们通过执行
./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar --spring.profiles.active=logger --spring.config.location=http://<your file location path>/external.properties来运行我们的应用程序,以便在日志中看到以下输出:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of my.config.value property is: From HTTP Config
它是如何工作的...
在深入探讨外部配置设置的细节之前,让我们快速看一下添加的代码,以便在日志中打印属性值。关注的元素是@Value注解,它可以用于类字段或方法参数;它还指示 Spring 自动将注解变量注入到注解中定义的值。如果值位于以美元符号为前缀的括号中(${ }),Spring 将用相应的应用程序属性值替换它,如果没有提供默认值,则通过在冒号(:)之后添加文本数据来添加默认值。
在我们的案例中,我们将其定义为@Value("${my.config.value:}")String configValue,除非存在名为my.config.value的应用程序属性,否则将默认值空字符串分配给configValue方法参数。这种结构非常方便,消除了显式连接环境对象实例以从中获取特定属性值的需要,同时在测试期间简化了代码,减少了需要模拟的对象。
支持指定应用程序属性配置文件的位置,旨在支持动态的多种环境拓扑,尤其是在云环境中。这种情况通常发生在编译后的应用程序被打包成不同的云镜像,这些镜像旨在不同的环境中使用,并且由 Packer、Vagrant 等部署工具特别组装时。
在这种情况下,在制作镜像时将配置文件放入镜像文件系统中是很常见的,具体取决于它旨在哪个环境。Spring Boot 提供了一个非常方便的功能,可以通过命令行参数指定配置属性文件的位置,该文件应添加到应用程序配置包中。
使用--spring.config.location启动选项,我们可以指定一个或多个文件的位置,然后可以通过逗号(,)与默认值分开,以添加到默认值中。文件指定可以是本地文件系统中的文件、类路径或远程 URL。位置将由DefaultResourceLoader类解析,或者如果通过SpringApplication构造函数或 setter 配置,则由SpringApplication实例提供的实现解析。
如果位置包含目录,名称应以/结尾,以便 Spring Boot 知道它应在这些目录中查找application.properties文件。
如果您想更改文件的默认名称,Spring Boot 也提供了这个功能。只需将--spring.config.name选项设置为所需的任何文件名。
重要的是要记住,无论是否存在--spring.config.location设置,默认的搜索路径classpath:,classpath:/config,file:,file:config/都将始终使用。这样,您始终可以保留application.properties中的默认配置,只需通过启动设置覆盖您需要的部分。
使用环境变量外部化环境配置
在之前的菜谱中,我们多次提到,可以通过使用操作系统环境变量来传递和覆盖 Spring Boot 应用程序的配置值。操作系统依赖于这些变量来存储有关各种事物的信息。我们可能需要设置JAVA_HOME或PATH几次,这些都是环境变量的例子。如果一个人使用像 Heroku 或 Amazon AWS 这样的 PaaS 系统部署他们的应用程序,那么 OS 环境变量也是一个非常重要的特性。在这些环境中,数据库访问凭证和各种 API 令牌等配置值都是通过环境变量提供的。
它们的力量来自于完全外部化简单键值对配置的能力,无需依赖于将属性或某些其他文件放置在特定位置,也不需要在应用程序代码库中硬编码。这些变量对特定的操作系统也是不可知的,可以在 Java 程序中以相同的方式使用,即System.getenv(),无论程序在哪个操作系统上运行。
在这个菜谱中,我们将探讨如何利用这种力量将配置属性传递给我们的 Spring Boot 应用程序。我们将继续使用之前菜谱中的代码库,并尝试几种不同的启动应用程序和使用操作系统环境变量来更改某些属性配置值的方法。
如何做到这一点...
- 在之前的菜谱中,我们添加了一个名为
my.config.value的配置属性。让我们通过运行./gradlew clean bootJar来构建应用程序,并通过运行MY_CONFIG_VALUE="From ENV Config" ./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar --spring.profiles.active=logger来启动它,以便在日志中看到以下输出:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of
my.config.value property is: From ENV Config
-
如果我们想在运行应用程序时通过 Gradle 的
bootRun任务使用环境变量,命令行将是MY_CONFIG_VALUE="From ENV Config" ./gradlew clean bootRun,并且应该产生与前面步骤相同的输出。 -
足够方便的是,我们甚至可以混合和匹配配置的设置方式。我们可以使用环境变量来配置
spring.config.location属性,并使用它来加载外部属性文件中的其他属性值,就像我们在之前的菜谱中所做的那样。让我们通过执行SPRING_CONFIG_LOCATION=file:/home/<username>/external.properties ./gradlew bootRun来启动我们的应用程序。我们应该在日志中看到以下内容:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of
my.config.value property is: From Home Directory Config
虽然使用环境变量非常方便,但如果这些变量的数量变得太多,它确实会有维护开销。为了帮助解决这个问题,将SPRING_CONFIG_LOCATION变量设置为配置特定环境属性文件的位置是一种很好的做法,通常是通过从 URL 位置加载它们。
它是如何工作的...
如你在环境配置层次结构部分所学,Spring Boot 提供了多种提供配置属性的方式。这些方式中的每一种都通过适当的PropertySource实现来管理。当我们实现ApacheCommonsConfigurationPropertySource时,我们探讨了如何创建自定义的PropertySource实现。Spring Boot 已经为我们提供了一个SystemEnvironmentPropertySource实现,我们可以直接使用。这个实现甚至会被自动注册到环境接口的默认实现中:SystemEnvironment。
由于SystemEnvironment实现为众多不同的PropertySource实现提供了一个组合外观,因此覆盖操作是无缝进行的,这仅仅是因为SystemEnvironmentPropertySource类在列表中的位置高于application.properties文件。
你应该注意的一个重要方面是,使用ALL_CAPS和下划线(_)来分隔单词,而不是使用传统的点(.)分隔的all.lower.cased格式来命名 Spring Boot 中的配置属性。这是由于某些操作系统的特性,尤其是 Linux 和 OS X,它们禁止在名称中使用点(.),而鼓励使用ALL_CAPS下划线分隔的表示法。
在不希望使用环境变量来指定或覆盖配置属性的情况下,Spring 为我们提供了一个-Dspring.getenv.ignore系统属性,可以将其设置为 true,以防止使用环境变量。如果你在日志中看到由于你的代码在某些应用程序服务器或特定的安全策略配置中运行而导致的错误或异常,你可能希望将此设置更改为 true,因为这些配置可能不允许访问环境变量。
使用 Java 系统属性外部化环境配置
尽管环境变量在罕见情况下可能会出现或错过,但古老的 Java 系统属性始终可以信赖它们会为你提供支持。除了使用以双横线(--)为前缀的属性名称表示的环境变量和命令行参数之外,Spring Boot 还为你提供了使用纯 Java 系统属性来设置或覆盖配置属性的能力。
这在许多情况下都很有用,尤其是如果你的应用程序在一个容器中运行,该容器在启动时通过系统属性设置某些值,而你想要访问这些值,或者如果属性值不是通过命令行-D参数设置,而是在某个库中通过代码和调用System.setProperty(...)来设置,尤其是如果属性值是从某种静态方法内部访问时。虽然这些情况可能很少见,但只要有一个情况,就足以让你费尽心思尝试将这个值整合到你的应用程序中。
在这个菜谱中,我们将使用与之前相同的应用程序可执行文件,唯一的区别是我们使用 Java 系统属性而不是命令行参数或环境变量来在运行时设置我们的配置属性。
如何做到这一点...
- 让我们通过设置
my.config.value配置属性来继续我们的实验。通过运行./gradlew clean bootJar来构建应用程序,并通过运行java -Dmy.config.value="From System Config" -jar ./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar来启动它,以便在日志中看到以下内容:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of my.config.value property is: From System Config
- 如果我们想在运行应用程序时使用 Gradle 的
bootRun任务设置 Java 系统属性,我们需要将此添加到build.gradle文件中的applicationDefaultJvmArgs配置。让我们将-Dmy.config.value=Gradle添加到这个列表中,并通过运行./gradlew clean bootRun来启动应用程序。我们应该在日志中看到以下内容:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of my.config.value property is: Gradle
- 由于我们将
applicationDefaultJvmArgs设置与launch.script共享,通过运行./gradlew clean bootJar重新构建应用程序,并通过运行./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar来启动它,应该会在日志中产生与前面步骤相同的输出。
它是如何工作的...
你可能已经猜到了,Java 系统属性是通过与用于环境变量的类似机制消耗的,而且你的猜测是正确的。唯一的真正区别是PropertySource的实现。这次,StandardEnvironment使用了更通用的MapPropertySource实现。
你可能也注意到了,需要使用java -Dmy.config.value="From System Config" -jar ./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar命令来启动我们的应用程序,而不是简单地调用自执行打包 JAR。这是因为,与环境变量和命令行参数不同,Java 系统属性必须在所有其他内容之前设置在 Java 可执行文件上。
我们确实通过在我们的build.gradle文件中有效地硬编码值来绕过了这个需求,这结合了我们为launch.script所做的增强,使我们能够将my.config.value属性嵌入到自执行 jar 的命令行中,以及使用 Gradle 的bootRun任务。
使用此方法配置属性的风险是它将始终覆盖我们在配置的高层设置的值,例如application.properties和其他配置。除非你明确构造 Java 可执行命令行并且不使用打包 JAR 的自启动功能,否则最好不要使用 Java 系统属性,而是考虑使用命令行参数或环境变量。
使用 JSON 外部化环境配置
我们已经查看了许多不同的方法来外部添加或覆盖特定属性的值,无论是通过使用环境变量、系统属性还是命令行参数。所有这些选项都为我们提供了大量的灵活性,但除了外部属性文件外,都限制于一次设置一个属性。当涉及到使用属性文件时,其语法并不完全适合表示嵌套的层次数据结构,可能会有些棘手。为了避免这种情况,Spring Boot 为我们提供了一种能力,也可以外部传递包含整个配置设置层次结构的 JSON 编码内容。
在这个配方中,我们将使用与之前相同的应用程序可执行文件,唯一的区别是使用外部 JSON 内容在运行时设置我们的配置属性。
如何操作...
- 让我们通过设置
my.config.value配置属性来继续我们的实验。通过运行./gradlew clean bootJar来构建应用程序,并通过运行java -jar ./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar --spring.application.json={"my":{"config":{"value":"From external JSON"}}}来启动它,以便在日志中看到以下内容:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of my.config.value property is: From external JSON
-
如果我们想要能够使用 Java 系统属性设置内容,我们可以使用
-Dspring.application.json代替,将相同的 JSON 内容作为值分配。 -
或者,我们也可以依赖
SPRING_APPLICATION_JSON环境变量以下述方式传递相同的 JSON 内容:
SPRING_APPLICATION_JSON={"my":{"config":{"value":"From external JSON"}}} java -jar ./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar --spring.profiles.active=logger
它是如何工作的...
就像我们所查看的每一种其他配置方法一样,JSON 内容是由一个专门的EnvironmentPostProcessor实现消费的。唯一的区别是将 JSON 树扁平化为一个平面属性映射,以匹配点分隔的属性命名风格。在我们的例子中,my->config->value嵌套映射被转换为一个只有一个键的平面映射,键为my.config.value,值为From external JSON。
JSON 内容的设置可以来自任何在加载时从环境中可用的属性源,它包含一个名为spring.application.json的键,其值为有效的 JSON 内容,并且不仅限于通过环境变量设置或使用SPRING_APPLICATION_JSON名称或 Java 系统属性设置。
这种功能可以非常方便地批量提供外部定义的环境特定配置。最佳做法是通过在机器实例上使用机器/镜像配置工具(如 Chef、Puppet、Ansible、Packer 等)设置SPRING_APPLICATION_JSON环境变量来实现。这使您能够在外部存储整个配置层次结构在一个 JSON 文件中,然后在配置时间只需设置一个环境变量,就可以在特定的机器上简单地提供正确的内容。所有在该机器上运行的应用程序在启动时将自动消费它。
配置 Consul
到目前为止,我们所做的所有配置都与本地数据集相关联。在真实的大型企业环境中,情况并不总是如此,并且经常有在大量实例或机器上做出配置更改的愿望。
有许多工具可以帮助您完成这项任务,在这个菜谱中,我们将探讨其中一个,我认为它在众多工具中脱颖而出,它能够使用分布式数据存储干净优雅地配置启动应用程序的环境变量。这个工具的名称是Consul。它是 Hashicorp 的开源产品,旨在发现和配置大型分布式基础设施中的服务。
在这个菜谱中,我们将探讨如何安装和配置 Consul,并实验一些它提供的关键功能。这将为我们下一个菜谱提供必要的熟悉度,在那里我们将使用 Consul 来提供启动我们的应用程序所需的配置值。
如何操作...
- 前往
consul.io/downloads.html下载适合您所使用的操作系统的相应存档。Consul 支持 Windows、OS X 和 Linux,因此它应该适用于大多数读者。
如果您是 OS X 用户,您可以通过运行brew install caskroom/cask/brew-cask然后运行brew cask install consul来使用 Homebrew 安装 Consul。
- 安装完成后,我们应该能够运行
consul --version并看到以下输出:
Consul v1.0.1
Protocol 2 spoken by default, understands 2 to 3 (agent will automatically use protocol >2 when speaking to compatible agents)
- 在 Consul 成功安装后,我们应该能够通过运行
consul agent -server -bootstrap-expect 1 -data-dir /tmp/consul命令来启动它,我们的终端窗口将显示以下内容:
==> WARNING: BootstrapExpect Mode is specified as 1; this is the same as Bootstrap mode.
==> WARNING: Bootstrap mode enabled! Do not enable unless necessary
==> WARNING: It is highly recommended to set GOMAXPROCS higher than 1
==> Starting Consul agent...
==> Starting Consul agent RPC...
==> Consul agent running!
Node name: <your machine name>'
Datacenter: 'dc1'
Server: true (bootstrap: true)
Client Addr: 127.0.0.1 (HTTP: 8500, HTTPS: -1, DNS: 8600, RPC: 8400)
Cluster Addr: 192.168.1.227 (LAN: 8301, WAN: 8302)
Gossip encrypt: false, RPC-TLS: false, TLS-Incoming: false
Atlas: <disabled>
==> Log data will now stream in as it occurs:
2017/12/17 20:34:43 [INFO] serf: EventMemberJoin: <your machine name> 192.168.1.227
2017/12/17 20:34:43 [INFO] serf: EventMemberJoin: <your machine name>.dc1 192.168.1.227
2017/12/17 20:34:43 [INFO] raft: Node at 192.168.1.227:8300 [Follower] entering Follower state
2017/12/17 20:34:43 [INFO] consul: adding server <your machine name> (Addr: 192.168.1.227:8300) (DC: dc1)
2017/12/17 20:34:43 [INFO] consul: adding server <your machine name>.dc1 (Addr: 192.168.1.227:8300) (DC: dc1)
2017/12/17 20:34:43 [ERR] agent: failed to sync remote state: No cluster leader
2017/12/17 20:34:45 [WARN] raft: Heartbeat timeout reached, starting election
2017/12/17 20:34:45 [INFO] raft: Node at 192.168.1.227:8300 [Candidate] entering Candidate state
2017/12/17 20:34:45 [INFO] raft: Election won. Tally: 1
2017/12/17 20:34:45 [INFO] raft: Node at 192.168.1.227:8300 [Leader] entering Leader state
2017/12/17 20:34:45 [INFO] consul: cluster leadership acquired
2017/12/17 20:34:45 [INFO] consul: New leader elected: <your machine name>
2017/12/17 20:34:45 [INFO] raft: Disabling EnableSingleNode (bootstrap)
2017/12/17 20:34:45 [INFO] consul: member '<your machine name>' joined, marking health alive
2017/12/17 20:34:47 [INFO] agent: Synced service 'consul'
- 当 Consul 服务运行时,我们可以通过运行
consul members命令来验证它包含一个成员,并应该看到以下结果:
Node Address Status Type Build Protocol DC
<your_machine_name> 2.168.1.227:8301 alive server 0.5.2 2 dc1
- 虽然 Consul 也可以提供服务发现、健康检查、分布式锁等功能,但我们将重点关注键值服务,因为这是我们将在下一个菜谱中用于提供配置的服务。所以,让我们通过执行
curl -X PUT -d 'From Consul Config' http://localhost:8500/v1/kv/bookpub/my/config/value命令将From Consul Config值放入键值存储。
如果您使用 Windows,您可以从curl.haxx.se/download.html获取 curl。
- 我们也可以通过运行
curl http://localhost:8500/v1/kv/bookpub/my/config/value命令来检索数据,并应该看到以下输出:
[{"CreateIndex":20,"ModifyIndex":20,"LockIndex":0,"Key":"bookpub/my/config/value","Flags":0,"Value":"RnJvbSBDb25zdWwgQ29uZmln"}]
-
我们可以通过运行
curl -X DELETE http://localhost:8500/v1/kv/bookpub/my/config/value命令来删除此值。 -
为了修改现有值并将其更改为其他内容,请执行
curl -X PUT -d 'newval' http://localhost:8500/v1/kv/bookpub/my/config/value?cas=20命令。
如何工作...
关于 Consul 如何工作以及其键/值服务的所有可能选项的详细解释将需要一本书,所以在这里我们只看看基本的部分。强烈建议您阅读 Consul 的文档consul.io/intro/getting-started/services.html。
在第 3 步中,我们以服务器模式启动了 Consul 代理。它作为主主节点,在实际部署中,运行在各个实例上的本地代理将使用服务器节点来连接并检索数据。为了我们的测试目的,我们将只使用这个服务器节点,就像它是一个本地代理一样。
启动时显示的信息告诉我们,我们的节点已作为服务器节点启动,在8500端口上建立 HTTP 服务,以及 DNS 和 RPC 服务,如果这是人们选择连接的方式。我们还可以看到集群中只有一个节点,那就是我们的节点,我们作为被选出的领导者运行在健康状态。
由于我们将通过 cURL 使用方便的 RESTful HTTP API,我们所有的请求都将使用 localhost 的8500端口。作为一个 RESTful API,它完全遵循 CRUD 动词术语,为了插入数据,我们将使用/v1/kv端点的PUT方法来设置bookpub/my/config/value键。
获取数据甚至更加简单:我们只需向相同的/v1/kv服务发送一个使用所需键的GET请求。对于DELETE也是如此,唯一的区别是方法名称。
更新操作需要在 URL 中提供更多信息,即cas参数。此参数的值应该是所需键的ModifyIndex,这可以从GET请求中获得。在我们的例子中,它的值为 20。
使用 Consul 和 envconsul 外部化环境配置
在之前的菜谱中,我们已经安装了 Consul 服务,并对其键/值功能进行了实验,以了解我们如何操作其中的数据,以便将 Consul 集成到我们的应用程序中,并使数据提取过程从应用程序的角度来看无缝且非侵入性。
由于我们不希望我们的应用程序了解任何关于 Consul 的信息,并且必须显式地连接到它,尽管存在这种可能性,我们将使用另一个实用程序,这个实用程序也是由 Hashicorp 创建的,开源的,称为envconsul。它将为我们连接到 Consul 服务,提取指定的配置键/值树,并在启动我们的应用程序的同时将其作为环境变量暴露出来。很酷,对吧?
准备工作
在我们启动之前,在之前的菜谱中创建的应用程序,我们需要安装 envconsul 实用程序。
从github.com/hashicorp/envconsul/releases下载您相应操作系统的二进制文件,并将其提取到您选择的任何目录中,尽管将其放在 PATH 中某个位置会更好。
一旦从下载的存档中提取了 envconsul,我们就可以开始使用它来配置我们的应用程序了。
如何做...
-
如果您还没有将
my/config/value键的值添加到 Consul 中,让我们通过运行curl -X PUT -d 'From Consul Config' http://localhost:8500/v1/kv/bookpub/my/config/value来添加它。 -
第一步是确保 envconsul 可以连接到 Consul 服务器,并且根据我们的配置密钥提取正确数据。让我们通过运行
envconsul --once --sanitize --upcase --prefix bookpub env命令来执行一个简单的测试。我们应该在输出中看到以下内容:
...
TERM=xterm-256color
SHELL=/bin/bash
LANG=en_US.UTF-8
HOME=/Users/<your_user_name>
...
MY_CONFIG_VALUE=From Consul Config
- 在我们验证 envconsul 返回正确数据给我们之后,我们将使用它通过运行
envconsul --once --sanitize --upcase --prefix bookpub ./gradlew clean bootRun来启动我们的BookPub应用程序。一旦应用程序启动,我们应该在日志中看到以下输出:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of my.config.value property is: From Consul Config
-
我们也可以通过运行
./gradlew clean bootJar来构建自启动的可执行 JAR 文件,然后通过运行envconsul --once --sanitize --upcase --prefix bookpub ./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar来启动它,以确保我们在日志中看到与前一步骤相同的输出。如果你看到的是Gradle而不是From Consul Config,请确保build.gradle中的applicationDefaultJvmArgs配置中没有包含-Dmy.config.value=Gradle。 -
envconsul 的另一个神奇能力不仅可以将配置键值导出为环境变量,还可以监视任何更改,并在 Consul 中的值更改时重新启动应用程序。让我们通过运行
envconsul --sanitize --upcase --prefix bookpub ./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar来启动我们的应用程序,我们应该在日志中看到以下值:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of my.config.value property is: From Consul Config
- 我们现在将使用 consul 命令获取我们键的当前
ModifyIndex,并在另一个终端窗口中执行curl http://localhost:8500/v1/kv/bookpub/my/config/value,获取ModifyIndex值,并使用它来执行curl -X PUT -d 'From UpdatedConsul Config' http://localhost:8500/v1/kv/bookpub/my/config/value?cas=<ModifyIndex Value>。我们应该看到我们的运行应用程序神奇地重新启动,并且我们的新更新值在日志的末尾显示:
2017-12-17 --- ication$$EnhancerBySpringCGLIB$$b123df6a : Value of my.config.value property is: From UpdatedConsul Config
它是如何工作的...
我们刚才做的事情很酷,对吧?让我们更详细地检查幕后发生的魔法。我们将从分析命令行并解释每个参数控制选项的作用开始。
我们第一次执行的命令行是 envconsul --once --sanitize --upcase --prefix bookpub ./gradlew clean bootRun,让我们看看我们到底做了什么,如下所示:
-
首先,人们可能会注意到没有关于我们应该连接到哪个 Consul 节点的指示。这是因为有一个隐含的理解或假设,即你已经在本地
localhost:8500上运行了一个 Consul 代理。如果这不是出于任何原因的情况,你总是可以通过在命令行中添加--consul localhost:8500参数来显式指定要连接的 Consul 实例。 -
--prefix选项指定了要查找不同值的起始配置密钥段。当我们向 Consul 添加密钥时,我们使用了以下密钥:bookpub/my/config/value。通过指定--prefix bookpub选项,我们告诉 envconsul 移除密钥中的bookpub部分,并使用bookpub中的所有内部树元素来构建环境变量。因此,my/config/value成为环境变量。 -
--sanitize选项告诉 envconsul 将所有无效字符替换为下划线 (_)。所以,如果我们只使用--sanitize,我们最终会得到my_config_value作为环境变量。 -
--upcase选项,正如你可能已经猜到的,将环境变量密钥更改为全部大写字母,因此当与--sanitize选项结合使用时,my/config/value密钥被转换为MY_CONFIG_VALUE环境变量。 -
--once选项表示我们只想将密钥一次外部化为环境变量,并且不想持续监控 Consul 集群中的变化。如果我们的前缀树中的某个密钥改变了其值,我们将重新外部化密钥作为环境变量并重新启动应用程序。
最后这个选项 --once 提供了一些非常有用的功能选择。如果你只对通过使用 Consul 共享配置来启动你的应用程序感兴趣,那么密钥将被设置为环境变量,应用程序将被启动,envconsul 将认为其任务已完成。然而,如果你希望监控 Consul 集群中密钥/值的变化,并在变化发生后重新启动应用程序以反映新的变化,那么请移除 --once 选项,envconsul 将在变化发生后重新启动应用程序。
这种行为对于诸如数据库连接配置的几乎即时更改等事情非常有用和方便。想象一下,你需要快速从一台数据库切换到另一台,并且你的 JDBC URL 通过 Consul 进行配置。你所需要做的只是推送一个新的 JDBC URL 值,envconsul 将几乎立即检测到这种变化并重新启动应用程序,告诉它连接到新的数据库节点。
目前,此功能通过向运行中的应用程序进程发送传统的 SIGTERM 信号来实现,告诉它终止,一旦进程退出,重新启动应用程序。这不一定总是期望的行为,特别是如果应用程序启动并能够处理流量需要一些时间的话。你不想你的整个 Web 应用程序集群被关闭,即使只是几分钟。
为了更好地处理这种情况,envconsul 被增强以能够发送一系列标准信号,这些信号可以通过新添加的--kill-signal选项进行配置。使用此选项,我们可以在检测到键/值变化后,指定任何 SIGHUP、SIGTERM、SIGINT、SIGQUIT、SIGUSR1 或 SIGUSR2 信号来代替默认的 SIGTERM,发送给运行中的应用程序进程。
由于大多数行为都非常特定于特定的操作系统和在其上运行的 JVM,Java 中的进程信号处理并不清晰和直接。列表中的某些信号无论如何都会终止应用程序,或者在 SIGQUIT 的情况下,JVM 会将核心转储打印到标准输出。然而,根据操作系统,我们可以配置 JVM 来让我们使用 SIGUSR1 和 SIGUSR2 而不是对这些信号本身进行操作,但不幸的是,这个主题超出了本书的范围。
这里有一个如何处理信号处理程序的示例:github.com/spotify/daemon-java,或者查看 Oracle Java 文档中的docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/signals.html以获取详细说明。
第七章:健康监控和数据可视化
在本章中,我们将涵盖以下食谱:
-
编写自定义健康指标
-
配置管理上下文
-
发射指标
-
通过 JMX 监控 Spring Boot
-
通过 SSHd Shell 管理 Spring Boot 并编写自定义远程 Shell 命令
-
将微米级指标与 Graphite 集成
-
将微米级指标与 Dashing 集成
简介
在上一章中,你学习了一些高效打包应用程序并使其准备就绪以进行部署的技术,我们还探讨了提供环境配置而无需更改代码的多种技术。随着部署和配置问题的解决,最后一个(但同样重要)的步骤是确保我们对应用程序在生产环境中的运行有完整的可见性、监控和管理控制,因为它暴露在客户(滥用)的恶劣环境中。
正如飞行员不喜欢盲目飞行一样,开发者如果看不到他们辛勤工作的应用程序在生产中的表现,也不会感到兴奋。我们希望在任何给定时间,都能了解 CPU 的利用率如何,我们消耗了多少内存,我们的数据库连接是否正常可用,在任何给定时间间隔内使用系统的客户数量等等。我们不仅想知道所有这些信息,还希望能够通过漂亮的图表、图形和可视化仪表板来查看它们。这些对于在大屏幕等离子显示器上进行监控以及给老板留下深刻印象非常有用,以便表明你一切尽在掌握之中。
本章将帮助你学习必要的技巧来增强我们的应用程序,以便暴露自定义指标、健康状态等,以及如何从我们的应用程序中获取监控数据,并将其存储在 Graphite 中进行历史参考,或者使用这些数据通过 Dashing 和 Grafana 框架创建实时监控仪表板。我们还将探讨使用强大的 CRaSH 框架集成连接到运行实例并执行各种管理任务的能力。
编写自定义健康指标
了解生产环境中运行的应用程序的状态,尤其是在大规模分布式系统中,这和拥有自动化测试和部署等事物一样(如果不是更重要的话)重要。在当今快节奏的 IT 世界中,我们真的无法承受太多的停机时间,因此我们需要随时掌握应用程序的健康状况信息,以便在第一时间采取行动。如果至关重要的数据库连接出现问题,我们希望立即看到并能够迅速解决问题;客户不会在等待很长时间后才去其他网站。
我们将在前一章结束时的状态继续工作在我们的 BookPub 应用程序中。在这个菜谱中,我们将添加必要的 Spring Boot starters 以启用我们应用程序的监控和仪表化,甚至将编写我们自己的健康指标。
如何操作...
- 我们需要做的第一件事是在我们的
build.gradle文件中添加对 Spring Boot Actuator starter 的依赖项,内容如下:
dependencies {
...
compile("org.springframework.boot:spring-boot-starter-
data-rest")
// compile("org.springframework.boot:spring-boot-starter-
jetty") //
Need to use Jetty instead of Tomcat
compile("org.springframework.boot:spring-boot-starter-
actuator")
compile project(':db-count-starter')
...
}
- 仅添加此依赖项就已经使我们能够访问 Spring 管理的
/actuator/*端点,例如/env、/info、/metrics和/health(尽管它们默认是禁用的,除非在application.properties文件中配置了management.endpoints.web.exposure.include=*属性)。因此,让我们通过执行./gradlew clean bootRun命令来启动我们的应用程序,然后我们可以通过打开浏览器并访问http://localhost:8080/actuator/health来访问新可用的/health端点,以便查看新端点的实际操作,如下面的截图所示:

- 要获取我们应用程序健康状态的更多详细信息,让我们配置它以显示详细的健康输出,通过向
application.properties文件添加management.endpoint.health.show-details=always属性并重新启动我们的应用程序。现在,当我们通过浏览器访问http://localhost:8080/actuator/health时,我们应该看到类似于以下截图的内容:

- 在添加了
actuator依赖项并详细配置了/health端点后,我们现在可以在我们的应用程序上添加和执行所有类型的监控功能。让我们继续操作,通过向位于我们项目根目录的build.gradle文件中添加以下内容的指令来填充/info端点:
springBoot {
buildInfo {
properties {
additional = [
'description' : project.description
]
}
}
}
- 接下来,我们将在我们项目的根目录下创建一个名为
gradle.properties的新属性文件,其内容如下:
version=0.0.1-SNAPSHOT
description=BookPub Catalog Application
-
我们还将向位于项目根目录的
settings.gradle文件中添加rootProject.name='BookPub-ch7'。 -
现在,让我们通过执行
./gradlew clean bootRun来启动我们的应用程序,然后我们可以通过打开浏览器并访问http://localhost:8080/actuator/info来访问新可用的/info端点,以查看新端点的实际操作,如下所示:

-
既然我们已经掌握了事情的工作原理,让我们继续创建我们的自定义健康指标,它将通过
/health端点访问,以便报告每个存储库条目的计数状态。如果它们大于或等于零,我们认为是UP,否则我们并不真正清楚发生了什么。显然,如果发生了异常,我们将报告DOWN。让我们首先将位于项目根目录下的db-count-starter/src/main/java/com/example/bookpubstarter/dbcount目录中的DbCountRunner.java文件中的getRepositoryName(...)方法可见性从private更改为protected。 -
接下来,我们将在项目根目录下的
db-count-starter目录中的build.gradle文件中添加相同的依赖项compile("org.springframework.boot:spring-boot-starter-actuator")。 -
现在,我们将在项目根目录下的
db-count-starter/src/main/java/com/example/bookpubstarter/dbcount目录中创建一个名为DbCountHealthIndicator.java的新文件,其内容如下:
public class DbCountHealthIndicator implements HealthIndicator {
private CrudRepository repository;
public DbCountHealthIndicator(CrudRepository repository) {
this.repository = repository;
}
@Override
public Health health() {
try {
long count = repository.count();
if (count >= 0) {
return Health.up().withDetail("count",
count).build();
} else {
return Health.unknown().withDetail("count",
count).build();
}
} catch (Exception e) {
return Health.down(e).build();
}
}
}
- 接下来,我们将修改位于项目根目录下的
db-count starter/src/main/java/com/example/bookpubstarter/dbcount目录中的EnableDbCounting.java文件,其内容如下:
@Import({DbCountAutoConfiguration.class,
HealthIndicatorAutoConfiguration.class})
- 最后,为了自动注册我们的
HealthIndicator类,我们将在项目根目录下的db-count-starter/src/main/java/com/example/bookpubstarter/dbcount目录中的DbCountAutoConfiguration.java文件中添加以下内容:
@Autowired
private HealthAggregator healthAggregator;
@Bean
public HealthIndicator dbCountHealthIndicator(Collection<CrudRepository> repositories) {
CompositeHealthIndicator compositeHealthIndicator = new
CompositeHealthIndicator(healthAggregator);
for (CrudRepository repository : repositories) {
String name = DbCountRunner.getRepositoryName
(repository.getClass());
compositeHealthIndicator.addHealthIndicator(name, new
DbCountHealthIndicator(repository));
}
return compositeHealthIndicator;
}
- 因此,让我们通过执行
./gradlew clean bootRun命令来启动我们的应用程序,然后我们可以通过打开浏览器并访问http://localhost:8080/actuator/health来查看我们的新HealthIndicator类在行动中的效果,如下所示:

它是如何工作的...
Spring Boot Actuator starter 添加了多个重要功能,这些功能可以让我们深入了解应用程序的运行状态。该库包含多个自动配置,这些配置添加并配置了各种端点以访问应用程序的运行监控数据和健康状态。这些端点都共享一个公共上下文路径:/actuator。要公开除/info和/health之外的其他端点,我们需要通过设置management.endpoints.web.exposure.include=*属性来显式公开它们。当该值设置为*时,它将公开所有端点。以下端点可供我们深入了解应用程序的运行状态和配置:
-
/env: 此端点使我们能够查询应用程序通过环境实现可以访问的所有环境变量,这是我们之前看到的。当您需要调试特定问题并想知道任何给定配置属性的值时,它非常有用。如果我们通过访问http://localhost:8080/actuator/env端点,我们将看到多个不同的配置部分,例如,类路径资源[tomcat.https.properties]、applicationConfig: [classpath:/application.properties]、commonsConfig、systemEnvironment、systemProperties以及其他。它们都代表环境中的一个单独的PropertySource实例实现,根据它们在层次结构中的位置,可能或可能不被用于在运行时提供值解析。要找出用于解析特定值的确切条目,例如,对于book.count.rate属性,我们可以通过访问http://localhost:8080/actuator/env/book.counter.rateURL 来查询它。默认情况下,我们应该得到 10,000 作为结果,除非当然,通过系统环境或命令行参数设置了不同的值作为覆盖。如果您真的想深入了解代码,EnvironmentEndpoint类负责处理此功能背后的逻辑。 -
/configprops: 此端点向您展示了各种配置属性对象的设置,例如我们的WebConfiguration.TomcatSslConnectorProperties启动器。它与/env端点略有不同,因为它提供了对配置对象绑定的洞察。如果我们打开浏览器访问http://localhost:8080/actuator/configprops并搜索custom.tomcat.https,我们将看到用于配置TomcatSslConnector的配置属性对象的条目,该对象由 Spring Boot 自动填充并为我们绑定。 -
/conditions: 此端点作为我们在第四章“编写自定义 Spring Boot 启动器”中看到的自动配置报告的基于网络的类似物。这样,我们可以随时使用浏览器获取报告,而无需启动应用程序并使用特定的标志来打印它。 -
/beans: 此端点旨在列出由 Spring Boot 创建并可在应用程序上下文中使用的所有 bean。 -
/mappings: 此端点公开了应用程序支持的所有 URL 映射列表以及HandlerMappingbean 实现的引用。这对于回答特定 URL 将被路由到何处的问题非常有用。尝试访问http://localhost:8080/actuator/mappings以查看应用程序可以处理的所有路由列表。 -
/threaddump: 这个端点允许从运行中的应用程序中提取线程转储信息。当尝试诊断潜在的线程死锁时,它非常有用。 -
/heapdump: 这个端点与/dump类似,但不同的是它产生堆转储信息。 -
/info: 这个端点显示了我们所添加的基本描述和应用程序信息,我们已经看到了它的实际应用,因此现在应该对我们来说很熟悉。构建工具中的良好支持使我们能够配置额外的或替换build.gradle配置中的现有值,然后这些值将被传播以由/info端点消费。此外,在访问/info端点时,任何以info.开头的application.properties文件中定义的属性都将显示出来,所以您绝对不仅限于build.gradle配置。配置此特定端点以返回相关信息在构建各种自动化发现和监控工具时非常有帮助,因为它是一种以漂亮的 JSON RESTful API 形式公开应用程序特定信息的好方法。 -
/actuator: 这个端点以 超文本应用语言(HAL)风格的 JSON 格式列表提供了所有可用执行器端点的链接。 -
/health: 这个端点提供了有关应用程序总体健康状况以及各个组件的详细分解和健康状况的信息。 -
/metrics: 这个端点提供了由指标子系统发出的所有各种数据点的概述。您可以通过在浏览器中访问http://localhost:8080/actuator/metricsURL 来实验它。我们将在下一道菜中更详细地介绍这一点。
既然我们已经了解了 Spring Boot Actuator 为我们提供了哪些一般性信息,我们就可以继续查看我们如何使自定义 HealthIndicator 类工作以及 Spring Boot 中的整个健康监控系统是如何工作的细节。
正如您所看到的,使基本的 HealthIndicator 接口工作非常简单;我们只需创建一个实现类,当调用 health() 方法时,它将返回一个 Health 对象。您只需将 HealthIndicator 类的实例作为 @Bean 暴露出来,Spring Boot 就会将其拾取并添加到 /health 端点。
在我们的情况下,我们更进一步,因为我们必须处理为每个CrudRepository实例创建HealthIndicator的需求。为了实现这一点,我们创建了一个CompositeHealthIndicator的实例,并向其中添加了每个CrudRepository的所有DbHealthIndicator实例。然后我们将这个实例作为@Bean返回,这就是 Spring Boot 用来表示健康状态的方式。作为一个组合体,它保留了内部层次结构,正如从表示健康状态的返回 JSON 数据中可以明显看出。我们还添加了一些额外的数据元素,以提供入口计数以及每个特定存储库的名称,这样我们就可以区分它们。
看着代码,你可能想知道:我们连接的这个HealthAggregator实例是什么?我们需要HealthAggregator实例的原因是CompositeHealthIndicator需要知道如何决定所有嵌套HeathIndicators的内部组合是否代表整体良好的或不良的健康状态。想象一下,所有存储库都返回UP,只有一个返回DOWN。这意味着什么?组合指标整体健康吗?或者它也应该报告DOWN,因为有一个内部存储库有问题?
默认情况下,Spring Boot 已经创建并使用了一个HealthAggregator的实例,所以我们只是自动注入并在我们用例中使用了它。我们确实必须显式添加HealthIndicatorAutoConfiguration和MetricsDropwizardAutoConfiguration类的导入,以满足DataJpaTest和WebMvcTest切片测试期间的 bean 依赖关系,因为那些只部分实例化了上下文,并且缺少了 actuator 自动配置。
尽管默认实现是一个OrderedHealthAggregator的实例,它只是收集所有内部状态响应,并从DOWN、OUT_OF_SERVICE、UP和UNKNOWN中选择优先级最低的,但这并不总是必须这样。例如,如果组合指标由冗余服务连接的指标组成,只要至少有一个连接是健康的,你的组合结果可以是UP。创建自定义HealthAggregator接口非常简单;你所要做的就是要么扩展AbstractHealthAggregator,要么实现HealthAggregator接口本身。
配置管理上下文
Spring Boot Actuator 默认在主应用程序上下文中创建了一组管理端点和支持 bean,这些端点在配置的 HTTP 端口server.port上可用。然而,由于安全或隔离的原因,我们可能希望将主应用程序上下文与管理上下文分开,或者在不同的端口上公开管理端点。
Spring Boot 为我们提供了一个配置独立子应用程序上下文的能力,该上下文将继承主应用程序上下文的所有内容,但允许定义仅对管理功能可用的 bean。同样,对于在不同的端口上公开端点或使用不同的连接器安全性的方式,主应用程序可以使用 SSL,而管理端点可以使用纯 HTTP 进行访问。
如何实现...
让我们假设,出于某种原因,我们想要将我们的 JSON 转换器更改为使用SNAKE_CASE(所有单词之间用下划线分隔的小写字母)输出字段名称。
- 首先,让我们创建一个名为
ManagementConfiguration.java的类,该类包含我们的管理上下文配置,位于项目根目录src/main/java/com/example/bookpub目录下,并包含以下内容:
@ManagementContextConfiguration
public class ManagementConfiguration
implements WebMvcConfigurer {
@Override
public void configureMessageConverters(
List<HttpMessageConverter<?>> converters) {
HttpMessageConverter c = new
MappingJackson2HttpMessageConverter(
Jackson2ObjectMapperBuilder.json().
propertyNamingStrategy(PropertyNamingStrategy.SNAKE_CAS).
build()
);
converters.add(c);
}
}
- 我们还需要将此类添加到位于项目根目录
src/main/resources/META-INF目录下的spring.factories文件中,并添加以下内容:
org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration=com.example.bookpub.ManagementConfiguration
- 为了避免我们的配置被主应用程序上下文的组件扫描检测到,我们需要通过在项目根目录
src/main/java/com/example/bookpub目录下的BookPubApplication.java文件中添加以下内容来排除它:
@ComponentScan(excludeFilters =
@ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = ManagementContextConfiguration.class
)
)
- 要有一个独立的管理上下文,我们需要使用不同的端口来启动它,因此让我们修改位于项目根目录
src/main/resources目录下的application.properties文件,并添加以下内容:
management.server.port=8081
management.endpoints.web.exposure.include=*
- 最后,让我们通过执行
./gradlew clean bootRun来启动我们的应用程序,然后我们可以通过打开浏览器并访问http://localhost:8081/actuator/threaddump端点来查看我们的新配置生效。返回的 JSON 的字段名称都应该全部是小写,并且单词应该使用下划线分隔,或者称为SNAKE_CASE。或者,通过访问http://localhost:8080/books/978-1-78528-415-1端点,我们应该继续看到LOWER_CAMEL_CASE格式的 JSON 字段名称。
它是如何工作的...
Spring Boot 认识到存在许多原因,它需要能够为管理端点和其他执行器组件的工作方式提供单独的配置,这与主应用程序不同。这种配置的第一级可以通过设置大量以management.*开头的直观属性来实现。我们已经使用了一个这样的属性,即management.server.port,来设置管理接口的端口号为8081。我们还可以设置诸如 SSL 配置、安全设置或网络 IP 接口地址等,以便将监听器绑定到。我们还有能力通过设置相应的属性来配置每个单独的actuator端点,这些属性以management.endpoint.<name>.*开头,并具有各种设置,具体取决于特定端点的目标。
由于安全原因,各种管理端点暴露的数据,尤其是来自敏感端点(如/health、/env等)的数据,可能对外部恶意人员非常有价值。为了防止这种情况发生,Spring Boot 为我们提供了配置端点是否通过management.endpoint.<name>.enabled=false可用的能力。我们可以通过设置适当的management.endpoint<name>.enabled=false属性来指定我们想要禁用的单个端点,或者使用management.endpoints.web.exposure.exclude=<name>来告诉 Spring Boot 是否应该启用此端点,但不要通过 WEB HTTP API 访问方法暴露。
或者,我们可以设置management.server.port=-1来禁用这些端点的 HTTP 暴露,或者使用不同的端口号,以便管理端点和实时服务在不同的端口上。如果我们只想通过 localhost 访问,我们可以通过配置management.server.address=127.0.0.1来防止外部访问。甚至可以将上下文 URL 路径配置为其他路径,例如通过management.server.context-path=/admin配置为/admin。这样,要访问/health端点,我们将访问http://127.0.0.1/admin/health而不是默认的/actuator上下文路径。这在你想要通过防火墙规则控制和限制访问时可能很有用,因此你只需添加一个过滤器来阻止外部访问所有应用程序的/admin/*。随着 Spring Security 的添加,还可以配置身份验证,要求用户登录才能访问端点。
在使用属性控制行为不足以应对的情况中,Spring Boot 提供了一种机制,通过使用 spring.factories 和相应的 ManagementContextConfiguration 注解来提供替代应用程序上下文配置。这使得我们能够告知 Spring Boot 在创建管理上下文时应自动加载哪些配置。此注解的预期用途是将配置保存在一个独立的、可共享的依赖库中,位于主应用程序代码之外。
在我们的示例中,因为我们将其放在相同的代码库中(为了简单起见),我们不得不进行额外的一步,并在 BookPubApplication.java 文件中定义排除过滤器,在设置主应用程序时排除 ManagementContextConfiguration 类的组件扫描。我们必须这样做的原因很简单——如果我们查看 ManagementContextConfiguration 注解的定义,我们会看到它是一个带有内部 @Configuration 注解的元注解。这意味着当我们的主应用程序正在配置时,组件扫描将自动检测应用程序代码的类路径树中所有带有 @Configuration 注解的类,并且因此,它将所有标记为 ManagementContextConfiguration 的配置放入主上下文中。我们已经通过排除过滤器避免了这种情况。或者,更好的方法是使用不同的包层次结构将这些配置放在一个单独的库中,这将防止组件扫描捕获它们,但由于 spring.factories 中为 org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration 的条目,自动配置仍然有效,因为它告诉 Spring Boot 自动将这些配置添加到管理上下文中。
为了使管理上下文与主应用程序分离,有必要配置它使用 management.server.port 属性在单独的端口上运行。如果没有此设置,所有对象都将使用共享的应用程序上下文。
发射指标
之前的配方概述了 Spring Boot Actuators 提供的功能。我们玩转了不同的管理端点,如 /info 和 /health,甚至创建了自己的健康指标以添加到默认集合中。然而,除了健康状态之外,作为开发人员和运维人员,我们还想能够持续查看和监控许多其他内容,仅仅知道链路是功能性的还不够。我们还想看到打开会话的数量、对应用程序的并发请求、延迟等。在本配方中,你将了解 Spring Boot 的指标报告功能,以及如何添加我们自己的指标和一些快速简单的方式来可视化它们。
准备工作
为了更好地可视化指标,我们将使用一个位于 github.com/codecentric/spring-boot-admin 的优秀开源项目 spring-boot-admin。它提供了一个简单的 Web UI,在 Spring Boot Actuators 之上,以提供更美观的各种数据视图。
我们将根据 github.com/codecentric/spring-boot-admin#server-application 中的说明,在 Gradle 中创建一个简单的管理应用程序。执行以下简单步骤:
- 访问 start.spring.io 并创建一个新的应用程序模板,以下字段如下:
-
生成一个:Gradle 项目
-
使用:Java
-
Spring Boot:2.0.0 (SNAPSHOT)
-
组:
org.sample.admin -
工件:
spring-boot-admin-web -
名称:
Spring Boot Admin Web -
描述:
Spring Boot Admin Web 应用程序 -
包名:
org.sample.admin -
打包:Jar
-
Java 版本:8
-
在“搜索依赖项”下选择 Actuator 选项
-
点击“生成项目”快捷键 alt + 下载应用程序模板存档
-
从您选择的目录中提取内容
-
在提取的目录中,执行
gradle wrapper命令行以生成 gradlew 脚本 -
在
build.gradle文件中,将以下依赖项添加到dependencies块中:
compile("de.codecentric:spring-boot-admin-server:2.0.0-SNAPSHOT")
compile("de.codecentric:spring-boot-admin-server-ui:2.0.0-SNAPSHOT ")
- 我们还需要更新
repositories块,以引用使用snapshots存储库(截至编写时,SBA 尚未发布):
maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
- 打开位于
src/main/java/spring-boot-admin-web目录下的SpringBootAdminWebApplication.java文件,并将以下注解添加到SpringBootAdminWebApplication类中:
@SpringBootApplication
@EnableAdminServer
public class SpringBootAdminWebApplication {
public static void main(String[] args) {
SpringApplication.run(
SpringBootAdminWebApplication.class,
args);
}
}
- 打开位于
src/main/resources目录下的application.properties文件,并添加以下设置:
server.port: 8090
spring.application.name: Spring Boot Admin Web
spring.cloud.config.enabled: false
spring.jackson.serialization.indent_output: true
- 现在我们已经准备好通过运行
./gradlew bootRun来启动 Admin Web 控制台,并在浏览器中打开http://localhost:8090以查看以下输出:

如何做...
- 随着 Admin Web 的运行,我们现在可以开始向我们的
BookPub应用程序添加各种指标。让我们像在HealthIndicators中做的那样,公开我们的数据存储库的相同信息,但这次我们将公开计数数据作为指标。我们将在db-count-starter子项目中继续添加代码。因此,让我们在项目根目录下的db-count-starter/src/main/java/com/example/bookpubstarter/dbcount目录中创建一个名为DbCountMetrics.java的新文件,并添加以下内容:
public class DbCountMetrics implements MeterBinder {
private Collection<CrudRepository> repositories;
public DbCountMetrics(Collection<CrudRepository> repositories)
{
this.repositories = repositories;
}
@Override
public void bindTo(MeterRegistry registry) {
for (CrudRepository repository : repositories) {
String name = DbCountRunner.getRepositoryName
(repository.getClass());
String metricName = "counter.datasource."
+ name;
Gauge.builder(metricName, repository,
CrudRepository::count)
.tags("name", name)
.description("The number of entries in "
+ name + "repository")
.register(registry);
}
}
}
- 接下来,为了自动注册
DbCountMetrics,我们将在项目根目录下的db-count-starter/src/main/java/com/example/bookpubstarter/dbcount目录中的DbCountAutoConfiguration.java文件中添加以下内容:
@Bean
public DbCountMetrics
dbCountMetrics(Collection<CrudRepository> repositories) {
return new DbCountMetrics(repositories);
}
- 为了使线程转储在 Spring Boot Admin UI 中正确显示,我们需要将我们的 JSON 转换器从
SNAKE_CASE更改为LOWER_CAMEL_CASE,通过更改位于我们项目根目录src/main/java/com/example/bookpub目录中的ManagementConfiguration.java文件,内容如下:
propertyNamingStrategy(
PropertyNamingStrategy.LOWER_CAMEL_CASE
)
- 因此,让我们通过执行
./gradlew clean bootRun来启动我们的应用程序,然后我们可以通过打开浏览器并访问http://localhost:8081/actuator/metrics来访问/metrics端点,以查看我们添加到现有指标列表中的新DbCountMetrics类,如下所示:

-
我们下一步要做的是让我们的应用程序出现在我们之前创建的 Spring Boot Admin Web 中。为了实现这一点,我们需要在我们的项目根目录的
build.gradle文件中添加对compile("de.codecentric:spring-boot-admin-starter-client:2.0.0-SNAPSHOT")库的依赖。 -
此外,位于我们项目根目录
src/main/resources目录中的application.properties需要添加以下条目:
spring.application.name=BookPub Catalog Application
server.port=8080
spring.boot.admin.client.url=http://localhost:8090
- 再次,让我们通过执行
./gradlew clean bootRun来启动我们的应用程序,如果我们现在通过将浏览器指向http://localhost:8090访问 Spring Boot Admin Web,我们应该在我们的应用程序列表中看到一个名为BookPub Catalog Application的新条目。如果我们点击右侧的详细信息按钮并滚动到健康部分,我们将看到我们的自定义健康指标以及其他以更美观的层次结构条目形式报告的指标,如下所示:

它是如何工作的...
在我们深入了解创建和发布指标细节之前,让我们简单谈谈 Spring Boot Admin Web。它是一个简单的网页图形用户界面,在后台使用与我们在前一个菜谱中了解的 Spring Boot Actuator 相同的端点。当我们点击 Admin Web 中的各种链接时,数据从应用程序中提取出来,并以图形化的方式展示——没有魔法!
除了添加客户端库依赖项之外,我们只需配置几个属性,就可以让我们的应用程序连接并注册到 Admin Web:
-
spring.application.name=BookPub Catalog Application:此配置指定了我们选择使用的应用程序名称。也可以使用在gradle.properties中定义的描述属性值,通过 Gradle 的资源处理任务来实现。Admin Web 在显示应用程序列表时使用此值。 -
spring.boot.admin.client.url=http://localhost:8090: 这配置了 Admin Web 应用程序的位置,以便我们的应用程序知道如何注册自己。由于我们运行在端口8080上,我们选择将 Admin Web 配置为监听端口8090,但可以选择任何所需的端口。您可以通过访问codecentric.github.io/spring-boot-admin/current/来查看更多配置选项。
如果我们还想通过 UI 启用日志级别控制,我们需要将 Jolokia JMX 库添加到我们的compile("org.jolokia:jolokia-core:+")构建依赖中,并在项目根目录的src/main/resources目录中添加一个logback.xml文件,内容如下:
<configuration>
<include
resource="org/springframework/boot/logging/logback/base.xml"/>
<jmxConfigurator/>
</configuration>
Spring Boot 中的度量设施非常强大且可扩展,提供了多种不同的方法来发布和消费度量。从 Spring Boot 2.0 开始,底层使用Micrometer.io库提供了一个非常全面的监控解决方案。Spring Boot 默认配置了一些数据度量,用于监控系统资源,如堆内存、线程计数、系统运行时间等,以及数据库使用情况和 HTTP 会话计数。MVC 端点也被度量以测量请求延迟,该延迟以毫秒为单位,以及每个端点请求状态的计数器。
通过 Spring Boot 在运行时提供的MeterRegistry实现,发出各种度量,如度量、计数器、计时器等。该注册器可以轻松地自动装配到任何 Spring 管理的对象中,并用于发布度量。
例如,我们可以轻松地计算特定方法被调用的次数。我们只需要在创建对象期间自动装配MeterRegistry的实例,并在方法的开始处放置以下行:
meterRegistry.counter("objectName.methodName.invoked").increment();
每次方法被调用时,特定的度量计数将增加。
这种方法将给我们计数,但如果我们想测量延迟或其他任意值,我们需要使用Gauge来提交我们的度量。为了测量我们的方法执行所需的时间,我们可以在方法开始时使用MeterRegistry记录时间:
long start = System.currentTimeMillis();
然后,我们在代码和返回之前再次捕获时间:
long end = System.currentTimeMillis();.
然后,我们将发出度量meterRegistry.gauge("objectName.methodName.latency", end - start);,这将更新最后。使用gauge进行计时是非常基础的,而MeterRegistry实际上提供了一种专门的度量类型——Timer。例如,Timer 度量提供了包装可运行或可调用的 lambda 表达式并自动计时执行的能力。使用 Timer 而不是Gauge的另一个好处是,Timer 度量会保留事件计数以及每次发生的延迟。
MeterRegistry实现涵盖了大多数简单用例,并且在我们自己代码中操作并且有灵活性将其添加到所需位置时非常方便。然而,情况并不总是如此,在这些情况下,我们将需要通过创建MeterBinder的自定义实现来包装我们想要监控的任何内容。在我们的情况下,我们将使用它来公开数据库中每个存储库的计数,因为我们不能在CrudRepository代理实现中插入任何监控代码。
当MeterRegistry实现没有足够的灵活性时,例如,当需要将对象包装在像Gauge这样的仪表中时,大多数仪表实现都提供了流畅的构建器以获得更多灵活性。在我们的例子中,为了包装存储库指标,我们使用了一个Gauge流畅构建器来构建Gauge:
Gauge.builder(metricName, repository, CrudRepository::count)
主要构建方法接受以下三个参数:
-
metricName: 这指定了用于唯一标识此指标的名字。 -
repository: 这提供了一个对象,我们可以在其上调用方法,该方法应返回一个数值,该数值是gauge将报告的。 -
CrudRepository::count: 这是应该调用repository对象的方法,以获取当前条目数。
这使我们能够构建灵活的包装器,因为我们所需要做的只是提供一个对象,该对象将公开必要的数值,以及一个函数引用,该函数引用应在实例上调用以在gauge评估期间获取该值。
用于导出仪表的MeterBinder接口定义了一个方法,
void bindTo(MeterRegistry);,实现者需要根据确切监控的内容进行编码。实现类需要以@Bean的形式公开,它将在应用程序初始化期间自动被拾取和处理。假设确实使用提供的MeterRegistry实现注册了创建的Meter实例,通常是通过调用.builder(...).register(registry)来终止流畅构建器的链,那么指标将通过MetricsEndpoint公开,每次调用/metrics操作器时,都会公开注册到注册表的仪表。
重要的是要提到,我们已经在主应用程序上下文中创建了MeterBinder和HealthIndicator豆子,而不是在管理上下文中。原因是尽管数据是通过管理端点公开的,但端点豆子,如MetricsEndpoint,是在主应用程序上下文中定义的,因此期望所有其他自动装配的依赖关系也在这里定义。
这种方法很安全,因为为了获取信息,需要通过 WebMvcEndpointHandlerMapping 实现外观,它在管理上下文中创建,并使用主应用程序上下文中的代理端点。查看 MetricsEndpoint 类和相应的 @Endpoint 注解以了解详细信息。
通过 JMX 监控 Spring Boot
在当今这个时代,RESTful HTTP JSON 服务是访问数据的事实标准方式,但这并不是唯一的方式。另一种相当流行且常见的管理实时系统的方式是通过 JMX。好消息是 Spring Boot 已经提供了与通过 HTTP 相同级别的支持来暴露管理端点。实际上,这些确实是相同的端点;它们只是被 MBean 容器包装起来。
在本配方中,我们将探讨如何通过 JMX 获取与通过 HTTP 相同的信息,以及如何通过 Jolokia JMX 库使用 HTTP 暴露一些由第三方库提供的 MBeans。
准备工作
如果您还没有为之前的配方添加 Jolokia JMX 库,那么请将 compile("org.jolokia:jolokia-core:+") 构建依赖项添加到我们的构建中,并将 management.jolokia.enabled=true 属性添加到 application.properties 文件中,因为我们需要它们来通过 HTTP 暴露 MBeans。
如何操作...
- 在添加 Jolokia JMX 依赖项后,我们只需要通过执行
./gradlew clean bootRun来构建和启动我们的应用程序,现在我们可以简单地启动 jConsole 来查看在org.springframework.boot域下暴露的各种端点:

- 将 Jolokia JMX 库添加到类路径后,Spring Boot 还启用了通过
/jolokia端点使用 HTTP API 访问所有已注册的 MBeans。为了找出我们的 Tomcat HTTP 端口8080连接器的maxThreads设置,我们可以通过选择Tomcat:type=ThreadPool,name="http-nio-8080"MBean 上的maxThreads属性来使用 jConsole 查找它,以获取200的值,或者我们可以使用 Jolokia JMX HTTP,通过打开浏览器并访问http://localhost:8081/actuator/jolokia/read/Tomcat:type=ThreadPool,name=%22http-nio-8080%22/maxThreads来获取以下 JSON 响应:
{"request":
{"mbean":"Tomcat:name="http-nio-8080",type=ThreadPool",
"attribute":"maxThreads",
"type":"read"
},
"value":200,"timestamp":1436740537,"status":200}
它是如何工作的...
默认情况下,当 Spring Boot Actuator 添加到应用程序中时,会自带所有端点和管理服务启用。这包括 JMX 访问。如果出于某种原因,有人想通过 JMX 禁露特定的端点,这可以通过添加 management.endpoints.jmx.exclude=<id> 来轻松配置;或者为了禁用所有 Spring MBeans 的导出,我们可以在 application.properties 中配置 spring.jmx.enabled=false 设置。
类路径中 Jolokia 库的存在触发了 Spring Boot 的JolokiaManagementContextConfiguration,这将自动配置接受/jolokia操作路径请求的ServletRegistrationBean。也可以通过management.endpoint.jolokia.config.*属性集设置各种 Jolokia 特定的配置选项。完整的列表可在jolokia.org/reference/html/agents.html#agent-war-init-params找到。如果你想使用 Jolokia,但想手动设置它,我们可以通过在application.properties中配置management.endpoint.jolokia.enabled=false属性设置来告诉 Spring Boot 忽略其存在。
通过 SSHd Shell 管理 Spring Boot 和编写自定义远程 Shell 命令
你们中的一些人可能正在回忆那些美好的旧时光,那时所有的管理都是通过直接在机器上使用 SSH 完成的,那时一个人拥有完全的灵活性和控制权,或者甚至使用 SSH 连接到管理端口,直接对运行中的应用程序应用所需的任何更改。尽管 Spring Boot 在 2.0 版本中移除了与 CRaSH Java Shell 的本地集成,但有一个开源项目,sshd-shell-spring-boot,它恢复了这种能力。
对于这个配方,我们将使用我们在本章早期创建的健康指示器和管理端点。我们将通过 SSH 控制台访问公开相同的功能。
如何做到这一点...
- 让 SSHd Shell 工作起来的第一步是在我们的
build.gradle文件中添加必要的依赖启动项,如下所示:
dependencies {
...
compile("org.springframework.boot:spring-boot-starter-actuator")
compile("io.github.anand1st:sshd-shell-spring-boot-starter:3.2.1")
compile("de.codecentric:spring-boot-admin-starter-client:2.0.0-SNAPSHOT")
compile("org.jolokia:jolokia-core:+")
...
}
- 我们还需要显式地启用它,通过在项目根目录下的
src/main/resources目录中的application.properties文件中设置以下属性,需要增强以下条目:
sshd.shell.enabled=true
management.endpoint.shutdown.enabled=true
-
现在,让我们通过执行
./gradlew clean bootRun来启动我们的应用程序,然后通过执行ssh -p 8022 admin@localhost来通过 SSH 连接到它。 -
我们将被提示输入密码,所以让我们在应用程序启动日志中找到自动生成的哈希密钥,它看起来如下:
********** User password not set. Use following password to login:
8f20cf10-7d67-42ac-99e4-3a4a77ca6c5f **********
- 如果密码输入正确,我们将看到以下欢迎提示:
Enter 'help' for a list of supported commands
app>
- 接下来,我们将通过输入
health来调用我们现有的/health端点,我们应该得到以下结果:
{
"status" : "UP",
"details" : {
"dbCount" : {
"status" : "UP",
"details" : {
"ReviewerRepository" : {
...
},
"PublisherRepository" : {
...
},
"AuthorRepository" : {
...
},
"BookRepository" : {
...
}
}
},
"diskSpace" : {
"status" : "UP",
"details" : {
"total" : 249795969024,
"free" : 14219882496,
"threshold" : 10485760
}
},
"db" : {
"status" : "UP",
"details" : { "database" : "H2", "hello" : 1 }
}
}
}
-
输入
help将显示所有现有命令的列表,这样你可以尝试一些命令来查看它们的功能,然后我们将继续添加我们自己的 SSHd Shell 命令,这将使我们能够通过命令行添加新的发布者到系统中。 -
在我们项目的根目录下的
src/main/java/com/example/bookpub/command中创建一个名为commands的新目录。 -
在我们项目的根目录下的
src/main/java/com/example/bookpub/command目录中添加一个名为Publishers.java的文件,内容如下:
package com.example.bookpub.command;
import com.example.bookpub.entity.Publisher;
import com.example.bookpub.repository.PublisherRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import sshd.shell.springboot.autoconfiguration.SshdShellCommand;
import sshd.shell.springboot.console.ConsoleIO;
import java.util.HashMap;
import java.util.Map;
@Component
@SshdShellCommand(value = "publishers", description = "Publisher management. Type 'publishers' for supported subcommands")
public class PublishersCommand {
@Autowired
private PublisherRepository repository;
@SshdShellCommand(value = "list", description = "List of publishers")
public String list(String _arg_) {
List list = new ArrayList();
repository.findAll().forEach(publisher ->
list.add(publisher);
);
return ConsoleIO.asJson(list);
}
@SshdShellCommand(value = "add", description = "Add a new publisher. Usage: publishers add <name>")
public String add(String name) {
Publisher publisher = new Publisher(name);
try {
publisher = repository.save(publisher);
return ConsoleIO.asJson(publisher);
} catch (Exception e) {
return String.format("Unable to add new publisher named %s%n%s", name, e.getMessage());
}
}
@SshdShellCommand(value = "remove", description = "Remove existing publisher. Usage: publishers remove <id>")
public String remove(String id) {
try {
repository.deleteById(Long.parseLong(id));
return ConsoleIO.asJson(String.format("Removed publisher %s", id));
} catch (Exception e) {
return String.format("Unable to remove publisher with id %s%n%s", id, e.getMessage());
}
}
}
-
在构建了命令之后,现在让我们通过执行
./gradlew clean bootRun来启动我们的应用程序,然后通过执行ssh -p 8022 admin@localhost通过 SSH 连接到它,并使用生成的密码散列登录。 -
当我们输入发布者时,我们会看到所有可能的命令列表,如下所示:
app> publishers
Supported subcommand for publishers
add Add a new publisher. Usage: publishers add <name>
list List of publishers
remove Remove existing publisher. Usage: publishers remove <id>
- 让我们通过输入
publishers add Fictitious Books来添加一个发布者,我们应该看到以下消息:
{
"id" : 2,
"name" : "Fictitious Books"
}
- 如果我们现在输入
publishers list,我们将获得所有书籍的列表:
[ {
"id" : 1,
"name" : "Packt"
}, {
"id" : 2,
"name" : "Fictitious Books"
} ]
-
移除发布者是一个简单的命令
publishers remove 2,它应该响应"Removed publisher 2"消息。 -
为了确认发布者确实已经删除,执行
publishers list,我们应该看到以下输出:
[ {
"id" : 1,
"name" : "Packt"
} ]
它是如何工作的...
Spring Boot 与 SSHd Shell 的集成提供了许多开箱即用的命令。我们可以调用通过 HTTP 和 JMX 向我们开放的相同管理端点。我们可以获取 JVM 信息,更改日志配置,甚至与 JMX 服务器和所有注册的 MBeans 进行交互。所有可能性的列表非常令人印象深刻,功能非常丰富,所以我肯定会建议您通过访问github.com/anand1st/sshd-shell-spring-boot来阅读 SSHd Shell 的参考文档。
在 Spring Boot 中,预期任何注解了@SshdShellCommand的类都将自动被拾取并注册为 SSHd Shell 命令。注解属性值转换为主命令名称。在我们的例子中,我们将类注解属性值字段设置为publishers,这成为了 SSH Shell 控制台中的顶级命令名称。
如果命令包含子命令,例如在我们的发布者命令示例中,那么,类中同样注解了@SshdShellCommand的方法将被注册为子命令。如果一个类只有一个方法,它将自动成为给定类中唯一的命令,当输入命令名称时执行。如果我们想在类命令中放置多个子命令,就像我们处理发布者一样,每个转换为命令的方法都需要注解@SshdShellCommand。
目前,SSHd Shell 框架有一个限制,只能将一个属性参数传递给命令,但正在进行工作以扩展该功能。在此期间,建议使用 JSON 有效载荷与命令进行通信,作为输入或输出。
注解上可用的以下属性:
-
value:此属性定义了命令或子命令名称。尽管方法名称不需要与命令名称匹配,但将两者保持同步以使代码更易读是一个好习惯。 -
description:此属性定义了在调用help命令时显示的文本。这是一个与用户沟通如何使用命令、它接受哪些输入等的好地方。在 Shell 中提供尽可能多的描述和文档是一个好主意,这样用户就可以清楚地了解需要发生什么以及如何调用命令。手册页面很棒,所以请保持文档质量上乘。 -
roles:此属性使我们能够定义对执行给定命令的权限的安全约束。如果也使用了 Spring Security,SSHd Shell 提供了配置自定义或特定AuthenticationProvider的能力,用于处理用户身份验证和角色绑定。例如,连接到公司的 LDAP 服务器并允许开发者使用他们的常规凭证,同时根据特定组织的需要配置不同的角色访问控制将变得非常容易。
可以通过使用帮助命令或输入顶级命令的名称来查询每个命令的用法。
虽然 SSHd Shell 内置了许多命令,但我们可以轻松地添加自定义命令,利用标准的 Spring / Spring Boot 编程风格,通过使用 @Autowired 和 @Component 注解来获取在应用启动生命周期中自动配置的必要依赖。
SSHd Shell 还提供了一种很好的功能,允许使用通过管道符号 (|) 调用的后处理器。当前的支持允许输出高亮 | h packt,这将突出显示输出中的单词 packt,或者通过电子邮件发送响应输出 | m my@email.com,这将把命令的响应发送到指定的电子邮件地址,前提是 Spring Mail 也已配置并可用。
如果我们能够将不同的命令链接在一起,就像在 Linux 中那样,这将非常棒,有助于在信息量变得过于庞大时处理输出和过滤必要的数据。想象一下,我们的出版商列表命令返回的不是 2,而是 2000 个出版商。从这个列表中,我们想要找到以 Pa 开头的那些。
即使 SSHd Shell 并未提供此类功能作为默认选项,但它确实允许我们通过定义扩展 BaseUserInputProcessor 类的 bean 来实现自己的后处理器。让我们创建一个提供对过滤 JSON 响应的支持的后处理器,类似于 jq 命令行工具的工作方式。
要实现这一点,让我们在项目根目录的 src/main/java/com/example/bookpub/command 目录中创建另一个名为 JsonPathUserInputProcessor.java 的类,其内容如下:
@Component
@Order(3)
public class JsonPathUserInputProcessor
extends BaseUserInputProcessor {
private final Pattern pattern = Pattern.compile("[\w\W]+\s?\|\s?jq (.+)");
@Override
public Optional<UsageInfo> getUsageInfo() {
return Optional.of(new UsageInfo(Arrays.<UsageInfo.Row>asList(
new UsageInfo.Row("jq <arg>", "JSON Path Query <arg> in response output of command execution"),
new UsageInfo.Row("", "Example usage: help | jq $.<name>"))));
}
@Override
public Pattern getPattern() {
return pattern;
}
@Override
public void processUserInput(String userInput) throws
InterruptedException, ShellException{
String[] part = splitAndValidateCommand(userInput, "\|", 2);
Matcher matcher = pattern.matcher(userInput);
Assert.isTrue(matcher.find(), "Unexpected error");
String jsonQuery = matcher.group(1).trim();
try {
String output = processCommands(part[0]);
Object response = JsonPath.read(output, jsonQuery);
ConsoleIO.writeJsonOutput(response);
} catch (Exception e) {
ConsoleIO.writeOutput(String.format("Unable to process
query %s%n%s", jsonQuery, e.getMessage()));
}
}
}
使用管道功能,我们可以轻松地将 publishers list 命令与 jq 命令链接起来:
publishers list | jq $..[?(@.name =~ /Pa.*/i)]
在我们的示例中,这应该只返回一条记录,如下所示:
[ {
"id" : 1,
"name" : "Packt"
} ]
虽然它不是完整的管道功能,但输入处理器的使用允许添加诸如排序、过滤和显示渲染等功能,这为模块化和重用常见行为提供了更多灵活性。
SSHd Shell Spring Boot 集成提供了一些配置选项,允许我们禁用组件、配置身份验证设置以及指定用户名、密码,甚至密钥证书。例如,如果我们想使用特定的用户名和密码,我们可以通过配置以下属性来实现:
sshd.shell.username=remote
sshd.shell.password=shell
在现实世界的企业环境中,更常见的是使用共享密钥进行受限访问,这些可以通过sshd.shell.publicKeyFile=<key path>或sshd.shell.hostKeyFile=<key path>属性进行配置。或者,正如之前已经提到的,使用自定义的AuthenticationProvider实现与 Spring Security 结合使用,可以将身份验证机制集成到公司的身份验证系统中。
将 Micrometer 指标与 Graphite 集成
在本章的早期部分,你学习了 Spring Boot 提供的监控能力。我们看到了编写自定义HealthIndicators、创建指标以及使用MeterRegistry来发射数据的示例。简单的 Spring Boot Admin Web 框架为我们提供了一些很好的图形用户界面来可视化数据,但这些指标都是即时的,没有长期保留和历史访问。无法观察趋势、检测基准线的偏差以及将今天与上周进行比较并不是一个好的策略,尤其是对于企业级复杂系统来说。我们都希望能够访问过去几周甚至几个月的时间序列数据,并在出现意外情况时设置警报和阈值。
这个配方将向我们介绍一个惊人的时间序列图形工具:Graphite。Graphite 是一个两部分的系统。它为数值时间序列数据提供存储,并提供一个服务以按需图形或以 JSON 流的形式公开这些数据。你将学习如何将 Spring 的 Micrometer 监控框架与 Graphite 集成和配置,以便将 Spring Boot 应用程序的监控数据发送到 Graphite,并稍微玩一下 Graphite 来可视化我们所收集的不同统计数据。
准备工作
Graphite 是一个用 Python 编写的应用程序,因此能够在几乎任何支持 Python 及其库的系统上运行。在给定的系统上安装 Graphite 有多种方式,从从源代码编译,使用pip,到为各种 Linux 发行版预构建的 RPM。
对于所有不同的安装策略,请查看 Graphite 文档在graphite.readthedocs.org/en/latest/install.html。OS X 用户可以阅读位于gist.github.com/relaxdiego/7539911的一个非常好的分步指南。
为了本菜谱的目的,我们将使用一个包含 Graphite 及其对应 Grafana 的预制 Docker 容器。虽然有许多预制的 Docker 镜像包含 Graphite 和 Grafana 的组合,但我们将使用来自registry.hub.docker.com/u/alexmercer/graphite-grafana/的一个,因为它包含所有正确的配置,这将使我们能够快速开始:
-
第一步将是下载所需的 Docker 容器镜像。我们将通过执行
docker pull alexmercer/graphite-grafana来完成此操作。容器大小约为 500 MB;因此,下载可能需要几分钟,具体取决于你的连接速度。 -
Graphite 和 Grafana 都将它们的数据存储在数据库文件中。我们需要创建外部目录,这些目录将位于容器外部,并且我们将通过 Docker 数据卷将它们连接到一个正在运行的实例。
-
在你的系统中的任何位置创建一个 Graphite 数据目录,例如,在
<user_home>/data/graphite。 -
在
<user_home>/data/grafana创建一个 Grafana 数据目录。
-
-
在这个容器中,Graphite 数据将存储在
/var/lib/graphite/storage/whisper,而 Grafana 将数据存储在/usr/share/grafana/data。因此,我们将使用这些路径作为启动容器时的内部卷挂载目标。 -
通过执行
docker run -v <user_home>/data/graphite:/var/lib/graphite/storage/whisper -v <user_home>/data/grafana:/usr/share/grafana/data -p 2003:2003 -p 3000:3000 -p 8888:80 -d alexmercer/graphite-grafana来运行容器。-
在 Docker 中,
-v选项配置卷挂载绑定。在我们的示例中,我们将外部<user_home>/data/graphite目录配置为与容器中/var/lib/graphite/storage/whisper目录引用相同。同样适用于<user_home>/data/grafana映射。我们甚至可以在<user_home>/data/graphite或data/grafana目录中查看它们包含的子目录和文件。 -
-p选项配置端口映射,类似于目录卷。在我们的示例中,我们将以下三个不同的端口映射为从容器外部可访问的内部端口,这些端口绑定到各种服务:2003:2003:这个端口映射将 Graphite 数据流监听器(称为Carbon-Cache Line Receiver)外部化,我们将连接到它以发送指标数据。3000:3000: 这个端口映射将 Grafana Web 仪表板 UI 外部化,我们将使用它来在 Graphite 数据上创建可视化仪表板。8888:80: 这个端口映射将 Graphite Web UI 外部化。尽管它运行在容器中的端口80上,但在我们的开发机器上,端口80很可能没有开放;因此,最好将其映射到其他一些更高的端口号,例如在我们的情况下,8080或8888,因为8080已经被我们的BookPub应用程序占用。
-
-
如果一切按计划进行,Graphite 和 Grafana 应该已经启动并运行,因此我们可以通过将浏览器指向
http://localhost:8888来访问 Graphite,并应该看到以下输出:

- 要查看 Grafana,将浏览器指向
http://localhost:3000以查看以下输出:

- Grafana 的默认登录名和密码是
admin/admin,可以通过 Web UI 管理员进行更改。
对于使用 boot2docker 的 OS X 用户,IP 地址不会是 localhost,而是 boot2docker IP 调用的结果。
- 一旦我们进入 Grafana,我们需要将我们的 Graphite 实例作为
DataSource添加,因此点击图标,转到数据源,并添加一个Type Graphite, Url http://localhost:80, Access的新源代理:

如何做到这一点...
随着 Graphite 和 Grafana 的启动和运行,我们现在可以开始配置我们的应用程序,以便将指标发送到端口 2003 上的 Graphite 监听器。为此,我们将使用 Codahale/Dropwizard 指标库,该库完全由 Spring Boot 支持,因此需要最少的配置:
- 我们的第一件事是添加必要的库依赖。在
build.gradle文件中扩展依赖块,添加以下内容:
compile("io.micrometer:micrometer-registry-graphite:latest.release")
- 在项目根目录的
src/main/java/com/example/bookpub目录中创建一个名为MonitoringConfiguration.java的文件,并包含以下内容:
@Configuration
@ConditionalOnClass(GraphiteMeterRegistry.class)
public class MonitoringConfiguration {
private static final Pattern blacklistedChars =
Pattern.compile("[{}(),=\[\]/]");
@Bean
public MeterRegistryCustomizer<GraphiteMeterRegistry>
meterRegistryCustomizer() {
return registry -> {
registry.config()
.namingConvention(namingConvention());
};
}
@Bean
public HierarchicalNameMapper hierarchicalNameMapper(){
return (id, convention) -> {
String prefix = "bookpub.app.";
String tags = "";
if (id.getTags().iterator().hasNext()) {
tags = "."
+ id.getConventionTags(convention)
.stream()
.map(t -> t.getKey() + "."
+ t.getValue()
)
.map(nameSegment ->
nameSegment.replace(" ", "_")
)
.collect(Collectors.joining("."));
}
return prefix
+ id.getConventionName(convention)
+ tags;
};
}
@Bean
public NamingConvention namingConvention() {
return new NamingConvention() {
@Override
public String name(String name,
Meter.Type type,
String baseUnit) {
return format(name);
}
@Override
public String tagKey(String key) {
return format(key);
}
@Override
public String tagValue(String value) {
return format(value);
}
private String format(String name) {
String sanitized =
Normalizer.normalize(name,
Normalizer.Form.NFKD);
// Changes to the original
// GraphiteNamingConvention to use "dot"
// instead of "camelCase"
sanitized =
NamingConvention.dot.tagKey(sanitized);
return blacklistedChars
.matcher(sanitized)
.replaceAll("_");
}
};
}
}
- 我们还需要将我们的 Graphite 实例的配置属性设置添加到项目根目录下的
src/main/resources目录中的application.properties文件中:
management.metrics.export.graphite.enabled=true
management.metrics.export.graphite.host=localhost
management.metrics.export.graphite.port=2003
management.metrics.export.graphite.protocol=plaintext
management.metrics.export.graphite.rate-units=seconds
management.metrics.export.graphite.duration-units=milliseconds
management.metrics.export.graphite.step=1m
-
现在,让我们通过执行
./gradlew clean bootRun来构建和运行我们的应用程序,如果我们正确配置了所有内容,它应该可以无问题启动。 -
应用程序运行起来后,我们应该开始看到一些数据被添加到指标下的树中,这些数据来自 Graphite 和
bookpub数据节点。为了增加一些现实感,让我们打开浏览器并加载一个书 URL,http://localhost:8080/books/978-1-78528-415-1/,几十次以生成一些指标。 -
让我们继续查看 Graphite 中的某些指标,并将数据时间范围设置为 15 分钟,以便获得一些近距离的图表,这些图表将类似于以下截图:

- 我们还可以通过创建一个新的仪表板并添加一个 Graph 面板来使用这些数据在 Grafana 中创建一些看起来很酷的仪表板,如下截图所示:

- 新创建的 Graph 面板将看起来像这样:

- 点击无标题(点击此处)标签,选择编辑,并在文本字段中输入指标名称
bookpub.app.http.server.requests.exception.None.method.GET.status.200.uri._books__isbn_.count,如下截图所示:

- 点击仪表板将退出编辑模式。
想要获取更详细的教程,请访问 docs.grafana.org/guides/gettingstarted/.
它是如何工作的...
要启用通过 Graphite 导出指标,我们添加了对 io.micrometer:micrometer-registry-graphite 库的额外依赖。然而,在底层,它依赖于 Dropwizard 指标库来提供 Graphite 集成,因此它将在我们的 build 文件中添加以下新依赖项:
-
io.dropwizard.metrics:metrics-core: 这个依赖项添加了基本的 Dropwizard 功能,MetricsRegistry,常见的 API 接口和基类。这是使 Dropwizard 运作并集成到 Spring Boot 以处理指标所需的最小配置。 -
io.dropwizard.metrics:metrics-graphite: 这增加了对GraphiteReporter的支持,并且配置 Dropwizard 将它收集的监控数据发送到我们的 Graphite 实例是必需的。
为了保持事物的整洁和良好的分离,我们创建了一个单独的配置类,其中包含所有与监控相关的豆子和设置:MonitoringConfiguration。在这个类中,我们配置了三个 @Bean 实例:一个定制的 MeterRegistryCustomizer 实现来定制 GraphiteMeterRegistry 实例,HigherarchicalNameMapper 和与其一起的 NamingConvention。
我们必须创建自己的定制的理由有两点。我们希望遵守经典的 Graphite 指标命名方案,该方案使用点(.)符号来分隔层次结构中的指标名称。不幸的是,由于任何原因,Micrometer Graphite 实现选择了使用 camelCase 压缩符号,这使得像 counter.datasource.BookRepository 这样的指标名称被转换成 counterDatasourceBookRepository,以便在 Graphite 中显示。这样的长名称,没有层次树,当存在许多指标时,在 Graphite UI 中进行搜索和发现变得非常困难。此外,所有指标都被放置在根(/)树下,而没有创建一个专门的应用程序文件夹,这也导致了可读性和使用性的下降。我们在 HigherarchicalNameMapper 实例中添加了代码,将应用程序前缀添加到所有导出到 Graphite 的指标上,这样它们都会被放入 subtree: /bookpub/app/*.:
String prefix = "bookpub.app.";
...
return prefix + id.getConventionName(convention) + tags;
NamingConvention 提供了关于如何将特定的度量名称、键、值和标签转换为适当的 Graphite 变体的精确配置。在 format(String name) 方法中,我们声明我们想要使用点(.)分隔元素,通过 NamingConvention.dot 实现来执行。
management.metrics.export.graphite 属性组定义了如何将数据发送到 Graphite 实例。我们配置它每分钟这样做一次,将所有时间间隔,如延迟测量,转换为毫秒,以及所有变量速率,如某些时间框架内的请求数量,转换为秒。大多数这些值都有 Graphite 提供的默认配置设置,但如果需要,可以更改。
注意,我们使用了 @ConditionalOnClass 注解来指示我们只想在 Micrometer Graphite 提供的类 GraphiteMeterRegistry.class 存在于类路径上时应用此 @Configuration。这是必要的,以避免在测试期间尝试实例化 Graphite bean,因为在测试环境中可能没有运行并可供使用的 Graphite 实例。
如您从 Graphite UI 可用的指标中可以看到,有许多指标是开箱即用的。其中一些值得注意的是关于 JVM 和 OS 指标,它们将内存和线程指标暴露在内存和线程数据节点中的其他数据中。它们可以在 Graphite 树中的 Metrics/bookpub/app/jvm、Metrics/bookpub/app/process 或 Metrics/bookpub/app/system 中找到。
Micrometer 核心库提供了一系列用于额外系统指标的度量绑定器。如果需要导出诸如线程或执行器信息之类的数据,或查看文件描述符,可以通过简单地声明一个返回 new JvmThreadMetrics() 或 new FileDescriptorMetrics() 等的方法来导出额外的 bean。
运行的应用程序将收集所有注册到 MeterRegistry 的指标以及每个配置的导出器(在我们的案例中,GraphiteMeterRegistry)在定时间隔内向其目的地报告所有这些指标。适当的导出器实现运行在单独的 ThreadPool 中,因此不在主应用程序线程之外,也不会干扰它们。然而,如果度量实现内部使用某些 ThreadLocal 数据,则应记住这一点,因为这些数据对导出器不可用。
将 Micrometer 指标与 Dashing 集成
之前的配方让我们窥见了如何在应用程序运行时收集各种指标。我们还看到了将此数据可视化为一系列历史趋势图的能力是多么强大。
虽然 Grafana 和 Graphite 提供了非常强大的功能,可以以图表的形式操作数据,构建充满阈值、应用数据函数等复杂仪表板,但有时我们想要更简单、更易读、更具有小部件风格的东西。这正是 Dashing 提供的仪表板体验。
Dashing 是由 Shopify 开发的一个流行的仪表板框架,用 Ruby/Sinatra 编写。它为您提供了创建由不同类型的仪表板小部件组成的仪表板的能力。我们可以有图表、仪表、列表、数值或纯文本来显示信息。
在这个菜谱中,我们将安装 Dashing 框架,学习如何创建仪表板,直接从应用程序发送和消费报告数据以及从 Graphite 获取数据,并使用 Dashing API 将数据推送到 Dashing 实例。
准备工作
为了让 Dashing 运行,我们需要有一个安装了 Ruby 1.9+ 和 RubyGems 的环境。
通常,Ruby 应该在 Linux 和 OS X 的任何常见发行版上可用。如果您正在运行 Windows,我建议使用 rubyinstaller.org 来获取安装包。
一旦您有了这样的环境,我们将安装 Dashing 并创建一个新的仪表板应用程序供我们使用,如下所示:
-
安装 Dashing 非常简单;只需执行 gem install dashing 命令即可在您的系统上安装 Dashing RubyGems。
-
RubyGem 成功安装后,我们将通过在希望创建仪表板应用程序的目录中执行 dashing new
bookpub_dashboard命令来创建名为bookpub_dashboard的新仪表板。 -
一旦生成了仪表板应用程序,请转到
bookpub_dashboard目录并执行bundle命令来安装所需的依赖项 gems。 -
在 gems bundle 安装完成后,我们可以通过执行
dashing start命令来启动仪表板应用程序,然后通过浏览器访问http://localhost:3030来查看以下结果:

如何操作...
如果您仔细查看我们闪亮的新仪表板的 URL,您会发现它实际上说的是 http://localhost:3030/sample 并显示一个自动生成的示例仪表板。我们将使用这个示例仪表板来做出一些更改,以便直接显示我们应用程序的一些指标以及从 Graphite 数据 API 端点获取一些原始指标。
为了演示如何将来自 /actuator/metrics 端点的应用程序数据连接起来,以便在 Dashing 仪表板中显示,我们将更改 Buzzwords 小部件以显示我们数据存储库的计数,如下所示:
-
在开始之前,我们需要将
'httparty', '>= 0.13.3'钩子添加到位于bookpub_dashboard目录的Gemfile文件中,这将使我们能够使用 HTTP 客户端从 HTTP 端点提取监控指标。 -
添加钩子后,再次运行
bundle命令以安装新添加的钩子。 -
接下来,我们需要修改位于
bookpub_dashboard/dashboards目录的sample.erb仪表板定义,将<div data-id="buzzwords" data-view="List" data-unordered="true" data-title="Buzzwords" data-moreinfo="# of times said around the office"></div>替换为<div data-id="repositories" data-view="List" data-unordered="true" data-title="Repositories Count" data-moreinfo="# of entries in data repositories"></div>。 -
替换小部件后,我们将在
bookpub_dashboard/jobs目录中创建一个名为repo_counters.rb的新数据提供作业文件,其内容如下:
require 'httparty'
repos = ['AuthorRepository', 'ReviewerRepository', 'BookRepository', 'PublisherRepository']
SCHEDULER.every '10s' do
data = JSON.parse(HTTParty.get("http://localhost:8081/metrics").body)
repo_counts = []
repos.each do |repo|
current_count = data["counter.datasource.#{repo}"]
repo_counts << { label: repo, value: current_count }
end
send_event('repositories', { items: repo_counts })
end
- 在所有代码更改到位后,让我们通过执行
dashing start命令来启动我们的仪表板。在浏览器中转到http://localhost:3030/sample,以查看显示如下图标的新小部件:

-
如果我们使用远程 Shell 登录到应用程序,就像我们在本章前面所做的那样,并添加一个发布者,我们会看到仪表板上的计数器增加。
-
将数据推送到仪表板的另一种方法是使用它们的 RESTful API。让我们通过执行
curl -d '{ "auth_token": "YOUR_AUTH_TOKEN", "text": "My RESTful dashboard update!" }' http://localhost:3030/widgets/welcome来更新左上角的文本小部件中的文本。 -
如果一切正常,我们应该在“Hello”标题下看到文本更新为我们的新值,
My RESTful dashboard update!。 -
在运行多个相同类型应用程序实例的环境中,直接从每个节点拉取数据可能不是一个好主意,尤其是如果它们非常动态,可以随意来去。建议您从更静态且更知名的位置获取数据,例如 Graphite 实例。为了演示易变的数据指标,我们将消耗
Eden、Survivor和OldGen空间的内存池数据,而不是显示收敛、协同和估值图仪表板。我们将首先替换位于bookpub_dashboard/jobs目录的sample.rb作业文件的内容,如下所示:
require 'httparty'
require 'date'
eden_key = "bookpub.app.jvm.memory.used.area.heap.id.PS_Eden_Space"
survivor_key = "bookpub.app.jvm.memory.used.area.heap.id.PS_Survivor_Space"
oldgen_key = "bookpub.app.jvm.memory.used.area.heap.id.PS_Old_Gen"
SCHEDULER.every '60s' do
data = JSON.parse(HTTParty.get("http://localhost:8888/render/?from=-11minutes&target=#{eden_key}&target=#{survivor_key}&target=#{oldgen_key}&format=json&maxDataPoints=11").body)
data.each do |metric|
target = metric["target"]
# Remove the last data point, which typically has empty value
data_points = metric["datapoints"][0...-1]
if target == eden_key
points = []
data_points.each_with_index do |entry, idx|
value = entry[0] rescue 0
points << { x: entry[1], y: value.round(0)}
end
send_event('heap_eden', points: points)
elsif target == survivor_key
current_survivor = data_points.last[0] rescue 0
current_survivor = current_survivor / 1048576
send_event("heap_survivor", { value:
current_survivor.round(2)})
elsif target == oldgen_key
current_oldgen = data_points.last[0] rescue 0
last_oldgen = data_points[-2][0] rescue 0
send_event("heap_oldgen", {
current: current_oldgen.round(2),
last: last_oldgen.round(2)
})
end
end
end
- 在位于
bookpub_dashboard/dashboards目录的sample.erb模板中,我们将用以下替代方案替换 Synergy、估值和收敛图:
-
<div data-id="synergy" data-view="Meter" data-title="Synergy" data-min="0" data-max="100"></div>被替换为<div data-id="heap_survivor" data-view="Meter" data-title="Heap: Survivor" data-min="0" data-max="100" data-moreinfo="In megabytes"></div> -
<div data-id="valuation" data-view="Number" data-title="Current Valuation" data-moreinfo="In billions" data-prefix="$"></div>被替换为<div data-id="heap_oldgen" data-view="Number" data-title="Heap: OldGen" data-moreinfo="In bytes" ></div> -
<div data-id="convergence" data-view="Graph" data-title="Convergence" style="background-color:#ff9618"></div>被替换为<div data-id="heap_eden" data-view="Graph" data-title="Heap: Eden" style="background-color:#ff9618" data-moreinfo="In bytes"></div>
- 在所有更改完成后,我们可以重新启动仪表板应用程序,并在浏览器中重新加载
http://localhost:3030以查看以下结果:

它是如何工作的...
在这个菜谱中,我们看到了如何直接从我们的应用程序中提取数据,并通过 Graphite,使用 Dashing 仪表板进行渲染,以及直接使用他们的 RESTful API 将信息推送到 Dashing。一次看到某物总比听七次要好,这一点在尝试获取代表系统在运行时行为的整体关键指标,并能够快速对数据进行操作时尤为正确。
虽然不深入探讨 Dashing 的内部结构,但仍然有必要提及一些关于数据如何进入 Dashing 的事情。这可以通过以下两种方式发生:
-
计划任务:这是用来从外部源提取数据
-
RESTful API:这是用来从外部将数据推送到 Dashing
计划任务定义在生成的仪表板应用程序的jobs目录中。每个文件都包含一段用SCHEDULER.every块包裹的 ruby 代码,它计算数据点并将新数据发送到适当的部件以进行更新。
在我们的菜谱中,我们创建了一个名为repo_counters.rb的新任务,我们使用了httparty库来直接调用我们的应用程序实例的/actuator/metrics/#{name}端点,并提取了每个预定义存储库的计数器。在遍历指标时,我们创建了一个repo_counts集合,其中包含每个存储库的数据,包括标签显示和值计数。生成的集合以event: send_event('repositories', { items: repo_counts })的形式发送到存储库小部件以进行更新。
我们配置了这个任务每 10 秒执行一次,但如果数据变化率不是非常频繁,这个数字可以改为几分钟甚至几小时。每次调度程序运行我们的任务时,存储库小部件都会通过客户端 WebSocket 通信以新数据更新。在dashboards/sample.erb中查找,我们可以使用data-id="repositories"找到小部件的定义。
除了添加我们自己的新任务外,我们还修改了现有的sample.rb任务,使用 Graphite 的 RESTful API 从 Graphite 获取数据,以填充不同类型的控件,以便显示内存堆数据。因为我们不是直接从应用程序实例中获取数据,所以将代码放在同一个任务中是个好主意,因为任务可能有不同的时间间隔——在我们的案例中,确实如此。由于我们每分钟只向 Graphite 发送一次数据,所以没有必要比这更频繁地获取数据。
要从 Graphite 获取数据,我们使用了以下 API 调用:
/render/?from=-11minutes&target= bookpub.app.jvm.memory.used.area.heap.id.PS_Eden_Space &target= bookpub.app.jvm.memory.used.area.heap.id.PS_Survivor_Space &target= bookpub.app.jvm.memory.used.area.heap.id.PS_Old_Gen &format=json&maxDataPoints=11
看看前面代码片段中提到的以下参数:
-
target:此参数是一个重复的值,定义了我们想要检索的所有不同指标列表。 -
from:此参数指定时间范围;在我们的案例中,我们要求回退 11 分钟的数据。 -
format:此参数配置所需的输出格式。我们选择了 JSON,但还有许多其他格式。请参阅graphite.readthedocs.org/en/latest/render_api.html#format。 -
maxDataPoints:此参数表示我们想要获取的条目数量。
我们要求 11 个条目而不是 10 个条目的原因是因为短范围请求的最后一条记录,这些请求只包含几分钟,有时会返回为空。我们只使用前 10 个条目并忽略最近的条目,以避免奇怪的数据可视化。
遍历目标数据,我们将填充适当的控件,例如heap_eden、heap_survivor和heap_oldgen,并使用它们指定的数据,如下所示:
-
heap_eden:这是一个Graph控件,如sample.erb模板中定义的data-view="Graph"属性,因此它需要一个包含x和y值的点集合作为数据输入。x值代表时间戳,它方便地由 Graphite 返回,并由 Graph 控件自动转换为分钟显示值。y值代表以字节为单位的内存池利用率。由于 Graphite 的值是十进制数的形式,我们需要将其转换为整数,以便看起来更好。 -
heap_survivor:这是一个Meter控件,如sample.erb模板中定义的data-view="Meter"属性,因此它需要一个在模板配置的范围内的简单数值作为数据输入。在我们的案例中,范围设置为data-min="0" data-max="100"属性。尽管我们选择将数字四舍五入到两位小数,但它可能只是一个整数,因为对于仪表盘显示来说足够精确。你也会注意到在sample.rb内部,我们将原始值(以字节为单位)转换为兆字节,以便更好地阅读——current_survivor = current_survivor / 1048576。 -
heap_oldgen: 这是一个Number小部件,如sample.erb模板中以data-view="Number"属性的形式定义的,因此它需要一个当前值的数据输入,以及可选的最后一个值;在这种情况下,将显示百分比变化以及变化方向。由于我们获取了最后 10 个条目,我们在检索当前值和最后一个值时没有问题,因此我们可以轻松满足这一要求。
在这个菜谱中,我们还通过尝试使用curl命令来更新欢迎小部件的值来实验了 Dashing 的 RESTful API。这是一个推送操作,可以在没有公开数据 API 的情况下使用,但你有能力创建某种脚本或代码来将数据发送到 Dashing。为了实现这一点,我们使用了以下命令:curl -d '{ "auth_token": "YOUR_AUTH_TOKEN", "text": "My RESTful dashboard update!" }' http://localhost:3030/widgets/welcome。
Dashing API 接受 JSON 格式的数据,通过 POST 请求发送,该请求包含以下参数,这些参数对于小部件以及作为 URL 路径一部分的 widget ID 是必需的:
-
auth_token: 这允许安全地更新数据,并且可以在仪表盘根目录下的config.ru文件中进行配置。 -
text: 这是一个正在被更改的widget属性。由于我们正在更新一个Text小部件,如sample.erb模板中定义的,以data-view="Text"属性的形式,我们需要发送文本以更新。 -
/widgets/<widget id>: 这个 URL 路径标识了更新目标的具体小部件。id对应于sample.erb模板中的声明。在我们的例子中,它看起来像data-id="welcome"。
各种小部件的定义也可以被操作,社区已经创建了一个非常丰富的小部件集合,可在github.com/Shopify/dashing/wiki/Additional-Widgets找到。这些小部件将被安装到仪表盘的widgets目录中,可以通过简单地运行dashing install <GIST>来安装,其中GIST是 GitHub Gist 条目的哈希值。
与我们的sample.erb模板类似,仪表盘模板文件也可以被修改,以创建每个特定仪表盘的所需布局以及多个仪表盘模板,这些模板可以被轮换或直接手动加载。
每个仪表盘代表一个网格,各种小部件被放置在其中。每个小部件由一个带有适当配置属性的<div>条目定义,并且应该嵌套在<li>网格元素中。我们可以使用数据元素属性来控制每个小部件在网格中的位置,如下所示:
-
data-row: 这代表小部件应该放置的行号。 -
data-col: 这代表小部件应该放置的列号。 -
data-sizex:这定义了小部件在水平方向上跨越的列数 -
data-sizey:这定义了小部件在垂直方向上跨越的行数
现有的小部件可以被修改以改变它们的视觉和感觉效果,同时扩展它们的功能;因此,我们能够拥有的信息显示类型是无限的。你绝对应该查看dashing.io以获取更多详细信息。
第八章:Spring Boot DevTools
在本章中,我们将学习以下主题:
-
将 Spring Boot DevTools 添加到项目中
-
配置 LiveReload
-
配置动态应用程序重启触发器
-
使用远程更新
简介
在 DevOps、敏捷软件开发实践、微服务的引入以及越来越多的团队进行持续开发和部署的世界中,能够快速看到应用程序的代码更改,而不必经历整个重新编译整个项目、重建和重启应用程序的过程,这一点变得更加重要。
容器化服务如 Docker 的出现,在访问实际应用程序运行环境方面也提出了挑战。它通过抽象和封装运行时环境改变了机器的概念,移除了使用任何端口来获取访问的能力。
Spring Boot DevTools 提供了使用 HTTP 远程调试隧道进行选择性地重新加载类和调试运行在 Docker 容器中的应用程序的能力,以便为开发者提供一个快速反馈循环,以便在运行中的应用程序中看到他们的更改,而无需经历长时间的重构建和重启周期。
将 Spring Boot DevTools 添加到项目中
从 Spring Boot 1.3 版本开始,我们能够在项目中利用 DevTools 组件,以实现诸如代码更改时自动重启应用程序、重新加载浏览器窗口以更新 UI 或远程重新加载应用程序等功能。
DevTools 模块适用于 Maven 和 Gradle,并且与 Eclipse 或 IntelliJ IDEA 编辑器配合良好。
在本章中,我们将介绍与 Gradle 和 IntelliJ IDEA 的集成,但有关使用 Spring Boot DevTools 的详细信息,请参阅docs.spring.io/spring-boot/docs/current/reference/html/using-boot-devtools.html上的文档。
如何操作...
继续我们的BookPub项目,我们将通过以下步骤将 DevTools 模块添加到主构建配置中:
- 将以下内容添加到位于项目根目录的
build.gradle文件中:
dependencies {
...
compile("io.dropwizard.metrics:metrics-graphite:3.1.0")
compile("org.springframework.boot:spring-boot-devtools")
runtime("com.h2database:h2")
...
}
-
通过运行
./gradlew clean bootRun来启动应用程序。 -
应用程序启动后,你可能会在控制台日志中注意到关于无法注册 Spring Boot admin(除非你有一个正在运行的)的输出警告,如下所示:Failed to register application as Application.... 让我们从项目根目录下的
build/resources/main目录中的application.properties文件进行实时更改,并添加以下内容的属性条目:
spring.boot.admin.auto-registration=false
- 在不进行其他操作的情况下,保存文件后,我们应该看到控制台日志显示应用程序上下文正在重启。
它是如何工作的...
如您现在可能已经了解的那样,当我们将 spring-boot-devtools 模块作为依赖项添加时,会发生一些自动配置的魔法,以添加许多组件。一些监听器和自动配置扩展了应用程序上下文,以处理代码更改并执行适当的重启和重新加载,包括本地和远程。
在我们的配方中,我们进行了一个快速测试,以确保重启功能正常工作,并且通过在 application.properties 文件中更改属性来确保一切已配置。您可能已经注意到,我们不是在 src/main/resources/application.properties 中进行更改,而是在位于 build/resources/main 目录下的编译版本中进行更改。这样做的原因是因为我们在 Gradle 构建阶段使用的 info. 块的属性占位符替换。如果我们只更改原始文件并使用 IntelliJ 编译选项,它将不会执行所需的替换,从而导致重启失败。
当 DevTools 启用时,启动后的应用程序开始监视类路径上的类更改。当任何类或资源更改时,它将作为 DevTools 通过刷新包含项目代码库的类加载器(这不是包含静态依赖项工件类的类加载器)来重新加载应用程序的触发器。
以下链接提供了详细的工作原理解释:
在可重新加载的类加载器完成刷新后,应用程序上下文会自动重启,从而有效地导致应用程序重启。
配置 LiveReload
对于从事前端 Web 应用程序的人来说,可能会同意,一旦后端代码或资源发生更改,能够自动重新加载页面将节省几个点击,并防止忘记重新加载导致浪费调试努力和追逐不存在的错误的情况。幸运的是,DevTools 通过提供 LiveReload 服务器实现来救命,该实现可以与 LiveReload 浏览器扩展一起使用,在后台更改发生时自动重新加载页面。
如何操作...
如果将 DevTools 模块添加到构建依赖项中,LiveReload 服务器已自动启动。然而,我们确实需要通过执行以下步骤来安装和启用浏览器扩展:
- 除非浏览器已经安装了 LiveReload 扩展,否则请访问
livereload.com/extensions/并为您的浏览器选择合适的扩展(Firefox、Safari 和 Chrome 都受支持)。
对于 Internet Explorer 用户,有一个第三方扩展可以在github.com/dvdotsenko/livereload_ie_extension/downloads找到。
-
安装扩展后,通常需要在页面上通过点击工具栏中的按钮来启用它。在 Chrome 浏览器中,它看起来是这样的:
![]()
-
启用扩展后,我们可以继续进行另一个更改,就像在之前的菜谱中做的那样(或任何其他代码或资源更改),或者简单地执行
touch build/resources/main/application.properties命令。我们应该在后台看到应用程序重新加载,以及浏览器页面在之后重新加载。
它是如何工作的...
通过添加 LiveReload 浏览器扩展,并将 LiveReload 服务器嵌入到我们的BookPub应用程序中,现在浏览器能够通过 WebSocket 连接到后端服务器以监控更改。当 Spring Boot DevTools 检测到应该触发重新加载的更改时,它将触发重新加载,并向浏览器发送通知以重新加载页面。
如果需要禁用 DevTools 功能中的 LiveReload 部分,可以通过添加spring.devtools.livereload.enabled=false属性通过任何支持的配置选项轻松实现,无论是属性文件、环境变量还是系统属性。
配置动态应用程序重启触发器
在之前的菜谱中,我们已经探讨了 DevTools 在代码或资源更改后重启应用程序以及与浏览器通信以重新加载页面时的基本功能。本节将介绍我们可以利用的各种配置选项,以向 Spring Boot DevTools 准确指示我们希望由哪些事件触发,以及何时触发。
如何操作...
默认情况下,将 DevTools 模块添加到项目中将使其监控所有类或资源,这可能会变成不希望的行为,尤其是在多模块仓库中。当在 IntelliJ 或 Eclipse 等 IDE 中构建和启动项目时,这一点尤为明显。我们需要通过调整配置设置来告诉 DevTools 排除我们项目中的db-count-starter子模块从监视列表:
- 让我们在项目根目录下的
db-count-starter/src/main/resources/META-INF目录中创建一个名为spring-devtools.properties的文件,并包含以下内容:
restart.exclude.db-count-starter=/db-count-starter/build/(classes|resources)/main
-
接下来,我们需要在 IDE 中启动我们的应用程序,通过打开位于项目根目录下的
src/main/java/com/example/bookpub目录中的BookPubApplication类,并在运行或调试模式下启动main(String[] args)方法。 -
排除
db-count-starter模块后,我们可以安全地更改一个文件,例如位于项目根目录下的db-count-starter/build/resources/main/META-INF目录中的spring.factories资源文件,结果却看到应用程序没有被重新启动 -
如果我们想要完全禁用重启功能,我们可以通过向位于项目根目录下的
src/main/resources目录中的application.properties文件添加以下属性来实现:
spring.devtools.restart.enabled=false
- 在重新启动我们的应用程序后,即使是
build/resources/main/application.properties文件的更改,也就是从类路径中加载的内容,也不会触发应用程序重启
它是如何工作的...
在这个菜谱中,我们查看了一些不同的重新加载触发配置,因此让我们逐个查看它们,以了解在哪里最好使用它们:
-
spring.devtools.restart.enabled:此属性提供了最简单的控制,完全启用或禁用 DevTools 的重启功能。当值为false时,无论类路径上的类或资源发生何种更改,都不会重新启动应用程序。 -
spring.devtools.restart.exclude:此属性提供了一种停止特定类路径重新加载的能力。此属性接受使用 Ant 路径匹配模式风格的逗号分隔值。默认排除值是"META-INF/maven/**,META-INF/resources/**,resources/**,static/**,public/**,templates/**,**/*Test.class,**/*Tests.class,git.properties,META-INF/build-info.properties"。 -
spring.devtools.restart.additional-exclude:此属性提供了在不复制/粘贴默认值的情况下向默认排除列表添加的便利性,而是简单地添加到它们,同时保留原始默认值。它采用相同的逗号分隔 Ant 路径匹配模式风格输入。 -
spring.devtools.restart.additional-paths:此属性提供了监视类路径之外资源的能力。例如,这可能是应用程序启动时加载的config目录,如果配置条目发生变化,则希望重新启动应用程序。它接受绝对文件路径的逗号分隔列表。 -
spring.devtools.restart.poll-interval:此属性指定在检查类路径更改之间暂停多长时间,以毫秒为单位。默认值为1000毫秒,但如果需要节省一些 CPU 周期,这将有效。 -
spring.devtools.restart.quiet-period:此属性控制在重启发生之前,在类路径没有任何更改的情况下应经过多少毫秒。这是确保如果发生连续更改,重启不会变得压倒性的必要条件。默认值为400毫秒,但如有需要可以更改。 -
spring.devtools.restart.trigger-file:此属性通过监视trigger文件的变化来提供对何时重新启动的显式控制。这在类路径持续变化的情况下很有用,你不想陷入重新启动循环。
所列出的所有先前属性设置通常在开发人员工作的所有应用程序项目中共享,因此 DevTools 提供了在此处定义全局属性的能力,这使得在多个项目中共享开发配置变得方便,无需在所有不同的代码库中复制/粘贴相同的值。
在内部,此功能作为PropertySource实现,并将其添加到配置优先级层次结构的顶部。这意味着不仅spring.devtools配置家族,任何添加到全局文件的属性都将应用于所有使用 DevTools 的应用程序。
控制重新加载触发器的一种方法是使用META-INF/spring-devtools.properties,其中包含restart.exclude.<name>和restart.include.<name>配置。默认情况下,应用程序的重新启动仅由实际类或资源的变化触发,这些类或资源位于类路径上且未打包到 JAR 文件中。这允许你将大多数类保留在不可重新加载的基本类加载器中,从而大大减少了需要监控变化条目数量。
在开发人员与相互依赖的多个项目一起工作或在一个多模块仓库(如BookPub)中工作的场合,可能希望将一些 JAR 文件添加到可重新加载的类加载器中,并监视它们的变化。这通常适用于指向build/libs或target目录的依赖项,其中它们内部的 JAR 文件是构建任务执行的直接结果,并且通常频繁重建。
另一个用例,我们在本食谱中探讨了,是包含或排除build/classes或target/classes从监视列表中。如果一个多模块项目在 IDE 中加载,类路径通常包含对子模块构建目录的直接引用,而不是编译的 JAR 工件,根据用例,我们可能选择包含或排除这些内容以触发重新加载。
键的<name>部分并不重要,只要它是唯一的即可,因为所有的META-INF/spring-devtools.properties文件都将被加载为组合文件,无论它们是否位于 JAR 文件内部还是直接在项目中。建议的方法是使用子模块/工件名称,因为它通常可以确保唯一性。如果有多个模式适用,则可以在名称后附加一个序列号,例如restart.exclude.db-count-starter-1和restart.exclude.db-count-starter-2。每个键的值应包含一个有效的正则表达式模式,可以针对类路径中的每个条目进行评估,以确定特定的类路径 URL 是否应该进入可重新加载或基本类加载器。
使用远程更新
随着 Docker 的日益流行,越来越多的应用程序被构建和部署为 Docker 容器。Docker 的一个伟大特性是将运行时环境与宿主操作系统隔离开来,但这种隔离也使得在真实环境中对应用程序进行持续更改和测试变得困难。每次属性文件或 Java 类的更改,都需要重新构建一切,创建新的 Docker 镜像,重启容器等等。对于每一次更改,这都是一大堆工作。
尽管不幸的是,从 2.0 版本开始,Spring Boot 已经移除了远程调试的能力,但仍然有一个非常有用的功能,可以在你编写代码时,从 IDE 中远程重新加载代码更改,而无需至少重新构建应用程序 JAR 和 Docker 镜像。
远程重启功能为更好的持续开发提供了解决方案,使得可以在远程进行动态应用程序重启,就像它是在本地机器上一样。
如何实现...
如你所猜,远程重启涉及一个在本地运行的代理,并向远程客户端发送指令。DevTools 提供了一个这样的代理实现——RemoteSpringApplication:
- 为了启用远程重启功能,我们需要在项目根目录下的
src/main/resources目录中添加一个名为application.properties的属性文件,内容如下:
spring.devtools.remote.secret=our-secret
- 下一步是在 IDE 中为
RemoteSpringApplication类创建一个 Java 应用程序启动配置。
确保程序参数字段包含你试图调试的应用程序的基本 URL 以及端口非常重要。确保工作目录指向主项目,并且模块的类路径指向主项目模块。
下页的图显示了在 IntelliJ IDEA 中此类配置的外观。Eclipse IDE 也会有类似的形式。

- 填写完所有字段后,我们需要通过点击运行来在 IDE 中启动
RemoteSpringApplication。如果一切配置正确,我们应该在日志中看到类似的输出:
. ____ _ __ _ _
/\ / ___'_ __ _ _(_)_ __ __ _ ___ _
( ( )___ | '_ | '_| | '_ / _` | | _ ___ _ __ ___| |_ ___
\/ ___)| |_)| | | | | || (_| []::::::[] / -_) ' / _ _/ -_) ) ) ) )
' |____| .__|_| |_|_| |___, | |_|____|_|_|____/_____|/ / / /
=========|_|==============|___/===================================/_/_/_/
:: Spring Boot Remote :: (v2.0.0.BUILD-SNAPSHOT)
2017-12-26 21:33:28.520 INFO o.s.b.devtools.RemoteSpringApplication : Starting RemoteSpringApplication v2.0.0.BUILD-SNAPSHOT ...
2017-12-26 21:33:28.524 INFO o.s.b.devtools.RemoteSpringApplication : No active profile set, falling back to default profiles: default
2017-12-26 21:33:28.781 INFO s.c.a.AnnotationConfigApplicationContext : Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@6babf3bf: startup date [Tue Dec 26 21:33:28 CST 2017]; root of context hierarchy
2017-12-26 21:33:29.295 WARN o.s.b.d.r.c.RemoteClientConfiguration : The connection to http://127.0.0.1:8080 is insecure. You should use a URL starting with 'https://'.
2017-12-26 21:33:29.368 DEBUG o.s.b.devtools.restart.ChangeableUrls : Matching URLs for reloading : [file:/.../ch8/build/classes/main/, file:/.../ch8/build/resources/main/]
2017-12-26 21:33:29.401 INFO o.s.b.d.a.OptionalLiveReloadServer : LiveReload server is running on port 35729
2017-12-26 21:33:29.443 INFO o.s.b.devtools.RemoteSpringApplication : Started RemoteSpringApplication in 1.497 seconds (JVM running for 2.248)
-
为了模拟远程操作,我们将在一个单独的命令行中启动应用程序,执行
./gradlew clean bootJar命令,然后执行./build/libs/bookpub-0.0.1-SNAPSHOT-exec.jar。 -
一旦应用程序启动,查看日志中的最后一行,它应该看起来像以下内容:
INFO 50926 --- [ main] ication$$EnhancerBySpringCGLIB$$11c0ff63 : Value of my.config.value property is:
-
my.config.value的属性值没有被设置,因为我们没有在我们的application.properties文件中定义它,并且我们没有使用任何环境变量或启动系统属性设置来设置它。 -
让我们假设我们需要进行实时更改,并从项目根目录下的
build/resources/main目录修改application.properties文件,内容如下:
my.config.value=Remote Change
- 现在,我们应该在控制台中看到我们的应用程序已经自动重启,一切完成后,我们应该看到类似以下的内容:
INFO 50926 --- [ restartedMain] ication$$EnhancerBySpringCGLIB$$11c0ff63 : Value of my.config.value property is: Remote Change
它是如何工作的...
它可能看起来像是巫术魔法,但远程重启功能背后的科学相当简单。在底层,当包含 DevTools 模块时,/.~~spring-boot!~/restart的 HTTP 端点处理器会自动添加。这允许RemoteSpringApplication进程通过 HTTP 隧道将代码更改有效载荷发送到远程应用,并返回。
为了确保没有恶意的外部调试连接能够连接到我们的远程应用程序,spring.devtools.remote.secret属性的值会被发送并验证,以建立请求的真实性。
在食谱的第 2 步中,我们使用http://127.0.0.1:8080作为程序参数值启动了RemoteSpringApplication进程,这是RemoteSpringApplication知道如何与我们的远程应用通信的方式。RemoteSpringApplication类本身通过监控类路径来扫描本地文件更改。
在食谱的第 6 步中,当我们向代码中的配置添加属性时,非常重要的一点是要注意,我们对application.properties文件进行了更改,该文件位于RemoteSpringApplication类的运行类路径中,而不是在src/main/resources下,而是在build/resources/main目录下,这是 Gradle 放置所有编译文件的目录——希望这是你的 IDE 用作运行RemoteSpringApplication的类路径的相同目录。如果这不是你的 IDE 使用的路径,你应该在 IDE 编译类的相应文件夹中进行更改——对于 IntelliJ IDEA,默认情况下将是out/production/resources目录。
如果需要在作为 Docker 容器运行的应用程序中启用 DevTools,我们需要明确配置build脚本,通过在主项目的build.gradle文件中添加以下内容来实现:
bootJar {
...
excludeDevtools = false
}
我们需要这样做的原因是,默认情况下,当 Spring Boot 应用程序被重新打包用于生产部署时,这在构建 Docker 容器镜像时是常见的情况,DevTools 模块在构建时会从类路径中排除。为了防止这种情况发生,我们需要告诉构建系统不要排除该模块,以便利用其功能,即远程重启。
第九章:Spring Cloud
在本章中,我们将学习以下主题:
-
Spring Cloud 入门
-
使用 Spring Cloud Consul 进行服务发现
-
使用 Spring Cloud Netflix—Feign
-
使用 Spring Cloud Netflix—Eureka 进行服务发现
-
使用 Spring Cloud Netflix—Hystrix
简介
在整本书中,我们学习了如何创建应用程序、配置 RESTful 服务、进行测试、集成指标和其他管理组件,以及处理打包和部署等。现在,是时候看看应用程序之外的世界——无处不在的云环境了。
在本章中,我们将探讨如何使应用程序适合云环境,如何处理在云中运行的分布式应用程序的动态特性,如何使我们的应用程序对世界可见,如何发现其他服务端点,如何调用它们,以及如何处理各种错误条件。
Spring Cloud 入门
Spring Cloud 项目家族为 Spring Boot 提供了各种框架的集成扩展,这些框架提供了分布式服务发现、配置、路由、服务调用等功能。通过使用统一的 API,我们可以将这些概念添加到我们的应用程序中,并在需要时灵活地更改具体实现,而无需对代码库进行重大修改。
如何操作...
我们将首先通过将它们添加到主构建配置中,增强我们的BookPub项目的基础 Spring Cloud 模块:
- 将以下内容添加到项目根目录下的
build.gradle文件中:
...
apply plugin: 'docker'
dependencyManagement {
imports {
mavenBom 'org.springframework.cloud:spring-cloud-dependencies:Finchley.BUILD-SNAPSHOT'
}
}
jar {
baseName = 'bookpub'
version = '0.0.1-SNAPSHOT'
}
...
dependencies {
...
compile("org.springframework.boot:spring-boot-devtools")
compile("org.springframework.cloud:spring-cloud-context")
compile("org.springframework.cloud:spring-cloud-commons")
runtime("com.h2database:h2")
...
}
-
通过运行
./gradlew clean bootRun启动应用程序 -
应用程序启动后,即使看起来没有发生任何新的事情,如果我们打开浏览器到
http://localhost:8081/actuator/env(环境的管理端点),我们将看到新的属性源出现:
{
"name": "springCloudClientHostInfo",
"properties": {
"spring.cloud.client.hostname": {
"value": "127.0.0.1"
},
"spring.cloud.client.ip-address": {
"value": "127.0.0.1"
}
}
}
- 在项目根目录下的
src/main/resources目录下创建一个bootstrap.properties文件,内容如下(此时应在application.properties文件中相应地注释掉相同的属性):
spring.application.name=BookPub-ch9
-
通过运行
./gradlew clean bootRun启动应用程序 -
应用程序启动后,打开我们的浏览器到
http://localhost:8081/env,我们将看到新的属性源出现:
{
"name": "applicationConfig: [classpath:/bootstrap.properties]",
"properties": {
"spring.application.name": {
"value": "BookPub-ch9",
"origin": "class path resource [bootstrap.properties]:1:25"
}
}
}
它是如何工作的...
在我们深入探讨事物的工作原理之前,让我们回顾一下我们对项目所做的更改。第一步是增强build.gradle构建配置,通过导入 Spring Cloud 发布列车的一个物料清单(BOM)声明——mavenBom 'org.springframework.cloud:spring-cloud-dependencies: Finchley.BUILD-SNAPSHOT'。虽然我们可以选择性地导入显式定义的spring-cloud-context和spring-cloud-commons库的版本,但通过依赖打包的 BOM,我们可以确保我们将使用经过相互兼容性测试的不同组件的正确版本。
在特定发布列车中包含的每个 Spring Cloud 模块的特定版本可以在cloud.spring.io/中查看。
我们首先添加了对spring-cloud-context和spring-cloud-commons库的依赖,以展示 Spring Cloud 提供的基本通用设施,然后再深入到特定启动器集成,例如spring-cloud-netflix或spring-cloud-consul。这些基本库提供了一个接口和通用功能的基础,这些功能被用于构建所有不同的特定云集成。以下是它们的目的:
-
spring-cloud-commons:这个库提供了一系列共享的通用接口和基类,定义了服务发现、服务路由、负载均衡、断路器、功能能力和一些基本配置的概念。例如,这就是自动配置环境为springCloudClientHostInfo属性源的库。 -
spring-cloud-context:这是负责引导和配置各种集成的基础,例如特定的服务发现实现如 Consul,或特定的断路器实现如Hystrix。这是通过创建一个隔离的引导应用程序上下文来实现的,该上下文负责在主应用程序启动之前加载和配置所有组件。
引导应用程序上下文在应用程序启动周期早期创建,并且它由一个单独的文件配置——bootstrap.properties(也支持 YAML 变体)。由于在云中运行的应用程序通常依赖于许多外部配置源、服务查找等,引导上下文的目的就是配置这些功能并从外部获取所有必要的配置。
为了清楚地将应用程序配置与 Bootstrap 分开,我们将描述应用程序的内容、配置外部配置或其他环境变量(如服务发现调用位置)放入bootstrap.properties而不是application.properties。在我们的示例中,我们将spring.application.name配置放入bootstrap.properties,因为该信息将在 Bootstrap 阶段需要;它可以用于从远程配置存储中查找配置。
由于 Bootstrap 应用程序上下文确实是一个真实的 Spring 应用程序上下文,因此两者之间存在父子关系,其中 Bootstrap 应用程序上下文成为 Spring Boot 应用程序上下文的父级。这意味着 Bootstrap 上下文中定义的所有 bean 和属性源都可以在应用程序上下文中使用。
当 Spring Cloud 添加到应用程序中时,它会自动提供特定 Spring Cloud 模块(如 Spring Cloud Consul)的集成框架,通过现在众所周知的spring.factories配置声明进行插入。spring-cloud-commons内部提供的注解,即@SpringCloudApplication、@EnableDiscoveryClient、@EnableCircuitBreaker,以及由spring-cloud-context库提供的@BootstrapConfiguraion和PropertySourceLocator接口,旨在定义用于自配置特定组件(如 Consul 这样的发现客户端、Hystrix 这样的断路器或 ZooKeeper 这样的远程配置源)的集成点。
让我们详细检查这些内容:
-
@SpringCloudApplication: 这个注解类似于@SpringBootApplication,本质上是一个元注解,除了它还包装了@EnableDiscoveryClient和@EnableCircuitBreaker注解,并且还用@SpringBootApplication进行了元注解。当您想在应用程序中启用发现客户端和断路器功能时,使用此注解是一个好主意。 -
@EnableDiscoveryClient: 这个注解用于指示 Spring Cloud 应该初始化提供的发现客户端以进行服务注册,具体取决于包含的集成库,例如 Consul、Eureka、ZooKeeper 等。 -
@EnableCircuitBreaker: 这个注解用于指示 Spring Cloud 应该根据特定集成库的依赖初始化断路器功能,例如 Hystrix。 -
PropertySourceLocator: 集成库使用此接口来实现从提供的数据存储中提取远程配置的特定功能。每个提供加载远程配置能力的集成模块都会注册一个实现此类型的 bean,该 bean 公开一个由集成支持的PropertySource实现。 -
@BootstrapConfiguration: 这个注解类似于@ManagementContextConfiguration注解,并且(主要)是一个标记注解,旨在识别spring.factories描述符中的关键部分,以指示在 Spring Cloud Bootstrap 过程中应该加载哪些配置类,并使其成为 Bootstrap 应用程序上下文的一部分。这些配置在启动时由BootstrapApplicationListener读取并初始化指定的配置。通常,这就是配置类被配置的地方,这些类定义并暴露实现PropertySourceLocator的 bean。
使用 Spring Cloud Consul 进行服务发现
在分布式计算的世界中,服务成为可丢弃的商品是非常常见的。服务的典型生命周期可能是几天,甚至可能只有几个小时,一个实例由于任何原因崩溃,然后几秒钟后自动启动一个新实例,这种情况并不罕见。当应用程序的状态如此短暂时,维护一个静态连接的架构就变得非常困难,因为服务知道它们依赖的服务确切位置,而拓扑结构总是在变化。
为了帮助解决这个问题,服务发现层就派上用场了,它维护着集中式和分布式的服务注册状态,随时准备根据需求提供最新的信息。应用程序在启动时会注册自己,提供有关其位置的信息,以及可能有关其能力、服务水平、健康检查状态等信息。
在本书的早期部分,在第六章第六章,应用程序打包和部署中,我们介绍了 Consul,并使用它进行外部应用程序配置消费。在本食谱中,我们将进一步探讨 Consul 的功能,并学习如何使用spring-cloud-consul模块自动将我们的应用程序注册到 Consul 中。
如何实现...
查看以下步骤以设置服务发现:
- 通过修改位于我们项目根目录下的
build.gradle文件,将spring-cloud-commons和spring-cloud-context模块替换为spring-cloud-starter-consul-all,内容如下:
...
dependencies {
...
compile("io.dropwizard.metrics:metrics-graphite:3.1.0")
compile("org.springframework.boot:spring-boot-devtools")
//compile("org.springframework.cloud:spring-cloud-context")
//compile("org.springframework.cloud:spring-cloud-commons")
compile("org.springframework.cloud:spring-cloud-starter-consul-all")
runtime("com.h2database:h2")
...
}
...
- 在添加了 Consul 依赖项后,我们将通过修改位于项目根目录下的
src/main/java/com/example/bookpub目录中的BookPubApplication.java文件,使我们的应用程序在启动时自动注册到本地代理,内容如下:
...
@EnableScheduling
@EnableDbCounting
@EnableDiscoveryClient
public class BookPubApplication {
...
}
- 由于 Consul 已成功安装,如第六章第六章中设置 Consul食谱中所述的步骤,我们应该能够通过运行
consul agent -server -bootstrap-expect 1 -data-dir /tmp/consul来启动它,并且我们的终端窗口应该显示以下输出:
==> Starting Consul agent...
==> Starting Consul agent RPC...
==> Consul agent running!
Version: 'v1.0.2'
...
-
在 Consul 代理成功启动并运行后,我们将通过运行
./gradlew clean bootRun来启动我们的应用程序。 -
当我们查看启动日志滚动时,有几个有趣的条目表明应用程序正在与代理交互,因此请关注日志中的以下内容:
...
2017-12-26 --- b.c.PropertySourceBootstrapConfiguration : Located property source: CompositePropertySource [name='consul', propertySources=[ConsulPropertySource [name='config/BookPub-ch9/'], ConsulPropertySource [name='config/application/']]]
...
2017-12-26 --- o.s.c.consul.discovery.ConsulLifecycle : Registering service with consul: NewService{id='BookPub-ch9-8080', name='BookPub-ch9', tags=[], address='<your_machine_name>', port=8080, check=Check{script='null', interval=10s, ttl=null, http=http://<your_machine_name>:8081/health, tcp=null, timeout=null}}
2017-12-26 --- o.s.c.consul.discovery.ConsulLifecycle : Registering service with consul: NewService{id='BookPub-ch9-8080-management', name='BookPub-ch9-management', tags=[management], address='://<your_machine_name>', port=8081, check=Check{script='null', interval=10s, ttl=null, http=http://chic02qv045g8wn:8081/health, tcp=null, timeout=null}}
...
- 为了验证我们的应用程序已注册并且与本地 Consul 代理进行通信,请在浏览器中打开
http://localhost:8081/actuator/consul来查看 Consul 代理信息,如下面的截图所示:![]()
它是如何工作的...
当我们将 spring-cloud-starter-consul-all 作为构建依赖项添加时,它会自动拉取所有必要的组件以启用应用程序的 Consul 功能。我们自动获得了 spring-cloud-consul-binder、spring-cloud-consul-core、spring-cloud-consul-config 和 spring-cloud-consul-discovery 工件添加到我们的类路径中。让我们来看看它们:
-
spring-cloud-consul-core: 此工件提供了基本的自动配置,以暴露通用的ConsulProperties,以及如果启用了 Spring Boot Actuator 功能,则初始化和设置/consul管理端点。 -
spring-cloud-consul-config: 这提供了ConsulPropertySourceLocator的实现,在引导过程中使用,以配置ConsulPropertySourcebean,允许从 Consul 键值存储中消费远程配置。它还设置了一个ConfigWatch变更观察者,如果在应用程序运行时 Consul 键值存储中的配置键值发生变化,则会触发RefreshEvent到应用程序上下文。这允许在无需重新部署和重启应用程序的情况下重新加载配置属性。 -
spring-cloud-consul-discovery: 这提供了服务发现、服务注册和服务调用的所有功能和实现。 -
spring-cloud-consul-binder: 这提供了 Consul 事件功能与 Spring Cloud Stream 框架的集成,使其能够从 Consul 发送和接收事件,并在应用程序内对它们做出响应。虽然这超出了本章的范围,但可以从cloud.spring.io/spring-cloud-stream/获取更多信息。
当将 spring-cloud-consul-config 添加到类路径时,会自动注册 ConsulPropertySource,但对于 spring-cloud-consul-discovery 模块则不是这样。服务发现功能更为侵入性,因此需要开发人员额外一步确认,以表明确实需要它。这是通过在主应用程序类中添加 @EnableDiscoveryClient 注解来实现的;在我们的例子中是 BookPubApplication。
一旦添加了@EnableDiscoveryClient注解,Spring Cloud(更确切地说,是来自spring-cloud-commons模块的EnableDiscoveryClientImportSelector类)将扫描所有spring.factories文件以查找org.springframework.cloud.client.discovery.EnableDiscoveryClient键的存在,并将所有相关配置加载到主应用程序上下文中。如果我们查看位于spring-cloud-consul-discovery JAR 文件下的META-INF/目录中的spring.factories文件,我们将看到以下条目:
# Discovery Client Configuration
org.springframework.cloud.client.discovery.EnableDiscoveryClient=\
org.springframework.cloud.consul.discovery.ConsulDiscoveryClientConfiguration
这告诉我们,当启用发现客户端时,ConsulDiscoveryClientConfiguration将被消费,并且所有定义的 bean 都将添加到应用程序上下文中。
如果使用自定义服务发现机制,可以使用类似的方法。需要创建一个自定义配置类,公开DiscoveryClient接口的自定义实现,并在存档中包含的spring.factories文件中进行配置。一旦加载该 JAR,如果启用了发现客户端功能,配置将自动消费。
Spring Cloud Consul 库提供了非常细粒度的配置和选择所需功能的能力,如果不是所有功能都适用于特定用例。有关各种配置和使用选项的详细信息,请参阅cloud.spring.io/spring-cloud-consul/。
使用 Spring Cloud Netflix – Feign
在上一个菜谱中,我们探讨了如何为我们的应用程序启用服务发现能力,以便能够将我们的服务注册到世界上,以及了解其他服务存在的地方。这个菜谱将帮助我们更好地与这些信息互动,并消费这些服务,而无需显式编写任何处理服务发现及其相关问题的逻辑。
为了实现这个目标,我们将查看另一个由 Spring Cloud Netflix 模块家族提供的 Spring Cloud 集成——Netflix Feign。Feign 简化了将服务 API 调用绑定到相应的 HTTP API 对应方的过程。它提供了自动服务映射和发现,能够将 Java 类型转换为 HTTP 请求 URL 路径、参数和响应负载,以及错误处理。
为了简化,在这个菜谱中,我们将创建一个Client控制器,它将充当我们的BookPub应用程序服务的客户端,通过 Feign 注解的 Java 服务接口调用我们的 API,依赖 Consul 提供服务发现功能。
如何做到这一点...
- 我们将首先将 Netflix Feign 模块依赖项添加到我们的项目中。让我们修改位于我们项目根目录的
build.gradle文件,内容如下:
dependencies {
...
compile("org.springframework.cloud:spring-
cloud-starter-consul-all")
compile("org.springframework.cloud:spring-
cloud-starter-openfeign")
runtime("com.h2database:h2")
...
}
-
在添加了依赖项后,我们的下一步是创建一个 Java API 接口,描述我们想要如何定义与
BookPub服务的交互。让我们在项目根目录下的src/main/java/com/example/bookpub目录中创建一个api包。 -
在新创建的
api包中,让我们创建一个名为BookPubClient.java的 API 类文件,其内容如下:
package com.example.bookpub.api;
import com.example.bookpub.entity.Book;
import org.springframework.cloud.netflix.feign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@FeignClient("http://BookPub-ch9")
public interface BookPubClient {
@RequestMapping(value = "/books/{isbn}",
method = RequestMethod.GET)
public Book findBookByIsbn(@PathVariable("isbn") String isbn);
}
- 在我们定义了 API 之后,是时候告诉我们的应用程序我们想要启用 Feign 支持了。我们将通过修改项目根目录下的
src/main/java/com/example/bookpub目录中的BookPubApplication.java文件来实现这一点,其内容如下:
...
@EnableDiscoveryClient
@EnableFeignClients
public class BookPubApplication {...}
- 最后,让我们在项目根目录下的
src/main/java/com/example/bookpub/controllers目录中创建一个名为ClientController.java的新文件,以通过调用BookPubClient来创建客户端控制器,其内容如下:
...
@RestController
@RequestMapping("/client")
public class ClientController {
@Autowired
private BookPubClient client;
@RequestMapping(value = "/book/{isbn}",
method = RequestMethod.GET)
public Book getBook(@PathVariable String isbn) {
return client.findBookByIsbn(isbn);
}
}
- 一切设置和完成后,让我们通过执行
./gradlew clean bootRun命令来启动应用程序。
确保 Consul 代理也在后台运行,否则服务注册将失败。
-
一旦应用程序启动并运行,让我们在浏览器中打开
http://localhost:8080/client/book/978-1-78528-415-1来查看 Consul 代理信息,如下所示:![]()
-
如果我们查看应用程序控制台日志,我们还将看到表示我们的 Feign 客户端已初始化并正常工作的条目。你应该看到类似以下内容:
2017-12-26 --- c.n.u.concurrent.ShutdownEnabledTimer : Shutdown hook installed for: NFLoadBalancer-PingTimer-BookPub-ch9
2017-12-26 --- c.netflix.loadbalancer.BaseLoadBalancer : Client:BookPub-ch9 instantiated a LoadBalancer:DynamicServerListLoadBalancer:{NFLoadBalancer:name=BookPub-ch9,current list of Servers=[],Load balancer stats=Zone stats: {},Server stats: []}ServerList:null
2017-12-26 --- c.n.l.DynamicServerListLoadBalancer : Using serverListUpdater PollingServerListUpdater
2017-12-26 --- c.netflix.config.ChainedDynamicProperty : Flipping property: BookPub-ch9.ribbon.ActiveConnectionsLimit to use NEXT property: niws.loadbalancer.availabilityFilteringRule.activeConnectionsLimit = 2147483647
2017-12-26 --- c.n.l.DynamicServerListLoadBalancer : DynamicServerListLoadBalancer for client BookPub-ch9 initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=BookPub-ch9,current list of Servers=[192.168.1.194:8080],Load balancer stats=Zone stats: {unknown=[Zone:unknown; Instance count:1; Active connections count: 0; Circuit breaker tripped count: 0; Active connections per server: 0.0;]
},Server stats: [[Server:192.168.1.194:8080; Zone:UNKNOWN; Total Requests:0; Successive connection failure:0; Total blackout seconds:0; Last connection made:Wed Dec 31 18:00:00 CST 1969; First connection made: Wed Dec 31 18:00:00 CST 1969; Active Connections:0; total failure count in last (1000) msecs:0; average resp time:0.0; 90 percentile resp time:0.0; 95 percentile resp time:0.0; min resp time:0.0; max resp time:0.0; stddev resp time:0.0]
]}ServerList:ConsulServerList{serviceId='BookPub-ch9', tag=null}
- 最后一件我们应该做的事情是让我们的测试与所有新添加的框架一起工作。因为 Spring Cloud 不会将自己添加到测试生命周期中,所以我们应该明确禁用在测试期间对由 Spring Cloud 库创建的 bean 的任何依赖。为此,让我们向项目根目录下的
src/test/resources目录中的application.properties文件添加以下属性:
spring.cloud.bus.enabled=false
spring.cloud.consul.enabled=false
spring.cloud.consul.discovery.enabled=false
eureka.client.enabled=false
autoconfigure.exclude=com.example.bookpub.
MonitoringConfiguration.class
- 我们还需要在
src/test/java/com/example/bookpub目录下的JpaAuthorRepositoryTests.java和WebMvcBookControllerTests.java文件中添加一个对BookPubClient的 Mock 依赖,其内容如下:
@MockBean
private BookPubClient client;
它是如何工作的...
与前一个配方中看到的情况类似,在主应用程序类 BookPubApplication 上使用 @EnableFeignClients 注解,明确告诉 Spring Cloud 它应该扫描所有带有 @FeignClient 注解的接口,并根据它们的定义创建服务客户端实现。@EnableFeignClients 注解在本质上与 @ComponentScan 注解相似,提供属性来控制要扫描哪些包以查找带有 @FeignClient 注解的类或显式列出应使用的 API 类。
默认情况下,所有 Feign 客户端实现都是使用在FeignClientsConfiguration类中定义的组件进行配置的,但可以通过@EnableFeignClients注解的defaultConfiguration属性提供替代配置类。
简而言之,每个带有@FeignClient注解的接口定义都会获得一个由 Java 动态代理对象组成的实现,该对象处理所有接口方法调用(通常使用FeignInvocationHandler来处理所有请求)。调用处理器负责做一些事情。
一旦调用任何方法,首先使用提供的发现客户端(在我们的例子中是ConsulDiscoveryClient)根据@FeignClient注解的name属性定位服务实例。在我们的例子中,我们已将name属性的值声明为http://BookPub-ch9,因此所有名称设置为BookPub-ch9的注册服务实例都将作为可能的候选返回。这个名称可以是服务名称本身,或者,正如我们在我们的例子中所做的那样,可以指定一个可选的协议。这是一个有用的功能,因为并非所有服务发现提供者都支持指定确切的服务调用方式,因此如果我们想使用 HTTPS 进行安全调用,我们可以明确指定协议以帮助 Feign 进行正确的调用。
注解上还有许多其他配置属性可用,例如,为了告诉 Feign 直接调用指定的 URL 而不是进行服务查找,有一个可以配置的url属性。
要查看可能的属性及其用例的完整列表,请访问cloud.spring.io/spring-cloud-netflix/single/spring-cloud-netflix.html#spring-cloud-feign。
对于给定服务的实例列表,会被另一个 Netflix 库——Ribbon 提供的内部负载均衡器所包装。它使用指定的算法在服务实例之间进行轮询,并在发现客户端表示它们不健康时将不良实例从循环中移除。
要查看有关负载均衡规则和其他设置的配置选项的完整列表,请访问cloud.spring.io/spring-cloud-netflix/single/spring-cloud-netflix.html#spring-cloud-ribbon。
当确定特定的实例后,会创建一个 HTTP 请求,使用标准的 Spring HttpMessageConverter bean 将方法参数转换为 HTTP 请求路径变量和查询参数。完成所有这些后,请求通过配置的 HTTP 客户端发送,并将响应转换为 API 接口上声明的返回类型,使用相同的转换器进行转换。
现在我们已经了解了@FeignClient注解是什么以及一旦 API 定义的方法被调用,幕后会发生什么,让我们看看如何注解应该转换为远程服务调用的接口方法。方便的是,并且是有意为之,我们可以使用与我们在@Controller注解的类中声明控制器映射时相同的注解。我们想要映射到远程服务的 API 接口中的每个方法都应该用@RequestMapping注解。path属性对应于我们想要调用的远程服务的 URL 路径。
在我们的示例中,我们想要调用BookController.getBook(...)方法,这对应于/books/{isbn}URL 路径。这正是我们为path属性设置的值,并确保我们在findBookByIsbn(...)方法中用@PathVariable("isbn")注解isbn参数,以将其链接到映射模板中的{isbn}占位符。
作为一般规则,@RequestMapping注解的功能与在控制器中使用时完全相同,只是配置与出站请求相关,而不是入站请求。当配置注解的consumes属性时,可能会特别令人困惑,即consumes = "application/json",因为它表示远程端期望 JSON 作为负载的内容类型。
使用 Spring Cloud Netflix 进行服务发现 – Eureka
我们已经看到了如何使用 HashiCorp Consul 进行服务发现并将其集成到我们的应用程序中。这个配方将介绍一个替代方案,一个非常流行的来自 Netflix 的 Eureka 服务发现框架。Eureka 是由 Netflix 开发的,旨在帮助解决他们在 AWS 中 RESTful 服务的服务发现、健康检查和负载平衡问题。
与 Consul 不同,Eureka 专注于服务发现的任务,并不提供许多额外的功能,例如键值存储服务或事件传递。然而,它在所做的事情上非常出色,应该被视为一个可行的服务发现解决方案。
如何操作...
在我们添加 Eureka 到我们的应用程序的步骤之前,我们需要启动并运行 Eureka 服务本身。幸运的是,Spring Cloud 团队已经足够出色,提供了一个示例项目,使得创建 Eureka 服务器实例并运行它变得非常简单。让我们看看以下步骤:
-
要启动并运行,请访问
github.com/spring-cloud-samples/eureka并使用 git clone 命令git@github.com:spring-cloud-samples/eureka.git将仓库克隆到您的机器上。 -
完成这些后,运行
./gradlew clean bootRun以启动服务器: -
一旦服务器启动并运行,我们需要将以下依赖项添加到位于我们项目根目录的
build.gradle文件中:
//compile("org.springframework.cloud:spring-cloud-starter-consul-all")
compile("org.springframework.cloud:spring-cloud-starter-feign")
compile("org.springframework.cloud:spring-cloud-starter-eureka-client")
- 具有讽刺意味的是,我们此时只需执行
./gradlew clean bootRun命令重新启动我们的应用程序。
确保 Eureka 服务器在后台运行,否则,尽管应用程序会启动,但 BookPubClient 调用将会失败。
-
一旦应用程序启动并运行,让我们在浏览器中打开
http://localhost:8080/client/book/978-1-78528-415-1,我们应该看到与之前配方中完全相同的响应。 -
只为了确认我们的应用程序确实已注册到 Eureka,我们可以在
http://localhost:8761URL 打开浏览器,我们应该在我们的服务列表下看到我们的服务:

它是如何工作的...
通过看似无力的改变,我们已经将一个服务发现提供者,Consul,切换为另一个,Eureka。表面上看起来变化不大,但实际上在底层做了很多工作。我们之所以能够如此轻松地做到这一点,是因为 spring-cloud-commons 和 spring-cloud-context 基础库提供的公共 API 集合。通过 spring.factories 描述符提供的自动模块加载支持,允许在初始化不同的服务发现提供者时进行透明替换。只要我们在 BookPubApplication 类上保留 @EnableDiscoveryClient 注解,Spring Cloud 就会承担繁重的工作,负责加载适当的自动配置文件并设置所有正确的豆(Beans)以使我们的应用程序能够与 Eureka 一起工作。
我们不得不在配方步骤的第一步就移除 Consul 依赖,这样做是为了消除 DiscoveryClient 实现的不确定性。如果不这样做,我们的应用程序上下文最终会包含 DiscoveryClient 接口的不同实现,这本身并不是什么坏事,除非 Spring Cloud 需要消除歧义并选择一个,而且可能不会选择我们想要的那个。
如果我们在 build.gradle 文件中留下 spring-cloud-starter-consul-all 依赖,并尝试运行应用程序,它将在启动时失败,并在日志中看到以下条目:
WARN 5592 --- [ restartedMain] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jmxMBeanExporter' defined in class path resource [org/springframework/boot/actuate/autoconfigure/endpoint/jmx/JmxEndpointAutoConfiguration.class]: Invocation of init method failed; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.cloud.client.serviceregistry.ServiceRegistryAutoConfiguration$ServiceRegistryEndpointConfiguration': Unsatisfied dependency expressed through field 'registration'; nested exception is org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'org.springframework.cloud.client.serviceregistry.Registration' available: expected single matching bean but found 2: eurekaRegistration,consulRegistration
如您从异常中可以看到,Spring 自动装配无法决定应该使用哪个服务注册表。这是因为 Eureka 和 Consul 都自动创建了一个 Registration 实例,而自动装配只需要一个。
由于只有一个注册表是硬性要求,因此最好不要配置多个发现客户端依赖库以避免错误。如果由于某种原因,多个库必须存在于类路径中,则应使用配置属性显式启用/禁用特定的客户端实现。例如,Consul 和 Eureka 都提供了配置来切换状态。如果我们更喜欢使用 Consul 提供服务发现功能,我们可以在 application.properties 中设置 spring.cloud.consul.discovery.enabled=true 和 eureka.client.enabled=false。
使用 Spring Cloud Netflix – Hystrix
在本章中,我们探讨了所有适用于在云环境中成功运行微服务应用的所有方面。我们学习了如何更好地集成到一个动态变化的生态系统,消费远程配置属性,注册服务,以及发现和调用其他服务。在本食谱中,我们将探讨在分布式、高度易变的云环境中操作的一个非常重要的方面——断路器。
我们将要探讨的断路器功能的具体实现是 Netflix Hystrix。它提供了一种非常强大且方便的方式来注释我们的服务调用,并处理诸如远程服务故障、队列备份、过载、超时等问题。通过在应用程序中实现断路器,开发人员可以确保如果特定的服务端点因请求过载或任何类型的故障而出现问题时,整体应用程序的稳定性。
如何做到这一点...
- 要开始使用 Hystrix,我们需要将
spring-cloud-starter-hystrix库添加到我们的项目中。让我们修改位于项目根目录下的build.gradle文件,内容如下:
dependencies {
...
compile("org.springframework.cloud:
spring-cloud-starter-consul-all")
compile("org.springframework.cloud:
spring-cloud-starter-openfeign")
compile("org.springframework.cloud:
spring-cloud-starter-eureka-client")
compile("org.springframework.cloud:
spring-cloud-starter-netflix-hystrix")
runtime("com.h2database:h2")
runtime("mysql:mysql-connector-java")
...
}
- 在添加 Hystrix 依赖项后,我们需要为我们的应用程序启用 Hystrix。类似于我们启用服务发现的方式,我们将通过修改位于项目根目录下的
src/main/java/com/example/bookpub目录中的BookPubApplication.java文件来实现这一点。
...
@EnableDiscoveryClient
@EnableFeignClients
@EnableCircuitBreaker
public class BookPubApplication {...}
- 现在,让我们对位于项目根目录下的
src/main/java/com/example/bookpub/controllers目录中的BookController.java文件进行一些修改:
@RequestMapping(value = "", method = RequestMethod.GET)
@HystrixCommand(fallbackMethod = "getEmptyBooksList")
public Iterable<Book> getAllBooks() {
//return bookRepository.findAll();
throw new RuntimeException("Books Service Not Available");
}
public Iterable<Book> getEmptyBooksList() {
return Collections.emptyList();
}
...
- 由于 Hystrix 的内部功能,我们还需要修改我们的实体模型,以便它们能够预加载关系关联。在项目根目录下的
src/main/java/com/example/bookpub/entity目录中的Author.java、Book.java和Publisher.java文件,让我们进行以下修改:
- 在
Author.java文件中,进行以下修改:
@OneToMany(mappedBy = "author", fetch = FetchType.EAGER)
private List<Book> books;
- 在
Book.java文件中,进行以下修改:
@ManyToOne(fetch = FetchType.EAGER)
private Author author;
@ManyToOne(fetch = FetchType.EAGER)
private Publisher publisher;
@ManyToMany(fetch = FetchType.EAGER)
private List<Reviewer> reviewers;
- 在
Publisher.java文件中,进行以下修改:
@OneToMany(mappedBy = "publisher", fetch = FetchType.EAGER)
private List<Book> books;
-
最后,我们准备好通过执行
./gradlew clean bootRun命令来重新启动我们的应用程序。 -
当应用程序启动后,让我们在浏览器中打开
http://localhost:8080/books,我们应该看到一个空白的 JSON 列表作为结果:

它是如何工作的...
在这个配方中,我们在将 Hystrix 依赖库添加到我们的项目后做了三件事。因此,让我们详细查看每个步骤,以了解确切发生了什么:
-
@EnableCircuitBreaker注解与@EnableDiscoveryClient或@EnableFeignClients类似,它明确表示我们希望 Spring Cloud 从所有具有org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker键定义的库中加载适当的配置。 -
在 Hystrix 的情况下,它会加载
HystrixCircuitBreakerConfiguration,这为在应用程序内启用 Hystrix 功能提供了必要的配置。它创建的一个 bean 是HystrixCommandAspect类。它的目的是检测所有被@HystrixCommand注解的方法,并将它们包装在一个处理程序中,以检测错误、超时和其他不良行为,并根据配置适当地处理它们。 -
这个由 Hystrix 库提供的
@HystrixCommand注解旨在标记代表Hystrix-guarded commands的方法,即我们希望使用 Hystrix 来保护免受级联故障和过载的方法。这个注解有几个属性,可以根据期望的行为以多种不同的方式进行配置。 -
在我们的例子中,我们使用了最典型的属性——
fallbackMethod,它允许我们配置一个具有匹配签名的替代方法,如果实际方法由于任何原因失败,则可以自动调用它。这是主要用例,它提供了使用合理的默认值来指定服务优雅降级的可能性,而不是将异常向上抛出。 -
我们使用它将失败的调用重定向到
getEmptyBooksList()方法,该方法返回一个静态的空列表。这样,当实际的getAllBooks()方法失败时,我们可以优雅地降级并返回一个空集合,这作为响应 JSON 看起来很漂亮。在确实希望将特定类型的异常传播到堆栈上的情况下,我们可以使用ignoreExceptions属性显式配置这些,并将其设置为所需的异常类。 -
要配置特定命令的断路器行为,我们可以使用
commandProperties或threadPoolProperties属性设置多个不同的选项。在那里,我们可以设置诸如执行超时、备份队列大小等许多其他设置。
要查看可用的完整属性列表,请参阅github.com/Netflix/Hystrix/tree/master/hystrix-contrib/hystrix-javanica#configuration。
最后要讨论的一件事是我们对实体模型所做的修改,将关系关联注解的fetch设置为FetchType.EAGER。我们之所以必须这样做,是因为 Hibernate 处理关联加载的方式。默认情况下,这些是使用FetchType.LAZY设置加载的,这意味着 Hibernate 只会建立关系,但数据的加载将不会发生在 getter 方法被调用之前。使用 Hystrix 时,默认情况下这可能会导致类似以下错误:
failed to lazily initialize a collection of role: com.example.bookpub.entity.Book.reviewers, could not initialize proxy - no Session (through reference chain: com.example.bookpub.entity.Publisher["books"]->org.hibernate.collection.internal.PersistentBag[0]->com.example.bookpub.entity.Book["reviewers"])
这是因为 Hystrix 默认使用ThreadPool来执行方法调用,并且由于延迟加载的数据需要在调用时访问数据存储,Hibernate 需要存在一个活跃的会话来处理请求。由于 Hibernate 将会话存储在ThreadLocal中,因此在 Hystrix 在调用期间使用的池化执行线程中显然不存在。
一旦我们将获取方式改为懒加载,所有数据将在原始 Hibernate 线程中的仓库交互时加载。我们可以选择配置我们的@HystrixCommand注解以使用相同的执行线程,如下所示:
commandProperties = {
@HystrixProperty(name="execution.isolation.strategy",
value="SEMAPHORE")
}
虽然 Hystrix 强烈建议使用默认的THREAD策略,但在我们绝对需要驻留在相同的调用线程的情况下,SEMAPHORE可以帮助我们。
或者,我们可以在application.properties文件中使用hystrix.command.default.execution.isolation.strategy=SEMAPHORE设置相同的配置,或者如果我们只想为特定的@HystrixCommand进行配置,我们可以使用commandKey属性的值,默认情况下是该注解方法的名称,而不是属性名称的默认部分。对于我们的BookController方法的具体示例,配置键将看起来像hystrix.command.getAllBooks.execution.isolation.strategy=SEMAPHORE。这是由于 Spring Cloud-Netflix Archaius 桥的存在,它使得所有 Spring 环境属性对 Archaius 配置管理器可见,因此所有 Netflix 组件都可以访问。
Spring Cloud Hystrix 集成还提供了一个/hystrix.stream操作端点,该端点可以被 Hystrix 仪表板消费,用于可视化应用程序中所有断路器的状态。
为了快速启动仪表板,Spring Cloud 提供了一个示例应用程序,可以在github.com/spring-cloud-samples/hystrix-dashboard找到:

同样的流也可以输入到Netflix Turbine Stream Aggregator,可在github.com/Netflix/Turbine下载,用于跨多个实例的数据聚合,之后可以使用相同的仪表板进行可视化。
还可以使用spring-cloud-starter-turbine依赖库和@EnableTurbine注解在一个基本的 Spring Boot 应用中,类似于 Hystrix 仪表板示例。










浙公网安备 33010602011771号