Spring Boot でサービス開発 #3
前回のふりかえり
前回までに、MySQL が提供している sakila データベースのサンプルを使用して、Actor テーブルの一覧を出力する REST API をさっと作ってみました。しかし、前回作成したクラスにはドメインモデル貧血症という問題点があります。
今回はドメイン駆動設計の戦術的 DDD の概念を取り入れながら、前回のコードをリファクタリングしていきます。
ドメインモデル貧血症
具体的には、ActorId は0以上を期待するが、0以外が登録される可能性があったり、firstName や lastName が null や空文字だった場合、エラーにならず、そのままデータベースに登録できるようなクラスの作りになっていました。
今回はこのドメインモデル貧血症を解決しつつ、Actor テーブルの一覧を出力するための REST API に改造を加えたいと思います。
Actor.java をおさらいしてみます。
actorId は long 型でした。しかし、このままでは long 型の全てを許容してしまいます。ここでは 必ず正の数値であることをビジネスロジックとして実装します。
ActorId
ActorId.java を上記のように設定します。ポイントは引数つきのコンストラクタ ActorId(long value) になります。ここで id が負の値で設定される場合は IllegalArgumentException を発生させています。これにより、内部で所持する long 型の value は必ず正であることが保証されます。
ActorName
前回は String firstName; String lastName と表記されていたこれらのプロパティを ActorName というクラスにひとまとめにしています。
ActorId の値が必ず正であるように、firstName や lastName にはビジネスロジックがあるはずです。例えば、null や空文字ではないことが考えられます。また、sakila データベース上の fist_name 列、last_name 列の最大値は 45 文字が最大なので、45文字以上の文字列が設定された場合は Exception が発生するようになっています。
Actor
Actor クラスは上記の ActorId、ActorName を組み合わせて表現されています。
Value Object と Entity
ドメイン駆動設計において ActorId、ActorName は Value Object、Actor は Entity と呼ばれます。
Actor は識別子(AuthorId) を持っています。ActorId が同じであれば同一人物であると見なす必要があるオブジェクトを DDD では Entity と言います。Actor を構成する要素である、ActorName は変化する可能性があります。例えば、結婚して lastName が変わるかもしれません。しかし Actor としては名前が変わったとしても Actor としては同一人物としてみなす必要があります。こうしたオブジェクトを Entity として扱います。
Actor は Serializable を継承して equals() をオーバーライドしていますが、actorId が一致していれば、一致するというロジックを実装しています。
しかし、ActorName 自体は中の firstName や lastName の値が変わったとしても同一の ActorName オブジェクトであることを認識する必要はありません。こうしたオブジェクトのことを Value Object と呼びます。
ActorRepository
CrudRepository を継承した ActorRepository は以下のようになります。
ActorController
REST API を実装する ActorController は以下のようになります。前回と異なり、sakila データベースに値を登録するエンドポイントも入れています。
Spring Boot の起動
前回とは異なるやり方で Spring Boot のアプリケーションを起動します。SakilaApplication.java を開き、main() メソッドがある左の緑の三角形を押して、起動します。
コンソールにログが出力され、Spring Boot が起動したことがわかります。
IntelliJ IDEA 左下の Scratches and Consoles から前回作成した「sakila.http」を開き、以下のように編集します。
GET http://localhost:8080/api/id/200
Content-type: application/json
左側にある緑色の三角形を押して、REST API を呼び出してみましょう。今回呼び出すのは actorId = 200 のレコードです。
http://localhost:8080/api/id/200
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 19 Oct 2022 08:29:18 GMT
Keep-Alive: timeout=60
Connection: keep-alive{
"lastUpdate": "2006-02-14T19:34:33.000+00:00",
"actorId": {
"value": 200
},
"actorName": {
"firstName": "THORA",
"lastName": "TEMPLE"
}
}
Response file saved.
> 2022-10-19T172919.200.jsonResponse code: 200; Time: 25ms; Content length: 124 bytes
上記のようなログが出力され、THORA TEMPLE という Actor が取得できることがわかりました。
プロパティとロジックをセットでカプセル化
以下のリクエストを飛ばしてみましょう。
GET http://localhost:8080/api/id/-1
Content-Type: application/json
この内容でリクエストを飛ばすと、もちろんエラーになります。
http://localhost:8080/api/id/-1HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 19 Oct 2022 09:05:34 GMT
Connection: close{
"timestamp": "2022-10-19T09:05:34.972+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/api/id/-1"
}
Response file saved.
> 2022-10-19T180535.500.jsonResponse code: 500; Time: 52ms; Content length: 110 bytes
Spring Boot のログには authorId を0以下を設定できない旨のエラーがしっかりと出力されています。
エラー内容をカスタマイズすれば、どういう理由でエラーになったか(今回の場合は 0 以下を設定していること)を API の呼び出し元に返却することも可能です。ユーザーインターフェースはデータ生成時に裏で REST API を呼び出し、エラーになればその理由をそのままユーザーインターフェースに出力することができます。
ドメイン駆動設計を用いてプロパティ(データ)とロジック(ビジネスロジック)をセットでオブジェクト内に閉じ込めます。上記のように、ユーザーがプレゼンテーション層で入力したデータはプレゼンテーション層でバリデーションチェックをする必要がありません。これによりビジネスロジックが外部に漏出することを防ぎます。
ビジネスロジックを回避する方法
とは言いつつも、JPA を利用したデータベースの操作は引数なしのコンストラクタを用意する必要があり、これを使うことでビジネスロジックの抜け穴を利用することもできます。
sakila.http を以下のように変更します。
ポイントは lastName が空文字になっている点です。
POST http://localhost:8080/api/regist
Content-Type: application/json
{
"lastUpdate": "2022-10-19T19:34:33.000+00:00",
"actorId": {
"value": 201
},
"actorName": {
"firstName": "KENTA",
"lastName": ""
}
}
これを実行した場合、エラーになることを期待します。なぜなら lastName が文字の場合は、IllegalArgumentException を発生させるコンストラクタを記述したからです。
http://localhost:8080/api/registHTTP/1.1 200
Content-Length: 0
Date: Wed, 19 Oct 2022 09:16:28 GMT
Keep-Alive: timeout=60
Connection: keep-alive<Response body is empty>Response code: 200; Time: 213ms; Content length: 0 bytes
しかし実際は上記のようなログを出力し、登録に成功します。
では IntelliJ IDEA の「Database」ビューから「actor」テーブルをダブルクリックして、インサートが無事行われているのか確認してみます。
lastName が空文字にもかかわらず、201 行目に挿入されていることがわかりました。
これは RequestBody に渡ってくる以下の JSON を SpringBoot が Actor クラスにデシリアライズする際に、Actor オブジェクトを引数ありのコンストラクタではなく、引数なしのコンストラクタを利用して生成し、そして、プロパティを直接操作しているということが予想されます。
{
"lastUpdate": "2022-10-19T19:34:33.000+00:00",
"actorId": {
"value": 201
},
"actorName": {
"firstName": "KENTA",
"lastName": ""
}
}
JPA でデータベースを操作する際に問題になるのが引数なしのコンストラクタを用意する必要があることです。ここが DDD と JPA を組み合わせる上での最大のネックになっています。
これは REST API の URL で解決すべき問題なのでしょうか。
http://localhost:8080/api/regist?actorId=201&firstName=kenta&lastName=
とし、クエリを元に ActorId や ActorName を API 内で生成するようにすれば、この問題は解決しますが、API のパス長が長くなるという問題を抱えます。
どなたか解決策をご存じでしたら教えていただきたいです。
2022/10/31 追記
上記の JSON デシリアライザが引数なしのコンストラクタを使用してオブジェクトを生成し、各プロパティを設定する件については、以下の方法で解決できることがわかりました。
ActorId/ActorName/Actor クラスの引数ありのコンストラクタに @JsonCreator というアノテーションを付与します。
ActorId.java
@JsonCreator
public ActorId(long value) {
if(value < 0){
throw new IllegalArgumentException("ActorId should not be set less than 0.");
}
this.value = value;
}
ActorName.java
/**
* Constructor for ActorName class.
*
* @param firstName First name of actor.
* @param lastName Last name of actor.
*/
@JsonCreator
public ActorName(String firstName, String lastName) {
if (firstName == null || lastName == null) {
throw new IllegalArgumentException("First name or last name of actor should not be set null.");
}
if (firstName.isEmpty() || lastName.isEmpty()) {
throw new IllegalArgumentException("First name or last name of actor should not be set empty.");
}
if (firstName.length() >= FIRST_NAME_MAX_LENGTH || lastName.length() >= LAST_NAME_MAX_LENGTH) {
throw new IllegalArgumentException("First name or last name of actor should not be less than 45 length.");
}
this.firstName = firstName;
this.lastName = lastName;
}
Actor.java
/**
* Constructor for Actor class.
*
* @param actorId ActorId of actor.
* @param actorName ActorName of actor.
*/
@JsonCreator
public Actor(@JsonProperty("actorId") ActorId actorId, @JsonProperty("actorName") ActorName actorName) {
if (actorId == null) {
throw new IllegalArgumentException("ActorId should not be set null.");
}
if (actorName == null) {
throw new IllegalArgumentException("ActorName should not be set null.");
}
this.actorId = actorId;
this.actorName = actorName;
}
これにより、以下の sakila.http を実行したとしても、200 で成功はせず、期待通りのエラーを受け取ることが可能となりました。
POST http://localhost:8080/api/regist
Content-Type: application/json
{
"lastUpdate": "2022-10-19T19:34:33.000+00:00",
"actorId": {
"value": 201
},
"actorName": {
"firstName": "KENTA",
"lastName": ""
}
}
受け取ったレスポンスは以下の通りです。
http://localhost:8080/api/registerHTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 31 Oct 2022 10:42:36 GMT
Connection: close{
"timestamp": "2022-10-31T10:42:36.695+00:00",
"status": 400,
"error": "Bad Request",
"path": "/api/register"
}
Response file saved.
> 2022-10-31T194236.400.jsonResponse code: 400; Time: 642ms; Content length: 103 bytes
コンソールには期待通り空文字のためにエラーになったことがログ出力されています。