LaravelのControllerのライフサイクルとサービスコンテナへの束縛登録のベタープラクティス
こんにちは、ナカエです。
本日はLaravel Advent Calendar 2019 - Qiitaの13日目の記事です。
昨日はういろうさんのLaravel6.x系以降のバージョニングについての解説記事でした。
はじめに
本記事では"Serviceクラス"という言葉を
- FWユーザが作成した独自クラス
- サービスコンテナによってControllerに注入されるクラス
という程度の広い意味で用いています。"Serviceクラス”の中にHTTP層の処理が混ざっていても気にしないでください。
TL;DR
- ServiceクラスをControllerにコンストラクタ注入した場合とメソッド注入した場合では依存解決されるタイミングが大きく異なる
- Controllerのコンストラクタのタイミングでは、HTTPリクエストから引き出したい情報(Cookie、セッションデータ、ログインユーザetc.)が不完全な状態になっていることがある
- HTTPリクエストや実行時刻に依存するランタイムの情報をコンストラクタ注入するクラス設計は避けるべき。ランタイムの情報はメソッドの引数として渡す
- どうしても避けられない場合は、ランタイムの情報を引数としてServiceクラスを遅延生成するためのファクトリを噛ませると良い
- 初期化時の処理とランタイムの処理を区別していこう
- BEAR.Sundayはいいぞ
Controllerのコンストラクタでログインユーザが取れない事案
Controllerの複数のアクションでログインユーザを利用するので処理を共通化したい、というのはLaravel初学者が考えがちなことの一つだと思います。
処理を共通化するならコンストラクタだな!と単純に考えた結果、下記のようなコードが生まれます。
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
class HogeController
{
/** @var \App\User $user */
private $user
public function __construct()
{
$this->middleware('auth');
$this->user = \Auth::user();
info('user', [$this->user]);
}
}
ログに出力される$this->userは見事nullとなります。
当たり前じゃんと笑うことなかれ。今となっては3年以上前の話ですが、Laravel5.2まではこのコードでも$userが取得できていたのですよ。 5.3へのアップグレードでこの暗黙の実装が変更され、現在では動的なMiddleware登録による少しトリッキーなコードを書く必要があります。
public function __construct()
{
$this->middleware('auth');
$this->middleware(function ($request, $next) {
$this->user = Auth::user();
return $next($request);
});
}
Taylor氏曰く、ログインユーザはControllerのメソッドに渡されるRequestから導出されるのが自然で、Requestが渡っていないコンストラクタで取得できているのがそもそもおかしかったとのこと。
ごもっともではあるのですが、納得できない、スッキリしないという声をたまに聞きます。 単にコンストラクタに処理を移しただけで挙動が変わるのは直感に反するようです。
Service、Controller、Middlewareのインスタンス化とメソッド実行の順序を理解すると、この挙動の謎は全て解けます。
Service、Controller、Middlewareの実行順序の検証
以下、簡単に実行順序を検証してみましょう。
※ 検証時のLaravelフレームワークのバージョンは6.2です。
Service、Middleware、Controllerを作成し、コンストラクタおよびメソッドにログ出力処理を埋め込みます。
Controlelrのコンストラクタに注入するService
<?php
declare(strict_types=1);
namespace App\Service;
use Illuminate\Http\Request;
class InjectedIntoConstructorService
{
public function __construct(Request $request)
{
info('InjectedIntoConstructorService constructor', [
'session configured' => $request->hasSession(),
'cookie' => $request->cookie(config('session.cookie')),
]);
}
public function execute(Request $request)
{
info('InjectedIntoConstructorService::execute()', [
'session configured' => $request->hasSession(),
'cookie' => $request->cookie(config('session.cookie')),
]);
}
}
Controlelrのアクションメソッドに注入するService
<?php
declare(strict_types=1);
namespace App\Service;
use Illuminate\Http\Request;
class InjectedIntoActionService
{
public function __construct(Request $request)
{
info('InjectedIntoActionService constructor', [
'session configured' => $request->hasSession(),
'cookie' => $request->cookie(config('session.cookie')),
]);
}
public function execute(Request $request)
{
info('InjectedIntoActionService::execute()', [
'session configured' => $request->hasSession(),
'cookie' => $request->cookie(config('session.cookie')),
]);
}
}
Controller
- InjectedIntoConstructorServiceをコンストラクタに注入
- InjectedIntoActionServiceをactionメソッドに注入
- 依存の束縛の設定はauto-wiring頼み
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Service\InjectedIntoActionService;
use App\Service\InjectedIntoConstructorService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class MyController
{
/** @var InjectedIntoConstructorService */
private $constructorService;
public function __construct(InjectedIntoConstructorService $constructorService)
{
info('MyController constructor');
$this->constructorService = $constructorService;
}
public function action(Request $request, InjectedIntoActionService $actionService): Response
{
info('MyController::action() start');
$this->constructorService->execute($request);
$actionService->execute($request);
info('MyController::action() end');
return response('OK');
}
}
Middleware
HTTPリクエストのハンドリングと終了処理を記述
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
class MyMiddleware
{
public function __construct()
{
info('MyMiddleware constructor');
}
public function handle($request, \Closure $next)
{
info('MyMiddleware::handle() start');
$response = $next($request);
info('MyMiddleware::handle() end');
return $response;
}
public function terminate()
{
info('MyMiddleware::terminate()');
}
}
KernelへのMiddleware登録
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
// 略
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\MyMiddleware::class, // 一番内側に登録
],
'api' => [
'throttle:60,1',
'bindings',
],
];
// 略
}
イベントリスナの設定
ルートの決定とHTTPリクエストのハンドリング終了のイベントを拾うためにイベントリスナを設定します。
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Foundation\Http\Events\RequestHandled;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
/** @var Dispatcher $events */
$events = $this->app->make('events');
$events->listen(RouteMatched::class, function () {
info('route matched');
});
$events->listen(RequestHandled::class, function () {
info('request handled');
});
}
}
ルート登録
<?php
/** @var \Illuminate\Routing\Router\Router $router */
$router->get('/', 'MyController@action');
検証結果
php artisan serveなどを利用してWebサーバを立ち上げてアクセスします。
ログ出力
ログには下記のように出力されます。 行末の括弧つきの数値は実際には出力されない識別用の番号です。
[2019-12-12 14:51:15] local.INFO: route matched (1)
[2019-12-12 14:51:15] local.INFO: InjectedIntoConstructorService constructor {"session configured":false,"cookie":"eyJpdiI6IlhSOHZ5clFpdG82MSt4d0R5MFZEcEE9PSIsInZhbHVlIjoiZUp4T0YxWjFyUmRSR3pBdWdGOFwvWVZzV3VXQUFQMFRqMVFhM0FTUnhCM1F1dk5pRUhsQmJwcGlzSXU4cnVcL1NRIiwibWFjIjoiMDE1YjgwZTQwZDRmODFjMjBlMmEyYmRhMjY3NWJhZjU1ZmUwNTVjN2ZkYjZkMjEyMWMwN2QzMjAzYjJlOWQwZSJ9"} (2)
[2019-12-12 14:51:15] local.INFO: MyController constructor (3)
[2019-12-12 14:51:15] local.INFO: MyMiddleware constructor (4)
[2019-12-12 14:51:15] local.INFO: MyMiddleware::handle() start (5)
[2019-12-12 14:51:15] local.INFO: InjectedIntoActionService constructor {"session configured":true,"cookie":"YlXZhhpLMj2j7cfUyoDxNVJ9QoMkundbaXutkdjd"} (6)
[2019-12-12 14:51:15] local.INFO: MyController::action() start (7)
[2019-12-12 14:51:15] local.INFO: InjectedIntoConstructorService::execute() {"session configured":true,"cookie":"YlXZhhpLMj2j7cfUyoDxNVJ9QoMkundbaXutkdjd"} (8)
[2019-12-12 14:51:15] local.INFO: InjectedIntoActionService::execute() {"session configured":true,"cookie":"YlXZhhpLMj2j7cfUyoDxNVJ9QoMkundbaXutkdjd"} (9)
[2019-12-12 14:51:15] local.INFO: MyController::action() end (10)
[2019-12-12 14:51:15] local.INFO: MyMiddleware::handle() end (11)
[2019-12-12 14:51:15] local.INFO: request handled (12)
[2019-12-12 14:51:15] local.INFO: MyMiddleware constructor (13)
[2019-12-12 14:51:15] local.INFO: MyMiddleware::terminate() (14)
処理順の解説
今回出力したログは、Laravelアプリケーション全体のライフサイクルの中では、Kernelのブートストラップが完了した後の処理に相当します。
まずはじめにRouterでRouteが検索され(1)、決定したRouteに応じて必要なMiddlewareの情報を収集します。RouteにControllerのアクションを登録している場合は、このMiddlewareを収集する段階でControllerをコンテナ経由で依存解決しインスタンス化する必要があります (3)。Controllerのコンストラクタで動的に登録されるMiddlewareも収集対象とするためです。 MyControllerが依存しているInjectedIntoConstructorServiceが、MyControllerよりも先に解決されています(2)。
続いて、収集したMiddlewareは優先順位を考慮して並べ替えられ、アクションと合わせてミドルウェアパイプラインが構成されます。
HTTPリクエストを構成したパイプラインに送り込んで処理します。このパイプラインの処理の中で、Middlewareのコンテナ経由のインスタンス化(4)とMiddlewareによるHTTPリクエストの処理の前半部分(5)が順次行われていきます。
次にControllerのアクションが呼び出されるわけですが、メソッドを呼び出す前に引数を補完するため、ルートパラメータの解決やコンテナからの注入による依存解決が行われます(6)。
Controllerアクションの呼び出し(7)の後の流れは比較的単純で、action()メソッドに記述した順番で処理が行われています(8)〜 (10)。
Controllerアクションが返したHTTPレスポンスは、Middlewareの後半の処理を通って出力されます(11)、(12)。
アプリケーションがHTTPレスポンスを返し終えた後に残るは、後始末(terminate)の処理です(14)。 今回はMiddlewareをシングルトンとしてコンテナに登録していないため、handle時とは別のインスタンスが生成されています(13)。
着目ポイント
InjectedIntoConstructorService、InjectedIntoActionServiceの2つのServiceのコンストラクタとメソッドのログ出力(2)、(6)、(8)、(9) に注目してください。
これらの中で、(2)のInjectedIntoConstructorServiceのコンストラクタだけが、MiddlewareによるHTTPリクエストの処理(5)の前に呼び出されています。
ログのContextとして出力された値から、
- セッションの開始(SessionStart Middlewareの担当する処理)
- Cookieの暗号化の解除(EncryptCookies Middlewareの担当する処理)
がまだ行われていないことが読み取れます。Middlewareで処理された後でないと、セッションからデータを取得したり、ユーザを取得することができないということです。
どうしてもControllerのプロパティとしてログインユーザを取得したい場合に、Controller Middlewareを用いて設定をコンストラクタのタイミングから遅延させる方法が提示されているのはこのためです。
ここまでのまとめ
ServiceクラスをControllerにコンストラクタで注入した場合とメソッドで注入した場合では、Serviceクラスのインスタンスが依存解決されるタイミングが大きく異なります。
アクションメソッドに注入していた時は正常に動いていたServiceクラスが、コンストラクタ注入に変えた途端動かなくなるということも起こり得ます。 逆に言えば、Controllerのコンストラクタを起点として依存解決されるServiceクラスのコンストラクタにおいて、HTTPリクエストから導出される情報を使うことを避ければこの手の不具合は回避できます。
記事の後半では、サービスコンテナによる依存解決のタイミングに起因する問題をより一般化して捉え、サービスコンテナへの束縛登録のベタープラクティスについて考察します。
アプリケーションの初期化時の処理とランタイムの処理
一般的に、Webアプリケーションの処理はHTTPリクエストに依らず行える初期化時の処理とHTTPリクエストがないと決定できないランタイムの処理に分けられます。
PHPの通常の実行方式では、HTTPリクエストごとに状態を残さないことを基本とするため、リクエストごとに初期化処理とランタイムの処理が続けて行われるという特有の事情があります。このため、初期化時の処理とランタイムの処理の区別を意識するPHP開発者の割合は、他のプログラミング言語を利用する開発者のそれに比べて低い印象です。
DIコンテナもHTTPリクエストごとにリセットされる前提のライブラリが多いです。リクエスト依存の、もしくはミュータブルなオブジェクトをコンテナに登録していて、2回目以降のHTTPリクエスト時に状態がらみのバグを引き起こすような経験をすることはほとんどないでしょう。我々PHP開発者は、PHPの割り切った設計によってHTTPリクエストをまたぐアプリケーションの状態に無頓着でいることが許されているとも言えます。
BEAR.Sundayに見る早期束縛と遅延束縛の区別
そんなPHPのFWにも例外が一部あります。本記事で例にあげるのはBEAR.Sundayです。
BEAR.Sundayにおけるアプリケーションの初期化のタイミングはコンパイルタイムと呼ばれ、ランタイムとは明確に区別されます。 また、コンパイルタイムに決定される束縛を早期束縛(Eager Binding)、ランタイムに解決する束縛を遅延束縛(Lazy Binding)と呼びます。
コンパイルタイムの早期束縛を優先し、DIフレームワークであるRay.DIのDependency Inejctorを利用して依存を解決するのが公式に推奨される方針です。 ランタイムにおける遅延束縛が必要な場合は、AOPフレームワークであるRay.AOPによるアスペクト(横断的処理)によって依存を注入し解決します。 この制約によって、BEAR.Sundayのユーザはコンパイルタイムとランタイムの区別を明確に意識する必要が出てきます。
BEAR.Sundayは早期束縛と遅延束縛を分ける制約の他にもいくつかの制約を課すことで、依存解決されたアプリケーションのルートオブジェクトを丸ごとキャッシュし、PHPのFWでありながらHTTPリクエストごとの初期化処理を前倒しにして大幅にスキップすることを可能にしています。
コンパイルタイムにより多くの処理を済ませておく本番環境向けの設定は、ランタイムのエラーの低減につながり、高速化だけでなく安全性にも貢献します。
Laravelへの応用
LaravelではHTTPリクエストそのものやFormRequestなど一部のランタイムに依存するクラスをサービスコンテナから解決することも可能なため、早期束縛も遅延束縛の区別なく依存解決をサービスコンテナに任せてしまうことができます。
しかし、できる限り早期束縛を優先するという方針はFWやプログラミング言語の枠を超えて有用です。 イミュータブルな設計を促進し、記事冒頭で例に挙げたような、FW利用者の直感と異なる不具合が起こる可能性を減らしてくれます。
ランタイムの情報をコンストラクタの引数に取らない
HTTPリクエストや実行時刻なしには定まらないランタイムの情報(特にセッションデータ、ログインユーザなど)をコンストラクタの引数に取らないのがまず一点目です。Controllerに引数として渡ってくるRequestクラスから、必要な情報を取り出し、Serviceクラスのメソッドに渡すようにしましょう。
Before
class UserBlogPostService
{
private $user;
public function __constructor(User $user)
{
$this->user = $user;
}
public function latest(): ?BlogPost
{
$this->user->blogPosts()->latest()->first();
}
}
class UserBlogPostController
{
public function latest(Request $request)
{
$service = new UserBlogPostService($request->user()); // Request依存のため、Auto-wiringによるServiceの注入がやりづらい。
$blogPost = $service->latest();
// 略
}
}
After
class UserBlogPostService
{
public function latest(User $user): ?BlogPost
{
return $user->blogPosts()->latest()->first();
}
}
class UserBlogPostController
{
public function latest(Request $request, UserBlogPostService $service)
{
$blogPost = $service->latest($request->user());
// 略
}
}
ランタイムの情報を引数とするファクトリを間接層として使う
どうしてもコンストラクタでランタイムの情報を受けるに準ずる設計をしたい場合は、ランタイムの情報を引数としてクラスを生成するファクトリを間接層として用意します。
class UserBlogServiceFactory {
public function make(Request $request): UserBlogService
{
return new UserBlogService($request->user());
}
}
class UserBlogPostController
{
public function latest(Request $request, UserBlogServiceFactory $factory)
{
$service = $factory->make($request);
$blogPost = $service->latest();
// 略
}
}
利用したいランタイムの情報がUserクラスの場合は、Userの取得を遅延できる既存のGuardやAuthManagerを利用するのも一つの選択肢です。
class UserBlogPostService
{
private $guard;
public function __constructor(Guard $guard)
{
$this->guard = $guard;
}
public function latest(): ?BlogPost
{
return $this->guard->user()->blogPosts()->latest()->first();
}
}
class UserBlogPostController
{
public function latest(UserBlogPostService $service)
{
$blogPost = $service->latest();
// 略
}
}
コンテキストによる束縛(Contextual Binding)を活用する
同じIntefaceに対する異なる実装を使い分けるため、Controllerなどで動的に依存を修正するケースも見受けられますが、束縛の登録がServiceProvider以外に散らばると処理を追いづらい場合も多いです。
Before
class AppServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->bind(
CommentRepositoryInterface::class,
CommentReadRepository::class
);
}
}
class CommentsController
{
public function find(int $commentId, ReadCommentService $service)
{
$comment = $service->find($commentId);
// 略
}
public function store(StoreCommentRequest $request, StoreCommentService $service)
{
app()->bind(CommentRepositoryInterface::class, CommentWriteRepository::class);
$comment = $service->store($request->user(), $request->validated());
// 略
}
}
コンテキストによる束縛を利用することで、ServiceProvider内で束縛を完結させることができる場合もあります。
After
class AppServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->bind(
CommentRepositoryInterface::class,
CommentReadRepository::class
);
$this->app->when(StoreCommentService::class)
->needs(CommentRepositoryInterface::class)
->give(CommentWriteRepository::class);
}
}
class CommentsController
{
public function find(int $commentId, ReadCommentService $service)
{
$comment = $service->find($commentId);
// 略
}
public function store(StoreCommentRequest $request, StoreCommentService $service)
{
$comment = $service->store($request->user(), $request->validated());
// 略
}
}
おまけ: FormRequestってどう?
FormRequestはRequestを継承したランタイムに依存するクラスですが、FormRequestをControllerにコンストラクタ注入しようとする人はそうそういないでしょう。Controllerアクションごとのバリデーションを行うことを前提とした設計は、自然とメソッドへの注入を選択させます。 間違った使い方をしにくいという点において優れていると言えますが、FormRequestServiceProviderを見るとそのしわ寄せに依存解決の挙動が少し複雑になっていることがうかがえます。
最後に
色々と書きたいことが多く長文になりました。要は、Controllerのコンストラクタを起点とする依存解決は初期化時に使うもの、アクションメソッドを起点とする依存解決はランタイムに使うものと認識しておくと良いでしょう。
上記でPHPは事情が特殊であると述べましたが、PHPにもWebアプリケーションの初期化処理をサーバやワーカの起動時の一度きりにする実行形式を採用していこうとする流れが一部であります。ReactPHP、Swoole、RoadRunnerなどがそれです。
今後は、初期化時の処理とランタイムの処理の区別、DIコンテナへの束縛の設定方針、およびHTTPリクエストをまたいでもイミュータブルとなる設計が重要になる場面も増えるだろうと予想しています。