generated by DALL-E3
はじめに
モダン開発連載 もいよいよ最終回。わたくしneueがお届けいたします。
番外編 の冒頭でも触れていた内容にはなりますが、設計思想とフレームワークの良いとこ取りをしようとすると、必然的に両方に即したアプローチが求められます。中には思想同士が競合する部分もあり、折衷案を出すことすら困難で取捨選択を迫られることもあります。
そういったアーキテクチャの不和を、開発者が極力意識せずにフレームワーク利用の延長線 ぐらいの感覚でプロダクト自体の設計・実装を進めることができ、量産されていくコードも自然に一貫性を保たれる ような開発者インターフェイスを提供するには、水面下で多くの工夫を凝らす必要がありました。
まだ改善の余地がある状態ではありますが、本記事ではモダン開発の下地に取り入れた工夫をご紹介いたします。似た立場で開発基盤の設計と格闘している方にとって、何かしらの参考になれば幸いです。
※本記事で扱われている言語はPHP、フレームワークはLaravelです
具体例の紹介
各概念における抽象クラスの作成
量産対象となる、フレームワークであらかじめ用意されているクラス(Controller
, FormRequest
など)および、独自に追加したクラス(Entity
, ValueObject
など)ほぼ全てに、独自の抽象クラスを作成しました。
一貫して抽象クラスを継承させておくことで、以下のようなメリットを得ることができます。
あらかじめ全体共通のふるまいを定義できる
後から全体共通のふるまいが必要になった際に、具象クラスの修正が不要
各具象クラス及び生成されたインスタンスが何者であるかを外部から判定しやすくする
<?php
abstract class BaseEntity {}
class User extends BaseEntity {}
<?php
if ( is_a ( User:: class , BaseEntity:: class , true )) {}
$ user = User:: create( ~~~ ) ;
if ( is_a ( $ user , BaseEntity:: class )) {}
継承先からは継承元のふるまいを一部変更することはできても、打ち消しをすることはできないため、全体ではないが一部で共有されるふるまいは別の形で実現します。
下位概念が対象範囲のケースは、上位概念を継承した下位概念の抽象クラスを作成し、そちらに定義
個別の判断が必要なケースは、トレイトに定義
<?php
abstract class BaseEntity {
}
abstract class BaseRootEntity extends BaseEntity {
}
abstract class BaseLocalEntity extends BaseEntity {
}
<?php
class User extends BaseModel
{
use SoftDeletesTrait;
︙
}
量産対象となる具象クラスの記述量を減らす
レイヤーを分割したことで、情報の受け渡しにおいて同じような情報を複数回記述する必要が出てきました。
例えば、レイヤー間でDTOに詰めたり取り出したりする工程であったり、同クラスの静的メソッド間での持ち回し(例:エンティティのファクトリーメソッドに渡ってきた引数一式をバリデーションやコンストラクターに渡す)など、個数が多くなりがちな属性周りにはこの問題が付いて回ります。
情報の一部を選択して渡すケースではさておき、全て(または一定条件に一致するもの)を渡すケースについては、列挙することが人為的ミスの温床にもなりかねません。
もっと簡素な書き方で実現できないかと頭を捻り続け、結果的には黒魔術に手を染めることになりました。
LaravelDataの活用
レイヤー間については、番外編で紹介したLaravelData を基盤としたDTOによって解決しています。
ユーザー入力からユースケースへの情報受け渡し に記載した通り、独自の入力用メソッドさえ定義してしまえば、あらゆる形式(リクエストオブジェクトやJSON文字列など)から、スキーマとして定めたデータ構造に当てはめることができ、受け渡し先が要求する形式で取り出すことが可能です。
入出力ペアごとにDTOの抽象クラスを作り、入力と取り出し補助に関するロジックはそちらにカプセル化しました。
連想配列と引数のアンパックの活用
PHPではメソッドの呼び出し時に、配列やTraversableなオブジェクトを引数リストにアンパックできます。その際、添字配列の代わりに連想配列を渡すと、キー名と合致するパラメーターに対して引数が展開 される仕様になっています。
<?php
function sum( int $ foo , int $ bar , int $ baz ) : int {
return $ foo * $ bar + $ baz ;
}
sum( ... [ 1 , 2 , 3 ]) ;
sum( ... [ 'baz' => 1 , 'bar' => 2 ,'foo' => 3 ]) ;
sum( ... [ 'foo' => 3 , 'bar' => 2 , 'baz' => 1 , 'foobar' => 1 ]) ;
同クラス内でほぼ同じパラメーターを持つメソッドへの引数の受け渡しについては、わざわざデータオブジェクトを介在させる必要もないため、上記仕様の活用を起点に対応方法を考えました。
ちなみに、引数の単純な添字配列についてはfunc_get_args()
で取得でき、呼び出し元と呼び出し先のパラメーターの数や順序が完全一致していれば、そのまま渡すだけで済んでしまう話ではあるのですが、多くのケースでは一部のみの受け渡しや、値を書き換えてから渡す必要がありました。
下記の課題を解決すれば採用が現実的であると判断し、PHPの機能を調査しました。
自身に渡された引数とパラメーター名を使って連想配列を作れること
自身に渡されなかった(省略された)引数は、デフォルト値に置き換わること
呼び出し先メソッドのパラメーターに存在するキーのみに対象をフィルタリングできること(エラー防止)
手動で一部の値の上書き・引数の追加ができること
リフレクションによる内部情報の利用(黒魔術)
リフレクション を使用することで、クラスや関数など多くの内部情報(型やプロパティ、コメントさえ)にアクセスできるようになるため、課題解決に必要だった以下の情報が入手できます。
呼び出し元メソッドのパラメーター(名前、初期値)
呼び出し先メソッドのパラメーター(指定した名前のものが存在するか)
以下の工程を踏むことで、目的のデータが作成可能になりました。
自身に渡された引数配列とパラメーター名配列をarray_combine
して連想配列を作成
省略された引数はパラメーターの初期値で置き換え
呼び出し先メソッドのパラメーターに存在するキーの項目だけに絞り込み
上書き・項目追加用の連想配列をマージ
ただし、抽象クラス側に定義したこのメソッドを呼び出す際、引数と呼び出し元メソッドの情報を渡す必要が出てきてしまいました。
<?php
class User
{
public static function create( 〜〜〜) : User
{
$ arguments = func_get_args () ;
static :: validateFromArguments( $ arguments , 'create' ) ;
static :: createInstanceFromArguments( $ arguments , [ ]) ;
}
}
バックトレースによる呼び出し元情報の参照(暗黒魔術)
PHPにはdebug_backtrace という、バックトレースを生成するエラー処理のための関数があります。
指定したスタックフレーム数まで、呼び出し元のメソッド・関数に関する情報を参照できます。
こちらを利用することで、以下の情報が入手できます。
呼び出し元のクラス名・メソッド名
呼び出し元のメソッドに渡された引数
これらの情報を利用することで、呼び出し元で引数配列を先に取得する必要もなくなり、呼び出し元のメソッドが複数(N:1)のケースにおいて、どのメソッドから呼び出したかという文字列情報も不要になりました。
<?php
static :: validateFromArguments() ;
static :: createInstanceFromArguments([ ]) ;
使用にあたっては、スタックフレーム数の指定や、不必要な情報の参照を無効化するオプションを指定しないと、メモリの負荷が高くなってしまうため注意が必要です。
実際の使用にあたって
正直なところ、内部情報や実行時のトレース情報を利用することは、関数の純粋さが大きく失われてしまい抵抗感はあります。
ただし、Laravel内部でも使用されている ことや、実装者視点で得られる恩恵があまりにも大きいことから、今回は用途限定で採用することにしました。
また、渡していないデータが暗黙的に参照されること自体も本来アンチパターン であり、コードを読んだだけでは挙動が想像できないため、ささやかな情報としてメソッド名にFromArguments
といった接尾辞を含めることにしました。
ビジネスルール検証におけるValidatorの活用
DDDにおいて、ドメインオブジェクトが持つビジネスルールは、原則クラス内にカプセル化します。
インスタンスの作成や更新前に各属性に関するルールチェック、後に複数属性にまたがる整合性チェックを遅延バリデーションの形で行い、ルール違反が見つかった場合には例外をスローするといったアプローチが一般的かと思います。
従来パターンにおける課題
属性数が多くなったりビジネスルールが複雑になるにつれて、検証ロジックのコード量・分岐数の増加を招きます。
中でも、属性のルールチェックに関してはルール自体が汎用性が高いものであることが多いにも拘らず、通常の分岐を用いると行数が増加 していってしまい、ルールが把握しづらくなってしまいます。
<?php
class User
{
private function setAge( int $ age ) : void
{
$ errors = [] ;
if ( strlen ( $ age ) > 3 ) {
$ errors [] = 'age.maxDigits' ;
}
if ( $ age < 0 ) {
$ errors [] = 'age.positiveInteger' ;
}
if ( $ age > 120 ) {
$ errors [] = 'age.max' ;
}
if ( count ( $ errors )) {
throw new DomainValidateError( $ errors ) ;
}
$ this -> age = $ age ;
}
}
Validatorを用いる前提で宣言的に記述する方式
各属性に関するルールチェックはLaravelのValidatorに依存させる形で実装して、ルールはFormRequestのように宣言的に記述する方式を採用しました。
ルールを、配列を返す静的メソッドに切り出すことで、各属性の一般的なビジネスルールが一目瞭然になりました。
<?php
class BaseEntity
{
protected static function validate() : bool
{
}
}
class User extends BaseEntity
{
private static function rules() : array
{
return [
'firstName' => [ 'max:80' ] ,
'lastName' => [ 'max:80' ] ,
'age' => [ 'positive_integer' , 'max_digits:3' , 'max:120' ] ,
] ;
}
}
オブジェクトが入れ子になっている場合の責務の所在
各オブジェクトで直接検証を行うのはプリミティブ型の属性に限定し、オブジェクト型が指定された属性の検証は、当該オブジェクト側の責務として、再帰的に検証される 作りにしています。
例えば、年齢がAge
といった値オブジェクトとして実装されているのであれば、ルールの定義・検証はいずれもUser
でなくAge
側で行います。
<?php
class User extends BaseEntity
{
private function __construct (
protected string $ firstName ,
protected Age $ age ,
) {}
public static function create(
string $ firstName ,
int $ age ,
) : User
{
static :: validate() ;
$ convertedValues = static :: convertObjects([ 'age' => $ age ]) ;
$ user = static :: createInstance( $ convertedValues ) ;
$ user -> validateIntegrity() ;
return $ user ;
}
private static function rules() : array
{
return [
'firstName' => [ 'max:80' ] ,
] ;
}
}
class Age extends BaseValueObject {
public readonly int $ value ;
private function __construct ( int $ age )
{
$ this -> value = $ age ;
}
public static function create( int $ age ) : Age
{
static :: validate() ;
return static :: createInstance() ;
}
private static function rules() : array
{
return [
'age' => [ 'positive_integer' , 'max_digits:3' , 'max:120' ] ,
] ;
}
}
その他ポイント
Validatorを用いると、起点となるユーザーインターフェイス層で違反を扱う際に下記のようなメリットもあります。
特別な工夫なしに各属性のルール違反をひとまとめにできる(ErrorBag
)
メッセージのフォーマットとして、Laravelのメッセージファイルを利用できる
また、サンプルコードにしれっと紛れ込んでいますが、Laravel標準のバリデーションルール には存在しないもので、ビジネスルールとして頻出するものについては拡張しています。(例:正の整数、カタカナのみなど)
今回触れなかった整合性チェックについては、クロージャ形式で混在させることも不可能ではないものの、視認性が高くはなく再利用性も低いため、従来のValidator
を使用しない記述方式で遅延バリデート*1 することにしました。
フロントエンドバリデーションとの数値ルール共有
ユーザー体験向上のためには、即座に応答できるフロントエンド時点でも入力バリデーションを行う必要があります。
ビジネスルールとユーザー入力における制限が必ずしも一致するとは限りませんが、多くは連動しています。
ドメイン層とユーザーインターフェイス層で、同一の根拠を持つバリデーションルールを別々に保持することは、差異発生のリスクがあるため、ドメイン層にのみ情報を持ち、そちらに依存させる ことが望ましいでしょう。
ルールセットとしてフロントエンドと同期するには、TypeScriptの型やZod などのスキーマの形式で取得可能にするのが1つの理想ではあります。
現時点では、個別で取り出したいケースがある点も考慮し、多重管理リスクの大きい数値系ルール のみを扱いやすい形式で切り出し、Controllerから取得可能にしました。
<?php
class User
{
public static function numericLimits() : array
{
return [
'firstName' => [
'max' => 80 ,
] ,
'lastName' => [
'max' => 80 ,
] ,
] ;
}
private static function rules() : array
{
return [
'age' => [ 'positive_integer' ] ,
] ;
}
}
数値系ルールを構造化したオブジェクトをViewに渡すことで、属性値などとして個別参照が可能です。
< x-form -input
type = "tel"
︙
: maxlength = "$numericLimits->user->phoneNumber->max"
/>
Eloquent Modelとの共存
Laravelのコアといっても過言ではないEloquent Modelは、包括的な概念でありつつ、Laravelが提供する多くの機能との関連性を持っています。
レイヤーを越境しやすい性質を持っていることから、DDDやレイヤードアーキテクチャといった設計思想における責務分離の考え方と、最も競合しやすい概念 ではないでしょうか。
ORMとして同品質の概念を再開発する自信と時間はさすがにないため、使用自体は許容しつつRepository
やQueryService
から情報を返す際に、ドメインオブジェクトに変換してから返却するルールにすることで、インフラストラクチャ層から外へはModelを原則露出させない 方式を選択しています。
<?php
class UserRepository extends BaseRepository
{
public function find( int $ id ) : ? User
{
return UserModel:: find( $ id ) ? -> convertEntity() ;
}
}
class UserModel extends BaseModel
{
public function convertEntity() : User
{
return User:: reconstruct(
id: $ this -> id,
name: $ this -> name ,
︙
) ;
}
}
Modelの責務としては
データベース操作の抽象化
データベース上とシステム上で扱うデータ形式の相互変換
リレーション先のデータ取得
に留めて、各層からは直接依存させないことで、あくまでORMとしての役割を担ってもらっています。
ユーティリティクラスなどのレイヤー外概念
プログラムで取り扱う概念の中には、問題領域・解決領域いずれも直接の関係はなく、開発そのものや非機能要件にまつわる課題を解決するための概念が存在します。
これらはいずれかのレイヤーに所属させても矛盾が発生することがあるため、切り離して考えることにしました。
(正確には、この領域から各レイヤーへの依存は許可せず、この領域に対する依存については制約を設けない)
情報・関数
システムによって変動することのない、再利用性の高い補助的な手続き(例:文字列加工、年号変換)については、ユーティリティクラスとして外部化し、Laravelのヘルパー 同様、エイリアスとして登録することでどこからでも参照できるようにしました。
具体的な手続きに名前を付けて外部化する ことで、メインロジックをクリーンに保てるのと同時に、本来求められているふるまいが際立つことで、処理のアウトラインを追う 目的でのコードリーディング負荷は大きく下げられます。
より良い手段が見つけられた際に仕組みの交換が容易になることもメリットに感じました。
<?php
$ originalAttributes = $ request -> attributes () ;
$ replacedKeys = array_map ( '\Str::camel' , array_keys ( $ originalAttributes )) ;
$ attributes = array_combine ( $ replacedKeys , $ originalAttributes ) ;
$ attributes = \ArrayHelper:: replaceKeys( $ request -> attributes () , '\Str::camel' ) ;
内製パッケージ
今回のシステムに合わせて開発したものの、システムを選ばずに導入ができそうな機能(例:操作ログ)や、外部サービスとの連携の仕組み(例:Amazon TimestreamのORM)などについては、関連ファイルをあえて一纏めにした状態で管理しています。
異なるシステムが持つ要求の差異を吸収できるほどの汎用化を終えたら、社内向けのパッケージレジストリ上に管理を移行することを前提として、あらかじめ関連コードが分散しないよう関心事にフォーカスしたディレクトリ構成 となっています。
また、動作に必要な実装をシステム側で管理する場合は、必ずインターフェイスを提供することで、規格を分かりやすく実行前から基準を満たしているかを確認できるようにしています。
App\Support\Packages\OperationalLogger
├── OperationalLogger.php
├── OperationalLoggerRepositoryInterface.php
├── Http
│ └── Middleware
│ └── RecordOperationalLogs.php
└── Providers
└── OperationalLoggerServiceProvider.php
文字列フォーマット・設定値
前述の2つとは性質が異なりますが、同じくレイヤー外で扱う情報はあります。
画面に表示する各種メッセージの文字列フォーマット、データベースで管理するほどではない挙動制御のための設定値については、Laravelの設計に則ってlang
, config
配下に独自の定義を追加することで、一元管理による一貫性の維持とハードコーディング防止に努めています。
<?php
formatDate( $ date , config( 'date.formate.datetime' )) ;
class ItemNotFoundException extends Exception
{
public function __construct ( $ itemName , $ code = 0 , $ previous = null )
{
parent ::__construct ( trans( 'exception.item_not_found' , [ 'item' => $ itemName ]) , $ code , $ previous ) ;
}
}
課題として、config()
で返却される値について型の保証が標準では難しいため、エイリアス関数を用意してそちらで返却時の型情報を補完するか、各設定にあるPHPDocの情報を参照してヒントを与えるような、PHPStanの拡張機能を自作する必要性を感じています。
Make系コンソールコマンドの充実
DDD・レイヤードアーキテクチャに則って開発していることもあり、従来のLaravelに比べて開発時に扱う概念(クラス)の種類は多くなっています。
独自クラス(エンティティ、目的別DTOなど)は、Web上に情報が掲載されていないため、
必要プロパティの示唆
定義するメソッドのサンプル
取り扱いにあたっての注意点
といった情報を目に留まる場所に記述して、正しく扱えるようにする必要があるのと、
既存クラスについても、
名前空間の変更
継承元の抽象クラスの独自化
コメントの日本語化・PHPDocにおける型定義の詳細化
といった調整箇所が存在します。
Laravelには、各種クラスのファイルを生成するためのArtisanコマンドが整備されており、その中でファイルの雛形(スタブという)も管理されているため、仕組みに乗る形でパターン別の対応を行いました。
既存クラス
静的文字列の変更のみ
引数やオプションの拡張、名前空間の変更、動的文字列の変更が必要
スタブファイルの編集
対象のMakeCommandを継承したコマンドを作成・処理の上書き
新規クラス
また、他コマンドからのコマンド呼び出し を活用することで、特定のクラスと実質セットで作成することになるインターフェイスやユニットテストを同時作成するようなオプションを拡張し、作業工程の短縮を図っています。
例:
# User集約内にRelatedPersonというローカルエンティティとそれに紐づく
# エンティティコレクション(エンティティのリストをラップしたクラス)を生成
php artisan make:entity User/RelatedPerson -lc
# Actionとそれに紐づく入力用DTO、ユニットテストを生成
php artisan make:action User/Get -dt
今回は作成しませんでしたが、フローチャート次第で使用スタブが変わるようなケースでは、オプションの組み合わせで判断するのではなく、対話式のインターフェイスにしてみると無効な組み合わせパターンを防ぐことができそうです。
仕組みが整っていない場合にはよくある、類似ファイルを複製して調整を掛ける運用では、記述の過不足が発生するリスクがあるため、スタブファイルをバージョン管理し、基本ルールに変更が生じた場合はそちらにも修正を加える 運用が安全です。
静的解析・リントツールの整備
コード品質の維持には、一貫性を守るためのルール整備が必要です。
しかし、ルールを人力だけで徹底しようとすると、かえって作業効率が低下したりレビュー工数が肥大化したりすることが予想されるため、可能な限りルール違反は静的解析で検出できるようにしています。
PHPStanの拡張機能は、Laravel組み込みクラスへの対応としてlarastan を、レイヤードアーキテクチャにおける正しくない方向への依存発生、所定の抽象クラスを継承していない具象クラスの検出に、phpat を活用しています。
また、独自クラスにおける必須指定などの開発時に遵守すべきルール・制約 についてもこの層でカバーします。
専用のPHPStan拡張機能を作成することで、実行時に検証を挟まずとも早期検出ができるため、ロジック側に防御的なコードが氾濫することを防止できます。
エディター・CI上でチェックを通過してからレビューに進むフローにすることで、レビュアーの負荷も低下します。
今後の計画
記述量への対策
記述量が多い部分については、まだ全てが解消には至っておらずボトルネックとなっています。
しかし、レイヤーを分割し然るべき粒度で概念を分けるとなると、元の状態に比べてある程度増えることは当然とも感じます。
記述量を減らすといったアプローチだけではなく、1つのクラスの内容を元に関連クラスの内容を生成するジェネレーター・コンバーターの開発であったり、PHPDocの型情報とパラメーターなどの重複部分についてはAIの力を借りて自動補完できるようにするなど、まだ試せていないアプローチもこれからトライしていこうと思います。
自動テスト関連の強化・ハードル低下
自動テストに関しては、前回の記事 にもある通り、依存の中心に関するユニットテストに留まっており万全とは言えない状況です。
ユニットテストだけではカバーができていない箇所について、統合テストやE2Eテストを導入できるよう、テスト実装の共通処理を抽象化するなどして実装ハードルの低下を目指しつつ、カバレッジについても基準等を検討していければと考えています。
DevContainerの導入
環境構築の時間を短縮しつつ、設定が近い状態でチーム開発ができるようにするため、ローカル環境の動作に必要なDocker Composeの定義や、VS Codeの設定ファイル雛形・推奨拡張機能の定義をバージョン管理対象に含めて運用しています。
しかし、実際に0から環境構築を行い開発に着手できるようになるまで、一部工程については手順書が必要な状況です。
DevContainer化することで、コマンド一発でエディターのセットアップも含めた開発環境を立ち上げられるようにし、最終的にはGitHub Codespacesを活用して、レビュアーは自身のローカルを汚さずともレビュイーの作業環境をチェックできるような状態を作れるようにしたいと思います。
おわりに
断片的な紹介ではあるものの、気付けばなかなかのボリュームになっていました。
まだ改善の余地が多い熟成度なので、今後も開発現場からのフィードバックに耳をしっかりと向けながら、思考と選択を繰り返していこうと思います。
また、冒頭でもお伝えしましたが、モダン開発連載は今回で終了となります。
振り返ってみると想像以上にチームのキャパシティを超えた挑戦にはなり、一部は方針転換をすることにはなりましたが、プロジェクト開始前に比べて設計に対する捉え方・考え方が大きく成長したメンバーが複数名いたことは、弊社にとっても大きな実りになったと感じています。
仕組みの構築という支援的なポジションではありましたが、その一助となれたことは、個人的に良い経験となりました。
限られたリソースの中でも、こういった投資的な取り組みが持続できる組織でありたいと切に思います。
一緒に探求してみたい方、もっと良い方法がある…と左手が疼いている方、ぜひ一度お話だけでも聞きにきていただけたら幸いです。
recruit.styleedge.co.jp