ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
# 使用Spring构建REST服务 REST易于构建和使用,因此已迅速成为在Web上构建Web服务的实际标准。 关于REST如何适用于微服务世界,还有很多讨论,但是-在本教程中-让我们来看构建RESTful服务。 为什么要REST? REST包含Web的戒律,包括其体系结构,优势和其他所有内容。 鉴于其作者Roy Fielding参与了十二个规范网络操作的规范,这不足为奇。 有什么好处? Web及其核心协议HTTP提供了一系列功能: * 适当的动作( `GET`, `POST`, `PUT`, `DELETE`, …​) * 快取 * 重定向和转发 * 安全性(加密和身份验证) 这些都是构建弹性服务的关键因素。 但这还不是全部。 网络是建立在许多微小的规格之上的,因此它能够轻松发展,而不会陷入“标准之战”。 开发人员可以利用实现这些不同规格的第三方工具包,立即拥有客户端和服务器技术。 通过在HTTP之上进行构建,REST API提供了以下构建方法: * 向后兼容的API * 可进化的API * 可扩展的服务 * 安全的服务 * 无状态到有状态服务的范围 重要的是要认识到,REST 无处不在,而是一个标准, *本身* 而是 一种方法,一种样式,一组 *约束* 架构上的 ,可以帮助您构建Web规模的系统。 在本教程中,我们将使用Spring产品组合来构建RESTful服务,同时利用REST的无堆栈功能。 ## 入门 在学习本教程时,我们将使用 [Spring Boot](https://spring.io/projects/spring-boot) 。 转到 [Spring Initializr](https://start.spring.io/) 并将以下依赖项添加到项目中: * 网页 * JPA * H2 将名称更改为“工资单”,然后选择“生成项目”。 一个 `.zip`将下载。 解压缩。 在内部,您会发现一个简单的基于Maven的项目,其中包括 `pom.xml`构建文件(注意:您 *可以* 使用Gradle。本教程中的示例将基于Maven。) Spring Boot可以与任何IDE一起使用。 您可以使用Eclipse,IntelliJ IDEA,Netbeans等。Spring [Tool Suite](https://spring.io/tools/) 是基于Eclipse的开源IDE发行版,它提供Eclipse的Java EE发行版的超集。 它包含的功能使使用Spring应用程序的工作变得更加轻松。 绝不是必需的。 但是,如果您想 额外的 考虑一下 **魅力** 为按键提供 ,请 。 这是一个演示如何开始使用STS和Spring Boot的视频。 这是使您熟悉这些工具的一般介绍。 ## 到目前为止的故事... 让我们从我们可以构造的最简单的东西开始。 实际上,为了使其尽可能简单,我们甚至可以省略REST的概念。 (稍后,我们将添加REST以了解它们之间的区别。) 大图:我们将创建一个简单的工资服务来管理公司的员工。 我们将员工对象存储在(H2内存)数据库中,并(通过称为 访问它们 JPA的方式 ) 。 然后,我们将使用允许通过Internet访问的内容(称为Spring 包装 MVC 层)进行 。 以下代码在我们的系统中定义了一个Employee。 nonrest / src / main / java / payroll / Employee.java ~~~ package payroll; import java.util.Objects; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; @Entity class Employee { private @Id @GeneratedValue Long id; private String name; private String role; Employee() {} Employee(String name, String role) { this.name = name; this.role = role; } public Long getId() { return this.id; } public String getName() { return this.name; } public String getRole() { return this.role; } public void setId(Long id) { this.id = id; } public void setName(String name) { this.name = name; } public void setRole(String role) { this.role = role; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Employee)) return false; Employee employee = (Employee) o; return Objects.equals(this.id, employee.id) && Objects.equals(this.name, employee.name) && Objects.equals(this.role, employee.role); } @Override public int hashCode() { return Objects.hash(this.id, this.name, this.role); } @Override public String toString() { return "Employee{" + "id=" + this.id + ", name='" + this.name + '\'' + ", role='" + this.role + '\'' + '}'; } } ~~~ 尽管很小,但此Java类包含许多内容: * `@Entity` 是一个JPA批注,以使该对象准备好存储在基于JPA的数据存储区中。 * `id`, `name`, 和 `role`是Employee 属性 [域对象的](https://www.google.com/search?q=what+is+a+domain+object+in+java) 。 `id` 标有更多的JPA批注以指示它是主键,并由JPA提供程序自动填充。 * 当我们需要创建新实例但还没有ID时,会创建一个自定义构造函数。 有了这个领域对象定义,我们现在可以转向 [Spring Data JPA](https://spring.io/guides/gs/accessing-data-jpa/) 来处理繁琐的数据库交互。 Spring Data JPA存储库是接口的接口,这些方法支持针对后端数据存储创建,读取,更新和删除记录。 在适当的情况下,某些存储库还支持数据分页和排序。 Spring Data根据在接口中的方法命名中找到的约定来综合实现。 除了JPA,还有多种存储库实现。 您可以使用Spring Data MongoDB,Spring Data GemFire,Spring Data Cassandra等。对于本教程,我们将坚持使用JPA。 Spring使访问数据变得容易。 通过简单地声明以下内容 `EmployeeRepository` 界面,我们将自动能够 * 创造新员工 * 更新现有的 * 删除员工 * 查找员工(一个或全部,或按简单或复杂属性搜索) nonrest / src / main / java / payroll / EmployeeRepository.java ~~~ package payroll; import org.springframework.data.jpa.repository.JpaRepository; interface EmployeeRepository extends JpaRepository<Employee, Long> { } ~~~ 为了获得所有这些免费功能,我们要做的就是声明一个扩展Spring Data JPA的接口。 `JpaRepository`,将域类型指定为 `Employee` 和id类型为 `Long`. Spring Data的 [存储库解决方案](https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories) 可以避开数据存储细节,而可以使用特定于域的术语解决大多数问题。 信不信由你,这足以启动一个应用程序! Spring Boot应用程序至少是一个 `public static void main` 入口点和 `@SpringBootApplication`注解。 这告诉Spring Boot尽可能地提供帮助。 nonrest / src / main / java / payroll / PayrollApplication.java ~~~ package payroll; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class PayrollApplication { public static void main(String... args) { SpringApplication.run(PayrollApplication.class, args); } } ~~~ `@SpringBootApplication`是一个元注释,可引入 **组件扫描** , **自动配置** 和 **属性支持** 。 在本教程中,我们不会深入探讨Spring Boot的细节,但从本质上讲,它将启动servlet容器并提供我们的服务。 尽管如此,没有数据的应用程序并不是很有趣,所以让我们预加载它。 Follow类将在Spring之前自动加载: nonrest / src / main / java / payroll / LoadDatabase.java ~~~ package payroll; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration class LoadDatabase { private static final Logger log = LoggerFactory.getLogger(LoadDatabase.class); @Bean CommandLineRunner initDatabase(EmployeeRepository repository) { return args -> { log.info("Preloading " + repository.save(new Employee("Bilbo Baggins", "burglar"))); log.info("Preloading " + repository.save(new Employee("Frodo Baggins", "thief"))); }; } } ~~~ 加载后会发生什么? * Spring Boot将运行所有 `CommandLineRunner` 一旦应用程序上下文被加载,beans。 * 该跑步者将要求提供一份 `EmployeeRepository` 您刚刚创建的。 * 使用它,它将创建两个实体并将其存储。 右键单击并 **运行** `PayRollApplication`,这就是您得到的: 控制台输出的片段,显示数据的预加载 ~~~ ... 2018-08-09 11:36:26.169 INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=1, name=Bilbo Baggins, role=burglar) 2018-08-09 11:36:26.174 INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=2, name=Frodo Baggins, role=thief) ... ~~~ 这不是 **完整的** 日志,而只是预加载数据的关键部分。 (的确,请查看整个控制台。这很荣耀。) ## HTTP是平台 要使用Web层包装存储库,必须使用Spring MVC。 多亏了Spring Boot,几乎没有基础代码可以使用。 相反,我们可以专注于操作: nonrest / src / main / java / payroll / EmployeeController.java ~~~ package payroll; import java.util.List; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController class EmployeeController { private final EmployeeRepository repository; EmployeeController(EmployeeRepository repository) { this.repository = repository; } // Aggregate root // tag::get-aggregate-root[] @GetMapping("/employees") List<Employee> all() { return repository.findAll(); } // end::get-aggregate-root[] @PostMapping("/employees") Employee newEmployee(@RequestBody Employee newEmployee) { return repository.save(newEmployee); } // Single item @GetMapping("/employees/{id}") Employee one(@PathVariable Long id) { return repository.findById(id) .orElseThrow(() -> new EmployeeNotFoundException(id)); } @PutMapping("/employees/{id}") Employee replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) { return repository.findById(id) .map(employee -> { employee.setName(newEmployee.getName()); employee.setRole(newEmployee.getRole()); return repository.save(employee); }) .orElseGet(() -> { newEmployee.setId(id); return repository.save(newEmployee); }); } @DeleteMapping("/employees/{id}") void deleteEmployee(@PathVariable Long id) { repository.deleteById(id); } } ~~~ * `@RestController`指示每种方法返回的数据将直接写入响应主体中,而不呈现模板。 * 一个 `EmployeeRepository` 由构造函数注入到控制器中。 * 我们为每个操作提供路线( `@GetMapping`, `@PostMapping`, `@PutMapping` 和 `@DeleteMapping`,对应于HTTP `GET`, `POST`, `PUT`, 和 `DELETE`电话)。 (注意:阅读每种方法并了解它们的作用非常有用。) * `EmployeeNotFoundException` 是一个例外,用于指示何时查找员工但未找到该员工。 nonrest / src / main / java / payroll / EmployeeNotFoundException.java ~~~ package payroll; class EmployeeNotFoundException extends RuntimeException { EmployeeNotFoundException(Long id) { super("Could not find employee " + id); } } ~~~ 当一个 `EmployeeNotFoundException`抛出该异常,Spring MVC配置的这个额外花絮用于呈现 **HTTP 404** : nonrest / src / main / java / payroll / EmployeeNotFoundAdvice.java ~~~ package payroll; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; @ControllerAdvice class EmployeeNotFoundAdvice { @ResponseBody @ExceptionHandler(EmployeeNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) String employeeNotFoundHandler(EmployeeNotFoundException ex) { return ex.getMessage(); } } ~~~ * `@ResponseBody`表示此建议已直接呈现到响应主体中。 * `@ExceptionHandler` 将建议配置为仅在以下情况下响应 `EmployeeNotFoundException` 被抛出。 * `@ResponseStatus` 说要发出 `HttpStatus.NOT_FOUND`,即 **HTTP 404** 。 * 建议的主体生成内容。 在这种情况下,它会给出异常消息。 要启动该应用程序,请右键单击 `public static void main` 在 `PayRollApplication`并选择 **运行** 从IDE ,或者: Spring Initializr使用Maven包装器,因此键入: ~~~ $ ./mvnw clean spring-boot:run ~~~ 或者使用您安装的Maven版本键入以下命令: ~~~ $ mvn clean spring-boot:run ~~~ 应用启动后,我们可以立即对其进行查询。 ~~~ $ curl -v localhost:8080/employees ~~~ 这将产生: ~~~ * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > GET /employees HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.54.0 > Accept: */* > < HTTP/1.1 200 < Content-Type: application/json;charset=UTF-8 < Transfer-Encoding: chunked < Date: Thu, 09 Aug 2018 17:58:00 GMT < * Connection #0 to host localhost left intact [{"id":1,"name":"Bilbo Baggins","role":"burglar"},{"id":2,"name":"Frodo Baggins","role":"thief"}] ~~~ 在这里,您可以以压缩格式查看预加载的数据。 如果您尝试查询一个不存在的用户... ~~~ $ curl -v localhost:8080/employees/99 ~~~ 你得到... ~~~ * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > GET /employees/99 HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.54.0 > Accept: */* > < HTTP/1.1 404 < Content-Type: text/plain;charset=UTF-8 < Content-Length: 26 < Date: Thu, 09 Aug 2018 18:00:56 GMT < * Connection #0 to host localhost left intact Could not find employee 99 ~~~ 此消息很好地显示了 **HTTP 404** 错误和自定义消息“ **找不到雇员99”** 。 显示当前编码的交互并不难... 如果您使用Windows命令提示符发出cURL命令,则以下命令可能无法正常工作。 您必须选择一个支持单引号引号的终端,或者使用双引号然后将其转义为JSON。 创建一个新的 `Employee` 记录我们在终端中使用以下命令- `$` 开头表示此命令是终端命令: ~~~ $ curl -X POST localhost:8080/employees -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}' ~~~ 然后,它将存储新创建的员工并将其发送回给我们: ~~~ {"id":3,"name":"Samwise Gamgee","role":"gardener"} ~~~ 您可以更新用户。 让我们改变他的角色。 ~~~ $ curl -X PUT localhost:8080/employees/3 -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}' ~~~ 我们可以看到更改反映在输出中。 ~~~ {"id":3,"name":"Samwise Gamgee","role":"ring bearer"} ~~~ 构建服务的方式可能会产生重大影响。 在这种情况下,我们说 update ,但是 replace 是一个更好的描述。 例如,如果未提供名称,则将其清空。 最后,您可以像这样删除用户: ~~~ $ curl -X DELETE localhost:8080/employees/3 # Now if we look again, it's gone $ curl localhost:8080/employees/3 Could not find employee 3 ~~~ 这一切都很好,但是我们有RESTful服务吗? (如果您没有收到提示,那么答案是否定的。) 缺少了什么? ## 是什么使RESTful变得有趣? 到目前为止,您已经有了基于Web的服务,该服务可以处理涉及员工数据的核心操作。 但这还不足以使事情变得“ RESTful”。 * 漂亮的网址,例如 `/employees/3` 不是REST。 * 仅仅使用 `GET`, `POST`等不是REST。 * 安排所有CRUD操作不是REST。 实际上,到目前为止,我们更好地描述了 **RPC** ( **远程过程调用** )。 那是因为没有办法知道如何与该服务进行交互。 如果您今天发布了此文档,则还必须编写文档或将开发人员的门户托管在所有详细信息的某个位置。 Roy Fielding的这一声明可能进一步为 之间的区别提供了线索 **REST** 和 **RPC** : > 人们对将任何基于HTTP的接口称为REST API的人数感到沮丧。 今天的示例是SocialSite REST API。 那就是RPC。 它尖叫RPC。 显示器上耦合太多,因此应给定X等级。 > > 要使REST体系结构风格清晰地认识到超文本是一种约束,需要采取什么措施? 换句话说,如果应用程序状态的引擎(以及API)不是由超文本驱动的,则它不能是RESTful的,也不能是REST API。 时期。 是否有一些需要修复的损坏的手册? —罗伊·菲尔丁(Roy Fielding) https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven 在我们的表示中不包含超媒体的副作用是客户端必须使用硬编码URI来导航API。 这导致了与电子商务在网络上兴起之前一样的脆弱性。 这表明我们的JSON输出需要一点帮助。 介绍 [Spring HATEOAS](https://spring.io/projects/spring-hateoas) ,这是一个Spring项目,旨在帮助您编写超媒体驱动的输出。 要将服务升级为RESTful,请将其添加到您的构建中: 将Spring HATEOAS添加到 `dependencies` 的部分 `pom.xml` ~~~ <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> </dependency> ~~~ 这个很小的库将为我们提供构造以定义RESTful服务,然后将其呈现为可接受的格式以供客户端使用。 任何RESTful服务的关键要素是添加 [链接](https://tools.ietf.org/html/rfc8288) 到相关操作的 。 为了使您的控制器更加RESTful,请添加如下链接: 获取单个项目资源 ~~~ @GetMapping("/employees/{id}") EntityModel<Employee> one(@PathVariable Long id) { Employee employee = repository.findById(id) // .orElseThrow(() -> new EmployeeNotFoundException(id)); return EntityModel.of(employee, // linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel(), linkTo(methodOn(EmployeeController.class).all()).withRel("employees")); } ~~~ 本教程基于Spring MVC,并使用来自的静态帮助器方法。 WebMvcLinkBuilder建立这些链接。 如果您在项目中使用Spring WebFlux,则必须改为使用 WebFluxLinkBuilder. 这与我们以前的非常相似,但是有一些变化: * 方法的返回类型已从更改为 `Employee` 到 `EntityModel<Employee>`. `EntityModel<T>` 是Spring HATEOAS的通用容器,它不仅包含数据,还包含链接的集合。 * `linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel()` 要求Spring HATEOAS建立指向 `EmployeeController` 的 `one()`方法,并将其标记为 [自我](https://www.iana.org/assignments/link-relations/link-relations.xhtml) 链接。 * `linkTo(methodOn(EmployeeController.class).all()).withRel("employees")` 要求Spring HATEOAS构建到聚合根的链接, `all()`,并将其称为“员工”。 “建立链接”是什么意思? Spring HATEOAS的核心类型之一是 `Link`。 它包括一个 **URI** 和一个 **rel** (关系)。 链接是赋予网络权力的要素。 在万维网出现之前,其他文档系统会呈现信息或链接,但是将文档与具有这种关系元数据的链接紧密地联系在一起就是网络。 Roy Fielding鼓励使用使网络成功的相同技术来构建API,链接就是其中之一。 如果重新启动应用程序并查询 的雇员记录 *Bilbo* ,您将得到与之前稍有不同的响应: 冰壶漂亮当您的curl输出变得更加复杂时,它可能变得难以阅读。 使用此 或 技巧 其他技巧 来美化curl所返回的json:# The indicated part pipes the output to json_pp and asks it to make your JSON pretty. (Or use whatever tool you like!) # v------------------v curl -v localhost:8080/employees/1 | json_pp RESTful代表单个员工 ~~~ { "id": 1, "name": "Bilbo Baggins", "role": "burglar", "_links": { "self": { "href": "http://localhost:8080/employees/1" }, "employees": { "href": "http://localhost:8080/employees" } } } ~~~ 解压缩后的输出不仅显示您先前看到的数据元素( `id`, `name` 和 `role`),但也 `_links`包含两个URI的条目。 整个文档使用 格式化 [HAL](http://stateless.co/hal_specification.html) 。 HAL是一个轻量级的 [介质类型](https://tools.ietf.org/html/draft-kelly-json-hal-08) ,允许编码不只是数据,而且还超媒体管制,提醒消费者,他们可以向浏览API的其他部分。 在这种情况下,存在一个“自我”链接(有点像 `this`代码中的语句)以及返回 的链接 **[聚合根](https://www.google.com/search?q=What+is+an+aggregate+root)** 。 为了使聚合根ALSO更具RESTful,您希望包括顶级链接,同时ALSO包括其中的所有RESTful组件。 所以我们把这个 获取聚合根 ~~~ @GetMapping("/employees") List<Employee> all() { return repository.findAll(); } ~~~ 进入这个 获取聚合的根 **资源** ~~~ @GetMapping("/employees") CollectionModel<EntityModel<Employee>> all() { List<EntityModel<Employee>> employees = repository.findAll().stream() .map(employee -> EntityModel.of(employee, linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(), linkTo(methodOn(EmployeeController.class).all()).withRel("employees"))) .collect(Collectors.toList()); return CollectionModel.of(employees, linkTo(methodOn(EmployeeController.class).all()).withSelfRel()); } ~~~ 哇! 这种方法,以前只是 `repository.findAll()`,都长大了! 不要担心。 让我们打开包装。 `CollectionModel<>`是另一个Spring HATEOAS容器; 它旨在封装资源集合,而不是单个资源实体,例如 `EntityModel<>` 从以前开始。 `CollectionModel<>`,也允许您包含链接。 不要让第一个陈述漏掉。 “封装集合”是什么意思? 员工收款? 不完全的。 由于我们在谈论REST,因此它应该封装 集合 **员工资源的** 。 这就是为什么您要获取所有员工,然后将其转换为以下列表的原因 `EntityModel<Employee>`对象。 (感谢Java 8流!) 如果重新启动应用程序并获取聚合根,则现在可以看到它的外观。 RESTful表示员工资源集合 ~~~ { "_embedded": { "employeeList": [ { "id": 1, "name": "Bilbo Baggins", "role": "burglar", "_links": { "self": { "href": "http://localhost:8080/employees/1" }, "employees": { "href": "http://localhost:8080/employees" } } }, { "id": 2, "name": "Frodo Baggins", "role": "thief", "_links": { "self": { "href": "http://localhost:8080/employees/2" }, "employees": { "href": "http://localhost:8080/employees" } } } ] }, "_links": { "self": { "href": "http://localhost:8080/employees" } } } ~~~ 对于服务于员工资源集合的聚合根,有一个顶层 **“自我”** 链接。 在 **“收藏”** 被列在下面 **“\_embedded”** 部分; 这就是HAL表示集合的方式。 并且集合中的每个成员都有其信息以及相关链接。 添加所有这些链接的意义何在? 随着时间的推移,它使发展REST服务成为可能。 可以维护现有链接,而将来可以添加新链接。 较新的客户端可以利用新链接,而旧客户端可以在旧链接上维持自己的状态。 如果服务被重新定位和移动,这将特别有用。 只要保持链接结构,客户端就可以查找并与事物进行交互。 ## 简化链接创建 在前面的代码中,您是否注意到在创建单个员工链接时重复执行此操作? 两次显示了提供指向员工的单个链接以及创建指向聚合根的“员工”链接的代码。 如果那引起您的关注,那就好! 有一个解决方案。 简而言之,您需要定义一个函数来转换 `Employee` 反对 `EntityModel<Employee>`对象。 虽然您可以轻松地自己编写此方法,但在实现Spring HATEOAS的过程中仍有很多好处 `RepresentationModelAssembler` 界面-可以为您完成工作。 evolution / src / main / java / payroll / EmployeeModelAssembler.java ~~~ package payroll; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.server.RepresentationModelAssembler; import org.springframework.stereotype.Component; @Component class EmployeeModelAssembler implements RepresentationModelAssembler<Employee, EntityModel<Employee>> { @Override public EntityModel<Employee> toModel(Employee employee) { return EntityModel.of(employee, // linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(), linkTo(methodOn(EmployeeController.class).all()).withRel("employees")); } } ~~~ 这个简单的界面有一种方法: `toModel()`。 它基于转换非模型对象( `Employee`)到基于模型的对象( `EntityModel<Employee>`). 您之前在控制器中看到的所有代码都可以移入此类。 并通过应用Spring Framework的 `@Component` 注释,则在应用启动时将自动创建汇编器。 Spring HATEOAS所有模型的抽象基类是 RepresentationModel。 但为简单起见,我建议使用 EntityModel&lt;T&gt; 作为将所有POJO轻松包装为模型的机制。 要利用此汇编器,您只需更改 `EmployeeController` 通过将汇编程序注入构造函数中。 将EmployeeModelAssembler注入控制器 ~~~ @RestController class EmployeeController { private final EmployeeRepository repository; private final EmployeeModelAssembler assembler; EmployeeController(EmployeeRepository repository, EmployeeModelAssembler assembler) { this.repository = repository; this.assembler = assembler; } ... } ~~~ 从这里,您可以在单项employee方法中使用该汇编器: 使用汇编器获取单项资源 ~~~ @GetMapping("/employees/{id}") EntityModel<Employee> one(@PathVariable Long id) { Employee employee = repository.findById(id) // .orElseThrow(() -> new EmployeeNotFoundException(id)); return assembler.toModel(employee); } ~~~ 这段代码几乎相同,除了不是创建 `EntityModel<Employee>`在这里,您将其委托给汇编器。 也许看起来并不多。 在聚合根控制器方法中应用相同的内容会更加令人印象深刻: 使用汇编器获取聚合根资源 ~~~ @GetMapping("/employees") CollectionModel<EntityModel<Employee>> all() { List<EntityModel<Employee>> employees = repository.findAll().stream() // .map(assembler::toModel) // .collect(Collectors.toList()); return CollectionModel.of(employees, linkTo(methodOn(EmployeeController.class).all()).withSelfRel()); } ~~~ 再次,代码几乎相同,但是您必须替换所有代码 `EntityModel<Employee>` 与创建逻辑 `map(assembler::toModel)`。 感谢Java 8方法参考,将其插入并简化您的控制器非常容易。 A key design goal of Spring HATEOAS is to make it easier to do The Right Thing™. In this scenario: adding hypermedia to your service without hard coding a thing. At this stage, you’ve created a Spring MVC REST controller that actually produces hypermedia-powered content! Clients that don’t speak HAL can ignore the extra bits while consuming the pure data. Clients that DO speak HAL can navigate your empowered API. But that is not the only thing needed to build a truly RESTful service with Spring. ## Evolving REST APIs With one additional library and a few lines of extra code, you have added hypermedia to your application. But that is not the only thing needed to make your service RESTful. An important facet of REST is the fact that it’s neither a technology stack nor a single standard. REST是体系结构约束的集合,采用这些约束可使您的应用程序更具弹性。 弹性的关键因素是,当您升级服务时,您的客户不会遭受停机时间的困扰。 在“过去”的日子里,升级是臭名昭著的,因为它破坏了客户。 换句话说,对服务器的升级需要对客户端的更新。 在当今时代,升级花费的停机时间甚至数小时甚至数分钟可能会导致数百万美元的收入损失。 一些公司要求您向管理层提出计划,以最大程度地减少停机时间。 过去,您可以在周日凌晨2:00进行升级,而此时负载已降至最低。 但是,在当今与其他时区的国际客户进行的基于Internet的电子商务中,这种策略并不那么有效。 [基于SOAP的服务 基于](https://www.tutorialspoint.com/soap/what_is_soap.htm) 和 [CORBA的服务](https://www.corba.org/faq.htm) 非常脆弱。 很难推出可以同时支持新旧客户端的服务器。 借助基于REST的实践,它变得容易得多。 特别是使用Spring堆栈。 ### 支持对API的更改 想象一下这个设计问题:您已经使用此工具推出了一个系统 `Employee`基于记录。 该系统是一个重大打击。 您已将系统卖给了无数企业。 突然之间,需要将员工姓名拆分为 `firstName` 和 `lastName` 出现。 哦哦 没想到。 在打开之前 `Employee` 类并替换单个字段 `name` 和 `firstName`和lastName`,停下来想一想。 这样会打断任何客户吗? 升级它们需要多长时间。 您甚至控制所有访问您服务的客户端吗? 停机时间=赔钱。 管理层为此做好了准备吗? 有一种古老的策略要比REST早很多年。 > 切勿删除数据库中的列。 —未知 您始终可以将列(字段)添加到数据库表中。 但是不要带走一个。 RESTful服务的原理是相同的。 向您的JSON表示中添加新字段,但不要花任何时间。 像这样: 支持多个客户端的JSON ~~~ { "id": 1, "firstName": "Bilbo", "lastName": "Baggins", "role": "burglar", "name": "Bilbo Baggins", "_links": { "self": { "href": "http://localhost:8080/employees/1" }, "employees": { "href": "http://localhost:8080/employees" } } } ~~~ 注意这种格式的显示方式 `firstName`, `lastName`, 和 `name`? 它具有重复信息的功能,目的是为新老客户提供支持。 这意味着您可以升级服务器而无需同时升级客户端。 一个很好的举动应该可以减少停机时间。 而且,您不仅应该以“旧方式”和“新方式”显示此信息,还应该以两种方式处理传入的数据。 如何? 简单的。 像这样: 处理“旧”和“新”客户的员工记录 ~~~ package payroll; import java.util.Objects; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; @Entity class Employee { private @Id @GeneratedValue Long id; private String firstName; private String lastName; private String role; Employee() {} Employee(String firstName, String lastName, String role) { this.firstName = firstName; this.lastName = lastName; this.role = role; } public String getName() { return this.firstName + " " + this.lastName; } public void setName(String name) { String[] parts = name.split(" "); this.firstName = parts[0]; this.lastName = parts[1]; } public Long getId() { return this.id; } public String getFirstName() { return this.firstName; } public String getLastName() { return this.lastName; } public String getRole() { return this.role; } public void setId(Long id) { this.id = id; } public void setFirstName(String firstName) { this.firstName = firstName; } public void setLastName(String lastName) { this.lastName = lastName; } public void setRole(String role) { this.role = role; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Employee)) return false; Employee employee = (Employee) o; return Objects.equals(this.id, employee.id) && Objects.equals(this.firstName, employee.firstName) && Objects.equals(this.lastName, employee.lastName) && Objects.equals(this.role, employee.role); } @Override public int hashCode() { return Objects.hash(this.id, this.firstName, this.lastName, this.role); } @Override public String toString() { return "Employee{" + "id=" + this.id + ", firstName='" + this.firstName + '\'' + ", lastName='" + this.lastName + '\'' + ", role='" + this.role + '\'' + '}'; } } ~~~ 此类与以前的版本非常相似 `Employee`。 让我们来看一下更改: * 场地 `name` 已被取代 `firstName` 和 `lastName`. * 旧的“虚拟”吸气剂 `name` 财产, `getName()`被定义为。 它使用 `firstName` 和 `lastName` 产生价值的字段。 * 旧的“虚拟”二传手 `name` 属性也被定义, `setName()`。 它解析输入的字符串并将其存储到适当的字段中。 当然,对您的API所做的每一次更改都不像拆分字符串或合并两个字符串那样简单。 但是,对于大多数情况,一定要想出一套转换方法,对吗? 别忘了去更改预加载数据库的方式(在 LoadDatabase)以使用此新的构造函数。log.info("Preloading " + repository.save(new Employee("Bilbo", "Baggins", "burglar"))); log.info("Preloading " + repository.save(new Employee("Frodo", "Baggins", "thief"))); #### 适当的回应 朝着正确方向迈出的另一步涉及确保您的每个REST方法都返回正确的响应。 像这样更新POST方法: POST处理“旧”和“新”客户端请求 ~~~ @PostMapping("/employees") ResponseEntity<?> newEmployee(@RequestBody Employee newEmployee) { EntityModel<Employee> entityModel = assembler.toModel(repository.save(newEmployee)); return ResponseEntity // .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) // .body(entityModel); } ~~~ * 新的 `Employee`对象将像以前一样保存。 但是使用 `EmployeeModelAssembler`. * 春季MVC `ResponseEntity`用于创建 **HTTP 201已创建** 状态消息。 这种类型的响应通常包括一个 **Location** 响应标头,并且我们使用从模型的自相关链接派生的URI。 * 此外,返回已保存对象的基于模型的版本。 进行这些调整后,您可以使用相同的端点来创建新的员工资源,并使用旧版 `name` 场地: ~~~ $ curl -v -X POST localhost:8080/employees -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}' ~~~ 输出如下所示: ~~~ > POST /employees HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.54.0 > Accept: */* > Content-Type:application/json > Content-Length: 46 > < Location: http://localhost:8080/employees/3 < Content-Type: application/hal+json;charset=UTF-8 < Transfer-Encoding: chunked < Date: Fri, 10 Aug 2018 19:44:43 GMT < { "id": 3, "firstName": "Samwise", "lastName": "Gamgee", "role": "gardener", "name": "Samwise Gamgee", "_links": { "self": { "href": "http://localhost:8080/employees/3" }, "employees": { "href": "http://localhost:8080/employees" } } } ~~~ 这不仅使生成的对象在HAL中呈现(两者都 `name` 也 `firstName`/ `lastName`),还 了 **Location** 填充 标头 `[http://localhost:8080/employees/3](http://localhost:8080/employees/3)`。 具有超媒体功能的客户端可以选择“浏览”该新资源并继续与之交互。 PUT控制器方法需要类似的调整: 为不同的客户处理PUT ~~~ @PutMapping("/employees/{id}") ResponseEntity<?> replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) { Employee updatedEmployee = repository.findById(id) // .map(employee -> { employee.setName(newEmployee.getName()); employee.setRole(newEmployee.getRole()); return repository.save(employee); }) // .orElseGet(() -> { newEmployee.setId(id); return repository.save(newEmployee); }); EntityModel<Employee> entityModel = assembler.toModel(updatedEmployee); return ResponseEntity // .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) // .body(entityModel); } ~~~ 这 `Employee` 从 `save()` 然后使用 `EmployeeModelAssembler` 变成一个 `EntityModel<Employee>`目的。 使用 `getRequiredLink()` 方法,您可以检索 `Link` 由创建 `EmployeeModelAssembler` 与一个 `SELF`rel。 此方法返回一个 `Link` 必须将其变成 `URI` 与 `toUri` 方法。 由于我们需要比 更详细的HTTP响应代码 **200 OK** ,因此我们将使用Spring MVC的 `ResponseEntity`包装纸。 它有一个方便的静态方法 `created()`我们可以在其中插入资源的URI。 如果 这是有争议的, **HTTP 201 Created** 带有正确的语义, 因为我们不一定要“创建”新资源。 但是它预装了 **Location** 响应标头,因此请与它一起运行。 ~~~ $ curl -v -X PUT localhost:8080/employees/3 -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}' * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > PUT /employees/3 HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.54.0 > Accept: */* > Content-Type:application/json > Content-Length: 49 > < HTTP/1.1 201 < Location: http://localhost:8080/employees/3 < Content-Type: application/hal+json;charset=UTF-8 < Transfer-Encoding: chunked < Date: Fri, 10 Aug 2018 19:52:56 GMT { "id": 3, "firstName": "Samwise", "lastName": "Gamgee", "role": "ring bearer", "name": "Samwise Gamgee", "_links": { "self": { "href": "http://localhost:8080/employees/3" }, "employees": { "href": "http://localhost:8080/employees" } } } ~~~ 该员工资源现已更新,并且位置URI被发送回。 最后,适当地更新DELETE操作: 处理DELETE请求 ~~~ @DeleteMapping("/employees/{id}") ResponseEntity<?> deleteEmployee(@PathVariable Long id) { repository.deleteById(id); return ResponseEntity.noContent().build(); } ~~~ 这将返回 **HTTP 204 No Content** 响应。 ~~~ $ curl -v -X DELETE localhost:8080/employees/1 * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > DELETE /employees/1 HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.54.0 > Accept: */* > < HTTP/1.1 204 < Date: Fri, 10 Aug 2018 21:30:26 GMT ~~~ 对字段中的字段进行更改 Employee 该类将需要与您的数据库团队协调,以便他们可以将现有内容正确迁移到新列中。 现在,您可以进行升级了,它不会打扰现有的客户端,而新的客户端可以利用这些增强功能! 顺便说一句,您是否担心通过网络发送太多信息? 在某些每个字节都很重要的系统中,API的发展可能需要退居二线。 但是,在进行测量之前,不要追求这种过早的优化。 ## 将链接构建到您的REST API中 到目前为止,您已经建立了具有裸露骨骼链接的可演化API。 为了增加您的API并更好地为您的客户服务,您需要接受 的概念 **Hypermedia作为应用程序状态引擎** 。 这意味着什么? 在本节中,您将详细研究它。 业务逻辑不可避免地建立涉及流程的规则。 此类系统的风险在于,我们经常将此类服务器端逻辑带入客户端,并建立牢固的耦合。 REST旨在断开此类连接并最大程度地减少这种耦合。 为了说明如何在不触发客户端变更的情况下应对状态变化,请设想添加一个可以执行订单的系统。 第一步,定义一个 `Order` 记录: links / src / main / java / payroll / Order.java ~~~ package payroll; import java.util.Objects; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.Table; @Entity @Table(name = "CUSTOMER_ORDER") class Order { private @Id @GeneratedValue Long id; private String description; private Status status; Order() {} Order(String description, Status status) { this.description = description; this.status = status; } public Long getId() { return this.id; } public String getDescription() { return this.description; } public Status getStatus() { return this.status; } public void setId(Long id) { this.id = id; } public void setDescription(String description) { this.description = description; } public void setStatus(Status status) { this.status = status; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Order)) return false; Order order = (Order) o; return Objects.equals(this.id, order.id) && Objects.equals(this.description, order.description) && this.status == order.status; } @Override public int hashCode() { return Objects.hash(this.id, this.description, this.status); } @Override public String toString() { return "Order{" + "id=" + this.id + ", description='" + this.description + '\'' + ", status=" + this.status + '}'; } } ~~~ * 该课程需要一个JPA `@Table` 批注将表的名称更改为 `CUSTOMER_ORDER` 因为 `ORDER` 不是表格的有效名称。 * 它包括一个 `description` 领域以及 `status` 场地。 从客户提交订单到完成或取消订单之时,订单必须经历一系列特定的状态转换。 可以将其捕获为Java `enum`: 链接/src/main/java/payroll/Status.java ~~~ package payroll; enum Status { IN_PROGRESS, // COMPLETED, // CANCELLED } ~~~ 这个 `enum` 捕获各种状态 `Order`可以占领。 对于本教程,让我们保持简单。 为了支持与数据库中的订单进行交互,您必须定义一个相应的Spring Data存储库: Spring Data JPA的 `JpaRepository` 基本介面 ~~~ interface OrderRepository extends JpaRepository<Order, Long> { } ~~~ With this in place, you can now define a basic `OrderController`: links/src/main/java/payroll/OrderController.java ~~~ @RestController class OrderController { private final OrderRepository orderRepository; private final OrderModelAssembler assembler; OrderController(OrderRepository orderRepository, OrderModelAssembler assembler) { this.orderRepository = orderRepository; this.assembler = assembler; } @GetMapping("/orders") CollectionModel<EntityModel<Order>> all() { List<EntityModel<Order>> orders = orderRepository.findAll().stream() // .map(assembler::toModel) // .collect(Collectors.toList()); return CollectionModel.of(orders, // linkTo(methodOn(OrderController.class).all()).withSelfRel()); } @GetMapping("/orders/{id}") EntityModel<Order> one(@PathVariable Long id) { Order order = orderRepository.findById(id) // .orElseThrow(() -> new OrderNotFoundException(id)); return assembler.toModel(order); } @PostMapping("/orders") ResponseEntity<EntityModel<Order>> newOrder(@RequestBody Order order) { order.setStatus(Status.IN_PROGRESS); Order newOrder = orderRepository.save(order); return ResponseEntity // .created(linkTo(methodOn(OrderController.class).one(newOrder.getId())).toUri()) // .body(assembler.toModel(newOrder)); } } ~~~ * 它包含与到目前为止构建的控制器相同的REST控制器设置。 * 它同时注入 `OrderRepository` 以及(尚未构建) `OrderModelAssembler`. * The first two Spring MVC routes handle the aggregate root as well as a single item `Order` resource request. * The third Spring MVC route handles creating new orders, by starting them in the `IN_PROGRESS` state. * All the controller methods return one of Spring HATEOAS’s `RepresentationModel` subclasses to properly render hypermedia (or a wrapper around such a type). 在构建之前 `OrderModelAssembler`,让我们讨论一下需要发生的事情。 您正在建模之间的状态流 `Status.IN_PROGRESS`, `Status.COMPLETED`, 和 `Status.CANCELLED`。 向客户端提供此类数据时,很自然的事情是让客户端根据此有效负载决定它可以做什么。 但这是错误的。 在此流程中引入新状态时会发生什么? UI上各种按钮的放置可能是错误的。 如果您更改了每个州的名称,可能是在编码国际支持并显示每个州的特定于语言环境的文本时呢? 那很可能会破坏所有客户。 输入 **HATEOAS** 或 **Hypermedia作为应用程序状态引擎** 。 与其让客户端解析有效负载,不如让客户端链接以发出有效动作信号。 将基于状态的操作与数据的有效负载分离。 换句话说,当 **CANCEL** 和 **COMPLETE** 是有效动作时,将它们动态添加到链接列表中。 链接存在时,客户端仅需要向用户显示相应的按钮。 这使客户端不必知道何时需要执行此类操作,从而减少了服务器及其客户端在状态转换逻辑上不同步的风险。 已经接受Spring HATEOAS的概念 `RepresentationModelAssembler` 组件,将这样的逻辑放在 `OrderModelAssembler` 将是捕获此业务规则的理想场所: 链接/src/main/java/payroll/OrderModelAssembler.java ~~~ package payroll; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.server.RepresentationModelAssembler; import org.springframework.stereotype.Component; @Component class OrderModelAssembler implements RepresentationModelAssembler<Order, EntityModel<Order>> { @Override public EntityModel<Order> toModel(Order order) { // Unconditional links to single-item resource and aggregate root EntityModel<Order> orderModel = EntityModel.of(order, linkTo(methodOn(OrderController.class).one(order.getId())).withSelfRel(), linkTo(methodOn(OrderController.class).all()).withRel("orders")); // Conditional links based on state of the order if (order.getStatus() == Status.IN_PROGRESS) { orderModel.add(linkTo(methodOn(OrderController.class).cancel(order.getId())).withRel("cancel")); orderModel.add(linkTo(methodOn(OrderController.class).complete(order.getId())).withRel("complete")); } return orderModel; } } ~~~ This resource assembler always includes the **self** link to the single-item resource as well as a link back to the aggregate root. But it also includes two conditional links to `OrderController.cancel(id)` as well as `OrderController.complete(id)` (not yet defined). These links are ONLY shown when the order’s status is `Status.IN_PROGRESS`. 如果客户可以采用HAL并具有读取链接的能力,而不是简单地读取普通的旧JSON数据,则可以交易对订单系统领域知识的需求。 这自然减少了客户端和服务器之间的耦合。 它为调整订单履行流程打开了一扇门,而不会破坏流程中的客户。 要完善订单履行,请将以下内容添加到 `OrderController` 为了 `cancel` 手术: 在OrderController中创建“取消”操作 ~~~ @DeleteMapping("/orders/{id}/cancel") ResponseEntity<?> cancel(@PathVariable Long id) { Order order = orderRepository.findById(id) // .orElseThrow(() -> new OrderNotFoundException(id)); if (order.getStatus() == Status.IN_PROGRESS) { order.setStatus(Status.CANCELLED); return ResponseEntity.ok(assembler.toModel(orderRepository.save(order))); } return ResponseEntity // .status(HttpStatus.METHOD_NOT_ALLOWED) // .header(HttpHeaders.CONTENT_TYPE, MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE) // .body(Problem.create() // .withTitle("Method not allowed") // .withDetail("You can't cancel an order that is in the " + order.getStatus() + " status")); } ~~~ 它检查 `Order`状态,然后才能将其取消。 如果状态无效,则返回 [RFC-7807](https://tools.ietf.org/html/rfc7807) `Problem`,一个支持超媒体的错误容器。 如果转换确实有效,则转换 `Order` 到 `CANCELLED`. And add this to the `OrderController` as well for order completion: Creating a "complete" operation in the OrderController ~~~ @PutMapping("/orders/{id}/complete") ResponseEntity<?> complete(@PathVariable Long id) { Order order = orderRepository.findById(id) // .orElseThrow(() -> new OrderNotFoundException(id)); if (order.getStatus() == Status.IN_PROGRESS) { order.setStatus(Status.COMPLETED); return ResponseEntity.ok(assembler.toModel(orderRepository.save(order))); } return ResponseEntity // .status(HttpStatus.METHOD_NOT_ALLOWED) // .header(HttpHeaders.CONTENT_TYPE, MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE) // .body(Problem.create() // .withTitle("Method not allowed") // .withDetail("You can't complete an order that is in the " + order.getStatus() + " status")); } ~~~ 这实现了类似的逻辑,以防止 `Order` 除非处于适当状态,否则状态将无法完成。 让我们更新 `LoadDatabase` 预装一些 `Order`以及 `Employee`它是以前加载的。 更新数据库预加载器 ~~~ package payroll; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration class LoadDatabase { private static final Logger log = LoggerFactory.getLogger(LoadDatabase.class); @Bean CommandLineRunner initDatabase(EmployeeRepository employeeRepository, OrderRepository orderRepository) { return args -> { employeeRepository.save(new Employee("Bilbo", "Baggins", "burglar")); employeeRepository.save(new Employee("Frodo", "Baggins", "thief")); employeeRepository.findAll().forEach(employee -> log.info("Preloaded " + employee)); orderRepository.save(new Order("MacBook Pro", Status.COMPLETED)); orderRepository.save(new Order("iPhone", Status.IN_PROGRESS)); orderRepository.findAll().forEach(order -> { log.info("Preloaded " + order); }); }; } } ~~~ 现在您可以测试了! 要使用新创建的订单服务,只需执行一些操作: ~~~ $ curl -v http://localhost:8080/orders { "_embedded": { "orderList": [ { "id": 3, "description": "MacBook Pro", "status": "COMPLETED", "_links": { "self": { "href": "http://localhost:8080/orders/3" }, "orders": { "href": "http://localhost:8080/orders" } } }, { "id": 4, "description": "iPhone", "status": "IN_PROGRESS", "_links": { "self": { "href": "http://localhost:8080/orders/4" }, "orders": { "href": "http://localhost:8080/orders" }, "cancel": { "href": "http://localhost:8080/orders/4/cancel" }, "complete": { "href": "http://localhost:8080/orders/4/complete" } } } ] }, "_links": { "self": { "href": "http://localhost:8080/orders" } } } ~~~ 该HAL文档根据其当前状态立即显示每个订单的不同链接。 * 一阶,被 **已完成** 只有导航链接。 状态转换链接未显示。 * 第二个订单( **IN\_PROGRESS)** 另外具有 **取消** 链接和 **完整** 链接。 尝试取消订单: ~~~ $ curl -v -X DELETE http://localhost:8080/orders/4/cancel > DELETE /orders/4/cancel HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.54.0 > Accept: */* > < HTTP/1.1 200 < Content-Type: application/hal+json;charset=UTF-8 < Transfer-Encoding: chunked < Date: Mon, 27 Aug 2018 15:02:10 GMT < { "id": 4, "description": "iPhone", "status": "CANCELLED", "_links": { "self": { "href": "http://localhost:8080/orders/4" }, "orders": { "href": "http://localhost:8080/orders" } } } ~~~ 此响应显示 的 **HTTP 200** 指示成功 状态代码。 响应HAL文档显示该订单处于新状态( `CANCELLED`)。 改变状态的链接也消失了。 如果您再次尝试相同的操作... ~~~ $ curl -v -X DELETE http://localhost:8080/orders/4/cancel * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > DELETE /orders/4/cancel HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.54.0 > Accept: */* > < HTTP/1.1 405 < Content-Type: application/problem+json < Transfer-Encoding: chunked < Date: Mon, 27 Aug 2018 15:03:24 GMT < { "title": "Method not allowed", "detail": "You can't cancel an order that is in the CANCELLED status" } ~~~ ......您会看到 **HTTP 405方法不允许** 响应。 **删除** 已成为无效操作。 这 `Problem` 响应对象清楚地表明不允许您“取消”已经处于“已取消”状态的订单。 此外,尝试完成相同的订单也会失败: ~~~ $ curl -v -X PUT localhost:8080/orders/4/complete * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > PUT /orders/4/complete HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.54.0 > Accept: */* > < HTTP/1.1 405 < Content-Type: application/problem+json < Transfer-Encoding: chunked < Date: Mon, 27 Aug 2018 15:05:40 GMT < { "title": "Method not allowed", "detail": "You can't complete an order that is in the CANCELLED status" } ~~~ 完成所有这些操作后,您的订单履行服务便可以有条件地显示可用的操作。 它还可以防止无效操作。 By leveraging the protocol of hypermedia and links, clients can be built sturdier and less likely to break simply because of a change in the data. And Spring HATEOAS eases building the hypermedia you need to serve to your clients. ## Summary Throughout this tutorial, you have engaged in various tactics to build REST APIs. As it turns out, REST isn’t just about pretty URIs and returning JSON instead of XML. Instead, the following tactics help make your services less likely to break existing clients you may or may not control: * 不要删除旧字段。 相反,支持他们。 * 使用基于rel的链接,这样客户端就不必对URI进行硬编码。 * 尽可能保留旧的链接。 即使必须更改URI,也请保留rels,以便较旧的客户端可以使用较新的功能。 * 使用链接(而不是有效负载数据)来指示客户端何时可以进行各种状态驱动操作。 似乎需要一些努力才能建立起来 `RepresentationModelAssembler`每种资源类型的实现,并在所有控制器中使用这些组件。 但是,服务器端设置的这一额外点(借助Spring HATEOAS可以轻松实现)可以确保您控制的客户端(更重要的是,那些您不需要的客户端)可以在您开发API时轻松升级。 到此,我们的教程结束了如何使用Spring构建RESTful服务。 本教程的每个部分在单个github存储库中作为单独的子项目进行管理: * **nonrest** —没有超媒体的简单Spring MVC应用程序 * **rest** — Spring MVC + Spring HATEOAS应用程序,每个资源都有HAL表示形式 * **Evolution** — REST应用程序,其中的字段已演化,但保留了旧数据以实现向后兼容性 * **链接** \-REST应用程序,其中条件链接用于向客户端发送有效状态更改信号 要查看使用Spring HATEOAS的更多示例,请参见 [https://github.com/spring-projects/spring-hateoas-examples](https://github.com/spring-projects/spring-hateoas-examples) 。 要进行更多探索,请查看Spring队友Oliver Gierke的以下视频: