REST は、簡単に構築して使用できるため、Web 上で Web サービスを構築するための事実上の標準になりました。

REST がマイクロサービスの世界にどのように適合するかについては、はるかに大きな議論がありますが、このチュートリアルでは、RESTful サービスの構築について見ていきましょう。

なぜ REST なのでしょうか? REST は、そのアーキテクチャ、利点、その他すべてを含む Web の指針を包含しています。これは、その作者である Roy Fielding が、Web の動作を規定する 10 以上の仕様に関わっていたことを考えると、驚くべきことではありません。

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

  • 適切なアクション (GETPOSTPUTDELETE, …​)

  • キャッシング

  • リダイレクトと転送

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

これらはすべて、回復力のあるサービスを構築するための重要な要素です。しかし、それだけではありません。Web は多くの小さな仕様で構築されているため、標準化の戦いにとらわれずに簡単に進化させることができました。

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

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

  • 下位互換性のある API

  • 進化する API

  • 拡張可能なサービス

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

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

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

入門

このチュートリアルを進めるときは、Spring Boot を使用します。Spring Initializr (英語) に移動し、次の依存関係をプロジェクトに追加します。

  • Web

  • JPA

  • H2

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

Spring Boot はどんな IDE でも動作します。Eclipse、IntelliJ IDEA、Netbeans などが使えます。Eclipse Pleiades All in One (STS, Lombok 付属) または Eclipse Spring Tool Suite (英語) はオープンソースの Eclipse ベースの IDE ディストリビューションで、Java EE ディストリビューションの Eclipse のスーパーセットを提供し、Spring アプリケーションでの作業をより簡単にする機能が含まれています。これは決して必須ではありません。しかし、キーストロークにさらなるが必要な場合は、このセットを検討してみてください。以下は、STS と Spring Boot を使い始める方法を示すビデオです。これは、ツールに慣れるための一般的な導入です。

これまでのストーリー ...

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

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

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

nonrest/src/main/java/payroll/Employee.java
package payroll;

import java.util.Objects;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.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 アノテーションです。

  • idname、および role は、Employee ドメインオブジェクト (英語) の属性です。id は、それが主キーであり、JPA プロバイダーによって自動的に入力されることを示すために、より多くの JPA アノテーションでマークされています。

  • 新しいインスタンスを作成する必要があるが、まだ ID を持っていない場合、カスタムコンストラクターが作成されます。

このドメインオブジェクト定義を使用して、面倒なデータベースの相互作用を処理するために Spring Data JPA に目を向けることができます。

Spring Data JPA リポジトリは、バックエンドデータストアに対するレコードの作成、読み取り、更新、削除をサポートするメソッドとのインターフェースです。一部のリポジトリは、必要に応じて、データのページングと並べ替えもサポートしています。Spring Data は、インターフェースのメソッドの命名に見られる規則に基づいて実装を合成します。

JPA 以外にも複数のリポジトリ実装があります。Spring Data MongoDB、Spring Data GemFire、Spring Data Cassandra などを使用できます。このチュートリアルでは、JPA を使用します。

Spring を使用すると、データに簡単にアクセスできます。次の EmployeeRepository インターフェースを宣言するだけで、自動的に次のことが可能になります。

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

  • 既存のものを更新する

  • 従業員を削除する

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

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

それでも、データのないアプリケーションはあまり面白くないため、プリロードしましょう。次のクラスは、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 のコピーをリクエストします。

  • これを使用して、2 つのエンティティを作成して保存します。

右クリックして PayRollApplication実行すると、これが得られます:

データのプリロードを示すコンソール出力の断片
...
2018-08-09 11:36:26.169  INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=1, name=Bilbo Baggins, role=burglar)
2018-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(() -> {
        newEmployee.setId(id);
        return repository.save(newEmployee);
      });
  }

  @DeleteMapping("/employees/{id}")
  void deleteEmployee(@PathVariable Long id) {
    repository.deleteById(id);
  }
}
  • @RestController は、各メソッドによって返されるデータが、テンプレートをレンダリングする代わりに、レスポンス本文に直接書き込まれることを示します。

  • EmployeeRepository は、コンストラクターによってコントローラーに注入されます。

  • 各操作のルートがあります(HTTP GETPOSTPUT に対応する @GetMapping@PostMapping@PutMapping と @DeleteMapping、および DELETE 呼び出し)。(NOTE: 各メソッドを読んで、それらが何をするのかを理解することは有用です。)

  • 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 構成のこの追加の tidbit を使用して HTTP 404 をレンダリングします。

nonrest/src/main/java/payroll/EmployeeNotFoundAdvice.java
package payroll;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

@ControllerAdvice
class EmployeeNotFoundAdvice {

  @ResponseBody
  @ExceptionHandler(EmployeeNotFoundException.class)
  @ResponseStatus(HttpStatus.NOT_FOUND)
  String employeeNotFoundHandler(EmployeeNotFoundException ex) {
    return ex.getMessage();
  }
}
  • @ResponseBody は、このアドバイスがレスポンス本文に直接レンダリングされることを通知します。

  • @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 2018 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 2018 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 ではありません。

実際、これまでに作成したものは、RPCRemote Procedure Call)としてより適切に記述されています。これは、このサービスとやり取りする方法を知る方法がないためです。これを今日公開した場合は、ドキュメントを作成するか、すべての詳細を含む開発者のポータルをホストする必要があります。

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

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

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

pom.xml の dependencies セクションに SpringHATEOAS を追加する
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

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

RESTful サービスの重要な要素は、関連する操作へのリンク (英語) を追加することです。コントローラーをより RESTful にするには、次のようなリンクを追加します。

単一アイテムのリソースを取得する
@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"));
}

このチュートリアルは 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 の従業員レコードをクエリすると、以前とは少し異なるレスポンスが返されます。

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

この展開された出力には、前に見たデータ要素(idname および role)だけでなく、2 つの URI を含む _links エントリも表示されます。このドキュメント全体は、HAL (英語) を使用してフォーマットされます。

HAL は軽量で MEDIATYPE (英語) 彼らに向かって移動することができます。API の他の部分に消費者に警告するだけではないデータだけでなく、ハイパーメディアコントロールをコードすることができます。この場合、集約ルート (英語) に戻るリンクとともに「自己」リンク(コード内の this ステートメントのようなもの)があります。

集約ルートもより RESTful にするために、トップレベルのリンクを含め、RESTful コンポーネントも含める必要があります。

これを回する

集約ルートの取得
@GetMapping("/employees")
List<Employee> all() {
  return repository.findAll();
}

これに

集約ルート resource の取得
@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<> は別の SpringHATEOAS コンテナーです。これは、以前の EntityModel<> のような単一のリソースエンティティではなく、リソースのコレクションをカプセル化することを目的としています。CollectionModel<> でも、リンクを含めることができます。

その最初の声明をすり抜けさせないでください。「コレクションのカプセル化」とはどういう意味でしょうか?従業員のコレクション?

そうでもない。

REST について話しているため、従業員のリソースのコレクションをカプセル化する必要があります。

すべての従業員を取得し、EntityModel<Employee> オブジェクトのリストに変換するのはそのためです。(Java 8 Streams に感謝します!)

アプリケーションを再起動して集約ルートをフェッチすると、現在の状態を確認できます。

従業員リソースのコレクションの 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" は、"_embedded" セクションにリストされています。これが HAL がコレクションを表す方法です。

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

これらすべてのリンクを追加する意味は何ですか?これにより、REST サービスを時間の経過とともに進化させることができます。既存のリンクは維持できますが、将来は新しいリンクを追加できます。新しいクライアントは新しいリンクを利用できますが、レガシークライアントは古いリンクを維持できます。これは、サービスが再配置されて移動する場合に特に役立ちます。リンク構造が維持されている限り、クライアントは物事を見つけて操作することができます。

以前のコードで、単一の従業員のリンク作成の繰り返しに気づきましたか?従業員への単一のリンクを提供し、集約ルートへの「従業員」リンクを作成するコードが 2 回表示されました。それがあなたの懸念を引き起こしたなら、いいです!解決策があります。

簡単に言うと、Employee オブジェクトを EntityModel<Employee> オブジェクトに変換する関数を定義する必要があります。このメソッドは自分で簡単にコーディングできますが、Spring HATEOAS の RepresentationModelAssembler インターフェースを実装することで、将来的にはメリットがあります。

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() という 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;
  }

  ...

}

ここから、そのアセンブラを単一アイテムの従業員メソッドで使用できます。

アセンブラーを使用して単一アイテムのリソースを取得する
@GetMapping("/employees/{id}")
EntityModel<Employee> one(@PathVariable Long id) {

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

  return assembler.toModel(employee);
}

このコードはほとんど同じですが、ここで EntityModel<Employee> インスタンスを作成する代わりに、アセンブラーに委譲する点が異なります。多分それはあまり見えません。

集約ルートコントローラーメソッドで同じことを適用すると、より印象的です。

アセンブラーを使用して集約ルートリソースを取得する
@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 8 メソッドリファレンスのおかげで、非常に簡単にプラグインしてコントローラーを簡素化できます。

Spring HATEOAS の主要な設計ゴールは、TheRightThing ™をより簡単に実行できるようにすることです。このシナリオでは、ハードコードせずにハイパーメディアをサービスに追加します。

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

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

進化する REST API

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

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

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

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

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

API のサポートの変更

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

え〜と ... 考えていませんでした。

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

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

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

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

データベーステーブルにはいつでも列(フィールド)を追加できます。しかし、1 つを奪わないでください。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"
    }
  }
}

この形式が firstNamelastName および name をどのように示しているかに注目してください。情報の複製を行いますが、目的は古いクライアントと新しいクライアントの両方をサポートすることです。つまり、クライアントを同時にアップグレードする必要なく、サーバーをアップグレードできます。ダウンタイムを短縮する良い動き。

また、この情報を「古い方法」と「新しい方法」の両方で表示するだけでなく、受信データを両方の方法で処理する必要があります。

どうやって? 簡単です。次のようにします。

「古い」クライアントと「新しい」クライアントの両方を処理する従業員レコード
package payroll;

import java.util.Objects;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.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 プロパティの「仮想」setter も定義されています。setName()。受信文字列を解析し、適切なフィールドに保存します。

もちろん、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 メソッドがそれぞれ適切なレスポンスを返すようにすることです。次のように POST メソッドを更新します。

「古い」および「新しい」クライアントリクエストを処理する 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);
}
  • 新しい 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"}'

出力は次のとおりです。

> 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 2018 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 の両方)でレンダリングされるだけでなく、Location ヘッダーに http://localhost:8080/employees/3 が取り込まれます。ハイパーメディアを使用するクライアントは、この新しいリソースに「サーフィン」して、それと対話することを選択できます。

PUT コントローラーメソッドには、同様の調整が必要です。

さまざまなクライアントの 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(() -> {
        newEmployee.setId(id);
        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 を取得できます。このメソッドは、toUri メソッドで URI に変換する必要がある Link を返します。

200 OK よりも詳細な HTTP レスポンスコードが必要なため、Spring MVC の ResponseEntity ラッパーを使用します。リソースの URI をプラグインできる便利な静的メソッド created() があります。必ずしも新しいリソースを「作成」しているわけではないため、HTTP 201 Created が正しいセマンティクスを備えているかどうかは議論の余地があります。ただし、Location レスポンスヘッダーがプリロードされているため、実行してください。

$ curl -v -X PUT localhost:8080/employees/3 -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}'

* 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 2018 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 が返送されました。最後に、DELETE 操作を適切に更新します。

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 2018 21:30:26 GMT
Employee クラスのフィールドに変更を加えるには、既存のコンテンツを新しい列に適切に移行できるように、データベースチームとの調整が必要です。

これで、既存のクライアントに影響を与えないアップグレードの準備が整いましたが、新しいクライアントは拡張機能を利用できます!

ネットで情報を送り過ぎるのは心配ですか? すべてのバイトが重要なシステムでは、API の進化は後回しにする必要があるかもしれません。ただし、測定するまでは、このような時期尚早な最適化は行わないでください。

これまで、ベアボーンリンクを使用して進化可能な API を構築してきました。API を成長させ、クライアントにより良いサービスを提供するには、アプリケーション状態のエンジンとしてハイパーメディアの概念を採用する必要があります。

どういう意味でしょうか?このセクションでは、詳細を探ります。

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

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

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

links/src/main/java/payroll/Order.java
package payroll;

import java.util.Objects;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.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 はテーブルの有効な名前ではないため、クラスには JPA @Table アノテーションが必要で、テーブルの名前を CUSTOMER_ORDER に変更します。

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

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

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

enum Status {

  IN_PROGRESS, //
  COMPLETED, //
  CANCELLED
}

この enum は、Order が占有できるさまざまな状態をキャプチャーします。このチュートリアルでは、シンプルにしましょう。

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

Spring Data JPA の JpaRepository ベースインターフェース
interface OrderRepository extends JpaRepository<Order, Long> {
}

これを配置すると、基本的な OrderController を定義できるようになります。

links/src/main/java/payroll/OrderController.java
@RestController
class OrderController {

  private final OrderRepository orderRepository;
  private final OrderModelAssembler assembler;

  OrderController(OrderRepository orderRepository, OrderModelAssembler assembler) {

    this.orderRepository = orderRepository;
    this.assembler = assembler;
  }

  @GetMapping("/orders")
  CollectionModel<EntityModel<Order>> all() {

    List<EntityModel<Order>> orders = orderRepository.findAll().stream() //
        .map(assembler::toModel) //
        .collect(Collectors.toList());

    return 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.COMPLETED と Status.CANCELLED の間の状態の流れをモデリングしています。このようなデータをクライアントに提供する際の自然なことは、クライアントがこのペイロードに基づいて何ができるかを決定できるようにすることです。

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

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

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

Engine of Application State として HATEOAS または Hypermedia を入力します。クライアントがペイロードを解析する代わりに、リンクを与えて有効なアクションを通知します。データのペイロードから状態ベースのアクションを分離します。つまり、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) (まだ定義されていない)への 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 (英語) 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

{
  "_embedded": {
    "orderList": [
      {
        "id": 3,
        "description": "MacBook Pro",
        "status": "COMPLETED",
        "_links": {
          "self": {
            "href": "http://localhost:8080/orders/3"
          },
          "orders": {
            "href": "http://localhost:8080/orders"
          }
        }
      },
      {
        "id": 4,
        "description": "iPhone",
        "status": "IN_PROGRESS",
        "_links": {
          "self": {
            "href": "http://localhost:8080/orders/4"
          },
          "orders": {
            "href": "http://localhost:8080/orders"
          },
          "cancel": {
            "href": "http://localhost:8080/orders/4/cancel"
          },
          "complete": {
            "href": "http://localhost:8080/orders/4/complete"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/orders"
    }
  }
}

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

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

  • IN_PROGRESS である 2 番目の順序には、complete リンクだけでなく cancel リンクも追加されています。

オーダーをキャンセルしてみてください:

$ curl -v -X DELETE http://localhost:8080/orders/4/cancel

> DELETE /orders/4/cancel HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Mon, 27 Aug 2018 15:02:10 GMT
<
{
  "id": 4,
  "description": "iPhone",
  "status": "CANCELLED",
  "_links": {
    "self": {
      "href": "http://localhost:8080/orders/4"
    },
    "orders": {
      "href": "http://localhost:8080/orders"
    }
  }
}

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

同じ操作をもう一度試してみると ...

$ curl -v -X DELETE http://localhost:8080/orders/4/cancel

* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> DELETE /orders/4/cancel HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 405
< Content-Type: application/problem+json
< Transfer-Encoding: chunked
< Date: Mon, 27 Aug 2018 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 レスポンスオブジェクトは、すでに「キャンセル済み」ステータスのオーダーを「キャンセル」できないことを明確に示しています。

さらに、同じオーダーを完了しようとしても失敗します。

$ curl -v -X PUT localhost:8080/orders/4/complete

* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> PUT /orders/4/complete HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 405
< Content-Type: application/problem+json
< Transfer-Encoding: chunked
< Date: Mon, 27 Aug 2018 15:05:40 GMT
<
{
  "title": "Method not allowed",
  "detail": "You can't complete an order that is in the CANCELLED status"
}

これらすべてが整っていれば、オーダー処理サービスは、利用可能な操作を条件付きで表示できます。また、無効な操作から保護します。

ハイパーメディアとリンクのプロトコルを活用することにより、クライアントをより強固に構築でき、データの変更だけでクライアントが壊れる可能性が低くなります。また、Spring HATEOAS を使用すると、クライアントに提供するために必要なハイパーメディアを簡単に作成できます。

要約

このチュートリアル全体を通して、RESTAPI を構築するためのさまざまな戦術に取り組んできました。結局のところ、REST は単なる URI ではなく、XML ではなく JSON を返します。

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

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

  • 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 チームメイトのオリバージールケによる次のビデオを参照してください。

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

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