【Service Worker 基礎知識】いろいろなキャッシュ戦略

Service Worker では、キャッシュやリクエストの処理方法を制御できます。 考えられるいくつかのパターンがあり、「キャッシュ戦略」として一般化されています。

前の記事で、Service Worker の仕組みは理解できたと思います。しかし、いざ実装しようとすると設計につまづくと思います。幸い、キャッシュに関してはいくつかの考えうるパターンが確立されており、ほとんどの場合はいずれかのパターンに該当します。これらのパターンを組み合わせることで実用性のある Service Worker キャッシュ機能を実装することができると思います。

以降の内容で、これらのパターン「キャッシュ戦略」を順に見ていきます。

パターン: アセットをキャッシュするタイミング

キャッシュするタイミングに迷う場合、下記のどのパターンに該当するかを考えます。ほとんどの場合はいずれかに該当するはずです。

キャッシュタイミングのタイミング最適なケース
インストール時 – 依存関係としてサイトの特定のバージョンに対して静的と見なしうるもの
インストール時 – 依存関係としてではなくサイズが大きく、すぐには必要とされないリソース
(例:ゲームの後の方のレベルで使用されるアセットなど)
アクティベート時クリーンアップと移行
ユーザー操作時サイト全体をオフラインにできない場合に、ユーザーがオフラインで利用したいコンテンツを選択できるようにする
(例: YouTube などの動画、Wikipedia の記事、Flickr の特定のギャラリーなど)
ネットワークの応答時頻繁に更新されるリソース
(例:ユーザーの受信トレイや記事コンテンツなど)
Stale-While-Revalidate頻繁に更新されるが、必ずしも最新のバージョンでなくてもよいリソース
(例:アバター)
プッシュ メッセージ時通知に関連するコンテンツ。 また、頻繁には変更されないが即座に同期することに意味があるコンテンツ
(例:チャット メッセージ、ニュース速報、メール、TODO リストの更新やカレンダーの予定変更など)
バックグラウンド同期時急を要さない更新
(例:ソーシャル メディアのタイムラインやニュース記事など)

インストール時 – 依存関係として

install イベント時に cache.addAll() の中でキャッシュするアセットを指定して return します。インストールが成功するためには、すべてキャッシュされる必要があります。1つでもキャッシュできなければインストールは失敗します。

  1. self.addEventListener('install', function(event) {
  2. event.waitUntil(
  3. caches.open('mysite-static-v3').then(function(cache) {
  4. return cache.addAll([
  5. // キャッシュするアセット(依存関係)
  6. ]);
  7. })
  8. );
  9. });

インストール時 – 依存関係としてではなく

install イベント時に cache.addAll() の中でキャッシュするアセットを指定しますが、return はしません。これにより、キャッシュの成否がインストールの成否に影響しません。

  1. self.addEventListener('install', function(event) {
  2. event.waitUntil(
  3. caches.open('mygame-core-v1').then(function(cache) {
  4. cache.addAll(
  5. // キャッシュするアセット(非依存関係)
  6. );
  7. return cache.addAll(
  8. // キャッシュするアセット(依存関係)
  9. );
  10. })
  11. );
  12. });

アクティベート時

activte イベント時に不要なキャッシュを削除します。アクティベート中は、fetch などの他のイベントはキューに入れられるため、アクティベーションは最小限に留めます。古いバージョンがアクティブだったときに実行できなかった処理のみにアクティベーションを使用します。

  1. self.addEventListener('activate', function(event) {
  2. event.waitUntil(
  3. caches.keys().then(function(cacheNames) {
  4. return Promise.all(
  5. cacheNames.filter(function(cacheName) {
  6. // ここで true を返した場合、そのキャッシュは削除される
  7. }).map(function(cacheName) {
  8. return caches.delete(cacheName);
  9. })
  10. );
  11. })
  12. );
  13. });

ユーザー操作時

click などのユーザーの操作によるイベントを監視します。「後で読む」ボタンが良い例です。

  1. document.querySelector('.cache-article').addEventListener('click', function(event) {
  2. event.preventDefault();
  3.  
  4. var id = this.dataset.articleId;
  5. caches.open('mysite-article-' + id).then(function(cache) {
  6. fetch('/get-article-urls?id=' + id).then(function(response) {
  7. // /get-article-urls は、JSON エンコードされた アセットのURLの配列を返す
  8. return response.json();
  9. }).then(function(urls) {
  10. cache.addAll(urls);
  11. });
  12. });
  13. });

ネットワーク応答時

リクエストに一致するものがキャッシュ内になければ、ネットワークから取得してページに送信し、同時にキャッシュにも追加します。

  1. self.addEventListener('fetch', function(event) {
  2. event.respondWith(
  3. caches.open('mysite-dynamic').then(function(cache) {
  4. return cache.match(event.request).then(function (response) {
  5. // キャッシュがあればそれを返す
  6. return response || fetch(event.request).then(function(response) {
  7. // キャッシュに追加
  8. cache.put(event.request, response.clone());
  9. // ネットワークから取得したものを返す
  10. return response;
  11. });
  12. });
  13. })
  14. );
  15. });

Stale-While-Revalidate

直前に紹介した「ネットワークの応答時」と似ていますが、こちらは、キャッシュがあってもなくてもアセットをネットワークから取得し、そしてキャッシュを更新します。

  1. self.addEventListener('fetch', function(event) {
  2. event.respondWith(
  3. caches.open('mysite-dynamic').then(function(cache) {
  4. return cache.match(event.request).then(function(response) {
  5. // ネットワークから取得してキャッシュを更新
  6. var fetchPromise = fetch(event.request).then(function(networkResponse) {
  7. cache.put(event.request, networkResponse.clone());
  8. return networkResponse;
  9. });
  10. // キャッシュまたは取得したアセットを返す
  11. return response || fetchPromise;
  12. })
  13. })
  14. );
  15. });

プッシュ メッセージ時

例: Twitter アプリの問題点

  1. デバイスがオンライン時にプッシュ通知を受信し、表示される。
  2. その後、デバイスがオフラインになったときに、プッシュ通知をタップして Twitter を起動する
  3. ネットワークに繋がらないため、該当のツイートを取得できない
  4. 一度タップしたため、プッシュ通知が消えている。

これはよくない例です。本来あるべき姿は、プッシュ通知を受信したら、表示する前にアセットをキャッシュしておくことです。そうすれば、デバイスがオフラインのときでも該当のツイートを参照できます。

  1. // プッシュ通知受信時
  2. self.addEventListener('push', function(event) {
  3. if (event.data.text() == 'new-email') {
  4. event.waitUntil(
  5. caches.open('mysite-dynamic').then(function(cache) {
  6. return fetch('/inbox.json').then(function(response) {
  7. // キャッシュへ追加
  8. cache.put('/inbox.json', response.clone());
  9. return response.json();
  10. });
  11. }).then(function(emails) {
  12. // 通知を行う
  13. registration.showNotification("New email", {
  14. body:"From " + emails[0].from.name
  15. tag: "new-email"
  16. });
  17. })
  18. );
  19. }
  20. });
  21.  
  22. // 通知ボタンクリック時
  23. self.addEventListener('notificationclick', function(event) {
  24. if (event.notification.tag == 'new-email') {
  25. new WindowClient('/inbox/');
  26. }
  27. });

バックグラウンド同期時

sync イベント時にキャッシュへ追加します。

  1. self.addEventListener('sync', function(event) {
  2. if (event.id == 'update-leaderboard') {
  3. event.waitUntil(
  4. caches.open('mygame-dynamic').then(function(cache) {
  5. return cache.add('/leaderboard.json');
  6. })
  7. );
  8. }
  9. });

パターン: リクエストへの応答

キャッシュは使わなければ意味がありません。リクエストがあったとき、キャッシュを提供するのか、ネットワークから取得するのか、タイミングを含めると様々な制御方法が考えられます。

リクエストへの応答パターン最適なケース
Cache Only(キャッシュのみ)サイトの特定の「バージョン」に対して静的と見なしうるすべてのもの
(補足:install イベントでキャッシュされているはずなので、存在を前提とする)
Network Only(ネットワークのみ)アナリティクス ping、GET 以外のリクエストなど、オフラインに相当するものがない
Cache First(キャッシュになければネットワークから取得)オフラインファーストのアプリを作成する場合
キャッシュとネットワークの優劣ディスク アクセスが低速な端末でパフォーマンスを追及する場合の小さなアセット
Network First(ネットワークから取得できなければキャッシュから取得)サイトの「バージョン」外で頻繁に更新されるリソースが取得できなかった場合の応急策
(例:記事、アバター、ソーシャル メディアのタイムライン、ゲームの得点ランキングなど)
先にキャッシュ、次にネットワーク頻繁に更新されるコンテンツ
(例:記事、ソーシャル メディアのタイムライン、ゲームの得点ランキングなど)
汎用的なフォールバックキャッシュやネットワークからリソースを提供できない場合
(例:アバターなどのセカンダリ画像、失敗した POST リクエスト、「このページはオフラインでは利用できません」というページ)
Service Worker 側のテンプレートサーバー レスポンスをキャッシュできなかったページ

Cache Only(キャッシュのみ)

  1. self.addEventListener('fetch', function(event) {
  2. // もしキャッシュが見つからなかったとき、(ネットワークから取得できる状態でも)エラーとなります。
  3. event.respondWith(caches.match(event.request));
  4. });

Network Only(ネットワークのみ)

  1. self.addEventListener('fetch', function(event) {
  2. event.respondWith(fetch(event.request));
  3. // event.respondWith() とはしないでください。ブラウザはデフォルトの動作になります。
  4. });

Cache First(キャッシュになければネットワークから取得)

オンラインファーストのアプリの場合、大抵この方法が適用されます。

  1. self.addEventListener('fetch', function(event) {
  2. event.respondWith(
  3. caches.match(event.request).then(function(response) {
  4. return response || fetch(event.request);
  5. })
  6. );
  7. });

キャッシュとネットワークの優劣

一般的にはないことですが、キャッシュを取得するよりもネットワークからの方が先だった場合、それをレスポンスします。

  1. // Promise.race() は、いずれか1つが拒否されると全体でも拒否するためこのケースには相応しくありません。
  2. function promiseAny(promises) {
  3. return new Promise((resolve, reject) => {
  4. // promises の要素が全ての Promise オブジェクトであることを保証する
  5. promises = promises.map(p => Promise.resolve(p));
  6. // 最初に1つ resolve した時点で全体でも resolve にする
  7. promises.forEach(p => p.then(resolve));
  8. // すべて reject だったら全体でも reject
  9. promises.reduce((a, b) => a.catch(() => b))
  10. .catch(() => reject(Error("All failed")));
  11. });
  12. };
  13.  
  14. self.addEventListener('fetch', function(event) {
  15. event.respondWith(
  16. promiseAny([
  17. caches.match(event.request),
  18. fetch(event.request)
  19. ])
  20. );
  21. });

Network First(ネットワークから取得できなければキャッシュから取得)

  1. self.addEventListener('fetch', function(event) {
  2. event.respondWith(
  3. // ネットワークから取得
  4. fetch(event.request).catch(function() {
  5. // ネットワークがダメだったのでキャッシュ
  6. return caches.match(event.request);
  7. })
  8. );
  9. });

先にキャッシュ、次にネットワーク

ページのコード:

  1. var networkDataReceived = false;
  2.  
  3. startSpinner();
  4.  
  5. // 新しいデータを取得する
  6. var networkUpdate = fetch('/data.json').then(function(response) {
  7. return response.json();
  8. }).then(function(data) {
  9. networkDataReceived = true;
  10. updatePage(data);
  11. });
  12.  
  13. // キャッシュデータを取得する
  14. caches.match('/data.json').then(function(response) {
  15. if (!response) throw Error("No data");
  16. return response.json();
  17. }).then(function(data) {
  18. // 新しいネットワークデータを上書きしないでください
  19. if (!networkDataReceived) {
  20. updatePage(data);
  21. }
  22. }).catch(function() {
  23. // キャッシュされたデータを取得できませんでした。ネットワークは最後の希望です。
  24. return networkUpdate;
  25. }).catch(showErrorMessage).then(stopSpinner);

Service Worker のコード:

  1. self.addEventListener('fetch', function(event) {
  2. event.respondWith(
  3. caches.open('mysite-dynamic').then(function(cache) {
  4. return fetch(event.request).then(function(response) {
  5. cache.put(event.request, response.clone());
  6. return response;
  7. });
  8. })
  9. );
  10. });

汎用的なフォールバック

  1. self.addEventListener('fetch', function(event) {
  2. event.respondWith(
  3. // キャッシュ取得を試みる
  4. caches.match(event.request).then(function(response) {
  5. // ネットワークにフォールバックする
  6. return response || fetch(event.request);
  7. }).catch(function() {
  8. // 両方とも失敗した場合、一般的なフォールバックを表示する
  9. return caches.match('/offline.html');
  10. // 実際にはURLやヘッダーに応じてたくさんのフォールバックコンテンツを用意する必要があります。
  11. // 例えば、「No Image」画像
  12. })
  13. );
  14. });

Service Worker 側のテンプレート

  1. self.importScripts('templating-engine.js');
  2.  
  3. self.addEventListener('fetch', function(event) {
  4. var requestURL = new URL(event.request.url);
  5.  
  6. event.respondWith(
  7. Promise.all([
  8. // キャッシュからテンプレートを取得
  9. caches.match('/article-template.html').then(function(response) {
  10. return response.text();
  11. }),
  12. // キャッシュからデータを取得
  13. caches.match(requestURL.path + '.json').then(function(response) {
  14. return response.json();
  15. })
  16. ]).then(function(responses) {
  17. var template = responses[0];
  18. var data = responses[1];
  19.  
  20. return new Response(renderTemplate(template, data), {
  21. headers: {
  22. 'Content-Type': 'text/html'
  23. }
  24. });
  25. })
  26. );
  27. });

まとめ

「アセットをキャッシュするタイミング」と「リクエストへの応答」のパターンを見てきました。ほとんどの場合は、それぞれいずれかのパターンを採用して最適なキャッシュ戦略を立てることができるでしょう。特に、次のキャッシュ戦略についてはおさえておくと良いです。

  • Stale While Revalidate
  • Network First
  • Cache First
  • Network Only
  • Cache Only

なお、これらの実装には、Workbox というライブラリーを使うことで効率的に行うことができます。Service Worker を実装する際には是非利用したいおすすめのライブラリーです。

参考資料