Upgrade to Pro — share decks privately, control downloads, hide ads and more …

QA環境で誰でも自由自在に現在時刻を操って検証できるようにした話

kalibora
December 21, 2024

 QA環境で誰でも自由自在に現在時刻を操って検証できるようにした話

kalibora

December 21, 2024
Tweet

More Decks by kalibora

Other Decks in Programming

Transcript

  1. 自己紹介 Toshiyuki Fujita (@kalibora) PHP 歴は20 年くらい Yahoo! JAPAN ->

    (Crocos) -> OTOBANK -> RABO 基本的にはずっとPHP を書いてメシを食 ってます(ありがたい) 今回の件は OTOBANK 時代にやった話 Symfony, Doctrine が大好きです
  2. アプローチ2: 組み込みクラス/ メソッド/ 関数に介入する 古くは php-timecop PHP 拡張でPHP 標準の時刻系関数の値を固定する 最近だと多く使われてそうなのは

    Carbon:setTestNow() プロジェクト全体で Carbon に依存している場合はこの方法が取れる class Campaign { public function isActive(): bool { $now = Carbon::now() return $this->beginAt <= $now && $now < $this->endAt; } }
  3. アプローチ7: 現在時刻へのアクセスを行うインターフ ェイスを抽出 PSR-20: Clock - symfony/clock, lcobucci/clock readonly class

    CampaignService { public function __construct( private ClockInterface $clock, ) {} public function isActive(Campaign $campaign): bool { $now = $this->clock->now(); return $campaign->getBeginAt() <= $now && $now < $campaign->getEndAt(); } }
  4. リクエスト時刻を表すクラスの作成 リクエスト時刻を表す RequestTime クラスを作成。単に DateTimeImmutable を継承。 <?php namespace App\ValueObject; final

    class RequestTime extends \DateTimeImmutable { public const string REQUEST_ATTR_NAME = '_app_request_time'; // リクエストのattributesに格納する際の名前 private bool $debug = false; // デバッグモードかどうかを表すフラグ、本当のリクエスト時刻か偽装されたものか?を判断 public function isDebug(): bool { return $this->debug; } public function setDebug(bool $debug): self { $clone = clone $this; $clone->debug = $debug; return $clone; } }
  5. リクエスト時刻を抽出するクラスの作成 先ほど作成した RequestTime を Request から抽出するクラスの Interface を定義。 <?php namespace

    App\Service\RequestTime; use App\ValueObject\RequestTime; use Symfony\Component\HttpFoundation\Request; interface ExtractorInterface { public function extract(Request $request): RequestTime; }
  6. 本番用のリクエスト時刻を抽出するクラスを作成 まずは通常の $_SERVER['REQUEST_TIME'] から RequestTime を抽出するクラスを作成。 これは本番環境用。 <?php namespace App\Service\RequestTime;

    use App\ValueObject\RequestTime; use Symfony\Component\HttpFoundation\Request; readonly class Extractor implements ExtractorInterface { public function extract(Request $request): RequestTime { $timestamp = $request->server->getInt('REQUEST_TIME'); // $_SERVER['REQUEST_TIME'] と等価 $timezone = new \DateTimeZone(date_default_timezone_get()); return (new RequestTime("@{$timestamp}"))->setTimezone($timezone); } }
  7. 開発用のリクエスト時刻を抽出するクラスを作成 次に独自のHTTP ヘッダーの値から任意の時刻の RequestTime を抽出するクラスを作成。 こ れは dev や test

    環境用。 <?php // (snip) final readonly class DebugExtractor extends Extractor { public function extract(Request $request): RequestTime { $value = $request->headers->get('X-Debug-Request-Time'); $requestTime = null !== $value ? $this->extractFromDebugHeader($value) : null; return $requestTime ?? parent::extract($request); } private function extractFromDebugHeader(string $value): ?RequestTime { try { $requestTime = ctype_digit($value) ? new RequestTime("@{$value}") : new RequestTime($value); } catch (DateMalformedStringException $e) { return null; // 入力が不正でもエラーにせず無視する } // 独自のHTTPヘッダーから取得した場合は debug を true にしておく return $requestTime->setDebug(true)->setTimezone(new DateTimeZone(date_default_timezone_get())); } }
  8. DI で環境ごとにリクエスト抽出クラスを使い分ける Symfony では環境ごとに実際にInject するクラスを使い分けることはできる。 config/services.yaml では下記の様に、 ExtractorInterface として Extractor

    を使うよ うに設定。 App\Service\RequestTime\ExtractorInterface: class: App\Service\RequestTime\Extractor config/services_dev.yaml と config/services_test.yaml では下記の様に ExtractorInterface として DebugExtractor を使うように設定。 App\Service\RequestTime\ExtractorInterface: class: App\Service\RequestTime\DebugExtractor これで dev, test 環境の時のみ、独自のHTTP ヘッダーを用いて、リクエスト時刻を任意 の時間に偽装する事が出来るようになる。 (本番では $_SERVER['REQUEST_TIME'] からし か抽出しない。実行時のif 文分岐ではないのでパフォーマンスにも影響なし)
  9. イベントリスナーにて抽出したリクエスト時刻を設定する Symfony の イベントリスナー を使ってリクエストの attributes に、先程までに作った Extractor で抽出した RequestTime

    クラスを設定。 Request に情報を追加するので、サブスクライブするイベントは kernel.request 。 <?php // (snip) final readonly class RequestTimeSubscriber implements EventSubscriberInterface { public function __construct(private ExtractorInterface $extractor, private TwigEnvironment $twig) {} public static function getSubscribedEvents(): array { return [KernelEvents::REQUEST => ['onKernelRequest', 10]]; // Security Firewall よりは優先させる } public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); $requestTime = $this->extractor->extract($request); $request->attributes->set(RequestTime::REQUEST_ATTR_NAME, $requestTime); // リクエストの attributes に設定 $this->twig->addGlobal('request_time', $requestTime); // Twig にグローバル変数として設定 } }
  10. 例えばこんな風に使う( コントローラー内) <?php // (snip) #[Route('/campaigns')] class CampaignController extends AbstractController

    { #[Route('/{id}', requirements: ['id' => '\d+'])] public function detail(Campaign $campaign, Request $request): Response { $requestTime = $request->attributes->get(RequestTime::REQUEST_ATTR_NAME); if (!$campaign->isActive($requestTime)) { throw $this->createNotFoundException(); } return $this->render('campaign/detail.html.twig', [ 'campaign' => $campaign, ]); } }
  11. 例えばこんな風に使う(Twig テンプレート内) {% extends 'base.html.twig' %} {% block title %}{{

    campaign.title }}{% endblock %} {% block body %} <h1>{{ campaign.title }}</h1> 現在時刻は <code>{{ request_time|date('Y/m/d H:i:s') }}</code> です。 {% if campaign.isActive(request_time) %} <p>キャンペーン開催中です。</p> {% else %} {% if request_time < campaign.beginAt %} <p>このキャンペーンはまだ開始していません。</p> {% else %} <p>このキャンペーンは終了しました。</p> {% endif %} {% endif %} {% endblock %}
  12. Controller の引数にリクエスト時刻を渡せるようにする Symfony ではコントローラーのメソッドの引数に独自の引数を渡す事が出来る機能 が あるので、下記のようなクラスを作成すれば <?php // (snip) final

    readonly class RequestTimeResolver implements ValueResolverInterface { public function resolve(Request $request, ArgumentMetadata $argument): iterable { $argumentType = $argument->getType(); if (!$argumentType || !is_a($argumentType, RequestTime::class, true)) { return []; } yield $request->attributes->get(RequestTime::REQUEST_ATTR_NAME); } }
  13. Controller の引数にリクエスト時刻を渡せるようにする 下記のようなコントローラーのメソッドの引数で直接 RequestTime を受け取れる。 <?php // (snip) #[Route('/campaigns')] class

    CampaignController extends AbstractController { #[Route('/{id}', requirements: ['id' => '\d+'])] public function detail(Campaign $campaign, RequestTime $requestTime): Response { if (!$campaign->isActive($requestTime)) { throw $this->createNotFoundException(); } return $this->render('campaign/detail.html.twig', [ 'campaign' => $campaign, ]); } }