使用 Spring 构建 REST 服务

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

关于 REST 如何融入微服务世界,可以进行更广泛的讨论。但是,在本教程中,我们只关注构建 RESTful 服务。

为什么选择 REST?REST 遵循 Web 的原则,包括其架构、优势以及其他一切。这并不奇怪,因为它的作者(Roy Fielding)参与了可能决定 Web 如何运行的十几项规范。

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

  • 合适的动作(GETPOSTPUTDELETE 等)

  • 缓存

  • 重定向和转发

  • 安全性(加密和身份验证)

这些都是构建弹性服务时的关键因素。但是,这还不是全部。Web 由许多微小的规范构建而成。这种架构使其能够轻松地发展,而不会陷入“标准之争”。

开发人员可以利用实现这些不同规范的第三方工具包,并立即获得客户端和服务器技术。

通过构建在 HTTP 之上,REST API 提供了构建以下内容的方法

  • 向后兼容的 API

  • 可扩展的 API

  • 可扩展的服务

  • 可保护的服务

  • 从无状态到有状态服务的范围

请注意,REST 虽然无处不在,但本身并不是一个标准,而是一种方法、一种风格,是对您的架构的一组约束,可以帮助您构建 Web 规模的系统。本教程使用 Spring 产品组合构建 RESTful 服务,同时利用 REST 的无状态特性。

入门

要开始,您需要

在我们完成本教程的过程中,我们将使用 Spring Boot。转到 Spring Initializr 并将以下依赖项添加到项目中

  • Spring Web

  • Spring Data JPA

  • H2 数据库

将名称更改为“Payroll”,然后选择生成项目。将下载一个 .zip 文件。解压缩它。在里面,您应该会找到一个简单的基于 Maven 的项目,其中包含一个 pom.xml 构建文件。(注意:您可以使用 Gradle。本教程中的示例将基于 Maven。)

要完成本教程,您可以从头开始一个新项目,或者可以查看 GitHub 中的 解决方案存储库

如果您选择创建自己的空白项目,本教程将逐步引导您构建应用程序。您不需要多个模块。

而不是提供一个最终的解决方案,已完成的 GitHub 存储库 使用模块将解决方案分成四个部分。GitHub 解决方案存储库中的模块相互构建,其中 links 模块包含最终解决方案。模块映射到以下标题

迄今为止的故事

本教程首先在 nonrest 模块 中构建代码。

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

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

以下代码定义了我们系统中的Employee

nonrest/src/main/java/payroll/Employee.java
package payroll;

import java.util.Objects;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.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 的数据存储中。

  • idnamerole 是我们 Employee 域对象的属性。id 使用更多 JPA 注解进行标记,以指示它是主键,并由 JPA 提供程序自动填充。

  • 当我们需要创建一个新实例但还没有 id 时,会创建一个自定义构造函数。

使用此域对象定义,我们现在可以转向 Spring Data JPA 来处理繁琐的数据库交互。

Spring Data JPA 存储库是包含方法的接口,这些方法支持针对后端数据存储创建、读取、更新和删除记录。某些存储库还支持数据分页和排序(在适当的情况下)。Spring Data 基于接口中方法命名中发现的约定合成实现。

除了 JPA 之外,还有多个存储库实现。您可以使用 Spring Data MongoDBSpring 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 的 存储库解决方案 使我们能够避开数据存储细节,而是通过使用特定于域的术语来解决大多数问题。

信不信由你,这足以启动一个应用程序!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 容器并提供我们的服务。

没有数据的应用程序不是很有趣,因此我们预加载它具有数据。以下类由 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 bean。

  • 此运行程序请求您刚刚创建的 EmployeeRepository 的副本。

  • 此运行程序创建两个实体并将其存储。

右键单击并运行 PayRollApplication,您将获得

显示数据预加载的控制台输出片段
...
20yy-08-09 11:36:26.169  INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=1, name=Bilbo Baggins, role=burglar)
20yy-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(() -> {
        return repository.save(newEmployee);
      });
  }

  @DeleteMapping("/employees/{id}")
  void deleteEmployee(@PathVariable Long id) {
    repository.deleteById(id);
  }
}
  • @RestController 指示每个方法返回的数据直接写入响应正文,而不是呈现模板。

  • 一个 EmployeeRepository 通过构造函数注入到控制器中。

  • 我们为每个操作都有路由(@GetMapping@PostMapping@PutMapping@DeleteMapping,分别对应于 HTTP GETPOSTPUTDELETE 调用)。(我们建议阅读每个方法并了解它们的作用。)

  • 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.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
class EmployeeNotFoundAdvice {

  @ExceptionHandler(EmployeeNotFoundException.class)
  @ResponseStatus(HttpStatus.NOT_FOUND)
  String employeeNotFoundHandler(EmployeeNotFoundException ex) {
    return ex.getMessage();
  }
}
  • @RestControllerAdvice 表示此建议直接呈现到响应正文中。

  • @ExceptionHandler 将建议配置为仅在抛出 EmployeeNotFoundException 时响应。

  • @ResponseStatus 表示发出 HttpStatus.NOT_FOUND,即HTTP 404 错误。

  • 建议的主体生成内容。在这种情况下,它提供了异常的消息。

要启动应用程序,您可以右键单击 PayRollApplication 中的 public static void main 并从您的 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 20yy 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 20yy 18:00:56 GMT
<
* Connection #0 to host localhost left intact
Could not find employee 99

此消息很好地显示了带有自定义消息的HTTP 404错误:Could not find employee 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"}
构建服务的方式可能会产生重大影响。在这种情况下,我们说的是更新,但替换是一个更好的描述。例如,如果未提供名称,则它将被置空。

最后,你可以删除用户,如下所示

$ 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”。

  • 漂亮的URL,例如`/employees/3`,不是REST。

  • 仅仅使用GETPOST等不是REST。

  • 所有CRUD操作都已列出,这不是REST。

事实上,我们目前构建的内容更适合描述为RPC远程过程调用),因为无法知道如何与该服务进行交互。如果你今天发布了它,你还需要编写文档或在某个地方托管开发者门户,其中包含所有详细信息。

Roy Fielding的这条声明可能会进一步揭示RESTRPC之间的区别

我越来越沮丧,因为很多人将任何基于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。这导致了与Web电子商务兴起之前相同的脆弱特性。这意味着我们的JSON输出需要一点帮助。

Spring HATEOAS

现在我们可以引入Spring HATEOAS,这是一个旨在帮助你编写超媒体驱动输出的Spring项目。要将你的服务升级为RESTful,请将以下内容添加到你的构建中

如果你正在解决方案存储库中进行操作,下一节将切换到rest模块
将Spring HATEOAS添加到pom.xmldependencies部分
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

这个小型库为我们提供了定义RESTful服务并在客户端可接受的格式中呈现它的构造。

任何RESTful服务的一个关键要素是添加指向相关操作的链接。要使你的控制器更具RESTful特性,请将以下链接添加到EmployeeController中现有的one方法中

获取单个项目资源
@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"));
}

你还需要包含新的导入

细节
import org.springframework.hateoas.EntityModel;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

本教程基于Spring MVC,并使用WebMvcLinkBuilder中的静态帮助器方法来构建这些链接。如果你的项目中使用的是Spring WebFlux,则必须使用WebFluxLinkBuilder

这与我们之前的内容非常相似,但有一些内容发生了变化

  • 方法的返回类型已从Employee更改为EntityModel<Employee>EntityModel<T>是来自Spring HATEOAS的通用容器,它不仅包含数据,还包含链接集合。

  • linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel()要求Spring HATEOAS构建到EmployeeControllerone方法的链接,并将其标记为self链接。

  • linkTo(methodOn(EmployeeController.class).all()).withRel("employees")要求Spring HATEOAS构建到聚合根all()的链接,并将其称为“employees”。

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

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
单个员工的RESTful表示
{
  "id": 1,
  "name": "Bilbo Baggins",
  "role": "burglar",
  "_links": {
    "self": {
      "href": "https://127.0.0.1:8080/employees/1"
    },
    "employees": {
      "href": "https://127.0.0.1:8080/employees"
    }
  }
}

此解压缩输出不仅显示了你之前看到的的数据元素(idnamerole),还显示了一个包含两个URI的_links条目。整个文档都是使用HAL格式化的。

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

要使聚合根也更具RESTful特性,你需要包含顶级链接,同时还包含其中的任何RESTful组件。

因此,我们修改以下内容(位于已完成代码的nonrest模块中)

获取聚合根
@GetMapping("/employees")
List<Employee> all() {
  return repository.findAll();
}

我们想要以下内容(位于已完成代码的rest模块中)

获取聚合根资源
@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 Streams!)

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

curl -v localhost:8080/employees | json_pp
员工资源集合的RESTful表示
{
  "_embedded": {
    "employeeList": [
      {
        "id": 1,
        "name": "Bilbo Baggins",
        "role": "burglar",
        "_links": {
          "self": {
            "href": "https://127.0.0.1:8080/employees/1"
          },
          "employees": {
            "href": "https://127.0.0.1:8080/employees"
          }
        }
      },
      {
        "id": 2,
        "name": "Frodo Baggins",
        "role": "thief",
        "_links": {
          "self": {
            "href": "https://127.0.0.1:8080/employees/2"
          },
          "employees": {
            "href": "https://127.0.0.1:8080/employees"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "https://127.0.0.1:8080/employees"
    }
  }
}

对于此聚合根(提供员工资源集合),存在一个顶级“self”链接。“collection”列在“_embedded”部分下方。这就是HAL表示集合的方式。

集合中的每个成员都具有其信息以及相关链接。

添加所有这些链接的目的是什么?它使随着时间的推移发展REST服务成为可能。可以维护现有链接,同时可以添加新的链接。较新的客户端可能会利用新的链接,而旧客户端可以依靠旧链接维持自身。如果服务被重新定位和移动,这尤其有用。只要链接结构得到维护,客户端仍然可以查找和交互。

如果你正在解决方案存储库中进行操作,下一节将切换到evolution模块

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

你需要定义一个函数,将Employee对象转换为EntityModel<Employee>对象。虽然你可以轻松地自己编写此方法,但Spring HATEOAS的RepresentationModelAssembler接口可以为你完成这项工作。创建一个新的类EmployeeModelAssembler

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<T>作为你的机制,以便轻松地将所有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;
  }

  ...

}

从这里,你可以在已经存在于EmployeeController中的单个项目员工方法one中使用该组装器

使用组装器获取单个项目资源
	@GetMapping("/employees/{id}")
	EntityModel<Employee> one(@PathVariable Long id) {

		Employee employee = repository.findById(id) //
				.orElseThrow(() -> new EmployeeNotFoundException(id));

		return assembler.toModel(employee);
	}

此代码几乎相同,除了你将EntityModel<Employee>实例的创建委托给组装器。也许这并不令人印象深刻。

在聚合根控制器方法中应用相同的方法更令人印象深刻。此更改也针对EmployeeController

使用组装器获取聚合根资源
@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());
}

代码再次几乎相同。但是,你可以用map(assembler::toModel)替换所有这些EntityModel<Employee>创建逻辑。感谢Java方法引用,插入和简化你的控制器变得超级容易。

Spring HATEOAS的一个关键设计目标是使执行“正确的事情”变得更容易™。在这种情况下,这意味着向你的服务添加超媒体,而无需硬编码任何内容。

在这个阶段,你已经创建了一个 Spring MVC REST 控制器,它实际上可以生成超媒体驱动的内容。不会使用 HAL 的客户端可以在消费纯数据时忽略额外的部分。会使用 HAL 的客户端可以导航你增强的 API。

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

REST API 的演变

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

REST 是一组架构约束,当被采用时,可以使你的应用程序更具弹性。弹性的一个关键因素是,当你对服务进行升级时,你的客户端不会出现停机。

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

有些公司要求你向管理层提交一个计划,以最大限度地减少停机时间。在过去,你可以在星期天凌晨 2 点进行升级,那时负载最小。但在当今基于互联网的电子商务中,由于存在其他时区的国际客户,因此此类策略的效果并不理想。

基于 SOAP 的服务基于 CORBA 的服务 非常脆弱。很难推出一个可以同时支持旧客户端和新客户端的服务器。使用基于 REST 的实践,它变得容易得多,尤其是在使用 Spring 栈的情况下。

支持 API 的更改

想象一下这个设计问题:你已经推出了一个基于 Employee 记录的系统。该系统非常受欢迎。你已将你的系统销售给了无数的企业。突然之间,需要将员工的姓名拆分为 firstNamelastName

糟糕。你没想到这一点。

在你打开 Employee 类并将单个字段 name 替换为 firstNamelastName 之前,请停下来思考一下。这会破坏任何客户端吗?升级需要多长时间?你是否控制着所有访问你服务的客户端?

停机 = 损失金钱。管理层准备好了吗?

有一种比 REST 早多年的旧策略。

永远不要删除数据库中的列。
— 未知

你总是可以向数据库表中添加列(字段)。但不要删除任何列。RESTful 服务中的原理相同。

向你的 JSON 表示中添加新字段,但不要删除任何字段。像这样

支持多个客户端的 JSON
{
  "id": 1,
  "firstName": "Bilbo",
  "lastName": "Baggins",
  "role": "burglar",
  "name": "Bilbo Baggins",
  "_links": {
    "self": {
      "href": "https://127.0.0.1:8080/employees/1"
    },
    "employees": {
      "href": "https://127.0.0.1:8080/employees"
    }
  }
}

此格式显示了 firstNamelastNamename。虽然它存在信息重复,但目的是支持旧客户端和新客户端。这意味着你可以在不强制客户端同时升级的情况下升级服务器。这是一个好举措,应该可以减少停机时间。

你不仅应该以“旧方式”和“新方式”显示此信息,还应该以这两种方式处理传入的数据。

处理“旧”和“新”客户端的员工记录
package payroll;

import java.util.Objects;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.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 已被 firstNamelastName 替换。

  • 定义了旧 name 属性的“虚拟” getter,即 getName()。它使用 firstNamelastName 字段生成一个值。

  • 还定义了旧 name 属性的“虚拟” setter,即 setName()。它解析传入的字符串并将其存储到相应的字段中。

当然,并非所有对 API 的更改都像拆分字符串或合并两个字符串那样简单。但对于大多数场景,想出一个转换集肯定并非不可能,对吧?

不要忘记更改如何在 LoadDatabase 中预加载数据库以使用此新构造函数。

log.info("Preloading " + repository.save(new Employee("Bilbo", "Baggins", "burglar")));
log.info("Preloading " + repository.save(new Employee("Frodo", "Baggins", "thief")));

正确的响应

朝着正确方向迈出的另一步涉及确保你的每个 REST 方法都返回正确的响应。更新 EmployeeController 中的 POST 方法(newEmployee

处理“旧”和“新”客户端请求的 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);
}

你还需要添加导入语句

细节
import org.springframework.hateoas.IanaLinkRelations;
import org.springframework.http.ResponseEntity;
  • 与之前一样,保存新的 Employee 对象。但是,结果对象被包装在 EmployeeModelAssembler 中。

  • Spring MVC 的 ResponseEntity 用于创建 **HTTP 201 Created** 状态消息。此类型的响应通常包含 **Location** 响应头,我们使用从模型的自相关链接派生的 URI。

  • 此外,还返回了已保存对象的基于模型的版本。

通过这些调整,你可以使用相同的端点创建新的员工资源并使用旧的 name 字段

$ curl -v -X POST localhost:8080/employees -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}' | json_pp

输出如下

细节
> POST /employees HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type:application/json
> Content-Length: 46
>
< Location: https://127.0.0.1:8080/employees/3
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Fri, 10 Aug 20yy 19:44:43 GMT
<
{
  "id": 3,
  "firstName": "Samwise",
  "lastName": "Gamgee",
  "role": "gardener",
  "name": "Samwise Gamgee",
  "_links": {
    "self": {
      "href": "https://127.0.0.1:8080/employees/3"
    },
    "employees": {
      "href": "https://127.0.0.1:8080/employees"
    }
  }
}

这不仅以 HAL 呈现了结果对象(name 以及 firstNamelastName),而且还使用 https://127.0.0.1:8080/employees/3 填充了 **Location** 标头。一个超媒体驱动的客户端可以选择“浏览”到这个新资源并继续与之交互。

EmployeeController 中的 PUT 控制器方法(replaceEmployee)需要类似的调整

处理不同客户端的 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(() -> {
        return repository.save(newEmployee);
      });

  EntityModel<Employee> entityModel = assembler.toModel(updatedEmployee);

  return ResponseEntity //
      .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) //
      .body(entityModel);
}

然后,由 save() 操作构建的 Employee 对象被包装在 EmployeeModelAssembler 中以创建 EntityModel<Employee> 对象。使用 getRequiredLink() 方法,你可以检索由 EmployeeModelAssembler 使用 SELF rel 创建的 Link。此方法返回一个 Link,必须使用 toUri 方法将其转换为 URI

由于我们希望获得比 **200 OK** 更详细的 HTTP 响应代码,因此我们使用 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"}' | json_pp
细节
* 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: https://127.0.0.1:8080/employees/3
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Fri, 10 Aug 20yy 19:52:56 GMT
{
	"id": 3,
	"firstName": "Samwise",
	"lastName": "Gamgee",
	"role": "ring bearer",
	"name": "Samwise Gamgee",
	"_links": {
		"self": {
			"href": "https://127.0.0.1:8080/employees/3"
		},
		"employees": {
			"href": "https://127.0.0.1:8080/employees"
		}
	}
}

该员工资源现已更新,并且已发送回位置 URI。最后,更新 EmployeeController 中的 DELETE 操作(deleteEmployee

处理 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 20yy 21:30:26 GMT
更改 Employee 类中的字段需要与你的数据库团队协调,以便他们可以将现有内容正确迁移到新列中。

你现在已准备好进行升级,该升级不会干扰现有客户端,而新客户端可以利用增强功能。

顺便说一句,你是否担心在网络上传输过多的信息?在某些每个字节都很重要的系统中,API 的演变可能需要退居次要地位。但是,除非你衡量更改的影响,否则你不应该追求这种过早优化。

如果你在 解决方案存储库 中跟着操作,下一节将切换到 links 模块

到目前为止,你已经使用基本链接构建了一个可演变的 API。为了发展你的 API 并更好地为你的客户端服务,你需要接受 **超媒体作为应用程序状态引擎** 的概念。

这意味着什么?本节将详细探讨它。

业务逻辑不可避免地会建立涉及流程的规则。此类系统的风险在于,我们经常将此类服务器端逻辑带入客户端并建立强耦合。REST 的目标是打破这种连接并最大限度地减少这种耦合。

为了展示如何在不触发客户端中断更改的情况下应对状态更改,想象一下添加一个履行订单的系统。

第一步,定义一个新的 Order 记录

links/src/main/java/payroll/Order.java
package payroll;

import java.util.Objects;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.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 字段。

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

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

enum Status {

  IN_PROGRESS, //
  COMPLETED, //
  CANCELLED
}

enum 捕获 Order 可以占据的各种状态。在本教程中,我们将保持简单。

为了支持与数据库中的订单进行交互,你必须定义一个相应的 Spring Data 存储库,称为 OrderRepository

Spring Data JPA 的 JpaRepository 基础接口
interface OrderRepository extends JpaRepository<Order, Long> {
}

我们还需要创建一个名为 OrderNotFoundException 的新异常类

细节
package payroll;

class OrderNotFoundException extends RuntimeException {

  OrderNotFoundException(Long id) {
    super("Could not find order " + id);
  }
}

有了这些,你现在就可以定义一个包含必需导入语句的基本 OrderController

导入语句
import java.util.List;
import java.util.stream.Collectors;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.http.ResponseEntity;
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.RequestBody;
import org.springframework.web.bind.annotation.RestController;
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

  • 前两个 Spring MVC 路由处理聚合根以及单个项目 Order 资源请求。

  • 第三个 Spring MVC 路由处理创建新订单,方法是将它们置于 IN_PROGRESS 状态。

  • 所有控制器方法都返回 Spring HATEOAS 的 RepresentationModel 子类之一,以正确呈现超媒体(或此类类型的包装器)。

在构建 OrderModelAssembler 之前,我们应该讨论需要发生的事情。你正在对 Status.IN_PROGRESSStatus.COMPLETEDStatus.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;
  }
}

此资源组装器始终包含指向单个项目资源的 **self** 链接以及指向聚合根的链接。但是,它还包含两个指向 OrderController.cancel(id)OrderController.complete(id)(尚未定义)的条件链接。只有当订单的状态为 Status.IN_PROGRESS 时,才会显示这些链接。

如果客户端能够采用 HAL 并在读取数据时理解链接,而不是仅仅读取普通 JSON 数据,他们就可以免于了解订单系统领域的知识。这自然减少了客户端和服务器之间的耦合。它也为调整订单履行流程打开了大门,同时避免在过程中破坏客户端。

为了完善订单履行流程,请为 OrderControllercancel 操作添加以下内容

在 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 Problem(一个支持超媒体的错误容器)。如果转换有效,则将 Order 的状态转换为 CANCELLED

现在,我们需要在 OrderController 中为订单完成添加这个操作

在 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 https://127.0.0.1:8080/orders | json_pp
细节
{
  "_embedded": {
    "orderList": [
      {
        "id": 3,
        "description": "MacBook Pro",
        "status": "COMPLETED",
        "_links": {
          "self": {
            "href": "https://127.0.0.1:8080/orders/3"
          },
          "orders": {
            "href": "https://127.0.0.1:8080/orders"
          }
        }
      },
      {
        "id": 4,
        "description": "iPhone",
        "status": "IN_PROGRESS",
        "_links": {
          "self": {
            "href": "https://127.0.0.1:8080/orders/4"
          },
          "orders": {
            "href": "https://127.0.0.1:8080/orders"
          },
          "cancel": {
            "href": "https://127.0.0.1:8080/orders/4/cancel"
          },
          "complete": {
            "href": "https://127.0.0.1:8080/orders/4/complete"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "https://127.0.0.1:8080/orders"
    }
  }
}

此 HAL 文档根据每个订单的当前状态立即显示不同的链接。

  • 第一个订单的状态为 COMPLETED,仅包含导航链接。状态转换链接未显示。

  • 第二个订单的状态为 IN_PROGRESS,此外还包含 cancel 链接和 complete 链接。

现在尝试取消一个订单

$ curl -v -X DELETE https://127.0.0.1:8080/orders/4/cancel | json_pp
您可能需要根据数据库中的特定 ID 替换前面 URL 中的数字 4。此信息可以在之前的 /orders 调用中找到。
细节
> 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 20yy 15:02:10 GMT
<
{
  "id": 4,
  "description": "iPhone",
  "status": "CANCELLED",
  "_links": {
    "self": {
      "href": "https://127.0.0.1:8080/orders/4"
    },
    "orders": {
      "href": "https://127.0.0.1:8080/orders"
    }
  }
}

此响应显示 HTTP 200 状态代码,表示操作成功。响应 HAL 文档显示订单处于其新状态 (CANCELLED)。此外,状态更改链接也消失了。

现在再次尝试相同的操作

$ curl -v -X DELETE https://127.0.0.1:8080/orders/4/cancel | json_pp
细节
* 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 20yy 15:03:24 GMT
<
{
  "title": "Method not allowed",
  "detail": "You can't cancel an order that is in the CANCELLED status"
}

您可以看到 HTTP 405 Method Not Allowed 响应。DELETE 已成为无效操作。Problem 响应对象清楚地表明您不允许取消已经处于“CANCELLED”状态的订单。

此外,尝试完成相同的订单也会失败

$ curl -v -X PUT localhost:8080/orders/4/complete | json_pp
细节
* 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 20yy 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,以便旧客户端能够访问新功能。

  • 使用链接而不是有效负载数据来指示客户端何时可以使用各种状态驱动操作。

为每种资源类型构建 RepresentationModelAssembler 实现并在所有控制器中使用这些组件可能看起来需要一些工作。但是,这种额外的服务器端设置(由于 Spring HATEOAS 而变得容易)可以确保您控制的客户端(更重要的是,您不控制的客户端)能够在您改进 API 时轻松升级。

本教程到此结束,介绍了如何使用 Spring 构建 RESTful 服务。本教程的每个部分都作为一个单独的子项目在一个 GitHub 存储库中管理

  • nonrest — 没有超媒体的简单 Spring MVC 应用程序

  • rest — 使用 HAL 表示每种资源的 Spring MVC + Spring HATEOAS 应用程序

  • evolution — REST 应用程序,其中字段已改进,但保留了旧数据以实现向后兼容性

  • links — REST 应用程序,其中使用条件链接向客户端发出有效状态更改信号

要查看更多使用 Spring HATEOAS 的示例,请访问 https://github.com/spring-projects/spring-hateoas-examples

要进行更多探索,请查看 Spring 团队成员 Oliver Drotbohm 的以下视频

想要编写新的指南或为现有指南做出贡献?请查看我们的 贡献指南

所有指南都使用 ASLv2 许可证发布代码,并使用 署名-非衍生作品创作共用许可证 发布文本。

获取代码