はじめに
こんにちは。前回の記事ではコード標準化に勤しんでいたneueです。
連載中のモダン開発を行う際、自身はプロジェクトの本メンバーではないものの、
処理の共通化や骨組みの構築などを通して、開発支援に近い立場で参画していました。
モダン開発を実現するにあたり、設計思想の遵守に必要なロジックを愚直にコードで表現していると、早い段階で手続きの多重化やコード量の増加と向き合うことになるのではないかと思います。
その設計思想に寄り添って作られたフレームワークがあれば話は別ですが、実際のところは既存のフレームワークとの共存方法を探りつつ、フレームワークがもたらす利便性や開発体験を、ある程度損なわない仕組みを自前で用意することになるのではないでしょうか。
今回はモダン開発の番外編として裏側の一部を切り取り、データクラス(主にDTO)の実装に使用したLaravel-dataというライブラリについて、紹介いたします。
DTO(Data Transfer Object)とは
DTOとは、プログラミングにおけるデザインパターンの一種で、関連するデータがまとまっており、書き込み/読み取りが可能なオブジェクトのことを指します。
主に、プログラム間やレイヤー間のデータの受け渡しに用いられます。
DTOを介することで、以下のようなメリットを享受することができ、
レイヤードアーキテクチャに則った今回の設計でも活用しました。
- 受け渡し先に必要なデータ構造にすることで、責任の範囲を明確化できる
- メソッドのパラメーターをシンプルに保てることで、定義の重複や改修時の不備を避けられる
- DTOの生成時点で型チェックやバリデーションを行うことができる
- 利用側は必須・任意パラメーターの情報を前提とした実装にすることができる
- (濫用は避けるべきではあるが)再利用が可能になる
Laravel-dataとは
Laravel-dataは、Spatieが提供する以下のような機能を持つデータクラスを作成するためのライブラリです。
DTOをはじめとしたデータクラスの実装にあたり、あると便利な機能が揃っている印象でした。
使用方法
導入はいたってシンプルで、基本的にはLaravel-dataが提供するData
の子クラスを作成するだけです。
<?php use Spatie\LaravelData\Data; class PostData extends Data { public function __construct( public string $title, public string $body, public ?int $authorId, ) {} }
作成した子クラスにアトリビュートを定義することで、値の検証や変換の機能を付与することができ、データオブジェクト生成用のメソッドを追加することで、読み手に優しく防御的なデータクラスが表現できます。
<?php use Spatie\LaravelData\Attributes\Computed; use Spatie\LaravelData\Attributes\MapInputName; use Spatie\LaravelData\Attributes\Validation\Max; use Spatie\LaravelData\Attributes\Validation\Min; use Spatie\LaravelData\Data; use Spatie\LaravelData\Mappers\SnakeCaseMapper; use App\Models\Author; use App\Models\Post; // 生成元オブジェクト上でスネークケースのプロパティ名をキャメルケースに変換 #[MapInputName(SnakeCaseMapper::class)] class PostData extends Data { // 計算済みプロパティはコンストラクターのプロパティに含めずに定義 #[Computed] public ?string $authorName; public function __construct( // バリデーションルールを定義 #[Min(20), Max(50)] public string $title, public string $body, public ?int $authorId, ) { // パラメーターに渡された値を元にした計算をコンストラクター内で行いプロパティの値を初期化 $this->authorName = is_int($authorId) ? Author::first($authorId)?->name : null; } // Postインスタンスからのデータオブジェクト生成用メソッド // ※Eloquent Modelは標準で生成メソッドが用意されているため、互換性があれば定義不要 public static function fromPost(Post $post): self { return new self($post->title, $post->body, $post->author?->id); } }
実際にデータオブジェクトを生成する際は、from
メソッドを用いるのが一般的です。
他言語でいうところのオーバーロードに近い使用感で、引数の数や型を元に、実際に処理に使うメソッドが自動的に選択されます。
<?php PostData::from(['title' => 'title', 'body' => 'body']); PostData::from('{"title":"title","body":"title"}'); PostData::from(Post::first(1));
その他の機能についても、公式ドキュメントにてサンプルコード込みで簡潔に紹介されており、比較的学習コストは低いため、この記事を読んで気になった方はぜひ一度ご確認ください。
使用にあたって工夫したこと
基本的には標準機能を活用しつつも、実際の使用にあたってはいくつか検討や調整が必要になりました。
データ種別ごとの親クラスを作成
具象データクラスは、目的ごとの共通のふるまい(生成ロジック、変換に必要な処理など)を一元化するため、Data
を直接継承せず、目的別の抽象データクラスを経由させました。
<?php abstract class BaseActionInputDto extends Data { // Actionへの入力用DTOで使用する共通のふるまいをメソッドとして定義 } // 上記の抽象クラスを継承することで、Dataの提供する機能+目的別の追加機能が使用可能 class UserCreateActionInputDto extends BaseActionInputDto {} class PostEditActionInputDto extends BaseActionInputDto {}
アトリビュートにまつわる注意点
Data
の作り上、アトリビュートは実行時のクラスに定義したものだけが読み取られます。
つまり、マッピングなどのルールが共通事項であっても抽象クラス側に書くことができません。
定義先が分散しない利点はありますが、個別の特性か共通の特性かが分かりにくくなるので、やや惜しさを感じます。
<?php #[MapInputName(SnakeCaseMapper::class)] // 効かない abstract class BaseActionInputDto extends Data {} #[MapInputName(SnakeCaseMapper::class)] // 効く class UserCreateActionInputDto extends BaseActionInputDto {}
共通の抽象クラス
余談にはなりますが、データクラスに限らず同種クラスの単位では共通の抽象クラスを経由させておくことで、静的解析やメタ的な判定がしやすくなるという副次的なメリットもあります。
その際は、phpatなどを用いて継承のルールが徹底される状態にしておくと信用度も上がります。
<?php $isEntity = is_a($user, BaseEntity::class);
自動キャスト
Laravel-dataにはキャストという機能があり、入出力間で型を変換することが可能です。
主にプリミティブ値(文字列、数値など)として入力されたものを、DateTime
やEnum
など扱いやすいオブジェクトに変換する目的で使用されることが多く、独自のキャストクラスを追加することもできます。
Enum
BackedEnum
用のキャストクラスは標準で用意されていましたが、システム上ではPureEnumが多かったこともあり要件を満たせませんでした。
標準のキャストクラスを無効化したうえで、共通の親クラスであるUnitEnum
用のキャストクラスを作成することで、値だけでなくインスタンス名からもEnumキャストできるようにしました。
配列の要素
配列における各要素の型は、PHPDoc上では明示できるものの、現在のPHPの型宣言では表現することができません。 ゆえに型宣言を元にしてキャストすることも難しいのですが、要素の型を情報として付与するアトリビュートを作成し、配列に自動適用するキャストクラスを作成することで実現しました。
<?php private function __construct( // int配列にしたいとき #[ArrayOf('int')] public array $counts, // 特定のEnum配列にしたいとき #[ArrayOf(ClientType::class)] public array $clientTypes, ) {}
任意プロパティの省略
Laravel-dataには、オプショナルプロパティという機能があり、一部のプロパティが省略可能なデータオブジェクトを生成できます。
基本的にはユースケースの単位で専用のDTOを用意しているので常に必要なものではありませんが、例えば同一の更新処理であっても、一部のプロパティが操作したユーザーの権限によって送信されないケースなどで重宝します。
nullといった値に置き換えて判別する方法と比べて「そもそも情報を送信しない場合」と「送信はするが値が空の場合」の区別が可能になり、型宣言を見るだけでどのパターンが許容されているプロパティなのかを把握しやすくなります。
<?php class UserDto extends BaseDto { public function __construct( // 空の値が許容されるプロパティ public ?string $nullableProperty, // 未送信が許容されるが空の値は許可されないプロパティ public string|Optional $optionalProperty, // 未送信も空の値も許容されるプロパティ public string|Optional|null $optionalNullableProperty, ) {} }
遅延格納型の計算値プロパティ
パラメーターで渡された値を元にした計算値プロパティは、コンストラクター内で初期化を行うことで実現可能です。
ただし、常に利用されない値など利用されるタイミングで初期化処理が走ればよいものも多かったため、抽象クラス側のマジックメソッド(__get
, __isset
)と、具象クラス側のアクセサーメソッド(getXxxxAttribute
)を定義することで実現しました。
<?php abstract class BaseDto extends Data { public function __get(string $name): mixed { // $this->get{$name}Attributeメソッドの存在確認+実行結果の返却 } } class UserDto extends BaseDto { protected function getFullNameAttribute(): string { return $this->lastName . ' ' . $this->firstName; } } echo $user->fullName; // 'Yamada Taro'
※アクセサー用にgetXxxxAttribute
といった名前のメソッド名を定義する方式は、Laravel8以前のEloquent Modelから着想を得ていますが、9以降は実装方式が変更されているので、今後見直す予定です。
主な使用箇所
続いて、実際のシステムではどのような場面で使用したのかをご紹介します。
ユーザー入力からユースケースへの情報受け渡し
DTOの使用例としては、ユーザーインターフェイス層とアプリケーション層間のデータ連携があります。
ユーザーが入力した情報を元に一度DTOを生成して、ユースケースに受け渡すようなシーンです。
基底のFormRequestに、内部に持つリクエストオブジェクトを用いてDTOを生成するメソッドを定義しておくことで、コントローラー側は入出力とオブジェクト間の中継役に徹することができます。
コントローラー上の流れ
- 対象画面用のFormRequestがDIされ、入力値のバリデーションが行われる
- バリデーションを通過した際に、リクエストオブジェクトから対象ユースケース用のDTOを生成
- 対象ユースケースの呼び出し時の引数としてDTOを渡す
<?php class UpdateController extends BaseController { public function __invoke(UpdateRequest $request, UpdateUserAction $updateUserAction): RedirectResponse { // DTO作成・ユースケース実行時などのエラーハンドリングは省略 $updateUserAction($request->toData(UpdateUserActionInputDto::class)); return redirect()->route('users.show', ['id' => $request->id]); } }
ドメインオブジェクトの情報をビュー向けに加工
もう1つの使用例は、ドメインオブジェクトの情報をビューで扱う際に使用する独自のデータオブジェクトです。
クエリのユースケースにはドメインオブジェクト(エンティティなど)を返すものがありますが、対象のビューにとっては不要な情報を含んでいたり、外部オブジェクトへの参照がID形式などの情報の過不足に加え、連載でも触れられていた不都合な点があるため直接は使用できません。
ビューの責務としては渡されたデータを過度な加工なく表示する程度に留めておきたく、かといってコントローラー上で細かい加工処理まで受け持ってしまうと全体の流れの見通しが悪くなりがちです。
加工内容や具体的な方法については、いくつかのパターンに分類できることもあり、ビューで必要な情報・加工方式を定義するだけで希望形式のデータオブジェクトを返却するデータクラスを作成することになりました。
コントローラー上の流れ
- ユースケースを用いて対象のエンティティを取得
- エンティティ(と情報突合用の別オブジェクトのコレクション)からデータオブジェクトを生成
view()
の第2引数(配列)の一要素としてデータオブジェクトを渡す
<?php class EditController extends BaseController { public function __invoke(string $id, GetUserAction $getUserAction, GetAllDepartmentsAction $getAllDepartmentsAction): View { // 部署のエンティティコレクションを(再利用するのであらかじめ)取得 $departments = $getAllDepartmentsAction(); return view('pages.users.edit', [ 'user' => UserVoo::from( // 対象のエンティティ(ユースケース経由で取得) $getUserAction($id), // ユーザー内の所属部署がID参照のため、部署情報との突合用に部署のエンティティコレクションを渡す ['department' => $departments], ), // 部署のエンティティコレクションからデータオブジェクト配列に変換(collectionFromは独自実装) 'departments' => DepartmentVoo::collectionFrom($departments), ]); } }
データクラス上の処理
利用側をシンプルに保つ反面、裏側の処理としてはデータクラスの定義を走査して、対象のドメインオブジェクトの情報を加工しつつ当てはめたものをデータオブジェクトとして返す、泥臭い作りになりました。
なお、突合時に必要な外部コレクションの取得を内部に持たせることも技術的には可能でしたが、ユーティリティ的概念からユースケースへの暗黙的な依存が生まれるリスクも感じたため、今回はコントローラー上で取得したものを引数として渡す実装にしました。
- データの絞り込み
- 入力されたエンティティ・値オブジェクトから、データクラスに定義したプロパティの情報のみを取得
- データの構造化
- データクラスのプロパティの型として別のデータクラスを指定している場合は再帰的に処理(Laravel-dataは入れ子に対応)
- データの加工
- データの拡張
- 定義での表現が困難な項目や計算値は、アクセサーメソッドを定義することで実現
おわりに
ライブラリの力も借りつつ、データの挙動を制御する処理を基底側に閉じ込めることで、実際の開発時に多く目に触れるであろう具象クラスは、概念の所有する情報が際立つオブジェクトに近づけることができました。
もちろん良いことばかりではなく、与えられている責務がデータクラスの範疇を超えているという見方や、ブラックボックス化やパフォーマンスなどの懸念がないわけではありません。
設計思想であらかじめ定めた遵守すべきラインの中で、本来の目的・運用時のコスト・開発者の属性・準備期間なども加味して今回の最適解を導き出したつもりですが、状況が変化したタイミングでより良い作りにアップデートすること(できるような作りを意識すること)が前提になるのではないかと思います。
長くなりましたが、スタイル・エッジでは、こういった縁の下のパワータイプなエンジニアもガッツリ募集中です!一緒に足場を組み立ててみたいと思いましたら、ぜひ採用サイトをご覧ください。 recruit.styleedge.co.jp