Spring5-项目-全-
Spring5 项目(全)
原文:
zh.annas-archive.org/md5/f612f4826ccbf1c2840de1bc530d1904译者:飞龙
前言
Spring 使得创建 RESTful 应用、与社交服务交互、与现代化数据库通信、保护系统安全以及使代码模块化和易于测试变得简单。这本书将向您展示如何使用 Spring 5.0 的各个特性和第三方工具构建各种项目。
本书面向对象
这本书是为希望了解如何使用 Spring 开发复杂且灵活的应用的合格 Spring 开发者所写。您必须具备良好的 Java 编程知识,并熟悉 Spring 的基础知识。
本书涵盖内容
第一章,创建一个列出世界国家和它们 GDP 的应用,是关于启动基于 Spring 的 Web 应用开发的。我们将专注于使用 Spring MVC、Spring Data 和世界银行 API 为不同国家的一些统计数据以及 MySQL 数据库创建一个 Web 应用。应用的核心数据将来自 MySQL 附带的世界数据库样本。这个应用的 UI 将由 Angular.js 提供。我们将遵循 WAR 模型进行应用部署,并在最新版本的 Tomcat 上部署。
第二章,构建响应式 Web 应用,是关于纯使用 Spring 的新 WebFlux 框架构建 RESTful Web 服务应用。Spring WebFlux 是一个新的框架,它以函数式方式帮助创建响应式应用。
第三章,Blogpress – 一个简单的博客管理系统,是关于创建一个简单的基于 Spring Boot 的博客管理系统,该系统使用 Elasticsearch 作为数据存储。我们还将使用 Spring Security 实现用户角色管理、认证和授权。
第四章,构建中央认证服务器,是关于构建一个将作为认证和授权服务器的应用。我们将使用 OAuth2 协议和 LDAP 构建一个支持认证和授权的中央应用。
第五章,使用 JHipster 查看国家和它们的 GDP 应用,回顾了我们在第一章中开发的应用,创建一个列出世界国家和它们 GDP 的应用,我们将使用 JHipster 开发相同的应用。JHipster 帮助开发 Spring Boot 和 Angular.js 的生产级应用,我们将探索这个平台并了解其特性和功能。
第六章,创建在线书店,是关于通过以分层方式开发 Web 应用来创建一个销售书籍的在线商店。
第七章,使用 Spring 和 Kotlin 的任务管理系统,探讨了如何使用 Spring 框架和 Kotlin 构建任务管理系统。
要充分利用本书
在阅读本书之前,需要具备 Java、Git 和 Spring 框架的良好理解。虽然希望有深入的对象导向编程知识,但前两章中回顾了一些关键概念。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书名,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:
-
Windows 的 WinRAR/7-Zip
-
Mac 的 Zipeg/iZip/UnRarX
-
Linux 的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Spring 5.0 Projects。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/上找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781788390415_ColorImages.pdf。
代码实战
访问以下链接查看代码运行的视频:bit.ly/2ED57Ss
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“上一行必须在<Host></Host>标签之间添加。”
代码块设置如下:
<depedency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
<depedency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
任何命令行输入或输出应如下编写:
$ mvn package
粗体: 表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“下载 STS,将其解压缩到你的本地文件夹中,然后打开.exe文件以启动 STS。启动后,创建一个具有以下属性的 Spring Starter Project,类型为 Spring Boot。”
警告或重要提示看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈: 如果你对此书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送邮件。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这个错误。请访问www.packt.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。
如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com。
评论
请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?潜在的读者可以看到并使用你的无偏见意见来做出购买决定,Packt 公司可以了解你对我们的产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问packt.com。
第一章:创建一个列出世界各国及其 GDP 的应用程序
Spring 是一个促进基于 JVM 的企业应用程序开发的生态系统。这是通过 Spring 提供的各种模块实现的。其中之一,称为 Spring-core,是 Spring 生态系统中的框架核心,它提供了依赖注入、Web 应用程序、数据访问、事务管理、测试等方面的支持。
在本章中,我们将从头开始,使用 Spring 框架开发一个简单的应用程序。不需要熟悉 Spring 框架,我们将在本章结束时确保您对 Spring 框架的使用有足够的信心。
本章将涵盖以下主题:
-
应用程序简介
-
理解数据库结构
-
理解世界银行 API
-
设计线框图
-
创建一个空的应用程序
-
定义模型类
-
定义数据访问层
-
定义 API 控制器
-
部署到 Tomcat
-
定义视图控制器
-
定义视图
技术要求
本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Spring-5.0-Projects/tree/master/chapter01。代码可以在任何操作系统上执行,尽管它只在 Windows 上进行了测试。
应用程序简介
我们将开发一个应用程序来展示各个国家的 GDP 信息。我们将利用 MySQL 提供的示例 World DB(dev.mysql.com/doc/world-setup/en/world-setup-installation.html)来列出国家,并获取详细视图以显示国家信息及其从世界银行 API(datahelpdesk.worldbank.org/knowledgebase/articles/898599-api-indicator-queries)获取的 GDP 信息。
列表将使用 World DB 中可用的国家数据。在详细视图中,我们将使用 World DB 中可用的数据来列出城市和语言,并使用世界银行 API 获取额外的信息和该国的 GDP 信息。
我们还将支持编辑国家条目的基本详情,从国家条目中添加和删除城市,以及从国家条目中添加和删除语言。在这个应用程序中,我们将使用以下工具和技术:
-
使用 Spring MVC 框架实现 MVC 模式
-
与 MySQL DB 的交互将使用 Spring JDBC 模板完成
-
与世界银行 API 的交互将使用 RestTemplate 完成
-
视图将使用名为 Thymeleaf 的模板框架创建
-
前端将由 jQuery 和 Bootstrap 驱动
理解数据库结构
如果你没有安装 MySQL,请转到 MySQL 链接(dev.mysql.com/downloads/installer)来安装它,并在它不可用的情况下用世界数据库填充。附录还将指导你如何使用 MySQL Workbench 和 MySQL 命令行工具运行查询。
以下是世界数据库模式的示意图:

数据库模式很简单,包含以下三个表:
-
城市:与国家表中三个字符国家代码相对应的城市列表。
-
国家:国家列表,其中主键是三个字符的国家代码。有一个列包含 ISO 国家代码。
-
国家语言:与国家相对应的语言列表,其中国家的一种语言被标记为官方语言。
理解世界银行 API
世界银行公开了大量的 API(www.worldbank.org/),API 文档可以在以下位置找到(datahelpdesk.worldbank.org/knowledgebase/articles/889386-developer-information-overview)。在可用的 API 中,我们将使用指标 API(datahelpdesk.worldbank.org/knowledgebase/articles/898599-api-indicator-queries),这些 API 代表诸如总人口、GDP、GNI、能源使用等方面的信息。
使用指标 API,我们将获取数据库中过去 10 年可用的国家的 GDP 信息。让我们看看 API 的 REST URL 和 API 返回的数据,如下所示:
GET http://api.worldbank.org/countries/BR/indicators/NY.GDP.MKTP.CD?format=json&date=2007:2017
在此 URL 中,BR是国家代码(巴西)。NY.GDP.MKTP.CD是世界银行 API 内部用于调用指标 API 的标志。请求参数date表示所需 GDP 信息的持续时间。
以下是你将从前一个 API 获取的响应摘录:
[
{
"page": 1,
"pages": 1,
"per_page": "50",
"total": 11
},
[
....// Other country data
{
"indicator": {
"id": "NY.GDP.MKTP.CD",
"value": "GDP (current US$)"
},
"country": {
"id": "BR",
"value": "Brazil"
},
"value": "1796186586414.45",
"decimal": "0",
"date": "2016"
}
]
]
前面的响应显示了 2016 年巴西的 GDP 指标(以美元计)。
设计应用程序屏幕的线框
线框是应用程序或网站的基本骨架。它给出了最终应用程序的外观。它基本上有助于决定导航流程,理解功能,设计用户界面,并在应用程序存在之前设定期望。这个过程极大地帮助开发者、设计师、产品所有者和客户同步工作,避免任何差距。我们将遵循相同的模式,并将设计各种应用程序线框,如下所示。
国家列表页面
我们将使其简单。主页显示带有分页的国家列表,并允许通过国家名称进行搜索以及通过大陆/地区进行筛选。以下将是我们的应用程序的主页:

国家详情页
此屏幕将显示国家的详细信息,如城市、语言和从世界银行 API 获取的 GDP 信息。GDP 数据将以图形视图显示。页面看起来如下:

国家编辑页
在国家列表页,将有一个名为“编辑”的按钮。点击它后,系统将显示编辑模式的国家,允许更新国家的详细信息。以下是国家基本详细信息编辑视图的结构:

添加新的城市和语言
在国家详情页,通过点击“新建”按钮,可以访问两个模态视图,一个用于添加新的城市,另一个用于添加新的语言。以下是用以添加新国家和语言的两个模态对话框的视图。它们将单独打开:

创建一个空的应用程序
我们将使用 Maven 生成一个具有 Java 基于 Web 应用程序所需结构的空应用程序。如果您尚未安装 Maven,请按照以下说明(maven.apache.org/install.html)安装 Maven。安装后,运行以下命令以创建一个空的应用程序:
mvn archetype:generate -DgroupId=com.nilangpatel.worldgdp -DartifactId=worldgdp -Dversion=0.0.1-SNAPSHOT -DarchetypeArtifactId=maven-archetype-webapp
运行前面的命令将显示确认的命令行参数值,如下截图所示:

您必须在之前截图所示的命令提示符中键入Y以完成空项目的创建。现在,您可以将此项目导入您选择的 IDE 中,并继续开发活动。为了简化,我们将使用 Eclipse,因为它是当今 Java 社区中最受欢迎的 IDE 之一。
在成功创建应用程序后,您将看到以下截图所示的文件夹结构:

在创建默认项目结构时,您将默认看到添加的index.jsp。您必须将其删除,因为在我们的应用程序中,我们将使用 Thymeleaf——另一种模板引擎来开发着陆页。
定义模型类
现在,让我们创建 Java 类来模拟数据库中的数据以及来自世界银行 API 的数据。我们的方法很简单。我们将为数据库中的每个表创建一个 Java 类,数据库的列将成为 Java 类的属性。
在生成的应用程序中,main 目录下缺少 java 文件夹。我们将手动创建 java 文件夹,并将 com.nilangpatel.worldgdp 打包,这将作为应用程序的根包。让我们继续实施我们决定的方法。但在那之前,让我们看看一个有趣的项目,名为 Project Lombok。
Project Lombok 提供了用于生成您的获取器、设置器、默认和重载构造函数以及其他样板代码的注解。有关如何与您的 IDE 集成的更多详细信息,可以在他们的项目网站上找到(projectlombok.org/)。
我们需要更新我们的 pom.xml 以包含对 Project Lombok 的依赖项。以下是你需要复制并添加到 XML 中相关位置的 pom.xml 部分内容:
<properties>
<java.version>1.8</java.version>
<lombok.version>1.16.18</lombok.version>
</properties>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<version>${lombok.version}</version>
</dependency>
我们接下来要创建的所有模型类都属于 com.nilangpatel.worldgdp.model 包。以下代码给出了表示 Country 数据的模型类:
@Data
@Setter
@Getter
public class Country {
private String code;
private String name;
private String continent;
private String region;
private Double surfaceArea;
private Short indepYear;
private Long population;
private Double lifeExpectancy;
private Double gnp;
private String localName;
private String governmentForm;
private String headOfState;
private City capital;
private String code2;
}
City 类尚未创建,让我们继续创建它,如下所示:
@Data
@Setter
@Getter
public class City {
private Long id;
private String name;
private Country country;
private String district;
private Long population;
}
接下来是建模 CountryLanguage 类,这是国家使用的语言,如下所示:
@Data
@Setter
@Getter
public class CountryLanguage {
private Country country;
private String language;
private String isOfficial;
private Double percentage;
}
我们还需要一个模型类来映射从世界银行 API 获得的 GDP 信息。让我们继续创建一个 CountryGDP 类,如下所示:
@Data
@Setter
@Getter
public class CountryGDP {
private Short year;
private Double value;
}
在此时刻,一切工作得非常完美。但当您开始在某个其他类中调用这些模型类的获取器和设置器时,您可能会遇到编译错误。这是因为我们需要再进行一步来配置 Lombok。在您定义了 Maven 依赖项后,您将在 IDE 中看到 JAR 引用。只需右键单击它,然后选择“运行 As* |* Java Application”选项。或者,您可以从终端执行以下命令,位置是在 Lombok JAR 文件所在的目录下,如下所示:
java -jar lombok-1.16.18.jar
在这里,lombok-1.16.18.jar 是 JAR 文件名。您将看到一个单独的窗口弹出,如下所示:

通过点击“指定位置...”按钮选择您的 IDE 位置。一旦选择,点击“安装 / 更新”按钮来安装它。您将收到一条成功消息。只需重新启动 IDE 并重新构建项目,您就会看到仅通过定义 @Setter 和 @Getter,实际的设置器和获取器就可供其他类使用。您不再需要显式添加它们。
使用 Hibernate Validator 添加验证
我们需要在模型类中添加一些检查,以确保从 UI 发送的数据不是无效的。为此,我们将使用 Hibernate Validator。您需要添加以下 Hibernate 依赖项:
<properties>
<java.version>1.8</java.version>
<lombok.version>1.16.18</lombok.version>
<hibernate.validator.version>6.0.2.Final</hibernate.validator.version>
</properties>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>${hibernate.validator.version}</version>
</dependency>
现在回到 com.nilangpatel.worldgdp.model.Country 并更新它,如下所示:
@Data public class Country {
@NotNull @Size(max = 3, min = 3) private String code;
@NotNull @Size(max = 52) private String name;
@NotNull private String continent;
@NotNull @Size(max = 26) private String region;
@NotNull private Double surfaceArea;
private Short indepYear;
@NotNull private Long population;
private Double lifeExpectancy;
private Double gnp;
@NotNull private String localName;
@NotNull private String governmentForm;
private String headOfState;
private City capital;
@NotNull private String code2;
}
接下来是更新 com.nilangpatel.worldgdp.model.City 类,方式类似,如下所示:
@Data public class City {
@NotNull private Long id;
@NotNull @Size(max = 35) private String name;
@NotNull @Size(max = 3, min = 3) private String countryCode;
private Country country;
@NotNull @Size(max = 20) private String district;
@NotNull private Long population;
}
最后,更新 com.nilangpatel.worldgdp.model.CountryLanguage 类,如下所示:
@Data
public class CountryLanguage {
private Country country;
@NotNull private String countryCode;
@NotNull @Size(max = 30) private String language;
@NotNull @Size(max = 1, min = 1) private String isOfficial;
@NotNull private Double percentage;
}
定义数据访问层 - Spring JDBC 模板
我们有模型类,它们反映了我们从世界银行 API 获得的数据库中数据的结构。现在我们需要开发一个数据访问层,与我们的 MySQL 交互,并将存储在数据库中的数据填充到模型类的实例中。我们将使用 Spring JDBC 模板来实现与数据库所需的交互。
首先,我们需要 JDBC 驱动程序来连接任何 Java 应用程序与 MySQL。这可以通过向我们的pom.xml添加以下依赖项和版本属性来实现:
<properties>
<java.version>1.8</java.version>
<lombok.version>1.16.18</lombok.version>
<hibernate.validator.version>6.0.2.Final</hibernate.validator.version>
<mysql.jdbc.driver.version>5.1.44</mysql.jdbc.driver.version>
</properties>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.jdbc.driver.version}</version>
</dependency>
无论您看到<something.version>1.5.6</something.version>,它都应放在<properties></properties>标签内。不会重复提及这一点。这是为了将使用的库版本集中在一个地方,便于维护和查找。
任何以<dependency></dependency>开始的依赖项都应放在<dependencies></dependencies>列表中。
现在我们需要将 Spring 核心 APIs 以及 Spring JDBC APIs(其中包含 JDBC 模板)添加到我们的pom.xml中。以下是这两个依赖项的简要介绍:
-
Spring 核心 APIs:它为我们提供了核心的 Spring 功能,如依赖注入和配置模型
-
Spring JDBC APIs:它为我们提供了创建
DataSource实例和与数据库交互所需的 API
由于这是一个示例应用程序,我们没有使用 Hibernate 或其他 ORM 库,因为它们除了基本的 CRUD 操作之外还提供了许多功能。相反,我们将编写 SQL 查询,并使用 JDBC 模板与它们一起使用,以使事情更简单。
以下代码显示了两个库的dependency信息:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>${spring.version}</version>
</dependency>
除了前两个依赖项之外,我们还需要添加一些 Spring 依赖项来帮助我们使用注解(如@bean、@Service、@Configuration、@ComponentScan等)设置基于 Java 的配置,并使用注解进行依赖注入(@Autowired)。为此,我们将添加以下依赖项:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
定义 JDBC 连接属性
我们将在application.properties文件中定义 JDBC 连接属性,并将其放置在src/main/resources中。我们定义的属性如下:
dataSourceClassName=com.mysql.jdbc.Driver
jdbcUrl=jdbc:mysql://localhost:3306/worldgdp
dataSource.user=root
dataSource.password=test
前述属性是基于以下假设:MySQL 运行在端口3306上,数据库用户名和密码分别为root和test。您可以根据本地配置更改这些属性。下一步是定义一个属性解析器,以便在代码中使用时能够解析属性。我们将使用@PropertySource注解,以及PropertySourcesPlaceholderConfigurer的一个实例,如下面的代码所示:
@Configuration
@PropertySource("classpath:application.properties")
public class PropertiesWithJavaConfig {
@Bean
public static PropertySourcesPlaceholderConfigurer
propertySourcesPlaceholderConfigurer() {
return new PropertySourcesPlaceholderConfigurer();
}
}
我们将遵循将所有配置类放在com.nilangpatel.worldgdp.config中,并将任何根配置放在com.nilangpatel.worldgdp包中的约定。
此类从类路径(src/main/resources)中存储的 application.properties 文件读取所有属性。接下来是配置一个 javax.sql.DataSource 对象,该对象将使用 application.properties 文件中定义的属性连接到数据库。我们将使用 HikariCP 连接池库来创建我们的 DataSource 实例。然后,我们将使用此 DataSource 实例来实例化 NamedParameterJdbcTemplate。我们将使用 NamedParameterJdbcTemplate 来执行所有我们的 SQL 查询。在这个阶段,我们需要添加一个必要的依赖项来使用 HikariCP 库,如下所示:
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>${hikari.version}</version>
</dependency>
DBConfiguration 数据源配置类应如下所示:
@Configuration
public class DBConfiguration {
@Value("${jdbcUrl}") String jdbcUrl;
@Value("${dataSource.user}") String username;
@Value("${dataSource.password}") String password;
@Value("${dataSourceClassName}") String className;
@Bean
public DataSource getDataSource() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(jdbcUrl);
ds.setUsername(username);
ds.setPassword(password);
ds.setDriverClassName(className);
return ds;
}
@Bean
public NamedParameterJdbcTemplate namedParamJdbcTemplate() {
NamedParameterJdbcTemplate namedParamJdbcTemplate =
new NamedParameterJdbcTemplate(getDataSource());
return namedParamJdbcTemplate;
}
}
让我们对代码中使用的几个新事物进行简要介绍:
-
@Configuration: 这是用来指示 Spring 框架该类创建包含一些配置的 Java 对象 -
@Bean: 这是一个方法级注解,用于指示 Spring 框架该方法返回由 Spring 框架管理的 Java 对象的生命周期,并将其注入声明其依赖项的地方 -
@Value: 这用于引用在application.properties中定义的属性,这些属性由PropertiesWithJavaConfig类中定义的PropertySourcesPlaceholderConfigurer实例解析
总是编写 JUnit 单元测试是一个好习惯。我们将为我们的应用程序编写测试用例。为此,我们需要创建运行我们的 JUnit 测试的相应配置类。在下一节中,我们将探讨设置测试环境。
设置测试环境
在这里,我们采用先测试的方法。因此,在编写查询和 DAO 类之前,让我们为我们的单元测试设置环境。如果你找不到 src/test/java 和 src/test/resources 文件夹,那么请继续创建它们,无论是从你的 IDE 还是从你的操作系统文件资源管理器中创建。
src/test/java 文件夹将包含所有 Java 代码,而 src/test/resources 将包含测试用例所需的属性文件和其他资源。在创建所需的文件夹后,项目结构看起来就像以下截图所示:

我们将使用 H2 数据库作为测试环境的源数据。为此,我们将更新我们的 Maven 依赖项以添加 H2 和 JUnit 依赖项。H2 是最受欢迎的嵌入式数据库之一。以下是你需要在你的 pom.xml 中添加的依赖项信息:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
</dependency>
我们已经有一个 spring.version 的属性,但我们需要为另外两个版本属性,如下面的代码所示:
<junit.version>4.12</junit.version>
<assertj.version>3.12.0</assertj.version>
<h2.version>1.4.198</h2.version>
在 MySQL 中可用的 World DB 模式与 H2 不兼容,但请放心。与 H2 兼容的 World DB 模式可在本章的源代码中找到,您可以从 GitHub 下载(github.com/PacktPublishing/Spring-5.0-Projects/tree/master/chapter01)。它保存在项目的src/test/resources文件夹中。文件名为h2_world.sql。我们将使用此文件启动我们的 H2 数据库,其中包含所需的表和数据,然后这些数据将在我们的测试中可用。
接下来是配置 H2,我们配置的一项是包含模式和数据的 SQL 脚本文件的名称。此 SQL 脚本文件应在类路径上可用。以下是在src/test/java文件夹下的com.nilangpatel.worldgdp.test.config包中创建的配置类:
@Configuration
public class TestDBConfiguration {
@Bean
public DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.generateUniqueName(true)
.setType(EmbeddedDatabaseType.H2)
.setScriptEncoding("UTF-8")
.ignoreFailedDrops(true)
.addScript("h2_world.sql")
.build();
}
@Bean("testTemplate")
public NamedParameterJdbcTemplate namedParamJdbcTemplate() {
NamedParameterJdbcTemplate namedParamJdbcTemplate =
new NamedParameterJdbcTemplate(dataSource());
return namedParamJdbcTemplate;
}
}
除了 H2 配置外,我们还通过提供内置的其他方法中构建的 H2 数据源来初始化NamedParameterJdbcTemplate。
我们添加了一些特定于 JUnit 的其他依赖。您可以通过下载源代码来参考它们。
定义 RowMapper
由于我们使用 JDBC 模板,我们需要一种将数据库中的数据行映射到 Java 对象的方法。这可以通过实现RowMapper接口来实现。我们将为所有三个实体定义映射类。对于Country,原始映射类如下所示:
public class CountryRowMapper implements RowMapper<Country>{
public Country mapRow(ResultSet rs, int rowNum)
throws SQLException {
Country country = new Country();
country.setCode(rs.getString("code"));
country.setName(rs.getString("name"));
country.setContinent(rs.getString("continent"));
country.setRegion(rs.getString("region"));
country.setSurfaceArea(rs.getDouble("surface_area"));
country.setIndepYear(rs.getShort("indep_year"));
country.setPopulation(rs.getLong("population"));
country.setLifeExpectancy(rs.getDouble("life_expectancy"));
country.setGnp(rs.getDouble("gnp"));
country.setLocalName(rs.getString("local_name"));
country.setGovernmentForm(rs.getString("government_form"));
country.setHeadOfState(rs.getString("head_of_state"));
country.setCode2(rs.getString("code2"));
if ( Long.valueOf(rs.getLong("capital")) != null ) {
City city = new City();
city.setId(rs.getLong("capital"));
city.setName(rs.getString("capital_name"));
country.setCapital(city);
}
return country;
}
}
然后我们定义City的映射类如下所示:
public class CityRowMapper implements RowMapper<City>{
public City mapRow(ResultSet rs, int rowNum)
throws SQLException {
City city = new City();
city.setCountryCode(rs.getString("country_code"));
city.setDistrict(rs.getString("district"));
city.setId(rs.getLong("id"));
city.setName(rs.getString("name"));
city.setPopulation(rs.getLong("population"));
return city;
}
}
最后,我们定义CountryLanguage如下所示:
public class CountryLanguageRowMapper implements
RowMapper<CountryLanguage> {
public CountryLanguage mapRow(ResultSet rs, int rowNum)
throws SQLException {
CountryLanguage countryLng = new CountryLanguage();
countryLng.setCountryCode(rs.getString("countrycode"));
countryLng.setIsOfficial(rs.getString("isofficial"));
countryLng.setLanguage(rs.getString("language"));
countryLng.setPercentage(rs.getDouble("percentage"));
return countryLng;
}
}
设计 CountryDAO
让我们继续在com.nilangpatel.worldgdp.dao包中定义CountryDAO类,以及所需的方法,从getCountries方法开始。此方法将获取国家的详细信息以在列表页中显示。在过滤国家列表时也会调用此方法。基于列表、过滤和分页,我们将此方法中使用的查询拆分为以下部分:
- 选择条件:
private static final String SELECT_CLAUSE = "SELECT "
+ " c.Code, "
+ " c.Name, "
+ " c.Continent, "
+ " c.region, "
+ " c.SurfaceArea surface_area, "
+ " c.IndepYear indep_year, "
+ " c.Population, "
+ " c.LifeExpectancy life_expectancy, "
+ " c.GNP, "
+ " c.LocalName local_name, "
+ " c.GovernmentForm government_form, "
+ " c.HeadOfState head_of_state, "
+ " c.code2 ,"
+ " c.capital ,"
+ " cy.name capital_name "
+ " FROM country c"
+ " LEFT OUTER JOIN city cy ON cy.id = c.capital ";
- 搜索条件:
private static final String SEARCH_WHERE_CLAUSE = " AND ( LOWER(c.name) "
+ " LIKE CONCAT('%', LOWER(:search), '%') ) ";
- 大洲过滤条件:
private static final String CONTINENT_WHERE_CLAUSE =
" AND c.continent = :continent ";
- 地区过滤条件:
private static final String REGION_WHERE_CLAUSE =
" AND c.region = :region ";
- 分页条件:
private static final String PAGINATION_CLAUSE = " ORDER BY c.code "
+ " LIMIT :size OFFSET :offset ";
由:<<variableName>>定义的占位符将被NamedParameterJdbcTemplate中提供的Map中的值替换。这样我们可以避免将值连接到 SQL 查询中,从而避免 SQL 注入的风险。getCountries()的定义现在如下所示:
public List<Country> getCountries(Map<String, Object> params){
int pageNo = 1;
if ( params.containsKey("pageNo") ) {
pageNo = Integer.parseInt(params.get("pageNo").toString());
}
Integer offset = (pageNo - 1) * PAGE_SIZE;
params.put("offset", offset);
params.put("size", PAGE_SIZE);
return namedParamJdbcTemplate.query(SELECT_CLAUSE
+ " WHERE 1 = 1 "
+ (!StringUtils.isEmpty((String)params.get("search"))
? SEARCH_WHERE_CLAUSE : "")
+ (!StringUtils.isEmpty((String)params.get("continent"))
? CONTINENT_WHERE_CLAUSE : "")
+ (!StringUtils.isEmpty((String)params.get("region"))
? REGION_WHERE_CLAUSE : "")
+ PAGINATION_CLAUSE,
params, new CountryRowMapper());
}
接下来是实现getCountriesCount方法,它与getCountries类似,但返回匹配WHERE子句的条目数,而不应用分页。实现如下所示:
public int getCountriesCount(Map<String, Object> params) {
return namedParamJdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM country c"
+ " WHERE 1 = 1 "
+ (!StringUtils.isEmpty((String)params.get("search"))
? SEARCH_WHERE_CLAUSE : "")
+ (!StringUtils.isEmpty((String)params.get("continent"))
? CONTINENT_WHERE_CLAUSE : "")
+ (!StringUtils.isEmpty((String)params.get("region"))
? REGION_WHERE_CLAUSE : ""),
params, Integer.class);
}
然后我们实现getCountryDetail方法来获取给定代码的国家详细信息,如下所示:
public Country getCountryDetail(String code) {
Map<String, String> params = new HashMap<String, String>();
params.put("code", code);
return namedParamJdbcTemplate.queryForObject(SELECT_CLAUSE
+" WHERE c.code = :code", params,
new CountryRowMapper());
}
在所有之前的 DAO 方法实现中,我们使用了在定义 RowMapper部分中定义的CountryRowMapper。
最后,我们定义了一个允许编辑国家信息的方法,如下面的代码所示:
public void editCountryDetail(String code, Country country) {
namedParamJdbcTemplate.update(" UPDATE country SET "
+ " name = :name, "
+ " localname = :localName, "
+ " capital = :capital, "
+ " continent = :continent, "
+ " region = :region, "
+ " HeadOfState = :headOfState, "
+ " GovernmentForm = :governmentForm, "
+ " IndepYear = :indepYear, "
+ " SurfaceArea = :surfaceArea, "
+ " population = :population, "
+ " LifeExpectancy = :lifeExpectancy "
+ "WHERE Code = :code ",
getCountryAsMap(code, country));
}
之前的方法使用一个辅助方法来构建一个Map对象,该方法通过使用Country对象中的数据。我们需要这个映射,因为我们将会将其用作namedParamJdbcTemplate的参数源。
辅助方法具有简单的实现方式,如下面的代码所示:
private Map<String, Object> getCountryAsMap(String code, Country country){
Map<String, Object> countryMap = new HashMap<String, Object>();
countryMap.put("name", country.getName());
countryMap.put("localName", country.getLocalName());
countryMap.put("capital", country.getCapital().getId());
countryMap.put("continent", country.getContinent());
countryMap.put("region", country.getRegion());
countryMap.put("headOfState", country.getHeadOfState());
countryMap.put("governmentForm", country.getGovernmentForm());
countryMap.put("indepYear", country.getIndepYear());
countryMap.put("surfaceArea", country.getSurfaceArea());
countryMap.put("population", country.getPopulation());
countryMap.put("lifeExpectancy", country.getLifeExpectancy());
countryMap.put("code", code);
return countryMap;
}
让我们为CountryDAO类编写 JUnit 测试,我们还没有创建这个类。在com.nilangpatel.worldgdp.test.dao包中创建CountryDAOTest类,如下所示:
@RunWith(SpringRunner.class)
@SpringJUnitConfig( classes = {
TestDBConfiguration.class, CountryDAO.class})
public class CountryDAOTest {
@Autowired CountryDAO countryDao;
@Autowired @Qualifier("testTemplate")
NamedParameterJdbcTemplate namedParamJdbcTemplate;
@Before
public void setup() {
countryDao.setNamedParamJdbcTemplate(namedParamJdbcTemplate);
}
@Test
public void testGetCountries() {
List<Country> countries = countryDao.getCountries(new HashMap<>());
//AssertJ assertions
//Paginated List, so should have 20 entries
assertThat(countries).hasSize(20);
}
@Test
public void testGetCountries_searchByName() {
Map<String, Object> params = new HashMap<>();
params.put("search", "Aruba");
List<Country> countries = countryDao.getCountries(params);
assertThat(countries).hasSize(1);
}
@Test
public void testGetCountries_searchByContinent() {
Map<String, Object> params = new HashMap<>();
params.put("continent", "Asia");
List<Country> countries = countryDao.getCountries(params);
assertThat(countries).hasSize(20);
}
@Test
public void testGetCountryDetail() {
Country c = countryDao.getCountryDetail("IND");
assertThat(c).isNotNull();
assertThat(c.toString()).isEqualTo("Country(code=IND, name=India, "
+ "continent=Asia, region=Southern and Central Asia, "
+ "surfaceArea=3287263.0, indepYear=1947, population=1013662000, "
+ "lifeExpectancy=62.5, gnp=447114.0, localName=Bharat/India, "
+ "governmentForm=Federal Republic, headOfState=Kocheril Raman Narayanan, "
+ "capital=City(id=1109, name=New Delhi, countryCode=null, "
+ "country=null, district=null, population=null), code2=IN)");
}
@Test public void testEditCountryDetail() {
Country c = countryDao.getCountryDetail("IND");
c.setHeadOfState("Ram Nath Kovind");
c.setPopulation(1324171354l);
countryDao.editCountryDetail("IND", c);
c = countryDao.getCountryDetail("IND");
assertThat(c.getHeadOfState()).isEqualTo("Ram Nath Kovind");
assertThat(c.getPopulation()).isEqualTo(1324171354l);
}
@Test public void testGetCountriesCount() {
Integer count = countryDao.getCountriesCount(Collections.EMPTY_MAP);
assertThat(count).isEqualTo(239);
}
}
在配置 JUnit 测试时,使用 Spring 测试框架需要注意以下几点,包括以下内容:
-
@RunWith用于用自定义测试运行器替换 JUnit 的测试运行器,在这种情况下,是 Spring 的SpringRunner。Spring 的测试运行器有助于将 JUnit 与 Spring 测试框架集成。 -
@SpringJUnitConfig用于提供包含所需配置的类列表,以满足运行测试的依赖项。
许多选择 ORM 框架的人可能会觉得编写像这样的复杂 SQL 查询很尴尬。然而,从下一章开始,我们将开始使用 Spring Data 框架与各种数据源进行交互;数据库是其中之一,我们将使用 Spring Data JPA 来访问。在这里,我们想展示 Spring JDBC 提供程序如何与数据库交互。
设计 CityDAO
以下是一些com.nilangpatel.worldgdp.dao.CityDAO类需要支持的重要操作:
-
获取一个国家的城市
-
根据给定的 ID 获取城市详细信息
-
向国家添加一个新城市
-
从国家中删除给定的城市
让我们继续实现这些功能,从getCities开始,如下所示:
public List<City> getCities(String countryCode, Integer pageNo){
Map<String, Object> params = new HashMap<String, Object>();
params.put("code", countryCode);
if ( pageNo != null ) {
Integer offset = (pageNo - 1) * PAGE_SIZE;
params.put("offset", offset);
params.put("size", PAGE_SIZE);
}
return namedParamJdbcTemplate.query("SELECT "
+ " id, name, countrycode country_code, district, population "
+ " FROM city WHERE countrycode = :code"
+ " ORDER BY Population DESC"
+ ((pageNo != null) ? " LIMIT :offset , :size " : ""),
params, new CityRowMapper());
}
我们正在使用分页查询来获取一个国家的城市列表。我们还需要这个方法的另一个重载版本,它将返回一个国家的所有城市,我们将使用这个查询来在编辑国家时选择其首都。重载版本如下:
public List<City> getCities(String countryCode){
return getCities(countryCode, null);
}
接下来是实现获取城市详细信息的方法,如下面的代码所示:
public City getCityDetail(Long cityId) {
Map<String, Object> params = new HashMap<String, Object>();
params.put("id", cityId);
return namedParamJdbcTemplate.queryForObject("SELECT id, "
+ " name, countrycode country_code, "
+ " district, population "
+ " FROM city WHERE id = :id",
params, new CityRowMapper());
}
然后我们按照以下方式实现添加城市的方法:
public Long addCity(String countryCode, City city) {
SqlParameterSource paramSource = new MapSqlParameterSource(
getMapForCity(countryCode, city));
KeyHolder keyHolder = new GeneratedKeyHolder();
namedParamJdbcTemplate.update("INSERT INTO city("
+ " name, countrycode, "
+ " district, population) "
+ " VALUES (:name, :country_code, "
+ " :district, :population )",
paramSource, keyHolder);
return keyHolder.getKey().longValue();
}
正如我们在添加国家时看到的,这也会使用一个辅助方法从City数据中返回一个Map,如下所示:
private Map<String, Object> getMapForCity(String countryCode, City city){
Map<String, Object> map = new HashMap<String, Object>();
map.put("name", city.getName());
map.put("country_code", countryCode);
map.put("district", city.getDistrict());
map.put("population", city.getPopulation());
return map;
}
在addCity中需要注意的一个重要事项是使用KeyHolder和GeneratedKeyHolder来返回由自动递增生成的(作为cityId的)主键,如下所示:
KeyHolder keyHolder = new GeneratedKeyHolder();
//other code
return keyHolder.getKey().longValue();
最后,我们实现从国家中删除城市的方法,如下面的代码所示:
public void deleteCity(Long cityId) {
Map<String, Object> params = new HashMap<String, Object>();
params.put("id", cityId);
namedParamJdbcTemplate.update("DELETE FROM city WHERE id = :id", params);
}
现在让我们为CityDAO添加一个测试。在src/test/java文件夹下的com.nilangpatel.worldgdp.test.dao包中添加CityDAOTest类,如下所示:
@RunWith(SpringRunner.class)
@SpringJUnitConfig( classes = {
TestDBConfiguration.class, CityDAO.class})
public class CityDAOTest {
@Autowired CityDAO cityDao;
@Autowired @Qualifier("testTemplate")
NamedParameterJdbcTemplate namedParamJdbcTemplate;
@Before
public void setup() {
cityDao.setNamedParamJdbcTemplate(namedParamJdbcTemplate);
}
@Test public void testGetCities() {
List<City> cities = cityDao.getCities("IND", 1);
assertThat(cities).hasSize(10);
}
@Test public void testGetCityDetail() {
Long cityId = 1024l;
City city = cityDao.getCityDetail(cityId);
assertThat(city.toString()).isEqualTo("City(id=1024, name=Mumbai (Bombay), "
+ "countryCode=IND, country=null, district=Maharashtra, population=10500000)");
}
@Test public void testAddCity() {
String countryCode = "IND";
City city = new City();
city.setCountryCode(countryCode);
city.setDistrict("District");
city.setName("City Name");
city.setPopulation(101010l);
long cityId = cityDao.addCity(countryCode, city);
assertThat(cityId).isNotNull();
City cityFromDb = cityDao.getCityDetail(cityId);
assertThat(cityFromDb).isNotNull();
assertThat(cityFromDb.getName()).isEqualTo("City Name");
}
@Test (expected = EmptyResultDataAccessException.class)
public void testDeleteCity() {
Long cityId = addCity();
cityDao.deleteCity(cityId);
City cityFromDb = cityDao.getCityDetail(cityId);
assertThat(cityFromDb).isNull();
}
private Long addCity() {
String countryCode = "IND";
City city = new City();
city.setCountryCode(countryCode);
city.setDistrict("District");
city.setName("City Name");
city.setPopulation(101010l);
return cityDao.addCity(countryCode, city);
}
}
设计 CountryLanguageDAO
我们需要公开以下 API 来与countrylanguage表交互:
-
获取给定国家代码的语言列表
-
通过检查该语言是否已存在来为某个国家添加一种新语言
-
删除某个国家的语言
为了保持简洁,我们将展示涵盖这三个场景的方法实现。完整的代码可以在本书下载的代码中的 com.nilangpatel.worldgdp.dao.CountryLanguageDAO 类中找到。以下是为这些方法实现编写的代码:
public List<CountryLanguage> getLanguages(String countryCode, Integer pageNo){
Map<String, Object> params = new HashMap<String, Object>();
params.put("code", countryCode);
Integer offset = (pageNo - 1) * PAGE_SIZE;
params.put("offset", offset);
params.put("size", PAGE_SIZE);
return namedParamJdbcTemplate.query("SELECT * FROM countrylanguage"
+ " WHERE countrycode = :code"
+ " ORDER BY percentage DESC "
+ " LIMIT :size OFFSET :offset ",
params, new CountryLanguageRowMapper());
}
public void addLanguage(String countryCode, CountryLanguage cl) {
namedParamJdbcTemplate.update("INSERT INTO countrylanguage ( "
+ " countrycode, language, isofficial, percentage ) "
+ " VALUES ( :country_code, :language, "
+ " :is_official, :percentage ) ",
getAsMap(countryCode, cl));
}
public boolean languageExists(String countryCode, String language) {
Map<String, Object> params = new HashMap<String, Object>();
params.put("code", countryCode);
params.put("lang", language);
Integer langCount = namedParamJdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM countrylanguage"
+ " WHERE countrycode = :code "
+ " AND language = :lang", params, Integer.class);
return langCount > 0;
}
public void deleteLanguage (String countryCode, String language ) {
Map<String, Object> params = new HashMap<String, Object>();
params.put("code", countryCode);
params.put("lang", language);
namedParamJdbcTemplate.update("DELETE FROM countrylanguage "
+ " WHERE countrycode = :code AND "
+ " language = :lang ", params);
}
private Map<String, Object> getAsMap(String countryCode, CountryLanguage cl){
Map<String, Object> map = new HashMap<String, Object>();
map.put("country_code", countryCode);
map.put("language", cl.getLanguage());
map.put("is_official", cl.getIsOfficial());
map.put("percentage", cl.getPercentage());
return map;
}
设计世界银行 API 的客户端
我们需要从 WorldBank API 获取 GDP 数据。正如我们讨论的那样,这是一个 REST 端点,我们必须发送一些参数并获取响应。为此,我们将使用 RestTemplate 来进行 REST 调用。以下是对 com.packt.external.WorldBankApiClient 类的定义,该类用于调用世界银行 API 并处理其响应以返回 List<CountryGDP>:
@Service
public class WorldBankApiClient {
String GDP_URL = "http://api.worldbank.org/countries/%s/indicators/NY.GDP.MKTP.CD?"
+ "format=json&date=2008:2018";
public List<CountryGDP> getGDP(String countryCode) throws ParseException {
RestTemplate worldBankRestTmplt = new RestTemplate();
ResponseEntity<String> response
= worldBankRestTmplt.getForEntity(String.format(GDP_URL, countryCode), String.class);
//the second element is the actual data and its an array of object
JSONParser parser = new JSONParser();
JSONArray responseData = (JSONArray) parser.parse(response.getBody());
JSONArray countryDataArr = (JSONArray) responseData.get(1);
List<CountryGDP> data = new ArrayList<CountryGDP>();
JSONObject countryDataYearWise=null;
for (int index=0; index < countryDataArr.size(); index++) {
countryDataYearWise = (JSONObject) countryDataArr.get(index);
String valueStr = "0";
if(countryDataYearWise.get("value") !=null) {
valueStr = countryDataYearWise.get("value").toString();
}
String yearStr = countryDataYearWise.get("date").toString();
CountryGDP gdp = new CountryGDP();
gdp.setValue(valueStr != null ? Double.valueOf(valueStr) : null);
gdp.setYear(Short.valueOf(yearStr));
data.add(gdp);
}
return data;
}
}
定义 API 控制器
到目前为止,我们已经编写了与数据库交互的代码。接下来是编写控制器代码。我们将有两种类型的控制器——一种返回视图名称(在我们的案例中是 Thymeleaf 模板)并在模型对象中填充视图数据,另一种公开 RESTful API。我们需要在 pom.xml 中添加以下依赖项:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>${spring.version}</version>
</dependency>
将 spring-webmvc 添加到依赖项中会自动包含 spring-core、spring-beans 和 spring-context 依赖项。因此,我们可以从 pom.xml 中移除它们。
使用 @EnableWebMvc 启用 Web MVC
为了能够使用 Spring MVC 功能,我们需要一个被注解为 @Configuration 的类,并注解为 @EnableWebMvc。@EnableWebMvc 注解从 Spring MVC 框架中的 WebMvcConfigurationSupport 类导入 Spring MVC 相关配置。如果我们需要覆盖任何默认导入的配置,我们必须实现 Spring MVC 框架中存在的 WebMvcConfigurer 接口并覆盖所需的方法。
我们将创建一个 AppConfiguration 类,其定义如下:
@EnableWebMvc
@Configuration
@ComponentScan(basePackages = "com.nilangpatel.worldgdp")
public class AppConfiguration implements WebMvcConfigurer{
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("/static/");
}
}
在之前的配置中,以下是一些需要注意的重要事项:
-
@EnableWebMvc:此注解导入与 Spring MVC 相关的配置。 -
@ComponentScan:此注解用于声明需要扫描以查找 Spring 组件(可以是@Configuration、@Service、@Controller、@Component等)的包。如果没有定义任何包,则从定义类的包开始扫描。 -
WebMvcConfigurer:我们将实现此接口以覆盖之前代码中看到的某些默认 Spring MVC 配置。
配置部署到 Tomcat 而不使用 web.xml
由于我们将部署应用程序到 Tomcat,我们需要向应用程序服务器提供 servlet 配置。我们将在单独的部分中查看如何部署到 Tomcat,但现在我们将查看 Java 配置,这对于部署到 Tomcat 或任何应用程序服务器都是足够的,无需额外的web.xml。以下给出了 Java 类定义:
public class WorldApplicationInitializer extends
AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return null;
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class[] {AppConfiguration.class};
}
@Override
protected String[] getServletMappings() {
return new String[] { "/" };
}
}
AbstractAnnotationConfigDispatcherServletInitializer抽象类是实现WebApplicationInitializer接口的,用于注册 Spring 的DispatcherServlet实例,并使用其他@Configuration类来配置DispatcherServlet。
我们只需要重写getRootConfigClasses(), getServletConfigClasses(), 和 getServletMappings()方法。前两个方法指向需要加载到 servlet 上下文中的配置类,最后一个方法用于为DispatcherServlet提供 servlet 映射。
DispatcherServlet遵循前端控制器模式,其中有一个注册的 servlet 用于处理所有 web 请求。这个 servlet 使用RequestHandlerMapping并根据映射到实现的 URL 调用相应的实现。
我们需要对 Maven WAR 插件进行少量更新,以便在没有找到web.xml时不会失败。这可以通过更新pom.xml文件中的<plugins>标签来实现,如下所示:
<build>
<finalName>worldgdp</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<executions>
<execution>
<id>default-war</id>
<phase>prepare-package</phase>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
现在我们已经准备好实现我们的控制器了。一旦我们实现了所有的 RESTful API 控制器,我们将向您展示如何部署到 Tomcat。
定义国家资源的 RESTful API 控制器
让我们定义针对国家资源的 RESTful API 控制器。以下是对应的控制器模板:
@RestController
@RequestMapping("/api/countries")
@Slf4j
public class CountryAPIController {
@Autowired CountryDAO countryDao;
@Autowired WorldBankApiClient worldBankApiClient;
@GetMapping
public ResponseEntity<?> getCountries(
@RequestParam(name="search", required = false) String searchTerm,
@RequestParam(name="continent", required = false) String continent,
@RequestParam(name="region", required = false) String region,
@RequestParam(name="pageNo", required = false) Integer pageNo
){
//logic to fetch contries from CountryDAO
return ResponseEntity.ok();
}
@PostMapping(value = "/{countryCode}",
consumes = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<?> editCountry(
@PathVariable String countryCode, @Valid @RequestBody Country country ){
//logic to edit existing country
return ResponseEntity.ok();
}
@GetMapping("/{countryCode}/gdp")
public ResponseEntity<?> getGDP(@PathVariable String countryCode){
//logic to get GDP by using external client
return ResponseEntity.ok();
}
}
以下是从之前的代码中需要注意的一些事项:
-
@RestController:这个注解用于将一个类标注为控制器,其中每个 RESTful 方法都返回响应体中的数据。 -
@RequestMapping:这个用于分配访问资源的根 URL。 -
@GetMapping和@PostMapping:这些用于分配将用于调用资源的 HTTP 动词。资源的 URL 在注解中传递,包括其他请求头,这些请求头用于消费和产生信息。
让我们按顺序实现每个方法,从getCountries()开始,如下所示:
@GetMapping
public ResponseEntity<?> getCountries(
@RequestParam(name="search", required = false) String searchTerm,
@RequestParam(name="continent", required = false) String continent,
@RequestParam(name="region", required = false) String region,
@RequestParam(name="pageNo", required = false) Integer pageNo
){
try {
Map<String, Object> params = new HashMap<String, Object>();
params.put("search", searchTerm);
params.put("continent", continent);
params.put("region", region);
if ( pageNo != null ) {
params.put("pageNo", pageNo.toString());
}
List<Country> countries = countryDao.getCountries(params);
Map<String, Object> response = new HashMap<String, Object>();
response.put("list", countries);
response.put("count", countryDao.getCountriesCount(params));
return ResponseEntity.ok(response);
}catch(Exception ex) {
log.error("Error while getting countries", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error while getting countries");
}
}
以下是从之前的代码中需要注意的一些事项:
-
@RequestParam:这个注解用于声明控制器端点接受的请求参数。参数可以提供默认值,也可以设置为必填。 -
ResponseEntity:这个类用于返回响应体,以及其他响应参数,如状态、头信息等。
接下来是编辑国家详情的 API,如下所示:
@PostMapping("/{countryCode}")
public ResponseEntity<?> editCountry(
@PathVariable String countryCode, @Valid @RequestBody Country country ){
try {
countryDao.editCountryDetail(countryCode, country);
Country countryFromDb = countryDao.getCountryDetail(countryCode);
return ResponseEntity.ok(countryFromDb);
}catch(Exception ex) {
log.error("Error while editing the country: {} with data: {}",
countryCode, country, ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error while editing the country");
}
}
以下是从之前的代码实现中需要注意的一些事项:
-
@PathVariable:这用于声明任何需要成为控制器端点 URL 路径一部分的变量。在我们的情况下,我们希望国家代码成为 URL 的一部分。因此,URL 将采用/api/countries/IND的形式。 -
@Valid:这触发了 Bean Validation API 来检查每个类属性的约束。如果客户端的数据无效,它将返回 400。 -
@RequestBody:这用于捕获请求体中发送的数据,并使用Jackson库将请求体中的 JSON 数据转换为相应的 Java 对象。
API 的其他实现可以在CountryAPIController类中找到。API 控制器的测试可以在CountryAPIControllerTest类中找到,这些类都包含在此书的源代码中。
定义城市资源的 RESTful API 控制器
对于城市资源,我们需要以下 API:
-
获取给定国家的城市
-
向国家添加一个新城市
-
从国家中删除城市
此控制器的代码可以在CityAPIController类中找到,API 控制器的测试可以在CityAPIControllerTest类中找到,这些类都包含在此书的源代码中。
定义国家语言资源的 RESTful API 控制器
对于CountryLanguage资源,我们需要以下 API:
-
获取一个国家的语言
-
为一个国家添加一种语言
-
从国家中删除一种语言
此控制器的代码可以在CountryLanguageAPIController类中找到,API 控制器的测试可以在CountryLanguageAPIControllerTest类中找到,这些类都包含在此书的源代码中。
部署到 Tomcat
在我们开始处理视图的视图和控制器之前,我们将把到目前为止开发的程序部署到 Tomcat。您可以从这里下载 Tomcat 8.5(tomcat.apache.org/download-80.cgi)。安装就像将 ZIP/TAR 文件提取到您的文件系统中一样简单。
让我们在 Tomcat 中创建一个用户admin和manager-gui角色。为此,需要编辑apache-tomcat-8.5.23/conf/tomcat-users.xml并添加以下行:
<role rolename="manager-gui" />
<user username="admin" password="admin" roles="manager-gui" />
启动 Tomcat 很简单,如下所示:
-
导航到
apache-tomcat-8.5.23/bin -
运行
startup.bat
导航到http://localhost:8080/manager/html,输入admin作为用户名,admin作为密码,以便能够查看 Tomcat 的管理控制台。页面初始部分将列出当前实例中部署的应用程序,页面后部将找到上传 WAR 文件以部署应用程序的选项,如下面的截图所示:

我们可以上传运行mvn package后生成的 WAR 文件,或者更新 Tomcat 实例的server.xml以引用项目的目标目录,以便能够自动部署。后一种方法可用于开发,而前一种 WAR 部署方法可用于生产。
在一个生产系统中,你可以让持续部署服务器生成一个 WAR 文件并部署到远程的 Tomcat 实例。在这种情况下,我们将使用更新 Tomcat 配置的后一种方法。你必须在位于TOMCAT_HOME/conf/server.xml的 Tomcat 的server.xml文件中添加以下代码行:
<Context path="/world" docBase="<<Directory path where you keep WAR file>>"
reloadable="true" />
前面的行必须添加到<Host></Host>标签之间。或者,你也可以在你的 IDE(例如 Eclipse)中配置 Tomcat,这对于开发来说更方便。我们将使用 Maven 构建项目,但在那之前,请将以下配置添加到pom.xml的<properties></properties>部分:
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.source>1.8</maven.compiler.source>
这将确保在通过命令行使用 Maven 构建(打包)应用程序时选择正确的 Java 编译器版本。接下来是使用mvn package构建项目,并使用TOMCAT_HOME/bin/startup.bat运行 Tomcat,一旦服务器启动,你可以在浏览器中访问 API http://localhost:8080/worldgdp/api/countries,以查看以下输入:

定义视图控制器
我们将有一个视图控制器,ViewController.java定义在com.nilangpatel.worldgdp.controller.view中。视图控制器将负责填充视图模板所需的数据,并将 URL 映射到相应的视图模板。
我们将使用 Thymeleaf (www.thymeleaf.org) 作为服务器端模板引擎,并使用 Mustache.js (github.com/janl/mustache.js) 作为我们的客户端模板引擎。使用客户端模板引擎的优势在于,任何以 JSON 形式异步加载的数据都可以通过生成 HTML 轻松添加到 DOM 中。我们将在第三章中进一步探讨 Thymeleaf 和 Mustache.js,Blogpress – 一个简单的博客管理系统。
使用 Vue.js、React.js、Angular.js 等框架有更好的方法来做这件事。我们将在下一节查看视图模板。让我们继续讨论视图控制器。视图控制器应该映射以下场景的正确视图模板和数据:
-
国家列表
-
查看国家详情
-
编辑国家详情
让我们看看以下ViewController类的骨架结构定义:
@Controller
@RequestMapping("/")
public class ViewController {
@Autowired CountryDAO countryDao;
@Autowired LookupDAO lookupDao;
@Autowired CityDAO cityDao;
@GetMapping({"/countries", "/"})
public String countries(Model model,
@RequestParam Map<String, Object> params
) {
//logic to fetch country list
return "countries";
}
@GetMapping("/countries/{code}")
public String countryDetail(@PathVariable String code, Model model) {
//Logic to Populate the country detail in model
return "country";
}
@GetMapping("/countries/{code}/form")
public String editCountry(@PathVariable String code,
Model model) {
//Logic to call CountryDAO to update the country
return "country-form";
}
}
以下是从上一段代码中的一些重要事项:
-
@Controller:此注解用于声明一个控制器,可以返回要渲染的视图模板名称,以及返回响应体中的 JSON/XML 数据。 -
@ResponseBody:当此注解存在于控制器的方法上时,表示该方法将返回响应体中的数据,因此 Spring 不会使用视图解析器来解析要渲染的视图。@RestController注解默认将其添加到所有方法上。 -
Model:此实例用于传递构建视图所需的数据。
在列出国家时,服务器端使用 Thymeleaf 模板引擎渲染完整的 HTML,因此我们需要获取请求参数,如果 URL 中存在,并获取一个过滤和分页的国家列表。我们还需要填充查找数据,即 <select> 控件的数据,这些控件将用于过滤数据。让我们看看它的实现如下:
@GetMapping({"/countries", "/"})
public String countries(Model model,
@RequestParam Map<String, Object> params
) {
model.addAttribute("continents", lookupDao.getContinents());
model.addAttribute("regions", lookupDao.getRegions());
model.addAttribute("countries", countryDao.getCountries(params));
model.addAttribute("count", countryDao.getCountriesCount(params));
return "countries";
}
之前的代码相当简单。我们使用 DAO 类将所需数据填充到 Model 实例中,然后返回视图名称,在这种情况下是 countries。同样,其余的方法实现可以在 ViewController 控制器类中找到。
定义视图模板
我们将使用 Thymeleaf 模板引擎来处理服务器端模板。Thymeleaf 提供了各种方言和条件块来渲染静态 HTML 中的动态内容。让我们看看 Thymeleaf 的简单语法元素,如下所示:
<!-- Dynamic content in HTML tag -->
<div class="alert alert-info">[[${country.name}]]</div>
<!-- Dynamic attributes -->
<span th:class="|alert ${error ? 'alert-danger': _}|">[[${errorMsg}]]</span>
<!-- Looping -->
<ol>
<li th:each="c : ${countries}">
[[${c.name}]]
</li>
</ol>
<!-- Conditionals -->
<div class="alert alert-warning" th:if="${count == 0}">No results found</div>
<!-- Custom attributes -->
<div th:attr="data-count=${count}"></div>
<!-- Form element value -->
<input type="text" th:value="${country.name}" name="name" />
从之前的示例中,我们可以观察到 Thymeleaf 要评估的项目前面带有 th: 前缀,并且可以在标签之间渲染任何内容,可以使用 th:text 或 [[${variable}]]。后者语法是在 Thymeleaf 3 中引入的。这是一个非常简短的入门,因为深入探讨 Thymeleaf 超出了本书的范围。可以在 www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html 找到解释模板不同部分的美丽指南。
配置 Thymeleaf 模板引擎
为了使用 Thymeleaf 模板引擎与 Spring MVC,我们需要进行一些配置,其中我们设置 Thymeleaf 模板引擎并更新 Spring 的视图解析器以使用模板引擎解析任何视图。在继续之前,我们需要在 pom.xml 中定义所需的依赖项如下:
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf-spring5</artifactId>
<version>${thymeleaf.version}</version>
</dependency>
<dependency>
<groupId>nz.net.ultraq.thymeleaf</groupId>
<artifactId>thymeleaf-layout-dialect</artifactId>
<version>${thymeleaf-layout-dialect.version}</version>
</dependency>
让我们按顺序定义配置视图解析器,首先设置模板解析器如下:
@Bean
public ClassLoaderTemplateResolver templateResolver() {
ClassLoaderTemplateResolver templateResolver
= new ClassLoaderTemplateResolver();
templateResolver.setPrefix("templates/");
templateResolver.setSuffix(".html");
templateResolver.setTemplateMode(TemplateMode.HTML);
templateResolver.setCacheable(false);
return templateResolver;
}
之前的配置设置了模板位置,模板引擎将使用它来解析模板文件。接下来是定义模板引擎,它将使用 SpringTemplateEngine 和之前定义的模板解析器,如下所示:
@Bean
public SpringTemplateEngine templateEngine() {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver());
templateEngine.addDialect(new LayoutDialect());
return templateEngine;
}
在之前的配置中,我们使用了由Emanuel Rabina创建的 Thymeleaf 布局方言(github.com/ultraq/thymeleaf-layout-dialect)。这个布局方言帮助我们创建一个视图装饰器框架,其中所有模板都将被基本模板装饰,而装饰后的模板只需提供完成页面的必要内容。因此,所有的页眉、页脚、CSS、脚本和其他常见 HTML 都可以放在基本模板中。这在很大程度上防止了冗余。在我们的示例应用中,位于worldgdp/src/main/resources/templates的base.html文件是其他模板使用的基本模板。
接下来是定义一个 Thymeleaf 视图解析器,该解析器将覆盖 Spring 的默认视图解析器,如下所示:
@Bean
public ViewResolver viewResolver() {
ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
viewResolver.setTemplateEngine(templateEngine());
viewResolver.setCharacterEncoding("UTF-8");
return viewResolver;
}
之前的配置可以在com.packt.config.ViewConfiguration类中找到。
管理静态资源
如果你回顾一下com.nilangpatel.worldgdp.AppConfiguration类,你会看到我们已经覆盖了WebMvcConfigurer接口的addResourceHandlers方法。在下面的代码中显示的方法实现中,我们将静态资源前缀 URL /static/**映射到webapp目录中的静态资源位置/static/:
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("/static/");
}
我们在项目的/src/main/webapp/static文件夹中添加了一些静态资源(CSS 和 JavaScript)。请下载本章的代码,并对照它们进行参考。
创建基本模板
我们之前提到,我们将使用 Thymeleaf 布局方言来创建一个基本模板,并使用基本模板来装饰所有其他模板。基本模板将包含所有的 CSS 链接、JavaScript 源文件链接、页眉和页脚,如下面的代码所示:
<!DOCTYPE html>
<html
>
<head>
<title layout:title-pattern="$CONTENT_TITLE - $LAYOUT_TITLE">World In Numbers</title>
<meta name="description" content=""/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<!-- Include all the CSS links -->
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<a class="navbar-brand" href="#">WORLD IN NUMBERS</a>
<div class="collapse navbar-collapse" id="navbarColor01">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" th:href="@{/countries}">Countries</a>
</li>
</ul>
</div>
</nav>
<div class="container">
<div class="content">
<div layout:fragment="page_content">
<!-- Placeholder for content -->
</div>
</div>
</div>
<div class="modal" id="worldModal" >
</div>
<footer id="footer"></footer>
<!-- /.container -->
<!-- Include all the Javascript source files -->
<th:block layout:fragment="scripts">
<!-- Placeholder for page related javascript -->
</th:block>
</body>
</html>
以下模板的两个主要重要部分如下:
-
<div layout:fragment="page_content"></div>:使用基本模板作为装饰器的其他模板将在这一部分提供它们的 HTML。Thymeleaf 布局方言在运行时将基本模板的内容装饰到这个 HTML 上。 -
<th:block layout:fragment="scripts"></th:block>:类似于之前的 HTML 内容,任何特定页面的 JavaScript 或指向特定 JavaScript 源文件的链接都可以添加到这一部分。这有助于将特定页面的 JavaScript 隔离在其自己的页面上。
任何想要将基本模板用作装饰器的模板,需要在<html>标签中声明此属性,layout:decorate="~{base}"。我们不会深入到单个模板的内容,因为它们主要是 HTML。所有模板都可以在位置worldgdp/src/main/resources/templates找到。我们有三个模板:
-
countries.html:这是用于显示带有过滤和分页的国家列表 -
country-form.html:这是用于编辑一个国家的详细信息 -
country.html:这是用于显示一个国家的详细信息
记录配置
在我们深入到开发应用程序的其他步骤之前,定义一个日志级别和格式是一个好的实践。然而,打印日志以期望的格式,并带有各种日志级别,这是一个可选但良好的实践。为此,添加一个名为logback.xml的 XML 文件,其中包含以下内容:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
</Pattern>
</layout>
</appender>
<logger name="com.nilangpatel.worldgdp" level="debug" additivity="false">
<appender-ref ref="STDOUT" />
</logger>
<root level="debug">
<appender-ref ref="STDOUT" />
</root>
</configuration>
Logback 作为流行的 Log4j 项目的继任者被开发出来,并用作 Java 应用程序的日志框架。此配置定义了模式以及日志级别。要启用应用程序中的 logback,您需要在pom.xml中添加以下依赖项:
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback.version}</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
<version>${logback.version}</version>
</dependency>
运行应用程序
由于我们已经配置了 Tomcat 的部署,你现在应该有应用程序正在运行。你始终可以下载本书的源代码;在worldgdp文件夹下找到源代码。下载后,你必须使用以下方式使用 Maven 构建它:
$ mvn package
上述命令将运行测试。位于target目录中的 WAR 文件worldgdp.war可以通过 Manager 应用程序上传到 Tomcat,或者复制到TOMCAT_HOME/webapps文件夹。然后 Tomcat 将展开存档并部署应用程序。
以下是一些应用程序运行时的截图,从列表页面开始:

接下来是显示国家详细信息的页面:

用于编辑国家详细信息的表单如下截图所示:

然后,我们有用于向国家添加新城市的弹出窗口,如下截图所示:

同样,我们还有一个用于添加新国家语言的弹出窗口,如下所示:

摘要
本章旨在启动您使用 Spring 框架的工作。我们从从头创建项目结构和设计视图模板开始,涵盖了构建基于 Spring 的 Web 应用程序的各种技术和工具。
详细了解概念进行更多动手练习始终是一个好的实践。接下来,你可以考虑通过采用一些其他世界银行 API 来进一步增强应用程序。在本章中,我们已经使用自己的方式配置了大部分内容。
然而,Spring 提供了一个名为Spring Boot的工具,它真的有助于以自动化的方式完成大部分配置,让你可以专注于开发应用程序。在随后的章节中,我们将更详细地探讨如何使用 Spring Boot 在 Spring 中开发 Web 应用程序。
在下一章中,我们将探讨 Spring 框架中名为响应式编程的另一个重要特性,使用 WebFlux 来实现。我们将学习响应式范式的基础知识,了解其优势,并探索各种响应式库。Spring 使用Reactor——一个提供响应式流实现以开发基于 Web 应用的库。因此,准备好在第二章中探索所有这些新颖且令人兴奋的主题吧。
第二章:构建响应式 Web 应用程序
我们在第一章中通过探索 Spring 框架及其模块系统的一些基础知识开始了我们的旅程,第一章,“创建一个列出世界国家和其 GDP 的应用程序”。现在,让我们暂时放下 Spring 框架的所有新和高级主题,在本章中,我们将探讨最受欢迎的主题之一:通过采用响应式范式来创建高度可扩展和响应式的应用程序。
技术世界正在从阻塞、同步和线程驱动实现迁移到非阻塞、异步和基于事件的系统,这些系统具有弹性,能够以一致的反应时间管理非常大的数据量。这是响应式系统解决的核心问题。
从编程模型的角度来看,响应式编程影响了从命令式风格到异步逻辑声明式组合的范式转变。Spring 框架通过从版本 5 开始将响应式流的能力纳入其核心框架来实现这一点。
在本章中,我们将从以下令人兴奋的维度和角度讨论和探索响应式编程:
-
什么是响应式系统
-
响应式编程简介
-
响应式编程的基本知识、优势和特性
-
Java 中的响应式编程
-
WebFlux 简介
-
Spring 对响应式编程的支持
-
使用 WebFlux 在响应式编程中的函数式工作方式
-
响应式范式中的 WebSocket 支持
技术要求
本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Spring-5.0-Projects/tree/master/chapter02。代码可以在任何操作系统上执行,尽管它只在 Windows 上进行了测试。
响应式系统
“响应式”这个词今天很流行,对不同的人有不同的含义,如轻量级、实时、异步、流式等。在更广泛的意义上,“响应式”指的是一系列设计技术或原则,是一种在分布式环境中考虑系统架构的方式。它包括工具、设计方法和实施程序。
可以用团队的类比来描述响应式系统:个人球员相互协作以实现一个期望的目标。组件之间的交互是区分响应式系统与其他系统的主要质量。组件可以单独操作,或者仍然与其他组件和谐工作,作为一个整体系统实现预期的结果。换句话说,这是系统设计允许单个子应用程序形成一个单一逻辑系统,执行特定任务,并相互保持意识。这使得决策成为可能,如负载均衡、扩展和缩减、故障转移缓解等。
当谈论反应式主题时,主要在软件设计和开发的背景下,人们通常将术语反应式系统和反应式编程互换使用,尽管它们并不完全相同。反应式系统是消息驱动的,与网络上的分布式进程通信相关联,而反应式编程通常是事件驱动的,并在本地处理。
许多软件工程师认为反应式系统与基于异步消息的系统相同。但根据反应式宣言,反应式系统是一种以响应式风格开发分布式系统的架构方式。它具有以下基本特性:
-
响应性:它表明系统应在合理的时间内处理和响应用户请求。
-
弹性:它表明即使在出现故障的情况下,系统也应保持响应。简而言之,任何类型的错误都不应使系统进入非响应状态。所有可能引起系统错误的所有因素都必须得到妥善处理,而不会导致系统停止。
-
可伸缩性:系统即使在可变负载下也应保持响应。它应根据负载灵活地扩展和缩减,并以合理的资源使用来处理它。为了实现这一点,应用程序必须设计成避免任何中央瓶颈。
-
消息驱动:反应式系统内的组件应通过异步消息传递相互交互。这带来了组件之间的松散耦合、责任隔离和位置透明性。
在这些特性中,响应性、弹性和可伸缩性是今天几乎所有现实世界应用的标准要求。它们看起来简单直接,但实现起来却很棘手。正是消息驱动的需求将响应式系统与其他系统区分开来。
反应式系统使用异步消息传递机制在组件之间进行交互。它还提供了一种非阻塞机制来控制数据流。在构建反应式系统时,在所有相关点上,数据处理操作都作为流流程组成。简而言之,反应式系统专注于流处理。
反应式编程
反应式编程可用于构建反应式系统。根据定义,反应式编程是一种围绕数据流和变化传播对齐的编程实践或模式。数据的变化通过底层执行模型自动通过数据流传播。
为了简单起见,反应式编程是一种更有效地处理异步数据流的方法。换句话说,它是一种处理异步数据流的编程方式,或者也可以称为异步编程的一个子集。反应式编程是一种执行方式,其中新信息将推动流程前进,而不是由执行线程控制流程。
数据流是一系列在系统执行过程中发生的业务事件,例如各种键盘或鼠标事件、HTML 字段更改、HTTP 请求、通知、REST API 数据获取、触发验证、更改 Web 组件状态、数据更新,或任何其他可能引起数据流变化或改变程序行为的事情。
简而言之,响应式编程涵盖了由异步数据流引起的流中的动态反应。当一个组件发生变化时,响应式库或框架会自动将这些变化传播到其他组件。定义一个静态的传播顺序是完全可能的。
以下图表显示了响应式编程与命令式编程的不同之处:

在命令式编程中,线程以同步方式相互通信,导致阻塞通信。一个线程必须等待依赖资源的线程空闲,这可能导致系统效率低下,并容易形成瓶颈。另一方面,响应式编程不需要等待;事实上,一旦资源可用,它就会得到通知,这样它就可以在同时做其他工作。这减少了系统挂起的可能性,并使其响应。这有效地保持了资源的平稳使用。
响应式编程建议将给定的需求分解成单独的步骤,这些步骤可以以异步、非阻塞的方式完成,并在之后组合成最终输出。在响应式编程的上下文中,异步意味着消息或事件的处理发生在某个任意时间,最可能是在未来。
响应式编程的异步和非阻塞特性在资源共享的应用环境中特别有用;在资源被其他地方占用时,无需停止执行线程。
响应式编程的基本概念
在过程式编程模型中,任务被描述为一系列按顺序执行的操作。另一方面,响应式编程模型促进了必要的安排来传播变化,这有助于决定做什么而不是如何做。
让我们用一个非常基础的例子来理解这个概念,如下所示:
int num1=3;
int num2=5;
int num3 = num1 + num2;
System.out.println("Sum is -->"+num3);
num1=6;
num2=8;
System.out.println("Sum is -->"+num3);
这就是我们通常在过程式编程风格中做的事情。在这段代码中,我们只是将两个分配给第三个数的数字相加,然后打印出来。在下一行,我们改变了初始两个数的值,但它并没有更新第三个数。这是因为 num1 + num2 在那一行被评估并赋值给 num3。现在考虑以下 Excel 表中的相同方程:

在这种情况下,E 列和 F 列的变化总是监听 G 列的变化。这就是响应式编程所做的事情。它将变化传播给对那些变化感兴趣的部分。
你可能在日常编码实践中无意中使用了响应式编程。例如,如果你创建了一个用户注册界面,其中验证用户输入的用户名是否已在系统中存在,进行 Ajax 调用并显示一条适当的消息说“此用户名已被使用”。
另一个例子是你在网页上通过鼠标点击或按键定义的监听器或回调函数。在这些情况下,鼠标点击和焦点消失事件(用于用户名验证)可以被视为你可以监听并执行适当动作或函数的事件流。
这只是事件流的一个用途。响应式编程允许你观察并对事件流引起的任何变化做出反应,如数据库中的变化、用户输入、属性更新、外部资源的数据等。让我们通过一个现实生活中的例子来理解它。
假设你想投资共同基金,并且有许多公司提供代为投资的设施。它们还会提供各种基金的表现统计数据,包括其历史、市场份额、资本投资比率等等。基于这些数据,它们会给出一些分类,如中等风险、低风险、中等高风险、高风险等等。它们还会根据每个基金的表现给出评级。
评级和分类将建议用户根据他们的需求(短期、长期等)和能够承担的风险类型选择特定的基金。评级和分类的变化可以被视为一个事件(或数据)流,这将导致系统更改对用户的建议。另一个数据流的实际例子是社交媒体动态,如 Facebook、Twitter 等。
函数响应式是一种以函数方式对数据流做出反应的范例,提供了额外的功能,如过滤器、组合、缓冲区、映射等。使用它们,你可以在数据流上执行某些操作,这有助于它更好地做出反应。以之前的共同基金例子,过滤器函数可以用来实时建议那些安全的投资基金。
响应式编程主要用于构建交互式用户界面和其他需要时间交互的系统,如图形应用程序、动画、模拟、聊天机器人等。
反压
在响应式编程中,你应该了解的一个重要概念是背压。它显著提高了响应式编程相对于传统代码的性能。它究竟是什么呢?它被认为是一种非阻塞的监管机制,用于发送异步消息或反馈到流源以进行负载调节。向流发送者的通信可能是停止请求或警报。然而,它也可能是关于接收者处理更多消息的意图。向发送者的通信必须是非阻塞的。这很重要。
考虑到可观察者(事件的来源)发送数据的速度高于订阅者实际处理的速度的情况。在这种情况下,订阅者会处于压力状态,无法正确处理流量,系统出现意外行为的高概率很高。为了避免这种情况,必须有一些安排来传达订阅者可以消耗数据的速度,并将其反馈给可观察者。
通知事件源机制,表示“嘿,我现在压力很大,所以不要发送更多消息,因为我可以在特定时间内消耗 X 条消息”,这种机制被称为背压。如果没有这种机制,系统可能会不断增大缓冲区大小,直到耗尽内存错误。当发射速度超过消费速度时,就需要背压。它将确保系统在负载下保持弹性,并提供用于做出决策的信息,即系统是否需要额外的资源。
响应式编程的好处
几年前,用户交互仅限于在网页上填写表单并将其提交给服务器。在当时,这对于自给自足的应用程序来说已经足够了。今天,在移动和响应式需求的时代,一个展示实时信息的丰富用户界面预计将提供广泛的交互可能性。
此外,像云环境、分布式应用程序、物联网和实时应用程序等不同类型的应用程序需要大量的用户交互。这可以通过响应式编程来实现。它用于构建松散耦合、响应式和可扩展的应用程序,这些应用程序对失败的容忍度更高。使用响应式编程有许多优点,如下所述:
-
资源利用:响应式编程的一个基本好处是优化硬件资源利用,如处理器、内存、网络等。它还通过减少序列化来提高性能。
-
增强用户体验:响应式编程通过使用异步机制来提供更好的用户体验,使应用程序更加流畅、响应迅速,并且易于交互。
-
一致性:你可以设计 API,使其在包括数据库调用、前端、网络、计算或任何其他你需要与响应式编程一起使用的东西在内的各个方面都具有更多的一致性。
-
轻松处理:响应式编程提供了对异步操作的一等支持和明显的机制,无需额外操作。此外,它还使处理 UI 交互和事件管理变得更加容易。
-
简单的线程管理:响应式编程使得它比常规的线程机制更简单。使用响应式编程来实现复杂的线程实现,使并行工作以同步方式进行,并在函数完成后执行回调,这些操作都更容易实现。
-
提高开发者生产力:在典型的命令式编程模型中,开发者必须做大量工作以保持一种简单直接的方法来实现异步和非阻塞计算。另一方面,响应式编程通过提供这些功能来应对这一挑战,因此开发者不需要在元素之间进行显式协调。
响应式编程技术
在大多数情况下,响应式编程是基于事件的。在响应式编程中,API 以以下两种风味公开:
-
回调:在这种类型中,匿名例程被注册为事件源作为回调函数。当数据流触发事件时,它们将被调用。
-
声明式:事件通过定义良好的函数组合被观察,如过滤器、映射以及其他基于流的操作,如计数、触发等。
响应式编程重视数据流而非控制流,因此将其视为数据流编程并不罕见。以下是一些用于实现响应式编程的技术:
-
未来和承诺:它被定义为定义变量并为其赋值的技术。尽管未来和承诺可以互换使用,但它们并不完全相同。未来用于描述变量的只读视图(或者说,定义变量),而承诺是一个可写、单次赋值的容器,用于在将来设置变量的值。
-
响应式流:它被定义为处理异步流的标准化,它使得从事件发起的源到观察它们的目标的非阻塞、背压转换变得可能。
-
数据流变量:它是一个其值依赖于给定输入、操作和其他单元格的变量,并在源实体发生变化时自动更新。你可以将数据流变量想象成一个电子表格单元格,其中一个单元格的值变化会导致基于分配公式的连锁反应影响其他单元格。
此外,还有各种前端库可用,如 React.js、AngularJS、Ractive.js、Node.js 等,它们用于开发响应式前端应用程序。其他提供对响应式应用程序原生支持的编程语言和框架包括 Scala、Clojure、GoLang,以及 Java 9 和 Spring 5。我们将在本章后面看到 Spring 5 的响应式特性。
Java 中的响应式编程
异步处理方法在处理大量数据或大量用户时是一个完美的选择。这将使系统响应更快,并提高整体用户体验。在 Java 中使用自定义代码实现异步处理将会很繁琐,并且更难实现。在这种情况下,响应式编程将是有益的。
Java 不像 Scala 或 Clojure 等其他基于 JVM 的编程语言那样提供对响应式编程的原生支持。然而,从版本 9 开始,Java 已经开始原生支持响应式编程。除了 Java 9 中的原生支持之外,还有其他实现层可以帮助使用较旧版本的 Java(如 Java 8)实现响应式编程。我们将看到其中的一些,如下所述。
响应式流
响应式流被简单地描述为一个提供异步流处理标准,具有非阻塞背压的倡议。这是一个简单直接的说法。然而,重要的是要注意,这里的第一个重点是异步流处理,而不仅仅是异步编程。如前所述,异步系统已经存在很长时间了。
在处理流之前,首先是接收流数据。异步地,这意味着在流的领域中管理不确定性的风险。例如,可能会有多少更多的数据或消息?另一个挑战可能是如何知道流何时完成发送数据。可能会有很多问题,我们将在稍后看到所有这些问题.
在 Java 中,响应式编程是通过使用响应式流(Reactive Streams)来实现的。它是一个由 Pivotal、Netflix、Red Hat、Twitter、Lightbend(之前称为 Typesafe)、Kaazing、Oracle 等公司合作制定的 API 规范,或者说是一个由多个公司提供的低级合同。你可以将响应式流 API 视为类似于 JPA 或 JDBC。实际的实现由各个供应商提供。
例如,JPA 规范有 Hibernate、TopLink、Apache OpenJPA 等供应商提供实际实现。同样,有许多流行的基于 JVM 的库支持响应式编程,如 Reactor、Akka stream、Ratpack、Vert.x 等。它们都提供了响应式流规范的实现,这带来了互操作性。
响应式流规范
让我们更详细地了解反应式流的规范。它处理流的异步处理。让我们看看在github.com/reactive-streams/reactive-streams-jvm上可用的规范。它包括以下两个部分:
-
API:这描述了规范。
-
技术兼容性套件(TCK):这是一个用于实现兼容性测试的标准测试套件或标准。简而言之,它将确保给定的实现符合声明的规范。
仔细查看 API,我们发现它相当简单,只包含以下四个接口:
-
发布者:此接口代表一个实体,它作为无界序列事件或元素的提供者。它将根据订阅者的需求发布元素。
-
订阅者:它代表了一个从发布者那里消费事件的消费者。为此,它将订阅发布者。
-
订阅:此接口说明了订阅者向发布者订阅或注册的过程。
-
处理器:它是发布者和订阅者的组合。它代表一个实现双方合同的加工阶段。
Java 9 开始为反应式流提供原生支持。这些接口的实现是 Java 9 中 Flow API 的一部分。查看包含反应式流的 JAR 文件的结构,我们发现以下结构:

这看起来相当直接,实现一组接口对于任何 Java 开发者来说都不应该是一个挑战。我们能否用这些接口的实现进入生产环境,并且它是否会给我们一个稳定的系统?我们是否准备好开始反应式开发?答案是,还不太行。
以异步方式传递消息是反应式流的关键关注领域。它确保不仅消费者不会因所有分布式系统而超负荷,发布者也会在有一个或多个订阅者处理消息较慢的情况下得到保护。它主要说明这是您应该以受保护的方式从线程A传递消息到线程B的方式,以确保发布者和订阅者都得到保护。
让我们进一步挖掘规范,(我们稍后会提到 TCK)并看看它们如何与反应式流宣言的原始声明相对应。从发布者开始,我们看到规范还定义了一套必须由规范的实现者遵守的规则。
规则为所有四个接口:发布者、订阅者、订阅和处理器定义。在这里不可能列出所有规则,也不需要这样做,因为规则可在以下位置找到:github.com/reactive-streams/reactive-streams-jvm/blob/v1.0.2/README.md。
然而,为了从响应式流宣言中提取一些相关性,让我们看看一些重要的规则。我们将从所有四个接口中各分析一两条规则,以帮助你了解它们的布局。在阅读这些规则和规范之前,请先查看术语表。
你应该看看其余的规则,因为通过阅读它们,你会对规则的详细程度有一个很好的了解。在你读完所有规则之后,你将对响应式流的实现预期有非常清晰的认识。
发布者规则
Publisher具有以下接口定义:
public static interface Publisher<T> {
public void subscribe(Subscriber<? super T> subscriber);
}
规则 1.1 指出,Publisher向Subscriber发出的onNext信号总数必须始终小于或等于该Subscriber订阅请求的总元素数. 这里有多重含义。让我们逐一分析它们:
-
首要的是,必须由
Subscriber(总数→1 - N)向Publisher发起消息请求。因此,Publisher不能自行开始向未察觉的订阅者发送消息,因为这些订阅者可能还在决定何时开始接收消息。此外,有些人可能仍在执行一些初始任务以开始接收消息。 -
其次,只有当
Publisher收到请求后,它才能开始向Subscriber传输消息。作为对Publisher消息请求的响应,Subscriber接收Subscription。现在Subscriber可以使用Subscription与Publisher交互,反之亦然。Subscription中说明了Publisher应该发送多少消息,因此Subscribers请求的消息数应该小于或等于该数字[message count <= total number]。 -
第三,
Publisher不能向Subscriber发送比Subscriber请求的更多的消息。
这三点共同构成了我们在开始介绍响应式流时提到的背压的一部分。
并且是的,根据其他规则,Subscriber从Publisher请求的计数对Publisher不具有约束力,即与消息计数无关。Publisher允许发送少于Subscriber请求的消息数。这可以用以下方式描述。
订阅者规则
Subscriber具有以下接口定义:
public interface Subscriber<T> {
public void onSubscribe(Subscription s);
public void onNext(T t);
public void onError(Throwable t);
public void onComplete();
}
规则 2.1 指出,Subscriber必须通过Subscription.request(long n)来表示需求,以接收onNext信号。这条规则与Publisher规则 1.1 相一致,因为它确立了Subscriber的责任,即告知何时以及愿意接收多少消息。
规则 2.4 指出,.onComplete()和Subscriber.onError(Throwable t)在接收到信号后必须考虑Subscription已被取消。在这里,再次突出了设计意图。发送消息的设计顺序确保了从Publisher到Subscriber的消息发送过程完全解耦。因此,Publisher不受Subscriber保持监听意图的约束,从而确保了非阻塞的安排。
一旦Publisher发送了一条消息,它就没有其他消息需要通过Subscriber.onComplete()发送,并且Subscription对象就不再有效/可用。这类似于当通过Subscriber.onError(Throwable t)抛出异常时。Subscription对象不能再被Subscriber用来请求更多消息。
值得一提的是,关于相同设计的一些其他规则。这些是规则 2.9 和 2.10,它们关注Subscription.request(long n)。规则指出,Subscriber可以在没有或有一个先前的Subscription.request(long n)调用的情况下,通过onError信号或onComplete信号获得。
订阅规则
以下接口描述了Subscription的表示法:
public interface Subscription {
public void request(long n);
public void cancel();
}
规则 3.2 指出,Subscription必须允许Subscriber在onNext或onSubscribe内部同步调用Subscription.request。它讨论了通过限制只有在Publisher从Subscriber那里收到进一步请求信号时才发布消息来防止Publisher和Subscriber。这是以同步方式进行,以避免栈溢出。
在类似的情况下,另一条规则,编号 3.3,指出,Subscription.request()必须在Publisher和Subscriber之间可能的同步递归上设置一个上限。它在某种程度上补充了规则 3.2,通过在递归交互中确定onNext()和request()调用的上限来做出决定。设置上限将避免在调用线程栈时崩溃。从编号 3.5 到 3.15 的规则描述了请求的取消和完成的行为。
处理器规则
处理器的接口定义如下:
public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {
}
它只有两条规则。第一条规则讨论了Subscriber和Publisher都必须遵守的合同,而第二条规则旨在处理错误情况,要么恢复,要么传播给Subscriber。
反应式流 TCK
仅实现 Reactive Streams 规范中定义的接口并不足以构建 Reactive Streams。该规范包括一系列组件和规则。组件部分由我们讨论的四个接口负责,而规则则由 Reactive Streams 的技术兼容性工具包(TCK)定义。
Reactive Streams TCK 是响应式 Streams 实现者的指南,用于验证其实现是否符合规范中定义的规则。TCK 是用 Java 中名为TestNG的测试框架开发的,并且可以在其他基于 JVM 的编程语言中使用,如 Kotlin 和 Scala。
TCK 涵盖了规范中定义的大部分规则,但不是全部,因为对于某些规则,无法构建自动化的测试用例。因此,从理论上讲,它不能完全符合规范;然而,它对于验证大多数重要规则是有帮助的。
TCK 包含四个 TestNG 测试类,包含测试用例,实施者可以扩展它们并提供自己的Publisher、Subscriber、Subscription和Processor实现,以验证规范规则。您可以从以下链接获取更多详细信息:github.com/reactive-streams/reactive-streams-jvm/tree/master/tck。
RxJava
从版本 8 开始,Java 开始支持作为内置功能的响应式特性,但它们并没有被广泛使用,也没有在开发者中流行起来。然而,一些第三方在 Java 中的响应式编程实现展示了其优势,并在 Java 社区中越来越受欢迎。
除了被称为Reactive Extension(或简称 ReactiveX)的工具集之外,没有其他任何东西可以用来实现用于组合异步和基于事件的程序的可观察序列的响应式编程。这是一个 Java VM(虚拟机)的 Reactive Extension 实现。最初在 Microsoft 平台上编写,Reactive Extension 为各种其他编程语言提供了响应式能力,其中最流行的是 Java 编程语言的 RxJava。
这是第一个针对 Java 平台的特定 Reactive Extension API。RxJava 与较旧的 Java 版本兼容,并提供了一个方便的设施来为 Java 和 Android 平台编写异步、基于事件的程序。ReactiveX 还涵盖了其他编程语言,如 RxJs、Rx.Net、UnixRx、RxScala、RxCloujure、RxCPP、Rx.rb 和 RxKotlin,以及其他平台和框架,如 RxCocoa、RxAndroid 和 RxNetty。
RxJava 的解剖结构
RxJava 基本上扩展了观察者模式以支持对事件/数据序列的迭代,并允许在同时抽象出低级细节,如线程、同步、并发和线程安全的同时形成序列。
在撰写本文时,RxJava-2.6 的当前版本仅依赖于 Reactive Streams API,并为 Java 6 及其后续版本以及 Android 2.3+提供支持。在深入探讨 RxJava 之前,让我们看看 ReactiveX 的基本构建块如下:
-
Observable:它基本上是一个数据流,换句话说,是一个数据源。它可以根据配置一次性或连续地定期发出数据。Observable可以根据与Observable一起使用的操作符在特定事件上发送特定数据。简而言之,Observable是向其他组件提供数据的数据提供者。 -
Observer:Observable发出的数据流由观察者消费。为此,它们需要使用subscribeOn()方法订阅Observable。一个或多个观察者可以订阅Observable。当Observable发送数据时,所有注册的观察者都会通过onNext()回调方法接收数据。一旦接收到数据,就可以对它执行任何操作。如果在传输过程中发生任何错误,观察者将通过onError()回调接收错误数据。 -
Scheduler:它们用于线程管理,以在 ReactiveX 中实现异步编程。它们将指导Observable和Observer选择特定的线程来执行操作。为此,Scheduler为Observer和Observable分别提供了observerOn()和scheduleOn()方法。
让我们通过一个实际例子来理解这些概念。我们将在 Eclipse 中创建一个 Maven 项目,其设置如下:

我们需要提供 RxJava 特定的依赖。当前版本是 2.2.6。添加依赖后,pom.xml应如下所示:
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>rx-java</groupId>
<artifactId>simple-rx-java-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Simple RxJava demo</name>
<dependencies>
<dependency>
<groupId>io.reactivex.rxjava2</groupId>
<artifactId>rxjava</artifactId>
<version>2.2.6</version>
</dependency>
</dependencies>
</project>
创建一个新的 Java 类,并添加以下代码到该类中:
public class RxJavaBasics {
public static void main(String[] args) {
/* Observable */
Observable<String> adminUsers =
Observable.just("Dave",
"John",
"Nilang",
"Komal",
"David");
/* Observer in form of lambda expression */
adminUsers.subscribe(s -> System.out.println(s));
}
}
adminUsers实例是类型为Observable<String>的实例,它推送五个字符串字面量(管理员用户的名称),这本质上是一个数据流或数据源。为了简单起见,我们使用了字符串字面量,但Observable可以从任何来源推送数据或事件,例如数据库查询结果、社交媒体动态、REST API 响应或任何类似的东西。
Observable.just()方法用于发出一组固定的字符串字面量。代码的最后一行描述了Observer如何使用subscribe()方法订阅Observable。它被定义为 lambda 表达式,指定了对从Observable接收到的字符串进行什么操作。这种关系可以用以下图示来描述:

在此代码中,Observer只是打印字符串字面量。RxJava 提供了几个可以在Observable和Observer之间使用的操作符。这些操作符用于转换或操作传递之间的每个推送数据。每个操作符处理来自先前Observable的数据,并返回新的Observable。让我们使用一个名为map的操作符,并按如下方式更新代码:
adminUsers.map(s->s.startsWith("D") ? s:"*******")
.subscribe(s -> System.out.println(s));
在此代码中,adminUsers可观察发射的数据在发送到Observer之前通过一个map操作符。这里的map操作符提供了一个 lambda 表达式,用于处理从adminUsers提交的数据。它基本上如果返回字符串以D开头则打印返回的字符串,否则简单地返回一个带星号标记(*)的字符串。map操作符返回一个新的Observable,该Observable返回由map操作符处理的数据,并将其最终发送到Observer。您将看到以下输出:

观察者事件调用
我们到目前为止讨论的是关于如何在 RxJava 中使用Observable的非常高级的信息。它基本上通过一系列操作符(如果定义)将给定类型的项(数据或事件)推送到Observer。让我们深入了解更多细节,以了解在交互过程中底层工作的机制以及 RxJava 如何遵守响应式流规范。
Observable通过以下事件调用与Observers交互:
-
onNext:这是数据/事件逐个发送到所有已注册Observers的调用。 -
onComplete:此事件用于向所有人发出通信完成的信号。 -
Observers:它简单地表示不再发生onNext调用。 -
onError:在onComplete()调用之前发生任何错误时,使用onError()事件从Observable向Observers发出错误信号。Observable将停止发射数据,而Observers将处理错误。
这些事件被定义为Observer类型的抽象方法,我们将在本章后面看到其实际实现类型。首先,让我们看看在以下代码与交互过程中这些事件调用是如何发生的:
public class RxJavaCreateDemo {
public static void main(String[] args) {
Observable<String> daysOfWeek = Observable.create(
sourceEmitter -> {
try {
sourceEmitter.onNext("Sunday");
sourceEmitter.onNext("Monday");
sourceEmitter.onNext("Tuesday");
sourceEmitter.onNext("Wednesday");
sourceEmitter.onNext("Thursday");
sourceEmitter.onNext("Friday");
sourceEmitter.onNext("Saturday");
sourceEmitter.onComplete();
}catch(Exception e) {
sourceEmitter.onError(e);
}
});
Observable<String> daysInUpperCase= daysOfWeek.map(day->day.toUpperCase())
.filter(day->day.startsWith("S"));
daysInUpperCase.subscribe(day->System.out.println("Day is -->"+day));
}
}
Observable.create()是一个工厂方法,用于使用发射器创建Observable。发射器的onNext()方法用于向Observable链(最终到已注册的Observers)发射(发送)数据/事件(逐个)。onComplete()方法用于终止进一步的通信。
如果在onComplete()之后尝试进行onNext()调用,则数据将不会传输。如果发生任何错误,将调用onError()方法。它用于将错误推送到由Observer处理的Observable链。在此代码中,没有异常发生的可能性,但您可以使用onError()处理任何错误。
我们使用了map和filter算子来将数据转换为大写并分别以D开头。最后,它们通过Observer打印出来。数据流将从onNext() → map → filter → Observer发生。每个算子将返回链中的新Observable类。
你会注意到,在第一个例子中,我们使用了Observable.just()方法来发射数据。它内部为每个推送的值调用onNext()方法。在获取最后一个值后,它将调用onComplete()。因此,Observable.just()相当于Observable.create()在每一个数据上调用onNext(),在最后一个数据上调用onComplete()。create()方法通常用于非反应式源。
可观察的迭代器
Observable支持从任何可迭代源发射数据,例如列表、映射、集合等。它将为可迭代类型的每个项目调用onNext(),一旦迭代器结束,它将自动调用onComplete()。Java 中的可迭代通常用于集合框架,因此带有可迭代的Observable可以在从集合类获取数据时使用。
让我们看看如何使用它,如下所示:
public class RxJavaIterableDemo {
public static void main(String[] args) {
List<EmployeeRating> employeeList = new ArrayList<EmployeeRating>();
EmployeeRating employeeRating1 = new EmployeeRating();
employeeRating1.setName("Lilly");
employeeRating1.setRating(6);
employeeList.add(employeeRating1);
employeeRating1 = new EmployeeRating();
employeeRating1.setName("Peter");
employeeRating1.setRating(5);
employeeList.add(employeeRating1);
employeeRating1 = new EmployeeRating();
employeeRating1.setName("Bhakti");
employeeRating1.setRating(9);
employeeList.add(employeeRating1);
employeeRating1 = new EmployeeRating();
employeeRating1.setName("Harmi");
employeeRating1.setRating(9);
employeeList.add(employeeRating1);
Observable<EmployeeRating> employeeRatingSource =
Observable.fromIterable(employeeList);
employeeRatingSource.filter(employeeRating ->
employeeRating.getRating() >=7).subscribe(empRating ->
System.out.println("Star Employee: " + empRating.getName()
+ " Rating : "+empRating.getRating()));
}
}
我们正在使用fromIterable()方法通过传递这个列表来填充EmployeeRating列表并创建Observable。EmployeeRating类是一个简单的 POJO,包含以下name和rating属性:
class EmployeeRating{
private String name;
private int rating;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getRating() {
return rating;
}
public void setRating(int rating) {
this.rating = rating;
}
}
RxJava 通过提供接口的实现来符合反应式流规范。让我们回忆一下,onNext()、onError()、onSubscribe()和onComplete()方法是观察者接口的一部分。RxJava 提供了这些接口的实现来处理相应的事件。
自定义观察者
我们已经看到数据是如何从Observable发射出来,通过算子流向下传递,最终到达Observer的。显然,我们可以这样说,数据是从一系列Observable传输的,因为每个算子返回新的Observable,形成一个Observable链。第一个发射源头的Observable被称为Observable源。因此,我们可以这样说,Observable.create()和Observable.just()返回Observable源。
我们可以提供我们的自定义实现来处理Observer事件,如下所示:
public class RxJavaCustomObserverDemo {
public static void main(String[] args) {
Observable<String> months =
Observable.just("January", "February", "March", "April",
"May","June","July","August");
Observer<String> customObserver = new Observer<String>() {
@Override
public void onSubscribe(Disposable d) {
System.out.println(" Subscription initiated ...");
}
@Override
public void onNext(String value) {
System.out.println("The value " + value +" is received from Observable");
}
@Override
public void onError(Throwable e) {
e.printStackTrace();
}
@Override
public void onComplete() {
System.out.println("Done!");
}
};
months.filter(month -> month.endsWith("y"))
.subscribe(customObserver);
}
}
与之前的例子一样,我们使用月份列表定义了Observable。我们还定义了自定义Observers,并实现了将在特定事件上被调用的各种方法。当我们注册观察者(在我们的例子中是customObserver)时,Observable将在Observer上调用onSubscribe()方法。
每次当Observable发射数据时,它将调用注册观察者的onNext(),然后由观察者处理。在发送最后一个数据时,Observable将在Observer上调用onComplete()方法。如果在中间发生任何错误,Observable将在Observer上调用onError()方法。
当然,数据将通过Observable链传递。在前一个例子中,从Observable源(在这种情况下是months)发出的数据将被传递到下游的filter操作符,然后到达观察者或数据消费和处理的端点。通过处理,我们指的是数据可以被保存到数据库中,作为服务器响应发送,写入外部文档管理系统,组成用于 UI 渲染的结构,或者简单地打印到控制台。
你将得到以下输出:

在此示例中,我们使用匿名类来提供观察者方法的自定义实现。然而,你可以使用 lambda 表达式来完成这个目的。
Observable 类型
在我们之前 RxJava 部分的子节中看到的示例中,数据是在 Observable 中创建的。然而,在实际场景中,这些数据来自数据库、REST API 等其它来源。任何一组数据/值的表示被称为生产者。根据程序引用的位置,Observables 大致分为以下两类。
冷 Observable
当Observable本身创建程序或,比如说,Observable产生数据流本身时,它被称为冷Observable。一般来说,Observable是惰性的,这意味着它只有在任何观察者订阅它时才会发出数据。冷Observable总是为每个订阅者启动新的执行。
换句话说,冷Observable为每个观察者发出单独的数据/事件流。我们迄今为止看到的所有示例都是冷Observable类型,其中我们使用just()或create()方法创建数据流。让我们看看以下示例,看看冷Observable在多个观察者订阅时的表现。
public class RxJavaColdObservable {
public static void main(String[] args) {
Observable<String> source =
Observable.just("One","Two","Three","Four","Five");
//first observer
source.filter(data->data.contains("o"))
.subscribe(data -> System.out.println("Observer 1 Received:" + data));
//second observer
source.subscribe(data -> System.out.println("Observer 2 Received:" + data));
}
}
在此代码中,数据是由Observable本身创建的,因此被称为冷 Observable。我们订阅了两个不同的观察者。当你运行此代码时,你将得到以下输出:

冷Observable为每个Observer提供单独的数据流,因此当我们为第一个Observer应用过滤器时,对第二个Observer没有影响。如果有多个Observer,那么Observable将依次向所有观察者发出数据序列。
热 Observable
另一方面,热Observable的生产者是在它外部创建或激活的。热Observable发出所有观察者共享的流。让我们看看以下示例:
public class RxJavaHotObservable1 {
public static void main(String args[]) {
Observable<Long> observableInterval = Observable.interval(2, TimeUnit.SECONDS);
PublishSubject<Long> publishSubject = PublishSubject.create();
observableInterval.subscribe(publishSubject);
publishSubject.subscribe(i -> System.out.println("Observable #1 : "+i));
addDelay(4000);
publishSubject.subscribe(i -> System.out.println("Observable #2 : "+i));
addDelay(10000);
}
private static void addDelay(int miliseconds) {
try {
Thread.sleep(miliseconds);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在这个例子中,observableInterval Observable发出的是事件而不是数据。interval方法用于在给定的时间间隔内发出连续的数字。我们使用了PublishSubject来使这个Observable成为热类型。它可以表现为Observable或Observer。在这种情况下,它是Observable链的一部分。然后我们简单地向PublishSubject添加两个订阅者,并在它们之间设置一些延迟。你将得到以下输出:

第二个Observer在延迟之后订阅了第一个Observer。Observable每两秒发出一个连续数字。第二个Observer从第四秒开始。热Observable只发出一个流,这个流被所有Observer共享。因此,对于第二个Observer来说,实际值从2开始,而不是0,因为它在一段时间后才开始订阅。
在这种意义上,热Observable可以与对广播电台的订阅相比较。开始收听的人将无法听到他订阅之前播放的内容,因为它对所有订阅者(或者说在响应式语言中的Observer)都是共同的。还有其他创建热Observable的方法。以下我们将看到其中一种:
public class RxJavaHotObservable2 {
public static void main(String args[]) {
Observable<Long> observableInt = Observable.interval(2, TimeUnit.SECONDS);
ConnectableObservable<Long> connectableIntObservable = observableInt.publish();
connectableIntObservable.subscribe(i -> System.out.println("Observable #1 : "+i));
connectableIntObservable.connect();
addDelay(7000);
connectableIntObservable.
subscribe(i -> System.out.println("Observable #2 : "+i));
addDelay(10000);
}
private static void addDelay(int miliseconds) {
try {
Thread.sleep(miliseconds);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在此代码中,使用ConnectableObservable创建了热Observable。它不会开始发出数据,直到对其调用connect方法,这使得它更加可控。在调用connect方法不久之后,它将开始一个单一的流,这个流被所有Observer共享。你将得到以下输出:

你可以看到第二个Observer是如何因为延迟订阅而错过了前几个项目的。你可以通过在它上面调用publish方法将任何冷Observable转换为ConnectableObservable。
获取Observable的其他方式
到目前为止,我们已经看到了如何使用just()、create()和interval()获取Observable。然而,还有其他获取Observable的来源。你可以从以下位置获取每个来源的详细信息:github.com/ReactiveX/RxJava/wiki/Creating-Observables
-
range:如果你想发出一个连续的整数范围,你可以使用Observable.range(int from, int to)调用。正如其名所示,它将从起始值开始以增量增加,直到达到结束计数。 -
empty:在罕见的情况下,你需要创建一个发出什么也不调用onComplete()的Observable。在这种情况下,你可以使用这个源类型通过Observable.empty()调用。 -
never:它与empty等价,区别在于它永远不会调用onComplete(),并使Observable等待以发出状态。这也不常使用。 -
错误:如果你希望创建一个立即调用onError()的Observable,你可以使用这个源通过Observable.error()调用。这主要用于测试目的。 -
future:它是在很久以前引入的,用作尚未生成的结果的占位符。可观察对象比future更强大,但如果你在使用旧库,可以使用Observable.future()调用将可观察对象转换为future。 -
defer:这基本上用于为每个观察者创建一个单独的状态。当流源是状态性的时很有用。如果你想让你的观察者反映可观察状态的变化,那么你可以使用这个源类型并通过Observable.defer()调用。 -
single:这种类型的可观察对象只发射一个值,可以使用Single.just()方法调用。 -
maybe:它与single类型相似,唯一的区别是它最多发射零或一个数据,并且可以使用Maybe.just()调用。 -
fromCallable:如果你想在发射数据之前执行某些计算操作,可以使用这个源并通过Observable.fromCallable()调用。如果在执行过程中发生任何错误,并且你想通过onError()调用将错误传递给Observable链而不是抛出错误,可以使用此源类型。
运算符
在之前的示例中,我们已经看到了像 map 和 filter 这样的运算符。它们基本上用于对数据流执行特定操作,并返回新的可观察对象以形成可观察链。运算符本身是它们所调用的可观察对象的观察者。
RxJava 有一套丰富的运算符,用于执行以下类别的各种操作:
-
创建可观察对象:用于创建新
可观察对象的一组运算符。 -
转换可观察对象:用于转换它们所调用的可观察对象发射的项目。
-
过滤可观察对象:用于发射选择性数据的运算符。
-
组合可观察对象:用于将多个源可观察对象组合成一个单一的
可观察对象。 -
错误处理:用于从
可观察对象通知的错误条件中恢复的运算符。 -
实用运算符:用于对
可观察对象执行一些杂项操作。 -
条件运算符和布尔运算符:用于评估一个或多个
可观察对象或甚至已发射的项目。 -
数学和聚合:用于对整个发射数据序列执行各种操作的运算符。
值得访问:reactivex.io/documentation/operators.html,以获取每个运算符的完整详细信息,而不是在这里列出详细信息。
项目 Reactor
Reactor 可以称为 JDK 之上的反应式库。Java 不支持原生的反应式编程,Reactor 是众多库之一。Reactor 来自开源组织 Pivotal,并符合反应式流标准。它是基于 Java 8 和 ReactiveX 词汇构建的。
值得注意的是,尽管异步似乎是对 Reactive Programming 重要的属性,但 Reactor 并不强迫您选择异步/同步,因为它支持两者。这取决于选择的调度器。选择权在您手中。为了更好地理解 Reactor,我们需要更详细地了解 Reactive Streams。
Reactor 功能
Reactor 提供基于事件的架构,用于处理大量并发和异步请求,从而构建一个非阻塞和具备背压的系统。使用 Project Reactor,您无需自己实现 Reactive Streams,因为它提供了一套嵌入式且可互操作的模块。它提供了以下令人惊叹的功能:
处理高容量数据流
Project Reactor 能够提供特定数据基数(从生成无限流到发布单个数据条目)的 API 支持。
Project Reactor 允许订阅者在数据流元素到达时即时处理它们,而不是等待整个数据流处理完毕。这使得数据处理操作更加灵活和优化,通过提高资源利用率。分配给订阅者的内存需求有限,因为数据处理发生在特定时间到达的项的子集中,而不是一次性处理整个数据流。这也使得系统更加响应,因为结果将从接收到的第一组元素开始,而不是等待所有项目接收和处理完毕后交付最终输出。
推/拉机制
Project Reactor 对实现推/拉功能提供了良好的支持。存在一些实际场景,其中消费者以比生产者发射数据更慢的速度摄取数据。在这种情况下,生产者将引发事件并等待观察者拉取它。在某些情况下,消费者的工作速度比生产者快。为了处理这种情况,消费者等待从生产者端推来的事件。Project Reactor 允许在必要时使这种流程具有动态性。它将由生产和消费的速度来控制。
独立处理并发
Reactor 执行范式能够独立处理并发,这真正使其与并发无关。Reactor 库以更抽象的方式处理数据流,而不是讨论如何执行不同类型的流。在各种操作期间发生的交易是默认安全的。Reactor 提供了一套操作符,以不同的方式处理不同的同步流。
操作符
Reactor 提供了一套操作符,通过以不同的方式处理不同的同步流,在执行模型中发挥着至关重要的作用。这些操作符可用于过滤、映射、选择、转换和组合数据流。它们可以与其他操作符结合使用,构建高级、易于操作和高度定制的数据管道,以您希望的方式处理流。
Reactor 子项目
Project Reactor 由以下子项目组成:
-
反应堆核心: 此项目提供了一个响应式流规范的实现。Spring Framework 5.0 通过以 Reactor Core 子项目为基础,提供了对响应式编程的支持。
-
Reactor Test: 它包含测试验证所需的必要工具。
-
Reactor Extra: 在 Reactor Core 之上,此项目提供了各种操作符,用于在数据流上执行所需操作。
-
Reactor IPC: 此项目在各种网络协议(如 HTTP、TCP、UDP 和 WebSocket)上提供带背压的非阻塞进程间通信支持。由于这种特性,此模块在构建异步微服务架构时也很有帮助。
-
Reactor Netty: 它用于向 Netty(一个用于开发网络应用程序的客户端服务器框架)提供响应式功能。
-
Reactive Kafka: 它是基于 Apache Kafka 的项目的响应式 API。它用于以非阻塞和函数式的方式与 Kafka 进行通信。
-
Reactive RabbitMQ: 此项目用于为 RabbitMQ(一个消息代理系统)提供响应式功能。
Reactor 类型
Project Reactor 是基于它们处理的元素数量构建的两个核心类型。它们被认为是使用 Reactor 创建响应式系统的主构建块。它们是 Flux 和 Mono。它们都实现了 Publisher<T> 接口,并符合响应式流规范,并配备了反应式拉取和背压功能。它们还有其他一些有用的方法。以下将详细探讨:
-
Flux:它可以被认为是 RxJava 的 Observable 的等价物,可以发出零个或多个项目,以成功或错误信号结束。简而言之,它代表具有零个或多个元素的异步事件流。 -
Mono:它一次最多只能发出一个元素。它与 RxJava 侧的Single和MaybeObservable 类型等价。Mono类型可用于一对一请求-响应模型实现;例如,一个希望发送完成信号的作业可以使用Mono类型反应器。
反应堆类型可以处理元素数量的明显差异提供了有用的语义,并使得选择哪种反应堆类型变得容易。如果模型是某种 fire and forget,则选择 Mono 类型。如果执行涉及流中的多个数据项或元素,则 Flux 类型更为合适。
此外,各种操作符在决定反应器类型方面起着至关重要的作用。例如,在 Flux<T> 类型上调用 single() 方法将返回 Mono<T>,而使用 concatWith() 将多个 Mono<T> 类型的实体连接起来将得到 Flux<T> 类型。反应器类型可以影响我们可以与之一起使用的操作符。例如,一些操作符适用于 Flux 或 Mono 中的任何一个,而另一些则可以用于两者。
Reactor 动作
让我们通过一个实际示例来了解更多关于反应器 API 的信息。创建一个新的 Maven 项目,类似于我们在 RxJava 的解剖结构 部分中创建的项目。截至写作时,Project Reactor 的当前版本是 3.2.6。我们需要为反应器提供一个 Maven 依赖项,如下所示:
<project
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>reactor-demo</groupId>
<artifactId>simple-reactor-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Smiple Reactor Dmo</name>
<dependencies>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.2.6.RELEASE</version>
</dependency>
</dependencies>
</project>
当我们定义一个 Reactor 依赖项时,Reactive Streams JAR 将作为传递依赖项添加。接下来,需要添加一个 Java 类,如下所示:
public class ReactorBasic {
private static List<String> carModels = Arrays.asList(
"Era","Magna","Sportz","Astha","Astha(O)");
public static void main(String args[]) {
Flux<String> fewWords = Flux.just("Hello", "World");
Flux<String> manyWords = Flux.fromIterable(carModels);
Mono<String> singleWord = Mono.just("Single value");
fewWords.subscribe(t->System.out.println(t));
System.out.println("-----------------------------");
manyWords.subscribe(System.out::println);
System.out.println("-----------------------------");
singleWord.subscribe(System.out::println);
}
}
我们已经使用 Flux 和 Mono 创建了各种发布者。just() 方法用于填充流。我们还可以使用 fromIterable() 方法访问可迭代类型(如 List、Set、n),并通过该方法形成数据流。还有一些其他方法,如 from()、fromArray() 和 fromStream(),分别用于从其他生产者、数组以及现有的 Java 流构建数据流,并可以如下使用:
public class ReactorFromOtherPublisher {
public static void main(String[] args) {
Flux<String> fewWords = Flux.just("One","Two");
/* from array */
Flux<Integer> intFlux = Flux.fromArray(new Integer[]{1,2,3,4,5,6,7});
/* from Java 8 stream */
Flux<String> strFlux = Flux.fromStream(Stream.of(
"Ten", "Hundred", "Thousand", "Ten Thousands", "Lac","Ten Lac", "Crore"));
/* from other Publisher */
Flux<String> fromOtherPublisherFlux = Flux.from(fewWords);
intFlux.subscribe(System.out::println);
strFlux.subscribe(System.out::println);
fromOtherPublisherFlux.subscribe(System.out::println);
}
}
订阅者可以通过 subscribe() 方法连接。这与我们在 RxJava 中的 Observable 所做的是类似的。使用 Flux,我们可以创建一个有限或无限的流发布者。
我们还可以控制生成带有值的流或只是一个空流。所有这些都可以通过 Flux 类提供的几个实用方法来完成,如下所示:
-
Flux.empty(): 它用于生成一个没有值且只执行完成事件的空流。 -
Flux.error(): 它通过生成一个没有任何值但只有错误的错误流来表示错误条件。 -
Flux.never(): 如其名所示,它生成一个没有任何类型事件的流。 -
Flux.defer(): 当订阅者订阅Flux时,它用于构建发布者。简而言之,它是惰性的。
订阅者类型
Flux 和 Mono 类都允许使用 Java 8 lambda 表达式作为订阅者。它们还支持 subscribe() 方法的各种重载版本,如下面的代码所示。
public class ReactorWithSubscriberWays {
public static void main(String[] args) {
List<String> monthList = Arrays.asList(
"January","February","March","April","May");
Flux<String> months = Flux.fromIterable(monthList);
/* 1) No events is consumed. */
months.subscribe();
/* 2) Only value event is consumed */
months.subscribe(month->System.out.println("->"+month));
/* 3) Value and Error (total 2) events are handled */
months.subscribe(month->System.out.println("-->"+month),
e->e.printStackTrace());
/* 4) Value, Error and Completion (total 3) events are subscribed */
months.subscribe(month->System.out.println("--->"+month),
e->e.printStackTrace(),
()->System.out.println("Finished at THIRD PLACE.. !!"));
/* 5) Value, Error, Completion and Subscription (total 4) events are subscribed */
months.subscribe(month->System.out.println("---->"+month),
e->e.printStackTrace(),
()->System.out.println("Finished at FOURTH PLACE ..!!"),
s -> {System.out.println("Subscribed :");
s.request(5L);});
}
}
Flux 类使用字符串列表创建。subscribe() 方法有五种不同的使用方式,每种方式都提供了捕获各种事件的能力。具体如下:
-
第一种版本不消耗任何事件。
-
第二种变体消耗值事件,并且它使用 lambda 表达式定义。
-
第三个
subscribe()方法作为第二个参数监听错误事件以及值事件。我们只是通过 lambda 表达式简单地打印堆栈跟踪。 -
第四个版本消耗值、错误和完成事件。在数据流完成时,将执行完成事件,我们通过 lambda 表达式来监听它。
-
第五个版本消耗值、错误、完成和订阅事件。
Subscription类型的最后一个参数使这个版本的subscribe()成为特例。Subscription类型有一个名为request()的方法。发布者不会发送任何事件,除非和直到订阅者通过Subscription.request()调用发送一个需求信号。这仅适用于为订阅者定义了Subscription的情况。我们必须进行方法调用s.request(5L),这意味着发布者只能发送五个元素。这少于发布者中的总元素数,并触发完成事件。在我们的例子中,数据流中的总元素数是五个,因此它将调用完成事件。如果你传递少于五个,你将不会收到完成事件调用。
自定义订阅者
在某些场景下,在 Publisher 上调用 Subscribe 方法是不合适的,你可能想编写具有自己处理的自定义订阅者。Reactor 框架通过扩展 reactor.core.publisher.BaseSubscriber<T> 抽象类来提供定义自定义订阅者的支持。你不需要直接实现 Reactive Streams 规范的 Subscribe 接口。相反,你需要仅扩展这个类,如下所示来应用自定义实现:
static class CustomSubscriber extends BaseSubscriber<String>{
@Override
protected void hookOnSubscribe(Subscription subscription) {
System.out.println("Fetching the values ...!!");
subscription.request(10);
}
@Override
protected void hookOnNext(String value) {
System.out.println("Fetchig next value in hookOnNext()-->"+value);
}
@Override
protected void hookOnComplete() {
System.out.println("Congratulation, Everything is completed successfully ..!!");
}
@Override
protected void hookOnError(Throwable throwable) {
System.out.println("Opps, Something went wrong ..!! "+throwable.getMessage());
}
@Override
protected void hookOnCancel() {
System.out.println("Oh !!, Operation has been cancelled ..!! ");
}
@Override
protected void hookFinally(SignalType type) {
System.out.println("Shutting down the operation, Bye ..!! "+type.name());
}
}
BaseSubscriber 类提供了各种钩子方法,这些方法代表相应的事件。它是一个占位符,用于提供自定义实现。实现这些方法类似于使用我们在“订阅者类型”部分看到的 subscribe() 方法的各种版本。例如,如果你只实现了 hookOnNext、hookOnError 和 hookOnComplete 方法,那么它就等同于 subscribe() 的第四个版本。
hookOnSubscribe() 方法促进了订阅事件。背压通过 subscription.request() 提供。你可以请求任意数量的元素。例如,按照以下方式更新 hookOnSubscribe() 方法的代码:
@Override
protected void hookOnSubscribe(Subscription subscription) {
System.out.println("Fetching the values ...!!");
for(int index=0; index<6;index++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
subscription.request(1);
}
}
我们通过在循环中调用 subscription.request(1) 逐个请求记录。为了了解它是如何工作的,我们在请求之间加入了两秒的延迟,这样你将每两个请求得到一个记录。一旦所有数据完成,它将触发完成事件,并调用 hookOnComplete() 方法。输出将如下所示:

Reactor 生命周期方法
Reactor 提供了生命周期方法来捕获发布者-订阅者通信中发生的各种事件。这些生命周期方法与 Reactive Streams 规范保持一致。Reactor 生命周期方法可用于为给定事件钩子自定义实现。让我们通过以下代码了解它是如何工作的:
public class ReactorLifecycleMethods {
public static void main(String[] args) {
List<String> designationList = Arrays.asList(
"Jr Consultant","Associate Consultant","Consultant",
"Sr Consultant","Principal Consultant");
Flux<String> designationFlux = Flux.fromIterable(designationList);
designationFlux.doOnComplete(
() -> System.out.println("Operation Completed ..!!"))
.doOnNext(
value -> System.out.println("value in onNext() ->"+value))
.doOnSubscribe(subscription -> {
System.out.println("Fetching the values ...!!");
for(int index=0; index<6;index++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
subscription.request(1);
}
})
.doOnError(
throwable-> {
System.out.println("Opps, Something went wrong ..!! "
+throwable.getMessage());
})
.doFinally(
(signalType->
System.out.println("Shutting down the operation, Bye ..!! "
+signalType.name())))
.subscribe();
}
我们使用列表中的数据创建Flux对象,然后以链式调用各种生命周期方法,如doOnComplete()、doOnNext()、doOnSubscribe()、doOnError()和doOnTerminate()。最后,我们调用subscribe()方法,它不会消费事件,但所有生命周期方法将在适当的事件触发时执行。
这与自定义订阅者部分中的自定义订阅者实现类似。你将看到类似的输出。这些生命周期方法的详细信息如下:
-
doOnComplete(): 一旦Subscriber接收到了所有数据,这个方法就会被调用。 -
doOnNext(): 这个方法将监听来自生产者的值事件。 -
doOnSubscribe(): 用于连接Subscription。它可以通过定义使用subscription.request()调用需要多少更多元素来控制背压。 -
doOnError(): 如果发生任何错误,这个方法将被执行。 -
doOnTerminate(): 一旦操作完成,无论是成功还是出错,这个方法将被调用。在手动取消事件中不会考虑它。 -
doOnEach(): 如其名所示,它将在流处理期间引发的所有Publisher事件上被调用。 -
doFinally(): 由于错误、取消或事件的成功完成,这个方法将在流关闭时被调用。
Ratpack
Ratpack 是一组 Java 库,它们是事件驱动的、非阻塞的、高性能的,并且本质上是异步的,用于构建具有 HTTP 的扩展服务。它符合 Reactive Streams 规范,这意味着它自带互操作性。它是基于 Netty 构建的——一个用于在网络中构建客户端-服务器应用程序的框架,具有快速和简单开发的特点。
它是一个用于开发高效和轻量级基于 JVM 的应用程序的 Web 框架。它有自己的测试库,可以轻松设置测试用例。Spring 支持 Ratpack。你可以从其官方网站了解更多关于 Ratpack 的信息:ratpack.io。
Akka stream
Akka stream 在 Akka 工具包之上提供了一个 Reactive Streams 规范的实现,它使用 Actor 模式进行并发执行模型。它使用 Actor 异步和非阻塞地处理数据流,并提供了背压。除了 Java 之外,Akka 还与 Scala 语言很好地协同工作。更多关于 Akka stream 的信息,请访问链接akka.io/docs。
Vert.x
Vert.x 是 Eclipse Foundation 项目提供的另一个工具包,用于构建基于 JVM 的响应式系统。它还提供了类似于 Ratpack 的 Reactive Streams 规范的实现。Vert.x 支持并允许使用 RxJava 构建响应式系统。不用说,Vert.x 本质上是基于事件的和非阻塞的。它支持各种编程语言,如 Java、JavaScript、Ruby、Groovy、Ceylon、Scala、Kotlin 等。更多关于它的信息,请访问vertx.io。
Spring 框架中的响应式支持
Spring 是一个模块化框架,用于构建应用程序的各个方面,从 Web 层到持久化层。每个模块都被视为一个子框架,针对特定的开发领域。例如,为了支持使用 Servlet API 的 Web 层,Spring MVC 模块被包含在 Spring 框架中。
类似地,为了在 Web 层支持响应式堆栈,Spring 框架 5.0 引入了 Spring WebFlux。它是完全非阻塞的、支持背压、异步的,并且符合响应式流规范。它可以在 Servlet 3.1+、Netty 和 Undertow 容器上运行。
Spring 框架同时提供了两个堆栈,即 Spring Web MVC 和 Spring WebFlux,开发者可以自由选择使用其中的任何一个,或者在某些场景下混合使用它们来开发基于 Spring 的 Web 应用程序。一个典型的例子是使用 Spring MVC 控制器与响应式 WebClient;我们将在本章的后续部分更多地讨论这一点。
Spring WebFlux
Spring 5 通过 Spring WebFlux 令人印象深刻地支持创建响应式系统。它是一个基于 Project Reactor API 开发的新响应式 Web 应用程序框架,也可以用来构建微服务。将任何应用程序变为响应式的最显著和直接的好处是为其带来异步特性。
非响应式和基于传统的 Java 应用程序使用线程机制进行异步和并行编程。然而,线程的使用在任何方式上都不够高效和可扩展。另一方面,Spring WebFlux 鼓励基于事件循环的编程,这种方式是异步和非阻塞的。本节在 Spring 框架和响应式编程的背景下介绍了 WebFlux。
Spring MVC 与 Spring WebFlux 的比较
Spring MVC 自 2.0 版本以来一直是 Spring 框架的一部分,并且从那时起,在用 Spring 框架开发基于 Web 的应用程序时,已经成为事实上的标准。为了支持响应式编程,Spring 引入了 WebFlux 模块。因此,了解 Spring MVC 和 Spring WebFlux 之间的相似之处和不同之处非常重要。
Spring 团队采取了艰难的方式,使 WebFlux 的语法与 Spring MVC 相似,但在底层它采用了完全新的技术。这两个模块之间主要的区别在于它们处理请求的机制。Spring MVC 基于纯 Servlet API,并使用线程池。这意味着每个请求从控制器到持久化层都有一个线程,并且可能因为所需的资源而被阻塞。
然而,Spring WebFlux 基于响应式架构,并使用事件循环机制,提供开箱即用的非阻塞支持。在事件循环机制中,所有事情都是对事件的反应。它类似于回调函数;当任何事件发生时,回调函数就会被触发。事件循环的概念是由 Node.js 引入的。
在内部,WebFlux 需要 servlet API 支持,它作为一个适配层,使得 WebFlux 可以部署在 servlet 和非 servlet 容器上。Spring MVC 建立在 servlet API 之上,本质上是一种同步的(如 Filter、Servlet 等)API,并且执行阻塞 IO 流。
另一方面,WebFlux 是基于异步 API(WebHandler、WebFilter 等)和非阻塞 IO 机制(如Flux和Mono)开发的,分别用于处理最多一个值和多个元素的流。尽管 Spring WebFlux 基于 reactor 并且默认使用,但它也支持其他响应式实现,如 Java 9 Flow API、RxJava 和 Akka stream。
然而,这两个框架都支持一些共同特性,例如使用一些注解(如@Controller和@RequestMapping)以及对一些知名服务器的支持。
我们在谈论 Spring 中 WebFlux 对响应式编程的支持;这并不意味着 Spring MVC 没有用处。这两个框架都在解决应用的不同关注点。就像任何框架一样,WebFlux 可能不是所有应用类型的最佳选择。
因此,与其根据其特性选择框架,不如根据需求选择。如果你的现有 Spring MVC 应用运行良好,完全没有必要将其完全迁移到 WebFlux。WebFlux 的出色之处在于,它可以与 Spring MVC(如果需要明确指定)一起使用,而不会出现任何问题。
除了这个之外,如果你的现有 Spring MVC 应用依赖于其他本质上是同步和阻塞的部分,那么适应 WebFlux 特定的更改将阻碍充分利用响应式范式。然而,你可以决定,如果你的应用主要是处理数据流,那么可以选择 WebFlux。如果你在寻找可扩展性和性能,那么你可以在应用中使用 WebFlux 特定的更改。
响应式跨越 Spring 模块
通过引入响应式 Web 框架 WebFlux,Spring 也对其他模块进行了必要的修改,以提供对 WebFlux 的一级支持。Spring Boot、Spring Security、Thymeleaf 和 Spring Data 是少数几个具备 WebFlux 功能的模块。这可以用以下图表来描述:

Spring Data 采用了响应式范式,并开始支持使用@Tailable注解从数据库中获取无限流。Spring Data JPA 主要与 RDBMS 相关联,本质上是一种阻塞的,因此它不能支持响应式编程。
Spring MVC 本质上是一种阻塞的;然而,我们可以对某些部分使用响应式编程,这些部分可以被转换为响应式。例如,Spring MVC 控制器可以配备Flux和Mono类型来以响应式的方式处理数据流。
除了这些,WebFlux 还支持一些注解,如@Controller、@RequestMapping等,因此您可以以增量方式将 Spring MVC 应用程序转换为 WebFlux。我们将通过创建一个示例应用程序来了解 Spring 框架通过 WebFlux 提供的更多关于反应式支持细节。
Spring WebFlux 应用程序
我们将使用 WebFlux 框架创建一个示例 Web 应用程序。该应用程序将简单地从数据存储中访问现有的学生信息。我们不会创建一个完整的应用程序,而是更多地关注如何使用 WebFlux 框架以反应式的方式访问数据。
我们将使用 Spring Boot 来启动开发。对于 Spring Boot 的新手来说,它是一个工具,也是 Spring Horizon 的一部分,旨在加快和简化基于 Spring 的新应用程序的启动和开发。
您可能在 Spring 项目中反复遇到庞大的 XML 和其他配置。Spring 团队对此非常清楚,并最终开发了一个名为 Spring Boot 的工具,旨在让开发者摆脱提供样板配置的负担,这不仅繁琐,而且耗时。
我们将创建一个使用 MongoDB 作为数据存储的示例 Web 应用程序。在处理反应式编程时,建议使用非阻塞和反应式数据存储,如 MongoDB、Couchbase、Cassandra 等。我们将使用一个名为Spring Tool Suite(STS)的工具,它是一个基于 Eclipse 的 IDE。它提供了创建基于 Spring Boot 的应用程序的支持。从spring.io/tools3/sts/all下载它并在您的本地计算机上安装。
这里提供的 STS 链接是 3.x 版本。在撰写本文时,STS 的当前版本是 4.x。本书中创建的所有代码都是基于 STS 3.x 的,因此提供的链接是 3.x 版本。然而,您可以下载 STS 的最新版本并使用代码而不会遇到任何问题。
下载后,打开它,选择“*文件 | 新建 | *Spring Starter Project”菜单,并按以下方式填写表单:

点击“下一步”按钮,您将需要定义依赖项。选择以下依赖项。您可以使用文本框“可用”来搜索特定的依赖项:
-
Web: 用于添加 Spring MVC 特定依赖项。
-
Reactive Web: 用于添加 WebFlux 特定依赖项。
-
DevTools: 在开发中非常有用,因为它会自动刷新嵌入式容器中的更改,以便快速查看更改。
-
Reactive MongoDB: 在反应式范式下工作的 Spring Data MongoDB 依赖项。确保您不要选择 MongoDB,它是用于在非反应式模型中与 MongoDB 一起工作的依赖项。
点击“完成”,您将在 STS 的“包资源管理器”(或“项目资源管理器”)部分看到一个创建的项目。一旦项目创建,我们将执行以下步骤。
MongoDB 安装
首先,您需要在本地机器上安装 MongoDB。它既可以作为独立服务器,也可以作为云服务进行分发。从以下链接下载最新版本:www.mongodb.com/download-center/community。从列表中选择适当的操作系统,并在您的机器上安装它。
MongoDB 没有任何 UI 来访问它。然而,它提供了一个名为 Compass 的其他工具,可以从以下链接下载:www.mongodb.com/download-center/compass。选择适当的版本和目标平台,并下载它们。在大多数情况下,它是直接可执行的。默认情况下,MongoDB 通过 27017 端口可访问。只需将 Compass 连接到 MongoDB 服务器,确保它在连接之前正在运行。
MongoDB 数据结构
在使用 MongoDB 之前,了解其中使用的模式和数据结构非常重要。就像关系型数据库一样,我们首先需要在 MongoDB 中创建一个数据库。除了数据库,我们还需要创建一个集合。您可以将集合视为在 RDBMS 中的数据库表。
连接 Compass(默认:无凭据)并点击“创建数据库”按钮,您将看到如下所示的模型窗口:

您需要在模型窗口中给出数据库名称和集合名称,然后点击“创建数据库”按钮。现在,您可以将学生集合的数据插入到 MongoDB 中。
创建 Spring Data 存储库
Spring Data 提供了一致的基于 Spring 的编程模型来访问数据。它抽象了低级样板细节,可以用来访问包括 SQL(关系型和非关系型)数据库、map-reduce 框架、基于云的数据服务等多种数据存储。Spring Data 存储库基本上实现了数据访问层,并为与底层数据存储进行交互提供了抽象访问。
我们将配置 Spring Data 存储库以与 MongoDB 交互。第一步是创建一个实体对象(领域模型)。Spring Data 允许以面向对象的方式访问数据,因此我们首先需要定义实体类并提供与持久化模型的必要映射。实体类可以创建如下:
@Document(collection="Student")
public class Student {
@Id
@JsonIgnore
private String id;
@NotNull(message="Roll no can not be empty")
private Integer rollNo;
@NotNull(message="Name can not be empty")
private String name;
@NotNull(message="Standard can not be empty")
private Integer standard;
//.. getter and setter
}
这个 POJO 类使用 @Document 注解表示 MongoDB 中的学生实体。您需要在这里给出与我们在 MongoDB 中创建的相同集合名称。属性 ID 将由 MongoDB 自动生成,可以视为 Student 实体的主键,因此它被标记为 @Id 注解。
接下来添加一个 Spring Data 存储库。Spring 为特定的数据存储提供了存储库支持。对于 MongoDB,Spring Data 存储库应如下所示:
@Repository
public interface StudentMongoRepository extends ReactiveMongoRepository<Student, String>{
public Mono<Student> findByRollNo(Integer rollNo);
public Mono<Student> findByName(String name);
}
Spring Data 提供了ReactiveMongoRepository接口,可以扩展以定义自定义仓库。它为Student类型,当我们想要与 MongoDB 交互时是一个对象实体类型,以及String,它代表主键(在我们的例子中是 ID)。
Spring Data 仓库提供了一个名为查询方法的不错特性,它通过遵循一定的命名约定根据特定的列或属性值访问数据。例如,findByName(String name)将返回匹配名称的StudentData。Spring Data 提供了这些方法的底层实现。为了简单起见,我们只保留了两个方法。
要确保 Spring 应用程序连接到 MongoDB,我们需要在application.properties文件中添加以下属性:
spring.data.mongodb.host=localhost
spring.data.mongodb.port=27017
spring.data.mongodb.database=StudentData
这与在数据库中定义连接属性等效。
WebFlux 编程模型
Spring WebFlux 足够灵活,可以支持不同的开发模式。你可以在 WebFlux 中使用以下两种编程模型创建应用程序:
-
注解控制器:这与 Spring MVC 控制器非常相似。
-
功能端点:功能端点用于使用函数式编程特性处理和路由请求。
我们将在我们创建的带有 Spring Data 仓库和实体类的示例 WebFlux 应用程序中探索这两种选项。下一步是创建一个控制器,这可以通过以下两种方式完成。
注解控制器
WebFlux 提供了类似于 Spring MVC 框架的基于注解的配置支持。首先,我们将创建一个注解控制器类,该类从服务器端发布Student实体的响应式流,如下所示:
@RestController
@RequestMapping("api")
public class StudentWebFluxController {
@Autowired
private StudentMongoRepository studentMongoRepository;
@GetMapping("/getStudent/{rollNo}")
public Mono<ResponseEntity<Student>> getStudent(@PathVariable("rollNo") Integer rollNo) {
Mono<Student> studentMonoObj = studentMongoRepository.findByRollNo(rollNo);
return studentMonoObj.map(student -> ResponseEntity.ok(student))
.defaultIfEmpty(ResponseEntity.notFound().build());
}
}
StudentWebFluxController是注解控制器。它与 Spring MVC 控制器相似。使用@RestController注解将此控制器定义为 REST 控制器。使用@RequestMapping注解定义此控制器的 URL 映射。
studentMongoRepository Spring Data 仓库支持非阻塞的响应式流。getStudent()方法将根据输入值rollNo返回单个Student对象。然而,返回类型不仅仅是响应Student;相反,它是Mono类型,因为它最多返回一个元素,所以Mono类型更合适。
该仓库基于rollNo提供了Mono<Student>,然后我们调用映射函数将Mono<Student>类型的对象映射到Mono<ResponseEntity<Student>>,随后将由 WebFlux 框架处理以返回学生数据。直接从 MongoDB 添加一些值,并尝试使用 REST 客户端(例如 Postman)通过 URL localhost:8080/api/getStudent/21(使用8080端口,学生rollNo为21)访问它,你将得到以下输出:

如果我们想访问多个学生,我们需要使用Flux返回类型,因为它可以发出 0 到 N 个元素。让我们向控制器添加一个方法来获取所有学生,如下所示:
@GetMapping("/getAllStudent")
public Flux<Student> getAllStudent() {
Flux<Student> allStudents = studentMongoRepository.findAll();
return allStudents;
}
从 MongoDB 添加一些更多的学生数据,并访问 URL localhost:8080/api/getAllStudent,您将看到以下结果:

WebFlux 控制器端点以Flux或Mono的形式返回一个发布者。在第二个方法中,我们返回所有学生时,它可以以服务器端事件(SSE)的形式发送到浏览器。为此,您需要将返回类型定义为text/event-stream。SSE 是允许浏览器通过 HTTP 连接从服务器接收自动更新的技术。
这意味着什么?如果我们有一个非常大的流,那么 WebFlux 控制器将像从反应式存储库(在我们的例子中是 MongoDB)接收数据一样发送数据,并将其发送到浏览器,而不是获取所有记录,这会导致阻塞条件。这就是在 Spring WebFlux 中使用反应式编程处理大量流的方式。
功能端点
Spring Framework 5 支持使用 WebFlux 的函数式编程模型来构建反应式 Web 应用程序。这是使用 Spring MVC 风格的注解控制器的一个替代方案。Spring WebFlux 中的函数式编程风格使用以下基本组件:
-
HandlerFunction:用于处理请求。它是 Spring MVC 控制器处理方法的一个替代方案,并且工作方式与之相似。 -
RouterFunction:用于路由传入的 HTTP 请求。RouterFunction是使用@RequestMapping注解进行请求映射的一个替代方案,并且工作方式与之相似。
函数式反应式编程所需的艺术品
让我们先了解这些组件。它们在 Spring WebFlux 中定义为接口。HandlerFunction接口如下所示:
@FunctionalInterface
public interface HandlerFunction<T extends ServerResponse> {
Mono<T> handle(ServerRequest request);
}
此接口类似于Function<T,R>类型,它接受一个值(T类型的值)并返回另一个值(R类型的值)。在这种情况下,它相当于Function<ServerRequest,Mono<T>>。它非常像 servlet。T类型是应该实现ServerResponse接口的函数的响应类型,该接口代表服务器端的 HTTP 响应。
handle()方法接受ServerRequest对象类型并返回一个Mono对象类型。ServerRequest代表 HTTP 请求,我们可以从这个请求中获取头和体。ServerRequest和ServerResponse都是 Spring WebFlux 反应式 API 的一部分。
你可能会注意到,我们不是在同一个方法调用中放置请求和响应,而是从handle()方法返回响应,这实际上使其无副作用且易于测试。让我们看看RouterFunction的样子。同样,它也是接口类型,如下所示:
@FunctionalInterface
public interface RouterFunction<T extends ServerResponse> {
Mono<HandlerFunction<T>> route(ServerRequest request);
//.. other methods.
}
此接口有route()方法,该方法返回HandlerFunction,它与给定的请求相匹配。此方法用于通过应用RequestPredicate创建路由。当谓词匹配时,它将返回处理器函数,该函数基本上处理请求。"RequestPredicate"是 Java8 函数式接口,是 Spring WebFlux 反应式 API 的一部分。它用于测试给定的ServerRequest进行路由,如下所示:
@FunctionalInterface
public interface RequestPredicate {
boolean test(ServerRequest request);
//Other functions
}
让我们在函数式编程风格中创建控制器。我们将编写一个与注解控制器工作方式完全相同但以函数方式工作的控制器。
Spring WebFlux 中功能方法的先决条件
为了以功能方式工作,我们首先需要做的是让我们的项目 WebFlux 具备意识。为此,我们需要将@EnableWebFlux注解添加到主引导类中,如下所示:
@SpringBootApplication
@EnableWebFlux
public class SpringWebFluxDemoApplication {
// other code ..
}
我们还需要在application.properties文件中添加以下属性来指示 Spring,应用程序是reactive类型的:
spring.main.web-application-type=reactive
定义路由器和处理器
下一步是定义获取学生数据用的路由器和处理器。让我们回顾一下,路由器用于路由请求,它们在注解控制器中起到@RequestMapping的作用,而处理器实际上处理传入的请求,这与 Spring MVC 控制器处理器方法类似。路由器类如下所示:
@Configuration
public class StudentRouter {
@Autowired
private StudentHandler studentHandler;
@Bean
RouterFunction<ServerResponse> returnStudent() {
return RouterFunctions.route(RequestPredicates.GET("/api/f/getStudent/{rollNo}"),
studentHandler::getStudent);
}
@Bean
RouterFunction<ServerResponse> returnAllStudent() {
return RouterFunctions.route(RequestPredicates.GET("/api/f/getAllStudent"),
studentHandler::getAllStudent);
}
}
需要使用@Configuration注解声明路由器类,以便 Spring 容器在上下文加载时选择此类并执行必要的配置。我们有两种方法来获取单个学生和所有学生数据列表。
路由器是通过RouterFunctions.route()调用创建的。RouterFunctions实用类有很多有用的功能。route()方法需要两个参数。第一个参数是RequestPredicate类型的。另一个辅助类RequestPredicates用于为每个路由方法定义带有 URL 模式的RequestPredicate。RequestPredicate类有各种对应 HTTP 方法的方法。
我们使用了GET方法,因为我们想用GET方法从 REST 客户端拉取数据。这里重要的是要定义任何路径变量以及将在处理器中接收的 URL 模式,以便执行必要的操作。
第二个参数是 HandlerFunction<T> 类型,它由 StudentHandler 类的相应方法提供。studentHandler::getStudent 和 studentHandler::getAllStudent 的双列注释将分别调用 StudentHandler 类的 getStudent() 和 getAllStudent() 方法。StudentHandler 类应如下所示:
@Component
public class StudentHandler {
@Autowired
private StudentMongoRepository studentMongoRepository;
public Mono<ServerResponse> getStudent(ServerRequest serverRequest) {
int rollNo = getInt(serverRequest.pathVariable("rollNo"));
Mono<Student> studentMonoObj = studentMongoRepository.findByRollNo(rollNo);
return ServerResponse.ok().body(studentMonoObj, Student.class);
}
public Mono<ServerResponse> getAllStudent(ServerRequest serverRequest) {
Flux<Student> allStudents = studentMongoRepository.findAll();
return ServerResponse.ok().body(allStudents, Student.class);
}
private int getInt(String intStr) {
int returnVal=0;
if(intStr !=null !intStr.isEmpty()) {
try {
returnVal = Integer.parseInt(intStr);
}catch(Exception e) {
e.printStackTrace();
}
}
return returnVal;
}
}
每个处理程序方法都将有一个 ServerRequest 对象作为参数,当它们从路由器调用时,将由 Spring WebFlux 框架提供。ServerRequest 类代表 HTTP 请求,我们可以从中获取参数和体。
在 getStudent() 方法中,我们正在读取 rollNo 路径变量,并将其传递给存储库方法以获取学生数据。这里的路径变量名称必须与在路由器中声明的 URL 模式部分(/api/f/getStudent/{**rollNo**})中的路径变量相同。最后,使用 ServerResponse 类构建响应并返回。为了区分功能端点,我们已更新了 URL 模式(在中间添加了 /f/ 以表示其功能端点)。你将得到与注解控制器类似的输出。
处理程序方法不需要用 @Bean 注解定义,否则在启动应用时你会得到一个错误。
组合处理程序和路由器
我们为处理程序和路由器编写了两个不同的类;然而,我们可以在一个类中声明覆盖路由器和处理程序功能的配置。这可以通过将处理程序和路由器方法对组合在一个单独的方法中来实现,如下所示:
@Configuration
public class StudentRouterHandlerCombined {
@Autowired
private StudentMongoRepository studentMongoRepository;
@Bean
RouterFunction<ServerResponse> returnStudentWithCombineFun(){
HandlerFunction<ServerResponse> studentHandler =
serverRequest -> {
int rollNo = getInt(serverRequest.pathVariable("rollNo"));
return ServerResponse.ok().
body(studentMongoRepository.findByRollNo(rollNo)
, Student.class);
};
RouterFunction<ServerResponse> studentResponse =
RouterFunctions.route(
RequestPredicates.GET("/api/f/combine/getStudent/{rollNo}"),
studentHandler);
return studentResponse;
}
@Bean
RouterFunction<ServerResponse> returnAllStudentWithCombineFun(){
HandlerFunction<ServerResponse> studentHandler =
serverRequest ->
ServerResponse.ok().
body(studentMongoRepository.findAll(), Student.class);
RouterFunction<ServerResponse> studentResponse =
RouterFunctions.route(
RequestPredicates.GET("/api/f/combine/getAllStudent"),
studentHandler);
return studentResponse;
}
private int getInt(String intStr) {
int returnVal=0;
if(intStr !=null !intStr.isEmpty()) {
try {
returnVal = Integer.parseInt(intStr);
}catch(Exception e) {
e.printStackTrace();
}
}
return returnVal;
}
}
这个类有两个方法,分别用于获取单个学生和所有学生。在每个方法中,我们首先创建处理程序的一个实例,然后将其传递到创建路由器时的 route() 方法中。使用 lambda 表达式来定义处理程序。代码简单直接。再次为了使其独特,我们通过在中间添加 /combine/ 来更改 URL 模式,这样可以通过 URL localhost:8080/api/f/combine/getStudent/21 和 localhost:8080/api/f/combine/getAllStudent 分别访问获取单个学生和所有学生的端点。你将得到与单独定义处理程序和路由器时类似的输出。
你可能想知道底层是如何工作的。RouterFunctionMapping 类型的“豆”在应用启动时扫描包并检索所有 RouterFunctions。这个“豆”是在 WebFluxConfigurationSupport 中创建的,它是 Spring WebFlux 配置的大本营。当我们把 @EnableWebFlux 注解和 spring.main.web-application-type=reactive 属性定义到主引导类中时,所有这些事情就开始发生了。
组合路由器
如果你的配置中有许多路由器,你可以基本上使用and运算符将它们组合成一个链。在前面的示例中,我们在两个不同的方法中定义了两个路由器。这可以在一个方法中合并如下:
@Configuration
public class StudentCompositeRoutes {
@Autowired
private StudentMongoRepository studentMongoRepository;
@Bean
RouterFunction<ServerResponse> compositeRoutes(){
RouterFunction<ServerResponse> studentResponse =
RouterFunctions.route(RequestPredicates.
GET("/api/f/composite/getStudent/{rollNo}"),
serverRequest -> {
int rollNo = getInt(serverRequest.pathVariable("rollNo"));
return ServerResponse.ok().
body(studentMongoRepository.
findByRollNo(rollNo), Student.class);
})
.and(
RouterFunctions.route(RequestPredicates.
GET("/api/f/composite/getAllStudent"),
serverRequest ->
ServerResponse.ok().
body(studentMongoRepository.findAll(), Student.class))
);
return studentResponse;
}
private int getInt(String intStr) {
int returnVal=0;
if(intStr !=null !intStr.isEmpty()) {
try {
returnVal = Integer.parseInt(intStr);
}catch(Exception e) {
e.printStackTrace();
}
}
return returnVal;
}
}
使用and运算符来组合两个路由器。此外,rout()函数的第二个参数是HandlerFunction<T>类型,使用 lambda 表达式定义。这就是如何使用and运算符在一个链调用中组合多个路由器。为了区分这个特性,我们再次更改端点 URL 模式,因为我们已经将/combine/替换为/composite/添加到 URL 模式中。不用说,在这种情况下你也会得到类似的结果。
WebSocket 支持
WebSocket 是一种协议,允许服务器和客户端之间进行全双工、双向通信。在建立连接时,它使用 HTTP 进行初始握手。一旦完成,它将请求协议升级。Spring WebFlux 框架基于 Java WebSocket API 支持客户端和服务器之间的反应式 WebSocket 通信。定义 WebSocket 是一个两步过程,如下所示:
-
定义处理程序以管理 WebSocket 请求
-
定义映射以访问特定处理程序
在 WebFlux 中,WebSocket 通过实现WebSocketHandler接口来处理。它有一个名为handle()的方法。正如其名称所暗示的,WebSocketSession代表由单个客户端形成的连接。
通过Flux类型的receive()和send()方法可访问的两个单独的流与WebSocketSession相关联,分别用于处理传入请求和发送消息。我们首先定义处理程序映射如下:
@Autowired
SampleWebSocketHandler studentWebSocketHandler;
@Bean
public HandlerMapping webSockertHandlerMapping() {
Map<String, WebSocketHandler> map = new HashMap<>();
map.put("/student", studentWebSocketHandler);
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
mapping.setUrlMap(map);
return mapping;
}
@Bean
public WebSocketHandlerAdapter handlerAdapter() {
return new WebSocketHandlerAdapter();
}
使用@Bean注解的方法webSockertHandlerMapping用于将我们的自定义处理程序与特定的 URL 模式映射,通过该模式它可以被访问。SampleWebSocketHandler自定义处理程序通过@Autowired注解注入,其代码如下:
@Component
public class SampleWebSocketHandler implements WebSocketHandler{
private ObjectMapper objMapper = new ObjectMapper();
@Autowired
StudentMongoRepository studentMongoRepository;
@Override
public Mono<Void> handle(WebSocketSession webSocketSession) {
Flux<Student> allStudentSource = studentMongoRepository.findAll();
System.out.println(" ****** Incoming messages ****** ");
webSocketSession.receive().subscribe(System.out::println);
System.out.println(" ****** Sending Student data ****** ");
return webSocketSession.send(allStudentSource.map(student->{
return writeValueAsSTring(student);
}).map(webSocketSession::textMessage)
);
}
private String writeValueAsSTring(Object obj) {
try {
return objMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return "No data";
}
}
SampleWebSocketHandler类提供了WebSocketHandler接口的实现,其中包含handle()方法。在该方法中,我们简单地从StudentMongoRepository获取所有学生数据,并在WebSocketSession上调用send()方法。在send()方法中,我们首先使用ObjectMapper将Student对象转换为 JSON 字符串,最后调用WebSocketSession的textMessage()方法将其转换为WebSocketMessage。
接下来,创建客户端。我们将用 JavaScript 编写客户端代码,并在浏览器中调用服务器以查看流数据是如何逐个接收的。你可以创建一个包含以下代码的 HTML 文件。
<html>
<body>
Hello
</body>
<script>
var socket = new WebSocket('ws://localhost:8080/student');
socket.addEventListener('message', function (event) {
window.alert('message from server: ' + event.data);
});
</script>
</html>
几乎所有现代浏览器都支持 WebSocket 通信。在浏览器中打开这个 HTML 文件,你会看到学生数据逐个通过浏览器弹窗显示。这就是 Spring WebFlux 反应式范式中的 WebSocket 通信方式。
摘要
反应式技术无疑是一种有潜力的新技术,它将有助于构建可扩展且高性能的应用程序。Spring 通过一个名为 WebFlux 的新框架,出色地支持了反应式系统。反应式技术是下一代应用程序的未来,它几乎在所有地方都需要:数据存储、中间层、前端,甚至是移动平台。
通过本章,我们学习了反应式系统和反应式编程的基础知识,以及实现它们的各种技术。然后,我们了解了反应式流(Reactive Streams),这是实现反应式系统最受欢迎的方法之一。从反应式流规范和基本原理开始,我们探索了各种基于 JVM 的库,它们为特定的规范提供了实现。我们使用 RxJava 和 Project Reactor 进行了一些实际操作,并学习了其背后的原理。
在同一方向上,我们看到了 Spring 框架如何在反应式范式下提供支持。然后,我们通过创建基于 Spring Boot 的 Web 应用程序来探索 Spring WebFlux 框架。除了基于注解的 WebFlux 支持,类似于 Spring MVC,Spring 还支持使用函数式编程范式创建反应式系统。我们通过一系列示例探索了函数式编程中的各种选项。
为了充分利用 Spring WebFlux 的反应式能力,数据存储也应支持反应式编程,这也是我们选择 MongoDB 作为数据存储的原因,并且我们学习了如何在 Spring Boot 工具中配置它。
迄今为止,这已经是一次非常激动人心的旅程,我们将在下一章继续我们的旅程,通过探索 Elasticsearch 与 Spring 框架的集成这一有趣的主题。我们将开发一个名为 Blogpress 的示例应用程序。我们还将更详细地了解 Spring Boot、Thymeleaf 等技术;敬请期待,准备在下一章中进一步探索。
第三章:Blogpress - 一个简单的博客管理系统
Spring 支持在 Java 平台上开发企业级应用程序。在其范围内有许多这样的应用程序,其中最流行的是 Spring 模型-视图-控制器(MVC)、Spring Security、Spring Data、Spring Batch 和 Spring Cloud。
在前两章中,我们开始探索 Spring MVC 框架,以及其他构建块,如 Spring Data 和 RestTemplate,以及像 Angular、Bootstrap 和 jQuery 这样的 JavaScript 框架,以构建基于 Web 的应用程序。我们还看到了如何借助 WebFlux(一个用于创建反应式 Web 应用程序的框架)来构建反应式 Web 应用程序。
创建一个企业级基于 Spring 的应用程序需要大量的配置,这使得开发过程变得相当繁琐和复杂。除此之外,设置复杂的依赖关系也需要大量的努力。通常,在基于 Spring 的 Web 应用程序中使用的库需要一些常见的配置来将它们绑定在一起。
考虑到任何基于 Spring 的应用程序,你可能需要执行某些重复性任务,特别是配置相关的任务,例如,导入所需的模块和库以解决依赖项;进行与应用程序各层相关的配置,例如,在 DAO 层的数据源和事务管理,以及在 Web 层的视图解析和资源管理等等。
这是一个在创建任何基于 Spring 的 Web 应用程序时必须遵循的强制性程序。简而言之,开发人员通常会跨应用程序复制配置,并且在集成库时可能不会遵循最佳实践。
所有这些因素激发了 Spring 团队提出一个框架,该框架通过自动配置提供对所有 Spring 库的集成,这基本上消除了你的重复性工作。除此之外,它还提供了生产就绪功能,如应用程序指标和监控、日志记录和部署指南。这个框架被称为 Spring Boot。
在本章中,我们将继续我们的旅程,并探讨使用 Spring 库和其他第三方库构建博客管理 Web 应用程序的不同组件,所有这些库都通过 Spring Boot 提供的自动配置绑定在一起,以及我们对自动配置的一些覆盖。
本章将涵盖以下主题:
-
带有 Spring Boot 的项目骨架
-
Spring MVC 框架中应用程序的主要流程
-
使用Thymeleaf和
Mustache.js的表示层 -
使用 Spring Security 使应用程序安全——涵盖身份验证和授权
-
在 Elasticsearch 中开发后端层,它存储应用程序数据并提供基于 REST 的 CRUD 操作
-
开发 Spring MVC REST 服务
技术要求
本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Spring-5.0-Projects/tree/master/chapter03/blogpress。代码可以在任何操作系统上执行,尽管它只在 Windows 上进行了测试。
应用程序概述
以现实生活中的例子为最佳方法来探索和理解给定框架上下文的概念。目的是不展示如何构建 Web 应用程序;相反,我们将展示应用程序的重要组成部分,以便任何人都可以选择一个组件并在另一个应用程序中使用它。您可以在为本书下载的代码中找到完整的完整解决方案。
让我们开始行动。首先,我们将简要介绍我们的应用程序——博客。我们将称之为Blogpress。请注意,它不会是一个完整的博客应用程序;相反,我们将开发以下功能:
-
登录页面——显示博客列表及其链接。
-
当用户点击任何博客时,系统将以查看模式打开它,以及所有(已批准)评论。
-
用户可以在同一屏幕上添加评论。
-
此外,用户可以使用给定的搜索文本搜索博客,目标是博客的标题或正文。
-
有登录功能。两个人可以登录到应用程序——博客用户和管理员:
-
博客用户可以添加/更新/删除博客。他只能编辑自己创建的博客。
-
管理员用户可以执行博客用户可以执行的所有可能操作,以及管理(批准或拒绝)匿名用户给出的评论。
-
-
当任何用户添加评论时,将向管理员用户发送电子邮件以进行审核。一旦评论被批准,将向用户发送电子邮件作为通知。
基于 Spring Boot 的项目骨架
Spring Boot 自动化创建配置的过程,从而加快了开发过程。简而言之,Spring Boot 使开发周期更短,对于构建生产就绪的应用程序或服务,配置最少或没有配置。它使用约定优于配置的方法来提供快速应用程序开发。
Spring Boot 的目的不是提供任何新功能;相反,因为它建立在 Spring 框架之上,它使用现有的 Spring 框架功能来提供开箱即用的预配置应用程序骨架,这是一种开发入门模式。
Spring Boot 相较于传统的基于 Spring 的应用程序创建方式具有各种优势,如下所述:
-
具有自动配置功能
-
轻松管理依赖关系
-
支持嵌入服务器以简化开发过程
-
提供了使用 Maven 或 Gradle 构建应用程序的支持
-
简化了与其他 Spring 模块的集成
-
加快开发过程
-
支持命令行和 IDEs 轻松开发和测试应用程序
配置 IDE Spring Tool Suite
让我们开始开发我们的博客应用程序——Blogpress。如描述,我们将首先使用 Spring Boot 创建应用程序。使用 IDE 开发应用程序是目前大多数开发者首选的最直接、简单、方便和有利的方法。我们使用 IDE 来开发我们的应用程序。
Spring 提供了一个基于 Eclipse 的 IDE,称为Spring Tool Suite(STS),可以轻松地开发任何基于 Spring 的应用程序。从以下链接下载 STS 的最新版本:spring.io/tools.
STS 与 Eclipse、Visual Studio 和基于 Atom 的代码编辑器一起提供。您可以根据需要使用其中任何一个。
我们将在本章中使用基于 Eclipse 的 IDE STS 来构建应用程序。下载 STS,将其解压缩到您的本地文件夹中,然后打开.exe文件以启动 STS。启动后,创建一个具有以下属性的 Spring Boot 启动项目:
-
名称:
blogpress -
类型:Maven(您也可以选择 Gradle)
-
打包:Jar
-
Java 版本:8(或更高)
-
语言:Java
-
组:这将是一个 Maven
groupId,因此请给出适当的值 -
生成物:这将是一个 Maven
artifactId,因此请给出适当的值 -
版本:
0.0.1-SNAPSHOT—我们应用程序的构建版本 -
描述:
一个简单的博客管理系统
您也可以从命令窗口创建 Spring Boot 应用程序。Spring 提供了一个名为 Spring 命令行界面(CLI)的工具来支持这一点。创建 Spring Boot 启动项目的另一种方法是使用start.spring.io/。您需要定义依赖项,它将允许用户从网络上下载整个项目结构。
保持所有默认选项,点击完成以创建 Spring Boot 应用程序。您将在pom.xml中看到以下依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Spring Boot 提供了各种针对每个依赖项的启动器,以便它们的 JAR 文件可以在类路径中找到。由于我们想要开发一个 Web 应用程序(Spring MVC),我们在之前的代码中保留了spring-boot-starter-web启动器(实际上,它是在创建 STS 项目时添加的)。
Spring 提供了一系列用于特定功能的依赖项,以启动器的形式。这是在 Spring Boot 应用程序中管理依赖项的一种便捷方式。当您指定特定的启动器时,Spring Boot 将为您自动拉取应用程序中的所有(递归)依赖项。例如,如果您希望使用 JPA 将数据存储添加到应用程序中,只需将spring-boot-starter-jpa添加到 Spring Boot 应用程序的pom.xml中。所有依赖项都将由 Spring Boot 处理,这样您就可以专注于业务实现。
你会在pom.xml的父元素中看到spring-boot-starter-parent。这是 Spring Boot 的魔力所在。通过这个声明,你的应用程序扩展了所有 Spring Boot 的能力,如下面的代码片段所示:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.0.BUILD-SNAPSHOT</version>
<relativePath/>
</parent>
Spring Model-View-Controller(MVC)网络流程
下一步是添加 Spring MVC 功能。在上一步中,我们将所需的 Web 启动依赖项添加到pom.xml文件中。接下来,我们将在我们的应用程序中定义 Spring MVC 组件。
由于这是一个 Spring Boot 应用程序,我们不需要从头开始声明一切。定义控制器和视图层是至关重要的。首先,我们将声明一个 Spring MVC 控制器,如下面的代码片段所示:
@Controller
public class BlogController {
private Logger logger = LoggerFactory.getLogger(BlogController.class);
@GetMapping("/")
public String showLandingPage(Model model) {
logger.info("This is show home page method ");
return "home";
}
}
@Controller注解描述了这个类作为 Spring MVC 控制器。它基本上指示 Spring Boot 应用程序,这个组件将服务于基于 Web 的请求。它匹配正确的 URL 模式以调用特定的控制器及其方法。
在之前的声明中,我们只为控制器方法提供了 URL 模式。然而,Spring 允许你为控制器声明 URL 模式。由于我们的应用程序只需要某些功能,一个控制器就足够了,因此,我们没有为控制器声明 URL 模式。所以,所有当前应用程序的 Web 请求(具有http://host/context/controllerUrlPattern模式的请求)都将路由到这个控制器。
@RequestMapping("/controllerUrlPattern")注解用于在控制器级别描述 URL 模式。在这种情况下,http://host/context/controllerUrlPattern模式将到达这个控制器。它所有方法的 URL 模式都将附加在http://host/context/controllerUrlPattern之后。
总是使用记录器是一个好习惯。Spring 提供了LoggerFactory类来获取当前类的记录器实例。你可以在适当的位置调用各种方法,如info、debug、error等。
Spring MVC 控制器方法可以通过一个独特的 URL 进行映射,以便它可以由匹配的请求 URL 模式触发。在之前的例子中,showLandingPage()方法是一个控制器方法。它被映射到 URL/,这意味着http://localhost:8080/blogpress/ URL(假设你在本地使用8080端口运行应用程序,并且blogpress是你的应用程序的名称)将调用此方法。
此方法返回一个字符串home,它表示表示层中的组件。Spring MVC 足够灵活,可以选择所需的表示框架。因此,没有必要使用特定技术作为你的表示层。你可以使用JavaServer Pages(JSPs)、Thymeleaf 或如 Angular 这样的 UI 框架作为 Spring MVC Web 应用程序的前端。
在本章中,我们将使用 Thymeleaf 来构建表示层。
使用 Thymeleaf 的表示层
Thymeleaf 是一个模板引擎,用于在服务器端处理 XML、HTML、JavaScript、CSS 和纯文本模板。你可能会有这样的疑问:为什么是 Thymeleaf?我们已经有 JSP 了。使用 Thymeleaf 相对于 JSP 有什么好处?
答案是,Thymeleaf 是按照自然模板概念设计的,并提供设计原型支持,而不会影响模板的使用。换句话说,由于其本质,Thymeleaf 可以被开发者和设计团队使用,而不会受到限制或两者之间有依赖关系。
Thymeleaf 的另一个优点是它符合网络标准,主要是 HTML5。这将使你在需要时能够完全验证模板。
Thymeleaf 的工作原理
对于我们的博客应用,我们将使用 Thymeleaf HTML 模板。使用 Thymeleaf 的第一步是指导 Spring Boot,使其能够为我们应用提供所有必要的特定于 Thymeleaf 的配置。在pom.xml中添加以下条目:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-Thymeleaf </artifactId>
</dependency>
Thymeleaf 提供某些标签(一种 JSP 标签)来将动态值嵌入到模板中。我们将首先介绍我们在 Blogpress 应用中打算使用的最基本标签。将动态值插入 Thymeleaf 模板的机制与你在 JSP 标签中看到的不同,因此在开始使用之前熟悉它是相当重要的。
你可以看到 Thymeleaf 的另一个启动器。这就是 Spring Boot 如何让开发者的生活变得简单。
方言、处理器和表达式对象
方言是一组你在模板中使用的功能,包括预处理和后处理逻辑以及表达式对象。处理逻辑(在模板中嵌入动态 HTML 和值)由称为处理器的对象执行,而表达式对象用于描述执行特定操作的标准表达式。简而言之,处理器对象处理 DOM 节点或元素,而表达式对象用于评估表达式。
方言可以与处理器或表达式对象一起使用,也可以两者都使用。让我们看看如何在模板中声明和使用方言:
<!DOCTYPE html>
<html >
<span th:text="${name}">
在之前的代码片段中,th被称为方言前缀。这意味着该方言提供的所有属性和标签都以th:开头。这是一个标准且开箱即用的方言,对于大多数场景来说已经足够了。你可以将方言视为 JSP 中的Taglibs。
你可以在模板中定义多个方言。此外,你还可以创建自己的自定义方言,并在自定义处理器中添加自定义处理逻辑。
text属性代表一个处理器,它只是将值放入 span 中,而${...}描述的是值表达式对象,它简单地从模型中拉取属性值。以下是在 Thymeleaf 中可用的表达式对象类型:
-
变量表达式对象:它们用于显示模型属性的值。它们的形式是 Spring 表达式语言(EL)。它们可以用
${...}语法来描述。 -
选择表达式对象:这些与表达式对象类似,但只能应用于之前选定的对象。它们可以与
*{...}语法一起使用。 -
消息(国际化)表达式:这些用于国际化目的和提供特定语言的短信。您可以使用
#{...}语法。 -
链接(URL)表达式:这些用于动态应用链接。例如,表单操作、HREF、链接 JS/CSS 或其他静态资源等。使用
@{...}语法。 -
片段表达式:这代表模板的一部分,可以在类似或其他模板中重复使用。它们可以使用
~{...}语法。
接下来,我们将看到我们将在后续应用程序中使用的一些处理器。它们可以与以下列出的各种表达式对象一起使用:
-
th:text—这用于与值表达式对象一起使用,为 HTML 元素如span、li、div等放置动态文本。 -
th:value—可以使用此处理器提供输入元素的值。 -
th:action—这可以用来向 HTML 表单提供操作值。 -
th:href—正如其名所示,这用于在链接中提供 URL(导入 CSS)和 HTML 中的标签。 -
th:src—这用于在 HTML 中动态提供脚本(和其他类似)元素的源 URL。 -
th:each—这用于循环中的th:text,以构建重复的 HTML 代码,即 HTML 表格的行。 -
**th:if**和th:unless—这些用于条件性地放置动态值。
Thymeleaf 支持在 HTML5 风格中定义属性和元素名称。例如,您可以用data-th-text作为 HTML 元素的属性来代替th:text,在 HTML5 中被视为自定义元素。
为什么 Thymeleaf 是一个自然的模板
我们已经看到标准方言的处理器被放置为 HTML 元素的属性。由于这种安排,即使在模板引擎处理之前,浏览器也可以正确渲染 Thymeleaf 模板。
这很可能是因为浏览器简单地忽略了它们,将它们视为自定义属性,因此在显示时没有问题。在 JSP 的情况下则不可能。例如,以下标签的 JSP 在浏览器中不会被渲染:
<form:input name="name" path=”name”/>
如果您使用 Thymeleaf 编写,它将如下所示:
<input type="text" name="name" value="Nilang" th:value="${name}" />
浏览器将完美地显示前面的代码。此外,Thymeleaf 允许您提供(可选的)值属性(在我们的例子中是Nilang),当在浏览器上运行时将静态显示。当相同的代码由 Thymeleaf 模板引擎处理时,值将通过实时评估${name}表达式来替换。
这就是为什么 Thymeleaf 被称为自然模板引擎的原因。它允许设计师与开发者合作,而不会产生对任何一方的依赖。
现在,让我们讨论一下我们将在我们的博客应用程序中使用的 Thymeleaf 模板。当你创建一个 Spring Boot 应用程序时,你将在src/main/resources文件夹中看到一个templates文件。我们所有的 Thymeleaf 模板都驻留在那里。
我们将在我们的应用程序中使用以下模板:
-
header.html: 这个模板包含一个常见的 JS/CSS 包含头部,以及一个导航栏。它包含在所有其他模板中。 -
home.html: 显示主页内容。 -
login.html: 允许用户登录到系统中。 -
user-page.html: 一旦博客用户登录,他将会进入这个页面。 -
view-blog.html: 以只读模式打开特定的博客。 -
search.html: 显示搜索结果。 -
new-blog.html: 博客用户或管理员可以通过这个模板创建一个新的博客。 -
manage-comments.html: 管理员用户可以批准/拒绝评论。 -
edit-blog.html: 允许博客用户/管理员编辑现有的博客。 -
admin-page.html: 一旦管理员用户登录,他们将会进入这个页面。
我们将首先添加两个模板——home和header。在进一步操作之前,让我们看看 Thymeleaf 的一个酷炫特性,我们将在我们的应用程序中使用。就像 JSP 一样,你可以将一个模板包含到另一个模板中。此外,Thymeleaf 还允许你只包含模板的一部分(片段),而不是整个模板,这是 JSP 所做不到的。
这是一个很棒的功能,因为你可以在一个模板中定义常见的片段,并在其他模板中包含它们。在我们的例子中,我们在header.html模板中定义了常见的头部元素,如下面的代码片段所示:
<!DOCTYPE html>
<html >
<head th:fragment="jscssinclude">
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=yes">
<!-- Bootstrap CSS -->
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/css/blogpress.css}">
<script th:src="img/jquery.min.js}" type="text/javascript"></script>
<script th:src="img/popper.js}" type="text/javascript"></script>
<script th:src="img/bootstrap.min.js}" type="text/javascript"></script>
<title th:text="${pageTitle}">Title</title>
</head>
<body>
<div th:fragment="header_navigation">
<div class="jumbotron text-center jumbotron-fluid"
style="margin-bottom:0; padding:2rem 1 rem" >
<h1>Blog Press</h1>
<p>Let's do Blogging ..!</p>
</div>
<nav class="navbar navbar-expand-sm bg-dark navbar-dark">
<button class="navbar-toggler" type="button"
data-toggle="collapse" data-target="#collapsibleNavbar">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="collapsibleNavbar">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" th:href="@{/}">Home</a>
</li>
</ul>
</div>
</nav>
</div>
</body>
</html>
该片段由th:fragment标签定义。你可以看到 Thymeleaf 标签(带有方言前缀、处理器和表达式对象)是如何用来导入各种静态资源(JS/CSS),并且向 HTML 元素添加动态值(在我们的例子中是${pageTitle})。
我们可以将这个头部(定义为片段)包含在其他模板中。例如,在我们的home.html模板中,我们使用了它如下:
<!DOCTYPE html>
<html >
<head th:replace="header :: jscssinclude"></head>
<body>
<div th:replace="header :: header_navigation"></div>
This is Home page
</body>
</html>
th:replace标签用于从其他模板引用片段代码。你只需要将模板的名称(你从中引用片段的模板)用两个冒号(::)和片段的名称(你用th:fragment标签定义的)放在一起。这就是 Thymeleaf 允许你将一组模板代码作为片段引用到其他模板中的方式。
我们已经定义了另一个片段,称为header_navigation,在前面代码片段中的主页模板中引用。它用于显示我们应用程序的导航菜单。
在此刻,我们还需要将静态资源(JS/CSS)放入我们的应用程序中。你将在项目结构中看到一个静态文件夹,所有静态资源都应该放在那里。Spring Boot 会将静态文件夹中的所有内容视为静态资源。在静态文件夹下创建css、img和js文件夹,并将以下资源放入其中:
-
在
css文件夹中,添加以下内容:bootstrap.min.css
-
在
js文件夹中,添加以下内容:-
Bootstrap.min.js -
Jquery.min.js -
popper.js
-
现在是时候运行我们的应用程序以查看主页了。你可以在服务器上构建和部署它,并通过http://localhost:8080/blogpress URL 访问它,你将看到一个带有页眉和导航栏的主页。接下来,我们必须使用 Spring 安全使我们的应用程序安全。安全性是任何应用程序今天的一个重要方面和核心关注点。
使用 Spring Security 使应用程序安全
我们的 Blogpress 应用程序具有登录功能,用于访问普通(匿名)用户无法访问的某些页面和功能。如果我们从头开始自己构建认证和授权,则需要付出大量的努力。Spring 提供了一个名为 Spring Security 的功能,它正好满足我们的需求。
Spring Security 是一个开源的、高度综合的、强大且可定制的框架,用于在基于 J2EE 的 Web 应用程序中实现认证和授权。它是 Spring 框架的一个子项目(模块)。
在进一步讨论之前,了解认证和授权之间的区别非常重要。
认证是验证或确定某人或某物所声称身份的过程。执行认证有多种机制。执行认证最直接的方式是提供用户名和密码。其他方式包括通过 LDAP、单点登录、OpenId 和 OAuth。
另一方面,授权更多地与你可以执行的动作的权限相关。简而言之,认证意味着你是谁,而授权意味着你在系统中可以做什么。
Spring Security 提供了许多开箱即用的功能,包括认证、授权、防止 CSRF 攻击、servlet API 集成支持、Spring MVC 集成、记住我功能、SSO 实现支持、LDAP 认证支持、OpenID 集成、Web 服务安全支持、WebSocket 安全支持、Spring Data 集成,等等。
尽管撰写本文时 Spring Security 的最新版本(5.1.0)支持 XML 和注解支持,但如果你自己设置它,仍然需要进行大量的配置。但不必担心,因为 Spring Boot 与你同在。
Spring Boot 也支持 Spring Security 的集成。就像与其他模块的集成一样,你需要添加一个必需的启动器(starter)来使 Spring Security 与 Spring Boot 一起工作。在 pom.xml 文件中添加以下依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
一旦你设置了之前提到的配置,Spring Security 就会激活并阻止在没有有效凭证的情况下访问应用中的任何页面,即使是公开页面。当你访问 http://localhost:8080/blogpress 时,你会看到一个登录界面。
Spring Security 及其默认(自动)配置允许你使用特定的凭证登录。用户名将是 user,密码将由 Spring Security 随机生成并在服务器日志中像这样打印出来:
使用生成的安全密码:89ca7b55-6a5d-4dd9-9d02-ae462e21df81。
你可以在 property 文件中覆盖用户名和密码。在项目结构中,你将在 src/main/resources 文件夹中看到 application.properties 文件。只需向其中添加以下两个属性:
spring.security.user.name=nilang
spring.security.user.password=password
现在,你可以使用之前提到的凭证访问应用,但你仍然需要认证才能访问公开页面。默认情况下,Spring Security 使用默认(或自动)配置激活,这保护了所有端点。这不是我们想要的。因此,我们需要指导 Spring Security 我们想要使哪些端点(URL)安全,哪些不安全。
为了做到这一点,首先我们需要禁用默认的安全(自动)配置。这里有两种可能的选择。
排除自动配置
在主 bootstrap 类的 @SpringBootApplication 注解中添加一个 exclude 属性,如下面的代码片段所示:
@SpringBootApplication(exclude = { SecurityAutoConfiguration.class })
public class BlogpressApplication {
public static void main(String[] args) {
SpringApplication.run(BlogpressApplication.class, args);
}
}
或者,你可以在 application.properties 文件中添加以下属性:
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
你可以选择之前描述的任何一种方法来禁用或排除安全(自动)配置。排除自动配置仅在需要集成由自定义提供者提供的安全性的特定场景中适用。
替换自动配置
另一种禁用自动安全配置的方法是用我们自己的自定义配置覆盖它。Spring Security 是一个高度可定制的框架,并提供了基于 URL 和角色的细粒度访问机制。
要用自定义配置替换自动配置,我们需要指定配置类,如下面的代码片段所示:
@Configuration
@EnableWebSecurity
@ComponentScan("com.nilangpatel.blogpress")
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
…..
}
WebSecurityConfig 自定义配置类扩展了 WebSecurityConfigurerAdapter 抽象类。这个抽象类有某些扩展点(以抽象方法的形式提供,你可以提供自己的自定义实现)和常见任务的默认实现。
由于我们的类 (WebSecurityConfig) 提供了自定义配置,我们必须使用 @Configuration 和 @ComponentScan("com.nilangpatel.blogpress") 注解来定义它。你需要将自定义配置类所在的包名(@ComponentScan 注解中)提供进去。
@EnableWebSecurity 注解也很重要,因为我们正在禁用默认的安全配置。没有它,我们的应用程序将无法启动。我们现在将覆盖 WebSecurityConfigurerAdapter 类的一个方法,该方法将用于定义网络配置,并添加一个额外的方法,用于定义用户详情:
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**");
web.ignoring().antMatchers("/css/**");
}
Spring Security 默认适用于所有请求——包括静态资源。此方法用于定义静态资源的转义序列。如果在这里没有配置为忽略,Spring Security 将默认阻止它们。在没有之前讨论的配置的情况下,静态资源将不会被加载到浏览器中,因此你将看不到任何 javascript、css 或 images 文件。接下来,我们将按照以下方式将用户详情添加到同一个类中:
// create users and admin
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder encoder = passwordEncoder();
auth.inMemoryAuthentication() .passwordEncoder(encoder)
.withUser("blogUser1").password(encoder.encode("password")).authorities("USER")
.and()
.withUser("blogUser2").password(encoder.encode("password")).authorities("USER")
.and()
.withUser("blogAdmin").password(encoder.encode("password")).authorities("ADMIN");
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
configureGlobal 方法在这里被用来动态创建一个带有密码和角色的用户名。它通过 @Autowired 注解声明,以便 Spring 将 AuthenticationManagerBuilder 类的对象注入其中。AuthenticationManagerBuilder 类用于提供 AuthenticationManager 的实现。正如我们所见,Spring Security 允许各种认证机制,并为这些机制中的每一个提供了 AuthenticationManager 的实现,例如内存认证、LDAP 认证、JDBC 认证、OAuth 认证等等。
为了使事情简单,我们使用了内存认证,它只是简单地将用户详情存储在内存中。然而,这并不适合生产环境。你应该在数据库中创建用户详情,Spring Security 足够灵活,也支持这种场景。
使密码安全是任何安全框架最重要的核心部分,因此 Spring Security 提供了相应的编码机制。它提供了 BCryptPasswordEncoder,这是一个用于编码密码的编码类。它使用 bcrpt 算法进行编码,这是一种在 Spring Security 中广泛使用的非常强大的密码散列程序。
Spring Security 还提供了一个名为 NoOpPasswordEncoder 的类,以防你希望以明文形式存储密码。然而,从版本 5 开始,Spring 决定弃用它,并且它可能在未来的版本中被移除。这是因为将密码以明文形式存储是不被鼓励的,可能会导致安全漏洞。因此,你永远不应该使用 NoOpPasswordEncoder 类(甚至对于任何原型验证都不应该使用)。
我们使用了名为 configureGlobal 的方法,但你绝对可以自由选择你认为合适的方法。
接下来,我们将覆盖另一个方法,这是一个扩展点,为我们应用程序中的每个端点提供自定义安全设置,如下所示:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/").permitAll()
.antMatchers("/controlPage/")
.hasAnyAuthority("ROLE_USER","ROLE_ADMIN")
.and()
.formLogin().loginPage("/login").permitAll()
.defaultSuccessUrl("/controlPage")
.failureUrl("/login?error=true")
.and()
.logout()
.permitAll().logoutSuccessUrl("/login?logout=true");
}
我们覆盖了具有HttpSecurity作为方法参数的 configure 方法,以提供自定义安全配置。如果您打开父类(WebSecurityConfigurerAdapter)的原始 configure 方法,它看起来如下所示。将原始方法的引用并排放置将有助于您理解我们为我们的 Blogpress 应用程序提供的自定义配置。
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic();
}
默认的configure方法简单地认证所有请求。它允许用户通过基于表单的登录进行认证,并支持 HTTP 基本认证。这就是为什么我们在 Spring Boot 中激活 Spring Security 而没有自定义安全配置时,会立即得到默认登录页面。
在 Spring Security 范式中,principal指的是用户名,而authorities指的是用户拥有的角色。在处理 Spring Security 时牢记这些术语将有助于您更好地理解概念。
现在,让我们看看我们在覆盖的 configure 方法中做了哪些自定义,如下所示:
-
antMatchers("/", "/home").permitAll()这一行将允许列出的 URL 无需任何认证。这意味着/(默认 URL —http://localhost:8080/blogpress)URL 是公开可访问的。您可以在逗号分隔的列表中提供任何其他 URL。 -
下一个
antMatchers("/controlPage").hasAnyAuthority("ROLE_USER","ROLE_ADMIN")行使得/controlPageURL 对具有ROLE_USER或ROLE_ADMIN角色的任何用户可访问。 -
下一个
formLogin().loginPage("/login").permitAll()行允许我们设置登录页面 URL。我们保留了/loginURL,但您可以提供任何自定义登录 URL。由于登录页面应该是公开可访问的,permitAll()方法将使登录 URL 对所有用户可访问。 -
一旦 Spring Security 认证了用户,它将发送到成功页面。您可以使用
defaultSuccessUrl("/controlPage")配置自定义成功页面。在这种情况下,成功 URL 是/controlPage。 -
类似地,如果认证失败,它应该发送到错误页面。
failureUrl("/login?error=true")这一行将在认证失败时将流程发送到/loginURL(包括参数)。 -
最后,
permitAll().logoutSuccessUrl("/login?logout=true")这一行配置了注销页面。一旦用户注销,系统将触发/loginURL(包括参数)。
我们已经添加了我们的自定义安全配置;现在,是时候在 Spring MVC 中添加与之前在 Spring 配置中提到的每个 URL 相对应的方法。在 Spring MVC 中添加以下方法:
@GetMapping("/")
public String showHomePage(Model model) {
logger.info("This is show home page method ");
setProcessingData(model, BlogpressConstants.TITLE_HOME_PAGE);
return "home";
}
@GetMapping("/controlPage")
public String showControlPage(Model model) {
logger.info("This is control page ");
setProcessingData(model, BlogpressConstants.TITLE_LANDING_CONTROL_PAGE);
return "control-page";
}
@GetMapping("/login")
public String showLoginPage(@RequestParam(value = "error",required = false) String error,
@RequestParam(value = "logout", required = false) String logout,Model model) {
logger.info("This is login page URL ");
if (error != null) {
model.addAttribute("error", "Invalid Credentials provided.");
}
if (logout != null) {
model.addAttribute("message", "Logged out");
}
setProcessingData(model, BlogpressConstants.TITLE_LOGIN_PAGE);
return "login";
}
showHomePage 方法负责在用户点击导航中的“主页”链接时显示主页。它与 / URL 关联,并将显示 home.html(Thymeleaf)模板。此外,当你访问 http://localhost:8080/blogpress URL 时,此方法也会被调用。
showControlPage 方法与 /controlPage URL 关联,并在成功认证后被调用。此方法将用户引导到 control-page.html(Thymeleaf)模板,该模板根据角色显示管理链接。例如,具有 ROLE_ADMIN 角色的用户可以看到“管理博客”和“管理评论”的链接,而具有 ROLE_USER 角色的用户将只能看到“管理博客”链接。
showLoginPage 方法代表登录功能。它与 /login URL 关联。它根据参数值存储消息以及页面标题属性,该属性用于显示页面标题(在 header.html 模板中)。最后,它打开 login.html 模板。
除了这些方法之外,还添加了以下方法,这些方法直接使用 ${} 表达式在 Thymeleaf 模板中存储可用的模型属性:
@ModelAttribute("validUserLogin")
public boolean isUserLoggedIn() {
return SecurityContextHolder.getContext().getAuthentication() != null && SecurityContextHolder.getContext().getAuthentication().isAuthenticated() &&
//when Anonymous Authentication is enabled
!(SecurityContextHolder.getContext().getAuthentication() instanceof AnonymousAuthenticationToken);
}
@ModelAttribute("currentUserName")
public String getCurrentUserName() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
@ModelAttribute("hasAdminRole")
public boolean checkIfUserHasAdminRole(){
return checkIfUserHasRole(BlogpressConstants.ROLE_ADMIN);
}
@ModelAttribute("hasUserRole")
public boolean checkIfUserHasUserRole(){
return checkIfUserHasRole(BlogpressConstants.ROLE_USER);
}
private boolean checkIfUserHasRole(String roleName) {
boolean hasUserRole = SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
.anyMatch(r -> r.getAuthority().equals(roleName));
return hasUserRole;
}
isUserLoggedIn 方法检查是否有用户当前登录。它将由 Thymeleaf 模板中的 ${validUserLogin} 表达式调用。getCurrentUserName 方法简单地提供当前登录的用户名。checkIfUserHasUserRole 和 checkIfUserHasAdminRole 方法简单地检查相应的角色。你可以看到 SecurityContextHolder 类是如何用来获取用户登录详情的。这个类负责存储当前认证用户的详情,也称为主体。
我们逐渐完善了 Blogpress 应用程序,现在它已经配备了 Spring MVC、Thymeleaf 和 Spring Security。所有这些快速开发都是借助 Spring Boot 实现的。接下来我们要开发的部分是一个数据层,这是我们应用中最重要和关键的部分。正如我们提到的,我们将使用 Elasticsearch 构建数据层。
使用 Elasticsearch 存储数据
Elasticsearch 是一个高度可扩展的全文搜索开源 RESTful 搜索、索引和分析引擎,它是在 Lucene 的基础上开发的。它是目前构建企业应用中最受欢迎的搜索引擎之一。它可以非常快速地保存、搜索和分析大量数据。主要,它用于需要复杂搜索的应用。
它是用 Java 开发的,提供近乎实时的结果。它设计用于在分布式环境中工作,以提供高可用性和可伸缩性。它是面向文档的,以 JSON 格式存储复杂的实体结构,并提供一个网络界面进行交互。
Elasticsearch 主要用于在应用程序中搜索大量匹配产品(例如,电子商务)、使用自动完成功能进行部分输入或分析以分布式方式存储的大量原始数据。
艺术品
理解一些与 Elasticsearch 频繁使用的术语非常重要,这将帮助你了解 Elasticsearch 的构建方式和它的工作原理。它们是 Elasticsearch 的核心。我们将详细查看每个术语。
文档
在 Elasticsearch 中存储的基本信息单元被称为 文档。你可以将文档视为关系型数据库管理系统(RDBMS)中的一个实体。例如,可以为员工创建一个文档,另一个文档用于薪资,等等。文档将由 Elasticsearch 引擎进行索引,并以 JSON 格式呈现。每个文档都与文档类型相关联。你可以将文档类型与 Plain Old Java Object(POJO)类相关联,而文档作为 POJO 类的对象。
索引
索引是一组具有相似结构的文档。你可以为员工数据定义一个 索引,另一个用于薪资数据,等等。索引可以通过与其关联的名称进行识别。索引名称用于索引、搜索以及包含文档的 CRUD 操作。你可以定义尽可能多的索引。索引始终独立于其他索引。在 Elasticsearch 中,一组索引被称为 indices。
在版本 6.0.0 之前,Elasticsearch 允许为给定索引创建多个文档类型。例如,你可以为用户和员工(或更多)创建文档类型以进行索引组织。从版本 6 开始,Elasticsearch 对给定索引只允许一个文档类型的创建。因此,你需要为每个文档类型创建一个单独的索引。
集群和节点
Elasticsearch 是一个分布式系统,这意味着它可以水平扩展,并在多个服务器上运行以以最佳速度处理大量数据。这种服务器的网络被称为 集群,而单个服务器则被称为 节点。
节点和集群都通过名称进行标识。对于节点,Elasticsearch 在启动时生成一个默认的随机 全局唯一标识符(UUID)。如果你愿意,你可以更改默认名称。节点名称很重要,因为它将有助于管理与节点名称关联的服务器。
节点使用集群名称来加入集群。默认情况下,所有节点都与名为 elasticsearch 的集群相关联。你可以为给定的集群创建尽可能多的节点。
分片和副本
Elasticsearch 以文档的形式存储数据,这些文档被分组到一个索引中。在大量数据的情况下,单个索引中的文档数量可能会超过底层硬件容量的限制。例如,存储在单个索引中的超过万亿的文档可能需要高达100 GB的空间,这可能无法在一个节点中存储。
作为解决这个问题的一种方法,Elasticsearch 提供了一个机制将索引分割成多个部分;每个部分都可以被视为一个单独的索引,并且可以存储在多个节点上。索引的部分被称为分片。这也会提高搜索性能,因为搜索可以在多个分片上同时进行。
副本,正如其名所暗示的,是分片的副本。它们是为了故障转移而创建的;如果某个分片出现故障或离线,副本将被用来提供服务并使系统高度可用。
简而言之,一个索引可以被分割成多个分片;每个分片可以有零个或多个副本。因此,每个索引都有一个主分片,以及零个或多个副本分片。默认情况下,Elasticsearch 为每个索引关联五个主分片和一个副本(截至最新稳定版本 6.4.1)。
对于我们的 Blogpress 应用程序,我们将保持默认值,一个节点拥有默认分片和副本设置的索引。索引的名称将是blog。
与 Elasticsearch 交互
Elasticsearch 提供了一种用于搜索、索引和执行其他 CRUD 操作与之交互的方式。它提供了一个 RESTful API 用于交互,因此你可以使用各种 HTTP 方法(GET、POST、PUT、DELETE等)来处理对 Elasticsearch 的任何操作。
Elasticsearch 不维护请求的状态,因此每个请求都是独立的,信息以 JSON 格式交换。各种 HTTP 方法用于在 Elasticsearch 上执行 CRUD 操作。例如,使用GET方法检索数据,而PUT、POST和DELETE用于更新或删除记录。
由于 Elasticsearch 公开了 REST API,你可以使用任何 REST 客户端(例如,Postman)与之交互。此外,为了分析和可视化数据,Elasticsearch 提供了一个名为Kibana的另一个免费开源工具。它提供了一个基于浏览器的简单界面来执行搜索、查看和其他 CRUD 操作,以及丰富的数据分析,以各种表格、图表和类似地图的内存形式呈现,以及磁盘利用率、索引和文档信息。它还帮助管理索引和文档类型,对文档数据进行 CRUD 操作等。
安装
让我们先安装 Elasticsearch。从 www.elastic.co/downloads 下载 Elasticsearch ZIP 套件。将其解压缩到您的本地驱动器中,并运行 bin/elasticsearch。默认情况下,它将在 9200 端口上可用。一旦启动并运行,您就可以通过 http://localhost:9200 访问它。
您可以从相同的 www.elastic.co/downloads URL 下载并安装 Kibana。解压缩套件并运行 bin/kibana。您可以在 5601 端口上访问 Kibana,即 http://localhost:5601。
Elasticsearch 还为 Windows 提供了 MSI 安装程序,这是在 Windows 机器上安装 Elasticsearch 的简单方法。
Elasticsearch RESTful API
接下来,我们快速查看一些用于在 Elasticsearch 上执行各种活动的 API。由于 Elasticsearch 提供了用于交互的 REST 接口,因此您可以使用任何 REST 客户端,例如 Postman。或者,您可以使用 Kibana Dev Tools 执行 REST 调用。它们之间有一些小的区别。
我们将通过一个 student 实体的例子来了解各种 RESTful API。目的是解释如何创建 students 索引;创建 student 文档类型;添加、更新和删除 student 数据;以及删除文档类型和索引。
创建索引 – students
使用您的 REST 客户端(Postman),输入以下内容:
-
URL:
http://localhost:9200/students -
方法:PUT
-
类型:JSON(application/json)
-
主体:
{
}
使用 Kibana,转到 Kibana 中的 Dev Tools 选项并输入以下脚本:
PUT students
{
}
您将看到以下输出:

我们已经创建了一个没有明确设置的 student 索引,因此 Elasticsearch 使用默认设置创建了索引——五个分片和一个副本。您可以在 Kibana 的管理选项中看到这些详细信息。如果您希望给出精确的分片和副本数量(而不是默认的五个和一个),您可以在创建 student 索引时在主体中添加 JSON 设置,如下所示:
使用 REST 客户端(Postman),输入以下内容:
-
URL:
http://localhost:9200/students -
方法:PUT
-
类型:JSON(application/json)
-
主体:
{
"settings" : {
"index" : {
"number_of_shards" : 3,
"number_of_replicas" : 2
}
}
}
使用 Kibana,转到 Dev Tools 并输入以下脚本:
PUT student
{
"settings" : {
"index" : {
"number_of_shards" : 3,
"number_of_replicas" : 2
}
}
}
在上一个例子中,索引是通过三个分片和两个副本创建的。这就是在 Elasticsearch 中创建索引时指定特定设置的方法。
创建文档类型 – 学生
在创建索引之后,序列中的下一个操作是创建文档类型。我们将在 students 索引内创建一个名为 student 的文档类型。同样,这可以通过 REST 客户端或 Kibana 完成。我们将详细查看这两种选项。
使用 REST 客户端(Postman),输入以下内容:
-
URL:
http://localhost:9200/students/_mapping/student -
方法:POST
-
类型:JSON(application/json)
-
主体:
{
"properties":{
"id":{"type":"long"},
"name":{"type":"text"},
"standard":{"type":"integer"},
"division":{"type":"text"},
"gender":{"type":"text"}
}
}
使用 Kibana,转到 Dev Tools 选项并添加以下脚本:
PUT students/_mapping/student
{
"properties": {
"id":{"type":"long"},
"name":{"type":"text"},
"standard":{"type":"integer"},
"division":{"type":"text"},
"Gender":{"type":"text"}
}
}
您可以使用这两种选项中的任何一种来创建文档类型。我们在 students 索引中创建了具有 ID、姓名、标准、班级和性别属性的 student 文档类型. 我们的结构已准备好将数据添加到 Elasticsearch。接下来,我们将看到如何插入我们定义的 student 类型的数据。
在版本 6 之前,Elasticsearch 允许在同一个索引中创建多个文档类型。从 6 版本开始,它们对创建文档类型做了限制,即在该索引中只能创建一个文档类型。
添加文档(学生数据)
使用 REST 客户端(Postman),输入以下内容:
-
URL:
http://localhost:9200/students/student/1 -
方法:PUT
-
类型:JSON (application/json)
-
主体:
{
"name":"Nilang",
"standard":3,
"division":"B",
"gender":"M"
}
使用 Kibana,进入 Dev Tools 选项并输入以下脚本:
PUT students/student/1
{
"name":"Nilang",
"standard":3,
"division":"B",
"gender":"M"
}
您可以使用以下 REST API 验证插入的数据。
读取文档(学生数据)
使用 REST 客户端,输入以下内容**:
-
URL:
http://localhost:9200/students/student/1 -
方法:GET
使用 Kibana,输入以下内容:
GET students/student/1
您将得到以下 JSON 作为输出:
{
"_index": "students",
"_type": "student",
"_id": "1",
"_version": 1,
"found": true,
"_source": {
"name": "Nilang",
"standard": 1,
"division": "B",
"gender": "M"
}
}
首先,它显示了索引和文档类型。_id 属性代表我们在创建数据时在 http:/localhost:9200/students/student/1 URL 中提供的 ID。如果您使用任何现有的 _id,Elasticsearch 将简单地使用当前值更新该记录。_version 属性代表记录被更新的次数。_source 属性代表我们提供的数据。
更新文档(学生数据)
要更新数据,使用的语法与添加文档相同。在添加时,如果系统中不存在 ID,则 Elasticsearch 将为您自动生成一个。例如,以下命令将更新具有 _id 等于五的现有学生记录。
使用 REST 客户端(Postman),使用以下内容**:
-
URL:
http://localhost:9200/students/student/5 -
方法:POST
-
类型:JSON (application/json)
-
主体:
{
"name":"Robert",
"standard":6,
"division":"C",
"gender":"M"
}
使用 Kibana,进入 Dev Tools 并执行以下查询:
PUT students/student/5
{
"name":"Robert",
"standard":6,
"division":"C",
"gender":"M"
}
插入和更新操作使用类似的语法,如果您尝试添加具有已存在 ID 的记录,那么该记录可能会错误地被更新。为了避免这种情况,您可以使用 localhost:9200/students/student/1/_create URL。如果存在具有 1 ID 的记录,这将引发错误。同样,如果您希望更新记录,可以使用 localhost:9200/students/student/1/_update。在更新记录时,如果记录不存在,它将引发错误。
在添加文档记录时,如果您不提供 _id,Elasticsearch 将为您自动生成一个。
删除文档(学生数据)
删除文档很简单。您需要使用 HTTP DELETE 方法。只需指定要删除的文档的 _id,如下所示。
使用 REST 客户端(Postman),执行以下操作:
-
URL:
http://localhost:9200/students/student/1 -
方法:DELETE
使用 Kibana,使用以下内容:
DELETE students/student/1
搜索查询
Elasticsearch 通过在 URL 末尾传递 /_search 提供搜索功能。它可以在服务器 URL、索引或类型之后应用。例如,在我们的案例中,如果我们想搜索名为 nilang 的学生文档,我们必须使用以下查询。
使用 REST 客户端(Postman),使用以下方法:
-
URL:
http://localhost:9200/students/student/_search?q=name:nilang -
方法: GET
使用 Kibana,使用:
GET students/student/_search?q=name:nilang
或者,您可以使用以下语法进行搜索。这对于具有多个搜索标准的多字段复杂搜索非常有用:
GET students/student/_search
{
"query": {
"match": {
"name": "nilang"
}
}
}
为 Blogpress 创建索引和文档类型
在掌握如何创建索引和文档类型,以及如何在 Elasticsearch 中插入文档数据的基本知识后,我们将为 Blogpress 应用程序创建这些工件。在这个应用程序中,我们需要存储博客和评论的数据。博客和评论之间存在一对一的关系(一个博客有多个评论),我们将创建一个索引结构,以便多个评论与单个博客相关联。
Elasticsearch 提供嵌套数据类型来索引对象数组,并将它们作为独立文档维护。我们将为单个博客维护一个评论数组。我们将给索引命名为 blog 并设置文档类型为 blog。以下是一个可以运行的脚本,用于创建 blog 索引:
PUT blog
{
"mappings":{
"blog":{
"properties":{
"title":{"type":"text"},
"body":{"type":"text"},
"status":{"type":"text"},
"createdBy":{"type":"text"},
"createdDate":{"type":"date",
"format": "MM-dd-yyyy'T'HH:mm:ss"},
"publishDate":{"type":"date",
"format": "MM-dd-yyyy'T'HH:mm:ss"},
"comments":{
"type":"nested",
"properties":{
"id":{"type":"text"},
"parentId":{"type":"keyword"},
"childSequence":{"type":"integer"},
"position":{"type":"text"},
"status":{"type":"keyword"},
"level":{"type":"integer"},
"user":{"type":"text"},
"emailAddress":{"type":"text"},
"commentText":{"type":"text"},
"createdDate":{"type":"date",
"format": "MM-dd-yyyy'T'HH:mm:ss"}
}
}
}
}
}
}
在前面的脚本中,我们同时创建了索引和文档类型。mappings 旁边的元素表示文档类型的名称,而索引名称与 PUT HTTP 方法(在我们的案例中为 blog)一起使用。所有属性都是自解释的,除了定义为一个 nested 类型及其属性的评论。日期的格式可以通过 format 属性设置。
Elasticsearch 与 Spring Data 集成
我们将配置 Elasticsearch 作为数据库,为 Blogpress 应用程序提供各种 CRUD 操作。我们将使用 Spring Data 进行此集成。Spring Data 为从各种提供者(如关系数据库、非关系数据库、Map-Reduce 框架和云服务)进行数据访问提供了一个抽象层。
对于这些数据提供者中的每一个,Spring 都提供了一套库来与之交互,同时保持以对称方式与之交互的抽象。Spring Data 涵盖了多个模块,包括 Spring Data Common、Spring Data JPA、Spring Data REST、Spring Data LDAP、Spring Data MongoDB、Spring Data JDBC 以及更多。Spring Data Elasticsearch 是其中之一,提供与 Elasticsearch 搜索引擎的数据访问。
我们将为 Blogpress 应用程序使用 Spring Data Elasticsearch 模块。首先要做的是使此模块在我们的应用程序中可用。不出所料,这可以通过在 pom.xml 中定义一个启动器来完成,如下所示:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
Elasticsearch 提供了一个 Java API,可以以编程方式与之交互。在激活上述启动器后不久,所需的 JAR 文件将被添加到类路径中,以便访问 Elasticsearch Java API。此时,我们需要指导 Spring Data Elasticsearch 模块关于 Elasticsearch 服务器正在运行的集群名称、端口和主机名。您可以在application.properties文件中(位于src/main/resource文件夹中)定义以下配置:
elasticsearch.clustername=elasticsearch
elasticsearch.host=localhost
elasticsearch.port=9300
这与定义数据库 URL、驱动类名称和数据库交互凭据的 Spring Data 相当。下一步是定义一个配置类,它基本上使用之前的信息并准备与 Elasticsearch 交互所需的工件,如下所示:
@Configuration
@EnableElasticsearchRepositories(basePackages = "com.nilangpatel.blogpress.repository")
@ComponentScan(basePackages = { "com.nilangpatel.blogpress.config" })
public class ElasticDataConfig {
@Value("${elasticsearch.host}")
private String esHost;
@Value("${elasticsearch.port}")
private int esPort;
@Value("${elasticsearch.clustername}")
private String esClusterName;
@Bean
public Client client() throws Exception {
TransportClientFactoryBean transportClientFactory = new TransportClientFactoryBean();
transportClientFactory.setClusterName(esClusterName);
transportClientFactory.afterPropertiesSet();
return transportClientFactory.getObject()
.addTransportAddress(
new TransportAddress(InetAddress.getByName(esHost), esPort));
}
@Bean
public ElasticsearchTemplate elasticsearchTemplate() throws Exception {
return new ElasticsearchTemplate(client());
}
}
这个类使用@Value注解读取在application.properties文件中定义的集群名称、端口和主机值。client()方法使用TransactionClientFactory对象读取配置数据,并返回一个TransportClient类的对象,该对象代表与 Elasticsearch 交互的客户端接口。
下一个elasticsearchTemplate()方法使用此客户端对象并创建ElasticsearchTemplate对象。Spring 为每个数据提供者提供了数据访问模板类。ElasticsearchTemplate类的对象使用TransportClient类的对象初始化。此方法使用@Bean注解定义,以便可以通过@Autowired注解在其他类中访问ElasticsearchTemplate对象。
此初始化发生在启动应用程序时。ElasticsearchTemplate类是 Elasticsearch 引擎与 Spring Data 交互的单一点。此类中的@EnableElasticsearchRepositories注解用于指向我们接下来要定义的 Spring JPA 仓库包。在此之前,我们首先定义一个表示 Elasticsearch 中文档的模型类。
Spring Data Elasticsearch 模型类
Spring Data 简化了各种数据提供者的数据访问对象(DAO)层实现。DAO 机制通过提供数据访问抽象,使系统松散耦合,允许在不影响业务实现的情况下更改底层数据提供者。
它允许以面向对象的方式与数据层进行交互。这意味着您可以使用实体类对象创建、读取、更新和删除数据。这种抽象也适用于 Spring Data Elasticsearch 模块。您可以用对象的形式访问数据。为此,我们需要定义一个模型(实体)类,它代表我们在 Elasticsearch 中定义的数据结构,如下所示:
@Document(indexName = "blog", type = "blog")
public class Blog {
@Id
private String _id;
private String title;
private String body;
private String status;
private String createdBy;
@JsonFormat
(shape = JsonFormat.Shape.STRING, pattern = "MM-dd-yyyy'T'HH:mm:ss")
private Date createdDate;
@JsonFormat
(shape = JsonFormat.Shape.STRING, pattern = "MM-dd-yyyy'T'HH:mm:ss")
private Date publishDate;
@Field(includeInParent=true, type = FieldType.Nested)
private List<Comment> comments;
// Getter and setters for above properties
模型类是一个带有@Document注解的 POJO,它定义了该类关联的索引和文档类型名称。Blog类的对象之前代表 Elasticsearch 中blog索引和blog文档的文档数据。@Id注解用于为博客文档定义一个唯一的 ID。你可以将其与关系型数据库中的主键相关联。日期字段使用@JsonFormat注解定义,该注解用于定义所需的日期格式。
@Field注解用于定义字段的额外元数据。例如,在评论的情况下,它被定义为nested类型,因为在 Java 中没有直接映射 Elasticsearch nested类型的可用。对于其他属性,Java 类型直接映射到 Elasticsearch 类型。接下来,我们将使用 Spring Data 定义一个 DAO 层。
在映射脚本中提到的日期格式应该与 POJO 类中用@JsonFormat注解定义的日期格式完全匹配。如果不匹配,系统在插入记录时会显示错误。
将 Elasticsearch 与 Spring Data 连接
Spring Data 有一个称为仓库的概念,它是数据存储的抽象。设计用来添加一个额外的层,通过提供仓库抽象和为每个数据提供者提供具体的仓库实现(包含所有样板代码),它带来了巨大的力量和灵活性。
对于 Elasticsearch,Spring Data 提供了一个名为ElasticsearchRepository的仓库接口。此接口(及其父接口)具有与 Elasticsearch 交互所需的所有必需方法。为了获得 Spring Data 的好处,我们需要扩展此接口,以便 Spring Data 可以自动提供具体的实现。显然,所有必需的 CRUD 方法都包含在标准的 DAO 中。
让我们利用 Spring Data 为 Blogpress 应用程序提供 Elasticsearch 的能力。首先,让我们定义一个自定义的仓库接口,该接口扩展了ElasticsearchRepository<T, ID extends Serializable>,其中T代表实体类,ID代表实体类中的唯一 ID,如下所示:
public interface BlogRepository extends ElasticsearchRepository<Blog, String>
Blog实体类有一个_Id(一个字符串)作为唯一标识符(使用@Id注解声明)。我们的 DAO 层已经准备好了所有基本的 CRUD 操作。始终定义服务类,展示服务层是一个好主意。因此,我们将声明BlogService服务类如下:
@Component
public class BlogService {
@Autowired
private BlogRepository blogRepository;
....
}
使用@Autowired注解,Spring 会将BlogRepository对象注入到我们的服务类中,该对象可以用来执行各种 CRUD 操作。接下来,我们可以开始对 Elasticsearch 中的博客数据进行 CRUD 操作。
使用 Spring Data 在 Elasticsearch 中执行 CRUD 操作
DAO 和服务层的基本结构已准备就绪。我们现在可以开始执行 CRUD 操作。正如我们所见,只需声明一个自定义仓库接口,Spring 就在 DAO 层提供了所有基本的 CRUD 操作。
添加博客数据
首先,我们将创建一个新的博客记录。为此,在BlogService类中添加以下方法:
public void addUpdateBlog(Blog blog) {
blogRepository.save(blog);
}
BlogRepository对象由 Spring 注入,可用于执行添加操作。这个服务方法应该从 Spring 控制器中调用。在控制器类中添加以下方法来管理添加(或更新)新博客的功能:
@GetMapping("/showAddNew")
public String showAddNew(Model model) {
logger.info("This is addNew page URL ");
setProcessingData(model, BlogpressConstants.TITLE_NEW_BLOG_PAGE);
return "add-new";
}
@PostMapping("/addNewBlog")
public String addNewBlog(@RequestParam(value = "title",required = true) String title,
@RequestParam(value = "body",required = true) String body,Model model) {
logger.info("Adding new blog with title :"+title );
Blog blog = new Blog();
blog.setTitle(title);
blog.setBody(body);
blog.setCreatedBy(getCurrentUserName());
blog.setCreatedDate(new Date());
blog.setPublishDate(new Date());
blog.setStatus(BlogStatus.PUBLISHED.getStatus());
blogService.addNewBlog(blog);
return "home";
}
showAddNew()方法简单地打开add-new.html Thymeleaf 模板。当用户点击导航中的“添加新内容”链接时,此方法将被调用,并显示此模板,用户可以在其中添加带有标题和正文的新的博客。
第二个方法——addNew,使用@PostMapping注解声明,它接受title和body作为请求参数,创建一个Blog类型的对象,设置这些值并调用服务类的addNewBlog()方法。你可以在 Kibana 中执行以下查询来查看插入到 Elasticsearch 中的数据:
GET blog/blog/_search
读取博客数据
接下来是在主页上以表格格式显示博客条目。当用户点击它时,系统将打开博客的详细视图(显示标题、全文和所有评论)。为了在主页上列出博客,我们将以编程方式从 Elasticsearch 获取博客数据。在BlogService类中添加以下方法:
public List<Blog> getAllBlogs() {
List<Blog> blogList = new ArrayList<Blog>();
Iterable<Blog> blogIterable = blogRepository.findAll();
Iterator<Blog> blogIterator = blogIterable.iterator();
while(blogIterator.hasNext()) {
blogList.add(blogIterator.next());
}
return blogList;
}
getAllBlogs()方法简单地调用blogRepository上的findAll()方法来获取所有博客条目。这个服务方法可以从控制器中调用,以在主页上显示这些数据。我们将使用 REST 控制器来展示如何利用 Spring REST 控制器来展示数据。我们将在稍后介绍,所以请继续阅读。
总是建议使用带有分页的仓库方法。由于本章的目的是展示各种组件及其工作方式,我没有使用分页来简化内容。
搜索博客数据
由于这是一个博客应用程序,搜索是一个明显的功能。我们将允许用户通过匹配搜索文本与博客标题和正文来搜索博客。我们可以通过在 URL 末尾传递/_search来搜索文档。Elasticsearch 提供布尔查询,可以根据各种条件搜索数据。
在我们的案例中,搜索文本应该与标题或正文或两者匹配。这可以通过以下布尔搜索查询实现:
GET blog/blog/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "java" }},
{ "match": { "body": "java" }}
]
}
}
}
should条件等同于OR条件。Elasticsearch 提供了must条件,如果你想要使用AND条件进行搜索。你可以指定任意多的属性。字符串java是搜索文本。这个查询可以用以下方式在 Java 中编程实现:
QueryBuilder booleanQry = QueryBuilders.boolQuery()
.should(QueryBuilders.termQuery("title", searchTxt))
.should(QueryBuilders.termQuery("body", searchTxt));
SearchResponse response = elasticsearchTemplate.getClient().prepareSearch("blog")
.setTypes("blog")
.setQuery(booleanQry)
.execute().actionGet();
我们正在创建一个布尔查询,并配置搜索文本为标题和正文属性。搜索结果将以 JSON 格式返回,包含 response 对象。您可以解析 JSON 以获取所需输出。
使用 Elasticsearch 聚合添加评论数据
博客已被添加到系统中。现在用户可以添加评论。接下来,我们将看到如何添加评论。正如讨论的那样,Comment 文档类型被定义为博客文档中的一个 nested 类型。这意味着博客文档包含一个评论对象的数组,形成一个一对多的关系。我们还需要创建一个如下所示的评论模型类:
public class Comment {
private String id;
private String blogId;
private String parentId;
private int childSequence;
private String position;
private String status;
private int level;
private String user;
private String emailAddress;
private String commentText;
@JsonFormat
(shape = JsonFormat.Shape.STRING, pattern = "MM-dd-yyyy'T'HH:mm:ss")
private Date createdDate;
//Getter and Setter methods
.....
}
由于这个操作是在博客内部嵌套的,因此不需要定义 @Document 注解,因为它并不直接与任何文档类型相关联。在添加评论时,需要关注以下某些元数据:
-
我们提供了具有回复功能的评论。一旦用户对任何评论进行回复,它将被添加到下一级,将其视为子评论。为了维护这一点,我们使用
level属性,它简单地显示了评论放置的级别。 -
blogId属性简单地持有与该评论关联的博客的 ID。由于这是一个嵌套对象,在大多数情况下,不需要父文档 ID。但我们将向管理员用户展示评论列表以进行审核和回复。为了使评论管理简单,我们只是在评论中添加了blogId。 -
parentId属性持有父评论的 ID,如果它被放置为回复,否则它将为零。 -
childSequence属性简单地显示了特定级别的序列号。例如,如果有总共两个回复(在第二级),并且用户尝试添加第三个回复(在第二级),那么childSequence属性将为三。此属性用于构建position属性的值。 -
position属性将是level和childSequence的组合。这用于对评论进行排序,以便它们以正确的顺序显示在给定的博客中。
由于评论是博客的 nested 类型,没有这样的方法可以单独保存评论。相反,我们需要获取所有评论,将新的评论添加到相关的博客中,然后保存整个博客。一切都很直接,除了获取 childSequence 的值。我们将通过以下聚合查询看到如何获取给定级别的最大 childSequence:
GET blog/blog/_search
{
"query": {
"match": {
"_id": "1huEWWYB1CjEZ-A9sjir"
}
},
"aggs": {
"aggChild": {
"nested": {
"path": "comments"
},
"aggs": {
"filterParentId": {
"filter": {
"nested": {
"path": "comments",
"query": {
"match": {
"comments.parentId": "0"
}
}
}
},
"aggs": {
"maxChildSeq": {
"max": {
"field": "comments.childSequence"
}
}
}
}
}
}
}
}
在我们理解查询之前,我们需要看看什么是聚合。在 Elasticsearch 中,聚合是一种用于在搜索查询上提供聚合数据的机制。它们用于构建复杂查询。它们分为以下四个类别:
-
分桶
-
指标
-
矩阵
-
管道
这些聚合类型都可以以嵌套的方式使用,这意味着它可以作为另一个聚合的子聚合来使用,以解决非常复杂的查询。现在,让我们回到查找 childSequence 的查询,并理解它。
首个 query 标准与 blogId (_id) 的值匹配。在 query 标准开始时给出的任何属性都将与 blog 属性的值匹配。接下来是应用于 nested 文档的聚合查询——comments。每个聚合查询都有一个名称。第一个聚合查询的名称是 aggChild。
进一步来说,名为 filterParentId 的下一个聚合查询简单地匹配 parentId,这实际上就是父评论的 ID。这是为了在给定的评论作为父评论的情况下找到 childSequence。对于顶级评论,这个值必须是零。最后一个名为 maxChildSeq 的聚合查询简单地找到 childSequence 的最大值。它使用最大标准。每个 nested 聚合查询简单地应用前一个聚合查询给出的搜索标准。你将得到类似于以下查询的结果:
"aggregations": {
"aggChild": {
"doc_count": 4,
"filterParentId": {
"doc_count": 2,
"maxChildSeq": {
"value": 3
}
}
}
}
查询结果包含其他信息,但我们只关注 aggregation。结果显示了每个聚合查询的文档计数。maxChildSeq 的值为三意味着在一级(顶级评论)有三个评论,所以当用户添加一个新的(顶级)评论时,childSequence 将是四。
这就是基于 REST 的查询。对于 Blogpress 应用程序,我们需要在 Java 类中执行类似的查询。Elasticsearch 提供了 Java API 来执行通过 REST 查询可以执行的所有操作。当我们定义 Spring Boot 中的 Elasticsearch 启动器时,所需的 Elasticsearch JAR 文件都位于类路径中。为了使用 Java API 编写前面的查询,我们需要在我们的 Elasticsearch 存储库中编写一个自定义的获取方法。
Spring Data 是一个可扩展的框架,允许我们在它提供的开箱即用的存储库之上提供自定义实现。因此,我们首先将以下步骤扩展到 Elasticsearch 存储库。
-
定义一个名为
BlogRepositoryCustom的自定义存储库接口。 -
我们最初创建的
BlogRepository接口应该扩展此接口,以及ElasticsearchRepository<Blog, String>,如下所示:
public interface BlogRepository extends ElasticsearchRepository<Blog, String>,BlogRepositoryCustom
- 定义一个实现
BlogRepositoryCustom接口的自定义存储库实现类,如下所示:
@Repository
public class BlogRepositoryCustomImpl implements BlogRepositoryCustom {
private static Logger logger = LoggerFactory.getLogger(BlogRepositoryCustomImpl.class);
@Autowired
private ElasticsearchTemplate elasticsearchTemplate;
....
}
此类必须使用 @Repository 注解声明。我们可以在此类中定义任何自定义方法。我们想要编写一个使用 Elasticsearch Java API 来查找给定级别上最大子序列的方法,因此我们将它写在这个类中,如下所示:
public int getCurrentChildSequence(String blogId,String parentCommentId) {
int currentChildSeq=0;
TermQueryBuilder termQueryBuilder = new TermQueryBuilder("comments.parentId", parentCommentId);
NestedAggregationBuilder aggregationBuilder = AggregationBuilders.nested("aggChild", "comments").subAggregation(AggregationBuilders.filter("filterParentId", termQueryBuilder).subAggregation(AggregationBuilders.max("maxChildSeq").field("comments.childSequence")));
TermQueryBuilder rootTermQueryBuilder = new TermQueryBuilder("_id", blogId);
SearchResponse response = elasticsearchTemplate.getClient().prepareSearch("blog")
.setTypes("blog")
.setQuery(rootTermQueryBuilder)
.addAggregation(aggregationBuilder)
.execute().actionGet();
if(response !=null) {
if(response.getAggregations() !=null) {
List<Aggregation> aggLst = response.getAggregations().asList();
if(aggLst !=null) {
Aggregation resultAgg = aggLst.get(0);
if(resultAgg !=null) {
//getMaxChildSequenceFromJson method parse the json to get max child sequence
currentChildSeq = getMaxChildSequenceFromJson(resultAgg.toString());
}
}
}
}
//Adding one to set next sequence
currentChildSeq=currentChildSeq+1;
return currentChildSeq;
}
AggregationBuilders 类用于构建聚合查询。Elasticsearch Java API 是自我解释的,简单易懂。您可以轻松地将此 Java API 查询与 REST 查询相关联。我们首先创建一个嵌套聚合查询,然后添加一个作为子聚合的过滤聚合查询,之后是一个 max 聚合。
blogId 的值是通过 TermQueryBuilder 类添加的。最后,我们从 elasticsearchTemplate 获取 Elasticsearch 客户端,通过提供索引名称(blog)、文档类型(blog)、根级查询(针对 blogId)以及最后设置聚合来启动搜索。这个 Java API 返回我们为 REST 查询获取的聚合 JSON,您可以使用 JSON API 处理以获取所需的结果。
使用 Elasticsearch 聚合读取评论数据
一旦添加了评论,当用户打开博客时它们必须可见。这种情况很简单。由于评论是博客的嵌套对象,当我们使用以下 API 读取博客时,所有评论也将作为博客对象的一部分提供:
Optional<Blog> blogObj = blogRepository.findById(blogId);
if(blogObj.isPresent()) {
return blogObj.get();
}else {
return null;
}
findById 方法是由默认的仓库实现提供的,在运行时可用。我们传递 blogId,它将获取博客的所有详细信息以及评论(作为嵌套对象)。
读取评论的第二个场景是管理员用户打开管理评论页面,在此页面上显示所有评论以供审核。在这种情况下,系统将显示添加到任何博客的所有评论,因此有必要从所有博客中获取所有评论。
实现这一目标的第一种方法是获取所有博客,提取评论,并将它们附加起来构建评论列表。但这种方法并不是理想的解决方案,因为它需要手动完成许多工作。我们可以使用 Elasticsearch 聚合查询来完成这项任务。默认情况下,nested 对象不能直接作为父对象获取,因此需要聚合:
GET blog/blog/_search
{
"aggs": {
"aggChild": {
"nested": {
"path": "comments"
},
"aggs": {
"aggSortComment": {
"top_hits": {
"sort": [
{
"comments.createdDate": {
"order": "desc"
}
}
],"from": 0,
"size": 10
}
}
}
}
}
}
此查询具有 top_hits 聚合,它简单地列出所有 nested 对象。我们需要按 createdDate 的降序(最近添加的应放在顶部)排序数据,因此添加了排序标准。from 和 size 标准用于分页。from 标准表示从第一条记录的偏移量,而 size 显示每页的记录总数。
默认情况下,如果没有提供 size 值,top_hits 将返回三条记录。此外,允许的最大大小为 100,因此在使用 top_hits 时,您必须使用分页。
此查询返回结果。以下片段显示了完整结果的聚合数据:
"aggregations": {
"aggChild": {
"doc_count": 7,
"aggSortComment": {
"hits": {
"total": 7,
"max_score": null,
"hits": [
{
"_index": "blog",
"_type": "blog",
"_id": "Bsz2Y2YBksR0CLn0e37E",
"_nested": {
"field": "comments",
"offset": 2
},
"_score": null,
"_source": {
"id": "e7EqiPJHsj1539275565438",
"blogId": "Bsz2Y2YBksR0CLn0e37E",
"parentId": "0",
"childSequence": 2,
"position": "1.2",
"status": "M",
"level": 1,
"user": "Nilang Patel",
"emailAddress": "nilprofessional@gmail.com",
"commentText": "installatin of java. great blog",
"createdDate": "10-11-2018T16:32:45"
},
"sort": [
1539275565000
]
},
{
.... Other JSON Objects, each represents comment data.
}...
]
}
}
}
}
您可以使用以下方式使用 Elasticsearch Java API 编写之前的查询:
public List<Comment> getAllComments(int from, int size){
NestedAggregationBuilder aggregation = AggregationBuilders.nested("aggChild", "comments").
subAggregation(AggregationBuilders.topHits("aggSortComment").sort("comments.createdDate", SortOrder.DESC).from(from).size(size));
SearchResponse response = elasticsearchTemplate.getClient().prepareSearch("blog")
.setTypes("blog")
.addAggregation(aggregation)
.execute().actionGet();
List<Aggregation> responseAgg = response.getAggregations().asList();
//getAllCommentsFromJson method process the json and return desire data.
return getAllCommentsFromJson(responseAgg.get(0).toString());
}
再次强调,这一点是显而易见的。首先,我们使用AggregationBuilders创建一个嵌套聚合查询,并添加top_hits类型的子聚合,同时使用from和size设置添加排序条件。获取响应的过程与我们用于获取最大子序列的方法相同。
如果我们需要显示具有特定状态值的评论,我们可以使用以下查询:
GET blog/blog/_search
{
"_source": false,
"aggs": {
"aggChild": {
"nested": {
"path": "comments"
},
"aggs": {
"aggStatsComment": {
"terms": {
"field": "comments.status",
"include": "K"
},
"aggs": {
"aggSortComment": {
"top_hits": {
"sort": [
{
"comments.createdDate": {
"order": "desc"
}
}
],
"from": 0,
"size": 10
}
}
}
}
}
}
}
}
已添加了检查状态字段值的聚合查询术语。您可以使用通配符(*)作为匹配条件,例如,A*将匹配所有以A开头的状态。等效的 Java API 如下所示:
public List<Comment> getCommentsForStatus(String status,int from, int size) {
IncludeExclude includeExclude = new IncludeExclude(status, null);
NestedAggregationBuilder aggregation = AggregationBuilders.nested("aggChild", "comments").
subAggregation(AggregationBuilders.terms("aggStatsComment").
field("comments.status").includeExclude(includeExclude).
subAggregation(AggregationBuilders.topHits("aggSortComment").size(10).sort("com ments.createdDate", SortOrder.DESC))
);
SearchResponse response = elasticsearchTemplate.getClient().prepareSearch("blog")
.setTypes("blog")
.addAggregation(aggregation)
.execute().actionGet();
List<Aggregation> responseAgg = response.getAggregations().asList();
return getAllCommentsWithStatusFromJson(responseAgg.get(0).toString());
}
使用 Elasticsearch 更新和删除评论数据
更新nested对象的过程很简单。Elasticsearch 不提供直接更新特定nested对象的方法。相反,您需要从根文档中获取所有nested对象,找到特定的nested对象(可能通过某些唯一标识符),更新它,将nested对象列表分配回根文档,并保存根文档。例如,我们可以使用以下方法更新博客的特定评论(nested)对象的状态。该方法定义在服务类中:
public void updateCommentStatus(String blogId,String commentId, List<Comment> commentList, String updatedStatus) {
if(commentList !=null) {
for(Comment comment: commentList) {
if(comment.getId().equals(commentId)) {
comment.setStatus(updatedStatus);
break;
}
}
Blog blog = this.getBlog(blogId);
blog.setComments(commentList);
blogRepository.save(blog);
}
}
删除评论的过程类似。只需从列表中移除所需的评论对象,并保存博客对象以删除评论。
在 Elasticsearch 中实现一对一关系的另一种方式是通过父子结构。然而,它比nested对象慢。nested对象的唯一缺点是,每当任何nested对象被更新时,根文档都需要重新索引。但由于数据的检索,这相对较快,因此nested对象比父子结构更受欢迎。
我们已经了解了如何与 Elasticsearch 交互并获取数据。接下来,我们将看到如何在前端显示这些数据。
在 Spring 中使用 RESTful Web 服务显示数据
Spring 通过其 web MVC 模块提供 RESTful Web 服务的实现。每个注解的创建 RESTful Web 服务与 Web MVC 架构或多或少相似。RESTful Web 服务可以通过 REST 控制器构建。Web MVC 和 REST 控制器之间明显的区别是它们创建 HTTP 响应的方式。
传统的 Web MVC 使用各种视图技术(如 JSP、Thymeleaf 等)来构建响应,而 REST 控制器返回的对象被转换为 JSON(或根据配置转换为 XML),最终作为 HTTP 响应发送。对于我们的 Blogpress 应用程序,我们将在以下两个用例中使用 RESTful 服务:
-
在主页上显示博客列表
-
当特定博客打开供查看时显示博客评论
要实现这一点,我们将编写如下所示的新控制器类:
@RestController
@RequestMapping("api")
public class BlogRESTController {
private Logger logger = LoggerFactory.getLogger(BlogRESTController.class);
@Autowired
private BlogService blogService;
@RequestMapping(value = "/listBlogs", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<Blog>> getAllBlogJSON() {
logger.info("getting all blog data in json format ");
List<Blog> allBlogs = blogService.getAllBlogs();
return new ResponseEntity<List<Blog>>(allBlogs, HttpStatus.OK);
}
@RequestMapping(value = "/listAllComments", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<Comment>> getAllCommentJSON() {
logger.info("getting all blog data in json format ");
List<Comment> allComments = blogService.getAllComments(0, 100);
return new ResponseEntity<List<Comment>>(allComments, HttpStatus.OK);
}
}
REST 控制器必须使用@RestController注解进行定义。由于我们现在有两个控制器(一个是普通的 Web MVC,另一个是 REST 控制器),我们使用@RequestMapping来定义请求映射以区分 URL 模式。
@RequestMapping注解定义了方法 URL、HTTP 方法名称以及该方法生成的输出 MIME 类型。getAllBlogJSON()方法获取Blog对象的列表,并通过ResponseEntity发送它,同时附带 HTTP 响应代码。ResponseEntity类表示响应体、头部和状态码,并用于准备 HTTP 响应。要使用它,只需将其定义为方法的返回类型(端点)即可。
或者,可以在方法级别使用@ResponseBody注解来生成 HTTP 响应。ResponseEntity与@ResponseBody功能完全相同,但提供了额外的功能,包括设置 HTTP 响应代码,因此更好。
ResponseEntity类型是泛型的,因此你可以用它发送任何类型的对象。两个方法分别返回Blog和Comment的对象。Spring 会自动将对象列表转换为 JSON 字符串,并将其作为 HTTP 体返回。MediaType类提供了各种 MIME 类型。第一个方法可以通过http://localhost:8080/api/listBlogs URL 访问,第二个方法可以通过http://localhost:8080/api/listAllComments访问。
接下来我们将看到如何通过表示层来展示这些数据。对于我们的 Blogpress 应用,我们使用了 Thymeleaf 模板来构建视图层。Thymeleaf 模板在服务器端进行处理。我们将使用另一个名为Mustache的模板引擎来进行客户端处理。
使用 Mustache 模板构建 UI
Mustache 是一个适用于多种语言的 Web 模板,如 JavaScript、Ruby、PHP、Python、Perl、Android、C++、Java 等,具有语言特定的实现。在我们的 Blogpress 应用中,我们将使用 Mustache 来处理 JavaScript,因此我们需要在 Blogpress 应用中包含Mustache.js。让我们首先了解Mustache.js适用的用例。
很常见,为了在 HTML 中显示动态值,我们会将数据与 HTML 片段混合,然后更新 DOM 标记以显示最终输出。以下是这个方法的示例:
$("#addAddress").live('click', function(){;
var oldAddress = "";//Assume that oldAddress value supplied from server side.
var newContent = "<div id='group2' class='accordion-group'>" +
"<input type='text' id='address' class='textbox-input'"+ oldAddress +"/>" + "</div>";
$("#accordion1").html(newContent);
});
这种代码不仅会制造出维护噩梦,而且会将 UI 和动态数据逻辑混合在一起,从而导致它们之间紧密耦合。这阻止了代码的重用,并破坏了关注点分离原则。
解决这类问题的最佳方案是使用某种 HTML 模板。目前有许多客户端 HTML 模板引擎可用,Mustache.js就是其中之一,我们选择了它来构建我们的 Blogpress 应用的一些页面。让我们通过以下一个非常简单的示例来看看它是如何工作的:
<div id="studentSection"></div>
<script id="greeting_template" type="text/template">
<div>
Hello, <b><span>{{firstName}}</span></b> <span>{{lastName}}</span>
<div>
</script>
<script type="text/javascript">
var template = $("#greeting_template").html();
var student = {"firstName":"Nilang","lastName":"Patel"};
var text = Mustache.render(template, student);
$("#studentSection").html(text);
</script>
这个例子是自我解释的。模板已经使用<script>的text/template类型定义。使用Mustache.js,我们正在读取模板并传递student对象。在模板中,使用{{...}}注释插入动态值。这不仅使代码清晰,而且可以轻松适应未来的任何变化。
Mustache.js是一个无逻辑模板,这意味着它不包含如 if-else、for 等过程性语句,但我们可以使用标签来实现某种循环和条件。对于我们的 Blogpress 应用程序,我们在以下两个页面中使用Mustache.js:
-
主页以列表格式显示所有博客,信息尽可能少
-
管理评论页面,其中列出所有评论供管理员用户审核和回复
首先,我们将处理主页,其中所有博客都以列表形式显示。以下是在主页上的 Mustache 模板的代码:
<!-- Define the template -->
<script id="blog_template" type="text/template">
{{#blogs}}
<div class="card bg-white mb-3">
<div class="card-body">
<h5 class="card-title">{{title}}</h5>
<p class="card-text">{{body}}</p>
<form th:action="@{/viewBlog}" method="post">
<input type="hidden" name="blogId" value="{{id}}">
<button type="submit" class="btn btn-primary">Read More ..</button>
</form>
</div>
<div class="card-footer text-muted">
By : <b>{{createdBy}}</b> comments: <b>{{comments.length}}</b> Published on <b>{{publishDateForDisplay}}</b>
</div>
</div>
{{/blogs}}
</script>
<div class="container">
<div class="blogpress-section" id="blogList">
</div>
</div>
<script th:inline="javascript" type="text/javascript">
jQuery(document).ready(function(){
var blogData = {};
var template = $("#blog_template").html();
jQuery.get(/*[[@{/api/listBlogs}]]*/, function(data, status){
blogData["blogs"] = data;
var text = Mustache.render(template, blogData);
$("#blogList").html(text);
});
});
</script>
第一个脚本标签使用text/template类型定义模板。{{#blogs}}和{{/blogs}}表达式以两种方式评估。如果博客键存在并且具有 false 值或空列表(如果是数组类型),则之间的 HTML 代码将不会显示。如果是 true 或非空列表(数组),则将渲染之间的 HTML。
在我们的案例中,我们希望使用Mustache.js模板显示博客列表。数据通过 Ajax 从 REST Web 服务(最终调用 REST 控制器)填充。如果成功,数据将存储在blogData对象中,以blogs作为键。这个键在Mustache.js模板({{#blogs}} ....{{/blogs}})中用于迭代博客数组。单个属性使用{{...}}表达式放置。例如,{{body}}将显示博客对象中 body 属性的价值。Mustache.render接受模板和数据,并生成最终输出,该输出附加到具有blogList ID 的div中。
我们在第二个脚本标签中使用了th:inline。这是一个 Thymeleaf 标签。如果你需要在脚本标签中替换值,你需要使用th:inline来定义它。Thymeleaf 的值可以使用/*[[,,,]]*/注释来插入。在这种情况下,我们传递了一个动态 URL,因此在/*[ .. ]*/内部使用了@{/api/listBlogs}(这样最终的 URL 将是http://localhost:8080/api/listBlogs)。这看起来就像下面的截图:

另一个页面是管理评论页面,其中评论使用Mustache.js模板显示,如下所示:
<script id="comment_template" type="text/template">
{{#comments}}
<div class="card bg-white mb-3">
<div class="card-body">
<div class="card-title">
<div class="clearfix">
<p class="mb-0">
By <span class="float-left">{{user}}</span>
On <span class="float-right">{{createdDateForDisplay}}</span>
</p>
</div>
</div>
<p class="card-text">{{commentText}}</p>
<div class="card-footer text-muted">
{{#showApproveReject}}
<div>
<form th:action="@{/updateCommentStatus}" method="post" id="updateCommentStatusFrm-{{id}}">
<input type="hidden" name="blogId" value="{{blogId}}">
<input type="hidden" name="commentId" value="{{id}}">
<input type="hidden" name="commentStatus" id="commentStatus-{{id}}" value="">
<button type="button" class="btn btn-primary" id="approveComment-{{id}}">Approve</button>
<button type="button" class="btn btn-primary" id="rejectComment-{{id}}">Reject</button>
</form>
</div>
{{/showApproveReject}}
{{#showReply}}
<div>
<form th:action="@{/replyComment}" method="post">
<input type="hidden" name="blogId" value="{{blogId}}">
<input type="hidden" name="commentId" value="{{commentId}}">
<button type="button" class="btn btn-primary">Reply</button>
</form>
</div>
{{/showReply}}
</div>
</div>
</div>
{{/comments}}
</script>
<div class="container">
<div class="blogpress-section" id="commentList"></div>
</div>
<script th:inline="javascript" type="text/javascript">
jQuery(document).ready(function(){
var commentData = {};
var template = $("#comment_template").html();
jQuery.get(/*[[@{/api/listAllComments}]]*/, function(data, status){
for (var i = 0; i < data.length; i++) {
var comment = data[i];
if(comment.status === 'M'){
comment["showApproveReject"]="true";
}
if(comment.status === 'A'){
comment["showReply"]="true";
}
}
commentData["comments"] = data;
var text = Mustache.render(template, commentData);
$("#commentList").html(text);
});
});
</script>
此模板与我们在家页面上看到的博客列表模板类似。这里额外的一点是,showApproveReject 和 showReply 属性被设置为 true 值。由于 Mustache 是一个无逻辑模板,没有直接的条件语句提供,例如 if-else。添加条件的唯一方法是使用 {{#attribute}} ... {{/attribute}} 表达式,它会检查属性键是否存在并设置为 true。
在管理评论页面中,每个评论都会列出供管理员审核。如果评论状态为 M(审核中),系统会显示按钮——批准和拒绝。如果被批准(状态为 A),则系统会显示回复评论的选项。使用 Mustache.js 模板,我们无法直接检查状态值。因此,在评论对象中添加了两个额外的键(showApproveReject 和 showReply),并将其设置为 true,基于状态值。
这将看起来像以下截图:

摘要
我们已经走了一段漫长的旅程。没有比通过真实场景学习底层概念、工具和技术更好的构建应用程序的方法了。在本章中,我们以博客应用程序为基础,使用一系列框架构建了各种层。
以 Spring 框架为基础,我们开始了我们的旅程——Spring Boot——一个快速开发工具,所有底层配置都可以通过一种自动模式完成。我们使用 Spring MVC 网络框架和 Thymeleaf 构建了第一层,Thymeleaf 是一种构建视图层的自然模板引擎。我们还使用 Spring Security 构建了认证和授权,这是应用程序的重要组成部分。
我们使用 Elasticsearch——一个开源的、高度可扩展的搜索引擎,主要用于索引和分析目的——实现了 Blogpress 应用程序的数据源。在探索基本概念之后,我们学习了如何创建索引、文档类型,并添加文档数据,然后通过一个 student 实体的示例学习了如何在 Elasticsearch 中搜索它们。
进一步来说,我们学习了如何为 Blogpress 应用程序创建具有嵌套对象的数据结构。在嵌套对象中插入数据以及使用各种搜索和聚合机制检索数据是我们实现的数据层的主要核心。
为了进一步将 Elasticsearch 中开发的持久化层与 Spring MVC 的前端层连接起来,我们使用了 Spring Data 模块。然后,我们利用 Spring Data 框架的扩展能力,使用 Elasticsearch Java API 实现了自定义查询。最后,我们看到了客户端模板引擎 Mustache.js 如何有助于解决动态数据逻辑与 HTML 片段混合的问题。
在下一章中,我们将专注于使用 Spring Security 来确保应用程序的安全性。我们将更详细地讨论 Spring Security 与 OAuth 2 的集成——这是一个广泛使用的授权协议。我们还将探讨 轻量级目录访问协议(LDAP)与 Spring Security 的集成,以构建一个支持身份验证和授权的中心应用程序。
第四章:构建中央认证服务器
实现安全约束是任何应用程序的核心要求。Spring 为应用程序的各个方面提供支持,包括安全。Spring 框架的一个模块,称为 Spring Security,专门设计来满足安全需求。它是一个强大且高度自适应的框架,提供开箱即用的认证和授权。
Spring Security 是任何基于 Spring 的应用程序的标准安全解决方案。当我们将其与其他系统集成时,我们可以看到 Spring Security 的真实实力。其功能可以轻松扩展,以满足定制需求。本章将专门介绍 Spring Security。
在上一章中,我们探讨了 Spring 框架的力量,其中各种模块和第三方库在 Spring Boot 中绑定在一起:一个建立在 Spring 框架之上的模块,主要设计用于引导和开发具有自动配置的基于 Spring 的应用程序。我们将在本章继续使用它,以构建中央认证和授权系统。
Spring Security 是一个高度可定制的框架,我们可以将其与其他提供访问控制数据的系统集成。在本章中,我们将仔细研究轻量级目录访问协议(LDAP)和OAuth与 Spring Security 的集成。
本章将涵盖以下有趣的主题:
-
对 LDAP 和数据结构的基本理解
-
LDAP 服务器(Apache DS)的配置
-
使用 LDAP 在 Spring Security 中进行认证
-
使用 Spring Security 进行 LDAP 授权
-
OAuth 基础和不同的授权类型
-
Spring Security 与 OAuth 的集成
-
在 Spring Security 中使用 LDAP 和 OAuth 进行双重认证
-
在 Spring Security 中使用自定义授权服务器实现 OAuth
技术要求
本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Spring-5.0-Projects/tree/master/chapter04。代码可以在任何操作系统上执行,尽管它只在 Windows 上进行了测试。
LDAP
当电子邮件被引入并在企业中开始使用时,一个挑战是查找从未与你交流过的人的电子邮件地址。这需要某种类型的中央存储库来搜索组织内部其他人的电子邮件地址。
需要是发明之母。对中央数据仓库的需求将微软、莲花、网景和 IBM 等公司聚集在一起,他们定义了一个称为LDAP的标准。它是一种通过网络目录结构访问存储数据的协议。
LDAP 服务器以分层方式存储和索引数据,可以被 LDAP 感知客户端访问。数据可以被过滤以选择存储在不同实体中的特定个人或组。例如,想象一下搜索所有位于芝加哥的来自行政部门且工作超过三年的员工,并接收他们的全名、职位和电子邮件地址。使用 LDAP 完全可以做到这一点。
除了联系信息外,LDAP 还可以用来存储访问控制数据,然后可以使用这些数据来进行身份验证和授权。我们将从介绍 LDAP 的基础知识以及如何使用它构建数据结构开始。
什么是 LDAP?
LDAP 是访问和管理存储在网络目录结构中的分层信息的行业标准。LDAP 已经使用了很长时间。如今,它主要用于构建身份验证系统;然而,这绝对不是它的唯一目的。LDAP 还可以用来存储需要集中访问的任何类型的信息(例如,组织中的电子邮件或联系信息)。
将用户(或任何其他)信息,如用户名、电子邮件、密码等存储在一个地方的主要动机是为了方便提供管理和维护支持。例如,而不是在组织中单独处理每个子组的用户列表,LDAP 可以用来将它们作为一个中央存储库来管理,可以从网络中的任何位置访问。以下是一些 LDAP 是完美匹配的用例:
-
允许用户使用相同的凭据在多个应用程序中登录,例如在内部网和本地机器上登录。
-
为一组用户提供基于角色的访问权限;例如,访问内部网站上的特定页面,或访问文档管理系统中的文档。
-
收集用户联系信息并使其在全球范围内可用,以便组织中的任何用户都可以访问它们。
LDAP 是一种访问存储在目录中的结构化信息的方式。为此,它遵循客户端-服务器模型,其中数据存储在 LDAP 服务器上,客户端可以提出请求以访问所需信息(通过 LDAP API)。
存储在 LDAP 服务中的信息不打算在每次访问时都进行更改,这使得 LDAP 成为一种写入一次、读取多次的服务形式。例如,考虑到这些记录随着每次操作而改变,LDAP 不适合维护在线购物应用的交易记录。然而,LDAP 可以用来维护变化频率较低的数据,如用户账户、用户地址等。
存储在 LDAP 服务器上的数据不是以关系形式存在的;而是分层的。然而,LDAP 使用数据库来内部存储信息,但以分层的方式呈现。
除了定义如何访问目录服务中的数据外,LDAP 还定义了数据的呈现方式。要理解此数据信息模型,了解 LDAP 中使用的某些术语至关重要。这不仅将帮助您更好地了解其工作原理,还将说明如何在 LDAP 中创建和搜索数据:
-
目录信息树(DIT):正如我们讨论的那样,LDAP 服务器以分层(或树状)形式存储信息。这个树称为目录信息树。
-
条目:由于树有节点,DIT 中的节点称为条目。存储在条目中的信息以键值对的形式存在。每个条目有一个父条目(除了根条目)和零个或多个子条目。子条目是其父条目的其他子条目的兄弟条目。
-
根/基本/后缀:DIT 的顶级条目称为根、基本或后缀。
-
区分名称(DN):DIT 中的每个条目都应该由一个唯一的标识符识别。这个唯一的标识符称为区分名称。通常,它是由一个或多个逗号分隔的键值对组成的字符串,这些键值对共同唯一地识别树中的节点(条目)。例如,字符串
dc=nilangpatel,cd=com可以是根实体的 DN。 -
相对区分名称(RDN):相对于其父实体的唯一标识字符串称为相对区分名称。DN 在全球范围内唯一标识实体,而 RDN 在兄弟姐妹中唯一标识实体。
-
对象类:每个实体由一个或多个
objectClasses组成。每个对象类都有一个名称和零个或多个属性。objectclass被视为属性的容器,它将控制可以添加到实体中的属性类型。 -
属性:属性是
objectclass的一部分。它有一个名称和值。它还有一个缩写或别名。
以下是我们将在本章中使用的一些属性(及其对象类)的列表:
| 属性名称 | 别名名称 | 描述 | 对象类 |
|---|---|---|---|
dc |
domainComponent |
域名的一部分;例如,domain.com、domain 或 com |
dcObject |
o |
organizationName |
组织名称 | organization |
ou |
organisationalUnitName |
部门或任何子组 | organizationUnit |
cn |
common name |
实体的名称 | person, organizationalPerson, organizationalRole, groupOfNames, applicationProcess, applicationEntity, posixAccount, device |
sn |
surname |
姓氏或家族名称 | person |
uid |
userid |
用户名或其他唯一值 | account, inetOrgPerson, posixAccount |
userPassword |
- |
用于某种形式的访问控制的用户密码 | organization, organizationalUnit, person, dmd, simpleSecurityObject, domain, posixAccount |
- LDAP 数据交换格式 (LDIF):这是一个 ASCII 文件格式,用于以文本文件的形式描述 LDAP 数据的层次树结构。LDAP 数据可以以 LDIF 文件格式导入或导出。
配置 Apache DS 作为 LDAP 服务器
我们将使用 Apache 目录服务器 (Apache DS),一个可扩展的、现代的、可嵌入的 LDAP 服务器,来展示 LDAP 认证。它完全用 Java 编写。Apache DS 是一个独立的 LDAP 服务器。在使用它时,你需要某种 LDAP 浏览器来可视化并操作数据。
然而,Apache 提供了另一个工具,称为 Apache 目录工作室,这是一个基于 Eclipse 的应用程序。它捆绑了 Apache DS 和 LDAP 浏览器,作为一个单一包提供。它特别为 Apache DS 设计;然而,你可以用它与任何 LDAP 服务器(如 OpenLDAP)一起使用。
当你使用 Apache 目录工作室时,你不再需要获取另一个 LDAP 服务器,因为它自带 Apache DS(一个 LDAP 服务器)。
下载 Apache 目录工作室 (directory.apache.org/studio/),在本地机器上解压,然后双击 ApacheDirectoryStudio 可执行文件以打开它。
首先,我们需要添加 Apache DS 服务器。为此,转到 LDAP 服务器选项卡(通常位于窗口底部),右键点击那里,选择新建 | 新服务器,并选择 Apache DS 服务器的最新版本。一旦服务器被添加,右键点击它并选择 运行 以启动服务器。
服务器启动并运行后,我们需要创建一个连接。右键点击 连接 选项卡,选择新建连接,并输入以下信息:
-
连接名称: 任何合适的名称。
-
主机名:localhost。
-
端口: 任何 LDAP 服务器的默认端口是
389。然而,Apache DS 的默认端口是10389。不言而喻,该端口可以更改。
保持其余选项不变,点击 下一步 按钮填写以下详细信息:
-
认证方法: 简单认证
-
绑定 DN 或用户:
uid=admin,ou=system -
绑定密码:
Secret
这是默认的管理员凭证,可以通过点击 检查认证 按钮进行验证。点击完成,你将在 LDAP 浏览器窗口中看到详细信息,如下所示截图:

接下来,我们将开始创建一个数据结构来存储用户详细信息。正如你所见,LDAP 可以用来存储任何类型的信息,但我们将使用 LDAP 进行认证。LDAP 以树(DIT)的形式存储数据,因此我们将创建一个 DIT 结构。
示例 DIT 结构
接下来,让我们先看看在 LDAP 中常用的一些数据结构,然后从中选择一个:

此结构以一个名为 packt(o=packt)的组织开始,然后是每个部门的组织单元(子组),最后是用户。在某些地方,子组织有一个用户组,然后是用户。
树也可以根据互联网域名进行排列,如下所示:

此结构以 example.com 作为域名。你可以使用不同的名称多次提供相同的属性,如之前所示。dc 代表 domain component。它后面跟着 users 和 printers(设备)的子组,最后列出 users 和设备(printers)。
另一个选项可能如下所示:

此选项以组织(o=packtPublisher)为根,有两个子节点 users 和 roles 作为 ou(组织单元或子组)。users 条目将包含用户条目,而 roles 条目将包含角色条目。在本章中,我们将选择此选项在 LDAP 中构建 DIT。LDAP 允许通过属性成员将用户与角色关联。你将在本书后面的 在 LDAP 服务器中创建角色 部分看到更多关于此的信息。
Apache DS 分区
Apache DS 有一个称为 分区 的概念。每个分区包含一个实体树(DIT),它与其他分区的实体树完全断开连接。这意味着一个分区中实体树发生的更改永远不会影响其他分区的实体树。每个分区都有一个唯一的 ID。它还有一个称为 分区后缀 的命名上下文,可以将其视为该分区中 DIT 的根(或基础);所有条目都存储在其下。
要在 Apache DS 中创建分区,请双击 LDAP 服务器选项卡中的服务器实例,它将打开服务器配置。打开服务器配置的分区选项卡,单击 添加 按钮,并为 分区常规详情 部分提供以下值:

-
ID:
packtPublisher -
后缀:
o=packtPublisher
将所有其他值保留为默认值,并保存配置。重启服务器以使分区生效。新的分区将在 LDAP 浏览器中可用,如下截图所示:

我们将在该分区下创建一个实体树(DIT)。分区后缀(o=packtPublisher)将被视为 DIT 的基础(或根)。接下来,我们将在其下创建条目。
LDAP 结构
进一步操作,我们将查看 DIT 中的 DN 和 RDN 概念。我们针对您在“示例 DIT 结构”部分看到的第三个选项来定位我们的 DIT。让我们回忆一下,RDN 在兄弟姐妹中唯一地区分条目。这意味着它是使条目在父条目下独特的关键。我们可以使用任何属性来声明 RDN。此外,在每个级别,用作 RDN 的属性可以不同。
在我们的例子中,基的 RDN 是o=packtPublisher(这是分区后缀)。我们使用了属性o,它是组织名称。在其下方,有两个子项,其中使用了ou属性作为 RDN。属性ou代表组织单元。这两个子项分别代表用户和角色。
我们将在users实体下存储用户信息(用户名和密码,以及其他信息)。因此,users实体的子项是实际的用户实体,其中使用uid属性作为 RDN。属性uid代表用户 ID。通常,在任何组织中,要么使用用户名,要么使用电子邮件 ID 进行登录;因此,我们可以将它们中的任何一个作为uid属性的值。在这种情况下,我们将用户名作为uid。
我们已经介绍了 RDN 的工作原理。现在,让我们看看 DN 是如何工作的。正如我们描述的那样,DN 唯一地标识了给定 DIT 中的任何条目。换句话说,DN 在整个树(DIT)中使条目独特。DN 是由给定实体的 RDN 组成的逗号分隔字符串,包括所有其父实体,直到根实体。因此,DN 是根据每个级别的给定 RDN 自动计算的。
在上一个例子中,每个实体(带有uid=npatel)的 DN 将是uid=npatel, ou=users,和o=packtPublisher。同样,users实体的 DN 是ou=users和o=packtPublisher。这就是通过在每个级别附加 RDN 来计算 DN 的方式。
让我们在 Apace DS 中创建这个结构。按照以下步骤在 Apache Directory Studio 中展开 DIT 结构:
-
右键单击分区
o=packtPublisher,然后选择“新建”|“新建条目”。 -
选择一个选项,“从头创建条目”,然后点击“下一步”。
-
我们将添加一个实体类型组织单元,因此请选择
organizationalUnit对象类,然后点击“下一步”。 -
我们将使用
ou(组织单元)作为 RDN,并使用users作为其值。当您给出 RDN 的值时,DN 会自动计算。您可以为 RDN 使用多个属性(或多次使用相同的属性,具有不同的值)。然后,DN 将通过在每个名称后附加逗号来计算。 -
点击“下一步”,然后点击“完成”按钮,条目
ou=users将被添加到o=packtPublisher下。更新后的结构将在 LDAP 浏览器窗口中可见。
一旦添加了users条目,我们可以在其下方插入单个用户条目。步骤如下:
-
右键单击
users实体(ou=users),然后选择“新建 | 新条目”。 -
选择“从头创建条目”选项,然后点击下一步。
-
我们将添加一个用户,因此请选择代表组织内人员的
inetOrgPerson对象类。点击下一步按钮。 -
我们将添加一个用户,因此我们将使用属性
uid(用户 ID)作为 RDN。只需将值设置为npatel。您可以提供任何唯一标识用户的值。此时,DN 计算为uid=npatel,ou=users,o=packtPublisher。 -
点击下一步,您将看到一些必需的属性,如
cn和sn。cn代表通用名称,而sn表示姓氏。可以为cn和sn分别提供名字和姓氏。 -
我们想要认证一个用户,因此用户实体必须有一个密码字段(属性)。只需在此屏幕上右键单击并选择“新建属性”。
-
将属性类型指定为
userPassword,然后点击“下一步 | 完成”;将弹出一个新窗口,在其中可以输入密码。 -
为密码输入适当的值,确认密码,并将哈希方法设置为 SHA;点击“确定”按钮。从父窗口中点击“完成”按钮。
使用前面的步骤添加两到三个这样的用户。我们将在“使用 Spring Security 的 LDAP 授权”部分查看如何添加角色并创建实际角色。
Apache DS 的默认配置包含一个后缀为dc=example, dc=com的数据分区。此分区也可以使用,但为了详细了解概念,我们已创建了一个单独的分区。
恭喜!配置 LDAP 的基本步骤已完成。LDAP 服务器已准备好使用。接下来,我们将探讨如何使用 Spring Security 对 LDAP 服务器进行用户认证。
Spring Security 与 LDAP 集成
在第三章,“Blogpress - 一个简单的博客管理系统”中,我们提供了关于 Spring Boot 的信息,并探讨了如何使用它创建应用程序。在本章中,我们将使用 Spring Boot 构建一个应用程序,以展示 Spring Security 中的认证和授权。在LDAP部分,我们介绍了 LDAP 服务器的安装和设置,以及其数据结构。在本节中,您将了解 Spring Security 如何与 LDAP 协同工作。
Spring Security 是一个高度综合和可扩展的框架;它为基于 J2EE 的 Web 和 REST 应用程序提供认证和授权支持。我们将探讨如何将 Spring Security 与 LDAP 集成以执行认证。根据定义,认证是验证或决定个人或实体是否为其声称的身份的机制。
为了演示认证,我们首先将使用 Spring Boot 构建一个 Web 应用程序,然后将其与 LDAP 集成。
使用 Spring Boot 创建 Web 应用程序
我们已经创建了数据结构,并在 LDAP 服务器中添加了用户及其凭据。接下来,我们将构建一个 Web 应用程序并集成 Spring Security,该应用程序将与 LDAP 服务器进行身份验证。在前一章中,我们使用 Spring MVC 和 Thymeleaf 构建了一个应用程序。我们将重用相同的架构并创建以下工件的应用程序:
-
Spring Boot,用于创建 Web 应用程序并对其他模块进行自动配置
-
Thymeleaf,用于表示层
-
Spring Security,用于与 LDAP 执行身份验证。
如前一章所述,在本章中,我们将使用基于 Eclipse 的 IDE,称为Spring Tool Suite (STS)。创建一个名为SpringAuth的 Spring Boot 应用程序,并添加其他合适的参数,如组、工件、版本、描述和 Java 包。确保将以下启动器添加到pom.xml中:
<depedency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-ldap</artifactId>
</dependency>
在前面的条目中,spring-security-ldap不是一个启动器,而是一个普通依赖项。它提供了在 Spring Security 与 LDAP 集成中所需的相应依赖项。
我们将配置 LDAP 服务器作为数据源以获取用户详情并在 Spring Security 中执行身份验证。此时,Spring Security 应该知道如何连接到 LDAP 服务器。这些细节包括 URL、基础 RDN 和管理员凭据。我们将在application.properties文件中定义这些细节,如下所示。
spring.ldap.urls=ldap://localhost:10389
spring.ldap.base=o=packtPublisher
spring.ldap.password=secret
spring.ldap.username=uid=admin,ou=system
这些细节很简单。用户名和密码代表 LDAP 管理员凭据。由于我们处理的是 LDAP,管理员用户名采用 DN 形式(如uid=adminou=system),而不是直接值(如 admin)。我们可以将这些细节与我们用于与数据库交互的信息(如 URL、端口、用户名和密码)联系起来。我们将读取这些细节并将它们提供给 Spring Security 以与 LDAP 建立连接。为此,我们将编写一个配置类,如下所示:
@Configuration
@ComponentScan(basePackages = { "com.nilangpatel.springldap.config" })
public class LdapDataConfig {
@Value("${spring.ldap.urls}")
private String ldapUrls;
@Value("${spring.ldap.base}")
private String ldapBase;
@Value("${spring.ldap.password}")
private String ldapManagerPwd;
@Value("${spring.ldap.username}")
private String ldapManagerUserName;
@Bean("ldapAuthStructure")
public LdapAuthStructure getLDAPAuthStructure() {
LdapAuthStructure authStructure = new LdapAuthStructure();
authStructure.setLdapUrl(ldapUrls);
authStructure.setLdapBase(ldapBase);
authStructure.setLdapManagerDn(ldapManagerUserName);
authStructure.setLdapManagerPwd(ldapManagerPwd);
authStructure.setUserDnPattern("uid={0},ou=users");
authStructure.setUserSearchBase("ou=roles");
return authStructure;
}
}
此类简单地使用@Value注解读取 LDAP 连接属性,存储在LdapAuthStructure类的实例中,并将其定义为 Spring Bean,以便它可供其他类使用。LdapAuthStructure是一个自定义类,用于存储 LDAP 配置属性。我们将使用以下两个附加属性进行 Spring 与 LDAP 的集成:
-
userDnPattern:其值是uid={0},ou=users。这实际上是一个 DN 模式(相对于ou=users实体)。{0}将在 Spring 运行时被实际值(如uid、用户 ID)替换。 -
userSearchBase:它表示用户基础(ou=users)。基本上,它表示一个用户可以搜索的实体。 -
groupSearchBase:它表示组基础(ou=roles)。我们将使用此属性在后续部分执行授权。
接下来,我们将使用这些属性并使用安全配置类初始化 Spring Security,如下所示:
@Configuration
@EnableWebSecurity
@ComponentScan("com.nilangpatel.springldap.security")
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private LdapAuthStructure ldapAuthStructure;
private Logger logger = LoggerFactory.getLogger(WebSecurityConfig.class);
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**");
web.ignoring().antMatchers("/css/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/").permitAll()
.anyRequest().fullyAuthenticated().and()
.formLogin().loginPage("/login").permitAll()
.defaultSuccessUrl("/privatePage",true)
.failureUrl("/login?error=true")
.and()
.logout()
.permitAll().logoutSuccessUrl("/login?logout=true");
logger.info("configure method is called to make the resources secure ...");
}
@Override
protected void configure(AuthenticationManagerBuilder authManagerBuilder) throws Exception {
authManagerBuilder.ldapAuthentication()
.userDnPatterns(ldapAuthStructure.getUserDnPattern())
.userSearchBase(ldapAuthStructure.getUserSearchBase())
.contextSource()
.url(ldapAuthStructure.getLdapUrl()+"/"+ldapAuthStructure.getLdapBase())
.managerDn(ldapAuthStructure.getLdapManagerDn())
.managerPassword(ldapAuthStructure.getLdapManagerPwd())
.and()
.passwordCompare()
.passwordEncoder(new LdapShaPasswordEncoder())
.passwordAttribute("userPassword");
logger.info("configure method is called to build Authentication manager ...");
}
}
WebSecurityConfig 是一个扩展 WebSecurityConfigurerAdapter 类的自定义类。这个类是声明安全相关细节的单一点。configure(WebSecurity web) 方法用于忽略来自安全上下文的静态资源(JS 和 CSS)请求。如果没有这个方法,浏览器中不会加载任何静态资源,因为它们将被 Spring Security 过滤。
对于我们的应用程序,我们没有使用任何图像;如果我们使用了,图像路径(通常为 /img/**)也需要被忽略。
下一个方法 configure(HttpSecurity http) 用于设置各种页面(URL)的规则。我们的目的是展示使用 LDAP 的认证;因此,为了简化,有三个页面和相应的 URL,如下所示:
-
主页面(URL 为
/)。这是一个着陆页面,意味着当用户输入http://localhost:8080/springauth(假设8080为服务器端口,springauth为上下文)时,此页面将被打开。 -
登录页面(URL 为
/login)。它将显示一个登录表单,用户可以通过该表单进行认证。 -
一个私有页面(URL 为
/privatePage)。这是一个受保护的页面,并且仅对登录用户可用。用户登录成功后将被重定向到该页面。
主页面和登录页面对所有用户开放,而私有页面在登录成功后设置为成功 URL。这是一个简单的表单登录。下一个方法 configure(AuthenticationManagerBuilder authManagerBuilder) 实际上执行了魔法。这是配置 LDAP 作为认证机制的地方。
在这个类中,LdapAuthStructure 对象正在自动装配,它由 LdapDataConfig 类提供,我们在其中使用 @Bean 注解声明了它。让我们回顾一下,LdapAuthStructure 类持有 LDAP 连接数据,我们在 configure(AuthenticationManagerBuilder authManagerBuilder) 方法中使用这些数据。在 AuthenticationManagerBuilder 类上的 ldapAuthentication() 调用将设置认证类型为 LDAP。
此外,它还会设置其他连接数据,如 userDnPattern 和 userSearchBase。url 的值是 LDAP URL 和 LDAP 基础(例如,localhost:10389/o=packtPublisher)的组合。最后,管理员凭据通过 managerDn() 和 managerPassword() 调用提供。我们还需要提供一个密码编码器。它必须与我们在 LDAP 服务器上使用的相同的密码编码器,因此我们使用了 LdapShaPasswordEncoder。最后,我们只是提到了我们在 LDAP 服务器上设置的密码字段。
LDAP 认证配置已完成。当我们使用在 Apache DS 服务器中创建的(相同)凭据登录时,登录将会成功。我们已将uid设置为userDnPatterns,因此在登录时请使用uid作为用户名。
恭喜!Spring Security 与 LDAP 的集成已成功执行。用户将通过 Spring Security 对 LDAP 进行身份验证。如果您只需要使用 LDAP 进行身份验证,我们迄今为止所做的配置就足够了。接下来,我们将探讨如何通过 Spring Data 仓库与 LDAP 服务器交互,这是我们将在本章后面与 LDAP 和 OAuth 一起工作时使用的。
使用 Spring Data 管理 LDAP 用户
我们集成的一部分已经完成。LDAP 管理员可以从 LDAP 服务器配置用户;然后,他们可以通过使用 Spring Security 创建的 Web 应用程序进行身份验证。然而,我们仍然需要处理单独的系统(Apache DS)来维护用户信息。
如果管理员可以直接从 Spring Web 应用程序中维护用户,那会多么酷啊?这是一个很好的想法,因为它不仅会使管理员的工作变得简单,而且用户可以直接在基于 Spring 的 Web 应用程序中更新他们的个人资料信息(如密码、名字、姓氏等)。这完全可以通过 Spring Boot 实现。我们正在讨论从 Web 应用程序中执行对 LDAP 服务器的 CRUD 操作。
在我们的应用程序中,LDAP 被用作数据存储,我们在其中维护用户数据。每次我们需要在基于 Spring 的应用程序中处理任何类型的数据提供者时,我们都需要使用Spring Data,这是 Spring 家族的一个模块,专门设计用来与数据存储交互。Spring Data 提供了一个抽象层来与底层数据提供者交互,同时为每个数据提供者提供了实现,如 JPA、REST、Elasticsearch(我们在上一章中使用过)、Mongo DB 等。Spring Data LDAP 与 LDAP 服务器交互,我们将在应用程序中使用它。
Spring 为每个这些数据提供者提供了一套库,可以通过在 Spring Boot 应用程序中指定相应的启动器来使用。Spring Data LDAP 模块可以通过以下pom.xml中的启动器条目进行集成:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-ldap</artifactId>
</dependency>
在我们定义了这个启动器之后,所有必需的 JAR 文件都将出现在类路径中。接下来,我们将创建模型(实体)类。
Spring Data 模型
Spring Data 模块提供了一个 DAO 的实现,目的是使对各种数据提供者的数据访问保持一致。这带来了在无需太多努力的情况下切换底层持久化技术的巨大灵活性。最终,这将使系统松散耦合。
Spring Data 允许以面向对象的方式与持久化层交换数据,这意味着我们可以使用实体类对象执行 CRUD 操作。不用说,Spring Data LDAP 模块还简化了以模型类对象的形式处理 LDAP 服务器的功能。因此,首先需要定义一个模型(实体)类,该类代表我们在 LDAP 服务器中定义的用户数据,如下所示:
@Entry(
base = "ou=users",
objectClasses = {"top", "person", "organizationalPerson","inetOrgPerson"})
public final class LdapAuthUser implements Persistable<Name> {
@Id
private Name id;
@Attribute(name = "uid")
@DnAttribute(value="uid")
private String userName;
@Attribute(name = "sn")
private String surName;
@Attribute(name = "cn")
private String firstName;
@Attribute(name = "userPassword")
private String password;
@Transient
private boolean isNew;
//.. setter and getter methods
}
LdapAuthUser 类代表我们在 Apache DS 中创建的 LDAP 用户。使用 @Entity 注解将 Java 类映射到 LDAP 服务器中的实体。base 代表 users 实体的基础(ou=users),而 objectClasses 用于定义用于创建用户实体的对象类层次结构。
使用 @Attribute 注解将实例变量映射到 LDAP 实体的属性。使用 @DnAttribute 注解自动填充从找到的条目的区分名称中的值。特别关注 id 实例变量。它将是 javax.naming.Name 类型。
在 LDAP 实体中没有直接属性,例如 id,但 Spring Data 需要为模型类的每个实例分配某种唯一标识符(例如,在关系型数据库中的 PK)。因此,在内部,它为模型类定义的 base(在 @Entity 注解中)分配一个相对的 DN。例如,如果用户的 uid 是 npatel,则 id 将是 uid=npatel,ou=users。
另一个独特之处在于,此模型类实现了 Persistable<Name> 接口。特别是对于 LDAP,在添加新实体时,Spring Data 没有任何方法知道实体是新的还是现有的。因此,每次 Spring Data 尝试搜索现有实体时,如果实体不存在,它将抛出一个错误。为了避免这种情况,在添加新记录时,我们将显式设置 isNew 属性为 true,这样 Spring Data 就可以通过重写方法 isNew() 获取其值。
模型类现在已准备好使用。接下来,我们将使用 Spring Data 对 LDAP 用户执行 CRUD 操作。
Spring Data LDAP 存储库
正如你所见,Spring Data 在与持久化层一起工作时提供了一个抽象(接口)层,以支持各种数据存储,包括关系型数据库、非关系型数据库、MapReduce 框架、云服务等。抽象从 CrudRepository 接口开始,该接口提供了基本的 CRUD 操作,无论底层数据存储如何。此接口涵盖了所有基本的 CRUD 操作。
Spring Data 定义了各种接口,这些接口在CrudRepository之上针对每个数据提供者都是特定的。对于 LDAP 支持,Spring Data 提供了LdapRepository接口,该接口基本上扩展了CrudRepository接口,这是我们将在自定义仓库中扩展的接口。该接口具有执行 CRUD 操作所需的所有方法。通过扩展特定的仓库接口,所有标准数据访问的基本 CRUD 操作都将 readily 可用。
让我们在应用程序中添加 Spring Data 对 LDAP 的支持。自定义仓库接口将如下所示:
@Repository
public interface LdapAuthRepository extends LdapRepository<LdapAuthUser>{
}
使用@Repository注解来描述这是一个仓库接口。自定义接口LdapAuthRepository扩展了LdapRepository,其中包含我们在上一节中创建的模型实体LdapAuthUser。在声明此接口后,Spring Data 提供了我们可以在服务类中使用的 CRUD 实现,如下所示:
@Component
public class LdapAuthService {
private Logger logger = LoggerFactory.getLogger(LdapAuthService.class);
@Autowired
private LdapAuthRepository ldapAuthRepository;
//Create
public void addUser(LdapAuthUser ldapAuthUser) {
Name dn = LdapNameBuilder
.newInstance()
.add("uid", ldapAuthUser.getUserName())
.add("ou", "users")
.build();
boolean isExist = ldapAuthRepository.existsById(dn);
if(isExist ==false) {
ldapAuthRepository.save(ldapAuthUser);
}else {
logger.info("User with username "+ldapAuthUser.getUserName()+" is already exist ");
}
}
//Read
public LdapAuthUser getUser(String userName) {
Optional<LdapAuthUser> ldapAuthUserOptional = ldapAuthRepository.
findOne(LdapQueryBuilder.query().where("uid").is(userName));
if(ldapAuthUserOptional.isPresent()) {
return ldapAuthUserOptional.get();
}else {
return null;
}
}
//Update
public void updateLdapUser(LdapAuthUser ldapUser) {
ldapAuthRepository.save(ldapUser);
}
//Delete
public void deleteUser(String userName) {
Optional<LdapAuthUser> ldapAuthUserOptional = ldapAuthRepository.
findOne(LdapQueryBuilder.query().where("uid").is(userName));
if(ldapAuthUserOptional.isPresent()) {
ldapAuthRepository.delete(ldapAuthUserOptional.get());
}else {
logger.info("User with username "+userName+" does not exist ");
}
}
}
在此服务类中,使用@Autowired注解注入了LdapAuthRepository类的对象。它用于调用 CRUD 方法,如下所示:
- 创建:使用
existsById()命令来检查是否已存在具有相同 ID 的用户。ID 的类型为javax.naming.Name。LdapNameBuilder类用于构建 ID。如果用户不存在,则会在ldapAuthRepository对象上调用save方法,以创建一个新的对象。我们可以通过 Spring MVC 控制器调用此服务方法来添加记录。我们需要创建LdapAuthUser对象,设置数据,并从控制器方法中调用服务方法,如下所示:
LdapAuthUser ldapUser = new LdapAuthUser();
ldapUser.setUserName("kpatel");
ldapUser.setPassword("test1234");
ldapUser.setFirstName("Komal");
ldapUser.setSurName("Patel");
ldapUser.setIsNew(true);
Name dn = LdapNameBuilder.newInstance()
.add("ou=users")
.add("uid=kpatel")
.build();
ldapUser.setId(dn);
ldapAuthService.addUser(ldapUser);
-
-
要创建新用户,首先需要创建模型类(
LdapAuthUser)的对象及其属性。 -
由于我们正在创建新用户,需要将
isNew设置为true,以确保 Spring Data 模块将其视为新记录。如果不这样做,系统将抛出错误。 -
我们还需要设置
id的值。LdapNameBuilder用于创建javax.naming.Name类型的对象(id)。在id中还需要添加类似uid=kpatel(用户名)和ou=users的值。
-
-
读取:要读取具有
username的 LDAP 用户,使用findOne()方法。我们需要将 LDAP 查询传递到这个方法中。LdapQueryBuilder类用于创建 LDAP 查询,该查询将username与uid匹配。 -
更新:更新操作很简单。
ldapAuthRepository的save方法实际上会更新 LDAP 用户。 -
删除:在删除用户时,首先需要检查用户是否存在。再次,可以使用
findOne来检索现有用户。只有当用户存在时,才能执行delete操作。
此外,我们可以使用以下方法在服务类中以编程方式执行身份验证,如下所示:
public boolean authenticateLdapUserWithContext(String userName, String password) {
return ldapAuthRepository.authenticateLdapUserWithContext(userName, password);
}
public boolean authenticateLdapUserWithLdapQuery(String userName, String password) {
return ldapAuthRepository.authenticateLdapUserWithLdapQuery(userName, password);
}
authenticateLdapUserWithLdapQuery 和 authenticateLdapUserWithContext 是在 LdapAuthRepositoryCustomImpl 类中定义的自定义方法,其中我们可以定义自定义方法以与 LDAP 交互。我们将在下一节进一步讨论此主题。
在认证成功后,这些方法将返回 true;否则,它们将返回 false。我们需要以纯文本格式为这两个方法传递密码。这就是 Spring Data 仓库如何用于在 LDAP 用户上执行 CRUD 操作。或者,我们也可以使用 LdapTemplate 来执行 CRUD 操作,以及其他复杂的业务功能。
使用 LdapTemplate 执行 CRUD 操作
Spring Data 仓库是与底层数据提供者交互的便捷方式,因为它易于使用且代码量少,因为实现是由 Spring Data 模块提供的。然而,这种简单性伴随着某些限制。例如,使用仓库编程模型,我们只有基本的 CRUD 操作可用。对于更复杂的业务需求,我们需要扩展它并提供自己的仓库实现。这就是模板模型出现的地方。
Spring Data 模块中的模板模型在方便性上不如仓库抽象,但在提供更细粒度控制复杂操作方面更强大。我们将使用 Spring Data 模板模型查看相同的 CRUD 操作。当然,目的是学习如何准备 Spring Data 模板,以便它可以用于复杂的业务功能。
初始化 LdapTemplate
Spring Data 为每个底层数据提供者提供模板,如 JdbcTemplate、JpaTemplate、MongoTemplate、ElasticSearchTemplate、CassandraTemplate 等。LdapTemplate 是用于与 LDAP 服务器通信的模板。我们首先初始化 LdapTemplate。将以下方法添加到 LdapDataConfig 类中:
@Bean("ldapTemplate")
public LdapTemplate getLdapTemplate() {
return new LdapTemplate(getLdapContextSrc());
}
@Bean
public ContextSource getLdapContextSrc() {
LdapContextSource ldapContextSrc = new LdapContextSource();
ldapContextSrc.setUrl(ldapUrls);
ldapContextSrc.setUserDn(ldapManagerUserName);
ldapContextSrc.setPassword(ldapManagerPwd);
ldapContextSrc.setBase(ldapBase);
ldapContextSrc.afterPropertiesSet();
return ldapContextSrc;
}
getLdapContextSrc() 方法首先创建一个 LdapContextSource 对象,并使用从 application.properties 中读取的 LDAP 连接参数对其进行初始化。@Bean 注解将此对象导出为一个 Spring bean。第二个方法 getLdapTemplate() 使用 LdapContextSoruce 对象,并初始化 LdapTemplate 类的对象;然后,它通过 @Bean 注解以 id=ldapTemplate 的形式暴露为一个 Spring bean。
使用 LdapTemplate 执行 CRUD 操作
现在,我们已经初始化了 LdapTemplate 对象。接下来,我们将使用它来执行各种 CRUD 操作。我们将在 Spring Data 仓库结构中使用 LdapTemplate。为此,我们需要扩展 Spring Data 仓库模型并提供自定义实现。
创建一个接口:LdapAuthRepositoryCustom。这是一个可以定义自定义方法的地方,这些方法在仓库抽象中不可直接使用。更新LdapAuthRepository接口的定义,如下所示:
@Repository
public interface LdapAuthRepository extends LdapRepository<LdapAuthUser>,LdapAuthRepositoryCustom
{
}
这是自定义实现与 Spring Data 仓库框架的粘合点。最后,定义LdapAuthRepositoryCustomImpl类,该类实现了LdapAuthRepositoryCustom接口。这是定义自定义方法的地方,如下所示:
@Repository
public class LdapAuthRepositoryCustomImpl implements LdapAuthRepositoryCustom {
private Logger logger = LoggerFactory.getLogger(LdapAuthRepositoryCustomImpl.class);
@Autowired
private LdapTemplate ldapTemplate;
...// Custom implementation method.
LdapAuthRepositoryCustomImpl实现了LdapAuthRepositoryCustom接口,用于声明自定义仓库方法。这个类有一个类型为LdapTemplate的实例变量,它通过@Autowired注解(在LdapDataConfig类中创建)进行注入。接下来,我们将查看这个类中定义的一些方法,如下所示。
- 创建操作:以下代码块描述了如何使用
ldapTemplate通过创建操作添加新的 LDAP 用户:
@Override
public void create(LdapAuthUser ldapAuthUser) {
ldapAuthUser.setIsNew(true);
ldapTemplate.create(ldapAuthUser);
}
@Override
public void createByBindOperation(LdapAuthUser ldapAuthUser) {
DirContextOperations ctx = new DirContextAdapter();
ctx.setAttributeValues("objectclass", new String[] {"top", "person", "organizationalPerson","inetOrgPerson"});
ctx.setAttributeValue("cn", ldapAuthUser.getFirstName());
ctx.setAttributeValue("sn", ldapAuthUser.getSurName());
ctx.setAttributeValue("uid", ldapAuthUser.getUserName());
ctx.setAttributeValue("password", ldapAuthUser.getPassword());
Name dn = LdapNameBuilder.newInstance()
.add("ou=users")
.add("uid=bpatel")
.build();
ctx.setDn(dn);
ldapTemplate.bind(ctx);
}
第一种方法很简单。它使用ldapTemplate通过模型对象创建 LDAP 用户。我们已将isNew设置为true,以确保在创建 LDAP 用户时不会出现任何问题。第二种方法createByBindOperation使用低级 API 创建 LDAP 用户。首先初始化DirContextAdapter对象,包含各种模型属性,如objectClass、cn、sn、uid、userPassword和dn。使用LdapNameBuilder类创建 LDAP 用户的 DN。最后,使用ldapTemplate的bind方法创建用户。我们可以使用这两种方法中的任何一种来创建用户。
- 读取操作:以下代码块展示了如何使用
ldapTemplate通过读取操作获取 LDAP 用户:
@Override
public LdapAuthUser findByUserName(String userName) {
return ldapTemplate.findOne(
LdapQueryBuilder.query().where("uid").is(userName), LdapAuthUser.class);
}
@Override
public List<LdapAuthUser> findByMatchingUserName(String userName) {
return ldapTemplate.find(
LdapQueryBuilder.query().where("uid").like(userName), LdapAuthUser.class);
}
@Override
public LdapAuthUser findByUid(String uid) {
return ldapTemplate.findOne(LdapQueryBuilder.query().where("uid").is(uid), LdapAuthUser.class);
}
@Override
public List<LdapAuthUser> findAllWithTemplate() {
return ldapTemplate.findAll(LdapAuthUser.class);
}
@Override
public List<LdapAuthUser> findBySurname(String surName) {
return ldapTemplate.find(LdapQueryBuilder.query().where("sn").is(surName), LdapAuthUser.class);
}
这些是一些从 LDAP 服务器读取用户的方法。LdapQueryBuilder用于构建一个查询,可以用于执行对各种属性(如uid和surname)的搜索。它也可以用于使用like查询查找具有匹配属性的用户。
- 更新操作:以下代码块展示了如何使用
ldapTemplate通过更新操作更新 LDAP 用户:
@Override
public void updateWithTemplate(LdapAuthUser ldapAuthUser) {
ldapTemplate.update(ldapAuthUser);
}
update方法很简单。update()方法用于使用模型对象更新 LDAP 用户。
- 删除操作:以下代码块描述了如何使用
ldapTemplate通过 DELETE 操作删除 LDAP 用户:
@Override
public void deleteFromTemplate(LdapAuthUser ldapAuthUser) {
ldapTemplate.delete(ldapAuthUser);
}
@Override
public void deleteFromTemplateWithUnbind(String userName) {
Name dn = LdapNameBuilder.newInstance()
.add("ou=users")
.add("uid="+userName)
.build();
ldapTemplate.unbind(dn);
}
第一种方法很简单。它只是调用ldapTemplate对象上的delete方法来删除 LDAP 用户。第二种方法首先创建用户 DN,然后调用ldapTemplate上的unbind方法来删除用户。
在ldapTemplate上的delete方法简单地调用unbind方法,并对给定的实体进行空检查。因此,delete()和unbind()这两个方法最终都在做同样的事情。
除了基本的 CRUD 操作外,我们还可以使用ldapTemplate执行一些其他操作,如下所示:
@Override
public boolean authenticateLdapUserWithLdapQuery(String userName, String password) {
try {
ldapTemplate.authenticate(LdapQueryBuilder.query().where("uid").is(userName), password);
return true;
}catch(Exception e) {
logger.error("Exception occuired while authenticating user with user name "+userName,e.getMessage(),e);
}
return false;
}
@Override
public boolean authenticateLdapUserWithContext(String userName, String password) {
DirContext ctx = null;
try {
String userDn = getDnForUser(userName);
ctx = ldapTemplate.getContextSource().getContext(userDn, password);
return true;
} catch (Exception e) {
// If exception occurred while creating Context, means - authentication did not succeed
logger.error("Authentication failed ", e.getMessage(),e);
return false;
} finally {
// DirContext must be closed here.
LdapUtils.closeContext(ctx);
}
}
第一个方法通过传递LdapQuery和密码在ldapTemplate上调用authenticate方法。使用LdapQueryBuilder为给定用户名创建 LDAP 查询。第二个方法通过传递用户 DN 和密码在ldapTemplate对象上调用getContextSource().getContet()。上下文在结束时需要关闭。使用getDnForUser()方法根据给定的userName获取用户 DN,如下所示:
private String getDnForUser(String uid) {
List<String> result = ldapTemplate.search(
LdapQueryBuilder.query().where("uid").is(uid),
new AbstractContextMapper<String>() {
protected String doMapFromContext(DirContextOperations ctx) {
return ctx.getNameInNamespace();
}
});
if(result.size() != 1) {
throw new RuntimeException("User not found or not unique");
}
return result.get(0);
}
ldapTemplate的search方法通过传递LdapQuery和ContextMapper的实现来调用,并最终返回给定用户名的用户 DN(例如,uid=npatel,ou=users,o=packtPublisher)。
使用 Spring Security 的 LDAP 授权
您在上一节中看到了使用 Spring Security 的 LDAP 身份验证。接下来,我们将探讨如何执行授权。让我们回顾一下,授权是一个验证过程,以确定实体是否应该有权访问某些内容。简而言之,授权涉及将确定谁被允许做什么的规则。在身份验证成功后,用户可以根据他们拥有的权限执行各种操作。
让我们回顾一下,身份验证处理登录凭证以验证有效用户。授权更多的是检查用户是否有权执行各种操作,如添加、更新、查看或删除资源。授权发生在用户成功认证之后。在本节中,我们将探讨如何授权 LDAP 用户。
到目前为止,您已经看到用户详情是在 LDAP 服务器中维护的,Spring Security 使用它来进行身份验证。同样,我们将在 LDAP 服务器中设置授权详情,并在 Spring Security 中获取它们以实现授权。
在 LDAP 服务器中创建角色
如您在上一节中看到的,我们在根实体o=packtPublisher下创建了users实体(ou=users),并将所有用户都保存在该实体下,在 LDAP 服务器中。同样,为了存储授权信息,我们将在 Apache DS 中直接在根实体下创建一个新的实体,步骤如下:
-
在 LDAP 浏览器窗口中,右键单击分区
o=packtPublisher并选择 New | New Entry。 -
选择从零开始创建条目并点击 Next 按钮。
-
我们将添加一个实体类型组织单元,因此选择 organizationalUnit 对象类并点击 Next 按钮。
-
我们将使用
ou(组织单元)作为 RDN,并将角色作为其值。当我们给出 RDN 的值时,DN 会自动计算。您可以为 RDN 使用多个属性(或相同的属性多次,具有不同的值)。然后,DN 将通过在每个属性后附加逗号来计算。 -
点击“下一步”按钮,然后点击“完成”按钮,
ou=roles将被添加到o=packtPublisher之下。更新后的结构将在 LDAP 浏览器窗口中可见。
接下来,我们将在 ou=roles 条目下添加实际的角色条目。步骤如下:
-
在角色实体(
ou=roles)上右键单击,并选择“新建 | 新条目”。 -
选择“从头创建条目”选项,然后点击“下一步”.。
-
要添加一个角色,选择表示角色的对象类
groupOfNames。点击“下一步”按钮。 -
我们将添加一个角色,因此我们将使用属性
cn(通用名称)作为 RDN。只需将值设置为ADMIN。此时,DN 被计算为cn=ADMIN,ou=roles,o=packtPublisher。点击“下一步”按钮。 -
由于此实体具有
groupOfNames作为对象类,系统将在下一个窗口中要求进行成员分配。 -
点击“浏览”按钮,并在
o=packtPublisher条目下选择您想要分配此角色的用户。点击“确定”按钮。 -
以下是在给定角色中分配多个成员的步骤:
-
从 LDAP 浏览器窗口中选择任何角色条目。在中间部分(所选角色的详细信息以表格格式可见)右键单击,并选择“新建属性”。
-
将属性类型值设为
member,点击“下一步”.,然后点击“完成”按钮;您将看到用于选择分配此角色的用户的相同窗口。
-
执行这些步骤,并在角色条目下创建以下两个角色:
-
ADMIN -
USER
角色结构已在 Apache DS 中创建。现在我们将导入这些详细信息以执行授权。
将角色信息导入以执行授权
在本章的“示例 DIT 结构”部分,我们在根实体(o=packtPublisher)下创建了一个角色实体(ou=roles)。该角色实体包含各种子实体作为其角色。我们将探讨如何使用这些角色通过 Spring Security 进行授权。我们已配置 Spring Security 使用 LDAP 进行认证。现在我们将添加两个示例页面,并配置它们,使得一个页面只能由具有 ADMIN 角色的用户访问,而另一个页面则由具有 USER 或 ADMIN 角色的用户访问。
要实现这一点,需要在为 Spring Security 配置创建的 WebSecurityConfig 类的 configure 方法中进行更改。更新后的方法应如下所示:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/").permitAll()
.antMatchers("/adminPage/").hasAnyAuthority("ADMIN")
.antMatchers("/userPage/").hasAnyAuthority("USER")
.anyRequest().fullyAuthenticated()
.and()
.formLogin().loginPage("/login").permitAll()
.defaultSuccessUrl("/privatePage",true)
.failureUrl("/login?error=true")
.and()
.logout()
.permitAll().logoutSuccessUrl("/login?logout=true");
logger.info("configure method is called to make the resources secure ...");
}
我们已添加了一个管理员页面(URL 为 /adminPage/)和一个用户页面(URL 为 /usePage/),并已配置它们,以便分别由具有 ADMIN 和 USER 角色的用户访问。
此外,我们还需要在src/main/resources/templates文件夹下创建相应的 Thymeleaf 模板,并在 Spring MVC 控制器类中为这两个页面创建条目,并更新菜单结构(在页眉模板中定义),以容纳这些页面。详细内容在源代码中有所说明,源代码可在 GitHub 上找到(github.com/PacktPublishing/Spring-5.0-Projects)。
接下来,我们将更新代表 LDAP 配置的configure方法,使用 Spring Security。此方法接受一个类型为AuthenticationManagerBuilder的对象。在做出必要的更改后,此方法将如下所示:
@Override
protected void configure(AuthenticationManagerBuilder authManagerBuilder) throws Exception {
authManagerBuilder.ldapAuthentication()
.userDnPatterns(ldapAuthStructure.getUserDnPattern())
.userSearchBase(ldapAuthStructure.getUserSearchBase())
.groupSearchBase(ldapAuthStructure.getGroupSearchBase())
.groupSearchFilter("member={0}").rolePrefix("")
.contextSource()
.url(ldapAuthStructure.getLdapUrl()+"/"+ldapAuthStructure.getLdapBase())
.managerDn(ldapAuthStructure.getLdapManagerDn())
.managerPassword(ldapAuthStructure.getLdapManagerPwd())
.and()
.passwordCompare()
.passwordEncoder(new LdapShaPasswordEncoder())
.passwordAttribute("userPassword");
logger.info("configure method is called to build Authentication manager ...");
}
以下是我们在授权方面所做的更改:
-
添加了
groupSearchBase方法调用,并将值传递为ou=roles,这代表组搜索的基础。组搜索基础值(ou=roles)存储在我们创建的用于保存 LDAP 连接属性的ldapAuthStructure对象中。 -
添加了一个
groupSearchFilter方法调用,并将值传递为member={0}。它用于定义搜索成员的模式。{0}将在运行时被实际的用户 DN 所替换。 -
将额外的
rolePrefix("")方法放置以设置角色前缀。如果没有这个方法调用,Spring Security 将使用ROLE_前缀来添加角色名称。例如,对于在 LDAP 服务器中定义的ADMIN角色,Spring Security 实际返回的角色将是ROLE_ADMIN。为了避免这种情况,我们调用此方法并简单地传递一个空字符串,这样我们就可以得到在 LDAP 服务器中定义的确切角色名称。
授权配置部分已经完成。你可以在 Apache DS 中创建一些示例用户,分配给他们角色,并检查他们是否能够访问我们创建的页面。没有任何角色的用户无法访问任何页面(无论是管理员页面还是用户页面)。
这就是使用 Spring Security 集成 LDAP 的全部内容。在下一节中,我们将探讨 OAuth 集成。
OAuth
一个典型的 Web 应用程序需要凭证,以用户名/密码的形式进行身份验证。HTML 表单用于在浏览器中请求凭证,然后将它们发送到服务器。服务器随后验证信息,在服务器端创建并维护一个会话,并将会话 ID 发送回浏览器。
会话 ID 将在每个请求中发送,服务器将根据会话 ID 将会话与信息进行映射,并从数据库中提取某些信息以执行授权。浏览器通常将会话 ID 存储在 cookies 中。只要会话处于活动状态,用户就可以根据分配的权限访问受限制的资源。
这是一个相当简单且易于客户端-服务器交互的机制,因此,它至今仍被许多 Web 应用程序和服务所使用。然而,这种模型存在某些局限性,如下所述:
-
通常,cookie 是有状态的,因此服务器需要跟踪会话并在每次请求时与数据库(或在内存中)进行核对。这可能导致服务器上的开销。此外,授权过程与应用服务器紧密相关,导致系统紧密耦合。
-
对于 REST 客户端,如原生移动应用,cookie 可能无法正常工作。
-
如果应用功能分布在多个域中,可能需要额外的配置来维护 cookie 值。
-
基于 cookie 的模型存在授予第三方客户端访问权限的限制。
OAuth克服了这些限制。根据定义,它是一个授权框架(或更精确地说,是一个协议),允许应用访问同一或不同应用内的资源。换句话说,OAuth 2.0 允许第三方应用代表资源所有者有限地访问受保护资源。它支持移动、Web 和桌面应用的授权流程。当前该协议的版本是 OAuth 2.0。我们将 OAuth 2.0 与 Spring Security 集成。
尽管 OAuth 具有某些与带有会话的正常客户端-服务器机制不同的特性,但前者不能取代后者。例如,银行应用必须使用客户端-服务器机制来实现。这里给出的比较是为了展示 OAuth 如何被用来提供对第三方应用的访问。
OAuth 角色
在继续之前,了解 OAuth 中使用的某些术语非常重要。这将提供对其底层概念的深刻理解。它们被称为 OAuth 角色,如下所示:
-
资源所有者:可以授予受保护资源访问权限的个人或实体。OAuth 协议不仅限于个人。应用与应用之间的交互可以通过 OAuth 进行。如果资源所有者是个人(或用户),则称为最终用户。
-
授权服务器:正如其名所示,它是一个提供授权的实体,形式为令牌。在资源所有者成功认证后,授权服务器将向客户端颁发访问令牌。
-
资源服务器:这是持有受保护资源的服务器。当请求受保护资源的请求到达资源服务器时,它将使用授权服务器验证访问令牌并相应地做出响应。
-
客户端:发起请求以支持资源所有者访问受保护资源的实体称为客户端。它可以以任何形式存在,如请求凭证的移动应用或提供社交媒体(如 Facebook 或 Google)替代登录功能的基于 Web 的应用。
这些角色之间的关系在以下图中展示:

最终用户,即资源所有者,与一个充当客户端的应用程序进行交互。在这里,客户端将与授权服务器通信。资源所有者提供凭证,并在授权服务器首先进行认证。成功识别后,授权服务器会发放一个访问令牌,该令牌由客户端用于访问资源服务器上的受保护资源,以支持资源所有者。授权服务器也被称为身份提供者。
让我们通过一个现实生活中的场景来了解 OAuth 中的授权过程。假设约翰有一辆带有智能钥匙的汽车。如今,带有智能钥匙的汽车很常见,没有智能钥匙在口袋里,车辆无法操作(甚至无法解锁或启动)。约翰已经让他的朋友查尔斯去机场接他。他已经给了他一个智能钥匙。查尔斯使用智能钥匙启动了汽车。
在这个类比中,智能钥匙赋予查尔斯操作约翰汽车的授权,因为授权涉及用户可以访问的资源以及他们可以对这些资源做什么。在这种情况下,约翰是最终用户(资源所有者),而查尔斯是客户端。智能钥匙是访问令牌,而汽车的安保系统可以被视为授权服务器(或身份提供者),它通过智能钥匙(访问令牌)授权查尔斯(客户端)。整个汽车是资源服务器(查尔斯可以使用汽车的其它功能,如空调、音响系统等,因为他有权使用带有访问令牌(智能钥匙)的汽车)。
授权类型
OAuth 协议的核心是提供访问令牌进行授权。获取访问令牌的方式被称为授权类型。有各种方式(授权类型)来访问和使用访问令牌。OAuth 2.0 为不同的场景提供不同的授权类型,如应用程序的信任级别、应用程序类型等。
OAuth 2.0 支持以下类型的授权。选择最适合应用程序的类型取决于该应用程序的类型:
-
授权码
-
隐式
-
资源所有者密码凭证
-
客户端凭证
让我们详细看看每种授权类型:它们是如何工作的,以及它们最适合哪些情况。
授权码
作为最常用和最广泛使用的授权类型,授权码授权最适合服务器端应用程序。客户端将是一个 Web 应用程序。为了确保客户端与授权服务器正确交互,需要配置某些连接参数,如客户端 ID和客户端密钥,与客户端一起。由于客户端是 Web 应用程序,这些参数可以保密地维护。
在这种授权类型中,客户端必须能够与用户代理(浏览器)协作,因为授权码是通过浏览器路由的。授权码授权获取访问令牌的过程可以用以下图表描述。由于资源所有者在授权服务器上进行了身份验证,其凭证将不会与客户端共享:

在此授权类型中,访问令牌是通过以下步骤获得的:
-
客户端被配置为与授权服务器建立连接。它将在用户代理(浏览器)中打开链接。此链接包含其他信息,这些信息将被授权服务器用于识别并回应客户端。通常,链接将在新窗口中打开,并包含一个登录表单,作为授权的第一步来验证用户。
-
然后,用户(资源所有者)以用户名和密码的形式输入凭证。
-
浏览器(用户代理)然后将这些凭证发送到授权服务器。
-
授权服务器验证凭证,并将带有授权码的响应发送回客户端。
-
在收到授权码后,客户端将与之交换以从授权服务器获取访问令牌,以及可选的刷新令牌。
-
获取访问令牌后,客户端可以与资源服务器通信以获取受保护资源。
授权码流可以与 Web 和移动应用客户端一起使用。通常,Web 应用客户端使用客户端 ID和客户端密钥,而移动应用客户端使用证明密钥用于代码交换(PKCE)机制,并利用代码挑战和代码验证器。
隐式
隐式授权类型是专门为在浏览器中运行的单一页面 JavaScript 应用程序设计的。它与授权码流最相似。唯一的区别在于授权码交换的过程。在隐式授权类型中,客户端不会从授权服务器接收授权码,与授权码授权类型不同,这是出于安全原因。
或者,一旦用户代理成功发送凭证,授权服务器将直接向客户端颁发访问令牌。由于隐式流针对的是单一页面 JavaScript 应用程序,因此也不允许刷新令牌。整个过程在以下图表中描述。
由于授权服务器直接颁发访问令牌,因此与客户端和授权服务器之间的请求-响应往返次数减少,与授权码流相比:

此过程按照以下顺序发生:
-
客户端将在用户代理(浏览器)中打开一个新窗口,其中包含一个登录表单,以验证用户身份作为授权的第一步。
-
然后,用户(资源所有者)以用户名和密码的形式输入凭证。
-
浏览器(用户代理)然后将这些凭证发送到授权服务器。
-
授权服务器验证凭证,并将访问令牌直接发送给客户端。
-
在获得访问令牌后,客户端可以与资源服务器交谈以获取受保护的资源。
资源所有者密码凭证
资源所有者密码凭证授权类型应用于高度受信任的客户端,因为它直接处理用户凭证。换句话说,此授权类型仅在资源所有者和客户端之间有大量确定性时才应使用。通常,客户端将是一个第一方应用程序。凭证将由客户端直接使用,以与授权服务器交互并获得访问令牌。流程可以用以下图示描述:

此流程可以描述如下:
-
客户端高度受信任,因此它将直接要求资源所有者提供凭证。客户端可能是一个高度宠爱的应用程序。
-
凭证将由客户端发送到授权服务器。客户端还将向授权服务器发送其自己的身份。作为回应,授权服务器将发送访问令牌,以及可选的刷新令牌。
-
客户端使用访问令牌来访问资源服务器受保护的资源。
客户端凭证
客户端凭证授权类型与资源所有者密码凭证流程类似。在客户端凭证授权中,客户端与授权服务器交互,通过发送客户端 ID 和客户端密钥来提供识别,并获得访问令牌。一旦收到访问令牌,客户端将与资源服务器交互。在这种情况下,不应使用刷新令牌。流程图如下所示:

-
客户端 ID和客户端密钥与客户端配置。客户端将与授权服务器交互以获取访问令牌。
-
在获得访问令牌后,客户端可以与资源服务器交互以访问受保护的资源。
应使用哪种授权类型?
在了解了每种授权类型的详细信息后,了解给定应用程序的正确授权类型非常重要。在授权类型选择中起着关键作用的因素有很多,例如最终用户识别、客户端类型(服务器端、基于 Web、本地、客户端)以及客户端和资源所有者之间的保证水平。
如果我们计划构建一个应用程序并允许其他第三方应用程序访问资源,那么授权码流是正确的选择。它是公开托管应用程序高度推荐的一种授权类型。另一方面,如果客户端是基于 JavaScript 并在浏览器中运行,我们应该为第三方客户端选择隐式授权类型,而对于第一方客户端,则应使用资源所有者密码凭证授权。
如果客户端是本地(移动)应用程序,我们可以选择资源所有者密码凭证授权类型。如果资源所有者不需要最终用户的身份,并且客户端本身表现得像资源所有者,我们应该使用客户端凭证授权类型。通常,客户端凭证授权用于机器(而不是用户)需要授权访问受保护资源,且不需要用户权限。
Spring Security 与 OAuth 集成
在了解了 OAuth 2.0 的基本原理及其工作方式之后,我们现在将探讨 OAuth 在 Spring Security 中的集成。我们将继续使用为 LDAP 创建的相同应用程序,并对其进行必要的更改以实现 OAuth 集成。
对于 OAuth 演示,我们将使用现成的授权提供程序。Spring Security 默认支持 Google、Facebook、Okta 和 GitHub 提供程序。选择其中之一只需要进行某些配置,然后一切就会开始工作。我们将选择 Google 作为授权服务器(提供程序)来为我们构建 OAuth。在这个集成中,我们将使用授权码作为授权类型。
每个授权提供程序都支持某种机制,允许客户端与服务建立连接。这个过程被称为应用注册。
应用注册
让我们在 Google 上注册(或创建)一个应用程序,该应用程序提供连接访问以使用授权服务。这是开始实现 OAuth 之前的一个基本步骤。Google 提供了一个API 控制台用于注册应用程序。访问 API 控制台需要一个有效的 Google 账户。请访问console.developers.google.com/apis/credentials并按照以下步骤操作:
-
在“凭证”选项卡中单击“创建凭证”按钮,然后单击“OAuth 客户端 ID”选项。选择应用程序类型为 Web 应用程序。
-
给出适当的名称(例如
SpringOAuthDemo)。 -
我们需要在 Google 控制台中设置授权重定向 URI,它代表用户在通过 Google 成功授权后将被重定向的路径。Spring Security 为 Google 提供的默认实现已将重定向 URI 配置为
/login/oauth2/code/google。显然,在我们的情况下,有效的重定向 URI 将是localhost:8080/springuath/login/oauth2/code/google(考虑到8080为端口号,springauth为上下文名称)。在 Google 控制台的“授权重定向 URI”中提供此 URI,然后点击“创建”按钮。
应用程序成功注册后,Google 将创建客户端凭据,以客户端 ID 和客户端密钥的形式,如下面的截图所示:

客户端 ID 是一种公钥,由 Google 服务 API 用于识别我们已注册的应用程序。它还用于构建 Google 登录表单的授权 URL。客户端密钥,正如其名称所示,是一种私钥。当(我们注册的应用程序)请求用户账户的详细信息时,在用户凭证发送的那一刻,客户端密钥将用于验证应用程序的身份。因此,客户端密钥必须在应用程序和客户端之间保持私密。
Spring Boot 应用程序中的更改
完成前面的要求后,我们将开始对我们为 LDAP 认证创建的 Spring Boot 应用程序进行必要的更改。目标是构建一个中央认证和授权服务器,该服务器将同时与两种技术(LDAP 和 OAuth)协同工作。
当用户输入普通用户名和密码时,认证和授权将通过 LDAP 完成。我们将使用 Google 和 OAuth 配置我们的应用程序。OAuth 集成的第一步是声明相关的启动器。Spring Boot 以启动器的形式提供对 OAuth 的支持。在pom.xml文件中添加以下启动器条目:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
定义此启动器后,Spring Boot 将自动添加以下依赖项,这些依赖项对于 OAuth 集成是必需的:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-core</artifactId>
</dependency>
接下来,我们将在application.properties文件中添加客户端 ID 和客户端密钥的属性,以及提供者和客户端名称。这些属性以spring.security.oauth.client.registration为前缀,包括提供者名称和属性名称。按照以下方式设置这些属性,使用我们在上一节中在 Google 开发者控制台中创建的Client ID和Client Secret:
spring.security.oauth2.client.registration.google.provider=google
spring.security.oauth2.client.registration.google.client-name=Google
spring.security.oauth2.client.registration.google.client-id=<GOOGLE_CLIENT_ID>
spring.security.oauth2.client.registration.google.client-secret=<GOOGLE_SECRET>
默认 OAuth 配置
Spring Security 允许配置多个 OAuth 客户端。除了 Google 之外,Spring Security 还支持 Facebook、GitHub 和 Okta 的默认配置,开箱即用。这意味着所有必需的类和配置都 readily 可用,我们只需要定义客户端凭证(Client ID和Client Secret)。接下来,我们将更新WebSecurityConfig类的configure(HttpSecurity http)方法,如下所示:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login();
super.configure(http);
}
oauth2Login()方法将启动 OAuth 调用。在此时刻,当执行应用程序时,前面的方法将向 Google 发起 OAuth 调用。用户将被要求提供有效的 Google 登录凭证。认证成功后,它将显示主页。
如果我们为至少一个客户端设置了属性,Spring Security 将自动启用Oauth2ClientAutoConfiguration类,这将使所有必要的安排以启用 OAuth 登录,甚至不需要定义前面的WebSecurityConfig类。
如果在application.properties文件中配置了多个客户端,系统将显示这些客户端的列表,并带有默认的登录表单(用户名和密码)。这将是一个自动生成的登录页面。我们可以使用任何配置的客户端进行登录。
在顶部显示用户名以指示当前登录的用户是非常明显的。我们可以使用以下代码片段获取在 Google 上认证的用户名:
@ModelAttribute("currentUserName")
public String getCurrentUserName() {
String name = "";
if(SecurityContextHolder.getContext().getAuthentication() !=null) {
if(SecurityContextHolder.getContext().getAuthentication()
instanceof OAuth2AuthenticationToken) {
OAuth2AuthenticationToken oauth2Authentication =
(OAuth2AuthenticationToken)SecurityContextHolder.getContext().getAuthentication();
name = (String)oauth2Authentication.getPrincipal().getAttributes().get("name");
}else {
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
LdapAuthUser ldapUser = ldapAuthService.getUser(userName);
if(ldapUser !=null) {
name = ldapUser.getFirstName()+" "+ldapUser.getSurName();
}
}
}
return name;
}
此方法使用ModelAttribute定义,这意味着它可以直接在表示层中使用${currentUserName}表达式。我们正在获取Authentication的一个实例,并检查它是否为OAuth2AuthenticationToken类型。getPrincipal()方法将返回用户详情以及属性。name属性将返回使用 Google 登录的用户名。
另一部分将在我们使用 LDAP 进行认证时执行,它从Authentication对象中获取userName,然后调用自定义服务方法(ldapAuthService.getUser())来获取 LDAP 用户对象。然后使用它来获取用户名(名和姓)。
使用自定义登录页面的 OAuth
这就是 OAuth 如何与 Spring Security 集成的。在前面的配置中,Spring Boot 提供了一个自动生成的登录页面,这可能适用于测试目的。在实际场景中,我们可能需要一个定制的登录页面。要构建自定义登录页面,我们需要在configure(HttpSecurity http)方法中进行某些配置更改,如下所示:
http.authorizeRequests()
.antMatchers("/","/login").permitAll()
.antMatchers("/adminPage/").hasAnyAuthority("ADMIN")
.antMatchers("/userPage/").hasAnyAuthority("USER")
.anyRequest().fullyAuthenticated()
.and()
.oauth2Login().loginPage("/login")
.defaultSuccessUrl("/privatePage",true)
.failureUrl("/login?error=true")
.and()
.logout()
.permitAll().logoutSuccessUrl("/login?logout=true");
这看起来与我们为 LDAP 配置的类似。唯一的区别是我们使用oauth2Login()而不是formLogin()。在末尾已经移除了对super.configure(http)的调用,因为它不再需要。如果我们将其放置在那里,Spring Boot 将显示一个自动生成的登录页面,所以请确保在末尾将其移除。
此更改将显示自定义登录页面,但 OAuth 提供者的列表不会自动可见。我们需要手动准备此列表,并在登录页面中显示它们。为了实现这一点,我们需要更新控制器方法showLoginPage(),如下所示。此方法基本上显示登录页面:
@Autowired
private ClientRegistrationRepository clientRegistrationRepository;
@GetMapping("/login")
public String showLoginPage(@RequestParam(value = "error",required = false) String error,
@RequestParam(value = "logout", required = false) String logout,Model model) {
logger.info("This is login page URL ");
if (error != null) {
model.addAttribute("error", "Invalid Credentials provided.");
}
if (logout != null) {
model.addAttribute("message", "Logged out");
}
String authorizationRequestBaseUri = "oauth2/authorization";
Map<String, String> oauth2AuthenticationUrls = new HashMap<String, String>();
Iterable<ClientRegistration> clientRegistrations = (Iterable<ClientRegistration>) clientRegistrationRepository;
clientRegistrations.forEach(registration ->
oauth2AuthenticationUrls.put(registration.getClientName(),
authorizationRequestBaseUri + "/" + registration.getRegistrationId()));
model.addAttribute("urls", oauth2AuthenticationUrls);
setProcessingData(model, LdapAuthConstant.TITLE_LOGIN_PAGE);
return "login";
}
这是我们在本章开头创建的登录方法,用于显示登录表单。更改专门针对 OAuth 进行。首先,注入了ClientRegistrationRepository的实例,它表示存储 OAuth 客户端主要详情的仓库。它是一个接口类型,Spring Boot 提供了InMemoryClientRegistrationRepository类的实例作为默认实现。InMemoryClientRegistrationRepository维护一个ClientRegistration映射,该映射表示 OAuth 提供者。
在前面的代码中,我们从clientRegistrationRepository获取ClientRegistration映射,迭代它,并使用以oauth2/authorization为前缀的名称和授权 URL 准备 OAuth 提供者列表。我们将它设置为模型属性,以便它对表示层可用。应用这些更改后,登录页面将如下所示:

OAuth 和 LDAP 的双因素认证
应用程序现在将显示自定义登录页面,以及我们配置的 OAuth 客户端列表。尽管如此,当我们手动在登录表单中输入凭据时,什么也不会发生,因为 Spring Security 已配置为 OAuth。当用户在登录表单中输入凭据时,应使用 LDAP 进行认证。为了实现这一点,我们需要在 LDAP 中进行特定的配置。
在这里,目标是使用 LDAP 进行手动认证。Spring Security 提供了一种通过实现AuthenticationProvider接口来定义自定义认证提供者的方式。首先,我们将编写一个实现此接口并执行 LDAP 认证的类,如下所示:
@Component
public class CustomLdapAuthProvider implements AuthenticationProvider{
@Autowired
LdapAuthService ldapAuthService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String userName = authentication.getPrincipal().toString();
String password = authentication.getCredentials().toString();
boolean isAuthenticate = ldapAuthService.authenticateLdapUserWithContext(userName, password);
if(isAuthenticate == true) {
List<LdapGranntedAuthority> userRoles = ldapAuthService.getUserAuthorities(userName);
return new UsernamePasswordAuthenticationToken(
userName, password, userRoles);
}else {
return null;
}
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(
UsernamePasswordAuthenticationToken.class);
}
}
类CustomLdapAuthProvider使用@Component注解定义,这意味着 Spring 会将其配置为一个 bean,以便其他带有@Autowired注解的组件可以使用它。AuthenticationProvider接口声明了以下两个方法:
-
Authentication authenticate(Authentication authentication):这是我们可以提供自定义认证的地方。 -
boolean supports(Class<?> authentication):此方法指示此自定义认证提供者是否支持指示的认证对象。
在authenticate方法中,我们首先从Authentication实例中获取username和password。然后,通过调用我们已为 LDAP 认证创建的自定义服务方法authenticateLdapUserWithContext进行手动认证。如果此方法返回true,则表示凭证有效。
除了验证凭证外,我们还需要获取用户拥有的权限(角色)。为此,我们在自定义仓库impl类(LdapAuthRepositoryCustomImpl)中定义了一个方法,如下所示:
@Override
public List<LdapGranntedAuthority> getUserAuthorities(String userName) {
AndFilter groupFilter = new AndFilter();
groupFilter.and(new EqualsFilter("objectclass","groupOfNames"));
groupFilter.and(new EqualsFilter("member","uid="+userName+",ou=users,o=packtPublisher"));
List<LdapGranntedAuthority> userRoleLst = ldapTemplate.search(LdapQueryBuilder.query().
filter(groupFilter),new LdapRoleMapper());
return userRoleLst;
}
getUserAuthorities方法接受userName并返回权限列表。让我们回顾一下,对于角色,我们在 Apache DS 中创建了一个单独的实体(ou=roles)。它的所有子实体代表实际的角色(属性cn作为角色名称)。任何是特定权限(角色)成员的 LDAP 用户都会通过member属性添加。目标是获取当前用户是成员的所有权限。
ldapTemplate上的搜索方法接受LdapQuery和ContextMapper对象,并返回权限列表。LdapQuery使用objectclass的组过滤器以及成员属性的值构建。objectclass的值与我们在 LDAP 中给实体(ou=roles)指定的objectclass名称相同。成员属性的值将是一个有效的用户 DN;例如,uid=npatel,ou=users,o=packtPublisher。ContextMapper是一种从search方法中检索所需值的机制。更具体地说,ContextMapper可以用来检索自定义结果(将选定的值包装在自定义 POJO 中)。
这可以通过提供ContextMapper接口的实现来完成。为此,我们创建了一个类,如下所示:
public class LdapRoleMapper implements ContextMapper<LdapGranntedAuthority>{
@Override
public LdapGranntedAuthority mapFromContext(Object ctx) throws NamingException {
DirContextAdapter adapter = (DirContextAdapter) ctx;
String role = adapter.getStringAttribute("cn");
LdapGranntedAuthority ldapGranntedAuthority = new LdapGranntedAuthority();
ldapGranntedAuthority.setAuthority(role);
return ldapGranntedAuthority;
}
}
ContextMapper接口是一个原始类型,因此我们将类型定义为LdapGranntedAuthority,这实际上是一个自定义 POJO。在mapFromContext方法中,使用DirContextAdapter对象通过属性cn获取角色的名称。然后,将此角色名称设置在LdapGranntedAuthority实例中,并最终返回它。LdapGranntedAuthority类如下所示:
public class LdapGranntedAuthority implements GrantedAuthority {
String authority;
public void setAuthority(String authority) {
this.authority = authority;
}
@Override
public String getAuthority() {
return authority;
}
}
此 POJO 实现了GrantedAuthority接口以设置权限(角色)的名称。现在,让我们回到CustomLdapAuthProvider类的authenticate方法。在获取权限后,我们使用用户名、密码和权限列表创建UsernamePasswordAuthenticationToken类的对象。UsernamePasswordAuthenticationToken类基本上提供了Authentication接口的实现。
接下来,借助这个自定义认证提供者,我们将进行手动认证。在此之前,我们必须更改登录表单的操作,因为默认的(/login)将无法自动工作。将登录表单的路径从 /login 更改为 /ldapLogin。我们还需要创建一个相应的控制器方法来手动处理登录流程,如下所示:
@Autowired
CustomLdapAuthProvider customLdapAuthProvider;
@PostMapping("/ldapLogin")
public String ldapAuthenticate(HttpServletRequest req,@RequestParam(value = "username",required = true) String username,
@RequestParam(value = "password", required = true) String password,RedirectAttributes redirectAttributes) {
UsernamePasswordAuthenticationToken authReq
= new UsernamePasswordAuthenticationToken(username, password);
Authentication auth = customLdapAuthProvider.authenticate(authReq);
if(auth !=null) {
logger.info(" If user is authenticated .... "+auth.isAuthenticated());
SecurityContext sc = SecurityContextHolder.getContext();
sc.setAuthentication(auth);
HttpSession session = req.getSession(true);
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, sc);
if(auth.isAuthenticated() == true) {
return "redirect:/privatePage";
}else {
redirectAttributes.addAttribute("error", "true");
return "redirect:/login";
}
}else { // failed authentication - either username or password fails.
redirectAttributes.addAttribute("error", "true");
return "redirect:/login";
}
}
CustomLdapAuthProvider 实例通过 @Autowired 注解进行注入。此方法使用 @PostMapping 注解定义,用于处理使用 POST 方法的登录表单。我们使用登录表单提交的用户名和密码创建 UsernamePasswordAuthenticationToken 实例,并将其传递给 CustomLdapAuthProvider 的 authenticate 方法,该方法基本上使用 LDAP 进行身份验证并获取用户权限。一旦完成身份验证,我们将认证对象存储在 Spring Security 上下文中。
最后,我们已经将安全上下文保存在 HTTP 会话中,以便 Spring Security 将在会话中创建并维护用户认证信息。在执行所有这些过程之后,我们通过在认证对象上调用 isAuthenticated 方法来检查认证是否成功。根据认证状态(成功或失败),我们将流程重定向到私有页面(在成功认证的情况下)或登录页面(在认证失败的情况下)。这全部都是关于使用 LDAP 和 OAuth 进行双重认证。接下来,我们将展示在自定义授权服务器上的 OAuth 实现。
使用自定义授权服务器的 OAuth 授权
现在,您已经看到了使用第三方提供者(Google)通过 Spring 集成 OAuth。在本节中,我们将构建一个自定义授权服务器(提供者)并执行 OAuth 授权。我们还将构建自己的资源服务器,并在授权完成后访问资源。
在上一节中,我们使用 Google 的授权代码授予类型。在本节中,我们将实现隐式授予类型。让我们回顾一下,隐式授予类型是专门为 JavaScript 应用程序设计的。由于它在浏览器中运行,授权服务器直接发送访问令牌。出于安全考虑,不支持刷新令牌。
我们将首先开发一个自定义授权服务器(提供者),它将提供访问令牌。我们可以将其视为上一节中我们开发授权客户端的地方,即 Google。对于我们的自定义授权,我们将设置一个带有凭证(ID 和密钥)的客户端,这些凭证将用于提供授权(以提供访问令牌的形式)。
我们将为授权服务器和资源服务器创建单独的 Spring Boot 应用程序,并且需要同时运行它们,以便测试功能。为了避免端口冲突(两个应用程序的默认端口都是 8080),在运行时需要明确设置端口。为此,您需要在 application.properties 文件中提供一个不同的端口属性 server.port。
授权服务器配置
我们将开发一个单独的 Spring Boot 应用程序,并应用必要的配置以将其用作授权服务器。这是我们自定义的授权服务器。创建一个名为 SpringCustomAuthorization 的 Spring Boot 应用程序,并添加以下组件,它基本上是构建自定义授权服务器的基本入口点:
@Configuration
@EnableAuthorizationServer
public class CustomAuthorizationConfig extends AuthorizationServerConfigurerAdapter{
@Autowired
@Qualifier("authenticationManager")
private AuthenticationManager authenticationManager;
@Autowired
PasswordEncoder encoder;
@Override
public void configure(ClientDetailsServiceConfigurer clients)
throws Exception {
clients.inMemory()
.withClient("c1")
.authorizedGrantTypes("implicit")
.scopes("read", "write", "trust")
.secret(encoder.encode("123"))
.redirectUris("http://localhost:8082/privatePage")
.resourceIds("oauth2-server");
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123");
return converter;
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Override
public void configure(
AuthorizationServerEndpointsConfigurer endpoints)
throws Exception {
endpoints
.authenticationManager(authenticationManager)
.tokenServices(tokenServices())
.tokenStore(tokenStore())
.accessTokenConverter(accessTokenConverter());
}
@Bean("resourceServerTokenServices")
@Primary
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(false);
defaultTokenServices.setAccessTokenValiditySeconds(120);
defaultTokenServices.setTokenEnhancer(accessTokenConverter());
return defaultTokenServices;
}
}
使用 @EnableAuthorizationServer 注解,这个类声明提供了一个授权服务器的配置。为了更详细地理解这个概念,我们将把配置自定义授权服务器的每个步骤与我们已经在 Google OAuth 集成中完成的工作联系起来。在这个配置中的第一步是定义一个客户端,它基本上是与授权服务器通信以获取访问令牌。
configure(ClientDetailsServiceConfigurer clients) 方法用于定义一个具有各种元数据的客户端,如客户端 ID、授权类型、作用域、密钥和重定向 URI。resourceId 用于与资源服务器配对。我们将在本章后面创建资源服务器时配置相同的 resourceId。我们在这里使用的客户端类型是内存中的,这对于开发目的来说是合适的。
另一种类型是 JDBC,其中客户端详细信息可以存储在数据库中。我们可以使用这种方法配置多个客户端,并且每个客户端都可以通过调用 .and() 方法进行分隔。客户端是在授权服务器中创建的。我们可以将此与我们在 Google 开发者控制台中创建的客户端联系起来。
tokenStore() 方法用于构建访问令牌。Spring 提供了各种机制,如 InMemoryTokenStore、JdbcTokenStore、JwkTokenStore 和 JwtTokenStore,用于创建令牌。在这些机制中,我们使用了 JwtTokenStore。accessTokenConverter() 方法用于使用签名密钥对令牌进行编码/解码。
在配置资源服务器中的令牌存储时,我们需要使用相同的签名密钥。tokenServices() 方法用于使用令牌存储和几个设置来配置令牌服务。由于授权类型是隐式的,因此不允许刷新令牌,所以我们设置 setSupportRefreshToken() 为 false。我们还可以通过 setAccessTokenValiditySeconds() 方法设置令牌的有效时长。由于这是一个隐式流程,并且将被 JavaScript 应用程序使用,因此出于安全原因,令牌应该是短暂的。
最后,configure(AuthorizationServerEndpointsConfigurer endpoints)方法是一个粘合点,用于将我们迄今为止配置的所有内容组合在一起。通常,用户认证在授权之前进行,AuthenticationManager对象用于执行认证。在定义授权配置后,让我们通过应用安全配置来使其安全,如下所示:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean("authenticationManager")
public AuthenticationManager authenticationManagerBean() throws Exception {
AuthenticationManager authenticationManager = super.authenticationManagerBean();
return authenticationManager;
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**");
web.ignoring().antMatchers("/css/**");
}
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth.inMemoryAuthentication()
.withUser("john").password(new BCryptPasswordEncoder().encode("123")).authorities("USER");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/oauth/authorize","/").permitAll()
.and()
.formLogin().loginPage("/login").permitAll();
}
@Bean("encoder")
public BCryptPasswordEncoder encoder(){
return new BCryptPasswordEncoder();
}
}
通过@EnableWebSecurity注解,Spring Security 将被应用于授权服务器。这是一个你已经在前面章节中见过的正常 Spring Security 配置。/oauth/authorize是一个默认的授权端点。资源服务器将在这个路径上发起授权调用,因此我们在configure方法中进行了配置。
我们已创建了一个具有凭证和权限的内存用户。我们可以将其与在 Google 中持有的用户账户关联,这是当我们使用 Google 进行授权时被询问的。在我们的情况下,当在自定义授权服务器中启动授权过程时,我们将提供此凭证。
我们的资源服务器现在已准备就绪。它不能直接访问;相反,资源服务器会以某些参数的形式发起请求。接下来,我们将构建资源服务器。
资源服务器配置
如其名所示,一个资源服务器持有资源(以数据、服务、文件等形式),资源所有者可以通过提供有效的授权来访问这些资源。授权提供的过程以令牌共享机制的形式发生。
授权服务器在认证后创建一个令牌,该令牌由资源服务器用于提供受限制的资源。对于所有受保护资源的传入请求,资源服务器将使用授权服务器检查访问令牌的有效性。简要来说,这就是系统的流程。现在,我们将使用单独的 Spring Boot 应用程序创建一个资源服务器。
Spring 允许通过声明某些基本配置来创建资源服务器,如下所示:
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
private static final String RESOURCE_ID = "oauth2-server";
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources
.tokenStore(tokenStore())
.resourceId(RESOURCE_ID);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and().exceptionHandling().accessDeniedHandler(new OAuth2AccessDeniedHandler());
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123");
return converter;
}
}
@EnableResourceServer注解将指示 Spring Security 将组件视为资源服务器,并使用访问令牌对传入请求进行认证。在这个配置中,我们使用与授权服务器相同的resourceId。此外,创建和转换令牌以及签名密钥的过程与我们在授权服务器中实现的过程相同。
对于类型为Jwt的令牌,我们也可以使用公私钥作为签名密钥来生成访问令牌。在正常情况下,授权和资源服务器中声明的签名密钥必须相同。
这个类的configure(HttpSecurity http)方法是我们配置受保护资源路径的地方。在我们的例子中,我们正在配置/api/**,这意味着任何以/api/开头的路径都被视为安全的。没有有效的令牌,用户无法访问该路径。我们还定义了适当的拒绝处理器,以在无效令牌或权限不足的情况下显示适当的消息。接下来,我们将按照以下方式配置资源服务器的 Spring Security:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/","/customAuth").permitAll()
.anyRequest().authenticated();
}
}
这是一个简单的安全配置,我们已声明某些路径对所有用户可访问。接下来,我们将创建一个 Spring MVC 控制器,该控制器将显示我们可以从中启动授权的页面,如下所示:
@Controller
public class WebController {
private Logger logger = LoggerFactory.getLogger(WebController.class);
@GetMapping("/")
public String showHomePage(Model model) {
logger.info("This is show home page method ");
setProcessingData(model, "Home Page");
return "home";
}
@GetMapping("/privatePage")
public String showControlPage(Model model) {
logger.info("This is privaet page ");
setProcessingData(model, "Private Page");
return "private-page";
}
@GetMapping("/customAuth")
public String authorizeUser(Model model,@Value("${custom.auth.authorization-uri}") String authorizationUri,
@Value("${custom.auth.client-id}") String clientId,
@Value("${custom.auth.client-secret}") String clientSecret,
@Value("${custom.auth.grant-type}") String grantType,
@Value("${custom.auth.response-type}") String responseType) {
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(authorizationUri)
.queryParam("username", clientId)
.queryParam("password", clientSecret)
.queryParam("grant_type", grantType)
.queryParam("response_type", responseType)
.queryParam("client_id", clientId);
return "redirect:"+uriBuilder.toUriString();
}
}
前两个方法分别显示了主页和私有页面。我们将在主页上显示一个链接,该链接将启动授权过程。此链接将调用authorizeUser()方法(带有链接/customAuth)。此方法从application.properties文件中检索定义的客户端元数据,如下所示:
custom.auth.authorization-uri=http://localhost:8081/oauth/authorizee
custom.auth.client-id=c1
custom.auth.client-secret=123
custom.auth.grant-type=implicit
custom.auth.response-type=token
在authorizeUser()方法中,我们将流程重定向到授权 URI,并带有client-id、client-secret、grant-type和response-type参数。oauth/authorize是默认的授权端点。
让我们回顾一下,在授权服务器中进行客户端设置时,我们将redirectUri配置为localhost:8082/privatePage,这意味着在授权后,流程将返回此 URL,最终显示一个私有页面,以及访问令牌。
一旦我们获得令牌,我们就可以开始消费受保护资源了。我们在资源服务器配置中已将路径/api/**定义为受保护资源。因此,让我们创建一个 REST 控制器,如下所示,它将提供资源。为了演示目的,我们将返回一些示例数据:
@RestController
@RequestMapping("/api")
public class ServiceAPIController {
private Logger logger = LoggerFactory.getLogger(ServiceAPIController.class);
@RequestMapping("/currentUser")
public Principal getUser(Principal user) {
return user;
}
@RequestMapping("/adminresource")
public String adminResource(Principal user) {
return "{\"id\":\"" + user.getName() + "\",\"content\":\"Hello World\"}";
}
@RequestMapping(value="/usergreeting", method = RequestMethod.GET, produces = {MediaType.APPLICATION_JSON_VALUE})
public String userResource(Principal user) {
return "{\"id\":\"" + user.getName() + "\",\"content\":\"Hello World\"}";
}
@RequestMapping(value = "/userlist", method = RequestMethod.GET)
public ResponseEntity<List<SampleUser>> listAllSampleUsers() {
logger.info("Listing all users...");
SampleUser s1 = new SampleUser();
SampleUser s2 = new SampleUser();
s1.setFirstName("Nilang");
s1.setLastName("Patel");
s2.setFirstName("Komal");
s2.setLastName("Patel");
List<SampleUser> users = new ArrayList<SampleUser>();
users.add(s1);
users.add(s2);
return new ResponseEntity<List<SampleUser>>(users, HttpStatus.OK);
}
}
REST 控制器配置了路径/api,这意味着所有方法都可以通过有效的授权令牌访问。现在,是时候运行应用程序并测试流程了。我们将首先运行资源服务器应用程序(localhost:8082,假设它在端口8082上运行)。它将显示一个链接,该链接将流程重定向到授权服务器。
在流程到达授权服务器后不久,它将提示登录页面。这是因为授权服务器在开始授权之前需要有效的身份验证。这是有道理的,因为授权服务器将授权给定的用户账户,为此,用户必须登录。我们将使用为授权服务器创建的内存凭据。
登录后不久,授权服务器显示一个中间页面,并要求用户允许或拒绝。根据这一点,用户可以访问资源服务器上的受限制资源,如下所示:

这类似于我们在 Google 开发者控制台中创建客户端并使用它进行授权的情况。当时,首先我们向 Google 提供了凭证,在认证之后,它要求我们批准客户端的访问权限。当我们授权客户端时,它将重定向到资源服务器的私有页面,并带有访问令牌。
在此时刻,响应中返回的访问令牌包含#字符。为了访问任何受保护的资源,我们需要将访问令牌与?连接,这样它就会作为请求参数提供。如果没有这样做,资源服务器将不允许访问任何受保护的资源。
现在,我们可以使用访问令牌访问受保护的资源。带有路径/api的 REST 控制器将提供资源。例如,URL localhost:8082/api/usergreeting?access_token=<token_string> 将给出以下输出:

如果在令牌有效期过后进行请求,将显示token expired错误。如果原始令牌被修改,它也会抛出invalid token错误。如果没有提供令牌,它将显示类似Full authentication is required to access this resource的错误。
方法级别资源权限
现在,授权用户可以访问我们已配置的所有资源。如果资源可以根据用户角色进行访问,那会多么好?这将提供对资源的更细粒度控制。通过在 Spring Security 中应用方法级别的配置,这是可能的。为此,首先,我们需要定义一个代表方法级别 Spring Security 配置的类,如下所示:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
return new OAuth2MethodSecurityExpressionHandler();
}
}
@EnableGlobalMethodSecurity注解是必需的,用于在方法级别定义授权约束。基类GlobalMethodSecurityConfiguration提供了方法级别安全配置的默认实现。
我们已重写createExpressionHandle方法,用OAuth2MethodSecurityExpressionHandler代替默认提供的DefaultMethodSecurityExpressionHandler。Spring Security 使用 AOP 代理机制来应用方法级别的安全配置。
prePostEnabled和securedEnabled选项在方法级别启用相应的注解,以定义授权规则。例如,prePostEnabled将允许定义 Spring Security 的预/后注解@PreAuthorize和@PostAuthorize。这些注解允许基于表达式的访问控制,这意味着我们可以使用 Spring EL (表达式语言)来描述规则。
@PreAuthorize将在方法执行前评估表达式,而@PostAuthorize将在方法执行完成后验证表达式,并可能更改结果。@EnableGlobalMethodSecurity的securedEnabled选项用于定义@Secured注解。使用@Secured注解,我们可以在方法上指定一系列角色。让我们看看这个注解的一些示例,如下:
@Secured("ROLE_USER")
public String getUserAccnt() {
....
}
@Secured({ "ROLE_USER", "ROLE_ADMIN" })
public String getCompanyPolicy() {
....
}
getUserAccnt方法将由具有ROLE_USER角色的用户访问。getCompanyPolicy方法将由至少具有ROLE_USER和ROLE_ADMIN中一个角色的用户调用。@Secured注解不允许使用 Spring EL 定义表达式。另一方面,@PreAuthorize和@PostAuthorize注解允许使用 Spring EL 定义表达式,这意味着我们可以定义更复杂的条件。让我们看看@PreAuthorize的一些示例,如下:
@PreAuthorize("hasAuthority('ADMIN') and #oauth2.hasScope('read')")
public String adminResource(Principal user) {
...
}
@PreAuthorize("hasAuthority('USER') and #oauth2.hasScope('read') or (!#oauth2.isOAuth() and hasAuthority('USER'))")
public String userResource(Principal user) {
...
}
@PreAuthorize("hasAuthority('ADMIN') or #oauth2.hasScope('trust') or (!#oauth2.isOAuth() and hasAuthority('ADMIN'))")
public ResponseEntity<List<SampleUser>> listAllSampleUsers() {
...
}
第一个方法(adminResource)对具有USER角色的用户是可访问的,客户端应具有read作用域。让我们回忆一下,对于我们在授权服务器中配置的客户端,我们设置了三个作用域:读取、写入和信任。#oauth2变量是默认提供的,用于检查客户端具有的各种作用域。我们可以利用#oauth2变量的其他方法,如clientHasRole()、clientHasAnyRole()、hasAnyScope()、isOAuth()、isUser()、isClient()等。它们在OAuth2SecurityExpressionMethods类中定义。简而言之,变量#oauth2代表这个类的对象。
第二个方法(userResource)稍微复杂一些,可以在以下条件下访问:
-
用户具有
USER角色且客户端具有read作用域 -
请求不是 OAuth 类型(可能由机器客户端发起)且用户具有
USER角色
第三个方法(listAllSampleUsers)与第二个方法类似,可以在以下情况下访问:
-
用户具有
ADMIN角色 -
客户端具有
trust作用域 -
请求不是 OAuth 类型(可能由机器客户端发起)且用户具有
ADMIN角色
这就是如何使用自定义授权服务器实现隐式授权类型。在使用隐式授权类型时,需要注意某些事项。由于它是为 JavaScript 应用程序设计的,因此授权服务器和资源服务器都应该是安全可访问的(使用 HTTPS)。第二件事是,在隐式授权类型中,访问令牌直接由授权服务器返回给浏览器,而不是在受信任的后端;强烈建议配置短期访问令牌,以减轻访问令牌泄露的风险。
在隐式流中,另一个挑战是它不允许刷新令牌。这意味着在短期令牌过期后,用户应该被提示再次启动流程;或者,可能更好的方法是设置一个机制,如iframe,以无中断地获取新令牌。
作为练习,你可以在授权服务器中创建一些具有不同角色的更多用户,并配置资源方法,检查它们的可访问性。
摘要
安全性是任何系统的一个基本组成部分。其有效性取决于各种方面,如简单性、功能丰富性、与其他系统的集成容易度、灵活性、健壮性等。整个章节都是基于 Spring Security 的。它是一个功能齐全的框架,用于保护基于 J2EE 的应用程序。
在本章中,我们更深入地探讨了 Spring Security,特别是它如何与 LDAP 和 OAuth 集成。我们从 LDAP 的基础知识开始,包括其数据结构和配置;我们在 Apache DS 中创建了结构,它是一个 LDAP 服务器。然后,我们探讨了与 Spring Security 集成的所需配置。
除了与 LDAP 进行身份验证外,我们还探讨了如何从 Spring 应用程序中管理 LDAP 中的用户。我们使用了 Spring Data 框架来实现这一点。接下来,我们在 LDAP 中创建了一个角色(权限)的结构。按照相同的顺序,我们获取了角色详情,并在 Spring Security 中实现了基于 LDAP 的授权。
在本章的后面部分,我们开始介绍另一种机制,称为 OAuth。它是一个基于令牌的授权的开放标准。我们从 OAuth 角色的基础知识开始,然后探讨了各种授权类型的细节;你还学习了在何时使用哪种授权。进一步地,我们开始介绍 Spring Security 与 OAuth 的集成。我们使用 Google 实现了 Spring Security 的授权代码流。
使用默认的 OAuth 实现,Spring Security 会显示自动生成的登录页面。我们展示了如何在 OAuth 中实现自定义登录页面。到那时,你只看到了两种不同的机制,LDAP 和 OAuth,独立使用。我们将它们集成在一起,创建了双重认证。
然后,我们实现了基于授权代码流的 OAuth。接下来,我们展示了如何使用自定义授权和资源服务器实现隐式流。我们对授权和资源服务器进行了一系列配置,并成功实现了隐式流。在最后,我们在 Spring Security 中应用了方法级授权。
在下一章中,我们将探索另一个工具,称为 JHipster。它是一个开源的应用程序生成框架,主要用于使用响应式 Web 前端(Angular 或 React)和 Spring 框架作为后端来开发 Web 应用程序和微服务。
第五章:使用 JHipster 查看国家和它们的 GDP 的应用程序
随着时间的推移,不断变化的企业功能要求交付团队以快速的速度交付高质量的软件产品。为了满足这一期望,IT 行业已经专注于使软件开发流程更加流畅和自动化。因此,许多新的平台正在涌现,旨在在极短的时间内生成准备就绪的应用程序代码。
我们从开发的一个简单应用程序开始,该应用程序使用 Spring 框架和世界银行 API 展示了各国国内生产总值(GDP)信息,如第一章中所述,创建一个列出世界各国及其 GDP 的应用程序。Spring 框架提供了一种简单的方法来轻松开发企业级应用程序。
随着 Spring Boot 框架的诞生,使用 Spring 框架的开发变得比以往任何时候都要快和智能。在随后的章节中,我们转向了 Spring Boot 并探讨了其功能,特别是与其他 Spring 和第三方库和模块的集成。
在本章中,我们将探讨另一个名为 JHipster 的框架,它只需点击几下就能在 Spring 基础上创建应用程序,使 Spring 开发变得愉快。我们将利用 JHipster 从第一章,创建一个列出世界各国及其 GDP 的应用程序开始开发,展示如何展示各国 GDP 信息以及开发流程是如何被简化和自动化的。在本章中,我们将涵盖以下有趣的主题:
-
介绍 JHipster
-
安装
-
应用程序创建
-
实体的建模和创建
-
创建 GDP 应用程序
-
学习如何在 JHipster 应用程序中添加自定义功能
-
JHipster 的其他功能
技术要求
本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Spring-5.0-Projects/tree/master/chapter05。代码可以在任何操作系统上执行,尽管它只在 Windows 上进行了测试。
介绍 JHipster
简而言之,JHipster 是一个代码生成工具,它建立在大量的开发、构建、测试和部署框架和平台之上。它是一个现代的 Web 应用程序开发平台,用于构建基于 Java 的全面 Web 应用程序的所有层,从前端到数据库。JHipster 在底层支持各种框架,为用户提供了在开始应用程序开发时进行选择的机会。
JHipster 是一个免费的开源平台,旨在极大地简化在 Spring Framework 和 Angular 或 React 技术上生成、开发和部署单体和微服务应用程序的过程。在 JHipster 中构建应用程序之前,用户将被询问各种问题,以便根据用户选择的选项生成一个生产就绪的应用程序。JHipster 为应用程序提供以下工具和框架的支持,无需额外安装:
-
构建工具: Maven, Gradle
-
开发平台: Spring Framework
-
安全框架: Spring Security
-
模板化: Thymeleaf
-
微服务: Netflix OSS
-
关系型数据库管理系统(RDBMS): H2, MySQL, Oracle, PostgreSQL, MS SQL, MariaDB
-
数据流处理: Kafka
-
数据库跟踪器: Liquibase
-
NoSQL: MonboDB, Cassandra, Couchbase, Hazelcast
-
缓存实现: Infinispan, Ehcache
-
搜索引擎: Elasticsearch 和 Elasticsearch, Logstash, and Kibana 堆栈(ELK)
-
监控: Prometheus
-
对象关系映射(ORM): Hibernate
-
测试框架: Cucumber, Browsersync, Jest, Protractor 测试
-
负载测试: Gatling
-
用户界面: Bootstrap, HTML5, CSS3, SaaS, Redux
-
JavaScript 框架: Angular, TypeScript, React, Webpack
-
部署: Docker, Kubernetes, Boxfuse, Rancher
-
云支持: Heroku, Cloud Foundry, AWS, OpenShift
-
CI/CD: Jenkins, Travis CI, GitLab CI, CircleCI
JHipster 生成的代码符合行业标准、最佳实践和质量规范。除了自动生成应用程序代码外,JHipster 还支持更流畅的应用程序自动化测试和持续集成与交付。这对组织来说可以带来巨大的好处,如下所示:
-
以统一和受控的方式在各种平台和框架上创建应用程序。
-
大部分样板代码都是自动生成的,因此开发者可以专注于业务需求的实现。这将提高开发者的生产效率,并大大缩短整体项目交付时间表。
-
从前端到数据库表的整个应用程序中轻松集成更改。
-
应用程序的整体代码质量得到提升。
-
组织中的不同项目可以轻松共享通用工件。因此,项目团队的整体生产率将得到提高。
安装 JHipster
JHipster 推荐普通用户使用 npm 进行安装。npm 是来自 Node.js 的包管理器,用于安装各种软件。它是世界上最大的软件仓库,在这里你可以找到成千上万的开源软件包。如果未安装 npm,只需访问 Node 网站的下载部分(nodejs.org/en/download)并安装最新的 64 位 长期支持(LTS)版本,因为 JHipster 不支持非 LTS 版本。
一旦安装了npm,运行以下命令从命令行安装 JHipster 包:
npm install -g generator-jhipster
JHipster 使用另一个名为Yeoman (yeoman.io/)的工具来生成应用程序代码,该工具与 JHipster Node 包一起安装。创建应用程序后,JHipster 提供了一个选项,可以使用 Maven 或 Gradle 构建它。为此,JHipster 将安装 Maven 和 Gradle 所需的包装器,因此不需要明确安装,特别是对于构建应用程序。
JHipster 也可以使用 Yarn 进行本地安装,Yarn 是另一种用于安装软件的包管理器。使用 Yarn 安装 JHipster 的过程几乎与npm相同。
创建应用程序
在安装 JHipster 之后,下一步是创建一个应用程序。在您的本地机器中创建一个具有适当名称的项目目录,从命令提示符中选择此目录,并执行以下命令。项目目录的名称为gdp,但可以是任何有效的名称:
jhipster
在运行此命令后不久,JHipster 将开始提出一系列问题,并根据用户给出的答案决定需要生成的内容,如下所示:
-
您想创建哪种类型的应用程序? 有四种可能的选择,如下所示:
-
单体应用程序:此选项用于创建一个自包含的应用程序。这是创建简单应用程序的推荐选项,因此我们将选择此选项。
-
微服务应用程序:如果您想基于微服务架构设计应用程序,您可以选择此选项。
-
微服务网关:微服务网关用于构建具有 UI 的基于微服务应用程序。默认情况下,微服务应用程序没有 UI。
-
JHipster UAA 服务器:JHipster 支持创建带有用户身份验证和授权 (UAA) 的应用程序。
-
-
您的应用程序的基本名称是什么? 您需要给您的应用程序起一个名字。默认情况下,它采用与项目目录相同的名称。如果您愿意,可以给它另一个名称。
-
您的默认 Java 包名是什么? 接下来,您需要提供一个 Java 包名。您可以提供一个合适的名称(它将被视为基本包,并且所有其他 Java 源文件都将相对于此包生成)。
-
您想使用 JHipster Registry 来配置、监控和扩展您的应用程序吗? 这个问题涉及到在我们的应用程序中使用 JHipster Registry。该注册器在基于微服务应用程序中广泛使用,用于注册各种服务。对于单体应用程序,我们仍然可以使用它,因为尽管它是一种注册器,但它关注应用程序的健康状况,这有助于我们监控应用程序。它以
Docker镜像的形式提供。为了简单起见,我们不会使用它,所以选择“否”并继续。 -
您想使用哪种身份验证类型? 接下来是身份验证机制。它提供了三个选项供您选择,如下所示。我们将选择第三个选项(HTTP 会话身份验证):
-
JWT 身份验证: JSON Web Token(JWT),是一种用于在两个实体之间以 JSON 形式传输信息的开放标准。身份验证是 JWT 最常见的使用场景。
-
OAuth2/OIDC 身份验证: JHipster 为 Keycloak 和OpenID Connect(OIDC)提供了完整的 OAuth2 支持,当我们选择此选项时,它将默认生成。Keycloak 是一个开源的身份代理和访问管理解决方案。OpenID Connect(OIDC)是 OAuth2 协议之上的简单身份层。
-
HTTP 会话身份验证: 这通过会话来验证用户。这是最常用的选项。
-
-
您想使用哪种数据库? 接下来,它会询问我们希望在应用程序中使用哪种类型的数据库。JHipster 支持各种 SQL 数据库。它还支持三种 NoSQL 数据库——MongoDB、Couchbase 和 Cassandra,后者具有 Spring 数据后端。我们将选择 SQL。
-
您想使用哪种生产/开发数据库? 您将被询问分别选择特定数据库用于生产和开发。JHipster 为各种环境(如开发、生产等)维护了各种配置文件。它将根据您的选择配置数据库。在我们的案例中,我们将选择 MySQL 用于生产和开发。
-
您想使用 Spring 缓存抽象吗? 进一步操作时,它会询问您想使用的缓存机制类型,例如 Ehcache、Hazelcast、Memcached 或者完全不使用缓存;Spring 缓存抽象将用于连接任何一种。我们可以根据特定的业务需求和底层硬件架构(单节点、多节点、分布式等)选择任何一种。我们将选择 Ehcache(默认选择)。
-
您想使用 Hibernate 二级缓存吗? 在这里,我们有使用 Hibernate 二级缓存的选项。选择是此选项。
-
您想使用 Maven 还是 Gradle 来构建后端? 您将被要求选择 Maven 或 Gradle 作为构建工具。我们将选择 Maven。
-
您想使用哪些其他技术? 在最后,JHipster 将询问添加一些额外的技术,例如 Elasticsearch、WebSocket、使用 Kafka 的异步消息传递以及使用 OpenAPI 生成器的 API-first 开发。API-first 是一种首先设计 API 的应用程序设计方法,然后在这些 API 之上开发 Web 或移动应用程序。如今,许多公司都在采用这种方法,而 JHipster 默认支持它。为了使事情简单直接,我们不会选择它们中的任何一个。由于这是一个多选选择器,您只需按Enter键继续,而不选择任何选项。
-
您想为客户端使用哪个框架? 下一个问题将询问您选择一个前端框架,即 Angular 或 React。选择 Angular 并按Enter键。
-
您想启用 Sass 样式表预处理程序吗? 接下来,它将询问您是否要使用语法上令人惊叹的样式表(*Sass)样式表预处理程序。选择“是”。
-
您想启用国际化支持吗? 如果您想添加国际化支持,请选择一种本地语言。选择英语作为答案。
-
请选择要安装的附加语言: 除了您的母语外,您还可以添加对其他语言的支持。JHipster 支持大约 30 种语言。为了简化流程,我们不会添加任何附加语言。
-
除了 JUnit 和 Jest,您还想使用哪些测试框架? 在此屏幕上,您将被要求选择单元测试框架。JHipster 支持 Gatling、Cucumber 和 Protractor 框架,以及默认的 JUnit 进行单元测试。选择它们中的任何一个,然后继续下一步。
-
您想从 JHipster 市场安装其他生成器吗? 最后一个问题将询问您是否要从 JHipster 市场添加额外的模块。这是一个第三方生成器的集合,它们在 JHipster 核心之上工作,可以访问其变量和函数,并充当子生成器。您可以通过从 JHipster 市场下载它们来在您的应用程序中使用它们(
www.jhipster.tech/modules/marketplace)。我们将为这个选项选择“否”。
项目结构
现在,请放松并休息,JHipster 将开始根据我们选择的选项创建应用程序。在这个时候,JHipster 将生成我们应用程序的代码和项目结构。简而言之,JHipster 生成以下内容以使应用程序准备好运行:
-
Spring Boot 应用程序
-
Angular JS 应用程序(在前端)
-
Liquibase 变更日志文件(用于数据库表数据定义语言(DDL)操作)
-
其他配置文件
一旦应用程序创建完成,我们可以配置一个集成开发环境(IDE)以进行进一步的开发。JHipster 支持广泛的 IDE,包括 Eclipse、IntelliJ IDEA 和 Visual Studio Code。您可以在www.jhipster.tech/configuring-ide了解更多关于这个主题的信息。应用程序的结构如下所示:

让我们依次查看每个 Java 包,如下所示:
-
com.nilangpatel.aop.logging: 这包含了面向切面编程(AOP)的日志建议。 -
com.nilangpatel.config: 这个包包含了属性、缓存、数据库、配置文件、Liquibase、日志、Spring Security、度量、Web、区域设置等各种配置,以及跨应用程序使用的常量。 -
com.nilangpatel.config.audit: JHipster 提供了开箱即用的审计功能。这个包包含了专门用于审计的配置。 -
com.nilangpatel.domain: 这包含了我们所创建的自定义实体的所有模型对象,以及其他核心模型对象。 -
com.nilangpatel.domain.enumeration: 这包含了我们在JHipster 域语言(JDL)中声明的枚举。我们将在下一节中进一步讨论 JDL。 -
com.nilangpatel.repository: 每个自定义和开箱即用的实体的 Spring Data Java 持久性 API(JPA)存储库都存储在这里。 -
com.nilangpatel.security: 所有与安全相关的类,例如Roles常量、UserDetail服务等,都存储在这个包中。 -
com.nilangpatel.service: 这包含了服务层的接口,用于开箱即用和自定义实体。 -
com.nilangpatel.service.dto: 用于在控制器和服务之间传输的数据传输对象(DTOs)保存在这里。 -
com.nilangpatel.service.mapper: 用于将模型对象映射到 DTOs 的映射器类将存储在这个包中。 -
com.nilangpatel.service.util: 这个包包含了一些实用类。 -
com.nilangpatel.web.rest: 每个实体的所有表示状态转移(REST)控制器都生成在这个包下。 -
com.nilangpatel.web.rest.error: 这里提供了针对 REST 调用的特定异常。 -
com.nilangpatel.web.rest.util: 这个包包含了一些在 REST 调用中使用的实用类。 -
com.nilangpatel.web.rest.vm: 这包含了视图模型,主要用于 UI 中的管理标签页。
除了 Java 类和包之外,JHipster 还在src/main/resource文件夹中生成某些资源。具体如下:
-
config: 这包含了各种配置文件,例如用于 Spring Boot 的application.properties文件,包含各种配置文件,一些 Liquibase 配置文件,以及用于导入和配置 HTTPS 配置证书的changelog文件和 keystore 文件。 -
i18: 这包含我们在应用程序创建期间选择的多种语言的属性文件。 -
templates: 这个文件夹包含各种邮件模板,如激活、账户创建和密码重置,以及错误模板。
是时候运行应用程序了。JHipster 提供了以下命令来使用 Maven 构建应用程序。确保你在命令提示符中的项目目录:
mvnw
除了构建应用程序,此命令还会将其部署到嵌入式网络服务器(默认情况下与 Spring Boot 一起提供)。它可以通过http://localhost:8080访问,如下所示:

如果一个应用程序需要在任何应用服务器上部署,JHipster 提供了一种生成可执行 WAR 文件的方法,对于 Maven 使用命令mvnw -Pprod package,对于 Gradle 使用gradlew -Pprod bootWar。
JHipster 生成一组页面和一些用户账户以开始。点击“账户”|“登录”以登录到应用程序。默认情况下,“管理员”用户可以使用凭证admin/admin登录,普通用户可以使用user/user登录。管理员用户可以访问“管理”菜单,从那里可以执行各种管理功能。
实体创建
一个网络应用程序有一些数据库交互,至少包括基本的创建、读取、更新和删除(CRUD)操作。如果手动完成,则需要大量的努力。在这种情况下,需要完成以下任务:
-
创建数据库表,以及它们的关联和约束
-
构建模型实体和构建数据访问对象(DAO)层以提供与数据库的数据接口
-
生成一个服务层来封装业务逻辑
-
准备网络控制器和前端层,包括所有验证,以将数据存储在相应的实体表中
除了这个之外,可能还需要额外的努力来适应任何层的未来变化。JHipster 为此问题提供了一个巧妙的解决方案。在创建应用程序后,我们需要构建一个数据访问层,JHipster 使整个过程自动化。
JHipster 中一个称为实体生成的概念使这一切成为可能。实体是 JHipster 应用程序的构建元素。实体生成过程包括以下各种任务:
-
创建数据库表并维护其更改(通过配置)
-
构建一个 JPA 模型类,以及一个 Spring Data JPA 仓库
-
创建一个可选的服务层以适应业务规则
-
创建支持基本 CRUD 操作和前端 Angular 路由的 REST 控制器
-
组件和服务以及 HTML 视图,包括集成和性能测试
这不酷吗?让我们见证创建实体和自动生成代码的过程。
使用 CLI 添加实体
为了演示在 JHipster 中创建实体的过程,我们首先创建一个简单的实体,称为Owner,它有一个属性,称为name。JHipster 允许创建实体以及数据访问、服务层、控制器和该实体的前端层的方式与我们在上一节中看到的生成应用程序代码的过程相同。两者都可以使用 CLI 完成。
对于实体生成,JHipster 内部使用 Yeoman 工具生成代码。让我们创建我们的第一个实体。执行以下命令以创建实体:
jhipster entity Owner
Owner 是一个实体的名称。此命令将为 Owner 创建一个实体,并将启动一个向用户提出几个问题的向导,如下所示:
-
你想要在你的实体中添加一个字段吗? 如果你希望为你的实体添加一个字段,请选择 y。
-
你的字段名称是什么? 你可以在这里给出属性名称。
-
你的字段类型是什么? 你需要提供属性的类型。JHipster 支持各种属性类型,包括
string、integer、long、float、double、BigDecimal和LocalDate。 -
你想要在你的字段中添加验证规则吗? 这关系到你是否希望在实体的属性上添加任何约束。选择 y。
-
你想要添加哪些验证规则? JHipster 还允许你添加各种约束,包括
required、unique、min值、max值和正则表达式模式,以验证输入。你可以选择多个约束。
在添加属性的前一个过程中,可以重复此过程以添加更多属性到其类型和约束。我们将创建具有 name 属性的 Owner 实体,该属性为 String 类型,并带有 required 约束。
JHipster 还允许你定义与另一个实体的关系。一旦我们完成添加属性,它将要求我们添加一个关系。由于我们只创建了 Owner 实体,我们将在添加另一个实体后添加关系。我们将在稍后看到如何添加关系。
目前,只是说“不”(n)添加关系,JHipster 将显示与服务和控制器层相关的下一组问题,如下所示:
-
你想要为你的业务逻辑使用一个单独的服务类吗? 在这个问题中,我们被问及是否希望添加服务层,可能的选项如下。我们将选择第三个选项:
-
不,REST 控制器应该直接使用存储库;REST 控制器将直接调用存储库。不需要添加服务层。
-
是的,生成一个单独的服务类;服务层仅通过服务类添加。REST 控制器将调用此类进行任何数据库交互。我们可以在服务类中编写额外的业务逻辑。
-
是,生成单独的服务接口和实现;在这种情况下,服务层添加了接口和实现。这种设计的明显优势是我们可以在不更改其他代码的情况下提供服务接口的另一个实现。
-
-
您想使用 DTO 吗? 下一个问题与 DTO 相关。JHipster 提供了一个为每个实体创建 DTO 的选项。它使用 MapStruct,另一个代码生成工具,用于将 Java 实体映射到生成 DTO。基本上,它用于将 DTO 的值映射到模型实体,反之亦然。此问题的选项如下。我们将选择第二个选项:
-
不,直接使用实体;实体对象用于在所有层之间传递数据。
-
是,使用 MapStruct 生成 DTO;这将为每个实体生成相应的 DTO。控制器将创建 DTO 的实例并将其传递到服务层。服务类将 DTO 映射到实体并调用存储库与数据库交互。
-
-
您想添加过滤功能吗? 这将提供动态过滤选项以搜索特定实体。它使用 JPA 静态元模型进行过滤选项。如果我们选择是,JHipster 将为从表示层到 DAO 的完整代码创建代码。尽管过滤选项非常有用,但为了简单起见,我们将选择否。
-
您想在实体上使用分页吗? 下一个问题涉及到分页模式。JHipster 支持以下分页模式。我们将选择第二个选项:
-
不;这意味着没有分页。所有记录将显示在单个页面上。这将为大数据集创建性能问题。
-
是,使用分页链接;这显示了带有跳转页面的分页链接。这是最常见的分页样式。
-
是,使用无限滚动;这使用无限滚动来显示数据。滚动将起到分页的作用。
-
现在,JHipster 将开始创建实体,并在发现冲突时要求覆盖某些文件。这是因为 JHipster 将开始再次生成代码。对于所有提示,请继续说“是”(y)并按Enter键,最后,您将看到一个消息说实体已创建。接下来,让我们创建另一个实体,称为Car,具有name、model和manufacture year属性。按照之前的步骤创建Car实体。
JHipster 提供了一个在创建实体时建立关系的选项。因此,如果您刚刚添加了一个实体并尝试与另一个实体建立关系,您将收到一个错误,例如说另一个实体未找到。所以,在与其他实体建立关系时,请确保它已经创建。
在第 5 步之后,它将询问添加关系。我们已添加了一个Owner实体,并想建立多对一的关系(多个Car可以与一个Owner相关联)。以下是在第 5 步之后将具体询问关系的几个问题:
-
您想添加另一个实体的关系吗? 在这里选择 Y。
-
其他实体的名称是什么? 这指的是我们想要建立关系的实体的名称。在这里,请将实体的名称作为
Owner给出。 -
关系的名称是什么? 默认为
owner(这是您想要给出的关系名称。默认情况下,系统将给出另一侧实体名称的小写形式。如果您愿意,您可以更改它)。 -
关系的类型是什么? 可能的选项有一对多、多对一、多对多和一对一。它们相当直接。我们将选择多对一,因为我们正在与
Car实体建立关系。 -
当您在客户端显示此关系时,您想使用
Owner中的哪个字段? 这个问题询问在显示或添加Car数据时,是否应该显示Owner实体的列名。内部上,JHipster 始终使用 ID 列来设置表之间的关系。将name作为此问题的答案,因为Owner只有一个列(name)。 -
您想为此关系添加任何验证规则吗? 这基本上是对外键列添加验证。
-
您想添加哪些验证规则? 必须进行可能的验证。
在此之后,它将开始从第 6 步提问。完成第 9 步以添加Car实体。此时,我们有两个实体——Owner和Car——它们之间存在关系,以及前端、控制器、服务层和 DAO 层的源代码。
现在,是时候构建我们的应用程序了。**mvnw** Maven 命令不仅会构建应用程序,还会在嵌入式服务器上部署和运行它。在生成实体后,当我们使用此命令构建和部署应用程序时,JHipster 将为每个实体创建/更新相应的数据库表。
在构建应用程序之前,请确保您已在application-prod.yml文件中设置数据库凭据,位于src/main/resources/config文件夹中,按照您本地的 MySQL 配置。属性名称为spring:datasource:username和spring:datasource:password. 如果没有这些设置,运行应用程序时将会出现错误。
让我们为我们的实体添加一些数据。使用管理员凭据(admin/admin)登录,然后转到实体 | 拥有者,首先添加拥有者数据。将使用创建新拥有者按钮来插入拥有者记录。同样,我们也可以为 Car 实体添加数据。由于我们已经从 Car 到 Owner 创建了一个多对一的关系(即许多 Car 实例与一个 Owner 相关联),你将看到一个字段,可以在添加 Car 实体时选择 Owner 值。
Car 实体的记录,以及指向 Owner 实体的引用,将如下所示:

Owner 实体的 name 属性值在此处作为引用可见,这是我们创建关系时选择的。此页面还显示了链接类型的分页,这是我们创建 Car 实体时默认选择的。除此之外,你可以为每个单独的实体执行 CRUD 操作,而无需自己编写任何代码。这绝对是一个节省大量开发时间和精力的酷特性。
默认情况下,JHipster 为每个实体表创建一个 ID 列作为主键。对于代码自动生成,不支持将自定义列定义为主键。然而,如果特定的列需要作为主键,你需要在运行 mvnw 命令之前修改生成的源代码。
实体建模
你已经看到了 JHipster 如何通过自动化许多事情来加速开发。在应用程序中建模一个实体以前需要许多活动,包括表生成;创建 DAO、服务和表示层;以及每个实体的验证和用户界面。
虽然 Spring Boot 在编写样板代码方面提供了很大的帮助,但开发者仍然需要编写大量代码才能看到一些效果。这是一项相当繁琐且逻辑上重复的工作。你已经看到了 JHipster 如何在这个场景中提供帮助,通过自动生成代码来构建一个完全功能的 Spring Boot 应用程序,而无需你自己编写任何代码。
使用完整工作代码设计实体只是向 JHipster 提供某些信息的问题。乍一看,这似乎很棒,但硬币的另一面也有问题。考虑一下这样一个场景,你需要使用 JHipster CLI 集成超过五六十个实体,这在编写企业应用程序时是完全可能的。有时,实体的总数会超过一百个。
在这种情况下,使用 CLI 编写每个实体并提供所有元数据,以及与其他实体的关系,是痛苦的。作为解决方案,JHipster 提供了一个图形工具,我们可以一次性设计所有实体。目的是通过可视化工具简化定义关系的流程,而不是通过经典的方式,即问答。有两种选项可以用于可视化建模实体,如下所示:
-
使用 统一建模语言(UML)进行建模
-
使用 JDL 进行建模
使用 UML 进行建模
在此选项中,我们需要将所有实体设计为类图,然后将其导入 JHipster 以一次性生成所有实体的代码。因此,整个过程分为两个独立工作的部分,如下所示:
-
使用可视化工具设计实体的类图
-
导出类图并将其导入 JHipster
在应用程序开发的早期阶段,类图主要用于设计领域模型。通过展示类的属性和操作,以及与其他类的关系,类图描述了应用程序的静态视图。类图中所用的类直接映射到面向对象的语言,并用于建模数据库表。
JHipster 在生成应用程序代码的过程中提供了这一过程的便利。已经设计了一个名为 JHipster UML 的独立工具;它读取类图以生成实体结构。可以从 Git 仓库安装,或作为单独的 npm 包,以下命令:
//For Global installation
npm install -g jhipster-uml
//For local installation
npm install jhipster-uml --dev
大多数今天可用的工具都允许将类图导出为 XMI 格式。JHipster UML 读取 XMI 文件并生成实体。由于此工具从类图中生成 JHipster 的实体,因此属性类型的选择仅限于 JHipster 支持的类型列表。以下列出了 JHipster 支持的属性类型,以及每个属性类型的可能验证规则:
| No. | Attribute type | Possible validations |
|---|---|---|
| 1 | string |
required, minlength, maxlength, pattern |
| 2 | integer |
required, min, max |
| 3 | long |
required, min, max |
| 4 | BigDecimal |
required, min, max |
| 5 | float |
required, min, max |
| 6 | double |
required, min, max |
| 7 | enum |
required |
| 8 | Boolean |
required |
| 9 | LocalDate |
required |
| 10 | ZonedDateTime |
required |
| 11 | blob |
required, minbytes, maxbytes |
| 12 | AnyBlob |
required, minbytes, maxbytes |
| 13 | ImageBlob |
required, minbytes, maxbytes |
| 14 | TextBlob |
required, minbytes, maxbytes |
首先,我们需要为每个领域模型设计类图,以及它们之间的关系。JHipster 推荐使用以下工具生成类图:
-
Modelio
-
UML Designer
-
GenMyModel
在这些工具中,前两个是完全开源的基于 Eclipse 的图形工具,可以从各自的网站下载,而第三个是基于浏览器的免费工具,可以直接在网络上使用(存在某些限制)。一旦类图准备就绪,将其导出为 XMI 文件,然后在命令提示符中执行以下命令以生成实体结构。确保在执行此命令时位于项目目录中:
jhipster-uml <class-diagram.xmi>
这将生成实体结构。JHipster UML 还提供了各种选项来指定分页模式,例如是否要使用 DTO 或为每个实体添加服务类。这些选项可以与之前的命令一起使用,如下所示:
// If you wish to use DTO. The possible values would be MapStruct
jhipster-uml <class-diagram.xmi> --dto <value>
//Type of pagination pattern.The possible values are [pager,pagination,infinite-scroll]
jhipster-uml <class-diagram.xmi> --paginate <value>
//If you need to add service layer with class and implementation. The values would be [serviceClass,serviceImpl]
jhipster-uml <class-diagram.xmi> --service <value>
根据你提供的选项,JHipster UML 生成实体和其他源代码。最后,你需要执行mvnw命令,这样它就会在数据库中创建/修改所需的实体表,以及 Liquibase 变更日志文件,并将应用程序部署到服务器。在定义类图中的类之间的关系时,你需要确保它们在 JHipster 中是被允许的。支持的关系如下:
-
双向一对多关系
-
单向多对一关系
-
多对多关系
-
双向一对一关系
-
单向一对一关系
默认情况下,JHipster 代码生成器不支持单向一对多关系。JHipster 建议使用双向一对多关系。
使用 JHipster 领域语言(JDL)建模
将领域模型设计为类图,然后基于该图生成源代码是 JHipster 中创建实体的一种快速方法。因此,与逐个使用 CLI 创建它们相比,它可以节省时间。然而,你仍然需要依赖第三方应用程序来使用 JHipster。有可能 JHipster 对特定版本的支持非常有限,或者,在最坏的情况下,完全不兼容。
作为解决方案,JHipster 提供了一个名为JDL studio的独立工具。它是一个在线工具,用于创建实体并在它们之间建立关系。使用 JDL studio 的明显优势是它由 JHipster 团队设计和维护,因此几乎不存在版本不兼容和其他问题。你可以使用稳定版本时充满信心。如果出现任何问题,你可以轻松地从官方 JHipster 问题跟踪器获取更新或支持。
使用 JDL studio 创建实体比使用 UML 建模实体更加简单。JHipster 领域语言(JDL)是一种用于在单个文件(有时是多个文件)中构建实体的领域语言,语法简单且易于使用。
与 JDL 一起工作有两种方式。你可以使用 JHipster IDE 或在线 JDL-Studio(start.jhipster.tech/jdl-studio)。JHipster IDE 是针对知名 IDE 的插件或扩展,包括 Eclipse、Visual Studio 和 Atom。在线 JDL-Studio 是一个基于浏览器的 IDE,你可以用它以脚本形式构建实体及其关系,这些脚本是用 JDL 编写的。你可以将其与编写创建数据库表及其关系的 SQL 脚本联系起来。
为了简化,我们将通过在线 JDL-Studio 创建实体的简单示例。在为每个实体编写定义时,JDL-Studio 会同时绘制实体图及其关系。当打开在线 JDL-Studio 时,你将默认看到一些示例实体,包括它们的关系和其他参数,以给你一些如何开始使用它的想法。
让我们在在线 JDL-Studio 中创建School和Teacher实体,以及它们之间的关系(一对一)。打开 URL 并添加这些实体的定义,如下所示:
entity School {
name String required
eduType EducationType required
noOfRooms Integer required min(5) max(99)
}
enum EducationType {
PRIMARY, SECONDARY, HIGHER_SECONDARY
}
entity Teacher {
name String required
age Integer min(21) max(58)
}
// defining multiple one-to-many relationships with comments
relationship OneToMany {
School{teacher} to Teacher{school(name) required}
}
// Set pagination options
paginate School with infinite-scroll
paginate Teacher with pagination
// Use data transfer objects (DTO)
dto * with mapstruct
// In case if DTO is not required for specific (comma separated)entities.
// dto * with mapstruct except School
// Set service options to all except few
service all with serviceImpl
// In case if service layer is not required for certain
// (comma separated) entities. Just uncomment below line
// service all with serviceImpl except School
每个实体都可以用entity关键字定义,包括其属性和数据类型。我们还可以为每个属性定义某些验证。这些验证不仅对数据库表级别施加相应的约束,而且在前端侧也施加约束。maxlength验证表示给定属性的列最大长度。min和max验证描述了要输入的最小和最大值。实体之间的关系可以用以下语法定义:
relationship (OneToMany | ManyToOne | OneToOne | ManyToMany) {
<OWNER entity>[{<Relationship name>[(<Display field>)]}] to <DESTINATION entity>[{<Relationship name>[(<Display field>)]}]
}
relationship可以使用以下选项进行使用:
-
(一对一 | 多对一 | 一对一 | 多对多): 关系的可能类型。 -
OWNER 实体: 关系的拥有者实体。也可以将其描述为关系的来源。拥有方实体必须在左侧。 -
DESTINATION 实体: 这是关系结束的另一端实体,即目标实体。 -
关系名称: 这是表示另一端类型的字段名称。 -
显示字段: 在为实体添加记录时,JHipster 会在屏幕上显示另一端实体下拉菜单。此属性显示将在下拉菜单中显示的另一端实体的字段名。默认情况下,它是另一端实体的 ID(主键)。 -
required: 这确定是否必须在下拉菜单中选择另一端实体。
paginate、dto和service关键字分别用于定义分页模式的配置选项、是否需要生成 DTO 以及是否需要生成带有实现的服务层。它们相当直观,你可以在使用 CLI 创建实体时将其与相应的选项联系起来。JHipster 还支持使用*进行大量操作和排除选项(使用except关键字),这些功能非常强大且方便。简而言之,JDL-Studio 根据我们实体的定义生成以下图表:

在这个例子中,我们定义了一个双向关系。如果需要单向关系,你只需在两边删除名称或关系即可。例如,School和Teacher实体之间的单向关系可以定义如下:
relationship OneToMany {
School to Teacher
}
JHipster 不支持单向的一对多关系,但它的样子是这样的。在定义关系时,你需要意识到 JHipster 支持的关系,我们已经在上一节中讨论过。
除了生成实体代码,JDL 还用于从头创建应用程序,包括部署选项。因此,你不必使用基于 CLI 的问答方法,而可以在单个 JDL 文件中定义所有配置选项,并一次性创建一个应用程序。
使用模型生成实体
我们已经在 JDL studio 中定义了实体。现在,我们将指导 JHipster 生成实体,包括数据库表和源代码。这个过程涉及以下两个任务:
-
导出实体定义
-
导入 JDL 文件以生成所需的工件
从 JDL-Studio,你可以将定义导出为 JDL(.jh)文件。JHipster 提供了一个子生成器,用于导入 JDL 文件,以下命令:
jhipster import-jdl <your_jdl_file.jh>
不言而喻,你需要在这个命令下执行此命令。在成功构建和部署后,你将在“实体”菜单中看到School和Teacher实体。你也可以验证相应的表是否已生成。如果一个应用程序有大量实体,将它们全部放入单个 JDL 文件中是非常困难的。如果某个实体出现错误,整个生成实体的过程将无法正常工作。在最坏的情况下,如果多个团队正在工作,那么它将创建维护问题。
JHipster 通过允许使用多个 JDL 文件来解决此问题,以便相关实体可以分组到单独的 JDL 文件中。import-jdl子生成器允许导入由空格分隔的多个文件。在首次执行此命令时,它将生成实体和所有源代码。您需要使用mvnw命令构建和部署应用程序,以便必要的数据库更改得到反映。
第二次及以后,import-jdl将仅重新生成已更改的实体。如果您希望从头开始重新生成所有实体,则需要添加- force选项。请注意,此选项将删除应用于实体的所有自定义。在构建和部署应用程序时,某些验证会在使用mvnw命令时捕获,如下所示:
-
在列类型为
integer、long、BigDecimal、LocalDate、Boolean、enum、double等情况下,不允许使用maxlength和minlength验证。 -
如果对于某个实体,服务层被跳过,那么如果为该实体选择了带有
mapstruct的 DTO 选项,JHipster 会显示警告。在这种情况下,应用程序可能无法正常工作。 -
在添加单行注释时,需要在
//之后留一个空格,否则 JHipster 会显示错误,并且实体将无法正确生成。
展示国家国内生产总值
现在您已经了解了如何创建应用程序和建模实体,我们将开始使用 JHipster 创建一个显示各国 GDP 的应用程序。我们将这样做以展示 JHipster 在自动生成的代码中应用自定义的能力。
应用程序和实体创建
请参考创建应用程序部分来创建一个新的应用程序,命名为gdp。我们将构建一个与我们在第一章中创建的应用程序具有相似功能的应用程序,即使用 Spring 框架创建一个列出世界各国及其 GDP 的应用程序。为了展示如何显示各个国家的 GDP 数据,我们参考了 MySQL 数据库中的一个示例国家、城市和国家语言数据(dev.mysql.com/doc/index-other.html),并使用 REST 服务通过世界银行 API(datahelpdesk.worldbank.org/knowledgebase/articles/898614-aggregate-api-queries)获取特定国家的 GDP 数据。我们将使用相同的参考来构建一个使用 JHipster 的应用程序。
为了简化,我们将使用必要的列来完成应用程序的目的。首先理解表结构非常重要。数据库表及其关系细节如下:

让我们先定义实体。JHipster 推荐使用 JDL 进行实体和代码生成,因此我们将使用它来创建我们的实体结构并生成我们的服务层、REST 控制器和 DTO,以及前端层的一组组件。JDL 脚本如下所示:
entity Country{
code String required maxlength(3)
name String required maxlength(52)
continent Continent required
region String required maxlength(26)
surfaceArea Float required
population Integer required
lifeExpectancy Float
localName String required maxlength(45)
governmentForm String required maxlength(45)
}
entity City{
name String required maxlength(35)
district String required maxlength(20)
population Integer required
}
entity CountryLanguage{
language String required
isOfficial TrueFalse required
percentage Float required
}
enum Continent {
ASIA, EUROPE, NORTH_AMERICA, AFRICA, OCEANIA, ANTARCTICA, SOUTH_AMERICA
}
enum TrueFalse{
T, F
}
// Set pagination options
paginate Country, City, CountryLanguage with pagination
// Use data transfer objects (DTO)
dto * with mapstruct
// Set service options. Alternatively 'Service all with sericeImpl can be used
service all with serviceImpl
relationship OneToMany{
Country{city} to City {country(name) required}
Country{countryLanguage} to CountryLanguage{country(name) required}
}
filter Country
此脚本包含相应表的实体定义,以及用于Continent和TrueFalse的enum。我们还定义了分页模式、DTO 结构、服务层(使用Service类和接口serviceImpl),以及一种关系类型。Country将与City和CountryLanguage都有一对多关系。
在关系中的country(name),在另一边它将显示国家名称作为参考,而不是国家默认的ID。请特别注意最后一个选项——filter。这为Country实体声明了filter选项,用于在检索实体的记录时应用各种过滤条件。我们将在开发自定义屏幕部分更详细地探讨这一点。JDL 图应如下所示:

由于我们在 MySQL 提供的每个表中省略了一些列,因此还需要在相应表的插入脚本中进行必要的更改。你将在项目结构的download文件夹中找到修改后的插入脚本。在此时刻,你必须应用插入脚本才能继续前进。
在 JHipster 中使用数据库处理枚举数据
接下来,我们将运行应用程序并验证 JHipster 是否已创建三个实体,并且它们在实体菜单中可用以执行各种 CRUD 操作。第一次运行应用程序时,我们将得到一个错误,如下所示:
org.springframework.dao.InvalidDataAccessApiUsageException: Unknown name value [Asia] for enum class [com.nilangpatel.domain.enumeration.Continent]; nested exception is java.lang.IllegalArgumentException: Unknown name value [Asia] for enum class [com.nilangpatel.domain.enumeration.Continent] at org.springframework.orm.jpa.EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(EntityManagerFactoryUtils.java:367) ....
此错误发生在检索大陆数据并尝试将其与Continent枚举数据类型映射时。根本原因是我们在Country实体的continent列中定义了类型为Continent枚举。通过插入脚本(从 MySQL 网站)添加的该列的实际值与Continent枚举值并不完全相同。例如,数据库中的实际值是Asia,而相应的枚举是ASIA。
continent列的另一个值是North America,而相应的枚举是NORTH_AMERICA。由于 Java 中枚举的限制,我们无法在值中间放置空格,这就是我们保持值为NORTH_AMERICA、SOUTH_AMERICA等等的原因。由于这种限制,加上大小写差异,你将在运行应用程序时得到之前的异常。
作为一种解决方案,我们需要提供数据库列中实际值到 Java 枚举值的某种映射。为此,我们将使用 JPA 属性转换器机制。它基本上用于定义一个方法来将数据库值转换为属性在 Java 中的表示,反之亦然。打开位于com.nilangpatel.domain包下的Country.java类,并更新continent属性的注解声明如下:
@NotNull
//@Enumerated(EnumType.STRING) // commented original
@Convert(converter=ContinentEnumConvertor.class) // added newly
@Column(name = "continent", nullable = false)
private Continent continent;
最初,它被定义为@Enumerated(EnumType.STRING),并添加了注释的@Convert注解。这个注解需要实现javax.persistence.AttributeConverter接口。实现由ContinentEnumConvertor自定义类提供,如下所示:
public class ContinentEnumConvertor implements AttributeConverter<Continent, String>{
@Override
public String convertToDatabaseColumn(Continent continent) {
return continent.getName();
}
@Override
public Continent convertToEntityAttribute(String continentValue) {
return Continent.getContinent(continentValue);
}
}
这两种方法将在数据库和 Java 中相应的枚举值之间转换值。我们还需要在Continent枚举类中进行必要的更改,如下所示:
public enum Continent {
ASIA("Asia"), EUROPE("Europe"), NORTH_AMERICA("North America"), AFRICA("Africa"), OCEANIA("Oceania"), ANTARCTICA("Antarctica"), SOUTH_AMERICA("South America");
private String name;
Continent(String name){
this.name=name;
}
public String getName() {
return this.name;
}
public static Continent getContinent(String name) {
Continent returnContinent = null;
switch(name){
case "Asia": returnContinent = Continent.ASIA;break;
case "Europe": returnContinent = Continent.EUROPE;break;
case "North America": returnContinent = Continent.NORTH_AMERICA;break;
case "Africa": returnContinent = Continent.AFRICA;break;
case "Oceania": returnContinent = Continent.OCEANIA;break;
case "Antarctica": returnContinent = Continent.ANTARCTICA;break;
case "South America": returnContinent = Continent.SOUTH_AMERICA;break;
default: returnContinent = null;
}
return returnContinent;
}
}
运行应用程序,你会看到实体,JHipster 允许对仅登录用户执行 CRUD 操作。然而,你仍然会看到大陆值被渲染为枚举值,例如ASIA、NORTH_AMERICA等等,而不是实际的数据库列值。
原因是,在创建应用程序时启用国际化支持,JHipster 会为各种标签、错误消息和各种枚举生成显示值。它很好地为每个工件创建了一个单独的键值对文件。这些文件为每个特定语言的文件夹生成,位于src/main/webapp/i18n文件夹下。例如,Country实体的语言键及其值在src/main/webapp/i18n/en/country.json文件中创建。
由于我们的应用程序只有一种语言,即English,因此语言键仅在en文件夹下为English语言生成,如下所示:

键和值以 JSON 格式创建。要了解其结构,请打开country.json文件,其外观如下:
{
"gdpApp": {
"country": {
"home": {
"title": "Countries",
"createLabel": "Create a new Country",
},
"created": "A new Country is created with identifier {{ param }}",
"delete": {
"question": "Are you sure you want to delete Country {{ id }}?"
},
"detail": {
"title": "Country"
},
"code": "Code",
"name": "Name",
"continent": "Continent",
....
}
}
}
标题可以通过gdpApp.country.home.title键访问。这将在 HTML 模板中使用。打开位于/src/main/webapp/app/entities/country文件夹下的country.component.html文件,你会看到以下代码来使用这个键:
<div>
<h2 id="page-heading">
<span jhiTranslate="gdpApp.country.home.title">Countries</span>
....
JHipster 已创建各种模块以支持验证、枚举、读取和解析 JSON 等。其中之一是translation,它支持国际化。这些模块在 JHipster 安装期间作为jhipster-core包安装,位于项目目录下创建的node_modules文件夹中。如果你需要添加更多标签,可以将键放在相应的 JSON 文件中,并使用jhiTranslate来渲染值。
现在,回到我们显示Country实体屏幕上的枚举值而不是实际数据库值的问题。这是因为continent.json中的翻译默认使用枚举值生成。您可以通过以下方式更改它,以便在屏幕上显示正确的洲际值:
"ASIA": "Asia",
"EUROPE": "Europe",
"NORTH_AMERICA": "North America",
"AFRICA": "Africa",
"OCEANIA": "Oceania",
"ANTARCTICA": "Antarctica",
"SOUTH_AMERICA": "South America",
现在,一切应该按预期工作。管理员能够看到所有三个实体,并且可以正确地执行 CRUD 操作。我们现在将开发自定义屏幕,以按国家显示 GDP 数据。
在服务、持久化和 REST 控制器层中的过滤器提供
让我们回顾一下,在用 JDL 创建实体时,我们在 JDL 脚本的最后设置了Country实体的过滤器选项。让我们看看这如何影响服务、持久化和 REST 控制器层。
持久化层
当我们为任何实体添加过滤器选项时,JHipster 会对相应实体的存储库接口进行必要的更改。在我们的例子中,CountryRepository现在扩展了JpaSpecificationExecutor接口,该接口用于向存储库添加Specification功能,如下所示:
public interface CountryRepository extends JpaRepository<Country, Long>, JpaSpecificationExecutor<Country>
Spring Data JPA 提供了一个Specification接口来执行条件查询,该查询用于根据数据库列上的各种条件从数据库中检索值。
服务层
在服务层,JHipster 在服务包下生成一个名为xxxQueryService的单独类。对于Country实体,创建了一个新的服务类CountryQueryService。这个类的作用是检索具有过滤标准的数据,因此它只包含获取方法,如下所示:
public Page<CountryDTO> findByCriteria(CountryCriteria criteria, Pageable page) {
log.debug("find by criteria : {}, page: {}", criteria, page);
final Specification<Country> specification = createSpecification(criteria);
return countryRepository.findAll(specification, page)
.map(countryMapper::toDto);
}
JHipster 为每个使用过滤器选项声明的实体生成一个普通 Java 对象(POJO)类。这用于将前端传递的过滤器值从服务层传递。在我们的例子中,JHipster 生成了一个CountryCriteria类,为Country实体服务此目的。这个类包含域对象中每个相应字段的过滤器。如果没有应用过滤器,这将检索所有实体。
JHipster 创建了各种过滤器类型,对应于每个包装类类型。对于任何自定义类型,它创建一个内部类,该类扩展了io.github.jhipster.service.filter.Filter类。CountryCriteria类如下所示:
public class CountryCriteria implements Serializable {
/**
* Class for filtering Continent
*/
public static class ContinentFilter extends Filter<Continent> {
}
private static final long serialVersionUID = 1L;
private LongFilter id;
private StringFilter code;
private StringFilter name;
private ContinentFilter continent;
private StringFilter region;
private FloatFilter surfaceArea;
private IntegerFilter population;
private FloatFilter lifeExpectancy;
private StringFilter localName;
private StringFilter governmentForm;
private LongFilter cityId;
private LongFilter countryLanguageId;
//setters and getters
}
Country域类中的continent属性是枚举类型,因此 JHipster 创建了一个内部过滤器类ContinentFilter,对于其他类型的包装类属性,它使用相应的过滤器。从前端,您需要根据属性类型以特定方式传递搜索文本作为请求参数,如下所示。考虑属性名为abc:
-
如果属性
abc是字符串类型:abc.contains=<search_text>:列出所有abc的值包含search_text的实体。
-
如果属性
abc是任何数字类型(float、long、double、integer)或日期:-
abc.greaterThan=<search_text>:列出所有abc的值大于search_text的实体。 -
abc.lessThan=<search_text>:列出所有abc的值小于search_text的实体。 -
abc.greaterOrEqualThan=<search_text>:列出所有abc的值大于或等于search_text的实体。 -
abc.lessOrEqualThan=<search_text>:列出所有abc的值小于或等于search_text的实体。
-
-
如果属性
abc是自定义类型:-
abc.equals=<search_text>:列出所有abc的值与search_text完全相同的实体。 -
abc.in=<comma separated search_text values>:列出所有abc的值在search_text列表中的实体。 -
abc.specified=true:列出所有abc的值不为 null 的实体,这意味着已指定。 -
abc.specified=false:列出所有abc的值为 null 的实体,这意味着未指定。
-
这些规则可以组合多个属性以形成复杂查询。
REST 控制器层
当应用过滤器选项时,JHipster 也会对 REST 控制器进行必要的更改。例如,对于实体Country的 REST 控制器CountryResouce的所有get方法现在都接受CountryCriteria作为参数以支持过滤操作,如下所示:
@GetMapping("/countries")
@Timed
public ResponseEntity<List<CountryDTO>> getAllCountries(
CountryCriteria criteria, Pageable pageable) {
log.debug("REST request to get Countries by criteria: {}", criteria);
Page<CountryDTO> page = countryQueryService.findByCriteria(criteria, pageable);
HttpHeaders headers = PaginationUtil.
generatePaginationHttpHeaders(page, "/api/countries");
return ResponseEntity.ok().headers(headers).body(page.getContent());
}
这就是过滤器选项如何影响持久层、服务层和 REST 控制器层代码生成。在单过滤器配置下,JHipster 会进行所有必要的更改。然而,默认情况下,为每个实体生成的 REST 控制器都受到 Spring Security 的保护。您可以在com.nilangpatel.config.SecurityConfiguration类的config()方法中验证这一点,如下所示:
public void configure(HttpSecurity http) throws Exception {
....
.and()
.authorizeRequests()
.antMatchers("/api/register").permitAll()
.antMatchers("/api/activate").permitAll()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/account/reset-password/init").permitAll()
.antMatchers("/api/account/reset-password/finish").permitAll()
.antMatchers("/api/**").authenticated()
....
}
除了注册、激活、认证和重置密码操作外,所有其他 URL(/api/**)都限制为登录用户。但是,在我们的情况下,我们希望向普通用户展示国家 GDP 数据,而不需要登录。为此,我们需要创建一个具有不同 URL 模式的自定义 REST 控制器,如下所示:
@RestController
@RequestMapping("/api/open")
public class GenericRestResource {
private final Logger log = LoggerFactory.getLogger(GenericRestResource.class);
private final CountryQueryService countryQueryService;
public GenericRestResource(CountryQueryService countryQueryService) {
this.countryQueryService = countryQueryService;
}
@GetMapping("/search-countries")
@Timed
public ResponseEntity<List<CountryDTO>> getAllCountriesForGdp(
CountryCriteria criteria, Pageable pageable) {
log.debug("REST request to get a page of Countries");
Page<CountryDTO> page = countryQueryService.findByCriteria
(criteria, pageable);
HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(
page, "/api/open/search-countries");
return ResponseEntity.ok().headers(headers).body(page.getContent());
}
@GetMapping("/show-gdp/{id}")
@Timed
public ResponseEntity<CountryDTO> getCountryDetails(@PathVariable Long id) {
log.debug("Get Country Details to show GDP information");
CountryDTO countryDto = new CountryDTO();
Optional<CountryDTO> countryData = countryService.findOne(id);
return ResponseEntity.ok().body(countryData.orElse(countryDto));
}
}
第一种方法与在CountryResource中自动生成的方法类似。第二种方法将用于显示 GDP 数据,我们将在创建该屏幕时使用它。此控制器的 URL 模式映射为/api/open。创建单独的 REST 控制器的目的是通过在SecurityConfiguration的configure方法中配置其 URL 模式,使其无需登录即可访问,如下所示:
public void configure(HttpSecurity http) throws Exception {
....
.antMatchers("/api/activate").permitAll()
.antMatchers("/api/open/**").permitAll()
.antMatchers("/api/authenticate").permitAll()
....
}
此控制器现在公开可访问。我们将在“开发自定义屏幕”部分使用控制器方法构建前端层。
向现有实体添加过滤器选项
如果实体已经生成而没有过滤选项,并且你想稍后添加它,你需要执行某些步骤。以下有两种可能的方法:
-
使用命令提示符,执行以下操作:
-
在项目目录下的
.jhipster文件夹中打开实体的 JSON 文件。例如,对于一个Country实体,你将在.jhipster文件夹中看到一个名为Country.json的文件。 -
如果
service键的值是no,则将其更改为serviceClass或serviceImpl. 服务层选项必须启用才能使用过滤选项。 -
将
jpaMetamodelFiltering键的值更改为true。 -
使用
jhipster entity <entity_name>命令重新生成实体。
-
-
使用 JDL,执行以下操作:
-
在 JDL 脚本文件中添加一行,包含
filter <entity_name>。 -
使用
jhipster jhipster-jdl <jdl_file>命令重新导入定义。
-
在这两种情况下,在重新生成实体时,自定义将重置,因此在执行此任务之前请进行适当的备份。
开发自定义屏幕
默认情况下,JHipster 只向登录用户显示实体。我们应用程序的目标是向最终用户显示给定国家的 GDP 数据。为了实现这一点,国家数据必须公开可见。换句话说,它必须无需登录即可访问。为了使其更用户友好,我们将设计两个不同的屏幕流程。
第一个屏幕将列出系统中所有可用的国家。选择任何一个国家将在第二个屏幕上显示该国的实际 GDP,并带有图形展示。这些是我们需要从头开始开发并插入到 JHipster 项目结构中的自定义屏幕,我们将在本节中这样做。
搜索国家屏幕
在此屏幕中,我们将列出系统中所有可用的国家,并使用分页。它与 Country 实体屏幕相同,但对所有用户(无需登录)都可用。为了提供更好的用户体验,我们将在该屏幕上添加过滤器以查找特定的国家。它看起来如下:

此屏幕有两个过滤器。第一个过滤器将匹配国家名称中的搜索文本(contains 条件),而第二个将比较选定的洲(equals 条件)。这些过滤器帮助用户立即找到他们想要的国。为了使其更简单,我们只添加了关于每个国家的一些信息列,这似乎适合这个屏幕。在每条国家记录的末尾,视图按钮将导航到第二个屏幕,显示该国的 GDP 信息。
JHipster 提供了 Angular 或 React 作为开发前端的选择。我们选择了 Angular 来创建这个应用程序。因此,我们所有的开箱即用的屏幕都是使用 Angular 框架生成的。由于这是一个自定义屏幕,我们需要使用各种 Angular 组件来开发它。在 /src/webapp/app 文件夹内创建一个 gdp 文件夹,在下一个子节中,我们将在其中创建 Angular 组件,以构建自定义屏幕。
创建 Angular 服务
Angular 是一个模块化框架,在其中我们编写许多组件,每个组件都针对特定的目的。很多时候,我们需要一些在多个组件之间共享的通用功能。此外,我们可能还需要通过 REST 调用来从数据库中获取记录。这就是创建 Angular 服务完美合理的地方。对于我们的 GDP 应用程序,我们需要在 Angular 服务中按如下方式获取国家数据:
@Injectable({ providedIn: 'root'})
export class CountryGDPService {
public searchCountryUrl = SERVER_API_URL + 'api/open/search-countries';
public showGDPUrl = SERVER_API_URL + 'api/open/show-gdp';
constructor(private http: HttpClient) { }
query(req?: any): Observable<EntityArrayResponseType> {
const options = createRequestOption(req);
return this.http.get<ICountry[]>(this.searchCountryUrl,
{ params: options, observe: 'response' });
}
find(id: number): Observable<EntityResponseType> {
return this.http.get<ICountry>(`${this.showGDPUrl}/${id}`,
{ observe: 'response' });
}
}
query 方法用于获取所有国家,这些请求参数是由 search-country 组件发送的。第二个方法 find 用于根据给定的 id 值获取特定的国家。这个服务类使用 Angular 框架提供的 HttpClient 模块,以对新建的 REST 控制器进行 REST 调用。
使用 api/open/search-countries 和 api/open/show-gdp URL 来调用 REST 控制器的 getAllCountriesForGdp() 和 getCountryDetails() 方法。然而,服务组件的 find() 方法正动态地将 id 值传递到 URL 中,使用 ${this.showGDPUrl}/${id} 表达式。这个服务类对两个屏幕都是通用的。
创建 Angular 路由器
下一个组件是 Angular 路由器。Angular 路由器用于管理应用程序导航和不同组件之间的路由。Angular 路由器使用浏览器 URL 来将其映射到特定的组件。它对浏览器 URL 进行各种类型的处理,例如解析以验证 URL 是否有效;如果提供了该选项,则进行重定向;将组件与 URL 段进行匹配;验证给定的 URL 是否可以通过设置的安全卫士访问;运行关联的解析器以动态添加数据;最后,激活组件并执行导航。我们将如下编写 Angular 路由器:
@Injectable({ providedIn: 'root' })
export class CountryGDPResolve implements Resolve<ICountry> {
constructor(private service: CountryGDPService) {}
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Country> {
const id = route.params['id'] ? route.params['id'] : null;
if (id) {
return this.service.find(id).pipe(
filter((response: HttpResponse<Country>) => response.ok),
map((country: HttpResponse<Country>) => country.body)
);
}
return of(new Country());
}
}
export const countryGDPRoute: Routes = [
{
path: 'countries',
component: SearchCountryComponent,
resolve: {
pagingParams: JhiResolvePagingParams
},
data: {
defaultSort: 'name,asc',
pageTitle: 'gdpApp.country.home.title'
},
},
{
path: 'showGDP/:id',
component: CountryGDPComponent,
resolve: {
country: CountryGDPResolve
}
},
];
它包括一个 resolve 类(CountryGDPResolve)和路由数组。当用户点击“查看”按钮以启动到第二个屏幕的转换时,resolve 类根据国家 ID 获取完整的国家模型数据。它使用服务组件进行 REST 调用来获取国家信息。路由数组持有组件和它们将通过哪些 URL 触发的配置映射。这个 Angular 路由器对两个屏幕都是通用的。
Angular 模块
如我们所知,Angular 是一个模块化框架。Angular 中的模块用于将相关的组件、管道、指令和服务分组,形成一个独立的单元,可以与其他模块结合形成一个完整的应用程序。模块可以控制哪些组件、服务和其他工件对其他模块是隐藏的还是可见的,这与 Java 类有公共和私有方法的方式非常相似。我们将使用一个名为 CountryGDPModule 的单个模块,如下所示:
const ENTITY_STATES = [...countryGDPRoute];
@NgModule({
imports: [GdpSharedModule, RouterModule.forChild(ENTITY_STATES)],
declarations: [
SearchCountryComponent,
CountryGDPComponent,
],
entryComponents: [SearchCountryComponent , CountryGDPComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class CountryGDPModule {}
它定义了构成此模块所需的所有组件和路由器。这对两个屏幕都是常见的。
创建一个 Angular 组件以显示国家列表
接下来,我们将编写一个组件,在第一个屏幕上显示国家列表。组件是我们创建 Angular 应用程序的基本构建块。每个 Angular 应用程序至少有一个组件。组件包含应用程序数据和逻辑,用于在关联的 HTML 模板中显示数据。我们将为应用程序中的每个屏幕编写一个单独的组件。对于第一个屏幕,我们将编写一个 search-country 组件,如下所示:
@Component({
selector: 'jhi-search-country',
templateUrl: './search-country.component.html',
})
export class SearchCountryComponent implements OnInit {
countries: ICountry[];
routeData: any;
totalItems: any;
queryCount: any;
itemsPerPage: any;
page: any;
predicate: any;
previousPage: any;
reverse: any;
// variables for country name and continent filters.
nameFilter: String;
continentFilter: String;
constructor(
private countryGDPService: CountryGDPService,
private activatedRoute: ActivatedRoute,
private router: Router,
) {
this.itemsPerPage = ITEMS_PER_PAGE;
this.routeData = this.activatedRoute.data.subscribe(data => {
this.page = data.pagingParams.page;
this.previousPage = data.pagingParams.page;
this.reverse = data.pagingParams.ascending;
this.predicate = data.pagingParams.predicate;
});
}
loadAll() {
this.countryGDPService
.query({
page: this.page - 1,
size: this.itemsPerPage,
sort: this.sort(),
'name.contains': this.nameFilter,
'continent.equals' : this.continentFilter
})
.subscribe(
(res: HttpResponse<ICountry[]>) => this.paginateCountries(res.body, res.headers),
);
}
.....
}
可以使用 @component() 装饰器创建一个 Angular 组件。SearchCountryComponent 类代表 search-country 组件。它通过某些变量定义,这些变量用于分页和过滤目的。CountryGDPService 对象通过构造函数注入到组件类中,将在其他方法中用于获取国家数据。构造函数使用分页变量初始化,这些变量用于处理第一个屏幕上的分页。
在组件类通过构造函数初始化后不久,Angular 将调用 ngOnInit() 方法。在这个方法中,我们初始化了一些参数,并通过 loadAll() 调用其他方法。此方法调用 countryGDPService 对象的 query() 方法以获取国家信息。
query() 方法接受各种分页和过滤参数。page、size 和 sort 是分页参数,而 name.contains 和 continent.equals 是过滤参数。它们最终通过 CountryGDPService 提交给 REST 控制器。
name.contains 过滤参数用于根据 name 属性过滤国家数据。由于它是 String 类型,我们使用了 contains 条件。同样,另一个过滤参数 continent.equals 用于过滤 continent 属性的数据。由于它是 enum 类型,我们使用了 equals 条件。这就是我们在 Service 层 部分下,在 服务、持久化和 REST 控制器层中的过滤提供 子部分中看到的内容。
其他函数,如searchCountries()、trackId()、loadPage()等,直接从与search-country组件关联的 HTML 模板中调用。您可以在 GitHub 章节的源代码中看到它,网址为github.com/PacktPublishing/Spring-5.0-Blueprints/tree/master/chapter04。
Angular 模板用于显示国家列表
最后,我们需要一个 HTML 模板来在屏幕上渲染国家数据。每个 Angular 组件都与一个@Component装饰器关联一个 HTML 模板。对于我们的第一个屏幕,以显示国家列表,HTML 模板如下所示:
<form name="searchCountriesForm" novalidate (ngSubmit)="searchCountries()">
<div class="container mb-5">
<div class="row">
<div class="col-6">
<label class="form-control-label"
jhiTranslate="gdpApp.country.name" for="nameFilter">Name</label>
<input type="text" class="form-control"
name="nameFilter" id="nameFilter" [(ngModel)]="nameFilter" maxlength="52"/>
</div>
<div class="col-4">
<label class="form-control-label" jhiTranslate="gdpApp.country.continent"
for="continentFilter">Continent</label>
<select class="form-control" name="continentFilter"
[(ngModel)]="continentFilter" id="continentFilter">
<option value="">
{{'gdpApp.Continent.ALL' | translate}}</option>
<option value="ASIA">
{{'gdpApp.Continent.ASIA' | translate}}</option>
<option value="EUROPE">
{{'gdpApp.Continent.EUROPE' | translate}}</option>
<option value="NORTH_AMERICA">
{{'gdpApp.Continent.NORTH_AMERICA' | translate}}</option>
<option value="AFRICA">
{{'gdpApp.Continent.AFRICA' | translate}}</option>
<option value="OCEANIA">
{{'gdpApp.Continent.OCEANIA' | translate}}</option>
<option value="ANTARCTICA">
{{'gdpApp.Continent.ANTARCTICA' | translate}}</option>
<option value="SOUTH_AMERICA">
{{'gdpApp.Continent.SOUTH_AMERICA' | translate}}</option>
</select>
</div>
<div class="col-2 align-self-end">
<label class="form-control-label" for="search-countries"></label>
<button type="submit" id="search-countries" class="btn btn-primary">
<fa-icon [icon]="'search'"></fa-icon><span>Search</span>
</button>
</div>
</div>
</div>
</form>
<div class="table-responsive" *ngIf="countries">
<table class="table table-striped">
<thead>
<tr jhiSort [(predicate)]="predicate"
[(ascending)]="reverse" [callback]="transition.bind(this)">
<th jhiSortBy="code"><span jhiTranslate="gdpApp.country.code">
Code</span> <fa-icon [icon]="'sort'"></fa-icon></th>
<th jhiSortBy="name"><span jhiTranslate="gdpApp.country.name">
Name</span> <fa-icon [icon]="'sort'"></fa-icon></th>
<th jhiSortBy="continent"><span jhiTranslate="gdpApp.country.continent">
Continent</span> <fa-icon [icon]="'sort'"></fa-icon></th>
<th jhiSortBy="region"><span jhiTranslate="gdpApp.country.region">
Region</span> <fa-icon [icon]="'sort'"></fa-icon></th>
<th jhiSortBy="surfaceArea"><span jhiTranslate="gdpApp.country.surfaceArea">
Area</span> <fa-icon [icon]="'sort'"></fa-icon></th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let country of countries ;trackBy: trackId">
<td>{{country.code}}</td>
<td>{{country.name}}</td>
<td jhiTranslate="{{'gdpApp.Continent.' + country.continent}}">
{{country.continent}}</td>
<td>{{country.region}}</td>
<td>{{country.surfaceArea}}</td>
<td class="text-right">
<div class="btn-group flex-btn-group-container">
<button type="submit"
[routerLink]="['/showGDP', country.id ]"
class="btn btn-info btn-sm">
<fa-icon [icon]="'eye'"></fa-icon>
<span class="d-none d-md-inline"
jhiTranslate="entity.action.view">View GDP</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="countries && countries.length">
<div class="row justify-content-center">
<jhi-item-count [page]="page" [total]="queryCount"
[itemsPerPage]="itemsPerPage"></jhi-item-count>
</div>
<div class="row justify-content-center">
<ngb-pagination [collectionSize]="totalItems"
[(page)]="page" [pageSize]="itemsPerPage" [maxSize]="5" [rotate]="true"
[boundaryLinks]="true" (pageChange)="loadPage(page)"></ngb-pagination>
</div>
</div>
HTML form用于渲染过滤选项,国家名称作为文本字段,大陆作为下拉菜单。在过滤表单之后,它以表格格式显示国家,底部有分页。每行的最后一列有一个“查看”按钮,该按钮使用/showGDP URL 打开下一个屏幕,并传递当前国家的id。
显示 GDP 屏幕
此屏幕以图形表示方式显示所选国家的基本数据和 GDP 数据。我们将使用世界银行 API 以 JSON 格式获取信息,并将其提供给图表模块以渲染 GDP 数据的图表。此屏幕如下所示:

此屏幕使用与第一个屏幕相同的 service、router 和 module artifacts,但将使用一个单独的组件和 HTML 模板,您将在下一节中看到。
用于显示国家 GDP 的 Angular 组件
show-gdp组件从第一个屏幕获取国家数据,调用世界银行 API,并以 JSON 格式获取数据,最后将其发送到图表模块进行渲染。此组件如下所示:
@Component({
selector: 'jhi-show-gdp',
templateUrl: './show-gdp.component.html',
})
export class CountryGDPComponent implements OnInit {
currentCountry: ICountry;
data: IGdpData[];
preGDPUrl = 'http://api.worldbank.org/v2/countries/';
postGDPUrl = '/indicators/NY.GDP.MKTP.CD?format=json&per_page=' + 10;
year = [];
gdp = [];
chart = [];
noDataAvailale: any;
constructor(
private activatedRoute: ActivatedRoute,
private httpClient: HttpClient
) {
this.activatedRoute.data.subscribe(data => {
this.currentCountry = data.country;
});
}
ngOnInit() {
const gdpUrl = this.preGDPUrl + this.currentCountry.code
+ this.postGDPUrl;
this.httpClient.get(gdpUrl).subscribe(res => {
this.noDataAvailale = true;
const gdpDataArr = res[1];
if ( gdpDataArr ) {
this.noDataAvailale = false;
gdpDataArr.forEach(y => {
this.year.push(y.date);
this.gdp.push(y.value);
});
this.year = this.year.reverse();
this.gdp = this.gdp.reverse();
this.chart = new Chart('canvas', {
type: 'line',
data: {
labels: this.year,
datasets: [
{
data: this.gdp,
borderColor: '#3cba9f',
fill: true
}
]
},
options: {
legend: {
display: false
},
scales: {
xAxes: [{
display: true
}],
yAxes: [{
display: true
}],
}
}
});
}
});
}
}
在此组件的构造函数中,我们从 Angular router 获取所选国家。在CountryGDPResolve类的resolve()方法中,我们从 URL 中的ID参数获取国家对象,然后此对象通过 router 可用,因为我们已在countryGDPRoute中为此组件提供了 resolve 配置,如下所示:
{
path: 'showGDP/:id',
component: CountryGDPComponent,
resolve: {
country: CountryGDPResolve
}
}
一旦我们获取到国家信息,我们将调用世界银行 API。此 URL 如下所示:
api.worldbank.org/v2/countries/**IND**/indicators/NY.GDP.MKTP.CD?format=json&per_page=10。
在这个 URL 中,国家代码是动态插入的,来自路由器提供的国家数据。per_page属性返回这么多年的 GDP 数据。前面的例子显示了印度国家过去十年的 GDP 数据。在获取 JSON 数据后,我们正在迭代并准备两个数组,year和gdp,并将它们传递给图表模块以在屏幕上生成图表。图表模块可以作为 node 模块安装,使用npm install chart.js命令。
Angular 模板显示国家 GDP
最后,show-gdp组件的模板将渲染图表并显示国家 GDP 数据。模板如下所示:
<div class="container">
<h2 id="page-heading">
<span> GDP data for country <b>{{currentCountry.name}}</b></span>
</h2>
<br/>
<div class="row">
<div class="col-4">
<dl class="row">
<dt class="col-sm-4">Code</dt>
<dd class="col-sm-8">{{currentCountry.code}}</dd>
<dt class="col-sm-4">Name</dt>
<dd class="col-sm-8">{{currentCountry.name}}</dd>
<dt class="col-sm-4">Continent</dt>
<dd class="col-sm-8">{{'gdpApp.Continent.' +
currentCountry.continent | translate }}</dd>
<dt class="col-sm-4">Region</dt>
<dd class="col-sm-8">{{currentCountry.region}}</dd>
<dt class="col-sm-4">Surface Area</dt>
<dd class="col-sm-8">{{currentCountry.surfaceArea}}</dd>
<dt class="col-sm-4"></dt>
<dd class="col-sm-8">
<div class="btn-group">
<button type="submit"
[routerLink]="['/countries']"
class="btn btn-info btn-sm">
<span class="d-none d-md-inline">Back</span>
</button>
</div>
</dd>
</dl>
</div>
<div class="col-8">
<div *ngIf="noDataAvailale">
GDP data for <b>{{currentCountry.name}}</b> is not available
</div>
<div *ngIf="chart">
<canvas id="canvas">{{ chart }}</canvas>
</div>
</div>
</div>
</div>
它显示了所选国家的一些详细信息,然后是一个图表的占位符。noDataAvailale变量用于显示消息,在所选国家没有 GDP 数据的情况下。它在show-gdp组件中设置,当调用世界银行 API 时。
将 GDP 模块连接到 AppModule
现在一切都已完成。我们的 GDP 模块已准备好执行。最后一步是将它连接到 JHipster 项目结构。您已经看到,一个模块由多个工件组成,例如组件、管道、服务和路由器。多个模块组合在一起形成一个应用程序。
每个 Angular 应用程序至少有一个模块,称为根模块,它用于启动应用程序。通常,这个模块被称为AppModule,这是惯例。由于我们的应用程序前端是基于 Angular 构建的,因此存在一个AppModule。位于/src/main/webapp/app文件夹下的app.module.ts文件代表了一个AppModule。
我们需要在AppModule中配置我们的自定义模块以使其生效。这可以通过在app.module.ts文件中的@NgModule下的imports声明中添加我们的模块来实现,如下所示:
@NgModule({
imports: [
....
GdpHomeModule,
GdpAccountModule,
// jhipster-needle-angular-add-module JHipster will add new module here
GdpEntityModule,
CountryGDPModule
在将实体添加到应用程序后,JHipster 生成一个名为XXXEntityModule的通用模块,它包含与所有实体相关的所有工件的引用。在实体生成后不久,JHipster 将此模块条目添加到@NgModule内部的导入数组中。我们已经为 GDP 模块(CountryGDPModule)添加了另一个条目。这就是任何自定义模块如何连接到AppModule的方式。
更新导航
我们的自定义模块已经准备好,并且已经连接到AppModule,以使其生效。然而,还有一个小细节需要处理,那就是定位导航以启动 GDP 模块。最佳选择是将导航链接放在页面顶部的导航栏中。JHipster 提供了一个作为单独模块的导航栏,它显示了各种链接。其中一些是公开可见的,而其他一些则仅供登录和Admin用户使用。
要添加链接,我们需要修改位于/src/main/webapp/app/layouts/navbar文件夹下的导航模板文件navbar.component.html,如下所示:
<div class="navbar-collapse collapse" id="navbarResponsive"
[ngbCollapse]="isNavbarCollapsed" [ngSwitch]="isAuthenticated()">
<ul class="navbar-nav ml-auto">
<li class="nav-item" routerLinkActive="active"
[routerLinkActiveOptions]="{exact: true}">
<a class="nav-link" routerLink="/" (click)="collapseNavbar()">
<span>
<fa-icon icon="home"></fa-icon>
<span jhiTranslate="global.menu.home">Home</span>
</span>
</a>
</li>
<li class="nav-item" routerLinkActive="active"
[routerLinkActiveOptions]="{exact: true}">
<a class="nav-link" routerLink="/countries"
(click)="collapseNavbar()">
<span>
<fa-icon icon="list"></fa-icon>
<span>Countries</span>
</span>
</a>
</li>
....
我们在导航栏中突出显示的 HTML 代码中添加了粗体,以显示“国家”菜单项。它看起来如下:

routerLink的路径定义为“国家”,这最终会触发t=the search-country组件在第一个屏幕上显示带有筛选选项的国家列表。这就是你如何在 JHipster 项目中添加自定义屏幕的方式。
其他 JHipster 功能
到目前为止,你已经看到了如何使用 JHipster 创建完整和可生产的应用程序。你已经看到了如何创建实体并定义它们之间的关系。我们还添加了自定义屏幕并开发了各种工件,这样你就可以学习如何向由 JHipster 生态系统生成的应用程序添加自定义代码。
这些是一些非常棒的功能,不仅使开发者的生活更轻松,而且通过自动化许多流程,使开发者更加高效。我们现在将探讨这些功能。
IDE 支持
在本章的开头,你看到了如何通过回答各种问题来使用 JHipster CLI 创建应用程序。这已经足够开始使用 JHipster 了。然而,为了提高生产效率,建议使用 IDE 进行开发。JHipster 支持广泛的 IDE,包括 Eclipse、Visual Studio Code、IntelliJ IDEA 等等。在使用 IDE(或简单的文本编辑器)时,你需要确保排除某些文件夹进行索引,如node_modules、build和target,以减少应用程序的初始加载时间。
开箱即用的设置屏幕
JHipster 提供了一些开箱即用的屏幕。大致上,它们可以分为三个不同的组,如下所示:
-
首页和登录屏幕
-
管理
-
账户管理
首页和登录屏幕
启动时,JHipster 显示带有欢迎信息的首页。这是默认的首页,你可以根据应用程序需求进行更改。在本地开发中,默认选择dev配置文件,因此你将在左上角看到一个“开发”标签。在页面的顶部部分,你会看到一个导航菜单。在没有登录的情况下,它将显示以下菜单项:
-
首页:显示主页的链接。
-
语言:这是有条件的。只有当你选择了多个语言时,此菜单才会可见。
-
账户:此部分显示子菜单项,例如“登录”和“注册”。
点击“登录”选项后,你会看到一个登录页面,如下所示:

此屏幕涵盖了“记住我”、“忘记密码了吗?”以及“注册新账户”等功能。忘记密码功能需要电子邮件验证。为此,你需要在应用程序属性文件中配置 SMTP。默认情况下,JHipster 创建了以下两个用户:
-
管理员:用户名—
admin,密码—admin,角色—admin。 -
用户:用户名—
user,密码—user,角色—user。
账户管理
JHipster 自带登录功能。它还结合了账户管理。JHipster 提供的账户屏幕支持以下形式的子菜单中的各种操作:
-
设置:此屏幕允许更新用户账户详情,例如姓名、电子邮件地址和语言。语言下拉菜单显示了系统中的所有可用语言,这些语言是在应用程序创建期间配置的。
-
密码:此屏幕用于更新当前登录用户的密码。
-
注册:此屏幕用于将新用户注册到系统中。仅在用户未登录时才可用。用户创建后不久,将启动一个激活流程,包括激活邮件和验证。发送电子邮件需要在应用程序属性中完成 SMTP 配置。请注意,如果在应用程序创建期间选择 OAuth 作为认证机制,JHipster 将不会显示此屏幕。
管理
使用管理员凭据登录后,您将看到一个包含“管理”选项的导航菜单。它涵盖了用于管理整个应用程序的各种模块。这对于开发和监控应用程序都很有用。
它包含以下各节中描述的各种子菜单。
用户管理
这是一个一站式屏幕,用于管理应用程序的注册用户。您可以从此屏幕添加新用户,修改、删除或激活/停用现有用户。它还显示了具有各种属性的用户列表,例如ID、用户名、电子邮件、激活/停用、语言、角色、创建日期、修改者和修改日期,并支持分页。
指标
JHipster 提供各种屏幕来分析应用程序性能和其他指标,如下所示:
-
JVM 度量:此部分显示了 JVM 特定的统计信息,如内存利用率、线程计数、线程转储和垃圾回收详细信息。
-
HTTP 请求:此指标显示了 HTTP 请求的聚合详情,包括其状态码。
-
服务统计信息:此处显示了各种内置和自定义服务的执行时间详情。查看各种服务的使用情况很有用。
-
缓存统计信息:此指标涵盖了实体缓存的详细信息。
-
数据源统计信息:此处将显示数据源详情。
提供了刷新按钮以更新指标的最新值。
健康状况
此屏幕显示了与应用程序健康相关的各种信息,例如底层数据库和磁盘空间。它用于提前做出关于数据存储的决定。
配置
此屏幕显示了应用于应用程序的当前配置。在出现任何问题或检查进一步性能改进的可能性时,特别有用。它涵盖了 Spring 框架特定配置、服务器配置、系统环境配置和应用程序属性。由于这涉及敏感数据,默认情况下只有管理员才能看到。
审计
JHipster 为用户认证提供审计日志。由于在 JHipster 中认证是通过 Spring Security 完成的,它专门捕获与认证相关的安全事件,并将它们存储在独立的 Spring 数据仓库的数据库中。从安全角度来看,这些信息非常有用。此屏幕以表格格式显示所有这些数据,并具有分页功能。
日志
此屏幕显示了运行时各种应用程序日志级别,例如TRACE、DEBUG、INFO、WARN、ERROR和OFF,以及类和包。它还允许更新单个类和包的日志级别。这在解决应用程序问题时非常有用。
API
JHipster 使用 Swagger,这是一个用于描述 API 结构的框架。正如您所看到的,JHipster 支持实体创建,并公开 REST API。它使用 Swagger 来记录实体 REST API。JHipster 还提供了一个用户界面,使用示例数据与 API 交互,并返回输出。
维护代码质量
JHipster 在创建应用程序和实体时生成大量的样板代码。它在生成代码时遵循最佳实践,以保持代码质量。然而,JHipster 第一次只是创建应用程序代码,用户必须在之后根据业务需求添加自定义代码。
为了即使在添加自定义代码后也能保持代码质量,JHipster 允许使用 Sonar 分析整个应用程序代码——Sonar 是一款专门用于监控代码质量的工具。代码分析使用SonarCloud——Sonar 的云版本。为此,您必须在 Git 中提交代码。
您还可以在本地 Sonar 服务器上分析代码。为此,您必须在本地设置和运行 Sonar 服务器。Sonar 服务器运行的默认端口是9000,因此您需要确保在pom.xml中配置的 Sonar 端口(如果构建类型是 Maven)相同。执行mvnw test sonar:sonar命令,您将在 Sonar 中看到代码分析,如下所示:

这有助于在添加自定义代码到应用程序后仍保持代码质量。
微服务支持
在本章中,我们使用 JHipster 创建了一个单体应用程序。然而,它也允许用户创建基于微服务应用程序。基于微服务的架构将整个单体应用程序(包括前端和后端)拆分为小型且独立的模块化服务。这是软件开发的一种独特方式,在过去几年中迅速发展。
每个模块化服务都可以通过一个独特且简单的 API 与其他服务交互。与单体设计相比,微服务架构具有许多优势,例如独立开发和部署、轻松管理故障转移、开发人员可以在独立团队中工作、实现持续交付等。
通常,微服务架构没有前端层,但 JHipster 支持带有前端微服务网关以处理网络流量。它作为代理微服务为最终用户提供服务。简而言之,用户可以通过网关与微服务交互。JHipster 微服务模型由一个网关服务、一个注册表以及至少一个或多个我们可以使用 JHipster 创建的微服务应用程序组成,这些应用程序可以通过后端代码访问,并使用 API 进行访问。
Docker 支持
Docker 是一个支持容器化的开源软件平台,它使得应用程序的部署变得便携和自包含。它可以将整个应用程序(包括 SQL 和 NoSQL 数据库、Sonar 配置等)及其依赖项打包成一个单一的容器镜像,以便在任何环境中进行部署和测试。
JHipster 为单体和基于微服务的应用程序提供了开箱即用的 Docker 支持。Docker 是为 Linux 开发的,但为 macOS 和 Windows 提供了单独的版本。JHipster 在应用程序生成时创建 Dockerfile。Dockerfile 包含 Docker 容器用于构建 Docker 镜像的指令集。
JHipster 还支持从 Docker Hub 拉取 Docker 镜像。这是一个在线注册表,用于发布公共和私有 Docker 镜像。这极大地帮助了在不进行本地安装的情况下使用第三方工具,因为 Docker 镜像可以被拉取并在本地容器上运行。
配置文件管理
配置文件是一组针对特定环境的配置,如开发、测试、生产等。JHipster 支持配置文件管理,并自带两个配置文件——dev和prod。默认情况下,它使用dev配置文件。JHipster 为每个配置文件提供单独的应用程序属性文件。
在生产环境中,您需要使用./mvnw -Pprod命令为 Maven 和./gradlew -Pprod命令为 Gradle 启用生产配置文件。如果您需要在生产中导出可执行的 WAR 文件,可以使用 Maven 的./mvnw -Pprod package命令和 Gradle 的./gradlew -Pprod package命令。
实时重载
在软件开发过程中,从时间管理角度来看,最具挑战性的因素之一是重新编译代码、部署并重新启动服务器以查看所做的更改。通常,用 JavaScript 编写的客户端代码不需要编译,可以在浏览器刷新后立即反映更改。尽管如此,最新的前端框架在修改脚本文件后需要某种形式的转译。
在这种情况下,对于任何单个代码更改,通常需要构建、部署和重新启动服务器。这将严重影响开发者的生产力。为了避免这种情况,JHipster 支持一种称为实时重新加载的机制。JHipster 生成一个基于 Spring Boot 的应用程序,并包含DevTools模块,以在服务器上刷新更改而不进行冷启动。默认情况下,这是启用的,所以每当发生任何 Java 代码更改时,它将自动在服务器上刷新它们。任何前端代码的实时重新加载都可以通过BrowserSync实现,可以使用npm start命令启动,并且可以通过http://localhost:9000访问.。
测试支持
测试是任何软件开发过程中的一个重要组成部分。它为应用程序或产品提供质量保证。在创建应用程序和实体时,JHipster 会为前端和后端(或服务器端)创建各种自动化的单元测试用例。
服务器端单元测试用例生成在/src/test/java文件夹中。它们涵盖了应用程序的各个层次,如存储库、服务、安全、REST API 和分页。它们被分组在相应的包中。您可以从 IDE 中运行单个测试用例,或者使用mvnw test命令从命令提示符中运行所有测试用例。确保在执行此命令时您位于应用程序目录中。在 Gradle 的情况下,您需要执行gradle test命令。
前端(或客户端)单元测试可以使用npm test命令执行。这将执行位于/src/test/javascript/spec文件夹中的 typescript 的各种 JavaScript 测试用例。JHipster 默认还支持使用 Jest 框架进行端到端客户端测试用例。可选地,还可以使用其他框架,如Gatling、Cucumber和Protractor,进行客户端端到端测试。
升级 JHipster
与其他框架不同,升级 JHipster 是一个痛苦的过程。一个名为JHipster 升级的子生成器用于升级现有应用程序到新版本,而不会删除自应用程序首次创建以来添加的任何自定义更改。这非常有用,尤其是在 JHipster 的新版本发布时,其中包含已知的错误修复和安全补丁。可以使用以下命令执行 JHipster 升级:
jhipster upgrade
为了使整个升级过程自动化,JHipster 借助以下步骤使用 Git:
-
上述命令检查是否有新的 JHipster 版本可用,除非明确给出
--force标志。如果提供了此选项,无论已安装的版本是否为最新版本,都会触发升级子生成器。 -
整个升级过程依赖于 Git,因此如果应用程序未使用 Git 初始化(如果未安装 Git),JHipster 将初始化 Git 并将当前代码提交到 master 分支。
-
JHipster 将检查是否有未提交的本地代码。如果代码未提交,则升级过程将失败。
-
接下来,它将检查 Git 中是否存在
jhipster_upgrade分支。如果不存在,这将创建。这个分支专门用于 JHipster 升级过程,因此它永远不应该手动更新。 -
JHipster 将检出
jhipster_upgrade分支。 -
在这个阶段,JHipster 已经升级到最新版本。
-
当前项目目录被清理,并且从零开始生成应用程序,包括实体。
-
生成的代码将被提交到
jhipster_upgrade分支。 -
最后,
jhipster_upgrade分支将与启动jhipster_upgrade命令的原始分支合并。 -
如果出现任何冲突,您需要手动解决并提交它们。
持续集成支持
自动化测试极大地有助于使系统无错误,即使在添加新功能之后。JHipster 为生成的代码创建单元和集成测试用例,这在一定程度上是有帮助的。在实际场景中,我们需要添加针对自定义业务实现的进一步单元测试用例;例如,您可能添加了一些自定义屏幕、控制器和服务层,您需要为这些层编写额外的单元测试用例。
此外,我们还需要为新引入的 API 添加集成测试用例。除此之外,我们还需要添加针对前端定制的客户端测试用例。
目前,测试和持续集成已成为软件开发过程中的一个重要组成部分。测试有助于生产出高质量的产品,而持续集成不过是不断合并和测试新引入的代码更改,这有助于识别潜在的错误。这是通过执行针对代码的自动化单元、集成和端到端测试用例来实现的。一个典型的例子是在 Git 上的每次提交时触发自动化测试套件;或者更高效地,按照预定义的计划运行它。
通过实施持续集成流程,可以实现自动化测试模型的好处,以确保新代码更改不会引入对稳定版本的回归。这保证了新更改的合并和部署到生产环境时的信心。
持续测试、集成和持续部署导致了一个称为 持续集成/持续部署(CI/CD)的概念,它执行持续集成、测试和部署代码。可以通过各种 CI/CD 工具实现持续交付。JHipster 为市场上现有的知名 CI/CD 工具提供了优雅的支持,例如 Jenkins、Travis CI、GitLab CI 和 Circle CI。
社区支持和文档
无论软件框架或产品有多好,其流行程度取决于用户从文档和社区获得帮助的容易程度。JHipster 在其官方网站上提供了非常棒的文档,这足以开始使用它。
除了官方的 GitHub 论坛,还有很多其他资源和论坛可供选择,在这些论坛中,你可以在使用 JHipster 的过程中轻松获得任何问题或问题的帮助。此外,开发者们提供专业的帮助,包括及时回答问题和优先提供错误修复。这确实有助于吸引开发者和组织开始使用 JHipster。
JHipster Marketplace
谁不喜欢使用符合业务需求的可重用组件或模块呢?这将大大节省开发时间。作为一个开源软件,JHipster 团队不仅创造了一个以框架形式出现的大作,而且还维护了一个可重用模块的仓库,称为 Marketplace。
你可以根据需要下载各种模块,并将其直接插入到你的应用程序中。你可以将你的模块贡献回 Marketplace,这样其他社区用户就可以从该模块中受益。这是 JHipster 提供的一个伟大平台,用于与社区分享可重用代码。
摘要
这确实是一次伟大的旅程,探索了一个用于构建强大 Web 应用程序的新框架。JHipster 是一个真正出色的工具,可以快速构建现代和可生产的应用程序。由于许多事情都是自动完成的,JHipster 不仅使开发者的工作变得更简单,而且提高了整体项目交付进度。
在本章的开头,我们探讨了 JHipster 作为框架的基础知识,以及安装指南。继续前进,你学习了 JHipster 如何通过问答方式生成应用程序代码。它使用另一个名为 Yeoman 的工具来生成应用程序代码。
将领域对象作为实体实现并支持完整的 CRUD 操作是任何应用程序最重要的部分。在下一步中,我们学习了 JHipster 如何通过实体生成来支持领域对象的建模。实体生成可以通过三种选项来完成:基于 Yeoman 的经典选项、UML 方法以及使用 JDL-Studio。我们已经详细地研究了它们。
在介绍完这些特性后,我们开始创建一个应用,用于展示按国家划分的 GDP 信息。这要求我们构建自定义界面,并发现了如何在 JHipster 生成的应用中添加自定义功能。我们还收集了在不同层次(包括持久层、服务层、REST 控制器和前端层)生成的代码的一些细节,这将帮助我们适应任何未来的自定义需求。
在接近尾声时,我们查看了一些未见过的特性,展示了 JHipster 是一个多么稳健的平台,以及它如何使我们能够轻松地构建基于 Spring 的企业级应用。在下一章中,你将学习如何创建一个基于 Spring 的微服务架构应用,以及这与单体应用相比有何不同,以及它可能带来的好处。
第六章:创建一个在线书店
以分层方式开发任何 Web 应用程序总是有益的。一个突出的 n 层(或有时 3 层)解决方案是分层架构,它已被广泛采用作为设计大多数应用程序的标准模式。这并不是一个新概念,我们已经使用了很长时间。
分层架构将整个应用程序划分为各种关注层,这些层在逻辑上是相互不同的:
-
表示层:这一层包含负责构建用户界面或向最终用户显示输出的工件
-
应用层:这一层包含业务逻辑,并与表示层和持久层交互,以构成应用程序的流程
-
持久层:这一层将数据存储在数据库、文件系统和外源等数据存储中,并检索它
分层架构有许多优势,如可重用性、灵活性和一致性增加,因为层在逻辑上是分离的。使用分层架构构建的应用程序本质上是自包含的,并且可以独立于其他应用程序执行。它们被称为单体应用程序。
自本书开始以来,我们一直在创建一个单体应用程序来展示和探索各种概念。尽管在逻辑上被分离为 n 层架构,但随着时间的推移,单体应用程序在规模和复杂性达到一定程度后,将面临维护噩梦。这样的应用程序将所有功能和功能都包含在一个单一包中,该包被封装为单个可部署单元。
另一方面,微服务是一种不同的架构方法,由谷歌、亚马逊、Netflix 和其他许多组织采用,旨在满足现代商业需求并开发本质上复杂的应用程序。微服务架构有助于解决单体应用程序出现的各种问题。
微服务的概念并不新鲜;它已经崭露头角,以克服先前架构的限制。在本章中,我们将通过开发一个在线书店应用程序并探讨以下主题来密切观察微服务架构的演变:
-
微服务架构简介
-
微服务架构的原则和特性
-
设计微服务前端的各种方法
-
定义数据库结构
-
探索各种 Spring Cloud 和 Netflix OSS 组件以设计微服务
-
制作一个安全的微服务应用程序
技术要求
本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Spring-5.0-Projects/tree/master/chapter06。代码可以在任何操作系统上执行,尽管它只在 Windows 上进行了测试。
微服务介绍
微服务已成为一种有希望的架构模式,被广泛接受作为解决现代商业趋势的方案。企业使用各种渠道,如移动平台、大数据分析和社交媒体互动,作为增长业务和快速寻找新客户的元素。有了所有这些元素,组织正在尝试设计创新,以帮助他们获得强大的市场份额,这是一个使用传统交付方法相当难以实现的目标。
在过去,企业为满足其业务需求开发了单一的单体和累积应用程序。如今,这种状况已经改变,因为目标已经转向开发一个具有短周转时间、专注于特定业务需求的智能解决方案。
一个很好的例子是,一家旅游公司使用单一的单体应用程序来执行其业务。如果他们想通过根据用户搜索或更具体地说,根据他们的旧旅行、任何特殊场合或节日季节、用户偏好或兴趣来建议新的旅行想法来改善客户体验,会怎样呢?
在许多场景中,组织希望为这些用例中的每一个实施独立解决方案,并将它们集成到核心业务逻辑中,而不是将所有内容作为一个单一应用程序来维护,这意味着他们必须不断更新和测试整个应用程序以应对未来的业务变化,如下面的图示所示:

而不是将这些独立的功能与核心业务逻辑一起集成,它们可以独立插入。这种方法在允许以较低采用成本进行新更改方面更加灵活。此外,它们可以独立且更有效地进行测试。任何进一步的更改都可以轻松适应。
这样的业务场景需要一个能够以最小的影响和成本适应变化的架构,这使得它更加敏捷。这就是为什么开发微服务方法的原因。
微服务架构专注于以小部分设计应用程序,每个部分都关注特定的功能,而不是将整个应用程序作为一个单体架构中的黑盒。
在过去几年中,技术范式的革命彻底改变了我们开发应用程序的方式。这包括前端层,有各种流行的框架,如 Angular、React、Backbone 和 Bootstrap,它们完全改变了用户界面。
随着云意识和容器机制的出现,设计和实现中间层的方法受到了影响。这还包括了从使用关系型数据库到 NoSQL 的设计方式的改变,从而解决了特定的架构问题。
微服务架构
随着时间的推移,架构风格已经显著改进。各种架构模式,如主机、客户端-服务器、多层和面向服务的架构(SOA),在历史上的不同时期都曾流行。然而,这些架构都涉及开发某种类型的单体应用程序,无论是直接还是间接。
随着技术栈的革命发生,微服务架构由于所有先前架构的改进而变得突出。目标是提供敏捷性,减少采用新更改的周转时间,实现可扩展的性能,并充分利用现代工具和框架。
微服务架构将应用程序分解成小的、独立的子系统。它们也可以被称为系统中的系统,如下面的图所示:

尽管在单体架构中,组件存储在不同的逻辑层中,但它们被封装在单个应用程序结构中。另一方面,微服务系统是一组独立的子系统,每个子系统都封装在自己的项目结构中,并以独立单元的形式部署。你可以将微服务架构视为一个拼图,其中每个微服务都是整个应用程序的构建块。
简而言之,在单体系统中,组件在逻辑上是不同的,但属于单个物理应用程序的一部分,而在微服务架构中,子系统是实际的物理应用程序,它们形成一个巨大的逻辑应用程序。
微服务架构现在广泛用作一组标准,用于重构单体应用程序。它起源于六边形模式,微服务架构促进了将业务功能封装到单个独立的单元中,该单元与其他功能隔离。
六边形架构将输入和输出放在六边形的边缘,并将业务逻辑保持在中心。这种安排将应用程序与外部关注点隔离开来,如下所示:

内部部分由业务逻辑组成,而外部部分包括 UI、消息传递、REST、数据库和其他内容。外部部分可以互换,而不会影响核心应用程序功能。每个业务功能都可以使用六边形模型进行设计,然后通过标准通信机制与其他部分交互。
让我们通过一个例子来观察六边形模式。假设你正在开发一个 EMI 计算器应用程序,该应用程序根据总贷款金额、利率和期限计算本金和利息金额。该应用程序保留扫描用户输入以计算贷款数据的功能。获取用户输入的逻辑与 EMI 计算器应用程序紧密相关。经过一段时间后,另一个应用程序需要使用 EMI 计算器应用程序。在这种情况下,输入机制需要更新。
为了克服这个问题,六边形模式建议通过定义某种标准接口来隔离 EMI 计算逻辑和输入接收机制。这样,EMI 计算器完全不知道输入来自哪里。在这种情况下,接收输入的接口被称为端口,而其实施被称为适配器,如下所示:

六边形模式也被称为端口和适配器模式。微服务架构的概念源于六边形模式,其中每个六边形结构代表一个自包含且松散耦合的服务(子系统)。添加更多的六边形模型相当于添加更多的独立服务。
正因如此,微服务概念在逻辑上被比作蜂巢,其中多个六边形结构形成一个庞大而坚固的结构。同样,单个服务(相当于单个六边形结构)组成一个更大的应用程序。
微服务原则
虽然没有直接的定义或标准来定义微服务,但在设计微服务应用程序时,必须考虑某些质量、标准和原则。让我们在本节中查看其中的一些。
单一责任的高内聚
高内聚意味着一个模块或单元应该执行单一的业务功能。在微服务架构中,单个服务应该为特定的应用程序上下文承担单一责任。不允许多个服务之间共享单一责任。此外,单个服务也不应处理多个责任,以便使整个系统真正模块化。
这是单体架构和微服务架构之间显著的不同之一。组件在逻辑上是分离的,但仍然是单个应用程序的一部分,并且在前者中共享一些共同的责任,但在后者中设计为独立的小型应用程序。
在设计微服务时,目标应该是微服务执行的业务功能的范围,而不是使其更小。术语微有时具有误导性,暗示你应该使服务尽可能小。应该优先考虑范围,而不是服务的大小。
服务自治
在构建微服务应用程序时,主要目标是使每个成员(服务)成为一个独立且独立的构建块。为了确保每个服务以最佳性能运行并提供可靠的输出,它们必须完全控制它们使用的底层资源。
这可以通过服务自治原则来实现。它建议每个服务都应该以自治为设计目标。通过这样做,服务对其执行环境的控制和所有权将更加有效,这在单体架构中共享资源时是难以实现的。这将极大地帮助处理应用程序的可伸缩性。
松耦合
应该以微服务架构的方式设计应用程序,使得每个单元(服务)应该对其他组件或服务的影响(理想情况下)为零或最小。如果服务之间需要任何形式的交互或通信,它们也应该在本质上松耦合。这就是为什么同步调用 RESTful API 或异步调用消息框架更可取的原因。
通过封装隐藏实现
微服务必须将底层实现细节与外部世界隔离,并定义一个标准接口来与之交互。这不仅会降低复杂性,还会增强轻松适应新变化的能力,使整个系统更加灵活和健壮。
领域驱动设计
领域驱动设计(DDD)是一种根据应用程序中使用的实际领域模型来设计系统的方式。DDD 的架构风格用于以独立单元开发应用程序,每个单元代表一个特定的领域模型。它还建议了领域模型之间行为和通信的方式。理想的 DDD 具有开发模块化应用程序所需的所有品质。因此,它是实施微服务架构的理想候选方案。
微服务特点
下面是微服务架构的一些特点:
-
这是一种将应用程序设计为一组小服务的方式,每个服务在自己的流程中执行。
-
微服务可以通过 HTTP API 或某些时间消息机制(如 AMQP 或 JMS)等内部交互。
-
每个微服务都是构建来执行特定的业务需求的。换句话说,它们与特定的业务需求或能力对齐。
-
微服务可以独立部署,并使用自动化机制。
-
需要某种形式的通用或中心化流程来管理微服务,这些微服务可能或可能不使用与单个微服务相同的技术栈。
-
微服务独立管理它们的生命周期。
-
对一个微服务的更改不会影响其他微服务,因为它们是独立运行的。
基于 Spring Cloud 的微服务
通常,微服务被设计为部署在分布式系统中。在分布式环境中存在一些常见的模式。Spring Cloud 提供了一系列预定义的模式实现,我们可以使用它们快速构建微服务应用程序。它们被认为是 Spring Cloud 的子项目。我们将简要地看看其中的一些,同时也会看到如何在开发我们的在线书店应用程序时使用它们。
配置管理
配置是任何应用程序的一部分,在 Spring 世界中,它们通常以属性文件的形式存在,通常与应用程序代码捆绑在一起。每次配置更改时部署整个服务是一项繁琐的工作。如果配置可以在应用程序外部进行管理会怎样?这是一个好主意,因为外部管理配置允许我们在不部署甚至重启服务的情况下反映更改。这正是配置管理所做的。配置可以即时进行。
服务发现
正如我们所见,微服务应用程序是一组自包含且独立部署的服务,它们运行在相同的或不同的物理机器上,或在云上。每个服务都可以被视为一个执行特定职责的独立进程。
虽然它们在执行不同的业务功能方面是分离的,但作为整个应用程序的一部分,它们是相互关联的,因此需要某种通信机制,并且要有明确的标准。
对于进程间通信以及访问特定服务,我们需要知道服务的端口和 IP 地址。传统的单体应用程序通常使用静态端口和 IP 地址进行部署和访问。此外,它们通常在一个单独的包中部署,这样所有组件/服务都可以使用相同的端口和 IP 地址进行访问。端口和 IP 地址更改的可能性也非常低。
相比之下,微服务应用程序在本质上具有分布式特性,可能部署在不同的机器或云中。此外,可以通过添加更多服务实例来提高系统的可伸缩性。在未来,可能会动态地添加新服务。因此,微服务的位置是动态的。
Spring Cloud 提供了一个服务发现功能,实际上用于在分布式环境中定位服务。Spring Cloud 默认提供基于 Netflix Eureka 的发现服务。或者,我们也可以使用 Consul、Cloud Foundry 或 Apache ZooKeeper 与 Spring Cloud 结合作为服务发现支持。
熔断器
尽管微服务被设计来处理单一职责,但它们有时需要依赖其他服务来执行属于他人的操作集。在这个依赖通道中,如果某个服务宕机,错误将会传播到同一通道上的其他服务。为了避免这种情况,Spring Cloud 提供了一个基于 Netflix Hystrix 的容错解决方案,这是电路断路器模式的一种实现。
路由
由于微服务的位置可以动态更改,因此需要一个路由机制来将请求发送到特定的服务端点。Spring Cloud 通过 Zuul——来自 Netflix 的另一个工具,它是一个用于路由目的的服务器端负载均衡器——提供了一个简单而有效的方法来路由具有高级横切能力(如安全、监控、过滤和身份验证)的 API。Zuul 还可以用作微代理,它使用配置的代理 URL 来路由应用程序。
另一个用于路由的组件是 Spring Cloud Gateway,它是 Spring 本地开发的。它基于 Spring Framework 5 构建,并且由于其与 Spring 的紧密集成,可能为开发者提供更好的体验。
Spring Cloud 安全
尽管微服务通过标准接口访问,但在某些用例中它们需要身份验证和授权。保护微服务系统比保护单体系统更复杂。Spring 通过 Spring Cloud Security 和 Auth2 协议支持微服务的身份验证,以在分布式环境中传播安全上下文。
分布式跟踪服务
在微服务架构中,应用程序流程可能需要通过多个服务调用的链来执行单个业务用例。手动使用多个微服务的日志跟踪活动并不是一个有效的解决方案。我们可能无法从中得到我们想要的结果。理解一系列服务调用之间发生的事情非常重要。如果出现问题时,这非常有帮助。Spring Cloud 通过 Spring Cloud Sleuth 提供了一种有效的方法来跟踪分布式系统中的应用程序流程。它收集调用跟踪数据,这些数据可以导出到 Zipkin——另一个用于可视化调用跟踪的工具。
Spring Cloud Stream
为了处理大量数据流,我们可能需要与消息代理实现(如 RabbitMQ 或 Apache Kafka)合作。Spring Cloud 通过 Spring Cloud Stream 提供了与消息代理的高层抽象的简单集成。因此,我们不必实际实现消息代理,Spring Cloud Stream 将在运行时根据其配置处理消息并将它们传递给实际的代理客户端。这使得代码可移植,并且与任何消息代理实现松散耦合。
开发在线书店应用程序
现在我们已经了解了微服务架构,让我们现在进行一个实际练习,以更详细地了解这个概念。我们将遵循微服务模式来开发一个简单的在线书店应用程序。
应用程序架构
我们需要首先设计应用程序的架构。在设计基于微服务的应用程序时,我们首先需要考虑一个单体应用程序,然后推导出各种相互独立的部分或组件,这些部分或组件可以被视为可能的独立微服务候选。
我们将根据前几节中提到的标准,如单一职责、服务自治、松耦合、封装和 DDD,将应用程序分解成小部分,具体如下:
-
用户管理
-
订单管理
-
目录管理
-
库存管理
它们被认为是独立的领域或业务功能。我们将为每个领域创建独立的微服务,并采用以下高级架构:

数据库设计
在将应用程序分解以采用微服务架构时,我们还需要重新思考数据库设计。在分布式环境中,数据库设计有多种选择。
所有微服务的单一单体数据库
在这种方法中,尽管微服务被独立设计为单独的子系统,但它们仍然共享一个单一的单体数据库,如下所示:

每个微服务都有自己的表集,但它们都是单一数据库模式的一部分。这种选择的明显优势在于简单性,因为单个数据库可以轻松操作。此外,事务可以以更一致的方式进行。
然而,根据最佳实践,微服务应该是独立可部署的,以获得更好的扩展优化。独立可部署的另一个好处是快速采用变化。一旦多个服务依赖于单个单体数据存储,这种灵活性就会降低,无法充分利用分布式环境,如高内聚和松耦合。
此外,多个团队通常在应用程序方面工作。在处理数据库变更时,他们还需要面对与其他团队的耦合。这将减缓开发速度,并最终导致交付延迟。因此,这不是一个理想的场景。
分离的服务来处理数据库交互
在这个场景中,我们不会与所有服务共享一个公共数据库,而是一个单独的服务将仅与数据库交互。所有其他服务都将通过这个服务进行任何数据库操作,而不是直接连接到数据库,如下所示:

虽然管理数据库相关操作的依赖关系已转移到单独的服务中,但这仍然是一种类似单体式的方法,并且具有第一个选项的所有局限性。因此,这也不是为微服务架构设计数据库的优化方式。
每个微服务都有自己的数据库
此选项为每个独立服务提供单独的数据库,如下所示:

与服务之间共享单个数据库不同,每个服务的数据库是该服务的一个组成部分,并且不能被其他服务直接访问。这种选择的一个灵活性在于,每个服务都可以选择最适合其能力的数据存储类型。例如,如果你有一个搜索服务,要在系统中执行搜索,你可以使用Elasticsearch作为数据存储。
此模型有两个进一步的选择:
-
每个服务独立的数据库架构:仍然使用单个数据库服务器,但为每个微服务有一个独立的架构。此选项使所有权更清晰,并且对于大多数情况来说是一个理想的选项。
-
每个服务独立的数据库服务器:为每个微服务设计独立的数据库服务器。对于需要高吞吐量的服务,可以考虑此选项。
为了简化,我们将使用 MySQL 来存储数据。根据系统架构,每个微服务将有一个独立的数据库架构。
用户架构
此架构包含存储用户相关数据的表。用户表包含特定于用户的数据,这些数据将用于身份验证,而配送地址表包含配送地址信息:

用户表和配送地址表之间存在一对一的关系。
订单架构
此架构包含两个表,订单和订单项。它们之间的关系如下:

订单表包含每个订单的通用详细信息,如 orderId、userId、订单日期、总金额和配送地址。订单项表保存单个项目的详细信息。
目录架构
此架构包含产品详情。由于这是一个在线书店应用程序,书籍表包含书籍的详细信息。类别和出版社表分别包含关于类别和出版社的详细信息。这些表之间的关系如下:

书籍表与类别和出版社表之间存在多对一的关系。
库存架构
每个商店都有一个库存。此架构存储包含关于书籍信息的库存。有两个表存储此信息。库存表包含产品的当前库存(在我们的例子中是书籍),而库存历史表显示了将新书添加到系统中的历史记录:

这些表之间没有关系。
使用 Spring Boot 创建微服务
我们将开发一个在线书店应用程序,该应用程序具有小型且可独立部署的微服务架构,这些微服务可以由各个团队开发。预计它们将以快速的开发周期完成。这正是 Spring Boot 的设计目的。它是一个用于在短时间内创建生产级 Spring 企业应用程序的工具,配置简单。
我们将首先使用 Spring Boot 开发单个服务以快速开发它们。Spring Cloud 也与 Spring Boot 具有坚实的集成能力。在开发微服务时,Spring Boot 负责所有底层事情,使我们能够专注于业务逻辑。
首先,我们将为特定功能创建 Spring Boot 应用程序。一旦创建,我们将在每个应用程序中添加微服务特定的功能:
-
user-service: 此服务旨在执行与用户相关的各种操作,例如注册、登录和用户交互 -
inventory-service: 此服务仅由管理员执行各种库存操作 -
catalog-service: 此服务负责管理目录信息,例如添加书籍、类别和出版社详情 -
order-service: 此服务处理用户下订单
当使用 spring-io 初始化器或Spring Tool Suite(STS,由 Spring 提供的基于 Eclipse 的 IDE)创建应用程序时,最初我们将添加以下依赖项:
-
DevTools: 通过添加自动部署/重启功能作为 Maven 依赖项来提高开发时间体验。
-
JPA: 这将添加一个特定于 JPA 的启动依赖项,用于添加 JPA 功能。我们将使用 JPA(Hibernate 实现)与数据库交互。
-
MySQL: 这将添加一个 MySQL 连接器 JAR 以连接到数据库。
-
Web: 这用于向应用程序添加 Spring MVC 功能。我们将使用 REST 控制器来访问单个微服务应用程序。
添加微服务特定功能
我们为每个单独的功能创建了各种 Spring Boot 应用程序。它们默认情况下都可以逐个访问(在端口8080上)。然而,它们还没有准备好作为微服务执行。现在,我们将通过在每个 Spring Boot 应用程序的pom.xml文件中添加依赖项条目来添加微服务特定的功能。
在pom.xml的dependencies部分添加以下条目:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
您还需要在<java-version>条目旁边添加当前版本的 Spring Cloud 条目,如下所示:
<spring-cloud.version>Greenwich.RC2</spring-cloud.version>
在pom.xml的dependencies部分完成后添加以下条目:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
在我们开发的四个 Spring Boot 应用程序中都要进行这些更改。应用这些更改后,它们将不再作为独立的 Spring Boot 应用程序运行,因为我们现在正朝着微服务架构迈进。
开发服务发现服务器
在微服务架构中,真正的挑战是访问特定的服务,因为它们是动态创建和销毁的,所以它们的位置不断变化。此外,我们还需要某种类型的服务间通信,以满足跨越微服务的某些业务用例。此外,每个微服务的多个实例可以创建以扩展应用程序的性能。
在这种情况下,必须有一种机制来定位微服务。Spring Cloud 提供了一个基于 Netflix Eureka 的服务发现组件来实现这一目的。微服务可以自行注册到发现服务器,以便其他服务可以访问和与之交互。Eureka 服务器基本上用于发现、自我注册和负载均衡。
接下来需要创建一个基于 Eureka 的服务,该服务充当服务发现服务器。创建基于 Eureka 的发现服务类似于仅通过少量配置更改创建 Spring Boot 应用程序。使用以下数据创建一个新的 Spring Starter 项目:

在下一屏幕上,选择“云发现”选项下的 Eureka 服务器作为依赖项,然后点击完成。一旦项目创建完成,打开 bootstrap 类,并按照以下方式添加突出显示的代码:
@SpringBootApplication
@EnableEurekaServer
public class EurekaDiscoveryServiceApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaDiscoveryServiceApplication.class, args);
}
}
默认情况下,Eureka 服务器选项未启用。使用 @EnableEurekaServer 注解使其对应用程序生效。这意味着该应用程序将作为一个 Eureka 发现服务器运行。接下来,我们将在 application.properties 文件中添加某些属性,如下所示:
#Configure this Discovery Server
eureka.client.registerWithEureka = false
eureka.client.fetch-registry=false
#In case if Eureka port need to be changed from default 8080
server.port = 8761
默认情况下,当前的 Eureka 服务器也是一个 Eureka 客户端,并会尝试将自己注册为 Eureka 服务器上的 Eureka 客户端。由于我们希望这个应用程序仅作为服务器运行,我们需要显式设置 eureka.client.registerWithEureka 属性为 false。默认情况下,Eureka 服务器通过端口 8080 可访问,并且可以使用 server.port 属性进行更改。
每个 Eureka 客户端将从 Eureka 服务器获取注册详情。在我们的案例中,我们不想获取注册详情,因此,我们显式设置 eureka.client.fetch-registry 属性为 false。现在运行应用程序,Eureka 服务器可通过 http://localhost:8761 访问。它将显示服务器详情和所有注册的服务详情,如下所示:

目前,我们的 Eureka 发现服务器上尚未注册任何服务,因此在“当前注册到 Eureka 的实例”部分中显示无内容。
Eureka 服务器可以以独立模式或集群模式启动。为了简单起见,我们选择了独立模式。
接下来,我们需要将我们开发的四个微服务注册到 Eureka 发现服务器。我们已经为它们添加了微服务特定的依赖项。现在,我们需要添加 Eureka 客户端配置。由于我们配置了 Eureka 服务器的方式,我们需要在每个服务的 bootstrap 类中配置 Eureka 客户端。例如,用户服务 bootstrap 类的 Eureka 客户端配置如下以粗体突出显示:
@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
@EnableDiscoveryClient 注解将启用客户端配置。此外,我们还需要在 application.properties 文件中添加以下属性:
spring.application.name=user-service
server.port=8791
spring.application.name 属性将用于将应用程序注册为具有特定名称。添加客户端配置并启动其他服务,你将看到它们如下注册到 Eureka 服务器:

另一个注解 @EnableEurekaClient 也可以用于 Eureka 客户端配置。@EnableDiscoveryClient 和 @EnableEurekaClient 之间的区别在于前者更具有 Spring 意识,并且与 Eureka 之外的发现实现(如 Consul 和 ZooKeeper)一起工作;后者仅针对 Eureka。因此,如果类路径中有 Eureka,它们之间没有区别。
Spring Cloud Eureka 由客户端和服务器组件组成。所有微服务都注册在服务器注册表中,而每个单独的服务都表现为客户端。任何想要发现其他服务的服务也应该对 Eureka 客户端有意识。注册在服务器上发生时,使用客户端标识(及其名称)和 URL(及其端口)。
客户端与服务器之间的通信流程如下:
-
在启动微服务时,它将联系服务器组件并提供注册所需的元数据。
-
Eureka 服务器验证元数据并进行注册。
-
注册后,微服务端点每 30 秒(默认值)向服务器注册表发送 ping 请求以标记其存在。
-
服务器将不断验证 ping 请求,如果在一定时间内没有请求,它将自动从注册表中删除该服务。
-
服务器将注册信息与所有 Eureka 意识的客户端共享,并将它们存储在本地缓存中。然后,微服务客户端使用这些信息在分布式环境中定位其他客户端。
-
服务器每 30 秒(默认值)将注册信息的更新推送到所有客户端。
-
在服务器上注册的微服务可以被分组到一个区域中。在这种情况下,区域信息可以在注册时提供。
-
当任何微服务向另一个微服务发送请求时,Eureka 服务器将尝试在同一区域内搜索运行的服务实例以减少延迟。
-
Eureka 客户端与服务器之间的交互通过 REST 和 JSON 进行。
设计 API 网关
在典型的微服务应用中,可能存在超过一百个微服务相互交互的情况。对于所有这些微服务,需要实现一些共同的特性:
-
安全性:我们可能需要检查认证和授权,或任何其他用于调用微服务的安全策略
-
限制调用频率:在给定时间内,只允许特定微服务进行一定数量的调用
-
容错:如果任何服务无法响应,则发送适当的错误信号
-
监控:用于监控微服务间传递的特定事件或值
-
服务聚合:在单个响应中提供多个微服务的聚合响应,特别是在带宽受限的环境中
-
路由:基于某些标准(例如,如果需要调用转发),将特定用户的所有调用路由到特定区域的服务
-
负载均衡:维护调用流以平衡服务实例的负载
除了这些,我们可能还想限制一些服务对最终用户的访问并保持其私有。为了实现这些目标,我们需要某种形式的 API 网关,它将拦截来自最终用户的所有调用以及所有服务间通信。因此,微服务现在将通过 API 网关直接通过 REST 调用相互交互,该网关将提供之前列出的所有功能。由于所有调用都通过 API 网关路由,它也可以用于调试和分析目的。
Spring Cloud 通过另一个名为Zuul的 Netflix 实现提供 API 网关支持。接下来,我们将看看如何设置 Zuul。
设置 Zuul 作为 API 网关
我们将创建一个 Zuul 代理服务器作为一个独立的 Spring Boot 服务,并将其注册到 Eureka 发现服务器。在 Spring STS 中创建一个新的 Spring 启动项目,并添加 Zuul、Eureka 发现和 DevTool 作为依赖项。
一旦创建,打开bootstrap类并按照以下方式更新:
@EnableZuulProxy
@EnableDiscoveryClient
@SpringBootApplication
public class ZuulApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ZuulApiGatewayApplication.class, args);
}
}
@EnableZuulProxy注解将使此服务成为 Zuul 服务器。我们还需要使用@EnableDiscoveryClient注解将其注册到 Eureka 发现服务器。每个注册到 Eureka 名称服务器的服务都需要一个名称(以及一个端口)。在application.properties文件中添加以下详细信息以设置 Zuul 服务器:
spring.application.name=zuul-api-gateway
server.port=8781
现在 API 网关服务器已经准备好并配置好了,但我们没有指定在拦截请求时应该做什么。Zuul 通过各种过滤器提供请求处理支持。它们被分类为预、后、路由和错误过滤器,每个都针对特定的服务调用生命周期。由于 Zuul 是基于 Spring Boot 的服务,我们可以通过编程方式自定义 API 网关。此外,对于任何特殊需求,Zuul 支持开发自定义过滤器。我们将很快看到如何添加自定义过滤器,并检查如何使用它拦截请求。
Zuul API 网关服务器也被称为边缘服务器。
设计 UI
正如我们所见,微服务架构最适合现代大型且本质上分布式的应用。这种架构有助于将单一单体应用的团队拆分为一组小型且独立的团队,专注于单个模块或功能。
微服务模式有其自身的优势,例如管理可伸缩性和复杂性,以及在短时间内轻松采用新变化。我们已经探讨了 Spring Cloud 组件如何帮助在 Spring 框架中构建分布式应用。到目前为止,我们只讨论了中间和后端层。本节专门讨论一个有趣的话题:如何设计微服务前端。
与单体架构不同,微服务应用的前端可以采用不同的方法,如下所示:
-
单体前端
-
微前端
-
组合或模块化前端
单体前端
虽然微服务模式将单体后端划分为多个独立的服务,但对于前端来说这可能并不简单。在单体前端方法中,我们将整个用户界面保持在单个大型前端应用程序中,该应用程序将通过 REST 调用与对应的服务通信以执行任务或向最终用户展示数据,如下所示:

这种方法的明显优势是实现简单和整个应用中的 UI 一致性,因为所有内容都在一个地方。缺点是,由于多个团队正在单个 UI 应用上工作,可能会出现大量关于库版本、样式等问题上的冲突。
由于所有内容都在一个屋檐下,随着应用的扩展,采用变化变得更加困难。在一段时间内,当业务需求增加时,维护应用的 UI 变得更加困难,因为多个团队大部分时间都在解决问题。
只有当你确信应用仅被划分为几个具有有限未来增长范围的微服务时,才选择这种方法。
微前端
在这种方法中,每个微服务都有自己的 UI,限于其执行的功能。因此,除了后端之外,前端也根据单个微服务的范围进行分解,如下所示:

这种方法消除了单体前端的所有限制,但同时也引入了一些新的挑战。尽管微服务被分割成自包含和独立的可执行单元,并且最终的前端应该以单个接口呈现。在微前端方法中,挑战在于将单个微服务的 UI 组合成单一格式。有几种实现方式。尽管它们克服了第一种方法的限制,但同时也引入了一些其他问题:
-
在微服务之间同步 UI:通过这种方式,只需将所有服务的 UI 复制粘贴到对方,并使用 API 网关。尽管这似乎很简单,但它会产生巨大的维护问题。
-
IFrame:使用一个单独的布局,其中可以结合单个微服务的输出和 IFrame。然而,这种方法也不完美,因为 IFrame 有其自身的限制。
-
HTML 片段:你可以编写自己的 JavaScript 代码,并通过 HTML 片段粘合微服务的内 容。然而,存在一些限制,并且你还需要自己编写大量的自定义脚本。此外,还可能存在服务脚本和样式的冲突。
复合前端
这种方法是一种微前端方法,具有正确聚合微服务输出的解决方案。布局将通过单个 UI 应用程序创建,而各个微服务的业务 UI 将通过 Web 组件插入,如下所示:

每个微服务负责在页面上生成一个小 UI 区域。可以通过在流行的前端框架(如 Angular 和 React)中创建组件来轻松设计复合 UI。在此基础上,single-spa(single-spa.js.org)框架被设计用来展示聚合输出。它基本上是一个 JavaScript 库,将微服务的复合输出作为一个在浏览器中运行的单一页面应用程序显示。Single-spa 允许不同框架开发的微服务共存。
这意味着你可以用 Angular 开发一个微服务,用 React 开发第二个,用 Vue 开发第三个,以此类推。这带来了极大的灵活性,并实现了微服务架构完全独立于后端到 UI 的开发目标。作为第二种方法的增强版本,复合前端方法不仅克服了单体前端方法的限制,而且还提出了在微服务架构中开发前端层的正确方式。
其他 Spring Cloud 和 Netflix OSS 组件
Spring Cloud 在微服务应用中广泛使用的各种 Netflix 组件之上提供了一个包装器。我们已经探讨了 Eureka 发现服务器和 Zuul。在本节中,我们将探讨更多 Netflix OSS 组件,以及 Spring Cloud 的其他功能。
Spring Cloud 中的动态配置
如我们所知,微服务架构由许多小型且可独立部署的微服务组成,它们处理最终用户调用并相互交互。根据项目需求,它们可能运行在各种环境中,如开发、测试、预发布、生产等。为了提高应用程序的扩展能力,可能配置了多个微服务实例与负载均衡器一起工作。
每个微服务都有一组配置,包括数据库配置、与其他服务的交互、消息代理配置和自定义配置。在各个环境之间处理微服务配置是微服务架构中最具挑战性的部分之一。
手动维护每个微服务的配置对于运维团队来说太复杂且困难。最佳解决方案是将配置从每个微服务中分离出来,并将它们全部维护在一个中央位置。这样,可以更有效地处理环境对配置的依赖。
Spring Cloud 提供了一个名为Spring Cloud Config的组件,用于外部化微服务配置。它使用 Git 仓库将所有配置存储在一个地方,如下面的图所示:

我们将使用 Spring Cloud Config 功能创建一个用于集中配置的独立服务。接下来的几节将解释如何做到这一点。
第 1 步 – 创建配置服务器的 Spring Boot 服务
这是将组件作为 Spring Boot 应用程序创建的最直接方法。使用 STS 创建一个应用程序,并选择 DevTool 和 Config server 依赖项。一旦项目创建完成,你可以在pom.xml中看到负责将 Spring Cloud Config 功能添加到 Spring Boot 应用程序的依赖项,如下所示:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
接下来,让我们启用 Spring Cloud Config 功能。打开主bootstrap类,并添加@EnableConfigServer注解,用于启用外部配置:
@EnableConfigServer
@SpringBootApplication
public class SpringCloudConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(SpringCloudConfigServerApplication.class, args);
}
}
由于配置服务器也将注册到命名服务器(Eureka 服务器),我们需要在application.properties文件中定义某些属性,如下所示:
spring.application.name=spring-cloud-config-server
server.port=8901
接下来,让我们安装和配置 Git。
第 2 步 – 使用 Git 仓库配置 Spring Cloud Config
Spring Cloud Config 服务器已准备就绪。现在,我们需要为 Git 仓库集成进行必要的配置。首先,在你的本地机器上安装 Git 的最新版本(git-scm.com/downloads),并确保它在某个路径上可用。在你的本地机器上创建一个目录,并使用以下命令初始化 Git 仓库:
git init
这将在本地机器上创建一个空的 Git 仓库。接下来,添加一个包含配置细节的属性文件。由于配置服务器可以在 Git 中为多个微服务存储配置细节,因此我们需要遵循一定的命名约定来命名属性文件,如下所示:
{microserivce-name}.properties
{microservice-name}-{active-profile}.properties
正如我们所见,微服务的 ID(或名称)是在 application.properties 文件中通过 spring.application.name 属性定义的。因此,我们需要在 Git 仓库中创建一个具有该名称的属性文件。例如,如果微服务名称是 catalog-service,那么你需要创建一个 catalog-service.properties 文件,并存储该微服务的所有配置。
对于不同的环境,你可以在微服务名称后附加一个活动配置文件来创建一个属性文件。例如,开发环境的属性文件名称将是 catalog-service-dev.properties。一旦创建了文件,添加配置细节并将它们通过以下命令提交到 Git 仓库:
git add -A
git commit -m "Comment for commit"
Git 仓库现在已准备就绪,因此我们需要将配置服务器指向它。按照以下方式打开配置服务器的 application.properties 文件:
spring.cloud.config.server.git.uri=file://D:/projects/book_writing/git_central_config
由于这是一个本地 Git 仓库,因此使用 file:// 来指定仓库文件夹的位置,以便指向本地文件系统。配置服务器还允许使用远程 Git 仓库进行配置。在这种情况下,你需要为 spring.cloud.config.server.git.uri 属性提供一个类似 https://github.com/<<accoun-tname>>/<<repo-name>>.git 的 Git 克隆 URL。
我们将添加一些示例配置,并查看它们如何反映在相应的微服务中。创建一个 service.properties 文件,并添加以下属性:
catalog.sample.data=Hello world !!
第 3 步 - 使用 Spring Cloud Config Client 组件使每个微服务 Spring Cloud Config 兼容
最后一步是对微服务(配置客户端)进行必要的更改,以便在 Git 仓库中更新配置后,配置服务器将传播配置。此时的重要点是创建一个名为 bootstrap.properties 的新属性文件,并将所有属性从 application.properties 文件复制过来,或者你可以直接将 application.properties 重命名为 bootstrap.properties。
原因是 Spring 会首先处理 bootstrap.properties 文件,甚至在与它链接的引导应用程序和配置服务器进行配置更新之前。你需要在 bootstrap.application 文件中添加一个特定的属性,该属性将用于将微服务与配置服务器连接,如下所示:
spring.cloud.config.uri=http://localhost:8901
配置服务器可通过 http://localhost:8901 访问。微服务将使用此 URL 获取配置细节。接下来,我们将通过 REST 控制器在微服务中访问我们在 Git 仓库中声明的配置,如下所示:
@RestController
@RequestMapping("/api/test")
@RefreshScope
public class TestRESTController {
Logger logger = LoggerFactory.getLogger(this.getClass());
@Value("${catalog.sample.data}")
private String data;
@GetMapping("/getSampleData")
public String getSampleData() {
logger.info(" sample data value is -->"+this.data);
return this.data;
}
}
在这个控制器中,我们使用 @Value 注解通过 catalog.sample.data 访问配置。这个注解用于读取在本地 application.properties 文件中定义的属性。神奇的是,我们没有为分类服务定义任何这样的属性,但它将连接到配置服务器并从 Git 仓库内部获取这个属性值。
@RefreshScope 注解将在 Git 仓库中任何变更发生时用于获取最新的配置值。您需要为读取配置值的组件声明 @RefreshScope。当您启动 catalog-service 微服务时,它将尝试从配置服务器读取配置,您可以从以下日志中验证它:
[restartedMain] c.c.c.ConfigServicePropertySourceLocator : Fetching config from server at : http://localhost:8901
在启动时,catalog-service 微服务将通过配置服务器从 Git 获取配置。这可以通过 http://localhost:8792/api/test/getSampleData REST URL 进行验证。当我们对配置进行更改并将其提交到 Git 时,它们必须传播到微服务。这不会自动完成,您需要使用 Spring Boot 提供的监控和管理应用程序的工具 Actuator 手动刷新。我们将使用 Actuator 的 /refresh 端点来刷新微服务,以获取最新的配置更改。
从 Spring Boot 2.0 开始,Actuator 的某些端点(包括 /refresh)默认未启用。要启用它们,您需要将以下属性添加到 catalog-service 微服务的 bootstrap.properties 文件中:
management.endpoints.web.exposure.include=*
现在,所有端点都可用,可以通过向 http://localhost:8792/actuator/refresh 发送 POST 请求来完成配置传播。由于这是一个 POST 调用,您需要使用 Postman 等 REST 客户端。刷新完成后,您将看到以下输出:
[
"config.client.version",
"catalog.sample.data"
]
这就是如何在无需重新启动微服务的情况下即时应用配置的方法。整个过程可以按以下顺序执行一系列操作:
-
更新 Git 仓库中的文件
-
执行 Git 提交
-
执行刷新操作,您将看到相应的微服务中反映了更改
这是管理配置的一个很好的特性,它可以轻松地应用于特定的微服务。然而,并非所有属性都可以这样应用。例如,应用程序名称和特定于数据库的属性不能通过 Spring Cloud Config 在运行时应用。但是,可以动态地应用自定义配置。
使用 Feign 在微服务之间进行 RESTful 调用
在微服务架构中,通常微服务通过 HTTP REST Web 服务调用来相互交互。通常,RestTemplate在基于 Spring 的应用程序中用作客户端,以编程方式执行 REST API 调用。然而,它需要大量的代码来执行简单的 REST 调用。为了简化这一点,Spring Cloud 提供了 Feign,这是另一个 REST 客户端,它使 REST 通信比RestTemplate简单得多。让我们看看 Feign 如何使调用其他服务变得容易。
例如,inventory-service需要与catalog-service微服务通信以获取书籍详情。在这种情况下,inventory-service将发出 REST 调用以获取给定bookId的Book对象。这通常使用以下RestTemplate客户端发生:
@GetMapping("/get-inventory/{bookId}")
public ResponseEntity<BookDTO> getInventory(@PathVariable("bookId") Integer bookId) {
String url = "http://localhost:8792/api/catalog/get-book/"+bookId;
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<BookDTO> returnValue = restTemplate.getForEntity(url, BookDTO.class);
return returnValue;
}
我们正在使用RestTemplate调用catalog-service微服务以获取给定bookId的书籍详情。Spring Cloud 从 Netflix 继承了另一个组件,称为Feign,它可以作为一个声明式的 REST 客户端,极大地简化了操作。它很容易与 Ribbon 集成,Ribbon 可以用作客户端负载均衡器;我们将在下一节中讨论这一点。要使用 Feign,您需要添加以下启动器依赖项:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
接下来,让我们启用 Feign。打开bootstrap类,并添加一个@EnableDiscoveryClient注解来扫描 Feign 客户端,如下所示:
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients(basePackages="com.bookstore.inventory")
public class InventoryServiceApplication {
public static void main(String[] args) {
SpringApplication.run(InventoryServiceApplication.class, args);
}
}
现在,我们将使用 Feign 来调用服务。我们需要创建一个 Feign 代理来与其他服务通信,就像我们使用 JPA 仓库与数据库交互一样。以下是如何使用 Java 创建 Feign 代理:
@FeignClient(name="catalog-service",url="http://localhost:8792", path="/api/catalog")
public interface CatalogServiceProxy {
@GetMapping("/get-book/{bookId}")
public ResponseEntity<BookDTO> getInventory(@PathVariable("bookId") Integer bookId);
}
@FeignClient注解用于定义 Feign 代理。name属性指向 Eureka 命名服务器中声明的目标微服务的名称(在application.properties或bootstrap.properties文件中使用spring.application.name属性指定)。url是目标微服务可访问的地址。path属性用于添加所有方法级映射使用的路径前缀。
我们已经创建了与在 REST 控制器中创建的方法签名相同的接口方法。我们将在 REST 控制器中使用此代理如下:
@RestController
@RequestMapping("/api/inventory")
public class InventoryRESTController {
@Autowired
CatalogServiceProxy catalogServiceProxy;
@GetMapping("/get-inventory/{bookId}")
public ResponseEntity<BookDTO> getInventory(@PathVariable("bookId") Integer bookId) {
return catalogServiceProxy.getInventory(bookId);
}
}
CatalogServiceProxy的实例通过@Autowired注解由 Spring 注入。您可以看到制作 RESTful Web 服务是多么简单。所有细节都从控制器转移到 Feign 代理。您将获得与RestTemplate相同的输出,但代码是解耦和简化的。
假设您正在对catalog-service微服务进行超过一打的 REST 调用。在这种情况下,Feign 代理帮助我们在一个地方管理所有代码。其他组件类不需要了解底层细节。
使用 Ribbon 进行负载均衡
在上一节中,我们看到了inventory-service如何通过 Feign 调用catalog-service来获取书籍详情。然而,在分布式环境中,创建多个微服务实例来处理巨大的应用负载是完全可能的。
在多实例环境中,需要一个机制来无缝地平衡和分配输入请求的负载,将它们发送到可用的实例。系统变得容错。它还通过避免单个实例过载来提高吞吐量、减少响应时间并优化资源利用率,从而使系统更加可靠和高度可用。
Netflix 提供了一个名为 Ribbon 的组件,它在执行 HTTP 和 TCP 调用时充当客户端负载均衡器,提供了许多灵活性和控制。术语客户端指的是单个微服务,因为 Ribbon 可以用来平衡微服务对其他服务的调用流量。
Eureka 可以轻松地与 Ribbon 集成;然而,我们可以配置 Ribbon 而不使用 Eureka。我们将看到如何配置带有和没有 Eureka 的 Ribbon。
配置 Ribbon 而不使用 Eureka
我们将在从inventory-service到catalog-service的调用中配置 Ribbon,所以如果你已经配置了 Eureka 服务器,那么在了解 Ribbon 如何在没有 Eureka 的情况下工作之前,暂时将其移除。首要的事情是添加 Ribbon 启动器依赖项。由于我们想要处理从inventory-service发起的调用,因此需要在inventory-service中添加以下依赖项:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</dependency>
在上一节中,我们配置了 Feign 客户端来处理 REST 调用。我们将使用 Ribbon 与 Feign 客户端一起使用。打开我们在上一节中创建的代理类,并添加以下 Ribbon 配置:
@FeignClient(name="catalog-service", path="/api/catalog" )
@RibbonClient(name="catalog-service")
public interface CatalogServiceProxy {
@GetMapping("/get-book/{bookId}")
public ResponseEntity<BookDTO> getInventory(@PathVariable("bookId") Integer bookId);
}
@RibbonClient注解用于声明 Ribbon 配置,其中name属性指向我们想要实现负载均衡的应用程序。我们使用FeignClient配置的 URL 现在被移除,并可以在application.properties文件中如下定义:
catalog-service.ribbon.listOfServers=http://localhost:8792,http://localhost:8799
属性名将以我们使用@RibbonClient注解的应用程序名称开头。我们需要定义以逗号分隔的 URL,每个 URL 都指向catalog-service微服务的单个实例。根据此配置,Ribbon 客户端将处理来自invoice-service到catalog-service的调用,该服务在端口8792和8799上运行两个实例。
我们将对inventory-service上的 Feign 客户端进行调用,这最终会触发对catalog-service的调用。我们将观察到请求被分配到catalog-service微服务的两个实例。为了验证请求来自哪个实例,我们将在BookDTO中添加当前服务器端口,这将在响应中显示。当前服务器端口可以按以下方式获取:
@Autowired
private Environment env;
@GetMapping("/get-book/{bookId}")
public ResponseEntity<BookDTO> getBook(@PathVariable("bookId") Integer bookId) {
......
bookDto.setSmallDesc(bookObject.getSmallDesc());
bookDto.setTitle(bookObject.getTitle());
bookDto.setPort(env.getProperty("local.server.port"));
......
}
Spring 注入了Environment类的实例,可以用来获取当前环境详情。当前端口号可以通过local.server.port属性访问。接下来,我们将在这两个端口上运行两个catalog-service微服务实例。
要在特定端口上启动 Spring Boot 应用程序,您需要右键单击微服务项目,选择 Run As | Run Configurations,并在 Arguments 选项卡中添加带有-Dserver.port参数的端口号。您还可以在 Name 中追加端口号,以便它可以很容易地被识别,如下所示:

要添加另一个实例,您需要在之前窗口中创建的catalog-service实例上右键单击,选择 Duplicate,并遵循相同的步骤。第二次,使用端口8799,如下所示:

与inventory-service一起运行这两个实例。当您访问http://localhost:8793/api/inventory/get-inventory/3时,您将看到第一次请求的端口号是8792,第二次请求的端口号是8799。这就是请求逐个路由到特定实例的方式。
使用 Eureka 配置 Ribbon
第一种方法的问题在于我们仍然需要手动定义实例 URL。使用 Eureka,我们可以利用其动态解析微服务名称的能力,不再需要硬编码的 URL。使用 Eureka 后,事情变得更加简单直接。Ribbon 和 Feign 的配置将按以下方式更改:
@FeignClient(name="catalog-service", path="/api/catalog" )
@RibbonClient(name="catalog-service")
public interface CatalogServiceProxy {
@GetMapping("/get-book/{bookId}")
public ResponseEntity<BookDTO> getInventory(@PathVariable("bookId") Integer bookId);
}
对于@FeignClient注解不再需要url属性。您还可以从inventory-service微服务的application.properties文件中删除catalog-service.ribbon.listOfServers属性。启动两个catalog-service实例以及inventory-service实例,并在进行之前确保 Eureka 正在运行。您将在 Eureka 控制台中看到两个catalog-service实例正在运行,如下所示:

当您访问http://localhost:8793/api/inventory/get-inventory/3时,您将得到相同的行为。一旦请求到达端口为8792的实例,第二个实例在8799,第三个实例也在8792。这就是 Ribbon 与 Feign 配合配置以实现负载均衡的方式。您可以尝试创建更多实例并检查其行为。此外,如果任何实例出现故障,Ribbon 将停止向它们发送请求,这使得系统具有容错性。
使用 RestTemplate 进行负载均衡
Spring Cloud 也支持使用RestTemplate实现负载均衡的实现。在这种情况下,您需要暴露具有负载均衡能力的RestTemplate实例,而不是直接使用它,如下所示:
@LoadBalanced
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
@Autowired
RestTemplate restTemplate;
在上一个例子中,@LoadBalanced注解将执行在 REST 调用中我们调用的其他服务实例之间平衡负载的魔法。您需要在控制器中注入此对象并使用它进行调用。
配置 API 网关
在前面的章节中,我们看到了如何定义 Zuul 作为 API 网关。在随后的章节中,我们探讨了其他 Netflix 组件,如 Feign 和 Ribbon,以进行 RESTful 调用以实现服务间通信。然而,我们创建的交互是直接在服务之间发生的。尽管我们已经配置了 Zuul 作为 API 网关,但我们还没有将其用作请求流的中心点。在本节中,我们将进行必要的配置更改,以便每个请求都通过我们的 API 网关。
我们将首先学习如何实现一个自定义过滤器,并将其配置到 API 网关中,以跟踪请求并在日志中打印它。为了简单起见,我们将捕获当前请求的一些细节。打开我们为 Zuul 创建的 Spring Boot 应用程序,并添加一个过滤器类,如下所示:
@Component
public class CustomPreFilter extends ZuulFilter {
private static Logger _logger = LoggerFactory.getLogger(ZuulFilter.class);
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
HttpServletRequest request = RequestContext.
getCurrentContext().getRequest();
_logger.info("********** REQUEST STARTED ******************");
_logger.info("Port :"+ request.getLocalPort());
_logger.info("HTTP Method :"+ request.getMethod());
return null;
}
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 0;
}
}
对于任何自定义过滤器,您需要扩展 Netflix 提供的抽象类ZuulFilter。我们需要提供某些抽象方法的实现,如下所示:
-
shouldFilter(): 我们根据此方法的返回值来决定是否应用此过滤器。 -
filterType(): 如我们所见,Zuul 支持各种过滤器类型,如pre、post、error*等。pre过滤器将在请求到达 Zuul 之后和路由到其他微服务之前执行。同样,post过滤器将在响应从其他微服务返回后执行,而error过滤器类型将在请求过程中发生错误时触发。 -
filterOrder(): 我们可以定义任意数量的过滤器。此方法定义了它们的优先级顺序。 -
run(): 此方法是一个占位符,您可以在其中编写您的过滤器逻辑。
我们将使用另一个过滤器,该过滤器在响应返回时触发,过滤器类型为post,如下所示:
@Component
public class CustomPostFilter extends ZuulFilter {
private static Logger _logger = LoggerFactory.getLogger(ZuulFilter.class);
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
_logger.info("********* REQUEST ENDED *************");
return null;
}
@Override
public String filterType() {
return "post";
}
@Override
public int filterOrder() {
return 0;
}
}
接下来,让我们看看如何通过我们的 API 网关访问微服务。我们已经为inventory-service公开了一个 REST API,地址为http://localhost:8793/api/inventory/get-inventory/3,现在我们将更新此 URL 以通过 API 网关路由请求。API 网关 URL 的模式如下:
http://<API_Gateway_Host>:<API_Gateway_Port>/{service-name}/{uri}
Zuul API 网关将使用 Eureka 命名服务器连接到所需的微服务。在之前的模式中,服务名称是注册在 Eureka 命名服务器中的服务的名称(在application.properties或bootstrap.properties文件中的spring.application.name属性)。API 网关可通过http://localhost:8781访问,因此要使用 API 网关访问inventory-service URL,新的 URL 将是http://localhost:8781/inventory-service/api/inventory/get-inventory/3。您将在 Zuul 日志中获取以下请求详情:
o.s.web.servlet.DispatcherServlet : Completed initialization in 9 ms
com.netflix.zuul.ZuulFilter : ******************* REQUEST STARTED ***********
com.netflix.zuul.ZuulFilter : Port :8781
com.netflix.zuul.ZuulFilter : HTTP Method :GET
.........
com.netflix.zuul.ZuulFilter : ******************* REQUEST ENDED *************
这就是我们如何通过 Zuul API 网关使用各种过滤器跟踪请求。然而,调用是通过 Feign 从inventory-service转发到catalog-service的,这仍然绕过了 API 网关,直接调用微服务。现在,让我们看看如何配置 Feign,以便调用通过 Zuul API 网关路由。原始的 Feign 代理如下:
@FeignClient(name="catalog-service", path="/api/catalog")
@RibbonClient(name="catalog-service")
public interface CatalogServiceProxy {
...
}
更新的 Feign 代理接口如下:
@FeignClient(name="zuul-api-gateway", path="y/api/catalog")
@RibbonClient(name="catalog-service")
public interface CatalogServiceProxy {
....
}
变化发生在@FeignClient注解的服务名称中。之前,它直接指向catalog-service,但现在它指向了 Zuul API 网关服务。zuul-api-gateway是使用application.properties文件中的spring.application.name属性定义的 Zuul API 网关服务的名称。
再次运行 URL,你会看到日志打印了两次。日志首先在请求到达inventory-service时打印,然后在请求通过 Feign 从inventory-service路由到catalog-service时再次打印。这就是 Zuul 是如何配置来跟踪微服务之间发出的每个请求的。
保护应用程序
在典型的单体应用程序中,当用户登录时,将创建一个 HTTP 会话来保存用户特定的信息,然后直到会话过期都会使用这些信息。会话将由服务器端的一个通用安全组件维护,并且所有请求都通过它传递。因此,在单体应用程序中处理用户身份验证和授权是直接的。
如果我们想要遵循相同的模式来构建微服务架构,我们需要在每个微服务级别以及中央位置(网关 API)实现安全组件,所有请求都从这里路由。这是因为微服务通过网络进行交互,所以应用安全约束的方法是不同的。
使用 Spring Security 是满足基于 Spring 的 Java 应用程序安全需求的标准做法。对于微服务,Spring Cloud Security(Spring Cloud 的另一个组件)提供了一个一站式解决方案,将 Spring Security 功能与微服务架构的各种组件(如网关代理、配置服务器、负载均衡器等)集成。
在微服务环境中,可以通过广泛使用的标准安全协议,如 OAuth2 和 OpenID Connect 来解决安全担忧。在第四章,构建中央认证服务器中,我们详细讨论了 OAuth2。现在,我们将看看它如何被用来满足微服务架构中的安全需求。
让我们看看 OAuth 安全系统在微服务架构中是如何工作的。授权的高级流程如下:

为了理解这一系列操作,让我们以在线书店用户下单的情况为例。整个过程如下几个步骤:
-
用户尝试通过 Zuul 代理服务器(API 网关)访问订单页面,但没有会话或访问令牌。
-
Zuul 代理随后将用户重定向到一个预配置了参数的授权服务器,例如授权类型、客户端 ID、令牌 URL 和授权 URL。
-
如果用户未登录,授权服务器将重定向到登录页面。
-
一旦用户使用有效的凭据登录,授权服务器将生成一个令牌并将其发送回 API 网关。
-
在收到令牌后,API 网关(Zuul 代理服务器)将令牌向下传播到它所代理的微服务。
-
对于受限制的资源,系统将检查是否存在有效的令牌。如果没有,用户将被重定向到登录页面(或者根据系统配置的授权类型刷新令牌)。
授权服务器将被实现为一个独立的微服务并在 Eureka 发现服务器中注册。它可以作为一个带有安全特定starter依赖的 Spring Boot 应用程序创建,如下所示:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
Spring Cloud Security 为 OAuth 和标准 Spring Security 提供了不同的启动器,用于微服务架构。接下来,我们将添加必要的配置,使该应用程序成为一个授权服务器,如下所示:
@Configuration
@EnableAuthorizationServer
public class CustomAuthorizationConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
@Qualifier("authenticationManager")
private AuthenticationManager authenticationManager;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("testClientId")
.secret(new BCryptPasswordEncoder().encode("test123"))
.authorizedGrantTypes("authorization_code", "refresh_token", "implicit", "password", "client_credentials")
.scopes("registeredUser","admin")
.redirectUris("http://localhost:8781/inventory-test/api/inventory/home")
.resourceIds("oauth2-server");
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123");
return converter;
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Override
public void configure(
AuthorizationServerEndpointsConfigurer endpoints)
throws Exception {
endpoints
.authenticationManager(authenticationManager)
.tokenServices(tokenServices())
.tokenStore(tokenStore())
.accessTokenConverter(accessTokenConverter());
}
@Bean("resourceServerTokenServices")
@Primary
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new
DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(false);
defaultTokenServices.setAccessTokenValiditySeconds(120);
defaultTokenServices.setTokenEnhancer(accessTokenConverter());
return defaultTokenServices;
}
}
使用@EnableAuthorizationServer注解来声明该组件为授权服务器。OAuth 可以通过各种第三方客户端完成,Spring Security 提供了对 Google、Facebook、Okta 和 GitHub 的原生支持。在我们的案例中,我们将定义一个自定义授权服务器。
该类的configure(ClientDetailsServiceConfigurer clients)方法用于定义自定义授权客户端的配置。它使用各种配置初始化客户端,例如ClientId、秘密(一种客户端密码)、客户端想要支持的可能的授权授予类型、可以用于微调访问控制的各个范围,以及用户权限和resourceId。
Spring OAuth 足够灵活,允许使用各种机制生成访问令牌,JWT 就是其中之一。tokenStore()和tokenService()方法用于应用 JWT 所需的配置。configure(AuthorizationServerEndpointsConfigurer endpoints)方法用于配置令牌,以及认证管理器。AuthenticationManager对象如下从WebSecurityConfig类注入:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
@Override
@Bean("authenticationManager")
public AuthenticationManager authenticationManagerBean() throws
Exception {
AuthenticationManager authenticationManager =
super.authenticationManagerBean();
return authenticationManager;
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**");
web.ignoring().antMatchers("/css/**");
}
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth.inMemoryAuthentication()
.withUser("john").password(
encoder().encode("999")).authorities("USER");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/oauth/authorize","/oauth/token","/").permitAll()
.and()
.formLogin().loginPage("/login").permitAll();
}
@Bean("encoder")
public BCryptPasswordEncoder encoder(){
return new BCryptPasswordEncoder();
}
}
这个类负责配置各种端点、静态资源以及登录页面,还包括认证机制。这全部关于授权服务器配置。正如我们所知,所有请求都是通过 Zuul 代理服务器(一个 API 网关)路由的,因此我们必须配置它将受限资源的请求路由到授权服务器。
授权服务器提供了一个访问令牌,该令牌将与请求一起路由(在头部)。当其他微服务读取它时,它们将使用授权服务器验证访问令牌,以允许用户访问受限资源。简而言之,访问令牌将被路由到各种微服务。这需要某种形式的单点登录(SSO)实现,而使用 Spring Cloud Security,我们可以做到这一点。
此外,由用户发起的特定功能(例如,下订单)最终将涉及与其他微服务和 Zuul 代理服务器的交互,因此在 OAuth 术语中,它们被视为资源服务器。首先,将 @EnableOAuth2Sso 注解添加到 Zuul 代理应用程序的 bootstrap 类中,如下所示:
@EnableZuulProxy
@EnableOAuth2Sso
@EnableDiscoveryClient
@SpringBootApplication
public class ZuulApiGatewayApplication {
...
}
这个注解允许 Zuul 代理服务器将授权服务器生成的访问令牌向下传递给其他参与请求处理的微服务。Zuul 代理服务器以及其他微服务的资源服务器配置应如下所示:
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter{
private static final String RESOURCE_ID = "oauth2-server";
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources
.tokenStore(tokenStore())
.resourceId(RESOURCE_ID);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http
.requestMatcher(new RequestHeaderRequestMatcher("Authorization"))
.authorizeRequests()
// Microservice specific end point configuration will go here.
.antMatchers("/**").authenticated()
.and().exceptionHandling().accessDeniedHandler(new
OAuth2AccessDeniedHandler());
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123");
return converter;
}
}
@EnableResourceServer 注解将使组件作为一个资源服务器。resourceId 应该与我们配置在授权服务器中的一致。我们还在授权服务器中使用了相同的 JWT 令牌配置。configure 方法用于设置单个微服务端点的配置。
我们还需要在 application.properties 文件中设置某些属性,这些属性将由资源服务器用于与授权服务器交互,如下所示:
#security.oauth2.sso.login-path=/login
security.oauth2.client.access-token-uri=http://localhost:9999/oauth/token
security.oauth2.client.user-authorization-uri=http://localhost:9999/oauth/authorize
security.oauth2.client.client-id=testClientId
security.oauth2.client.client-secret=test123
security.oauth2.client.scope=registeredUser,admin,openid
security.oauth2.client.grant-type=implicit
security.oauth2.resource.id=oauth2-server
security.oauth2.resource.jwt.key-value=123
授权服务器配置为访问 localhost:9999。资源服务器配置,连同之前的属性,需要放置在我们想要通过 OAuth 安全访问的每个微服务中。
摘要
与本书中迄今为止看到的其他章节和应用不同,这一章介绍了在分布式环境中的一种新的应用程序开发类型。微服务这个术语自 2011 年以来就已经存在。它作为之前架构的增强而出现。
随着 Spring Cloud 的引入,开发者可以在分布式环境中提供各种常见模式的实现。从 Spring Boot 开始,创建一个微服务应用程序只需进行少量配置。
在本章的开头,我们探讨了什么是微服务以及它与单体架构的区别,随后介绍了在开发微服务系统时需要遵守的各种原则和标准。然后,我们简要探讨了各种 Spring Cloud 组件和其他 Netflix OSS 组件。
我们还通过构建一个真实世界的示例——在线书店应用程序,学习了如何创建微服务应用程序。我们从应用程序的架构和数据库设计开始。我们研究了如何以 Spring Boot 应用程序的形式创建微服务,并配置了所需的配置。
我们随后看到了构建各种 Netflix OSS 和 Spring Cloud 组件的实际示例,例如 Eureka 发现服务器、Zuul 代理服务器、Feign 客户端、Ribbon 和 Spring Cloud Config。这些组件是开发分布式应用程序的构建块。我们还看到了构建微服务前端的各种选项和方法。最后,我们使用 Spring Cloud Security 确保了应用程序的安全性。
Java 长期以来一直是构建基于 Spring 的应用程序的唯一事实上的编程语言。然而,Spring 背后的公司 Pivotal 的团队已经开始支持其他函数式编程语言,例如 Scala。从版本 5 开始,Spring 宣布支持 Kotlin,这是一种主要用于 Android 应用程序的基于 JVM 的语言。在下一章中,我们将深入探讨使用 Kotlin 构建 Spring 应用程序的全新方式。
第七章:使用 Spring 和 Kotlin 的任务管理系统
在前面的章节中,我们深入探讨了各种主题和概念。从纯 Spring 框架开始,到 Spring Boot,我们学习了如何快速、轻松地使用 Spring Boot 创建企业级应用程序。
我们还学习了 Spring 框架与其他工具和技术的集成,例如 Elasticsearch、LDAP 和 OAuth,这些都属于 Spring Boot 的范畴。然后,我们学习了使用 Spring 作为后端和 Angular 作为前端创建应用程序的新方法,这个工具叫做JHipster。
然后,我们发现了如何在微服务这种分布式环境的维度中创建应用程序。在本章中,我们将进一步探索 Spring 框架的另一个完全不同的维度,看看它如何被一种名为Kotlin的新编程语言所支持。
作为一种编程语言,Kotlin 在开发者和公司中迅速流行起来。Kotlin 的第一个稳定版本于 2016 年正式发布。就在第二年,谷歌正式宣布 Kotlin 是 Android 平台移动开发的官方支持语言。这极大地提高了 Kotlin 的知名度和采用率。
从版本 5 开始,Spring 宣布支持使用 Kotlin 在 Spring 框架上开发企业应用程序。在本章中,我们将探讨如何使用 Kotlin 开发基于 Spring 的应用程序。我们将构建一个名为 Task Management 的应用程序,使用 Spring Boot 和 Kotlin,并涵盖以下内容:
-
Kotlin 简介
-
Kotlin 作为编程语言的基本功能
-
Kotlin 与 Java 的比较
-
Spring 对 Kotlin 的支持
-
使用 Kotlin 在 Spring 中开发任务管理应用程序
技术要求
本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Spring-5.0-Projects/tree/master/chapter07。代码可以在任何操作系统上执行,尽管它只在 Windows 上进行了测试。
介绍 Kotlin
Kotlin 是一种针对Java 虚拟机(JVM)的语言,因此可以替代 Java 使用。无论是服务器端、移动端还是 Web 端,你都可以在 Java 目前使用的任何地方使用 Kotlin。它由一家名为JetBrains的公司赞助;它是开源的,你可以从 GitHub(github.com/jetbrains/kotlin)下载源代码。他们计划在不久的将来推出 Kotlin 用于嵌入式和 iOS 平台。
Kotlin 作为一种函数式编程语言提供了良好的支持。函数式编程这个术语用来描述一种声明性范式,其中程序是通过表达式或声明创建的,而不是通过命令的执行。函数式编程模型本质上为应用程序带来某些品质,如更紧凑和可预测的代码、易于测试的能力、可重用性等等。Kotlin 通过内置特性引入了函数式范式。
Java 和 Kotlin 之间有许多相似之处,因此产生了这样的问题:当 Java 已经被广泛使用并且超过二十年来非常受欢迎时,为什么我们还需要另一种编程语言。答案在于 Kotlin 拥有一些酷炫的特性,这使得它成为开发基于 JVM 的应用程序的更好选择。
互操作性
Kotlin 最有希望的特性之一是其互操作性。Kotlin 与 Java 完全兼容。应用程序可以结合这两种语言。可以从 Kotlin 中调用 Java 库,而无需任何转换或麻烦。同样,用 Kotlin 编写的代码也可以轻松地从 Java 中调用。这极大地帮助 Java 开发者轻松地从 Java 迁移到 Kotlin。
将代码从一个编程语言迁移到另一个编程语言是一项相当繁琐且耗时的工作,尤其是当这些编程语言在规则、语法、特性等方面不兼容时。尽管 Kotlin 中有一些在 Java 中直接或间接不存在的功能,但正是 Kotlin 的互操作性允许同时运行这两种编程语言的代码。您不必将所有 Java 代码迁移到 Kotlin。以下图表展示了 Kotlin 的互操作性:

此外,Kotlin 的标准库依赖于 Java 类库,这使得可以重用依赖项,并且在任何地方都不需要代码重构。例如:Kotlin 的集合框架建立在 Java 的集合 API 之上。
简洁而强大
在使用 Kotlin 的过程中,你会发现另一个很好的品质就是它的简洁性。Kotlin 的语法易于阅读和理解,即使没有任何编程语言的前置知识。Kotlin 拥有一些特性,使其成为一个真正简洁的语言,例如类型接口、数据类、属性、智能转换等等。我们将在本章后面更详细地介绍它们。
基于这些特性,用 Kotlin 编写的代码紧凑而不失其功能。在许多方面,Kotlin 比 Java 更简洁,因此我们可以用更少的代码实现相同的功能。这极大地提高了可读性和易用性。开发者可以轻松地阅读、编写和更新程序,即使它是别人编写的。
此外,Kotlin 通过各种功能如默认参数、扩展函数和对象声明等,能够加速日常开发任务的执行。您的代码更加紧凑且稳健,同时不会引发任何可维护性问题。这将降低系统出现错误的可能性。
Kotlin 是作为 Java 的增强而非完全新的语言而演化的。因此,您在 Java 中拥有的技能和知识可以应用于 Kotlin,使其成为一门易于学习的语言。
安全特性
您会喜欢 Kotlin 的另一个原因是它的安全特性。用 Kotlin 编写的代码比用 Java 编写的代码要安全得多。Kotlin 的设计旨在防止常见的编程错误,从而使得系统更加稳定,减少崩溃和故障。
对于任何允许空引用的编程语言,在应用程序执行过程中都可能会创建运行时异常,例如 NullPointerException。不恰当地处理此类场景可能会导致系统突然崩溃。如果您有 Java 的经验,您可能已经遇到过这样的场景。
Kotlin 就是基于这个理念设计的,并定义了两种引用类型:可空类型和不可空类型。默认情况下,Kotlin 不允许使用空值引用,并强制开发者以特定方式处理它们。这大大减少了由 NullPointerException 引起的问题的可能性。
IDE 支持
JetBrains,Kotlin 背后的公司,因其名为 IntelliJ IDEA 的 集成开发环境(IDE)而闻名,并且显然为 Kotlin 提供了一级支持。就 IDE 而言,Eclipse 也是 Java 开发者中最受欢迎的之一,因此 JetBrains 也为 Eclipse 提供了一个 Kotlin 插件。
在 Java 刚刚发展的早期,初始阶段并没有 IDE,开发者必须使用文本编辑器进行编码。没有 IDE 的安全和便利性进行工作是非常困难的。当 Eclipse 进入市场时,开发者迅速采用了它,从那时起,它已经成为 Java 开发者中流行且广泛接受的工具。
另一方面,Kotlin 从一开始就幸运地获得了 IDE 支持。这迅速提升了 Kotlin 的知名度。它非常方便且易于学习。开发者能够快速生成高质量的代码,从而提高软件开发周期。
不言而喻,Kotlin 允许使用文本编辑器进行编码。此外,您还可以使用命令提示符构建 Kotlin 应用程序。另外,如果您是 Android 开发者,它还有自己的 IDE,名为 Android Studio,这是基于 IntelliJ IDEA IDE 开发的。
Kotlin 特性
Kotlin 的设计初衷不是与 Java 竞争,而是要成为一个具有 Java 所不具备的额外功能的良好 JVM 语言。与 Java 相比,Kotlin 作为一种语言,拥有许多新颖且令人兴奋的特性,这些特性提高了代码的可读性和可维护性。
理解 Kotlin 的基本特性至关重要。在本节中,我们将探讨一些对于在 Kotlin 中构建应用程序至关重要的特性。
函数的概念
根据定义,函数是一组执行特定任务的语句集合或组。它是任何程序的基本构建块。你可以在 Kotlin 中将函数等同于 Java 中的方法;然而,它们之间有一些区别。Kotlin 中的函数可以在顶级定义,这意味着它不需要被包含在类中。函数可以是类的一部分,也可以在另一个函数中定义。
在 Kotlin 中,函数获得了一级支持,这意味着它支持所有操作,可以存储到变量和数据结构中,可以作为参数传递给其他函数,也可以从其他(顶级)函数返回,可以定义为表达式,还有更多。所有这些特性都带来了极大的灵活性,使 Kotlin 非常简洁。我们将看到 Kotlin 中函数的以下用法。
函数作为表达式
在编写函数时,你需要将代码放在函数体中。然而,在 Kotlin 中,你可以将函数定义为表达式。例如:你想找出两个数字中的最小值,你编写了一个名为min()的函数,如下所示:
fun main(args: Array<String>) {
print(min(4,7))
}
fun min(numberA: Int, numberB:Int) : Int {
if(numberA < numberB){
return numberA
}else{
return numberB
}
}
这将返回两个给定数字中的最小值。这个函数可以按照以下表达式风格编写:
fun min(numberA: Int, numberB:Int) : Int = if(numberA < numberB){ numberA }else{ numberB }
这就是代码看起来既简洁又具有表现力的方式。此外,请注意,我们已经删除了return关键字,因为 Kotlin 足够智能,可以在不显式指定return关键字的情况下返回最后一个值。这被称为单行或一行函数。然而,对于复杂的函数,你可以像以下这样编写多行:
fun min(numberA: Int, numberB:Int) : Int
= if(numberA < numberB){
numberA
}else{
numberB
}
这看起来更紧凑且更具表现力。
默认函数参数
通常,我们提供给函数或构造函数的数据量在不同用例中会有所不同。系统应该足够灵活,即使我们没有提供所有参数的值,也能产生所需的结果。
如果你想在 Java 中实现这个功能,你需要根据需要编写几个重载的函数或构造函数。你将不得不多次编写相同的方法,但输入参数不同,并使用默认值调用其他构造函数或方法。这很快会导致代码冗长——为同一件事反复编写代码。
Kotlin 通过一个名为默认函数参数的功能提供了对这种场景直观的解决方案。这听起来很简单。你需要为那些你认为在执行函数时可能没有提供值的函数参数定义默认值。
例如:假设我们编写了一个函数来计算立方体的体积,如下所示:
fun getVolume(length:Int, width:Int,height:Int):Int{
return length * width * height;
}
在调用此函数时,您需要传递所有三个参数,否则编译器将发出错误信号。现在,假设我们希望系统在没有明确提供的情况下使用默认高度10。它可以写成以下形式:
fun getVolume(length:Int, width:Int,height:Int =10):Int{
return length * width * height;
}
此函数可以调用为getVolume(2,4),并将10作为默认值替换为height。
扩展函数
如果您需要通过一组新功能扩展一个类,您需要更新 Java 源代码。然而,如果您正在使用第三方库,您可能没有源代码。一个类需要被扩展以容纳其他函数。您还可以使用各种设计模式,如装饰者和策略,来实现这一目的。
然而,Kotlin 允许通过一个称为扩展函数的功能直接向现有类添加额外的函数。正如其名所示,扩展函数扩展了类的功能,而不接触其源代码。换句话说,您不再需要继承要扩展的类。这听起来非常有趣。它表现得就像其他成员函数一样,但声明在类的外部。
假设您需要将给定的字符串转换为驼峰式。Kotlin 的String类不提供直接将字符串转换为驼峰式的功能。我们将使用扩展函数来定义一个单独的函数,该函数实际上会执行这项工作,如下所示:
fun String.camelCase():String{
var camelCaseStr = StringBuffer()
var wordLst : List<String> = this.trim().split(" ")
for(word in wordLst){
camelCaseStr.append(word.replaceFirst(word[0], word[0].toUpperCase())).append(" ")
}
return camelCaseStr.trim().toString()
}
camelCase()函数是String类上的扩展函数,它返回字符串。自定义逻辑位于此函数的主体中。您可以在任何字符串字面量上调用camelCase()函数,如下所示:
fun main(args: Array<String>) {
print("this is just for sample".camelCase())
// This will print as—This Is Just For Sample
}
camelCase()函数是我们自定义的函数,因此您可以使用任何您认为合适的其他名称。让我们再看一个扩展函数的例子。假设您想找到一个整数的平方。同样,Kotlin 不提供直接的功能,但我们可以编写扩展函数如下:
fun Int.square():Int{
return this * this
}
square()扩展函数可以在整数字面量中调用,如下所示:
fun main(args: Array<String>) {
print(3.square())
}
这是一个非常出色的功能,我们可以使用它来扩展功能,而无需更新代码或继承基类。
Lambda 表达式或函数字面量
每种编程语言都提供了定义字面量的规定,例如字符串、整数、双精度浮点数等。它们可以以特定方式定义,例如字符串字面量Hello,双精度浮点数字面量34.23等。Kotlin 允许我们通过将代码括在花括号中来定义一个函数字面量,如下所示:
//This is functional literal
{ println(" This is function literal ")}
这个函数可以按照以下方式正常声明:
fun printMsg(message:String){
println(message)
}
它本质上与函数字面量相同。但函数字面量看起来既紧凑又富有表现力。函数字面量可以被分配给变量,并在代码的稍后位置调用,如下所示:
//Functional literal assigned to variable
var greetingMsg = { println("Hello World ...!!!")}
//Calling the function through literal
greetingMsg()
函数字面量可以像第一行那样分配给变量。就像其他函数一样,函数字面量可以用变量(带括号)调用,如第二行所示。函数字面量有以下某些特性:
-
它代表函数的块或主体,该块没有名称。
-
它与任何实体(如类、接口或对象)无关联或绑定,因此不允许使用访问修饰符。
-
由于它没有名称,因此被称为匿名。
-
它可以作为参数传递给其他函数(主要是高阶函数)。
-
它通常被大括号包围,且没有
fun关键字。 -
它也被称为 lambda 表达式。
Kotlin 还允许将参数传递给函数字面量或 lambda 表达式,如下所示:
//Lambda with parameter
var showWarning = {message : String -> println(message)}
//Calling Lambda expression with parameter
showWarning(" Warning 1 occurred ..!!!")
showWarning(" Warning 2 occurred ..!!!")
lambda 表达式由一个箭头(->)分为两部分。左边是参数部分,而右边是 lambda 或函数主体。允许使用逗号分隔的列表来指定多个参数,且不需要用括号括起来,如下所示:
//Multiple parameters
var addition = { num1: Int, num2: Int ->
println("sum of $num1 and $num2 is ..${num1+num2}")
}
addition(3, 5)
我们可以以稍微不同的方式编写此代码,如下所示:
var addition2 : (Int,Int)-> Unit = { num1, num2 ->
println("sum of $num1 and $num2 is ..${num1+num2}")
}
addition2(3, 5)
在此代码中,参数的声明被从 lambda 表达式中移出。Kotlin 中的 Unit 函数相当于 Java 中的 void。这个特性使得 Kotlin 成为一个真正的函数式语言。函数字面量也可以作为另一个函数的参数。
将 lambda 传递给另一个函数
Kotlin 允许我们将函数传递给其他(高阶)函数作为参数,使用 lambda 表达式。这样的函数可以接受 lambda 表达式或匿名函数作为参数。在进一步讨论这个主题之前,让我们首先了解什么是函数类型。
Kotlin 是一种静态类型语言,函数也需要有类型。这被称为函数类型。以下是一些定义它们的示例:
-
()->Int:返回整数类型且不接受任何参数的函数类型。 -
(Int)->Unit:接受一个整数参数且不返回任何内容的函数类型。 -
()->(Int)->String:返回另一个函数的函数类型,该函数最终返回一个字符串。后一个函数接受一个整数作为参数。
现在,让我们看看如何将函数类型定义为外部函数的输入参数。考虑这样一个场景,你正在为银行贷款应用程序设计。你需要检查资格标准并决定是否可以申请贷款。执行此任务的函数应如下所示:
data class Applicant(
var applicantId: Int,
var name: String,
var age: Int,
var gender: String)
fun isEligibleForLoan (mobileNo:String, eligibilityScore:(applicantId:Int)->Double) : Boolean{
//Business logic to fetch applicant details from given mobileNo
var applicant = Applicant(12,"Nilang",38,"M");
var score = eligibilityScore(applicant.applicantId);
return score >80
}
isEligibleForLoan() 函数是一个高阶函数,它接受两个参数。第一个参数是申请人的手机号码,它将从中获取申请人的详细信息。第二个参数是函数类型,我们可以将其视为一种接口类型。它简单地根据给定的申请人 ID 计算资格分数。该函数类型的实际实现将在调用 isEligibleForLoan() 函数时提供,如下所示:
var isEligible = isEligibleForLoan("9998789671",{
applicantId -> //Write logic to calculate the
//eligibility of candidate and return the score
85.23 // This is sample value
})
println(" isEligibile: $isEligible ")
我们需要在第二个参数中传递一个 lambda 表达式。这实际上是一个匿名函数,它接受应用程序 ID 作为输入参数并计算资格分数。分数将被返回到 isEligibleForLoan() 函数,然后根据分数,我们返回是否可以申请贷款。
如果函数类型是最后一个参数,那么 Kotlin 允许以稍微不同的方式调用它。前面的函数可以如下替代调用:
var isEligible2 = isEligibleForLoan("9998789671"){
applicantId -> //Write logic to calculate the eligibility
//of candidate and return the score
75.23 // This is sample value
}
这样,lambda 表达式被放置在括号之外,这更加具有表达性。但这只有在最后一个参数声明了函数类型时才可行。将 lambda 表达式作为函数类型传递是有用的,尤其是在 Ajax 调用中,我们希望在从响应中获取数据后更新页面元素,而不冻结用户界面。通过 lambda 表达式注入的函数将作为回调函数工作。
从另一个函数中返回一个函数
我们已经看到了如何在调用另一个函数时将函数类型定义为参数。借助 lambda 表达式,这变得更加简单。更进一步,Kotlin 允许从另一个函数中返回一个函数。让我们了解它是如何工作的。
假设我们有一个名为 WildAnimal 的接口,该接口由以下三个类实现:
interface WildAnimal{
fun setName(name:String)
fun bark():String
}
class Dog : WildAnimal{
private var dogName: String = ""
override fun bark(): String {
print(" Bhao bhao ...")
return "${dogName} Dog is barking ..."
}
override fun setName(name: String) {
this.dogName = name
}
}
class Fox : WildAnimal{
private var foxName: String = ""
override fun bark(): String {
print(" Haaaaoooooo...")
return "${foxName} Fox is barking ..."
}
override fun setName(name: String) {
this.foxName = name
}
}
class Lion : WildAnimal{
private var lionName: String = ""
override fun bark(): String {
print(" HHHHHAAAAAAAAAAA...")
return "${lionName} Lion is Barking ..."
}
override fun setName(name: String) {
this.lionName = name
}
}
每个类实现了两个方法——setName() 和 bark(),分别用于设置动物和显示叫声。我们将为每个类创建一个实例,设置其名称,并调用 bark() 函数以打印叫声和动物名称。为此,我们将编写如下函数:
fun getAnimalVoiceFun(animal: WildAnimal):(String) -> String{
return {
animal.setName(it)
animal.bark()
}
}
getAnimalVoiceFun 函数接受 WildAnimal 的实现作为参数,并返回一个接受 String 作为参数并返回 String 作为输出的函数。getAnimalVoiceFun 函数体内部的 { } 大括号中的代码表示从该函数返回的函数。it 参数指向封装函数的 String 参数。
实际返回字符串的 animal.bark() 函数最终将从内部函数返回。这个函数可以稍微以不同的方式编写,如下所示:
fun getAnimalVoiceFun(animal: WildAnimal):(name:String) -> String{
return {
animal.setName(name=it)
animal.bark()
}
}
差别在于我们正在声明参数的名称——类型字符串的 name,并在封装函数中使用它作为 name=it 表达式。在这两种之前的方法中,括号代表函数,所以 fun 关键字是静默的。然而,你可以这样声明:
fun getAnimalVoiceFun(animal: WildAnimal):(String) -> String{
return fun(name:String):String {
animal.setName(name)
return animal.bark()
}
以这种方式,我们明确地使用了 fun 关键字来封装函数。此外,你必须在封装函数中明确提及 return 关键字。你可以使用这两种方式之一来声明 getAnimalVoiceFun 函数。你可以这样调用这个函数:
println(getAnimalVoiceFun(Lion())("Jake"))
println(getAnimalVoiceFun(Dog())("Boomer"))
println(getAnimalVoiceFun(Fox())("Lilli"))
我们正在调用 getAnimalVoiceFun 函数,使用相应类的实例。你可以看到如何将包含在单独括号中的第二个字符串参数提供给函数内部的函数——getAnimalVoiceFun。你将得到以下输出:

在 Kotlin 中,函数可以被定义为类型。我们可以使用函数类型来声明前面的函数,如下所示:
val getAnimalVoice: (WildAnimal) ->(String)-> String = {
animal:WildAnimal -> {
animal.setName(it)
animal.bark()
}
}
getAnimalVoice 变量被定义为函数类型,它接受一个 WildAnimal 对象并返回另一个函数,该函数接受 String 作为输入参数(使用 it 关键字)并返回一个 String 输出(通过调用 animal.bark())。这个 lambda 表达式被用来定义这个函数。可以这样调用它:
println(getAnimalVoice(Lion())("Lio"))
println(getAnimalVoice(Dog())("Tommy"))
println(getAnimalVoice(Fox())("Chiku"))
输出将如下所示:

可能还有其他简单的方法来设置动物名称并打印叫声。然而,我们刚刚看到了通过从另一个函数返回函数来实现这一点的可能性。你可以编写一些适用于多个函数的通用逻辑,将通用代码作为单独的函数返回将是使用函数作为另一个函数返回类型的一个理想场景。这正是 Kotlin 的灵活性和简洁性所在。
空安全
当与 Java 一起工作时,当程序执行并尝试访问设置为 null 且未用适当值初始化的变量时,它将使系统崩溃并产生称为 NullPointerException 的经典异常。
如我们所知,Kotlin 是一种静态类型语言,所以所有内容都被定义为类型,包括空值。在 Kotlin 中,空可性是一个类型。默认情况下,Kotlin 编译器不允许任何类型有空值。通常,当我们定义变量时,我们会在声明时设置它们的值。
但是,在某些异常情况下,你不想在声明时初始化变量。在这种情况下,使用这些变量时,Kotlin 编译器将提出质疑。例如:以下代码在 Kotlin 中将产生错误信号:
var msg:String = "Sample Message !!"
msg = null
var anotherMsg :String = null
初始化了某个值的变量不允许被重新赋值为 null。此外,任何定义为特定类型(在我们的例子中是String)的变量也不允许用 null 初始化。Kotlin 编译器有这个限制,以避免NullPointerException,因此它会在编译时捕获这个错误,而不是在运行时导致系统崩溃的错误。
Kotlin 希望我们在声明时初始化类型化变量。正如我们所说,有些情况下我们必须用 null 初始化变量,而 Kotlin 以不同的方式允许这样做,如下所示:
var nullableMsg : String? = " I have some value ..!! "
println(nullableMsg)
nullableMsg=null
可空变量可以用带有类型(称为安全调用运算符)的问号来定义。你现在可以分配 null 值。然而,当我们定义一个可空变量并尝试调用其方法时,编译器会显示如下错误:
var nullableMsg2 : String? = " I have some value ..!! "
println(nullableMsg2.length) // Compiler will show an error here.
原因是 Kotlin 不允许在没有显式检查 null 或以安全方式调用方法的情况下对可空类型调用方法。前面的代码可以按照以下方式重写,以避免编译错误:
//Correct ways to call nullable variable
if(nullableMsg2 !=null){
println(nullableMsg2.length) // Option 1
}
println(nullableMsg2?.length) // Option 2
nullableMsg2?方法是以安全方式调用可空变量的方式。如果变量为 null,Kotlin 会静默地跳过该调用并返回 null 值。这是在 Kotlin 中进行安全 null 检查的更简洁方式。但如果你想要确保返回值,即使它是 null,那么你可以使用另一种方法,如下所示:
println(nullableMsg2?.length ?: -1)
额外的问号和冒号(?:)被称为Elvis 运算符。它基本上类似于 if-else 块,如果非 null 则返回长度。如果是 null,则返回-1。这基本上是三元运算符的简写形式,例如if(a) ? b : c,但更简洁、更紧凑。这将防止在运行时意外抛出NullPointerException。
数据类
你可能创建了一个没有特定业务逻辑或功能的数据容器类。这种情况通常在遵循值对象或数据传输对象模式时发生。这类类通常如下所示:
// Java code
public class StudentVOJava {
private String name;
private int age;
private int standard;
private String gender;
public StudentVO(String name, int age, int standard, String gender) {
this.name = name;
this.age = age;
this.standard = standard;
this.gender = gender;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public int getStandard() {
return standard;
}
public void setStandard(int standard) {
this.standard = standard;
}
public String getGender() {
return gender;
}
public void setGender(String gender) {
this.gender = gender;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
StudentVO studentVO = (StudentVO) o;
return age == studentVO.age &&
standard == studentVO.standard &&
Objects.equals(name, studentVO.name) &&
Objects.equals(gender, studentVO.gender);
}
@Override
public int hashCode() {
return Objects.hash(name, age, standard, gender);
}
@Override
public String toString() {
return "StudentVO{" +
"name='" + name + '\'' + ", age=" + age +
", standard=" + standard +
", gender='" + gender + '\'' +
'}';
}
}
这个类没有特别之处,只有几个属性、getter、setter、构造函数以及hashCode()、equals()和toString()的实现。大量的样板代码会真正分散任何业务功能。正因为如此,代码的可读性、可搜索性和简洁性都降低了。
在现代 IDE 的帮助下,生成这段代码只需要几秒钟,但仍然存在可读性问题。用 Kotlin 编写的相同代码不仅一开始看起来更干净,而且有助于关注那些无聊的样板代码之外的重要部分,如下所示:
data class StudentVOKotlin(var name: String, var age: Int,
var standard: Int, var gender: String)
所有内容都只在一行代码中完成,而且也更易读。这样的声明将创建设置器、获取器、构造函数以及 toString()、hashCode() 和 equals() 方法的实现,以及其他幕后的有用功能,这些都是 Kotlin 自动完成的。如果你想使你的类不可变,只需用 val 而不是 var 声明属性,如下所示:
data class StudentVOKotlin(val name: String, val age: Int,
val standard: Int, val gender: String)
在 Kotlin 中,可以使用 val 定义不可变变量。现在你只能对 StudentVOKotlin 类的对象调用获取器。我们可以如下使用这个类:
fun main(args: Array<String>) {
var student = StudentVOKotlin("Nilang",10,5,"M")
println("Student is $student") // This will call toString()
//This will call getter of respective properties
println("age of ${student.name} is ${student.age}")
}
这确实使代码更加紧凑和易读。
互操作性
正如我们所见,Kotlin 与 Java 完全兼容,因此你可以在同一个项目中同时编写 Java 和 Kotlin 函数,并相互调用。让我们了解这种魔法是如何发生的。在此之前,让我们看看幕后发生了什么。
例如,你已经在CheckOperability.kt文件中编写了一个 Kotlin 函数,如下所示:
fun greeting(name: String){
print(" Hello $name !!!")
}
这段代码将由 Kotlin 编译器编译并转换为字节码。生成的 Java 类文件如下所示:
public final class CheckInterOperabilityKt
{
public static final void greeting(@NotNull String name)
{
//Some code for null type check added by Kotlin at this place.
String str = " Hello " + name + " !!!";System.out.print(str);
}
}
如你所见,Kotlin 将 .kt 文件(CheckInterOperabilityKt.class)转换为相应的 Java 类。在 Kotlin 中定义的 greeting() 函数也被转换为 Java 函数。默认情况下,Kotlin 中的所有函数都是静态的。此外,Kotlin 不会强迫你在没有返回值的情况下定义 void(实际上 Kotlin 有 Unit 来代替 void)。在转换过程中,它将添加 void 和 static 关键字到函数中。
我们现在将看到如何从 Kotlin 调用 Java 代码,反之亦然。
从 Java 调用 Kotlin 代码
让我们在 KotlinFile.kt 文件中创建一个 Kotlin 函数,该函数简单地执行两个给定数字的乘法,如下所示:
fun multiply(a:Int, b:Int):Int{
print("Calling multiply function From Kotlin....")
return a * b
}
我们希望将 Kotlin 的 multiply() 函数调用到 Java 类中。正如我们所知,Kotlin 编译器将此文件处理成一个 Java 类,其中它将 multiply() 方法定义为生成类文件 KotlinFileKt.class 的静态方法,以便可以通过 KotlinFileKt.multiply() 表达式访问。
如果你希望从 Kotlin 源文件更改 Java 类文件名,你需要在 Kotlin 文件中将其定义为 @file:JvmName("CustomKotlinFile")。在这种情况下,Kotlin 编译器将生成 CustomKotlinFile.class 文件,函数可以通过 CustomKotlinFile.multiply() 调用访问。
让我们先添加 Java 类,并按如下方式调用 Kotlin 函数:
public class JavaFile {
public static void main(String args[]){
System.out.print(KotlinFileKt.multiply(3,4));
}
}
这就是 Kotlin 函数如何从 Java 类中调用的方式。现在让我们看看如何从 Kotlin 调用 Java 函数。
从 Kotlin 调用 Java 代码
让我们在 Java 文件中定义一个简单的函数,该函数执行两个数字的加法,如下所示:
public static int add(int num1, int num2){
return num1+num2;
}
这可以在 Kotlin 文件中以类似我们调用 Kotlin 代码到 Java 的方式调用。由于这是一个静态函数,我们可以用 Kotlin 中的 Java 类来调用它,如下所示:
fun main(args: Array<String>) {
var total = JavaFile.add(5,6)
print("Value from Java is $total")
}
Kotlin 中的 main 函数表示执行点,类似于 Java 中的 public static void main()。
智能转换
当使用 Java 时,您可能会遇到需要在对对象进行进一步处理之前对其进行转换的场景。然而,即使对传递的对象类型有确定性,您仍然需要在 Java 中显式地转换对象,如下所示:
//Java code
public static void main(String[] args){
Object name = "Nilang";
if(name instanceof String){
greetingMsg((String) name);
}
}
private static void greetingMsg(String name){
System.out.print(" Welcome "+name+" ..!!");
}
如果尝试在不进行显式转换的情况下调用 greetingMsg(),Java 编译器将显示错误,因为 name 变量是 Object 类型。在这种情况下,尽管编译器知道 name 只能是 String 类型(通过条件 if(name instanceOf String)),Java 编译器仍然需要显式转换。换句话说,即使实际上是不必要的,我们仍然需要进行转换。
然而,在 Kotlin 的情况下,如果参数被证明是所需类型,则不需要显式转换。以下是这样在 Kotlin 中编写的代码:
//Kotlin code
fun main(args: Array<String>) {
val name: Any = "Nilang"
if(name is String) {
greetingMsg(name)
}
}
private fun greetingMsg(name: String) {
print(" Welcome $name ..!!")
}
Kotlin 中的 Any 类型与 Java 中的 Object 相当。在这个代码中,编译器知道传递给 greetingMsg 函数的输入只能是 String 类型(通过 if 条件),因此不需要显式转换。这被称为 Kotlin 中的 智能 转换。
运算符重载
运算符重载是 Kotlin 的另一个便捷特性,这使得它更具表达性和可读性。它允许您使用标准符号,如 +、-、*、/、%、<、> 等,对任何对象执行各种操作。在底层,运算符重载会启动一个函数调用,以执行各种数学运算、比较、数组索引操作等等。
类如 int、byte、short、long、double、float 等为每个这些运算符定义了相应的函数。例如:如果我们对一个整数执行 a+b,Kotlin 将在内部调用 a.plus(b),如下所示:
var num1 = 10
var num2 = 5
println(num1+num2)
println(num1.plus(num2))
两个打印语句显示相同的结果。我们可以通过重载相应的函数来定义自定义类中运算符的工作方式。例如,我们有一个名为 CoordinatePoint 的类,它表示图中给定点的 x 和 y 坐标。如果我们想覆盖这个类上的运算符,那么应该这样编写代码:
data class CoordinatePoint(var xPoint: Int, var yPoint: Int){
// overloading + operator with plus function
operator fun plus(anotherPoint: CoordinatePoint) : CoordinatePoint {
return CoordinatePoint(xPoint + anotherPoint.xPoint, yPoint + anotherPoint.yPoint)
}
// overloading - operator with minus function
operator fun minus(anotherPoint: CoordinatePoint) : CoordinatePoint {
return CoordinatePoint(xPoint - anotherPoint.xPoint, yPoint - anotherPoint.yPoint)
}
}
fun main(args: Array<String>) {
var point1 = CoordinatePoint(2,5)
var point2 = CoordinatePoint(4,3)
//This will call overloaded function plus()
var point3 = point1 + point2
//This will call overloaded function minus()
var point4 = point1 - point2
println(point3)
println(point4)
}
CoordinatePoint 类是我们自定义的类,当使用这个类的对象以及相应的运算符时,实际上会调用 plus() 和 minus() 函数。operator 关键字用于将相应的函数与运算符关联。
除了算术运算之外,Kotlin 还支持其他运算符,例如 索引访问 运算符、in 运算符、调用 运算符、参数 赋值 运算符、等于 运算符、函数 运算符等等。通过运算符重载,这段代码更加紧凑、简洁,当然也更清晰。
Kotlin 与 Java 的比较
在了解了 Kotlin 的功能丰富性之后,将其与 Java 进行比较将非常有帮助。这并不是为了证明某种语言比另一种语言更合适,而是为了列出差异,以便在不同场景下更容易做出选择:
-
空安全:Kotlin 提供了一种优雅的方式来定义和处理可空类型,而 Java 没有类似的功能。
-
扩展函数:Java 需要继承类来扩展,而 Kotlin 允许你定义扩展函数而不需要继承任何类。你还可以为自定义类定义扩展函数。
-
类型引用:在 Java 中,我们需要显式指定变量的类型,而 Kotlin 会根据赋值来处理它,因此你不必在所有情况下定义类型。
-
函数式编程:Kotlin 是一种函数式语言,并为函数提供了许多有用的特性。另一方面,Java 已经开始支持 lambda 表达式。
-
协程支持:协程是轻量级线程,用于处理异步、非阻塞代码。Kotlin 内置支持协程。协程由用户管理。另一方面,Java 通过底层操作系统管理的多线程支持类似的功能。
-
数据类:在 Java 中,我们需要手动声明构造函数、getter、setter、
toString()、hashCode()和equals(),而 Kotlin 会在幕后完成所有这些。 -
智能转换:Java 需要显式检查转换,而 Kotlin 会智能地完成这项工作。
-
已检查异常:Java 支持已检查异常,而 Kotlin 不支持。
Spring 对 Kotlin 的支持
由于其惊人的特性,Kotlin 的受欢迎程度迅速增长,许多框架也开始支持它。Spring 框架自 5.0 版本起就允许使用 Kotlin 开发 Spring 应用程序。尽管 Kotlin 与 Java 完全兼容,但你仍然可以编写纯且完全符合 Kotlin 语法的应用程序。Kotlin 丰富的特性提高了生产力,并且与 Spring 结合得很好,适用于应用程序开发。
正如我们所见,Kotlin 中的扩展函数是一种非侵入式的方式,提供了一种更好的替代方案,用于实用类或创建类层次结构以添加新功能。Spring 利用这个特性将新的 Kotlin 特定功能应用于现有的 Spring API。它主要用于依赖管理。同样,Spring 也将框架 API 转换为空安全,以充分利用 Kotlin。
即使 Spring Boot 也从 2.x 版本开始提供了一级 Kotlin 支持。这意味着你可以用 Kotlin 编写基于 Spring 的应用程序,就像 Spring 是 Kotlin 的本地框架一样。2018 年 10 月发布的 Kotlin 当前版本是 1.3,Spring 支持 1.1 及更高版本的 Kotlin。
开发应用程序 – 任务管理系统
本章旨在使用 Spring Boot 和 Kotlin 创建一个名为任务管理系统(TMS)的应用程序。在之前的章节中,我们使用 Java 创建了各种应用程序。在本节中,我们将学习如何使用 Spring Boot 在 Kotlin 中开发基于 Spring 的应用程序。
使用 TMS,我们将实现以下功能;而不是制作功能齐全且功能丰富的应用程序,我们的重点将是如何在开发基于 Spring 的应用程序时利用 Kotlin 的能力:
-
创建任务并将其分配给用户。
-
管理员用户查看、更新和删除任务。
-
管理员和被分配任务的普通用户可以为给定任务添加注释。
-
使用 Spring Security 实现身份验证和授权。
-
为了简单起见,我们将公开 REST 服务以添加用户。将有一个管理员用户和一个或多个普通用户。
使用 Kotlin 创建 Spring Boot 项目
首先是通过 Spring Boot 初始化器创建项目结构。在所有前面的章节中,我们都在Spring Tool Suite(STS——一个基于 Eclipse 的 IDE)中创建了项目结构。还有另一种从网络创建它的方法。访问 start.spring.io/ URL,并按照以下方式填写数据:

确保您选择编程语言选项为 Kotlin,并选择最新的稳定版 Spring Boot 版本(目前为 2.1.2)。同时选择以下依赖项:
-
JPA:用于通过Java 持久化 API(JPA)与数据库交互
-
Web: 添加 Spring 模型-视图-控制器(MVC)特定功能
-
安全性:需要添加 Spring Security 功能
-
DevTools:用于在代码更改时进行实时重新加载
-
Thymeleaf:使用 Thymeleaf 模板设计视图
-
MySQL: 用于与 MySQL 数据库交互的 Java 连接器
点击“生成项目”按钮,您将看到应用程序结构作为 ZIP 包下载。只需在您的本地机器上解压缩即可。到目前为止,我们在前面的章节中使用了 STS——一个 Eclipse IDE——作为 IDE 来开发各种应用程序。然而,为了获得更全面的体验,在本章中我们将使用 IntelliJ IDEA(一个知名 IDE,原生支持 Kotlin)。
IntelliJ IDEA 有两种版本:社区版和终极版。前者免费提供给 JVM 和 Android 开发,而后者用于 Web 和企业开发,具有更多功能支持。它适用于流行的操作系统——Windows、macOS 和 Linux。我们将使用社区版。从 www.jetbrains.com/idea/download URL 下载它,并在您的本地机器上安装它。
要导入项目,请打开 IntelliJ IDEA IDE,选择 File | Open 菜单,然后选择从 Spring 初始化器下载的已提取的项目结构文件夹。您将在基于 Kotlin 的 Spring 应用程序中看到的第一个区别是文件夹结构。Kotlin 源代码将位于 src/main/kotlin,而不是标准基于 Java 的应用程序的 src/main/java。
为了支持 Kotlin,Spring 需要某些依赖项,这些依赖项在从 Spring 初始化器生成 pom.xml 时会自动添加。您将看到以下 Kotlin 特定依赖项:
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
对于 Kotlin 1.2 及更高版本,需要 kotlin-stdlib-jdk8 依赖项。对于 Kotlin 1.1,您需要使用 kotlin-stdlib-jre8。Kotlin-reflect 是 Kotlin 中使用的反射功能。
数据库设计
要存储与任务相关的数据,我们将使用 MySQL 数据库。我们还将用户和角色信息存储在数据库表中。表及其关系细节如下所示:

表的详细信息如下:
-
task:此表存储系统添加的任务的详细信息。它与 comments 表具有一对多关系。
-
评论:当用户输入 task 评论时,它将被添加到这个表中。
-
用户:这是一个存储用户详情的主表。它与表 role 具有多对多关系。它还与 task 和 comments 表具有一对多关系。
-
角色:这是一个角色主表。主要包含两个角色——
ROLE_USER和ROLE_ADMIN。它与表 users 具有多对多关系。 -
user_role:这是一个链接表,将存储 role 和 users 的关联数据。
实体类
我们将使用 Spring Data JPA 与数据库交互,因此首先我们需要编写实体类。一个实体类将被映射到一个数据库表,其属性将被映射到表列。JPA 实体类是一个用户定义的 POJO 类,它不过是一个具有特定 JPA 特定注解的普通 Java 类,并且能够表示数据库中的对象。
我们将为除了链接表 user_role 之外的所有表创建一个单独的实体类,该链接表通过 @ManyToMany 注解处理。具体细节如下。
用户
users 表的实体类应如下所示:
@Entity
@Table(name="users",catalog="task_mgmt_system")
class User {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
@Column(name = "id")
private var id:Int? = null
@Column(name="username")
private var username : String? = null
@Column(name="password")
private var password : String? = null
@Column(name="firstname")
private var firstname : String? = null
@Column(name="lastname")
private var lastname : String? = null
@Column(name="enabled")
private var enabled : Boolean = false
@ManyToMany(cascade = [CascadeType.PERSIST],fetch = FetchType.EAGER)
@JoinTable(
name = "user_role",
joinColumns = [JoinColumn(name = "user_id",referencedColumnName = "id") ],
inverseJoinColumns = [JoinColumn(name = "role_id",referencedColumnName = "id")]
)
private var roles: Set<Role>? = null
@OneToMany
@JoinColumn(name ="user_id")
private var comments : MutableSet<Comments>? = null
//.... Getters and Setters
}
@Entity 注解用于声明该类是 JPA 实体。@Table 注解用于将类映射到特定的数据库表。该类的属性通过 @Column 注解映射到相应的列。属性被定义为可空的,因为它们将在运行时填充。
@JoinTable 注解用于声明链接表,JoinColumn 用于定义与链接表关联的表之间的列引用。在 Kotlin 中,多对多关系的声明与 Java 中略有不同。相同的配置在 Java 中如下声明:
@ManyToMany(cascade = CascadeType.PERSIST,fetch = FetchType.EAGER )
@JoinTable(
name = "user_role",
joinColumns = @JoinColumn(name = "user_id",referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "role_id",referencedColumnName = "id")
)
这里的明显区别是 @JoinTable 注解的 joinColumns 属性的声明。在 Java 中,它使用注解声明,而在 Kotlin 中,它被定义为一个数组。另一个区别是在 @ManyToMany 注解中定义 cascade 属性。
角色
对应于 role 表的实体类如下所示:
@Entity
@Table(name="role",catalog="task_mgmt_system")
class Role {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
@Column(name = "id")
private var id:Int? = null
@Column(name="role")
private var role : String? = null
@ManyToMany(mappedBy = "roles",cascade = [CascadeType.PERSIST])
private var users:Set<User>? = null
// ... Getters and Setters
}
在 User 和 Role 实体之间的多对多关系中,所有者属于 User 实体,因此 Role 实体中的 @ManyToMany 注解使用 mappedBy 属性定义。
任务
与 task 表关联的实体类应如下所示:
@Entity
@Table(name="task",catalog="task_mgmt_system")
class Task {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
@Column(name = "id")
private var id :Int?=null
@Column(name="title")
private var title : String? = null
@Column(name="detail")
private var detail : String? = null
@Column(name="assigned_to")
private var assignedTo : Int? = null
@Column(name="status")
private var status : String? = null
@OneToMany
@JoinColumn(name ="task_id")
private var comments : MutableSet<Comments>? = null
// .. Getters and Setters
}
@OneToMany 注解用于声明与 comments 表的一对多关系。@JoinColumn 注解用于声明 comments 表中的列引用。
评论
comments 表的实体类应如下所示:
@Entity
@Table(name="comments",catalog="task_mgmt_system")
class Comments {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private var id :Int?=null
@ManyToOne
@JoinColumn(name = "user_id",nullable = false)
private var user : User? = null
@ManyToOne
@JoinColumn(name = "task_id", nullable = false)
private var task : Task? = null;
private var comment:String? = null
// .. Getters and Setters
}
@ManyToOne 注解用于声明与 任务 表的多对一关系。@JoinColumn 注解用于定义参考列(主键)。
Spring Security
Spring Security 是在基于 Spring 的应用程序中实现安全约束的事实标准。在前面的章节中,我们使用 Spring Security 与内存模型一起实现用户认证和授权。内存模型仅应用于测试目的。在实际场景中,认证和授权细节是从其他系统获取的,以使其与应用程序代码松散耦合,例如 LDAP、OAuth 等。
在 第三章,Blogpress – 一个简单的博客管理系统 中,我们详细学习了如何配置 Spring Security 使用 LDAP 和 OAuth。在本章中,我们将使用数据库表来存储认证和授权细节。首先,让我们创建一个类并定义如下安全配置:
@Configuration
@EnableWebSecurity
@ComponentScan("com.nilangpatel.tms.security")
class WebSecurityConfig : WebSecurityConfigurerAdapter() {
@Throws(Exception::class)
override fun configure(web: WebSecurity){
web.ignoring().antMatchers("/js/**")
web.ignoring().antMatchers("/css/**")
}
@Throws(Exception::class)
override fun configure(http: HttpSecurity) {
// .... HttpSecurity specific configuration
}
}
这是一个典型的安全配置。我们为 Kotlin 声明注解的方式与 Java 中所做的方式相似。然而,存在以下差异:
-
在 Kotlin 中,我们可以使用冒号 (
:) 来声明继承。WebSecurityConfigurerAdapter类是一个父类,但令人惊讶的是它是一个 Java 类。你可以从另一个 Java 类扩展你的 Kotlin 类,这是完全可以接受的。这就是 Kotlin 如何与 Java 深度互操作。另一个区别是父类使用构造函数表示法(带有括号)。 -
要覆盖父类的方法,Kotlin 使用
override关键字。 -
由于 Kotlin 不直接支持检查异常,因此使用
@Throws注解来定义异常详细信息。
接下来,需要配置访问机制以访问系统中的各个页面。这可以通过重写配置方法来完成,它基本上提供了特定的 HTTP 安全配置。它看起来如下所示:
@Throws(Exception::class)
override fun configure(http: HttpSecurity) {
http.authorizeRequests()
.antMatchers("/","/login","/api/register").permitAll()
.antMatchers("/controlPage/**","/getAllUsers/**",
"/allTaskList/**","/addTaskComment/**","/viewTask/**")
.hasAnyAuthority(TaskMgmntConstant.ROLE_USER,TaskMgmntConstant.ROLE_ADMIN)
.antMatchers("/showAddTask/**","/showEditTask/**",
"/addTask/**","/updateTask/**","/deleteTask/**")
.hasAnyAuthority(TaskMgmntConstant.ROLE_ADMIN)
.and()
.formLogin().loginPage("/login").permitAll()
.defaultSuccessUrl("/controlPage",true)
.failureUrl("/login?error=true")
.and().csrf().disable()
.logout()
.permitAll().logoutSuccessUrl("/login?logout=true")
}
这里值得注意的是,我们已经为所有用户配置了可访问的各个 URL,甚至无需登录,只有管理员需要登录。我们还配置了登录、成功、失败和注销 URL。我们将在 定义 Spring MVC 控制器 部分中更多地讨论它们。
现在,我们将配置一个认证机制。Spring 支持多种选项,例如内存、LDAP、OAuth 等。对于这个应用程序,我们将从数据库中获取用户详细信息。要使用数据库实现 Spring Security,有两种方法。
查询方法
Spring Security 需要用户及其角色详细信息来执行安全检查。在这个方法中,我们将使用 SQL 查询获取用户和角色详细信息。我们将在 application.properties 文件中定义一个查询,如下所示:
spring.queries.users-query= select username, password, enabled from users where username=?
spring.queries.roles-query= select u.username, r.role from users u inner join user_role ur on(u.id=ur.user_id) inner join role r on(ur.role_id=r.id) where u.username=?
第一个查询获取用户详细信息,第二个查询检索给定用户名的角色列表。这些属性可以在 WebSecurityConfig 类中读取,如下所示:
@Value("\${spring.queries.users-query}")
private val usersQuery: String? = null
@Value("\${spring.queries.roles-query}")
private val rolesQuery: String? = null
在 Kotlin 中,符号 $ 用于在 String 中打印变量,而不需要显式使用 + 运算符。由于我们想从 application.properties 文件中读取属性,我们必须使用转义字符(\)以及 $ 运算符。除此之外,变量被声明为可空(使用 String?),因为它们将在运行时由 Spring 填充。
接下来,我们将重写 configure() 方法来定义认证配置,如下所示:
@Throws(Exception::class)
override fun configure(auth: AuthenticationManagerBuilder?) {
auth!!.jdbcAuthentication()
.usersByUsernameQuery(usersQuery)
.authoritiesByUsernameQuery(rolesQuery)
.dataSource(dataSource)
.passwordEncoder(passwordEncoder())
}
IntelliJ IDEA 的好处是,无论何时你传递任何 Java 代码,它都会提示将其转换为 Java。选择“是”后,它将自动将 Java 代码转换为 Kotlin 代码。!! 符号是一个非空断言运算符,它基本上将任何值转换为非空类型,如果变量为空,则抛出 NullPointerException。它是 Kotlin 的空安全特性的一部分。dataSource 和 passwordEncoder 方法可以定义如下:
@Autowired
private var dataSource: DataSource? = null
@Bean
fun passwordEncoder(): BCryptPasswordEncoder {
return BCryptPasswordEncoder()
}
dataSource 将在运行时由 Spring 注入,因此必须声明为可空(使用 ?)。我们将使用 BCryptPasswordEncoder 以 bcrpt 算法对密码进行编码,这被认为是一个非常强大的编码算法。
userQuery 和 roleQuery 对象不是必需的。如果你不提供它们,你需要设计具有预定义名称和列的表。用户表必须以名称 users 创建,包含列 username、password 和 enabled,而 角色 表必须以名称 authorities 创建。
此方法有一定的限制。例如:userQuery必须以相同的顺序返回username、password和enabled列值,而roleQuery必须以相同的顺序返回username和role name。如果此顺序发生任何变化,它可能无法正常工作。
UserDetailsService 方法
获取用户和角色信息的另一种方法是使用UserDetailsService接口。它是一种抽象的方式来获取认证和授权详情。它有一个方法——loadUserByUsername(),该方法将根据username返回用户详情。你可以覆盖它并编写自己的逻辑来检索用户详情。
Spring 提供了一个名为DaoAuthenticationProvider的类,它基本上在认证过程中使用UserDetailsService实现来获取用户详情。此方法的灵活性意味着我们可以定义一个自定义方法来获取用户详情。我们将在 JPA 仓库的User实体中定义一个方法。JPA 是与关系数据库交互的 Java 对象的标准方式。仓库看起来如下:
@Repository
interface UserRepository : JpaRepository<User, Int> {
fun findByUsername(username: String): User?
}
UserRepository是一个扩展 Java JpaRepository接口的 Kotlin 接口。@Repository注解用于声明此接口为 JPA 仓库。findByUsername方法是一个查询方法,它将获取用户。Spring Data JPA 有一个基于仓库方法名的内置查询构建机制。
对于findByUsername方法,它将首先移除findBy前缀,并从方法名的其余部分构建查询。在这种情况下,它将内部创建一个查询,例如select * from users where username=?。此方法返回User实体类的对象。接下来,我们需要提供一个自定义用户服务,为此我们将实现UserDetailsService如下:
@Service
class CustomUserDetailsService : UserDetailsService {
@Autowired
private val userRepository: UserRepository? = null
@Throws(UsernameNotFoundException::class)
override fun loadUserByUsername(username: String): UserDetails {
val user = userRepository?.findByUsername(username) ?:
throw UsernameNotFoundException(username)
return CustomUserPrinciple(user)
}
}
CustomUserDetailsService类使用@Service注解声明为服务组件。它覆盖了loadUserByUsername()方法,其中我们可以编写自定义逻辑来获取用户详情。我们创建的findByUsername()仓库方法在这里用于获取用户详情。
返回类型是UserDetails,它实际上是一个存储用户信息的接口,然后封装成用于后续认证的对象。我们已创建一个CustomUserPrinciple类来提供UserDetails的实现如下:
class CustomUserPrinciple : UserDetails {
constructor(user: User?) {
this.user = user
}
private var user:User? = null
override fun isEnabled(): Boolean {
return true
}
override fun getUsername(): String {
return this.user?.getUsername() ?: ""
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
override fun getPassword(): String {
return this.user?.getPassword() ?: ""
}
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return true
}
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
var userRoles:Set<Role>? = user?.getRoles() ?: null
var authorities:MutableSet<GrantedAuthority> = HashSet<GrantedAuthority>()
for(role in userRoles.orEmpty()){
authorities.add(CustomGrantedAuthority(role))
}
return authorities
}
}
UserDetails接口需要实现以下某些方法:
-
isEnable(): 此方法基本上返回用户是否已激活。在实际场景中,必须有一个单独的数据库列来检查用户是否已启用。为了简单起见,我们简单地返回true,假设所有用户都已启用。如果用户返回false,Spring Security 将不允许登录。 -
getUsername(): 这只是返回用户名。 -
isCredentialsNonExpired(): 当你想在特定时间限制后对用户施加更新密码的约束时,这是一个非常有用的方法。在这个方法中,你需要根据你的要求检查密码是否已过期,并相应地返回值。为了简单起见,如果我们返回true,这意味着密码尚未过期。 -
getPassword(): 它应该返回密码。 -
isAccountNonExpired(): 这表示用户账户是否已过期。为了简单起见,我们只返回true。 -
isAccountNonLocked(): 这个方法用于检查用户账户是否被锁定。同样,为了简单起见,我们只返回true。 -
getAuthorities(): 这个方法返回授予用户的权限。我们从用户对象中检索角色并将它们包装在GrantedAuthority类型中。GrantedAuthority是一个接口。我们通过CustomGrantedAuthority类提供了一个实现,如下所示:
class CustomGrantedAuthority : GrantedAuthority{
private var role:Role?=null
constructor( role:Role ){
this.role = role
}
override fun getAuthority(): String {
return role?.getRole() ?: ""
}
}
- 我们通过构造函数注入一个用户对象,这些方法可以用来在每种方法中检索更多详细信息。
最后的部分是定义 Spring Security 配置。向WebSecurityConfig类添加以下方法:
@Throws(Exception::class)
override fun configure(auth: AuthenticationManagerBuilder?) {
auth!!.authenticationProvider(authenticationProvider())
}
@Bean
fun authenticationProvider(): DaoAuthenticationProvider {
val authProvider = DaoAuthenticationProvider()
authProvider.setUserDetailsService(userDetailService)
authProvider.setPasswordEncoder(passwordEncoder())
return authProvider
}
authenticationProvider()方法简单地创建一个DaoAuthenticationProvider类型的对象,配置用户详情服务对象和密码编码器,然后返回。它随后在configure()方法中被用作认证提供者。UserDetailService对象可以通过以下方式注入到同一个类中:
@Autowired
private var userDetailService: CustomUserDetailsService? = null
这种方法在允许以自定义方式获取用户详情方面更为灵活,这些详情随后被 Spring 用于执行各种安全约束。它简单地将认证和授权的逻辑与获取用户详情的机制解耦。这使得系统更加灵活。
定义 Spring MVC 控制器
我们的后端层已经准备好了,现在我们将设计控制器和视图层。我们决定使用 Spring MVC 作为前端,因为它是最适合基于 Web 的 Spring 应用的。在 Kotlin 中声明 Spring MVC 控制器的方式与我们在 Java 中做的是相似的,如下所示:
@Controller
class TaskMgmtSystemController {
// Controller methods...
}
Kotlin 的TaskMgmtSystemController类使用@Controller注解声明,这用于将类定义为 Spring MVC 控制器。在 Kotlin 中定义控制器方法的方式也与 Java 相似。例如:可以通过以下控制器方法显示主页:
@GetMapping("/")
fun showHomePage(model: Model): String {
logger.info("This will show home page ")
setProcessingData(model, TaskMgmntConstant.TITLE_HOME_PAGE)
return "home"
}
此方法与/ URL(可在localhost:8080访问)映射,并返回一个主页。如前所述,我们打算使用 Thymeleaf 模板来构建视图层。
如果你不太熟悉 Thymeleaf,它是一个用于生成视图的自然模板引擎,这些视图在服务器端进行处理。本书的 第三章,博客压力 - 一个简单的博客管理系统 中给出了详细说明。你可以参考它来了解更多关于 Thymeleaf 如何与 Spring 一起工作的信息。
我们还定义了常量来访问预定义值。与 Java 不同,我们无法在 Kotlin 接口中定义常量。要定义常量,我们将使用单例类。在 Kotlin 中,我们可以通过对象声明功能创建单例类。这可以通过 object 关键字实现。TaskMgmntConstant 单例类看起来如下:
object TaskMgmntConstant {
const val ROLE_USER :String = "ROLE_USER"
const val ROLE_ADMIN :String = "ROLE_ADMIN"
const val TITLE_HOME_PAGE: String = "Home"
const val TITLE_LOGIN_PAGE: String = "Login"
const val TITLE_LANDING_CONTROL_PAGE:String = "Control Page"
const val TITLE_ADD_TASK_PAGE:String = "Add Task"
const val TITLE_UPDATE_TASK_PAGE:String = "Update Task"
const val PAGE_TITLE: String = "pageTitle"
}
虽然这里没有使用 class 关键字,但此代码将 class 和单例声明结合在一起。内部,Kotlin 将创建 TaskMgmntConstant 类的单个静态实例。object 声明也可以包含函数,可以直接使用 object 声明名称来访问。这类似于在 Java 中访问类类型的静态变量和方法。
使用 const 关键字来定义常量。使用 const 关键字声明的变量是编译时常量,这意味着它们必须在编译时填充。正因为如此,它们不能分配给函数或类构造函数,只能分配给字符串或原始数据类型。
接下来,我们将看到如何定义其他操作的控制器方法如下。
显示控制页面
当用户登录时,系统将跳转到称为控制页面的页面。从该页面,用户可以查看任务并根据其角色执行各种操作。例如:普通用户可以看到分配给它的任务列表,并且可以为特定任务添加评论。管理员用户可以添加新任务、编辑和删除现有任务。此控制器方法简单地重定向用户到登录(或控制)页面。代码如下:
@GetMapping("/controlPage")
fun showControlPage(model:Model):String {
logger.info("Showing control page ")
setProcessingData(model, TaskMgmntConstant.TITLE_LANDING_CONTROL_PAGE)
return "control-page"
}
在 Spring Security 部分,我们配置了 /controlPage,使其对普通用户和管理员用户可访问。未经登录无法访问。
显示登录页面
此控制器方法将重定向用户到登录页面。它看起来如下:
@GetMapping("/login")
fun showLoginPage(@RequestParam(name = "error",required = false) error:String? ,
@RequestParam(name = "logout", required = false) logout:String?, model:Model):String {
logger.info("This is login page URL ")
if (error != null) {
model.addAttribute("error", "Invalid Credentials provided.")
}
if (logout != null) {
model.addAttribute("message", "Logged out")
}
setProcessingData(model, TaskMgmntConstant.TITLE_LOGIN_PAGE);
return "login"
}
它可以从导航菜单中访问。此方法还处理无效凭据的情况,并向用户显示适当的消息。
显示添加新任务页面
添加新任务功能配置为仅允许管理员用户。它将重定向用户到添加新任务页面。代码如下:
@GetMapping("/showAddTask")
fun showAddTask(model:Model):String {
logger.info("Going to show Add task page")
setProcessingData(model, TaskMgmntConstant.TITLE_ADD_TASK_PAGE)
return "task-add"
}
/showAddTask URL 在控制页面中配置为导航菜单。
显示编辑任务页面
只有管理员用户可以编辑现有任务。管理员用户可以在任务列表屏幕上的每个任务记录旁看到一个编辑按钮。点击它后,将触发此方法。它看起来如下。
@GetMapping("/showEditTask")
fun showEditTask(@RequestParam(name = "taskId",required = true) taskId: Int,
model:Model):String {
val task:Task? = taskRepository?.findById(taskId)?.get()
if(task !=null){
val userId: Int = task.getAssignedTo() ?: 0
val user:User? = userRepository?.findById(userId)?.get()
val taskDto = TaskDTO(task.getId(),task.getTitle(),
task.getDetail(),userId,(user?.getFirstname() + " "+user?.getLastname()),task.getStatus(),null)
model.addAttribute("task",taskDto)
}
logger.info("Going to show Edit task page")
setProcessingData(model, TaskMgmntConstant.TITLE_UPDATE_TASK_PAGE)
model.addAttribute("screenTitle","Edit Task")
return "task-edit"
}
taskId 参数将从任务列表屏幕作为请求参数发送。首先,我们使用 taskRepository 从给定的 taskId 获取任务对象,然后将其复制到 TaskDTO 对象中。你可以看到我们已使用 val 关键字声明了变量,它用于声明常量。Kotlin 建议在变量赋值后不再改变的情况下使用 val。TaskDTO 类是在 Kotlin 中定义的数据类,如下所示:
class TaskDTO( var id :Int?, var title : String?,
var detail : String?, var assignedTo : Int?, var assignedPerson:String?,
var status : String?, var comments : Set<Comments>?)
编辑任务屏幕如下所示:

添加新任务
只有管理员用户可以添加新任务。此控制器方法将在数据库中插入任务记录。如下所示:
@PostMapping("/addTask")
fun addTask(@RequestParam(name = "title",required = true) title:String,
@RequestParam(name = "detail",required = true) detail:String,
@RequestParam(name = "selectedUserId", required = true) selectedUserId:Int,
model:Model):String {
val task = Task()
task.setTitle(title)
task.setDetail(detail)
task.setAssignedTo(selectedUserId)
task.setStatus(TaskStatus.PENDING.getStatus())
taskRepository?.save(task)
logger.info("Goint to show Add task page")
setProcessingData(model, TaskMgmntConstant.TITLE_ADD_TASK_PAGE)
model.addAttribute("screenTitle","Add new Task")
return "redirect:allTaskList"
}
title、detail 和 userId 参数(任务分配给谁)来自添加任务屏幕。此方法简单地创建 Task 类的实例,填充其值,并在 taskRepsitory 中保存。与 Java 不同,在 Kotlin 中可以不使用 new 关键字创建实例。此外,Kotlin 在可能的情况下将变量的类型推迟。例如,我们没有定义 task 变量的类型,因为它被分配给 Task 类型类的对象,所以 Kotlin 理解它只能是 Task 类型。
我们不是重定向到特定的页面,而是重定向到另一个控制器方法,该方法的 URL 模式为 /allTaskList,它基本上显示任务列表。
更新任务
更新任务与添加新任务方法类似。只有管理员用户可以更新现有任务。此方法如下所示:
@PostMapping("/updateTask")
fun updateTask(@RequestParam(name = "taskId",required = true) taskId:Int,
@RequestParam(name = "title",required = true) title:String,
@RequestParam(name = "detail",required = true) detail:String,
@RequestParam(name = "selectedUserId", required = true) selectedUserId:Int,
model:Model):String {
val task:Task? = taskRepository?.findById(taskId)?.get()
if(task !=null) {
task.setTitle(title)
task.setDetail(detail)
task.setAssignedTo(selectedUserId)
taskRepository?.save(task)
}
logger.info("Going to show Add task page")
model.addAttribute("screenTitle","Edit Task")
setProcessingData(model, TaskMgmntConstant.TITLE_ADD_TASK_PAGE)
return "redirect:allTaskList"
}
代码看起来与 addTask() 方法类似。唯一的区别是我们获取现有任务的 taskId 作为额外的参数。首先,我们检索它,更新其值,并最终使用 taskRepository 保存它。此方法还重定向到另一个控制器方法,以 /allTaskList URL 模式显示任务列表。
添加任务评论
普通用户和管理员用户可以向现有任务添加评论。在以查看模式打开任务时,屏幕提供了一个添加评论的便利设施。添加任务评论的控制器方法的代码如下所示:
@PostMapping("/addTaskComment")
fun addTask(@RequestParam(name = "taskId",required = true) taskId:Int,
@RequestParam(name = "taskComment",required = true) taskComment:String,
model:Model):String {
val currentTask:Task? = taskRepository?.findById(taskId)?.get()
if(currentTask !=null) {
val principal = SecurityContextHolder.getContext().authentication.principal
if (principal is CustomUserPrinciple) {
val user = principal.getUser()
var existingComments: MutableSet<Comments>? = currentTask.getComments()
var comment:Comments?
if(existingComments == null || existingComments.isEmpty()) {
existingComments = mutableSetOf() // Inmitialize empty hash set
}
comment = Comments()
comment.setTask(currentTask)
if(user !=null) comment.setUser(user)
comment.setComment(taskComment)
comment = commentRepository?.save(comment)
if(comment !=null) {
existingComments.add(comment)
}
currentTask.setComments(existingComments)
taskRepository?.save(currentTask)
}
}
return "redirect:viewTask?taskId=$taskId"
}
在此方法中,taskId 和 taskComment 参数由用户可以添加评论的查看任务屏幕提供。我们从 taskId 获取 Task 对象,并获取其评论作为可变集合。
Kotlin 提供了一个 API 集合(列表、集合、映射等),其中可变类型和不可变类型之间有明确的区分。这非常方便,可以确保你避免错误并设计清晰的 API。当你声明任何集合时,比如 List<out T>,它默认是不可变的,Kotlin 允许只读操作,例如 size()、get() 等。你不能向其中添加任何元素。
如果您想修改集合,则需要显式使用可变类型,例如 MutableList<String>、MutableMap<String, String> 等。在我们的情况下,我们需要在现有集合中添加评论,因此我们使用了 MutableSet 类型。在添加第一个评论时,评论集合是空的,所以我们使用 mutableSetOf() 方法创建一个空集合。此方法用于动态创建 Set 类型的集合。
我们还需要将当前登录用户的 userId 添加到评论中。为此,我们调用 SecurityContextHolder.getContext().authentication.principal。SecurityContextHolder 类由 Spring Security 提供,用于获取各种安全相关信息。
获取所有用户
此方法将返回系统中所有可用的用户。它用于创建任务屏幕以获取用户列表以选择任务分配。方法如下:
@GetMapping("/getAllUsers")
fun getUsers(model:Model):String{
var users: List<User>? = userRepository?.findAll() ?: null;
model.addAttribute("users",users)
return "users"
}
我们从添加任务屏幕中的模型弹出窗口调用它。UI 将由 Thymeleaf 模板 users.html 渲染。
显示任务列表
此方法向普通用户和管理员用户显示任务列表。区别在于普通用户只能查看和添加评论,而管理员用户可以对任务记录进行查看、编辑和删除操作。另一个区别是普通用户可以看到分配给他的任务列表,而管理员用户可以看到系统中所有可用的任务。此方法应如下所示:
@GetMapping("/allTaskList")
fun showAllTaskList(@RequestParam(name = "myTask",required = false) myTask:String?,
model:Model):String{
var taskLst: List<Array<Any>>? = null
if("true" == myTask){
//get current user ID from Spring context
val principal = SecurityContextHolder.getContext().authentication.principal
if (principal is CustomUserPrinciple) {
val user = principal.getUser()
if(user !=null){
taskLst = taskRepository?.findMyTasks(user.getId() ?: 0)
}
model.addAttribute("screenTitle","My Tasks")
}
}else {
taskLst = taskRepository?.findAllTasks()
model.addAttribute("screenTitle","All Tasks")
}
val taskDtoLst:MutableList<TaskDTO> = ArrayList()
var taskDto:TaskDTO?
for(row in taskLst.orEmpty()){
taskDto = TaskDTO(row[0] as Int,row[1] as String,row[2] as String,
null, row[3] as String,row[4] as String,null)
taskDtoLst.add(taskDto)
}
model.addAttribute("tasks",taskDtoLst)
return "task-list"
}
此方法基于 myTask 请求参数执行两种操作。如果它可用,则仅拉取分配给当前用户的任务,否则获取所有任务。获取所有任务的功能对具有管理员角色的用户可用。从数据库中获取任务后,我们将它们映射到 TaskDTO 类的对象上。数据传输对象(DTO)是 Kotlin 数据类,如下所示:
class TaskDTO( var id :Int?, var title : String?,
var detail : String?, var assignedTo : Int?,
var assignedPerson:String?, var status : String?,
var comments : Set<Comments>?)
在任务列表中,我们显示分配给任务的用户名称。在 task 表中,我们存储 userId,因此我们需要通过组合任务和用户表来获取用户名。Spring Data JPA 提供了一种使用 @Query 注解方便地获取复杂查询结果的方法。
此注释用于使用 JAP 查询语言(甚至原生 SQL 查询)定义查询并将其绑定到 JPA 存储库的方法。当我们调用该存储库方法时,JPA 将执行带有 @Query 注解的方法附加的查询。以下是在存储库接口上定义两个方法的示例,这些方法使用连接查询:
@Repository
interface TaskRepository : JpaRepository<Task,Int>{
@Query("SELECT t.id, t.title, t.detail, concat(u.firstname,
' ',u.lastname) as assignedTo ,t.status FROM task t
inner join users u on t.assigned_to = u.id",
nativeQuery = true)
fun findAllTasks(): List<Array<Any>>
@Query("SELECT t.id, t.title, t.detail, concat(u.firstname,
' ',u.lastname) as assignedTo ,t.status FROM task t
inner join users u on t.assigned_to = u.id and
u.id =:userId",nativeQuery = true)
fun findMyTasks(userId : Int): List<Array<Any>>
}
第一种方法将获取系统中所有可用的任务,而第二种方法将仅获取分配给特定用户的任务。nativeQuery属性表示这是一个 SQL 查询。由于此查询返回多个表(在我们的例子中是task和users)的列,它返回一个Any类型的数组列表,而不是特定的实体类对象。List对象代表记录行,Array的元素是列,而Any表示 Kotlin 中任何可用的类型。Any在 Java 中相当于Object。
然后,它可以在控制器方法showAllTaskList中用于填充TaskDTO对象。关键字as用于将Any类型转换为相应的类型。Kotlin 编译器使用智能转换,因此您不需要显式检查给定的Any类型是否兼容。对于管理员用户,任务列表屏幕如下所示:

对于普通用户,情况如下所示:

查看任务
视图任务屏幕将以查看模式打开任务,这意味着它将显示任务的详细信息。它还允许您添加注释并显示添加到所选任务的注释列表。查看任务的控制器方法将填充任务和注释数据,并将用户重定向到查看任务屏幕。该方法如下所示:
@GetMapping("/viewTask")
fun viewTask(@RequestParam(name = "taskId",required = true)
taskId:Int,model:Model):String{
val selectedTask:Task? = taskRepository?.findById(taskId)?.get()
val user:User? = userRepository?.
findById(selectedTask?.getAssignedTo() ?: 0)?.get()
val taskDto= TaskDTO(selectedTask?.getId(),selectedTask?.getTitle(),
selectedTask?.getDetail(),selectedTask?.getAssignedTo(),
(user?.getFirstname() + " "+ user?.getLastname()),
selectedTask?.getStatus(),selectedTask?.getComments())
val commentLst: List<Array<Any>>? = commentRepository?.findByTaskId(taskId)
val commentDtoLst:MutableList<CommentDTO> = ArrayList()
var commentDto:CommentDTO?
for(row in commentLst.orEmpty()){
commentDto = CommentDTO(row[0] as Int,row[1] as String,row[2] as String)
commentDtoLst.add(commentDto)
}
model.addAttribute("task",taskDto)
model.addAttribute("taskComments",commentDtoLst)
model.addAttribute("screenTitle","Add Task Comment")
return "task-view"
}
taskId参数将从任务列表屏幕发送。首先,我们从给定的taskId获取任务详情。我们还从与Task对象关联的用户 ID 获取用户数据。对于注释,我们做同样的事情:从commentRepository获取具有给定taskId的注释。
我们从commentRepository获取注释,但我们也可以从Task对象获取它,因为它们有一对多关系。我们不那样获取的原因是,我们想显示分配给任务的用户名。如果我们从Task获取注释,它将返回Comments类型的对象集合,该对象具有用户 ID 但没有首字母和姓氏。因此,我们可能需要再次进行数据库调用以获取每个注释记录的首字母和姓氏。这可能会导致性能不佳。
作为一种替代方案,我们使用 JPA 查询语言机制,通过将join SQL 查询与存储库方法关联如下所示:
interface CommentRepository: JpaRepository<Comments, Int> {
@Query("SELECT c.id, c.comment, concat(u.firstname,' ',u.lastname)
FROM comments c inner join users u on c.user_id=u.id inner join task t
on t. id = c.task_id and t.id =:taskId",nativeQuery = true)
fun findByTaskId(taskId: Int):List<Array<Any>>
}
由于数据是从多个表(task和comments)中获取的,它以List<Array<Any>>类型返回。我们正在迭代它并填充CommentDTO列表,该列表被定义为以下数据类:
data class CommentDTO(var id:Int,var comment:String,var userName:String)
TaskDTO对象用于显示任务详情,而CommentDTO列表用于在查看任务屏幕中以表格格式显示评论。
删除任务
只有管理员用户可以删除现有任务。删除选项在任务列表屏幕中可见。删除任务的控制器方法如下所示:
@PostMapping("/deleteTask")
fun deleteTask(@RequestParam(name = "taskId",required = true) taskId:Int,model:Model):String{
var selectedTask:Task? = taskRepository?.findById(taskId)?.get()
if(selectedTask !=null) {
taskRepository?.delete(selectedTask)
}
return "redirect:allTaskList"
}
taskId参数在任务列表屏幕中提供。首先,我们从taskRepository获取带有taskId的Task对象,如果它不为空,则删除它。最后,我们重定向到另一个控制器方法,使用/allTaskList URL 以显示任务列表屏幕。
Kotlin 中的 REST 调用
让我们了解如何使用 Kotlin 进行 REST 调用。我们公开 REST API 以向系统中添加用户。需要提供基本用户详情以及角色信息。在 Kotlin 中定义 REST 控制器与 Java 类似,如下所示:
@RestController
@RequestMapping("/api")
class TaskMgmntRESTController {
...
}
TaskMgmntRESTController Kotlin 类被定义为 REST 控制器,使用@RestController并使用@RequestMapping注解配置了/api URL 模式。我们将编写一个处理用户注册的函数,如下所示:
@PostMapping(value = "/register", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun registerNewUser(@Valid @RequestBody userRegistrationDto: UserRegistrationDTO,
errors: Errors): ResponseEntity<List<String>> {
// registration code...
}
此函数使用@PostMapping注解定义,因此必须使用 HTTP POST 方法发送数据。此外,URL 映射是/register,因此有效路径将是/api/register以访问此函数(方法)。它以 JSON 格式消耗数据。Spring 将从 JSON 输入中填充UserRegistrationDTO对象。Kotlin 数据类如下所示:
data class UserRegistrationDTO(var username:String, var password:String,
var firstname:String, var lastname:String,
var roleList:List<String>)
username、password、firstname和lastname属性用于在用户表中插入记录,而roleList属性用于关联此用户拥有的角色。输入数据必须以 JSON 格式通过 REST 客户端使用 HTTP POST 方法提供,如下所示:
{
"username":"dav",
"password":"test",
"firstname":"Dav",
"lastname":"Patel",
"roleList":["ROLE_USER","ROLE_ADMIN"]
}
在registerNewUser方法中编写的代码将被分为以下两个部分。
验证
用户以 JSON 格式发送数据,在进入系统之前必须进行验证以避免任何后端错误。我们不会强加完整的验证列表,而是将实现一些基本验证。例如,验证现有用户名,角色列表具有除ROLR_USER和ROLE_ADMIN之外的其他无效值。代码如下所示:
if (userRegistrationDto.username != null) {
var existingUser : User? = userRepository?.findByUsername(
userRegistrationDto.username)
if (existingUser != null) {
errors.reject("Existing username","User is already exist with username
'${userRegistrationDto.username}'. ")
}
}
if( userRegistrationDto.roleList.isEmpty()){
errors.reject("No Roles provided","Please provide roles")
}else{
var validRole = true
var invalidRole:String?=null
for(roleName in userRegistrationDto.roleList){
if(!TaskMgmntConstant.getRolesLst().contains(roleName)){
validRole=false
invalidRole = roleName
break
}
}
if(!validRole){
errors.reject("Invalid Roles"," $invalidRole is not a valid role")
}
}
if (errors.hasErrors()) {
val errorMsg = ArrayList<String>()
errors.allErrors.forEach { a -> errorMsg.add(a.defaultMessage ?: "")
}
return ResponseEntity(errorMsg, HttpStatus.BAD_REQUEST)
} else {
// .. User Registration code goes here
}
首先,我们检查 JSON 数据中发送的用户名是否已在系统中存在。如果存在,则返回适当的消息。第二个检查是关于角色列表的。我们在TaskMgmntConstant类中创建了一个预定义的角色列表,其函数声明如下:
object TaskMgmntConstant {
const val ROLE_USER :String = "ROLE_USER"
const val ROLE_ADMIN :String = "ROLE_ADMIN"
//... Other constant declaration
fun getRolesLst():List<String>{
return listOf(ROLE_ADMIN, ROLE_USER)
}
}
让我们回顾一下TaskMgmntConstant是一个单例类,我们可以在常量之外定义函数。如果作为 JSON 字符串发送的roleList数据与这两个角色不同,则显示适当的消息。您可以通过forEach方法和 lambda 表达式看到如何使用 for 循环。如果发生任何错误,我们将使用 HTTP 状态 401(HttpStatus.BAD_REQUEST)发送验证消息。
用户注册
如果所有验证都满足,则我们将执行用户注册以及角色映射,如下所示:
val userEntity = User()
userEntity.setUsername(userRegistrationDto.username)
userEntity.setEnabled(true)
val encodedPassword = passwordEncoder?.encode(userRegistrationDto.password)
userEntity.setPassword(encodedPassword ?: "")
userEntity.setFirstname(userRegistrationDto.firstname)
userEntity.setLastname(userRegistrationDto.lastname)
var role:Role?=null
var roles: MutableSet<Role> = mutableSetOf()
for(roleName in userRegistrationDto.roleList){
role = roleRepository?.findByRole(roleName)
if(role !=null) {
roles.add(role)
}
}
userEntity.setRoles(roles)
userRepository?.save(userEntity)
val msgLst = Arrays.asList("User registered successfully")
return ResponseEntity(msgLst, HttpStatus.OK)
在这段代码中,我们创建了User对象,并从UserRegistrationDTO对象中填充其值。我们还创建了一个可变的角色列表,并通过从roleRepository获取基于在UserRegistrationDTO中填充的角色名称的角色来填充它。最后,我们将可变集合与User对象关联,并将其保存到userRepository中。
摘要
在这一章中,我们学习了 Kotlin 的基础知识和其各种特性,然后使用 Spring Boot 和 Kotlin 创建了一个应用。在很短的时间内,Kotlin 因其互操作性、简洁性、安全特性以及对知名 IDE 的支持而获得了巨大的动力和人气。
Spring 框架拥有众多特性,并且在开发现代企业应用中被广泛使用。凭借其对 Java、Scala、Groovy 和 Kotlin 等编程语言的顶级支持,Spring 框架已经成为企业应用开发框架中的主导者。
Spring 框架具有模块化设计,并在系统的各个方面提供无缝集成,例如前端、控制器层、安全、持久化、云支持、消息支持、Web 流程等等。随着 Spring Boot 的发明,基于 Spring 的应用开发比以往任何时候都要简单。
在这本书的整个过程中,我们探讨了 Spring 框架,通过在每一章中开发一个示例应用来展示其功能。这绝对可以增强你的信心,并鼓励你进一步探索这个框架。然而,我们建议你创建更多的示例应用,这样你可以获得更多的实践经验,并真正充分利用这个框架。
这是一个完美的方式结束我们的旅程。为了进一步阅读,你可以参考官方的 Spring 文档和论坛。Spring 有一个庞大的活跃社区,你可以找到许多个人博客,这些博客将帮助你学习和探索这些概念。


浙公网安备 33010602011771号