使用 Spring 构建 REST 服务

时间:2022-12-27 20:12:06

使用 Spring 构建 REST 服务

REST 已迅速成为在 Web 上构建 Web 服务的事实标准,因为它们易于构建和使用。

关于 REST 如何适应微服务领域,还有更大的讨论,但是在本教程中,让我们只看一下构建 RESTful 服务。

为什么休息?REST 包含 Web 的准则,包括其架构、优势和其他一切。这并不奇怪,因为它的作者罗伊·菲尔丁(Roy Fielding)参与了大约十几个规范,这些规范控制着网络的运作方式。

有什么好处?Web及其核心协议HTTP提供了一系列功能:

  • 适当的操作(、、、、...)GETPOSTPUTDELETE
  • 缓存
  • 重定向和转发
  • 安全性(加密和身份验证)

这些都是构建弹性服务的关键因素。但这还不是全部。网络是由许多微小的规范构建而成的,因此它能够轻松发展,而不会陷入“标准战争”。

开发人员能够利用实现这些不同规范的第三方工具包,并立即拥有触手可及的客户端和服务器技术。

通过在HTTP之上构建,REST API提供了构建的方法:

  • 向后兼容的 API
  • 可进化的接口
  • 可扩展的服务
  • 安全对象服务
  • 从无状态到有状态服务的一系列

重要的是要认识到,REST,无论多么普遍,本身并不是一个标准,而是一种方法,一种风格,一组对架构的约束,可以帮助你构建Web规模的系统。在本教程中,我们将使用 Spring 产品组合来构建 RESTful 服务,同时利用 REST 的无堆栈功能。

开始

在完成本教程时,我们将使用弹簧启动.转到Spring Initializr并将以下依赖项添加到项目中:

  • 太平绅士
  • H2

将名称更改为“工资单”,然后选择“生成项目”。A 将下载。解压缩它。在里面你会发现一个简单的,基于Maven的项目,包括一个构建文件(注意:你可以使用Gradle。本教程中的示例将基于 Maven。​​.zip​​​​pom.xml​

Spring Boot 可以与任何 IDE 一起使用。您可以使用Eclipse,IntelliJ IDEA,Netbeans等。弹簧工具套件是一个开源的、基于 Eclipse 的 IDE 发行版,它提供了 Eclipse 的 Java EE 发行版的超集。它包括使使用 Spring 应用程序更加容易的功能。这绝不是必需的。但是,如果您想为击键提供额外的魅力,请考虑一下。

到目前为止的故事...

让我们从我们可以构建的最简单的东西开始。事实上,为了使它尽可能简单,我们甚至可以省略REST的概念。 (稍后,我们将添加REST来理解其中的区别。

大图:我们将创建一个简单的工资单服务来管理公司的员工。我们将员工对象存储在(H2 内存中)数据库中,并访问它们(通过称为 JPA 的东西)。然后,我们将用允许通过互联网访问的东西(称为Spring MVC层)来包装它。

以下代码在我们的系统中定义了一个员工。

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​​、 和 是我们员工的属性namerole域对象. 用更多 JPA 注释标记,以指示它是主键,并由 JPA 提供程序自动填充。id
  • 当我们需要创建新实例但还没有 ID 时,会创建自定义构造函数。

有了这个域对象定义,我们现在可以转向春季数据 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的接口,将域类型指定为,将id类型指定为。​​JpaRepository​​​​Employee​​​​Long​

春季数据存储库解决方案可以避开数据存储细节,而是使用特定于域的术语解决大多数问题。

信不信由你,这足以启动一个应用程序!Spring 引导应用程序至少是一个入口点和注释。这告诉Spring Boot尽可能提供帮助。​​public static void main​​​​@SpringBootApplication​

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 容器并提供我们的服务。

尽管如此,没有数据的应用程序并不是很有趣,所以让我们预加载它。以下类将由 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 将运行所有 bean。CommandLineRunner
  • 此运行器将请求您刚刚创建的副本。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​​指示每个方法返回的数据将直接写入响应正文,而不是呈现模板。
  • 构造函数将 A 注入控制器。EmployeeRepository
  • 我们为每个操作都有路由(、 和 , 对应于 HTTP 、、 和调用)。(注意:阅读每种方法并了解它们的作用很有用。@GetMapping@PostMapping@PutMapping@DeleteMappingGETPOSTPUTDELETE
  • ​EmployeeNotFoundException​​是用于指示何时查找但未找到员工的例外。

nonrest/src/main/java/payroll/EmployeeNotFoundException.java

package payroll;

class EmployeeNotFoundException extends RuntimeException {

EmployeeNotFoundException(Long id) {
super("Could not find employee " + id);
}
}

当抛出 an 时,Spring MVC 配置的这个额外花絮用于渲染 HTTP 404:​​EmployeeNotFoundException​

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​​将建议配置为仅在引发 an 时响应。EmployeeNotFoundException
  • ​@ResponseStatus​​说发出一个,即一个HTTP 404HttpStatus.NOT_FOUND
  • 建议的正文生成内容。在这种情况下,它会给出异常的消息。

若要启动应用程序,请右键单击 ,然后选择“从 IDE 中运行”,或者:​​public static void main​​​​PayRollApplication​

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

此消息很好地显示了带有自定义消息“找不到员工 99”的 HTTP 404 错误。

不难显示当前编码的交互...


如果您使用 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"}

构建服务的方式可能会产生重大影响。在这种情况下,我们说更新,但替换是一个更好的描述。例如,如果未提供名称,则会将其注销。

最后,您可以像这样删除用户:

$ 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服务吗?(如果你没有抓住提示,答案是否定的。

缺少什么?

是什么让事物变得令人不安?

到目前为止,您有一个基于 Web 的服务来处理涉及员工数据的核心操作。但这还不足以让事情变得“RESTful”。

  • 漂亮的网址,比如不是REST。/employees/3
  • 仅仅使用 、 等不是 REST。GETPOST
  • 布置所有 CRUD 操作不是 REST。

事实上,到目前为止我们构建的内容更好地描述为 RPC远程过程调用)。这是因为没有办法知道如何与此服务交互。如果您今天发布了此内容,您还必须编写文档或在某个包含所有详细信息的开发人员门户。

Roy Fielding的这句话可能进一步为RESTRPC之间的区别提供了线索:


我对将任何基于 HTTP 的接口称为 REST API 的人数感到沮丧。今天的例子是SocialSite REST API。那就是RPC。它尖叫着RPC。显示的耦合太多,应该给它一个X等级。



需要做些什么来使 REST 架构风格明确超文本是一种约束的概念?换句话说,如果应用程序状态引擎(以及 API)不是由超文本驱动的,那么它就不能是 RESTful 的,也不能是 REST API。时期。是否有一些损坏的手册需要修复?


— 罗伊·菲尔丁
https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven

在我们的表示中不包含超媒体的副作用是,客户端必须对 URI 进行硬编码才能导航 API。这导致了在网络上电子商务兴起之前相同的脆性。这是一个信号,表明我们的 JSON 输出需要一点帮助。

正在推出春天的哈特亚斯,一个旨在帮助您编写超媒体驱动输出的 Spring 项目。要将服务升级到 RESTful,请将以下内容添加到您的构建中:

将春季 HATEOAS 添加到​​dependencies​​​​pom.xml​

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

这个小库将为我们提供定义 RESTful 服务的构造,然后将其呈现为可接受的格式以供客户端使用。

任何 RESTful 服务的关键要素是添加链接到相关操作。要使您的控制器更加 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,并使用静态辅助程序方法来构建这些链接。如果您在项目中使用 Spring WebFlux,则必须改用 .​​WebMvcLinkBuilder​​​​WebFluxLinkBuilder​


这与我们之前的情况非常相似,但有一些事情发生了变化:

  • 该方法的返回类型已从 更改为 。 是Spring HATEOAS的一个通用容器,它不仅包含数据,还包含链接集合。EmployeeEntityModel<Employee>EntityModel<T>
  • ​linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel()​​要求Spring HATEOAS构建指向 的方法的链接,并将其标记为EmployeeControllerone()自我链接。
  • ​linkTo(methodOn(EmployeeController.class).all()).withRel("employees")​​要求Spring HATEOAS建立与聚合根的链接,并将其称为“员工”。all()

我们所说的“建立链接”是什么意思?Spring HATEOAS 的核心类型之一是 。它包括一个 URI 和一个 rel(关系)。链接是赋予网络权力的东西。在万维网出现之前,其他文档系统会呈现信息或链接,但正是文档与这种关系元数据的链接将网络拼接在一起。​​Link​

Roy Fielding 鼓励使用与 Web 成功的相同技术构建 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



REST单个员工的表示形式

{
"id": 1,
"name": "Bilbo Baggins",
"role": "burglar",
"_links": {
"self": {
"href": "http://localhost:8080/employees/1"
},
"employees": {
"href": "http://localhost:8080/employees"
}
}
}

此解压缩输出不仅显示您之前看到的数据元素(和 ),还显示包含两个 URI 的条目。整个文档的格式为​​id​​​​name​​​​role​​​​_links​​哈尔.

HAL 是一种轻量级媒体类型这不仅允许对数据进行编码,还允许对超媒体控件进行编码,提醒消费者他们可以导航到 API 的其他部分。在这种情况下,有一个“self”链接(有点像代码中的语句)以及一个返回​​this​​聚合根目录​.

为了使聚合根也更 RESTful,您需要包含*链接,同时还要包含任何 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<>​​是另一个春天的HATEOAS容器;它旨在封装资源集合,而不是像以前那样封装单个资源实体。,也允许您包含链接。​​EntityModel<>​​​​CollectionModel<>​

不要让第一句话溜走。“封装集合”是什么意思?员工集合?

差一点。

由于我们谈论的是 REST,它应该封装员工资源的集合。

这就是为什么你获取所有员工,然后将它们转换为对象列表。(感谢Java 8 Streams!​​EntityModel<Employee>​

如果重新启动应用程序并获取聚合根,则可以看到它现在的外观。

REST 员工资源集合的表示形式

{
"_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 服务随着时间的推移而发展成为可能。可以保留现有链接,同时将来可以添加新链接。新客户端可以利用新链接,而旧客户端可以在旧链接上维持自身。如果服务被重新定位和移动,这将特别有用。只要保持链接结构,客户端仍然可以找到事物并与之交互。

简化链接创建

在前面的代码中,您是否注意到单个员工链接创建的重复性?用于向员工提供单个链接以及创建指向聚合根的“员工”链接的代码显示两次。如果这引起了您的关注,那很好!有一个解决方案。

简而言之,您需要定义一个将对象转换为对象的函数。虽然您可以轻松地自己编写此方法,但实现Spring HATEOAS的接口是有好处的 - 它将为您完成工作。​​Employee​​​​EntityModel<Employee>​​​​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 所有模型的抽象基类是 。但为了简单起见,我建议使用作为您的机制,轻松地将所有 POJO 包装为模型。​​RepresentationModel​​​​EntityModel<T>​

要利用此汇编程序,您只需通过在构造函数中注入汇编器来更改 。​​EmployeeController​

将员工模型汇编器注入控制器

@RestController
class EmployeeController {

private final EmployeeRepository repository;

private final EmployeeModelAssembler assembler;

EmployeeController(EmployeeRepository repository, EmployeeModelAssembler assembler) {

this.repository = repository;
this.assembler = assembler;
}

...

}

从这里,您可以在单项员工方法中使用该汇编程序:

使用汇编程序获取单个项目资源

@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());
}

同样,代码几乎相同,但是您可以将所有创建逻辑替换为 .借助 Java 8 方法引用,插入它并简化控制器非常容易。​​EntityModel<Employee>​​​​map(assembler::toModel)​

Spring HATEOAS的一个关键设计目标是使做正确的事情™变得更容易。在此方案中:将超媒体添加到服务中,而无需对内容进行硬编码。

在这个阶段,你已经创建了一个Spring MVC REST控制器,它实际上产生了超媒体驱动的内容!不使用 HAL 的客户端可以在使用纯数据时忽略额外的位。使用 HAL 的客户端可以导航您的授权 API。

但这并不是使用 Spring 构建真正的 RESTful 服务所需的唯一内容。

不断发展的 REST API

通过一个额外的库和几行额外的代码,您已经将超媒体添加到了应用程序中。但这并不是使您的服务成为 RESTful 所需的唯一内容。REST的一个重要方面是它既不是技术堆栈也不是单一标准。

REST 是体系结构约束的集合,采用这些约束后,应用程序将更具弹性。弹性的一个关键因素是,当您升级服务时,您的客户不会遭受停机的影响。

在“过去”的日子里,升级因破坏客户而臭名昭著。换句话说,升级到服务器需要更新客户端。在当今时代,升级花费数小时甚至数分钟的停机时间可能会造成数百万美元的收入损失。

一些公司要求您向管理层提供计划,以尽量减少停机时间。过去,您可以在负载最小的星期日凌晨 2:00 进行升级。但在当今与其他时区的国际客户的基于互联网的电子商务中,这种策略并不那么有效。

基于 SOAP 的服务和基于 CORBA 的服务非常脆弱。很难推出同时支持新旧客户端的服务器。使用基于 REST 的实践,这要容易得多。特别是使用弹簧堆栈。

支持对 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​

  • 字段已替换为 和 。namefirstNamelastName
  • 定义了旧属性的“虚拟”获取器。它使用 and 字段来生成值。namegetName()firstNamelastName
  • 还定义了旧属性的“虚拟”资源库。它解析传入的字符串并将其存储到适当的字段中。namesetName()

当然,并非对 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);
}
  • 新对象将像以前一样保存。但是生成的对象是使用 .EmployeeEmployeeModelAssembler
  • Spring MVC 用于创建 HTTP 201 Created 状态消息。这种类型的响应通常包括位置响应标头,我们使用从模型的自相关链接派生的 URI。ResponseEntity
  • 此外,返回已保存对象的基于模型的版本。

进行这些调整后,您可以使用同一终端节点创建新的员工资源,并使用旧字段:​​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​​​​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);
}

然后,使用 到 到对象中包装从操作生成的对象。使用该方法,您可以检索由 创建的 与 rel。此方法返回必须转换为 with 方法的 a。​​Employee​​​​save()​​​​EmployeeModelAssembler​​​​EntityModel<Employee>​​​​getRequiredLink()​​​​Link​​​​EmployeeModelAssembler​​​​SELF​​​​Link​​​​URI​​​​toUri​

由于我们想要比 200 OK 更详细的 HTTP 响应代码,我们将使用 Spring MVC 的包装器。它有一个方便的静态方法,我们可以在其中插入资源的 URI。HTTP 201 Created是否具有正确的语义是值得商榷的,因为我们不一定在“创建”新资源。但它预装了一个位置响应标头,因此请使用它运行。​​ResponseEntity​​​​created()​

$ 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 操作:

处理删除请求

@DeleteMapping("/employees/{id}")
ResponseEntity<?> deleteEmployee(@PathVariable Long id) {

repository.deleteById(id);

return ResponseEntity.noContent().build();
}

这将返回 HTTP 204 无内容响应。

$ 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 并更好地为客户服务,您需要接受超媒体作为应用程序状态引擎的概念

那是什么意思?在本节中,您将详细探索它。

业务逻辑不可避免地会构建涉及流程的规则。这种系统的风险是我们经常将这样的服务器端逻辑带入客户端并建立强大的耦合。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 + '}';
}
}
  • The class requires a JPA annotation changing the table’s name to because is not a valid name for table.@TableCUSTOMER_ORDERORDER
  • 它包括一个字段和一个字段。descriptionstatus

从客户提交订单到订单被履行或取消,订单必须经历一系列特定的状态转换。这可以捕获为Java:​​enum​

links/src/main/java/payroll/Status.java

package payroll;

enum Status {

IN_PROGRESS, //
COMPLETED, //
CANCELLED
}

这捕获了一个罐子占据的各种状态。对于本教程,让我们保持简单。​​enum​​​​Order​

为了支持与数据库中的订单交互,您必须定义相应的 Spring 数据存储库:

Spring Data JPA 的基本接口​​JpaRepository​

interface OrderRepository extends JpaRepository<Order, Long> {
}

完成此操作后,您现在可以定义一个基本的:​​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 控制器设置。
  • 它注入了一个以及一个(尚未构建)。OrderRepositoryOrderModelAssembler
  • 前两个Spring MVC路由处理聚合根以及单个项目资源请求。Order
  • 第三个Spring MVC路由通过在状态中启动新订单来处理创建新订单。IN_PROGRESS
  • 所有控制器方法都返回Spring HATEOAS的一个子类,以正确呈现超媒体(或围绕此类类型的包装器)。RepresentationModel

在构建 之前,让我们讨论一下需要发生什么。您正在对 、 和 之间的状态流进行建模。向客户端提供此类数据时,很自然的事情是让客户端根据此有效负载决定它可以做什么。​​OrderModelAssembler​​​​Status.IN_PROGRESS​​​​Status.COMPLETED​​​​Status.CANCELLED​

但那是错误的。

在此流中引入新状态时会发生什么情况?UI 上各种按钮的位置可能是错误的。

如果您更改了每个州的名称,也许是在编码国际支持并显示每个州特定于区域设置的文本时,该怎么办?这很可能会破坏所有客户端。

输入 HATEOAS 或超媒体作为应用程序状态的引擎。客户端不要解析有效负载,而是为它们提供链接以发出有效操作的信号。将基于状态的操作与数据有效负载分离。换句话说,当 CANCEL 和 COMPLETE 是有效操作时,请动态地将它们添加到链接列表中。客户端只需要在链接存在时向用户显示相应的按钮。

这使客户端不必知道此类操作何时有效,从而降低了服务器及其客户端在状态转换逻辑上不同步的风险。

在已经接受了Spring HATEOAS组件的概念之后,将这样的逻辑放入其中将是捕获此业务规则的理想位置:​​RepresentationModelAssembler​​​​OrderModelAssembler​

links/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;
}
}

此资源组装器始终包含指向单项资源的链接以及返回到聚合根的链接。但它还包括两个条件链接 以及(尚未定义)。仅当订单状态为 .​​OrderController.cancel(id)​​​​OrderController.complete(id)​​​​Status.IN_PROGRESS​

如果客户可以采用HAL和读取链接的能力,而不是简单地读取普通旧JSON的数据,他们就可以交换对订单系统领域知识的需求。这自然减少了客户端和服务器之间的耦合。它打开了调整订单履行流程的大门,而不会在此过程中破坏客户。

要完善订单履行,请将以下内容添加到操作中:​​OrderController​​​​cancel​

在订单控制器中创建“取消”操作

@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​​ ​​Problem​​,一个支持超媒体的错误容器。如果转换确实有效,则会将 转换为 。​​Order​​​​CANCELLED​

并将其添加到 以及 以及 完成订单:​​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​

让我们更新以预加载一些 s 以及之前加载的 s。​​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"
}

完成所有这些操作后,您的订单履行服务能够有条件地显示可用的操作。它还可以防止无效操作。

通过利用超媒体和链接的协议,客户端可以构建得更坚固,并且不太可能仅仅因为数据更改而中断。Spring HATEOAS 简化了构建您为客户提供服务所需的超媒体。

总结

在本教程中,您介绍了构建 REST API 的各种策略。事实证明,REST不仅仅是漂亮的URI和返回JSON而不是XML。

相反,以下策略有助于降低服务中断您可能控制也可能无法控制的现有客户端的可能性:

  • 不要删除旧字段。相反,支持他们。
  • 使用基于 rel 的链接,以便客户端不必对 URI 进行硬编码。
  • 尽可能长时间地保留旧链接。即使必须更改 URI,也要保留 rel,以便较旧的客户端具有访问新功能的路径。
  • 使用链接(而不是有效负载数据)在各种状态驱动操作可用时指示客户端。

为每种资源类型构建实现并在所有控制器中使用这些组件似乎有点费力。但是这种额外的服务器端设置(由于Spring HATEOAS而变得容易)可以确保您控制的客户端(更重要的是,您不控制的客户端)可以在您发展API时轻松升级。​​RepresentationModelAssembler​

我们关于如何使用 Spring 构建 RESTful 服务的教程到此结束。本教程的每个部分都作为单个 github 存储库中的单独子项目管理:

  • nonrest — 简单的 Spring MVC 应用程序,没有超媒体
  • rest — Spring MVC + Spring HATEOAS 应用程序,其中包含每个资源的 HAL 表示
  • 演进 — REST 应用,其中字段已演化,但保留旧数据以实现向后兼容性
  • 链接 — REST 应用,其中条件链接用于向客户端发出有效状态更改的信号