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

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

ブラウザによるキャッシュの仕組み

ウェブサーバは、キャッシュに関する情報をレスポンスヘッダに含めることで、そのレスポンスで返したリソースをどのようにキャッシュすべきかをウェブクライアントに指示することができる。ウェブクライアントはその指示に基づいた処理を行う。
この記事では、キャッシュの制御に使われる HTTP ヘッダについて見ていく。

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

  • ウェブサーバ
    • Deno 1.4.4
  • ウェブクライアント
    • Google Chrome 86.0.4240.75

Last-Modified と If-Modified-Since

Last-Modifiedは、当該リソースの最終更新日時を伝えるためのヘッダで、値として以下のようなRFC-1123形式の日時を設定する。

Wed, 14 Oct 2020 15:00:00 GMT

レスポンスヘッダにLast-Modifiedフィールドを含めると、次にそのリソースに対してリクエストを発行した際に、リクエストヘッダにIf-Modified-Sinceが含まれるようになる。

例えば、以下のサーバを起動した状態でhttp://localhost:8080/cacheにアクセスすると、レスポンスヘッダにlast-modified: Wed, 14 Oct 2020 15:00:00 GMTが含まれる。
そして、2 回目以降のアクセスでは、リクエストヘッダにIf-Modified-Since: Wed, 14 Oct 2020 15:00:00 GMTが含まれるようになる。
http://localhost:8080/ではlast-modifiedを返していないので、何度アクセスしてもリクエストヘッダにIf-Modified-Sinceが含まれることはない。

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.74.0/http/mod.ts";

listenAndServe({ port: 8080 }, async (req: ServerRequest) => {
  const body = `<html>
<head></head>
<body>
  <p>a</p>
  <p>
    <a href="/">/</a>
  </p>
  <p>
    <a href="/cache">/cache</a>
  </p>
</body>
</html>`;

  switch (req.url) {
    case "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body,
      });
      break;
    }
    case "/cache": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
          "Last-Modified": new Date(2020, 9, 15).toUTCString(),
        }),
        body,
      });
      break;
    }
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
});

今回は実装していないが、リクエストヘッダのIf-Modified-Sinceの日時とサーバ側が持っている「最終更新日時」を比較し、リソースの更新がなければステータスコード304を返してキャッシュを使わせる、ということが可能になる。

ETag と If-None-Match

Last-Modifiedはリソースの更新日時でキャッシュを利用すべきか判断するが、ETagはリソースの中身でキャッシュを利用すべきか判断する。
具体的には、リソースのハッシュ値などをETagフィールドの値として設定する。

以下のコードでは、レスポンスボディの MD5 ハッシュ値を、ETagの値として使っている。

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.74.0/http/mod.ts";
import { createHash } from "https://deno.land/std@0.74.0/hash/mod.ts";

listenAndServe({ port: 8080 }, async (req: ServerRequest) => {
  const body = `<html>
<head></head>
<body>
  <p>a</p>
  <p>
    <a href="/">/</a>
  </p>
  <p>
    <a href="/cache">/cache</a>
  </p>
</body>
</html>`;

  switch (req.url) {
    case "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body,
      });
      break;
    }
    case "/cache": {
      const md5 = (value: string) => {
        const hash = createHash("md5");
        hash.update(value);
        return hash.toString();
      };
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
          "ETag": md5(body),
        }),
        body,
      });
      break;
    }
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
});

http://localhost:8080/cacheにアクセスすると、レスポンスヘッダにetag: 434bb620aed649cf07bac5554569f60fが含まれる。
そして、2 回目以降のアクセスでは、リクエストヘッダにIf-None-Match: 434bb620aed649cf07bac5554569f60fが含まれるようになる。

リソースが変わらない限りはETagの値も変わらないので、ETagが変わらない限りはキャッシュを使わせる、ということが可能になる。
先程のコードに以下の変更を行い、実装してみる。

         hash.update(value);
         return hash.toString();
       };
-      req.respond({
-        status: 200,
-        headers: new Headers({
-          "Content-Type": "text/html",
-          "ETag": md5(body),
-        }),
-        body,
-      });
+      const isValidCache = (ifNoneMatch: string | null) => {
+        if (ifNoneMatch === null) return false;
+        return ifNoneMatch === md5(body);
+      };
+      if (isValidCache(req.headers.get("if-none-match"))) {
+        req.respond({
+          status: 304,
+          headers: new Headers({
+            "Content-Type": "text/html",
+          }),
+        });
+      } else {
+        req.respond({
+          status: 200,
+          headers: new Headers({
+            "Content-Type": "text/html",
+            "ETag": md5(body),
+          }),
+          body,
+        });
+      }
       break;
     }
     default:

http://localhost:8080/cacheへの初回アクセスではステータスコード200を返すが、それ以降は304を返し、ブラウザにキャッシュを使わせるようになる。
<p>a</p>の部分を<p>b</p>に変えると、isValidCachefalseを返すようになるため、最新のリソースをステータスコード200で返す。
そしてまた、リソースが更新されない限りは304を返すようになる。

Expires

ここまでの例では、キャッシュを使う場合も HTTP リクエスト自体は発生していた。
Expiresフィールドを使うと、HTTP リクエストを抑制できる。
このフィールドを使って有効期限を設定することで、その期限が切れるまではクライアントは HTTP リクエスト自体を行わず、キャッシュを使い続けるようになる。
値のフォーマットは、Last-Modifiedと同様に、RFC-1123形式。

以下のコードでは、Expiresとして、サーバへのアクセスがあった20秒後の時間を設定している。
そのため、http://localhost:8080/cacheにアクセスしてから20秒間は、何度http://localhost:8080/cacheにアクセスしても、HTTP リクエストが発行されない。
サーバへのリクエストそのものが発生していないため、例えリソースが更新されたとしてもそれに気付くことができない。そのため、Expires以降にアクセスを行ってリクエストが発生するまでは、キャッシュされた古いリソースを使い続けることになる。

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.74.0/http/mod.ts";

listenAndServe({ port: 8080 }, async (req: ServerRequest) => {
  const body = `<html>
<head></head>
<body>
  <p>a</p>
  <p>
    <a href="/">/</a>
  </p>
  <p>
    <a href="/cache">/cache</a>
  </p>
</body>
</html>`;

  switch (req.url) {
    case "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body,
      });
      break;
    }
    case "/cache": {
      const now = new Date();
      now.setSeconds(now.getSeconds() + 20);
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
          "Expires": now.toUTCString(),
        }),
        body,
      });
      break;
    }
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
});

ここまで紹介してきたLast-ModifiedETagと組み合わせて使うことも可能。
その場合、Expiresが切れるまではキャッシュを使い続け、それ以降のアクセスについては、サーバがリソースの更新日時やハッシュ値を検証して、キャッシュを使うかどうかクライアントに指示を出すことになる。

Cache-Control

Cache-Controlによる指示は強力で、ここまで紹介してきた機能よりも優先される。

例えば、"Cache-Control": "no-store"は「キャッシュを利用しない」を意味しており、この指定がある場合、キャッシュを利用させようとする他の指示は無効化される。
先程の例に"Cache-Control": "no-store"を追加してみる。

         headers: new Headers({
           "Content-Type": "text/html",
           "Expires": now.toUTCString(),
+          "Cache-Control": "no-store",
         }),
         body,

Expiresで「20秒間は HTTP リクエストを行わずキャッシュを使う」と指示しているが、それは無効化され、アクセスの度に HTTP リクエストが発行される。

そしてno-storeは、Last-ModifiedETagも無効化する。
具体的には、レスポンスヘッダにLast-ModifiedETagを含めても、次回以降のリクエストヘッダにIf-Modified-SinceIf-None-Matchも含まれなくなる。
そのため、常に初回アクセスと同様に扱われ、キャッシュが使われない。

Cache-Controlには他に、no-cachemax-age=nなどを指定することができる。

no-cacheは、常に HTTP リクエストを発生させ、キャッシュを使うかどうかをサーバに判断させる。
こちらはIf-Modified-SinceIf-None-Matchは消されないため、これらの値を使って、キャッシュを使うかどうかをサーバに判断させることができる。

max-age=nは、n秒間は HTTP リクエストを行わず、キャッシュを使う。それ以降はIf-Modified-SinceIf-None-Matchを使って、キャッシュを使うかどうかサーバが判断する。

no-cachemax-age=nでもExpiresは無効化される。
Expiresの値がなんであれ、no-cacheの場合は常に HTTP リクエストが発行されるし、max-age=nの場合はExpiresの日時ではなく「n秒間はキャッシュを使う」という指定が採用される。

参考資料