30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

HTTP ヘッダを使ったキャッシュと Service Worker を使ったキャッシュの関係

ブラウザは HTTP ヘッダを使ってキャッシュの制御を行うが、それ以外にも Service Worker と CacheStorage を使ったキャッシュも存在する。
Service Worker はリクエストを制御し書き換えることが可能なので、HTTP ヘッダの指定を無視した振る舞いをさせることができる。
例えば HTTP ヘッダを使ってキャッシュしないように設定したとしても、Service Worker でキャッシュしてそれを返してしまえば、サーバへの問い合わせは行われない。

この記事では、実際にコードを書いてどのような挙動になるのかを確認していく。

動作確認に使った環境は以下の通り。

  • ウェブサーバ
    • Deno v1.10.3
  • ウェブクライアント
    • Google Chrome 91.0.4472.77

環境構築

まずは検証用の環境を用意する。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Cache</title>
</head>
<body>
  <p id="result">No Data</p>
  <script>
  if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker.register('/service-worker.js');
      });
    }
  </script>
  <script src="/create-dom.js"></script>
</body>
</html>

このなかで/service-worker.jsを Service Worker として登録しているが、このファイルはまだ空でよい。

同じく HTML ファイルのなかで読み込まれているcreate-dom.jsは以下の内容にする。

function appendButton(text, eventListener) {
  const button = document.createElement("button");
  button.type = "button";
  button.textContent = text;
  button.addEventListener("click", eventListener);
  const paragraph = document.createElement("p");
  paragraph.appendChild(button);
  document.body.appendChild(paragraph);
}

async function fetchScore(url) {
  const res = await fetch(url);
  const json = await res.json();
  return { name: json.name, score: json.score };
}

function showResult({ name, score }) {
  const result = document.querySelector("#result");
  result.textContent = `${name}: ${score}`;
}

appendButton("Alice", async () => {
  showResult(await fetchScore("/api/alice"));
});

appendButton("Bob", async () => {
  showResult(await fetchScore("/api/bob"));
});

これで 2 つのボタンがページに追加される。どちらも API を叩き、その結果をページに表示する。

最後に、これらのファイルや API を配信するサーバを立てる。
server.jsという名前で以下のファイルを作る。

for await (const conn of Deno.listen({ port: 8080 })) {
  handleConn(conn);
}

async function handleConn(conn) {
  const httpConn = Deno.serveHttp(conn);
  for await (const { request, respondWith } of httpConn) {
    const path = new URL(request.url).pathname;
    switch (true) {
      case /^\/$/.test(path): {
        const html = await Deno.readFile(`./index.html`);
        respondWith(
          new Response(html, {
            status: 200,
            headers: { "Content-Type": "text/html" },
          })
        );
        break;
      }

      case /\.js$/.test(path): {
        const jsFile = await Deno.readFile(`.${path}`);
        respondWith(
          new Response(jsFile, {
            status: 200,
            headers: { "Content-Type": "text/javascript" },
          })
        );
        break;
      }

      case /^\/api\/alice$/.test(path): {
        respondWith(
          new Response(JSON.stringify({ name: "Alice", score: 90 }), {
            status: 200,
            headers: {
              "Content-Type": "application/json",
            },
          })
        );
        break;
      }

      case /^\/api\/bob$/.test(path): {
        respondWith(
          new Response(JSON.stringify({ name: "Bob", score: 80 }), {
            status: 200,
            headers: {
              "Content-Type": "application/json",
            },
          })
        );
        break;
      }

      default: {
        respondWith(
          new Response("Not Found\n", {
            status: 404,
            headers: { "Content-Type": "text/plain" },
          })
        );
        break;
      }
    }
  }
}

この状態で以下のコマンドを実行し、http://localhost:8080/にアクセスする。

$ deno run --allow-net --allow-read --unstable --watch server.js

Aliceボタンを押下するとAlice: 90が、Bobボタンを押下するとBob: 80が表示されるはず。
これで検証用の環境を用意できた。

HTTP ヘッダによるキャッシュ

まず、Cache-Controlフィールドを使ってキャッシュしてみる。

/api/aliceheaders"Cache-Control": "max-age=30",を追加する。

             status: 200,
             headers: {
               "Content-Type": "application/json",
+              "Cache-Control": "max-age=30",
             },
           })
         );

そうすると、/api/aliceのレスポンスヘッダにcache-control: max-age=30が追加され、30秒間キャッシュが有効になる。
そのためその間は、server.jsで定義しているAlicescoreをいくら変更しても、それをクライアントが取得することはない。
Bobについてはキャッシュが有効になっていないので、scoreを変更したあとにフェッチすれば、最新の値を取得できる。

Service Worker を使ったキャッシュ

空のファイルだったservice-worker.jsを以下の内容にする。

const VERSION = "v1";

self.addEventListener("install", (e) => {
  e.waitUntil(
    new Promise(async (resolve) => {
      const cache = await caches.open(VERSION);
      cache.addAll(["/api/bob"]);
      self.skipWaiting();
      resolve();
    })
  );
});

self.addEventListener("activate", (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      self.clients.claim();
      resolve();
    })
  );
});

self.addEventListener("fetch", (e) => {
  e.respondWith(
    caches.match(e.request).then((response) => {
      if (response) {
        return response;
      }
      return fetch(e.request);
    })
  );
});

こうすると、Service Worker のインストール時に/api/bobにリクエストを飛ばし、その結果をキャッシュするようになる。
また、fetchイベント内で、キャッシュがないかを確認している。そのため/api/bobにリクエストがあった場合は、Service Worker がキャッシュの内容を返す。サーバへの問い合わせは発生しない。
そのため、Service Worker のインストール時のレスポンスがそのまま使われ続ける。server.jsによる定義をいくら変更しても、それは反映されない。

これは、HTTP ヘッダでキャッシュしないように設定しても、Service Worker でキャッシュを返してしまえばサーバへのアクセスが発生しないことを意味する。

例えば、/api/bobCache-Controlを以下のように設定して、サーバへのアクセスが必ず発生するようにしてみる。

             status: 200,
             headers: {
               "Content-Type": "application/json",
+              "Cache-Control": "no-store, no-cache, must-revalidate",
             },
           })
         );

その後ブラウザの開発者ツールで Service Worker や CacheStorage を削除した上で、再度ページを読み込む。

そうすると、先程と同様に Service Worker のインストール時に/api/bobにリクエストを飛ばすが、そのレスポンスにはcache-control: no-store, no-cache, must-revalidateが含まれている。
この場合、通常は再アクセス時にサーバに問い合わせてレスポンスを取得するのだが、CacheStorage に保存したキャッシュを返すように Service Worker で設定しているため、サーバへの問い合わせが行われなくなってしまう。

ちなみに/api/aliceへのリクエストもfetchイベントを経由するのだが、マッチするキャッシュが CacheStorage に存在しないため、サーバへの問い合わせを行おうとする。
このとき、HTTP ヘッダによる有効なキャッシュが存在すれば、それが使われる。この記事の例だと、30秒間はキャッシュが使われ続ける。Service Worker のfetchイベントを経由したからといって挙動が変わってしまうということはない。

CacheStorage の削除

service-worker.jsの内容を以下のようにすると、ページのリロード時や再訪時に Service Worker が更新され、CacheStorage からv1と名付けられたキャッシュが削除される。

const VERSION = "v1";

self.addEventListener("install", (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      self.skipWaiting();
      resolve();
    })
  );
});

self.addEventListener("activate", (e) => {
  e.waitUntil(
    new Promise(async (resolve) => {
      await caches.delete(VERSION);
      self.clients.claim();
      resolve();
    })
  );
});

self.addEventListener("fetch", (e) => {
  e.respondWith(
    caches.match(e.request).then((response) => {
      if (response) {
        return response;
      }
      return fetch(e.request);
    })
  );
});

この状態でBobボタンを押下すると、常に最新の値をサーバから取得する。