到目前爲止,我們已經建立了具有簡純鏈接的可演化 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 簡化了構建爲客戶服務所需的超媒體的過程。