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
のライブラリWorkbox
はnode
のテストに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を利用して各リクエストに対して
msw
のhandler
からレスポンスを返すようにしています。- テストコード内でケースに応じてレスポンスの内容を変更したいため、(Next.jsの例になりますが)このように
test
関数を拡張しています。
- テストコード内でケースに応じてレスポンスの内容を変更したいため、(Next.jsの例になりますが)このように
- 外部環境へリクエストが飛ばないように向き先のURLを変えた
想定されるテストケース
これでテストコードを実装する準備は整いました。
本件の要件を思い出すと、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:8080
のService Worker
によってCacheStorage
に保存されたリソースはlocalhost:4000
から参照することが出来ないという同一オリジンポリシーに従った動作です。
Workerクラス
前述のWorkerというPlaywright
が提供するクラスですが、こちらはWeb Workerをテストコードから操作可能にするクラスとなっています。
serviceWorker.evaluate(() => self.caches.keys());
上記のevaluate関数を実行すると第一引数は本件の場合localhost:8080
のService Worker
のコンテキストによって評価されるスコープになっています。
例えばそのスコープ中でglobalThis.self
はServiceWorkerGlobalScopeを参照しており、Web Workerの仕様に沿うようにwindow
などのオブジェクトを参照することは出来なくなっています。
また、スコープ中で参照したい変数がある場合は第二引数に渡すこと、およびその諸条件をクリアしている場合によってのみ第一引数で取得することが可能になっていたりと注意が必要です。
ページコントロール
前編から何度か登場している言葉であるページコントロール可能である状態か、というものがService Worker
には存在しており、
ページコントロールが可能になった状態でないとページから発生するリクエストをService Worker
でフック出来ません。
余談ですが、本件のService Worker
スクリプト内では、NavigationPreloadManagerのプリロードレスポンスは実装やデバッグが複雑化することを考慮して、現段階では利用していません。
先ほどの想定されるテストケースを実装に落とし込むと、実際のテストコードでは前述のWorkerクラスを活用しながらCacheStorage
にアクセスして都度、値を確認したり加工したりという記述が登場します。
さらに本件のメインスクリプト(React)の事情とPlaywright
およびService Worker
の事情をまとめると下記のようになります。
- メインスクリプトは
Service Worker
がページコントロールしているかどうかを判断せずWebサーバーへリクエストを行う実装になっている- メインスクリプトに複雑なロジックを必要とせずにキャッシュ機能を実装できるという
Service Worker
の特性をそのまま生かしています。 - 具体的な数値は出していないですが、余分なリソース読み込みが発生しない本件のテスト実行環境では「相当に速い」タイミングでリクエストが発生します。
- メインスクリプトに複雑なロジックを必要とせずにキャッシュ機能を実装できるという
Playwright
はtest
関数ごとに新しいコンテキストを生成するので、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をなどを採用することで、プログラマブルなキャッシュ機構を、ブラウザ無しで近似的に構築可能でしょう。
また、筆者が入社する時点で既にシステムが堅牢に動くものであったこと、改善・保守・運用のフローが整っていたこと、 まだまだニコニコのサービスのドメイン知識が浅い中で丁寧にハンズオンしていただいたことで安心して実装に入れるなどの 環境を用意いただいたのはとても大きな要素です。 特にテストコード実装に関しては手探りの部分も多い中、納得できるまで実装およびレビューに付き合っていただいたので、苦労する部分もありながら楽しみつつ取り組むことができたと感じています。
株式会社ドワンゴでは、様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。