Spring-REST-教程-全-
Spring REST 教程(全)
原文:Spring REST
一、REST 简介
在本章中,我们将学习以下内容:
-
休止符基础
-
REST 资源及其表示
-
HTTP 方法和状态代码
-
理查森的成熟度模型
今天,网络已经成为我们生活中不可或缺的一部分——从在脸书上查看状态到在线订购产品,再到通过电子邮件交流。Web 的成功和无处不在导致组织将 Web 的架构原则应用于构建分布式应用。在这一章中,我们将深入 REST,一种形式化这些原则的架构风格。
什么是休息?
REST 代表表述性状态转移,是一种用于设计分布式网络应用的架构风格。罗伊·菲尔丁在他的博士论文 1 中创造了术语 REST,并提出以下六个约束或原则作为其基础:
-
客户端-服务器——客户端和服务器之间的关注点应该分开。这使得客户端和服务器组件能够独立发展,从而允许系统伸缩。
-
无状态—客户端和服务器之间的通信应该是无状态的。服务器不需要记住客户端的状态。相反,客户端必须在请求中包含所有必要的信息,以便服务器能够理解和处理它。
-
分层系统—客户端和服务器之间可以存在多个分层结构,如网关、防火墙和代理。可以透明地添加、修改、重新排序或删除层,以提高可伸缩性。
-
缓存—来自服务器的响应必须声明为可缓存或不可缓存。这将允许客户机或其中间组件缓存响应,并在以后的请求中重用它们。这降低了服务器的负载,有助于提高性能。
-
统一接口—客户端、服务器和中介组件之间的所有交互都基于其接口的统一性。这简化了整体架构,因为只要组件实现了商定的契约,它们就可以独立发展。统一接口约束进一步分解为四个子约束:资源标识、资源表示、自描述消息和作为应用状态引擎的超媒体,或 HATEOAS。我们将在本章的后面几节研究其中的一些指导原则。
-
按需代码—客户端可以通过按需下载和执行代码来扩展其功能。例子包括 JavaScript 脚本、Java 小程序、Silverlight 等等。这是一个可选约束。
遵守这些约束的应用被认为是 RESTful 的。正如您可能已经注意到的,这些约束并不决定用于开发应用的实际技术。相反,遵循这些指导方针和最佳实践将使应用可伸缩、可见、可移植、可靠,并且能够更好地执行。理论上,可以使用任何网络基础设施或传输协议构建 RESTful 应用。实际上,RESTful 应用利用了 Web 的特性和功能,并使用 HTTP 作为传输协议。
统一接口约束是 REST 应用区别于其他基于网络的应用的关键特性。REST 应用中的统一接口是通过资源、表示、URIs 和 HTTP 方法等抽象来实现的。在接下来的部分中,我们将研究这些重要的 REST 抽象。
了解资源
REST 中信息的关键抽象是资源。
—罗伊·菲尔丁
REST 的基础是资源的概念。资源是任何可以被访问或操作的东西。资源的例子包括“视频”、“博客条目”、“用户配置文件”、“图像”,甚至是有形的东西,如人或设备。资源通常与其他资源相关。例如,在电子商务应用中,客户可以订购任意数量的产品。在这个场景中,产品资源与相应的订单资源相关。也可以将资源分组到集合中。使用相同的电子商务示例,“订单”是单个“订单”资源的集合。
识别资源
在我们能够交互和使用资源之前,我们必须能够识别它。Web 提供了统一资源标识符或 URI,用于唯一地标识资源。URI 的语法是
scheme:scheme-specific-part
使用分号分隔scheme和scheme-specific-part。方案的例子包括http或ftp或mailto,并且用于定义 URI 其余部分的语义和解释。拿 URI 的例子来说——http://www.apress.com/9781484208427。示例的http部分是方案;它表示应该使用 HTTP 方案来解释 URI 的其余部分。HTTP 方案被定义为 RFC 7230 的一部分, 2 表示由我们的示例 URI 标识的资源位于主机名为apress.com的机器上。
表 1-1 显示了 URIs 的例子以及它们所代表的不同资源。
表 1-1
URI 和资源描述
|上呼吸道感染
|
资源描述
|
| --- | --- |
| http://blog.example.com/posts | 表示博客文章资源的集合。 |
| http://blog.example.com/posts/1 | 表示标识符为“1”的博客文章资源;这种资源被称为单体资源。 |
| http://blog.example.com/posts/1/comments | 表示与由“1”标识的博客条目相关联的评论集合;像这样驻留在资源下的集合称为子集合。 |
| http://blog.example.com/posts/1/comments/245 | 表示由“245”标识的注释资源 |
即使一个 URI 唯一地标识一个资源,一个资源也可能有多个 URI。例如,可以使用 URIs https://www.facebook.com 和 https://www.fb.com 访问脸书。术语 URI 别名用于指代标识相同资源的这种 URIs。URI 别名提供了灵活性和额外的便利,例如必须键入更少的字符才能找到资源。
URI 模板
当使用 REST 和 REST API 时,有时您需要表示 URI 的结构,而不是 URI 本身。例如,在一个博客应用中,URI http://blog.example.com/2014/posts将检索 2014 年创建的所有博客帖子。类似地,URIs http://blog.example.com/2013/posts、http://blog.example.com/2012/posts、,等将返回对应于 2013 年、2012 年等年份的博客帖子。在这种情况下,对于消费客户来说,知道描述 URIs 范围而不是单个 URIs 的 URI 结构http://blog.example.com/ 年 /posts会很方便。
RFC 6570 ( http://tools.ietf.org/html/rfc6570)中定义的 URI 模板提供了描述 URI 结构的标准化机制。这种情况下的标准化 URI 模板可以是
http://blog.example.com/{year}/posts
花括号{}表示模板的年份部分是一个变量,通常称为路径变量。消费客户端可以将这个 URI 模板作为输入,用正确的值替换 year 变量,并检索相应年份的博客文章。在服务器端,URL 模板允许服务器代码轻松地解析和检索变量值或 URI 的选定部分。
表示
RESTful 资源是抽象的实体。构成 RESTful 资源的数据和元数据需要在发送到客户机之前序列化成一个表示。这种表示可以被视为在给定时间点资源状态的快照。考虑一个电子商务应用中的数据库表,它存储所有可用产品的信息。当一个在线购物者使用他们的浏览器购买一个产品并请求它的详细信息时,应用会以 HTML 网页的形式提供产品的详细信息。现在,当编写原生移动应用的开发人员请求产品细节时,电子商务应用可能会以 XML 或 JSON 格式返回这些细节。在这两个场景中,客户端都没有与实际的资源(保存产品详细信息的数据库记录)进行交互。相反,他们处理其代表性。
Note
REST 组件通过来回传输资源的表示来与资源交互。他们从不直接与资源互动。
正如这个产品示例中所提到的,同一个资源可以有多个表示。这些表示形式从基于文本的 HTML、XML 和 JSON 格式到 pdf、JPEGs 和 MP4s 等二进制格式。客户端可以请求特定的表示,这个过程被称为内容协商。以下是两种可能的内容协商策略:
-
用期望的表示对 URI 进行后置处理——在这个策略中,请求 JSON 格式的产品细节的客户将使用 URI
http://www.example.com/products/143.json。不同的客户端可能使用 URIhttp://www.example.com/products/143.xml来获取 XML 格式的产品细节。 -
使用
Accept头——客户端可以用所需的表示填充 HTTPAccept头,并随请求一起发送。处理资源的应用将使用Accept头值来序列化请求的表示。RFC 2616 3 提供了一组详细的规则,用于指定一种或多种格式及其优先级。
Note
JSON 已经成为 REST 服务事实上的标准。本书中的所有例子都使用 JSON 作为请求和响应的数据格式。
HTTP 方法
“统一接口”约束通过一些标准化操作或动词来限制客户机和服务器之间的交互。在 Web 上,HTTP 标准 4 提供了八种 HTTP 方法,允许客户端交互和操纵资源。一些常用的方法有 GET、POST、PUT 和 DELETE。在我们深入研究 HTTP 方法之前,让我们回顾一下它们的两个重要特征——安全性和幂等性。
Note
HTTP 规范使用术语“方法”来表示 HTTP 操作,如 GET、PUT 和 POST。然而,术语 HTTP 动词也可以互换使用。
安全
如果 HTTP 方法不会对服务器状态造成任何改变,那么它就是安全的。考虑 GET 或 HEAD 之类的方法,它们用于从服务器检索信息/资源。这些请求通常被实现为只读操作,不会对服务器的状态造成任何改变,因此被认为是安全的。
安全的方法用于检索资源。然而,安全性并不意味着该方法每次都必须返回相同的值。例如,检索 Google 股票的 GET 请求可能会导致每次调用的值不同。但是只要它没有改变任何状态,它仍然被认为是安全的。
在现实世界的实现中,安全操作仍然可能有副作用。考虑这样一个实现,其中每个股票价格请求都记录在一个数据库中。从纯粹主义者的角度来看,我们正在改变整个系统的状态。然而,从实际的角度来看,因为这些副作用是服务器实现的唯一责任,所以该操作仍然被认为是安全的。
幂等性
如果一个操作产生相同的服务器状态,无论我们应用它一次还是多次,它都被认为是幂等的。诸如 GET、HEAD(也是安全的)、PUT 和 DELETE 之类的 HTTP 方法被认为是等幂的,这保证了客户端可以重复请求,并期望得到与发出一次请求相同的效果。第二个和随后的请求使资源状态保持与第一个请求完全相同的状态。
考虑您在电子商务应用中删除订单的场景。请求成功完成后,订单不再存在于服务器上。因此,将来任何删除该订单的请求仍然会导致相同的服务器状态。相比之下,考虑使用 POST 请求创建订单的场景。成功完成请求后,会创建一个新订单。如果您要重新“发布”相同的请求,服务器只需接受该请求并创建一个新订单。因为重复的 POST 请求会导致不可预见的副作用,所以 POST 不被认为是等幂的。
得到
GET 方法用于检索资源的表示形式。例如,URI http://blog.example.com/posts/1上的 GET 返回由 1 标识的博客文章的表示。相比之下,URI 上的 GEThttp://blog.example.com/posts检索一组博客文章。因为 GET 请求不修改服务器状态,所以它们被认为是安全的和等幂的。
这里显示了对http://blog.example.com/posts/1的一个假设的 GET 请求和相应的响应。
GET /posts/1 HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.5
Connection: keep-alive
Host: blog.example.com
Content-Type: text/html; charset=UTF-8
Date: Sat, 10 Jan 2015 20:16:58 GMT
Server: Apache
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns:="http://www.w3.org/1999/xhtml">
<head>
<title>First Post</title>
</head>
<body>
<h3>Hello World!!</h3>
</body>
</html>
除了表示之外,对 GET 请求的响应还包括与资源相关联的元数据。这种元数据被表示为一系列称为 HTTP 头的键值对。Content-Type和Server是您在这个响应中看到的头的例子。因为 GET 方法是安全的,所以可以缓存对 GET 请求的响应。
GET 方法的简单性经常被滥用,它被用来执行诸如删除或更新资源表示的操作。这种用法违反了标准的 HTTP 语义,是非常不鼓励的。
头
有时,客户端希望检查特定的资源是否存在,并不真正关心实际的表示。在另一个场景中,客户端希望在下载之前知道是否有更新版本的资源可用。在这两种情况下,就带宽和资源而言,GET 请求可能是“重量级”的。取而代之的是头的方法更合适。
HEAD 方法允许客户端仅检索与资源相关联的元数据。没有资源表示被发送到客户端。表示为 HTTP 头的元数据将与响应 GET 请求而发送的信息相同。客户端使用这些元数据来确定资源的可访问性和最近的修改。下面是一个假设的 HEAD 请求和响应。
HEAD /posts/1 HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.5
Connection: keep-alive
Host: blog.example.com
Connection: Keep-Alive
Content-Type: text/html; charset=UTF-8
Date: Sat, 10 Jan 2015 20:16:58 GMT
Server: Apache
像 GET 一样,HEAD 方法也是安全和幂等的,响应可以缓存在客户机上。
删除
DELETE 方法,顾名思义,请求一个资源被删除。收到请求后,服务器删除资源。对于可能需要很长时间才能删除的资源,服务器通常会发送一条确认消息,表明它已收到请求并将处理它。根据服务实现,资源可能会也可能不会被物理删除。
成功删除后,未来对该资源的 GET 请求将通过 HTTP 状态代码 404 产生“Resource Not Found”错误。我们将在一分钟内介绍状态代码。
在这个例子中,客户端请求删除由 1 标识的帖子。完成后,服务器可以返回状态代码 200 (OK)或 204 (No Content ),表明请求已成功处理。
Delete /posts/1 HTTP/1.1
Content-Length: 0
Content-Type: application/json
Host: blog.example.com
类似地,在这个例子中,与帖子#2 相关联的所有评论都被删除。
Delete /posts/2/comments HTTP/1.1
Content-Length: 0
Content-Type: application/json
Host: blog.example.com
因为 DELETE 方法修改系统的状态,所以它被认为是不安全的。但是,DELETE 方法被认为是幂等的;后续的删除请求仍然会使资源和系统处于相同的状态。
放
PUT 方法允许客户端修改资源状态。客户端修改资源的状态,并使用 PUT 方法将更新后的表示发送给服务器。收到请求后,服务器用新状态替换资源的状态。
在这个例子中,我们发送一个 PUT 请求来更新由 1 标识的帖子。该请求包含更新后的博文正文以及组成博文的所有其他字段。成功处理后,服务器将返回一个状态代码 200,表明请求已被成功处理。
PUT /posts/1 HTTP/1.1
Accept: */*
Content-Type: application/json
Content-Length: 65
Host: blog.example.com
BODY
{"title": "First Post","body": "Updated Hello World!!"}
考虑我们只想更新博客文章标题的情况。HTTP 语义规定,作为 PUT 请求的一部分,我们发送完整的资源表示,其中包括更新后的标题以及其他属性,如 blog post body 等,它们没有发生变化。然而,这种方法要求客户端具有完整的资源表示,如果资源非常大或者有很多关系,这可能是不可能的。此外,这将需要更高的数据传输带宽。因此,出于实际原因,设计倾向于接受部分表示作为 PUT 请求的一部分的 API 是可以接受的。
Note
为了支持部分更新,RFC 5789 ( http://www.ietf.org/rfc/rfc5789.txt )中增加了一个名为 PATCH 的新方法。我们将在本章的后面讨论补丁方法。
客户端也可以使用 PUT 方法创建新的资源。然而,只有当客户端知道新资源的 URI 时,这才是可能的。例如,在博客应用中,客户端可以上传与博客帖子相关联的图像。在这种情况下,客户端决定图像的 URL,如下例所示:
PUT http://blog.example.com/postsimg/author.jpg
PUT 不是一个安全的操作,因为它会改变系统状态。但是,它被认为是等幂的,因为将同一资源放置一次或多次会产生相同的结果。
邮政
POST 方法用于创建资源。通常,它用于在子集合(存在于父资源下的资源集合)下创建资源。例如,POST 方法可用于在博客应用中创建新的博客条目。这里,“posts”是位于博客父资源下的博客帖子资源的子集合。
POST /posts HTTP/1.1
Accept: */*
Content-Type: application/json
Content-Length: 63
Host: blog.example.com
BODY
{"title": "Second Post","body": "Another Blog Post."}
Content-Type: application/json
Location: posts/12345
Server: Apache
与 PUT 不同,POST 请求不需要知道资源的 URI。服务器负责为资源分配一个 ID,并决定资源将要驻留的 URI。在前面的例子中,博客应用将处理 POST 请求并在http://blog.example.com/posts/12345下创建一个新资源,其中“12345”是服务器生成的 id。响应中的Location头包含新创建的资源的 URL。
POST 方法非常灵活,通常在没有其他合适的 HTTP 方法时使用。考虑您想要为 JPEG 或 PNG 图像生成缩略图的场景。这里我们要求服务器对我们提交的图像二进制数据执行一个操作。像 GET 和 PUT 这样的 HTTP 方法并不适合这里,因为我们正在处理一个 RPC 风格的操作。这种情况使用 POST 方法处理。
Note
术语“控制器资源”被用来描述接受输入、执行某些动作并返回输出的可执行资源。尽管这些类型的资源不符合真正的 REST 资源定义,但是它们非常方便地公开复杂的操作。
POST 方法被认为是不安全的,因为它会改变系统状态。此外,多次 POST 调用会导致生成多个资源,这使它变得不可靠。
修补
正如我们前面讨论的,HTTP 规范要求客户机将整个资源表示作为 PUT 请求的一部分发送。作为 RFC 5789 ( http://tools.ietf.org/html/rfc5789 )的一部分提出的补丁方法用于执行部分资源更新。它既不安全也不幂等。下面是一个使用补丁方法更新博客文章标题的例子。
PATCH /posts/1 HTTP/1.1
Accept: */*
Content-Type: application/json
Content-Length: 59
Host: blog.example.com
BODY
{"replace": "title","value": "New Awesome title"}
请求正文包含对需要在资源上执行的更改的描述。在示例中,请求主体使用"replace"命令来指示需要替换"title"字段的值。
作为补丁请求的一部分,没有标准化的格式来描述对服务器的更改。不同的实现可能使用以下格式来描述相同的更改:
{"change" : "name", "from" : "Post Title", "to" : "New Awesome Title"}
目前,正在为 JSON 定义补丁格式( http://tools.ietf.org/html/draft-ietf-appsawg-json-patch )。缺乏标准导致实现以更简单的格式描述变更集,如下所示:
{"name" : "New Awesome Title"}
Crud and HTTP Verbs
数据驱动的应用通常使用术语 CRUD 来表示四种基本的持久性功能——创建、读取、更新和删除。一些构建 REST 应用的开发人员错误地将四个流行的 HTTP 动词 GET、POST、PUT 和 DELETE 与 CRUD 语义相关联。常见的典型联想是
Create -> POST
Update -> PUT
Read -> GET
Delete -> DELETE
这些相关性适用于读取和删除操作。但是,对于创建/更新和发布/上传来说,就不那么简单了。正如您在本章前面所看到的,只要满足幂等性约束,PUT 就可以用来创建资源。同理,如果 POST 用于更新( http://roy.gbiv.com/untangled/2009/it-is-okay-to-use-post ),也不会被认为是非 RESTful 的。客户端也可以使用补丁来更新资源。
因此,对于 API 设计者来说,为给定的操作使用正确的动词比简单地使用与 CRUD 的一对一映射更重要。
HTTP 状态代码
HTTP 状态代码允许服务器传达处理客户端请求的结果。这些状态代码分为以下几类:
-
信息代码—指示服务器已收到请求但尚未完成处理的状态代码。这些中间响应代码属于 100 系列。
-
成功代码—指示请求已被成功接收和处理的状态代码。这些代码属于 200 系列。
-
重定向代码—状态代码,表示请求已被处理,但客户端必须执行额外的操作才能完成请求。这些操作通常涉及重定向到不同的位置以获取资源。这些代码属于 300 系列。
-
客户端错误代码—表示客户端请求存在错误或问题的状态代码。这些代码属于 400 系列。
-
服务器错误代码—表示服务器在处理客户端请求时出错的状态代码。这些代码属于 500 系列。
HTTP 状态代码在 REST API 设计中起着重要的作用,因为有意义的代码有助于传达正确的状态,使客户端能够做出适当的反应。表 1-2 显示了一些您通常会遇到的重要状态代码。
表 1-2
HTTP 状态代码及其描述
|状态代码
|
描述
|
| --- | --- |
| 100(续) | 表示服务器已经收到请求的第一部分,请求的其余部分应该发送出去。 |
| 200(好的) | 表明请求一切顺利。 |
| 201(已创建) | 表示请求已完成,新资源已创建。 |
| 202(已接受) | 表示请求已被接受,但仍在处理中。 |
| 204(无内容) | 指示服务器已完成请求,并且没有要发送到客户端的实体主体。 |
| 301(永久移动) | 指示请求的资源已移动到新位置,需要使用新的 URI 来访问该资源。 |
| 400(错误请求) | 表示请求格式不正确,服务器无法理解该请求。 |
| 401(未经授权) | 指示客户端需要在访问资源之前进行身份验证。如果请求已经包含客户端的凭证,则 401 指示无效的凭证(例如,错误的密码)。 |
| 403(禁止) | 表示服务器理解请求,但拒绝执行请求。这可能是因为正在从黑名单中的 IP 地址或在批准的时间窗口之外访问资源。 |
| 404(未找到) | 指示请求的 URI 处的资源不存在。 |
| 406(不可接受) | 指示服务器能够处理该请求;但是,客户端可能不接受生成的响应。当客户端对 accept 头过于挑剔时,就会发生这种情况。 |
| 500(内部服务器错误) | 表示处理请求时服务器出错,请求无法完成。 |
| 503(服务不可用) | 表示请求无法完成,因为服务器过载或正在进行定期维护。 |
理查森的成熟度模型
由 Leonard Richardson 开发的 Richardson 成熟度模型(RMM)根据基于 REST 的 web 服务遵守 REST 原则的程度对它们进行分类。图 1-1 显示了这种分类的四个级别。

图 1-1
RMM 级别
RMM 对于理解不同风格的 web 服务以及它们的设计、好处和权衡是有价值的。
零级
这是服务最基本的成熟度级别。此级别的服务使用 HTTP 作为传输机制,并在单个 URI 上执行远程过程调用。通常,POST 或 GET HTTP 方法用于服务调用。基于 SOAP 和 XML-RPC 的 web 服务属于这一级。
一级
下一个级别更加严格地遵循 REST 原则,并引入了多个 URIs,每个资源一个。大型服务端点的复杂功能被分解成多个资源。然而,这一层中的服务使用一个 HTTP 动词(通常是 POST)来执行所有的操作。
第二层
这一级别的服务利用 HTTP 协议,并正确使用协议中可用的 HTTP 动词和状态代码。实现 CRUD 操作的 Web 服务是二级服务的好例子。
第三层
这是服务最成熟的层次,围绕超媒体作为应用状态引擎(HATEOAS)的概念构建。这个级别的服务通过提供包含其他相关资源和控件的链接的响应,告诉客户端下一步做什么,从而允许发现。
构建 RESTful API
设计和实现一个漂亮的 RESTful API 不亚于一门艺术,它需要时间、精力和多次迭代。一个设计良好的 RESTful API 允许您的最终用户轻松地使用该 API,并使其更容易被采用。概括地说,下面是构建 RESTful API 的步骤:
-
识别资源 REST 的核心是资源。我们开始对消费者感兴趣的不同资源进行建模。通常,这些资源可以是应用的域或实体。然而,并不总是需要一对一的映射。
-
确定端点—下一步是设计将资源映射到端点的 URIs。在第四章中,我们将探讨设计和命名端点的最佳实践。
-
识别操作—识别可用于对资源执行操作的 HTTP 方法。
-
识别响应—识别请求和响应的支持资源表示,以及要返回的正确状态代码。
在本书的其余部分,我们将探讨设计 RESTful API 并使用 Spring 技术实现它的最佳实践。
摘要
REST 已经成为当今建筑服务的事实标准。在这一章中,我们介绍了 REST 和抽象的基础知识,如资源、表示、URIs 和 HTTP 方法,它们构成了 REST 的统一接口。我们还查看了 RMM,它提供了 REST 服务的分类。
在下一章,我们将深入探究 Spring 及其简化 REST 服务开发的相关技术。
二、Spring Web MVC 入门
在本章中,我们将讨论以下内容:
-
Spring 及其特征
-
模型视图控制器模式
-
Spring Web MVC 及其组件
Java 生态系统中充满了诸如 Jersey 和 RESTEasy 之类的框架,它们允许您开发 REST 应用。Spring web MVC 就是这样一个流行的 Web 框架,它简化了 Web 和 REST 应用的开发。我们从 Spring 框架的概述开始这一章,并深入探究 Spring Web MVC 及其组件。
Note
这本书没有给出 Spring 和 Spring Web MVC 的全面概述。参考 Pro Spring 和 Pro Spring MVC 和 WebFlux (均由 Apress 出版)对这些概念的详细处理。
春季概览
Spring 框架已经成为构建基于 Java/Java EE 的企业应用的事实标准。Spring 框架最初由 Rod Johnson 在 2002 年编写,是 Pivotal Software Inc .(http://spring.io)拥有和维护的项目套件之一。除此之外,Spring 框架还提供了一个依赖注入模型 1 来减少应用开发的管道代码,支持面向方面编程(AOP)来实现横切关注点,并使其易于与其他框架和技术集成。Spring 框架由不同的模块组成,这些模块提供数据访问、工具、消息传递、测试和 web 集成等服务。不同的弹簧框架模块及其分组如图 2-1 所示。

图 2-1
Spring 框架模块
作为开发人员,您不必被迫使用 Spring 框架提供的所有东西。Spring 框架的模块化允许您根据您的应用需求挑选模块。在本书中,我们将重点介绍用于开发 REST 服务的 web 模块。此外,我们将使用一些其他的 Spring portfolio 项目,比如 Spring Data、Spring Security 和 Spring Boot。这些项目建立在 Spring 框架模块提供的基础设施之上,旨在简化数据访问、认证/授权和 Spring 应用创建。
开发基于 Spring 的应用需要彻底理解两个核心概念——依赖注入和面向方面编程。
依赖注入
Spring 框架的核心是依赖注入(DI)。顾名思义,依赖注入允许依赖被注入到需要它们的组件中。这使得这些组件不必创建或定位它们的依赖关系,从而允许它们松散耦合。
为了更好地理解 DI,考虑在在线零售店购买产品的场景。完成购买通常是使用一个组件来实现的,比如 OrderService。OrderService 本身将与一个 OrderRepository 交互,这个 order repository 将在数据库中创建订单详细信息,并与一个 NotificationComponent 交互,这个 notification component 将向客户发送订单确认。在传统实现中,OrderService 创建(通常在其构造函数中)OrderRepository 和 NotificationComponent 的实例并使用它们。尽管这种方法没有任何问题,但它会导致难以维护、难以测试和高度耦合的代码。
相反,DI 允许我们在处理依赖关系时采取不同的方法。使用 DI,您可以让 Spring 之类的外部进程创建依赖项,管理依赖项,并将这些依赖项注入到需要它们的对象中。因此,使用 DI,Spring 将创建 OrderRepository 和 NotificationComponent,然后将这些依赖项交给 OrderService。这使得 OrderService 不必处理 order repository/notification component 的创建,从而更容易测试。它允许每个组件独立发展,使得开发和维护更加容易。此外,它使不同的实现交换这些依赖关系或在不同的上下文中使用这些组件变得更加容易。
面向方面编程
面向方面编程(AOP)是一种实现横切逻辑或关注点的编程模型。日志、事务、度量和安全性是跨越(横切)应用不同部分的关注点的一些例子。这些关注点不处理业务逻辑,并且经常在应用中重复。AOP 提供了一种被称为方面的标准化机制,用于将这样的关注封装在一个单独的位置。然后这些方面被编织到其他对象中,这样横切逻辑就会自动应用到整个应用中。
Spring 通过它的 Spring AOP 模块提供了一个纯基于 Java 的 AOP 实现。Spring AOP 不需要任何特殊的编译,也不需要改变类装入器的层次结构。相反,Spring AOP 使用代理在运行时将方面编织到 Spring beans 中。图 2-2 展示了这种行为。当目标 bean 上的某个方法被调用时,代理会截获该调用。然后,它应用方面逻辑并调用目标 bean 方法。

图 2-2
Spring AOP 代理
Spring 提供了双代理实现——JDK 动态代理和 CGLIB 代理。如果目标 bean 实现了一个接口,Spring 将使用 JDK 动态代理来创建 AOP 代理。如果这个类没有实现接口,Spring 使用 CGLIB 来创建一个代理。
你可以在官方文档中了解更多关于 JDK 动态代理: https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/proxy.html
Spring Web MVC 概述
Spring web MVC 是 Spring 框架的 Web 模块的一部分,是构建基于 Web 的应用的流行技术。它基于模型-视图-控制器架构,并提供了丰富的注释和组件集。多年来,该框架不断演变;它目前提供了一组丰富的配置注释和特性,比如灵活的视图分辨率和强大的数据绑定。
模型视图控制器模式
模型视图控制器(MVC)是一种用于构建解耦 web 应用的架构模式。该模式将 UI 层分解为以下三个组件:
-
模型—模型代表数据或状态。在基于 web 的银行应用中,代表帐户、事务和报表的信息是该模型的示例。
-
视图(view )-提供模型的可视化表示。这是用户通过提供输入和查看输出进行交互的内容。在我们的银行应用中,显示账户和事务的网页就是视图的例子。
-
控制器——控制器负责处理用户操作,如按钮点击。然后,它与服务或存储库交互来准备模型,并将准备好的模型交给适当的视图。
每个组件都有特定的职责。它们之间的相互作用如图 2-3 所示。交互从控制器准备模型并选择要呈现的适当视图开始。视图使用模型中的数据进行渲染。与视图的进一步交互被发送到控制器,控制器重新开始这个过程。

图 2-3
模型视图控制器交互
Spring Web MVC 架构
Spring 的 Web MVC 实现围绕 dispatcher servlet——front controller 模式 2 的一个实现,作为处理请求的入口点。Spring Web MVC 的架构如图 2-4 所示。

图 2-4
Spring Web MVC 的架构
图 2-4 中的不同组件及其相互作用如下:
-
交互从 DispatcherServlet 接收来自客户机的请求开始。
-
DispatcherServlet 查询一个或多个 HandlerMapping,以找出可以为请求提供服务的处理程序。处理程序是对 Spring Web MVC 支持的控制器和其他基于 HTTP 的端点进行寻址的通用方式。
-
HandlerMapping 组件使用请求路径来确定正确的处理程序,并将其传递给 DispatcherServlet。HandlerMapping 还确定了在处理程序执行之前(前)和之后(后)需要执行的拦截器列表。
-
然后,DispatcherServlet 执行适当的预处理拦截器,并将控制传递给处理程序。
-
处理程序与任何需要的服务进行交互,并准备模型。
-
该处理程序还确定需要在输出中呈现的视图的名称,并将其发送给 DispatcherServlet。然后执行后进程拦截器。
-
接下来是 DispatcherServlet 将逻辑视图名传递给 ViewResolver,后者确定并传递实际的视图实现。
-
DispatcherServlet 然后将控制和模型传递给视图,视图生成响应。这个 ViewResolver 和视图抽象允许 DispatcherServlet 从特定的视图实现中分离出来。
-
DispatcherServlet 将生成的响应返回给客户端。
Spring Web MVC 组件
在上一节中,向您介绍了 Spring Web MVC 组件,如 HandlerMapping 和 ViewResolver。在这一节中,我们将更深入地了解这些以及其他的 Spring Web MVC 组件。
Note
在本书中,我们将使用 Java 配置来创建 Spring beans。与基于 XML 的配置相反,Java 配置提供了编译时安全性、灵活性和额外的功能/控制。
控制器
Spring Web MVC 中的控制器是使用原型org.springframework.stereotype.Controller声明的。Spring 中的原型指定了类或接口的角色或职责。清单 2-1 显示了一个基本的控制器。
@Controller
public class HomeController {
@GetMapping("/home.html")
public String showHomePage() {
return "home";
}
}
Listing 2-1HomeController Implementation
@Controller注释将HomeController类指定为 MVC 控制器。@GetMapping 是一个复合注释,充当@ request mapping(method = request method)的快捷方式。获取)。@GetMapping 批注将 web 请求映射到处理程序类和处理程序方法。在这种情况下,@GetMapping 指示当发出对home.html的请求时,应该执行showHomePage方法。showHomePage方法有一个很小的实现,只是返回逻辑视图名home。在这个例子中,这个控制器没有准备任何模型。
模型
Spring 提供了org.springframework.ui.Model接口,作为模型属性的持有者。清单 2-2 显示了带有可用方法的Model接口。顾名思义,addAttribute和addAttributes方法可以用来给模型对象添加属性。
public interface Model {
Model addAttribute(String attributeName, Object attributeValue);
Model addAttribute(Object attributeValue);
Model addAllAttributes(Collection<?> attributeValues);
Model addAllAttributes(Map<String, ?> attributes);
Model mergeAttributes(Map<String, ?> attributes);
boolean containsAttribute(String attributeName);
Map<String, Object> asMap();
Object getAttribute(String attributeName);
}
Listing 2-2Model Interface
控制器处理模型对象最简单的方法是将其声明为方法参数。清单 2-3 显示了带有模型参数的showHomePage方法。在方法实现中,我们向模型对象添加了currentDate属性。
@GetMapping("/home.html")
public String showHomePage(Model model) {
model.addAttribute("currentDate", new Date());
return "home";
}
Listing 2-3showHomePage with Model Attribute
Spring 框架努力将我们的应用从框架的类中分离出来。因此,处理模型对象的一种流行方法是使用清单 2-4 中所示的java.util.Map实例。Spring 将使用传入的 Map 参数实例来丰富暴露给视图的模型。
@GetMapping("/home.html")
public String showHomePage(Map model) {
model.put("currentDate", new Date());
return "home";
}
Listing 2-4showHomePage with Map Attribute
视角
Spring Web MVC 支持多种视图技术,比如 JSP、Velocity、FreeMarker 和 XSLT。Spring Web MVC 使用org.springframework.web.servlet.View接口来完成这个任务。View接口有两个方法,如清单 2-5 所示。
public interface View
{
String getContentType();
void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception;
}
Listing 2-5View Interface API
接口的具体实现负责呈现响应。这是通过覆盖render方法来实现的。getContentType方法返回生成的视图的内容类型。表 2-1 展示了 Spring Web MVC 提供的开箱即用的重要View实现。您会注意到所有这些实现都驻留在org.springframework.web.servlet.view包中。
表 2-1
Spring Web MVC 视图实现
|类别名
|
描述
|
| --- | --- |
| org.springframework.web.servlet.view.json.MappingJackson2JsonView | 查看编码模型属性并返回 JSON 的实现 |
| org.springframework.web.servlet.view.xslt.XsltView | 查看执行 XSLT 转换并返回响应的实现 |
| org.springframework.web.servlet.view.InternalResourceView | 将请求委托给 web 应用内部的 JSP 页面的视图实现 |
| org.springframework.web.servlet.view.tiles2.TilesView | 使用 Apache Tiles 配置进行图块定义和渲染的视图实现 |
| org.springframework.web.servlet.view.JstlView | 使用 JSTL 支持 JSP 页面的InternalResourceView的专门实现 |
| org.springframework.web.servlet.view.RedirectView | 查看重定向到不同(绝对或相对)URL 的实现 |
清单 2-6 显示了我们之前看到的HomeController的重新实现。这里我们创建了一个JstlView的实例,并设置了需要呈现的 JSP 页面。
@Controller
public class HomeController {
@RequestMapping("/home.html")
public View showHomePage() {
JstlView view = new JstlView();
view.setUrl("/WEB-INF/pages/home.jsp");
return view;
}
}
Listing 2-6HomeController View Implementation
控制器实现通常不处理视图实例。相反,它们返回逻辑视图名,如清单 2-1 所示,并让视图解析器确定和创建视图实例。这将控制器与特定的视图实现解耦,并使交换视图实现变得容易。此外,控制器不再需要知道错综复杂的情况,如视图的位置。
@RequestParam(请求参数)
@RequestParam注释用于将 Servlet 请求参数绑定到处理程序/控制器方法参数。使用类型转换将请求参数值自动转换为指定的方法参数类型。清单 2-7 展示了@RequestParam的两种用法。在第一种用法中,Spring 查找名为query的请求参数,并将其值映射到方法参数query。在第二种用法中,Spring 查找名为page的请求参数,将其值转换为整数,并将其映射到pageNumber方法参数。
@GetMapping("/search.html")
public String search(@RequestParam String query, @RequestParam("page") int pageNumber) {
model.put("currentDate", new Date());
return "home";
}
Listing 2-7RequestParam Usage
当使用@RequestParam注释方法参数时,指定的请求参数必须在客户机请求中可用。如果参数丢失,Spring 将抛出一个MissingServletRequestParameterException异常。解决这个问题的一个方法是将required属性设置为false,如清单 2-8 所示。另一个选项是使用defaultValue属性来指定一个默认值。
@GetMapping("/search.html")
public String search(@RequestParam String query, @RequestParam(value="page", required=false) int pageNumber, @RequestParam(value="size", defaultValue="10") int pageSize) {
model.put("currentDate", new Date());
return "home";
}
Listing 2-8Making a Request Parameter Not Required
@RequestMapping
正如我们在“控制器”一节中了解到的,@RequestMapping注释用于将 web 请求映射到处理程序类或处理程序方法。@RequestMapping提供了几个属性,可以用来缩小这些映射的范围。表 2-2 显示了不同的元素及其描述。
表 2-2
请求映射元素
|元素名称
|
描述
|
| --- | --- |
| 方法 | 将映射限制为特定的 HTTP 方法,如 GET、POST、HEAD、OPTIONS、PUT、PATCH、DELETE、TRACE |
| 生产 | 将映射缩小到由该方法生成的媒体类型 |
| 消耗 | 将映射缩小到方法使用者的媒体类型 |
| 头球 | 将映射缩小到应该出现的标题 |
| 名字 | 允许您为映射指定名称 |
| 参数 | 将映射限制为所提供的参数名和值 |
| 价值 | 特定处理程序方法的缩小路径(如果没有任何元素,缺省值是 main 元素) |
| 小路 | 特定处理程序方法的缩小路径(值的别名) |
@RequestMapping映射的默认 HTTP 方法是 GET。使用清单 2-9 中所示的method元素可以改变这种行为。只有在执行 POST 操作时,Spring 才会调用saveUser方法。对saveUser的 GET 请求将导致抛出异常。Spring 提供了一个方便的RequestMethod枚举,列出了可用的 HTTP 方法。
@RequestMapping(value="/saveuser.html", method=RequestMethod.POST)
public String saveUser(@RequestParam String username, @RequestParam String password) {
// Save User logic
return "success";
}
Listing 2-9POST Method Example
@RequestMapping 快捷方式批注
您可以对@RequestMapping 使用“快捷方式注释”。
它看起来可读性更好,因为您可以使用“快捷方式注释”来代替@RequestMapping。
所有快捷方式批注都从@RequestMapping 继承所有元素,不使用方法,因为方法已经在批注的标题中。
比如@GetMapping 和@RequestMapping(method = RequestMethod.GET)完全一样。
表 2-3
@RequestMapping 的快捷批注
|注释
|
更换
|
| --- | --- |
| @GetMapping | @RequestMapping(method = RequestMethod.GET) |
| @PostMapping | @RequestMapping(method = RequestMethod.POST) |
| @PutMapping | @RequestMapping(method = RequestMethod.PUT) |
| @DeleteMapping | @RequestMapping(method = RequestMethod.DELETE) |
| @补丁映射 | @RequestMapping(method = RequestMethod.PATCH) |
produces元素表示映射方法产生的媒体类型,比如 JSON 或 XML 或 HTML。produces元素可以将一个或多个媒体类型作为它的值。清单 2-10 显示了添加了produces元素的search方法。MediaType.TEXT_HTML值表示当在搜索.html上执行 GET 请求时,该方法返回一个 HTML 响应。
@GetMapping(value="/search.html", produces="MediaType.TEXT_HTML")
public String search(@RequestParam String query, @RequestParam(value="page", required=false) int pageNumber) {
model.put("currentDate", new Date());
return "home";
}
Listing 2-10Produces Element Example
客户端可能在/search.html上执行 GET 请求,但是发送一个带有值application/JSON的Accept报头。在这种情况下,Spring 不会调用search方法。相反,它将返回 404 错误。produces元素提供了一种方便的方式来限制控制器可以服务的内容类型的映射。同样,consumes元素用于指示带注释的方法所使用的媒体类型。
Accept and Content-Type Header
正如在第一章中所讨论的,REST 资源可以有多种表现形式。REST 客户端通常使用Accept和Content-Type头来处理这些表示。
REST 客户端使用Accept头来表示它们接受的表示。HTTP 规范允许客户端发送不同媒体类型的优先列表,它将接受这些媒体类型作为响应。收到请求后,服务器将发送优先级最高的表示。为了理解这一点,考虑 Firefox 浏览器的默认Accept头:
text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
q参数,也称为相对质量参数,表示偏好程度,取值范围从 0 到 1。从字符串中,我们可以推断出 HTML 和 XHTML 的优先级为 1,因为它们没有关联的q值。XML 媒体类型的优先级为 0.9,其余表示的优先级为 0.8。在收到这个请求时,服务器会尝试发送一个 HTML/XHTML 表示,因为它具有最高的优先级。
以类似的方式,REST 客户机使用Content-Type头来指示发送到服务器的请求的媒体类型。这允许服务器正确地解释请求并正确地解析内容。如果服务器无法解析内容,它将发送 415 不支持的媒体类型错误状态代码。
Spring Web MVC 允许对用@RequestMapping注释的方法进行灵活的签名。这包括可变的方法参数和方法返回类型。表 2-4 列出了允许的重要参数。关于允许参数的详细列表,请参考 Spring 的 Javadocs at http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/bind/annotation/RequestMapping.html 。
表 2-4
方法参数和描述
|方法参数
|
描述
|
| --- | --- |
| HttpServletRequest/HttpServletResponse | HTTP Servlet 请求和响应对象。允许原始访问客户端数据,如请求参数和头。 |
| HttpSession | 实例,表示用户的 HTTP 会话。 |
| Command object | Spring 用用户提交的数据填充/绑定的 POJO 或模型对象。命令对象可以用@ModelAttribute注释。 |
| BindingResult | 实例,表示命令对象的验证和绑定。此参数必须紧接在命令对象之前。 |
| HttpEntity<?> | 表示 HTTP 请求的实例。每个HttpEntity由请求体和一组头组成。 |
| Principal | 一个代表经过身份验证的用户的java.security.Principal实例。 |
表 2-5 显示了用@RequestMapping标注的方法支持的不同返回类型。
表 2-5
退货类型和说明
|返回类型
|
描述
|
| --- | --- |
| String | 表示逻辑视图名称。使用注册的视图解析器来解析物理视图,并生成响应。 |
| View | 表示视图的实例。在这种情况下,不执行视图解析,视图对象负责生成响应。例子包括JstlView、VelocityView、RedirectView等等。 |
| HttpEntity<?> | 表示 HTTP 响应的实例。每个HttpEntity由响应体和一组头组成。 |
| HttpHeaders | 实例捕获要返回的标头。响应将有一个空体。 |
| Pojo | 被认为是模型属性的 Java 对象。专门的RequestToViewNameTranslator用于确定适当的逻辑视图名称。 |
路径变量
@RequestMapping注释通过 URI 模板支持动态 URIs。如第一章所述,URI 模板是带有占位符或变量的 URIs。@PathVariable注释允许您通过方法参数访问和使用这些占位符。清单 2-11 给出了一个@PathVariable的例子。在这个场景中,getUser方法被设计为提供与路径变量{username}相关的用户信息。客户端将在 URL /users/jdoe上执行 GET,以检索与用户名jdoe相关联的用户信息。
@RequestMapping("/users/{username}")
public User getUser(@PathVariable("username") String username) {
User user = null;
// Code to construct user object using username
return user;
}
Listing 2-11PathVariable Example
解析视图
如前所述,Spring Web MVC 控制器可以返回一个org.springframework.web.servlet.View实例或一个逻辑视图名。当返回一个逻辑视图名时,使用一个ViewResolver将视图解析为一个View实现。如果这个过程由于某种原因失败,就会抛出一个javax.servlet.ServletException。ViewResolver接口只有一个方法,如清单 2-12 所示。
public interface ViewResolver
{
View resolveViewName(String viewName, Locale locale) throws Exception;
}
Listing 2-12ViewResolver Interface
表 2-6 列出了 Spring Web MVC 提供的一些ViewResolver实现。
你可能已经注意到,表 2-6 中不同的视图解析器模拟了我们之前看到的不同类型的视图。清单 2-13 显示了创建InternalViewResolver所需的代码。
同样,清单 2-13 显示了@Bean 注释——简而言之,在由@Configuration 注释的类中,由@Bean 定义的所有方法都将返回对象,这由 Spring Framework 控制,在帮助下,我们可以为一些对象定义行为,并在需要时使用@Inject 或@Autowired 注释调用任何地方的对象。默认情况下,Spring Framework 创建的每个对象都将被定义为一个@Bean。
表 2-6
ViewResolver 实现和描述
|返回类型
|
描述
|
| --- | --- |
| BeanNameViewResolver | ViewResolver实现,查找 id 与ApplicationContext中的逻辑视图名称相匹配的 bean。如果它在ApplicationContext中没有找到 bean,则返回一个null。 |
| InternalResourceViewResolver | 寻找具有逻辑视图名称的内部资源。内部资源的位置通常是通过在逻辑名称前加前缀和后缀路径和扩展名信息来计算的。 |
| ContentNegotiatingViewResolver | 将视图解析委托给其他视图解析器。视图解析器的选择基于所请求的媒体类型,而媒体类型本身是使用 Accept 头、文件扩展名或 URL 参数来确定的。 |
| TilesViewResolver | ViewResolver在瓦片配置中寻找与逻辑视图名称匹配的模板。 |
@Bean
public ViewResolver viewResolver() {
InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
viewResolver.setPrefix("/WEB-INF/jsp/");
viewResolver.setSuffix(".jsp");
return viewResolver;
}
Listing 2-13InternalViewResolver Example
异常处理程序
异常是任何应用的一部分,Spring 提供了HandlerExceptionResolver机制来处理那些意外的异常。HandlerExceptionResolver抽象类似于ViewResolver,用于解决错误视图的异常。清单 2-14 显示了HandlerExceptionResolver API。
public interface HandlerExceptionResolver {
ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex);
}
Listing 2-14HandlerExceptionResolver API
Spring 提供了几个现成的HandlerExceptionResolver实现,如表 2-7 所示。
表 2-7
HandlerExceptionResolver 实现和描述
|解析器实现
|
描述
|
| --- | --- |
| org.springframework.web.servlet.handler.SimpleMappingExceptionResolver | 将异常类名映射到视图名的异常解析器实现。 |
| org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver | 异常解析器实现,将标准的 Spring 异常转换为 HTTP 状态代码。 |
| org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver | Spring 应用中的自定义异常可以用@ResponseStatus进行注释,它将一个 HTTP 状态代码作为其值。这个异常解析器将异常转换为其映射的 HTTP 状态代码。 |
| org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver | 使用带注释的@ExceptionHandler方法解决异常的异常解决器实现。 |
SimpleMappingExceptionResolver已经存在了很长时间。Spring 3 引入了一种使用@ExceptionHandler策略处理异常的新方法。这为处理基于 REST 的服务中的错误提供了一种机制,在这种情况下,实际上没有视图可显示,而是返回数据。清单 2-15 显示了一个带有异常处理程序的控制器。任何现在在HomeController中抛出一个SQLException的方法都将在handleSQLException方法中得到处理。handleSQLException只是创建一个ResponseEntity实例并返回它。但是,可以执行额外的操作,如日志记录、返回额外的诊断数据等。
@Controller
public class HomeController {
@ExceptionHandler(SQLException.class)
public ResponseEntity handleSQLException() {
ResponseEntity responseEntity = new ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR);
return responseEntity;
}
@GetMapping("/stream")
public void streamMovie(HttpServletResponse response) throws SQLException {
}
}
Listing 2-15ExceptionHandler Example
带注释的方法只能处理发生在控制器或其子类中的异常。因此,如果我们需要在其他控制器中处理 SQL 异常,那么我们需要在所有这些控制器中复制并粘贴handleSQLException方法。这种方法有严重的局限性,因为异常处理确实是一个横切关注点,应该是集中的。
为了解决这个问题,Spring 提供了@ControllerAdvice注释。用@ControllerAdvice标注的类中的方法应用于所有的@RequestMapping方法。清单 2-16 显示了使用handleSQLException方法的GlobalExceptionHandler。正如您所看到的,GlobalExceptionHandler扩展了 Spring 的ResponseEntityExceptionHandler,它将默认的 Spring Web MVC 异常转换为带有 HTTP 状态代码的ResponseEntity。
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(SQLException.class)
public ResponseEntity handleSQLException() {
ResponseEntity responseEntity = new ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR);
return responseEntity;
}
}
Listing 2-16GlobalExceptionHandler Example
截击机
Spring Web MVC 提供了拦截器的概念来实现横切不同处理程序的关注点。考虑这样一个场景,您希望阻止对一组控制器的未经身份验证的访问。拦截器允许您集中这种访问逻辑,而不必在每个控制器中复制和粘贴代码。顾名思义,拦截器拦截一个请求;他们这样做是基于以下三点:
-
在控制器被执行之前。这允许拦截器决定它是需要继续执行链还是返回一个异常或自定义响应。
-
在控制器执行之后,但在响应发出之前。这允许拦截器向视图提供任何额外的模型对象。
-
在响应发出后,允许任何资源清理。
Note
Spring Web MVC 拦截器类似于 HTTP servlet 过滤器。两者都可以用来拦截请求和执行常见的问题。但是,它们之间有一些值得注意的差异。过滤器能够包装甚至交换HttpServletRequest和HttpServletResponse对象。拦截器不能修饰或交换那些对象。拦截器是 Spring 管理的 bean,我们可以很容易地在其中注入其他 spring beans。筛选器是容器管理的实例;它们没有提供注入 Spring 管理的 beans 的直接机制。
Spring Web MVC 为实现拦截器提供了HandlerInterceptor接口。清单 2-17 给出了HandlerInterceptor接口。如您所见,这三个方法对应于我们刚刚讨论的三个拦截器特性。
public interface HandlerInterceptor{
void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView);
boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler);
}
Listing 2-17HandlerInterceptor API
清单 2-18 给出了一个简单的拦截器实现。如您所见,SimpleInterceptor类扩展了HandlerInterceptorAdapter。HandlerInterceptorAdapter是一个方便的抽象类,它实现了HandlerInterceptor接口并提供了其方法的默认实现。
public class SimpleInterceptor extends HandlerInterceptorAdapter {
private static final Logger logger = Logger.getLogger(SimpleInterceptor.class);
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
logger.info("Inside the prehandle");
return false;
}
}
Listing 2-18Spring Web MVC Interceptor Example
拦截器可以使用InterceptorRegistry策略在 Spring Web 应用中注册。当使用 Java 配置时,这通常是通过创建一个扩展WebMvcConfigurerAdapter的配置类来实现的。Spring Web MVC 的WebMvcConfigurerAdapter类提供了用于访问InterceptorRegistry的addInterceptors方法。清单 2-19 显示了注册两个拦截器的代码:LocalInterceptor,它与 Spring 和我们的SimpleInterceptor一起出现。
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = { "com.apress.springrest.web" })
public class WebConfig extends WebMvcConfigurerAdapter {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LocaleChangeInterceptor());
registry.addInterceptor(new SimpleInterceptor()).addPathPatterns("/auth/**");
}
}
Listing 2-19Example Registering Interceptors
当拦截器被添加到拦截器注册中心时,拦截器被应用到所有的处理程序映射。因此,清单 2-19 中的LocaleChangeInterceptor应用于所有的处理程序映射。然而,也可以将拦截器限制在某些 URL 上。这在清单 2-19 中使用addPathPatterns方法进行了演示。这里我们指出SimpleInterceptor应该只应用于在auth路径下的 URL。
摘要
在这一章中,我们已经了解了 Spring 框架的基础和 Spring Web MVC 的不同组件。在下一章,我们将把所有的东西放在一起,看看如何使用 Spring Boot 构建我们的第一个 RESTful 应用。
三、REST Spring
在本章中,我们将讨论以下内容:
-
Spring Boot 的基本情况
-
构建 Hello World REST 应用
-
访问 REST 应用的工具
Spring 框架的目标之一是减少管道代码,以便开发人员可以集中精力实现核心业务逻辑。然而,随着 Spring 框架的发展,并向其投资组合中添加了几个子项目,开发人员最终花费了相当多的时间来设置项目、寻找项目依赖关系以及编写模板代码和配置。
Spring Boot 是一个 Spring portfolio 项目,旨在通过提供一组 starter 项目模板来简化 Spring 应用的引导。这些将根据项目能力提取所有需要的适当的依赖关系。例如,如果您启用 JPA 功能,它会自动包含所有相关的 JPA、Hibernate 和 Spring JAR 文件。
Spring Boot 还采用了一种固执己见的方法,并提供了默认配置,这大大简化了应用的开发。例如,如果 Spring Boot 在类路径中找到 JPA 和 MySQL JARs,它会自动配置一个 JPA 持久性单元。它还支持使用嵌入式 Jetty/Tomcat 服务器创建独立的 Spring 应用,使它们易于部署在任何只安装了 Java 的机器上。此外,它还提供了生产就绪功能,如指标和运行状况检查。通过这本书,我们将探索和学习 Spring Boot 的这些和其他特点。
Note
Spring Roo 是另一个 Spring portfolio 项目,试图提供快速的 Spring 应用开发。它提供了一个命令行工具,支持简单的项目引导,并为 JPA 实体、web 控制器、测试脚本和必要的配置等组件生成代码。尽管最初对这个项目有很多兴趣,但 Spring Roo 从未真正成为主流。AspectJ 代码生成和陡峭的学习曲线,再加上它试图接管你的项目,是它没有被采用的一些原因。相比之下,Spring Boot 采取了不同的方法;它侧重于启动项目并提供聪明、合理的默认配置。Spring Boot 不会生成任何代码来简化项目管理。
生成 Spring Boot 项目
从头开始创建 Spring Boot 项目是可能的。但是,Spring Boot 提供了以下选项来生成新项目:
-
使用 Spring Boot 的入门网站(
http://start.spring.io)。 -
使用 Spring 工具套件(STS) IDE。
-
使用引导命令行界面(CLI)。
我们将在本章探讨所有三种选择。然而,对于本书的其余部分,我们将选择 Boot CLI 来生成新项目。在我们开始项目生成之前,在您的机器上安装 Java 是很重要的。Spring Boot 要求您安装 Java SDK 1.8 或更高版本。在本书中,我们将使用 Java 1.8。
安装构建工具
Spring Boot 支持两种最流行的构建系统:Maven 和 Gradle。在本书中,我们将使用 Maven 作为我们的构建工具。Spring Boot 需要 Maven 版本 3.5 或更高版本。这里给出了在 Windows 机器上下载和配置 Maven 的步骤。Mac 和其他操作系统的类似说明可以在 Maven 的安装页面上找到( https://maven.apache.org/install.html ):
-
从
https://maven.apache.org/download.cgi下载最新的 Maven 二进制。在写这本书的时候,Maven 的当前版本是 3.8.1。对于 Windows,下载apache-maven-3.8.1-bin.zip文件。 -
解压
C:\tools\maven下 zip 文件的内容。 -
添加一个值为
C:\tools\maven\apache-maven-3.8.1的环境变量M2_HOME。这告诉 Maven 和其他工具 Maven 安装在哪里。还要确保JAVA_HOME变量指向已安装的 JDK。 -
将值
%M2_HOME%\bin附加到Path环境变量中。这允许您从命令行运行 Maven 命令。 -
打开一个新命令行,键入以下内容:
mvn - v
您应该会看到类似图 3-1 的输出,表明 Maven 已经成功安装。

图 3-1
Maven 安装验证
Note
要了解更多关于 Maven 的信息,请参考 Apress ( http://www.apress.com/9781484208427 )出版的介绍 Maven 。
使用 start.spring.io 生成项目
Spring Boot 在 http://start.spring.io 托管一个初始化应用。Initializr 提供了一个 web 界面,允许您输入项目信息并选择项目所需的功能,瞧——它将项目生成为一个 zip 文件。按照以下步骤生成我们的 Hello World REST 应用:

图 3-2
start.spring.io 网站
-
在浏览器中启动
http://start.spring.io网站,输入如图 3-2 所示的信息。 -
在 Dependencies ➤ Web 下,选择选项“Web ”,并指明您希望 Spring Boot 包含 Web 项目基础结构和依赖项。
-
然后点击“生成项目”按钮。这将开始下载
hello-rest.zip文件。
下载完成后,解压缩 zip 文件的内容。您将看到生成的hello-rest文件夹。图 3-3 显示生成的文件夹内容。

图 3-3
hello-rest 应用内容
快速浏览一下hello-rest的内容,可以看到我们有一个标准的基于 Maven 的 Java 项目布局。我们有src\main\java文件夹,存放 Java 源代码;src\main\resources,其中包含属性文件;静态内容,如 HTML\CSS\JS 文件;和包含测试用例的src\test\java文件夹。在运行 Maven 构建时,这个项目会生成一个 JAR 工件。现在,对于第一次使用 WAR 工件来部署 web 应用的人来说,这可能有点令人困惑。默认情况下,Spring Boot 创建独立的应用,其中所有的东西都打包到一个 JAR 文件中。这些应用将嵌入诸如 Tomcat 之类的 servlet 容器,并使用一种古老的main()方法来执行。
Note
Spring Boot 还允许您使用 WAR 工件,其中包含 html、css、js 和其他开发 web 应用所需的文件,这些应用可以部署到外部 Web 和应用容器中。
清单 3-1 给出了hello-rest应用的pom.xml文件的内容。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.3</version>
<relativePath/>
</parent>
<groupId>com.appress</groupId>
<artifactId>hello-rest</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Hello World REST</name>
<description>Hello World REST Application Using Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</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>
</build>
</project>
Listing 3-1hello-rest pom.xml file Contents
pom.xml文件中的groupId、artifactId和version元素对应于 Maven 描述我们项目的标准 GAV 坐标。parent标签表明我们将从spring-boot-starter-parent POM 继承。这确保了我们的项目继承了 Spring Boot 的默认依赖项和版本。元素列出了两个 POM 文件依赖关系:spring-boot-starter-web和spring-boot-starter-test。Spring Boot 使用术语starter POM来描述这样的 POM 文件。
这些 starter POMs 用于提取其他依赖项,实际上不包含任何自己的代码。例如,spring-boot-starter-web提取 Spring MVC 依赖项、Tomcat 嵌入式容器依赖项和用于 JSON 处理的 Jackson 依赖项。这些 starter 模块在提供所需的依赖项和将应用的 POM 文件简化为几行代码方面起着重要的作用。表 3-1 列出了一些常用的启动器模块。
表 3-1
Spring Boot 起动机模块
|Starter POM 依赖项
|
使用
|
| --- | --- |
| spring-boot-starter | 入门产品,引入了自动配置支持和日志记录等功能所必需的核心依赖项 |
| spring-boot-starter-aop | 引入面向方面编程和 AspectJ 支持的入门工具 |
| spring-boot-starter-test | Starter 引入了测试所需的依赖项,如 JUnit、Mockito 和spring-test |
| spring-boot-starter-web | 引入 MVC 依赖关系(spring-webmvc)和嵌入式 servlet 容器支持的启动程序 |
| spring-boot-starter-data-jpa | Starter 通过引入spring-data-jpa、spring-orm,和 Hibernate 依赖项来增加 Java 持久性 API 支持 |
| spring-boot-starter-data-rest | 引入spring-data-rest-webmvc将存储库公开为 REST API 的启动程序 |
| spring-boot-starter-hateoas | 为 HATEOAS REST 服务带来spring-hateoas依赖性的启动器 |
| spring-boot-starter-jdbc | 支持 JDBC 数据库的入门产品 |
最后,spring-boot-maven-plugin包含将应用打包成可执行的 JAR/WAR 并运行它的目标。
HelloWorldRestApplication.java类是我们应用的主类,包含了main()方法。清单 3-2 显示了HelloWorldRestApplication.java类的内容。@SpringBootApplication注释是一个方便的注释,相当于声明以下三个注释:
-
@Configuration—将带注释的类标记为包含一个或多个 Spring bean 声明。Spring 处理这些类来创建 bean 定义和实例。 -
@ComponentScan—这个类告诉 Spring 扫描并寻找用@Component, @Service, @Repository, @Controller, @RestController, and @Configuration标注的类。默认情况下,Spring 会扫描包中@ComponentScan注释类所在的所有类。为了覆盖默认行为,我们可以在 configuration 类中设置这个注释,并将 basePackages 参数定义为包的名称。 -
@EnableAutoConfiguration—启用 Spring Boot 的自动配置行为。基于在类路径中找到的依赖项和配置,Spring Boot 智能地猜测并创建 bean 配置。
典型的 Spring Boot 应用总是使用这三种注释。除了在这些场景中提供一个很好的选择外,@SpringBootApplication注释正确地表明了类的意图。
package com.apress.hellorest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class HelloWorldRestApplication {
public static void main(String[] args) {
SpringApplication.run(HelloWorldRestApplication.class, args);
}
}
Listing 3-2HelloWorldRestApplication Contents
main()方法只是将应用引导委托给SpringApplication的run()方法。run()将一个HelloWorldRestApplication.class作为它的参数,并指示 Spring 从HelloWorldRestApplication读取注释元数据,并从中填充ApplicationContext。
现在我们已经查看了生成的项目,让我们创建一个 REST 端点,它只返回“Hello REST”理想情况下,我们应该在一个单独的控制器 Java 类中创建这个端点。然而,为了简单起见,我们将在HelloWorldRestApplication中创建端点,如清单 3-3 所示。我们从添加@RestController开始,表示HelloWorldRestApplication有可能的 REST 端点。然后我们创建了helloGreeting()方法,它简单地返回问候“Hello REST”最后,我们使用RequestMapping注释将对“/greet”路径的 web 请求映射到helloGreeting()处理程序方法。
package com.apress.hellorest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
@SpringBootApplication
@RestController
public class HelloWorldRestApplication {
public static void main(String args) {
SpringApplication.run(HelloWorldRestApplication.class, args);
}
@GetMapping("/greet")
public String helloGreeting() {
return "Hello REST";
}
}
Listing 3-3Hello REST Endpoint
下一步是启动并运行我们的应用。为此,打开命令行,导航到hello-rest文件夹,并运行以下命令:
mvn spring-boot:run
您将看到 Maven 下载必要的插件和依赖项,然后它将启动应用,如下所示:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
[32m :: Spring Boot :: [39m [2m (v2.5.3)[0;39m
[2m2021-08-12 21:54:43.147[0;39m [32m INFO[0;39m [35m15012[0;39m [2m---[0;39m [2m[ main][0;39m [36mc.a.hellorest.HelloWorldRestApplication [0;39m [2m:[0;39m Starting HelloWorldRestApplication using Java 1.8 on DESKTOP-82GK4GP with PID 15012 (C:\Users\makus\OneDrive\Desktop\hello-rest\target\classes started by makus in C:\Users\makus\OneDrive\Desktop\hello-rest)
[2m2021-08-12 21:54:43.149[0;39m [32m INFO[0;39m [35m15012[0;39m [2m---[0;39m [2m[ main][0;39m [36mc.a.hellorest.HelloWorldRestApplication [0;39m [2m:[0;39m No active profile set, falling back to default profiles: default
[2m2021-08-12 21:54:43.843[0;39m [32m INFO[0;39m [35m15012[0;39m [2m---[0;39m [2m[ main][0;39m [36mo.s.b.w.embedded.tomcat.TomcatWebServer [0;39m [2m:[0;39m Tomcat initialized with port(s): 8080 (http)
[2m2021-08-12 21:54:43.851[0;39m [32m INFO[0;39m [35m15012[0;39m [2m---[0;39m [2m[ main][0;39m [36mo.apache.catalina.core.StandardService [0;39m [2m:[0;39m Starting service [Tomcat]
[2m2021-08-12 21:54:43.851[0;39m [32m INFO[0;39m [35m15012[0;39m [2m---[0;39m [2m[ main][0;39m [36morg.apache.catalina.core.StandardEngine [0;39m [2m:[0;39m Starting Servlet engine: [Apache Tomcat/9.0.50]
[2m2021-08-12 21:54:43.917[0;39m [32m INFO[0;39m [35m15012[0;39m [2m---[0;39m [2m[ main][0;39m [36mo.a.c.c.C.[Tomcat].[localhost]. [0;39m [2m:[0;39m Initializing Spring embedded WebApplicationContext
[2m2021-08-12 21:54:43.917[0;39m [32m INFO[0;39m [35m15012[0;39m [2m---[0;39m [2m[ main][0;39m [36mw.s.c.ServletWebServerApplicationContext[0;39m [2m:[0;39m Root WebApplicationContext: initialization completed in 734 ms
[2m2021-08-12 21:54:44.286[0;39m [32m INFO[0;39m [35m15012[0;39m [2m---[0;39m [2m[ main][0;39m [36mo.s.b.w.embedded.tomcat.TomcatWebServer [0;39m [2m:[0;39m Tomcat started on port(s): 8080 (http) with context path ''
[2m2021-08-12 21:54:44.297[0;39m [32m INFO[0;39m [35m15012[0;39m [2m---[0;39m [2m[ main]0;39m [36mc.a.hellorest.HelloWorldRestApplication [0;39m [2m:[0;39m Started HelloWorldRestApplication in 1.445 seconds (JVM running for 2.055)
为了测试我们正在运行的应用,启动浏览器并导航到http://localhost:8080/greet。注意,Spring Boot 作为Root上下文而不是hello-world上下文启动应用。你应该会看到一个类似于图 [3-4 的屏幕。

图 3-4
你好休息问候
Spring Initializr
位于 http://start.spring.io 的 Spring Initializr 应用本身就是使用 Spring Boot 构建的。你可以在 GitHub 的 https://github.com/spring-io/initializr 找到这个应用的源代码。您也可以构建并托管自己的 Initializr 应用实例。
除了提供 web 接口之外,Initializr 还提供了一个 HTTP 端点,该端点提供了类似的项目生成功能。事实上,Spring Boot 的 CLI 和诸如 STS 之类的 ide 在幕后使用这个 HTTP 端点来生成项目。
也可以使用 curl 从命令行调用 HTTP 端点。例如,下面的命令将使用 curl 生成hello-rest项目 zip 文件。–d选项用于提供作为请求参数传递的数据:
curl https://start.spring.io/starter.zip -d style=web -d name=hello-rest
使用 STS 生成项目
Spring Tool Suite 或 STS 是一个免费的基于 Eclipse 的开发环境,它为开发基于 Spring 的应用提供了强大的工具支持。您可以从 Pivotal 的网站 https://spring.io/tools 下载并安装最新版本的 STS。在写这本书的时候,STS 的当前版本是 4.11.0。
STS 提供了一个类似于 Initializr 的 web 界面的用户界面,用于生成 Boot starter 项目。以下是生成 Spring Boot 项目的步骤:

图 3-5
STS 春季启动项目
- 如果还没有启动 STS,请启动 STS。进入文件➤新建,点击 Spring Starter 项目,如图 3-5 所示。

图 3-6
起始项目选项
- 在以下屏幕中,输入如图 3-6 所示的信息。输入 Maven 的 GAV 信息。点击下一个。

图 3-7
起始项目选项
- 在以下屏幕中,输入如图 3-7 所示的信息。选择 web starter 选项。点击下一个。

图 3-8
起始项目位置
- 在接下来的屏幕上,更改您想要存储项目的位置。“完整 Url”区域显示 HTTP REST 端点以及您选择的选项(参见图 3-8 )。

图 3-9
STS Spring starter 项目资源
- 点击 Finish 按钮,您将看到在 STS 中创建了新的项目。项目的内容类似于我们之前创建的项目(参见图 3-9 )。
STS 的 starter 项目向导提供了一种生成新的 Spring Boot 项目的便捷方式。新创建的项目会自动导入到 IDE 中,并且可以立即用于开发。
使用 CLI 生成项目
Spring Boot 提供了一个命令行界面(CLI ),用于生成项目、构建原型和运行 Groovy 脚本。在开始使用 CLI 之前,我们需要安装它。以下是在 Windows 计算机上安装引导 CLI 的步骤:

图 3-10
Spring Boot CLI 目录
-
从 Spring 的网站
https://docs.spring.io/spring-boot/docs/current/reference/html/getting-started.html#getting-started.installing.cli下载最新版本的 CLI ZIP 发行版。在写这本书的时候,CLI 的当前版本是 2.5.3。这个版本可以直接从https://repo.spring.io/release/org/springframework/boot/spring-boot-cli/2.5.3/spring-boot-cli-2.5.3-bin.zip下载。 -
解压 zip 文件,将其内容(文件夹如
bin、lib)放在C:\tools\springbootcli下,如图 3-10 所示。 -
添加一个值为
c:\tools\springbootcli的新环境变量SPRING_HOME。 -
编辑
Path环境变量,并在其末尾添加%SPRING_HOME%/bin值。 -
打开一个新命令行,运行以下命令验证安装:
spring --version
您应该会看到类似于图 3-10 所示的输出。

图 3-11
Spring Boot CLI 安装
现在我们已经安装了 Boot CLI,生成新项目只需在命令行运行以下命令:
spring init --dependencies web rest-cli
该命令创建一个新的具有 web 功能的rest-cli项目。运行该命令的输出如清单 3-4 所示。
C:\test>spring init --dependencies web rest-cli
Using service at https://start.spring.io
Project extracted to 'C:\test\rest-cli'
Listing 3-4Boot CLI Output
访问 REST 应用
有几个免费的和商业的工具允许您访问和试验 REST API/应用。在这一节中,我们将看看一些流行的工具,它们允许您快速测试请求并检查响应。
邮递员
Postman 是一个 Chrome 浏览器扩展,用于发出 HTTP 请求。它提供了大量的特性,使得开发、测试和记录 REST API 变得容易。还提供了一个 Chrome 应用版本的 Postman,它提供了浏览器扩展中不提供的额外功能,如批量上传。
可以从 Chrome 网上商店下载并安装 Postman。要安装 Postman,只需启动 Chrome 浏览器并导航至 https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop 。你可能会被要求登录你的谷歌浏览器账户,并使用“新应用”安装对话框进行确认。安装完成后,您应该能够使用书签栏中的“应用图标”或通过键入chrome://apps/shortcut来定位并启动 Postman。图 3-10 显示了在 Chrome 浏览器中启动的 Postman。
Postman 提供了一个简洁直观的用户界面,用于编写 HTTP 请求、将其发送到服务器以及查看 HTTP 响应。它还会自动保存请求,以便将来运行时使用。图 3-12 显示了向我们的问候服务发出的 HTTP GET 请求及其响应。您还可以在左侧边栏的 History 部分看到保存的请求。

图 3-12
邮递员浏览器扩展
Postman 很容易将相关的 API 调用逻辑分组到集合中,如图 3-12 所示。一个集合下可能有请求的子集合。

图 3-13
邮递员收藏
剩馀客户端
RESTClient 是一个 Firefox 扩展,用于访问 REST APIs 和应用。与 Postman 不同,RESTClient 没有太多花哨的功能,但它提供了快速测试 REST API 的基本功能。要安装 RESTClient,启动 Firefox 浏览器并导航到 URL https://addons.mozilla.org/en-US/firefox/addon/restclient/ 。然后点击“+添加到 Firefox”按钮,在下面的“软件安装”对话框中,点击“立即安装”按钮。
安装完成后,您可以使用浏览器右上角的 RESTClient 图标
启动 RESTClient。图 3-14 显示了 RESTClient 应用对我们的 Greet 服务的请求以及相应的响应。

图 3-14
剩馀客户端
摘要
Spring Boot 提供了一种自以为是的方法来构建基于 Spring 的应用。在这一章中,我们研究了 Spring Boot 的特性,并用它构建了一个 Hello World REST 应用。我们还研究了用于测试和探索 REST API 的 Postman 和 RESTClient 工具。
在下一章,我们将开始开发一个更复杂的 REST 应用,并讨论识别和设计资源的过程。
四、开始实现快速投票应用
在本章中,我们将讨论以下内容:
-
分析快速投票的要求
-
识别快速投票资源
-
设计展示
-
实施快速投票
到目前为止,我们已经了解了 REST 的基础知识,并回顾了我们的技术实现选择——Spring MVC。现在是时候开发一个更复杂的应用了。在这一章中,我们将向你介绍一个应用的开端,我们将在整本书中使用它。我们称之为快速投票。我们将经历分析需求、识别资源、设计它们的表示,以及最后提供一个特性子集的实现的过程。在接下来的章节中,我们将通过添加新的特性、文档、安全性和版本来继续我们的设计和实现。
快速投票简介
如今,在许多网站上,民意调查已经成为征求社区观点和意见的一个流行选项。在线调查之间有一些差异,但是一个调查通常有一个问题和一个答案列表,如图 4-1 所示。

图 4-1
Web 投票示例
参与者通过选择一个或多个答案来投票并表达他们的观点。很多民调还允许参与者查看民调结果,如图 4-2 所示。

图 4-2
网络投票结果
想象一下成为 QuickPoll Inc .的一部分,这是一家新兴的软件即服务(或 SaaS)提供商,允许用户创建、操作和投票。我们计划向一小部分受众推出我们的服务,但我们打算成为一家全球性企业。除了网络之外,QuickPoll 还希望瞄准本地 iOS 和 Android 平台。为了实现这些崇高的目标,我们选择使用 REST 原则和 web 技术来实现我们的应用。
我们通过分析和理解需求来开始开发过程。我们的 QuickPoll 应用有以下要求:
-
用户与 QuickPoll 服务交互以创建新的投票。
-
每个投票都包含一组在投票创建过程中提供的选项。
-
投票中的选项可以在以后更新。
-
为了简单起见,QuickPoll 限制了对单一选项的投票。
-
参与者可以投任意数量的票。
-
任何人都可以查看投票结果。
我们从快速投票的一组简单要求开始。与任何其他应用一样,这些需求会不断发展和变化。我们将在接下来的章节中讨论这些变化。
设计快速投票
正如第一章所讨论的,设计一个 RESTful 应用通常包括以下步骤:
-
资源标识
-
资源表示
-
端点标识
-
动词/动作识别
资源标识
我们通过分析需求和提取名词开始资源识别过程。在高层次上,QuickPoll 应用有用户,他们创建投票并与之交互。从前面的语句中,您可以将 User 和 Poll 标识为名词,并将它们归类为资源。类似地,用户可以对投票进行投票并查看投票结果,从而使投票成为另一种资源。这个资源建模过程类似于数据库建模,因为它用于标识实体或标识域对象的面向对象的设计。
重要的是要记住,并非所有被识别的名词都需要作为资源公开。例如,一个投票包含几个选项,使选项成为另一个候选资源。使轮询选项成为一个资源需要一个客户机发出两个 GET 请求。第一个请求将获得一个投票表示;第二个请求将获得相关的选项表示。然而,这种方法使 API 变得冗长,并可能使服务器过载。另一种方法是在投票表示中包含选项,从而将选项作为资源隐藏起来。这将使 poll 成为粗粒度的资源,但是客户机将在一次调用中获得与 Poll 相关的数据。此外,第二种方法可以实施业务规则,例如要求至少有两个选项才能创建投票。
这种名词的方法让我们能够识别馆藏资源。现在,考虑这样一个场景,您想要检索给定投票的所有投票。要处理这个问题,您需要一个“投票”集合资源。您可以执行 GET 请求并获取整个集合。类似地,我们需要一个“投票”集合资源,它允许我们查询投票组并创建新的投票组。
最后,我们需要处理这样一个场景,在这个场景中,我们对一次投票的所有投票进行计数,并将计算结果返回给客户端。这包括循环一次投票的所有投票,根据选项对这些投票进行分组,然后对它们进行计数。这种处理操作通常使用“控制器”资源来实现,我们在第一章中介绍过。在这种情况下,我们为执行计数操作的计算机结果资源建模。表 4-1 显示了已识别的资源及其集合资源对应项。
表 4-1
快速投票应用的资源
|资源
|
描述
|
| --- | --- |
| 用户 | 单一用户资源 |
| 用户 | 集合用户资源 |
| 投票 | 单一轮询资源 |
| 民意调查 | 收集轮询资源 |
| 投票 | 单一投票资源 |
| 投票 | 集合投票资源 |
| 计算机结果 | 计数处理资源 |
资源表示
REST API 设计过程的下一步是定义资源表示和表示格式。REST APIs 通常支持多种格式,比如 HTML、JSON 和 XML。格式的选择很大程度上取决于 API 的受众。例如,公司内部的 REST 服务可能只支持 JSON 格式,而公共 REST API 可能支持 XML 和 JSON 格式。在本章和本书的其余部分,JSON 将是我们操作的首选格式。
JSON Format
JavaScript Object Notation(JSON)是一种用于交换信息的轻量级格式。JSON 中的信息是围绕两种结构组织的:对象和数组。
JSON 对象是名称/值对的集合。每个名称/值对由双引号中的字段名、冒号(:)和字段值组成。JSON 支持几种类型的值,如布尔值(true 或 false)、数字(int 或 float)、字符串、null、数组和对象。名称/值对的示例包括
"country" : "US"
"age" : 31
"isRequired" : true
"email" : null
JSON 对象用大括号({})括起来,每个名称/值对用逗号(,)分隔。下面是一个 person JSON 对象的例子:
{ "firstName": "John", "lastName": "Doe", "age" : 26, "active" : true }
另一个 JSON 结构是数组,是值的有序集合。每个数组都用方括号([ ])括起来,值之间用逗号分隔。以下是位置数组的示例:
[ "Salt Lake City", "New York", "Las Vegas", "Dallas"]
JSON 数组也可以包含对象作为它们的值:
[
{ "firstName": "Jojn", "lastName": "Doe", "age": 26, "active": true },
{ "firstName": "Jane", "lastName": "Doe", "age": 22, "active": true },
{ "firstName": "Jonnie", "lastName": "Doe", "age": 30, "active": false }
]
资源由一组属性组成,可以使用类似于面向对象设计的过程来识别这些属性。例如,投票资源有一个包含投票问题的问题属性和一个唯一标识投票的 id 属性。它还包含一组选项;每个选项都由一个值和一个 id 组成。清单 4-1 显示了一个带有样本数据的投票的表示。
{
"id": 2,
"question": "How will win SuperBowl this year?",
"options": [{"id": 45, "value": "New England Patriots"}, {"id": 49,
"value": "Seattle Seahawks"}, {"id": 51, "value": "Green Bay Packers"},
{"id": 54, "value": "Denver Broncos"}]
}
Listing 4-1Poll Representation
Note
在本章中,我们有意将用户排除在投票代表之外。在第八章中,我们将讨论用户表示及其与投票和投票资源的关联。
轮询集合资源的表示包含单个轮询的集合。清单 4-2 给出了带有虚拟数据的轮询收集资源的表示。
[
{
"id": 5,
"question": "q1",
"options": [
{"id": 6, "value": "X"}, {"id": 9, "value": "Y"},
{"id": 10, "value": "Z"}]
},
{
"id": 2,
"question": "q10",
"options": [{"id": 15, "value": "Yes"}, {"id": 16, "value": "No"}]
}
.......
]
Listing 4-2List of Polls Representation
投票资源包含投票的选项和唯一标识符。清单 4-3 显示了带有虚拟数据的投票资源表示。
{
"id": 245,
"option": {"id": 45, "value": "New England Patriots"}
}
Listing 4-3Vote Representation
清单 4-4 给出了带有虚拟数据的投票集合资源表示。
{
"id": 245,
"option": {"id": 5, "value": "X"}
},
{
"id": 110,
"option": {"id": 7, "value": "Y"}
},
............
Listing 4-4List of Votes Representation
ComputeResult 资源表示应该包括投票和投票选项的总数,以及与每个选项相关联的投票计数。清单 [4-5 用样本数据展示了这种表示。我们使用 totalVotes 属性保存投票,使用 results 属性保存选项 id 和相关的投票。
{
totalVotes: 100,
"results" : [
{ "id" : 1, "count" : 10 },
{ "id" : 2, "count" : 8 },
{ "id" : 3, "count" : 6 },
{ "id" : 4, "count" : 4 }
]
}
Listing 4-5ComputeResult Representation
既然我们已经定义了我们的资源表示,我们将继续为这些资源识别端点。
端点标识
REST 资源使用 URI 端点来标识。设计良好的 REST APIs 应该具有易于理解、直观且易于使用的端点。请记住,我们构建 REST APIs 是为了让消费者使用。因此,我们为端点选择的名称和层次结构对消费者来说应该是明确的。
我们使用行业中广泛使用的最佳实践和惯例为我们的服务设计端点。第一个约定是为我们的 REST 服务使用一个基本 URI。基本 URI 提供了访问 REST API 的入口点。公共 REST API 提供者通常使用子域如 http://api.domain.com 或 http://dev.domain.com 作为他们的基本 URI。流行的例子有 GitHub 的 https://api.github.com 和 Twitter 的 https://api.twitter.com 。通过创建单独的子域,您可以防止与网页发生任何可能的域名冲突。它还允许您实施不同于常规网站的安全策略。为了简单起见,在本书中我们将使用http://localhost:8080作为我们的基地 URI。
第二个约定是使用复数名词命名资源端点。在我们的 QuickPoll 应用中,这将导致一个端点http://localhost:8080/polls用于访问轮询收集资源。将使用诸如http://localhost:8080/polls/1234和http://localhost:8080/polls/3456之类的 URI 来访问各个投票资源。我们可以使用 URI 模板http://localhost:8080/polls/{pollId}来概括对个人投票资源的访问。类似地,端点http://localhost:8080/users和http://localhost:8080/users/{userId}用于访问集合和个人用户资源。
第三个约定建议使用 URI 层次结构来表示彼此相关的资源。在我们的 QuickPoll 应用中,每个投票资源都与一个投票资源相关。因为我们通常为投票投票,所以建议使用分层端点http://localhost:8080/polls/{pollId}/votes来获取或操作与给定投票相关的所有投票。同样,端点http://localhost:8080/polls/{pollId}/votes/{voteId}将返回投票的个人投票。
最后,端点http://localhost:8080/computeresult可以用来访问 ComputeResult 资源。为了使该资源正常工作并计算投票数,需要一个投票 id。因为ComputeResult与Vote、Poll和Option资源一起工作,所以我们不能使用第三种方法来设计本质上是分层的 URI。对于需要数据来执行计算的用例,第四个约定推荐使用查询参数。例如,客户机可以调用端点http://localhost:8080/computeresult?pollId=1234来计算 id 为 1234 的投票的所有票数。查询参数是向资源提供附加信息的极好工具。
在本节中,我们已经确定了 QuickPoll 应用中资源的端点。下一步是确定这些资源上允许的操作,以及预期的响应。
动作识别
HTTP 动词允许客户端使用其端点进行交互和访问资源。在我们的 QuickPoll 应用中,客户端必须能够对资源(如 Poll 和 Vote)执行一个或多个 CRUD 操作。分析“快速轮询简介”部分的用例,表 4-2 显示了轮询/轮询收集资源上允许的操作以及成功和错误响应。注意,在轮询收集资源上,我们允许 GET 和 POST 操作,但拒绝 PUT 和 Delete 操作。集合资源上的 POST 允许客户端创建新的投票。类似地,我们允许对给定的轮询资源执行 GET、PUT 和 Delete 操作,但拒绝 POST 操作。对于不存在的轮询资源上的任何 GET、PUT 和 DELETE 操作,该服务返回 404 状态代码。类似地,任何服务器错误都会导致向客户端发送状态代码 500。
表 4-2
轮询资源上允许的操作
|超文本传送协议
方法
|
资源
端点
|
投入
|
成功响应
|
错误响应
|
描述
|
| --- | --- | --- | --- | --- | --- |
| 得到 | /polls | 主体:空的 | 状态:200 正文:投票列表 | 现状:500 | 检索所有可用的投票 |
| 邮政 | /polls | 正文:新的民意调查数据 | 现状:201 正文:新创建的投票 id | 现状:500 | 创建新的投票 |
| 放 | /polls | 不适用的 | 不适用的 | 现状:400 | 禁止动作 |
| 删除 | /polls | 不适用的 | 不适用的 | 现状:400 | 禁止动作 |
| 得到 | /polls/{pollId} | 主体:空的 | 状态:200 正文:民意测验数据 | 状态:404 或 500 | 检索到现有的投票 |
| 邮政 | /polls/{pollId} | 不适用的 | 不适用的 | 现状:400 | 被禁止的 |
| 放 | /polls/{pollId} | 正文:带更新的轮询数据 | 状态:200 主体:空的 | 状态:404 或 500 | 更新现有投票 |
| 删除 | /polls/{pollId} | 主体:空的 | 状态:200 | 状态:404 或 500 | 删除现有的投票 |
同样,表 4-3 显示了投票/选票收集资源上允许的操作。
表 4-3
投票资源上允许的操作
|超文本传送协议
方法
|
资源
端点
|
投入
|
成功响应
|
错误响应
|
描述
|
| --- | --- | --- | --- | --- | --- |
| 得到 | /polls/{pollId}/votes | 主体:空的 | 状态:200 正文:投票列表 | 现状:500 | 检索给定投票的所有可用投票 |
| 邮政 | /polls/{pollId}/votes | 正文:新投票 | 现状:201 正文:新创建的投票 id | 现状:500 | 创建新投票 |
| 放 | /polls/{pollId}/votes | 不适用的 | 不适用的 | 现状:400 | 禁止动作 |
| 删除 | /polls/{pollId}/votes | 不适用的 | 不适用的 | 现状:400 | 禁止动作 |
| 得到 | /polls/{pollId}/votes/{voteId} | 主体:空的 | 状态:200 正文:投票数据 | 状态:404 或 500 | 检索现有投票 |
| 邮政 | /polls/{pollId}/votes/{voteId} | 不适用的 | 不适用的 | 现状:400 | 被禁止的 |
| 放 | /polls/{pollId}/votes/{voteId} | 不适用的 | 不适用的 | 现状:400 | 禁止投票不能根据我们的要求更新 |
| 删除 | /polls/{pollId}/votes/{voteId} | 不适用的 | 不适用的 | 现状:400 | 根据我们的要求,禁止投票不能被删除 |
最后,表 4-4 显示了 ComputeResult 资源上允许的操作。
表 4-4
计算机上允许的操作结果资源
|超文本传送协议
方法
|
资源
端点
|
投入
|
成功响应
|
错误响应
|
描述
|
| --- | --- | --- | --- | --- | --- |
| 得到 | /computeresult | 主体:空的参数:pollId | 状态:200 正文:投票计数 | 现状:500 | 返回给定投票的投票数 |
QuickPoll REST 服务的设计到此结束。在我们开始实施之前,我们将回顾一下 QuickPoll 的高级架构。
快速轮询架构
QuickPoll 应用将由一个 web 或 REST API 层和一个存储库层组成,一个域层(Web API 和存储库之间的层)横切这两个层,如图 4-3 所示。分层方法提供了清晰的关注点分离,使得应用易于构建和维护。每一层都使用定义明确的契约与下一层进行交互。只要契约得到维护,就有可能交换底层实现,而不会对整个系统产生任何影响。

图 4-3
快速轮询架构
Web API 层负责接收客户端请求、验证用户输入、与服务或存储库层交互以及生成响应。使用 HTTP 协议,在客户端和 Web API 层之间交换资源表示。这一层包含控制器/处理程序,通常非常轻量级,因为它将大部分工作委托给它下面的层。
领域层被认为是应用的“心脏”。这一层中的域对象包含业务规则和业务数据。这些对象是以系统中的名词为模型的。例如,我们的 QuickPoll 应用中的 Poll 对象将被视为域对象。
存储库或数据访问层负责与数据存储(如数据库、LDAP 或遗留系统)进行交互。它通常提供 CRUD 操作,用于在数据存储中存储和检索对象。
Note
细心的读者会注意到 QuickPoll 架构缺少一个服务层。服务层通常位于 API/表示层和存储库层之间。它包含粗粒度的 API,其方法满足一个或多个用例。它还负责管理事务和其他横切关注点,如安全性。
因为在本书中,我们不会处理 QuickPoll 应用的任何复杂用例,所以我们不会在我们的架构中引入服务层。
实施快速投票
我们通过使用 STS 生成一个 Spring Boot 项目来开始 QuickPoll 的实现。按照第三章的“使用 STS 生成项目”一节中讨论的步骤,创建一个名为 quick-poll 的项目。图 4-4 和 4-5 给出了项目生成过程中使用的配置信息。请注意,我们已经选择了“JPA”和“Web”选项。

图 4-5
QuickPoll Spring starter 项目依赖项

图 4-4
QuickPoll 春季启动项目
或者,您可以从本书的下载源代码中将 QuickPoll 项目导入您的 STS IDE。下载的源代码包含多个名为ChapterX的文件夹,其中X代表对应的章节号。每个ChapterX文件夹进一步包含两个子文件夹:一个starter文件夹和一个final文件夹。starter文件夹中有一个 QuickPoll 项目,您可以使用它来跟踪本章中描述的解决方案。
即使每一章都建立在前一章的基础上,starter 项目也允许你在书中跳来跳去。例如,如果您对了解安全性感兴趣,您可以简单地在Chapter8\starter文件夹下加载 QuickPoll 应用,并按照第八章中描述的解决方案进行操作。
顾名思义,final文件夹包含每章的完整解决方案/代码。为了尽量减少本章文本中的代码,我在一些代码清单中省略了 getter/setter 方法、导入和包声明。请参考final文件夹下的快速轮询代码,获取完整的代码列表。
默认情况下,Spring Boot 应用在端口 8080 上运行。因此,如果您打算运行两个版本的 QuickPoll,只需使用命令行选项-Dserver.port:
mvn spring-boot:run -Dserver.port=8181
Note
Java 持久性 API(JPA)是一种基于标准的 API,用于访问、存储和管理 Java 对象和关系数据库之间的数据。像 JDBC 一样,JPA 纯粹是一个规范,许多商业和开源产品如 Hibernate 和 TopLink 都提供 JPA 实现。对 JPA 的正式概述超出了本书的范围。详情请参考 Pro JPA 2 ( http://www.apress.com/9781430219569/ )了解。
域实现
域对象通常充当任何应用的主干。因此,我们实现过程的下一步是创建域对象。图 4-5 显示了一个 UML 类图,它代表了 QuickPoll 应用中的三个域对象以及它们之间的关系。

图 4-6
快速轮询域对象
在 quick-poll 项目中,在/src/main/java文件夹下创建一个com.apress.domain子包,并创建对应于我们识别的域对象的 Java 类。清单 4-6 给出了Option类的实现。如您所见,Option类有两个字段:id,用于保存身份;和value,对应选项值。此外,您将看到我们已经用 JPA 注释对该类进行了注释,例如@Entity和@Id。这允许使用 JPA 技术轻松持久化和检索Option类的实例。
package com.apress.domain;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@Entity
public class Option {
@Id
@GeneratedValue
@Column(name="OPTION_ID")
private Long id;
@Column(name="OPTION_VALUE")
private String value;
// Getters and Setters omitted for brevity
}
Listing 4-6Option Class
接下来,我们创建一个Poll类,如清单 4-7 所示,以及相应的 JPA 注释。Poll类有一个问题字段来存储投票问题。@OneToMany注释,顾名思义,表示一个Poll实例可以包含零个或多个Option实例。CascadeType.All表示任何数据库操作,比如对一个Poll实例的持久化、删除或合并,都需要传播到所有相关的Option实例。例如,当一个Poll实例被删除时,所有相关的Option实例都将从数据库中删除。
package com.apress.domain;
import java.util.Set;
import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToMany;
import javax.persistence.OrderBy ;
@Entity
public class Poll {
@Id
@GeneratedValue
@Column(name="POLL_ID")
private Long id;
@Column(name="QUESTION")
private String question;
@OneToMany(cascade=CascadeType.ALL)
@JoinColumn(name="POLL_ID")
@OrderBy
private Set<Option> options;
// Getters and Setters omitted for brevity
}
Listing 4-7Poll Class
最后,我们创建了Vote类,如清单 4-8 所示。@ManyToOne注释表明一个Option实例可以有零个或多个Vote实例与之相关联。
package com.apress.domain;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
@Entity
public class Vote {
@Id
@GeneratedValue
@Column(name="VOTE_ID")
private Long id;
@ManyToOne
@JoinColumn(name="OPTION_ID")
private Option option;
// Getters and Setters omitted for brevity
}
Listing 4-8Vote Class
知识库实现
存储库或数据访问对象(DAO)提供了与数据存储交互的抽象。传统上,存储库包括一个接口,该接口提供一组 finder 方法,例如用于检索数据的findById、findAll,以及保存和删除数据的方法。存储库还包括一个使用特定于数据存储的技术实现该接口的类。例如,处理数据库的存储库使用 JDBC 或 JPA 等技术,处理 LDAP 的存储库使用 JNDI。通常每个域对象都有一个存储库。
尽管这是一种流行的方法,但是在每个存储库实现中都有许多重复的样板代码。开发人员试图将通用功能抽象成通用接口和通用实现( http ://www.ibm.com/developerworks/library/j-genericdao/ )。然而,他们仍然需要为每个域对象创建一对存储库接口和类。通常这些接口和类是空的,只会导致更多的维护。
Spring Data 项目旨在通过完全消除编写任何存储库实现的需要来解决这个问题。使用 Spring 数据,您需要的只是一个存储库接口,在运行时自动生成它的实现。唯一的要求是应用存储库接口应该扩展许多可用的 Spring 数据标记接口中的一个。因为我们将使用 JPA 将 QuickPoll 域对象持久化到关系数据库中,所以我们将使用 Spring Data JPA 子项目的org.springframework.data.repository.CrudRepository标记接口。从清单 4-9 中可以看出,CrudRepository接口将它所操作的域对象的类型和域对象的标识符字段的类型作为其通用参数T和ID。
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S var1);
<S extends T> Iterable<S> saveAll(Iterable<S> var1);
Optional<T> findById(ID var1);
Iterable<T> findAll();
Iterable<T> findAllById(Iterable<ID> var1);
void deleteById(ID var1);
void delete(T var1);
void deleteAllById(Iterable<? extends ID> var1);
void deleteAll(Iterable<? extends T> var1);
void deleteAll();
// Utility Methods
long count();
boolean existsById(ID var1);
}
Listing 4-9CrudRepository API
我们通过在src\main\java文件夹下创建一个com.apress.repository包来开始我们的存储库实现。然后,我们创建一个如清单 4-10 所示的OptionRepository接口。如前所述,OptionRepository扩展了 Spring Data 的CrudRepository,从而继承了它所有的 CRUD 方法。因为OptionRepository与Option域对象一起工作,所以它将Option和Long作为通用参数值传递。
package com.apress.repository;
import org.springframework.data.repository.CrudRepository;
import com.apress.domain.Option;
public interface OptionRepository extends CrudRepository<Option, Long> {
}
Listing 4-10OptionRepository Interface
采用同样的方法,我们然后创建PollRepository和VoteRepository接口,如清单 4-11 和 4-12 所示。
public interface VoteRepository extends CrudRepository<Vote, Long> {
}
Listing 4-12OptionRepository Interface
public interface PollRepository extends CrudRepository<Poll, Long> {
}
Listing 4-11PollRepository Interface
嵌入式数据库
在上一节中,我们创建了存储库,但是我们需要一个关系数据库来保存数据。关系数据库市场充满了各种选择,从 Oracle 和 SQL Server 等商业数据库到 MySQL 和 PostgreSQL 等开源数据库。为了加快 QuickPoll 应用的开发,我们将使用 HSQLDB,这是一个流行的内存数据库。内存数据库(也称为嵌入式数据库)不需要任何额外的安装,可以简单地作为 JVM 进程运行。它们的快速启动和关闭能力使它们成为原型和集成测试的理想选择。同时,它们通常不提供持久存储,应用需要在每次启动时播种数据库。
Spring Boot 为嵌入 HSQLDB、H2 和 Derby 的数据库提供了出色的支持。唯一的要求是在pom.xml文件中包含一个构建依赖项。Spring Boot 负责在部署期间启动数据库,并在应用关闭期间停止数据库。不需要提供任何数据库连接 URL 或用户名和密码。清单 4-13 显示了需要添加到 QuickPoll 的pom.xml文件中的依赖信息。
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<scope>runtime</scope>
</dependency>
Listing 4-13HSQLDB POM.XML Dependency
API 实现
在这一节中,我们将创建 Spring MVC 控制器并实现我们的 REST API 端点。我们首先在src\main\java下创建com.apress.controller包来容纳所有的控制器。
PollController 实现
PollController提供了所有必要的端点来访问和操作轮询和轮询资源。清单 4-14 显示了一个基本的PollController类。
package com.apress.controller;
import javax.inject.Inject;
import org.springframework.web.bind.annotation.RestController;
import com.apress.repository.PollRepository;
@RestController
public class PollController {
@Inject
private PollRepository pollRepository;
}
Listing 4-14PollController Class
用一个@RestController注释对PollController类进行了注释。@RestController是一个方便而有意义的注释,与添加@Controller和@ResponseBody注释具有相同的效果。因为我们需要读取和存储Poll实例,所以我们使用@Inject注释将一个PollRepository实例注入到我们的控制器中。作为 Java EE 6 的一部分引入的javax.inject.Inject注释提供了声明依赖关系的标准机制。我们使用这个注释来支持 Spring 专有的@Autowired注释,以便更加兼容。为了使用@Inject注释,我们需要将清单 4-15 中所示的依赖项添加到pom.xml文件中。
<dependency>
<groupId>javax.inject</groupId>
<artifactId>javax.inject</artifactId>
<version>1</version>
</dependency>
Listing 4-15Inject Dependency in POM File
在/polls端点上的 GET 请求提供了 QuickPolls 应用中所有可用投票的集合。清单 4-16 显示了实现该功能的必要代码。shortcut注释声明了 URI 和允许的 HTTP 方法。getAllPolls方法使用ResponseEntity作为其返回类型,表明返回值是完整的 HTTP 响应。ResponseEntity让您完全控制 HTTP 响应,包括响应体和响应头。方法实现从使用PollRepository读取所有轮询开始。然后我们创建一个ResponseEntity的实例,并传入Poll数据和HttpStatus.OK状态值。Poll数据成为响应主体的一部分,OK(代码 200)成为响应状态代码。
@GetMapping("/polls")
public ResponseEntity<Iterable<Poll>> getAllPolls() {
Iterable<Poll> allPolls = pollRepository.findAll();
return new ResponseEntity<>(pollRepository.findAll(), HttpStatus.OK);
}
Listing 4-16GET Verb Implementation for /polls
让我们通过运行 QuickPoll 应用来快速测试我们的实现。在命令行中,导航到quick-poll项目目录并运行以下命令:
mvn spring-boot:run
在你的 Chrome 浏览器中启动 Postman 应用,输入网址http://localhost:8080/polls,如图 4-7 所示,点击发送。因为我们还没有创建任何投票,这个命令将导致一个空的集合。

图 4-7
获取所有投票请求
Note
下载的源代码包含一个导出的 Postman 集合,其中的请求可用于运行本章中的测试。只需将这个集合导入到您的 Postman 应用中,并开始使用它。
我们的下一站是实现向PollController添加新轮询的功能。我们通过实现后动词功能来实现这一点,如清单 4-17 所示。createPoll方法接受一个类型为Poll的参数。@RequestBody注释告诉 Spring 整个请求体需要转换成Poll的一个实例。Spring 使用传入的Content-Type头来标识一个合适的消息转换器,并将实际的转换委托给它。Spring Boot 附带了支持 JSON 和 XML 资源表示的消息转换器。在方法内部,我们简单地将Poll持久性委托给PollRepository的save方法。然后我们创建一个新的状态为CREATED (201)的ResponseEntity并返回它。
@PostMapping("/polls")
public ResponseEntity<?> createPoll(@RequestBody Poll poll) {
poll = pollRepository.save(poll);
return new ResponseEntity<>(null, HttpStatus.CREATED);
}
Listing 4-17Implementation to Create New Poll
虽然这个实现满足了请求,但是客户端无法知道新创建的Poll的 URI。例如,如果客户想要将新创建的Poll共享到一个社交网站,当前的实现是不够的。最佳实践是使用Location HTTP 头将 URI 传递给新创建的资源。构建 URI 需要我们检查HttpServletRequest对象,以获得诸如根 URI 和上下文之类的信息。Spring 通过其ServletUriComponentsBuilder实用程序类简化了 URI 生成过程:
URI newPollUri = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(poll.getId())
.toUri();
fromCurrentRequest方法通过从HttpServletRequest复制主机、模式、端口等信息来准备构建器。path方法将传入的路径参数附加到构建器中现有的路径上。在createPoll方法的情况下,这将导致http://localhost:8080/polls/{id}。buildAndExpand方法将构建一个UriComponents实例,并用传入的值替换任何路径变量(在我们的例子中是{id})。最后,我们调用UriComponents类上的toUri方法来生成最终的 URI。清单 4-18 显示了createPoll方法的完整实现。
@PostMapping("/polls")
public ResponseEntity<?> createPoll(@RequestBody Poll poll) {
poll = pollRepository.save(poll);
// Set the location header for the newly created resource
HttpHeaders responseHeaders = new HttpHeaders();
URI newPollUri = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(poll.getId())
.toUri();
responseHeaders.setLocation(newPollUri);
return new ResponseEntity<>(null, responseHeaders, HttpStatus.CREATED);
}
Listing 4-18Complete Implementation of Create Poll
要测试我们新添加的功能,请启动 QuickPoll 应用。如果您已经运行了应用,您需要终止该进程并重新启动它。如图 4-8 所示,在 Postman 中输入信息,点击发送。确保您已经添加了值为application/json的Content-Type标题。正文中使用的 JSON 如下所示:

图 4-8
创建投票邮递员示例
{
"question": "Who will win SuperBowl this year?",
"options": [
{"value": "New England Patriots"},
{"value": "Seattle Seahawks"},
{"value": "Green Bay Packers"},
{"value": "Denver Broncos"}]
}
请求完成后,您将看到状态 201 创建的消息和标题:
Content-Length ® 0
Date ® Mon, 23 Feb 2015 00:05:11 GMT
Location ® http://localhost:8080/polls/1
Server ® Apache-Coyote/1.1
现在让我们将注意力转向访问个人投票。清单 4-19 给出了必要的代码。shortcut annotations (@GetMapping, @PostMapping, etc.)中的值属性采用 URI 模板/polls/{pollId}。占位符{pollId}和@PathVarible注释允许 Spring 检查请求 URI 路径并提取 pollId 参数值。在这个方法中,我们使用PollRepository的findById finder 方法来读取投票,并将其作为ResponseEntity的一部分传递。
@GetMapping("/polls/{pollId}")
public ResponseEntity<?> getPoll(@PathVariable Long pollId) {
Optional<Poll> poll = pollRepository.findById(pollId);
if(!poll.isPresent()) {
throw new Exception("Pool not found");
}
return new ResponseEntity<>(poll.get(), HttpStatus.OK);
}
Listing 4-19Retrieving an Individual Poll
以同样的方式,我们实现了更新和删除一个Poll的功能,如清单 4-20 所示。
@PutMapping("/polls/{pollId}")
public ResponseEntity<?> updatePoll(@RequestBody Poll poll, @PathVariable Long pollId) {
// Save the entity
Poll newPoll = pollRepository.save(poll);
return new ResponseEntity<>(HttpStatus.OK);
}
@DeleteMapping("/polls/{pollId}")
public ResponseEntity<?> deletePoll(@PathVariable Long pollId) {
pollRepository.deleteById(pollId);
return new ResponseEntity<>(HttpStatus.OK);
}
Listing 4-20Update and Delete a Poll
一旦将这段代码添加到PollController中,重启 QuickPoll 应用并执行 Postman 请求,如图 4-8 所示,以创建一个新的投票。然后输入图 4-9 中的信息,创建一个新的邮递员请求并更新轮询。请注意,PUT 请求包含整个Poll表示以及 id。

图 4-9
更新投票
这就结束了PostController的实现。
VoteController 实现
遵循用于创建 PollController 的原则,我们实现了VoteController类。清单 4-21 给出了VoteController类的代码以及创建投票的功能。VoteController使用VoteRepository的注入实例在Vote实例上执行 CRUD 操作。
@RestController
public class VoteController {
@Inject
private VoteRepository voteRepository;
@PostMapping("/polls/{pollId}/votes")
public ResponseEntity<?> createVote(@PathVariable Long pollId, @RequestBody Vote vote) {
vote = voteRepository.save(vote);
// Set the headers for the newly created resource
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setLocation(ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(vote.getId()).toUri());
return new ResponseEntity<>(null, responseHeaders, HttpStatus.CREATED);
}
}
Listing 4-21VoteController Implementation
为了测试投票能力,向/polls/1/votes端点发送一个新的投票,在请求体中有一个选项,如图 4-10 所示。在成功执行请求时,您将看到一个值为http://localhost:8080/polls/1/votes/1的Location响应头。

图 4-10
投新的一票
接下来,我们看一下实现检索给定投票的所有投票的能力。VoteRepository中的findAll方法返回数据库中的所有投票。因为这不能满足我们的需求,我们需要向清单 4-22 所示的VoteRepository添加这个功能。
import org.springframework.data.jpa.repository.Query;
public interface VoteRepository extends CrudRepository<Vote, Long> {
@Query(value="select v.* from Option o, Vote v where o.POLL_ID = ?1 and
v.OPTION_ID = o.OPTION_ID", nativeQuery = true)
public Iterable<Vote> findByPoll(Long pollId);
}
Listing 4-22Modified VoteRepository Implementation
自定义查找器方法findVotesByPoll将Poll的 ID 作为其参数。这个方法上的@Query注释接受一个原生 SQL 查询以及设置为true的nativeQuery标志。在运行时,Spring Data JPA 用传入的pollId参数值替换?1占位符。接下来,我们在VoteController中实现/polls/{pollId}/votes端点,如清单 4-23 所示。
@GetMapping("/polls/{pollId}/votes")
public Iterable<Vote> getAllVotes(@PathVariable Long pollId) {
return voteRepository. findByPoll(pollId);
}
Listing 4-23GET All Votes Implementation
ComputeResultController 实现
我们剩下的最后一块工作是实现ComputeResult资源。因为我们没有任何域对象可以直接帮助生成这个资源表示,所以我们实现了两个数据传输对象或 dto—OptionCount和VoteResult。OptionCount DTO 包含选项的 ID 和为该选项投票的计数。VoteResult DTO 包含总投票数和一组OptionCount实例。这两个 dto 是在com.apress.dto包下创建的,它们的实现在清单 4-24 中给出。
package com.apress.dto;
public class OptionCount {
private Long optionId;
private int count;
// Getters and Setters omitted for brevity
}
package com.apress.dto;
import java.util.Collection;
public class VoteResult {
private int totalVotes;
private Collection<OptionCount> results;
// Getters and Setters omitted for brevity
}
Listing 4-24DTOs for ComputeResult Resources
遵循创建PollController和VoteController的原则,我们创建一个新的ComputeResultController类,如清单 4-25 所示。我们将VoteRepository的一个实例注入控制器,它用于检索给定投票的投票。computeResult方法将pollId作为其参数。@RequestParam注释指示 Spring 从 HTTP 查询参数中检索pollId值。使用新创建的ResponseEntity实例将计算结果发送给客户机。
package com.apress.controller;
@RestController
public class ComputeResultController {
@Inject
private VoteRepository voteRepository;
@GetMapping("/computeresult")
public ResponseEntity<?> computeResult(@RequestParam Long pollId) {
VoteResult voteResult = new VoteResult();
Iterable<Vote> allVotes = voteRepository.findByPoll(pollId);
// Algorithm to count votes
return new ResponseEntity<VoteResult>(voteResult, HttpStatus.OK);
}
}
Listing 4-25ComputeResultController implementation
有几种方法可以计算与每个选项相关的票数。该代码提供了一个这样的选项:
int totalVotes = 0;
Map<Long, OptionCount> tempMap = new HashMap<Long, OptionCount>();
for(Vote v : allVotes) {
totalVotes ++;
// Get the OptionCount corresponding to this Option
OptionCount optionCount = tempMap.get(v.getOption().getId());
if(optionCount == null) {
optionCount = new OptionCount();
optionCount.setOptionId(v.getOption().getId());
tempMap.put(v.getOption().getId(), optionCount);
}
optionCount.setCount(optionCount.getCount()+1);
}
voteResult.setTotalVotes(totalVotes);
voteResult.setResults(tempMap.values());
这就结束了ComputeResult控制器的实现。启动/重新启动快速轮询应用。使用先前的 Postman 请求,创建一个投票并对其选项进行投票。然后创建一个新的 Postman 请求,如图 4-11 所示,并提交它来测试我们的/computeresult端点。

图 4-11
计算机结果终点测试
成功完成后,您将看到类似如下的输出:
{
"totalVotes": 7,
"results": [
{
"optionId": 1,
"count": 4
},
{
"optionId": 2,
"count": 3
}
]
}
摘要
在本章中,我们学习了为 QuickPoll 应用创建 RESTful 服务。我们在这一章中的大多数例子都假设了一条“幸福之路”,在这条路上一切都按计划进行。然而,这在现实世界中很少发生。在下一章中,我们将着眼于处理错误、验证输入数据以及传达有意义的错误信息。
五、错误处理
在本章中,我们将讨论以下内容:
-
在 REST API 中处理错误
-
设计有意义的错误响应
-
验证 API 输入
-
外部化错误消息
对于程序员来说,错误处理是最重要的话题之一,但也是容易被忽视的话题。尽管我们怀着良好的意图开发软件,但事情确实会出错,我们必须准备好优雅地处理和交流这些错误。对于使用 REST API 的开发人员来说,通信方面尤其重要。设计良好的错误响应允许开发人员理解问题,并帮助他们正确使用 API。此外,良好的错误处理允许 API 开发人员记录有助于他们调试问题的信息。
快速轮询错误处理
在我们的 QuickPoll 应用中,考虑用户试图检索不存在的投票的场景。图 5-1 显示了邮递员请求一个 id 为 100 的不存在的轮询。

图 5-1
请求一个不存在的投票
收到请求后,QuickPoll 应用中的PollController使用PollRepository来检索投票。由于 id 为 100 的 poll 不存在,PollRepository的findById方法返回一个空选项,PollController向客户端发送一个空正文,如图 5-2 所示。

图 5-2
对不存在的投票的响应
Note
在本章中,我们将继续使用我们在上一章中构建的 QuickPoll 应用。代码也可以在下载的源代码的Chapter5\starter文件夹下找到。完成的解决方案可以在Chapter5\final文件夹下找到。由于我们在本章的一些清单中省略了 getter/setter 方法和导入,请参考final文件夹下的代码以获得完整的清单。Chapter5文件夹还包含一个导出的 Postman 集合,该集合包含与本章相关的 REST API 请求。
这种当前的实现是欺骗性的,因为客户端接收到状态码 200。相反,应该返回状态代码 404,表明请求的资源不存在。为了实现这个正确的行为,我们将在com.apress.controller.PollController的getPoll方法中验证投票 id,对于不存在的投票,抛出一个com.apress.exception.ResourceNotFoundException异常。清单 5-1 显示了修改后的getPoll实现。
@GetMapping("/polls/{pollId}")
public ResponseEntity<?> getPoll(@PathVariable Long pollId) {
Optional<Poll> poll = pollRepository.findById(pollId);
if(!poll.isPresent()) {
throw new ResourceNotFoundException("Poll with id " + pollId + " not found");
}
return new ResponseEntity<>(poll.get(), HttpStatus.OK);
}
Listing 5-1getPoll Implementation
ResourceNotFoundException是一个自定义异常,其实现如清单 5-2 所示。请注意,在类级别声明了一个@ResponseStatus注释。注释指示 Spring MVC,当抛出ResourceNotFoundException时,应该在响应中使用HttpStatus NOT_FOUND (404 代码)。
package com.apress.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
private static final long serialVersionUID = 1L;
public ResourceNotFoundException() {}
public ResourceNotFoundException(String message) {
super(message);
}
public ResourceNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
Listing 5-2ResourceNotFoundException Implementation
完成这些修改后,启动 QuickPoll 应用,并运行 Postman 请求,以进行 ID 为 100 的轮询。PollController返回正确的状态码,如图 5-3 所示。

图 5-3
对不存在的投票的新响应
除了 GET 之外,PUT、DELETE 和 PATCH 等其他 HTTP 方法也作用于现有的轮询资源。因此,我们需要在相应的方法中执行相同的轮询 ID 验证,以便向客户端返回正确的状态代码。清单 5-3 显示了封装到PollController的verifyPoll方法中的轮询 id 验证逻辑,以及修改后的getPoll、updatePoll和deletePoll方法。
protected Poll verifyPoll(Long pollId) throws ResourceNotFoundException {
Optional<Poll> poll = pollRepository.findById(pollId);
if(!poll.isPresent()) {
throw new ResourceNotFoundException("Poll with id " + pollId + " not found");
}
return poll.get();
}
@GetMapping("/polls/{pollId}")
public ResponseEntity<?> getPoll(@PathVariable Long pollId) {
return new ResponseEntity<>(verifyPoll(pollId), HttpStatus.OK);
}
@PutMapping("/polls/{pollId}")
public ResponseEntity<?> updatePoll(@RequestBody Poll poll, @PathVariable Long pollId) {
verifyPoll(pollId);
pollRepository.save(poll);
return new ResponseEntity<>(HttpStatus.OK);
}
@DeleteMapping("/polls/{pollId}")
public ResponseEntity<?> deletePoll(@PathVariable Long pollId) {
pollRepository.deleteById(pollId);
pollRepository.delete(pollId);
return new ResponseEntity<>(HttpStatus.OK);
}
Listing 5-3Updated PollController
错误响应
HTTP 状态代码在 REST APIs 中扮演着重要的角色。API 开发者应该努力返回指示请求状态的正确代码。此外,在响应正文中提供关于错误的有用的、细粒度的详细信息也是一种很好的做法。这些细节将使 API 消费者能够轻松地解决问题,并帮助他们恢复。如图 5-3 所示,Spring Boot 遵循这一惯例,并在错误响应主体中包含以下详细信息:
-
时间戳—错误发生的时间,以毫秒为单位。
-
status—与错误相关联的 HTTP 状态代码;这部分是多余的,因为它与响应状态代码相同。
-
错误—与状态代码相关联的描述。
-
exception-导致此错误的异常类的完全限定路径。
-
消息—提供有关错误的更多详细信息的消息。
-
路径-导致异常的 URI。
这些细节是由 Spring Boot 框架生成的。在非引导 Spring MVC 应用中,这个特性不是现成可用的。在本节中,我们将使用通用的 Spring MVC 组件为 QuickPoll 应用实现一个类似的错误响应,以便它在引导和非引导环境中都可以工作。在我们深入研究这个实现之前,让我们来看看两个流行的应用的错误响应细节:GitHub 和 Twilio。图 5-4 显示了 GitHub 对包含无效输入的请求的错误响应细节。message 属性给出了错误的简单描述,error 属性列出了输入无效的字段。在本例中,客户端的请求缺少问题资源的标题字段。

图 5-4
GitHub 错误响应
Twilio 提供了一个 API,允许开发人员以编程方式打电话、发送文本和接收文本。图 5-5 显示了缺少“收件人”电话号码的 POST 调用的错误响应。“状态”和“消息”字段类似于 Spring Boot 回复中的字段。代码字段包含一个数字代码,可用于查找有关异常的更多信息。more_info 字段包含错误代码文档的 URL。收到该错误时,Twilio API 消费者可以导航到 https ://www.twilio.com/docs/errors/21201 并获得更多信息来解决该错误。

图 5-5
Twilio 错误响应
很明显,对于错误没有一个标准的响应格式。由 API 和框架实现者决定发送给客户机的细节。然而,标准化响应格式的尝试已经开始,一个被称为 HTTP APIs 问题细节( http://tools.ietf.org/html/draft-nottingham-http-problem-06 )的 IETF 规范正在获得关注。受“HTTP APIs 的问题细节”规范的启发,清单 5-4 展示了我们将在 QuickPoll 应用中实现的错误响应格式。
{
"title" : "",
"status" : "",
"detail" : ",
"timestamp" : "",
"developerMessage: "",
"errors": {}
}
Listing 5-4QuickPoll Error Response Format
以下是快速轮询错误响应中字段的简要描述:
-
Title—title字段提供错误情况的简短标题。例如,作为输入验证结果的错误将具有标题“验证失败”同样,“内部服务器错误”将用于内部服务器错误。 -
Status—status字段包含当前请求的 HTTP 状态代码。尽管在响应体中包含状态代码是多余的,但它允许 API 客户端在一个地方查找进行故障排除所需的所有信息。 -
Detail—detail字段包含错误的简短描述。该字段中的信息通常是人类可读的,并且可以呈现给最终用户。 -
Timestamp—错误发生的时间,以毫秒为单位。 -
developerMessage—developerMessage包含与开发人员相关的异常类名或堆栈跟踪等信息。 -
错误-错误字段用于报告字段验证错误。
既然我们已经定义了错误响应,我们就可以修改 QuickPoll 应用了。我们首先创建响应细节的 Java 表示,如清单 5-5 所示。如您所见,ErrorDetail类缺少错误字段。我们将在下一节中添加该功能。
package com.apress.dto.error;
public class ErrorDetail {
private String title;
private int status;
private String detail;
private long timeStamp;
private String developerMessage;
// Getters and Setters omitted for brevity
}
Listing 5-5Error Response Details Representation
错误处理是一个横切关注点。我们需要一个应用范围的策略,以相同的方式处理所有的错误,并将相关的细节写入响应体。正如我们在第二章中讨论的,用@ControllerAdvice标注的类可以用来实现这样的横切关注点。清单 5-6 显示了带有恰当命名的handleResourceNotFoundException方法的RestExceptionHandler类。多亏了@ExceptionHandler注释,每当控制器抛出ResourceNotFoundException时,Spring MVC 就会调用RestExceptionHandler的handleResourceNotFoundException方法。在这个方法中,我们创建了一个ErrorDetail的实例,并用错误信息填充它。
package com.apress.handler;
import java.util.Date;
import javax.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import com.apress.dto.error.ErrorDetail;
import com.apress.exception.ResourceNotFoundException;
@ControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<?> handleResourceNotFoundException(ResourceNotFoundException rnfe, HttpServletRequest request) {
ErrorDetail errorDetail = new ErrorDetail();
errorDetail.setTimeStamp(new Date().getTime());
errorDetail.setStatus(HttpStatus.NOT_FOUND.value());
errorDetail.setTitle("Resource Not Found");
errorDetail.setDetail(rnfe.getMessage());
errorDetail.setDeveloperMessage(rnfe.getClass().getName());
return new ResponseEntity<>(errorDetail, null, HttpStatus.NOT_FOUND);
}
}
Listing 5-6RestExceptionHandler Implementation
为了验证我们新创建的处理程序是否按预期工作,重新启动 QuickPoll 应用,并向 id 为 100 的不存在的 Poll 提交一个 Postman 请求。您应该会看到如图 5-6 所示的错误响应。

图 5-6
ResourceNotFoundException 错误响应
输入字段验证
正如一句著名的谚语所说,“垃圾进来,垃圾出去”;输入字段验证应该是每个应用中的另一个重点领域。考虑这样一个场景:一个客户端请求创建一个新的投票,但是请求中不包含投票问题。图 5-7 显示了一个缺少问题的邮递员请求和相应的响应。确保在触发 Postman 请求之前将 Content-Type 头设置为“application/json”。从响应中,您可以看到投票仍然被创建。创建缺少问题的投票可能会导致数据不一致和其他错误。

图 5-7
创建带有缺失问题的投票
Spring MVC 提供了两个选项来验证用户输入。在第一个选项中,我们创建了一个实现了org.springframework.validation.Validator接口的验证器。然后,我们将这个验证器注入到控制器中,并手动调用验证器的 validate 方法来执行验证。第二种选择是使用 JSR 303 验证,这是一种旨在简化应用任何层中的字段验证的 API。考虑到框架的简单性和声明性,我们将在本书中使用 JSR 303 验证框架。
您可以在 https://beanvalidation.org/1.0/spec 了解更多 JSR 303。
JSR 303 和 JSR 349 定义了 Bean 验证 API 的规范(分别是版本 1.0 和 1.1)。它们通过一组标准化的验证约束为 JavaBean 验证提供了一个元数据模型。使用这个 API,您可以用诸如@NotNull和@Email这样的验证约束来注释域对象属性。实现框架在运行时强制执行这些约束。在本书中,我们将使用 Hibernate Validator,这是一个流行的 JSR 303/349 实现框架。表 5-1 显示了 Bean 验证 API 提供的一些现成的验证约束。此外,还可以定义自己的自定义约束。
表 5-1
Bean 验证 API 约束
|限制
|
描述
|
| --- | --- |
| NotNull | 注释字段不能有空值。 |
| Null | 注释字段必须为空。 |
| Max | 带注释的字段值必须是小于或等于注释中指定的数字的整数值。 |
| Min | 带注释的字段值必须是大于或等于注释中指定的数字的整数值。 |
| Past | 带注释的字段必须是过去的日期。 |
| Future | 带注释的字段必须是未来的日期。 |
| Size | 注释字段必须与注释中指定的最小和最大边界相匹配。对于作为集合的字段,集合的大小与边界相匹配。对于字符串字段,字符串的长度根据边界进行验证。 |
| Pattern | 带注释的字段必须与注释中指定的正则表达式匹配。 |
为了给 QuickPoll 添加验证功能,我们从注释 Poll 类开始,如清单 5-7 所示。因为我们希望确保每个投票都有一个问题,所以我们用一个@NotEmpty注释对问题字段进行了注释。javax.validation.constraints.NotEmpty注释不是 JSR 303/349 API 的一部分。相反,它是 Hibernate 验证器的一部分;它确保输入字符串不为空,并且其长度大于零。此外,为了简化投票体验,我们将限制每个投票包含不少于两个且不超过六个选项。
@Entity
public class Poll {
@Id
@GeneratedValue
@Column(name="POLL_ID")
private Long id;
@Column(name="QUESTION")
@NotEmpty
private String question;
@OneToMany(cascade=CascadeType.ALL)
@JoinColumn(name="POLL_ID")
@OrderBy
@Size(min=2, max = 6)
private Set<Option> options;
// Getters and Setters removed for brevity
}
Listing 5-7Poll Class Annotated with JSR 303 Annotations
我们现在将注意力转移到com.apress.controller.PollController上,并向createPoll方法的Poll参数添加一个@Valid注释,如清单 5-8 所示。@Valid注释指示 Spring 在绑定用户提交的数据后执行数据验证。Spring 将实际的验证委托给一个注册的验证器。随着 Spring Boot 将 JSR 303/JSR 349 和 Hibernate 验证器 jar 添加到类路径中,JSR 303/JSR 349 被自动启用,并将用于执行验证。
@GetMapping(value="/polls")
public ResponseEntity<?> createPoll(@Valid @RequestBody Poll poll) {
poll = pollRepository.save(poll);
// Set the location header for the newly created resource
HttpHeaders responseHeaders = new HttpHeaders();
URI newPollUri = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(poll.getId())
.toUri();
responseHeaders.setLocation(newPollUri);
return new ResponseEntity<>(null, responseHeaders, HttpStatus.CREATED);
}
Listing 5-8PollController Annotated with @Valid Annotations
像图 5-7 中一样,用一个遗漏的问题重复邮递员请求,你会看到操作失败,错误代码为 400,如图 5-8 所示。从错误响应中,注意 Spring MVC 完成了对输入的验证。在没有找到必需的问题字段时,它抛出了一个MethodArgumentNotValidException异常。

图 5-8
遗漏问题导致错误
尽管 Spring Boot 的错误消息很有帮助,但是为了与我们在清单 5-4 中设计的快速轮询错误响应保持一致,我们将修改RestExceptionHandler,以便我们可以拦截MethodArgumentNotValidException异常并返回适当的ErrorDetail实例。当我们设计 QuickPoll 错误响应时,我们设计了一个错误字段来保存我们的验证错误。一个字段可能有一个或多个相关联的验证错误。例如,在我们的投票示例中,缺少问题字段将导致“字段不能为空”验证错误。同样,空电子邮件地址可能会导致“字段不能为空”和“字段不是格式良好的电子邮件”验证错误。请记住这些验证约束,清单 5-9 显示了一个完整的错误响应和验证错误示例。error对象包含一个无序的键值错误实例集合。error 键表示存在验证错误的资源馈送的名称。错误值是表示验证错误详细信息的数组。从清单 5-9 中,我们可以看到字段 1 包含一个验证错误,字段 2 与两个验证错误相关联。每个验证错误本身都由表示违反的约束的代码和包含人类可读错误表示的消息组成。
{
"title" : "",
"status" : "",
"detail" : ",
"timestamp" : "",
"path" : "",
"developerMessage: "",
"errors": {
"field1" : [ {
"code" : "NotNull",
message" : "Field1 may not be null"
} ],
"field2" : [ {
"code" : "NotNull",
"message" : "Field2 may not be null"
},
{
"code" : "Email",
"message" : "Field2 is not a well formed email"
}]
}
}
Listing 5-9Validation Error Format
为了在 Java 代码中表示新添加的验证错误特性,我们创建了一个新的com.apress.dto.error.ValidationError类。清单 5-10 显示了ValidationError类和更新后的ErrorDetail类。为了生成清单 5-9 中所示的错误响应格式,ErrorDetail类中的错误字段被定义为一个Map,它接受String实例作为键,接受ValidationError实例列表作为值。
package com.apress.dto.error;
public class ValidationError {
private String code;
private String message;
// Getters and Setters removed for brevity
}
public class ErrorDetail {
private String title;
private int status;
private String detail;
private long timeStamp;
private String path;
private String developerMessage;
private Map<String, List<ValidationError>> errors = new HashMap<String, List<ValidationError>>();
// Getters and setters removed for brevity
}
Listing 5-10ValidationError and Updated ErrorDetail Classes
下一步是通过添加一个拦截和处理MethodArgumentNotValidException异常的方法来修改RestExceptionHandler。清单 5-11 显示了RestExceptionHandler中的handleValidationError方法实现。我们通过创建一个ErrorDetail的实例并填充它来开始方法实现。然后,我们使用传入的异常参数来获取所有字段错误,并遍历列表。我们为每个字段错误创建了一个ValidationError实例,并用代码和消息信息填充它。
@ControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidationError(MethodArgumentNotValidException manve, HttpServletRequest request) {
ErrorDetail errorDetail = new ErrorDetail();
// Populate errorDetail instance
errorDetail.setTimeStamp(new Date().getTime());
errorDetail.setStatus(HttpStatus.BAD_REQUEST.value());
String requestPath = (String) request.getAttribute("javax.servlet.error.request_uri");
if(requestPath == null) {
requestPath = request.getRequestURI();
}
errorDetail.setTitle("Validation Failed");
errorDetail.setDetail("Input validation failed");
errorDetail.setDeveloperMessage(manve.getClass().getName());
// Create ValidationError instances
List<FieldError> fieldErrors = manve.getBindingResult().getFieldErrors();
for(FieldError fe : fieldErrors) {
List<ValidationError> validationErrorList = errorDetail.getErrors().get(fe.getField());
if(validationErrorList == null) {
validationErrorList = new ArrayList<ValidationError>();
errorDetail.getErrors().put(fe.getField(), validationErrorList);
}
ValidationError validationError = new ValidationError();
validationError.setCode(fe.getCode());
validationError.setMessage(fe.getDefaultMessage());
validationErrorList.add(validationError);
}
return new ResponseEntity<>(errorDetail, null, HttpStatus. BAD_REQUEST);
}
/** handleResourceNotFoundException method removed **/
}
Listing 5-11handleValidationError Implementation
实施完成后,重新启动 QuickPoll 应用并提交一个缺少问题的投票。这将产生一个带有我们自定义错误响应的状态代码 400,如图 5-9 所示。

图 5-9
验证错误响应
外部化错误消息
我们已经在输入验证方面取得了相当大的进展,并为客户提供了描述性的错误消息,可以帮助他们进行故障排除并从这些错误中恢复过来。然而,实际的验证错误消息可能不是非常具有描述性,API 开发人员可能想要更改它。如果他们能够从外部属性文件中提取该消息,那就更好了。属性文件方法不仅简化了 Java 代码,还使交换消息变得容易,而无需修改代码。它还为未来的国际化/本地化需求奠定了基础。为此,在src\main\resources文件夹下创建一个 messages.properties 文件,并添加以下两条消息:
NotEmpty.poll.question=Question is a required field
Size.poll.options=Options must be greater than {2} and less than {1}
如你所见,我们对消息的每个键都遵循了惯例<<Constraint_Name>>.model_name.field_Name。model_name表示用户提交的数据绑定到的 Spring MVC 模型对象的名称。该名称通常使用@ModelAttribute注释来提供。在缺少此注释的情况下,模型名称是使用参数的非限定类名派生的。PollController的createPoll方法将一个com.apress.domain.Poll实例作为其模型对象。因此,在这种情况下,模型名称将被派生为 poll 。如果控制器将com.apress.domain.SomeObject的一个实例作为其参数,那么派生的模型名称将是 someObject 。重要的是要记住,Spring 不会使用方法参数的名称作为模型名称。
下一步是从文件中读取属性,并在创建ValidationError实例时使用它们。我们通过在RestExceptionHandler类中注入一个MessageSource的实例来实现。Spring 的MessageSource提供了一个容易解析消息的抽象。清单 5-12 显示了handleValidationError修改后的源代码。注意,我们使用MessageResource's getMessage方法来检索消息。
@ControllerAdvice
public class RestExceptionHandler {
@Inject
private MessageSource messageSource;
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public @ResponseBody ErrorDetail handleValidationError(MethodArgumentNotValidException manve, HttpServletRequest request) {
ErrorDetail errorDetail = new ErrorDetail();
// Populate errorDetail instance
errorDetail.setTimeStamp(new Date().getTime());
errorDetail.setStatus(HttpStatus.BAD_REQUEST.value());
String requestPath = (String) request.getAttribute("javax.servlet.error.request_uri");
if(requestPath == null) {
requestPath = request.getRequestURI();
}
errorDetail.setTitle("Validation Failed");
errorDetail.setDetail("Input validation failed");
errorDetail.setDeveloperMessage(manve.getClass().getName());
// Create ValidationError instances
List<FieldError> fieldErrors = manve.getBindingResult().getFieldErrors();
for(FieldError fe : fieldErrors) {
List<ValidationError> validationErrorList = errorDetail.getErrors().get(fe.getField());
if(validationErrorList == null) {
validationErrorList = new ArrayList<ValidationError>();
errorDetail.getErrors().put(fe.getField(), validationErrorList);
}
ValidationError validationError = new ValidationError();
validationError.setCode(fe.getCode());
validationError.setMessage(messageSource.getMessage(fe, null));
validationErrorList.add(validationError);
}
return errorDetail;
}
}
Listing 5-12Reading Messages from Properties File
重新启动 QuickPoll 应用并提交一个缺少问题的投票将导致新的验证错误消息,如图 5-10 所示。

图 5-10
新验证错误消息
改进 RestExceptionHandler
默认情况下,Spring MVC 通过抛出一组标准异常来处理错误场景,比如无法读取格式错误的请求或者找不到所需的请求参数。然而,Spring MVC 不会将这些标准的异常细节写到响应体中。为了保持 QuickPoll 客户端的一致性,Spring MVC 标准异常也以相同的方式处理,并且我们返回相同的错误响应格式,这一点很重要。一种简单的方法是在 RestExceptionHandler 中为每个异常创建一个处理程序方法。更简单的方法是让RestExceptionHandler类扩展 Spring 的ResponseEntityExceptionHandler。ResponseEntityExceptionHandler类包含一组受保护的方法,这些方法处理标准异常并返回包含错误细节的ResponseEntity实例。
扩展ResponseEntityExceptionHandler类允许我们覆盖与异常相关联的受保护方法,并返回一个ErrorDetail实例。清单 5-13 显示了一个修改过的RestExceptionHandler,它覆盖了handleHttpMessageNotReadable方法。方法实现遵循我们之前使用的相同模式——创建并填充一个ErrorDetail的实例。因为ResponseEntityExceptionHandler已经附带了一个MethodArgumentNotValidException的处理程序方法,我们已经将handleValidationError方法代码移动到一个被覆盖的handleMethodArgumentNotValid方法中。
@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(
HttpMessageNotReadableException ex, HttpHeaders headers,
HttpStatus status, WebRequest request) {
ErrorDetail errorDetail = new ErrorDetail();
errorDetail.setTimeStamp(new Date().getTime());
errorDetail.setStatus(status.value());
errorDetail.setTitle("Message Not Readable");
errorDetail.setDetail(ex.getMessage());
errorDetail.setDeveloperMessage(ex.getClass().getName());
return handleExceptionInternal(ex, errorDetail, headers, status, request);
}
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNot
ValidException manve, HttpHeaders headers, HttpStatus status, WebRequest request) {
// implementation removed for brevity
return handleExceptionInternal(manve, errorDetail, headers, status, request);
}
}
Listing 5-13RestExceptionHandler Handling Malformed Messages
让我们通过使用 Postman 提交一个不可读的消息(比如从 JSON 请求体中删除一个“,”来快速验证我们的实现。您应该会看到如图 5-11 所示的响应。

图 5-11
不可读消息错误
摘要
在这一章中,我们为基于 Spring MVC 的 REST 应用设计并实现了一个错误响应格式。我们还研究了验证用户输入和返回对 API 消费者有意义的错误消息。在下一章,我们将看看使用 Swagger 框架记录 REST 服务的策略。
六、日志记录 REST 服务
在本章中,我们将讨论以下内容:
-
招摇的基础
-
将 Swagger 用于 API 文档
-
定制 Swagger
文档是任何项目的一个重要方面。对于企业和开源项目来说尤其如此,在这些项目中,许多人协作来构建项目。在这一章中,我们将看看 Swagger,一个简化 REST API 文档的工具。
记录 REST API 以供消费者使用和交互是一项困难的任务,因为没有真正建立的标准。组织在历史上依赖手动编辑的文档向客户公开 REST 合同。对于基于 SOAP 的 web 服务,WSDL 充当客户端的契约,并提供操作和相关请求/响应有效负载的详细描述。WADL,即 Web 应用描述语言,规范试图填补 REST web 服务世界中的这一空白,但它并没有被广泛采用。近年来,描述 REST 服务的元数据标准的数量有所增长,比如 Swagger、Apiary 和 iODocs。它们中的大多数都是出于记录 API 的需要,从而扩大了 API 的应用范围。
时髦的
Swagger ( http://swagger.io )是创建交互式 REST API 文档的规范和框架。它使文档与 REST 服务的任何更改保持同步。它还提供了一组用于生成 API 客户端代码的工具和 SDK 生成器。Swagger 最初是由 Wordnik 在 2010 年初开发的,目前由 SmartBear 软件提供支持。
Swagger 是一个与语言无关的规范,其实现可用于多种语言,如 Java、Scala 和 PHP。在 https://github.com/springfox/springfox 可以找到规格的完整描述。该规范由两种文件类型组成——一个资源列表文件和一组描述 REST API 和可用操作的 API 声明文件。
名为“api-docs”的资源列表文件是描述 api 的根文档。它包含关于 API 的一般信息,例如 API 版本、标题、描述和许可证。顾名思义,资源列表文件还包含应用中所有可用的 API 资源。清单 6-1 显示了一个假设的 REST API 的示例资源清单文件。注意,Swagger 使用 JSON 作为它的描述语言。从清单 6-1 中的APIs数组可以看到,资源清单文件声明了两个 API 资源,分别是products和orders。URIs /default/products和/default/orders允许您访问资源的 API 声明文件。Swagger 允许对其资源进行分组;默认情况下,所有资源都归入default组,因此在 URI 中是“/default”组。info对象包含与 API 相关的联系和许可信息。
{
"apiVersion": "1.0",
"swaggerVersion": "1.2"
"apis": [
{
"description": "Endpoint for Product management",
"path": "/default/products"
},
{
"description": "Endpoint for Order management",
"path": "/default/orders"
}
],
"authorizations": { },
"info" : {
"contact": "contact@test.com",
"description": "Api for an ecommerce application",
"license": "Apache 2.0",
"licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.html",
"termsOfServiceUrl": "Api terms of service",
"title": "ECommerce App"
}
}
Listing 6-1Sample Resource File
API 声明文件描述了资源以及 API 操作和请求/响应表示。清单 6-2 显示了用于product资源的示例 API 声明文件,将在 URI /default/products上提供。basePath字段提供了服务于 API 的根 URI。resourcePath指定了相对于basePath的资源路径。在这种情况下,我们指定产品的 REST API 可以在http://server:port/products访问。APIs字段包含描述 API 操作的 API 对象。清单 6-2 描述了一个名为createProduct的 API 操作及其相关的 HTTP 方法、消费/产生的消息的媒体类型以及 API 响应。models字段包含任何与资源相关的模型对象。清单 6-2 显示了一个与product资源相关联的product模型对象。
{
"apiVersion": "1.0",
"swaggerVersion": "1.2"
"basePath": "/",
"resourcePath": "/products",
"apis": [
{
"description": "createProduct",
"operations": [
{
"method": "POST",
"produces": [ "application/json" ],
"consumes": [ "application/json" ],
"parameters": [ { "allowMultiple": false} ],
"responseMessages": [
{
"code": 200,
"message": null,
"responseModel": "object"
}
]
}
],
"path": "/products"
}
],
"models": {
"Product": {
"description": "",
"id": "Product",
"properties": { }
}
}
}
Listing 6-2Sample Products API Declaration File at /default/products
Note
在我们假设的例子中,Swagger 期望产品资源的 API 声明文件驻留在“/default/products”URI。这不应该与访问产品资源的实际 REST API 位置混淆。在本例中,声明文件表明在http://server:port/products URI 可以访问产品资源。
整合 Swagger
集成 Swagger 包括创建“api-docs”资源列表文件和一组描述 API 资源的 API 声明文件。有几个 Swagger 和社区拥有的项目集成了现有的源代码并自动生成这些文件,而不是手工编码这些文件。springfox-boot-starter就是这样一个框架,它简化了 Swagger 与基于 Spring MVC 的项目的集成。我们通过在pom.xml文件中添加清单 6-3 所示的springfox-boot-starter Maven 依赖项,开始 Swagger 与 QuickPoll 应用的集成。
Note
我们继续我们的传统,建立在我们在前几章中对 QuickPoll 应用所做的工作之上。您也可以使用下载源代码的Chapter6\starter文件夹中的 starter 项目。完成的解决方案可以在Chapter6\final文件夹下找到。
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
Listing 6-3Springfox-Boot-Starter Dependency
下一步,我们必须定义如清单 6-4 所示的 bean Docket。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
@Configuration
public class SwaggerConfiguration {
@Bean
public Docket api() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.paths(PathSelectors.any())
.build();
}
}
Listing 6-4Define Docket Bean
有了这个最小配置,运行 QuickPoll 应用并启动 URI http://localhost:8080/v3/api-docs。您应该会看到如图 6-1 所示的资源列表文件。

图 6-1
快速轮询资源列表文件
Swagger UI
资源列表和 API 声明文件是理解 REST API 的宝贵资源。Swagger UI 是 Swagger 的一个子项目,它获取这些文件并自动生成一个愉快、直观的界面来与 API 交互。使用这个接口,技术人员和非技术人员都可以通过提交请求来测试 REST 服务,并查看这些服务如何响应。Swagger UI 是使用 HTML、CSS 和 JavaScript 构建的,没有任何其他外部依赖。它可以托管在任何服务器环境中,甚至可以从您的本地机器上运行。
springfox-boot-starter 已经包含了使用来自http://localhost:8080/v3/api-docs的 JSON 的 Swagger UI 的工作,并在可读的 UI 中解析 JSON,如图 6-2 所示。

图 6-2
QuickPoll Swagger UI
没有一些修改,我们准备推出 Swagger UI。运行快速投票应用并导航至 URL http://localhost:8080/swagger-ui.html。你应该会看到 QuickPoll Swagger UI,如图 6-2 所示。
使用 UI,您应该能够执行诸如创建投票和读取所有投票之类的操作。
定制 Swagger
在前面的章节中,您已经看到,通过最少的配置,我们能够使用 Swagger 创建交互式文档。此外,当我们对服务进行更改时,该文档会自动更新。然而,您会注意到开箱即用,标题和 API 描述并不十分直观。此外,诸如“服务条款”、“联系开发人员”等 URL 也不起作用。当您探索 UI 时,诸如 Poll 和 Vote 之类的响应类在 Swagger UI 中是不可见的,用户不得不猜测操作的返回类型。
Swagger Springfox 提供了一个名为 Docket 的便捷构建器,用于定制和配置 Swagger。Docket 提供了方便的方法和合理的缺省值,但是它本身使用 ApiInfo 类来执行实际的配置。我们通过在 QuickPoll 应用中的 com.apress 包下创建一个 SwaggerConfig 类来开始我们的 Swagger 定制。用清单 6-5 的内容填充新创建的类。
package com.apress;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import java.util.Collections;
@Configuration
public class SwaggerConfiguration {
@Bean
public Docket api() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.basePackage("com.apress.controller"))
.paths(PathSelectors.any())
.build()
.apiInfo(apiInfo());
}
private ApiInfo apiInfo() {
return new ApiInfo(
"QuickPoll REST API",
"QuickPoll Api for creating and managing polls",
"http://example.com/terms-of-service",
"Terms of service",
new Contact("Maxim Bartkov", "www.example.com", "info@example.com"),
"MIT License", "http://opensource.org/licenses/MIT", Collections.emptyList());
}
}
Listing 6-5Custom Swagger Implementation
SwaggerConfig类用@Configuration标注,表明它包含一个或多个 Spring bean 配置。因为Docket依赖于框架的SpringSwaggerConfig,我们注入一个SpringSwaggerConfig的实例供以后使用。SpringSwaggerConfig是一个 Spring 管理的 bean,它在 Spring 的组件扫描 JAR 文件期间被实例化。
configureSwagger方法包含了我们的 Swagger 配置的内容。该方法用@Bean注释,向 Spring 表明返回值是一个 Spring bean,需要在一个BeanFactory中注册。Swagger Springfox 框架拾取这个 bean 并定制 Swagger。我们通过创建一个 SwaggerSpringMvcPlugin 实例来开始方法实现。然后,使用ApiInfoBuilder,我们创建一个ApiInfo对象,包含与 QuickPoll 应用相关的标题、描述、联系人和许可信息。最后,我们将创建的apiInfo和apiVersion信息传递给Docket实例并返回它。
Note
有可能有多种方法产生Docketbean。每个Docket将产生一个单独的资源列表。这在相同的 Spring MVC 应用服务于多个 API 或相同 API 的多个版本的情况下非常有用。
添加了新的SwaggerConfig类后,运行 QuickPoll 应用并导航到http://localhost:8080/swagger-ui.html.你会看到我们的用户界面中反映的变化,如图 6-3 所示。

图 6-3
更新了快速投票界面
从图 6-3 中,您会注意到除了三个 QuickPoll REST 端点之外,还有一个 Spring Boot 的“/error”端点。因为这个端点实际上没有任何用途,所以让我们在 API 文档中隐藏它。为此,我们将使用Docket类的便捷的includePattern方法。includePattern方法允许我们指定哪些请求映射应该包含在资源列表中。清单 6-6 显示了SwaggerConfig的configureSwagger方法的更新部分。默认情况下,paths方法采用正则表达式,在我们的例子中,我们明确列出了我们想要包含的所有三个端点。
docket
.apiInfo(apiInfo)
.paths(PathSelectors.regex("/polls/*.*|/votes/*.*|/computeresult/*.*"));
Listing 6-6ConfigureSwagger Method with IncludePatterns
重新运行 QuickPoll 应用,您将看到 Spring Boot 错误控制器不再出现在文档中。
配置控制器
Swagger Core 提供了一组注释,使得定制控制器文档变得容易。在本节中,我们将定制PollController,但是同样的原则也适用于其他 REST 控制器。Chapter6\final中下载的代码具有所有控制器的完整定制。
我们首先用清单 6-7 所示的@Api注释来注释PollContoller。@Api注释将一个类标记为 Swagger 资源。Swagger 扫描用@Api注释的类,读取生成资源列表和 API 声明文件所需的元数据。在这里,我们表示与PollController相关的文档将在/polls举行。记得开箱即用,Swagger 使用类名并生成 URI poll-controller ( http://localhost:8080/swagger-ui/index.html#!/poll-controller)来托管文档。随着我们的改变,PollController Swagger 文档可以在http://localhost:8080/swagger-ui.html#!/polls获得。使用@Api注释,我们还提供了与我们的 Poll API 相关的描述。
import io.swagger.annotations.Api;
@RestController
@Api(value = "polls", description = "Poll API")
public class PollController {
// Implementation removed for brevity
}
Listing 6-7@Api Annotation in Action
运行 QuickPoll 应用,在http://localhost:8080/swagger-ui/index.html导航到 Swagger UI 时,您会注意到更新后的 URI 路径和描述,如图 6-4 所示。

图 6-4
更新的轮询端点
现在我们将继续使用@ApiOperation注释定制 API 操作。这个注释允许我们定制操作信息,比如名称、描述和响应。清单 6-8 显示了应用于createPoll、getPoll和getAllPolls方法的@ApiOperation。我们使用value属性来提供操作的简要描述。Swagger 建议将该字段限制为 120 个字符。“注释”字段可用于提供有关操作的更多描述性信息。
import io.swagger.annotations.ApiOperation;
@ApiOperation(value = "Creates a new Poll", notes="The newly created poll Id will be sent in the location response header", response = Void.class)
@PostMapping("/polls")
public ResponseEntity<Void> createPoll(@Valid @RequestBody Poll poll) {
.......
}
@ApiOperation(value = "Retrieves a Poll associated with the pollId", response=Poll.class)
@GetMapping("/polls/{pollId}")
public ResponseEntity<?> getPoll(@PathVariable Long pollId) {
.........
}
@ApiOperation(value = "Retrieves all the polls", response=Poll.class, responseContainer="List")
@GetMapping("/polls")
public ResponseEntity<Iterable<Poll>> getAllPolls() {
..........
}
Listing 6-8@ApiOperation Annotated Methods
成功完成的createPoll方法向客户端发送一个空的主体和一个状态代码 201。然而,因为我们返回的是一个ResponseEntity,Swagger 无法计算出正确的响应模型。我们使用ApiOperation的response属性并将其设置为Void.class来解决这个问题。我们还将方法返回类型从ResponseEntity<?>更改为ResponseEntity<Void>,以使我们的意图更加清晰。
getPoll方法返回与传入的pollId参数相关联的轮询。因此,我们将ApiOperation的response属性设置为Poll.class。因为getAllPolls方法返回了Poll实例的集合,所以我们使用了responseContainer属性并将其值设置为List。
添加这些注释后,重新运行并启动 QuickPoll 应用的 Swagger UI,以验证描述、响应模型和注释部分是否已更改。例如,单击“Poll API”旁边的“polls”链接来展开 PollController 的操作。然后单击 GET 旁边的“/polls/{pollId}”链接,查看与getPoll方法相关联的响应模型。图 6-5 显示了更新后的响应模型。

图 6-5
GetPoll 方法的更新模型
我们之前使用的@ApiOperation允许我们指定操作的默认返回类型。正如我们在整本书中看到的,定义良好的 API 使用额外的状态代码,Swagger 提供了@ApiResponse注释来配置代码和相关的响应体。清单 6-9 显示了用@ApiResponse标注的状态代码 201 和 500 的 createPoll 方法。Swagger 要求我们将所有的@ApiResponse注释放在一个包装器@ApiResponse注释中。对于状态代码 201,我们添加了说明,指出如何检索新创建的投票 ID。通过状态代码 500,我们已经表明响应主体将包含一个ErrorDetail实例。
import com.wordnik.swagger.annotations.ApiResponse;
import com.wordnik.swagger.annotations.ApiResponses;
@ApiOperation(value = "Creates a new Poll", notes="The newly created poll Id will be sent in the location response header", response = Void.class)
@ApiResponses(value = {@ApiResponse(code=201, message="Poll Created Successfully", response=Void.class),
@ApiResponse(code=500, message="Error creating Poll", response=ErrorDetail.class) } )
@PostMapping("/polls")
public ResponseEntity<Void> createPoll(@Valid @RequestBody Poll poll) {
// Content removed for brevity
}
Listing 6-9@ApiResponse Annotations
运行 QuickPoll 应用并导航到 Swagger UI。单击“Poll API”旁边的“polls”链接,展开 PollController 的操作。然后单击 POST 旁边的“/polls”链接,查看更新的 notes 和ErrorDetail模型模式。图 6-6 显示了预期的输出。

图 6-6
修改的响应消息
快速浏览图 6-6 可以看到,我们得到的响应比配置的消息要多。这是因为 Swagger out of the box 为每个 HTTP 方法添加了一组默认响应消息。可以使用清单 6-10 所示的Docket类中的useDefaultResponseMessages方法来禁用这种行为。
public class SwaggerConfig {
@Bean
public Docket configureSwagger() {
// Content removed
docket.useDefaultResponseMessages(false);
return docket;
}
}
Listing 6-10Ignore Default Response Messages
运行 QuickPoll 应用并重复这些步骤,查看与“/polls”URI 上的 POST 操作相关的响应消息。如图 6-7 所示,不再显示默认响应消息。

图 6-7
更新的响应消息
除了我们看到的配置选项之外,Swagger 还提供了以下注释来配置模型对象:
-
@ApiModel-允许改变模型名称或提供相关模型描述的注释 -
@ApiModelProperty—可用于提供属性描述和允许值列表并指示是否需要的注释
摘要
文档在理解和使用 REST API 的过程中扮演着重要的角色。在这一章中,我们回顾了 Swagger 的基础知识,并将其与 QuickPoll 应用集成,以生成交互式文档。我们还研究了如何定制 Swagger 来满足我们特定应用的需求。
在下一章中,我们将着眼于 REST API 的版本控制技术以及分页和排序功能的实现。
七、版本控制、分页和排序
在本章中,我们将讨论以下内容:
-
REST 服务的版本控制策略
-
添加分页功能
-
添加排序功能
我们都熟悉这句著名的谚语“生活中唯一不变的是变化。”这适用于软件开发。在这一章中,我们将把 API 版本化作为处理这种变化的一种方式。此外,处理大型数据集可能会有问题,尤其是在涉及移动客户端时。大型数据集还会导致服务器过载和性能问题。为了处理这个问题,我们将使用分页和排序技术,并以可管理的块发送数据。
版本控制
随着用户需求和技术的变化,无论我们的设计是如何计划的,我们最终都会改变我们的代码。这将涉及通过添加、更新、有时删除属性来对 REST 资源进行更改。虽然 API 的关键——读取、创建、更新和删除一个或多个资源——保持不变,但这可能会导致表示发生如此剧烈的变化,以至于可能会破坏任何现有的消费者。类似地,对功能的更改,如保护我们的服务和要求身份验证或授权,会破坏现有的消费者。这样的重大变化通常需要新版本的 API。
在本章中,我们将在 QuickPoll API 中添加分页和排序功能。正如您将在后面的小节中看到的,这种变化将导致为一些 GET HTTP 方法返回的表示发生变化。在我们对 QuickPoll API 进行版本化以处理分页和排序之前,让我们来看看一些版本化的方法。
版本控制方法
对 REST API 进行版本控制有四种流行的方法:
-
URI 版本控制
-
URI 参数版本化
-
接受标题版本控制
-
自定义标题版本
这些方法都不是灵丹妙药,每一种都有其优点和缺点。在这一节中,我们将研究这些方法以及一些使用它们的真实世界的公共 API。
URI 版本控制
在这种方法中,版本信息成为 URI 的一部分。例如,http://api.example.org/v1/users和http://api.example.org/v2/users代表一个应用 API 的两个不同版本。这里我们使用v符号来表示版本,跟在v后面的数字1和2表示第一个和第二个 API 版本。
URI 版本控制是最常用的方法之一,被主要的公共 API 使用,如 Twitter、LinkedIn、Yahoo 和 SalesForce。以下是一些例子:
-
SalesForce:
http://na1.salesforce.com/services/data/v26.0 -
Twilio:
https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls
如你所见,LinkedIn、Yahoo 和 SalesForce 使用了v符号。除了主要版本,SalesForce 还使用次要版本作为其 URI 版本的一部分。相比之下,Twilio 采用了一种独特的方法,在 URI 中使用时间戳来区分其版本。
将版本作为 URI 的一部分非常有吸引力,因为版本信息就在 URI。它还简化了 API 开发和测试。人们可以通过 web 浏览器轻松浏览和使用不同版本的 REST 服务。相反,这可能会使客户的生活变得困难。例如,考虑一个客户机在其数据库中存储对用户资源的引用。在切换到新版本时,这些引用会过时,客户必须进行大量数据库更新,以将引用升级到新版本。
URI 参数版本化
这类似于我们刚刚看到的 URI 版本控制,只是版本信息被指定为 URI 请求参数。例如,URI http://api.example.org/users?v=2使用版本参数v来表示 API 的第二个版本。version 参数通常是可选的,API 的默认版本将继续处理没有 version 参数的请求。通常,默认版本是 API 的最新版本。
尽管不如其他版本化策略流行,但是一些主要的公共 API(如 Netf lix)已经使用了这种策略。URI 参数版本化与 URI 版本化具有相同的缺点。另一个缺点是,一些代理不缓存带有 URI 参数的资源,导致额外的网络流量。
接受标题版本控制
这种版本控制方法使用Accept头来传递版本信息。因为标头包含版本信息,所以对于 API 的多个版本,将只有一个 URI。
到目前为止,我们已经使用了标准的媒体类型,如"application/json"作为Accept头的一部分,来表示客户端期望的内容类型。为了传递额外的版本信息,我们需要一个自定义的媒体类型。创建自定媒体类型时,以下约定很受欢迎:
vnd.product_name.version+ suffix
vnd是自定义媒体类型的起点,表示供应商。产品或生产商名称是产品的名称,用于区分这种媒体类型和其他自定义产品媒体类型。版本部分用字符串表示,如v1或v2或v3。最后,后缀用于指定媒体类型的结构。例如,+json后缀表示遵循为媒体类型"application/json."建立的准则的结构,RFC 6389 ( https://tools.ietf.org/html/rfc6839 )给出了标准化前缀的完整列表,例如+xml、+json和+zip。例如,使用这种方法,客户端可以发送一个application/vnd.quickpoll.v2+json accept 头来请求 API 的第二个版本。
Accept头文件版本控制方法变得越来越流行,因为它允许在不影响整个 API 的情况下对单个资源进行细粒度的版本控制。这种方法会使浏览器测试变得更加困难,因为我们必须精心制作Accept头。GitHub 是一个流行的公共 API,它使用了这种Accept头策略。对于不包含任何Accept头的请求,GitHub 使用最新版本的 API 来满足请求。
自定义标题版本
定制头版本化方法类似于Accept头版本化方法,除了使用定制头而不是Accept头。微软 Azure 采用了这种方法,并使用了自定义标题x-ms-version。例如,为了在撰写本书时获得 Azure 的最新版本,您的请求需要包括一个自定义标题:
x-ms-version: 2021-09-14
这种方法与Accept头文件方法有相同的优点和缺点。因为 HTTP 规范提供了一种通过Accept头实现这一点的标准方法,所以定制头的方法还没有被广泛采用。
弃用 API
当您发布一个 API 的新版本时,维护旧版本变得很麻烦,并可能导致维护噩梦。要维护的版本数量及其寿命取决于 API 用户群,但是强烈建议至少维护一个旧版本。
不再维护的 API 版本需要被弃用并最终退役。重要的是要记住,弃用是为了表明 API 仍然可用,但将来将不复存在。API 用户应该得到大量关于弃用的通知,这样他们就可以迁移到新版本。
快速轮询版本控制
在本书中,我们将使用 URI 版本化方法来对 QuickPoll REST API 进行版本化。
实现和维护不同版本的 API 可能很困难,因为它通常会使代码变得复杂。我们希望确保一个版本的代码中的更改不会影响其他版本的代码。为了提高可维护性,我们希望确保尽可能避免代码重复。以下是组织代码以支持多个 API 版本的两种方法:
-
完整的代码复制——在这种方法中,您复制整个代码库,并为每个版本维护并行的代码路径。流行的 API builder Apigility 采用这种方法,并为每个新版本克隆整个代码库。这种方法使得不影响其他版本的代码更改变得容易。它还可以轻松切换后端数据存储。这也将允许每个版本成为一个独立的可部署的工件。尽管这种方法提供了很大的灵活性,但是我们将复制整个代码库。
-
特定于版本的代码复制——在这种方法中,我们只复制特定于每个版本的代码。每个版本都可以有自己的一组控制器和请求/响应 DTO 对象,但会重用大多数公共服务和后端层。对于较小的应用,这种方法可以很好地工作,因为特定于版本的代码可以简单地分成不同的包。对重用代码进行更改时必须小心,因为它可能会影响多个版本。
Spring MVC 使得使用 URI 版本管理方法来管理 QuickPoll 应用变得很容易。考虑到版本控制在管理变更中起着至关重要的作用,我们在开发周期中尽早进行版本控制是非常重要的。因此,我们将为目前为止开发的所有 QuickPoll 服务分配一个版本(v1))。为了支持多个版本,我们将遵循第二种方法,创建一组单独的控制器。
Note
在本章中,我们将继续建立在前几章中对 QuickPoll 应用所做的工作。或者,您可以使用下载源代码的Chapter7\starter文件夹中的一个 starter 项目。完成的解决方案可以在Chapter7\final文件夹下找到。有关包含 getter/setter 和附加导入的完整列表,请参考此解决方案。下载的Chapter7文件夹还包含一个导出的 Postman 集合,其中包含与本章相关的 REST API 请求。
我们通过创建两个包com.apress.v1.controller和com.apress.v2.controller来开始版本化过程。将所有控制器从com.apress.controller包装中移到com.apress.v1.controller。对于新的v1包中的每个控制器,添加一个类级别的@RequestMapping ("/v1")注释。因为我们将有多个版本的控制器,所以我们需要给每个控制器指定唯一的组件名。我们将遵循将版本号附加到非限定类名的惯例来派生我们的组件名。使用这个约定,v1 PollController将有一个组件名pollControllerV1。
清单 7-1 显示了经过这些修改的PollController类的部分。注意,组件名是作为一个值提供给@RestController注释的。类似地,将组件名 voteControllerV1 分配给v1 VoteController,将computeResultControllerV1分配给v1 ComputeResultController。
package com.apress.v1.controller;
import org.springframework.web.bind.annotation.RequestMapping;
@RestController("pollControllerV1")
@RequestMapping("/v1")
@Api(value = "polls", description = "Poll API")
public class PollController {
}
Listing 7-1Version 1 of the Poll Controller
Note
尽管VoteController和ComputeResultControler的行为和代码在不同版本中不会改变,但我们复制代码是为了保持简单。在真实的场景中,将代码重构为可重用的模块,或者使用继承来避免代码重复。
有了类级别的@RequestMapping注释,v1 PollController中的所有 URIs 都变成了相对于"/v1/."的,重启 QuickPoll 应用,并使用 Postman 验证您可以在新的http://localhost:8080/v1/polls端点创建一个新的 Poll。
为了创建 API 的第二个版本,将所有控制器从v1包复制到v2包。将类级别的RequestMapping值从"/v1/"更改为"/v2/",组件名称后缀从"V1"更改为"V2."。清单 7-2 显示了PollController的V2版本的修改部分。因为v2 PollController是v1 PollController的副本,我们从清单 7-2 中省略了PollController类的实现。
@RestController("pollControllerV2")
@RequestMapping("/v2")
@Api(value = "polls", description = "Poll API")
public class PollController {
// Code copied from the v1 Poll Controller
}
Listing 7-2Version 2 of the Poll Controller
一旦您完成了对三个控制器的修改,重新启动 QuickPoll 应用,并使用 Postman 验证您可以使用http://localhost:8080/v2/polls端点创建一个新的轮询。类似地,通过访问http://localhost:8080/v2/votes和http://localhost:8080/v2/computeresult端点,验证您可以访问VoteController和ComputeResultController端点。
SwaggerConfig
我们所做的版本更改需要更改我们的 Swagger 配置,以便我们可以使用 UI 来测试两个 REST API 版本并与之交互。清单 7-3 展示了重构后的com.apress.SwaggerConfig类。正如上一章所讨论的,一个springfox.documentation.spring.web.plugins.Docket实例代表一个 Swagger 组。因此,重构后的SwaggerConfig类包含两个方法,每个方法返回一个代表 API 组的Docket实例。另外,请注意,我们已经将 API 信息提取到它自己的方法中,并使用它来配置Docket的两个实例。
package com.apress;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import java.util.Collections;
@Configuration
public class SwaggerConfiguration {
@Bean
public Docket apiV1() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.regex("/v1/*.*"))
.build()
.apiInfo(apiInfo("v1"))
.groupName("v1")
.useDefaultResponseMessages(false);
}
@Bean
public Docket apiV2() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.regex("/v2/*.*"))
.build()
.apiInfo(apiInfo("v2"))
.groupName("v2")
.useDefaultResponseMessages(false);
}
private ApiInfo apiInfo(String version) {
return new ApiInfo(
"QuickPoll REST API",
"QuickPoll Api for creating and managing polls",
version,
"Terms of service",
new Contact("Maxim Bartkov", "www.linkedin.com/in/bartkov-maxim", "maxgalayoutop@gmail.com"),
"MIT License", "http://opensource.org/licenses/MIT", Collections.emptyList());
}
}
Listing 7-3Refactored SwaggerConfig Class
使用这个新重构的SwaggerConfig,重新启动 QuickPoll 应用,并在http://localhost:8080/swagger-ui/index.html在 web 浏览器中启动 Swagger UI。UI 启动后,在 Swagger UI 的输入框中添加请求参数?group=v2 to the http://localhost:8080/v2/api-docs URI,并点击 Explore。你应该看到如图 7-1 所示的v2版本的 API 并与之交互。

图 7-1
QuickPoll 2.0 版本的 Swagger UI
这就结束了版本化 QuickPoll 应用所需的配置,并为在本章的最后两节中添加分页和排序支持做好了准备。
页码
REST APIs 被各种各样的客户端使用,从桌面应用到 Web,再到移动设备。因此,在设计能够返回大量数据集的 REST API 时,出于带宽和性能原因,限制返回的数据量是很重要的。在移动客户端使用 API 的情况下,带宽问题变得更加重要。限制数据可以极大地提高服务器从数据存储中更快地检索数据的能力,以及客户机处理数据和呈现 UI 的能力。通过将数据分割成离散的页面或分页数据,REST 服务允许客户端以可管理的块滚动和访问整个数据集。
在我们开始在 QuickPoll 应用中实现分页之前,让我们先来看看四种不同的分页样式:页码分页、限制偏移量分页、基于光标的分页和基于时间的分页。
页码分页
在这种分页方式中,客户端指定包含所需数据的页码。例如,一个客户机想要我们假设的博客服务的第 3 页中的所有博客文章,可以使用下面的 GET 方法:
http://blog.example.com/posts?page=3
这个场景中的 REST 服务将通过一组 posts 进行响应。返回的帖子数量取决于服务中设置的默认页面大小。客户端可以通过传入页面大小参数来覆盖默认页面大小:
http://blog.example.com/posts?page=3&size=20
GitHub 的 REST 服务使用这种分页风格。默认情况下,页面大小设置为 30,但可以使用per_page参数覆盖:
https://api.github.com/user/repos?page=2&per_page=100
限制偏移量分页
在这种分页风格中,客户端使用两个参数:limit 和 offset 来检索它们需要的数据。limit 参数指示要返回的最大元素数,offset 参数指示返回数据的起始点。例如,要检索从项目编号 31 开始的 10 篇博客文章,客户端可以使用以下请求:
http://blog.example.com/posts?limit=10&offset=30
基于光标的分页
在这种分页风格中,客户机利用一个指针或一个光标来浏览数据集。游标是服务生成的随机字符串,充当数据集中某项的标记。为了理解这种风格,考虑一个客户机发出以下请求来获取博客帖子:
http://blog.example.com/posts
收到请求后,服务将发送类似以下内容的数据:
{
"data" : [
... Blog data
],
"cursors" : {
"prev" : null,
"next" : "123asdf456iamcur"
}
}
该响应包含一组博客,代表整个数据集的一个子集。作为响应一部分的cursors包含一个prev字段,可用于检索之前的数据子集。然而,因为这是初始子集,所以prev字段值为空。客户端可以使用next字段中的光标值,通过以下请求获得下一个数据子集:
http://api.example.com/posts?cursor=123asdf456iamcur
在收到这个请求时,服务将发送数据以及prev和next光标字段。Twitter 和脸书等应用使用这种分页风格来处理数据频繁变化的实时数据集(tweets 和 posts)。生成的游标通常不会永久存在,应该仅用于短期分页目的。
基于时间的分页
在这种分页风格中,客户端指定一个时间范围来检索他们感兴趣的数据。脸书支持这种分页风格,并要求将时间指定为 Unix 时间戳。这是脸书的两个请求示例:
https://graph.facebook.com/me/feed?limit=25&until=1364587774
https://graph.facebook.com/me/feed?limit=25&since=1364849754
两个示例都使用 limit 参数来指示要返回的最大项数。until参数指定时间范围的结束,而since参数指定时间范围的开始。
分页数据
前面几节中的所有分页样式都只返回数据的子集。因此,除了提供请求的数据之外,服务传递特定于分页的信息也变得很重要,比如记录总数或总页数或当前页码和页面大小。以下示例显示了带有分页信息的响应正文:
{
"data": [
... Blog Data
],
"totalPages": 9,
"currentPageNumber": 2,
"pageSize": 10,
"totalRecords": 90
}
客户端可以使用分页信息来评估当前状态,并构造 URL 来获取下一个或上一个数据集。服务采用的另一项技术是在一个特殊的Link头中包含分页信息。Link报头被定义为 RFC 5988 ( http://tools.ietf.org/html/rfc5988 )的一部分。它通常包含一组现成的向前和向后滚动的链接。GitHub 使用这种方法;下面是一个Link头值的例子:
Link: <https://api.github.com/user/repos?page=3&per_page=100>; rel="next", <https://api.github.com/user/repos?page=50&per_page=100>; rel="last"
快速投票分页
为了在 QuickPoll 应用中支持大型投票数据集,我们将实现页码分页样式,并将在响应正文中包含分页信息。
我们从配置 QuickPoll 应用开始实现,在引导过程中将虚拟轮询数据加载到数据库中。这将使我们能够测试我们的轮询和排序代码。为此,将下载的章节代码中的import.sql文件复制到src\main\resources文件夹中。import.sql文件包含用于创建测试投票的 DML 语句。Hibernate 开箱即用加载类路径下的import.sql文件,并执行其中的所有 SQL 语句。重启 QuickPoll 应用,在 Postman 中导航到http://localhost:8080/v2/polls;它应该列出所有加载的测试轮询。
Spring Data JPA 和 Spring MVC 提供了对页码分页样式的现成支持,使得我们的 QuickPoll 分页实现变得容易。Spring Data JPA 中分页(和排序)功能的核心是清单 7-4 中所示的org.springframework.data.repository.PagingAndSortingRepository接口。
public interface PagingAndSortingRepository<T, ID extends Serializable> extends CrudRepository<T, ID> {
Page<T> findAll(Pageable pageable);
Iterable<T> findAll(Sort sort);
}
Listing 7-4Spring Data JPA’s Paging and Sorting Repository
PagingAndSortingRepository接口扩展了我们目前在 QuickPoll 应用中使用的CrudRepository接口。此外,它还添加了两个 finder 方法,返回与所提供的分页和排序标准相匹配的实体。负责分页的findAll方法使用一个Pageable实例来读取页面大小和页码等信息。此外,它还需要排序信息,这一点我们将在本章的后面部分详细介绍。这个findAll方法返回一个包含数据子集和以下信息的Page实例:
-
元素总数-结果集中的元素总数
-
元素数量—返回子集中的元素数量
-
大小—每页中元素的最大数量
-
总页数-结果集中的总页数
-
number—返回当前页码
-
last—指示它是否是最后一个数据子集的标志
-
first—指示它是否是第一个数据子集的标志
-
排序—返回用于排序的参数(如果有)
在 QuickPoll 中实现分页的下一步是让我们的PollRepository扩展PagingAndSortingRepository而不是当前的CrudRepository。清单 7-5 展示了新的PollRepository实现。因为PagingAndSortingRepository扩展了CrudRepository,我们 API 的第一个版本所需的所有功能都保持不变。
package com.apress.repository;
import org.springframework.data.repository.PagingAndSortingRepository;
import com.apress.domain.Poll;
public interface PollRepository extends PagingAndSortingRepository<Poll, Long> {
}
Listing 7-5PollRepository Implementation
将存储库更改为使用PagingAndSortingRepository就结束了分页所需的后端实现。我们现在继续重构 V2 PollController,以便它使用新的分页查找器方法。清单 7-6 展示了 V2 com.apress.v2.controller.PollController的重构后的getAllPolls方法。注意,我们已经将Pageable参数添加到了getAllPolls方法中。在"/polls,"上收到 GET 请求时,Spring MVC 检查请求参数,构造一个Pageable实例,并将其传递给getAllPolls方法。通常,传入的实例属于类型PageRequest。然后将Pageable参数传递给新的 finder 方法,分页数据作为响应的一部分被返回。
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@RequestMapping(value="/polls", method=RequestMethod.GET)
@ApiOperation(value = "Retrieves all the polls", response=Poll.class, responseContainer="List")
public ResponseEntity<Page<Poll>> getAllPolls(Pageable pageable) {
Page<Poll> allPolls = pollRepository.findAll(pageable);
return new ResponseEntity<>(allPolls, HttpStatus.OK);
}
Listing 7-6GetAllPolls Method with Paging Functionality
快速轮询分页实现到此结束。重启 QuickPoll 应用,并使用 Postman 提交一个 GET 请求to http://localhost:8080/v2/polls?page=0&size=2。响应应该包含两个带有分页相关元数据的轮询实例。图 7-2 显示了请求以及响应的元数据部分。

图 7-2
分页结果以及分页元数据
Note
Spring Data JPA 使用基于零索引的分页方法。因此,第一页的页码从 0 开始,而不是从 1 开始。
更改默认页面大小
Spring MVC 使用一个org.springframework.data.web.PageableHandlerMethodArgumentResolver从请求参数中提取分页信息,并将Pageable实例注入控制器方法。开箱即用的,PageableHandlerMethodArgumentResolver类将默认页面大小设置为 20。因此,如果您在http://localhost:8080/v2/polls上执行 GET 请求,响应将包括 20 次轮询。虽然 20 是一个很好的默认页面大小,但有时您可能希望在应用中全局更改它。为此,您需要用您选择的设置创建并注册一个新的PageableHandlerMethodArgumentResolver实例。
需要更改默认 MVC 行为的 Spring Boot 应用需要创建类型为org.springframework.web.servlet.config.annotation.WebMvcConfigurer的类,并使用其回调方法进行定制。清单 7-7 显示了在com.apress包中新创建的QuickPollMvcConfigAdapter类,配置为将默认页面大小设置为 5。这里我们使用的是WebMvcConfigurer's addArgumentResolvers回调方法。我们通过创建一个PageableHandlerMethodArgumentResolver的实例来开始方法实现。setFallbackPageable方法,顾名思义,是 Spring MVC 在请求参数中找不到分页信息时使用的方法。我们创建一个默认页面大小为 5 的PageRequest实例,并将其传递给setFallbackPageable方法。然后,我们使用传入的argumentResolvers参数向 Spring 注册我们的PageableHandlerMethodArgumentResolver实例。
package com.apress;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class QuickPollMvcConfigAdapter implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
PageableHandlerMethodArgumentResolver phmar = new PageableHandlerMethodArgumentResolver();
// Set the default size to 5
phmar.setFallbackPageable(PageRequest.of(0, 5));
argumentResolvers.add(phmar);
}
}
Listing 7-7Code to Change Default Page Size to 5
重新启动 QuickPoll 应用,并使用 Postman 对http://localhost:8080/v2/polls执行 GET 请求。您将会注意到,现在的回复只包括五个投票。相关的分页元数据如清单 7-8 所示。
{
..... Omitted Poll Data ......
"totalPages": 4,
"totalElements": 20,
"last": false,
"size": 5,
"number": 0,
"sort": null,
"numberOfElements": 5,
"first": true
}
Listing 7-8Paging Metadata for Default Page Size 5
问我问题
排序允许 REST 客户端决定数据集中项目的排列顺序。支持排序的 REST 服务允许客户端提交带有用于排序的属性的参数。例如,客户端可以提交以下请求,根据博客帖子的创建日期和标题对其进行排序:
http://blog.example.com/posts?sort=createdDate,title
升序排序或降序排序
REST 服务还允许客户端指定两种排序方向之一:升序或降序。由于这方面没有固定的标准,以下示例展示了指定排序方向的常用方法:
http://blog.example.com/posts?sortByDesc=createdDate&sortByAsc=title
http://blog.example.com/posts?sort=createdDate,desc&sort=title,asc
http://blog.example.com/posts?sort=-createdDate,title
在所有这些例子中,我们按照博客文章创建日期的降序来检索它们。然后,创建日期相同的帖子会根据标题进行排序:
-
在第一种方法中,
sort参数清楚地指定了方向应该是上升还是下降。 -
在第二种方法中,我们对两个方向使用了相同的参数名。但是,参数值说明了排序方向。
-
最后一种方法使用“
-”符号来表示任何以“-”为前缀的属性都应该按降序排序。没有以“-为前缀的属性将按升序排序。
快速投票排序
考虑到排序通常与分页一起使用,Spring Data JPA 的PagingAndSortingRepository和Pageable实现被设计为从头开始处理和服务排序请求。因此,我们不需要任何显式的排序实现。
为了测试排序功能,使用 Postman 向http://localhost:8080/v2/polls/?sort=question提交一个 GET 请求。您应该会看到响应,其中民意调查按照问题文本的升序排序,并带有排序元数据。图 7-3 显示了邮递员请求以及分类元数据。

图 7-3
排序元数据
为了对具有不同排序方向的多个字段进行排序,Spring MVC 要求您遵循上一节中讨论的第二种方法。以下请求按升序问题值和降序id值排序:
http://localhost:8080/v2/polls/?sort=question,asc&sort=id,desc
摘要
在本章中,我们回顾了 REST API 版本控制的不同策略。然后,我们使用 URL 版本控制方法在 QuickPoll 中实现了版本控制。我们还回顾了使用分页和排序技术处理大型数据集的不同方法。最后,我们使用 Spring Data 的现成功能来实现页码分页样式。在下一章,我们将回顾确保 REST 服务的策略。
八、安全
在本章中,我们将讨论以下内容:
-
保障休息服务的策略
-
OAuth 2.0
-
Spring 安全框架的基础
-
实施快速轮询安全性
要求安全性的传统 web 应用通常使用用户名/密码进行身份验证。REST 服务带来了有趣的安全问题,因为它们可以被各种客户端使用,比如浏览器和移动设备。它们也可以被其他服务使用,这种机器对机器的通信可能没有任何人机交互。客户端代表用户消费 REST 服务也并不罕见。在本章中,我们将探索在使用 REST 服务时可以使用的不同的认证/授权方法。然后,我们将研究使用这些方法来保护我们的 QuickPoll 应用。
保障休息服务
我们首先调查六种用于保护 REST 服务的流行方法:
-
基于会话的安全性
-
HTTP 基本身份验证
-
摘要认证
-
基于证书的安全性
-
扩展验证
-
OAuth
基于会话的安全性
基于会话的安全模型依赖于服务器端会话来跨请求保持用户的身份。在典型的 web 应用中,当用户试图访问受保护的资源时,会出现一个登录页面。认证成功后,服务器将登录用户的信息存储在 HTTP 会话中。在后续请求中,将查询会话以检索用户信息,并用于执行授权检查。如果用户没有适当的授权,他们的请求将被拒绝。图 8-1 是这种方法的图示。

图 8-1
基于会话的安全流
像 Spring Security 这样的框架提供了使用这种安全模型开发应用的所有必要的管道。这种方法对于向现有 Spring Web 应用添加 REST 服务的开发人员来说非常有吸引力。REST 服务将从会话中检索用户身份,以执行授权检查并相应地提供资源。然而,这种方法违反了无状态 REST 约束。此外,因为服务器保存客户端的状态,所以这种方法是不可伸缩的。理想情况下,客户端应该保存状态,而服务器应该是无状态的。
HTTP 基本身份验证
当涉及到人工交互时,可以使用登录表单来获取用户名和密码。然而,当我们的服务与其他服务对话时,这可能是不可能的。HTTP 基本身份验证提供了一种机制,允许客户端使用交互和非交互方式发送身份验证信息。
在这种方法中,当客户端向受保护的资源发出请求时,服务器会发送一个 401“未授权”响应代码和一个“WWW-Authenticate”头。标头的“Basic”部分表示我们将使用基本身份验证,而“realm”部分表示服务器上受保护的空间:
GET /protected_resource
401 Unauthorized
WWW-Authenticate: Basic realm="Example Realm"
收到响应后,客户端用分号将用户名和密码连接起来,Base64 编码连接起来的字符串。然后,它使用标准的Authorization头将信息发送到服务器:
GET /protected_resource
Authorization: Basic bHxpY26U5lkjfdk
服务器解码提交的信息并验证提交的凭证。验证成功后,服务器完成请求。整个流程如图 8-2 所示。

图 8-2
HTTP 基本认证流程
因为客户端在每个请求中都包含了身份验证信息,所以服务器变成了无状态的。重要的是要记住,客户端只是对信息进行编码,而不是加密。因此,在非 SSL/TLS 连接上,有可能进行中间人攻击并窃取密码。
摘要认证
摘要式身份验证方法类似于前面讨论的基本身份验证模型,只是用户凭据是加密发送的。客户端提交对受保护资源的请求,服务器用 401“未授权”响应代码和 WWW-Authenticate 头进行响应。下面是一个服务器响应的示例:
GET /protected_resource
401 Unauthorized
WWW-Authenticate: Digest realm="Example Realm", nonce="P35kl89sdfghERT10Asdfnbvc", qop="auth"
注意,WWW-Authenticate指定了摘要认证模式以及服务器生成的随机数和 qop。随机数是用于加密目的的任意令牌。qop 或“保护质量”指令可以包含两个值— "auth"或"auth-int":
-
qop 值
"auth"指示摘要用于认证目的。 -
值
"auth-int"表示摘要将用于认证和请求完整性。
在接收到请求时,如果 qop 值被设置为"auth,",则客户端使用以下公式生成摘要:
hash_value_1 = MD5(username:realm:password)
has_value_2 = MD5(request_method:request_uri)
digest = MD5(hash_value_1:nonce:hash_value_2)
如果 qop 值设置为"auth-int,",客户端通过包含请求体来计算摘要:
hash_value_1 = MD5(username:realm:password)
has_value_2 = MD5(request_method:request_uri:MD5(request_body))
digest = MD5(hash_value_1:nonce:hash_value_2)
默认情况下,MD5 算法用于计算哈希值。摘要包含在Authorization报头中,并发送给服务器。收到请求后,服务器计算摘要并验证用户的身份。验证成功后,服务器完成请求。该方法的完整流程如图 8-3 所示。

图 8-3
摘要认证流程
摘要式身份验证方法比基本身份验证更安全,因为密码绝不会以明文形式发送。但是,在非 SSL/TLS 通信中,窥探器仍然可以检索摘要并重放请求。解决这个问题的一个方法是将服务器生成的随机数限制为只能一次性使用。此外,因为服务器必须生成用于验证的摘要,所以它需要能够访问密码的纯文本版本。因此,它不能采用更安全的单向加密算法,如 bcrypt,并且更容易受到服务器端的攻击。
基于证书的安全性
基于证书的安全模型依靠证书来验证一方的身份。在基于 SSL/TLS 的通信中,客户端(如浏览器)通常使用证书来验证服务器的身份,以确保服务器是它所声称的那样。此模型可以扩展到执行相互身份验证,其中服务器可以请求客户端证书作为 SSL/TLS 握手的一部分,并验证客户端的身份。
在这种方法中,在接收到对受保护资源的请求时,服务器将其证书提供给客户端。客户端确保可信的证书颁发机构(CA)颁发了服务器的证书,并将其证书发送给服务器。服务器验证客户端的证书,如果验证成功,将授予对受保护资源的访问权限。该流程如图 8-4 所示。

图 8-4
基于证书的安全流程
基于证书的安全模型消除了发送共享机密的需要,使其比用户名/密码模型更安全。然而,证书的部署和维护可能非常昂贵,通常用于大型系统。
扩展验证
随着 REST APIs 的流行,使用这些 API 的第三方应用的数量也显著增加。这些应用需要用户名和密码来与 REST 服务交互,并代表用户执行操作。这带来了巨大的安全问题,因为第三方应用现在可以访问用户名和密码。第三方应用中的安全漏洞可能会危及用户信息。此外,如果用户更改了他的凭证,他需要记得去更新所有这些第三方应用。最后,这种机制不允许用户撤销对第三方应用的授权。在这种情况下,撤销的唯一选择是更改他的密码。
扩展验证和 OAuth 方案提供了一种代表用户访问受保护资源而无需存储密码的机制。在这种方法中,客户端应用通常使用登录表单向用户请求用户名和密码。然后,客户端将用户名和密码发送给服务器。服务器接收用户的凭证并验证它们。验证成功后,将向客户端返回一个令牌。客户端丢弃用户名和密码信息,并将令牌存储在本地。当访问用户的受保护资源时,客户端会在请求中包含令牌。这通常是使用定制的 HTTP 头(如X-Auth-Token)来完成的。令牌的寿命取决于实现的服务。令牌可以一直保留,直到服务器将其撤销,或者令牌可以在指定的时间段内过期。该流程如图 8-5 所示。

图 8-5
扩展验证安全流程
Twitter 之类的应用允许第三方应用使用扩展验证方案访问它们的 REST API。然而,即使有了扩展验证,第三方应用也需要捕获用户名和密码,这就留下了误用的可能性。考虑到 XAuth 的简单性,当同一个组织同时开发客户端和 REST API 时,它可能是一个不错的选择。
OAuth 2.0
开放授权(OAuth)是一个框架,用于代表用户访问受保护的资源,而无需存储密码。OAuth 协议于 2007 年首次推出,并被 2010 年推出的 OAuth 2.0 所取代。在本书中,我们将回顾 OAuth 2.0 和一般原则。
OAuth 2.0 定义了以下四个角色:
-
资源所有者—资源所有者是希望授予其部分帐户或资源访问权限的用户。例如,资源所有者可以是 Twitter 或脸书用户。
-
客户端—客户端是希望访问用户资源的应用。这可能是一个第三方应用,如 Klout (
https://klout.com/)想要访问用户的 Twitter 帐户。 -
授权服务器—授权服务器验证用户的身份,并向客户端授予令牌以访问用户的资源。
-
资源服务器—资源服务器托管受保护的用户资源。例如,这将是 Twitter API 来访问推文和时间线等等。
图 8-6 描述了这四个角色之间的相互作用。OAuth 2.0 要求这些交互在 SSL 上进行。

图 8-6
OAuth 2.0 安全流程
在客户端参与图 8-6 所示的“OAuth 舞蹈”之前,它必须向授权服务器注册。对于大多数公共 API,如脸书和 Twitter,这涉及到填写申请表和提供有关客户端的信息,如应用名称、基本域和网站。注册成功后,客户端将收到一个客户端 ID 和一个客户端密码。客户端 ID 用于唯一标识客户端,并且是公开可用的。这些客户端凭证在 OAuth 交互中起着重要的作用,我们稍后将对此进行讨论。
OAuth 交互始于用户表达对使用第三方应用“客户机”的兴趣。客户端代表用户请求访问受保护资源的授权,并将用户/资源所有者重定向到授权服务器。客户端可以将用户重定向到的 URI 示例如下所示:
https://oauth2.example.com/authorize?client_id=CLIENT_ID&response_type=auth_code&call_back=CALL_BACK_URI&scope=read,tweet
任何生产 OAuth 2.0 交互都必须使用 HTTPS,因此,URI 以 https 开始。CLIENT_ID用于向授权服务器提供客户端的身份。scope 参数提供了客户端需要的一组逗号分隔的作用域/角色。
收到请求后,授权服务器通常会通过登录表单向用户提供身份验证质询。用户提供他的用户名和密码。成功验证用户凭证后,授权服务器使用CALL_BACK_URI参数将用户重定向到客户端应用。授权服务器还将授权代码附加到CALL_BACK_URI参数值上。下面是授权服务器可能生成的 URL 示例:
https://mycoolclient.com/code_callback?auth_code=6F99A74F2D066A267D6D838F88
然后,客户端使用授权码向授权服务器请求访问令牌。为此,客户端通常会在 URI 上执行 HTTP POST,如下所示:
https://oauth2.example.com/access_token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&
auth_code=6F99A74F2D066A267D6D838F88
如您所见,客户端在请求中提供了其凭证。授权服务器验证客户端的身份和授权码。验证成功后,它返回一个访问令牌。以下是 JSON 格式的响应示例:
{"access_token"="f292c6912e7710c8"}
收到访问令牌后,客户机将向资源服务器请求受保护的资源,并传递它获得的访问令牌。资源服务器验证访问令牌并为受保护的资源提供服务。
OAuth 客户端配置文件
OAuth 2.0 的优势之一是它支持各种客户端配置文件,如“web 应用”、“本机应用”和“用户代理/浏览器应用”。前面讨论的授权代码流(通常称为授权许可类型)适用于具有基于 web 的用户界面和服务器端后端的“web 应用”客户端。这允许客户端将授权代码存储在安全的后端,并在将来的交互中重用它。其他客户端配置文件有自己的流程,这些流程决定了四个 OAuth 2.0 参与者之间的交互。
纯基于 JavaScript 的应用或原生应用不能安全地存储授权码。因此,对于这样的客户端,来自授权服务器的回调不包含授权代码。取而代之的是,隐式授权类型方法被采用,并且访问令牌被直接移交给客户端,然后用于请求受保护的资源。属于此客户端配置文件的应用没有客户端密码,只是使用客户端 ID 进行标识。
OAuth 2.0 还支持授权流,称为密码授权类型,类似于上一节讨论的扩展验证。在这个流程中,用户直接向客户端应用提供他的凭证。他永远不会被重定向到授权服务器。客户端将这些凭证传递给授权服务器,并接收用于请求受保护资源的访问令牌。
OAuth 1.0 引入了几个实现复杂性,尤其是在用客户端凭证签署请求的加密要求方面。OAuth 2.0 简化了这一点,消除了签名,并要求所有交互都使用 HTTPS。然而,由于 OAuth 2 的许多特性是可选的,该规范导致了不可互操作的实现。
刷新令牌与访问令牌
访问令牌的生命周期可能是有限的,客户端应该为令牌不再工作的可能性做好准备。为了防止资源所有者重复认证,OAuth 2.0 规范提供了刷新令牌的概念。当授权服务器生成访问令牌时,它可以选择性地发布刷新令牌。客户端存储该刷新令牌,并且当访问令牌到期时,它联系授权服务器以获得一组新的访问令牌以及刷新令牌。规范允许为授权和密码授予类型的流生成刷新令牌。考虑到“隐式授权类型”缺乏安全性,刷新令牌被禁止用于这种客户端配置文件。
Spring 安全性概述
为了在 QuickPoll 应用中实现安全性,我们将使用另一个流行的 Spring 子项目,即 Spring Security。在我们继续实现之前,让我们了解一下 Spring Security 和组成框架的不同组件。
Spring Security,以前称为 Acegi Security,是一个保护基于 Java 的应用的框架。它提供了对各种身份验证系统的现成集成,如 LDAP、Kerberos、OpenID、OAuth 等等。通过最少的配置,它可以很容易地扩展到任何定制的身份验证和授权系统。该框架还实现了安全最佳实践,并内置了一些功能来防范诸如 CSRF、跨站点请求伪造、会话修复等攻击。
Spring Security 提供了一个一致的安全模型,可以用来保护 web URLs 和 Java 方法。下面列出了 Spring 安全认证/授权过程中涉及的高级步骤以及涉及的组件:
-
这个过程从用户请求一个受 Spring 保护的 web 应用上的受保护资源开始。
-
请求通过一系列被称为“过滤器链”的 Spring 安全过滤器,这些过滤器识别服务请求的
org.springframework.security.web.AuthenticationEntryPoint。AuthenticationEntryPoint将用一个认证请求来响应客户端。例如,这可以通过向用户发送登录页面来实现。 -
在从用户接收到诸如用户名/密码的认证信息时,创建一个
org.springframework.security.core.Authentication对象。清单 8-1 中显示了Authentication接口,它的实现在 Spring 安全性中扮演着双重角色。它们代表身份验证请求的令牌或身份验证成功完成后完全通过身份验证的主体。isAuthenticated方法可以用来确定一个Authentication实例所扮演的当前角色。在用户名/密码认证的情况下,getPrincipal方法返回用户名,getCredentials返回密码。getUserDetails方法包含 IP 地址等附加信息。 -
作为下一步,认证请求令牌被呈现给
org.springframework.security.authentication.AuthenticationManager。清单 8-2 中的AuthenticationManager,包含一个 authenticate 方法,该方法接受一个认证请求令牌并返回一个完全填充的Authentication实例。Spring 提供了一个名为ProviderManager的AuthenticationManager的现成实现。
public interface Authentication extends Principal, Serializable {
Object getPrincipal();
Object getCredentials();
Object getDetails();
Collection<? extends GrantedAuthority> getAuthorities();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
Listing 8-1Authentication API
- 为了执行认证,
ProviderManager需要将提交的用户信息与后端用户存储(如 LDAP 或数据库)进行比较。ProviderManager将这一职责委托给一系列org.springframework.security.authentication.AuthenticationProvider。这些AuthenticationProvider使用一个org.springframework.security.core.userdetails.UserDetailsService从后端存储中检索用户信息。清单 8-3 展示了用户详细信息服务 API。
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
Listing 8-2AuthenticationManager API
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
Listing 8-3UserDetailsService API
JdbcDaoImpl和LdapUserDetailService等UserDetailsService的实现将使用传入的用户名来检索用户信息。这些实现还将创建一组代表用户在系统中的角色/权限的GrantedAuthority实例。
-
AuthenticationProvider将提交的凭证与后端系统中的信息进行比较,在成功验证后,org.springframework.security.core.userdetails.UserDetails对象用于构建一个完全填充的Authentication实例。 -
然后将
Authentication实例放入一个org.springframework.security.core.context.SecurityContextHolder中。顾名思义,SecurityContextHolder,只是将登录用户的上下文与当前执行的线程相关联,以便在用户请求或操作中随时可用。在基于 web 的应用中,登录用户的上下文通常存储在用户的 HTTP 会话中。 -
然后,Spring Security 使用一个
org.springframework.security.access.intercept.AbstractSecurityInterceptor及其实现org.springframework.security.web.access.intercept.FilterSecurityInterceptor和org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor来执行授权检查。FilterSecurityInterceptor用于基于 URL 的授权,MethodSecurityInterceptor用于方法调用授权。 -
AbstractSecurityInterceptor依靠安全配置和一组org.springframework.security.access.AccessDecisionManager来决定用户是否被授权。授权成功后,用户就可以访问受保护的资源。
Note
为了简单起见,我在这些步骤中有意省略了一些 Spring 安全类。关于 Spring 安全和认证/授权步骤的完整回顾,请参考Pro Spring Security(a press,2019)。
现在,您已经对 Spring Security 的认证/授权流程及其一些组件有了基本的了解,让我们看看如何将 Spring Security 集成到我们的 QuickPoll 应用中。
保护快速投票
我们将在 QuickPoll 应用中实现安全性,以满足以下两个要求:
-
注册用户可以创建和访问投票。这使我们能够跟踪帐户、使用情况等等。
-
只有具有管理员权限的用户才能删除投票
Note
在本章中,我们将继续建立在前几章中对 QuickPoll 应用所做的工作。或者,您可以在下载的源代码的Chapter8\starter文件夹中使用一个 starter 项目。在本章中,我们将使用基本认证来保护快速轮询。然后我们将为 QuickPoll 添加 OAuth 2.0 支持。因此,Chapter8\final文件夹包含两个文件夹:quick-poll-ch8-final-basic-auth和quick-poll-ch8-final。quick-poll-ch8-final-basic-auth包含在 QuickPoll 中添加了基本认证的解决方案。quick-poll-ch8-final包含添加了基本认证和 OAuth 2.0 的完整解决方案。我们知道并非所有项目都需要 OAuth 2.0 支持。因此,将最终的解决方案分成两个项目可以让您检查和使用您需要的特性/代码。请参考final文件夹下的解决方案,获取包含 getter/setter 和附加导入的完整列表。下载的Chapter8文件夹还包含一个导出的 Postman 集合,其中包含与本章相关的 REST API 请求。
通过要求用户认证,我们将彻底改变 QuickPoll 应用的行为。为了允许现有用户继续使用我们的 QuickPoll 应用,我们将创建一个新版本(v3)的 API 来实现这些更改。为此,在src\main\java下创建一个新的com.apress.v3.controller包,并从com.apress.v2.controller包中复制控制器。对于新复制的控制器,将RequestMapping从/v2/更改为/v3/,并将控制器名称前缀从v2更改为v3以反映 API 版本 3。我们通过将清单 8-4 中所示的 Spring Security starter 依赖项添加到 QuickPoll 项目的pom.xml文件中来开始实现。这将把所有与 Spring 安全相关的 JAR 文件引入到项目中。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Listing 8-4Spring Starter POM
当在类路径中看到 Spring Security 时,Spring Boot 添加了默认的安全配置,该配置使用 HTTP 基本认证来保护所有的 HTTP 端点。启动 QuickPoll 应用,并使用 Postman 向http://localhost:8080/v3/polls提交一个 GET 请求。Postman 显示一个验证窗口,提示您输入用户名和密码,如图 8-7 所示。

图 8-7
邮递员中的基本认证窗口
Spring Boot 的默认安全配置是带有用户名 user 的单个用户。Spring Boot 为用户生成一个随机密码,并在应用启动时在INFO日志级别打印出来。在您的控制台/日志文件中,您应该会看到如下所示的条目:
Using default security password: 554cc6c2-67e1-4f1e-8c5b-096609e2d0b1
将在控制台中找到的用户名和密码输入 Postmaster 登录窗口,然后点击登录。Spring Security 将验证输入的凭证,并允许完成请求。
卷曲
到目前为止,我们一直使用 Postman 来测试我们的 QuickPoll 应用。在本章中,我们将结合 Postman 使用一个名为 cURL 的命令行工具。cURL 是一个流行的开源工具,用于与服务器交互和使用 URL 语法传输数据。它安装在大多数操作系统发行版中。如果您的系统上没有 cURL,请按照 http://curl.haxx.se/download.html 中的说明下载并在您的机器上安装 cURL。有关在 Windows 机器上安装 cURL 的说明,请参考附录 A。
要使用 cURL 测试我们的 QuickPoll 基本身份验证,请在命令行运行以下命令:
curl -vu user:554cc6c2-67e1-4f1e-8c5b-096609e2d0b1 http://localhost:8080/v3/polls
在这个命令中,–v选项请求 cURL 在调试模式(详细)下运行。–u选项允许我们指定基本认证所需的用户名和密码。在 http://curl.haxx.se/docs/manual.html 可以获得卷曲选项的完整列表。
用户基础设施设置
虽然 Spring Boot 已经大大简化了 Spring 安全集成,但是我们希望定制安全行为,以便它使用应用用户而不是 Spring Boot 的通用用户。我们还想对 v3 PollController应用安全性,让其他端点匿名访问。在我们研究定制 Spring 安全性之前,让我们设置一下创建/更新 QuickPoll 应用用户所需的基础设施。
我们首先创建一个如清单 8-5 所示的User域对象来表示快速轮询用户。User类包含了username、password、firstname和lastname等属性。它还包含一个布尔标志,用于指示用户是否具有管理权限。作为安全最佳实践,我们用@JsonIgnore注释了password字段。因此,密码字段将不包含在user的表示中,从而阻止客户端访问密码值。因为“用户”是 Oracle 等数据库中的一个关键字,所以我们使用了@Table注释为与这个User实体对应的表命名为“用户”。
package com.apress.domain;
import javax.persistence.Table;
import org.hibernate.annotations.Type;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.hibernate.annotations.Type;
import javax.validation.constraints.NotEmpty;
@Entity
@Table(name="USERS")
public class User {
@Id
@GeneratedValue
@Column(name="USER_ID")
private Long id;
@Column(name="USERNAME")
@NotEmpty
private String username;
@Column(name="PASSWORD")
@NotEmpty
@JsonIgnore
private String password;
@Column(name="FIRST_NAME")
@NotEmpty
private String firstName;
@Column(name="LAST_NAME")
@NotEmpty
private String lastName;
@Column(name="ADMIN", columnDefinition="char(3)")
@Type(type="yes_no")
@NotEmpty
private boolean admin;
// Getters and Setters omitted for brevity
}
Listing 8-5User Class
我们将在数据库中存储 QuickPoll 用户,因此需要一个UserRepository来对用户实体执行 CRUD 操作。清单 8-6 显示了在com.apress.repository包下创建的UserRepository接口。除了CrudRepository提供的查找器方法外,UserRepository还包含一个名为findByUsername的自定义查找器方法。Spring Data JPA 将提供一个运行时实现,以便findByUsername方法检索与传入的用户名参数相关联的用户。
package com.apress.repository;
import org.springframework.data.repository.CrudRepository;
import com.apress.domain.User;
public interface UserRepository extends CrudRepository<User, Long> {
public User findByUsername(String username);
}
Listing 8-6UserRepository Interface
QuickPoll 等应用通常有一个允许新用户注册的界面。为了简化本书,我们生成了一些测试用户,如清单 8-7 所示。将这些 SQL 语句复制到 QuickPoll 项目的 src\main\resources 文件夹下的import.sql文件的末尾。当应用被引导时,Hibernate 会将这些测试用户加载到“users”表中,并使它们可供应用使用。
insert into users (user_id, username, password, first_name, last_name, admin) values
(1, 'mickey', '$2a$10$kSqU.ek5pDRMMK21tHJlceS1xOc9Kna4F0DD2ZwQH/LAzH0ML0p6.', 'Mickey', 'Mouse', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(2, 'minnie', '$2a$10$MnHcLn.XdLx.iMntXsmdgeO1B4wAW1E5GOy/VrLUmr4aAzabXnGFq', 'Minnie', 'Mouse', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(3, 'donald', '$2a$10$0UCBI04PCXiK0pF/9kI7.uAXiHNQeeHdkv9NhA1/xgmRpfd4qxRMG', 'Donald', 'Duck', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(4, 'daisy', '$2a$10$aNoR88g5b5TzSKb7mQ1nQOkyEwfHVQOxHY0HX7irI8qWINvLDWRyS', 'Daisy', 'Duck', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(5, 'clarabelle', '$2a$10$cuTJd2ayEwXfsPdoF5/hde6gzsPx/gEiv8LZsjPN9VPoN5XVR8cKW', 'Clarabelle', 'Cow', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(6, 'admin', '$2a$10$JQOfG5Tqnf97SbGcKsalz.XpDQbXi1APOf2SHPVW27bWNioi9nI8y', 'Super', 'Admin', 'yes');
Listing 8-7Test User Data
注意,生成的测试用户的密码不是纯文本的。遵循良好的安全实践,我使用 BCrypt ( http://en.wikipedia.org/wiki/Bcrypt )自适应散列函数加密了密码值。表 8-1 显示了这些测试用户及其密码的纯文本版本。
表 8-1
测试用户信息
|用户名
|
密码
|
是管理员
|
| --- | --- | --- |
| Mickey | 奶酪 | 不 |
| Minnie | Red01 | 不 |
| Donald | 骗人的 | 不 |
| Daisy | 夸克 2 | 不 |
| Clarabelle | 牛叫声 | 不 |
| Admin | 管理 | 是 |
UserDetailsService 实现
在 Spring Security introduction 一节中,我们了解到,UserDetailsService通常用于检索user信息,在身份验证过程中会与用户提交的凭证进行比较。清单 8-8 显示了我们 QuickPoll 应用的UserDetailsService实现。
package com.apress.security;
import javax.inject.Inject;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import com.apress.domain.User;
import com.apress.repository.UserRepository;
@Component
public class QuickPollUserDetailsService implements UserDetailsService {
@Inject
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if(user == null) {
throw new UsernameNotFoundException(String.format("User with the username %s doesn't exist", username));
}
// Create a granted authority based on user's role.
// Can't pass null authorities to user. Hence initialize with an
empty arraylist
List<GrantedAuthority> authorities = new ArrayList<>();
if(user.isAdmin()) {
authorities = AuthorityUtils.createAuthorityList("ROLE_ADMIN");
}
// Create a UserDetails object from the data
UserDetails userDetails = new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
return userDetails;
}
}
Listing 8-8UserDetailsService Implementation for QuickPoll
QuickPollUserDetailsService类利用UserRepository从数据库中检索User信息。然后,它检查检索到的用户是否具有管理权限,并构造一个 admin GrantedAuthority,即ROLE_ADMIN。Spring 安全基础设施期望loadUserByUsername方法返回类型UserDetails的实例。因此,QuickPollUserDetailsService类创建了o.s.s.c.u.User实例,并用从数据库中检索到的数据填充它。o.s.s.c.u.User是UserDetails接口的具体实现。如果QuickPollUserDetailsService在数据库中找不到传入用户名的用户,它将抛出一个UsernameNotFoundException异常。
自定义 Spring 安全性
定制 Spring Security 的默认行为包括创建一个用@EnableWebSecurity注释的配置类。这个配置类通常扩展了提供帮助方法的org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurer类,以简化我们的安全配置。清单 8-9 显示了SecurityConfig类,它将包含 QuickPoll 应用的安全相关配置。
package com.apress;
import javax.inject.Inject;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Inject
private UserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(new BCryptPasswordEncoder());
}
}
Listing 8-9Security Configuration for QuickPoll
SecurityConfig类声明了一个userDetailsService属性,该属性在运行时被注入了一个QuickPollUserDetailsService实例。它还覆盖了一个超类的configure方法,该方法将一个AuthenticationManagerBuilder作为参数。AuthenticationManagerBuilder是一个助手类,它实现了 Builder 模式,提供了一种组装AuthenticationManager的简单方法。在我们的方法实现中,我们使用AuthenticationManagerBuilder来添加UserDetailsService实例。因为我们已经使用 BCrypt 算法加密了存储在数据库中的密码,所以我们提供了一个BCryptPasswordEncoder的实例。authentication manager 框架将使用密码编码器来比较用户提供的普通字符串和数据库中存储的加密哈希。
配置就绪后,重新启动 QuickPoll 应用,并在命令行运行以下命令:
curl -u mickey:cheese http://localhost:8080/v2/polls
如果您在没有–u选项和用户名/密码数据的情况下运行该命令,您将收到来自服务器的 403 错误,如下所示:
{"timestamp":1429998300969,"status":401,"error":"Unauthorized","message":"Full authentication is required to access this resource","path":"/v2/polls"}
保护 URI
上一节中介绍的SecurityConfig类通过配置 HTTP 基本认证来使用 QuickPoll 用户,使我们更进一步。但是,这种配置保护所有端点,并且需要身份验证才能访问资源。为了实现我们只保护 v3 轮询 API 的需求,我们将覆盖另一个WebSecurityConfigurer的config方法。清单 8-10 显示了需要添加到SecurtyConfig类中的配置方法实现。
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/v1/**", "/v2/**", "/swagger-ui/**", "/api-docs/**").permitAll()
.antMatchers("/v3/polls/ **").authenticated()
.and()
.httpBasic()
.realmName("Quick Poll")
.and()
.csrf()
.disable();
}
Listing 8-10New Config Method in SecurityConfig
清单 8-10 中传递给 config 方法的HttpSecurity参数允许我们指定 URI 应该是安全的还是不安全的。我们通过请求 Spring Security 不创建 HTTP 会话并且不在会话中存储登录用户的SecurityContext来开始方法实现。这是通过使用SessionCreationPolicy.STATELESS创建策略实现的。然后我们使用antMatchers来提供我们不希望 Spring 安全保护的蚂蚁风格的 URI 表达式。使用permitAll方法,我们指定 API 版本 1 和 2 以及 Swagger UI 应该是匿名可用的。下一个antMatchers和authenticated方法指定 Spring Security 应该只允许经过认证的用户访问 V3 Polls API。最后,我们启用 HTTP 基本身份验证,并将领域名称设置为“快速轮询”重新启动 QuickPoll 应用,应该只提示您对/v3/polls资源进行身份验证。
Note
跨站点请求伪造或 CSRF ( http://en.wikipedia.org/wiki/Cross-site_request_forgery )是一种安全漏洞,恶意网站通过这种漏洞迫使最终用户在他们当前已通过身份验证的不同网站上执行不需要的命令。默认情况下,Spring Security 启用 CSRF 保护,并强烈建议用户通过浏览器提交请求时使用它。对于非浏览器客户端使用的服务,可以禁用 CSRF。通过实现自定义的RequestMatcher,可以仅对某些 URL 或 HTTP 方法禁用 CSRF。
为了保持本书的简单和易管理,我们禁用了 CSRF 保护。
我们的最后一项安全要求是确保只有具有管理权限的用户才能删除投票。为了实现这个授权需求,我们将在deletePoll方法上应用 Spring Security 的方法级安全性。Spring 的方法级安全性可以使用恰当命名的org.springframework.security.config.annotation.method.configuration. EnableGlobalMethodSecurity注释来启用。清单 8-11 显示了添加到SecurityConfig类的注释。
package com.apress;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebMvcConfigurer {
// Content removed for brevity
}
Listing 8-11EnableGlobalMethodSecurity Annotation Added
Spring Security 支持丰富的类和方法级授权注释,以及基于标准的 JSR 250 注释安全性。EnableGlobalMethodSecurity中的prePostEnabled标志请求 Spring Security 启用执行方法调用前后授权检查的注释。下一步是用清单 8-12 所示的@PreAuthorize注释来注释 v3 PollController的deletePoll方法。
import org.springframework.security.access.prepost.PreAuthorize;
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public ResponseEntity<Void> deletePoll(@PathVariable Long pollId) {
// Code removed for brevity
}
Listing 8-12PreAuthorize Annotation Added
@PreAuthorize注释决定是否可以调用deletePoll方法。Spring Security 通过评估作为注释值传入的 Spring-EL 表达式来做出这个决定。在这种情况下,hasAuthority检查登录用户是否拥有“ROLE_ADMIN”权限。重启应用,并使用 Postman 在端点http://localhost:8080/v3/polls/12上执行删除。当提示输入凭证时,输入用户名 mickey 和密码 cheese,然后点击 Log In。图 8-8 显示了请求和相关输入。

图 8-8
删除未经授权用户的投票
由于用户 mickey 没有管理权限,您将看到来自服务的未授权响应,如图 8-9 所示。

图 8-9
未经授权的删除响应
现在让我们使用具有管理权限的管理员用户重试这个请求。在 Postman 中,点击基本认证选项卡,输入证书 admin/admin,点击“刷新标题”,如图 8-10 所示。在提交请求时,您应该看到 ID 为 12 的投票资源被删除了。

图 8-10
邮递员中的基本身份验证管理凭证
要使用 cURL 删除投票,请运行以下命令:
curl -i -u admin:admin -X DELETE http://localhost:3/v3/polls/13
前面提到的命令删除了一个 ID 为 13 的轮询资源。–i选项请求 curl 输出响应头。–X选项允许我们指定 HTTP 方法名。在我们的例子中,我们指定了 DELETE HTTP 方法。该结果的输出如清单 8-13 所示。
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sat, 25 Apr 2015 21:50:35 GMT
Listing 8-13Output of cURL Delete
摘要
安全性是任何企业应用的一个重要方面。在本章中,我们回顾了保护 REST 服务的策略。我们还深入研究了 OAuth 2,并回顾了它的不同组件。然后,我们使用 Spring Security 在 QuickPoll 应用中实现基本的身份验证。在下一章,我们将使用 Spring 的 RestTemplate 来构建 REST 客户端。我们还将使用 Spring MVC 测试框架在 REST 控制器上执行单元和集成测试。
九、客户端和测试
在本章中,我们将讨论以下内容:
-
使用 RestTemplate 构建客户端
-
Spring 测试框架基础
-
单元测试 MVC 控制器
-
集成测试 MVC 控制器
我们已经研究了使用 Spring 构建 REST 服务。在本章中,我们将研究如何构建使用这些 REST 服务的客户端。我们还将研究可用于执行 REST 服务的单元和端到端测试的 Spring 测试框架。
快速轮询 Java 客户端
消费 REST 服务包括构建一个 JSON 或 XML 请求负载,通过 HTTP/HTTPS 传输负载,并消费返回的 JSON 响应。这种灵活性为用 Java 构建 REST 客户机(或者,事实上,任何技术)打开了许多选择的大门。构建 Java REST 客户端的一种简单方法是使用核心 JDK 库。清单 9-1 展示了一个使用 QuickPoll REST API 读取投票的客户端示例。
public void readPoll() {
HttpURLConnection connection = null;
BufferedReader reader = null;
try {
URL restAPIUrl = new URL("http://localhost:8080/v1/polls/1");
connection = (HttpURLConnection) restAPIUrl.openConnection();
connection.setRequestMethod("GET");
// Read the response
reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
StringBuilder jsonData = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
jsonData.append(line);
}
System.out.println(jsonData.toString());
}
catch(Exception e) {
e.printStackTrace();
}
finally {
// Clean up
IOUtils.closeQuietly(reader);
if(connection != null)
connection.disconnect();
}
}
Listing 9-1Reading a Poll Using Java URLClass
尽管清单 9-1 中的方法没有任何问题,但是需要编写大量样板代码来执行一个简单的 REST 操作。如果我们包含解析 JSON 响应的代码,那么readPoll方法会变得更大。Spring 将这些样板代码抽象成模板和实用程序类,使得消费 REST 服务变得容易。
客户端
Spring 支持构建 REST 客户端的核心是org.springframework.web.client.RestTemplate。RestTemplate负责处理与 REST 服务通信所需的必要管道,并自动编组/解组 HTTP 请求和响应体。像 Spring 的其他流行助手类JdbcTemplate和JmsTemplate一样的RestTemplate也是基于模板方法设计模式的。 1
RestTemplate和相关的实用程序类是spring-web.jar文件的一部分。如果您正在使用RestTemplate构建一个独立的 REST 客户端,您需要将spring-web依赖项添加到您的pom.xml文件中,如清单 9-2 所示。
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.3.9</version>
</dependency>
Listing 9-2Spring-web.jar Dependency
RestTemplate使用六种常用的 HTTP 方法提供了执行 API 请求的便捷方法。在接下来的小节中,我们将研究其中的一些函数,以及一个通用而强大的交换方法来构建 QuickPoll 客户端。
Note
在本章中,我们将继续建立在前几章中对 QuickPoll 应用所做的工作。或者,您可以使用下载源代码的Chapter9\starter文件夹中的一个 starter 项目。完成的解决方案可以在Chapter9\final文件夹下找到。有关包含 getter/setter 和附加导入的完整列表,请参考此解决方案。
获得民意测验
RestTemplate提供了一个getForObject方法来使用 GET HTTP 方法检索表示。清单 9-3 展示了getForObject方法的三种风格。
public <T> T getForObject(String url, Class<T> responseType, Object... urlVariables) throws RestClientException {}
public <T> T getForObject(String url, Class<T> responseType, Map<String,?> urlVariables) throws RestClientException
public <T> T getForObject(URI url, Class<T> responseType) throws RestClientException
Listing 9-3GetForObject Method Flavors
前两个方法接受 URI 模板字符串、返回值类型和可用于扩展 URI 模板的 URI 变量。第三种风格接受完全形式的 URI 和返回值类型。对传入的 URI 模板进行编码,因此,如果 URI 已经被编码,您必须使用第三种方法。否则,它将导致 URI 的双重编码,导致格式错误的 URI 错误。
清单 9-4 显示了QuickPollClient类和getForObject方法的用法,以检索给定轮询 id 的轮询。QuickPollClient放在我们的 QuickPoll 应用的com.apress.client包下,并与我们的 QuickPoll API 的第一个版本交互。在接下来的部分中,我们将创建与 API 的第二和第三版本交互的客户端。RestTemplate是线程安全的,因此,我们创建了一个类级别的RestTemplate实例,供所有客户端方法使用。因为我们已经将Poll.class指定为第二个参数,RestTemplate使用 HTTP 消息转换器,并将 HTTP 响应内容自动转换为Poll实例。
package com.apress.client;
import org.springframework.web.client.RestTemplate;
import com.apress.domain.Poll;
public class QuickPollClient {
private static final String QUICK_POLL_URI_V1 = "http://localhost:8080/v1/polls";
private RestTemplate restTemplate = new RestTemplate();
public Poll getPollById(Long pollId) {
return restTemplate.getForObject(QUICK_POLL_URI_V1 + "/{pollId}", Poll.class, pollId);
}
}
Listing 9-4QuickPollClient and GetForObject Usage
这个清单展示了RestTemplate的威力。在清单 9-1 中花了大约十几行,但是我们可以使用RestTemplate用几行就完成了。可以用一个简单的QuickPollClient类中的main方法来测试getPollById方法:
public static void main(String[] args) {
QuickPollClient client = new QuickPollClient();
Poll poll = client.getPollById(1L);
System.out.println(poll);
}
Note
在运行main方法之前,确保已经启动并运行了 QuickPoll 应用。
检索轮询集合资源稍微有点复杂,因为将List<Poll>.class作为返回值类型提供给getForObject会导致编译错误。一种方法是简单地指定我们期望一个集合:
List allPolls = restTemplate.getForObject(QUICK_POLL_URI_V1, List.class);
然而,因为RestTemplate不能自动猜测元素的 Java 类类型,它会将返回集合中的每个 JSON 对象反序列化到一个LinkedHashMap中。因此,该调用将我们所有的投票作为类型List<LinkedHashMap>的集合返回。
为了解决这个问题,Spring 提供了一个org.springframework.core.ParameterizedTypeReference抽象类,可以在运行时捕获并保留泛型信息。因此,为了说明我们期待一个 Poll 实例列表的事实,我们创建了一个ParameterizedTypeReference的子类:
ParameterizedTypeReference<List<Poll>> responseType = new ParameterizedTypeReference<List<Poll>>() {};
RestTemplate特定于 HTTP 的方法,比如getForObject,不把ParameterizedTypeReference作为它们的参数。如清单 9-5 所示,我们需要结合ParameterizedTypeReference使用RestTemplate的exchange方法。exchange方法从传入的responseType参数中推断出返回类型信息,并返回一个ResponseEntity实例。在ResponseEntity上调用getBody方法给了我们Poll集合。
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpMethod;
public List<Poll> getAllPolls() {
ParameterizedTypeReference<List<Poll>> responseType = new ParameterizedTypeReference
<List<Poll>>() {};
ResponseEntity<List<Poll>> responseEntity = restTemplate.exchange(QUICK_POLL_URI_V1, HttpMethod.GET, null, responseType);
List<Poll> allPolls = responseEntity.getBody();
return allPolls;
}
Listing 9-5Get All Polls Using RestTemplate
我们也可以通过请求RestTemplate返回一组Poll实例来完成与getForObject类似的行为:
Poll[] allPolls = restTemplate.getForObject(QUICK_POLL_URI_V1, Poll[].class);
创建投票
RestTemplate提供了两种方法——postForLocation和postForObject—来对资源执行 HTTP POST 操作。清单 9-6 给出了这两种方法的 API。
public URI postForLocation(String url, Object request, Object... urlVariables) throws RestClientException
public <T> T postForObject(String url, Object request, Class<T> responseType, Object... uriVariables) throws RestClientException
Listing 9-6RestTemplate’s POST Support
postForLocation方法在给定的 URI 上执行 HTTP POST,并返回Location头的值。正如我们在 QuickPoll POST 实现中看到的,Location头包含新创建资源的 URI。postForObject的工作方式与postForLocation类似,但是将响应转换成表示。responseType参数表示预期的表示类型。
清单 9-7 展示了QuickPollClient的createPoll方法,它使用postForLocation方法创建了一个新的Poll。
public URI createPoll(Poll poll) {
return restTemplate.postForLocation( QUICK_POLL_URI_V1, poll);
}
Listing 9-7Create a Poll Using PostForLocation
用这段代码更新QuickPollClient的main方法,以测试createPoll方法:
public static void main(String[] args) {
QuickPollClient client = new QuickPollClient();
Poll newPoll = new Poll();
newPoll.setQuestion("What is your favourate color?");
Set<Option> options = new HashSet<>();
newPoll.setOptions(options);
Option option1 = new Option(); option1.setValue("Red"); options.add(option1);
Option option2 = new Option(); option2.setValue("Blue");options.add(option2);
URI pollLocation = client.createPoll(newPoll);
System.out.println("Newly Created Poll Location " + pollLocation);
}
PUT 方法
RestTemplate提供了名副其实的PUT方法来支持 PUT HTTP 方法。清单 9-8 显示了更新poll实例的QuickPollClient的updatePoll方法。注意,PUT方法不返回任何响应,而是通过抛出RestClientException或其子类来传达失败。
public void updatePoll(Poll poll) {
restTemplate.put(QUICK_POLL_URI_V1 + "/{pollId}", poll, poll.getId());
}
Listing 9-8Update a Poll Using PUT
删除方法
RestTemplate提供了三个重载的DELETE方法来支持删除 HTTP 操作。DELETE方法遵循类似于 PUT 的语义,并且不返回值。它们通过RestClientException或其子类传达任何异常。清单 9-9 显示了QuickPollClient类中的deletePoll方法实现。
public void deletePoll(Long pollId) {
restTemplate.delete(QUICK_POLL_URI_V1 + "/{pollId}", pollId);
}
Listing 9-9Delete a Poll
处理分页
在 QuickPoll API 的版本 2 中,我们引入了分页。因此,升级到版本 2 的客户端需要重新实现getAllPolls方法。所有其他客户端方法将保持不变。
要重新实现getAllPolls,我们的第一反应是简单地将org.springframework.data.domain.PageImpl作为参数化的类型引用传递:
ParameterizedTypeReference<PageImpl<Poll>> responseType = new ParameterizedTypeReference<PageImpl<Poll>>() {};
ResponseEntity<PageImpl<Poll>> responseEntity = restTemplate.exchange(QUICK_POLL_URI_2, HttpMethod.GET, null, responseType);
PageImpl<Poll> allPolls = responseEntity.getBody();
PageImpl是org.springframework.data.domain.Page接口的具体实现,可以保存 QuickPoll REST API 返回的所有分页和排序信息。这种方法的唯一问题是PageImpl没有默认的构造函数,Spring 的 HTTP 消息转换器会失败,并出现以下异常:
Could not read JSON: No suitable constructor found for type [simple type, class org.springframework.data.domain.PageImpl<com.apress.domain.Poll>]: can not instantiate from JSON object (need to add/enable type information?)
为了处理分页并成功地将 JSON 映射到一个对象,我们将创建一个 Java 类,它模仿PageImpl类,但也有一个默认的构造函数,如清单 9-10 所示。
package com.apress.client;
import java.util.List;
import org.springframework.data.domain.Sort;
public class PageWrapper<T> {
private List<T> content;
private Boolean last;
private Boolean first;
private Integer totalPages;
private Integer totalElements;
private Integer size;
private Integer number;
private Integer numberOfElements;
private Sort sort;
// Getters and Setters removed for brevity
}
Listing 9-10PageWrapper Class
Note
有时候,您需要从 JSON 生成 Java 类型。对于不提供 Java 客户端库的 API 来说尤其如此。在线工具 www.jsonschema2pojo.org 提供了一种从 JSON 模式或 JSON 数据生成 Java POJOs 的便捷方式。
PageWrapper类可以保存返回的内容,并具有保存分页信息的属性。清单 9-11 显示了利用PageWrapper与第二版 API 交互的QuickPollClientV2类。注意,getAllPolls方法现在有两个参数:page和size。page参数决定请求的页码,而size参数决定页面中包含的元素数量。这个实现可以进一步增强,以接受排序参数并提供排序功能。
package com.apress.client;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import com.apress.domain.Poll;
public class QuickPollClientV2 {
private static final String QUICK_POLL_URI_2 = "http://localhost:8080/v2/polls";
private RestTemplate restTemplate = new RestTemplate();
public PageWrapper<Poll> getAllPolls(int page, int size) {
ParameterizedTypeReference<PageWrapper<Poll>> responseType = new
ParameterizedTypeReference<PageWrapper<Poll>>() {};
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(QUICK_POLL_URI_2)
.queryParam("page", page)
.queryParam("size", size);
ResponseEntity<PageWrapper<Poll>> responseEntity = restTemplate.exchange
(builder.build().toUri(), HttpMethod.GET, null, responseType);
return responseEntity.getBody();
}
}
Listing 9-11QuickPoll Client for Version 2
处理基本身份验证
至此,我们已经为 QuickPoll API 的第一版和第二版创建了客户端。在第八章中,我们保护了 API 的第三个版本,任何与该版本的通信都需要基本的认证。例如,在没有任何身份验证的情况下,在 URI http://localhost:8080/v3/polls/3上运行 DELETE 方法会导致状态代码为 401 的HttpClientErrorException。
为了成功地与我们的 QuickPoll v3 API 交互,我们需要以编程方式对用户的凭证进行 base 64 编码,并构造一个authorization请求头。清单 9-12 展示了这样的实现:我们连接传入的用户名和密码。然后,我们对其进行 base 64 编码,并通过在编码值前面加上Basic来创建一个Authorization头。
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.http.HttpHeaders;
private HttpHeaders getAuthenticationHeader(String username, String password) {
String credentials = username + ":" + password;
byte[] base64CredentialData = Base64.encodeBase64(credentials.getBytes());
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Basic " + new String(base64CredentialData));
return headers;
}
Listing 9-12Authentication Header Implementation
RestTemplate的exchange方法可用于执行 HTTP 操作,并接收一个Authorization头。清单 9-13 展示了使用基本认证的deletePoll方法实现的QuickPollClientV3BasicAuth类。
package com.apress.client;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.HttpEntity;
public class QuickPollClientV3BasicAuth {
private static final String QUICK_POLL_URI_V3 = "http://localhost:8080/v3/polls";
private RestTemplate restTemplate = new RestTemplate();
public void deletePoll(Long pollId) {
HttpHeaders authenticationHeaders = getAuthenticationHeader("admin", "admin");
restTemplate.exchange(QUICK_POLL_URI_V3 + "/{pollId}",
HttpMethod.DELETE, new HttpEntity<Void>(authenticationHeaders), Void.class, pollId);
}
}
Listing 9-13QuickPoll Client with Basic Auth
Note
在这种方法中,我们为每个请求手动设置了身份验证头。另一种方法是实现一个定制的ClientHttpRequestInterceptor,它拦截每一个传出的请求,并自动将头附加到请求上。
测试 REST 服务
测试是每个软件开发过程的一个重要方面。测试有不同的风格,在这一章中,我们将关注单元测试和集成测试。单元测试验证单独的、隔离的代码单元是否按预期工作。这是开发人员通常执行的最常见的测试类型。集成测试通常跟在单元测试之后,关注之前测试过的单元之间的交互。
Java 生态系统充满了简化单元和集成测试的框架。JUnit 和 TestNG 已经成为事实上的标准测试框架,并为大多数其他测试框架提供了基础/集成。尽管 Spring 支持这两种框架,但我们将在本书中使用 JUnit,因为它为大多数读者所熟悉。
弹簧试验
Spring 框架提供了spring-test模块,允许您将 Spring 集成到测试中。该模块为环境 JNDI、Servlet 和 Portlet API 提供了一组丰富的注释、实用程序类和模拟对象。该框架还提供了跨测试执行缓存应用上下文的能力,以提高性能。使用这个基础设施,您可以轻松地将 Spring beans 和测试设备注入到测试中。要在非 Spring Boot 项目中使用 spring-test 模块,您需要包含 Maven 依赖项,如清单 9-14 所示。
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>3.5.9</version>
<scope>test</scope>
</dependency>
Listing 9-14Spring-test Dependency
Spring Boot 提供了一个名为spring-boot-starter-test的启动 POM,可以自动将spring-test模块添加到引导应用中。此外,starter POM 引入了 JUnit、Mockito 和 Hamcrest 库:
-
Mockito 是一个流行的 Java 模仿框架。它提供了一个简单易用的 API 来创建和配置模拟。更多关于莫克托的细节可以在
http://mockito.org/找到。 -
Hamcrest 是一个框架,为创建匹配器提供了强大的词汇表。简单地说,匹配器允许您将一个对象与一组期望进行匹配。匹配器改进了我们编写断言的方式,使它们更易于阅读。当测试过程中不满足断言时,它们还会生成有意义的失败消息。你可以在
http://hamcrest.org/了解更多关于 Hamcrest 的信息。
为了理解spring-test模块,让我们检查一个典型的测试用例。清单 9-15 展示了一个使用 JUnit 和spring-test基础设施构建的样本测试。
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = QuickPollApplication.class)
@WebAppConfiguration
public class ExampleTest {
@Before
public void setup() { }
@Test
public void testSomeThing() {}
@After
public void teardown() { }
}
Listing 9-15Sample JUnit Test
我们的示例测试包含三个方法— setup、testSomeThing和teardown,每个方法都用 JUnit 注释进行了注释。@Test注释将testSomeThing表示为 JUnit 测试方法。该方法将包含确保我们的生产代码按预期工作的代码。@Before注释指示 JUnit 在任何测试方法执行之前运行setup方法。用@Before标注的方法可以用来设置测试夹具和测试数据。类似地,@After注释指示 JUnit 在任何测试方法执行之后运行teardown方法。用@After标注的方法通常用于拆除测试夹具和执行清理操作。
JUnit 使用测试运行器的概念来执行测试。默认情况下,JUnit 使用BlockJUnit4ClassRunner测试运行器来执行测试方法和相关的生命周期(@Before或@After,等)。)方法。@RunWith注释允许您改变这种行为。在我们的例子中,使用@RunWith注释,我们指示 JUnit 使用SpringJUnit4ClassRunner类来运行测试用例。
SpringJUnit4ClassRunner通过执行加载应用上下文、注入自动连接的依赖项和运行指定的测试执行监听器等活动来添加 Spring 集成。为了让 Spring 加载和配置应用上下文,它需要 XML 上下文文件的位置或 Java 配置类的名称。我们通常使用@ContextConfiguration注释向SpringJUnit4ClassRunner类提供这些信息。
然而,在我们的例子中,我们使用了标准ContextConfiguration的专用版本SpringBootTest,它提供了额外的 Spring Boot 特性。最后,@WebAppConfiguration注释指示 Spring 创建应用上下文的 web 版本,即WebApplicationContext。
单元测试 REST 控制器
Spring 的依赖注入使得单元测试更加容易。依赖关系可以很容易地用预定义的行为来模仿或模拟,从而允许我们放大并孤立地测试代码。传统上,Spring MVC 控制器的单元测试遵循这个范例。例如,清单 9-16 展示了代码单元测试PollController的getAllPolls方法。
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.util.ArrayList;
import com.google.common.collect.Lists;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
public class PollControllerTestMock {
@Mock
private PollRepository pollRepository;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
}
@Test
public void testGetAllPolls() {
PollController pollController = new PollController();
ReflectionTestUtils.setField(pollController, "pollRepository", pollRepository);
when(pollRepository.findAll()).thenReturn(new ArrayList<Poll>());
ResponseEntity<Iterable<Poll>> allPollsEntity = pollController.getAllPolls();
verify(pollRepository, times(1)).findAll();
assertEquals(HttpStatus.OK, allPollsEntity.getStatusCode());
assertEquals(0, Lists.newArrayList(allPollsEntity.getBody()).size());
}
}
Listing 9-16Unit Testing PollController with Mocks
PollControllerTestMock实现使用Mockito的@Mock注释来模拟PollController唯一的依赖关系:PollRepository。为了让 Mockito 正确初始化带注释的pollRepository属性,我们要么需要使用MockitoJUnitRunner测试运行器运行测试,要么调用MockitoAnnotations中的initMocks方法。在我们的测试中,我们选择后一种方法,并在@Before方法中调用initMocks。
在testGetAllPolls方法中,我们创建了一个PollController的实例,并使用 Spring 的ReflectionTestUtils实用程序类注入了模拟PollRepository。然后我们使用 Mockito 的when和thenReturn方法来设置PollRepository mock 的行为。这里我们指出当调用PollRepository的findAll()方法时,应该返回一个空集合。最后,我们调用getAllPolls方法,验证findAll()方法的调用,并断言控制器的返回值。
在这种策略中,我们将PollController视为 POJO,因此不测试控制器的请求映射、验证、数据绑定和任何相关的异常处理程序。从 3.2 版本开始,spring-test模块包含了一个 Spring MVC 测试框架,允许我们将一个控制器作为一个控制器来测试。这个测试框架将把DispatcherServlet和相关的 web 组件(比如控制器和视图解析器)加载到测试上下文中。然后,它使用DispatcherServlet来处理所有请求并生成响应,就好像它运行在 web 容器中,而不实际启动 web 服务器。这允许我们对 Spring MVC 应用进行更彻底的测试。
Spring MVC 测试框架基础
为了更好地理解 Spring MVC 测试框架,我们探索了它的四个重要类:MockMvc、MockMvcRequestBuilders、MockMvcResultMatchers和MockMvcBuilders。从类名可以明显看出,Spring MVC 测试框架大量使用了 Builder 模式。22
测试框架的核心是org.springframework.test.web.servlet.MockMvc类,它可以用来执行 HTTP 请求。它只包含一个名为perform的方法,并具有以下 API 签名:
public ResultActions perform(RequestBuilder requestBuilder) throws java.lang.Exception
RequestBuilder参数提供了创建请求(GET、POST 等)的抽象。)被执行。为了简化请求构造,框架在org.springframework.test.web.servlet.request.MockMvcRequestBuilders类中提供了一个org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder实现和一组助手静态方法。清单 9-17 给出了一个使用前面提到的类构造的 POST HTTP 请求的例子。
post("/test_uri")
.param("admin", "false")
.accept(MediaType.APPLICATION_JSON)
.content("{JSON_DATA}");
Listing 9-17POST HTTP Request
post方法是MockMvcRequestBuilders类的一部分,用于创建 POST 请求。MockMvcRequestBuilders还提供了额外的方法,如get、delete,和put来创建相应的 HTTP 请求。param方法是MockHttpServletRequestBuilder类的一部分,用于向请求添加参数。MockHttpServletRequestBuilder提供了额外的方法,如accept、content、cookie和header来为正在构建的请求添加数据和元数据。
perform方法返回一个org.springframework.test.web.servlet.ResultActions实例,该实例可用于对执行的响应应用断言/期望。清单 9-18 展示了使用ResultActions的andExpect方法应用于示例 POST 请求响应的三个断言。status是org.springframework.test.web.servlet.result.MockMvcResultMatchers中的一个静态方法,允许您对响应状态应用断言。它的isOk方法断言状态代码是 200 (HTTPStatus。好的)。类似地,MockMvcResultMatchers中的content方法提供了断言响应体的方法。在这里,我们断言响应内容类型是“application/json”类型,并且匹配预期的字符串“JSON_DATA.”
mockMvc.perform(post("/test_uri"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{JSON_DATA}"));
Listing 9-18ResultActions
到目前为止,我们已经看到了使用MockMvc来执行请求和断言响应。在我们可以使用MockMvc之前,我们需要初始化它。MockMvcBuilders类提供了以下两种方法来构建一个MockMvc实例:
-
WebAppContextSetup—使用完全初始化的WebApplicationContext构建一个MockMvc实例。在创建MockMvc实例之前,加载与上下文相关的整个 Spring 配置。这项技术用于端到端测试。 -
StandaloneSetup-在不加载任何弹簧配置的情况下构建MockMvc。只加载基本的 MVC 基础设施来测试控制器。这种技术用于单元测试。
使用 Spring MVC 测试框架进行单元测试
现在我们已经回顾了 Spring MVC 测试框架,让我们看看如何使用它来测试 REST 控制器。清单 9-19 中的PollControllerTest类演示了对getPolls方法的测试。对于@ContextConfiguration注释,我们传入一个MockServletContext类,指示 Spring 设置一个空的WebApplicationContext。空的WebApplicationContext允许我们实例化和初始化我们想要测试的控制器,而不需要加载整个应用上下文。它还允许我们模仿控制器所需的依赖关系。
package com.apress.unit;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockServletContext;
import org.springframework.test.web.servlet.MockMvc;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = QuickPollApplication.class)
@ContextConfiguration(classes = MockServletContext.class)
@WebAppConfiguration
public class PollControllerTest {
@InjectMocks
PollController pollController;
@Mock
private PollRepository pollRepository;
private MockMvc mockMvc;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
mockMvc = standaloneSetup(pollController).build();
}
@Test
public void testGetAllPolls() throws Exception {
when(pollRepository.findAll()).thenReturn(new ArrayList<Poll>());
mockMvc.perform(get("/v1/polls"))
.andExpect(status().isOk())
.andExpect(content().string("[]"));
}
}
Listing 9-19Unit Testing with Spring MVC Test
在这种情况下,我们想要测试我们的PollController API 的版本 1。因此,我们声明了一个pollController属性,并用@InjectMocks对其进行了注释。在运行时,Mockito 看到了@InjectMocks注释,并将创建一个import com.apress.v1.controller.PollController.PollController的实例。然后,它使用构造函数/字段或 setter 注入将任何在PollControllerTest类中声明的 mocks 注入其中。我们班唯一的模拟是PollRepository。
在@Before带注释的方法中,我们使用MockMvcBuilders的standaloneSetup()方法来注册pollController实例。standaloneSetup()自动创建DispatcherServlet所需的最小基础设施,以满足与注册控制器相关的请求。standaloneSetup 构建的MockMvc实例存储在一个类级变量中,可供测试使用。
在testGetAllPolls方法中,我们使用 Mockito 对PollRepository mock 的行为进行编程。然后我们在/v1/polls URI 上执行一个 GET 请求,并使用status和content断言来确保返回一个空的 JSON 数组。这是我们在清单 9-16 中看到的版本的最大区别。在那里,我们测试了 Java 方法调用的结果。这里我们测试 API 生成的 HTTP 响应。
集成测试 REST 控制器
在上一节中,我们看了控制器及其相关配置的单元测试。然而,这种测试仅限于 web 层。有时候,我们希望测试从控制器到持久性存储的应用的所有层。在过去,编写这样的测试需要在嵌入式 Tomcat 或 Jetty 服务器中启动应用,并使用 HtmlUnit 或RestTemplate之类的框架来触发 HTTP 请求。依赖外部 servlet 容器可能很麻烦,并且经常会降低测试速度。
Spring MVC 测试框架为集成测试 MVC 应用提供了一个轻量级的、开箱即用的替代方案。在这种方法中,整个 Spring 应用上下文以及DispatcherServlet和相关的 MVC 基础设施被加载。一个模拟的 MVC 容器可用于接收和执行 HTTP 请求。我们与真正的控制者互动,这些控制者与真正的合作者一起工作。为了加速集成测试,复杂的服务有时会被嘲笑。此外,上下文通常配置为 DAO/repository 层与内存中的数据库交互。
这种方法类似于我们用于单元测试控制器的方法,除了以下三点不同:
-
与单元测试用例中的空上下文相反,整个 Spring 上下文被加载。
-
与通过
standaloneSetup.配置的端点相反,所有 REST 端点都可用 -
测试是使用真正的协作者针对内存数据库执行的,而不是模仿依赖者的行为。
清单 9-20 显示了对PollController的getAllPolls方法的集成测试。PollControllerIT级类似于我们之前看到的PollControllerTest。一个完全配置好的WebApplicationContext实例被注入到测试中。在@Before方法中,我们使用这个WebApplicationContext实例来构建一个使用MockMvcBuilders的webAppContextSetup的MockMvc实例。
package com.apress.it;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.context.WebApplicationContext;
import com.apress.QuickPollApplication;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = QuickPollApplication.class)
@WebAppConfiguration
public class PollControllerIT {
@Inject
private WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
@Before
public void setup() {
mockMvc = webAppContextSetup(webApplicationContext).build();
}
@Test
public void testGetAllPolls() throws Exception {
mockMvc.perform(get("/v1/polls"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(20)));
}
}
Listing 9-20Integration Testing with Spring MVC Test
testGetAllPolls方法实现使用MockMvc实例在/v1/polls端点上执行 GET 请求。我们使用两个断言来确保结果是我们所期望的:
-
isOK断言确保我们得到状态码 200。 -
JsonPath方法允许我们使用JsonPath表达式针对响应体编写断言。JsonPath (http://goessner.net/articles/JsonPath/)提供了一种提取 JSON 文档各部分的便捷方法。简单来说,JsonPath 之于 JSON 就像 XPath 之于 XML。
在我们的测试案例中,我们使用 Hamcrest 的hasSize匹配器断言返回的 JSON 包含 20 个投票。用于填充内存数据库的import.sql脚本包含 20 个轮询条目。因此,我们的断言使用幻数 20 进行比较。
摘要
Spring 提供了强大的模板和实用程序类来简化 REST 客户端开发。在本章中,我们回顾了 RestTemplate,并使用它来执行客户端操作,如对资源的 GET、POST、PUT 和 DELETE。我们还回顾了 Spring MVC 测试框架及其核心类。最后,我们使用测试框架来简化单元和集成测试的创建。
十、HATEOAS
在本章中,我们将讨论以下内容:
-
HATEOAS
-
JSON 超媒体类型
-
快速轮询 HATEOAS 实现
考虑一下与 Amazon.com 等电子商务网站的任何互动。你通常从访问网站的主页开始互动。主页可能包含描述不同产品和促销的文本、图像和视频。该页面还包含超链接,允许您从一个页面导航到另一个页面,允许您阅读产品详细信息和评论,并允许您将产品添加到购物车。这些超链接以及其他控件(如按钮和输入字段)还会引导您完成工作流程,如结帐。
工作流中的每个网页都为您提供了进入下一步或返回上一步甚至完全退出工作流的控件。这是网络的一个非常强大的功能——作为消费者,你可以使用链接来浏览资源,找到你需要的东西,而不必记住所有相应的 URIs。你只需要知道最初的 URI: http://www.amazon.com 。如果亚马逊进行品牌重塑,改变产品的 URIs,或者在结账流程中增加新的步骤,你仍然可以发现和执行所有的操作。
在这一章中,我们将回顾 HATEOAS,它是一个约束,允许我们构建像网站一样运行的弹性 REST 服务。
HATEOAS
应用的状态,或者说是 HATEOAS,是 REST 架构的一个关键约束。术语“超媒体”是指任何包含到其他媒体形式的链接的内容,如图像、电影和文本。正如你所经历的,网络是超媒体的典型例子。HATEOAS 背后的想法很简单——一个响应将包括到其他资源的链接。客户端将使用这些链接与服务器进行交互,这些交互可能会导致状态发生变化。
类似于人与网站的交互,REST 客户端点击初始 API URI,并使用服务器提供的链接来动态发现可用的动作并访问它需要的资源。客户不需要事先了解服务或工作流程中涉及的不同步骤。此外,客户端不再需要为不同的资源硬编码 URI 结构。这使得服务器可以随着 API 的发展而改变 URI,而不会破坏客户端。
为了更好地理解 HATEOAS,考虑一个假设的博客应用的 REST API。下面显示了一个检索标识符为 1 的博客文章资源的请求示例以及 JSON 格式的相关响应:
GET /posts/1 HTTP/1.1
Connection: keep-alive
Host: blog.example.com
{
"id" : 1,
"body" : "My first blog post",
"postdate" : "2015-05-30T21:41:12.650Z"
}
正如我们所预料的,从服务器生成的响应包含与 blog post 资源相关的数据。当 HATEOAS 约束应用于这个 REST 服务时,生成的响应中嵌入了链接。清单 10-1 显示了一个带有链接的示例响应。
{
"id" : 1,
"body" : "My first blog post",
"postdate" : "2015-05-30T21:41:12.650Z",
"links" : [
{
"rel" : "self",
"href" : http://blog.example.com/posts/1,
"method" : "GET"
}
]
}
Listing 10-1 Blog Post with Links
在这个响应中,links数组中的每个链接包含三个部分:
-
Href—包含可用于检索资源或更改应用状态的 URI -
Rel—描述href链接与当前资源的关系 -
Method—表示与 URI 交互所需的 HTTP 方法
从链接的href值可以看出,这是一个自引用链接。rel元素可以包含任意的字符串值,在本例中,它有一个值“self”来表示这种自我关系。正如在第一章中所讨论的,一个资源可以由多个 URIs 来标识。在这些情况下,自我链接有助于突出首选的规范 URI。在返回部分资源表示(例如,作为集合的一部分)的情况下,包括自链接将允许客户机检索资源的完整表示。
我们可以扩展博客帖子响应,以包括其他关系。例如,每篇博客文章都有一个作者,即创建文章的用户。每篇博文还包含一组相关的评论和标签。清单 10-2 展示了一个带有这些额外链接关系的博文表示示例。
{
"id" : 1,
"body" : "My first blog post",
"postdate" : "2015-05-30T21:41:12.650Z",
"self" : "http://blog.example.com/posts/1",
"author" : "http://blog.example.com/profile/12345",
"comments" : "http://blog.example.com/posts/1/comments",
"tags" : "http://blog.example.com/posts/1/tags"
}
Listing 10-2Blog Post with Additional Relationships
清单 10-2 中的资源表示采用了不同的方法,没有使用links数组。相反,到相关资源的链接被表示为 JSON 对象属性。例如,带有键author的属性将博客文章与其创建者链接起来。类似地,带有关键字comments和tags的属性将帖子与相关的评论和标签集合资源链接起来。
我们使用了两种不同的方法在表示中嵌入 HATEOAS 链接,以强调 JSON 文档中缺乏标准化链接。在这两种场景中,消费客户端可以使用rel值来识别和导航到相关资源。只要rel值不变,服务器就可以在不破坏客户端的情况下发布新版本的 URI。它还使消费开发人员无需依赖大量文档就能轻松探索 API。
HATEOAS Debate
到目前为止,我们为 QuickPoll 应用开发的 REST API 并没有遵循 HATEOAS 原则。这同样适用于当今消费的许多公共/开源 REST APIs。在 2008 年,Roy Fielding 在他的博客( http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven )中表达了对这种被称为 RESTful 但不是超媒体驱动的 API 的失望:
为了让 REST 架构风格清楚地认识到超文本是一种约束,需要做些什么?换句话说,如果应用状态的引擎(以及 API)不是由超文本驱动的,那么它就不可能是 RESTful 的,也不可能是 REST API。句号。是不是某个地方有什么坏掉的手册需要修理?
七年后,围绕超媒体的角色和什么被认为是 RESTful 的争论仍在继续。博客圈充斥着各种不同的观点,人们对双方都持热情的态度。所谓的超媒体怀疑论者认为超媒体过于学术化,并认为添加额外的链接会增加有效负载,增加不必要的复杂性来支持实际上并不存在的客户。
Kin Lane 在他的博文( http://apievangelist.com/2014/08/05/the-hypermedia-api-debate-sorry-reasonable-just-does-not-sell/ )中很好地总结了超媒体之争。
JSON 超媒体类型
简单地说,超媒体类型是一种媒体类型,它包含定义良好的链接资源的语义。HTML 媒体类型是超媒体类型的一个流行例子。然而,JSON 媒体类型不提供本地超链接语义,因此不被认为是超媒体类型。这导致了在 JSON 文档中嵌入链接的各种定制实现。在前一节中,我们已经看到了两种方法。
Note
生成 XML 响应的 REST 服务通常使用 Atom/AtomPub ( http://en.wikipedia.org/wiki/Atom_(standard) )格式来构建 HATEOAS 链接。
JSON 超媒体类型
为了解决这个问题并在 JSON 文档中提供超链接语义,已经创建了几种 JSON 超媒体类型:
-
哈尔——
-
JSON-LD——
http://json-ld.org -
JSON API-
http://jsonapi.org/
HAL 是最流行的超媒体类型之一,受 Spring 框架支持。在下一节中,我们将介绍 HAL 的基础知识。
硬件抽象层(Hardware Abstract Layer 的缩写)
HypertextAapplicationLlanguage,简称 HAL,是由迈克·凯利在 2011 年创建的一种精益超媒体类型。该规范支持 XML ( application/hal+xml)和 JSON ( application/hal+json)格式。
HAL 媒体类型将资源定义为状态容器、链接集合和一组嵌入式资源。图 10-1 显示了 HAL 资源结构。

图 10-1
HAL 资源结构
资源状态使用 JSON 属性或键/值对来表示。清单 10-3 显示了一个博客文章资源的状态。
{
"id" : 1,
"body" : "My first blog post",
"postdate" : "2015-05-30T21:41:12.650Z"
}
Listing 10-3Blog Post Resource State in HAL
该规范使用保留的_links属性来提供链接功能。属性是一个包含所有链接的 JSON 对象。_links中的每个链接都由它们的链接关系决定,其值包含 URI 和一组可选属性。清单 10-4 显示了用_links属性增加的博客文章资源。注意在comments链接值中使用了一个额外的属性总数。
{
"id" : 1,
"body" : "My first blog post",
"postdate" : "2015-05-30T21:41:12.650Z",
"_links" : {
"self": { "href": "http://blog.example.com/posts/1" },
"comments": { "href": "http://blog.example.com/posts/1/comments", "totalcount" : 20 },
"tags": { "href": "http://blog.example.com/posts/1/tags" }
}
}
Listing 10-4Blog Post Resource with Links in HAL
有些情况下,嵌入资源比链接资源更有效。这将防止客户端进行额外的往返,允许它直接访问嵌入的资源。HAL 使用保留的_embedded属性来嵌入资源。每个嵌入的资源都由它们与包含资源对象的值的链接关系来决定。清单 10-5 显示了嵌入了作者资源的博客文章资源。
{
"id" : 1,
"body" : "My first blog post",
"postdate" : "2015-05-30T21:41:12.650Z",
"_links" : {
"self": { "href": "http://blog.example.com/posts/1" },
"comments": { "href": "http://blog.example.com/posts/1/comments", "totalcount" : 20 },
"tags": { "href": "http://blog.example.com/posts/1/tags" }
},
"_embedded" : {
"author" : {
"_links" : {
"self": { "href": "http://blog.example.com/profile/12345" }
},
"id" : 12345,
"name" : "John Doe",
"displayName" : "JDoe"
}
}
}
Listing 10-5Blog Post Resource with Embedded Resource in HAL
快速投票中的 HATEOAS
Spring 框架提供了一个 Spring HATEOAS 库,它简化了遵循 HATEOAS 原则的 REST 表示的创建。Spring HATEOAS 提供了一个创建链接和组装表示的 API。在本节中,我们将使用 Spring HATEOAS 通过以下三个链接来丰富投票表示:
-
自引用链接
-
投票收集资源的链接
-
链接到计算机结果资源
Note
我们在本章中使用的 QuickPoll 项目可以在下载的源代码的Chapter10\starter文件夹中找到。要遵循本节中的说明,请将 starter 项目导入到您的 IDE 中。完整的解决方案可在Chapter10\final文件夹中找到。请参考完整代码清单的解决方案。
我们通过将清单 10-6 中所示的 Maven 依赖项添加到 QuickPoll 项目的pom.xml文件来开始 Spring HATEOAS 集成。
<dependency>
<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
<version>1.3.3</version>
</dependency>
Listing 10-6Spring HATEOAS Dependency
下一步是修改Poll Java 类,使生成的表示具有相关的链接信息。为了简化超媒体链接的嵌入,Spring HATEOAS 提供了一个资源类可以扩展的org.springframework.hateoas.RepresentationModel类。RepresentationModel类包含了几个添加和删除链接的重载方法。它还包含一个返回与资源相关的 URI 的getId方法。getId实现遵循 REST 原则:资源的 ID 是它的 URI。
清单 10-7 显示了修改后的Poll类扩展ResourceSupport。如果您还记得的话,Poll类已经包含了一个getId方法,该方法返回与相应数据库记录相关联的主键。为了适应由RepresentationModel基类引入的getId方法,我们将getId和setId Poll类方法重构为getPollId和setPollId。
package com.apress.domain;
import org.springframework.hateoas.RepresentationModel;
@Entity
public class Poll extends RepresentationModel {
@Id
@GeneratedValue
@Column(name="POLL_ID")
private Long id;
@Column(name="QUESTION")
private String question;
@OneToMany(cascade=CascadeType.ALL)
@JoinColumn(name="POLL_ID")
@OrderBy
private Set<Option> options;
public Long getPollId() {
return id;
}
public void setPollId(Long id) {
this.id = id;
}
// Other Getters and Setter removed
}
Listing 10-7Modified Poll Class
在第四章中,我们实现了PollController的createPoll方法,因此它使用getId方法构造了新创建的Poll资源的 URI。刚刚描述的getId到getPollId的重构要求我们更新createPoll方法。清单 10-8 显示了使用getPollId方法修改后的createPoll方法。
@RequestMapping(value="/polls", method=RequestMethod.POST)
public ResponseEntity<?> createPoll(@RequestBody Poll poll) {
poll = pollRepository.save(poll);
// Set the location header for the newly created resource
HttpHeaders responseHeaders = new HttpHeaders();
URI newPollUri = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(poll.getPollId()).toUri();
responseHeaders.setLocation(newPollUri);
return new ResponseEntity<>(null, responseHeaders, HttpStatus.CREATED);
}
Listing 10-8Modified createPoll() Method
Note
我们修改了我们的域Poll类,并让它扩展了RepresentationModel类。这种方法的另一种方法是创建一个新的PollResource类来保存Poll的表示,并让它扩展RepresentationModel类。使用这种方法,Poll Java 类保持不变。然而,我们需要修改PollController,以便它将表示从每个Poll复制到一个PollResource并返回PollResource实例。
Spring HATEOAS 集成的最后一步是修改PollController端点,这样我们就可以构建链接并将它们注入到响应中。清单 10-9 显示了PollController的修改部分。
package com.apress.controller;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
@RestController
public class PollController {
@RequestMapping(value="/polls", method=RequestMethod.GET)
public ResponseEntity<Iterable<Poll>> getAllPolls() {
Iterable<Poll> allPolls = pollRepository.findAll();
for(Poll p : allPolls) {
updatePollResourceWithLinks(p);
}
return new ResponseEntity<>(allPolls, HttpStatus.OK);
}
@RequestMapping(value="/polls/{pollId}", method=RequestMethod.GET)
public ResponseEntity<?> getPoll(@PathVariable Long pollId) {
Optional<Poll> p = pollRepository.findById(pollId);
if(!p.isPresent()) {
throw new Exception("Pool was not found");
}
updatePollResourceWithLinks(p.get());
return new ResponseEntity<> (p.get(), HttpStatus.OK);
}
private void updatePollResourceWithLinks(Poll poll) {
poll.add(linkTo(methodOn(PollController.class).getAllPolls()).slash(poll.getPollId()).withSelfRel());
poll.add(linkTo(methodOn(VoteController.class).getAllVotes(poll.getPollId())).withRel("votes"));
poll.add(linkTo(methodOn(ComputeResultController.class).computeResult(poll.getPollId())).withRel("compute-result"));
}
}
Listing 10-9PollController Modifications
因为链接需要在多个地方生成和注入,所以我们创建了一个updatePollResourceWithLinks方法来保存公共代码。Spring HATEOAS 提供了一个方便的ControllerLinkBuilder类,可以构建指向 Spring MVC 控制器的链接。updatePollResourceWithLinks方法实现使用了linkTo、methodOn,和slash实用程序方法。这些方法是 Spring HATEOAS ControllerLinkBuilder类的一部分,可以生成指向 Spring MVC 控制器的链接。生成的链接是对资源的绝对 URIs。这使得开发人员不必查找诸如协议、主机名、端口号等服务器信息,也不必到处复制 URI 路径字符串(/polls)。为了更好地理解这些方法,让我们分析一下这段代码:
linkTo(
methodOn(PollController.class).getAllPolls()
)
.slash(poll.getPollId())
.withSelfRel()
linkTo方法可以接受一个 Spring MVC 控制器类或它的一个方法作为参数。然后,它检查@RequestMapping注释的类或方法,并检索路径值来构建链接。methodOn方法创建了传入的PollController类的动态代理。当在动态代理上调用getAllPolls方法时,检查其@RequestMapping信息并提取值"/polls"。对于像getAllVotes这样需要参数的方法,我们可以传入一个空值。但是,如果该参数用于构建 URI,则应传入一个实值。
顾名思义,slash方法将投票的 ID 作为子资源附加到当前 URI。最后,withSelfRel方法指示生成的链接应该有一个值为“self.”的rel,ControllerLinkBuilder类使用ServletUriComponentsBuilder获取基本的 URI 信息,比如主机名,并构建最终的 URI。因此,对于 ID 为 1 的轮询,该代码将生成 URI: http://localhost:8080/polls/1。
完成这些更改后,运行 QuickPoll 应用,并使用 Postman 在http://localhost:8080/polls URI 上执行 GET 请求。您将看到生成的响应包括每个投票的三个链接。图 10-2 显示了邮递员请求和部分响应。

图 10-2
带链接的邮递员回复
摘要
在本章中,我们回顾了 HATEOAS 约束,它使开发人员能够构建灵活的、松散耦合的 API。我们简单介绍了一下,并使用 Spring HATEOAS 创建了符合 HATEOAS 原则的 QuickPoll REST 表示。
这就把我们带到了旅程的终点。通过这本书,您已经了解了简化 REST 开发的 REST 和 Spring 技术的关键特性。有了这些知识,您应该准备好开始开发自己的 RESTful 服务了。编码快乐!


浙公网安备 33010602011771号