使用-Spring5-构建-REST-Web-服务-全-
使用 Spring5 构建 REST Web 服务(全)
原文:
zh.annas-archive.org/md5/5A57DB9C3C86080E5A1093BAC90B467A译者:飞龙
前言
REST 是一种解决构建可扩展 Web 服务挑战的架构风格。在当今互联的世界中,API 在 Web 上扮演着核心角色。API 提供了系统相互交互的框架,而 REST 已经成为 API 的代名词。Spring 的深度、广度和易用性使其成为 Java 生态系统中最具吸引力的框架之一。因此,将这两种技术结合起来是非常自然的选择。
从 REST 背后的哲学基础开始,本书介绍了设计和实现企业级 RESTful Web 服务所需的必要步骤。采用实用的方法,每一章都提供了您可以应用到自己情况的代码示例。这第二版展示了最新的 Spring 5.0 版本的强大功能,使用内置的 MVC,以及前端框架。您将学习如何处理 Spring 中的安全性,并发现如何实现单元测试和集成测试策略。
最后,本书通过指导您构建一个用于 RESTful Web 服务的 Java 客户端,以及使用新的 Spring Reactive 库进行一些扩展技术,来结束。
这本书适合谁
本书适用于那些想要学习如何使用最新的 Spring Framework 5.0 构建 RESTful Web 服务的人。为了充分利用本书中包含的代码示例,您应该具备基本的 Java 语言知识。有 Spring Framework 的先前经验也将帮助您快速上手。
为了充分利用这本书
以下是测试本书中所有代码所需的要求的描述性列表:
-
硬件:64 位机器,至少 2GB RAM 和至少 5GB 的可用硬盘空间
-
软件:Java 9,Maven 3.3.9,STS(Spring Tool Suite)3.9.2
-
Java 9:所有代码都在 Java 9 上测试
-
SoapUI:REST API 调用使用 SoapUI 5.2.1(免费版本)
-
Postman:用于 REST 客户端测试,使用 Postman 5.0.4
下载示例代码文件
您可以从您的帐户在www.packtpub.com下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便文件直接发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packtpub.com。
-
选择“支持”选项卡。
-
点击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
一旦文件下载完成,请确保使用最新版本的解压缩或提取文件夹:
-
WinRAR/7-Zip 适用于 Windows
-
Zipeg/iZip/UnRarX 适用于 Mac
-
7-Zip/PeaZip 适用于 Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Building-RESTful-Web-Services-with-Spring-5-Second-Edition。我们还有其他代码包,来自我们丰富的书籍和视频目录,可以在github.com/PacktPublishing/上找到。快去看看吧!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/BuildingRESTfulWebServiceswithSpring5_ColorImages.pdf。
使用的约定
本书中使用了许多文本约定。
CodeInText:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“让我们向类中添加一个Logger;在我们的情况下,我们可以使用UserController。”
代码块设置如下:
@ResponseBody
@RequestMapping("/test/aop/with/annotation")
@TokenRequired
public Map<String, Object> testAOPAnnotation(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Aloha");
return map;
}
当我们希望引起你对代码块的特定部分的注意时,相关的行或项目会以粗体显示:
2018-01-15 16:29:55.951 INFO 17812 --- [nio-8080-exec-1] com.packtpub.restapp.HomeController : {test} info
2018-01-15 16:29:55.951 WARN 17812 --- [nio-8080-exec-1] com.packtpub.restapp.HomeController : {test} warn
2018-01-15 16:29:55.951 ERROR 17812 --- [nio-8080-exec-1] com.packtpub.restapp.HomeController : {test} error
任何命令行输入或输出都以以下方式书写:
mvn dependency:tree
粗体:表示一个新术语,一个重要的词,或者你在屏幕上看到的词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“现在你可以通过点击生成项目来生成项目。”
警告或重要提示会显示为这样。
提示和技巧会显示为这样。
第一章:一些基础知识
随着世界进入大数据时代,收集和处理数据成为大多数 Web 应用程序的主要部分,Web 服务也是如此,因为 Web 服务只处理数据,而不处理用户体验、外观和感觉的其他部分。尽管用户体验对所有 Web 应用程序都非常重要,但 Web 服务通过从客户端消费服务在处理数据方面起着重要作用。
在 Web 服务的早期,简单对象访问协议(SOAP)是所有后端开发人员的默认选择,他们处理 Web 服务消费。SOAP 主要用于 HTTP 和简单邮件传输协议(SMTP)在相同或不同平台上进行消息传输。当没有JavaScript 对象表示(JSON)格式可用于 Web 服务时,XML 曾是 SOAP 可用于 Web 服务消费的唯一格式。
然而,在 JSON 时代,表述性状态转移(REST)开始主导基于 Web 服务的应用程序,因为它支持多种格式,包括 JSON、XML 和其他格式。REST 比 SOAP 更简单,REST 标准易于实现和消费。此外,与 SOAP 相比,REST 更轻量级。
在本章中,我们将涵盖以下主题:
-
REST——基本理解
-
响应式编程及其基础知识,包括响应式编程的好处
-
使用响应式编程的 Spring 5 基础知识
-
将用作本书其余部分基础的示例 RESTful Web 服务
REST——基本理解
与流行观念相反,REST 不是一种协议,而是一种管理状态信息的架构原则。它主要用于 Web 应用程序。REST 是由 Roy Fielding 引入的,以克服 SOAP 中的实现困难。Roy 的博士论文为检索数据提供了一种简单的方法,而不管使用的平台是什么。您将在以下部分中看到 RESTful Web 服务的所有组件。
统一接口
在 REST 原则中,所有资源都由统一资源标识符(URI)标识。
HTTP REST 资源以 XML、JSON 和 RDF 等媒体类型表示。此外,RESTful 资源是自描述的,这意味着提供了足够的信息来描述如何处理请求。
在另一个 REST 原则中,客户端通过服务器动态提供的超媒体进行交互。除了端点,客户端不需要知道如何与 RESTful 服务进行交互。这个原则被称为超媒体作为应用状态的引擎(HATEOAS)。
客户端和服务器
通过分离 REST 实体,如客户端和服务器,我们可以减少 REST 原则的复杂性,这将显示服务器和客户端之间的明确边界。这种解耦将有助于开发人员独立地专注于客户端和服务器。此外,它将有助于管理客户端和服务器的不同角色。
无状态
在 REST 原则中,服务器不会在服务器端保留有关客户端会话的任何状态;因此,它是无状态的。如果从单个客户端向服务器发出两个调用,服务器将不会识别这两个调用是否来自同一个客户端。就服务器而言,每个请求都是独立的和新的。根据 URL、HTTP 标头和请求体,包括参数,操作可能会在服务器端发生变化。
可缓存的
使用 RESTful Web 服务,客户端可以缓存来自服务器的任何响应。服务器可以说明如何以及多长时间可以缓存响应。通过缓存选项,客户端可以使用响应而不是再次联系服务器。此外,缓存将通过避免客户端-服务器交互来提高可伸缩性和性能。
这个原则对可扩展性有显著的优势。缓存技术将在第八章 性能中讨论。
由于 REST 通常利用 HTTP,它继承了 HTTP 提供的所有缓存属性。
分层系统
通过提供分层系统,服务器可以隐藏其身份。通过这样做,客户端将不知道他们正在处理哪个服务器。这个策略通过提供中间服务器和支持负载平衡功能来提供更多的安全控制。此外,中间服务器可以通过负载平衡和共享缓存来提高可扩展性和性能。
按需代码(COD)
按需代码(COD)被认为是一个可选的原则。服务器可以通过传输可执行代码来扩展客户端的功能。例如,可以向基于 Web 的客户端提供 JavaScript 以自定义功能。由于按需代码减少了客户端的可见性,这个约束是可选的。也不是所有的 API 都需要这个功能。
更多关于 REST 的内容
在 Web 应用程序中,REST 通常是通过 HTTP 使用的。REST 不需要绑定到任何特定的协议。在 HTTP REST 中,我们主要使用GET、POST、PUT和DELETE方法来改变我们访问的资源的状态。其他 HTTP 方法,如OPTIONS、HEAD、CONNECT和TRACE,可以用于更高级的操作,例如用于缓存和调试目的。大多数服务器出于安全和简单性的原因已禁用了高级方法;但是,您可以通过调整服务器配置文件来启用它们。由于 JSON 被用作主要的媒体类型,我们在 Web 服务调用中也只使用 JSON 媒体类型。
命令式和响应式编程
让我们来看一下命令式编程和响应式编程之间的小比较:x = y + z。
在前面的表达式中,假设y = 10和z = 15。在这种情况下,x的值将是25。在表达式x = y + z的时候,x的值将被分配。在这个表达式之后,x的值将永远不会改变。
在传统编程世界中这是完全可以的。然而,我们可能需要一个场景,在这个场景中我们应该能够在改变y或z的值时跟进x。
我们的新场景基于以下值:
-
当y = 20和z = 15时,x = 35
-
当y = 20和z = 25时,x = 45
在日常编程中,我们通常使用的命令式编程中不可能出现上述情景。但在某些情况下,我们可能需要根据y或z的变化更新x的值。Reactive 编程是这种情况的完美解决方案。在 Reactive 编程中,x的值将会自动更新,以响应y或z的变化。
电子表格引用单元格是 Reactive 编程的最佳例子。如果一个单元格的值改变,被引用的单元格的值将自动更新。另一个例子可以在模型-视图-控制器架构中找到,Reactive 编程可以自动更新与模型相关联的视图。
Reactive 编程遵循观察者模式来操作和转换数据流,其中发布者(可观察者)根据订阅者的需求发出项目。当发布者发出项目时,订阅者从发布者那里消耗这些发出的项目。与迭代器拉取项目不同,在这里,发布者将项目推送给订阅者。
由于 Reactive 是非阻塞架构的一部分,当我们扩展应用程序时它将会很有用。此外,在非阻塞架构中,一切都被视为事件流。
我们将在本章后面讨论有关 Java 和 Spring 中的 Reactive 的更多内容。
Reactive Streams
Reactive Streams 主要是处理异步数据流的数据项,应用程序在接收到数据项时对其做出反应。这种模型更节省内存,因为它不依赖于任何内存中的数据。
响应式流有四个主要组件:
-
发布者。
-
订阅者。
-
订阅。
-
处理器。
发布者发布数据流,订阅者异步订阅该数据流。处理器在不需要改变发布者或订阅者的情况下转换数据流。处理器(或多个处理器)位于发布者和订阅者之间,将一个数据流转换为另一个数据流。
响应式编程的好处
Netflix、Pivotal、Twitter、Oracle 和 TypeSafe 的工程师支持响应式流方法。特别是 TypeSafe 对响应式流做出了更多贡献。甚至 Netflix 工程师用他们自己的话说:
“使用 RxJava 进行响应式编程使 Netflix 开发人员能够利用服务器端并发,而无需担心典型的线程安全和同步问题。”
以下是响应式编程的好处:
-
专注于业务逻辑
-
流处理导致内存效率
-
克服低级线程、同步和并发问题
响应式原则在实时案例中得到应用,例如实时数据库查询、大数据、实时分析、HTTP/2 等。
Java 和 Spring 5 中的响应式编程
Netflix 工程师引入了 RxJava,以支持 Java 8 中的响应式模型,并与 Reactive Streams 进行了桥接。然而,Java 从 Java 9 开始支持响应式模型,并且在 Java 9 中将 Reactive Streams 合并到了 JDK 中的java.util.concurrent.Flow中。
此外,Pivotal 推出了 Reactor 框架,该框架直接构建在 Reactive Streams 上,避免了对 Reactive Streams 的外部桥接。Reactor 被认为是第四代库。
最后,Spring Framework 5.0 添加了内置的响应式功能,包括用于 HTTP 服务器和客户端的工具。Spring 用户在处理 HTTP 请求时,特别是将响应式请求和背压问题分派给框架时,会发现注解和控制器非常方便。
响应式模型似乎在资源利用效率上是高效的,因为它可以使用更少的线程处理更高的负载。然而,响应式模型可能并不是所有问题的正确解决方案。在某些情况下,如果我们在错误的部分使用 Reactor,它可能会使情况变得更糟。
我们的 RESTful Web 服务架构
由于我们假设读者熟悉 Spring Framework,我们将直接关注我们将要构建的示例服务。
在本书中,我们将构建一个工单管理系统。为了清晰地描述工单管理系统及其使用方式,我们将提出一个场景。
假设我们有一个银行网站应用,由我们的客户 Peter 和 Kevin 使用,我们有 Sammy,我们的管理员,以及 Chloe,客户服务代表(CSR),在任何银行应用问题的情况下提供帮助。
如果 Kevin/Peter 在 Web 应用中遇到问题,他们可以在我们的工单管理系统中创建一个工单。这个工单将由管理员处理,并发送给处理工单的 CSR。
CSR 从用户那里获取更多信息,并将信息转发给技术团队。一旦 CSR 解决了问题,他们就可以关闭问题。
在我们的工单管理系统中,我们将使用以下组件:
| 工单 |
|---|
-
工单 ID -
创建者 ID -
创建时间 -
内容 -
严重程度(轻微,正常,重要,严重) -
状态(打开,进行中,已解决,重新打开)
|
| 用户 |
|---|
-
用户 ID -
用户名 -
用户类型(管理员,普通用户,CSR)
|
在这个工单管理系统中,我们将专注于:
-
用户创建一个工单。
-
用户更新工单。
-
管理员更新工单状态。
-
CSR 更新工单状态。
-
用户和管理员删除工单。
在初始章节中,当我们涉及诸如 AOP、Spring Security 和 WebFlux 等主题时,我们将讨论用户管理,以保持业务逻辑的简单性。然而,在第十三章中,票务管理-高级 CRUD,我们将讨论票务管理系统,并实现我们之前提到的所有业务需求。在第十三章中,票务管理-高级 CRUD,您将使用其他章节中使用的所有高级技术来完成我们的业务需求。
总结
到目前为止,我们已经了解了 REST 和响应式编程的基础知识,以及响应式流的必要性。我们已经学习了带有 Reactor 支持的 Spring 5。此外,我们已经定义了本书其余部分将使用的业务示例和架构。
在下一章中,我们将讨论使用 Maven 进行简单项目创建以及简单的 REST API。此外,我们将讨论 Maven 文件结构和依赖项,包括示例。
第二章:使用 Maven 在 Spring 5 中构建 RESTful Web 服务
在本章中,我们将构建一个简单的 REST Web 服务,返回Aloha。在进行实现之前,我们将专注于创建 RESTful Web 服务所涉及的组件。在本章中,我们将涵盖以下主题:
-
使用 Apache Maven 构建 RESTful Web 服务
-
使用 Eclipse IDE 或 STS 进行 Spring REST 项目
-
在 Eclipse/STS 中创建一个新项目
-
运行和测试我们的 REST API
Apache Maven
在构建 Jakarta Turbine 项目时,工程师们发现管理 Ant 构建工具很困难。他们需要一个简单的工具来构建具有清晰定义且易于理解的项目。他们的尝试塑造了 Apache Maven,并且 JAR 可以在中心位置跨多个项目共享。
有关 Maven 的更多信息可以在maven.apache.org找到。
Apache Maven 是为了支持 Java 项目和构建管理而创建的。此外,它的简化定义使 Java 开发人员在构建和部署 Java 项目时更加轻松。
在撰写本书时,Apache Maven 的最新版本是 3.5.0,可以从他们的网站下载:maven.apache.org/download.cgi。
Maven 3.3+需要 JDK 1.7 或更高版本。因此,请确保在使用 Maven 3.3 时检查您的 Java 版本。
您可以从上述链接获取二进制或源 ZIP 文件(或者您的操作系统所需的任何格式),并将 Maven 安装到您的计算机上。
可以通过在控制台/命令提示符中输入mvn --version命令来验证 Maven 的安装。如果安装成功,它将显示以下细节(仅适用于 Windows 操作系统):

为了清晰起见,以下图片显示了在 Ubuntu 上执行的 Maven 版本检查:

使用 Maven 创建项目
安装和验证 Maven 后,您将需要使用 Maven 创建一个项目。这可以在命令提示符中完成。只需在所需位置运行以下命令,然后项目将自动创建:
mvn archetype:generate -DgroupId=com.packtpub.restapp -DartifactId=ticket-management -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false -Dversion=1.0.0-SNAPSHOT
如果在创建项目时遇到任何问题,请在 Maven 中使用-X选项,如下所示。它将指出发生错误的位置:
mvn –X archetype:generate -DgroupId=com.packtpub.restapp -DartifactId=ticket-management -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false -Dversion=1.0.0-SNAPSHOT
在以下几点中,我们将逐个讨论用于创建 Maven 项目的命令的每个部分:
-
archetype:generate:如果目标是在指定的原型上创建一个新项目,可以使用这个命令,例如maven-archetype-quickstart。 -
-Dgroupid=com.packtpub.restapp:这部分定义了一个带有组标识符的项目,例如一个包。 -
-DartifcatId=ticket-management:这部分定义了我们的项目名称(文件夹)。 -
-DarchetypeArtifactId=maven-archetype-quickstart:这部分将用于在archetype:generate目标上选择原型。 -
-Dversion=1.0.0-SNAPSHOT:项目版本可以在这部分中提及。在部署和分发项目时会很有帮助。
在创建项目后查看 POM 文件
创建项目后,我们可以在项目文件夹中看到pom.xml文件。它将包含所有基本细节,例如groupId,name等。此外,您可以在dependencies配置部分下看到默认的Junit依赖项:
<project
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.packtpub.restapp</groupId>
<artifactId>ticket-management</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<name>ticket-management</name>
<url>http://maven.apache.org</url>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Maven 构件属于一个组(通常是com.organization.product),必须有一个唯一的标识符。
在上述 POM 文件中,version中的SNAPSHOT后缀告诉 Maven 这个项目仍在开发中。
POM 文件结构
在这里,我们将检查项目对象模型(POM)文件结构,看看它是如何组织的,pom.xml文件中有哪些部分可用。POM 文件可以有properties,dependencies,build和profiles。然而,这些部分对于不同的项目会有所不同。在其他项目中,我们可能不需要其中的一些部分:
<project>
// basic project info comes here
<properties>
// local project based properties can be stored here
<properties>
<dependencies>
// all third party dependencies come here
</dependencies>
<build>
<plugins>
// build plugin and compiler arguments come here
</plugins>
</build>
<profiles>
All profiles like staging, production come here
</profiles>
</project>
理解 POM 依赖关系
Maven 帮助管理你操作系统中的第三方库。在过去,你可能不得不手动将每个第三方库复制到你的项目中。当你有多个项目时,这可能是一个大问题。Maven 通过将所有库保存在每个操作系统的一个中央位置来避免这种第三方库管理混乱。无论你的项目数量如何,第三方库都只会下载到系统一次。
Maven 仓库可以在mvnrepository.com/找到。
每个操作系统都有自己的本地 Maven 仓库位置:
- Windows Maven 中央仓库位置:
C:\Users\<username>\.m2\repository\
- Linux Maven 中央仓库位置:
/home/<username>/.m2/repository
- MAC Maven 中央仓库位置:
/Users/<username>/.m2/repository
每当你向你的 POM 依赖项中添加第三方库时,指定的 JAR 和相关文件将被复制到\.m2\repository的位置。
我们将通过查看一个示例来了解 Maven 依赖结构。假设我们需要在我们的应用程序中使用 Log4j 版本 2.9.1。为了使用它,我们需要将依赖项添加到我们的项目中。我们可以从mvnrepository.com搜索log4j-core依赖项,并将依赖项复制到我们的 POM 下的dependencies中。
一个示例的 Maven 依赖如下:

将 Log4j 2.9.1 添加到 POM 依赖项
一旦依赖项被添加并且项目在你的 IDE 上更新,相应的库将被复制到\.m2\repository中:
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.9.1</version>
</dependency>
前面的依赖项log4j-core将被添加到 POM 下。在这个依赖项中,你可以看到groupId,artifactId和version的解释如下:
groupId用于使 JAR/WAR 文件在所有项目中保持唯一。由于它将被全局使用,Maven 建议包名遵循与域名和子组相同的规则。一个示例groupId是com.google.appengine。然而,一些第三方依赖项不遵循groupId包命名策略。检查以下示例:
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.9.9</version>
</dependency>
-
artifactId只是 JAR/WAR 文件的名称,不带扩展名。 -
version带有数字来显示 JAR 文件的版本。一些 JAR 文件带有额外的信息,比如RELEASE,例如3.1.4.RELEASE。
以下代码将下载spring-security-web库3.1.4的 JAR 文件到仓库位置:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>3.1.4.RELEASE</version>
</dependency>
Log4j-core文件(在 Windows 中)将显示如下:

有时,当你在 IDE 上更新项目时,你可能会看到.jar文件丢失。在这种情况下,删除整个文件夹(在我们的例子中是log4j-core文件夹),然后再次更新它们。为了更新丢失的 JAR 文件,在你删除文件夹后,只需更新你的 IDE(在我们的例子中是 STS/Eclipse),右键单击项目,然后选择 Maven | 更新项目。最后,确保你在文件夹下有.jar文件可用。
.m2\repository中的示例仓库应该如下所示:

当你更新一个项目(在 Eclipse 或任何其他 IDE 中),它将从远程 Maven 仓库获取 JAR 和相关文件到你系统的中央仓库。
依赖树
依赖树可以用于项目中定位特定的依赖项。如果你想了解任何特定的库,比如为什么使用它,你可以通过执行依赖树来检查。此外,依赖树可以展开以显示依赖冲突。
以下代码显示了依赖库以及它们的组织方式:
mvn dependency:tree
通过在项目文件夹(或者pom.xml文件可用的任何地方)上执行命令,你可以查看依赖树,其结构如下:
[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ ticket-management ---
[INFO] com.packtpub.restapp:ticket-management:jar:0.0.1-SNAPSHOT
[INFO] +- org.springframework:spring-web:jar:5.0.0.RELEASE:compile
[INFO] | +- org.springframework:spring-beans:jar:5.0.0.RELEASE:compile
[INFO] | \- org.springframework:spring-core:jar:5.0.0.RELEASE:compile
[INFO] | \- org.springframework:spring-jcl:jar:5.0.0.RELEASE:compile
[INFO] +- org.springframework.boot:spring-boot-starter-tomcat:jar:1.5.7.RELEASE:compile
[INFO] | +- org.apache.tomcat.embed:tomcat-embed-core:jar:8.5.20:compile
[INFO] | +- org.apache.tomcat.embed:tomcat-embed-el:jar:8.5.20:compile
[INFO] | \- org.apache.tomcat.embed:tomcat-embed-websocket:jar:8.5.20:compile
[INFO] +- org.springframework.boot:spring-boot-starter:jar:1.5.7.RELEASE:compile
[INFO] | +- org.springframework.boot:spring-boot:jar:1.5.7.RELEASE:compile
[INFO] | +- org.springframework.boot:spring-boot-autoconfigure:jar:1.5.7.RELEASE:compile
[INFO] | +- org.springframework.boot:spring-boot-starter-logging:jar:1.5.7.RELEASE:compile
[INFO] | | +- ch.qos.logback:logback-classic:jar:1.1.11:compile
[INFO] | | | \- ch.qos.logback:logback-core:jar:1.1.11:compile
[INFO] | | +- org.slf4j:jcl-over-slf4j:jar:1.7.25:compile
[INFO] | | +- org.slf4j:jul-to-slf4j:jar:1.7.25:compile
[INFO] | | \- org.slf4j:log4j-over-slf4j:jar:1.7.25:compile
[INFO] | \- org.yaml:snakeyaml:jar:1.17:runtime
[INFO] +- com.fasterxml.jackson.core:jackson-databind:jar:2.9.2:compile
[INFO] | +- com.fasterxml.jackson.core:jackson-annotations:jar:2.9.0:compile
[INFO] | \- com.fasterxml.jackson.core:jackson-core:jar:2.9.2:compile
[INFO] +- org.springframework:spring-webmvc:jar:5.0.1.RELEASE:compile
[INFO] | +- org.springframework:spring-aop:jar:5.0.1.RELEASE:compile
[INFO] | +- org.springframework:spring-context:jar:5.0.1.RELEASE:compile
[INFO] | \- org.springframework:spring-expression:jar:5.0.1.RELEASE:compile
[INFO] +- org.springframework.boot:spring-boot-starter-test:jar:1.5.7.RELEASE:test
[INFO] | +- org.springframework.boot:spring-boot-test:jar:1.5.7.RELEASE:test
[INFO] | +- org.springframework.boot:spring-boot-test-autoconfigure:jar:1.5.7.RELEASE:test
[INFO] | +- com.jayway.jsonpath:json-path:jar:2.2.0:test
[INFO] | | +- net.minidev:json-smart:jar:2.2.1:test
[INFO] | | | \- net.minidev:accessors-smart:jar:1.1:test
[INFO] | | | \- org.ow2.asm:asm:jar:5.0.3:test
[INFO] | | \- org.slf4j:slf4j-api:jar:1.7.16:compile
[INFO] | +- junit:junit:jar:4.12:test
[INFO] | +- org.assertj:assertj-core:jar:2.6.0:test
[INFO] | +- org.mockito:mockito-core:jar:1.10.19:test
[INFO] | | \- org.objenesis:objenesis:jar:2.1:test
[INFO] | +- org.hamcrest:hamcrest-core:jar:1.3:test
[INFO] | +- org.hamcrest:hamcrest-library:jar:1.3:test
[INFO] | +- org.skyscreamer:jsonassert:jar:1.4.0:test
[INFO] | | \- com.vaadin.external.google:android-json:jar:0.0.20131108.vaadin1:test
[INFO] | \- org.springframework:spring-test:jar:4.3.11.RELEASE:test
[INFO] +- io.jsonwebtoken:jjwt:jar:0.6.0:compile
[INFO] \- org.springframework.boot:spring-boot-starter-aop:jar:1.5.7.RELEASE:compile
[INFO] \- org.aspectj:aspectjweaver:jar:1.8.10:compile
Spring Boot
Spring Boot 是一个快速且易于配置的 Spring 应用程序。与其他 Spring 应用程序不同,我们不需要太多的配置来构建 Spring Boot 应用程序,因此您可以非常快速和轻松地开始构建它。
Spring Boot 帮助我们创建一个独立的应用程序,可以快速嵌入 Tomcat 或其他容器。
开发 RESTful Web 服务
要创建新项目,我们可以使用 Maven 命令提示符或在线工具,如 Spring Initializr(start.spring.io),生成项目基础。这个网站对于创建一个简单的基于 Spring Boot 的 Web 项目非常有用,可以让项目快速启动。
创建项目基础
让我们在浏览器中转到start.spring.io并通过填写以下参数来配置我们的项目以创建项目基础:
-
组:
com.packtpub.restapp -
Artifact:
ticket-management -
搜索依赖项:
Web(使用 Tomcat 和 Spring MVC 进行全栈 Web 开发)
配置完我们的项目后,它将如下截图所示:

现在,您可以通过单击“生成项目”来生成项目。项目(ZIP 文件)应下载到您的系统。解压缩.zip文件,您应该看到以下截图中显示的文件:

复制整个文件夹(ticket-management)并将其保存在所需的位置。
使用您喜欢的 IDE
现在是选择 IDE 的时候了。虽然有许多 IDE 用于 Spring Boot 项目,但我建议使用Spring Tool Suite(STS),因为它是开源的,易于管理项目。在我的情况下,我使用sts-3.8.2.RELEASE。您可以从此链接下载最新的 STS:spring.io/tools/sts/all。在大多数情况下,您可能不需要安装;只需解压文件并开始使用:

解压 STS 后,您可以通过运行STS.exe(如上截图所示)开始使用该工具。
在 STS 中,您可以通过选择现有的 Maven 项目导入项目,如下所示:

导入项目后,您可以在包资源管理器中看到项目,如下截图所示:

您可以默认查看主 Java 文件(TicketManagementApplication):

为了简化项目,我们将清理现有的 POM 文件并更新所需的依赖项。将此文件配置添加到pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<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>com.packtpub.restapp</groupId>
<artifactId>ticket-management</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>ticket-management</name>
<description>Demo project for Spring Boot</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.0.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>1.5.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<version>1.5.7.RELEASE</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.0.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.0.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<version>1.5.7.RELEASE</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
在上述配置中,您可以检查我们使用了以下库:
-
spring-web -
spring-boot-starter -
spring-boot-starter-tomcat -
spring-bind -
jackson-databind
由于项目需要上述依赖项才能运行,因此我们已将它们添加到我们的pom.xml文件中。
到目前为止,我们已经为 Spring Web 服务准备好了基本项目。让我们向应用程序添加基本的 REST 代码。首先,从TicketManagementApplication类中删除@SpringBootApplication注释,并添加以下注释:
@Configuration
@EnableAutoConfiguration
@ComponentScan
@Controller
这些注释将帮助该类充当 Web 服务类。在本章中,我不打算详细讨论这些配置将做什么。添加注释后,请添加一个简单的方法来返回一个字符串作为我们的基本 Web 服务方法:
@ResponseBody
@RequestMapping("/")
public String sayAloha(){
return "Aloha";
}
最后,您的代码将如下所示:
package com.packtpub.restapp.ticketmanagement;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Configuration
@EnableAutoConfiguration
@ComponentScan
@Controller
public class TicketManagementApplication {
@ResponseBody
@RequestMapping("/")
public String sayAloha(){
return "Aloha";
}
public static void main(String[] args) {
SpringApplication.run(TicketManagementApplication.class, args);
}
}
一旦所有编码更改完成,只需在 Spring Boot 应用程序上运行项目(Run As | Spring Boot App)。您可以通过在控制台中检查此消息来验证应用程序是否已加载:
Tomcat started on port(s): 8080 (http)
验证后,您可以通过在浏览器中简单地输入localhost:8080来检查 API。请查看下面的截图:

如果您想要更改端口号,可以在application.properties中配置不同的端口号,该文件位于src/main/resources/application.properties中。查看以下截图:

总结
在本章中,我们已经看到如何设置 Maven 构建以支持 Web 服务的基本实现。此外,我们还学习了 Maven 在第三方库管理以及 Spring Boot 和基本 Spring REST 项目中的帮助。在接下来的章节中,我们将更多地讨论 Spring REST 端点和 Reactor 支持。
第三章:Spring 中的 Flux 和 Mono(Reactor 支持)
在本章中,我们将向读者介绍更多在 Spring 5 中支持 Reactor 的实际方法,包括 Flux 和 Mono。用户将通过简单的 JSON 结果亲身体验 Flux 和 Mono。
本章将涵盖以下主题:
-
Reactive 编程和好处
-
Reactive Core 和 Streams
-
Spring REST 中的 Flux 和 Mono
-
使用 Reactive 的用户类——REST
Reactive 编程的好处
假设我们的应用程序中有一百万个用户交易正在进行。明年,这个数字将增加到 1000 万,所以我们需要进行扩展。传统的方法是添加足够的服务器(水平扩展)。
如果我们不进行水平扩展,而是选择使用相同的服务器进行扩展,会怎么样?是的,Reactive 编程将帮助我们做到这一点。Reactive 编程是关于非阻塞的、同步的、事件驱动的应用程序,不需要大量线程进行垂直扩展(在 JVM 内部),而不是水平扩展(通过集群)。
Reactive 类型并不是为了更快地处理请求。然而,它们更关注请求并发性,特别是有效地从远程服务器请求数据。通过 Reactive 类型的支持,您将获得更高质量的服务。与传统处理相比,传统处理在等待结果时会阻塞当前线程,而 Reactive API 仅请求可以消耗的数据量。Reactive API 处理数据流,而不仅仅是单个元素。
总的来说,Reactive 编程是关于非阻塞、事件驱动的应用程序,可以通过少量线程进行扩展,背压是确保生产者(发射器)不会压倒消费者(接收器)的主要组成部分。
Reactive Core 和 Streams
Java 8 引入了 Reactive Core,它实现了 Reactive 编程模型,并建立在 Reactive Streams 规范之上,这是构建 Reactive 应用程序的标准。由于 lambda 语法为事件驱动方法提供了更大的灵活性,Java 8 提供了支持 Reactive 的最佳方式。此外,Java 的 lambda 语法使我们能够创建和启动小型和独立的异步任务。Reactive Streams 的主要目标之一是解决背压问题。我们将在本章的后面部分更多地讨论背压问题。
Java 8 Streams 和 Reactive Streams 之间的主要区别在于 Reactive 是推模型,而 Java 8 Streams 侧重于拉模型。在 Reactive Streams 中,根据消费者的需求和数量,所有事件都将被推送给消费者。
自上次发布以来,Spring 5 对 Reactive 编程模型的支持是其最佳特性。此外,借助 Akka 和 Play 框架的支持,Java 8 为 Reactive 应用程序提供了更好的平台。
Reactor 是建立在 Reactive Streams 规范之上的。Reactive Streams 是四个 Java 接口的捆绑包:
-
Publisher -
Subscriber -
Subscription -
Processor
Publisher将数据项的流发布给注册在Publisher上的订阅者。使用执行器,Publisher将项目发布给Subscriber。此外,Publisher确保每个订阅的Subscriber方法调用严格有序。
Subscriber只有在请求时才消耗项目。您可以通过使用Subscription随时取消接收过程。
Subscription充当Publisher和Subscriber之间的消息中介。
Processor代表一个处理阶段,可以包括Subscriber和Publisher。Processor可以引发背压并取消订阅。
Reactive Streams 是用于异步流处理的规范,这意味着所有事件都可以异步产生和消费。
背压和 Reactive Streams
反压是一种机制,授权接收器定义它希望从发射器(数据提供者)获取多少数据。响应式流的主要目标是处理反压。它允许:
-
在数据准备好被处理后,控制转到接收器以获取数据
-
定义和控制要接收的数据量
-
高效处理慢发射器/快接收器或快发射器/慢接收器的情况
WebFlux
截至 2017 年 9 月,Spring 宣布了 5 的一般可用性。Spring 5 引入了一个名为 Spring WebFlux 的响应式 Web 框架。这是一个非阻塞的 Web 框架,使用 Reactor 来支持 Reactive Streams API。
传统上,阻塞线程会消耗资源,因此需要非阻塞异步编程来发挥更好的作用。Spring 技术团队引入了非阻塞异步编程模型,以处理大量并发请求,特别是对延迟敏感的工作负载。这个概念主要用于移动应用程序和微服务。此外,这个 WebFlux 将是处理许多客户端和不均匀工作负载的最佳解决方案。
基本 REST API
要理解 Flux 和 Mono 等响应式组件的实际部分,我们将不得不创建自己的 REST API,并开始在 API 中实现 Flux 和 Mono 类。在本章中,我们将构建一个简单的 REST Web 服务,返回Aloha。在进入实现部分之前,我们将专注于创建 RESTful Web 服务所涉及的组件。
在本节中,我们将涵盖以下主题:
-
Flux 和 Mono - Spring 5 的介绍:功能性 Web 框架组件
-
Flux 和 Mono - 在 REST API 中
Flux
Flux 是 Reactor 中的主要类型之一。Flux 相当于 RxJava 的 Observable,能够发出零个或多个项目,然后选择性地完成或失败。
Flux 是实现了 Reactive Streams 宣言中的Publisher接口的 Reactive 类型之一。Flux 的主要作用是处理数据流。Flux 主要表示N个元素的流。
Flux 是一个发布者,特定普通旧 Java 对象(POJO)类型的事件序列。
Mono
Mono 是 Reactor 的另一种类型,最多只能发出一个项目。只想要发出完成信号的异步任务可以使用 Mono。Mono 主要处理一个元素的流,而不是 Flux 的N个元素。
Flux 和 Mono 都利用这种语义,在使用一些操作时强制转换为相关类型。例如,将两个 Monos 连接在一起将产生一个 Flux;另一方面,在Flux<T>上调用single()将返回一个Mono <T>。
Flux 和 Mono 都是Reactive Streams(RS)发布者实现,并符合 Reactive-pull 反压。
Mono 在特定场景中使用,比如只产生一个响应的 HTTP 请求。在这种情况下,使用 Mono 将是正确的选择。
返回Mono<HttpResponse>来处理 HTTP 请求,就像前面提到的情况一样,比返回Flux<HttpResponse>更好,因为它只提供与零个或一个项目的上下文相关的操作符。
Mono 可以用来表示没有值的异步过程,只有完成的概念。
具有 Reactive 的 User 类 - REST
在第一章中,我们介绍了Ticket和User,这两个类与我们的 Web 服务有关。由于Ticket类与User类相比有点复杂,我们将使用User类来理解响应式组件。
由于 Spring 5 中的响应式还不是完全稳定的,我们只会在几章中讨论响应式。因此,我们将为基于响应式的 REST API 创建一个单独的包。此外,我们将在现有的pom.xml文件中添加基于响应式的依赖项。
首先,我们将不得不添加所有的响应式依赖。在这里,我们将在现有的pom.xml文件中添加代码:
<?xml version="1.0" encoding="UTF-8"?>
<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>com.packtpub.restapp</groupId>
<artifactId>ticket-management</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>ticket-management</name>
<description>Demo project for Spring Boot</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-bom</artifactId>
<version>Bismuth-RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.0.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>1.5.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<version>1.5.7.RELEASE</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.0.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.0.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<version>1.5.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.reactivestreams</groupId>
<artifactId>reactive-streams</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor.ipc</groupId>
<artifactId>reactor-netty</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>8.5.4</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.0.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
<version>5.0.0.RELEASE</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
对于与 Reactive 相关的工作,您可以使用现有项目,也可以创建一个新项目,以避免与非 Reactive(普通)REST API 发生冲突。您可以使用start.spring.io获取基本项目,然后使用上述配置更新 Maven 文件。
在前面的 POM 配置中,我们已经在现有的依赖项上添加了 Reactor 依赖项(如下所示):
-
reactive-streams -
reactor-core -
reactor-netty -
tomcat-embed-core -
spring-webflux
这些是使用 Reactor 所需的库。
User类的组件如下:
-
userid -
username -
user_email -
user_type(管理员,普通用户,CSR)
在这里,我们使用了User类的四个变量。为了更容易理解 Reactive 组件,我们只使用了两个变量(userid,username)。让我们创建一个只有userid和username的 POJO 类。
User POJO 类如下:
package com.packtpub.reactive;
public class User {
private Integer userid;
private String username;
public User(Integer userid, String username){
this.userid = userid;
this.username = username;
}
public Integer getUserid() {
return userid;
}
public void setUserid(Integer userid) {
this.userid = userid;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
在上面的类中,我使用了两个变量和一个构造函数来在实例化时填充变量。同时,使用 getter/setter 来访问这些变量。
让我们为User类创建一个 Reactive 存储库:
package com.packtpub.reactive;
import reactor.core.publisher.Flux;
public interface UserRepository {
Flux<User> getAllUsers();
}
在上面的代码中,我们为User引入了一个 Reactive 存储库和一个只有一个方法的类,名为getAllUsers。通过使用这个方法,我们应该能够检索到用户列表。现在先不谈 Flux,因为它将在以后讨论。
您可以看到这个UserRepository是一个接口。我们需要有一个具体的类来实现这个接口,以便使用这个存储库。让我们为这个 Reactive 存储库创建一个具体的类:
package com.packtpub.reactive;
import java.util.HashMap;
import java.util.Map;
import reactor.core.publisher.Flux;
public class UserRepositorySample implements UserRepository {
// initiate Users
private Map<Integer, User> users = null;
// fill dummy values for testing
public UserRepositorySample() {
// Java 9 Immutable map used
users = Map.of(
1, (new User(1, "David")),
2, (new User(2, "John")),
3, (new User(3, "Kevin"))
);
}
// this method will return all users
@Override
public Flux<User> getAllUsers() {
return Flux.fromIterable(this.users.values());
}
}
由于 Java 9 中有不可变映射可用,我们可以在我们的代码中使用不可变映射。然而,这些不可变对象仅适用于本章,因为我们不对现有条目进行任何更新。
在下一章中,我们将使用常规的映射,因为我们需要在 CRUD 操作中对它们进行编辑。
目前,我们能够从具体类中获取用户列表。现在我们需要一个 web 处理程序在控制器中检索用户。现在让我们创建一个处理程序:
package com.packtpub.reactive;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public class UserHandler {
private final UserRepository userRepository;
public UserHandler(UserRepository userRepository){
this.userRepository = userRepository;
}
public Mono<ServerResponse> getAllUsers(ServerRequest request){
Flux<User> users = this.userRepository.getAllUsers();
return ServerResponse.ok().contentType(APPLICATION_JSON).body(users, User.class);
}
}
最后,我们将需要创建一个服务器来保留 REST API。在下面的代码中,我们的Server类将创建一个 REST API 来获取用户:
package com.packtpub.reactive;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RequestPredicates.POST;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
import static org.springframework.web.reactive.function.server.RequestPredicates.method;
import static org.springframework.web.reactive.function.server.RequestPredicates.path;
import static org.springframework.web.reactive.function.server.RouterFunctions.nest;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
import static org.springframework.web.reactive.function.server.RouterFunctions.toHttpHandler;
import java.io.IOException;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.ipc.netty.http.server.HttpServer;
public class Server {
public static final String HOST = "localhost";
public static final int PORT = 8081;
public static void main(String[] args) throws InterruptedException, IOException{
Server server = new Server();
server.startReactorServer();
System.out.println("Press ENTER to exit.");
System.in.read();
}
public void startReactorServer() throws InterruptedException {
RouterFunction<ServerResponse> route = routingFunction();
HttpHandler httpHandler = toHttpHandler(route);
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
HttpServer server = HttpServer.create(HOST, PORT);
server.newHandler(adapter).block();
}
public RouterFunction<ServerResponse> routingFunction() {
UserRepository repository = new UserRepositorySample();
UserHandler handler = new UserHandler(repository);
return nest (
path("/user"),
nest(
accept(APPLICATION_JSON),
route(GET("/{id}"), handler::getAllUsers)
.andRoute(method(HttpMethod.GET), handler::getAllUsers)
).andRoute(POST("/").and(contentType(APPLICATION_JSON)), handler::getAllUsers));
}
}
我们将在接下来的章节中更多地讨论我们是如何做到这一点的。只要确保您能够理解代码是如何工作的,并且可以通过访问 API 在浏览器上看到输出。
运行Server.class,您将看到日志:
Press ENTER to exit.
现在您可以在浏览器/SoapUI/Postman 或任何其他客户端访问 API:
http://localhost:8081/user/
由于我们在 Reactive 服务器中使用了8081端口,我们只能访问8081而不是8080:
[
{
"userid": 100,
"username": "David"
},
{
"userid": 101,
"username": "John"
},
{
"userid": 102,
"username": "Kevin"
},
]
总结
到目前为止,我们已经看到如何设置 Maven 构建来支持我们的基本 Web 服务实现。此外,我们还学习了 Maven 在第三方库管理以及 Spring Boot 和基本 Spring REST 项目中的帮助。在接下来的章节中,我们将更多地讨论 Spring REST 端点和 Reactor 支持。
第四章:Spring REST 中的 CRUD 操作
在本章中,我们将介绍 Spring 5 Reactive REST 中的基本创建,读取,更新和删除(CRUD)API。在本章之后,您将能够在具有 Reactor 支持的 Spring 5 中执行简单的 CRUD 操作。
在本章中,我们将介绍以下方法:
-
将 CRUD 操作映射到 HTTP 方法
-
创建用户
-
更新用户
-
删除用户
-
阅读(选择)用户
Spring REST 中的 CRUD 操作
在本章中,我们将介绍 Spring 5 中的用户管理(带有 Reactive 支持)。我们将在用户管理中实现 CRUD 操作。
HTTP 方法
根据 HTTP 1.1 规范,以下是方法定义:
-
GET:此方法获取 URI 中提到的信息。GET方法可用于单个或多个项目。 -
POST:此方法创建 URI 中提到的项目。通常,POST方法将用于项目创建和更安全的选项。由于参数在POST中是隐藏的,因此与GET方法相比,它将更安全。 -
DELETE:此方法删除请求的 URI 中的项目。 -
PUT:此方法更新请求的 URI 中的项目。根据 HTTP 规范,如果项目不可用,服务器可以创建项目。但是,这将由设计应用程序的开发人员决定。 -
高级 HTTP 方法:虽然我们可能不会始终使用高级方法,但了解这些方法将是有益的:
-
HEAD:此方法获取有关资源的元信息,而不是资源本身作为响应。它将用于缓存目的。 -
TRACE:此方法主要用于调试目的,其中 HTTP 请求的内容将被发送回请求者。 -
CONNECT:这用于打开隧道,可用于代理目的。 -
OPTIONS:此方法用于描述目标资源的通信选项。
以下是我们 CRUD 操作的 HTTP 方法建议:
| 操作 | HTTP 方法 |
|---|---|
| 创建 | POST |
| 读取 | GET |
| 更新 | PUT |
| 删除 | DELETE |
在本章的其余部分,我们将展示如何构建 CRUD 操作。
响应式服务器初始化
在进入端点之前,我们将探索我们的文件结构,包括初始化程序、处理程序和存储库。
用于初始化我们的端口8081的Server类如下:
public class Server {
public static final String HOST = "localhost";
public static final int PORT = 8081;
public static void main(String[] args) throws InterruptedException, IOException{
Server server = new Server();
server.startReactorServer();
System.out.println("Press ENTER to exit.");
System.in.read();
}
public void startReactorServer() throws InterruptedException {
RouterFunction<ServerResponse> route = routingFunction();
HttpHandler httpHandler = toHttpHandler(route);
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
HttpServer server = HttpServer.create(HOST, PORT);
server.newHandler(adapter).block();
}
public RouterFunction<ServerResponse> routingFunction() {
// our Endpoints will be coming here
}
}
在上述方法中,我们创建了一个main类。在main方法中,我们将使用以下代码初始化服务器并启动服务器:
Server server = new Server();
server.startReactorServer();
上述方法将启动 Reactor 服务器。 Reactor 服务器的实现如下:
RouterFunction<ServerResponse> route = routingFunction();
HttpHandler httpHandler = toHttpHandler(route);
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
HttpServer server = HttpServer.create(HOST, PORT);
server.newHandler(adapter).block();
让我们稍后再看这段代码,因为这个概念是基于 Reactive 的。假设这段代码运行良好,我们将继续前进,重点放在端点上。
以下是映射我们所有 CRUD 操作的 REST 端点的方法:
public RouterFunction<ServerResponse> routingFunction() {
// our Endpoints will be coming here
}
您可能会在UserRepository和UserHandler上遇到错误。现在让我们填写这些:
package com.packtpub.reactive;
public interface UserRepository {
// repository functions will be coming here
}
在上述代码中,我们刚刚在现有包com.packtpub.reactive中添加了UserRepository接口。稍后,我们将为我们的业务需求引入抽象方法。
现在,我们可以添加一个UserHandler类,并添加必要的内容:
package com.packtpub.reactive;
// import statements
public class UserHandler {
private final UserRepository userRepository;
public UserHandler(UserRepository userRepository){
this.userRepository = userRepository;
}
}
在上面的代码中,UserHandler在其构造函数中初始化了UserRepository实例。如果有人获得了UserHandler的实例,他们将不得不将UserRepository类型传递给UserHandler的构造函数。通过这样做,UserRepository将始终被转发到UserHandler以满足业务需求。
存储库中的示例值
为了使用存储库,我们将不得不创建一个具体的类并填写一些值来测试GET操作。在下面的方法中,我们可以这样做:
package com.packtpub.reactive;
// import statements
public class UserRepositorySample implements UserRepository {
// initiate Users
private final Map<Integer, User> users = new HashMap<>();
// fill dummy values for testing
public UserRepositorySample() {
this.users.put(100, new User(100, "David"));
this.users.put(101, new User(101, "John"));
this.users.put(102, new User(102, "Kevin"));
}
}
在上述类中,我们刚刚实现了UserRepository并填写了一些示例值。
为了简化我们的代码,我们只使用基于应用程序的数据存储,这意味着一旦应用程序重新启动,我们的数据将被重新初始化。在这种情况下,我们无法在我们的应用程序中存储任何新数据。但是,这将帮助我们专注于我们的主题,比如与持久性无关的 Reactive 和 Spring 5。
我们可以在routing方法中使用这个示例存储库:
public RouterFunction<ServerResponse> routingFunction() {
UserRepository repository = new UserRepositorySample();
UserHandler handler = new UserHandler(repository);
}
上述行将在我们的存储库中插入虚拟值。这足以测试GET操作。
获取所有用户-映射
在routingFunction中,我们将为getAllUsers添加我们的第一个端点。起初,我们将在处理程序中保留null值,以避免代码中的错误:
return nest (
path("/user"),
nest(
accept(MediaType.ALL),
route(GET("/"), null)
)
);
上述的nest方法将用于路由到正确的函数,并且还将用于分组其他路由器。在上述方法中,我们在我们的路径中使用/user,并使用GET("/")方法作为路由器。此外,我们使用MediaType.ALL来接受所有媒体范围,以简化代码。
获取所有用户-处理程序和存储库中的实现
在这里,我们将在我们的存储库中定义和实现getAllUsers方法。此外,我们将通过UserHandler在main类中调用getAllUsers方法。
我们将在UserRepository类中添加一个getAllUsers方法的抽象方法:
Flux<User> getAllUsers();
与任何其他接口和具体类实现一样,我们必须在我们的接口中添加抽象方法,在我们的情况下是UserRespository。上述代码只是在UserRepository类中添加了getAllUsers。
在UserRepositorySample(UserRepository的具体类)中,我们将实现抽象方法getAllUsers:
// this method will return all users
@Override
public Flux<User> getAllUsers() {
return Flux.fromIterable(this.users.values());
}
在上面的代码中,我们已经添加了getAllUsers方法并实现了业务逻辑。由于我们已经在UserRepositorySample构造函数中定义了用户,我们只需要返回用户。Flux类有一个叫做fromIterable的方法,用于从我们的UserRepositorySample中获取所有用户。
fromIterable方法将返回一个发出 Java 集合接口中包含的项目的 Flux。由于 Collection 实现了 iterable 接口,fromIterable将是在我们的情况下返回Flux的完美方法。
在UserHandler.java文件中,我们将添加以 Reactive 方式获取所有用户的代码。以下代码将为我们提供必要的细节:
public Mono<ServerResponse> getAllUsers(ServerRequest request){
Flux<User> users = this.userRepository.getAllUsers();
return ServerResponse.ok().contentType(APPLICATION_JSON).body(users, User.class);
}
在上面的代码中,我们将从Flux中获取所有用户,并以 JSON 类型发送响应。服务器响应内容类型已更新为APPLICATION_JSON。
现在是时候在我们的路由方法中添加我们的第一个方法getAllUsers了。在这里,我们将只使用一个路由方法来映射所有的 REST API。
最后,在Server.java中,我们的路由函数将如下所示:
public class Server {
// existing code is hidden
public RouterFunction<ServerResponse> routingFunction() {
UserRepository repository = new UserRepositorySample();
UserHandler handler = new UserHandler(repository);
return nest (
path("/user"),
nest(
accept(MediaType.ALL),
route(GET("/"), handler::getAllUsers)
)
);
}
在上面的代码中,我们创建了一个UserRepository并将其转发给我们的UserHandler。UserHandler将自动调用UserSampleRepository中的getAllUsers方法。通过调用UserHandler的getAllUsers方法,我们将从我们之前实现的示例存储库类中获取所有用户。
在这里,我们使用nest方法并提供参数,比如 API 路径GET("/")和媒体类型。由于nest方法接受RoutingFunction作为第二个参数,我们可以在基本的nest方法中使用更多的nest方法。通过使用内部嵌套方法,我们已经实现了业务需求:我们的基本 REST API 从"/user"开始,并通过"/"基本获取用户 API 路由。
因此,基本的 API 路径/user将自动调用上面代码中实现的getAllUsers方法。
测试端点-获取所有用户
由于我们已经完成了第一个 API 的实现,现在我们可以通过在浏览器中调用以下 URI 来测试它:
http://localhost:8081/user
您应该得到以下结果:
[
{
userid: 100,
username: "David"
},
{
userid: 101,
username: "John"
},
{
userid: 102,
username: "Kevin"
}
]
您还可以在任何 REST 客户端中检查 API,比如 Postman/SoapUI 或其他任何 REST 客户端。
getUser-处理程序和存储库中的实现
在这里,我们将在存储库中定义和实现getUser方法。此外,我们将通过UserHandler在main类中调用getUser方法。
我们将在UserRepository类中为getUser方法添加一个抽象方法:
Mono<User> getUser(Integer id);
在这里,我们将添加getUser方法的代码。您可以看到我们使用了Mono返回类型来访问单个资源。
在UserRepositorySample类(UserRepository的具体类)中,我们将实现抽象方法getUser:
@Override
public Mono<User> getUser(Integer id){
return Mono.justOrEmpty(this.users.get(id));
}
在上述代码中,我们通过id检索了特定用户。此外,我们已经提到,如果用户不可用,应该要求该方法返回一个空的 Mono。
在UserHandler方法中,我们将讨论如何处理请求并应用我们的业务逻辑来获得响应:
public Mono<ServerResponse> getUser(ServerRequest request){
int userId = Integer.valueOf(request.pathVariable("id"));
Mono<ServerResponse> notFound = ServerResponse.notFound().build();
Mono<User> userMono = this.userRepository.getUser(userId);
return userMono
.flatMap(user -> ServerResponse.ok().contentType(APPLICATION_JSON).body(fromObject(user)))
.switchIfEmpty(notFound);
}
在上述代码中,我们刚刚将字符串id转换为整数,以便将其提供给我们的Repository方法(getUser)。一旦我们从Repository接收到结果,我们只需将其映射到带有JSON内容类型的Mono<ServerResponse>中。此外,我们使用switchIfEmpty来在没有项目可用时发送适当的响应。如果搜索项目不可用,它将简单地返回空的Mono对象作为响应。
最后,我们将在Server.java中的路由路径中添加getUser:
public RouterFunction<ServerResponse> routingFunction() {
UserRepository repository = new UserRepositorySample();
UserHandler handler = new UserHandler(repository);
return nest (
path("/user"),
nest(
accept(MediaType.ALL),
route(GET("/"), handler::getAllUsers)
)
.andRoute(GET("/{id}"), handler::getUser)
);
}
在上述代码中,我们刚刚在现有路由路径中添加了一个新条目.andRoute(GET("/{id}"), handler::getUser)。通过这样做,我们已经添加了getUser方法和相应的 REST API 部分来访问单个用户。重新启动服务器后,我们应该能够使用 REST API。
测试端点-获取用户
由于我们已经完成了第一个 API 实现,现在可以通过在浏览器中使用GET方法调用以下 URI 来测试它:
http://localhost:8081/user/100
您应该会得到以下结果:
{
userid: 100,
username: "David"
}
创建用户-在处理程序和存储库中的实现
在这里,我们将在存储库中定义和实现createUser方法。此外,我们将通过UserHandler在main类中调用createUser方法。
我们将在UserRepository类中为createUser方法添加一个抽象方法:
Mono<Void> saveUser(Mono<User> userMono);
在这里,我们将讨论如何使用示例存储库方法保存用户。
在UserRepositorySample(UserRepository的具体类)中,我们将实现抽象方法createUser:
@Override
public Mono<Void> saveUser(Mono<User> userMono) {
return userMono.doOnNext(user -> {
users.put(user.getUserid(), user);
System.out.format("Saved %s with id %d%n", user, user.getUserid());
}).thenEmpty(Mono.empty());
}
在上述代码中,我们使用doOnNext来保存用户在存储库中。此外,如果失败,该方法将返回空的Mono。
由于我们已经在存储库中添加了createUser方法,因此我们将在处理程序中进行后续操作:
public Mono<ServerResponse> createUser(ServerRequest request) {
Mono<User> user = request.bodyToMono(User.class);
return ServerResponse.ok().build(this.userRepository.saveUser(user));
}
在UserHandler类中,我们创建了createUser方法,通过处理程序添加用户。在该方法中,我们通过bodyToMono方法将请求提取为Mono。一旦创建了用户,它将被转发到UserRepository以保存该方法。
最后,我们将在Server.java的现有路由函数中添加 REST API 路径以保存用户:
public RouterFunction<ServerResponse> routingFunction() {
UserRepository repository = new UserRepositorySample();
UserHandler handler = new UserHandler(repository);
return nest (
path("/user"),
nest(
accept(MediaType.ALL),
route(GET("/"), handler::getAllUsers)
)
.andRoute(GET("/{id}"), handler::getUser)
.andRoute(POST("/").and(contentType(APPLICATION_JSON)), handler::createUser)
);
}
测试端点-创建用户
由于我们已经完成了第一个 API 实现,现在可以通过在浏览器中调用以下 URI 来测试它:
http://localhost:8081/user
由于我们无法在浏览器中使用POST方法,因此我们将在名为 Postman 的 REST API 客户端中进行测试:

添加新用户后,您可以通过调用getAllUsers URI(http://localhost:8081/user)来检查结果。
Postman是一个 REST 客户端,可用于构建,测试和共享 REST API 调用。在测试 REST API 时,这样的工具将非常有帮助,而无需编写测试代码。
SoapUI是另一个 REST 客户端,可以作为 Postman 的替代品使用。
更新用户-在处理程序和存储库中的实现
在这里,我们将在存储库中定义和实现updateUser方法。此外,我们将通过UserHandler在main类中调用updateUser方法。
我们将在UserRepository类中为updateUser方法添加一个抽象方法:
Mono<Void> updateUser(Mono<User> userMono);
在UserRepositorySample类中,我们将添加更新代码的逻辑。在这里,我们将使用userid作为键,并将User对象作为值存储在我们的映射中:
@;Override
public Mono<Void> updateUser(Mono<User> userMono) {
return userMono.doOnNext(user -> {
users.put(user.getUserid(), user);
System.out.format("Saved %s with id %d%n", user, user.getUserid());
}).thenEmpty(Mono.empty());
}
在上面的代码中,我们通过添加指定的用户(来自请求)来更新用户。一旦用户添加到列表中,该方法将返回Mono<Void>;否则,它将返回Mono.empty对象。
由于我们已经在存储库中添加了updateUser方法,现在我们将跟进我们的处理程序:
public Mono<ServerResponse> updateUser(ServerRequest request) {
Mono<User> user = request.bodyToMono(User.class);
return ServerResponse.ok().build(this.userRepository.saveUser(user));
}
在上述代码中,我们通过调用bodyToMono方法将用户请求转换为Mono<User>。bodyToMono方法将提取主体并转换为Mono对象,以便用于保存选项。
与其他 API 路径一样,我们在Server.java中添加了updateUser API:
public RouterFunction<ServerResponse> routingFunction() {
UserRepository repository = new UserRepositorySample();
UserHandler handler = new UserHandler(repository);
return nest (
path("/user"),
nest(
accept(MediaType.ALL),
route(GET("/"), handler::getAllUsers)
)
.andRoute(GET("/{id}"), handler::getUser)
.andRoute(POST("/").and(contentType(APPLICATION_JSON)), handler::createUser)
.andRoute(PUT("/").and(contentType(APPLICATION_JSON)), handler::updateUser)
);
}
测试端点 - updateUser
由于我们已经添加了deleteUser方法,现在我们将通过在 Postman 或 SoapUI 中使用PUT方法调用 URI http://localhost:8081/user 来测试它:

更新新用户后,您可以通过调用getAllUsers URI (http://localhost:8081/user) 来检查结果。
deleteUser - 处理程序和存储库中的实现
在这里,我们将在存储库中定义和实现deleteUser方法。此外,我们将通过UserHandler在main类中调用deleteUser方法。
像往常一样,我们将在UserRepository类中为deleteUser方法添加一个抽象方法:
Mono<Void> deleteUser(Integer id);
在UserRepositorySample.java文件中,我们将添加deleteUser方法来从列表中删除指定的用户:
@Override
public Mono<Void> deleteUser(Integer id) {
users.remove(id);
System.out.println("user : "+users);
return Mono.empty();
}
在上述方法中,我们只是从用户中删除元素并返回一个空的Mono对象。
由于我们已经在存储库中添加了deleteUser方法,现在我们将跟进我们的处理程序:
public Mono<ServerResponse> deleteUser(ServerRequest request) {
int userId = Integer.valueOf(request.pathVariable("id"));
return ServerResponse.ok().build(this.userRepository.deleteUser(userId));
}
最后,我们将在Server.java中的现有路由函数中添加 REST API 路径以保存user:
public RouterFunction<ServerResponse> routingFunction() {
UserRepository repository = new UserRepositorySample();
UserHandler handler = new UserHandler(repository);
return nest (
path("/user"),
nest(
accept(MediaType.ALL),
route(GET("/"), handler::getAllUsers)
)
.andRoute(GET("/{id}"), handler::getUser)
.andRoute(POST("/").and(contentType(APPLICATION_JSON)), handler::createUser)
.andRoute(PUT("/").and(contentType(APPLICATION_JSON)), handler::updateUser)
.andRoute(DELETE("/{id}"), handler::deleteUser)
);
}
测试端点 - deleteUser
由于我们已经完成了第一个 API 的实现,现在我们可以通过在客户端(Postman 或 SoapUI)中使用DELETE方法调用 URI http://localhost:8081/user/100 来测试它:

删除新用户后,您可以通过调用getAllUsers URI (http://localhost:8081/user) 来检查结果。
总结
在本章中,我们学习了如何使用 Reactive 支持(Flux 和 Mono)以及如何将我们的 API 与 Reactive 组件集成。我们已经学习了如何使用 Reactor 服务器对基于 Reactive 的 REST API 进行基本的 CRUD 操作。此外,我们还介绍了如何为我们的 CRUD 操作添加路由选项,并简要讨论了在 CRUD 操作中 Flux 和 Mono 的实现。
在接下来的章节中,我们将专注于 Spring 5 REST(不带 Reactor 支持),因为 Spring Reactive 库/ API 仍处于不稳定状态,并且在主流应用程序中并没有被广泛使用。尽管 Spring 团队正式发布了对 Reactive 的支持,但大多数业务需求并没有得到清晰的实现和文档化。考虑到这种情况,在接下来的章节中,我们将讨论不涉及 Reactive 相关主题的 Spring 5。
第五章:普通 REST 中的 CRUD 操作(不包括 Reactive)和文件上传
在上一章中,我们探讨了对 Reactive 支持的 CRUD 操作。由于 Spring 开发团队仍在更新更多的 Reactive 实体,Reactive 支持还没有达到他们的水平。尽管 Spring 5 的 Reactive 支持运行良好,但他们仍需要改进以使其更加稳定。考虑到这些要点,我们计划避免使用 Reactive 支持,以使其对您更加简单。
在本章中,我们将介绍 Spring 5(不包括 Reactive)REST 中的基本 CRUD(创建、读取、更新和删除)API。在本章之后,您将能够在 Spring 5 中进行简单的 CRUD 操作,而无需 Reactive 支持。此外,我们将讨论 Spring 5 中的文件上传选项。
在本章中,我们将涵盖以下方法:
-
将 CRUD 操作映射到 HTTP 方法
-
创建用户
-
更新用户
-
删除用户
-
读取(选择)用户
-
Spring 中的文件上传
将 CRUD 操作映射到 HTTP 方法
在上一章中,您看到了控制器中的 CRUD 操作。在本章中,我们将进行相同的 CRUD 操作;但是,我们已经排除了所有 Reactive 组件。
创建资源
要创建基本的 Spring 项目资源,您可以使用 Spring Initializr(start.spring.io/)。在 Spring Initializr 中,提供必要的详细信息:
使用 Java 和 Spring Boot 1.5.9 生成一个 Maven 项目。
组:com.packtpub.restapp
Artifact:ticket-management
搜索依赖项:选择Web(使用 Tomcat 和 Web MVC 进行全栈 Web 开发)依赖项
填写完详细信息后,只需点击Generate Project;然后它将以 ZIP 格式创建 Spring 基本资源。我们可以通过将它们导入 Eclipse 来开始使用项目。
Spring 5 的 POM 文件将如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<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>com.packtpub.restapp</groupId>
<artifactId>ticket-management</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>ticket-management</name>
<description>Demo project for Spring Boot</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.9.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencies>
<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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
让我们移除父级以简化 POM:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.9.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
由于我们移除了父级,我们可能需要在所有依赖项中添加版本。让我们在我们的依赖项中添加版本:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>1.5.9.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<version>1.5.9.RELEASE</version>
</dependency>
</dependencies>
由于依赖项 artifact spring-boot-starter-web版本1.5.9基于 Spring 4.3.11,我们将不得不升级到 Spring 5。让我们清理并升级我们的 POM 文件以引入 Spring 5 更新:
<?xml version="1.0" encoding="UTF-8"?>
<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>com.packtpub.restapp</groupId>
<artifactId>ticket-management</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>ticket-management</name>
<description>Demo project for Spring Boot</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>1.5.9.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<version>1.5.9.RELEASE</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
您可以在上述 POM 文件中看到与 Spring 5 相关的依赖项。让我们使用 REST 端点对它们进行测试。首先,创建一个 Spring Boot 主文件来初始化 Spring Boot:
@SpringBootApplication
public class TicketManagementApplication {
public static void main(String[] args) {
SpringApplication.run(TicketManagementApplication.class, args);
}
}
您可以通过右键单击项目并选择Run As | Spring Boot App在 Eclipse 上运行 Spring Boot。如果这样做,您将在 Eclipse 控制台中看到日志。
如果您看不到控制台,可以通过Window | Show View | Console获取它。
以下是一个示例日志。您可能看不到完全匹配;但是,您将了解服务器运行日志的外观:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v1.5.7.RELEASE)
2017-11-05 15:49:21.380 INFO 8668 --- [ main] c.p.restapp.TicketManagementApplication : Starting TicketManagementApplication on DESKTOP-6JP2FNB with PID 8668 (C:\d\spring-book-sts-space\ticket-management\target\classes started by infoadmin in C:\d\spring-book-sts-space\ticket-management)
2017-11-05 15:49:21.382 INFO 8668 --- [ main] c.p.restapp.TicketManagementApplication : No active profile set, falling back to default profiles: default
2017-11-05 15:49:21.421 INFO 8668 --- [ main] ationConfigEmbeddedWebApplicationContext : Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@5ea434c8: startup date [Sun Nov 05 15:49:21 EST 2017]; root of context hierarchy
2017-11-05 15:49:22.205 INFO 8668 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat initialized with port(s): 8080 (http)
2017-11-05 15:49:22.213 INFO 8668 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
...
..
...
...
2017-11-05 15:49:22.834 INFO 8668 --- [ main] o.s.j.e.a.AnnotationMBeanExporter : Registering beans for JMX exposure on startup
2017-11-05 15:49:22.881 INFO 8668 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
您应该在日志的最后几行看到Tomcat started on port(s): 8080。
当您检查 URI http://localhost:8080 时,您将看到以下错误:
Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.
Sun Nov {current date}
There was an unexpected error (type=Not Found, status=404).
No message available
先前的错误是说应用程序中没有配置相应的 URI。让我们通过在com.packtpub.restapp包下创建一个名为HomeController的控制器来解决这个问题:
package com.packtpub.restapp;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/")
public class HomeController {
@ResponseBody
@RequestMapping("")
public Map<String, Object> test(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Aloha");
return map;
}
}
在上述代码中,我们创建了一个名为HomeController的虚拟控制器,并将简单的map作为结果。此外,我们添加了新的控制器,我们需要让我们的主应用程序自动扫描这些类,在我们的情况下是TicketManagementApplication类。我们将通过在主类中添加@ComponentScan("com.packtpub")来告诉它们。最后,我们的主类将如下所示:
package com.packtpub.restapp.ticketmanagement;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
@ComponentScan("com.packtpub")
@SpringBootApplication
public class TicketManagementApplication {
public static void main(String[] args) {
SpringApplication.run(TicketManagementApplication.class, args);
}
}
当您重新启动 Spring Boot 应用程序时,您将看到 REST 端点正在工作(localhost:8080):
{
result: "Aloha"
}
Spring 5 中的 CRUD 操作(不包括 Reactive)
让我们执行用户 CRUD 操作。由于我们之前已经讨论了 CRUD 概念,因此在这里我们只讨论 Spring 5 上的用户管理(不包括 Reactive 支持)。让我们为 CRUD 端点填充所有虚拟方法。在这里,我们可以创建UserContoller并填充所有 CRUD 用户操作的方法:
package com.packtpub.restapp;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user")
public class UserController {
@ResponseBody
@RequestMapping("")
public Map<String, Object> getAllUsers(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Get All Users Implementation");
return map;
}
@ResponseBody
@RequestMapping("/{id}")
public Map<String, Object> getUser(@PathVariable("id") Integer id){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Get User Implementation");
return map;
}
@ResponseBody
@RequestMapping(value = "", method = RequestMethod.POST)
public Map<String, Object> createUser(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Create User Implementation");
return map;
}
@ResponseBody
@RequestMapping(value = "", method = RequestMethod.PUT)
public Map<String, Object> updateUser(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Update User Implementation");
return map;
}
@ResponseBody
@RequestMapping(value = "", method = RequestMethod.DELETE)
public Map<String, Object> deleteUser(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Delete User Implementation");
return map;
}
}
我们已经为所有 CRUD 操作填充了基本端点。如果您在 Postman 上调用它们,并使用适当的方法,如GET,POST,PUT和DELETE,您将看到提到适当消息的结果。
例如,对于getAllUsers API(localhost:8080/user),您将获得:
{
result: "Get All Users Implementation"
}
getAllUsers - 实现
让我们实现getAllUsers API。对于这个 API,我们可能需要在com.packtpub.model包下创建一个名为User的模型类:
package com.packtpub.model;
public class User {
private Integer userid;
private String username;
public User(Integer userid, String username){
this.userid = userid;
this.username = username;
}
// getter and setter methods
}
现在,我们将添加getAllUsers实现的代码。由于这是业务逻辑,我们将创建一个单独的UserService和UserServiceImpl类。通过这样做,我们可以将业务逻辑放在不同的地方,以避免代码复杂性。
UserService接口如下所示:
package com.packtpub.service;
import java.util.List;
import com.packtpub.model.User;
public interface UserService {
List<User> getAllUsers();
}
UserServiceImpl类的实现如下:
package com.packtpub.service;
import java.util.LinkedList;
import java.util.List;
import org.springframework.stereotype.Service;
import com.packtpub.model.User;
@Service
public class UserServiceImpl implements UserService {
@Override
public List<User> getAllUsers() {
return this.users;
}
// Dummy users
public static List<User> users;
public UserServiceImpl() {
users = new LinkedList<>();
users.add(new User(100, "David"));
users.add(new User(101, "Peter"));
users.add(new User(102, "John"));
}
}
在前面的实现中,我们在构造函数中创建了虚拟用户。当类由 Spring 配置初始化时,这些用户将被添加到列表中。
调用getAllUsers方法的UserController类如下:
@Autowired
UserService userSevice;
@ResponseBody
@RequestMapping("")
public List<User> getAllUsers(){
return userSevice.getAllUsers();
}
在前面的代码中,我们通过在控制器文件中进行自动装配来调用getAllUsers方法。@Autowired将在幕后执行所有实例化魔术。
如果您现在运行应用程序,可能会遇到以下错误:
***************************
APPLICATION FAILED TO START
***************************
Description:
Field userSevice in com.packtpub.restapp.UserController required a bean of type 'com.packtpub.service.UserService' that could not be found.
Action:
Consider defining a bean of type 'com.packtpub.service.UserService' in your configuration.
这个错误的原因是您的应用程序无法识别UserService,因为它在不同的包中。我们可以通过在TicketManagementApplication类中添加@ComponentScan("com.packtpub")来解决这个问题。这将识别不同子包中的所有@service和其他 bean:
@ComponentScan("com.packtpub")
@SpringBootApplication
public class TicketManagementApplication {
public static void main(String[] args) {
SpringApplication.run(TicketManagementApplication.class, args);
}
}
现在您可以在调用 API(http://localhost:8080/user)时看到结果:
[
{
userid: 100,
username: "David"
},
{
userid: 101,
username: "Peter"
},
{
userid: 102,
username: "John"
}
]
getUser - 实现
就像我们在第四章中所做的那样,Spring REST 中的 CRUD 操作,我们将在本节中实现getUser业务逻辑。让我们使用 Java 8 Streams 在这里添加getUser方法。
UserService接口如下所示:
User getUser(Integer userid);
UserServiceImpl类的实现如下:
@Override
public User getUser(Integer userid) {
return users.stream()
.filter(x -> x.getUserid() == userid)
.findAny()
.orElse(new User(0, "Not Available"));
}
在之前的getUser方法实现中,我们使用了 Java 8 Streams 和 lambda 表达式来通过userid获取用户。与传统的for循环不同,lambda 表达式使得获取详细信息更加容易。在前面的代码中,我们通过过滤条件检查用户。如果用户匹配,它将返回特定用户;否则,它将创建一个带有"Not available"消息的虚拟用户。
getUser方法的UserController类如下:
@ResponseBody
@RequestMapping("/{id}")
public User getUser(@PathVariable("id") Integer id){
return userSevice.getUser(100);
}
您可以通过访问客户端中的http://localhost:8080/user/100来验证 API(使用 Postman 或 SoapUI 进行测试):
{
userid: 100,
username: "David"
}
createUser - 实现
现在我们可以添加创建用户选项的代码。
UserService接口如下所示:
void createUser(Integer userid, String username);
UserServiceImpl类的实现如下:
@Override
public void createUser(Integer userid, String username) {
User user = new User(userid, username);
this.users.add(user);
}
createUser方法的UserController类如下:
@ResponseBody
@RequestMapping(value = "", method = RequestMethod.POST)
public Map<String, Object> createUser(
@RequestParam(value="userid") Integer userid,
@RequestParam(value="username") String username
){
Map<String, Object> map = new LinkedHashMap<>();
userSevice.createUser(userid, username);
map.put("result", "added");
return map;
}
前面的代码将在我们的映射中添加用户。在这里,我们使用userid和username作为方法参数。您可以在以下 API 调用中查看userid和username:

当您使用 SoapUI/Postman 调用此方法时,您将获得以下结果。在这种情况下,我们使用参数(userid,username)而不是 JSON 输入。这只是为了简化流程:
{"result": "added"}
updateUser - 实现
现在我们可以添加更新用户选项的代码。
UserService接口如下所示:
void updateUser(Integer userid, String username);
UserServiceImpl类的实现如下:
@Override
public void updateUser(Integer userid, String username) {
users.stream()
.filter(x -> x.getUserid() == userid)
.findAny()
.orElseThrow(() -> new RuntimeException("Item not found"))
.setUsername(username);
}
在前面的方法中,我们使用了基于 Java Streams 的实现来更新用户。我们只需应用过滤器并检查用户是否可用。如果userid不匹配,它将抛出RuntimeException。如果用户可用,我们将获得相应的用户,然后更新username。
updateUser方法的UserController类如下:
@ResponseBody
@RequestMapping(value = "", method = RequestMethod.PUT)
public Map<String, Object> updateUser(
@RequestParam(value="userid") Integer userid,
@RequestParam(value="username") String username
){
Map<String, Object> map = new LinkedHashMap<>();
userSevice.updateUser(userid, username);
map.put("result", "updated");
return map;
}
我们将尝试将userid为100的username从David更新为Sammy。我们可以从以下截图中查看 API 的详细信息:

当我们使用 SoapUI/Postman 扩展(http://localhost:8080/user)调用此 API(UPDATE方法)时,我们将得到以下结果:
{"result": "updated"}
您可以通过在 Postman 扩展中检查getAllUsers API(GET方法)(http://localhost:8080/user)来检查结果;您将得到以下结果:
[
{
"userid": 100,
"username": "Sammy"
},
{
"userid": 101,
"username": "Peter"
},
{
"userid": 102,
"username": "John"
},
{
"userid": 104,
"username": "Kevin"
}
]
deleteUser - 实现
现在我们可以添加deleteUser选项的代码。
UserService接口如下所示:
void deleteUser(Integer userid);
UserServiceImpl类的实现如下:
@Override
public void deleteUser(Integer userid) {
users.removeIf((User u) -> u.getUserid() == userid);
}
UserController类的deleteUser方法如下所示:
@ResponseBody
@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
public Map<String, Object> deleteUser(
@PathVariable("id") Integer userid) {
Map<String, Object> map = new LinkedHashMap<>();
userSevice.deleteUser(userid);
map.put("result", "deleted");
return map;
}
当您使用 Postman 扩展调用此 API(DELETE方法)(http://localhost:8080/user/100)时,您将得到以下结果:
{"result": "deleted"}
您还可以检查getAllUsers方法,以验证您是否已删除用户。
文件上传 - REST API
在支持NIO库和 Spring 的MultipartFile选项的支持下,文件上传变得非常容易。在这里,我们将添加文件上传的代码。
FileUploadService接口如下所示:
package com.packtpub.service;
import org.springframework.web.multipart.MultipartFile;
public interface FileUploadService {
void uploadFile(MultipartFile file) throws IOException;
}
在上述代码中,我们只是定义了一个方法,让具体类(实现类)覆盖我们的方法。我们在这里使用MultipartFile来传递文件,例如媒体文件,以满足我们的业务逻辑。
FileUploadServerImpl类的实现如下:
package com.packtpub.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
@Service
public class FileUploadServerImpl implements FileUploadService {
private Path location;
public FileUploadServerImpl() throws IOException {
location = Paths.get("c:/test/");
Files.createDirectories(location);
}
@Override
public void uploadFile(MultipartFile file) throws IOException {
String fileName = StringUtils.cleanPath(file.getOriginalFilename());
if (fileName.isEmpty()) {
throw new IOException("File is empty " + fileName);
} try {
Files.copy(file.getInputStream(),
this.location.resolve(fileName),
StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new IOException("File Upload Error : " + fileName);
}
}
}
在上述代码中,我们在构造函数中设置了位置,因此当 Spring Boot App 初始化时,它将设置正确的路径;如果需要,它将在指定位置创建一个特定的文件夹。
在uploadFile方法中,我们首先获取文件并进行清理。我们使用一个名为StringUtils的 Spring 实用类来清理文件路径。您可以在这里看到清理过程:
String fileName = StringUtils.cleanPath(file.getOriginalFilename());
如果文件为空,我们只是抛出一个异常。您可以在这里检查异常:
if(fileName.isEmpty()){
throw new IOException("File is empty " + fileName);
}
然后是真正的文件上传逻辑!我们只是使用Files.copy方法将文件从客户端复制到服务器位置。如果发生任何错误,我们会抛出RuntimeException:
try {
Files.copy(
file.getInputStream(), this.location.resolve(fileName),
StandardCopyOption.REPLACE_EXISTING
);
} catch (IOException e) {
throw new IOException("File Upload Error : " + fileName);
}
由于具体类已经完成了主要实现,控制器只是将MultipartFile传递给服务。我们在这里使用了POST方法,因为它是上传文件的完美方法。此外,您可以看到我们使用了@Autowired选项来使用service方法。
FileController类的uploadFile方法如下所示:
package com.packtpub.restapp;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.packtpub.service.FileUploadService;
@RestController
@RequestMapping("/file")
public class FileController {
@Autowired
FileUploadService fileUploadSevice;
@ResponseBody
@RequestMapping(value = "/upload", method = RequestMethod.POST)
public Map<String, Object> uploadFile(@RequestParam("file") MultipartFile file) {
Map<String, Object> map = new LinkedHashMap<>();
try {
fileUploadSevice.uploadFile(file);
map.put("result", "file uploaded");
} catch (IOException e) {
map.put("result", "error while uploading : "+e.getMessage());
}
return map;
}
}
测试文件上传
您可以创建一个 HTML 文件如下,并测试文件上传 API。您还可以使用任何 REST 客户端来测试。我已经给您这个 HTML 文件来简化测试过程:
<!DOCTYPE html>
<html>
<body>
<form action="http://localhost:8080/file/upload" method="post" enctype="multipart/form-data">
Select image to upload:
<input type="file" name="file" id="file">
<input type="submit" value="Upload Image" name="submit">
</form>
</body>
</html>
摘要
在本章中,我们已经介绍了 Spring 5 中的 CRUD 操作(不包括响应式支持),从基本资源开始进行自定义。此外,我们还学习了如何在 Spring 中上传文件。在下一章中,我们将更多地了解 Spring Security 和 JWT(JSON Web Token)。
第六章:Spring Security 和 JWT(JSON Web Token)
在本章中,我们将简单了解 Spring Security,并且我们还将讨论JSON Web Token(JWT)以及如何在我们的 web 服务调用中使用 JWT。这也将包括 JWT 的创建。
在本章中,我们将涵盖以下内容:
-
Spring Security
-
JSON Web Token(JWT)
-
如何在 web 服务中生成 JWT
-
如何在 web 服务中访问和检索 JWT 中的信息
-
如何通过添加 JWT 安全来限制 web 服务调用
Spring Security
Spring Security 是一个强大的身份验证和授权框架,将帮助我们提供一个安全的应用程序。通过使用 Spring Security,我们可以确保所有的 REST API 都是安全的,并且只能通过经过身份验证和授权的调用访问。
身份验证和授权
让我们举个例子来解释一下。假设你有一个有很多书的图书馆。身份验证将提供一个进入图书馆的钥匙;然而,授权将给予你取书的权限。没有钥匙,你甚至无法进入图书馆。即使你有图书馆的钥匙,你也只能取几本书。
JSON Web Token(JWT)
Spring Security 可以以多种形式应用,包括使用强大的库如 JWT 进行 XML 配置。由于大多数公司在其安全中使用 JWT,我们将更多地关注基于 JWT 的安全,而不是简单的 Spring Security,后者可以在 XML 中配置。
JWT 令牌在 URL 上是安全的,并且在单点登录(SSO)环境中与 Web 浏览器兼容。JWT 有三部分:
-
头部
-
有效载荷
-
签名
头部部分决定了应该使用哪种算法来生成令牌。在进行身份验证时,客户端必须保存服务器返回的 JWT。与传统的会话创建方法不同,这个过程不需要在客户端存储任何 cookie。JWT 身份验证是无状态的,因为客户端状态从未保存在服务器上。
JWT 依赖
为了在我们的应用程序中使用 JWT,我们可能需要使用 Maven 依赖。以下依赖应该添加到pom.xml文件中。您可以从以下链接获取 Maven 依赖:mvnrepository.com/artifact/javax.xml.bind。
我们在应用程序中使用了 Maven 依赖的版本2.3.0:
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
由于 Java 9 在其捆绑包中不包括DataTypeConverter,我们需要添加上述配置来使用DataTypeConverter。我们将在下一节中介绍DataTypeConverter。
创建 JWT 令牌
为了创建一个令牌,我们在SecurityService接口中添加了一个名为createToken的抽象方法。该接口将告诉实现类必须为createToken创建一个完整的方法。在createToken方法中,我们将只使用主题和到期时间,因为在创建令牌时这两个选项很重要。
首先,我们将在SecurityService接口中创建一个抽象方法。具体类(实现SecurityService接口的类)必须在其类中实现该方法:
public interface SecurityService {
String createToken(String subject, long ttlMillis);
// other methods
}
在上述代码中,我们在接口中定义了令牌创建的方法。
SecurityServiceImpl是一个具体的类,它通过应用业务逻辑来实现SecurityService接口的抽象方法。以下代码将解释如何使用主题和到期时间来创建 JWT:
private static final String secretKey= "4C8kum4LxyKWYLM78sKdXrzbBjDCFyfX";
@Override
public String createToken(String subject, long ttlMillis) {
if (ttlMillis <= 0) {
throw new RuntimeException("Expiry time must be greater than Zero :["+ttlMillis+"] ");
}
// The JWT signature algorithm we will be using to sign the token
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secretKey);
Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
JwtBuilder builder = Jwts.builder()
.setSubject(subject)
.signWith(signatureAlgorithm, signingKey);
long nowMillis = System.currentTimeMillis();
builder.setExpiration(new Date(nowMillis + ttlMillis));
return builder.compact();
}
上述代码为主题创建了令牌。在这里,我们已经硬编码了秘钥"4C8kum4LxyKWYLM78sKdXrzbBjDCFyfX",以简化令牌创建过程。如果需要,我们可以将秘钥保存在属性文件中,以避免在 Java 代码中硬编码。
首先,我们验证时间是否大于零。如果不是,我们立即抛出异常。我们使用 SHA-256 算法,因为它在大多数应用程序中都被使用。
安全哈希算法(SHA)是一种密码哈希函数。密码哈希是数据文件的文本形式。SHA-256 算法生成一个几乎唯一的、固定大小的 256 位哈希。SHA-256 是更可靠的哈希函数之一。
我们已在此类中将密钥硬编码。我们也可以将密钥存储在application.properties文件中。但是为了简化流程,我们已经将其硬编码:
private static final String secretKey= "4C8kum4LxyKWYLM78sKdXrzbBjDCFyfX";
我们将字符串密钥转换为字节数组,然后将其传递给 Java 类SecretKeySpec,以获取signingKey。此密钥将用于令牌生成器。此外,在创建签名密钥时,我们使用 JCA,这是我们签名算法的名称。
Java 密码体系结构(JCA)是 Java 引入的,以支持现代密码技术。
我们使用JwtBuilder类来创建令牌,并为其设置到期时间。以下代码定义了令牌创建和到期时间设置选项:
JwtBuilder builder = Jwts.builder()
.setSubject(subject)
.signWith(signatureAlgorithm, signingKey);
long nowMillis = System.currentTimeMillis();
builder.setExpiration(new Date(nowMillis + ttlMillis));
在调用此方法时,我们必须传递毫秒时间,因为setExpiration只接受毫秒。
最后,我们必须在我们的HomeController中调用createToken方法。在调用该方法之前,我们将不得不像下面这样自动装配SecurityService:
@Autowired
SecurityService securityService;
createToken调用编码如下。我们将主题作为参数。为了简化流程,我们已将到期时间硬编码为2 * 1000 * 60(两分钟)。
HomeController.java:
@Autowired
SecurityService securityService;
@ResponseBody
@RequestMapping("/security/generate/token")
public Map<String, Object> generateToken(@RequestParam(value="subject") String subject){
String token = securityService.createToken(subject, (2 * 1000 * 60));
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", token);
return map;
}
生成令牌
我们可以通过在浏览器或任何 REST 客户端中调用 API 来测试令牌。通过调用此 API,我们可以创建一个令牌。此令牌将用于用户身份验证等目的。
创建令牌的示例 API 如下:
http://localhost:8080/security/generate/token?subject=one
在这里,我们使用one作为主题。我们可以在以下结果中看到令牌。这就是我们为传递给 API 的所有主题生成令牌的方式:
{
result: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJvbmUiLCJleHAiOjE1MDk5MzY2ODF9.GknKcywiI-G4-R2bRmBOsjomujP0MxZqdawrB8TO3P4"
}
JWT 是一个由三部分组成的字符串,每部分用一个点(.)分隔。每个部分都经过 base-64 编码。第一部分是头部,它提供了关于用于签署 JWT 的算法的线索。第二部分是主体,最后一部分是签名。
从 JWT 令牌中获取主题
到目前为止,我们已经创建了一个 JWT 令牌。在这里,我们将解码令牌并从中获取主题。在后面的部分中,我们将讨论如何解码并从令牌中获取主题。
像往常一样,我们必须定义获取主题的方法。我们将在SecurityService中定义getSubject方法。
在这里,我们将在SecurityService接口中创建一个名为getSubject的抽象方法。稍后,我们将在我们的具体类中实现这个方法:
String getSubject(String token);
在我们的具体类中,我们将实现getSubject方法,并在SecurityServiceImpl类中添加我们的代码。我们可以使用以下代码从令牌中获取主题:
@Override
public String getSubject(String token) {
Claims claims = Jwts.parser() .setSigningKey(DatatypeConverter.parseBase64Binary(secretKey))
.parseClaimsJws(token).getBody();
return claims.getSubject();
}
在前面的方法中,我们使用Jwts.parser来获取claims。我们通过将密钥转换为二进制并将其传递给解析器来设置签名密钥。一旦我们得到了Claims,我们可以通过调用getSubject来简单地获取主题。
最后,我们可以在我们的控制器中调用该方法,并传递生成的令牌以获取主题。您可以检查以下代码,其中控制器调用getSubject方法,并在HomeController.java文件中返回主题:
@ResponseBody
@RequestMapping("/security/get/subject")
public Map<String, Object> getSubject(@RequestParam(value="token") String token){
String subject = securityService.getSubject(token);
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", subject);
return map;
}
从令牌中获取主题
以前,我们创建了获取令牌的代码。在这里,我们将通过调用获取主题 API 来测试我们之前创建的方法。通过调用 REST API,我们将得到之前传递的主题。
示例 API:
http://localhost:8080/security/get/subject?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJvbmUiLCJleHAiOjE1MDk5MzY2ODF9.GknKcywiI-G4-R2bRmBOsjomujP0MxZqdawrB8TO3P4
由于我们在调用generateToken方法创建令牌时使用了one作为主题,所以我们将在getSubject方法中得到"one":
{
result: "one"
}
通常,我们将令牌附加在标头中;然而,为了避免复杂性,我们已经提供了结果。此外,我们已将令牌作为参数传递给getSubject。在实际应用中,您可能不需要以相同的方式进行操作。这只是为了演示目的。
摘要
在本章中,我们已经讨论了 Spring Security 和基于 JWT 令牌的安全性,以获取和解码令牌。在未来的章节中,我们将讨论如何在 AOP 中使用令牌,并通过使用 JWT 令牌来限制 API 调用。
第七章:测试 RESTful Web 服务
在之前的章节中,我们已经讨论了如何创建 REST API 并在我们的 REST API 和服务方法中应用业务逻辑。然而,为了确保我们的业务逻辑,我们可能需要编写适当的测试用例并使用其他测试方法。测试我们的 REST API 将帮助我们在部署到生产环境时保持应用程序的清洁和功能。我们编写单元测试用例或其他测试方法越多,对于将来维护我们的应用程序来说就越好。
在本章中,我们将讨论以下用于我们示例 RESTful web 服务的测试策略:
-
在 Spring 控制器上进行 JUnit 测试
-
MockMvc(对控制器进行模拟)
-
Postman REST 客户端
-
SoapUI REST 客户端
-
jsoup 读取器作为客户端
JUnit
JUnit 是 Java 和 Spring 应用程序最简单和最受欢迎的测试框架。通过为我们的应用程序编写 JUnit 测试用例,我们可以提高应用程序的质量,避免出现错误的情况。
在这里,我们将讨论一个简单的 JUnit 测试用例,它调用userService中的getAllUsers方法。我们可以检查以下代码:
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserTests {
@Autowired
UserService userSevice;
@Test
public void testAllUsers(){
List<User> users = userSevice.getAllUsers();
assertEquals(3, users.size());
}
}
在前面的代码中,我们调用了getAllUsers并验证了总数。让我们在另一个测试用例中测试单用户方法:
// other methods
@Test
public void testSingleUser(){
User user = userSevice.getUser(100);
assertTrue(user.getUsername().contains("David"));
}
在前面的代码片段中,我们只是测试了我们的服务层并验证了业务逻辑。然而,我们可以通过使用模拟方法直接测试控制器,这将在本章后面讨论。
MockMvc
MockMvc 主要用于通过控制器测试代码。通过直接调用控制器(REST 端点),我们可以在 MockMvc 测试中覆盖整个应用程序。此外,如果我们在控制器上保留任何身份验证或限制,它也将在 MockMvc 测试用例中得到覆盖。
以下代码将使用 MockMvc 标准测试我们的基本 API(localhost:8080/):
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
@SpringBootTest
@RunWith(SpringJUnit4ClassRunner.class)
public class UserMockMVCTests {
@Autowired
private WebApplicationContext ctx;
private MockMvc mockMvc;
@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.ctx).build();
}
@Test
public void testBasicMVC() throws Exception {
MvcResult result = mockMvc
.perform(MockMvcRequestBuilders.get("/"))
.andExpect(status().isOk())
.andExpect(jsonPath("result", is("Aloha")))
.andReturn();
String content = result.getResponse().getContentAsString();
System.out.println("{testBasicMVC} response : " + content);
}
}
在前面的代码中,我们只是在setUp()方法中初始化了 Web 应用程序。此外,我们使用@Autowired注解绑定了WebApplicationContext。设置准备好后,我们创建一个名为testBasicMVC的方法来测试我们的普通 API(localhost:8080),它将返回"result: Aloha"。
当我们完成代码后,如果在 Eclipse 上选择 Run As | JUnit test 来运行它,前面的方法将被执行并显示结果。我们可以在 Eclipse 的 JUnit 窗口中查看成功的测试用例结果。
测试单个用户
到目前为止,我们只测试了一个普通的 REST API。在这里,我们可以再进一步,通过从userid获取单个用户来测试我们的用户 API。以下代码将带领我们实现获取单个用户:
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
@SpringBootTest
@RunWith(SpringJUnit4ClassRunner.class)
public class UserMockMVCTests {
@Autowired
private WebApplicationContext ctx;
private MockMvc mockMvc;
@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.ctx).build();
}
@Test
public void testBasicMVC() throws Exception {
MvcResult result = mockMvc
.perform(MockMvcRequestBuilders.get("/"))
.andExpect(status().isOk())
.andExpect(jsonPath("result", is("Aloha")))
.andReturn();
String content = result.getResponse().getContentAsString();
System.out.println("{testBasicMVC} response : " + content);
}
@Test
public void testSingleUser() throws Exception {
MvcResult result = mockMvc
.perform(MockMvcRequestBuilders.get("/user/100"))
.andExpect(status().isOk())
.andExpect(jsonPath("userid", is(100)))
.andExpect(jsonPath("username", is("David")))
.andReturn();
String content = result.getResponse().getContentAsString();
System.out.println("{testSingleUser} response : " + content);
}
}
在前面的代码(testSingleUser)中,我们可以看到我们期望status、userid和username分别为Ok、100和David。此外,我们打印从 REST API 获取的结果。
Postman
在之前的章节中,我们已经使用 Postman 来测试我们的 REST API。当我们需要完全测试应用程序时,Postman 会很有帮助。在 Postman 中,我们可以编写测试套件来验证我们的 REST API 端点。
获取所有用户 - Postman
首先,我们将从一个简单的 API 开始,用于获取所有用户:
http://localhost:8080/user
之前的方法将获取所有用户。获取所有用户的 Postman 截图如下:

在前面的截图中,我们可以看到我们之前添加的所有用户。我们使用了GET方法来调用这个 API。
添加用户 - Postman
让我们尝试使用POST方法在user中添加一个新用户:
http://localhost:8080/user
按照以下截图所示添加用户:

在前面的结果中,我们可以看到 JSON 输出:
{
"result" : "added"
}
生成 JWT - Postman
让我们尝试通过调用 Postman 中的生成令牌 API 来生成令牌(JWT):
http://localhost:8080/security/generate/token
我们可以清楚地看到我们在 Body 中使用subject来生成令牌。一旦我们调用 API,我们将获得令牌。我们可以在下面的截图中检查令牌:

从令牌中获取主题
通过使用我们之前创建的现有令牌,我们将通过调用获取主题 API 来获取主题:
http://localhost:8080/security/get/subject
结果将如下截图所示:

在前面的 API 调用中,我们在 API 中发送了令牌以获取主题。我们可以在生成的 JSON 中看到主题。
SoapUI
与 Postman 一样,SoapUI 是另一个用于测试 Web 服务的开源工具。SoapUI 帮助进行 Web 服务调用、模拟、仿真、负载测试和功能测试。SoapUI 在负载测试中被广泛使用,并且具有许多控件,使负载测试变得容易。
SoapUI 在 Windows 和 Linux 等操作系统中非常容易安装。其用户界面为我们提供了很大的灵活性,可以构建复杂的测试场景。此外,SoapUI 支持第三方插件,如TestMaker和Agiletestware,并且很容易与 NetBeans 和 Eclipse 等 IDE 集成。
获取所有用户 - SoapUI
我们将使用 SoapUI 测试我们的基本 API(/user)。当我们在 SoapUI 中使用GET方法时,以下方法将获取所有用户:
http://localhost:8080/user
获取所有用户的 SoapUI 截图如下:

我们将尝试使用POST方法添加用户:
http://localhost:8080/user
添加用户的截图如下:

在这个结果中,我们可以看到 JSON 输出:
{"result" : "added"}
生成 JWT SoapUI
我们将使用GET方法生成令牌如下:
http://localhost:8080/security/generate/token
在 SoapUI 中,我们使用subject作为参数。我们可以在下面的截图中看到这一点:

我们可以清楚地看到我们在 Body 中使用subject来生成令牌。此外,我们可以在 SoapUI 中看到 Style 为 QUERY。这将使我们的 Value(test)成为 API 的参数。
一旦我们调用 API,我们将获得令牌。我们可以在前面的截图中检查令牌。
从令牌中获取主题 - SoapUI
现在我们可以从之前生成的令牌中获取主题。我们可能需要将令牌作为参数传递以获取主题。
当我们在 SoapUI 中使用GET方法调用 API 时,以下 API 将从令牌中获取主题:
http://localhost:8080/security/get/subject
尽管我们可以在前面的 API 调用中使用POST方法,但我们只使用GET方法来简化流程,如下面的截图所示:

在前面的 API 调用中,我们在 API 中发送了令牌以获取主题。我们可以在生成的 JSON 中看到主题。
到目前为止,我们已经通过 SoapUI 测试了我们的 API。尽管 SoapUI 似乎比 Postman 更难一些,但在企业级负载测试和安全测试时可能非常有帮助。
jsoup
jsoup 是一个用于提取 HTML 文档并从 HTML DOM 获取详细信息的 Java 库。jsoup 使用 DOM、CSS 和类似 jQuery 的方法从任何网页中检索信息。尽管 jsoup 主要用于 HTML 文档解析,但在我们的应用程序中,我们将用它进行 API 测试。
首先,我们将在 jsoup 中调用 REST API 并将结果转换为 JSON。为了将字符串转换为 JSON,我们将使用 Gson 库。
对于 jsoup 和 Gson 库,我们可能需要在pom.xml中添加依赖项。以下是 jsoup 和 Gson 依赖项的代码:
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.8.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.2</version>
</dependency>
我们将在测试资源中使用 jsoup REST 消费者,这样测试将更容易:
String doc = Jsoup.connect("http://localhost:8080/user").ignoreContentType(true).get().body().text();
以下代码将以 HTML 形式调用 REST API 并将主体作为文本获取。通过这样做,我们将只获取 REST API 结果作为 JSON 文本。JSON 文本如下:
[{"userid":100,"username":"David"},{"userid":101,"username":"Peter"},{"userid":102,"username":"John"}]
一旦我们获得 JSON 文本,我们可以使用JsonParser类将其转换为 JSON 数组。以下代码将解析 JSON 文本并将其转换为JsonArray类:
JsonParser parser = new JsonParser();
JsonElement userElement = parser.parse(doc);
JsonArray userArray = userElement.getAsJsonArray();
一旦我们获得了 JSON 数组,我们可以简单地检查数组大小来验证我们的 REST API。以下代码将测试我们的 REST API 的大小:
assertEquals(3, userArray.size());
以下是完整的类和前面提到的代码:
import static org.junit.Assert.assertEquals;
import java.io.IOException;
import org.jsoup.Jsoup;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.packtpub.model.User;
@RunWith(SpringRunner.class)
@SpringBootTest
public class JsoupUserTest {
private final Logger _log = LoggerFactory.getLogger(this.getClass());
@Test
public void testUsersJsoup() throws IOException{
String doc = Jsoup.connect("http://localhost:8080/user").ignoreContentType(true).get().body().text();
_log.info("{test} doc : "+doc);
JsonParser parser = new JsonParser();
JsonElement userElement = parser.parse(doc);
JsonArray userArray = userElement.getAsJsonArray();
_log.info("{test} size : "+userArray.size());
assertEquals(3, userArray.size());
}
}
在前面的方法中,我们使用记录器打印大小。此外,我们使用assertEquals方法来检查用户数组大小。
由于这类似于 JUnit 测试,我们可能需要在 Eclipse 中使用 JUnit 测试选项进行测试。我们可以简单地右键单击文件,然后单击运行为| JUnit 测试。
获取用户 - jsoup
在之前的方法中,我们已经测试了 REST API 中的所有用户。现在,我们可以检查单个用户和详细信息。以下代码将测试单个用户 REST API:
@Test
public void testUserJsoup() throws IOException{
String doc = Jsoup.connect("http://localhost:8080/user/100").ignoreContentType(true).get().body().text();
Gson g = new Gson();
User user = g.fromJson(doc, User.class);
assertEquals("David", user.getUsername());
}
前面的代码将调用 REST API,以文本格式获取 JSON,然后将其转换为User类。一旦我们将它们转换为User类,我们可以通过assertEquals检查用户名。
添加用户 - jsoup
让我们尝试使用jsoup中的POST方法添加新用户。在这个 REST API(添加用户)中,我们可能需要向 REST API 传递一些参数。以下代码将调用添加用户 API 并获取结果:
@Autowired
UserService userSevice;
@Test
public void testUserAdditionJsoup() throws IOException{
String doc = Jsoup.connect("http://localhost:8080/user/")
.data("userid", "103")
.data("username", "kevin")
.ignoreContentType(true)
.post().body().text();
Gson g = new Gson();
Map<String, Object> result = g.fromJson(doc, Map.class);
_log.info("{test} result : "+result);
assertEquals("added", result.get("result"));
// user should be deleted as we tested the case already
userSevice.deleteUser(103);
}
在前面的代码中,我们使用了.post()方法来调用 API。此外,我们使用了.data()方法来传递参数。通过添加.ignoreContentType(),我们告诉Jsoup库我们不关心 API 返回的内容类型。此外,body().text()将以文本形式获取主体。
通过在assertEquals中检查结果,我们确保 API 正常工作。
要测试 jsoup,服务器需要运行,所以我们需要先运行服务器。然后我们可以运行我们的测试用例。要运行其他测试用例,如 JUnit 和 MockMvc,我们不需要服务器。
运行测试用例
首先,我们运行服务器并确保可以访问服务器。如果我们不运行服务器,我们将无法测试 jsoup,因此保持服务器运行。一旦服务器启动,右键单击项目运行为| JUnit 测试。我们可以在 JUnit 窗口中看到结果,如下图所示:
在前面的截图中,我们可以清楚地看到我们所有的测试用例都通过了。
摘要
在本章中,我们讨论了 RESTful Web 服务的各种测试方法。我们已经应用了 JUnit 测试,MockMvc,Postman 和 SoapUI。这些测试方法对于测试应用程序中的业务逻辑将非常有帮助。在下一章中,我们将讨论 REST 客户端和在 REST 客户端中消耗 RESTful 服务。
第八章:性能
在应用程序中,性能被认为是 RESTful Web 服务的主要标准。本章将主要关注如何改善应用程序的性能并减少响应时间。尽管性能优化技术可以应用在 Web 应用程序的不同层,我们将讨论 RESTful(Web)层。其余的性能优化技术将在[第十一章](c3ef97e3-fbad-4b9e-b7f8-91c6d3d6c6f0.xhtml)扩展中讨论。
本章将讨论以下主题:
-
HTTP 压缩
-
HTTP 缓存和 HTTP 缓存控制
-
在 REST API 中的缓存实现
-
使用 HTTP If-Modified-Since 标头和 ETags
HTTP 压缩
为了从 REST 服务中快速获取内容,数据可以被压缩并通过 HTTP 等协议发送。在压缩数据时,我们必须遵循一些编码格式,因此接收方将应用相同的格式。
内容协商
在请求服务器的资源时,客户端将有许多选项来接收各种表示的内容。例如,DOC/PDF 是数据类型表示。土耳其语或英语是语言表示,服务器可以以特定语言发送资源。服务器和客户端之间必须就资源将以哪种格式访问达成一致,例如语言、数据类型等。这个过程称为内容协商。
在这里,我们将讨论两种不同的内容协商机制:服务器驱动和代理驱动机制。在继续讨论这些机制之前,我们将讨论 Accept-Encoding 和 Content-Encoding,因为它们很重要。
接受编码
客户端将告诉服务器它可以接收哪种压缩算法。最常见的编码类型是gzip和deflate。在请求服务器时,客户端将在请求标头中共享编码类型。接受编码将用于此类目的。简而言之,客户端会告诉服务器,“我只接受提到的压缩格式”。
我们将看到以下示例Accept-Encoding:
Accept-Encoding: gzip, deflate
在前面的标头中,客户端表示它只能接受响应中的gzip或deflate。
其他可能的选项如下所述:
Accept-Encoding: compress, gzip
Accept-Encoding:
Accept-Encoding: *
Accept-Encoding: compress;q=0.5, gzip;q=1.0
Accept-Encoding: gzip;q=1.0, identity; q=0.5, *;q=0
我们可以看到compress值后面跟着q=0.5,这意味着质量评级只有0.5,与gzip评级的q=1.0相比,后者非常高。在这种情况下,客户端建议服务器可以使用gzip而不是compress。但是,如果gzip不可行,compress对于客户端来说也是可以接受的。
如果服务器不支持客户端请求的压缩算法,服务器应该发送一个带有406(不可接受)状态码的错误响应。
内容编码
Content-Encoding 是一个实体标头,用于将要从服务器发送到客户端的数据类型进行压缩。Content-Encoding 值告诉客户端在实体主体中使用了哪些编码。它将告诉客户端如何解码数据以检索值。
让我们来看看单个和多个编码选项:
// Single Encoding option
Content-Encoding: gzip
Content-Encoding: compress
// Multiple Encoding options
Content-Encoding: gzip, identity
Content-Encoding: deflate, gzip
在前面的配置中,Content-Encoding 提供了单个和多个选项。在这里,服务器告诉客户端它可以提供基于gzip和compress算法的编码。如果服务器提到了多个编码,这些编码将按照提到的顺序应用。
尽可能压缩数据是非常推荐的。
不建议在运行时更改内容编码。因为这将破坏未来的请求(例如在GET上进行PUT),在运行时更改内容编码根本不是一个好主意。
服务器驱动的内容协商
服务器驱动的内容协商是由服务器端算法执行的,以决定服务器必须发送给客户端的最佳表示。这也被称为主动内容协商。在服务器驱动的协商中,客户端(用户代理)将提供具有质量评级的各种表示选项。服务器中的算法将不得不决定哪种表示对客户端提供的标准最有效。
例如,客户端通过共享媒体类型标准请求资源,带有诸如哪种媒体类型对客户端更好的评级。服务器将完成其余工作并提供最适合客户需求的资源表示。
代理驱动的内容协商
代理驱动的内容协商是由客户端算法执行的。当客户端请求特定资源时,服务器将告知客户端有关资源的各种表示,包括内容类型、质量等元数据。然后客户端算法将决定哪种表示最佳,并再次从服务器请求。这也被称为被动内容协商。
HTTP 缓存
当客户端多次请求相同的资源表示时,从服务器端提供它将是浪费时间并且在 Web 应用程序中会耗时。如果资源被重复使用,而不是与服务器通信,它肯定会提高 Web 应用程序的性能。
缓存将被视为提高我们的 Web 应用性能的主要选项。Web 缓存避免了多次与服务器联系并减少了延迟;因此,应用程序将更快。缓存可以应用在应用程序的不同层面。在本章中,我们将只讨论 HTTP 缓存,这被认为是中间层。我们将在第十一章《扩展》中更深入地讨论其他形式的缓存。
HTTP 缓存控制
缓存控制是一个指定 Web 缓存操作指令的头字段。这些指令给出了缓存授权,定义了缓存的持续时间等。这些指令定义了行为,通常旨在防止缓存响应。
在这里,我们将讨论 HTTP 缓存指令:public,private,no-cache和only-if-cached指令。
公共缓存
如果缓存控制允许公共缓存,则资源可以被多个用户缓存。我们可以通过在Cache-Control标头中设置public选项来实现这一点。在公共缓存中,响应可能会被多个用户缓存,即使是不可缓存或可缓存的,也仅限于非共享缓存:
Cache-Control: public
在前面的设置中,public表示响应可以被任何缓存缓存。
私有缓存
与公共缓存不同,私有响应适用于单个用户缓存,而不适用于共享缓存。在私有缓存中,中间件无法缓存内容:
Cache-Control: private
前面的设置表明响应仅适用于单个用户,并且不应被任何其他缓存访问。
此外,我们可以在我们的标题设置中指定内容应该缓存多长时间。这可以通过max-age指令选项来实现。
检查以下设置:
Cache-Control: private, max-age=600
在前面的设置中,我们提到响应可以以私有模式(仅限单个用户)进行缓存,并且资源被视为新鲜的最长时间。
无缓存
对于访问动态资源可能不需要缓存。在这种情况下,我们可以在我们的缓存控制中使用no-cache设置来避免客户端缓存:
Cache-Control: no-cache
前面的设置将告诉客户端在请求资源时始终检查服务器。
此外,在某些情况下,我们可能需要禁用缓存机制本身。这可以通过在我们的设置中使用no-store来实现:
Cache-Control: no-store
前面的设置将告诉客户端避免资源缓存,并始终从服务器获取资源。
HTTP/1.0 缓存不会遵循 no-cache 指令,因为它是在 HTTP/1.1 中引入的。
缓存控制只在 HTTP/1.1 中引入。在 HTTP/1.0 中,只使用Pragma: no-cache来防止响应被缓存。
只有在缓存中有时效的资源时,客户端才会返回缓存的资源,而不是与服务器重新加载或重新验证。
在某些情况下,比如网络连接不佳,客户端可能希望返回缓存的资源,而不是与服务器重新加载或重新验证。为了实现这一点,客户端可以在请求中包含only-if-cached指令。如果收到,客户端将获得缓存的条目,否则将以504(网关超时)状态响应。
这些缓存控制指令可以覆盖默认的缓存算法。
到目前为止,我们已经讨论了各种缓存控制指令及其解释。以下是缓存请求和缓存响应指令的示例设置。
请求缓存控制指令(标准的Cache-Control指令,可以由客户端在 HTTP 请求中使用)如下:
Cache-Control: max-age=<seconds>
Cache-Control: max-stale[=<seconds>]
Cache-Control: min-fresh=<seconds>
Cache-Control: no-cache
Cache-Control: no-store
Cache-Control: no-transform
Cache-Control: only-if-cached
响应缓存控制指令(标准的Cache-Control指令,可以由服务器在 HTTP 响应中使用)如下:
Cache-Control: must-revalidate
Cache-Control: no-cache
Cache-Control: no-store
Cache-Control: no-transform
Cache-Control: public
Cache-Control: private
Cache-Control: proxy-revalidate
Cache-Control: max-age=<seconds>
Cache-Control: s-maxage=<seconds>
不可能为特定的缓存指定缓存指令。
缓存验证
当缓存中有一个新条目可以作为客户端请求时的响应时,它将与原始服务器进行检查,以查看缓存的条目是否仍然可用。这个过程称为缓存验证。此外,当用户按下重新加载按钮时,也会触发重新验证。如果缓存的响应包括Cache-Control: must revalidate头,则在正常浏览时会触发它。
当资源的时间过期时,它将被验证或重新获取。只有在服务器提供了强验证器或弱验证器时,才会触发缓存验证。
ETags
ETags 提供了验证缓存响应的机制。ETag 响应头可以用作强验证器。在这种情况下,客户端既不能理解该值,也无法预测其值。当服务器发出响应时,它生成一个隐藏资源状态的令牌:
ETag : ijk564
如果响应中包含ETag,客户端可以在未来请求的头部中发出If-None-Match来验证缓存的资源:
If-None-Match: ijk564
服务器将请求头与资源的当前状态进行比较。如果资源状态已更改,服务器将以新资源响应。否则,服务器将返回304 Not Modified响应。
Last-Modified/If-Modified-Since 头
到目前为止,我们已经看到了一个强验证器(ETags)。在这里,我们将讨论一个可以在头部中使用的弱验证器。Last-Modified响应头可以用作弱验证器。与生成资源的哈希不同,时间戳将用于检查缓存的响应是否有效。
由于此验证器具有 1 秒的分辨率,与 ETags 相比被认为是弱的。如果响应中存在Last-Modified头,则客户端可以发送一个If-Modified-Since请求头来验证缓存的资源。
当客户端请求资源时,会提供If-Modified-Since头。为了在一个真实的例子中简化机制,客户端请求将类似于这样:“我已经在上午 10 点缓存了资源 XYZ;但是如果自上午 10 点以来它已经改变了,那么获取更新的 XYZ,否则只返回304。然后我将使用之前缓存的 XYZ。”
缓存实现
到目前为止,我们在本章中已经看到了理论部分。让我们尝试在我们的应用程序中实现这个概念。为了简化缓存实现,我们将只使用用户管理。我们将使用getUser(单个用户)REST API 来应用我们的缓存概念。
REST 资源
在getUser方法中,我们将正确的userid传递给路径变量,假设客户端将传递userid并获取资源。有许多可用的缓存选项可供实现。在这里,我们将仅使用If-Modified-Since缓存机制。由于此机制将在标头中传递If-Modified-Since值,因此它将被转发到服务器,表示,如果资源在指定时间之后发生更改,请获取新资源,否则返回 null。
有许多实现缓存的方法。由于我们的目标是简化并清晰地传达信息,我们将保持代码简单,而不是在代码中添加复杂性。为了实现这种缓存,我们可能需要在我们的User类中添加一个名为updatedDate的新变量。让我们在我们的类中添加这个变量。
updatedDate变量将用作If-Modified-Since缓存的检查变量,因为我们将依赖于用户更新的日期。
客户端将询问服务器用户数据自上次缓存时间以来是否发生了更改。服务器将根据用户的updatedDate进行检查,如果未更新则返回 null;否则,它将返回新数据:
private Date updatedDate;
public Date getUpdatedDate() {
return updatedDate;
}
public void setUpdatedDate(Date updatedDate) {
this.updatedDate = updatedDate;
}
在前面的代码中,我们刚刚添加了一个新变量updatedDate,并为其添加了适当的 getter 和 setter 方法。稍后我们可能会通过添加 Lombok 库来简化这些 getter 和 setter 方法。我们将在接下来的章节中应用 Lombok。
此外,当我们获取类的实例时,我们需要添加另一个构造函数来初始化updatedDate变量。让我们在这里添加构造函数:
public User(Integer userid, String username, Date updatedDate){
this.userid = userid;
this.username = username;
this.updatedDate = updatedDate;
}
如果可能的话,我们可以将toString方法更改如下:
@Override
public String toString() {
return "User [userid=" + userid + ", username=" + username + ", updatedDate=" + updatedDate + "]";
}
在添加了所有上述提到的细节之后,我们的类将如下所示:
package com.packtpub.model;
import java.io.Serializable;
import java.util.Date;
public class User implements Serializable {
private static final long serialVersionUID = 1L;
public User() {
}
private Integer userid;
private String username;
private Date updatedDate;
public User(Integer userid, String username) {
this.userid = userid;
this.username = username;
}
public User(Integer userid, String username, Date updatedDate) {
this.userid = userid;
this.username = username;
this.updatedDate = updatedDate;
}
public Date getUpdatedDate() {
return updatedDate;
}
public void setUpdatedDate(Date updatedDate) {
this.updatedDate = updatedDate;
}
public Integer getUserid() {
return userid;
}
public void setUserid(Integer userid) {
this.userid = userid;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public String toString() {
return "User [userid=" + userid + ", username=" + username + ", updatedDate=" + updatedDate + "]";
}
}
现在,我们将回到之前章节中介绍的UserController,并更改getUser方法:
@RestController
@RequestMapping("/user")
public class UserController {
// other methods and variables (hidden)
@ResponseBody
@RequestMapping("/{id}")
public User getUser(@PathVariable("id") Integer id, WebRequest webRequest){
User user = userSevice.getUser(id);
long updated = user.getUpdatedDate().getTime();
boolean isNotModified = webRequest.checkNotModified(updated);
logger.info("{getUser} isNotModified : "+isNotModified);
if(isNotModified){
logger.info("{getUser} resource not modified since last call, so exiting");
return null;
}
logger.info("{getUser} resource modified since last call, so get the updated content");
return userSevice.getUser(id);
}
}
在前面的代码中,我们在现有方法中使用了WebRequest参数。WebRequest对象将用于调用checkNotModified方法。首先,我们通过id获取用户详细信息,并以毫秒为单位获取updatedDate。我们将用户更新日期与客户端标头信息进行比较(我们假设客户端将在标头中传递If-Not-Modified-Since)。如果用户更新日期比缓存日期更新,我们假设用户已更新,因此我们将不得不发送新资源。
由于我们在UserController中添加了记录器,因此我们可能需要导入org.apache.log4j.Logger。否则在编译时会显示错误。
如果用户在客户端缓存日期之后没有更新,它将简单地返回 null。此外,我们已经提供了足够的记录器来打印我们想要的语句。
让我们在 SoapUI 或 Postman 客户端中测试 REST API。当我们第一次调用 API 时,它将返回带有标头信息的数据,如下所示:

SoapUI 客户端
我们可以看到我们正在使用GET方法来调用此 API,并且右侧是响应标头。
在我们之前的屏幕截图中,我们使用了端口8081。默认情况下,Spring Boot 在端口8080上运行。如果要将其更改为8081,请在/src/main/resources/``application.properties中配置端口如下:
server.port = 8081
如果在指定位置下没有application.properties,则可以创建一个。
响应(JSON)如下所示:
{
"userid": 100,
"username": "David",
"updatedDate": 1516201175654
}
在前面的 JSON 响应中,我们可以看到用户详细信息,包括updatedDate。
响应(标头)如下所示:
HTTP/1.1 200
Last-Modified: Wed, 17 Jan 2018 14:59:35 GMT
ETag: "06acb280fd1c0435ac4ddcc6de0aeeee7"
Content-Type: application/json;charset=UTF-8
Content-Length: 61
Date: Wed, 17 Jan 2018 14:59:59 GMT
{"userid":100,"username":"David","updatedDate":1516201175654}
在前面的响应标头中,我们可以看到 HTTP 结果200(表示 OK)和Last-Modified日期。
现在,我们将在标头中添加If-Modified-Since,并更新我们从先前响应中获取的最新日期。我们可以在以下屏幕截图中检查If-Modified-Since参数:

在上述配置中,我们在标头部分添加了If-Modified-Since参数,并再次调用相同的 REST API。代码将检查资源是否自上次缓存日期以来已更新。在我们的情况下,资源没有更新,因此响应中将简单返回304。我们可以看到响应如下:
HTTP/1.1 304
Last-Modified: Wed, 17 Jan 2018 14:59:35 GMT
Date: Wed, 17 Jan 2018 15:05:29 GMT
HTTP 304(未修改)响应只是向客户端传达资源未修改,因此客户端可以使用现有缓存。
如果我们通过调用更新 REST API(使用PUT的http://localhost:8081/user/100)更新指定的用户,然后再次调用先前的 API(使用GET的http://localhost:8081/user/100),我们将获得新的资源,因为用户在客户端缓存之后已更新。
使用 ETags 进行缓存
在上一节中,我们探讨了基于更新日期的缓存。然而,当我们需要检查更新的资源时,我们可能并不总是需要依赖更新日期。还有另一种机制,称为 ETag 缓存,它提供了一个强验证器,用于检查资源是否已更新。ETag 缓存将是检查更新日期的常规缓存的完美替代品。
在 ETag 缓存中,响应标头将为主体提供哈希 ID(MD5)。如果资源已更新,标头将在 REST API 调用时生成新的哈希 ID。因此,我们无需像在上一节中那样显式检查信息。
Spring 提供了一个名为ShallowEtagHeaderFilter的过滤器来支持 ETag 缓存。让我们尝试在我们现有的应用程序中添加ShallowEtagHeaderFilter。我们将在我们的主应用程序文件(TicketManagementApplication)中添加代码:
@Bean
public Filter shallowEtagHeaderFilter() {
return new ShallowEtagHeaderFilter();
}
@Bean
public FilterRegistrationBean shallowEtagHeaderFilterRegistration() {
FilterRegistrationBean result = new FilterRegistrationBean();
result.setFilter(this.shallowEtagHeaderFilter());
result.addUrlPatterns("/user/*");
result.setName("shallowEtagHeaderFilter");
result.setOrder(1);
return result;
}
在上述代码中,我们将ShallowEtagHeaderFilter作为一个 bean 添加,并通过提供我们的 URL 模式和名称进行注册。因为我们目前只测试用户资源,所以我们将在我们的模式中添加/user/*。最后,我们的主应用程序类将如下所示:
package com.packtpub.restapp.ticketmanagement;
import javax.servlet.Filter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ImportResource;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
@ComponentScan("com.packtpub")
@SpringBootApplication
public class TicketManagementApplication {
public static void main(String[] args) {
SpringApplication.run(TicketManagementApplication.class, args);
}
@Bean
public Filter shallowEtagHeaderFilter() {
return new ShallowEtagHeaderFilter();
}
@Bean
public FilterRegistrationBean shallowEtagHeaderFilterRegistration() {
FilterRegistrationBean result = new FilterRegistrationBean();
result.setFilter(this.shallowEtagHeaderFilter());
result.addUrlPatterns("/user/*");
result.setName("shallowEtagHeaderFilter");
result.setOrder(1);
return result;
}
}
我们可以通过调用用户 API(http://localhost:8081/user)来测试这种 ETag 机制。当我们调用此 API 时,服务器将返回以下标头:
HTTP/1.1 200
ETag: "02a4bc8613aefc333de37c72bfd5e392a"
Content-Type: application/json;charset=UTF-8
Content-Length: 186
Date: Wed, 17 Jan 2018 15:11:45 GMT
我们可以看到ETag已添加到我们的标头中,带有哈希 ID。现在我们将使用If-None-Match标头和哈希值调用相同的 API。我们将在以下截图中看到标头:

当我们再次使用If-None-Match标头和先前哈希 ID 的值调用相同的 API 时,服务器将返回304状态,我们可以如下所示地看到:
HTTP/1.1 304
ETag: "02a4bc8613aefc333de37c72bfd5e392a"
Date: Wed, 17 Jan 2018 15:12:24 GMT
在这种机制中,实际的响应主体将不会被发送到客户端。相反,它会告诉客户端资源未被修改,因此客户端可以使用先前缓存的内容。304状态表示资源未被缓存。
总结
在这一章中,我们已经学习了 HTTP 优化方法,以提高应用程序的性能。通过减少客户端和服务器之间的交互以及通过 HTTP 传输的数据大小,我们将在 REST API 服务中实现最大性能。在第十一章中,我们将探讨其他优化、缓存和扩展技术,扩展,因为我们将讨论与 Web 服务性能相关的更高级的主题。
第九章:AOP 和 Logger 控制
在本章中,我们将学习 Spring 面向方面的编程(AOP)和日志控制,包括它们的理论和实现。我们将在我们现有的 REST API 中集成 Spring AOP,并了解 AOP 和日志控制如何使我们的生活更轻松。
在本章中,我们将涵盖以下主题:
-
Spring AOP 理论
-
Spring AOP 的实现
-
为什么我们需要日志控制?
-
我们如何实现日志控制?
-
集成 Spring AOP 和日志控制
面向方面的编程(AOP)
面向方面的编程是一个概念,它在不修改代码本身的情况下为现有代码添加新行为。当涉及到日志记录或方法认证时,AOP 概念真的很有帮助。
在 Spring 中,有许多方法可以使用 AOP。让我们不要深入讨论,因为这将是一个大的讨论话题。在这里,我们只讨论@Before切入点以及如何在我们的业务逻辑中使用@Before。
AOP(@Before)与执行
AOP 中的执行术语意味着在@Aspect注解本身中有一个切入点,它不依赖于控制器 API。另一种方法是您将不得不在 API 调用中明确提及注解。让我们在下一个主题中讨论显式切入点:
package com.packtpub.aop;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class TokenRequiredAspect {
@Before("execution(* com.packtpub.restapp.HomeController.testAOPExecution())")
public void tokenRequiredWithoutAnnoation() throws Throwable{
System.out.println("Before tokenRequiredWithExecution");
}
}
在这个切入点中,我们使用了@Before注解,它使用了execution(* com.packtpub.restapp.HomeController.testAOPWithoutAnnotation()),这意味着这个切入点将专注于一个特定的方法,在我们的例子中是HomeController类中的testAOPWithoutAnnotation方法。
对于与 AOP 相关的工作,我们可能需要将依赖项添加到我们的pom.xml文件中,如下所示:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.8.13</version>
</dependency>
上述依赖项将带来所有面向方面的类,以支持我们在本章中的 AOP 实现。
@Aspect:这个注解用于使类支持方面。在 Spring 中,可以使用 XML 配置或注解(如@Aspect)来实现方面。
@Component:这个注解将使类根据 Spring 的组件扫描规则可扫描。通过将这个类与@Component和@Aspect一起提及,我们告诉 Spring 扫描这个类并将其识别为一个方面。
HomeController类的代码如下所示:
@ResponseBody
@RequestMapping("/test/aop/with/execution")
public Map<String, Object> testAOPExecution(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Aloha");
return map;
}
在这里,我们只需创建一个新的方法来测试我们的 AOP。您可能不需要创建一个新的 API 来测试我们的 AOP。只要您提供适当的方法名,就应该没问题。为了使读者更容易理解,我们在HomeContoller类中创建了一个名为testAOPExecution的新方法。
测试 AOP @Before 执行
只需在浏览器中调用 API(http://localhost:8080/test/aop/with/execution)或使用任何其他 REST 客户端;然后,您应该在控制台中看到以下内容:
Before tokenRequiredWithExecution
尽管这个日志并不真正帮助我们的业务逻辑,但我们现在会保留它,以便读者更容易理解流程。一旦我们了解了 AOP 及其功能,我们将把它集成到我们的业务逻辑中。
AOP(@Before)与注解
到目前为止,我们已经看到了一个基于执行的 AOP 方法,可以用于一个或多个方法。然而,在某些地方,我们可能需要保持实现简单以增加可见性。这将帮助我们在需要的地方使用它,而且它不与任何方法绑定。我们称之为显式基于注解的 AOP。
为了使用这个 AOP 概念,我们可能需要创建一个接口,这个接口将帮助我们实现我们需要的东西。
TokenRequired只是我们Aspect类的一个基本接口。它将被提供给我们的Aspect类,如下所示:
package com.packtpub.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface TokenRequired {
}
@Retention:保留策略确定注解应在何时被丢弃。在我们的例子中,RetentionPolicy.RUNTIME将在 JVM 中通过运行时保留。
其他保留策略如下:
SOURCE:它将仅保留源代码,并且在编译时将被丢弃。一旦代码编译完成,注释将变得无用,因此不会写入字节码中。
CLASS:它将保留到编译时,并在运行时丢弃。
@Target:此注释适用于类级别,并在运行时匹配。目标注释可用于收集目标对象。
以下的tokenRequiredWithAnnotation方法将实现我们方面的业务逻辑。为了保持逻辑简单,我们只提供了System.out.println(..)。稍后,我们将向该方法添加主要逻辑:
@Aspect
@Component
public class TokenRequiredAspect {
// old method (with execution)
@Before("@annotation(tokenRequired)")
public void tokenRequiredWithAnnotation(TokenRequired tokenRequired) throws Throwable{
System.out.println("Before tokenRequiredWithAnnotation");
}
}
在前面的代码中,我们创建了一个名为tokenRequiredWithAnnotation的方法,并为该方法提供了TokenRequired接口作为参数。我们可以看到该方法顶部的@Before注释,并且@annotation(tokenRequired)。每次在任何方法中使用@TokenRequired注释时,将调用此方法。您可以如下所示查看注释用法:
@ResponseBody
@RequestMapping("/test/aop/with/annotation")
@TokenRequired
public Map<String, Object> testAOPAnnotation(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Aloha");
return map;
}
以前的 AOP 方法和这个之间的主要区别是@TokenRequired。在旧的 API 调用者中,我们没有明确提到任何 AOP 注释,但在此调用者中,我们必须提到@TokenRequired,因为它将调用适当的 AOP 方法。此外,在此 AOP 方法中,我们不需要提到execution,就像我们在以前的execution(* com.packtpub.restapp.HomeController.testAOPWithoutAnnotation())方法中所做的那样。
测试 AOP @Before 注释
只需在浏览器中或使用任何其他 REST 客户端调用 API(http://localhost:8080/test/aop/with/annotation);然后,您应该在控制台上看到以下内容:
Before tokenRequiredWithAnnotation
将 AOP 与 JWT 集成
假设您想要在UserContoller方法中限制deleteUser选项。删除用户的人应该具有适当的 JWT 令牌。如果他们没有令牌,我们将不允许他们删除任何用户。在这里,我们将首先有一个packt主题来创建一个令牌。
可以调用http://localhost:8080/security/generate/token?subject=packt生成令牌的 API。
当我们在主题中使用packt时,它将生成eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwYWNrdCIsImV4cCI6MTUwOTk0NzY2Mn0.hIsVggbam0pRoLOnSe8L9GQS4IFfFklborwJVthsmz0令牌。
现在,我们将不得不创建一个 AOP 方法,通过要求用户在delete调用的标头中具有令牌来限制用户:
@Before("@annotation(tokenRequired)")
public void tokenRequiredWithAnnotation(TokenRequired tokenRequired) throws Throwable{
ServletRequestAttributes reqAttributes = (ServletRequestAttributes)RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = reqAttributes.getRequest();
// checks for token in request header
String tokenInHeader = request.getHeader("token");
if(StringUtils.isEmpty(tokenInHeader)){
throw new IllegalArgumentException("Empty token");
}
Claims claims = Jwts.parser() .setSigningKey(DatatypeConverter.parseBase64Binary(SecurityServiceImpl.secretKey))
.parseClaimsJws(tokenInHeader).getBody();
if(claims == null || claims.getSubject() == null){
throw new IllegalArgumentException("Token Error : Claim is null");
}
if(!claims.getSubject().equalsIgnoreCase("packt")){
throw new IllegalArgumentExceptionception("Subject doesn't match in the token");
}
}
从前面的代码中可以看到 AOP 中的 JWT 集成。是的,我们已经将 JWT 令牌验证部分与 AOP 集成。因此,以后,如果有人调用@TokenRequired注释的 API,它将首先到达 AOP 方法并检查令牌匹配。如果令牌为空,不匹配或过期,我们将收到错误。所有可能的错误将如下所述。
现在,我们可以在UserController类中的 API 调用中开始使用@TokenRequired注释。因此,每当调用此deleteUser方法时,它将在执行 API 方法本身之前转到JWT,检查切入点。通过这样做,我们可以确保deleteUser方法不会在没有令牌的情况下被调用。
UserController类的代码如下:
@ResponseBody
@TokenRequired
@RequestMapping(value = "", method = RequestMethod.DELETE)
public Map<String, Object> deleteUser(
@RequestParam(value="userid") Integer userid){
Map<String, Object> map = new LinkedHashMap<>();
userSevice.deleteUser(userid);
map.put("result", "deleted");
return map;
}
如果令牌为空或为空,它将抛出以下错误:
{
"timestamp": 1509949209993,
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.reflect.UndeclaredThrowableException",
"message": "No message available",
"path": "/user"
}
如果令牌匹配,它将显示结果而不抛出任何错误。您将看到以下结果:
{
"result": "deleted"
}
如果我们在标头中不提供任何令牌,可能会抛出以下错误:
{
"timestamp": 1509948248281,
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.IllegalArgumentException",
"message": "JWT String argument cannot be null or empty.",
"path": "/user"
}
如果令牌过期,您将收到以下错误:
{
"timestamp": 1509947985415,
"status": 500,
"error": "Internal Server Error",
"exception": "io.jsonwebtoken.ExpiredJwtException",
"message": "JWT expired at 2017-11-06T00:54:22-0500\. Current time: 2017-11-06T00:59:45-0500",
"path": "/test/aop/with/annotation"
}
日志记录控制
日志记录在需要跟踪特定过程的输出时非常有用。当我们在服务器上部署应用程序后,它将帮助我们验证过程或找出错误的根本原因。如果没有记录器,将很难跟踪和找出问题。
在我们的应用程序中,有许多日志记录框架可以使用;Log4j 和 Logback 是大多数应用程序中使用的两个主要框架。
SLF4J,Log4J 和 Logback
SLF4j 是一个 API,帮助我们在部署过程中选择 Log4j 或 Logback 或任何其他 JDK 日志。SLF4j 只是一个抽象层,为使用我们的日志 API 的用户提供自由。如果有人想在他们的实现中使用 JDK 日志或 Log4j,SLF4j 将帮助他们在运行时插入所需的框架。
如果我们创建的最终产品不能被他人用作库,我们可以直接实现 Log4j 或 Logback。但是,如果我们有一个可以用作库的代码,最好选择 SLF4j,这样用户可以遵循他们想要的任何日志记录。
Logback 是 Log4j 的更好替代品,并为 SLF4j 提供本地支持。
Logback 框架
我们之前提到 Logback 比 Log4j 更可取;在这里我们将讨论如何实现 Logback 日志框架。
Logback 有三个模块:
-
logback-core:基本日志 -
logback-classic:改进的日志记录和 SLF4j 支持 -
logback-access:Servlet 容器支持
logback-core模块是 Log4j 框架中其他两个模块的基础。logback-classic模块是 Log4j 的改进版本,具有更多功能。此外,logback-classic模块本地实现了 SLF4j API。由于这种本地支持,我们可以切换到不同的日志框架,如Java Util Logging(JUL)和 Log4j。
logback-access模块为 Tomcat/Jetty 等 Servlet 容器提供支持,特别是提供 HTTP 访问日志功能。
Logback 依赖和配置
为了在我们的应用程序中使用 Logback,我们需要logback-classic依赖项。但是,logback-classic依赖项已经包含在spring-boot-starter依赖项中。我们可以在项目文件夹中使用依赖树(mvn dependency:tree)来检查:
mvn dependency:tree
在项目文件夹中检查依赖树时,我们将获得所有依赖项的完整树。以下是我们可以看到spring-boot-starter依赖项下的logback-classic依赖项的部分:
[INFO] | +- org.springframework.boot:spring-boot-starter:jar:1.5.7.RELEASE:compile
[INFO] | +- org.springframework.boot:spring-boot:jar:1.5.7.RELEASE:compile
[INFO] | +- org.springframework.boot:spring-boot-autoconfigure:jar:1.5.7.RELEASE:compile
[INFO] | +- org.springframework.boot:spring-boot-starter-logging:jar:1.5.7.RELEASE:compile
[INFO] | | +- ch.qos.logback:logback-classic:jar:1.1.11:compile
[INFO] | | | \- ch.qos.logback:logback-core:jar:1.1.11:compile
[INFO] | | +- org.slf4j:jcl-over-slf4j:jar:1.7.25:compile
[INFO] | | +- org.slf4j:jul-to-slf4j:jar:1.7.25:compile
[INFO] | | \- org.slf4j:log4j-over-slf4j:jar:1.7.25:compile
[INFO] | \- org.yaml:snakeyaml:jar:1.17:runtime
[INFO] +- com.fasterxml.jackson.core:jackson-databind:jar:2
由于必要的依赖文件已经可用,我们不需要为 Logback 框架实现添加任何依赖项。
日志级别
由于 SLF4j 定义了这些日志级别,实现 SLF4j 的人应该适应 SFL4j 的日志级别。日志级别如下:
-
TRACE:详细评论,在所有情况下可能不会使用 -
DEBUG:用于生产环境中调试目的的有用评论 -
INFO:在开发过程中可能有帮助的一般评论 -
WARN:在特定场景下可能有帮助的警告消息,例如弃用的方法 -
ERROR:开发人员需要注意的严重错误消息
让我们将日志配置添加到application.properties文件中:
# spring framework logging
logging.level.org.springframework = ERROR
# local application logging
logging.level.com.packtpub.restapp = INFO
在前面的配置中,我们已经为 Spring Framework 和我们的应用程序使用了日志配置。根据我们的配置,它将为 Spring Framework 打印ERROR,为我们的应用程序打印INFO。
类中的 Logback 实现
让我们给类添加一个Logger;在我们的情况下,我们可以使用UserController。我们必须导入org.slf4j.Logger和org.slf4j.LoggerFactory。我们可以检查以下代码:
private static final Logger _logger = LoggerFactory.getLogger(HomeController.class);
在前面的代码中,我们介绍了_logger实例。我们使用UserController类作为_logger实例的参数。
现在,我们必须使用_logger实例来打印我们想要的消息。在这里,我们使用了_logger.info()来打印消息:
package com.packtpub.restapp;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// other imports
@RestController
@RequestMapping("/")
public class HomeController {
private static final Logger _logger = LoggerFactory.getLogger(HomeController.class);
@Autowired
SecurityService securityService;
@ResponseBody
@RequestMapping("")
public Map<String, Object> test() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "Aloha");
_logger.trace("{test} trace");
_logger.debug("{test} debug");
_logger.info("{test} info");
_logger.warn("{test} warn ");
_logger.error("{test} error");
return map;
}
在前面的代码中,我们使用了各种记录器来打印消息。当您重新启动服务器并调用http://localhost:8080 REST API 时,您将在控制台中看到以下输出:
2018-01-15 16:29:55.951 INFO 17812 --- [nio-8080-exec-1] com.packtpub.restapp.HomeController : {test} info
2018-01-15 16:29:55.951 WARN 17812 --- [nio-8080-exec-1] com.packtpub.restapp.HomeController : {test} warn
2018-01-15 16:29:55.951 ERROR 17812 --- [nio-8080-exec-1] com.packtpub.restapp.HomeController : {test} error
正如您从日志中看到的,类名将始终在日志中以标识日志中的特定类。由于我们没有提及任何日志模式,记录器采用默认模式打印输出与类一起。如果需要,我们可以在配置文件中更改模式以获得定制日志。
在先前的代码中,我们使用了不同的日志级别来打印消息。对日志级别有限制,因此根据业务需求和实现,我们将不得不配置我们的日志级别。
在我们的日志配置中,我们只使用了控制台打印选项。我们还可以提供一个选项,将日志打印到我们想要的外部文件中。
总结
在本章中,我们涵盖了 Spring AOP 和日志控制的实现。在我们现有的代码中,我们介绍了 Spring AOP,并演示了 AOP 如何通过代码重用节省时间。为了让用户理解 AOP,我们简化了 AOP 的实现。在下一章中,我们将讨论如何构建一个 REST 客户端,并更多地讨论 Spring 中的错误处理。
第十章:构建 REST 客户端和错误处理
在之前的章节中,我们涵盖了 RESTful Web 服务的服务器端,包括 CRUD 操作。在这里,我们可以检查如何在代码中消费这些 API。REST 客户端将帮助我们实现这个目标。
在本章中,我们将讨论以下主题:
-
Spring 中的 RestTemplate
-
使用 Spring 构建 RESTful 服务客户端的基本设置
-
在客户端调用 RESTful 服务
-
定义错误处理程序
-
使用错误处理程序
构建 REST 客户端
到目前为止,我们已经创建了一个 REST API,并在诸如 SoapUI、Postman 或 JUnit 测试之类的第三方工具中使用它。可能会出现情况,您将不得不使用常规方法(服务或另一个控制器方法)本身来消费 REST API,比如在服务 API 中调用支付 API。当您在代码中调用第三方 API,比如 PayPal 或天气 API 时,拥有一个 REST 客户端将有助于完成工作。
在这里,我们将讨论如何构建一个 REST 客户端来在我们的方法中消费另一个 REST API。在进行这之前,我们将简要讨论一下 Spring 中的RestTemplate。
RestTemplate
RestTemplate是一个 Spring 类,用于通过 HTTP 从客户端消费 REST API。通过使用RestTemplate,我们可以将 REST API 消费者保持在同一个应用程序中,因此我们不需要第三方应用程序或另一个应用程序来消费我们的 API。RestTemplate可以用于调用GET、POST、PUT、DELETE和其他高级 HTTP 方法(OPTIONS、HEAD)。
默认情况下,RestTemplate类依赖 JDK 建立 HTTP 连接。您可以切换到使用不同的 HTTP 库,如 Apache HttpComponents 和 Netty。
首先,我们将在AppConfig类中添加一个RestTemplate bean 配置。在下面的代码中,我们将看到如何配置RestTemplate bean:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class AppConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
在上面的代码中,我们已经在这个类中使用了@Configuration注解来配置类中的所有 bean。我们还在这个类中引入了RestTemplate bean。通过在AppConfig类中配置 bean,我们告诉应用程序所述的 bean 可以在应用程序的任何地方使用。当应用程序启动时,它会自动初始化 bean,并准备在需要的地方使用模板。
现在,我们可以通过在任何类中简单地使用@Autowire注解来使用RestTemplate。为了更好地理解,我们创建了一个名为ClientController的新类,并在该类中添加了一个简单的方法:
@RestController
@RequestMapping("/client")
public class ClientController {
private final Logger _log = LoggerFactory.getLogger(this.getClass());
@Autowired
RestTemplate template;
@ResponseBody
@RequestMapping("/test")
public Map<String, Object> test(){
Map<String, Object> map = new LinkedHashMap<>();
String content = template.getForObject("http://localhost:8080/", String.class);
map.put("result", content);
return map;
}
}
在上面的代码中,我们使用了RestTemplate并调用了getForObject方法来消费 API。默认情况下,我们使用String.class来使我们的代码简单易懂。
当您调用这个 API http://localhost:8080/client/test/时,您将得到以下结果:
{
result: "{\"result\":"\Aloha\"}"
}
在上述过程中,我们在另一个 REST API 中使用了RestTemplate。在实时场景中,您可能会使用与调用第三方 REST API 相同的方法。
让我们在另一个方法中获取一个单个用户 API:
@ResponseBody
@RequestMapping("/test/user")
public Map<String, Object> testGetUser(){
Map<String, Object> map = new LinkedHashMap<>();
User user = template.getForObject("http://localhost:8080/user/100", User.class);
map.put("result", user);
return map;
}
通过调用上述 API,您将得到单个用户作为结果。为了调用这个 API,我们的User类应该被序列化,否则您可能会得到一个未序列化对象错误。让我们通过实现Serializable并添加一个序列版本 ID 来使我们的User类序列化。
您可以通过在 Eclipse 中右键单击类名并生成一个序列号来创建一个序列版本 ID。
在对User类进行序列化之后,它将如下所示:
public class User implements Serializable {
private static final long serialVersionUID = 3453281303625368221L;
public User(){
}
private Integer userid;
private String username;
public User(Integer userid, String username){
this.userid = userid;
this.username = username;
}
public Integer getUserid() {
return userid;
}
public void setUserid(Integer userid) {
this.userid = userid;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public String toString() {
return "User [userid=" + userid + ", username=" + username + "]";
}
}
最后,我们可以在浏览器中调用http://localhost:8080/client/test/user客户端 API,并得到以下结果:
{
result: {
userid: 100,
username: "David"
}
}
为了便于理解,我们只使用了GET方法。然而,我们可以使用POST方法并在 REST 消费者中添加参数。
错误处理
到目前为止,在我们的应用程序中,我们还没有定义任何特定的错误处理程序来捕获错误并将其传达到正确的格式。通常,当我们在 REST API 中处理意外情况时,它会自动抛出 HTTP 错误,如404。诸如404之类的错误将在浏览器中明确显示。这通常是可以接受的;但是,无论事情是对是错,我们可能需要一个 JSON 格式的结果。
在这种情况下,将错误转换为 JSON 格式是一个不错的主意。通过提供 JSON 格式,我们可以保持我们的应用程序干净和标准化。
在这里,我们将讨论如何在事情出错时管理错误并以 JSON 格式显示它们。让我们创建一个通用的错误处理程序类来管理我们所有的错误:
public class ErrorHandler {
@ExceptionHandler(Exception.class)
public @ResponseBody <T> T handleException(Exception ex) {
Map<String, Object> errorMap = new LinkedHashMap<>();
if(ex instanceof org.springframework.web.bind.MissingServletRequestParameterException){
errorMap.put("Parameter Missing", ex.getMessage());
return (T) errorMap;
}
errorMap.put("Generic Error ", ex.getMessage());
return (T) errorMap;
}
}
上面的类将作为我们应用程序中的通用错误处理程序。在ErrorHandler类中,我们创建了一个名为handleException的方法,并使用@ExceptionHandler注解。此注解将使该方法接收应用程序中的所有异常。一旦我们获得异常,我们可以根据异常的类型来管理应该做什么。
在我们的代码中,我们只使用了两种情况来管理我们的异常:
-
缺少参数
-
一般错误(除了缺少参数之外的所有其他情况)
如果在调用任何 REST API 时缺少参数,它将进入第一种情况,“参数缺失”,否则它将进入“通用错误”默认错误。我们简化了这个过程,以便新用户能够理解。但是,我们可以在这种方法中添加更多情况来处理更多的异常。
完成错误处理程序后,我们将不得不在我们的应用程序中使用它。应用错误处理程序可以通过多种方式完成。扩展错误处理程序是使用它的最简单方法:
@RestController
@RequestMapping("/")
public class HomeController extends ErrorHandler {
// other methods
@ResponseBody
@RequestMapping("/test/error")
public Map<String, Object> testError(@RequestParam(value="item") String item){
Map<String, Object> map = new LinkedHashMap<>();
map.put("item", item);
return map;
}
}
在上面的代码中,我们只是在HomeController类中扩展了ErrorHandler。通过这样做,我们将所有错误情况绑定到ErrorHandler以正确接收和处理。此外,我们创建了一个名为testError的测试方法来检查我们的错误处理程序。
为了调用这个 API,我们需要将item作为参数传递;否则它将在应用程序中抛出一个错误。因为我们已经定义了ErrorController类并扩展了HomeController类,缺少参数将使您进入前面提到的第一个情景。
只需在浏览器或任何 REST 客户端(Postman/SoapUI)中尝试以下 URL:http://localhost:8080/test/error。
如果您尝试上述端点,您将得到以下结果:
{
Parameter Missing: "Required String parameter 'item' is not present"
}
由于我们在错误处理程序中定义了 JSON 格式,如果任何 REST API 抛出异常,我们将以 JSON 格式获得错误。
自定义异常
到目前为止,我们只探讨了应用程序引发的错误。但是,如果需要,我们可以定义自己的错误并抛出它们。以下代码将向您展示如何在我们的应用程序中创建自定义错误并抛出它:
@RestController
@RequestMapping("/")
public class HomeController extends ErrorHandler {
// other methods
@ResponseBody
@RequestMapping("/test/error/{id}")
public Map<String, Object> testRuntimeError(@PathVariable("id") Integer id){
if(id == 1){
throw new RuntimeException("some exception");
}
Map<String, Object> map = new LinkedHashMap<>();
map.put("result", "one");
return map;
}
}
在上面的代码中,我们使用RuntimeException创建了一个自定义异常。这只是测试代码,向您展示自定义异常在错误处理中的工作原理。我们将在接下来的章节中在我们的应用程序中应用这个自定义异常。
如果您调用http://localhost:8080/test/error/1API,您将得到以下错误,这是由我们的条件匹配引起的:
{
Generic Error : "some exception"
}
摘要
在本章中,我们学习了如何使用RestTemplate构建 RESTful Web 服务客户端。此外,我们还涵盖了错误处理程序和集中式错误处理程序来处理所有容易出错的情况。在接下来的章节中,我们将讨论如何扩展我们的 Spring 应用程序,并简要讨论微服务,因为这些主题正在迅速增长。
第十一章:扩展
随着世界对网络的关注越来越多,我们所有的网络应用程序都需要处理更多的请求。为了应对更多的请求,我们可能需要扩展我们的应用程序来支持它们。
本章主要集中讨论可以应用于我们常规应用程序的技术、库和工具,以解决可扩展性问题。
在本章中,我们将讨论以下主题:
-
集群及其优势
-
负载均衡
-
扩展数据库
-
分布式缓存
集群
简而言之,集群就是添加多个服务器以提供相同的服务。这将帮助我们在灾难(如系统崩溃和其他不幸情况)期间避免中断。集群可以用作故障转移系统、负载均衡系统或并行处理单元。
故障转移集群是一组具有相同应用程序副本的服务器,以向客户端提供相同的服务,以维护应用程序和服务的高可用性。如果某个服务器因某种原因失败,其余服务器将接管负载,并为消费者提供不间断的服务。
-
扩展(垂直扩展):这是指向我们的服务器添加更多资源,例如增加 RAM、硬盘容量和处理器。虽然这可能是一个不错的选择,但它只适用于某些情况,而不是所有情况。在某些情况下,增加更多资源可能会很昂贵。
-
扩展(水平扩展):与在一个服务器内添加更多资源不同,扩展关注的是添加更多服务器/节点来处理请求。这种分组称为集群,因为所有服务器都在执行相同类型的任务,但在不同的服务器上复制,以避免中断。
集群的优势
集群是扩展服务的更受欢迎的解决方案,因为它提供了一种快速灵活的选项,可以在需要时添加更多服务器,而不会中断现有服务。在扩展期间可以提供不间断的服务。在扩展应用程序时,消费者不需要等待任何接近停机的事情。所有服务器负载都由中央负载平衡服务器正确平衡。
负载均衡
负载均衡器是集群中最有用的工具。负载均衡器使用各种算法,如轮询、最小连接等,将传入的请求转发到正确的后端服务器进行处理。
市场上有很多第三方负载均衡器可用,例如 F5(f5.com)、HAProxy(www.haproxy.org)等。尽管这些负载均衡工具的行为不同,但它们都专注于主要角色:将请求负载分发到可用的后端服务器,并在所有服务器之间保持平衡。通过适当的负载平衡,我们可以防止单个后端服务器过载。此外,大多数负载均衡器都配备了健康监控,例如检查可服务服务器的可用性。
除了在服务器之间进行主要请求分发外,负载均衡器还保护后端服务器免受前端服务器的影响。前端服务器不会知道将请求发送到哪个后端服务器,因为负载均衡器隐藏了所有关于后端服务器的细节。
扩展数据库
扩展数据库是架构设计中具有挑战性的部分之一。在这里,我们将讨论一些数据库扩展技术,以扩展我们的应用程序。
垂直扩展
正如我们之前讨论的,在应用程序服务器级别,我们也可以利用扩展技术来对我们的数据库服务器进行扩展。增加更多的计算能力,比如 CPU 和 RAM,将提高查询数据库的性能。通过使用垂直扩展技术,我们可以获得一致的性能,并且在出现问题时也很容易调试。此外,与水平扩展相比,垂直扩展提供了更高的效率。然而,垂直扩展可能需要定期停机来安装新硬件,并且受硬件容量的限制。
水平扩展
正如我们在应用程序级别讨论的水平扩展一样,我们可以通过向我们的集群添加更多机器来对数据库服务器进行相同的操作,以处理数据库负载。与垂直扩展相比,这要便宜得多;然而,这也伴随着集群配置、维护和管理成本。
读取副本
通过保留多个可用于读取的从库,我们可以显著改进我们的应用程序。读取副本有助于在所有只读从库中读取数据。然而,当我们需要发送写入请求时,我们可以使用主数据库。主数据库可以用于写入和读取,而从库只能用于读取。我们安装的从库越多,就可以处理更多基于读取的查询。这种读取副本技术在我们需要处理最小写入查询和最大读取查询的情况下非常有用。
连接池
当应用程序查询数据库时,它会创建客户端连接,发送查询并获取结果。由于与数据库的客户端连接是昂贵的操作,连接必须被重用以进行进一步的查询。连接池将在这种情况下有所帮助,通过防止为每个请求建立到数据库的连接。通过保持更好的连接池,比如 HikariCP,我们可以提高应用程序的性能。
使用多个主数据库
与读取副本不同,多主机制提供了复制多个数据库服务器的选项。与使用读取副本复制从库不同,这里我们复制主数据库以进行写入和读取数据。这种模式对于特定场景非常有用,比如 REST API 数据事务集中的应用程序。在多主模式中,我们需要我们的应用程序生成通用唯一标识符(UUID),以防止在多主复制过程中发生数据冲突。
数据库服务器的负载均衡
由于应用程序服务器的客户端连接限制是基于数据库供应商的,当应用程序服务器请求更多连接时,处理情况可能会有些棘手。通过保持负载均衡器,我们可以使用它们的连接池将数据库查询分发到可用的数据库服务器。借助负载均衡器,我们将确保所有数据库服务器负载均衡;然而,这取决于特定负载均衡器中使用的算法。
数据库分区
当我们处理需要高端服务器并且需要大量时间来查询的大型数据库时,分区数据库非常有帮助。此外,当我们的应用程序需要查询大量读取和写入请求时,这也是有用的。分区可以进行水平和垂直两种方式。水平和垂直分区都在以下部分中描述。
分片(水平分区)
数据库表可以根据任何特定属性分成多个表。例如,用户数据库可以分成两个不同的数据库,比如user_1和user_2,其中user_1表的用户名以A-N开头,而user_2表的用户名以O-Z开头。通过像之前那样分割数据库,我们可以减少每个表中的行数,从而提高性能。
垂直分区
在垂直分区中,数据库表可以根据业务概念分成多个表。例如,一个表可能有更多的列,以便其他表可以轻松访问以获得更好的性能。
通过进行水平和垂直分区,查询数据库所需的时间将减少,从而提高性能。此外,通过将大型数据库划分为小块,我们可以避免需要高端计算机。这些数据分片可以分布到低成本的服务器上以节省成本。然而,在特定场景下,数据共享可能是一个复杂的过程。
分布式缓存
分布式缓存技术将有助于提高 Web 服务的可伸缩性。与进程内缓存不同,分布式缓存不需要在相同的应用程序空间中构建。它们可以存储在集群的多个节点上。尽管分布式缓存部署在多个节点上,但它们提供单一的缓存状态。
数据层缓存
在数据库中添加缓存层将提供更好的性能。这被认为是改善性能的常见策略,特别是当我们的应用程序中读取请求很多时。在这里,我们将讨论 Hibernate 的缓存级别。
一级缓存
一级缓存是 Hibernate 启用的内置会话缓存,是通过所有请求的强制性缓存。在 Hibernate 中没有禁用一级缓存的选项。一级缓存与会话对象相关联,一旦会话过期,缓存将丢失。当我们第一次查询 Web 服务时,对象将从数据库中检索并存储在一级缓存中,该缓存与 Hibernate 会话相关联。如果我们再次请求相同的实体,它将从缓存中检索,而无需查询数据库。
二级缓存
二级缓存是 Hibernate 中的可选缓存。在我们的请求到达二级缓存之前,一级缓存将是联系点。二级缓存可以按类或集合配置,并负责在会话之间缓存对象。
由于只有少数类受益于缓存,默认情况下禁用了二级缓存。可以启用以服务设计师。
应用层缓存
与在数据库中缓存类似,我们还可以在应用程序层缓存任何对象以提高应用程序的性能。在这里,我们将讨论各种对象缓存,特别是键值缓存工具,并检查它们在市场上的独特性。
Memcached
由于大多数公司在其应用程序中使用 Memcached (https://memcached.org),我们认为 Memcached 是最强大的分布式缓存系统之一。它遵循分布式内存缓存机制,在重复的场景中非常有帮助,例如当多次请求相同的服务时。
Redis
Redis (redis.io) 是另一个可以用于缓存的内存键值存储。Redis 支持诸如哈希、列表、集合等数据结构。Redis 被认为是最受欢迎的键值存储之一,支持高级键值缓存。Redis 支持交集和并集等操作。由于其高级功能和速度,它比 Memcached 更受青睐。
Hazelcast
Hazelcast(hazelcast.com)是一个支持分布式集合并简化分布式计算的内存数据网格。它提供了一个简单的 API 和简单直接的部署策略。由于 Hazelcast 提供了 Memcached 客户端库,使用 Memcached 集群的应用程序可能能够适应 Hazelcast 集群。Hazelcast 架构支持在集群平台上的数据分发和高可伸缩性。它还提供智能同步和自动发现。Hazelcast 提供了分布式数据结构、分布式查询和分布式计算等功能。Spring Boot 在其框架中明确支持 Hazelcast 缓存。
Ehcache
Ehcache(www.ehcache.org)由于其简化的可扩展选项,主要用于小型到中型部署。它被认为是最广泛使用的分布式缓存之一。此外,Ehcache 提供了与其他流行库和框架集成的选项。Ehcache 的扩展从进程内缓存开始,经过混合的进程内和进程外部署。此外,Ehcache 推出了 Terracotta 服务器,以提高缓存性能。
Riak
Riak(github.com/basho/riak)是基于 Erlang 的键值数据存储,具有容错性和高可用性。在 Riak 中,数据可以存储在内存、磁盘或两者兼有。Riak 可以通过诸如 HTTP API 或本机 Erlang 接口之类的协议进行访问。Riak 支持主要语言,如 Java、C 和 Python。此外,它支持 MapReduce,可以在大数据相关操作中灵活使用。
Aerospike
Aerospike(www.aerospike.com)是一个开源的、针对闪存优化的、内存 NoSQL 数据库和键值存储。Aerospike 在三个层面上运行:针对闪存优化的数据层、自管理的分布层和集群感知的客户端层。为了确保一致性,分布层在所有数据中心都有副本。即使单个服务器节点失败或从集群中移除,这些副本也会保持功能正常。
Infinispan
Infinispan(infinispan.org/)是一个分布式的内存键值数据存储,可以用作缓存或数据网格。它可以作为库或通过诸如 REST 之类的协议进行访问。此外,Infinispan 可以与 JPA、JCache、Spring 和 Spark 集成。Infinispan 支持大多数与 MapReduce 相关的操作。
Cache2k
Cache2k(cache2k.org/)提供了 Java 应用程序中的内存对象缓存选项。Cache2k 主要侧重于 JVM 内部的缓存。
其他分布式缓存
之前,我们讨论了主要的缓存工具及其机制。在这里,我们将更多地讨论市场上可用的其他分布式缓存:
Amazon ElastiCache
ElastiCache 主要用作内存数据存储和缓存服务;它是由 AWS 引入的。借助 Amazon ElastiCache 的支持,我们可以快速部署我们的缓存环境,而无需进行任何复杂的安装。它支持 Memcached 和 Redis 缓存。
Oracle 分布式缓存(Coherence)
在这个分布式缓存中,数据被分区在集群中的所有计算机上。这些分区缓存将被配置为在集群中的节点上保留每个数据片段。分布式缓存是 Coherence 中最常用的缓存。
尽管市场上有很多缓存解决方案可供选择,但选择特定的解决方案取决于许多因素,如业务需求、性能需求、数据完整性、容错性、成本等。在应用程序层和数据库层添加正确的分布式缓存层将会带来更好的性能。
总结
在本章中,我们讨论了不同的库、工具和技术,以扩展 RESTful Web 服务。在开发应用程序时,我们将不得不通过使用明确定义的接口来寻找系统组件之间的松耦合。在接下来的章节中,我们将讨论微服务及其优势。
第十二章:微服务基础知识
尽管单体架构有其自身的好处,但当应用程序变得越来越大以支持各种类型的业务逻辑时,它给开发人员和部署工程师带来了很大的困难。即使是后端的一个小 bug 修复也会迫使开发人员在服务器上重新部署整个应用程序,导致不必要的维护。另一方面,微服务提供了将业务逻辑分离成服务的选项。因此,应用程序可以在不中断流程的情况下推送到服务器,尤其是最终用户不应该注意到任何中断。在本章中,我们将深入探讨一些关于微服务和相关主题的基础知识。
在本章中,我们将讨论:
-
单体架构及其缺点
-
微服务及其优势
-
微服务的基本特征
-
微服务组件
-
微服务工具
单体架构及其缺点
尽管微服务架构如今越来越受欢迎,但大多数公司仍然使用单体架构。作为单体应用程序,您可以将所有业务模块捆绑成一个单一单元,并将它们部署在所有需要的服务器上。如果应用程序需要进行任何更改,开发人员必须提供这些更改并重新部署应用程序的更新版本。在单体架构中,我们遵循服务模块之间的紧密耦合。
尽管单体架构有一些好处,但其缺点为另一种架构设计——微服务铺平了道路。在这里,我们将简要讨论单体架构的缺点:
-
对于每个 bug 修复或代码更改,我们必须在所有服务器上重新部署整个应用程序
-
如果单体应用程序存在任何常见问题,比如性能问题,它将影响整个应用程序,这可能很难找出并快速修复
-
更大的应用程序在部署期间可能需要更长的启动时间
-
库需求和冲突可能影响整个应用程序。我们将很难修复库以支持所有模块
-
单体架构的扩展可能很困难,因为所有模块都在一个统一的范围内
-
应用程序增长时,业务逻辑和实现的复杂性也会增加,这可能需要更多的时间来开发和维护
-
不经常、昂贵和大规模的部署选项:如果我们有多种类型的业务逻辑和层,并且想要升级一个业务逻辑,我们将需要部署所有其他层/服务
-
紧密耦合的服务在一个服务/层需要升级时会带来困难
服务发现
在微服务架构中,根据业务需求和服务负载,我们可能需要增加服务实例。在这种情况下,跟踪所有可用的服务实例及其信息,如端口号,可能很难管理。服务发现将帮助我们通过自动配置服务实例并在需要时查找它们来管理这些任务。
微服务简介
在一个大型应用程序中做一些改变对开发人员来说是一个不断的痛苦。每次我们在代码中做一个小改变,我们可能需要将整个应用程序部署到服务器上,这是一个耗时且繁琐的过程,特别是当我们有多个服务,比如会计、报告、用户管理等。微服务帮助我们摆脱这种痛苦。微服务的主要目标是将应用程序拆分为服务,并独立部署每个服务到我们的服务器上。通过这样做,我们在应用程序中提供了松散耦合的进程。此外,微服务可以部署在云中,以避免服务中断问题,并为消费者提供不间断的服务。
在微服务中,每个模块或业务部分都可以编写为一个单独的服务,以提供持续交付和集成。这些服务旨在满足特定的业务需求,并且可以通过自动化部署基础设施独立部署。管理这些服务可以是分散的,并且可以以不同的语言进行编程。
在转向组件之前,我们将简要讨论微服务的基本特征。
独立性和自治性
微服务作为单片环境的更好替代品。在微服务中,每个服务都可以在任何时候启动、停止、升级或替换,而不会中断其他服务。所有服务都是独立的,并且可以自动注册到我们的中央注册表中。
弹性和容错性
在复杂的应用程序设计中,创建一个具有弹性的系统对每个服务都至关重要。大多数云环境都需要一种架构设计,其中所有服务都能应对意外情况,比如停机等。这些情况可能包括接收到坏数据(损坏的数据),可能无法到达所需的服务,或者可能在并发系统中请求冲突。微服务需要对故障具有弹性,并且应该能够快速重启自己。
微服务应该防止故障通过系统中的其他依赖服务进行级联。
自动化环境
自动化应该是微服务架构设计中的一个重要因素,因为应用程序中将涉及许多服务,因此服务之间的交互将非常复杂。必须实施自动化监控和警报管理系统来增强微服务设计。所有服务都应记录其数据和指标,并且这些指标应得到适当监控,因为这将改善服务管理。
无状态
微服务是无状态的,这意味着它们不会在一个会话中保留数据到另一个会话。此外,微服务实例不会相互交互。当应用程序中有更多的微服务实例可用时,每个实例都不会知道其他实例,无论下一个实例是否存活。当我们扩展我们的应用程序时,这一特征非常有帮助。
微服务的好处
在本节中,我们将讨论在我们的应用程序中开发微服务的好处:
-
业务逻辑可以分组并开发成易于开发和部署的服务,具有多个服务实例
-
微服务可以通过将应用程序拆分为多个服务来避免复杂的应用程序,提供易于开发和维护业务逻辑,特别是在升级特定部分时
-
服务可以独立部署,而不会中断应用程序;因此,最终用户永远不会感受到任何服务中断
-
松散耦合的服务将在扩展应用程序方面提供更多的灵活性
-
单独升级服务以满足时尚的业务需求是方便的,开发人员可以引入新技术来开发服务
-
借助微服务,可以更容易地实现持续部署;因此,可以对所需的模块进行快速升级
-
扩展这些服务将非常灵活,特别是当特定的业务需求需要更多实例以为最终用户提供不间断的服务时
-
组织可以专注于可以快速移至生产环境的小批量工作,特别是在为特定客户测试新功能时
微服务组件
为了拥有完全功能的微服务应用程序,必须正确使用以下组件。这些组件帮助我们在服务之间解决复杂的业务逻辑分配:
-
配置服务器
-
负载均衡器
-
服务发现
-
断路器
-
边缘服务器
我们将在本节中简要讨论这些组件。
配置服务器
配置服务器将帮助我们存储将要部署的每个服务的所有可配置参数。如果需要,这些属性可以保存在存储库中。此外,配置服务器将提供更改应用程序配置的选项,而无需部署代码。一旦配置更改,它将自动反映在应用程序中,因此我们可以避免重新部署我们的服务。
由于我们的微服务应用中将有许多服务,拥有配置服务器将帮助我们避免服务重新部署,并且服务可以从服务器获取相应的配置。这也是持续交付的原则之一:将源代码与配置解耦。
负载均衡器
负载均衡器通过将负载分配给特定服务来作为扩展应用程序的支柱。负载均衡器被认为是微服务架构中的重要组成部分。与分布在服务器之间的常规负载均衡器不同,这些负载均衡器管理服务实例并在这些实例之间分配负载。借助服务发现组件的帮助,它们将获取有关可用服务实例的信息并分配负载。
Netflix Ribbon 被用作负载均衡器;我们将在本章的微服务工具部分探讨这一点。
断路器
由于我们的架构中有许多服务共同工作,每个服务可能相互依赖。有些情况会导致一些服务失败,并可能导致其他服务随之失败。为了避免这种情况,我们的架构应该具有容错性。使用断路器等模式可以减少微服务架构中的故障。
边缘服务器
边缘服务器实现了 API 网关模式,并且对外部世界的 API 行为像一堵墙。借助边缘服务器,所有公共流量将被转发到我们的内部服务。通过这样做,最终用户在未来我们的服务和内部结构发生任何变化时不会受到影响。Netflix Zuul 被用作边缘服务器,我们将在下一节中分享一些关于 Zuul 的内容。
微服务工具
Netflix 工程师为微服务开发做出了很大贡献,并为微服务生态系统引入了各种组件。在这里,我们将讨论可能涉及微服务的更多组件:
-
Netflix Eureka
-
Netflix Zuul
-
Spring Cloud Config 服务器
-
Netflix Ribbon
-
Spring Cloud Netflix
-
Spring Security OAuth2
-
Netflix Hystrix 和 Turbine
-
Eclipse Microprofile
我们将在接下来的部分中更多地讨论它们。
Netflix Eureka
Eureka 在微服务中扮演着服务发现服务的角色。它允许微服务在运行时注册自己,并在需要时帮助我们定位服务。它用于中间层服务器的负载平衡和故障转移。此外,Eureka 还配备了一个 Java 客户端(Eureka 客户端)以使服务交互更加容易。Eureka 服务器通过定位中间层服务器中的服务来充当中间层(服务级别)负载平衡工具。这些中间层(服务级别)负载平衡工具可能在类似 AWS 的云中不可用。
尽管 AWS 的弹性负载均衡器(ELB)可用于负载均衡服务,但它仅支持传统负载均衡器等端用户 Web 服务,而不支持中间层负载均衡。
在 Eureka 服务器中,客户端的实例知道他们需要与哪些服务通信,因为 Eureka 负载均衡器也专注于实例级别。Eureka 服务是无状态的,因此它们支持可伸缩性。由于服务器信息被缓存在客户端,负载均衡在负载均衡器宕机的情况下非常有帮助。
Eureka 在 Netflix 中用于 memcached 服务、cassandra 部署和其他操作。强烈建议在本地服务应该对公共服务禁用的中间层服务中使用 Eureka 服务器。
Netflix 开发人员启动了 Eureka 服务器并将其开源。后来,Spring 将其纳入了 Spring Cloud。在微服务架构中,服务应该是细粒度的,以提高应用程序的模块化,便于开发、测试和维护。
Netflix Zuul
Zuul 充当公共前门的门卫,并且不允许未经授权的外部请求通过。它还提供了我们服务器中微服务的入口点。Zuul 使用 Netflix Ribbon 来查找可用的服务并将外部请求路由到正确的服务实例。Zuul 支持动态路由、监控和安全性。
Zuul 的不同类型的过滤器,如PRE、ROUTING、POST和ERROR,有助于实现以下操作:
-
动态路由
-
洞察和监控
-
认证和安全
-
压力测试
-
多区域弹性
-
静态响应处理
Zuul 有多个组件:
-
zuul-core -
zuul-simple-webapp -
zuul-netflix -
zuul-netflix-webapp
Spring Cloud Netflix
Spring Cloud 提供了第三方云技术与 Spring 编程模型之间的交互。Spring Cloud Netflix 为 Spring Boot 提供了 Netflix 开源软件 (OSS)集成支持,通过自动配置和绑定到 Spring 环境来使用。通过在 Spring Boot 中添加一些注解,我们可以构建一个包括 Netflix 组件在内的大型分布式应用程序。
Spring Cloud Netfix 可以实现诸如服务发现、服务创建、外部配置、路由器和过滤器等功能。
Netflix Ribbon
Netflix 被服务消费者用于在运行时查找服务。Ribbon 从 Eureka 服务器获取信息以定位适当的服务实例。在 Ribbon 有多个实例可用的情况下,它将应用负载均衡机制来将请求分布到可用的实例上。Ribbon 不作为一个独立的服务运行,而是作为每个服务消费者中的一个嵌入式组件。具有客户端负载均衡是使用服务注册表的一个重大好处,因为负载均衡器让客户端选择服务的注册实例。
Ribbon 提供以下功能:
-
负载均衡规则(多个和可插拔的)
-
服务发现集成
-
对故障的弹性
-
云支持
Ribbon 有子组件,如ribbon-core、ribbon-eureka和ribbon-httpclient。
Netflix Ribbon 充当客户端负载均衡器,并且可以与 Spring Cloud 集成。
Netflix Hystrix
每个分布式环境都容易发生服务故障,这种情况可能经常发生。为了解决这个问题,我们的架构应该具有容错和延迟容忍性。Hystrix 是一个断路器,可以帮助我们避免这种情况,如服务依赖失败。Hystrix 可以防止服务过载,并在发生故障时隔离故障。
通过 Hystrix 支持,我们可以通过在微服务中添加延迟容忍和容错逻辑来控制它们之间的交互。在服务失败的情况下,Hystrix 提供了强大的回退选项,从而提高了系统的整体弹性。如果没有 Hystrix,如果内部服务失败,可能会中断 API 并破坏用户体验。
Hystrix 遵循一些基本的弹性原则,如下:
-
服务依赖的失败不应该对最终用户造成任何中断
-
在服务依赖失败的情况下,API 应该做出正确的反应
Hystrix 还有一个断路器回退机制,使用以下方法:
-
自定义回退:当客户端库提供回退或本地数据以生成响应时
-
失败静默:回退返回 null,在某些情况下很有帮助
-
快速失败:在特定情况下使用,如 HTTP 5XX 响应
Netflix Turbine
Turbine 用于将所有服务器发送事件(SSE)JSON 数据流聚合成一个流,可用于仪表板目的。Turbine 工具用于 Hystrix 应用程序,该应用程序具有实时仪表板,可从多台机器中聚合数据。Turbine 可以与支持 JSON 格式的任何数据源一起使用。Turbine 是数据不可知的,并且能够将 JSON 块视为键值对的映射。
Netflix 使用 Turbine 与 Eureka 服务器插件来处理因各种原因加入和离开集群的实例,例如自动缩放、不健康等。
HashiCorp Consul
Consul 是一个服务发现和配置工具,用于支持微服务。Consul 是由 Hashi Corp 于 2014 年发起的,主要专注于跨多个数据中心的分布式服务。此外,Consul 可以保护数据并与大型基础设施配合工作。通过使用键和值配置服务,并找到所需的服务,Consul 解决了微服务的核心问题。
Consul 有服务器和客户端,形成一个单一的 Consul 集群。在 Consul 集群中,节点将能够存储和复制数据。通过至少一个成员的地址的帮助,自动发现集群中的其他成员。此外,Consul 提供了动态基础设施,因此不需要额外的编码/开发来自动发现服务。
Consul 旨在为 DevOps 社区和应用程序开发人员提供支持现代和弹性基础设施的工具。
Eclipse MicroProfile
Eclipse MicroProfile 是由 RedHat、IBM 等公司和其他团体发起的,旨在为构建微服务提供规范。该项目始于 2016 年,最近发布了 MicroProfile 的 1.2 版本。它主要专注于优化企业 Java 以适应微服务架构。Payara Micro 和 Payara Servers 都与 Eclipse MicroProfile 兼容。
Eclipse MicroProfile 1.2 版本配备了配置 API、健康检查、容错、度量和其他支持微服务的必要工具。
总结
在本章中,我们简要讨论了单体应用及其缺点。然后我们谈到了微服务及其优点以及相关主题。此外,我们还讨论了微服务的基本原则,包括弹性和容错。
在本章的后面部分,我们讨论了微服务组件,并涵盖了与微服务相关的工具,如 Netflix Eureka、Zuul 等。在下一章和最后一章中,我们将处理一个包括身份验证和授权在内的高级 CRUD 操作的实时票务管理场景。
第十三章:票务管理-高级 CRUD
我们的应用程序必须满足实时业务案例,如票务管理。本章将回顾本书前几章涵盖的大部分主题。
在本章中,我们将创建一个实时场景,并实现我们场景的业务需求——用户、客户服务代表(CSR)和管理员的票务管理。
我们的最后一章包括以下主题:
-
客户创建票务
-
客户、CSR 和管理员更新票务
-
客户删除票务
-
CSR/管理员删除多张票
使用 CRUD 操作进行票务管理
在转向票务管理系统之前,我们将介绍业务需求。
假设我们有一个银行网站应用程序,可以由我们的客户 Peter 和 Kevin 使用,我们有 Sammy,我们的管理员,和 Chloe,CSR,在应用程序出现问题时提供帮助。
Peter 和 Kevin 在付款过程中遇到一些问题。当他们尝试点击付款交易提交按钮时,它不起作用。此外,交易视图在一个网页上。因此,我们的用户(Peter 和 Kevin)将创建一个票务来分享他们的问题。
一旦票务创建完成,客户/CSR/管理员可以对其进行更新。此外,客户可以删除自己的票务。在更新时,任何人都可以更改严重性;然而,只有 CSR 和管理员可以更改状态,因为票务状态与官方活动有关。
客户可以查看他们的票务总数或单张票,但一次只能删除一张票。多删除选项适用于 CSR 和管理员。但是,CSR 一次只能删除三张票。管理员将在票务管理应用程序中拥有完全控制,并可以随时删除任意数量的票。
注册
让我们开始编码以满足上述要求。首先,我们需要从客户、CSR 和管理员注册开始。由于这些用户具有不同的角色,我们将为每个用户分配不同的用户类型。
用户类型
为了区分用户,我们提出了三种不同的用户类型,因此当他们访问我们的 REST API 时,他们的授权将有所不同。以下是三种不同的用户类型:
| 名称 | 用户类型 |
|---|---|
| 普通用户/客户 | 1 |
| CSR | 2 |
| 管理员 | 3 |
用户 POJO
在我们之前的User类中,我们只有userid和username。为了满足我们之前提到的业务需求,我们可能需要两个更多的变量。我们将在现有的User类中添加password和usertype:
private String password;
/*
* usertype:
* 1 - general user
* 2 - CSR (Customer Service Representative)
* 3 - admin
*/
private Integer usertype;
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public void setUsertype(Integer usertype){
this.usertype = usertype;
}
public Integer getUsertype(){
return this.usertype;
}
在前面的代码中,我们刚刚添加了password和usertype。此外,我们为我们的变量添加了 getter 和 setter 方法。
您可以在我们的 GitHub 存储库上查看完整的User类(github.com/PacktPublishing/Building-RESTful-Web-Services-with-Spring-5-Second-Edition)。
您可能已经厌倦了添加 getter 和 setter 方法,因此我们将用 Lombok 库替换它们,这将在本章后面讨论。但是,Lombok 库与 Eclipse 或 STS IDE 存在一些冲突问题,您可能需要注意。在这些 IDE 的某些版本中,由于 Lombok 库的问题,您在类创建时将无法获得预期的行为。此外,一些开发人员提到他们在 Lombok 上有部署问题。
为了从我们的User类中自动生成用户 ID,我们将使用一个单独的计数器。我们将保留一个静态变量来做到这一点;在真实应用程序中保留静态计数器是不推荐的。为了简化我们的实现逻辑,我们使用了静态计数器。
以下代码将被添加到我们的User类中:
private static Integer userCounter = 100;
我们已经开始有100个用户。每当添加一个新用户时,它将自动增加userid并将其分配给新用户。
userCounter的起始点没有限制。通过将用户系列保持在2(2XX)和票务系列保持在3(3XX),读者更容易区分用户和票务。
现在我们将创建一个新的构造函数来将用户添加到我们的应用程序中。此外,我们将增加usercounter参数并将其分配为每个新用户的userid:
public User(String username, String password, Integer usertype) {
userCounter++;
this.userid = userCounter;
this.username = username;
this.password = password;
this.usertype = usertype;
}
上述构造函数将填充所有用户详情,包括userid(来自usercounter)。
在这里,我们将在UserServiceImpl类中添加一个新用户,包括username、password和usertype;每个用户的usertype都会有所不同(例如,管理员的usertype是3):
@Override
public void createUser(String username, String password, Integer usertype){
User user = new User(username, password, usertype);
this.users.add(user);
}
在上述代码中,我们创建了一个新用户并将其添加到现有用户列表中。
在上述代码中,我们没有在UserService中提及抽象方法。我们假设每个具体方法在接口中都有一个抽象方法。以后,请考虑在适当的接口中添加所有抽象方法。
客户注册
现在是添加客户的时候了。新客户将需要通过添加用户名和密码详情来创建一个账户。
我们将讨论客户注册 API。这个 API 将帮助任何新客户注册他们的账户:
@ResponseBody
@RequestMapping(value = "/register/customer", method = RequestMethod.POST)
public Map<String, Object> registerCustomer(
@RequestParam(value = "username") String username,
@RequestParam(value = "password") String password
) {
userSevice.createUser(username, password, 1);
return Util.getSuccessResult();
}
在上述代码中,我们已经添加了一个 API 来注册客户。调用这个 API 的人将被视为客户(而不是管理员/CSR)。正如你所看到的,我们已经将usertype设为1,因此它将被视为客户。
以下是客户注册的 SoapUI 截图:

此外,在上述代码中,我们使用了来自我们的Util类的getSuccessResult。我们将在以下代码中看到其他Util方法:
package com.packtpub.util;
import java.util.LinkedHashMap;
import java.util.Map;
public class Util {
public static <T> T getUserNotAvailableError(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result_code", 501);
map.put("result", "User Not Available");
return (T) map;
}
public static <T> T getSuccessResult(){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result_code", 0);
map.put("result", "success");
return (T) map;
}
public static <T> T getSuccessResult(Object obj){
Map<String, Object> map = new LinkedHashMap<>();
map.put("result_code", 0);
map.put("result", "success");
map.put("value", obj);
return (T) map;
}
}
在上述代码中,我们创建了一个Util类,用于保存将在不同控制器中使用的通用方法,例如Ticket和User。这些Util方法用于避免我们应用程序中的代码重复。
为了简化流程,在这段代码中我们没有使用任何异常处理机制。您可能需要使用适当的异常处理技术来实现这些方法。
管理员注册
每个应用程序都将有一个管理员来控制所有操作,例如删除客户和更改状态。在这里,我们将讨论管理员注册 API。
管理员注册 API 也将使用createUser方法来创建管理员。以下是管理员注册的代码:
@ResponseBody
@RequestMapping(value = "/register/admin", method = RequestMethod.POST)
public Map<String, Object> registerAdmin(
@RequestParam(value = "username") String username,
@RequestParam(value = "password") String password
) {
Map<String, Object> map = new LinkedHashMap<>();
userSevice.createUser(username, password, 3); // 3 - admin (usertype)
map.put("result", "added");
return map;
}
在上述代码中,我们在管理员注册中添加了代码,同时在createUser构造函数调用中提及了3(管理员的用户类型)。此外,您可以看到我们使用POST方法进行注册。
以下是http://localhost:8080/user/register/admin管理员注册 SoapUI API 调用的截图:

在我们的票务管理中,我们没有对用户重复注册进行任何限制,这意味着我们可以有许多具有相同名称的用户。我们建议您避免重复注册,因为这将破坏流程。为了尽可能简化我们的实现,我们忽略了这种限制。但是,您可以实现限制以改进应用程序。
CSR 注册
在这一部分,我们将讨论 CSR 注册。
客户注册只有一个区别——usertype。除了usertype和 API 路径之外,与其他注册调用没有任何不同:
@ResponseBody
@RequestMapping(value = "/register/csr", method = RequestMethod.POST)
public Map<String, Object> registerCSR(
@RequestParam(value = "username") String username,
@RequestParam(value = "password") String password
) {
userSevice.createUser(username, password, 2);
return Util.getSuccessResult();
}
与其他 API 一样,我们使用2(CSR 的用户类型)来注册 CSR。让我们看看在 SoapUI 中的 API 调用,如下所示:

登录和令牌管理
在上一节中,我们已经涵盖了用户注册主题,例如客户、管理员和 CSR。一旦用户成功注册,他们将需要登录以执行操作。因此,让我们创建与登录和会话相关的 API 和业务实现。
在转到登录和会话之前,我们将讨论 JSON Web Token,它将用于会话认证。由于我们已经在我们的securityService类中有createToken方法,我们只会讨论令牌生成中使用的subject。
生成令牌
我们可能需要使用 JSON Web Token 来进行会话。我们将使用现有的令牌生成方法来保留用户详细信息:
String subject = user.getUserid()+"="+user.getUsertype();
String token = securityService.createToken(subject, (15 * 1000 * 60)); // 15 mins expiry time
我们已经使用user.getUserid()+"="+user.getUsertype()作为主题。此外,我们已经提到15分钟作为到期时间,因此令牌将只在15分钟内有效。
客户登录
让我们为客户创建一个登录 API。客户必须提供用户名和密码详细信息作为参数。在实际应用中,这些详细信息可能来自 HTML 表单,如下所示:
@ResponseBody
@RequestMapping(value = "/login/customer", method = RequestMethod.POST)
public Map<String, Object> loginCustomer(
@RequestParam(value = "username") String username,
@RequestParam(value = "password") String password
) {
User user = userSevice.getUser(username, password, 1);
if(user == null){
return Util.getUserNotAvailableError();
}
String subject = user.getUserid()+"="+user.getUsertype();
String token = securityService.createToken(subject, (15 * 1000 * 60)); // 15 minutes expiry time
return Util.getSuccessResult(token);
}
在前面的代码中,我们通过传递所有必要的参数从userService调用了getUser方法。由于用户类型是1,我们在我们的方法中传递了1。一旦我们得到用户,我们就会检查它是否为空。如果为空,我们将简单地抛出错误。如果用户不为空,我们将创建一个令牌主题(user.getUserid()+"="+user.getUsertype())并创建一个具有15分钟到期时间的令牌,正如我们之前提到的那样。
如果一切如我们所期望的那样,我们将创建一个结果映射并将映射作为 API 响应返回。当我们调用此 API 时,这个映射将显示为我们结果中的 JSON 响应。
此外,在前面的代码中,我们使用了getUserNotAvailableError来返回错误详情。由于我们将在所有与会话相关的 API 中使用此错误,我们已经创建了一个单独的方法来避免代码重复。
在这里,我们可以看到客户登录的 SoapUI 截图:

在用户成功登录的情况下,我们将在响应 JSON 中获得一个令牌。我们将不得不使用令牌进行与会话相关的 API,如添加票务。这里提供了一个示例令牌:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDM9MSIsImV4cCI6MTUxNTg5MDMzN30.v9wtiG-fNWlpjgJmou7w2oxA9XjXywsH32cDZ-P4zM4
在一些方法中,我们可能会看到<T> T返回类型,这是 Java 泛型的一部分。通过保持这样的泛型,我们可以通过适当地进行转换来返回任何对象。
这是一个示例:
return (T) map; 返回类型
管理员登录
当我们看到客户登录部分时,我们也将为管理员创建一个登录 API。
在这里,我们将为管理员登录创建一个 API,并在成功验证后生成一个令牌:
@ResponseBody
@RequestMapping(value = "/login/admin", method = RequestMethod.POST)
public Map<String, Object> loginAdmin(
@RequestParam(value = "username") String username,
@RequestParam(value = "password") String password
) {
Map<String, Object> map = new LinkedHashMap<>();
User user = userSevice.getUser(username, password, 3);
if(user == null){
return Util.getUserNotAvailableError();
}
String subject = user.getUserid()+"="+user.getUsertype();
String token = securityService.createToken(subject, (15 * 1000 * 60)); // 15 mins expiry time
map.put("result_code", 0);
map.put("result", "success");
map.put("token", token);
return map;
}
前面的登录 API 将仅用于管理员目的。我们已经使用usertype作为3来创建一个管理员用户。此外,我们已经使用了Util方法getUserNotAvailableError。
这是管理员登录的 SoapUI 截图:

CSR 登录
在这一部分,我们将讨论 CSR 登录和在TicketController中为 CSR 生成令牌:
@ResponseBody
@RequestMapping(value = "/login/csr", method = RequestMethod.POST)
public Map<String, Object> loginCSR(
@RequestParam(value = "username") String username,
@RequestParam(value = "password") String password
) {
User user = userSevice.getUser(username, password, 2);
if(user == null){
return Util.getUserNotAvailableError();
}
String subject = user.getUserid()+"="+user.getUsertype();
String token = securityService.createToken(subject, (15 * 1000 * 60)); // 15 mins expiry time
return Util.getSuccessResult(token);
}
像往常一样,我们将从我们的列表中获取用户并检查是否为空。如果用户不可用,我们将抛出一个错误,否则代码将继续执行。与其他用户类型一样,我们将为 CSR 创建一个单独的 API,并将usertype作为1传递以创建一个 CSR。
您可以在以下截图中看到 CSR 登录 API:

票务管理
为了创建一个票,我们需要创建一个Ticket类并将票存储在列表中。我们将更多地讨论Ticket类,票务列表和其他与票务相关的工作,如用户票务管理,管理员票务管理和 CSR 票务管理。
票务 POJO
我们将创建一个Ticket类,并涉及一些基本变量来存储与票务相关的所有细节。以下代码将帮助我们理解Ticket类:
public class Ticket {
private Integer ticketid;
private Integer creatorid;
private Date createdat;
private String content;
private Integer severity;
private Integer status;
// getter and setter methods
@Override
public String toString() {
return "Ticket [ticketid=" + ticketid + ", creatorid=" + creatorid
+ ", createdat=" + createdat + ", content=" + content
+ ", severity=" + severity + ", status=" + status + "]";
}
private static Integer ticketCounter = 300;
public Ticket(Integer creatorid, Date createdat, String content, Integer severity, Integer status){
ticketCounter++;
this.ticketid = ticketCounter;
this.creatorid = creatorid;
this.createdat = createdat;
this.content = content;
this.severity = severity;
this.status = status;
}
}
前面的代码将存储票务详情,如ticketid,creatorid,createdat,content,severity和status。此外,我们使用了一个名为ticketCounter的静态计数器来在创建票务时递增ticketid。默认情况下,它将从300开始。
另外,我们已经使用了构造函数和toString方法,因为我们将在我们的实现中使用它们。
我们将不得不创建TicketService接口(用于抽象方法)和TicketServiceImpl具体类,用于所有与票务相关的业务逻辑实现。
以下代码将显示如何添加一张票:
@Override
public void addTicket(Integer creatorid, String content, Integer severity, Integer status) {
Ticket ticket = new Ticket(creatorid, new Date(), content, severity, status);
tickets.add(ticket);
}
ticketid as created by the incrementer in the Ticket class. Once the ticket is created, we add it to the ticket list, which will be used for other operations.
通过令牌获取用户
对于所有与票务相关的操作,我们需要用户会话。在登录方法中,我们在成功登录后获得了令牌。我们可以使用令牌来获取用户详细信息。如果令牌不可用,不匹配或过期,我们将无法获取用户详细信息。
在这里,我们将实现从令牌中获取用户详细信息的方法:
@Override
public User getUserByToken(String token){
Claims claims = Jwts.parser() .setSigningKey(DatatypeConverter.parseBase64Binary(SecurityServiceImpl.secretKey))
.parseClaimsJws(token).getBody();
if(claims == null || claims.getSubject() == null){
return null;
}
String subject = claims.getSubject();
if(subject.split("=").length != 2){
return null;
}
String[] subjectParts = subject.split("=");
Integer usertype = new Integer(subjectParts[1]);
Integer userid = new Integer(subjectParts[0]);
return new User(userid, usertype);
}
在上面的代码中,我们使用令牌来获取用户详细信息。我们使用 JWT 解析器首先获取声明,然后我们将获取主题。如果您记得,我们在为所有用户登录选项创建令牌时使用了user.getUserid()+"="+user.getUsertype()作为主题。因此,主题将采用相同的格式,例如,101(用户 ID)=1(用户类型)表示客户,因为客户的用户类型是1。
此外,我们还要检查主题是否有效,使用subject.split("=").length != 2。如果我们使用不同的令牌,它将简单地返回 null。
一旦我们得到了正确的主题,我们将获取userid和usertype,然后我们将通过创建User对象来返回用户。
因为getUserByToken对所有用户都是通用的,所以它将用于我们所有的用户检索方法。
用户票务管理
首先,为了简化我们的业务需求,我们保持只有客户可以创建票据的规则。管理员和 CSR 都不能创建票据。在实时情况下,您可能有不同的票务管理方法。但是,我们将尽量保持业务需求尽可能简单。
票务控制器
在这里,我们将讨论客户创建一张票据:
/*
* Rule:
* Only user can create a ticket
*/
@SuppressWarnings("unchecked")
@ResponseBody
@UserTokenRequired
@RequestMapping(value = "", method = RequestMethod.POST)
public <T> T addTicket(
@RequestParam(value="content") String content,
HttpServletRequest request
) {
User user = userSevice.getUserByToken(request.getHeader("token"));
ticketSevice.addTicket(user.getUserid(), content, 2, 1);
return Util.getSuccessResult();
}
当用户提交一张票据时,他们只会发送关于他们在应用程序中遇到的问题的详细信息。我们为这样的详细信息提供了内容变量。此外,我们从他们在标头中传递的令牌中获取用户详细信息。
我们可以在以下截图中看到成功的响应:

在之前的 API 中,我们已经使用了@UserTokenRequired注解来验证用户令牌。我们将在这里检查注解和实现的详细信息。
UserTokenRequired 接口
在这里,我们将介绍UserTokenRequired接口,并在下一节中跟进验证逻辑:
package com.packtpub.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface UserTokenRequired {
}
UserTokenRequiredAspect 类
这个类将在解密后检查用户令牌的用户 ID 和用户类型验证:
package com.packtpub.aop;
import javax.servlet.http.HttpServletRequest;
import javax.xml.bind.DatatypeConverter;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.packtpub.service.SecurityServiceImpl;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
@Aspect
@Component
public class UserTokenRequiredAspect {
@Before("@annotation(userTokenRequired)")
public void tokenRequiredWithAnnotation(UserTokenRequired userTokenRequired) throws Throwable{
ServletRequestAttributes reqAttributes = (ServletRequestAttributes)RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = reqAttributes.getRequest();
// checks for token in request header
String tokenInHeader = request.getHeader("token");
if(StringUtils.isEmpty(tokenInHeader)){
throw new IllegalArgumentException("Empty token");
}
Claims claims = Jwts.parser() .setSigningKey(DatatypeConverter.parseBase64Binary(SecurityServiceImpl.secretKey))
.parseClaimsJws(tokenInHeader).getBody();
if(claims == null || claims.getSubject() == null){
throw new IllegalArgumentException("Token Error : Claim is null");
}
String subject = claims.getSubject();
if(subject.split("=").length != 2){
throw new IllegalArgumentException("User token is not authorized");
}
}
}
在上述的UserTokenRequiredAspect类中,我们刚刚从标头中获取了令牌,并验证了令牌是否有效。如果令牌无效,我们将抛出异常。
如果用户为空(也许有错误或空令牌),它将在响应中返回"用户不可用"。一旦提供了必要的令牌,我们将通过调用TicketServiceImpl中的addTicket方法来添加票据,这是我们之前提到的。
严重级别如下:
-
次要:级别 1
-
正常:级别 2
-
主要:级别 3
-
关键:级别 4
级别 1 被认为是低的,级别 4 被认为是高的,如下所示
@SuppressWarnings ("unchecked")。在某些地方,我们可能已经使用了@SuppressWarnings注解,告诉编译器不需要担心正确的转换,因为这将得到处理。
如果用户在任何与会话相关的 API 中传递了错误的JWT,我们将得到以下错误:
{
"timestamp": 1515786810739,
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.IllegalArgumentException",
"message": "JWT String argument cannot be null or empty.",
"path": "/ticket"
}
上述错误只是提到JWT字符串为空或 null。
获取我的票据-客户
一旦票据创建,客户可以通过调用/my/tickets API 来查看他们的票据。以下方法将处理获取票据的要求:
@ResponseBody
@RequestMapping("/my/tickets")
public Map<String, Object> getMyTickets(
HttpServletRequest request
) {
User user = userSevice.getUserByToken(request.getHeader("token"));
if(user == null){
return Util.getUserNotAvailableError();
}
return Util.getSuccessResult(ticketSevice.getMyTickets(user.getUserid()));
}
在前面的代码中,我们通过令牌验证了用户会话,并获得了会话中用户的票务:

允许用户查看他们的单张票
与查看所有客户票务一样,客户也可以通过调用/{ticketid}API 查看他们自己的每张票的详细信息。让我们看看这个方法是如何工作的:
@ResponseBody
@TokenRequired
@RequestMapping("/{ticketid}")
public <T> T getTicket(
@PathVariable("ticketid") final Integer ticketid,
HttpServletRequest request
) {
return (T) Util.getSuccessResult(ticketSevice.getTicket(ticketid));
}
在前面的 API 中,在验证会话后,我们使用TicketServiceImpl中的getTicket方法来获取用户票务详情。
您可以使用此截图来验证结果:

您可以清楚地看到令牌在我们的标题中使用。没有令牌,API 将抛出异常,因为它是与会话相关的交易。
允许客户更新票务
假设客户想要出于某种原因更新他们自己的票务,例如添加额外信息。我们将为客户提供更新票务的选项。
更新票务-服务(TicketServiceImpl)
对于更新选项,我们将在我们的TicketServiceImpl类中添加updateTicket方法:
@Override
public void updateTicket(Integer ticketid, String content, Integer severity, Integer status) {
Ticket ticket = getTicket(ticketid);
if(ticket == null){
throw new RuntimeException("Ticket Not Available");
}
ticket.setContent(content);
ticket.setSeverity(severity);
ticket.setStatus(status);
}
在前面的方法中,我们通过getTicket方法检索了票务,然后更新了必要的信息,如content,severity和status。
现在我们可以在我们的 API 中使用updateTicket方法,这里提到了:
@ResponseBody
@RequestMapping(value = "/{ticketid}", method = RequestMethod.PUT)
public <T> T updateTicketByCustomer (
@PathVariable("ticketid") final Integer ticketid,
@RequestParam(value="content") String content,
HttpServletRequest request,
HttpServletResponse response
) {
User user = userSevice.getUserByToken(request.getHeader("token"));
if(user == null){
return getUserNotAvailableError();
}
ticketSevice.updateTicket(ticketid, content, 2, 1);
Map<String, String> result = new LinkedHashMap<>();
result.put("result", "updated");
return (T) result;
}
在前面的代码中,在验证会话后,我们调用了updateTicket并传递了新内容。此外,在成功完成后,我们向呼叫者发送了适当的响应。

对于更新选项,我们使用了PUT方法,因为这是用于更新目的的适当 HTTP 方法。但是,我们也可以使用POST方法进行此类操作,因为没有限制。
删除票务
到目前为止,我们已经涵盖了票务的创建、读取和更新操作。在本节中,我们将讨论客户的删除选项。
删除服务-服务(TicketServiceImpl)
我们将在我们的TicketServiceImpl类中添加deleteMyTicket方法,假设我们已经在我们的接口中添加了抽象方法:
@Override
public void deleteMyTicket(Integer userid, Integer ticketid) {
tickets.removeIf(x -> x.getTicketid().intValue() == ticketid.intValue() && x.getCreatorid().intValue() == userid.intValue());
}
在前面的代码中,我们使用了removeIf Java Streams 选项来查找并从流中删除项目。如果匹配了 userid 和 ticket,该项目将自动从流中删除。
删除我的票务-API(票务控制器)
我们可以调用我们在 API 中早期创建的deleteMyTicket方法:
@ResponseBody
@UserTokenRequired
@RequestMapping(value = "/{ticketid}", method = RequestMethod.DELETE)
public <T> T deleteTicketByUser (
@RequestParam("ticketid") final Integer ticketid,
HttpServletRequest request
) {
User user = userSevice.getUserByToken(request.getHeader("token"));
ticketSevice.deleteMyTicket(user.getUserid(), ticketid);
return Util.getSuccessResult();
}
像往常一样,我们将检查会话并在我们的TicketServiceImpl类中调用deleteTicketByUser方法。一旦删除选项完成,我们将简单地返回一个说“成功”的地图作为结果。
在删除票务后,这是 SoapUI 的响应:

在我们的票务 CRUD 中,当它为空时,我们没有选项来抛出异常。如果您删除了所有现有的票务并调用获取票务,您将获得一个带有空值的成功消息。您可以通过添加空检查和限制来改进应用程序。
管理员票务管理
在前一节中,我们看到了客户的票务管理。客户只对他们自己的票务有控制权,不能对其他客户的票务做任何事情。在管理员模式下,我们可以控制应用程序中的任何可用票务。在本节中,我们将看到管理员执行的票务管理。
允许管理员查看所有票务
由于管理员可以完全控制查看应用程序中的所有票务,因此我们在TicketServiceImpl类中保持查看票务方法非常简单,没有任何限制。
获取所有票务-服务(TicketServiceImpl)
在这里,我们将讨论管理员实现部分,以获取应用程序中的所有票务:
@Override
public List<Ticket> getAllTickets() {
return tickets;
}
在前面的代码中,我们没有任何特定的限制,只是从我们的票务列表中返回所有票务。
获取所有票务-API(票务控制器)
在票务控制器 API 中,我们将添加一个方法来获取管理员的所有票务:
@ResponseBody
@AdminTokenRequired
@RequestMapping("/by/admin")
public <T> T getAllTickets(
HttpServletRequest request,
HttpServletResponse response) {
return (T) ticketSevice.getAllTickets();
}
前面的 API/by/admin将在管理员需要查看所有票务时调用。我们在TicketServiceImpl类中调用了getAllTickets方法。
我们使用了一个简单的 AOP 来验证管理员令牌,称为@AdminTokenRequired。让我们看看这个 API 的实现部分。
AdminTokenRequired 接口
AdminTokenRequired接口将是我们实现的基础,我们稍后会涵盖:
package com.packtpub.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AdminTokenRequired {
}
在前面的代码中,我们为验证管理员令牌引入了接口。验证方法将在AdminTokenRequiredAspect类中跟进。
AdminTokenRequiredAspect 类
在切面类中,我们将对管理员令牌进行验证:
package com.packtpub.aop;
import javax.servlet.http.HttpServletRequest;
import javax.xml.bind.DatatypeConverter;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.packtpub.service.SecurityServiceImpl;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
@Aspect
@Component
public class AdminTokenRequiredAspect {
@Before("@annotation(adminTokenRequired)")
public void adminTokenRequiredWithAnnotation(AdminTokenRequired adminTokenRequired) throws Throwable{
ServletRequestAttributes reqAttributes = (ServletRequestAttributes)RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = reqAttributes.getRequest();
// checks for token in request header
String tokenInHeader = request.getHeader("token");
if(StringUtils.isEmpty(tokenInHeader)){
throw new IllegalArgumentException("Empty token");
}
Claims claims = Jwts.parser() .setSigningKey(DatatypeConverter.parseBase64Binary(SecurityServiceImpl.secretKey))
.parseClaimsJws(tokenInHeader).getBody();
if(claims == null || claims.getSubject() == null){
throw new IllegalArgumentException("Token Error : Claim is null");
}
String subject = claims.getSubject();
if(subject.split("=").length != 2 || new Integer(subject.split("=")[1]) != 3){
throw new IllegalArgumentException("User is not authorized");
}
}
}
在前面的代码中,我们在AdminTokenRequiredAspect类中提供了令牌验证技术。这个切面组件将在方法执行之前执行。此外,在这个方法中,我们检查了令牌是否为空和 null,以及令牌的用户类型。
检查管理员查看票务的 SoapUI 响应:

如果我们使用错误的令牌或空令牌,我们将得到这样的响应:
{
"timestamp": 1515803861286,
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.RuntimeException",
"message": "User is not authorized",
"path": "/ticket/by/admin"
}
通过保持 AOP 注解,我们可以在每个方法上有几行代码,因为注解会处理业务逻辑。
管理员更新票务
一旦票务创建完成,管理员就可以查看。与客户不同,管理员有更多的控制权,可以更新票务的状态和严重性,以及其内容。
通过管理员更新票务 - 服务(TicketServiceImpl)
在这里,我们将实现管理员更新票务的方法:
@ResponseBody
@RequestMapping(value = "/by/admin", method = RequestMethod.PUT)
public <T> T updateTicketByAdmin (
@RequestParam("ticketid") final Integer ticketid,
@RequestParam(value="content") String content,
@RequestParam(value="severity") Integer severity,
@RequestParam(value="status") Integer status,
HttpServletRequest request,
HttpServletResponse response
) {
User user = userSevice.getUserByToken(request.getHeader("token"));
if(user == null){
return getUserNotAvailableError();
}
ticketSevice.updateTicket(ticketid, content, severity, status);
Map<String, String> result = new LinkedHashMap<>();
result.put("result", "updated");
return (T) result;
}
在前面的代码中,我们在 API 中使用了/by/admin路径来区分这个 API 和客户的更新方法。此外,我们从请求中获取了严重性和状态参数。一旦管理员通过令牌验证,我们将调用updateTicket方法。如果你看到这个updateTicket方法,我们没有硬编码任何内容。
一旦更新过程完成,我们将返回结果"success"作为响应,你可以在截图中检查到:

在实际应用中,管理员可能无法控制客户的内容,比如问题。然而,我们为管理员提供了编辑内容的选项,以使我们的业务逻辑更加简单。
允许管理员查看单个票务
由于管理员对票务有完全的控制权,他们也可以查看用户创建的任何单个票务。由于我们已经定义了getTicketAPI/{ticketid},我们也可以将同样的 API 用于管理员的查看目的。
允许管理员删除票务
由于管理员有更多的控制权,我们为管理员提供了无限的多删除选项,以便在应用程序中一次性删除一大堆票务时非常方便。
删除票务 - 服务(TicketServiceImpl):
在下面的代码中,我们将讨论管理员的多票删除选项:
@Override
public void deleteTickets(User user, String ticketids) {
List<String> ticketObjList = Arrays.asList(ticketids.split(","));
List<Integer> intList =
ticketObjList.stream()
.map(Integer::valueOf)
.collect(Collectors.toList());
tickets.removeIf(x -> intList.contains(x.getTicketid()));
}
在前面的代码中,我们赋予管理员删除多个票务的权力。由于管理员有完全的控制权,我们在这里没有应用特定的过滤器。我们使用 Java Streams 将票务作为列表获取,然后将它们与票务 ID 匹配以从票务列表中删除。
通过管理员删除票务 - API(票务控制器):
以下方法将把ticketids转发到相应的TicketServiceImpl方法:
@ResponseBody
@AdminTokenRequired
@RequestMapping(value = "/by/admin", method = RequestMethod.DELETE)
public <T> T deleteTicketsByAdmin (
@RequestParam("ticketids") final String ticketids,
HttpServletRequest request
) {
User user = userSevice.getUserByToken(request.getHeader("token"));
ticketSevice.deleteTickets(user, ticketids);
return Util.getSuccessResult();
}
在前面的代码中,我们首先通过@AdminTokenRequired检查会话,然后在会话验证通过后删除票务。
我们可以通过这个 SoapUI 截图检查 API 的结果:

在多票删除选项中,我们使用逗号分隔的值来发送多个票务 ID。也可以使用单个ticketid来调用这个 API。
CSR 票务管理
最后,我们将在本节讨论 CSR 工单管理。CSR 可能没有像管理员那样的控制权;然而,在大多数情况下,他们在工单管理应用程序中有与管理员匹配的选项。在接下来的部分中,我们将讨论 CSR 在工单上的所有授权 CRUD 操作。
CSR 更新工单
在本节中,我们将讨论 CSR 通过工单管理更新工单的新内容、严重程度和状态:
@ResponseBody
@CSRTokenRequired
@RequestMapping(value = "/by/csr", method = RequestMethod.PUT)
public <T> T updateTicketByCSR (
@RequestParam("ticketid") final Integer ticketid,
@RequestParam(value="content") String content,
@RequestParam(value="severity") Integer severity,
@RequestParam(value="status") Integer status,
HttpServletRequest request
) {
ticketSevice.updateTicket(ticketid, content, severity, status);
return Util.getSuccessResult();
}
在上述代码中,我们获取了所有必要的信息,如内容、严重程度和状态,并将这些信息提供给updateTicket方法。
我们使用了一个简单的 AOP 来验证名为@CSRTokenRequired的管理员令牌。让我们来看看这个 API 的实现部分。
CSRTokenRequired AOP
AdminTokenRequired接口将是我们稍后将要实现的基础:
package com.packtpub.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CSRTokenRequired {
}
在上述代码中,我们引入了验证管理员令牌的注解。验证方法将在CSRTokenRequiredAspect类中跟进。
CSRTokenRequiredAspect
在CSRTokenRequiredAspect类中,我们将对管理员令牌进行验证:
package com.packtpub.aop;
import javax.servlet.http.HttpServletRequest;
import javax.xml.bind.DatatypeConverter;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.packtpub.service.SecurityServiceImpl;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
@Aspect
@Component
public class CSRTokenRequiredAspect {
@Before("@annotation(csrTokenRequired)")
public void adminTokenRequiredWithAnnotation(CSRTokenRequired csrTokenRequired) throws Throwable{
ServletRequestAttributes reqAttributes = (ServletRequestAttributes)RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = reqAttributes.getRequest();
// checks for token in request header
String tokenInHeader = request.getHeader("token");
if(StringUtils.isEmpty(tokenInHeader)){
throw new IllegalArgumentException("Empty token");
}
Claims claims = Jwts.parser() .setSigningKey(DatatypeConverter.parseBase64Binary(SecurityServiceImpl.secretKey))
.parseClaimsJws(tokenInHeader).getBody();
if(claims == null || claims.getSubject() == null){
throw new IllegalArgumentException("Token Error : Claim is null");
}
String subject = claims.getSubject();
if(subject.split("=").length != 2 || new Integer(subject.split("=")[1]) != 2){
throw new IllegalArgumentException("User is not authorized");
}
}
}
在上述代码中,我们在CSRTokenRequiredAspect类中提供了令牌验证技术。这个方面组件将在方法执行之前执行。此外,在这个方法中,我们检查令牌是否为空和 null,以及令牌的用户类型。
这是我们/ticket/{ticketid}更新 API 的截图:

CSR 查看所有工单
在查看所有工单方面,CSR 与管理员拥有相同的权限,因此我们不需要更改服务实现。但是,我们可能需要验证令牌以确保用户是 CSR。
通过 CSR 查看所有工单 - API(工单控制器)
当任何 CSR 调用时,以下内容将获取 CSR 的所有工单:
@ResponseBody
@CSRTokenRequired
@RequestMapping("/by/csr")
public <T> T getAllTicketsByCSR(HttpServletRequest request) {
return (T) ticketSevice.getAllTickets();
}
在上述 API 中,我们只使用了@CSRTokenRequired来验证用户。除了 API 路径和注解之外,其他都与管理员查看所有工单相同。
当我们检查 SoapUI 的截图时,我们可以清楚地看到客户创建的两张工单。

CSR 查看单个工单
除了多删除选项外,CSR 与管理员拥有相同的权限,我们可以在这里使用相同的/{ticketid},用于 CSR 和管理员查看单个工单 API。
CSR 删除工单
通过 CSR 删除工单几乎就像在管理员模式下删除工单一样。然而,我们的业务要求规定 CSR 一次不能删除超过三张工单。我们将在现有方法中添加具体逻辑。
删除工单 - 服务(TicketServivceImpl)
以下是 CSR 删除多张工单的服务实现:
@Override
public void deleteTickets(User user, String ticketids) {
List<String> ticketObjList = Arrays.asList(ticketids.split(","));
if(user.getUsertype() == 2 && ticketObjList.size() > 3){
throw new RuntimeException("CSR can't delete more than 3 tickets");
}
List<Integer> intList =
ticketObjList.stream()
.map(Integer::valueOf)
.collect(Collectors.toList())
;
tickets.removeIf(x -> intList.contains(x.getTicketid()));
}
对于删除多张工单,我们在TicketServiceImpl类中使用了现有的代码。然而,根据我们的业务要求,我们的 CSR 不能删除超过三张工单,因此我们添加了额外的逻辑来检查工单数量。如果工单列表大小超过三,我们会抛出异常,否则我们将删除这些工单。
通过 CSR 删除工单 - API(工单控制器)
在 API 中,我们将简单地调用我们之前实现的deleteTickets方法:
@ResponseBody
@CSRTokenRequired
@RequestMapping(value = "/by/csr", method = RequestMethod.DELETE)
public <T> T deleteTicketsByCSR (
@RequestParam("ticketids") final String ticketids,
HttpServletRequest request,
HttpServletResponse response
) {
User user = userSevice.getUserByToken(request.getHeader("token"));
ticketSevice.deleteTickets(user.getUserid(), ticketids);
Map<String, String> result = new LinkedHashMap<>();
result.put("result", "deleted");
return (T) result;
}
除了删除选项上的最大工单限制外,CSR 删除工单不需要进行太大的更改。但是,我们已经在我们的 API 中添加了@CSRTokenRequired注解。
这是 CSR 删除多张工单的 SoapUI 截图:

Postman 工具可能存在与DELETE选项相关的问题,包括参数(截至版本 5.4.0),当您在管理员和 CSR 中使用多删除 API 时,可能无法获得预期的结果。对于这种情况,请使用 SoapUI 客户端。
摘要
在这最后一章中,我们通过满足本章第一节中提到的所有业务需求,实现了一个小型的票务管理系统。这个实现涵盖了顾客、客服代表和管理员的票务 CRUD 操作。此外,我们的实现满足了业务需求,比如为什么客服代表不能一次删除超过三张票。


浙公网安备 33010602011771号