LaravelのUI層

0
601

最近はLaravelのコレクションに関して重点的に投稿を行っていましたが、今回は少し話題を変えてLaravelのUI層に関して考えてみたいと思います。
いきなり「UI層」と言いましたが、ここで言うUI層はレイヤードアーキテクチャにおけるUI層です。
「レイヤードアーキテクチャって何?」と言う方は例によってGoogle先生に。

いまさらレイヤードアーキテクチャ?

アーキテクチャとしては、昨今では「クリーンアーキテクチャ」がメジャーでしょうかね?
特にDDD(ドメイン駆動設計)とセットで語られることが多いように思います(蛇足ながら、DDDは特定のアーキテクチャを意識したものではなく、DDDとクリーンアーキテクチャを密接に考え過ぎるのは間違いらしいですが)。

私もクリーンアーキテクチャに関する解説を軽く眺めてみましたが、いささか食傷気味です。弊社が実装するような中小規模Webシステムには少々過剰な印象があります(軽く眺めただけで十分理解できていないだけの可能性も大ですが)。

アーキテクチャ導入の目的は単純に言えば「役割の明確な分割」であり、ある種の処理を行うのがどこであるかがはっきりしていて、同処理に変更が必要な場合も影響範囲が極力同箇所に限定されることと解釈しています。
ただ、どれほど立派なアーキテクチャを担いだとしても、それが実装に活かせなければ意味がありません。
よって、最もシンプルなレイヤードアーキテクチャくらいを参考に、具体的にLaravelでどのように実装するかを考えてみることから始めたいと思います。

なお、レイヤードアーキテクチャに関しても、あくまでも可読性・拡張性の良い実装を行うための参考としたいだけで、実装結果がレイヤードアーキテクチャの思想に完全に合致することを目指すものではありません。

なぜUI層?

なぜ今回UI層を取り上げたかと言うと、単純に昨今API周りの実装作業を行っていてそこに関心が集中していたからと言うことなのですが、この「API」自体がUI層に注目する理由と密接に関係します。

昨今まで弊社におけるLaravelでの開発では、フロントエンドにおける表示内容(HTML)はLaravelのテンプレートエンジン(Blade)を用いて生成していました。この方法ではHTMLやCSSなどの静的要素の作成はそれ専門の職人(コーダ)が行いますが、そこへの動的要素の反映、つまりはBlade固有の書式に関する記述はLaravel職人(プログラマ)が主に行っていました。分岐やループと言った「制御」は純粋なコーダにとっては不慣れな分野と思われるからです。

上記役割分担においては、プログラム本体もテンプレート上の動的要素反映部分もプログラマの領域であったため、両者の役割分担は比較的緩やかでした。例えば価格(数字)の表示で三桁ごとにカンマで区切る必要があった場合、プログラム本体側でそのように整形したものをBlade側に渡すか、単なる数字を渡してBlade側で整形するかはプログラマが決めれば良く、かつプログラマだけが知っていれば良かった内容でした。
加えてBladeにはEloquentやコレクションなどのLaravel固有のデータ構造をそのまま持ち込めたため、便利ではあるものの、両者の境界をより曖昧にする原因にもなっていました。

しかし、今後はNuxtを用いてフロントエンドを独立的に開発し、フロントエンドとバックエンド(Laravel)の繋がりをAPI(Ajax)に限定するように構造を変えていこうとしています。そうなると表示への動的要素反映はバックエンドから切り離され、プログラマ改めバックエンドエンジニアの領域ではなくなります。前述したような表示形式に関する整形もフロントエンド・バックエンドのいずれで行うかを取り決め、関連するデータのAPI上での扱いも明確にしておく必要が生じます。

そもそも従来のBlade方式ではどうしてもコーダとプログラマの共同作業となるため、情報のやり取りや調整が必要となり、効率的ではありませんでした。フロントエンド開発を独立させることで、このような煩わしさが軽減されます。
また、登録フォームなどの処理においても、従来は「フォーム表示」→「確認画面表示」→「登録完了」のページ遷移や情報の一時的保存・再取得(セッション関連操作)などにもバックエンドが関わる必要があり、バックエンド側の処理が煩雑となる要因の一つとなっていました。この流れをフロントエンド側でSPA的に独立して実施し、最後に必要な情報の保存だけがバックエンドに要求されるような形に変えれば、バックエンド側の処理内容はかなり簡素化されます。

と言うことで、Laravelからフロントエンド要素を分離する方向にシフトしようとしている訳ですが、バックエンド側の視点で見ればフロントエンドとの接点における変化を意味し、これこそがレイヤードアーキテクチャにおけるUI層として対処すべき内容です。
API経由の入出力に関する処理を独立的に行い、その影響を下層に波及させないと言うUI層の目的を果たすためにLaravelとしてどのような実装を行えば良いかを改めて考えてみるのが本投稿の趣旨です。

Laravelではどうする?

この点に関しては様々な意見があるようですが、個人的には以下の内容がUI層に該当する機能・処理内容だと考えています。

  • ルーティング(認証に関する確認やコントローラの呼び出し)
  • フォームリクエスト(入力データに関する構造変換・整形およびバリデーション)
  • コントローラ(あくまでアプリケーション層の処理の呼び出しに徹する)
  • APIリソース(出力データに関する構造変換・整形)

上記以外にもミドルウェアやサービスプロバイダなども絡んできますが、これらは少々実装寄りの話になるので、あくまでUI層と言う抽象的な概念の実体化として特徴的なものとしては上記4機能を上げておきたいと思います。

なお、ルーティングに関しては特筆すべき点はないので、その他3機能に関して個別に見ていきたいと思います。

コントローラ

実行順的にはフォームリクエストの方が先ですが、UI層の中心的な役割を担う機能としてまずはコントローラを見ていきたいと思います。

ここではサンプルとして以下のようなコントローラを考えます。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\SampleRequest;
use App\Http\Resources\SampleResource; 

use App\Services\SampleService;

class TestController extends Controller
{
    public function sample(SampleRequest $request, SampleService $service)
    {
        return new SampleResource($service->sample($request->postData));
    }
}

上記を呼び出すルート設定は以下です。

Route::post('test/sample', 'TestController@sample');

入力に関してはフォームリクエスト「SampleRequest」で構造変換・整形およびバリデーションを行いますが、呼び出しは上記のようにアクションメソッドの引数としてタイプヒンティングしておけば良いだけです。

下位(アプリケーション)層の処理に関してはサービスクラスとして定義するのが良いかと思います。
上記例では「SampleService」と言うサービスクラスに処理を託しますが、これも当該クラスを生成しておいてアクションメソッドの引数としてタイプヒンティングしておくと、Laravelのサービスコンテナなる機能で自動的に生成したインスタンスを渡してくれます。

出力に関してはAPIリソース「SampleResource」で構造変換・整形を行います。
APIリソースに関しては上記のように出力データ生成に必要な情報(オブジェクト)を引数にインスタンスを生成し、それをそのままコントローラの戻り値とすると、出力内容が自動的にJSON化されます。当然ながらJSON化する元のデータ構造はAPIリソース内で定義します。

かつては相当な規模のFATコントローラを作成したこともありましたが、その状況から考えると夢のようなダイエットを果たせました。
まぁ、上記はあくまで極めてシンプルなサンプルですが。

フォームリクエスト

フォームリクエストもartisanコマンドで生成できます。

php artisan make:request SampleRequest

単純に上記のように実行すると「app/Http/Requests/SampleRequest.php」が生成されますが、指定するクラス名を「Sub/HogeRequest」のようにパスを含めた指定にすると「app/Http/Requests/Sub/HogeRequest.php」が生成され、名前空間も相応な形になります。
この辺はコントローラやモデルなどでも同じですね。

「SampleRequest.php」の内容は以下の通り。

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class SampleRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
        ];
    }   
    
    public function prepareForValidation()
    {
        $this->merge([
            'postData' => [
                'inputData' => $this->input_data,
            ],  
        ]); 
    }   
}

上記においてメソッド「prepareForValidation」以外の部分は先のコマンドで自動生成されます。ただしメソッド「authorize」の戻り値はデフォルトで「false」になっており、そのままだと無条件で403エラーになりますので、まずはこの点を変更しておくことが必要です。
「prepareForValidation」に関してはその名の通りバリデーションに先行して入力値を加工することができるメソッドです。名前からしてあくまでバリデーションを前提とした加工を目的としたものかと思いますが、Laravel的に入力値の加工を想定した機能は同メソッドくらいしか見当たらないので、入力データに関する構造変換・整形には本メソッドを使いたいと思います。

上記からもわかるかと思いますが、どうもフォームリクエストはバリデーションを行うことを主目的として用意された機能のようです。しかしUI層的視点で言えばAPI経由で入力されたデータを内部的処理に適した形に変換することが目的であり、バリデーション(無効データの排除)はその一部に過ぎないと考えます。
例えば、DDDなどでも重視される「値オブジェクト」の考え方を採用した場合、入力されたプリミティブ型のデータを値オブジェクト化する過程にバリデーションが含まれますが、これは「不正な値を持ち得ないと言う値オブジェクトの性格から不正な入力値からのインスタンス生成はそれ自体が失敗する」と言うことであって、あくまで主目的は値オブジェクト化です。

バリデーションを前面に押し出したフォームリクエストをUI層における入力処理の本体として位置付けるのは若干収まりが悪い印象がありますが、他に適当な機能もなく、今のところはこのような形にしておきたいと思います。

なお、そもそも値オブジェクト化をUI層で行うことが適正かと言う点に関しても少々怪しいです。
私の拙い知識では値オブジェクトは当該ビジネス領域(ドメイン)に密接に関係するもので、それがUI層辺りで生成されて良いのかと言う疑問はあります。ただ、前述したように値オブジェクト化はバリデーションと密接に関係しており、従来フローにおけるバリデーションの位置付けから判断すれば、この辺で行うのが良さそうにも思われます。
この点に関しては今後も要検討と言うことで。

と言うような状況なので、先に示したサンプルではあくまで入力データに関する構造変換・整形の例として、入力された値を内部的なデータ構造である多次元配列に入れ替える程度の内容にしてあります。

APIリソース

APIリソースもartisanコマンドで生成できます。

php artisan make:resource SampleResource

こちらもパスを含んだクラス名を指定することで階層的なディレクトリ環境(名前空間)を構成できます。

「SampleResource.php」の内容は以下の通り。

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class SampleResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'output_data' => $this->prop1,
        ];
    }
}

全般的に自動生成された形のままで「toArray」の中身だけ独自の内容に変更します。
若干分かりにくいのは、引数が「\Illuminate\Http\Request」型のオブジェクトになっていますが、APIリソース生成時に引数で指定したオブジェクトのプロパティやメソッドは上記サンプルにもあるように「$this」経由で参照できるようになっており、$requestの使い所が今一つ分からない点です。ネットでの調査結果においても$requestを触っている例を見たことがありません。なぜこのようなインタフェースになっているんでしょうね?

なお、APIリソースに関してはオブジェクト単体を対象とする場合とコレクションを対象とする場合があったり、オブジェクトに関しても別のオブジェクトが関連づいている場合(Eloquentのリレーションなど)の扱いをどうするかなど、様々な観点での整理が必要です。
その辺をここに含めてしまうと話がややこしくなりそうなので、別の機会に整理したいと思います。

ここでは出力データに関する構造変換・整形の例として、単純に引数で指定されたオブジェクト内の特定のプロパティに設定された値を出力構造に反映するだけの内容にしてあります。

テスト

上記で作成した処理を動かしてみましょう。
Featureテストケースとして以下のようなものを作成してみました。

<?php

namespace Tests\Feature;

use Tests\TestCase;

use Mockery;
use App\Services\SampleService;

class ExampleTest extends TestCase
{
    public function testBasicTest()
    {
        $SampleModel = new SampleModel('ABC');
        $mock = Mockery::mock(SampleService::class);
        $mock->shouldReceive('sample')
             ->with([
                 'inputData' => 'abc',
             ])
             ->andReturn($SampleModel);
        $this->instance(SampleService::class, $mock);

        $response = $this->post('/api/test/sample', [
            'input_data' => 'abc',
        ]);
        $response->assertStatus(200);

        $outputArray = json_decode($response->getContent(), true);
        dump($outputArray);
        $this->assertEquals('ABC', $outputArray['data']['output_data']);
    }
}

class SampleModel
{
    public $prop1;

    public function __construct($arg)
    {
        $this->prop1 = $arg;
    }
}

サービスクラス「SampleService」は未実装なので、Mockeryで代用します。
若干特殊なのは、「SampleService」の戻り、つまり「SampleResource」生成時の引数としてオブジェクトが期待されている点で、それ用のダミークラス「SampleModel」も用意しておきます。今回の処理の流れで必要なのはプロパティ「$prop1」です。

テストケース全体の流れとしては、「SampleService」を入力された値(文字列)を大文字に変換して返す機能とし、POSTパラメータ「input_data」に「abc」と設定した内容が出力結果のJSON構造内「data.output_data」に「ABC」と設定されて戻ってくるかを確認する、と言うものです。
Mockeryの「with」メソッドでフォームリクエストの変換結果が期待通りの形になっているかを確認しており、テストケース自体の「assertEquals」メソッドでAPIリソースの変換結果が期待通りの形になっているかを確認しています。
一応確認のため出力結果(JSON)のデコード結果をdump出力しています。

で、実行結果は以下の通り。

PHPUnit 9.5.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.3
Configuration: /home/demo/product/demo/phpunit.xml

Test 'Tests\Feature\ExampleTest::testBasicTest' started
array:1 [
  "data" => array:1 [
    "output_data" => "ABC"
  ]
]
Test 'Tests\Feature\ExampleTest::testBasicTest' ended


Time: 00:00.075, Memory: 22.00 MB

OK (1 test, 3 assertions)

興味深い点は「3 assertions」となっている点です。
実は個人的には初めてMockeryを使ってみましたが、「shouldReceive」および「andReturn」メソッドの実行を1つのassertionと見做しているようです。

総括

とりあえずLaravelで実装するUI層と言うことで、コントローラを中心にフォームリクエストで処理した入力データを下層(サービスクラス)に渡し、APIリソースで下層の戻り値(オブジェクト)から必要な値を取得、処理し、出力データ(JSON)として返すと言う基本的な流れが確認できました。

レイヤードアーキテクチャに厳密に照らした場合に不適切な箇所はあるかと思いますが、理想と現実の間での調整になるかと思います。その叩き台となる形ができたと言うことで、現状は良しとしておきます。