Spring REST 教程(三)
原文:Spring 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 测试框架及其核心类。最后,我们使用测试框架来简化单元和集成测试的创建。
Footnotes 1en.wikipedia.org/wiki/Template_method_pattern
2
en.wikipedia.org/wiki/Builder_pattern
十、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 服务了。编码快乐!