Service WorkerとCache APIによるWebサーバーの負荷軽減とテスト実装について-後編


ニコニコQセクションのHajime-san(GitHub)です。

本記事は、Service WorkerとCache APIによるWebサーバーの負荷軽減とテスト実装について-前編の続きとなります。

テストについて

さて、前編Service Workerスクリプトの実装はローカル開発環境および検証環境での確認を経て本番環境にデプロイされているのですが、このままでは「なんとなくキャッシュが動いている」という状態がブラウザ上に構築されていることになるので、Service Workerの振る舞いの仕様書となるテストコードが欲しいというのが本稿のもう1つの主題になります。

Service Workerのテストについては筆者が知る範囲ではインターネットにはあまり知見が無い中で、 本件のようにUIに対して影響を与えないような実装に対してどのようにテストを実施するかどうかを検討した結果、「フロントエンドに閉じていてService Workerのサポートがあり、実際のブラウザを用いたテスト」を実現できれば適当であろうという結論に至りました。

テストに利用するフレームワークとしてPlaywrightを採用することとなったのですが、その理由としては以下になります。

  • ブラウザでテストを実行できる
    • ローカル開発時に動作確認をしたい場合などを除き、ヘッドレスモードで実行することになります。
    • 本件では複数ポート番号の管理が必要になることを懸念して利用していませんが、テストの並列実行も可能なのでケースによってはテスト実行がかなり速いです。
  • Service Worker関連のAPIは現状Expretimentalであるものの、実装されている
    • 現状はChrome/Chromiumのみサポートされています。
  • 開発がMicrosoftで現在も頻繁にアップデートされている
  • 筆者の利用経験がある

余談ですが先ほど登場したService WorkerのライブラリWorkboxnodeのテストにMochaを、ブラウザのテストにはSelenium WebdriverおよびSaucelabsを用いて実現しているようです。

テストを実行する環境

テストフレームワークが決まったところで、まずはテストに必要な環境を構築するところからスタートします。 Playwrightのテストはブラウザ上で行われるので、特定のURLにアクセスするなどの実際のユーザー操作がアプリケーションの入力値となり得ます。 先ほど挙げた「フロントエンドに閉じていて、実際のブラウザを用いたテスト」を実現するに当たって、Webサーバーへのリクエストが検証環境あるいはローカルWebサーバー開発環境へ飛ばないようにする必要があり、 これには既にプロジェクトでStorybookのモック資材として活用されているmswを再利用します。

テストを実行する環境は以下のようになりました。

  • localhost:4000
    • Playwrightのテストを実行するために必要なiframeを埋め込んだhtmlをホストしています。
    • expressでサーバーを立てています。
    • test関数実行時にこちらのURLにアクセスします。
      • 記事冒頭で出てきたニコニコインフォなどのiframeを呼び出す側の環境を想定しています。
  • localhost:8080
    • iframeとして実行されるリソースをホストしています。
    • 開発時に利用しているwebpack-dev-serverでサーバーを立てています。
    • Service Workerはこちらのリソースからlocalhost:4000を開いたブラウザにインストールされることとなります。
  • localhost:9090
    • 外部環境へリクエストが飛ばないように向き先のURLを変えたmswをホストしています。
    • expressでサーバーを立てています。
    • @mswjs/http-middlewareを利用して各リクエストに対してmswhandlerからレスポンスを返すようにしています。
      • テストコード内でケースに応じてレスポンスの内容を変更したいため、(Next.jsの例になりますが)このようにtest関数を拡張しています。
テスト構成図

想定されるテストケース

これでテストコードを実装する準備は整いました。

本件の要件を思い出すと、Service Workerの採用理由はWebサーバーからのレスポンスをブラウザのCacheStorageに保存し、Webサーバーへのリクエスト数を緩和させるということでした。

故にテストケースは大雑把に以下になります。

  • キャッシュがある時はCacheStorageからレスポンスが返却されるか
  • キャッシュの有効期限などは妥当なものか
  • キャッシュの有効期限が切れたらリフレッシュされるか

また、Service Workerの機能自体のテストケースは下記のようになります。

  • Service Workerがブラウザにインストールされるか
  • Service Workerのスクリプトが更新されたら古いキャッシュは全て削除されるか

PlaywrightによるService Workerのテストに必要な知識

Service Worker関連のAPIを有効にする

先ほど紹介したように、PlaywrightにおけるService Worker関連のAPIはExpretimentalであり、 それらのAPIを利用したテストの実行にあたっては実行時の環境変数を PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 playwright test のようにしてやると良いようです。

テストコードの実装例

本件ではiframeによるリソースの呼び出しによってブラウザにService Workerがインストールされるというやや特殊な構成になっています。 例えば「CacheStorageにキャッシュが存在するか」のようなテストコードを実装する際には下記コードのような前提を頭に入れておくことが必要になります。

test('caches.keys()から1つのcacheNameが返ることを期待する', async ({ page, context }) => {
  await page.goto('http://localhost:4000');
  await context.waitForEvent('serviceworker');
  // Playwrightによって取得されるWorkerクラスで、オリジナルのWorkerGlobalScope.selfオブジェクトとは別物
  // https://playwright.dev/docs/api/class-worker
  const serviceWorker = context.serviceWorkers()[0];

  // Service Workerがページコントロールするまで待機する(後述)
  await waitUntilServiceWorkerActivated(page, 'data-test-id=myIframe');

  // 2回目のリクエストを発生させてキャッシュを取得するためにリロードする
  await page.reload();

  // Workerクラスにより評価されたlocalhost:8080のService WorkerスクリプトがCacheStorageのキーを返す
  const cacheNamesBySW = await serviceWorker.evaluate(() => self.caches.keys());

  // 1つのcacheNameが存在する
  expect(cacheNamesBySW).toHaveLength(1);

  // 表示中のウィンドウ(タブ)であるlocalhost:4000のPageクラスにより評価されるwindow.cachesがCacheStorageのキーを返す
  const cacheNamesByHostedPage = await page.evaluate(() => window.caches.keys());

  // こちらでは配列が0で返るので下記は失敗する
  expect(cacheNamesByHostedPage).toHaveLength(1);
});

同一オリジンポリシー

先ほどの実装例にてwindow.caches.keys()CacheStorageから期待するキャッシュが返却されないのは、
localhost:8080Service WorkerによってCacheStorageに保存されたリソースはlocalhost:4000から参照することが出来ないという同一オリジンポリシーに従った動作です。

Workerクラス

前述のWorkerというPlaywrightが提供するクラスですが、こちらはWeb Workerをテストコードから操作可能にするクラスとなっています。

serviceWorker.evaluate(() => self.caches.keys());

上記のevaluate関数を実行すると第一引数は本件の場合localhost:8080Service Workerのコンテキストによって評価されるスコープになっています。 例えばそのスコープ中でglobalThis.selfServiceWorkerGlobalScopeを参照しており、Web Workerの仕様に沿うようにwindowなどのオブジェクトを参照することは出来なくなっています。 また、スコープ中で参照したい変数がある場合は第二引数に渡すこと、およびその諸条件をクリアしている場合によってのみ第一引数で取得することが可能になっていたりと注意が必要です。

ページコントロール

前編から何度か登場している言葉であるページコントロール可能である状態か、というものがService Workerには存在しており、 ページコントロールが可能になった状態でないとページから発生するリクエストをService Workerでフック出来ません。

余談ですが、本件のService Workerスクリプト内では、NavigationPreloadManagerのプリロードレスポンスは実装やデバッグが複雑化することを考慮して、現段階では利用していません。

先ほどの想定されるテストケースを実装に落とし込むと、実際のテストコードでは前述のWorkerクラスを活用しながらCacheStorageにアクセスして都度、値を確認したり加工したりという記述が登場します。
さらに本件のメインスクリプト(React)の事情とPlaywrightおよびService Workerの事情をまとめると下記のようになります。

  • メインスクリプトはService Workerがページコントロールしているかどうかを判断せずWebサーバーへリクエストを行う実装になっている
    • メインスクリプトに複雑なロジックを必要とせずにキャッシュ機能を実装できるというService Workerの特性をそのまま生かしています。
    • 具体的な数値は出していないですが、余分なリソース読み込みが発生しない本件のテスト実行環境では「相当に速い」タイミングでリクエストが発生します。
  • Playwrighttest関数ごとに新しいコンテキストを生成するので、Service Workerは各テストケースにおいて毎回インストールからスタートする
  • Service Workerのページコントロールはデバイスなどの実行環境に左右されるものの、50~500ms程度の時間が必要になり、これは上記の「相当に速い」と比較すると時間の掛かる処理

このような事情によって、「Service Workerのページコントロールを待たずにアプリケーションは動くが、テストコードはページコントロールが完了してキャッシュが取得できるまでCacheStorageにアクセスして欲しくない」という実装がテストコードに求められることとなります。

PlaywrightのドキュメントではService Workerのページコントロールを待機する実装例が紹介されているので参考にしつつ、本件の仕様を加味すると下記のような実装によりiframeで実行されるService Workerスクリプトのページコントロールを待機します。

import { Page } from '@playwright/test';

/**
 * Service Workerがページコントロールするまで待機する
 * @param page
 * @param selector
 * @returns
 */
export async function waitUntilServiceWorkerActivated(
  page: Page,
  ...selector: Parameters<Page['$']>
): Promise<void> {
  // localhost:8080のiframeへの参照を取得する
  // https://playwright.dev/docs/api/class-frame
  const elementHandle = await page.$(...selector)
  const frameHandle = await elementHandle?.contentFrame();
  // ページコントロールを待機する
  await frameHandle?.evaluate(async () => {
    const registration = await window.navigator.serviceWorker.getRegistration();
    if (registration?.active?.state === 'activated')
      return;
    await new Promise(res => window.navigator.serviceWorker.addEventListener('controllerchange', res));
  });
}

このようにテストコード内においてはページコントロールを待機することで、ページコントロールされていない初回のリクエストはそのまま通してしまい、リロードを挟むことにより2回目以降のリクエストはService Workerをフックするのでキャッシュが取得できているという、実際のアプリケーションの動作と近しい仕様を再現できます。

実現出来なかったもの

以上のような前提を踏まえてテストコードの実装に関してはほぼ満足するものが実装出来たのですが、 1つ実現出来なかったものとして「Service Workerのスクリプト自身が更新されたら〇〇する」のという振る舞いのテストについては、 公式のissueを見ると「Service Workerスクリプトへのリクエストをインターセプトしてそのバイト列を書き換えて新たなスクリプトであると認識させる」というテストコードは現状Playwrightでは実現出来ないようで、こちらに関してはissueおよび公式の実装に沿ってtest.fixme()で通過させることとなりました。

CI(GitHub Actions)でのテスト実行

ここまでは全てローカル開発環境での実行を前提にしてきましたが、当然CIでも実行しておきたい内容であるのでGitHub Actionsを利用している場合はこちらを参考にステップを追加するとよいでしょう。 npx playwright installでダウンロードされるブラウザバイナリをキャッシュするにはこの辺りが参考になります。

UIのテストに関してはかなり安定していると筆者が思っているPlaywrightですが、こと本件に関してはローカル開発環境におけるテスト実行も全て安定している訳ではなく、非同期で実行されるService Workerのイベントを同期的にテストするというのはFlakyであると受け入れることにし、

  • retry回数を2回にする
  • CIではテストのtimeoutを長めに設定する

などを考慮することでローカル開発環境およびCI上でもテストが通るようになりました。

終わりに

以上がニコニコオーディションシステムにおける課題解決に至る内容となります。 Service Workerとそのテストコードの実装というニッチな内容故に必要な前提などが多くなってしまいましたが、皆様の参考になれば幸いです。

テストコードもあるしこれで全く心配ない状態、と言ってしまいたのですが、実際はシステムが大きくスケールするとCacheStorageに保存されるキャッシュデータによってユーザーデバイスのストレージ容量に影響が出る可能性などは残っています。 しかし、このように仕様からの理解を深めたことによって今後発生しうる問題にも十分対応出来るのではないかと筆者は目論んでおります。

本稿のキャッシュの仕組みはモダンブラウザが提供する仕組みを利用したものですが、Cloudflareが提供するCloudflare WorkersおよびWorkers KVをなどを採用することで、プログラマブルなキャッシュ機構を、ブラウザ無しで近似的に構築可能でしょう。

また、筆者が入社する時点で既にシステムが堅牢に動くものであったこと、改善・保守・運用のフローが整っていたこと、 まだまだニコニコのサービスのドメイン知識が浅い中で丁寧にハンズオンしていただいたことで安心して実装に入れるなどの 環境を用意いただいたのはとても大きな要素です。 特にテストコード実装に関しては手探りの部分も多い中、納得できるまで実装およびレビューに付き合っていただいたので、苦労する部分もありながら楽しみつつ取り組むことができたと感じています。


株式会社ドワンゴでは、様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。