到目前为止,我们已经建立了具有简纯链接的可演化 API。为了发展我们的 API 并更好地为我们的客户端服务,我们需要拥抱 Hypermedia 作为应用状态引擎的概念。
这意味着什么?在该部分中,我们将详细研究它。
业务逻辑不可避免地建立涉及流程的规则。该类系统的风险在于我们经常将该类服务器端逻辑带入客户端并建立牢固的耦合。REST 旨在拆解该类联系并最小化这种耦合。
为了说明如何在不触发客户端变化的情况下应对状态变化,请设想添加一个可以接收订单的系统。
第一步,定义一个 Order
记录:
links/src/main/java/payroll/Order.java
package payroll;
import lombok.Data;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
@Entity
@Data
@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;
}
}
- 该类需要一个 JPA 的
@Table
注解,将表的名称更改为CUSTOMER_ORDER
,因为ORDER
不是该表的有效名称; - 它包括
description
字段和status
字段。
从客户端提交订单到完成或取消订单之时,订单必须经历一系列特定的状态转换。可将其捕获为 Java 的 enum
:
package payroll;
enum Status {
IN_PROGRESS,
COMPLETED,
CANCELLED;
}
该 enum
捕获了 Order 可以持有的各种状态。对于该教程,让我们保持简单。
为了支持与数据库中的订单进行交互,我们必须定义一个相应的 Spring Data 存储库:
Spring Data JPA 的 JpaRepository
基本接口
interface OrderRepository extends JpaRepository<Order, Long> {
}
现在,我们可以定义一个基本的 OrderController
:
links/src/main/java/payroll/OrderController.java
@RestController
class OrderController {
private final OrderRepository orderRepository;
private final OrderModelAssembler assembler;
OrderController(OrderRepository orderRepository,
OrderModelAssembler assembler) {
this.orderRepository = orderRepository;
this.assembler = assembler;
}
@GetMapping("/orders")
CollectionModel<EntityModel<Order>> all() {
List<EntityModel<Order>> orders = orderRepository.findAll().stream()
.map(assembler::toModel)
.collect(Collectors.toList());
return new CollectionModel<>(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
资源请求; - 第三个 Spirng MVC 路透通过以
IN_PROGRESS
状态启动它们来处理创建新订单; - 所有控制器方法都将返回 Spring HATEOAS 的
RepresentationModel
子类之一,以正确展示超媒体(或包裹该类的包装器)。
在构建 OrderModelAssembler
之前,让我们讨论需要发生的事情。我们正在建模 Status.IN_PROGRESS
、Status.COMPLETED
和 Status.CANCELLED
之间的状态流。向客户端提供该类数据时,很自然的事情是让客户端根据该有效负载决定它可以做什么。
但这是错误的。
在该流程中引入新状态时会发生什么?UI 上各种按钮的放置可能是错误的。
如果我们更改了每个状态的名称,可能是在编写国际支持并显示每个状态的特定于语言环境的文本时呢?那很可能会破坏所有客户端。
输入 HATEOAS 或 Hypermedia 作为应用状态引擎。与其让客户端解析有效负载,不如让客户端链接以发出有效动作信号。将基于状态的操作与数据的有效负载分离。换句话说,当 CANCEL 和 COMPLETE 是有效动作时,将它们动态添加到链接列表中。链接存在时,客户端仅需要向用户显示相应的按钮。
这使客户端不必知道何时这些操作有效,从而减少了服务器及其客户端在状态转换逻辑上不同步的风险。
已经拥抱了 Spring HATEOAS ResourceAssembler
组件的概念,将这样的逻辑放入 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 = new EntityModel<>(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<RepresentationModel> 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)
.body(new VndErrors.VndError("Method not allowed", "You can't cancel an order that is in the " + order.getStatus() + " status"));
}
它会在取消订单状态之前检查 Order
状态。如果状态无效,则返回 Spring HATEOAS VndError
,这是支持超媒体的错误容器。如果转换确实有效,则它将 Order
转换到 CANCELLED
。
并将其添加到 OrderController
中以完成订单:
在 OrderController 中创建 “完成” 操作
@PutMapping("/orders/{id}/complete")
ResponseEntity<RepresentationModel> 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)
.body(new VndErrors.VndError("Method not allowed", "You can't complete an order that is in the " + order.getStatus() + " status"));
}
这实现了类似的逻辑,以防止 Order
状态无法完成,除非处于适当的状态。
通过向 LoadDatabase
添加一些额外的初始化代码:
更新数据库预加载器
orderRepository.save(new Order("MacBook Pro", Status.COMPLETED));
orderRepository.save(new Order("iPhone", Status.IN_PROGRESS));
orderRepository.findAll().forEach(order -> {
log.info("Preloaded " + order);
});
…我们可以测一下!
要使用新创建的订单服务,只需执行一些操作:
$ curl -v http://localhost:8080/orders
{
"_embedded": {
"orderList": [
{
"id": 3,
"description": "MacBook Pro",
"status": "COMPLETED",
"_links": {
"self": {
"href": "http://localhost:8080/orders/3"
},
"orders": {
"href": "http://localhost:8080/orders"
}
}
},
{
"id": 4,
"description": "iPhone",
"status": "IN_PROGRESS",
"_links": {
"self": {
"href": "http://localhost:8080/orders/4"
},
"orders": {
"href": "http://localhost:8080/orders"
},
"cancel": {
"href": "http://localhost:8080/orders/4/cancel"
},
"complete": {
"href": "http://localhost:8080/orders/4/complete"
}
}
}
]
},
"_links": {
"self": {
"href": "http://localhost:8080/orders"
}
}
}
该 HAL 文档根据其当前状态立即显示每个订单的不同链接:
- COMPLETED 的第一个订单仅具有导航链接。状态转换链接未显示;
- 第二个顺序为 IN_PROGRESS,另外具有 cancel 链接和 complete 链接。
尝试取消订单:
$ curl -v -X DELETE http://localhost:8080/orders/4/cancel
> DELETE /orders/4/cancel HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Mon, 27 Aug 2018 15:02:10 GMT
<
{
"id": 4,
"description": "iPhone",
"status": "CANCELLED",
"_links": {
"self": {
"href": "http://localhost:8080/orders/4"
},
"orders": {
"href": "http://localhost:8080/orders"
}
}
}
该响应显示指示成功的 HTTP 200 状态码。响应的 HAL 文档以新状态(CANCELLED
)显示该订单。改变状态的链接也消失了。
如果我们再次尝试相同的操作…
$ curl -v -X DELETE http://localhost:8080/orders/4/cancel
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> DELETE /orders/4/cancel HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 405
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Mon, 27 Aug 2018 15:03:24 GMT
<
{
"logref": "Method not allowed",
"message": "You can't cancel an order that is in the CANCELLED status"
}
…我们看到 HTTP 405 Method Not Allowed。DELETE 已成为无效操作。VndError
响应对象明确指示不允许我们 “取消” 已经处于 “已取消” 状态的订单。
此外,尝试完成相同的订单也会失败:
$ curl -v -X PUT localhost:8080/orders/4/complete
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> PUT /orders/4/complete HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 405
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Mon, 27 Aug 2018 15:05:40 GMT
<
{
"logref": "Method not allowed",
"message": "You can't complete an order that is in the CANCELLED status"
}
完成所有这些操作后,我们的订单履行服务便可以有条件地显示可用的操作。它还可以防止无效操作。
通过利用超媒体和链接协议,可以使客户端更坚固,并且仅因数据更改而导致奔溃的可能性较小。Spring HATEOAS 简化了构建为客户服务所需的超媒体的过程。