使用 Spring 構建 REST 服務

REST 已迅速成為在 Web 上構建 Web 服務的實際標準,因為 REST 服務易於構建和消費。

關於 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

將 Name 改為 "Payroll",然後選擇 Generate Project。一個 .zip 檔案會下載。解壓它。在裡面,你會找到一個簡單的、基於 Maven 的專案,其中包含一個 pom.xml 構建檔案。(注意:你可以使用 Gradle。本教程中的示例將基於 Maven。)

要完成本教程,你可以從頭開始一個新專案,或者檢視 GitHub 中的解決方案倉庫

如果你選擇建立自己的空白專案,本教程將指導你按順序構建應用程式。你不需要多個模組。

GitHub 上完整的倉庫沒有提供一個單一的最終解決方案,而是使用模組將解決方案分為四個部分。GitHub 解決方案倉庫中的模組是相互構建的,其中 links 模組包含最終解決方案。模組對應以下標題:

目前進展

本教程首先構建 nonrest 模組中的程式碼。

我們從能構建的最簡單的事物開始。實際上,為了儘可能簡單,我們甚至可以省略 REST 的概念。(稍後,我們再新增 REST,以理解區別。)

總體思路:我們將建立一個簡單的工資服務,管理公司的員工。我們將 employee 物件儲存在 (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 provider 自動填充。

  • 當我們需要建立一個新例項但還沒有 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。

  • 這個 runner 請求一份你剛剛建立的 EmployeeRepository

  • runner 建立兩個實體並存儲它們。

右鍵點選並執行 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 透過建構函式注入到 controller 中。

  • 我們為每個操作都有路由(@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 表明此 advice 會直接渲染到響應體中。

  • @ExceptionHandler 配置此 advice 僅在丟擲 EmployeeNotFoundException 時響應。

  • @ResponseStatus 表示發出一個 HttpStatus.NOT_FOUND——即一個 HTTP 404 錯誤。

  • advice 的主體生成內容。在本例中,它提供異常的訊息。

要啟動應用程式,你可以右鍵點選 PayRollApplication 中的 public static void main,並從 IDE 中選擇執行

或者,Spring Initializr 建立了一個 Maven wrapper,因此你可以執行以下命令:

$ ./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 的這段話可能進一步揭示了 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。這導致了與 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 服務的關鍵要素是新增連結指向相關操作。為了使你的 controller 更 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 中的靜態助手方法構建這些連結。如果你在專案中使用 Spring WebFlux,則必須改用 WebFluxLinkBuilder

這與我們之前的程式碼非常相似,但有一些變化:

  • 方法的返回型別從 Employee 更改為 EntityModel<Employee>EntityModel<T> 是 Spring HATEOAS 中的一個泛型容器,它不僅包含資料,還包含一組連結。

  • linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel() 要求 Spring HATEOAS 構建一個連結指向 EmployeeControllerone 方法,並將其標記為自引用連結(self link)

  • 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),還包含一個 _links 條目,其中包含兩個 URI。整個文件使用 HAL 格式化。

HAL 是一種輕量級的 媒體型別,它不僅允許編碼資料,還允許編碼超媒體控制元件,提示消費者可以導航到的 API 的其他部分。在本例中,有一個“self”連結(有點像程式碼中的 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
如果你重啟應用程式並獲取聚合根,你現在可以看到它的樣子:
{
  "_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"
    }
  }
}

員工資源集合的 RESTful 表示

對於這個提供員工資源集合的聚合根,有一個頂級的“self”連結。“集合”列在 _embedded 部分下方。HAL 就是這樣表示集合的。

集合中的每個單獨成員都有他們的資訊以及相關的連結。

簡化連結建立

如果你正在跟隨解決方案倉庫,下一節將切換到evolution 模組

在前面的程式碼中,你注意到建立單個員工連結時的重複了嗎?提供單個連結指向員工以及建立指向聚合根的“employees”連結的程式碼顯示了兩次。如果這讓你有所擔憂,那很好!有一個解決方案。

你需要定義一個函式,將 Employee 物件轉換為 EntityModel<Employee> 物件。雖然你可以輕鬆地自己編寫這個方法,但 Spring HATEOAS 的 RepresentationModelAssembler 介面為你完成了這項工作。建立一個新類 EmployeeModelAssembler
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"));
  }
}

evolution/src/main/java/payroll/EmployeeModelAssembler.java

這個簡單的介面有一個方法:toModel()。它的基礎是將一個非模型物件(Employee)轉換為一個基於模型的物件(EntityModel<Employee>)。

你之前在 controller 中看到的所有程式碼都可以移到這個類中。此外,透過應用 Spring Framework 的 @Component 註解,當應用程式啟動時,assembler 會自動建立。

Spring HATEOAS 所有模型的抽象基類是 RepresentationModel。然而,為了簡單起見,我們建議使用 EntityModel<T> 作為將所有 POJO 輕鬆包裝為模型的一種機制。

要利用這個 assembler,你只需要修改 EmployeeController,在建構函式中注入 assembler。
@RestController
class EmployeeController {

  private final EmployeeRepository repository;

  private final EmployeeModelAssembler assembler;

  EmployeeController(EmployeeRepository repository, EmployeeModelAssembler assembler) {

    this.repository = repository;
    this.assembler = assembler;
  }

  ...

}

將 EmployeeModelAssembler 注入到 controller 中

從這裡開始,你可以在 EmployeeController 中已經存在的單個項員工方法 one 中使用這個 assembler。
	@GetMapping("/employees/{id}")
	EntityModel<Employee> one(@PathVariable Long id) {

		Employee employee = repository.findById(id) //
				.orElseThrow(() -> new EmployeeNotFoundException(id));

		return assembler.toModel(employee);
	}

使用 assembler 獲取單個項資源

這段程式碼幾乎相同,除了不再在這裡建立 EntityModel<Employee> 例項,而是委託給 assembler。也許這看起來並不令人印象深刻。

在聚合根 controller 方法中應用同樣的事情更令人印象深刻。這個更改也是針對 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());
}

使用 assembler 獲取聚合根資源

程式碼再次幾乎相同。然而,你可以用 map(assembler::toModel) 替換所有那些建立 EntityModel<Employee> 的邏輯。感謝 Java 方法引用,插入和簡化你的 controller 變得超級容易。

Spring HATEOAS 的一個關鍵設計目標是讓“做正確的事™”變得更容易。在這個場景中,這意味著在不硬編碼任何東西的情況下為你的服務新增超媒體。

在這個階段,你已經建立了一個 Spring MVC REST controller,它實際上生成了由超媒體驅動的內容。不支援 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 表示中新增新欄位,但不要刪除任何現有欄位。像這樣:
{
  "id": 1,
  "firstName": "Bilbo",
  "lastName": "Baggins",
  "role": "burglar",
  "name": "Bilbo Baggins",
  "_links": {
    "self": {
      "href": "https://:8080/employees/1"
    },
    "employees": {
      "href": "https://:8080/employees"
    }
  }
}

支援多個客戶端的 JSON

這種格式顯示了 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 記錄

  • 這個類與之前的 Employee 版本相似,但有一些變化:

  • 欄位 name 已被 firstNamelastName 替換。

  • 為舊 name 屬性定義了一個“虛擬”的 getter 方法 getName()。它使用 firstNamelastName 欄位生成一個值。

還定義了一個為舊 name 屬性的“虛擬” setter 方法 setName()。它解析傳入的字串並將其儲存到相應的欄位中。

當然,並非所有 API 更改都像拆分或合併兩個字串一樣簡單。但為大多數場景找到一套轉換方案肯定不是不可能的,對吧?

log.info("Preloading " + repository.save(new Employee("Bilbo", "Baggins", "burglar")));
log.info("Preloading " + repository.save(new Employee("Frodo", "Baggins", "thief")));

不要忘記更改如何預載入資料庫(在 LoadDatabase 中)以使用這個新的建構函式。

恰當的響應

朝正確方向邁出的另一步是確保你的每個 REST 方法都返回恰當的響應。更新 EmployeeController 中的 POST 方法(newEmployee):
@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);
}

處理“舊”客戶端和“新”客戶端請求的 POST 方法

詳情
import org.springframework.hateoas.IanaLinkRelations;
import org.springframework.http.ResponseEntity;
  • 你還需要新增匯入:

  • 新的 Employee 物件像之前一樣被儲存。然而,結果物件被 EmployeeModelAssembler 包裝。

  • Spring MVC 的 ResponseEntity 用於建立 HTTP 201 Created 狀態訊息。這種型別的響應通常包含一個 Location 響應頭,我們使用從模型的自引用連結派生的 URI。

此外,還會返回已儲存物件的模型版本。

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

有了這些調整,你可以使用同一個 endpoint 建立新的員工資源,並使用舊的 name 欄位:

詳情
> 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 controller 方法(replaceEmployee)需要類似的調整:
@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);
}

處理不同客戶端的 PUT 請求

save() 操作構建的 Employee 物件隨後被 EmployeeModelAssembler 包裝以建立 EntityModel<Employee> 物件。使用 getRequiredLink() 方法,你可以檢索由 EmployeeModelAssembler 建立的帶有 SELF rel 的 Link。這個方法返回一個 Link,必須使用 toUri 方法將其轉換為 URI

$ 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"
		}
	}
}

由於我們想要比 200 OK 更詳細的 HTTP 響應碼,我們使用 Spring MVC 的 ResponseEntity 包裝器。它有一個方便的靜態方法(created()),我們可以將資源的 URI 插入其中。HTTP 201 Created 是否攜帶正確的語義是值得商榷的,因為我們不一定“建立”了一個新資源。然而,它預載入了一個 Location 響應頭,所以我們使用它。重啟應用程式,執行以下命令,並觀察結果:

該員工資源現在已更新,並且位置 URI 已被髮送回。最後,更新 EmployeeController 中的 DELETE 操作(deleteEmployee):
@DeleteMapping("/employees/{id}")
ResponseEntity<?> deleteEmployee(@PathVariable Long id) {

  repository.deleteById(id);

  return ResponseEntity.noContent().build();
}

處理 DELETE 請求

$ 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
這將返回一個 HTTP 204 No Content 響應。重啟應用程式,執行以下命令,並觀察結果:

Employee 類中的欄位進行更改需要與你的資料庫團隊協調,以便他們能夠正確地將現有內容遷移到新列中。

你現在已經準備好進行一次升級,既不打擾現有客戶端,同時新版本的客戶端可以利用增強功能。

將連結構建到你的 REST API 中

如果你正在跟隨解決方案倉庫,下一節將切換到links 模組

到目前為止,你已經構建了一個帶有基本連結的可演進 API。要發展你的 API 並更好地服務客戶端,你需要擁抱作為應用程式狀態引擎的超媒體Hypermedia as the Engine of Application State)概念。

這是什麼意思?本節將詳細探討它。

業務邏輯不可避免地會構建涉及流程的規則。這類系統的風險在於,我們常常將伺服器端邏輯帶入客戶端,並建立強耦合。REST 旨在打破這種連線並最小化這種耦合。

為了展示如何在不觸發客戶端破壞性更改的情況下應對狀態變化,想象一下新增一個處理訂單的系統。

作為第一步,定義一個新的 Order 記錄:
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 + '}';
  }
}
  • links/src/main/java/payroll/Order.java

  • 該類需要一個 JPA @Table 註解,將表的名稱更改為 CUSTOMER_ORDER,因為 ORDER 不是有效的表名。

它包含一個 description 欄位以及一個 status 欄位。

訂單必須經歷一系列特定的狀態轉換,從客戶提交訂單直到訂單完成或取消。這可以表示為一個名為 Status 的 Java 列舉:
package payroll;

enum Status {

  IN_PROGRESS, //
  COMPLETED, //
  CANCELLED
}

links/src/main/java/payroll/Status.java

這個列舉捕獲了 Order 可以處於的各種狀態。對於本教程,我們保持簡單。

為了支援與資料庫中的訂單互動,你必須定義一個相應的 Spring Data 倉庫,稱為 OrderRepository
interface OrderRepository extends JpaRepository<Order, Long> {
}

Spring Data JPA 的 JpaRepository 基本介面

詳情
package payroll;

class OrderNotFoundException extends RuntimeException {

  OrderNotFoundException(Long id) {
    super("Could not find order " + id);
  }
}

我們還需要建立一個新的異常類,稱為 OrderNotFoundException

有了這些,你現在可以定義一個基本的 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;
匯入宣告
@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));
  }
}
  • links/src/main/java/payroll/OrderController.java

  • 它包含了與你目前為止構建的 controller 相同的 REST controller 設定。

  • 它注入了一個 OrderRepository 和一個(尚未構建的)OrderModelAssembler

  • 前兩個 Spring MVC 路由處理聚合根以及單個項的 Order 資源請求。

  • 第三個 Spring MVC 路由透過將新訂單開始於 IN_PROGRESS 狀態來處理新訂單的建立。

所有 controller 方法都返回 Spring HATEOAS 的 RepresentationModel 子類之一,以正確渲染超媒體(或此類型別的包裝器)。

在構建 OrderModelAssembler 之前,我們應該討論需要完成什麼。你正在建模 Status.IN_PROGRESSStatus.COMPLETEDStatus.CANCELLED 之間的狀態流轉。向客戶端提供此類資料時,自然的想法是讓客戶端根據此負載決定它們可以執行的操作。

但那樣是錯誤的。

當你在此流程中引入新狀態時會發生什麼?UI 上各種按鈕的位置可能會出錯。

如果你改變每個狀態的名稱,也許是在編碼國際化支援時顯示每個狀態的本地化文字?那很可能會破壞所有客戶端。

這使得客戶端不必知道何時此類操作有效,降低了伺服器及其客戶端在狀態轉換邏輯上不同步的風險。

既然已經接受了 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;
  }
}

這個資源彙編器總是包含指向單項資源的 self 連結以及指向聚合根的連結。但是,它也包含兩個條件連結:指向 OrderController.cancel(id)OrderController.complete(id)(尚未定義)。這些連結僅在訂單狀態為 Status.IN_PROGRESS 時顯示。

如果客戶端可以採用 HAL 並具備讀取連結的能力,而不是僅僅讀取普通的 JSON 資料,它們就可以不再需要關於訂單系統的領域知識。這自然減少了客戶端和伺服器之間的耦合。它還為調整訂單履行流程打開了大門,而不會在此過程中破壞客戶端。

為了完善訂單履行,請在 OrderController 中為 cancel 操作新增以下內容

在 OrderController 中建立“cancel”操作
@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 中建立“complete”操作
@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,除了之前載入的 Employee 物件之外,也預載入一些 Order 物件。

更新資料庫預載入器
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 文件立即根據每個訂單的當前狀態顯示了不同的連結。

  • 第一個訂單,狀態為 COMPLETED,只有導航連結。狀態轉換連結沒有顯示。

  • 第二個訂單,狀態為 IN_PROGRESS,額外包含 cancel 連結和 complete 連結。

現在嘗試取消訂單

$ 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 響應物件清楚地表明您不允許“cancel”一個已經處於“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 許可證釋出,文字內容以 署名-禁止演繹創意共享許可證 釋出。

獲取程式碼