このチュートリアルでは、Spring Data REST とその強力なバックエンド機能を使用するアプリのコレクションと、React の洗練された機能を組み合わせて、わかりやすい UI を構築します。
Spring Data REST: YouTube (英語) は、ハイパーメディアを使用したリポジトリをすばやく構築する方法を提供します。
React (英語) は、JavaScript での効率的で高速で使いやすいビューに対する Facebook のソリューションです。
パート 1 — 基本的な機能
Spring コミュニティへようこそ。
このセクションでは、最低限の Spring Data REST アプリケーションを迅速に起動して実行する方法を示します。次に、Facebook の React.js ツールセットを使用して、その上にシンプルな UI を構築する方法を示します。
ステップ 0 — 環境のセットアップ
このリポジトリからコード: GitHub (英語) を自由に入手して、フォローしてください。
自分でやりたい場合は、https://start.spring.io (英語) にアクセスして、次の依存関係を選択してください。
Rest リポジトリ
Thymeleaf
JPA
H2
このデモでは、Java 8、Maven プロジェクト、および Spring Boot の最新の安定版リリースを使用しています。また、ES6 (英語) でコーディングされた React.js も使用します。これにより、クリーンで空のプロジェクトが作成されます。そこから、このセクションに明示的に示されているさまざまなファイルを追加したり、前述のリポジトリから借用したりできます。
はじめに...
最初はデータがあるだけで十分でした。しかしその後、人々はさまざまな手段でデータにアクセスしたいと考えました。何年もの間、人々は多くの MVC コントローラーを組み合わせ、多くは Spring の強力な REST サポートを使用していました。しかし、繰り返し行うには多くの時間がかかります。
Spring Data REST は、いくつかの仮定が行われた場合にこの問題がどれほど簡単になるかを示しています。
開発者は、リポジトリモデルをサポートする Spring Data プロジェクトを使用します。
システムは、HTTP 動詞、標準化されたメディアタイプ、IANA 承認済みのリンク名 (英語) など、広く受け入れられている業界標準プロトコルを使用します。
ドメインを宣言する
ドメインオブジェクトは、Spring Data REST ベースのアプリケーションの基盤を形成します。このセクションでは、会社の従業員を追跡するアプリケーションを作成します。次のように、データ型を作成して開始します。
@Entity (1)
public class Employee {
private @Id @GeneratedValue Long id; (2)
private String firstName;
private String lastName;
private String description;
private Employee() {}
public Employee(String firstName, String lastName, String description) {
this.firstName = firstName;
this.lastName = lastName;
this.description = description;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Employee employee = (Employee) o;
return Objects.equals(id, employee.id) &&
Objects.equals(firstName, employee.firstName) &&
Objects.equals(lastName, employee.lastName) &&
Objects.equals(description, employee.description);
}
@Override
public int hashCode() {
return Objects.hash(id, firstName, lastName, description);
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
@Override
public String toString() {
return "Employee{" +
"id=" + id +
", firstName='" + firstName + '\'' +
", lastName='" + lastName + '\'' +
", description='" + description + '\'' +
'}';
}
}
1 | @Entity は、リレーショナルテーブルに格納するためのクラス全体を示す JPA アノテーションです。 |
2 | @Id および @GeneratedValue は、主キーを記録するための JPA アノテーションであり、必要に応じて自動的に生成されます。 |
このエンティティは、従業員情報を追跡するために使用されます。この場合、名前と職務説明です。
Spring Data REST は JPA に限定されません。このチュートリアルでは表示されませんが、多くの NoSQL データストアをサポートしています。詳細については、Spring Data REST API の自動生成 (Neo4j)、Spring Data REST API の自動生成 (JPA)、Spring Data REST API の自動生成 (MongoDB) を参照してください。 |
リポジトリの定義
Spring Data REST アプリケーションのもう 1 つの重要な部分は、次のような対応するリポジトリ定義です。
public interface EmployeeRepository extends CrudRepository<Employee, Long> { (1)
}
1 | リポジトリは Spring Data Commons の CrudRepository を継承し、ドメインオブジェクトの型とそのプライマリキーをプラグインします。 |
必要なのはそれだけです。実際、インターフェースがトップレベルで表示されている場合は、インターフェースにアノテーションを付ける必要もありません。IDE を使用して CrudRepository
を開くと、事前定義されたメソッドのコレクションが見つかります。
必要に応じて、独自のリポジトリを定義できます。Spring Data REST も同様にサポートしています。 |
デモのプリロード
このアプリケーションを使用するには、次のようにデータを事前にロードする必要があります。
@Component (1)
public class DatabaseLoader implements CommandLineRunner { (2)
private final EmployeeRepository repository;
@Autowired (3)
public DatabaseLoader(EmployeeRepository repository) {
this.repository = repository;
}
@Override
public void run(String... strings) throws Exception { (4)
this.repository.save(new Employee("Frodo", "Baggins", "ring bearer"));
}
}
1 | このクラスには Spring の @Component アノテーションが付いているため、@SpringBootApplication によって自動的に取得されます。 |
2 | Spring Boot の CommandLineRunner を実装するため、すべての Bean が作成および登録された後に実行されます。 |
3 | コンストラクター注入とオートワイヤーを使用して、Spring Data の自動作成された EmployeeRepository を取得します。 |
4 | run() メソッドはコマンドライン引数で呼び出され、データをロードします。 |
Spring Data の最大かつ最も強力な機能の 1 つは、JPA クエリを作成できることです。これにより、開発時間が短縮されるだけでなく、バグやエラーのリスクも軽減されます。Spring Data は、リポジトリクラス内のメソッドの名前を調べ、保存、削除、検索など、必要な操作を見つけ出します。
これが、空のインターフェースを作成し、すでに構築されている保存、検索、削除操作を継承する方法です。
ルート URI の調整
デフォルトでは、Spring Data REST は /
でリンクのルートコレクションをホストします。そのパスで Web UI をホストするため、次のようにルート URI を変更する必要があります。
spring.data.rest.base-path=/api
バックエンドの起動
完全に機能する REST API を作成するために必要な最後のステップは、次のように Spring Boot を使用して public static void main
メソッドを記述することです。
@SpringBootApplication
public class ReactAndSpringDataRestApplication {
public static void main(String[] args) {
SpringApplication.run(ReactAndSpringDataRestApplication.class, args);
}
}
前のクラスと Maven ビルドファイルが https://start.spring.io (英語) から生成されたと仮定すると、IDE 内でその main()
メソッドを実行するか、コマンドラインで ./mvnw spring-boot:run
を入力することで起動できます。(Windows ユーザーの場合は mvnw.bat
)。
Spring Boot の最新情報を把握していない場合は、Josh Long の紹介プレゼンテーション: YouTube (英語) を参照してください。 |
REST サービスのツアー
アプリケーションが実行されている状態で、 cURL (英語) (またはその他の任意のツール)を使用して、コマンドラインで確認できます。次のコマンド(出力とともに表示)は、アプリケーション内のリンクを一覧表示します。
$ curl localhost:8080/api { "_links" : { "employees" : { "href" : "http://localhost:8080/api/employees" }, "profile" : { "href" : "http://localhost:8080/api/profile" } } }
ルートノードに ping を実行すると、HAL 形式の JSON ドキュメント (英語) にラップされたリンクのコレクションが返されます。
_links
は利用可能なリンクのコレクションです。employees
は、EmployeeRepository
インターフェースによって定義された従業員オブジェクトの集約ルートを指します。profile
は IANA 標準の関係であり、サービス全体に関する検出可能なメタデータを指します。これについては後のセクションで説明します。
employees
リンクをナビゲートすることにより、このサービスをさらに掘り下げることができます。次のコマンド(出力とともに表示)はこれを行います。
$ curl localhost:8080/api/employees { "_embedded" : { "employees" : [ { "firstName" : "Frodo", "lastName" : "Baggins", "description" : "ring bearer", "_links" : { "self" : { "href" : "http://localhost:8080/api/employees/1" } } } ] } }
この段階では、従業員のコレクション全体を表示しています。
前に事前にロードしたデータとともに、self
リンクを持つ _links
属性が含まれています。これは、その特定の従業員の正規リンクです。正規とは何ですか? 「文脈の自由」を意味します。例: /api/orders/1/processor
を介して同じユーザーを取得でき、従業員は特定のオーダーの処理に関連付けられています。ここでは、他のエンティティとの関連はありません。
リンクは REST の重要な側面です。これらは、関連アイテムにナビゲートするためのパワーを提供します。これにより、変更が発生するたびに書き直さなくても、他の関係者が API をナビゲートできるようになります。クライアントの更新は、クライアントがリソースへのパスをハードコードする場合の一般的な問題です。リソースを再構築すると、コードに大きな混乱が生じる可能性があります。リンクが使用され、ナビゲーションルートが維持される場合、そのような調整を行うことは簡単かつ柔軟になります。 |
必要に応じて、その従業員を表示することもできます。次のコマンド(出力とともに表示)はこれを行います。
$ curl localhost:8080/api/employees/1 { "firstName" : "Frodo", "lastName" : "Baggins", "description" : "ring bearer", "_links" : { "self" : { "href" : "http://localhost:8080/api/employees/1" } } }
ドメインオブジェクトしかないため、_embedded
ラッパーは必要ないことを除いて、ここではほとんど変更はありません。
それはすべてうまくいきますが、おそらく新しいエントリを作成するのに苦労しています。次のコマンド(出力とともに表示)はこれを行います。
$ curl -X POST localhost:8080/api/employees -d "{\"firstName\": \"Bilbo\", \"lastName\": \"Baggins\", \"description\": \"burglar\"}" -H "Content-Type:application/json" { "firstName" : "Bilbo", "lastName" : "Baggins", "description" : "burglar", "_links" : { "self" : { "href" : "http://localhost:8080/api/employees/2" } } }
この関連ガイドに示されているように、PUT
、PATCH
、DELETE
を使用することもできます。ただし、ここでは、洗練された UI の構築に移ります。
カスタム UI コントローラーのセットアップ
Spring Boot を使用すると、カスタム Web ページを簡単に立ち上げることができます。まず、次のように Spring MVC コントローラーが必要です。
@Controller (1)
public class HomeController {
@RequestMapping(value = "/") (2)
public String index() {
return "index"; (3)
}
}
1 | @Controller は、このクラスを Spring MVC コントローラーとしてマークします。 |
2 | @RequestMapping は、/ ルートをサポートするために index() メソッドにフラグを立てます。 |
3 | テンプレートの名前として index を返します。Spring Boot の自動構成ビューリゾルバーは、src/main/resources/templates/index.html にマッピングします。 |
HTML テンプレートの定義
Thymeleaf を使用していますが、その機能の多くは実際には使用しません。開始するには、次のようにインデックスページが必要です。
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head lang="en">
<meta charset="UTF-8"/>
<title>ReactJS + Spring Data REST</title>
<link rel="stylesheet" href="/main.css" />
</head>
<body>
<div id="react"></div>
<script src="built/bundle.js"></script>
</body>
</html>
このテンプレートの重要な部分は、主要な <div id="react"></div>
コンポーネントです。ここで、レンダリングされた出力をプラグインするように React に指示します。
また、その bundle.js
ファイルがどこから来たのか疑問に思うかもしれません。構築方法は次のセクションで説明します。
このチュートリアルでは main.css を示していませんが、上にリンクされていることがわかります。CSS に関しては、Spring Boot は src/main/resources/static で見つかったものを自動的に提供します。そこに独自の main.css ファイルを配置します。私たちの焦点は CSS ではなく React と Spring Data REST であるため、チュートリアルには示されていません。 |
JavaScript モジュールのロード
このセクションでは、JavaScript 断片を軌道に乗せるための必要最低限の情報が含まれています。JavaScripts のコマンドラインツールをすべてインストールすることは可能ですが、少なくとも今はその必要はありません。代わりに必要なのは、pom.xml
のビルドファイルに以下を追加することです。
frontend-maven-plugin
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
</plugin>
この小さなプラグインは複数の手順を実行します。
install-node-and-npm
コマンドは、node.js とそのパッケージ管理ツールnpm
をtarget
フォルダーにインストールします。(これにより、バイナリがソース管理下に置かれなくなり、clean
でクリーンアウトできるようになります)。npm
コマンドは、指定された引数(install
)で npm バイナリを実行します。これにより、package.json
で定義されたモジュールがインストールされます。webpack
コマンドは webpack バイナリを実行し、webpack.config.js
に基づいてすべての JavaScript コードをコンパイルします。
これらの手順は順番に実行され、本質的に node.js をインストールし、JavaScript モジュールをダウンロードし、JS 断片を構築します。
どのモジュールがインストールされていますか? JavaScript 開発者は通常、npm
を使用して、次のような package.json
ファイルを作成します。
{
"name": "spring-data-rest-and-reactjs",
"version": "0.1.0",
"description": "Demo of ReactJS + Spring Data REST",
"repository": {
"type": "git",
"url": "[email protected] (英語) :spring-guides/tut-react-and-spring-data-rest.git"
},
"keywords": [
"rest",
"hateoas",
"spring",
"data",
"react"
],
"author": "Greg L. Turnquist",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/spring-guides/tut-react-and-spring-data-rest/issues"
},
"homepage": "https://github.com/spring-guides/tut-react-and-spring-data-rest",
"dependencies": {
"react": "^16.5.2",
"react-dom": "^16.5.2",
"rest": "^1.3.1"
},
"scripts": {
"watch": "webpack --watch -d --output ./target/classes/static/built/bundle.js"
},
"devDependencies": {
"@babel/core": "^7.1.0",
"@babel/preset-env": "^7.1.0",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.2",
"webpack": "^4.19.1",
"webpack-cli": "^3.1.0"
}
}
主な依存関係は次のとおりです。
react.js: このチュートリアルで使用されるツールキット
rest.js: REST 呼び出しを行うために使用される CujoJS ツールキット
webpack: JavaScript コンポーネントを単一のロード可能なバンドルにコンパイルするために使用されるツールキット
babel: ES6 を使用して JavaScript コードを記述し、ES5 にコンパイルしてブラウザーで実行するには
後で使用する JavaScript コードをビルドするには、次のように webpack (英語) のビルドファイルを定義する必要があります。
var path = require('path');
module.exports = {
entry: './src/main/js/app.js',
devtool: 'sourcemaps',
cache: true,
mode: 'development',
output: {
path: __dirname,
filename: './src/main/resources/static/built/bundle.js'
},
module: {
rules: [
{
test: path.join(__dirname, '.'),
exclude: /(node_modules)/,
use: [{
loader: 'babel-loader',
options: {
presets: ["@babel/preset-env", "@babel/preset-react"]
}
}]
}
]
}
};
この webpack 構成ファイル:
./src/main/js/app.js
としてエントリポイントを定義します。要するに、app.js
(まもなく作成するモジュール) は、JavaScript アプリケーションのpublic static void main()
のようなものです。webpack
は、最終的なバンドルがブラウザーに読み込まれたときに、何を起動するかを知るために、これを知っている必要があります。ブラウザーで JS コードをデバッグしているときに、元のソースコードにリンクして戻すことができるように、ソースマップを作成します。
すべての JavaScript 断片を
./src/main/resources/static/built/bundle.js
にコンパイルします。これは、Spring Boot uber JAR と同等の JavaScript です。すべてのカスタムコードとrequire()
呼び出しによってプルされたモジュールは、このファイルに詰め込まれます。ES6 React コードを標準ブラウザーで実行できる形式にコンパイルするために、
es2015
とreact
の両方のプリセットを使用して、Babel エンジンに接続します。
これらの各 JavaScript ツールの動作方法の詳細については、対応するリファレンスドキュメントを参照してください。
JavaScript の変更を自動的に確認したいですか? npm run-script watch を実行して、webpack を監視モードにします。ソースを編集すると、bundle.js が再生成されます。 |
これらすべての準備が整ったら、React 断片に焦点を合わせることができます。React 断片は、DOM のロード後に取得されます。次のように、パーツに分割されます。
webpack を使用して、必要なモジュールをフェッチします。
const React = require('react'); (1)
const ReactDOM = require('react-dom'); (2)
const client = require('./client'); (3)
1 | React は、このアプリのビルドに使用される Facebook のメインライブラリの 1 つです。 |
2 | ReactDOM は、React の DOM およびサーバーレンダラーへのエントリポイントとして機能するパッケージです。汎用の React パッケージと組み合わせることを目的としています。 |
3 | client は、rest.js を構成して、HAL、URI テンプレートなどのサポートを含めるカスタムコードです。また、デフォルトの Accept リクエストヘッダーを application/hal+json に設定します。ここでコードを読む: GitHub (英語) ことができます。 |
REST 呼び出しに使用するものは重要ではないため、client のコードは示されていません。ソースを確認するのは自由ですが、重要なのは、Restangular をプラグイン化しても(あるいは好きなものを使っても)、コンセプトは変わらないということです。 |
React の探求
React は、コンポーネントの定義に基づいています。多くの場合、1 つのコンポーネントは、親子関連で別のコンポーネントの複数のインスタンスを保持できます。この概念は、複数のレイヤーに拡張できます。
まず最初に、すべてのコンポーネントにトップレベルのコンテナーを用意しておくと非常に便利です。(これは、このシリーズ全体でコードを展開するにつれて明らかになります)現在、従業員リストのみがあります。ただし、後で他の関連コンポーネントが必要になる場合があるため、次から始めてください。
class App extends React.Component { (1)
constructor(props) {
super(props);
this.state = {employees: []};
}
componentDidMount() { (2)
client({method: 'GET', path: '/api/employees'}).done(response => {
this.setState({employees: response.entity._embedded.employees});
});
}
render() { (3)
return (
<EmployeeList employees={this.state.employees}/>
)
}
}
1 | class App extends React.Component{ … } は、React コンポーネントを作成するメソッドです。 |
2 | componentDidMount は、React が DOM のコンポーネントをレンダリングした後に呼び出される API です。 |
3 | render は、画面上にコンポーネントを「描画」する API です。 |
React では、大文字はコンポーネントの命名規則です。 |
App
コンポーネントでは、Spring Data REST バックエンドから従業員の配列が取得され、このコンポーネントの state
データに保存されます。
React コンポーネントには、状態とプロパティの 2 種類のデータがあります。
状態は、コンポーネントがそれ自体を処理することが期待されるデータです。変動したり変化したりする可能性のあるデータでもあります。状態を読み取るには、this.state
を使用します。更新するには、this.setState()
を使用します。this.setState()
が呼び出されるたびに、React は状態を更新し、前の状態と新しい状態の間の差分を計算し、ページ上の DOM に一連の変更を挿入します。これにより、UI が迅速かつ効率的に更新されます。
一般的な規則は、コンストラクターですべての属性を空にして状態を初期化することです。次に、componentDidMount
を使用して属性を設定することにより、サーバーからデータを検索します。それ以降は、ユーザーアクションまたはその他のイベントによって更新を実行できます。
Properties には、コンポーネントに渡されるデータが含まれます。プロパティは変更されませんが、代わりに固定値です。設定するには、すぐにわかるように、新しいコンポーネントを作成するときに属性に割り当てます。
JavaScript は、他の言語のようにデータ構造をロックダウンしません。値を割り当ててプロパティを破壊しようとすることはできますが、これは React の差動エンジンでは機能しないため、避ける必要があります。 |
このコードでは、関数は、rest.js の Promise 準拠 (英語) インスタンスである client
を介してデータをロードします。/api/employees
からの取得が完了すると、done()
内の関数を呼び出し、HAL ドキュメント(response.entity._embedded.employees
)に基づいて状態を設定します。先に curl /api/employees
の構造を参照し、この構造にどのようにマッピングされるかを確認してください。
状態が更新されると、フレームワークによって render()
関数が呼び出されます。従業員の状態データは、<EmployeeList />
React コンポーネントの作成に入力パラメーターとして含まれています。
次のリストは、EmployeeList
の定義を示しています。
class EmployeeList extends React.Component{
render() {
const employees = this.props.employees.map(employee =>
<Employee key={employee._links.self.href} employee={employee}/>
);
return (
<table>
<tbody>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Description</th>
</tr>
{employees}
</tbody>
</table>
)
}
}
JavaScript のマップ機能を使用して、this.props.employees
は従業員レコードの配列から <Element />
React コンポーネントの配列に変換されます(後ほど説明します)。
次のリストを考慮してください。
<Employee key={employee._links.self.href} data={employee} />
上記のリストは、key と data の 2 つのプロパティを持つ新しい React コンポーネント(大文字の形式に注意)を作成します。これらには、employee._links.self.href
および employee
からの値が提供されます。
Spring Data REST を使用する場合は常に、self リンクが特定のリソースのキーになります。React は子ノードの一意の識別子を必要とし、_links.self.href は完璧です。 |
最後に、次のように、マッピングを使用して構築された employees
の配列をラップした HTML テーブルを返します。
<table>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Description</th>
</tr>
{employees}
</table>
状態、プロパティ、HTML のこのシンプルなレイアウトは、React を使用して、シンプルでわかりやすいコンポーネントを宣言的に作成する方法を示しています。
このコードには HTML と JavaScript の両方が含まれているか判定します。はい、これは JSX (英語) です。使用する必要はありません。React は純粋な JavaScript を使用して作成できますが、JSX 構文は非常に簡潔です。Babel.js の迅速な作業のおかげで、トランスパイラーは JSX と ES6 の両方のサポートを一度に提供します。
JSX には、ES6 (英語) の断片も含まれています。このコードで使用されているものは、アロー関数 (英語) です。独自のスコープ this
でネストされた function()
を作成することを避け、self
変数 (英語) を必要としないようにします。
ロジックを構造に混ぜることが心配ですか? React の API は、状態とプロパティを組み合わせた優れた宣言型構造を推奨します。React は、関連のない JavaScript と HTML の束を混合する代わりに、関連する状態とプロパティの小さな断片を備えた単純なコンポーネントを構築することを推奨します。単一のコンポーネントを見て、設計を理解できます。その後、組み合わせてより大きな構造を簡単に作成できます。
次に、<Employee />
が何であるかを次のように実際に定義する必要があります。
class Employee extends React.Component{
render() {
return (
<tr>
<td>{this.props.employee.firstName}</td>
<td>{this.props.employee.lastName}</td>
<td>{this.props.employee.description}</td>
</tr>
)
}
}
このコンポーネントは非常に単純です。従業員の 3 つのプロパティを囲む 1 つの HTML テーブル行があります。プロパティ自体は this.props.employee
です。JavaScript オブジェクトを渡すと、サーバーから取得したデータを簡単に渡すことができることに注意してください。
このコンポーネントは、状態を管理したり、ユーザーの入力を処理したりしないため、他にすることはありません。このため、上の <EmployeeList />
に詰め込みたくなるかもしれません。これは絶対にやめましょう。アプリを 1 つのジョブをする小さなコンポーネントに分割することで、将来的に機能を追加することが容易になります。
最後のステップは、次のように全体をレンダリングすることです。
ReactDOM.render(
<App />,
document.getElementById('react')
)
React.render()
は 2 つの引数を受け入れます: 定義した React コンポーネントと、それを注入する DOM ノードを持っています。先ほど HTML ページから <div id="react"></div>
のアイテムを見たことを覚えていますか? ここでは、そのアイテムがピックアップされ、注入されます。
これらすべてを準備したら、アプリケーション(./mvnw spring-boot:run
)を再実行して http://localhost:8080 にアクセスしてください。次のイメージは、更新されたアプリケーションを示しています。

システムによってロードされた最初の従業員を見ることができます。
cURL を使用して新しいエントリを作成したことを覚えていますか? 次のコマンドを使用して再度実行します。
curl -X POST localhost:8080/api/employees -d "{\"firstName\": \"Bilbo\", \"lastName\": \"Baggins\", \"description\": \"burglar\"}" -H "Content-Type:application/json"
ブラウザーをリフレッシュすると、新しいエントリが表示されるはずです。

これで、Web サイトに両方のリストが表示されます。
レビュー
本セクション:
ドメインオブジェクトと対応するリポジトリを定義しました。
Spring Data REST に本格的なハイパーメディアコントロールを使用してエクスポートさせます。
親子関連で 2 つの単純な React コンポーネントを作成しました。
サーバーデータを取得し、単純な静的 HTML 構造としてレンダリングしました。
課題 ?
Web ページは動的ではありませんでした。新しいレコードを取得するには、ブラウザーをリフレッシュする必要がありました。
Web ページは、ハイパーメディアコントロールまたはメタデータを使用しませんでした。代わりに、
/api/employees
からデータをフェッチするためにハードコードされました。読み取り専用です。cURL を使用してレコードを変更できますが、Web ページにはインタラクティブ機能はありません。
次のセクションでこれらの欠点に対処します。
パート 2 - ハイパーメディアコントロール
前のセクションでは、Spring Data REST を使用して従業員データを保存するバックエンド給与サービスを作成する方法を見つけました。欠落していた主な機能は、ハイパーメディアコントロールとリンクによるナビゲーションを使用することでした。代わりに、データを見つけるためにパスをハードコードしました。
このリポジトリからコード: GitHub (英語) を取り込んで、自由にフォローしてください。このセクションでは、前のセクションのアプリケーションをベースにして、追加事項を加えています。
はじめに、データがありました... そして REST がありました
HTTP ベースのインターフェースを REST API と呼ぶ人が多いことに不満を感じています。今日の例は SocialSite REST API です。これは RPC です。ハイパーテキストが制約であるという概念で REST アーキテクチャスタイルを明確にするために何が必要なのでしょうか? 言い換えると、アプリケーション状態のエンジン(API)がハイパーテキストによって駆動されていない場合、RESTful にすることも、REST API にすることもできません。どこかに修正が必要な壊れたマニュアルがあるのでしょうか?
https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven
それでは、ハイパーメディアコントロール(つまり、ハイパーテキスト)とは正確に何であり、どのように使用できますか? 調べるために、一歩後退して、REST のコアミッションを見ていきます。
REST の概念は、Web を成功に導いたアイデアを借用し、API に適用することでした。Web の広大なサイズ、動的な性質、クライアント(つまりブラウザー)の更新頻度が低いにもかかわらず、Web は驚くべき成功を収めています。Roy Fielding は、いくつかの制約と機能を使用して、API の生成と消費を同様に拡張できるかどうかを確認しようとしました。
制約の 1 つは、動詞の数を制限することです。REST の場合、主なものは GET、POST、PUT、DELETE、PATCH です。他にもありますが、ここでは取り上げません。
GET: システムを変更せずにリソースの状態を取得する
POST: 場所を指定せに新しいリソースを作成する
PUT: 既存のリソースを置き換え、すでに存在する他のもの(存在する場合)を上書きする
DELETE: 既存のリソースを削除する
PATCH: 既存のリソースを変更する (新しいリソースを作成するよりも部分的に)
これらは、よく知られた仕様を持つ標準化された HTTP 動詞です。すでに作成された HTTP 操作を選択して使用することにより、新しい言語を発明したり、業界を教育したりする必要がなくなります。
REST のもう 1 つの制約は、メディアタイプを使用してデータの形式を定義することです。誰もが情報交換のために独自のダイアレクトを書く代わりに、いくつかのメディアタイプを開発するのが賢明でしょう。受け入れられる最も人気のあるものの 1 つは、メディアタイプ application/hal+json
の HAL です。Spring Data REST のデフォルトのメディアタイプです。重要な点は、REST には単一のメディアタイプが集中化されていないことです。代わりに、人々はメディアの種類を開発し、接続して試してみることができます。さまざまなニーズが利用可能になると、業界は柔軟に移動できます。
REST の主要な機能は、関連リソースへのリンクを含めることです。例: オーダーを表示している場合、RESTful API には、関連する顧客へのリンク、アイテムのカタログへのリンク、およびおそらくオーダー元のストアへのリンクが含まれます。このセクションでは、ページングを紹介し、ナビゲーションページングリンクの使用方法も確認します。
バックエンドからページングをオンにする
フロントエンドハイパーメディアコントロールの使用を開始するには、追加のコントロールをオンにする必要があります。Spring Data REST はページングのサポートを提供します。これを使用するには、リポジトリ定義を次のように微調整します。
public interface EmployeeRepository extends PagingAndSortingRepository<Employee, Long> {
}
インターフェースは、PagingAndSortingRepository
を継承しています。PagingAndSortingRepository
は、ページサイズを設定するためのオプションを追加し、ページ間を移動するためのナビゲーションリンクを追加しています。バックエンドの残りの部分は同じです(ただし、面白いことをするために、事前にロードされたデータ: GitHub (英語) をいくつか追加しています)。
アプリケーション(./mvnw spring-boot:run
)を再起動し、動作を確認します。次に、次のコマンド(出力とともに表示)を実行して、ページングの動作を確認します。
$ curl "localhost:8080/api/employees?size=2" { "_links" : { "first" : { "href" : "http://localhost:8080/api/employees?page=0&size=2" }, "self" : { "href" : "http://localhost:8080/api/employees" }, "next" : { "href" : "http://localhost:8080/api/employees?page=1&size=2" }, "last" : { "href" : "http://localhost:8080/api/employees?page=2&size=2" } }, "_embedded" : { "employees" : [ { "firstName" : "Frodo", "lastName" : "Baggins", "description" : "ring bearer", "_links" : { "self" : { "href" : "http://localhost:8080/api/employees/1" } } }, { "firstName" : "Bilbo", "lastName" : "Baggins", "description" : "burglar", "_links" : { "self" : { "href" : "http://localhost:8080/api/employees/2" } } } ] }, "page" : { "size" : 2, "totalElements" : 6, "totalPages" : 3, "number" : 0 } }
デフォルトのページサイズは 20 ですが、それほど多くのデータはありません。実際の動作を確認するために、?size=2
を設定します。予想どおり、2 人の従業員のみがリストされています。さらに、first
、next
、last
リンクもあります。ページパラメーターを含むコンテキストのない self
リンクもあります。
next
リンクに移動すると、prev
リンクも表示されます。次のコマンド(出力とともに表示)はこれを行います。
$ curl "http://localhost:8080/api/employees?page=1&size=2" { "_links" : { "first" : { "href" : "http://localhost:8080/api/employees?page=0&size=2" }, "prev" : { "href" : "http://localhost:8080/api/employees?page=0&size=2" }, "self" : { "href" : "http://localhost:8080/api/employees" }, "next" : { "href" : "http://localhost:8080/api/employees?page=2&size=2" }, "last" : { "href" : "http://localhost:8080/api/employees?page=2&size=2" } }, ...
URL クエリパラメーターで & を使用する場合、コマンドラインはそれが改行であると見なします。この問題を回避するには、URL 全体を引用符で囲みます。 |
それはきれいに見えますが、それを利用するためにフロントエンドを更新するとさらに良くなります。
関連によるナビゲート
Spring Data REST がすぐに提供するハイパーメディアコントロールの使用を開始するために、バックエンドでこれ以上の変更は必要ありません。フロントエンドでの作業に切り替えることができます。(それは Spring Data REST の美しさの一部です: 乱雑なコントローラーの更新はありません! )
このアプリケーションは「Spring Data REST 固有」ではないことを指摘することが重要です。代わりに、HAL (英語) 、URI テンプレート (英語) 、その他の標準を使用します。それが rest.js の使用が簡単な理由です。そのライブラリには HAL サポートが付属しています。 |
前のセクションでは、パスを /api/employees
にハードコードしました。代わりに、ハードコードする必要がある唯一のパスは、次のようにルートです
...
var root = '/api';
...
便利な小さな follow()
関数: GitHub (英語) を使用して、次のようにルートから開始して目的の場所に移動できます。
componentDidMount() {
this.loadFromServer(this.state.pageSize);
}
前のセクションでは、ロードは componentDidMount()
内で直接行われました。このセクションでは、ページサイズが更新されたときに従業員のリスト全体を再読み込みできるようにします。そのために、次のように loadFromServer()
に物を移動しました。
loadFromServer(pageSize) {
follow(client, root, [
{rel: 'employees', params: {size: pageSize}}]
).then(employeeCollection => {
return client({
method: 'GET',
path: employeeCollection.entity._links.profile.href,
headers: {'Accept': 'application/schema+json'}
}).then(schema => {
this.schema = schema.entity;
return employeeCollection;
});
}).done(employeeCollection => {
this.setState({
employees: employeeCollection.entity._embedded.employees,
attributes: Object.keys(this.schema.properties),
pageSize: pageSize,
links: employeeCollection.entity._links});
});
}
loadFromServer
は、前のセクションと非常によく似ています。ただし、follow()
を使用します。
follow()
関数の最初の引数は、REST 呼び出しを行うために使用されるclient
オブジェクトです。2 番目の引数は、開始するルート URI です。
3 番目の引数は、ナビゲートする関連の配列です。各文字列またはオブジェクトを指定できます。
関連の配列は ["employees"]
のように単純にすることができます。つまり、最初の呼び出しが行われたとき、employees
という名前の関連(または rel
)を _links
で調べます。href
を見つけてナビゲートします。配列に別の関連がある場合は、プロセスを繰り返します。
rel
だけでは不十分な場合があります。このコードの断片では、?size=<pageSize>
のクエリパラメーターもプラグインします。後で見るように、提供できる他のオプションがあります。
JSON スキーマメタデータの取得
サイズベースのクエリで employees
に移動すると、employeeCollection
が利用可能になります。前のセクションで、そのデータを <EmployeeList />
内に表示しました。このセクションでは、/api/profile/employees/
で見つかった JSON スキーマメタデータ (英語) を取得するために別の呼び出しを実行しています。
次の curl
コマンドを実行することにより、自分でデータを表示できます(出力とともに表示)。
$ curl http://localhost:8080/api/profile/employees -H "Accept:application/schema+json" { "title" : "Employee", "properties" : { "firstName" : { "title" : "First name", "readOnly" : false, "type" : "string" }, "lastName" : { "title" : "Last name", "readOnly" : false, "type" : "string" }, "description" : { "title" : "Description", "readOnly" : false, "type" : "string" } }, "definitions" : { }, "type" : "object", "$schema" : "https://json-schema.org/draft-04/schema#" }
/profile/employees のメタデータのデフォルト形式は ALPS (英語) です。ただし、この場合、コンテンツネゴシエーションを使用して JSON スキーマを取得しています。 |
`<App/>` コンポーネントの状態でこの情報をキャプチャーすることにより、後で入力フォームを作成するときにこの情報を活用できます。
新しいレコードを作成する
このメタデータを装備して、UI にいくつかの追加コントロールを追加できるようになりました。次のように、新しい React コンポーネント <CreateDialog />
を作成することから開始できます。
class CreateDialog extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(e) {
e.preventDefault();
const newEmployee = {};
this.props.attributes.forEach(attribute => {
newEmployee[attribute] = ReactDOM.findDOMNode(this.refs[attribute]).value.trim();
});
this.props.onCreate(newEmployee);
// clear out the dialog's inputs
this.props.attributes.forEach(attribute => {
ReactDOM.findDOMNode(this.refs[attribute]).value = '';
});
// Navigate away from the dialog to hide it.
window.location = "#";
}
render() {
const inputs = this.props.attributes.map(attribute =>
<p key={attribute}>
<input type="text" placeholder={attribute} ref={attribute} className="field"/>
</p>
);
return (
<div>
<a href="#createEmployee">Create</a>
<div id="createEmployee" className="modalDialog">
<div>
<a href="#" title="Close" className="close">X</a>
<h2>Create new employee</h2>
<form>
{inputs}
<button onClick={this.handleSubmit}>Create</button>
</form>
</div>
</div>
</div>
)
}
}
この新しいコンポーネントには、handleSubmit()
機能と予想される render()
機能の両方があります。
これらの関数を逆順で掘り下げ、まず render()
関数を調べます。
レンダリング
コードは、attributes
プロパティにある JSON スキーマデータをマッピングし、それを <p><input></p>
要素の配列に変換します。
key
は、React が複数の子ノードを区別するために再び必要です。これは、単純なテキストベースの入力フィールドです。
placeholder
を使用すると、フィールドがどちらであるかをユーザーに表示できます。name
属性を持つことに慣れているかもしれませんが、必須ではありません。React では、ref
は特定の DOM ノードを取得するためのメカニズムです(すぐにわかるように)。
これは、サーバーからデータをロードすることにより駆動されるコンポーネントの動的な性質を表しています。
このコンポーネントのトップレベル <div>
の中には、アンカータグと別の <div>
があります。アンカータグは、ダイアログを開くためのボタンです。そして、ネストされた <div>
は隠されたダイアログそのものです。この例では、純粋な HTML5 と CSS3 を使用しています。JavaScript はまったくありません! ダイアログの表示と非表示に使用される CSS コードを確認できます: GitHub (英語) 。ここでは詳しく説明しません。
<div id="createEmployee">
の内部には、入力フィールドの動的リストが挿入され、その後に作成ボタンが続くフォームがあります。そのボタンには onClick={this.handleSubmit}
イベントハンドラーがあります。これは、イベントハンドラーを登録する React の方法です。
React は、すべての DOM 要素にイベントハンドラーを作成しません。代わりに、はるかに高性能で洗練された (英語) ソリューションがあります。そのインフラストラクチャを管理する必要はなく、代わりに機能コードの作成に集中できます。 |
ユーザー入力の処理
handleSubmit()
関数は、最初にイベントが階層をさらに上にバブルするのを停止します。次に、React.findDOMNode(this.refs[attribute])
を使用して、同じ JSON スキーマ属性プロパティを使用して各 <input>
を検索します。
this.refs
は、名前で特定の React コンポーネントに手を伸ばして取得する方法です。仮想 DOM コンポーネントのみを取得していることに注意してください。実際の DOM 要素を取得するには、React.findDOMNode()
を使用する必要があります。
すべての入力を反復処理して newEmployee
オブジェクトを作成した後、新しい従業員レコードの onCreate()
へのコールバックを呼び出します。この関数は App.onCreate
内にあり、この React コンポーネントに別のプロパティとして提供されました。そのトップレベル関数がどのように動作するかを参照してください。
onCreate(newEmployee) {
follow(client, root, ['employees']).then(employeeCollection => {
return client({
method: 'POST',
path: employeeCollection.entity._links.self.href,
entity: newEmployee,
headers: {'Content-Type': 'application/json'}
})
}).then(response => {
return follow(client, root, [
{rel: 'employees', params: {'size': this.state.pageSize}}]);
}).done(response => {
if (typeof response.entity._links.last !== "undefined") {
this.onNavigate(response.entity._links.last.href);
} else {
this.onNavigate(response.entity._links.self.href);
}
});
}
ここでも、follow()
関数を使用して、POST 操作が実行される employees
リソースに移動します。この場合、パラメーターを適用する必要はなかったため、rel
インスタンスの文字列ベースの配列は問題ありません。この状況では、POST
呼び出しが返されます。これにより、次の then()
句で POST
の結果の処理を処理できます。
通常、新しいレコードはデータセットの最後に追加されます。特定のページを見ているため、新しい従業員レコードが現在のページにないことを期待するのは論理的です。これを処理するには、同じページサイズが適用されたデータの新しいバッチをフェッチする必要があります。その Promise は done()
内の最終節に対して返されます。
ユーザーはおそらく新しく作成された従業員を見たいと思うため、ハイパーメディアコントロールを使用して last
エントリに移動できます。
Promise ベースの API を初めて使用しますか? Promise (英語) は、非同期操作を開始し、タスクが完了したときに応答する関数を登録する方法です。Promise は、「コールバック地獄」を回避するために一緒に連鎖されるように設計されています。次のフローを参照してください。
when.promise(async_func_call())
.then(function(results) {
/* process the outcome of async_func_call */
})
.then(function(more_results) {
/* process the previous then() return value */
})
.done(function(yet_more) {
/* process the previous then() and wrap things up */
});
詳細については、promise に関するこのチュートリアル (英語) を参照してください。
promise で覚えておくべきシークレットは、then()
関数が値であろうと別の promise であろうと、何かを返す必要があるということです。done()
関数は何も返しません。また、チェーンは何も返しません。まだ気付いていない場合は、client
(rest.js からの rest
のインスタンス)と follow
関数が promise を返します。
データのページング
バックエンドでページングを設定し、新しい従業員を作成するときにすでにそれを利用し始めています。
前のセクションでは、ページコントロールを使用して last
ページにジャンプしました。UI に動的に適用し、ユーザーが必要に応じてナビゲートできるようにすることは非常に便利です。利用可能なナビゲーションリンクに基づいてコントロールを動的に調整することは素晴らしいことです。
まず、使用した onNavigate()
関数を確認しましょう。
onNavigate(navUri) {
client({method: 'GET', path: navUri}).done(employeeCollection => {
this.setState({
employees: employeeCollection.entity._embedded.employees,
attributes: this.state.attributes,
pageSize: this.state.pageSize,
links: employeeCollection.entity._links
});
});
}
これは、App.onNavigate
内の上部で定義されます。繰り返しますが、これは最上位コンポーネントの UI の状態を管理できるようにするためです。onNavigate()
を <EmployeeList />
React コンポーネントに渡した後、次のハンドラーがコーディングされ、一部のボタンのクリックを処理します。
handleNavFirst(e){
e.preventDefault();
this.props.onNavigate(this.props.links.first.href);
}
handleNavPrev(e) {
e.preventDefault();
this.props.onNavigate(this.props.links.prev.href);
}
handleNavNext(e) {
e.preventDefault();
this.props.onNavigate(this.props.links.next.href);
}
handleNavLast(e) {
e.preventDefault();
this.props.onNavigate(this.props.links.last.href);
}
これらの各関数はデフォルトイベントをインターセプトし、バブリングを防ぎます。次に、適切なハイパーメディアリンクを使用して onNavigate()
関数を呼び出します。
これで、EmployeeList.render
のハイパーメディアリンクに表示されるリンクに基づいて、条件付きでコントロールを表示できます。
render() {
const employees = this.props.employees.map(employee =>
<Employee key={employee._links.self.href} employee={employee} onDelete={this.props.onDelete}/>
);
const navLinks = [];
if ("first" in this.props.links) {
navLinks.push(<button key="first" onClick={this.handleNavFirst}><<</button>);
}
if ("prev" in this.props.links) {
navLinks.push(<button key="prev" onClick={this.handleNavPrev}><</button>);
}
if ("next" in this.props.links) {
navLinks.push(<button key="next" onClick={this.handleNavNext}>></button>);
}
if ("last" in this.props.links) {
navLinks.push(<button key="last" onClick={this.handleNavLast}>>></button>);
}
return (
<div>
<input ref="pageSize" defaultValue={this.props.pageSize} onInput={this.handleInput}/>
<table>
<tbody>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Description</th>
<th></th>
</tr>
{employees}
</tbody>
</table>
<div>
{navLinks}
</div>
</div>
)
}
前のセクションと同様に、this.props.employees
を <Element />
コンポーネントの配列に変換します。次に、navLinks
の配列を HTML ボタンの配列として構築します。
React は XML に基づいているため、< を <button> 要素内に配置することはできません。代わりに、エンコードされたバージョンを使用する必要があります。 |
次に、返された HTML の下部に {navLinks}
が挿入されているのを確認できます。
既存のレコードを削除する
エントリの削除ははるかに簡単です。必要なのは、HAL ベースのレコードを取得し、DELETE
を self
リンクに適用することだけです。
class Employee extends React.Component {
constructor(props) {
super(props);
this.handleDelete = this.handleDelete.bind(this);
}
handleDelete() {
this.props.onDelete(this.props.employee);
}
render() {
return (
<tr>
<td>{this.props.employee.firstName}</td>
<td>{this.props.employee.lastName}</td>
<td>{this.props.employee.description}</td>
<td>
<button onClick={this.handleDelete}>Delete</button>
</td>
</tr>
)
}
}
Employee コンポーネントのこの更新されたバージョンでは、行の最後に追加のエントリ(削除ボタン)が表示されます。クリックすると、this.handleDelete
を呼び出すように登録されます。handleDelete()
関数は、コンテキスト的に重要な this.props.employee
レコードを提供しながら、渡されたコールバックを呼び出すことができます。
これも、1 つの場所で最上位コンポーネントの状態を管理するのが最も簡単であることを示しています。これは常にそうであるとは限りません。ただし、多くの場合、状態を 1 か所で管理すると、物事をまっすぐに簡単に保つことが容易になります。コンポーネント固有の詳細(this.props.onDelete(this.props.employee) )でコールバックを呼び出すことにより、コンポーネント間の動作を非常に簡単に調整できます。 |
onDelete()
関数を App.onDelete
の先頭にトレースすると、その動作を確認できます。
onDelete(employee) {
client({method: 'DELETE', path: employee._links.self.href}).done(response => {
this.loadFromServer(this.state.pageSize);
});
}
ページベースの UI でレコードを削除した後に適用する動作は少し注意が必要です。この場合、サーバーからデータ全体をリロードし、同じページサイズを適用します。次に、最初のページが表示されます。
最後のページの最後のレコードを削除する場合、最初のページにジャンプします。
ページサイズの調整
ハイパーメディアが実際にどのように輝くかを確認する 1 つの方法は、ページサイズを更新することです。Spring Data REST は、ページサイズに基づいてナビゲーションリンクを流動的に更新します。
ElementList.render
の上部には <input ref="pageSize" defaultValue={this.props.pageSize} onInput={this.handleInput}/>
という HTML 要素があります。
ref="pageSize"
を使用すると、this.refs.pageSize
を使用してその要素を簡単に取得できます。defaultValue
は、状態のpageSize
で初期化します。以下に示すように、
onInput
はハンドラーを登録します。handleInput(e) { e.preventDefault(); const pageSize = ReactDOM.findDOMNode(this.refs.pageSize).value; if (/^[0-9]+$/.test(pageSize)) { this.props.updatePageSize(pageSize); } else { ReactDOM.findDOMNode(this.refs.pageSize).value = pageSize.substring(0, pageSize.length - 1); } }
イベントの泡立ちを停止します。次に、<input>
の ref
属性を使用して、すべて React の findDOMNode()
ヘルパー関数を介して DOM ノードを見つけ、その値を抽出します。入力が実際に数字であるかどうかは、数字の文字列であるかどうかを確認してテストします。その場合、コールバックを呼び出して、新しいページサイズを App
React コンポーネントに送信します。そうでない場合は、入力した文字が入力から取り除かれます。
App
は updatePageSize()
を取得するとどうなるでしょうか? 見てみな:
updatePageSize(pageSize) {
if (pageSize !== this.state.pageSize) {
this.loadFromServer(pageSize);
}
}
新しいページサイズの値により、すべてのナビゲーションリンクが変更されるため、データを再フェッチして最初から開始することをお勧めします。
すべてまとめる
これらすべての素晴らしい追加により、次のイメージに示すように、UI が大幅に改善されました。

ページサイズの設定は上部に、削除ボタンは各行に、ナビゲーションボタンは下部に表示されます。ナビゲーションボタンは、ハイパーメディアコントロールの強力な機能を示しています。
次のイメージでは、HTML 入力プレースホルダーにメタデータがプラグインされた CreateDialog
を見ることができます。

これは、ドメイン駆動型メタデータ(JSON スキーマ)と組み合わせたハイパーメディアを使用する力を実際に示しています。Web ページは、どのフィールドがどのフィールドであるかを知る必要はありません。代わりに、ユーザーはそれを見て、それを使用する方法を知ることができます。Employee
ドメインオブジェクトに別のフィールドを追加した場合、このポップアップは自動的にそれを表示します。
レビュー
本セクション:
Spring Data REST のページング機能を有効にしました。
ハードコードされた URI パスを捨て、関連名または「rels」と組み合わせたルート URI の使用を開始しました。
ページベースのハイパーメディアコントロールを動的に使用するように UI を更新しました。
従業員を作成および削除し、必要に応じて UI を更新する機能を追加しました。
ページサイズを変更し、UI が柔軟に応答できるようにしました。
課題 ?
Web ページを動的にしました。ただし、別のブラウザータブを開いて、同じアプリをポイントします。1 つのタブを変更しても、他のタブは何も更新されません。
次のセクションでその課題に対処します。
パート 3 - 条件付き操作
前のセクションでは、Spring Data REST のハイパーメディアコントロールをオンにし、ページングによって UI をナビゲートし、ページサイズの変更に基づいて動的にサイズを変更する方法を見つけました。従業員を作成および削除し、ページを調整する機能を追加しました。ただし、現在編集している同じデータの一部に対して他のユーザーが行った更新を考慮しないと、ソリューションは完成しません。
このリポジトリからコード: GitHub (英語) を自由に入手して、フォローしてください。このセクションは前のセクションに基づいていますが、追加機能が追加されています。
PUT するかしないか? それが問題です。
リソースをフェッチするとき、他の誰かがリソースを更新するとリソースが古くなる可能性があります。これに対処するために、Spring Data REST は、リソースのバージョン管理と ETag の 2 つのテクノロジーを統合します。
バックエンドのリソースをバージョン管理し、フロントエンドで ETag を使用することにより、条件付きで PUT
を変更できます。つまり、リソースが変更されたかどうかを検出し、PUT
(または PATCH
)が他の誰かの更新を踏みにじることを防ぐことができます。
REST リソースのバージョン管理
リソースのバージョン管理をサポートするには、この型の保護を必要とするドメインオブジェクトのバージョン属性を定義します。次のリストは、Employee
オブジェクトに対してこれを行うメソッドを示しています。
@Entity
public class Employee {
private @Id @GeneratedValue Long id;
private String firstName;
private String lastName;
private String description;
private @Version @JsonIgnore Long version;
private Employee() {}
public Employee(String firstName, String lastName, String description) {
this.firstName = firstName;
this.lastName = lastName;
this.description = description;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Employee employee = (Employee) o;
return Objects.equals(id, employee.id) &&
Objects.equals(firstName, employee.firstName) &&
Objects.equals(lastName, employee.lastName) &&
Objects.equals(description, employee.description) &&
Objects.equals(version, employee.version);
}
@Override
public int hashCode() {
return Objects.hash(id, firstName, lastName, description, version);
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Long getVersion() {
return version;
}
public void setVersion(Long version) {
this.version = version;
}
@Override
public String toString() {
return "Employee{" +
"id=" + id +
", firstName='" + firstName + '\'' +
", lastName='" + lastName + '\'' +
", description='" + description + '\'' +
", version=" + version +
'}';
}
}
version
フィールドにはjavax.persistence.Version
のアノテーションが付けられています。行が挿入および更新されるたびに、値が自動的に保存および更新されます。
個々のリソース(コレクションリソースではない)をフェッチすると、Spring Data REST はこのフィールドの値を使用して ETag レスポンスヘッダー (英語) を自動的に追加します。
個々のリソースとそのヘッダーの取得
前のセクションでは、コレクションリソースを使用してデータを収集し、UI の HTML テーブルに入力しました。Spring Data REST では、_embedded
データセットはデータのプレビューと見なされます。ETags のようなヘッダーを取得するには、データを確認するのに便利ですが、各リソースを個別に取得する必要があります。
このバージョンでは、loadFromServer
が更新されてコレクションを取得します。次に、URI を使用して個々のリソースを取得できます。
loadFromServer(pageSize) {
follow(client, root, [ (1)
{rel: 'employees', params: {size: pageSize}}]
).then(employeeCollection => { (2)
return client({
method: 'GET',
path: employeeCollection.entity._links.profile.href,
headers: {'Accept': 'application/schema+json'}
}).then(schema => {
this.schema = schema.entity;
this.links = employeeCollection.entity._links;
return employeeCollection;
});
}).then(employeeCollection => { (3)
return employeeCollection.entity._embedded.employees.map(employee =>
client({
method: 'GET',
path: employee._links.self.href
})
);
}).then(employeePromises => { (4)
return when.all(employeePromises);
}).done(employees => { (5)
this.setState({
employees: employees,
attributes: Object.keys(this.schema.properties),
pageSize: pageSize,
links: this.links
});
});
}
1 | follow() 関数は、employees コレクションリソースに移動します。 |
2 | 最初の then(employeeCollection ⇒ …) 句は、JSON スキーマデータをフェッチする呼び出しを作成します。これには、メタデータとナビゲーションリンクを <App/> コンポーネントに保存するための内部 then 句があります。 この埋め込まれた promise は |
3 | 2 番目の then(employeeCollection ⇒ …) 句は、従業員のコレクションを GET Promise の配列に変換して、個々のリソースをフェッチします。これは、各従業員の ETag ヘッダーをフェッチするために必要なものです。 |
4 | then(employeePromises ⇒ …) 句は GET Promise の配列を取り、when.all() で単一の Promise にマージします。これは、すべての GET Promise が解決されると解決されます。 |
5 | loadFromServer は done(employees ⇒ …) で終わり、このデータの融合を使用して UI 状態が更新されます。 |
このチェーンは、他の場所でも実装されています。例: onNavigate()
(異なるページへのジャンプに使用)は、個々のリソースを取得するために更新されます。ここに示されているものとほぼ同じであるため、このセクションでは省略しています。
既存のリソースの更新
このセクションでは、UpdateDialog
React コンポーネントを追加して、既存の従業員レコードを編集します。
class UpdateDialog extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(e) {
e.preventDefault();
const updatedEmployee = {};
this.props.attributes.forEach(attribute => {
updatedEmployee[attribute] = ReactDOM.findDOMNode(this.refs[attribute]).value.trim();
});
this.props.onUpdate(this.props.employee, updatedEmployee);
window.location = "#";
}
render() {
const inputs = this.props.attributes.map(attribute =>
<p key={this.props.employee.entity[attribute]}>
<input type="text" placeholder={attribute}
defaultValue={this.props.employee.entity[attribute]}
ref={attribute} className="field"/>
</p>
);
const dialogId = "updateEmployee-" + this.props.employee.entity._links.self.href;
return (
<div key={this.props.employee.entity._links.self.href}>
<a href={"#" + dialogId}>Update</a>
<div id={dialogId} className="modalDialog">
<div>
<a href="#" title="Close" className="close">X</a>
<h2>Update an employee</h2>
<form>
{inputs}
<button onClick={this.handleSubmit}>Update</button>
</form>
</div>
</div>
</div>
)
}
};
この新しいコンポーネントには、<CreateDialog />
コンポーネントと同様に、handleSubmit()
機能と予想される render()
機能の両方があります。
これらの関数を逆の順序で掘り下げ、最初に render()
関数を調べます。
レンダリング
このコンポーネントは、前のセクションの <CreateDialog />
と同じダイアログを表示および非表示にするために、同じ CSS/HTML 戦術を使用します。
JSON スキーマ属性の配列を HTML 入力の配列に変換し、スタイル設定のために段落要素でラップします。これも <CreateDialog />
と同じですが、1 つの違いがあります。フィールドには this.props.employee
がロードされます。CreateDialog
コンポーネントでは、フィールドは空です。
id
フィールドの構築方法は異なります。UI 全体に CreateDialog
リンクは 1 つしかありませんが、表示される行ごとに個別の UpdateDialog
リンクがあります。id
フィールドは self
リンクの URI に基づいています。これは、<div>
要素の React key
、HTML アンカータグ、非表示のポップアップで使用されます。
ユーザー入力の処理
送信ボタンは、コンポーネントの handleSubmit()
機能にリンクされています。これは、React refs (英語) を使用してポップアップの詳細を抽出するために、React.findDOMNode()
を便利に使用します。
入力値が抽出されて updatedEmployee
オブジェクトにロードされた後、最上位の onUpdate()
メソッドが呼び出されます。これにより、React の片方向バインディングのスタイルが継続され、呼び出し対象の関数が上位コンポーネントから下位コンポーネントにプッシュされます。このように、状態は依然として上部で管理されます。
条件付き PUT
データモデルにバージョニングを組み込むために、このすべての努力を行ってきました。Spring Data REST は、その値を ETag レスポンスヘッダーとして提供しました。ここで、それを有効に活用できます。
onUpdate(employee, updatedEmployee) {
client({
method: 'PUT',
path: employee.entity._links.self.href,
entity: updatedEmployee,
headers: {
'Content-Type': 'application/json',
'If-Match': employee.headers.Etag
}
}).done(response => {
this.loadFromServer(this.state.pageSize);
}, response => {
if (response.status.code === 412) {
alert('DENIED: Unable to update ' +
employee.entity._links.self.href + '. Your copy is stale.');
}
});
}
If-Match
リクエストヘッダー (英語) を持つ PUT
により、Spring Data REST は現在のバージョンに対して値をチェックします。受信 If-Match
値がデータストアのバージョン値と一致しない場合、Spring Data REST は HTTP 412 Precondition Failed
で失敗します。
Promises/A+ (英語) の仕様では、実際に API を then(successFunction, errorFunction) として定義しています。これまでのところ、成功関数でのみ使用されるのを見てきました。上記のコードフラグメントには、2 つの関数があります。success 関数は loadFromServer を呼び出しますが、error 関数は古いデータに関するブラウザーアラートを表示します。 |
すべてまとめる
UpdateDialog
React コンポーネントを定義し、最上位の onUpdate
関数にうまくリンクしたら、最後のステップはコンポーネントの既存のレイアウトに接続することです。
前のセクションで作成された CreateDialog
は、インスタンスが 1 つしかないため、EmployeeList
の上部に配置されます。ただし、UpdateDialog
は特定の従業員に直接結び付けられています。そのため、Employee
React コンポーネントで以下にプラグインされていることがわかります。
class Employee extends React.Component {
constructor(props) {
super(props);
this.handleDelete = this.handleDelete.bind(this);
}
handleDelete() {
this.props.onDelete(this.props.employee);
}
render() {
return (
<tr>
<td>{this.props.employee.entity.firstName}</td>
<td>{this.props.employee.entity.lastName}</td>
<td>{this.props.employee.entity.description}</td>
<td>
<UpdateDialog employee={this.props.employee}
attributes={this.props.attributes}
onUpdate={this.props.onUpdate}/>
</td>
<td>
<button onClick={this.handleDelete}>Delete</button>
</td>
</tr>
)
}
}
このセクションでは、コレクションリソースの使用から個々のリソースの使用に切り替えます。従業員レコードのフィールドは、this.props.employee.entity
にあります。ETag を見つけることができる this.props.employee.headers
にアクセスできます。
Spring Data REST でサポートされている他のヘッダー(Last-Modified など (英語) )は、このシリーズの一部ではありません。この方法でデータを構造化すると便利です。
.entity および .headers の構造は、rest.js: GitHub (英語) を選択した REST ライブラリとして使用する場合にのみ適切です。別のライブラリを使用する場合は、必要に応じて適応する必要があります。 |
実際の動作を見る
変更されたアプリケーションの動作を確認するには:
./mvnw spring-boot:run
を実行してアプリケーションを開始します。ブラウザーのタブを開き、http://localhost:8080 に移動します。
次のイメージのようなページが表示されるはずです。
Frodo の編集ダイアログを開きます。
ブラウザーで別のタブを開き、同じレコードを取得します。
最初のタブでレコードを変更します。
2 番目のタブで変更を試みてください。
次のイメージに示すように、ブラウザーのタブが変更されます。

これらの変更により、衝突を回避することでデータの整合性が向上します。
レビュー
本セクション:
JPA ベースの楽観的ロック用に
@Version
フィールドを使用してドメインモデルを構成しました。個々のリソースを取得するようにフロントエンドを調整しました。
ETag ヘッダーを個々のリソースから
If-Match
リクエストヘッダーにプラグインして、PUT を条件付きにしました。リストに表示されている従業員ごとに新しい
UpdateDialog
をコーディングしました。
これをプラグインすると、他のユーザーとの衝突や編集内容の上書きを簡単に回避できます。
課題 ?
悪いレコードを編集しているときを知ることは確かに素晴らしいことです。ただし、送信をクリックして確認するまで待つことをお勧めしますか?
リソースをフェッチするロジックは、loadFromServer
と onNavigate
の両方で非常に似ています。コードの重複を避ける方法はありますか?
JSON スキーマメタデータを使用して、CreateDialog
および UpdateDialog
入力を構築します。メタデータを使用して物事をより一般的なものにする他の場所を見ますか? Employee.java
にさらに 5 つのフィールドを追加したいと想像してください。UI を更新するには何が必要ですか?
パート 4 - イベント
前のセクションでは、同じデータを編集するときに他のユーザーとの衝突を避けるために条件付きリフレッシュを導入しました。また、楽観的ロックを使用してバックエンドでデータをバージョン管理する方法も学びました。誰かが同じレコードを編集した場合、ページをリフレッシュして取得できるようになったときに通知を受け取りました。
それはいいことです。でも、さらにいいことがあると思いませんか? 他の人がリソースを更新すると、UI がダイナミックに反応することです。
このセクションでは、Spring Data REST の組み込みイベントシステムを使用して、バックエンドの変更を検出し、Spring の WebSocket サポートを通じてすべてのユーザーに更新を公開する方法を学習します。その後、データが更新されると、クライアントを動的に調整できます。
このリポジトリからコード: GitHub (英語) を取り込んで、自由にフォローしてください。このセクションでは、前のセクションのアプリケーションをベースにして、追加事項を加えています。
Spring WebSocket サポートをプロジェクトに追加する
開始する前に、プロジェクトの pom.xml ファイルに依存関係を追加する必要があります。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
この依存関係は、Spring Boot の WebSocket スターターをもたらします。
Spring を使用した WebSockets の構成
Spring には強力な WebSocket サポートが付属しています。認識すべきことの 1 つは、WebSocket が非常に低レベルのプロトコルであることです。クライアントとサーバー間でデータを送信する手段を提供する以上のものはありません。推奨事項は、サブプロトコル(このセクションでは STOMP)を使用して、実際にデータとルートをエンコードすることです。
次のコードは、サーバー側で WebSocket サポートを構成します。
@Component
@EnableWebSocketMessageBroker (1)
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { (2)
static final String MESSAGE_PREFIX = "/topic"; (3)
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) { (4)
registry.addEndpoint("/payroll").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) { (5)
registry.enableSimpleBroker(MESSAGE_PREFIX);
registry.setApplicationDestinationPrefixes("/app");
}
}
1 | @EnableWebSocketMessageBroker は WebSocket サポートをオンにします。 |
2 | WebSocketMessageBrokerConfigurer は、基本機能を構成するための便利な基本クラスを提供します。 |
3 | MESSAGE_PREFIX は、すべてのメッセージのルートに付加するプレフィックスです。 |
4 | registerStompEndpoints() は、クライアントとサーバーがリンクするためのバックエンドでエンドポイントを構成するために使用されます(/payroll )。 |
5 | configureMessageBroker() は、サーバーとクライアント間でメッセージを中継するために使用されるブローカーを構成するために使用されます。 |
この構成を使用すると、Spring Data REST イベントをタップして、WebSocket を介して公開できます。
Spring Data REST イベントのサブスクライブ
Spring Data REST は、リポジトリで発生するアクションに基づいて、いくつかのアプリケーションイベントを生成します。次のコードは、これらのイベントのいくつかをサブスクライブする方法を示しています。
@Component
@RepositoryEventHandler(Employee.class) (1)
public class EventHandler {
private final SimpMessagingTemplate websocket; (2)
private final EntityLinks entityLinks;
@Autowired
public EventHandler(SimpMessagingTemplate websocket, EntityLinks entityLinks) {
this.websocket = websocket;
this.entityLinks = entityLinks;
}
@HandleAfterCreate (3)
public void newEmployee(Employee employee) {
this.websocket.convertAndSend(
MESSAGE_PREFIX + "/newEmployee", getPath(employee));
}
@HandleAfterDelete (3)
public void deleteEmployee(Employee employee) {
this.websocket.convertAndSend(
MESSAGE_PREFIX + "/deleteEmployee", getPath(employee));
}
@HandleAfterSave (3)
public void updateEmployee(Employee employee) {
this.websocket.convertAndSend(
MESSAGE_PREFIX + "/updateEmployee", getPath(employee));
}
/**
* Take an {@link Employee} and get the URI using Spring Data REST's {@link EntityLinks}.
*
* @param employee
*/
private String getPath(Employee employee) {
return this.entityLinks.linkForItemResource(employee.getClass(),
employee.getId()).toUri().getPath();
}
}
1 | @RepositoryEventHandler(Employee.class) は、このクラスにフラグを立てて、従業員に基づいてイベントをトラップします。 |
2 | SimpMessagingTemplate および EntityLinks は、アプリケーションコンテキストからオートワイヤーされます。 |
3 | @HandleXYZ アノテーションは、イベントをリッスンする必要があるメソッドにフラグを立てます。これらのメソッドはパブリックでなければなりません。 |
これらの各ハンドラーメソッドは、SimpMessagingTemplate.convertAndSend()
を呼び出して、WebSocket を介してメッセージを送信します。これは pub-sub アプローチであるため、接続されているすべてのコンシューマーに 1 つのメッセージが中継されます。
各メッセージのルートは異なり、複数のメッセージをクライアント上の別個の受信者に送信できるようにし、必要なオープン WebSocket は 1 つだけです(リソース効率の高いアプローチ)。
getPath()
は、Spring Data REST の EntityLinks
を使用して、特定のクラス型と ID のパスを検索します。クライアントのニーズに応えるために、この Link
オブジェクトはパスが抽出された Java URI に変換されます。
EntityLinks には、単一であれコレクションであれ、さまざまなリソースのパスをプログラムで見つけるためのユーティリティメソッドがいくつか付属しています。 |
基本的に、作成、更新、削除イベントをリッスンし、イベントが完了した後、それらのイベントの通知をすべてのクライアントに送信します。また、そのような操作が発生する前にインターセプトし、おそらくログに記録したり、何らかの理由でブロックしたり、ドメインオブジェクトを追加情報で装飾したりできます。(次のセクションでは、これの便利な使用方法を説明します。)
JavaScript WebSocket の構成
次のステップは、WebSocket イベントを消費するためのクライアント側コードを書くことです。メインアプリケーションの次のチャンクはモジュールをプルします。
var stompClient = require('./websocket-listener')
そのモジュールを以下に示します。
'use strict';
const SockJS = require('sockjs-client'); (1)
require('stompjs'); (2)
function register(registrations) {
const socket = SockJS('/payroll'); (3)
const stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
registrations.forEach(function (registration) { (4)
stompClient.subscribe(registration.route, registration.callback);
});
});
}
module.exports.register = register;
1 | WebSockets を介して話すために SockJS JavaScript ライブラリを取り込みます。 |
2 | stomp-websocket JavaScript ライブラリをプルして、STOMP サブプロトコルを使用します。 |
3 | WebSocket をアプリケーションの /payroll エンドポイントに向けます。 |
4 | 提供された registrations の配列を反復処理して、それぞれがメッセージの到着時にコールバックをサブスクライブできるようにします。 |
各登録エントリには route
と callback
があります。次のセクションでは、イベントハンドラーを登録する方法を確認できます。
WebSocket イベントの登録
React では、コンポーネントの componentDidMount()
関数は、DOM でレンダリングされた後に呼び出されます。また、コンポーネントがオンラインになり、ビジネスの準備が整うため、WebSocket イベントに登録するのに適切なタイミングです。次のコードでそれを行います。
componentDidMount() {
this.loadFromServer(this.state.pageSize);
stompClient.register([
{route: '/topic/newEmployee', callback: this.refreshAndGoToLastPage},
{route: '/topic/updateEmployee', callback: this.refreshCurrentPage},
{route: '/topic/deleteEmployee', callback: this.refreshCurrentPage}
]);
}
最初の行は以前と同じで、すべての従業員がページサイズを使用してサーバーから取得されます。2 行目は、WebSocket イベント用に登録されている JavaScript オブジェクトの配列を示しています。各オブジェクトには route
と callback
があります。
新しい従業員が作成されたときの動作は、データセットをリフレッシュしてから、ページングリンクを使用して最後のページに移動することです。最後に移動する前にデータをリフレッシュするのはなぜですか? 新しいレコードを追加すると、新しいページが作成される可能性があります。これが発生するかどうかを計算することは可能ですが、ハイパーメディアのポイントを覆します。カスタマイズされたページ数を組み合わせるのではなく、既存のリンクを使用して、パフォーマンスを向上させる理由がある場合にのみその道を進むことをお勧めします。
従業員がリフレッシュまたは削除されると、現在のページがリフレッシュされます。レコードをリフレッシュすると、表示しているページに影響します。現在のページのレコードを削除すると、次のページのレコードが現在のページに取り込まれるため、現在のページもリフレッシュする必要があります。
これらの WebSocket メッセージが /topic で始まるという要件はありません。pub-sub セマンティクスを示す一般的な規則です。 |
次のセクションでは、これらの操作を実行する実際の操作を確認できます。
WebSocket イベントへの反応と UI 状態の更新
次のコードチャンクには、WebSocket イベントを受信したときに UI 状態を更新するために使用される 2 つのコールバックが含まれています。
refreshAndGoToLastPage(message) {
follow(client, root, [{
rel: 'employees',
params: {size: this.state.pageSize}
}]).done(response => {
if (response.entity._links.last !== undefined) {
this.onNavigate(response.entity._links.last.href);
} else {
this.onNavigate(response.entity._links.self.href);
}
})
}
refreshCurrentPage(message) {
follow(client, root, [{
rel: 'employees',
params: {
size: this.state.pageSize,
page: this.state.page.number
}
}]).then(employeeCollection => {
this.links = employeeCollection.entity._links;
this.page = employeeCollection.entity.page;
return employeeCollection.entity._embedded.employees.map(employee => {
return client({
method: 'GET',
path: employee._links.self.href
})
});
}).then(employeePromises => {
return when.all(employeePromises);
}).then(employees => {
this.setState({
page: this.page,
employees: employees,
attributes: Object.keys(this.schema.properties),
pageSize: this.state.pageSize,
links: this.links
});
});
}
refreshAndGoToLastPage()
は、使い慣れた follow()
関数を使用して、size
パラメーターが適用された employees
リンクに移動し、this.state.pageSize
を接続します。レスポンスを受信したら、最後のセクションから同じ onNavigate()
関数を呼び出して、新しいレコードが見つかる最後のページにジャンプします。
refreshCurrentPage()
も follow()
関数を使用しますが、this.state.pageSize
を size
に、this.state.page.number
を page
に適用します。これにより、現在表示している同じページが取得され、それに応じて状態が更新されます。
この動作は、リフレッシュまたは削除メッセージが送信されたときにすべてのクライアントに現在のページをリフレッシュするように指示します。現在のページが現在のイベントとは関係がない可能性があります。ただし、それを把握するのは難しい場合があります。削除されたレコードがページ 2 にあり、ページ 3 を見ている場合はどうなるでしょうか? すべてのエントリが変更されます。しかし、これは望ましい動作なのでしょうか? 多分。そうでないかもしれない。 |
ローカル更新から状態管理を移動する
このセクションを完了する前に、認識すべきことがあります。UI の状態を更新する新しい方法、つまり WebSocket メッセージが到着したときを追加しました。しかし、状態を更新する古い方法はまだそこにあります。
コードの状態管理を簡素化するには、古い方法を削除します。つまり、POST
、PUT
、DELETE
呼び出しを送信しますが、その結果を使用して UI の状態を更新しないでください。代わりに、WebSocket イベントが元に戻るのを待ってから、更新を実行してください。
次のコードのチャンクは、前のセクションと同じ onCreate()
関数を示していますが、単純化されています:
onCreate(newEmployee) {
follow(client, root, ['employees']).done(response => {
client({
method: 'POST',
path: response.entity._links.self.href,
entity: newEmployee,
headers: {'Content-Type': 'application/json'}
})
})
}
ここでは、follow()
関数を使用して employees
リンクに到達し、POST
操作が適用されます。以前のように、client({method: 'GET' … })
に then()
または done()
がないことに注意してください。更新をリッスンするイベントハンドラーは、今見た refreshAndGoToLastPage()
にあります。
すべてまとめる
これらの修正を行った上で、アプリケーション(./mvnw spring-boot:run
)を起動して、いろいろと試してみましょう。ブラウザーのタブを 2 つ開き、両方が見えるようにサイズを変更します。片方のタブで更新を始めると、もう片方のタブが瞬時に更新されるのを確認してください。スマホを開いて同じページを見てみましょう。友人を見つけて、その人にも同じことをしてもらいましょう。このようなダイナミックな更新が、より行われるようになるかもしれません。
挑戦してみませんか? 前のセクションで紹介した、同じレコードを 2 つの異なるブラウザータブで開くというエクササイズを試してみてください。1 つのタブで更新しても、もう 1 つのタブでは更新されないようにしてみてください。もしそれが可能であれば、条件付きの PUT
コードが守ってくれるはずです。しかし、それを実行するのはさらに難しいかもしれません。
レビュー
このセクションでは、次のことを行いました。
SockJS フォールバックで Spring の WebSocket サポートを構成しました。
UI を動的に更新するために、Spring Data REST からイベントを作成、更新、削除するためにサブスクライブしました。
影響を受ける REST リソースの URI をコンテキストメッセージ("/topic/newEmployee","/topic/updateEmployee" など)とともに公開しました。
これらのイベントをリッスンするために WebSocket リスナーを UI に登録しました。
リスナーをハンドラーに接続して、UI の状態を更新しました。
これらのすべての機能を使用すると、2 つのブラウザーを並べて実行し、一方の更新が他方の更新にどのように反映されるかを簡単に確認できます。
課題 ?
複数のディスプレイはうまく更新されますが、正確な動作を磨くことが保証されます。例: 新しいユーザーを作成すると、すべてのユーザーが最後までジャンプします。これをどのように扱うべきかについての考えはありますか?
ページングは便利ですが、管理が難しい状態です。このサンプルアプリケーションのコストは低く、React は UI で多くのちらつきを引き起こすことなく DOM を更新するのに非常に効率的です。しかし、より複雑なアプリケーションでは、これらのアプローチのすべてが適合するわけではありません。
ページングを念頭に置いて設計する場合、クライアント間で予想される動作と、更新が必要かどうかを決定する必要があります。システムの要件とパフォーマンスによっては、既存のナビゲーションハイパーメディアで十分な場合があります。
パート 5 - UI と API の保護
前のセクションでは、Spring Data REST の組み込みイベントハンドラーと Spring Framework の WebSocket サポートを使用して、他のユーザーからの更新にアプリを動的にレスポンスさせました。ただし、適切なユーザーのみが UI とその背後にあるリソースにアクセスできるように、すべてを保護しないと完全なアプリケーションはありません。
このリポジトリからコード: GitHub (英語) を自由に入手して、フォローしてください。このセクションは、前のセクションのアプリに基づいて追加のものが追加されています。
Spring Security をプロジェクトに追加する
開始する前に、プロジェクトの pom.xml ファイルにいくつかの依存関係を追加する必要があります。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
これにより、Spring Boot の Spring Security スターターに加えて、Web ページでセキュリティルックアップを行うための追加の Thymeleaf タグが追加されます。
セキュリティモデルの定義
過去のセクションでは、素晴らしい給与システムを使用してきました。バックエンドで物事を宣言し、Spring Data REST に面倒をさせるのは便利です。次のステップは、セキュリティ管理策を講じる必要があるシステムをモデル化することです。
これが給与システムの場合、管理者のみがアクセスします。Manager
オブジェクトをモデリングして、物事を始めましょう。
@Entity
public class Manager {
public static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); (1)
private @Id @GeneratedValue Long id; (2)
private String name; (2)
private @JsonIgnore String password; (2)
private String[] roles; (2)
public void setPassword(String password) { (3)
this.password = PASSWORD_ENCODER.encode(password);
}
protected Manager() {}
public Manager(String name, String password, String... roles) {
this.name = name;
this.setPassword(password);
this.roles = roles;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Manager manager = (Manager) o;
return Objects.equals(id, manager.id) &&
Objects.equals(name, manager.name) &&
Objects.equals(password, manager.password) &&
Arrays.equals(roles, manager.roles);
}
@Override
public int hashCode() {
int result = Objects.hash(id, name, password);
result = 31 * result + Arrays.hashCode(roles);
return result;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPassword() {
return password;
}
public String[] getRoles() {
return roles;
}
public void setRoles(String[] roles) {
this.roles = roles;
}
@Override
public String toString() {
return "Manager{" +
"id=" + id +
", name='" + name + '\'' +
", roles=" + Arrays.toString(roles) +
'}';
}
}
1 | PASSWORD_ENCODER は、新しいパスワードを暗号化するか、パスワード入力を取得して比較前に暗号化する手段です。 |
2 | id 、name 、password 、roles は、アクセスを制限するために必要なパラメーターを定義します。 |
3 | カスタマイズされた setPassword() メソッドにより、パスワードが平文で保存されることはありません。 |
セキュリティ層を設計する際に留意すべき重要な点があります。データ(パスワードなど)の適切な部分を保護し、コンソールに出力したり、ログに記録したり、JSON 直列化を通じてエクスポートしたりしないでください。
パスワードフィールドに適用される
@JsonIgnore
は、このフィールドを直列化する Jackson から保護します。
マネージャーのリポジトリを作成する
Spring Data は、エンティティの管理が非常に得意です。これらのマネージャーを処理するリポジトリを作成してみませんか? 次のコードでそれを行います。
@RepositoryRestResource(exported = false)
public interface ManagerRepository extends Repository<Manager, Long> {
Manager save(Manager manager);
Manager findByName(String name);
}
通常の CrudRepository
を継承する代わりに、それほど多くのメソッドは必要ありません。代わりに、データ(更新にも使用される)を保存し、既存のユーザーを検索する必要があります。Spring Data Common の最小限の Repository
マーカーインターフェースを使用できます。定義済みの操作はありません。
Spring Data REST は、デフォルトで、見つかったリポジトリをエクスポートします。このリポジトリを REST 操作用に公開することは望ましくありません。@RepositoryRestResource(exported = false)
アノテーションを適用して、エクスポートをブロックします。これにより、リポジトリとそのメタデータが提供されなくなります。
従業員とマネージャーとのリンク
セキュリティのモデリングの最後の部分は、従業員をマネージャーに関連付けることです。このドメインでは、従業員は 1 人のマネージャーを持つことができ、マネージャーは複数の従業員を持つことができます。次のコードはその関連を定義しています。
@Entity
public class Employee {
private @Id @GeneratedValue Long id;
private String firstName;
private String lastName;
private String description;
private @Version @JsonIgnore Long version;
private @ManyToOne Manager manager; (1)
private Employee() {}
public Employee(String firstName, String lastName, String description, Manager manager) { (2)
this.firstName = firstName;
this.lastName = lastName;
this.description = description;
this.manager = manager;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Employee employee = (Employee) o;
return Objects.equals(id, employee.id) &&
Objects.equals(firstName, employee.firstName) &&
Objects.equals(lastName, employee.lastName) &&
Objects.equals(description, employee.description) &&
Objects.equals(version, employee.version) &&
Objects.equals(manager, employee.manager);
}
@Override
public int hashCode() {
return Objects.hash(id, firstName, lastName, description, version, manager);
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Long getVersion() {
return version;
}
public void setVersion(Long version) {
this.version = version;
}
public Manager getManager() {
return manager;
}
public void setManager(Manager manager) {
this.manager = manager;
}
@Override
public String toString() {
return "Employee{" +
"id=" + id +
", firstName='" + firstName + '\'' +
", lastName='" + lastName + '\'' +
", description='" + description + '\'' +
", version=" + version +
", manager=" + manager +
'}';
}
}
1 | マネージャー属性は、JPA の @ManyToOne 属性によってリンクされています。Manager は @OneToMany を必要としません。これは、ルックアップの必要性を定義していないためです。 |
2 | ユーティリティコンストラクター呼び出しが更新され、初期化がサポートされます。 |
マネージャーに対する従業員のセキュリティ設定
Spring Security は、セキュリティポリシーの定義に関する多数のオプションをサポートしています。このセクションでは、マネージャーのみが従業員の給与データを表示でき、保存、更新、削除の操作は従業員のマネージャーに限定されるように制限します。つまり、どのマネージャーもログインしてデータを表示できますが、変更を加えることができるのは特定の従業員のマネージャーだけです。次のコードはこれらのゴールを達成します。
@PreAuthorize("hasRole('ROLE_MANAGER')") (1)
public interface EmployeeRepository extends PagingAndSortingRepository<Employee, Long> {
@Override
@PreAuthorize("#employee?.manager == null or #employee?.manager?.name == authentication?.name")
Employee save(@Param("employee") Employee employee);
@Override
@PreAuthorize("@employeeRepository.findById(#id)?.manager?.name == authentication?.name")
void deleteById(@Param("id") Long id);
@Override
@PreAuthorize("#employee?.manager?.name == authentication?.name")
void delete(@Param("employee") Employee employee);
}
1 | インターフェースの上部にある @PreAuthorize は、ROLE_MANAGER を持つ人々へのアクセスを制限します。 |
save()
では、従業員のマネージャーが null(マネージャーが割り当てられていない場合の新しい従業員の最初の作成)であるか、従業員のマネージャーの名前が現在認証されているユーザーの名前と一致します。ここでは、Spring Security の SpEL 式を使用してアクセスを定義しています。null チェックを処理するための便利な ?.
プロパティナビゲーターが付属しています。HTTP 操作とメソッドをリンクする引数で @Param(…)
を使用することに注意することも重要です。
delete()
では、メソッドは従業員にアクセスするか、または id
のみを持っている場合、アプリケーションコンテキストで employeeRepository
を見つけ、findOne(id)
を実行し、現在認証されているユーザーに対してマネージャーをチェックする必要があります。
UserDetails
サービスの作成
セキュリティとの統合の一般的なポイントは、UserDetailsService
を定義することです。これは、ユーザーのデータストアを Spring Security インターフェースに接続する方法です。Spring Security には、セキュリティチェックのためにユーザーを検索する方法が必要です。これがブリッジです。ありがたいことに、Spring Data を使用すると、その労力はごくわずかです。
@Component
public class SpringDataJpaUserDetailsService implements UserDetailsService {
private final ManagerRepository repository;
@Autowired
public SpringDataJpaUserDetailsService(ManagerRepository repository) {
this.repository = repository;
}
@Override
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
Manager manager = this.repository.findByName(name);
return new User(manager.getName(), manager.getPassword(),
AuthorityUtils.createAuthorityList(manager.getRoles()));
}
}
SpringDataJpaUserDetailsService
は Spring Security の UserDetailsService
を実装しています。インターフェースには 1 つのメソッド loadUserByUsername()
があります。このメソッドは、Spring Security がユーザーの情報を調べることができるように、UserDetails
オブジェクトを返すことを目的としています。
ManagerRepository
があるため、この必要なデータをフェッチするために SQL または JPA 式を記述する必要はありません。このクラスでは、コンストラクター注入によってオートワイヤーされます。
loadUserByUsername()
は、先ほど書いたカスタムファインダー findByName()
を利用します。次に、UserDetails
インターフェースを実装する Spring Security User
インスタンスを取り込みます。また、Spring Securiy の AuthorityUtils
を使用して、文字列ベースのロールの配列から GrantedAuthority
タイプの Java List
に移行しています。
セキュリティポリシーの作成
リポジトリに適用される @PreAuthorize
式は、アクセスルールです。これらのルールは、セキュリティポリシーなしでは無効です。
@Configuration
@EnableWebSecurity (1)
@EnableGlobalMethodSecurity(prePostEnabled = true) (2)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter { (3)
@Autowired
private SpringDataJpaUserDetailsService userDetailsService; (4)
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(this.userDetailsService)
.passwordEncoder(Manager.PASSWORD_ENCODER);
}
@Override
protected void configure(HttpSecurity http) throws Exception { (5)
http
.authorizeRequests()
.antMatchers("/built/**", "/main.css").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.defaultSuccessUrl("/", true)
.permitAll()
.and()
.httpBasic()
.and()
.csrf().disable()
.logout()
.logoutSuccessUrl("/");
}
}
このコードには非常に複雑な部分があるため、最初にアノテーションと API について説明します。次に、それが定義するセキュリティポリシーについて説明します。
1 | @EnableWebSecurity は、Spring Boot に自動構成されたセキュリティポリシーを削除し、代わりにこのポリシーを使用するように指示します。クイックデモでは、自動構成されたセキュリティで問題ありません。しかし、現実のものであれば、自分でポリシーを作成する必要があります。 |
2 | @EnableGlobalMethodSecurity は、Spring Security の洗練された @Pre および @Post アノテーションでメソッドレベルのセキュリティをオンにします。 |
3 | ポリシーを記述するための便利な基本クラスである WebSecurityConfigurerAdapter を継承しています。 |
4 | フィールドインジェクションによって SpringDataJpaUserDetailsService をオートワイヤーし、configure(AuthenticationManagerBuilder) メソッドを介してプラグインします。Manager からの PASSWORD_ENCODER もセットアップされます。 |
5 | 重要なセキュリティポリシーは、configure(HttpSecurity) メソッド呼び出しを使用して Pure Java で記述されています。 |
セキュリティポリシーでは、先に定義したアクセスルールを使用してすべてのリクエストを承認するように規定されています。
antMatchers()
にリストされているパスには、静的 Web リソースをブロックする理由がないため、無条件のアクセスが許可されます。そのポリシーに一致しないものはすべて
anyRequest().authenticated()
に分類されます。つまり、認証が必要です。これらのアクセスルールが設定されると、Spring Security はフォームベース認証(成功時のデフォルトは
/
)を使用し、ログインページへのアクセスを許可するように指示されます。BASIC ログインも CSRF を無効にして設定されます。これは主にデモンストレーション用であり、慎重な分析のない本番システムには推奨されません。
ログアウトは、ユーザーを
/
に導くように構成されています。
BASIC 認証は、curl を試すときに便利です。curl を使用してフォームベースのシステムにアクセスするのは困難です。HTTP(HTTPS ではない)を介した任意のメカニズムで認証すると、ネットワーク上で資格情報が盗聴される危険性があることを認識することが重要です。CSRF はそのまま残すのに適したプロトコルです。BASIC および curl との対話を容易にするために無効になっています。本番環境では、オンのままにしておくことをお勧めします。 |
セキュリティの詳細を自動的に追加する
優れたユーザーエクスペリエンスの一部は、アプリケーションがコンテキストを自動的に適用できる場合です。この例では、ログインしたマネージャーが新しい従業員レコードを作成する場合、そのマネージャーがそれを所有することは理にかなっています。Spring Data REST のイベントハンドラーでは、ユーザーが明示的にリンクする必要はありません。また、ユーザーが誤って誤ったマネージャーにレコードを割り当てないようにします。SpringDataRestEventHandler
はそれを処理します:
@Component
@RepositoryEventHandler(Employee.class) (1)
public class SpringDataRestEventHandler {
private final ManagerRepository managerRepository;
@Autowired
public SpringDataRestEventHandler(ManagerRepository managerRepository) {
this.managerRepository = managerRepository;
}
@HandleBeforeCreate
@HandleBeforeSave
public void applyUserInformationUsingSecurityContext(Employee employee) {
String name = SecurityContextHolder.getContext().getAuthentication().getName();
Manager manager = this.managerRepository.findByName(name);
if (manager == null) {
Manager newManager = new Manager();
newManager.setName(name);
newManager.setRoles(new String[]{"ROLE_MANAGER"});
manager = this.managerRepository.save(newManager);
}
employee.setManager(manager);
}
}
1 | @RepositoryEventHandler(Employee.class) は、このイベントハンドラーに Employee オブジェクトのみに適用されるフラグを立てます。@HandleBeforeCreate アノテーションは、データベースに書き込まれる前に、受信 Employee レコードを変更する機会を提供します。 |
この状況では、現在のユーザーのセキュリティコンテキストを検索して、ユーザーの名前を取得できます。その後、findByName()
を使用して関連するマネージャーを検索し、マネージャーに適用できます。その人がまだシステムに存在しない場合、新しいマネージャーを作成するための少し余分な接着剤コードがあります。ただし、主にデータベースの初期化をサポートすることです。実際の運用システムでは、そのコードを削除し、代わりに DBA またはセキュリティ運用チームに依存してユーザーデータストアを適切に維持する必要があります。
マネージャーデータのプリロード
マネージャーの読み込みとこれらのマネージャーへの従業員のリンクは簡単です。
@Component
public class DatabaseLoader implements CommandLineRunner {
private final EmployeeRepository employees;
private final ManagerRepository managers;
@Autowired
public DatabaseLoader(EmployeeRepository employeeRepository,
ManagerRepository managerRepository) {
this.employees = employeeRepository;
this.managers = managerRepository;
}
@Override
public void run(String... strings) throws Exception {
Manager greg = this.managers.save(new Manager("greg", "turnquist",
"ROLE_MANAGER"));
Manager oliver = this.managers.save(new Manager("oliver", "gierke",
"ROLE_MANAGER"));
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("greg", "doesn't matter",
AuthorityUtils.createAuthorityList("ROLE_MANAGER")));
this.employees.save(new Employee("Frodo", "Baggins", "ring bearer", greg));
this.employees.save(new Employee("Bilbo", "Baggins", "burglar", greg));
this.employees.save(new Employee("Gandalf", "the Grey", "wizard", greg));
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("oliver", "doesn't matter",
AuthorityUtils.createAuthorityList("ROLE_MANAGER")));
this.employees.save(new Employee("Samwise", "Gamgee", "gardener", oliver));
this.employees.save(new Employee("Merry", "Brandybuck", "pony rider", oliver));
this.employees.save(new Employee("Peregrin", "Took", "pipe smoker", oliver));
SecurityContextHolder.clearContext();
}
}
1 つの欠点は、このローダーの実行時に Spring Security がアクティブになり、アクセスルールが完全に有効になることです。従業員データを保存するには、Spring Security の setAuthentication()
API を使用して、適切な名前とロールでこのローダーを認証する必要があります。最後に、セキュリティコンテキストがクリアされます。
保護された REST サービスのツアー
これらのすべての修正が完了したら、アプリケーション(./mvnw spring-boot:run
)を開始し、次の curl(出力とともに表示)を使用して修正をチェックアウトできます。
$ curl -v -u greg:turnquist localhost:8080/api/employees/1 * Trying ::1... * Connected to localhost (::1) port 8080 (#0) * Server auth using Basic with user 'greg' > GET /api/employees/1 HTTP/1.1 > Host: localhost:8080 > Authorization: Basic Z3JlZzp0dXJucXVpc3Q= > User-Agent: curl/7.43.0 > Accept: */* > < HTTP/1.1 200 OK < Server: Apache-Coyote/1.1 < X-Content-Type-Options: nosniff < X-XSS-Protection: 1; mode=block < Cache-Control: no-cache, no-store, max-age=0, must-revalidate < Pragma: no-cache < Expires: 0 < X-Frame-Options: DENY < Set-Cookie: JSESSIONID=E27F929C1836CC5BABBEAB78A548DF8C; Path=/; HttpOnly < ETag: "0" < Content-Type: application/hal+json;charset=UTF-8 < Transfer-Encoding: chunked < Date: Tue, 25 Aug 2015 15:57:34 GMT < { "firstName" : "Frodo", "lastName" : "Baggins", "description" : "ring bearer", "manager" : { "name" : "greg", "roles" : [ "ROLE_MANAGER" ] }, "_links" : { "self" : { "href" : "http://localhost:8080/api/employees/1" } } }
これは、最初のセクションで見たよりもはるかに詳細を示しています。まず、Spring Security はいくつかの HTTP プロトコルをオンにして、さまざまな攻撃ベクトル(プラグマ、期限切れ、X-Frame-Options など)から保護します。Authorization ヘッダーをレンダリングする -u greg:turnquist
で BASIC 資格情報も発行しています。
すべてのヘッダーの中で、バージョン管理されたリソースから ETag
ヘッダーを確認できます。
最後に、データ自体の内部に、新しい属性 manager
が表示されます。名前とロールは含まれていますが、パスワードは含まれていないことがわかります。これは、そのフィールドで @JsonIgnore
を使用しているためです。Spring Data REST はそのリポジトリをエクスポートしなかったため、その値はこのリソースでインライン化されます。次のセクションで UI を更新するときに、これを有効に活用します。
UI でのマネージャー情報の表示
バックエンドでこれらのすべての変更を行うと、フロントエンドでの更新に移行できます。まず、<Employee />
React コンポーネント内に従業員のマネージャーを表示できます。
class Employee extends React.Component {
constructor(props) {
super(props);
this.handleDelete = this.handleDelete.bind(this);
}
handleDelete() {
this.props.onDelete(this.props.employee);
}
render() {
return (
<tr>
<td>{this.props.employee.entity.firstName}</td>
<td>{this.props.employee.entity.lastName}</td>
<td>{this.props.employee.entity.description}</td>
<td>{this.props.employee.entity.manager.name}</td>
<td>
<UpdateDialog employee={this.props.employee}
attributes={this.props.attributes}
onUpdate={this.props.onUpdate}
loggedInManager={this.props.loggedInManager}/>
</td>
<td>
<button onClick={this.handleDelete}>Delete</button>
</td>
</tr>
)
}
}
これは、this.props.employee.entity.manager.name
の列を追加するだけです。
JSON スキーマメタデータのフィルタリング
データ出力にフィールドが表示される場合、JSON スキーマメタデータにエントリがあると想定しても安全です。次の抜粋で確認できます。
{ ... "manager" : { "readOnly" : false, "$ref" : "#/descriptors/manager" }, ... }, ... "$schema" : "https://json-schema.org/draft-04/schema#" }
manager
フィールドは、人々に直接編集してほしいものではありません。インライン化されているため、読み取り専用の属性として表示する必要があります。CreateDialog
および UpdateDialog
からインラインエントリを除外するには、loadFromServer()
で JSON スキーマメタデータを取得した後にそのようなエントリを削除できます。
/**
* Filter unneeded JSON Schema properties, like uri references and
* subtypes ($ref).
*/
Object.keys(schema.entity.properties).forEach(function (property) {
if (schema.entity.properties[property].hasOwnProperty('format') &&
schema.entity.properties[property].format === 'uri') {
delete schema.entity.properties[property];
}
else if (schema.entity.properties[property].hasOwnProperty('$ref')) {
delete schema.entity.properties[property];
}
});
this.schema = schema.entity;
this.links = employeeCollection.entity._links;
return employeeCollection;
このコードは、URI 関係と $ref エントリの両方を削除します。
不正アクセスのトラップ
バックエンドでセキュリティチェックを設定すると、誰かが認可なしにレコードを更新しようとした場合に備えてハンドラーを追加できます。
onUpdate(employee, updatedEmployee) {
if(employee.entity.manager.name === this.state.loggedInManager) {
updatedEmployee["manager"] = employee.entity.manager;
client({
method: 'PUT',
path: employee.entity._links.self.href,
entity: updatedEmployee,
headers: {
'Content-Type': 'application/json',
'If-Match': employee.headers.Etag
}
}).done(response => {
/* Let the websocket handler update the state */
}, response => {
if (response.status.code === 403) {
alert('ACCESS DENIED: You are not authorized to update ' +
employee.entity._links.self.href);
}
if (response.status.code === 412) {
alert('DENIED: Unable to update ' + employee.entity._links.self.href +
'. Your copy is stale.');
}
});
} else {
alert("You are not authorized to update");
}
}
HTTP 412 エラーをキャッチするコードがありました。これにより、HTTP 403 ステータスコードがトラップされ、適切なアラートが提供されます。
削除操作でも同じことができます。
onDelete(employee) {
client({method: 'DELETE', path: employee.entity._links.self.href}
).done(response => {/* let the websocket handle updating the UI */},
response => {
if (response.status.code === 403) {
alert('ACCESS DENIED: You are not authorized to delete ' +
employee.entity._links.self.href);
}
});
}
これは、調整されたエラーメッセージで同様にコーディングされます。
セキュリティの詳細を UI に追加する
このバージョンのアプリケーションを冠する最後のことは、ログインしているユーザーを表示し、react
<div>
の前にこの新しい <div>
を index.html
ファイルに含めることにより、ログアウトボタンを提供することです。
<div>
Hello, <span id="managername" th:text="${#authentication.name}">user</span>.
<form th:action="@{/logout}" method="post">
<input type="submit" value="Log Out"/>
</form>
</div>
すべてまとめる
フロントエンドでこれらの変更を確認するには、アプリケーションを再起動して http://localhost:8080 に移動します。
すぐにログインフォームにリダイレクトされます。このフォームは Spring Security によって提供されますが、必要に応じて独自に作成することもできます。次のイメージに示すように、greg
/ turnquist
としてログインします。

新しく追加されたマネージャー列が表示されます。次のイメージが示すように、oliver が所有する従業員が見つかるまで、数ページを通過します。

更新をクリックし、いくつかの変更を加えてから、もう一度更新をクリックします。次のポップアップで失敗するはずです。

削除を試してみると、同様のメッセージで失敗するはずです。新しい従業員を作成する場合は、それを自分に割り当てる必要があります。
レビュー
このセクションでは、次のことを行いました。
manager
のモデルを定義し、1 対多の関連を介して従業員にリンクしました。マネージャー用のリポジトリを作成し、Spring Data REST にエクスポートしないように指示しました。
従業員リポジトリの一連のアクセスルールを作成し、セキュリティポリシーも作成します。
別の Spring Data REST イベントハンドラーを作成して、発生する前に作成イベントをトラップし、現在のユーザーを従業員のマネージャーとして割り当てることができます。
UI を更新して、従業員の上司を表示し、不正な操作が行われたときにエラーポップアップを表示するようにしました。
課題 ?
Web ページは非常に洗練されています。しかし、関連とインラインデータの管理についてはどうでしょうか。作成および更新ダイアログは、実際には適していません。いくつかのカスタム記述フォームが必要になる場合があります。
管理者は従業員データにアクセスできます。従業員はアクセスできるべきですか? 電話番号や住所などの詳細を追加する場合、どのようにモデル化しますか? 特定のフィールドを更新できるように、従業員にシステムへのアクセスをどのように許可しますか? ページに配置するのに便利なハイパーメディアコントロールが他にありますか?
新しいガイドを作成したり、既存のガイドに貢献したいですか? 投稿ガイドラインを参照してください: GitHub (英語) 。
すべてのガイドは、コード用の ASLv2 ライセンス、およびドキュメント用の Attribution、NoDerivatives クリエイティブコモンズライセンス (英語) でリリースされています。 |