使用 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 許可,文字內容採用署名-禁止演繹知識共享許可

獲取程式碼