使用 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 資料庫

將名稱變更為「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 的這段話可能進一步揭示了 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 服務的關鍵要素都是將 連結 新增至相關作業。若要使您的控制器更 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 連結。

  • linkTo(methodOn(EmployeeController.class).all()).withRel("employees") 要求 Spring HATEOAS 建置指向聚合根 all() 的連結,並將其稱為「employees」。

我們所說的「建置連結」是什麼意思?Spring HATEOAS 的核心類型之一是 Link。它包含 URIrel (關係)。連結是賦予 Web 能力的原因。在 World Wide Web 之前,其他文件系統會呈現資訊或連結,但正是文件與這種關係中繼資料的連結將 Web 縫合在一起。

Roy Fielding 鼓勵使用使 Web 成功的相同技術來建構 API,而連結就是其中之一。

如果您重新啟動應用程式並查詢 Bilbo 的員工記錄,您會得到與先前稍有不同的回應

更漂亮的捲曲

當您的 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://127.0.0.1:8080/employees/1"
    },
    "employees": {
      "href": "https://127.0.0.1:8080/employees"
    }
  }
}

此解壓縮輸出不僅顯示了您先前看到的資料元素 (idnamerole),還顯示了包含兩個 URI 的 _links 項目。整個文件都使用 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
員工資源集合的 RESTful 表示
{
  "_embedded": {
    "employeeList": [
      {
        "id": 1,
        "name": "Bilbo Baggins",
        "role": "burglar",
        "_links": {
          "self": {
            "href": "https://127.0.0.1:8080/employees/1"
          },
          "employees": {
            "href": "https://127.0.0.1:8080/employees"
          }
        }
      },
      {
        "id": 2,
        "name": "Frodo Baggins",
        "role": "thief",
        "_links": {
          "self": {
            "href": "https://127.0.0.1:8080/employees/2"
          },
          "employees": {
            "href": "https://127.0.0.1:8080/employees"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "https://127.0.0.1:8080/employees"
    }
  }
}

對於這個提供員工資源集合的聚合根,有一個頂層 「self」 連結。「collection」 列在 「_embedded」 區段下方。這就是 HAL 表示集合的方式。

集合的每個個別成員都具有其資訊以及相關連結。

新增所有這些連結的意義是什麼?它使隨著時間推移演進 REST 服務成為可能。可以維護現有連結,同時可以在未來新增新連結。較新的用戶端可能會利用新連結,而舊版用戶端可以依靠舊連結維持自身運作。如果服務被重新定位和移動,這尤其有幫助。只要維護連結結構,用戶端仍然可以找到事物並與之互動。

如果您正在依照 解決方案儲存庫 進行操作,則下一節會切換到 evolution 模組

在先前的程式碼中,您是否注意到單一員工連結建立中的重複?提供單一員工連結以及建立指向聚合根的「employees」連結的程式碼已顯示兩次。如果這引起了關注,那就太好了!有一個解決方案。

您需要定義一個函數,將 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:00 負載最小時進行升級來擺脫困境。但在當今基於網際網路的電子商務中,國際客戶位於其他時區,這種策略不再那麼有效。

基於 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://127.0.0.1:8080/employees/1"
    },
    "employees": {
      "href": "https://127.0.0.1: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://127.0.0.1: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://127.0.0.1:8080/employees/3"
    },
    "employees": {
      "href": "https://127.0.0.1:8080/employees"
    }
  }
}

這不僅以 HAL 呈現產生的物件 (name 以及 firstNamelastName),而且 Location 標頭也填入了 https://127.0.0.1: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() 方法,您可以使用 SELF rel 擷取由 EmployeeModelAssembler 建立的 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://127.0.0.1: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://127.0.0.1:8080/employees/3"
		},
		"employees": {
			"href": "https://127.0.0.1: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 上各種按鈕的放置可能會出錯。

如果您更改每個狀態的名稱,也許是在編碼國際支援並顯示每個狀態的特定於語言環境的文字時?這很可能會破壞所有客戶端。

輸入 HATEOASHypermedia as the Engine of Application State(超媒體即應用程式狀態引擎)。不要讓客戶端解析有效負載,而是給他們連結來表示有效的操作。將基於狀態的操作與資料的有效負載分離。換句話說,當 CANCELCOMPLETE 是有效操作時,您應該動態地將它們新增到連結列表中。客戶端只需要在連結存在時才向使用者顯示相應的按鈕。

這使客戶端無需知道何時這些操作有效,從而降低伺服器及其客戶端在狀態轉換邏輯上失去同步的風險。

既然已經接受了 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 中建立「取消」操作
@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://127.0.0.1:8080/orders | json_pp
詳細資訊
{
  "_embedded": {
    "orderList": [
      {
        "id": 3,
        "description": "MacBook Pro",
        "status": "COMPLETED",
        "_links": {
          "self": {
            "href": "https://127.0.0.1:8080/orders/3"
          },
          "orders": {
            "href": "https://127.0.0.1:8080/orders"
          }
        }
      },
      {
        "id": 4,
        "description": "iPhone",
        "status": "IN_PROGRESS",
        "_links": {
          "self": {
            "href": "https://127.0.0.1:8080/orders/4"
          },
          "orders": {
            "href": "https://127.0.0.1:8080/orders"
          },
          "cancel": {
            "href": "https://127.0.0.1:8080/orders/4/cancel"
          },
          "complete": {
            "href": "https://127.0.0.1:8080/orders/4/complete"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "https://127.0.0.1:8080/orders"
    }
  }
}

此 HAL 文件立即顯示每個訂單的不同連結,基於其目前狀態。

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

  • 第二個訂單,狀態為 IN_PROGRESS,另外具有 cancel 連結以及 complete 連結。

現在嘗試取消訂單

$ curl -v -X DELETE https://127.0.0.1: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://127.0.0.1:8080/orders/4"
    },
    "orders": {
      "href": "https://127.0.0.1:8080/orders"
    }
  }
}

此回應顯示 HTTP 200 狀態碼,表示它已成功。回應 HAL 文件顯示該訂單處於其新狀態 (CANCELLED)。此外,狀態變更連結已消失。

現在再次嘗試相同的操作

$ curl -v -X DELETE https://127.0.0.1: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 — Spring MVC + Spring HATEOAS 應用程式,具有每個資源的 HAL 表示

  • evolution — REST 應用程式,其中欄位已演進,但舊資料已保留以實現向後相容性

  • links — REST 應用程式,其中條件連結用於向客戶端發送信號表示有效的狀態變更

要查看更多使用 Spring HATEOAS 的範例,請參閱 https://github.com/spring-projects/spring-hateoas-examples

要進行更多探索,請查看 Spring 團隊成員 Oliver Drotbohm 的以下影片

想要撰寫新的指南或貢獻現有指南?請查看我們的 貢獻指南

所有指南均以 ASLv2 許可證發布程式碼,並以 姓名標示-禁止改作創用 CC 授權條款 3.0 版 發布寫作內容。

取得程式碼