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秒間はキャッシュを使う」という指定が採用される。

参考資料

フォーム送信時の HTTP リクエストについて

この記事では、フォーム送信時に実際にどのようなデータが送信されるのか、ファイルを送信するためにはどうすればよいのか、などを Deno や curl で確認しながら見ていく。

動作確認に使った実行環境やツールのバージョンは以下の通り。

  • Google Chrome 85.0.4183.121
  • Deno 1.4.4
  • curl 7.54.0

サーバを立てる

まず、リクエストを受けるためのサーバを Deno で立てる。

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

const getRouting = async (req: ServerRequest) => {
  switch (req.url) {
    case "/": {
      const html = await Deno.readTextFile("./form.html");
      req.respond({
        status: 200,
        headers: new Headers({
          "content-type": "text/html",
        }),
        body: html,
      });
      break;
    }
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "content-type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
};

const postRouting = (req: ServerRequest) => {
  switch (req.url) {
    case "/":
      req.respond({
        status: 200,
        headers: new Headers({
          "content-type": "text/plain",
        }),
        body: "Your post was successful\n",
      });
      break;
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "content-type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
};

listenAndServe({ port: 8080 }, async (req: ServerRequest) => {
  console.log(`Request Method -> ${req.method}`);

  req.headers.forEach((value, key) => {
    console.log(`${bold(key)}: ${value}`);
  });

  const decoder = new TextDecoder("utf-8");
  const body = await Deno.readAll(req.body);
  console.log("Request Body");
  console.log(decoder.decode(body));

  switch (req.method) {
    case "GET":
      getRouting(req);
      break;
    case "POST":
      postRouting(req);
      break;
    default:
      req.respond({
        status: 405,
      });
  }
});

form.htmlの内容は以下の通り。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Form</title>
</head>
<body>
  <form method="POST" action="http://localhost:8080/">
    <label for="task">task: </label><input type="text" name="task" id="task"><br>
    <label for="priority">priority: </label><input type="text" name="priority" id="priority"><br>
    <p><input type="submit" value="submit"></p>
  </form>
</body>
</html>

deno run --allow-net --allow-read server.tsを実行すると、サーバが起動する。

URL エンコード

http://localhost:8080/にアクセスするとフォームが表示されるので、tasklearn JS & TSpriorityhighを入力して、送信してみる。
すると、以下の内容がログに表示される。

Request Method -> POST
host: localhost:8080
connection: keep-alive
content-length: 34
cache-control: max-age=0
upgrade-insecure-requests: 1
origin: http://localhost:8080
content-type: application/x-www-form-urlencoded
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
sec-fetch-site: same-origin
sec-fetch-mode: navigate
sec-fetch-user: ?1
sec-fetch-dest: document
referer: http://localhost:8080/
accept-encoding: gzip, deflate, br
accept-language: ja,en-US;q=0.9,en;q=0.8
Request Body
task=learn+JS+%26+TS&priority=high

最後のtask=learn+JS+%26+TS&priority=highがリクエストボディ。
各項目はキーと値が=で結合され、複数の項目がある場合は&で結合されて、一つの文字列になっている。
キーと値に含まれる&%26に変換されるため、区切り文字の&と混同されることはない。
今回の例だとスペースも+に変換されている。

learn JS & TS
↓ エンコード
learn+JS+%26+TS

フォーム送信と同様のリクエストを curl で発行するには、-dもしくは--data-urlencodeを使う。
それぞれのフラグの後ろにキー="値"という形で、送信したいデータを指定する。

まずは-dで送信してみる。

$ curl -d task="learn JS & TS" -d priority="high" http://localhost:8080
Request Method -> POST
host: localhost:8080
user-agent: curl/7.54.0
accept: */*
content-length: 32
content-type: application/x-www-form-urlencoded
Request Body
task=learn JS & TS&priority=high

無事に POST メソッドのリクエストを発行できた。
だがよく見てみると、スペースや&がエンコードされず、そのまま送信されてしまっている。

フォームでの送信と同様に自動的にエンコードしてもらいたい場合は、-dの代わりに--data-urlencodeを使う。

$ curl --data-urlencode task="learn JS & TS" --data-urlencode priority="high" http://localhost:8080
Request Method -> POST
host: localhost:8080
user-agent: curl/7.54.0
accept: */*
content-length: 40
content-type: application/x-www-form-urlencoded
Request Body
task=learn%20JS%20%26%20TS&priority=high

&%26にエンコードされているのはフォームの例と同じだが、スペースは+ではなく%20になっている。

これは、encodeURIComponentと同じ挙動。

console.log(encodeURIComponent("learn JS & TS")) // "learn%20JS%20%26%20TS"

このように、クライアントによってエンコードのロジックが異なる。

multipart/form-data

ここまでの例では、リクエストヘッダのcontent-typeは全てapplication/x-www-form-urlencodedだった。
この他に、multipart/form-dataもある。

form要素のenctype属性にmultipart/form-dataを指定すると、content-typemultipart/form-dataになる。

@@ -5,7 +5,7 @@
   <title>Form</title>
 </head>
 <body>
-  <form method="POST" action="http://localhost:8080/">
+  <form method="POST" action="http://localhost:8080/" enctype="multipart/form-data">
     <label for="task">task: </label><input type="text" name="task" id="task"><br>
     <label for="priority">priority: </label><input type="text" name="priority" id="priority"><br>
     <p><input type="submit" value="submit"></p>

先程と同じ値をフォームに入力して、送信してみる。
すると、リクエストは以下のような内容になった。

(中略)
content-type: multipart/form-data; boundary=----WebKitFormBoundaryKqKCxRXPgWo8hEBw
(中略)
Request Body
------WebKitFormBoundaryKqKCxRXPgWo8hEBw
Content-Disposition: form-data; name="task"

learn JS & TS
------WebKitFormBoundaryKqKCxRXPgWo8hEBw
Content-Disposition: form-data; name="priority"

high
------WebKitFormBoundaryKqKCxRXPgWo8hEBw--

content-typeboundary属性が設定されており、その値は----WebKitFormBoundaryKqKCxRXPgWo8hEBwになっている。
そしてボディでは、この文字列で、各項目を区切っている。

curl で同様のことを行うには、-dの代わりに-Fを使えばよい。

$ curl -F task="learn JS & TS" -F priority="high" http://localhost:8080

ファイルを送信したい場合は、multipart/form-dataを使う必要がある。
application/x-www-form-urlencodedだと、ファイルの情報を送信することができない。

例えば、フォームの内容を以下のように変える。

  <form method="POST" action="http://localhost:8080/">
    <input type="file" name="item" id="item"><br>
    <p><input type="submit" value="submit"></p>
  </form>

そして、以下の内容のtest.txtを送信する。

a
b

そうすると、リクエストボディは以下のようになる。

item=test.txt

name属性の値=ファイル名という情報しか存在せず、ファイルの内容を取得できない。

multipart/form-dataを使えば、ファイルの内容を取得できるようになる。

-  <form method="POST" action="http://localhost:8080/">
+  <form method="POST" action="http://localhost:8080/" enctype="multipart/form-data">
     <input type="file" name="item" id="item"><br>
     <p><input type="submit" value="submit"></p>
   </form>
------WebKitFormBoundaryIFek0BpUCfkWvtrd
Content-Disposition: form-data; name="item"; filename="test.txt"
Content-Type: text/plain

a
b

------WebKitFormBoundaryIFek0BpUCfkWvtrd--

ファイルの内容の他、そのファイル自身のContent-Typeなども取得できている。

curl で-Fオプションを使いながらファイル送信を行うには、attachment-file=@ファイルパスとすればよい。

curl -F attachment-file=@test.txt http://localhost:8080/

参考資料