開発ブログ

株式会社Nextatのスタッフがお送りする技術コラムメインのブログ。

電話でのお問合わせ 075-744-6842 ([月]-[金] 10:00〜17:00)

  1. top >
  2. 開発ブログ >
  3. PHP >
  4. Laravel >
  5. Laravelで未来の日時の状態をプレビューするためのMiddleware

Laravelで未来の日時の状態をプレビューするためのMiddleware

こんにちは、ナカエです。

Webアプリケーションを開発していて、未来の日時における画面やAPIのレスポンスを再現したいということがたまにあります。

そのためだけにわざわざ開発マシンやサーバの現在日時を変更するのも手間です。

今回はLaravelでMiddlewareを用いてアプリケーションの本体側に手を加えることなく、ブラウザから未来の日時の状態をプレビューする方法をご紹介します。

実装のアイデア

日時に関する処理のデバッグ・テストのベストプラクティスとして、System Clockと呼ばれるオブジェクトや関数を作り、日時を全てSystem Clock経由で取得することによりモックしやすくするという方法が広く知られています。

Laravelにおいては、アプリケーションのSystem Clockの役割を果たしているCarbonの参照する現在日時をCarbon::setTestNow()メソッドを通じて自由に変更することができます。 System Clockにリクエストから日時を注入するため、HTTPリクエストのヘッダに日時を指定できるようにするというのもよく取られる手段でしょう。

今回は、ブラウザから日時を指定したいという要件を満たすため、日時をクエリパラメータから取得し、以降のリクエストではCookieに日時の設定が保持されるようにします。

実装例

<?php
declare(strict_types=1);

namespace App\Http\Middleware;

use Carbon\Carbon;
use Closure;
use Exception;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;

/**
 * Class TimeTravel
 * \Illuminate\Session\Middleware\StartSessionより内側に配置する
 * @package App\Http\Middleware
 */
class TimeTravel
{
    /** クエリのキー */
    private const DATETIME_KEY = 'time_travel';

    /** Cookieのキー */
    private const COOKIE_KEY = 'time_travel';

    /** リセット時に指定する値 */
    private const RESET_VALUE = 'off';

    /**
     * @param Request $request
     * @param Closure $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        if (app()->environment('production') || !config('app.debug')) {
            return $next($request); // 本番環境以外でデバッグ時のみ適用
        }

        if ($this->shouldReset($request)) {
            return $this->handleReset($request, $next); // リセット
        }

        $targetDateTime = $this->extractTargetDateTime($request);
        if ($targetDateTime === null) {
            return $next($request); // 指定なしの場合は通常の処理
        }
        $targetDateTime = $targetDateTime->toImmutable();

        // 時間を変更
        $originalDateTime = Carbon::now()->toImmutable();
        logger()->info(
            self::class . ' start time traveling: '
            . $originalDateTime->toDateTimeString()
            . ' -> '
            . $targetDateTime->toDateTimeString()
        );
        Carbon::setTestNow($targetDateTime);

        /** @var Response $response */
        $response = $next($request);

        // 時間を戻す
        Carbon::setTestNow();
        logger()->info(
            self::class . ' end time traveling: '
            . $targetDateTime->toDateTimeString()
            . ' -> '
            . $originalDateTime->toDateTimeString()
        );

        // Cookieに設定を記憶
        $response->headers->setCookie(new Cookie(
            self::COOKIE_KEY,
            $targetDateTime->toDateTimeString(),
            $originalDateTime->addMinutes(config()->get('session.lifetime')),
            config()->get('session.path'),
            config()->get('session.domain'),
            config()->get('session.secure'),
            config()->get('session.http_only'),
            false,
            config()->get('session.same_site')
        ));

        return $response;
    }

    /**
     * 時間を元に戻すべきかどうか
     * @param Request $request
     * @return bool
     */
    private function shouldReset(Request $request): bool
    {
        return $request->query(self::DATETIME_KEY) === self::RESET_VALUE;
    }

    /**
     * 時間を元に戻す
     * @param Request $request
     * @param Closure $next
     * @return Response
     */
    private function handleReset(Request $request, Closure $next)
    {
        /** @var Response $response */
        $response = $next($request);
        $response->headers->clearCookie(self::DATETIME_KEY);
        return $response;
    }

    /**
     * 変更対象の日時を取得
     * @param Request $request
     * @return Carbon|null
     */
    private function extractTargetDateTime(Request $request): ?Carbon
    {
        // クエリパラメータからの上書きを優先し、クエリから取れない場合はCookieから取得
        $dateTimeTexts = [
            $request->query(self::DATETIME_KEY),
            $request->cookie(self::COOKIE_KEY)
        ];

        foreach ($dateTimeTexts as $dateTimeText) {
            if ($dateTimeText === null) {
                continue;
            }

            try {
                return Carbon::parse($dateTimeText);
            } catch (Exception $e) {
                logger()->warning(self::class . ' : failed to parse datetime text', [$dateTimeText]);
            }
        }

        return null;
    }
}

利用方法

このMiddlewareを'web'ミドルウェアグループの\Illuminate\Session\Middleware\StartSessionより内側に配置して利用します。
laravel_time_travel_middleware.png
URLのクエリパラメータに ?time_travel=2019-12-15T10:00:00 などと日付指定を追加することでプレビューする日時が指定され、Cookieに保存されます。
解除したい場合は ?time_travel=off としてください。

注意点

  • あくまでローカルやテスト環境のデバッグのためのものであり、本番環境での利用を意図したものではありません。
  • Cookieによるセッションを前提としています。アクセストークンを認証・認可に利用しているようなステートレスな環境では動作しません。
  • データベース側で現在日時を生成するなど、Carbonの支配下にない処理における日時をモックすることはできません。
  • \Illuminate\Session\Middleware\StartSessionより内側に配置しなければ、Cookieの有効期限がずれてセッションが破棄され、ユーザがログインできない場合があります。
  • 他のリクエストヘッダなどを参照して日時に関係する処理を行う場合も、このMiddlewareの外側で行わなければ想定外の動作となることがありえます。

日時の外部注入とMiddlewareにより簡単に任意の日時のアプリケーションの状態をプレビューすることができました。

アプリケーションに要求される機能としてプレビュー機能を作る場合はもう少し作り込みが必要かと思いますが、そうでない場合はお手軽でおすすめです。

TOPに戻る