Spring Boot 与 Docker
观察 GraphQL 的实际运行

REST 迅速成为构建 Web 服务的实际标准,因为 REST 服务易于构建且易于使用。

关于 REST 如何在微服务世界中发挥作用,可以进行更广泛的讨论。然而,在本教程中,我们仅关注如何构建 RESTful 服务。

为什么选择 REST?REST 秉承了 Web 的理念,包括其架构、优势以及其他一切。这并不奇怪,因为其作者(Roy Fielding)可能参与了数十个规范,这些规范定义了 Web 的运作方式。

有哪些优势?Web 及其核心协议 HTTP 提供了一系列功能:

  • 合适的操作(GETPOSTPUTDELETE 等)

  • 缓存

  • 重定向和转发

  • 安全性(加密和认证)

在构建弹性服务时,这些都是关键因素。然而,这还不是全部。网络是由许多小型规范构建而成的。这种架构使其能够轻松发展,而不会陷入“标准战争”的泥潭。

开发者可以利用实现这些多样化规范的第三方工具包,并立即掌握客户端和服务器技术。

通过在 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

尽管这个 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 接口,我们可以自动实现:

  • 创建新员工
  • 更新现有员工
  • 删除员工
  • 查找员工(单个、全部,或通过简单或复杂属性进行搜索)

要获得所有这些免费功能,我们只需声明一个接口,该接口继承自 Spring Data JPA 的 JpaRepository,并将域类型指定为 Employee,将 id 类型指定为 Long

Spring Data 的 repository 解决方案 使得我们可以绕过数据存储的细节,转而使用领域特定的术语来解决大多数问题。

信不信由你,这已经足以启动一个应用程序了!Spring Boot 应用程序至少需要一个 public static void main 入口点和 @SpringBootApplication 注解。这告诉 Spring Boot 在可能的情况下提供帮助。

@SpringBootApplication 是一个元注解,它引入了 组件扫描自动配置属性支持。我们在本教程中不会深入探讨 Spring Boot 的细节。但本质上,它会启动一个 servlet 容器并提供我们的服务。

一个没有数据的应用程序并不有趣,所以我们预先加载了一些数据。以下类由 Spring 自动加载:

当它被加载时会发生什么?

  • Spring Boot 在应用程序上下文加载完成后,会运行所有的 CommandLineRunner bean。

  • 这个 runner 会请求一份您刚刚创建的 EmployeeRepository 的副本。

  • runner 会创建两个实体并将它们存储起来。

右键点击并运行 PayRollApplication,您将看到:

这不是全部日志,而只是预加载数据的关键部分。

HTTP 是平台

要为您的仓库添加一个Web层,您必须转向Spring MVC。得益于Spring Boot,您只需添加少量代码。相反,我们可以专注于操作:

  • @RestController 表示每个方法返回的数据会直接写入响应体,而不是渲染模板。

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

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

  • EmployeeNotFoundException 是一个异常,用于表示当查找员工但未找到时的情况。

当抛出 EmployeeNotFoundException 时,Spring MVC 配置中的这一额外部分用于渲染 HTTP 404 错误:

  • @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

这样操作会得到以下结果:

您可以看到预加载的数据以紧凑的格式显示。

现在尝试查询一个不存在的用户,如下所示:

$ curl -v localhost:8080/employees/99

当您这样做时,您将获得以下输出:

这条消息很好地显示了一个 HTTP 404 错误,并带有自定义消息:找不到员工 99

展示当前编码的交互并不困难。

如果您使用 Windows 命令提示符来执行 cURL 命令,以下命令可能无法正常工作。您必须选择一个支持单引号参数的终端,或者使用双引号并转义 JSON 中的引号。

要创建一个新的 Employee 记录,请在终端中使用以下命令(开头的 $ 表示后面是终端命令):

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

然后它会存储新创建的员工并将其发送回给我们:

{"id":3,"name":"Samwise Gamgee","role":"gardener"}

您可以更新用户。例如,您可以更改角色:

$ curl -X PUT localhost:8080/employees/3 -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}'

现在我们可以看到输出中反映的变化:

{"id":3,"name":"Samwise Gamgee","role":"ring bearer"}

您构建服务的方式可能会产生重大影响。在这种情况下,我们说的是更新,但替换是更准确的描述。例如,如果没有提供名称,它将会被置为 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 服务?

到目前为止,您已经拥有了一个基于网络的服务,用于处理涉及员工数据的核心操作。然而,这还不足以使服务变得“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。这导致了与电子商务兴起之前相同的脆弱性。这表明我们的JSON输出需要一些帮助。

Spring HATEOAS

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

如果您正在按照解决方案仓库进行操作,下一节将切换到rest模块

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

任何 RESTful 服务的一个关键要素是向相关操作添加链接。为了使您的控制器更具 RESTful 特性,可以向 EmployeeController 中的现有 one 方法添加如下链接:

您还需要包含新的导入:

本教程基于 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(关系)。链接是赋予网络力量的关键。在万维网出现之前,其他文档系统也会呈现信息或链接,但正是这种带有关系元数据的文档链接将网络编织在了一起。

Roy Fielding 鼓励使用使网络成功的技术来构建 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

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

HAL 是一种轻量级的 媒体类型,它不仅可以编码数据,还可以编码超媒体控件,提醒消费者可以导航到 API 的其他部分。在这个例子中,有一个 "self" 链接(类似于代码中的 this 语句)以及一个返回到 聚合根 的链接。

为了使聚合根更具 RESTful 特性,您希望在包含顶级链接的同时,也包含其中的任何 RESTful 组件。

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

我们希望在以下内容中实现(位于完成代码的 rest 模块中):

这个方法,过去仅仅是 repository.findAll(),现在已经“成熟”了。不用担心,现在我们可以将其解包。

CollectionModel<> 是另一个 Spring HATEOAS 容器。它旨在封装资源集合,而不是像之前的 EntityModel<> 那样封装单个资源实体。CollectionModel<> 同样允许您包含链接。

不要忽视第一句话。“封装集合”是什么意思?员工集合吗?

不完全正确。

因为我们讨论的是 REST,它应该封装 员工资源 的集合。

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

如果您重启应用程序并获取聚合根,现在可以看到它的样子:

curl -v localhost:8080/employees | json_pp

对于这个聚合根(提供一组员工资源),有一个顶层的 "self" 链接。"collection" 列在 "_embedded" 部分之下。这是 HAL 表示集合的方式。

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

添加这些链接的意义何在?它使得 REST 服务能够随着时间的推移而演进。现有的链接可以保留,同时未来可以添加新的链接。新客户端可以利用新的链接,而旧客户端则可以继续使用旧的链接。如果服务被重新定位或移动,这一点尤其有用。只要链接结构保持不变,客户端仍然可以找到并与之交互。

如果您正在按照解决方案仓库进行操作,下一部分将切换到演进模块

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

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

这个简单的接口有一个方法:toModel()。它基于将一个非模型对象(Employee)转换为一个基于模型的对象(EntityModel<Employee>)。

您之前在控制器中看到的所有代码都可以移入这个类中。此外,通过应用 Spring Framework 的 @Component 注解,当应用程序启动时,这个组装器会自动创建。

Spring HATEOAS 的所有模型的抽象基类是 RepresentationModel。不过,为了简单起见,我们建议使用 EntityModel<T> 作为您的机制,以便轻松地将所有 POJO 包装为模型。

要利用这个装配器,您只需通过构造函数注入装配器来修改 EmployeeController

在这里,您可以在 EmployeeController 中已经存在的单条员工方法 one 中使用该装配器:

这段代码几乎相同,只是您将创建 EntityModel<Employee> 实例的工作委托给了装配器。这可能并不令人印象深刻。

在聚合根控制器方法中应用相同的东西则更为引人注目。这一更改同样适用于 EmployeeController 类:

这段代码再次几乎相同。然而,您可以用 map(assembler::toModel) 替换所有 EntityModel<Employee> 创建逻辑。得益于 Java 方法引用,插入并简化控制器变得非常容易。

Spring HATEOAS 的一个关键设计目标是让做正确的事情™变得更加容易。在这个场景中,这意味着在不进行硬编码的情况下为您的服务添加超媒体。

在此阶段,您已经创建了一个 Spring MVC REST 控制器,该控制器实际上生成了基于超媒体的内容。不支持 HAL 的客户端可以忽略这些额外的内容,直接使用纯数据。而支持 HAL 的客户端则可以导航到您增强后的 API。

然而,这只是使用 Spring 构建真正 RESTful 服务的其中一步。

演进 REST API

只需添加一个额外的库和几行额外的代码,您就可以为您的应用程序添加超媒体功能。但这并不是使您的服务成为 RESTful 的唯一要求。REST 的一个重要方面在于,它既不是一种技术堆栈,也不是单一标准。

REST 是一组架构约束,采用这些约束后,您的应用程序将变得更加健壮。健壮性的一个关键因素是,当您升级服务时,您的客户端不会遭受停机时间。

在“过去”,升级以破坏客户端而臭名昭著。换句话说,服务器的升级需要客户端也进行更新。在当今时代,升级过程中花费的数小时甚至数分钟的停机时间可能会导致数百万的收入损失。

一些公司要求您向管理层提交一个计划,以最小化停机时间。在过去,您可以在周日凌晨 2:00(负载最小时)进行升级。但在当今基于互联网的电子商务中,面对来自不同时区的国际客户,这样的策略效果并不理想。

基于 SOAP 的服务基于 CORBA 的服务极其脆弱。很难推出一个既能支持旧客户端又能支持新客户端的服务器。而采用基于 REST 的实践,尤其是使用 Spring 技术栈,事情会变得容易得多。

支持对 API 的更改

想象一下这个设计问题:您已经推出了一套基于 Employee 记录的系统。该系统大获成功,已经销售给无数企业。突然之间,出现了将员工姓名拆分为 firstNamelastName 的需求。

糟糕。您之前没有考虑到这一点。

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

停机时间等于金钱损失。管理层是否为此做好了准备?

有一种古老的策略,比 REST 早了很多年。

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

— 未知

您可以随时向数据库表中添加列(字段),但不要删除列。RESTful 服务中的原则也是如此。

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

这种格式显示了 firstNamelastNamename。虽然它存在信息重复,但其目的是同时支持旧客户端和新客户端。这意味着您可以升级服务器,而不需要客户端同时升级。这是一个减少停机时间的好举措。

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

这个类与前一个版本的 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):

你还需要添加以下导入:

  • 新的 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

输出如下:

这不仅使结果对象以 HAL 格式呈现(包括 name 以及 firstNamelastName),还填充了 Location 头,其值为 http://localhost:8080/employees/3。支持超媒体的客户端可以选择“导航”到这个新资源并继续与之交互。

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

save() 操作构建的 Employee 对象随后被包装在 EmployeeModelAssembler 中,以创建一个 EntityModel<Employee> 对象。使用 getRequiredLink() 方法,您可以检索由 EmployeeModelAssembler 创建的带有 SELF 关系的 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

该员工资源现已更新,位置URI也已返回。最后,更新 EmployeeController 中的 DELETE 操作 (deleteEmployee):

这将返回一个HTTP 204 No Content响应。重新启动您的应用程序,运行以下命令,并观察结果:

$ curl -v -X DELETE localhost:8080/employees/1

Employee 类中的字段进行更改需要与您的数据库团队进行协调,以便他们能够正确地将现有内容迁移到新列中。

您现在可以进行一次升级,该升级不会干扰现有客户端,同时新客户端可以充分利用增强功能。

顺便问一下,您是否担心在网络上发送过多信息?在某些系统中,每个字节都很重要,API 的演进可能需要退居次要地位。然而,在您衡量更改的影响之前,不应过早进行此类优化。

如果您正在按照解决方案仓库进行操作,下一节将切换到链接模块

到目前为止,您已经构建了一个带有基本链接的可演化API。为了扩展您的API并更好地服务客户,您需要接受超媒体作为应用状态引擎的概念。

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

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

为了展示如何在不触发客户端破坏性变化的情况下应对状态变化,假设我们添加一个完成订单的系统。

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

  • 该类需要一个 JPA @Table 注解,将表名更改为 CUSTOMER_ORDER,因为 ORDER 不是一个有效的表名。

  • 它包含一个 description 字段以及一个 status 字段。

从客户提交订单到订单被完成或被取消,订单必须经过一系列特定的状态转换。这可以通过一个名为 Status 的 Java enum 来捕获:

这个 enum 捕获了 Order 可能处于的各种状态。在本教程中,我们将其保持简单。

为了支持与数据库中的订单进行交互,您必须定义一个名为 OrderRepository 的 Spring Data 仓库:

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

有了这些基础,您现在可以定义一个基本的 OrderController,并导入所需的依赖项:

  • 它包含了与您目前构建的控制器相同的 REST 控制器配置。

  • 它注入了 OrderRepository 和一个(尚未构建的)OrderModelAssembler

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

  • 第三个 Spring MVC 路由处理创建新订单,将其初始状态设置为 IN_PROGRESS

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

在构建 OrderModelAssembler 之前,我们应该讨论需要实现的功能。您正在建模 Status.IN_PROGRESSStatus.COMPLETEDStatus.CANCELLED 之间的状态流转。通常情况下,当向客户端提供此类数据时,会允许客户端根据有效载荷决定它们可以执行的操作。

但这是错误的。

如果在流程中引入新状态会发生什么?UI 上各种按钮的布局可能会出错。

如果您更改每个状态的名称,例如在编码国际化支持并为每个状态显示特定语言环境的文本时,这很可能会导致所有客户端崩溃。

这时就需要引入 HATEOAS超媒体作为应用状态的引擎)。与其让客户端解析有效载荷,不如为它们提供表示有效操作的链接。将基于状态的操作与数据有效载荷解耦。换句话说,当 CANCELCOMPLETE 是有效操作时,您应该动态地将它们添加到链接列表中。客户端只需在链接存在时向用户显示相应的按钮。

这使客户端无需知道这些操作何时有效,从而减少了服务器及其客户端在状态转换逻辑上不同步的风险。

由于我们已经采用了 Spring HATEOAS 的 RepresentationModelAssembler 组件的概念,OrderModelAssembler 是捕捉此业务规则逻辑的绝佳场所:

此资源装配器始终包含指向单个资源项的 self 链接以及指向聚合根的链接。然而,它还包含两个指向 OrderController.cancel(id)OrderController.complete(id)(尚未定义)的条件链接。这些链接仅在订单状态为 Status.IN_PROGRESS 时显示。

如果客户端能够采用 HAL 并具备读取链接的能力,而不是简单地读取普通 JSON 的数据,它们就可以减少对订单系统领域知识的需求。这自然降低了客户端与服务器之间的耦合。同时,这也为在不破坏客户端的情况下调整订单履行流程打开了大门。

为了完善订单履行功能,请将以下内容添加到 OrderController 中以实现 cancel 操作:

它在允许取消订单之前检查 Order 状态。如果状态无效,则返回一个支持超媒体的错误容器 RFC-7807 Problem。如果状态转换确实有效,则将 Order 状态转换为 CANCELLED

现在我们还需要在 OrderController 中添加订单完成的逻辑:

它实现了类似的逻辑,以防止订单状态在不适当的状态下被标记为完成。

让我们更新 LoadDatabase,以预加载一些 Order 对象以及它之前加载的 Employee 对象。

现在可以测试了。重新启动应用程序以确保运行的是最新的代码更改。要使用新创建的订单服务,您可以执行以下操作:

$ curl -v http://localhost:8080/orders | json_pp

这份HAL文档根据每个订单的当前状态,立即展示了不同的链接。

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

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

现在尝试取消一个订单:

$ curl -v -X DELETE http://localhost:8080/orders/4/cancel | json_pp

您可能需要根据数据库中的特定ID替换前面URL中的数字4。该信息可以从之前的/orders调用中找到。

此响应显示了HTTP 200状态码,表明操作成功。响应的HAL文档展示了订单的新状态(CANCELLED)。此外,状态更改的链接已经消失。

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

$ curl -v -X DELETE http://localhost:8080/orders/4/cancel | json_pp

您可以看到 HTTP 405 方法不被允许 的响应。DELETE 操作已变为无效。Problem 响应对象明确表示您不能“取消”已经处于“已取消”状态的订单。

此外,尝试完成同一个订单也会失败:

$ curl -v -X PUT localhost:8080/orders/4/complete | json_pp

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

通过使用超媒体和链接的协议,客户端可以变得更加健壮,并且不太可能仅仅因为数据的改变而崩溃。Spring HATEOAS 简化了构建您需要提供给客户端的超媒体的过程。

总结

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

相反,以下策略有助于使您的服务不太可能破坏您可能控制或可能不控制的现有客户端:

  • 不要移除旧字段,而是继续支持它们。

  • 使用基于 rel 的链接,这样客户端就不需要硬编码 URI。

  • 尽可能保留旧链接。即使您必须更改 URI,也要保留 rels,以便旧客户端能够访问新功能。

  • 使用链接而非负载数据来指示客户端何时可以进行各种状态驱动的操作。

为每种资源类型构建 RepresentationModelAssembler 实现,并在所有控制器中使用这些组件,可能会显得有些费力。然而,这种额外的服务器端设置(得益于 Spring HATEOAS 变得简单)可以确保您控制的客户端(更重要的是,那些您无法控制的客户端)在您升级 API 时能够轻松地进行更新。

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

  • nonrest — 简单的 Spring MVC 应用程序,不包含超媒体

  • rest — Spring MVC + Spring HATEOAS 应用程序,每个资源都有 HAL 表示

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

  • links — REST 应用程序,使用条件链接向客户端发出有效状态变化的信号

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

想要进一步探索,请查看 Spring 团队成员 Oliver Drotbohm 的以下视频:

本页目录