phpでDDDをやってみたのでそこで得た知見を共有します。
ストーリーみたいなものはなくて、「こんな技術面の課題が出てきて、それにこう対応したよ」というTIPSをつらつらと書き連ねていきます。
前書き
phpのウェブフレームワークでradarphpというのがあります。
このフレームワークがDDD前提で作られていて、なかなか感じが良かったので見つけてすぐに触ってみたくなりました。
かねてからDDDに入門したいと思っていたこともあって、ちょうどその時HTTPサーバのスタブが必要だったので、試しにDDD + radarphpで作ってみることにしました。
これは趣味のプロジェクトだったのですが、作ってるうちにアプリを作ることよりも良いプログラムを書くことの方に興味が移ってしまって、最終的にプロジェクトの目標も「レイヤーやクラスを納得いくまできれいに整理すること」「グルーコード・ボイラープレート的コードを極力減らし、アプリ固有のコードの濃度を高めること」へと移っていきました。
良いプログラムを書くのは楽しいのでいろいろと模索しながら作ったのですが、その中で一定の知見として残った事柄を、この記事で紹介していこうと思います。
なお、作ったスタブHTTPサーバは「quickstub」と言います。リポジトリはここ。
構成はLAMPです。
アプリケーションの大まかな構成
本題に入る前にradarphpの簡単な説明をしておきます。
まず、DDDでは各レイヤーは次のように作用します。
従来のMVCをオーバーレイすると次のような感じでしょうか。
radarphpはMVCではなくADRというデザインパターンを採用しているのですが、radarphpはADRを少し改変したりしていて説明しだすと長くなるので、いきなり図にしてしまうと次のような感じです。
DDD前提なだけに、図がきれいに重なります。
radarphpのパターンの特徴は次の通りです。
- radarphpではインプット、ドメイン、レスポンダそれぞれをフレームワークが分離しているので境界と責務が明確
- MVCのビューは画面を作ることが責務で、リダイレクト処理などはコントローラが担っています。一方、radarphpのレスポンダは、画面の描画だけでなくHTTPヘッダも含めて、ドメイン層・アプリ層の出力をHTTPレスポンスに変換することが責務です。レスポンダはビューより責務が大きい
- MVCのコントローラは解体されて、それが担当していた責務はアプリ層の担当になったりフレームワークに吸収されたりする中、インプットという新たな部品が登場しています。これは、HTTPリクエストをドメイン層・アプリ層への入力に変換することが責務です
まとめると、radarphpでDDDをやろうとすると、次のような趣旨でクラスを作っていくことになります。
- HTTPリクエストをドメイン層の入力に変換するインプットをプレゼンテーション層に配置
- ドメイン層の出力をHTTPレスポンスに変換するレスポンダをプレゼンテーション層に配置
- アプリ層、ドメイン層、インフラ層はDDDに従ってよしなにやる
これでradarphpの説明はおしまいです。
以下、こんな感じでアプリを作って得た知見になります。
1 非アプリケーションのコードは別パッケージに
フレームワークを使っていてよく問題になるのが、フレームワークのカスタマイズやちょっとしたユーティリティの置き場所です。
本プロジェクトでは、CSRFトークンを扱うPSR-7ミドルウェアやフレームワークのルーティングの挙動を変えるクラスなどがありました。
よくあるやり方は、Commonなど共通のフォルダを作ってそこに置く、InfraやPresentationなど関連するレイヤーに置くといったところですが、思い切って別パッケージにするのが良かったです。
まずCommonフォルダに置く方法について。
DDDではドメイン、インフラなどレイヤーごとにフォルダを切り、それぞれの依存関係を気にかけます。
ですが、その横並びにCommonという層みたいなものが出来て、他の層から依存されたりされなかったりすると、なんとも収まりが悪いです。
次に関連するレイヤーに置く方法について。
この方法だと、たとえばCSRFトークンを扱うPSR-7ミドルウェアは、Web固有の関心事を扱っているので、プレゼンテーション層に置くことになります。
ただ、その層の中のクラスを見渡してみると、他のクラスがアプリ固有の事象を扱っているのに対して、こういったクラスだけが汎用的というかアプリ固有でない事象を扱っていて、こちらも収まりが悪いです。
これらのクラスは、そもそもアプリケーションのコードじゃないから同じリポジトリにあること自体がおかしいのです。
別パッケージにすることで、アプリケーションパッケージのファイル構成が見事にすっきりしました。
余計なものが一切ないこの爽快感は結構病みつきになります。
(追い出した汎用コードの方のリポジトリはこちら。)
とは言え、切り離した汎用パッケージとアプリケーションパッケージを同時に開発することには、相応の煩わしさもあります。
汎用コードの方を更新するたびにcomposer update
するのは、開発作業のリズムが失われてキツいです。
汎用コードの方は使い回しが効くのでいずれ枯れてくるはずです。それまでは、頑張るしかなさそうです。
2 DIとサービスロケータ
前提として、「DIとサービスロケータだったらDI使うのが正しいよね」という議論があります。
つまり、DIで問題ないんだったらDIを使っておくのが正解だと思います。
ところが、実際はそうはいきませんでした。
radarphpではアプリ層まではDIコンテナがオブジェクト生成の面倒を見てくれます。
裏を返せば、ドメイン層は面倒見てくれないので、ドメイン層ではIoCは使いづらいのです。
たとえば、ドメイン層のオブジェクトにログを吐かせたいと思ったとしても、ロガーをDIで注入するのは苦しいです。
「DIされる」という性質は伝播します。つまり、あるクラスAにDIしようと思ったら、Aをnewしているすべてのクラスに「クラスAのファクトリ(裏でDIコンテナがnewする)」をDIする必要が生じるので、「DIされる」という性質が広がっていくのです。
ロガーはほぼすべてのオブジェクトで使う可能性がありますので、そこらじゅうDIだらけになってしまいます。
このDIだらけの状態が正しいとはとても思えません。
それに、「ドメイン層は、DIも無いPlain Oldなオブジェクトで書きたい」という希望もあります。
うまい方法をいろいろ模索してみましたが、結局「現実問題、サービスロケータでいくしかないよね」という結論に至りました。
当プロジェクトでは、ロガー、イベントディスパッチャ(PubSubのハブ)とセッション(後述しますが、このアプリではセッションをクラス化しています)をサービスロケータから取得しています。
DIコンテナの代わりにサービスロケータを使うということは、テストの容易性や依存関係が最小限であることを捨てて、コーディングが楽であることを取ることです。
だから、サービスロケータへの依存が増えるほど、また別の面で苦労することになります。
今後は、どちらにするかを見極める目(あるいは経験)が必要になってきそうです。
なお、php7から導入された匿名クラスとclass_alias()
を組み合わせれば、Laravelのファサードみたいなサービスロケータは簡単に書けますね。
3 セッショングローバル領域はダサい
DDDやradarphpとは関係の薄い話です(後半にDDDが出てきます)。
私の周りには$_SESSION['foo']
とかSession::get('foo')
みたいなことをやっている人たちがいるのですが、世間ではどうなのでしょう?
セッションはアプリケーションのあらゆる場所で使われるユニバーサルサービスです。
セッション変数のキーがもう一つのグローバル領域になってしまっていますが、気にならないのでしょうか?
キーを定数定義クラスで一元管理とか、キーに名前空間を導入とか、どちらのやり方もキーにリテラルを使えるので強制力も無いし、重複があってもエラー出ないし、コードは読みづらいし、その場しのぎのように思えます。
Session::get(Const::MYNAMESPACE_USERID); // 読みづらくない??
「じゃあどうするか」ですが、普通にクラスにしてSession::getUserId()
とか$session->getUserId()
でいいと思います。このやり方ならキーにリテラルが使えないので強制力もありますし、重複があったらエラー出るし、何と言ってもコードが読みやすいです。
重要なことは、汎用でなく、アプリ固有のセッションクラスを作ることです。
セッション用にクラス作るの面倒と思う人もいるかもしれませんが、総タイプ量をざっと見積もってみたら、意外と変わらない気もしませんか?
それで、ここからはDDD的な話題です。
実は、セッションってレイヤーによって使い方が違いますよね。
ドメイン層やアプリ層ではアプリケーションのデータ、たとえばログイン中のユーザのデータなんかを格納します。
一方、プレゼンテーション層ではフラッシュデータとかエラーメッセージとか、プレゼンテーション層固有の使い方をします。
この2つの使い方を単一のクラスで表現するのはおかしいです。データの格納先が一緒というだけで、使い方は全然別なわけですから。
たとえば、ドメイン層からSession::setFlashFeedback()
なんてのが見えたらおかしいですよね。
そういうわけで、このアプリでは、Domain\SessionインターフェイスとWeb\Sessionインターフェイス(プレゼンテーション層で使うセッション)を定義して、これらのインターフェイスをInfra\Sessionクラスで実装することで対応しています。リポジトリで使うパターンの応用です。
namespace Infra;
class PhpSession implements \Domain\Session, \Web\Session {...}
ドメイン層にコンテキスト分割があったら、コンテキストごとにセッションクラスを用意するのも良さそうですね。
4 テンプレートへのデータ束縛を整理するとレスポンダがきれいになる
今度はレスポンダ(MVCならビューとコントローラが接するところ)の話題です。
レスポンダはドメイン層・アプリ層の出力をHTTPレスポンスに変換する部品です。
ですが、画面のあるアプリでは、ユーザ情報、通知、レコメンデーションなどなど、プログラムの本筋とは関係ないサイドコンテンツをページテンプレートに与えなければなりません。
これらのコンテンツをドメイン層・アプリ層から別途取得してテンプレートに束縛するところが、レスポンダを煩雑というか醜くします。
LaravelにView composerというのが出来ましたが、これはこの問題への回答でもあるはずです。
このアプリでは、(気に入るテンプレートエンジンが無かったこともあって、)本筋のコンテンツとサイドコンテンツを別々に取得できるテンプレートエンジンを作ってみました。
こうなると、レスポンダのプログラムの本筋はだいたい10行くらいで終わるので、本当に「ひと目」で責務が分かるようになります。
もちろん、レスポンダがきれいになるように書き方をルール化してしまえば、専用のテンプレートエンジンで補助しなくても同じことはできます。
5 プレゼンテーション層とドメイン層の間のデータ変換は半自動化の方向で
DDDではドメイン層のオブジェクトをどんどんクラス化していくことになります。
ただ、クラス化するとデータ変換が大変です。プレゼンテーション層から来たフォーム配列をドメインモデルに変換して、ドメイン処理をやったら永続化モデルに変換してからDBに保存して、今度は逆にプレゼンテーション層のためにドメインモデルをフォーム配列に戻してやらなければなりません。
これではコードが、本来どうでもいいはずのデータ変換ばかりになってしまいます。
それで、最初の図(上記)に戻ってみたいのですが、ドメイン層にはドメインモデルが、DBには永続化モデルがあります。が、Webのプレゼンテーション層にはモデルがありません。モデルが無いということはそこのデータの形は好きにしてもいいはずなので、ドメインオブジェクトにマッピングできるような形にした上で自動的に変換するようにしてしまいました。
分かりやすい例を挙げると、
class User {
public function modifyContact(Address $address, Tel $tel = null) {...}
}
class Address {
public function __construct(string $value) {...}
}
class Tel {
public function __construct(string $value) {...}
}
ユースケースが上記User::modifyContact()
を呼び出すものなら、もうその時点で自然なフォーム配列の形もバリデーションの内容も決まっています。
そしてそれはPHPプログラムで既に表現されているので、別途何かを定義するまでもなく、変換に必要な情報はリフレクションで手に入るわけです。
上記メソッド定義に合わせて、HTMLフォームを次のようにすれば、自動変換の準備は整ってしまいます。
<div>Address* : <input type="text" name="address[value]" value=""></div>
<div>Tel: <input type="text" name="tel[value]" value=""></div>
「1つのユースケースでドメイン処理を複数呼び出したい場合もあるのでは」と思う方もいるかもしれませんが、私は今のところ「それはない」と思っています。
そういう場合は、複数あるドメイン処理を1つのエントリーポイントにまとめてしまえばいいので。
この変換プログラムは最終的にライブラリに切り離したのですが、アプリケーションのコード量を大幅に減らしてくれる、有益なものが出来つつあるではないかと思っています。
6 ペイロードはプロトコルを決めてファクトリで作成
ペイロードというのは、アプリ層・ドメイン層の処理結果をプレゼンテーション層のレスポンダに運ぶコンテナです。
アプリ層は処理結果としてペイロードを作成し、レスポンダはそのペイロードを受け取ってそれをもとにHTTPレスポンスを作ります。
radarphpでは、ユースケース(アプリ層のオブジェクト)ごとに別々のペイロードクラスを用意するのではなく、すべてのユースケースで共通のペイロードクラスを使うことになっています。
実際、ユースケースごとに別々のペイロードクラスを作ると、あまり本質的でないクラスが大量に出来てしまいそうですので、radarphpの選択は正しいと思います。
ただ、次のように、プレゼンテーション層からの要請は意外と多いです。
- 処理の成功/失敗だけでなく、どんな失敗が起きたか知りたい。HTTPステータスに反映する
- フォーム入力をそのまま返してほしい。エラーが起きた場合にフォームを再表示するのに使う
- バリデーションエラーの内容を渡してほしい。フォームにエラー表示を添える
- 処理の対象となったエンティティを渡してほしい。たとえばページタイトルにエンティティの名前を表示するなど、画面の基本部分の表示に使う
- 当然だが、処理結果を渡してほしい
多くの場合アプリ層の処理は認証→権限チェック→バリデーション→ドメイン処理、と流れていきますが、どのステップも失敗してペイロードを返却する可能性があります。ですが、上記の通りペイロードはデータ項目が多いので作るのが意外と面倒、というか行数を食います。
また、ペイロードを作るたびに「このケースではどのデータを載せればいいのか?」と考えなくてはならないのも煩わしいです。
ですので、ペイロードは「どういう場合にどのデータを載せるか」「載せるデータはどういったものか」というプロトコルを事前に決めてしまって、それに従うのがいいようです。
ペイロード作成のプロトコルをファクトリクラスにまとめてしまえば、頭もコードもすっきりします(これをファクトリと呼んでいいのか微妙ですが)。
結び
今回紹介する知見は以上です。
他にも、たとえばDB周りが解決できてないとか、APIも議論のテーブルに乗せたいなとか、心残りもあるのですが、概ね満足したので一段落しました。
こういった技術面の知見を集めて脇を固めておいて、そしてアプリケーションドメインに集中できるようにしていきたいです。
なお、今回作ったスタブHTTPサーバはGitHubで公開しています。→ tyam/quickstub