使用 Spring 构建 REST 服务

REST 因其易于构建和消费,已迅速成为在 Web 上构建 Web 服务的实际标准。

关于 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 Database

将名称更改为“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"}
构建服务的方式可能产生重大影响。在这种情况下,我们说的是**更新**,但**替换**是更好的描述。例如,如果未提供名称,则它将变为 null。

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

$ 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 的这句话可能进一步揭示了 **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。这导致了与电子商务在 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 中的静态 helper 方法来构建这些链接。如果您的项目中使用 Spring WebFlux,则必须改用 WebFluxLinkBuilder

这与我们之前的非常相似,但有一些变化:

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

  • linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel() 要求 Spring HATEOAS 构建一个指向 EmployeeControllerone 方法的链接,并将其标记为自引用链接。

  • 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 输出变得更复杂时,可能会难以阅读。使用此方法或其他技巧来美化 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://:8080/employees/1"
    },
    "employees": {
      "href": "https://:8080/employees"
    }
  }
}

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

HAL 是一种轻量级媒体类型,它不仅允许编码数据,还允许编码超媒体控件,提醒消费者可以导航到的 API 的其他部分。在这种情况下,有一个“自”链接(有点像代码中的 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://:8080/employees/1"
          },
          "employees": {
            "href": "https://:8080/employees"
          }
        }
      },
      {
        "id": 2,
        "name": "Frodo Baggins",
        "role": "thief",
        "_links": {
          "self": {
            "href": "https://:8080/employees/2"
          },
          "employees": {
            "href": "https://:8080/employees"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "https://:8080/employees"
    }
  }
}

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

集合中的每个成员都包含他们的信息以及相关链接。

添加所有这些链接的目的是什么?它使得 REST 服务能够随着时间的推移而演进。现有链接可以保持不变,而新链接可以在将来添加。新的客户端可以利用新链接,而旧的客户端可以依靠旧链接维持自身。如果服务被重新定位和移动,这尤其有用。只要链接结构保持不变,客户端仍然可以找到并与事物交互。

如果您正在跟随解决方案仓库,下一节将切换到evolution 模块

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

您需要定义一个函数,将 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());
}

代码再次几乎相同。但是,您可以将所有 EntityModel<Employee> 创建逻辑替换为 map(assembler::toModel)。多亏了 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://:8080/employees/1"
    },
    "employees": {
      "href": "https://: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://: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://:8080/employees/3"
    },
    "employees": {
      "href": "https://:8080/employees"
    }
  }
}

这不仅将结果对象以 HAL 格式呈现(包括 name 以及 firstNamelastName),而且 **Location** 头部也填充了 https://:8080/employees/3。一个支持超媒体的客户端可以选择“浏览”到这个新资源并继续与之交互。

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://: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://:8080/employees/3"
		},
		"employees": {
			"href": "https://: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;
  }
}

此资源汇编器始终包含指向单项资源的**自**链接以及指向聚合根的链接。但是,它还包含两个条件链接,指向 OrderController.cancel(id)OrderController.complete(id)(尚未定义)。这些链接仅在订单状态为 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 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://:8080/orders | json_pp
详情
{
  "_embedded": {
    "orderList": [
      {
        "id": 3,
        "description": "MacBook Pro",
        "status": "COMPLETED",
        "_links": {
          "self": {
            "href": "https://:8080/orders/3"
          },
          "orders": {
            "href": "https://:8080/orders"
          }
        }
      },
      {
        "id": 4,
        "description": "iPhone",
        "status": "IN_PROGRESS",
        "_links": {
          "self": {
            "href": "https://:8080/orders/4"
          },
          "orders": {
            "href": "https://:8080/orders"
          },
          "cancel": {
            "href": "https://:8080/orders/4/cancel"
          },
          "complete": {
            "href": "https://:8080/orders/4/complete"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "https://:8080/orders"
    }
  }
}

此 HAL 文档立即显示了每个订单的不同链接,具体取决于其当前状态。

  • 第一个订单,**已完成**,只有导航链接。状态转换链接未显示。

  • 第二个订单,**进行中**,除了导航链接外,还有**取消**链接和**完成**链接。

现在尝试取消订单:

$ curl -v -X DELETE https://: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://:8080/orders/4"
    },
    "orders": {
      "href": "https://:8080/orders"
    }
  }
}

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

现在再次尝试相同的操作:

$ curl -v -X DELETE https://: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 许可,文字内容采用署名-禁止演绎知识共享许可

获取代码