HATEOAS で REST API の構築

REST サービスは構築も利用も簡単なため、Web 上で Web サービスを構築するための事実上の標準として急速に普及しました。

REST がマイクロサービスの世界にどう適合するかについては、さらに広範な議論が可能です。ただし、このチュートリアルでは、RESTful サービスの構築についてのみ説明します。

なぜ REST か ? REST は、Web のアーキテクチャ、利点、その他すべてを含む Web の原則を取り入れています。その作成者 (Roy Fielding) が、Web の動作を規定するおそらく 12 個の仕様に関与していたことを考えると、これは驚くことではありません。

どんなメリットがあるのでしょうか? Web およびそのコアプロトコルである HTTP は、機能のスタックを提供します。

  • 適切なアクション (GETPOSTPUTDELETE など)

  • キャッシング

  • リダイレクトとフォワード

  • セキュリティ (暗号化と認証)

これらはすべて、回復力のあるサービスを構築する際の重要な要素です。ただし、それだけではありません。Web は、多数の小さな仕様から構築されています。このアーキテクチャにより、「標準 war」にとらわれることなく、簡単に進化することができます。

開発者は、これらの多様な仕様を実装するサードパーティのツールキットを利用し、クライアントとサーバーの両方のテクノロジーを即座に利用できるようになります。

HTTP 上に構築することにより、REST API は以下を構築する手段を提供します。

  • 下位互換性のある API

  • 進化する API

  • 拡張可能なサービス

  • セキュリティ保護可能なサービス

  • ステートレスからステートフルのサービスの範囲

REST は、どんなに普及していても、それ自体は標準ではなく、Web スケールのシステムの構築に役立つアプローチ、スタイル、アーキテクチャに対する一連の制約であることに注意してください。このチュートリアルでは、Spring ポートフォリオを使用して、REST のスタックレス機能を活用しながら RESTful サービスを構築します。

入門

開始するには、次のものが必要です。

このチュートリアルでは、Spring Boot を使用します。Spring Initializr に移動して、次の依存関係をプロジェクトに追加します。

  • Spring Web

  • Spring Data JPA

  • H2 Database

名前を "Payroll" に変更し、プロジェクトを生成するを選択します。.zip ファイルがダウンロードされます。これを解凍します。その中に、pom.xml ビルドファイルを含むシンプルな Maven ベースのプロジェクトがあるはずです。(メモ: Gradle を使用できます。このチュートリアルの例は Maven ベースになります。)

チュートリアルを完了するには、最初から新しいプロジェクトを開始するか、GitHub のソリューションリポジトリ [GitHub] (英語) を参照することができます。

独自の空のプロジェクトを作成する場合は、このチュートリアルに従ってアプリケーションを順番に構築します。複数のモジュールは必要ありません。

完成した GitHub リポジトリ (英語) は、単一の最終ソリューションを提供するのではなく、モジュールを使用してソリューションを 4 つの部分に分割します。GitHub ソリューションリポジトリのモジュールは相互に構築され、links モジュールには最終ソリューションが含まれています。モジュールは次のヘッダーにマップされます。

これまでのストーリー

このチュートリアルは、nonrest モジュール [GitHub] (英語) でコードを構築することから始まります。

まずは、構築できる最も単純なものから始めます。実際、できるだけ単純にするために、REST の概念を省略することもできます。(後で、違いを理解するために REST を追加します。)

全体像: 会社の従業員を管理する簡単な給与計算サービスを作成します。従業員オブジェクトを (H2 インメモリ) データベースに保存し、( JPA と呼ばれるものを介して) アクセスします。次に、インターネット経由でアクセスできるようにするもの (Spring MVC レイヤーと呼ばれるもの) でそれをラップします。

次のコードは、システム内の Employee を定義します。

nonrest/src/main/java/ 給与 / 従業員 .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 インターフェースを宣言すると、次の操作が自動的に実行できます。

  • 新しい従業員を作成する

  • 既存の従業員を更新する

  • 従業員を削除する

  • 従業員を探す (1 つ、すべて、または単純または複雑なプロパティで検索)

非レスト /src/main/java/ 給与 /EmployeeRepository.java
package payroll;

import org.springframework.data.jpa.repository.JpaRepository;

interface EmployeeRepository extends JpaRepository<Employee, Long> {

}

このすべてのフリー機能を利用するには、Spring Data JPA の JpaRepository を継承するインターフェースを宣言し、ドメイン型を Employeeid 型を Long として指定するだけです。

Spring Data のリポジトリソリューションを使用すると、データストアの詳細を回避し、代わりにドメイン固有の用語を使用してほとんどの問題を解決できます。

信じられないかもしれませんが、アプリケーションを起動するにはこれで十分です。Spring Boot アプリケーションは、少なくとも public static void main エントリポイントと @SpringBootApplication アノテーションで構成されます。これにより、Spring Boot は可能な限り支援を受けることになります。

非レスト /src/main/java/ 給与 /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 の詳細については説明しません。ただし、本質的には、サーブレットコンテナーを起動してサービスを提供します。

データのないアプリケーションはあまり面白くないため、データがあることを事前にロードします。次のクラスは Spring によって自動的にロードされます。

非レスト /src/main/java/ 給与 /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 のコピーをリクエストします。

  • ランナーは 2 つのエンティティを作成し、保存します。

右クリックして 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 を使用すると、少しのコードを追加するだけで済みます。代わりに、アクションに集中できます。

非レスト /src/main/java/ 給与 /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 は、従業員が検索されましたが見つからなかったことを示すために使用される例外です。

非レスト /src/main/java/ 給与 /EmployeeNotFoundException.java
package payroll;

class EmployeeNotFoundException extends RuntimeException {

  EmployeeNotFoundException(Long id) {
    super("Could not find employee " + id);
  }
}

EmployeeNotFoundException がスローされると、Spring MVC 構成のこの追加情報を使用して、HTTP 404 エラーが発生します。

非レスト /src/main/java/ 給与 /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

このメッセージは、カスタムメッセージ Could not find employee 99 とともに HTTP 404 エラーをわかりやすく表示します。

現在コード化されているインタラクションを表示するのは難しくありません。

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" にするには不十分です。

  • `/employees/3` などのきれいな URL は REST ではありません。

  • GETPOST などを単に使用するだけでは REST ではありません。

  • すべての CRUD 操作をレイアウトすることは REST ではありません。

実際、これまでに構築したものは、このサービスとどのようにやり取りするかを知る方法がないため、RPC ( リモートプロシージャコール ) と表現した方が適切です。これを今日公開すると、すべての詳細を記載したドキュメントを作成するか、開発者ポータルをどこかにホストする必要もあります。

Roy Fielding のこのステートメントは、RESTRPC の違いの手がかりをさらに与える可能性があります。

どんな HTTP ベースのインターフェースでも REST API と呼ぶ人が増えてきて不満を感じています。今日の例は SocialSite の REST API です。これは RPC です。表示されているカップリングが多すぎて、X 評価をつけるべきだと思います。

ハイパーテキストが制約条件であるという概念を REST アーキテクチャのスタイルに明確にするためには、何が必要でしょうか? 言い換えれば、アプリケーションの状態のエンジン (つまり API) がハイパーテキストによって駆動されていない場合、RESTful であることはできず、REST API であることはできません。どこかに修正が必要な壊れたマニュアルがあるのでしょうか?

— Roy Fielding
https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven

表現にハイパーメディアを含めないことの副作用は、クライアントが API をナビゲートするために URI をハードコードする必要があることです。これは、Web 上で電子商取引が台頭する以前と同じ脆弱な性質につながります。これは、JSON 出力に少し助けが必要であることを意味します。

Spring HATEOAS

ここで、ハイパーメディア駆動型出力の作成を支援することを目的とした Spring プロジェクトである Spring HATEOAS を紹介します。サービスを RESTful にアップグレードするには、ビルドに次のコードを追加します。

ソリューションリポジトリ [GitHub] (英語) に沿って作業している場合は、次のセクションで REST モジュール [GitHub] (英語) に切り替わります。
pom.xml の dependencies セクションに Spring HATEOAS を追加する
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

この小さなライブラリは、RESTful サービスを定義し、それをクライアントが使用できる形式でレンダリングする構造を提供します。

RESTful サービスにとって重要な要素は、関連する操作へのリンク [IETF] (英語) を追加することです。コントローラーをより 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 に EmployeeController の one メソッドへのリンクを構築し、それを自己 (英語) リンクとしてフラグ付けするように要求します。

  • linkTo(methodOn(EmployeeController.class).all()).withRel("employees") は、Spring HATEOAS に集約ルート all() へのリンクを構築し、それを「従業員」と呼ぶように要求します。

「リンクを構築する」とはどういう意味でしょうか。Spring HATEOAS のコア型の 1 つは Link です。これには、URIrel (関連) が含まれます。リンクは Web を強化するものです。World Wide Web 以前は、他のドキュメントシステムで情報やリンクがレンダリングされていましたが、Web をまとめたのは、このような関連メタデータを使用してドキュメントをリンクすることでした。

Roy Fielding は、Web を成功に導いたのと同じ手法で API を構築することを奨励しており、リンクもその 1 つです。

アプリケーションを再起動して Bilbo の従業員レコードを照会すると、以前とは少し異なるレスポンスが返されます。

curl を prettier

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
従業員 1 人の RESTful 表現
{
  "id": 1,
  "name": "Bilbo Baggins",
  "role": "burglar",
  "_links": {
    "self": {
      "href": "http://localhost:8080/employees/1"
    },
    "employees": {
      "href": "http://localhost:8080/employees"
    }
  }
}

この解凍された出力には、先ほど見たデータ要素 (idnamerole) だけでなく、2 つの URI を含む _links エントリも表示されます。このドキュメント全体は、HAL (英語) を使用してフォーマットされています。

HAL は、データだけでなくハイパーメディアコントロールもエンコードできる軽量のメディア型 [IETF] (英語) であり、ナビゲートできる 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": "http://localhost:8080/employees/1"
          },
          "employees": {
            "href": "http://localhost:8080/employees"
          }
        }
      },
      {
        "id": 2,
        "name": "Frodo Baggins",
        "role": "thief",
        "_links": {
          "self": {
            "href": "http://localhost:8080/employees/2"
          },
          "employees": {
            "href": "http://localhost:8080/employees"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/employees"
    }
  }
}

従業員リソースのコレクションを提供するこの集約ルートには、最上位レベルの "self" リンクがあります。"collection" は、"_ 埋め込み " セクションにリストされています。これが、HAL がコレクションを表す方法です。

コレクションの各メンバーには、その情報と関連リンクがあります。

これらすべてのリンクを追加する意味は何でしょうか。それは、時間の経過とともに REST サービスを進化させることです。既存のリンクを維持しながら、将来的に新しいリンクを追加できます。新しいクライアントは新しいリンクを活用できますが、従来のクライアントは古いリンクで維持できます。これは、サービスが再配置および移動される場合に特に役立ちます。リンク構造が維持されている限り、クライアントは引き続き物事を見つけて対話できます。

ソリューションリポジトリ [GitHub] (英語) に沿って作業している場合は、次のセクションでは進化モジュール [GitHub] (英語) に進みます。

先ほどのコードで、単一の従業員リンクの作成が繰り返されていることに気付きましたか ? 従業員への単一のリンクを提供するコードと、集約ルートへの「従業員」リンクを作成するコードが 2 回表示されています。それが関心事であったなら、良いことです。解決策があります。

Employee オブジェクトを EntityModel<Employee> オブジェクトに変換する関数を定義する必要があります。このメソッドは自分で簡単にコーディングできますが、Spring HATEOAS の RepresentationModelAssembler インターフェースが代わりに作業を行います。新しいクラス EmployeeModelAssembler を作成します。

evolution/src/main/java/ 給与 /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() という 1 つのメソッドがあります。これは、非モデルオブジェクト(Employee)をモデルベースのオブジェクト(EntityModel<Employee>)に変換することに基づいています。

先ほどコントローラーで見たコードはすべてこのクラスに移動できます。また、Spring Framework の @Component アノテーションを適用することで、アプリの起動時にアセンブラーが自動的に作成されます。

Spring HATEOAS のすべてのモデルの抽象基本クラスは RepresentationModel です。ただし、簡単にするために、すべての POJO をモデルとして簡単にラップできるメカニズムとして EntityModel<T> を使用することをお勧めします。

このアセンブラを活用するには、コンストラクターにアセンブラを挿入して 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 の主な設計ゴールは、The Right Thing ™ をより簡単に実行できるようにすることです。このシナリオでは、ハードコードせずにサービスにハイパーメディアを追加することを意味します。

この段階で、ハイパーメディアを利用したコンテンツを実際に生成する Spring MVC REST コントローラーを作成しました。HAL に対応していないクライアントは、純粋なデータを消費しながら余分なビットを無視できます。HAL に対応するクライアントは、強化された API を操作できます。

しかし、Spring で真の RESTful サービスを構築するために必要なのはそれだけではありません。

進化する REST API

ライブラリを 1 つ追加し、コードを数行追加するだけで、アプリケーションにハイパーメディアを追加できます。ただし、サービスを RESTful にするために必要なのはそれだけではありません。REST の重要な側面は、REST がテクノロジスタックでも単一の標準でもないということです。

REST は、採用するとアプリケーションの回復力が大幅に向上するアーキテクチャ上の制約の集合です。回復力の重要な要素は、サービスをアップグレードしてもクライアントがダウンタイムに悩まされないことです。

「昔の」時代には、アップグレードはクライアントを破壊することで悪名高いものでした。つまり、サーバーのアップグレードにはクライアントの更新が必要でした。この時代には、アップグレードを行うために数時間または数分のダウンタイムが費やされ、何百万もの損失が発生する可能性があります。

一部の企業では、ダウンタイムを最小限に抑える計画を経営陣に提示する必要があります。以前は、負荷が最小であった日曜日の午前 2:00 にアップグレードすることで逃げることができました。しかし、他のタイムゾーンの国際的な顧客との今日のインターネットベースの電子商取引では、そのような戦略はそれほど効果的ではありません。

SOAP ベースのサービス (英語) CORBA ベースのサービス (英語) は非常に脆弱でした。古いクライアントと新しいクライアントの両方をサポートできるサーバーを展開するのは困難でした。REST ベースのプラクティスでは、特に Spring スタックを使用すると、はるかに簡単になります。

API の変更をサポートする

この設計上の問題を想像してください: この Employee ベースのレコードを使用してシステムを展開しました。このシステムは大ヒットし、数え切れないほどの企業にシステムを販売しました。突然、従業員の名前を firstName と lastName に分割する必要が生じました。

ああ、考えてなかったですね。

Employee クラスを開いて、単一フィールド name を firstName および lastName に置き換える前に、立ち止まって考えてください。クライアントが壊れることはありませんか ? アップグレードにはどのくらいの時間がかかりますか ? サービスにアクセスするすべてのクライアントを制御していますか ?

ダウンタイム = お金の損失。管理はその準備ができていますか?

REST に何年も先行する古い戦略があります。

データベースの列は絶対に削除しないでください。
 

データベーステーブルにはいつでも列 (フィールド) を追加できます。ただし、列を削除しないでください。RESTful サービスでも原則は同じです。

JSON 表現に新しいフィールドを追加しますが、フィールドを削除しないでください。次のようになります。

複数のクライアントをサポートする JSON
{
  "id": 1,
  "firstName": "Bilbo",
  "lastName": "Baggins",
  "role": "burglar",
  "name": "Bilbo Baggins",
  "_links": {
    "self": {
      "href": "http://localhost:8080/employees/1"
    },
    "employees": {
      "href": "http://localhost: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 は firstName および lastName に置き換えられました。

  • 古い name プロパティ getName() の「仮想」 getter が定義されています。これは、firstName フィールドと lastName フィールドを使用して値を生成します。

  • 古い name プロパティ setName() の「仮想」 setter も定義されています。これは、入力された文字列を解析し、適切なフィールドに格納します。

もちろん、API の変更は、文字列を分割したり、2 つの文字列を結合したりするのと同じくらい簡単です。しかし、ほとんどのシナリオで変換のセットを思い付くことは不可能ではないですよね ?

この新しいコンストラクターを使用するには、データベースをプリロードする方法 (LoadDatabase 内) を変更することを忘れないでください。

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

適切な対応

正しい方向へのもう 1 つのステップは、各 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 ステータスメッセージを作成するために使用されます。この型のレスポンスには通常、ロケーションレスポンスヘッダーが含まれ、モデルの自己関連リンクから派生した 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: http://localhost: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": "http://localhost:8080/employees/3"
    },
    "employees": {
      "href": "http://localhost:8080/employees"
    }
  }
}

これにより、結果のオブジェクトが HAL でレンダリングされるだけでなく ( name だけでなく、firstName と lastName も)、ロケーションヘッダーに http://localhost: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 ラッパーを使用します。これには、リソースの URI をプラグインできる便利な静的メソッド (created()) があります。必ずしも新しいリソースを「作成」するわけではないため、HTTP 201 Created が適切なセマンティクスを備えているかどうかは議論の余地があります。ただし、ロケーションレスポンスヘッダーが事前にロードされているため、これを使用します。アプリケーションを再起動し、次のコマンドを実行して結果を確認します。

$ 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: http://localhost: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": "http://localhost:8080/employees/3"
		},
		"employees": {
			"href": "http://localhost: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 クラスのフィールドに変更を加えるには、既存のコンテンツを新しい列に適切に移行できるように、データベースチームとの調整が必要です。

これで、既存のクライアントに支障をきたさずに、新しいクライアントが拡張機能を活用できるアップグレードの準備が整いました。

ネットワーク経由で送信する情報が多すぎるのではないかと心配していませんか ? 1 バイトでも重要なシステムでは、API の進化を後回しにする必要があるかもしれません。ただし、変更の影響を測定するまでは、このような時期尚早な最適化を追求すべきではありません。

ソリューションリポジトリ [GitHub] (英語) に沿って作業している場合は、次のセクションでリンクモジュール [GitHub] (英語) に切り替わります。

これまで、必要最低限のリンクを備えた進化可能な API を構築してきました。API を拡張し、クライアントにさらに優れたサービスを提供するには、アプリケーション状態のエンジンとしてハイパーメディアの概念を取り入れる必要があります。

それは何を意味するのでしょうか ? このセクションでは詳細に説明します。

ビジネスロジックは、プロセスを含むルールを必然的に構築します。このようなシステムのリスクは、多くの場合、そのようなサーバー側のロジックをクライアントに持ち込み、強い結合を構築することです。REST は、そのような接続を破壊し、そのような結合を最小化することです。

クライアントの重大な変更をトリガーせずに状態の変更に対処する方法を示すために、オーダーを満たすシステムを追加することを想像してください。

最初のステップとして、新しい Order レコードを定義します。

リンク /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 + '}';
  }
}
  • ORDER はテーブルの名前として有効ではないため、このクラスにはテーブル名を CUSTOMER_ORDER に変更する JPA @Table アノテーションが必要です。

  • description フィールドと status フィールドが含まれます。

オーダーは、顧客がオーダーを送信してから、それが履行されるかキャンセルされるまで、一連の状態遷移を経る必要があります。これは、Status と呼ばれる Java enum としてキャプチャーできます。

リンク /src/main/java/payroll/Status.java
package payroll;

enum Status {

  IN_PROGRESS, //
  COMPLETED, //
  CANCELLED
}

この enum は、Order が取り得るさまざまな状態をキャプチャーします。このチュートリアルでは、シンプルに説明します。

データベース内のオーダーとのやり取りをサポートするには、OrderRepository という対応する Spring Data リポジトリを定義する必要があります。

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;
リンク /src/main/java/ 給与 /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 の両方を注入します。

  • 最初の 2 つの Spring MVC ルートは、集約ルートと単一アイテムの Order リソースリクエストを処理します。

  • 3 番目の Spring MVC ルートは、IN_PROGRESS 状態で開始することにより、新しいオーダーの作成を処理します。

  • すべてのコントローラーメソッドは、Spring HATEOAS の RepresentationModel サブクラスの 1 つを返し、ハイパーメディア(またはそのような型のラッパー)を適切にレンダリングします。

OrderModelAssembler を構築する前に、何が必要か話し合う必要があります。Status.IN_PROGRESSStatus.COMPLETEDStatus.CANCELLED 間の状態の流れをモデル化しています。このようなデータをクライアントに提供する際には、このペイロードに基づいてクライアントが何をできるかを決定できるようにするのが自然なことです。

しかし、間違っているでしょう。

このフローで新しい状態を導入するとどうなるでしょうか? UI 上のさまざまなボタンの配置は、おそらく誤っているでしょう。

おそらく国際的なサポートをコーディングし、各状態のロケール固有のテキストを表示している間に、各状態の名前を変更した場合はどうなるでしょうか? それはほとんどすべてのクライアントを壊すでしょう。

アプリケーション状態のエンジンとして HATEOAS または Hypermedia を入力します。クライアントがペイロードを解析する代わりに、有効なアクションを通知するリンクを提供します。状態ベースのアクションをデータのペイロードから切り離します。つまり、CANCELCOMPLETE が有効なアクションである場合、リンクのリストに動的に追加する必要があります。クライアントは、リンクが存在する場合にのみ、ユーザーに対応するボタンを表示する必要があります。

これにより、クライアントはそのようなアクションが有効であるかどうかを知る必要がなくなり、状態遷移のロジックに関してサーバーとそのクライアントが同期しなくなるリスクが軽減されます。

Spring HATEOAS RepresentationModelAssembler コンポーネントの概念をすでに採用しているため、OrderModelAssembler は、このビジネスルールのロジックをキャプチャーするのに最適な場所です。

リンク /src/main/java/ 給与 /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) (まだ定義されていません) への 2 つの条件付きリンクも含まれます。これらのリンクは、オーダーのステータスが Status.IN_PROGRESS の場合にのみ表示されます。

クライアントが、単純な JSON のデータを読み取るのではなく、HAL とリンクを読み取る機能を採用できれば、オーダーシステムに関するドメイン知識の必要性をなくすことができます。これにより、クライアントとサーバー間の結合が自然に減ります。また、プロセス中にクライアントを中断することなく、オーダー履行のフローを調整することも可能になります。

オーダー処理を完了するには、cancel 操作の OrderController に以下を追加します。

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 [IETF] (英語)  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 を更新して、以前ロードしていた 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 http://localhost:8080/orders | json_pp
詳細
{
  "_embedded": {
    "orderList": [
      {
        "id": 3,
        "description": "MacBook Pro",
        "status": "COMPLETED",
        "_links": {
          "self": {
            "href": "http://localhost:8080/orders/3"
          },
          "orders": {
            "href": "http://localhost:8080/orders"
          }
        }
      },
      {
        "id": 4,
        "description": "iPhone",
        "status": "IN_PROGRESS",
        "_links": {
          "self": {
            "href": "http://localhost:8080/orders/4"
          },
          "orders": {
            "href": "http://localhost:8080/orders"
          },
          "cancel": {
            "href": "http://localhost:8080/orders/4/cancel"
          },
          "complete": {
            "href": "http://localhost:8080/orders/4/complete"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/orders"
    }
  }
}

この HAL ドキュメントは、現在の状態に基づいて、オーダーごとに異なるリンクをすぐに表示します。

  • 最初の順序である COMPLETED にはナビゲーションリンクのみが含まれます。状態遷移リンクは表示されません。

  • 2 番目のオーダーである IN_PROGRESS には、完了リンクに加えてキャンセルリンクもあります。

オーダーをキャンセルしてみます:

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

このレスポンスには、成功したことを示す HTTP 200 ステータスコードが表示されます。レスポンス HAL ドキュメントには、そのオーダーが新しい状態 (CANCELLED) で表示されます。また、状態を変更するリンクはなくなりました。

同じ操作をもう一度試してください。

$ curl -v -X DELETE http://localhost:8080/orders/4/cancel | 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 と、XML ではなく JSON を返すことだけではありません。

代わりに、次の戦術は、あなたのサービスがコントロールするかもしれない、またはコントロールしないかもしれない既存のクライアントを壊す可能性を低くできます:

  • 古いフィールドを削除しないでください。代わりに、サポートしてください。

  • 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 による次のビデオを参照してください。

新しいガイドを作成したり、既存のガイドに貢献したいですか? 投稿ガイドラインを参照してください [GitHub] (英語)

すべてのガイドは、コード用の ASLv2 ライセンス、およびドキュメント用の Attribution、NoDerivatives creative commons ライセンス (英語) でリリースされています。

コードを入手する