Laravel 終了処理ミドルウェア(Terminable Middleware)で遊んで見る

3 min read読了の目安(約3500字

はじめに

あまり語られる事のない、終了処理ミドルウェア(Terminable Middleware)で遊んで見ました。

参考:日本語ドキュメント:終了処理ミドルウェア

これはどんな時に使えるかというと、例えばちょっとしたログなんか取ったりする際に使えたりします(重要なログは、これではなく、要所要所で取るべきだと思いますが)。若しくは、文字通り何か終了処理をさせたいものがある場合にも使えます。

で、このミドルウェアの何がいいかと言うと、レスポンスを送った後に処理をするので、その分、ユーザーの待ち時間が少なくなります。但し、FastCGIを使っている場合に限ります。

普通のミドルウェアとどう違うの?

普通の before や after のミドルウェアとは、呼ばれるタイミングが異なります。
after より後で、レスポンスを送った後に呼ばれます。

ざっくり仕組みは?

終了処理ミドルウェアは、public/index.php の一番下の箇所から呼び出されます。

// 上部は略

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

$response = $kernel->handle(
    $request = Illuminate\Http\Request::capture()
);

$response->send();

$kernel->terminate($request, $response);

Ver.8以降では、ちょっとだけ記述が小難しくなっている為、Ver.7のを記述しています。

$kernel->terminate(...) の箇所から Illuminate\Foundation\Http\Kernel の terminate() メソッドが呼び出され、終了処理ミドルウェアが実行されます。

その1つ手前で、$response->send(); してますね。これは、Symfony の Symfony\Component\HttpFoundation\Response の send() を呼び出します。

以下のようになっています。

    public function send()
    {
        $this->sendHeaders();
        $this->sendContent();

        if (\function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request();
        } elseif (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) {
            static::closeOutputBuffers(0, true);
        }

        return $this;
    }

fastcgi_finish_request(); が呼び出される事で、ユーザーを待たせず、終了処理を行う事ができる訳ですね。FastCGI と言うだけあって、まさに Fast です。

と言うことで、少し遊んで見る

今回は、会員サイトで会員が何か行った際に簡易ログを取る、という想定で進めて行きます。
まずは、ミドルウェアを作成します。以下のコマンドを打ちます。

php artisan make:middleware LogAction

できた LogAction に以下を追加します。

    public function terminate($request, $response)
    {
        if ($request->isMethod('GET')) {
            return;
        }

        $id = $request->user()->id;

        sleep(3);

        $data = [
            'method' => $request->method(),  // 'POST', 'DELETE' など
            'url' => $request->url(),
            'input' => $request->input(), // ->all() ではないのでファイル系は除かれる
        ];

        info("User ID {$id}", $data); // ログを取る
    }

まずログは、GET以外の時に取るようにしています。
続けて、会員の連番IDを取得し、検証用に3秒sleepします。

その後、ちょっとした情報をログに取ります。
info() ヘルパー関数を使って、デフォルトのログチャンネルにinfoレベルのログとしていますが、本来であれば、ログチャンネルは分けた方が良いですね。

このミドルウェアを使用する為に、web.php に例えば以下のように記述します。

use App\Http\Middleware\LogAction;

Route::middleware(['auth', LogAction::class])->group(function () {
    // 会員用のルーティング設定をこの中に
});

ミドルウェアは、Http\Kernel にニックネームを登録してやるのが一般的かと思いますが、Laravel8以降、コントローラーもフルパスで書く時代ですので、ミドルウェアもフルパスで書くようにしています。(その方が対象ファイルにすぐ飛べます)

これで、会員サイトで何か情報を登録・更新・削除する度ごとに、ログが取られます。

丁度私のWEBサーバは、FastCGIを使っているので、3秒待たされずに画面が表示され、3秒後位にログが取られます。

ユーザーを待たせないというのはいいですね。但し、イケメン FastCGIを使っている場合に限ります。

ちなみに、FastCGI でなかったとした場合の環境を試すため、先程の send() メソッドの fastcgi_finish_request(); 周りをカットして、以下のようにすると、

    public function send()
    {
        $this->sendHeaders();
        $this->sendContent();

        if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) {
            static::closeOutputBuffers(0, true);
        }

        return $this;
    }

3秒程待たされてから画面が表示され、それと同時にログが取られます。

なんかいい感じだけど、注意点とかは?

基本的に、処理が前後するとバグになるような場合は、これは使うべきでは無いです。

昔のLaravelでは、セッションを保存するのに、この terminate() を使っていましたが、セッションをDBに保存するようにした場合で、DBに高負荷が掛かっていて処理が遅い場合、セッションがDBに保存される前に、次のリクエストがやってきてしまい、不具合が生じるという事がありました。
なので今は、terminate() ではなく、普通の after ミドルウェアに変更されています。

参考:GitHub [5.8] Fixes session attributes persistence

まとめ

この機能、いつお世話になる事やら…。

おかしな箇所等ありましたら、コメント下さい。