19.06.27
ServiceWorker, Cache API を使用して 4万件のアセット永続化を試した話
はじめまして。 ノックノート Webクライアントエンジニアの永田です。
今月から ノックノートエンジニアによるブログがスタートします。
日々の業務中に出た、技術的な発見や
開発・運用中のプロダクトで得た知見の共有・備忘録として記事を投稿していきます。
又、勉強会・カンファレンスへの参加や、レポート等も発信します!
第一回目の投稿では
弊社で運用中のスマートフォン向けブラウザゲームの、通信量削減施策として
ServiceWorker | Cache API
を使用したアセットキャッシュの導入検討と実験結果を残します。
ServiceWorker | Cache API について
ServiceWorker とは
- ・ブラウザで表示している Web ページとは別に、バックグラウンドで実行するスクリプト
- ・localhost | https 環境でのみ利用可能
- ・Webページから発生するネットワークリクエストをプロキシ可能
Cache API とは
MDN: window.caches
Goggle: Using the Cache API
- ・リクエスト / レスポンス を保存、取得する為のAPI
- ・ServiceWorker 上でネットワークリクエストをプロキシする事で、オフライン時にも レスポンスを返せるように作成された
- ・ServiceWorker からも Webページ上で動く JavaScript からでも操作可能
導入
導入を検討しているサービスでは、/assets
配下のパスで全てのアセットを配信しています。
ServiceWorker で /assets
配下へのリクエストをプロキシし
アセットキャッシュがあれば返却、無ければ取得&キャッシュ生成を行う処理を以下のような形で実装しました。
const CACHE_STORAGE_NAME = 'v1'; self.addEventListener('fetch', function(event) {
// assets 以外は何もしない。 if (!event.request.url.includes('assets/')) return;
event.respondWith( caches.match(event.request).then(function(resp) { // キャッシュが存在すれば、キャッシュを利用する。なければ取得し、キャッシュに保存する。 return resp || fetch(event.request).then(function(response) { return caches.open(CACHE_STORAGE_NAME).then(function(cache) { cache.put(event.request, response.clone()); return response; }); }); }); ); });
上記を実装した状態で、devtools 等で通信を確認すると from ServiceWorker の表示が確認できました!
以降は、プロダクトへの実装に向けて発生した課題と、ハマった罠について記載していきます。
課題1:Cache 毎のバージョニングをどうするか
導入を検討しているプロダクトでは、CDNから静的アセットの配信を行なっています。
ブラウザキャッシュの max-age は 31536000 ( 1年 ) とかなり大きくしているため
同一アセットに更新が発生した場合、ブラウザキャッシュを適切に飛ばす必要があります。
対策として全アセットのバージョン番号が管理され、リクエストパラメータに付与されています。
assets/hoge/fuga/moge.png?v=1
導入の実装ではリクエスト毎にキャッシュを保存するため
同一アセットの古いバージョンのキャッシュを破棄する必要があります。
資料を読みながら、動作確認していたところ
MDN: Cache.matchAll(url, { ignoreSearch: true })
を使用する事で、同一アセット・複数バージョンのキャッシュリストを取得する事ができました!
リストさえ取得できれば、後は不要になったアセットを破棄するだけです。
ウキウキで実装し、開発環境に適応しました。
課題2:ストレージ容量
キャッシュの保存・不要なバージョンの削除ができたところで
保存可能な容量が気になりはじめました。
Google: estimating-available-storage-space
を参考に実装を行なったところ、使用可能量の取得に成功しました!
こちらもウキウキで実装し、開発環境に適応しました。
課題3:ストレージ永続化
キャッシュ容量の上限に達した際、通常LRUで削除されてしまいますが
特定の条件を満たせば、永続化する事が可能です。
詳細に関しては弊社五嶋の以下の記事にまとめられておりますので
興味のある方はご覧ください
ハマった罠1: navigator.storage.estimate() で正しい値が取れない事がある
開発環境での検証中、全てのキャッシュを削除しキャッシュし直しを行う過程で発見しました。
MDN: CachStorage.delete()
window.caches.delete(storageName)
で全削除を行なった場合、navigator.storage.estimate()
で取れる値に変化がありませんでした…( 削除したはずのアセット分減らない )
MDN: Cache.keys
keys を使用し、個別のアセット毎に delete を実行すれば、即座に正常な値が取得できました。
ハマった罠2: Cache.keys() で DomException が発生する
罠1を解消すべく、保存済みの全てのキャッシュを取得するために Cache.keys() を行うも
数万件キャッシュしている状態で実行すると
Dom Exception operation too large
が発生してしまいました..
運用中のプロダクトには存在し得るアセットのリストを取得する手段があったので
アセットリストをループし、削除を行う事で対応しました。
ハマった罠3: Cache.matchAll の実行速度が…
運用中のプロダクトと同等のアセット量( 最大4万件程 )が存在する環境に上記実装を行い
Android端末にて、動作を確認していると、ものすごくモッサリする。
キャッシュあるはずなのに。遅い。
ボトルネックを探っていったところ、 Cache.matchAll(url, { ignoreSearch: true })
が原因でした。。一つのアセットの検索に 100ms 以上かかる事も。。。。。
cache.match 使用時
保存するキャッシュ領域をシャーディングし、検索母数を減らす事で対応を試みました。
キャッシュ領域のシャーディング
キャッシュ領域をシャーディングする際、以下のような実装を行いました。
const MAX_SHARD = 5;
self.addEventListener('fetch', function(event) { // assets 以外は何もしない。 if (!event.request.url.includes('assets/')) return; const normalizeUrl = new URL(event.request.url);
// リクエストurlからシャーディングキーを生成する。 const shard = new TextEncoder().encode(event.request.url).reduce((p, c) => p + c) / MAX_SHARD; event.respondWith( caches.match(event.request).then(function(resp) {
// キャッシュが存在すれば、キャッシュを利用する。なければ取得し、キャッシュに保存する。 return resp || fetch(event.request).then(function(response) { return caches.open(`sw-cache-${shard}}`).then(function(cache) { cache.put(event.request, response.clone()); return response; }); }); }) ); });
上記対応を行う事で1つのキャッシュ領域辺り、数百件程度であれば検索コストを許容できそうでした。
そもそも cache.matchAll を使用しない方が良い?
本記事作成中に
そもそも、リクエスト発行時に送る query param が最新であるという前提があれば
同一アセット・複数バージョンのキャッシュを保持する必要がない事に気付きました。
そこで
self.addEventListener('fetch', event => {
// キャッシュ使用する必要の無い物は弾く
if (!useCache(event)) return;
const normalizeURL = new URL(event.request.url);
const pathname = normalizeURL.pathname;
// シャーディングした cache 接続先を取得
const storageKey = getStorageKey(pathname);
event.respondWith(
caches.open(storageKey).then(responseCache => {
// .url には query param も含まれる。完全に一致した場合のみ、キャッシュを保持する。
if (responseCache && responseCache.url === request.url) {
return responseCache;
}
return fetch(request.url).then(response => {
if (!response.ok) return response;
return cache.put(pathname, response.clone()).then(() => response);
});
);
});
上記のように pathname 毎にレスポンスをキャッシュし
match 実行時も pathname で検索する事で検索パフォーマンスを大きく改善する事が出来ました。
以下、単一のcache 領域に 50 ~ 2000 件保存し、各段階で検索を実行した際のパフォーマンス測定の結果です。
matchAll 使用時には 350件あたりから 100ms を超えてきていましたが、match を使用する事で大きく改善が見られます。
cache.match 使用時
本番への反映はまだ行なっておりませんが
・アセット毎に、複数バージョンのキャッシュを作らない。( 必ず1アセット1キャッシュにする。 )
・キャッシュ領域はシャーディングし、検索対象の母数を下げる。
・検索時は match を使う。
これらに気をつけて実装を行えば、数万件のアセット永続化は理論上可能という結論に達しました。
本記事を作成するに辺り、挙動再調査の為のコード群を以下のリポジトリに残しました。
興味のある方は、お手元でお試しください。