JavaScriptのCache APIで有効期限付きの階層キャッシュを実装する

GPXトラックログを間引く・簡略化RWGPS地理院標高 - Chrome拡張機能 で国土地理院の標高タイルを利用する機能を付ける際、国土地理院の標高タイルをキャッシュする機能を実装したかった。

そこで有効期限を付けて、かつメモリとディスクの2階層構造を持つキャッシュを実装してみた。

要件と設計方針

  • 通信量およびCache APIでローカルに保存される容量を減らすため、標高タイルはPNG形式を使いたい
  • ネットワークよりは速いとは言えCache APIを呼び出すのは遅いので、ある程度はオンメモリでキャッシュを持ちたい
  • PNGからImageDataを取り出すのも時間がかかるので、オンメモリキャッシュにはresponseBodyではなく、読み込んだ後のImageDataを持ちたい
  • 国土地理院の標高タイルは更新されることがあるので、キャッシュには有効期限を設定したい

そこで

  • L1キャッシュはオンメモリでresponseBodyを欲しい形にしたものを保存
  • L2キャッシュはCache APIを使ってローカルにresponseを保存する(有効期限あり)

という2層構造のキャッシュを実装した。

実装したもの

作成したものは以下のLayeredCache

/**
 * L1, L2の2層のキャッシュ
 * @param {string} cache_name          Cache APIで使用する名前(cacheName)
 * @param {function} parser            responseを受け取ってキャッシュに保存する内容を返すPromiseを返すコールバック関数
 * @param {number} l1_cache_size       L1キャッシュに保持するURL件数
 * @param {number} l2_cache_expiration L2キャッシュの有効期限(ミリ秒)
 */
function LayeredCache(cache_name, parser, l1_cache_size = 1000, cache_expiration = 30 * 86400 * 1000){
  const HEADER_EXPIRATION = "_expire_on";
  const l1_cache = new Map();
  let l2_cache = {
    match: async function(){ return (void 0);},
    put:  async function(){ return (void 0);},
    delete:  async function(){ return true;}
  }; // ダミーのCacheオブジェクトを入れておく
  let prepared = false;
  
  if(window.caches){
    caches.open(cache_name).then((cs) => {
      l2_cache = cs;
      prepared = true;
    }).catch((e) => {
      console.error("Cache API ERROR");
      prepared = true;
    });
  }else{
    console.error("Cache API Not Supported");
    prepared = true;
  }
  
  /**
   * 指定したURLのデータをparser関数で処理した結果を返す。
   * L1にあればL1キャッシュからparserで処理済みの結果を返し、
   * 有効期限内のL2キャッシュ(Cache API)があれば、再度parser関数で処理して返す。
   * @param {string} url
   * @return {Promise}
   */
  this.fetch = async function(url){
    let data = null;
    let fetch_flag = false;
    let l1_update_flag = false;
    const now = Date.now();
    let expiration = now + cache_expiration;

    if(!prepared){
      // Cache APIの準備ができていなければ待機
      await new Promise((resolve, reject) => {
        const f = function(){
          if(prepared){
            resolve();
          }else{
            setTimeout(f, 10);
          }
        };
        setTimeout(f, 10);
      });
    }
    
    if(l1_cache.has(url)){
      // L1キャッシュにヒット
      const v = l1_cache.get(url);

      if(now > v.expire_on){
        // L1キャッシュで期限切れ
        fetch_flag = true;
      }else{
        // L1キャッシュの末尾に移動(LRU)
        l1_cache.delete(url);
        l1_cache.set(url, v);
        data = v.data;
      }

    }else{
      const response = await l2_cache.match(url);
      
      if((response === undefined)
        || response.headers.get(HEADER_EXPIRATION) === null
        || (now > Number.parseInt(response.headers.get(HEADER_EXPIRATION)))){
        // L2キャッシュにない場合、またはL2キャッシュが期限切れの場合
        fetch_flag = true;
      }else{
        data = await parser(response);
        expiration = Number.parseInt(response.headers.get(HEADER_EXPIRATION));
        
        l1_update_flag = true;
      }
    }
    
    if(fetch_flag){
      // 通信して取得する
      const response = await fetch(url);
      
      const copy = response.clone();
      const headers = new Headers(copy.headers);
      headers.append(HEADER_EXPIRATION, expiration);
      
      const body = await copy.blob();
      
      await l2_cache.put(url, new Response(body, {
        status: copy.status,
        statusText: copy.statusText,
        headers: headers
      }));
      
      data = await parser(response);
      l1_update_flag = true;
    }
    
    if(l1_update_flag){
      // L1キャッシュの末尾に保存
      l1_cache.set(url, {
        data: data,
        expire_on: expiration
      });
      if(l1_cache.length > l1_cache_size){
        l1_cache.delete(l1_cache.keys().next().value);
      }
    }
    
    return data;
  };
  
  return this;
}

Cache APIで有効期限を設定するには、cache.putに渡すときに独自のヘッダを付けて保存し、キャッシュから読み出すときにそのヘッダを確認することで実現できる。

LayeredCacheでは"_expire_on"というヘッダに1970年1月1日0時0分0秒から有効期限までの経過時間をミリ秒で記録している。

使い方

まずはresponseを欲しい形にするコールバック関数を定義する。

一番簡単な例としてresponse.text()を返すものの場合は以下となる。

const parser = function(response){
  if(response.ok){
    return response.text();
  }else{
    return "";
  }
}

parserPromiseを返すfunctionか、async functionとする。

そしてCache APIで使用するcacheNameと、L1キャッシュに保存するURL件数(デフォルト: 1000)、有効期限をミリ秒で指定(デフォルト: 30日)してインスタンスを生成する。

ここでは分かりやすくL1キャッシュは3件、L2キャッシュ有効期限は1分にする。

const lcache = new LayeredCache("cache_test", parser, 3, 60000);

あとはfetchにURLを指定すれば、L1→L2と検索してヒットすればキャッシュから読み出し、ヒットしなければfetchしたものをコールバック関数parserで処理した結果が返ってくる。

// 1回目はサーバに通信して取得
await lcache.fetch("https://cyberjapandata.gsi.go.jp/xyz/dem/14/14547/6461.txt");

// 2回目以降はL1キャッシュから取得
await lcache.fetch("https://cyberjapandata.gsi.go.jp/xyz/dem/14/14547/6461.txt");

// 新たに3件取得すると、最初のURLはL1キャッシュから外れ、L2キャッシュから取得する
await lcache.fetch("https://cyberjapandata.gsi.go.jp/xyz/dem/14/14547/6462.txt");
await lcache.fetch("https://cyberjapandata.gsi.go.jp/xyz/dem/14/14547/6463.txt");
await lcache.fetch("https://cyberjapandata.gsi.go.jp/xyz/dem/14/14547/6464.txt");
await lcache.fetch("https://cyberjapandata.gsi.go.jp/xyz/dem/14/14547/6461.txt");

// (1分待機)
// 1分以上経過していたときは再度サーバから取得する
await lcache.fetch("https://cyberjapandata.gsi.go.jp/xyz/dem/14/14547/6461.txt");

今までfetchを使っていたコードを以下のように書き換えればキャッシュを使えるようになる。

// (変更前)
fetch(url).then((response) => {
  return response.text();
}).then((text) => {
  console.log(text);
});

// (変更後)
lcache.fetch(url).then((text) => {
  console.log(text);
});

注意点・補足

  • 今の所GET専用で、fetchに細かいオプションは渡せず、cache.matchでも細かく照合条件を指定できない。

参考