こんにちは。アドグローブのソリューション事業部・エンジニアのIとMです。
今回はソフトウェアアーキテクチャパターンの1つ《ADRパターン》の紹介をさせていただきます。
概要
ADRパターンとはなにか
ソフトウェアアーキテクチャパターンの1つ。
従来からあるMVCにおける問題点を改善するため、Paul M.Jones氏が考案した設計パターンのことです。
github.com
提唱されている理由
Webアプリーケーション開発ではほぼ一般化したMVCパターンですが、開発規模が大きくなるにつれ問題点が出てきました。
Controllerの肥大化
システム規模が大きくなれば大きくなるほどControllerが担うAction数が多くなり、1つのControllerの処理が肥大化してしまう。
またAction名のバッティングやチーム開発でのConflictケースも多くもなってしまいます。
プレゼンテーションロジックの記述箇所が曖昧
MVCでは各ファイルで処理を分散する目的の設計ですが、ビジネスロジックから受け取ったデータに対して描画用に加工処理する記述場所は曖昧なので実装者によって処理記述場所がブレてしまいます。
またプレゼンテーションロジックをどこかに記述しようとしても、以下のようにMVCの本質である「責務の分散」が壊れていってしまいます。
- Controllerに記述→ Controllerが肥大化する
- Modelに記述 → ビジネスロジックとプレゼンテーションロジックは処理目的の性質が異なるため処理責任がブレる
- Viewに記述 → 描画領域においてデータ加工処理は記述してはいけない上にフロントエンドの可読性が落ちる
ADRが解決してくれること
Controllerの肥大化を防ぐ
Actionは「シングルアクションコントローラ」をルールとしているため、1つのファイルに1つの責務しか持たない状態になります。
そのため、単一のActionだけでコード量が肥大化することは起こりにくいため肥大化を防ぐことができます。
プレゼンテーションロジックの記述場所を確保する
プレゼンテーションロジックはResponderに記述できるため、ControllerやModelは本来の責務である「リクエストの中継処理」「ビジネスロジック」が守られます。
またapi化やResponseHeaderの調整もResponderにまとめられるため、処理の責務が明確となります。
それぞれの役割について
Actionの役割
アクションは「DomainとResponderの中継」が目的です。
データ加工などのビジネスロジックはDomainで完結させるようにします。
そのため、Actionクラスは「Domainの呼び出し」「Responderに加工データを流す」の2種までになります。
また原則として「シングルアクションコントローラー」で定義することで
「1クラス1メソッド」の構成になり、多人数で開発しても開発者によってAction名の名がブレにくくなります。
Domainの役割
ドメインは「ビジネスロジックやデータ加工」が目的です。
大規模システムになれば仕様が複雑化しますがDomainクラス配下に処理を分散していきます。
Responderの役割
レスポンダは「描画に伴うデータ加工」が目的です。
ドメインでデータ加工したものはレスポンダに渡して描画用の処理を行ってViewへ返すようにします。
例えば「jsonへ加工」「特定の条件時に文言を変更」「配列形式を描画用に変更」 などなど。
MVCでは主にモデルから受け取ったデータからViewへ返す中間に位置する処理がレスポンダの役割となります。
そのため、レスポンダからDBアクセス処理するような処理は記述してはいけません。
あくまでも「描画に伴うデータ加工」のみ処理を行うこと。
ADRで作ってみる
ここでは「Laravel」を使用しての実演となります。
ディレクトリ構成
sample-app
├ app
│ ├ Console
│ ├ Domain
│ │ └ Services ← 新規作成
│ ├ Exceptions
│ ├ Http
│ │ ├ Controllers
│ │ ├ Requests
│ │ └ Responders ← 新規作成
│ └ Models
├ bootstrap
├ config
├ database
├ lang
├ public
├ resources
├ routes
├ storage
└ tests
Routerの登録
<?php Route::group(['middleware' => ['web']], function() { Route::get('/ja/{id}', [\App\Http\Controllers\Ja\IndexAction::class, '__invoke']); Route::get('/en/{id}', [\App\Http\Controllers\En\IndexAction::class, '__invoke']); });
Actionの作成
<?php namespace App\Http\Controllers; use App\Domain\SampleDomain as Domain; use App\Http\Controllers\Controller; use App\Http\Responders\JaResponder as Responder; use Illuminate\Http\JsonResponse; class JaAction extends Controller { protected Domain $domain; protected Responder $responder; public function __construct (Domain $domain, Responder $responder) { $this->domain = $domain; $this->responder = $responder; } public function __invoke (int $id): JsonResponse { return $this->responder->response($this->domain->find($id)); } }
<?php namespace App\Http\Controllers; use App\Domain\SampleDomain as Domain; use App\Http\Controllers\Controller; use App\Http\Responders\EnResponder as Responder; use Illuminate\Http\JsonResponse; class EnAction extends Controller { protected Domain $domain; protected Responder $responder; public function __construct (Domain $domain, Responder $responder) { $this->domain = $domain; $this->responder = $responder; } public function __invoke (int $id): JsonResponse { return $this->responder->response($this->domain->find($id)); } }
Domainの作成
<?php namespace App\Domain; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; class SampleDomain { public function find (int $id): Collection { return self::getData()->where('id', $id)->first(); } private function getData (): Collection { return collect(range(0, 10))->map(function ($value, $key) { return collect([ 'id' => $key, 'first_name' => '太郎', 'last_name' => '田中', 'sum' => 1500000, 'create_at' => Carbon::now(), 'update_at' => Carbon::now(), ]); }); } }
Responderの作成
<?php namespace App\Http\Responders; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Illuminate\Support\Collection; class JaResponder { protected Response $response; public function __construct (Response $response) { $this->response = $response; } public function response (Collection $data): JsonResponse { //ここで表示用形式変換やレスポンスヘッダ情報の変更などを行う $res = $data; $res['sum'] = '¥' . number_format($data['sum']); $res['create_at'] = $data['create_at']->format('Y年n月j日'); $res['update_at'] = $data['update_at']->format('Y年n月j日'); return response()->json($res); } }
<?php namespace App\Http\Responders; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Illuminate\Support\Collection; class EnResponder { protected Response $response; public function __construct (Response $response) { $this->response = $response; } public function response (Collection $data): JsonResponse { $res = $data; $res['sum'] = '$'.round($data['sum'] / 133.08, 1); $res['create_at'] = $data['create_at']->format('D n/j/Y'); $res['update_at'] = $data['update_at']->format('D n/j/Y'); return response()->json($res); } }
リクエスト結果
// ja { "id": 1, "first_name": "太郎", "last_name": "田中", "sum": "¥1,500,000", "create_at": "2022年8月5日", "update_at": "2022年8月5日" } // en { "id": 1, "first_name": "太郎", "last_name": "田中", "sum": "$11271.4", "create_at": "Fri 8/5/2022", "update_at": "Fri 8/5/2022" }
終わりに
いかがでしたでしょうか。
「JaAction」「EnAction」ともに同じSampleDomainからデータ受け取っていますが、レスポンダによって異なった結果を得ることができています。
これによりDomainはビジネスロジックに集中し、レスポンダは描画用の処理に集中できる状態を作ることができました。
今回試してみてADRにも課題はありました。
- 小規模システムだとActionのように最小単位で持つと機能全体がControllerと比べて把握しづらい
- Service等のConstructorInjectionするようなクラスは毎回コンストラクタに同じようなことを書く必要があるので手間に感じた
- Actionsファイル数が多くなるため、命名規約を決めて統一しないと指定のファイルが見つけづらく散乱してしまう
小規模な開発だとADRでは細かすぎるのでMVCのほうが全体が把握しやすいので良いのかなと思います。
ただ今後規模が大きくなる見込みがある、大規模システムや開発人数が多い場合は1つのファイルが肥大化しにくいADRのほうが運用しやすい構成になるのかもしれません。
どんなものにも対応できる万能なアーキテクチャはないと思いますが、開発するアプリケーションに応じてベストな構成を選定すると良いと思います。
最後までお読みいただきありがとうございました。
参考文献
https://github.com/pmjones/adr
http://paul-m-jones.com/adr
https://martinbean.dev/blog/2016/10/20/implementing-adr-in-laravel
現在アドグローブではシステムエンジニアを含め、さまざまなポジションで一緒に働く仲間を募集しています。
詳細については下記からご確認ください。みなさまからのご応募お待ちしております。