WebのCache-Control

# はじめに

Webは、HTTP通信による単なるクライアントサーバシステムではなく、受け取ったHTTP Responseをキャッシュして再利用することを、基盤に組み込んでいます。

HTTPの`Cache-Control`ヘッダを使ってキャッシュ制御のヒントとなる情報を通信相手に提示し、ブラウザやWebサーバといった受け手それぞれがその情報をもとに自律的に振る舞うことで、Webでは分散システムとしてキャッシュ制御が成立しています。

このキャッシュ機構の存在によって、ブラウザからは通信なしでもハイパーリンク関係を処理できる状況が少なからず成立し、この結果、Webは通信効率の良いネットワークシステムとなっています。


この記事では、まず、Webでのキャッシュ制御が想定するモデルについてまとめ、そして、このモデルに対して、リクエストレスポンス双方の`Cache-Control`ヘッダの値がどのようなキャッシュ制御情報として意味を持つか、についてまとめています。

# Webのキャッシュ制御のモデル

以下の観点からのモデルの認識と識別ができるかどうかが、キャッシュ制御の理解のポイントになるでしょう:

- privateキャッシュとsharedキャッシュ
- fresh状態とstale状態
- キャッシュの検証
- キャッシュ検索キーを設定する`Vary`レスポンスヘッダ

## privateキャッシュとsharedキャッシュ

キャッシュは用途に応じて、privateキャッシュとsharedキャッシュの二種類があります。

- privateキャッシュ: ブラウザ等での、キャッシュデータをそのUser Agent自身のためだけに使う場合のキャッシュ
- sharedキャッシュ: プロキシやCDNのように、他のUser Agentへキャッシュしたデータを渡す前提のキャッシュ

sharedキャッシュは、User Agentとコンテントを返すオリジンサーバの間に存在する中間キャッシュの位置づけになります。
オリジンに至るまでに、sharedキャッシュが多段で存在することも想定されます。

User Agentとキャッシュ、キャッシュとオリジン、キャッシュとsharedキャッシュ、sharedキャッシュとキャッシュの間でも、HTTPリクエストレスポンスをメッセージとして用います。
それぞれのHTTPヘッダには、**URLとコンテントに関する通常の情報**と、**キャッシュ制御に関する情報**とが混在します。また、**直接の通信相手のみに向けてのキャッシュ制御情報**と、通信相手のsharedキャッシュを超えて**透過的に渡されるキャッシュ制御情報**の双方が存在する点も注意です。

## fresh状態とstale状態

キャッシュされた個々のHTTP Responseを含むキャッシュデータは、freshness(鮮度)を表す状態として、**fresh**(新鮮)状態と**stale**(古い)状態とに区別されます。


キャッシュデータの経過時間を**age**と呼び、`Date`レスポンスヘッダの時刻などを起点に計算されます。
fresh状態としての**生存期間(lifetime)**があり、ageがその生存期間を過ぎると、stale状態として扱われます。

キャッシュにマッチするHTTP Requestが発生したとき、その状態によって基本的には以下のように扱います(後述のキャッシュ制御情報によって、この基本動作も変更されえます):

- fresh状態: サーバに通信せずにキャッシュデータのHTTP Responseを再利用する
- stale状態: サーバに検証のHTTP Requestを送り、そのHTTP Responseからキャッシュを再利用するかしないか判断する

キャッシュ制御が成立しているときは、fresh状態は変更はない、stale状態は変更したかもしれない、という解釈で扱われるでしょう。いいかえると、fresh期間は、サーバ側で実際にfresh状態時間内に変更があってもよいけれど、個々のブラウザの側からは変更なしの状態としても成立させてある、併存期間であるとみなせるでしょうか。

### ageの計算

RFC7234の4.2.3に計算手順がありますが、HTTP Requestを出す側からの視点でまとめると、結果は3種の計算となります:

- 1. `Date`レスポンスヘッダがある場合: 現在時刻-Date時刻
- 2. `Age`レスポンスヘッダがある場合: Age秒+(現在時刻-リクエスト時刻)
- 3. ageの下限: 現在時刻-リクエスト時刻

この「リクエスト時刻」は、`Date`リクエストヘッダに相当するもので、**キャッシュしたHTTP Responseを得たときのHTTP Request**の開始時刻になるでしょう。
`Date`を使う場合は`Date`からの相対時間、`Age`を使う場合は`Age`にリクエストレスポンスでかかった時間を足したもの、と解釈できます。

そして、ageの値は、この3値の最大値を採用します。

また、キャッシュしたHTTP Responseにある`Age`を使って算出する場合でも、レスポンス時刻から現在時刻までの時間(現在時刻-レスポンス時刻)に、リクエストレスポンス時間(レスポンス時刻-リクエスト時刻)を、足し合わせた時間(現在時刻-レスポンス時刻+レスポンス時刻-リクエスト時刻 = 現在時刻-リクエスト時刻)と解釈でき、前にキャッシュしたものを使う場合であっても同じ計算でよいことが確認できます。

## キャッシュの検証

キャッシュ可能なデータの場合には、そのHTTP Responseに、データの内容に対応した`Last-Modified`ヘッダや`ETag`ヘッダが存在します。

キャッシュ検証のHTTP Requestでは、内容の更新の有無を確認するために、これらのReqponseヘッダの値をつかって、検証条件の`If-Modified-Size`ヘッダや`If-Non-Match`ヘッダを設定します。

- `Last-Modified: WWW, DD MM YYYY HH:mm:SS TTT` => `If-Modified-Since: WWW, DD MM YYYY HH:mm:SS TTT`
- `ETag: "xxx"` => `If-Non-Match: "xxx"`

Webサーバ側では、この検証HTTP Requestに対してデータに更新がない場合は、`304 Not Modified`なHTTP Responseを返せます。

User Agent側で304なHTTP Responseを受け取ったら、キャッシュしてあるデータを使うことになります。
ちなみに、この304のHTTP Responseに`Date`や`Cache-Control`等のレスポンスヘッダがある場合には、このヘッダの値でキャッシュしたHTTP Responseのヘッダ値を制御設定として上書きします。

304以外のHTTP Responseでは、エラーも含め、そのHTTP Responseのデータのほうを使います。たとえば`200 OK`の場合は、さらにキャッシュデータのほうも更新することになるでしょう。

また、キャッシュから検証HTTP Request自体発行できないなど、User Agent側で検証自体ができない場合、`504 Gateway Timeout`なHTTP Responseにもなります。

### キャッシュからHTTP Responseに付与されるHTTPレスポンスヘッダ

以下は、(shared)キャッシュがキャッシュデータを用いたHTTP Responseを返すときに追加するヘッダです:

- `Age: 60`: キャッシュ開始からの経過時間(秒)
- `Warning: NNN host "warning message"`: キャッシュしているhost(`host:port`または`-`)からの返すキャッシュデータへの付属情報

`Warning`ステータスには、以下のようなものがあります。複数の`Warning`ヘッダの列挙も可能です。

- `Warning: 110 - "Response Is Stale"`: stale状態のものを返した
- `Warning: 111 - "Revalidation Failed"`: サーバへ検証できなかった
- `Warning: 112 - "Disconnected Operation"`: (検証以前に)ネットワークに繋がってなかった
- `Warning: 113 - "Heuristic Expiration"`: 24時間以上経過したキャッシュである
- `Warning: 199 - "Miscellaneous Warning"`: その他の警告
- `Warning: 214 - "Transformation Applied"`: 渡したデータは元のものを変換してある
- `Warning: 299 - "Miscellaneous Persistent Warning"`: その他警告

1xx系のWarningは、キャッシュ自身の状態についてのものであり、受けたUser Agent側のキャッシュデータとして保存しません。2xx系のWarningは、User Agent側のキャッシュデータとしても保存されるものになります。

## キャッシュ検索キーを設定する`Vary`レスポンスヘッダ

`Vary`レスポンスヘッダとは、そのHTTP Responseが「HTTP Request中のとあるヘッダ情報をもとにして選ばれた表現(representation)である」ことを明示するレスポンス情報です。

たとえば、コンテントネゴシエーションとして`Accept-Language: ja`リクエストヘッダを見て、言語選択して返したコンテントである場合、`Vary: Accept-Language`をHTTP Responseのヘッダで設定します。

キャッシュでは、URIとHTTPメソッドに加え、この`Vary`ヘッダで指定されたHTTPリクエストヘッダの状態も、(検証や再利用で用いる)キャッシュしたHTTP Responseへの検索キーとして扱うことを要請するものです。

先の例の`Accept-Language: ja`なリクエストから返された`Vary: Accept-Language`なHTTP Responseは、`Accept-Language: ja-JP`や`Accept-Language: *`であったり`Accept-Language`ヘッダ自体が存在しないHTTP Requestに対しては、キャッシュを使う対象とならないことを意味します。
別の視点では、同じURLに対して、様々な`Accept-Language`の状態ごとのHTTP Responseを、別々にキャッシュできる、とも言えます(つまり十分キャッシュすれば、キャッシュからでもコンテントネゴシエーションが達成できる)。

例: 

- `Vary: *`: ヘッダ以外の任意の情報(ブラウザのIPアドレスやリクエストのタイミングなど)も含めた情報を用いて選ばれたコンテントである、という意味。あらゆるHTTP Requestにとって、このHTTP Responseはキャッシュデータにならない
- `Vary: Accept-Language, Accept`: `Accept-Language`と`Accept`のどちらかが存在するかしないかも含めて違うHTTP Requestにとって、このHTTP Responseはキャッシュデータにならない

`Vary`ヘッダで列挙するヘッダ名は標準外も含めて任意のものを指定でき、キャッシュではその有無や値の違いを解釈する必要があります。

## HTTPのキャッシュとしてMUST NOTな仕様の存在

HTTP仕様では、HTTPのキャッシュとしてやってはいけない振る舞いがMUST NOTやMUSTで記述されています。

キャッシュを使わないでスルーさせることは許されるけど、キャッシュデータを使わせるときに**やってはいけない**使わせ方について指定されている、ということです。

キャッシュ機能の実装やテストをするときは、このMUST NOT/MUSTを指定された仕様に注意して行う必要があるでしょう。

# `Cache-Control`リクエストヘッダの値

User Agentでは、`Cache-Control`リクエストヘッダをつけることで、キャッシュ状態に対する基本の動作とは違った扱いをお願いすることが可能になります(必ずしも、この指定の通りに振る舞うというわけではありませんが)。

HTTP1.1には、以下の値が存在します。これらの値は、カンマ区切りで組み合わせも可能です。

## 検証する条件の指定

- `max-age=60`: キャッシュ経過時間が60秒以内の場合、キャッシュデータでもよい
- `min-fresh=60`: fresh状態の期間が残り60秒以上の場合、キャッシュデータでもよい
- `max-stale=60`: stale状態の期間が開始60秒以内の場合、未検証のキャッシュデータでもよい
- `max-stale`: stale状態でも、未検証のキャッシュデータでもよい
- `no-cache`: fresh状態でも、必ずキャッシュデータを検証した結果を返してほしい

検証していないキャッシュデータを認める条件の指定です。
この条件を満たしていれば(sharedキャッシュとして別途で更新されうるキャッシュデータに対して`If-Modified-Since`や`If-Non-Match`のチェックは行ったうえで)キャッシュデータを返します。
デフォルトの設定より条件を狭める指定をして、検証リクエストを発行させるために用いられることが多いです。


注意点: 

- HTTP Requestの`max-age`期間がキャッシュのfresh生存期間より長い場合、`max-age`期間以内でもstale状態ならキャッシュ側で検証が行われます。
- HTTP Requestの`max-age`期間や`min-fresh`期間と`max-stale`期間とが併用されるとき、`max-stale`の開始起点は、それらの期間がfresh生存期間より短い場合、HTTP Requestのそれらの期間経過時点に差し替わります。
    - fresh生存期間が60秒で`max-age=30, max-stale=60`の場合、ageが30秒から90秒の間のキャッシュデータは未検証でうけいれてよい、となる
    - fresh生存期間が60秒で`min-fresh=20, max-stale=60`の場合、ageが40秒から100秒の間のキャッシュデータは未検証でうけいれてよい、となる

`max-age=0`は`If-Modified-Since`や`If-Non-Match`とともに使用され304を返す可能性がありますが、`no-cache`は`If-Modified-Since`や`If-Non-Match`を無視し304を返さないものです。
一方、この`no-cache`なリクエストを受ける中間キャッシュでは、要検証リクエストですが、キャッシュ機能を使用して、キャッシュにあるデータを渡せることを認めています。

## キャッシュデータの利用についての指定

- `no-store`: キャッシュ機能は使わずに、Responseを返してほしい(全てに優位)
- `only-if-cached`: キャッシュにあるデータを返してほしい(なければ`504 Gateway Timeout`)

`no-store`は、検証もすることなく、透過的にWebサーバからのResponseデータを返してほしいという意味です。`no-store`はキャッシュ機能を全く使うなという指示になるため、列挙してある他の`Cache-Control`ヘッダ値は無効化されます。多段の中間キャッシュすべてに対しても`no-store`を求めます。

`only-if-cached`は、`no-store`とは逆に、(sharedキャッシュの存在を想定して、)キャッシュしてあるResponseデータだけを返してほしいという意味です。

## その他の指定

- `no-transform`: 圧縮等のデータの変換をせずに、返してほしい(キャッシュしたものかどうかにかかわらず)

# `Cache-Control`レスポンスヘッダの値

`Cache-Control`レスポンスヘッダによって、Webサーバ側から、User Agentのキャッシュ制御に対するヒントを設定できます。User Agent側にはキャッシュ制御でのデフォルトの振る舞いがあり、それを変更するように作用します(必ずしも、この指定の通りに制御される、というわけでもありませんが)。

HTTP1.1には、以下の値が存在します。これらの値は、カンマ区切りで組み合わせも可能です。

## キャッシュ可能な条件の設定

- `no-store`: どのような場合でもキャッシュしてはいけない(全てに優位)
- `public`: どのような場合でもキャッシュしてよい
- `private`: sharedキャッシュでは、キャッシュしてはいけない
- `private="cookie, www-authenticate"`: sharedキャッシュでは、`Cookie`レスポンスヘッダと`WWW-Authenticate`レスポンスヘッダはキャッシュしてはいけない(それ以外はキャッシュしてよい)

リクエストの`no-store`と同様に、`no-store`はキャッシュ機能自体を使うな、という指示になり、他の値を無効化します。

`public`は、デフォルトの振る舞いでもキャッシュしないタイプのリクエストレスポンスであっても、キャッシュ可能にさせる作用となります(たとえば、`POST`メソッドの場合など)。

## キャッシュ状態の設定

- `max-age=60`: fresh状態の生存期間は60秒間
- `s-maxage=60`: sharedキャッシュのみ、fresh状態の生存期間は60秒間

User Agentでのデフォルトの生存期間を上書きします。sharedキャッシュでは、他へキャッシュデータを渡す場合の生存期間を、`s-maxage`のほうに従って設定します。


## 検証条件の設定

- `must-revalidate`: stale状態ならキャッシュデータの検証が必須
- `proxy-revalidate`: sharedキャッシュのみ、stale状態ならキャッシュデータの検証が必須
- `no-cache`: fresh状態であっても(shared/privateともに)キャッシュデータの検証が必須
- `no-cache="cookie, www-authenticate"`: (レスポンスヘッダリストを指定した`no-cache`)。キャッシュデータの`Cookie`レスポンスヘッダ値と`WWW-Authenticate`レスポンスヘッダ値は、未検証で再利用してはいけない(ボディや他のヘッダは未検証で使っても構わない)。

ネットワークが切れていたりとか、リクエストに`max-stale`設定がある等で、stale状態のキャッシュが検証なしで使われることもありえます。
しかし、`must-revalidate`や`proxy-revalidate`の値があると、検証なしでキャッシュを使う条件よりも優先され、キャッシュデータの検証をしにいかなくてはいけません。そして、ネットワークが切れている場合は、`504 Gateway Timeout`を返さなくてはいけません。

`no-cache`は、fresh状態でもキャッシュデータを未検証では使ってはいけない、という指示です。`must-revalidate`と違い、ネットワーク未接続時に`504`を返すことは要求されていません。

`no-cache`ではパラメータとしてレスポンスヘッダリストが指定可能となっています。HTTP/1.1仕様では、キャッシュデータのうちそのリストにあるヘッダは検証なしで再利用時してはいけない、とされています。
`private`のパラメータ指定では、指定されたレスポンスヘッダはキャッシュデータから消されますが、
`no-cache`のパラメータ指定では、指定されたレスポンスヘッダはキャッシュされたまま検証し、`304 Not Modified`とともにそのヘッダの新しい値が返されうる、となるのでしょうか。

`no-cache, max-age=360000`のように、データはずっとキャッシュさせておいて、`304 Not Modified`で返す前提で毎回検証HTTP Requestは発行させる、といった指示もできます。`must-revalidate, max-age=0`も毎回検証を発行させるけど、(キャッシュ実装次第ですが)キャッシュデータは消えやすくなるでしょう。



## その他の設定

- `no-transform`: 圧縮等のデータの変換をしないでほしい(キャッシュしたものかどうかにかかわらず)

# RFC5861の`Cache-Control`拡張: `stale-while-revalidate`と`stale-if-error`

RFC5861では、`Cache-Control`レスポンスヘッダの値が追加されています。

- `stale-while-revalidate=60`: stale状態開始60秒以内のageなら、キャッシュデータを使ってよい。一方で、並行してバックグラウンドで検証が必要
- `stale-while-revalidate`: stale状態のキャッシュデータを使ってよいが、並行してバックグラウンドで検証が必要
- `stale-if-error=60`: レスポンスがサーバーエラー時には、stale状態が開始60秒以内であれば、stale状態のキャッシュデータをつかってよい

`stale-while-revalidate`の場合、クライアントへのレスポンスとしてはキャッシュデータを使い、即時にデータを返します。その一方で、検証が必要な場合は、**検証リクエストを並行してバックグラウンドで発行**し、キャッシュデータの更新を行う、非同期検証となります。

`stale-if-error`は、URI解決で特定種のエラーが起きた場合にも、キャッシュデータを用いる機能です。
通常、キャッシュデータで代用できるのは、接続エラーのときだけでした。これを、サーバーエラー(`500 Internal Server Error`、`502 Bad Gateway`、`503 Service Unavailable`、`504 Gateway Timeout`)の場合にも拡大するものとなります。

# RFC8246の`Cache-Control`拡張: `immutable`

RFC8246でも、`Cache-Control`レスポンスヘッダの値が追加されています。

- `immutable`: (レスポンスデータを更新するつもりはないので)fresh状態のときは検証すべきでない

内容が更新されないURI、たとえば、リビジョン付きURIやコンテントのハッシュ値ベースのURIであることを、明示するために導入されています。

`immutable`がある場合、fresh状態では検証通信をしてはいけない、となります。ユーザー操作のブラウザのリロード(↻)では、キャッシュデータがfresh状態であっても検証通信を行います。`immutable`は、そのような場合でも検証通信は抑制してほしい、という意味になります。


# `Cache-Control`の実験

最後に、nodejsで書いた、Cache-Controlヘッダを返す実験Webサーバを用意し、アクセスしてブラウザの開発ツールの「ネットワーク」の情報を見て、リクエストレスポンスでのヘッダと結果を確認します。

実験Webサーバ実装は、以下のコードです。

```js:server.js
// $ node server.js
const http = require("http");

let count = 0;
const last = new Date();
http.createServer((req, res) => {
  console.log(req.headers);
  if (req.headers["if-modified-since"]) {
    console.log("use cache:", ++count);
    res.writeHead(304, {
      "Cache-Control": `max-age=${10 * count}`,
      "Date": new Date().toUTCString(),
    });
    res.end();
  } else {
    console.log("use html");
    res.writeHead(200, {
      "Content-Type": "text/html;charset=utf-8",
      "Last-Modified": last.toUTCString(),
      "Cache-Control": "max-age=0",
      "Date": new Date().toUTCString(),
    });
    res.end("<html><head><link rel='icon' href='data:;base64,='>" +
            "</head><body><a href='/'>Hello Cache Control</a></body<<?html>");
    count = 0;
  }
}).listen(3000, _ => console.log("http://localhost:3000/"));
```

- 実験サーバは、作成時刻(`Last-Modified`)を起動時とした同じコンテントを返します
    - コンテントは、自身のURLへのaタグリンクを入れておき、リンク経由でのURLアクセスを行えるようにします
    - (コンテントでは、余計なfaviconリクエストを送らせないよう`link rel="icon"`タグを入れています)
- 検証リクエスト(`if-modified-since`)を受けたら、304を返し、そのHTTP Responseで`max-age`を10秒づつ長くしていきます

これをブラウザで「開発ツール/デベロッパーツール」の「ネットワーク」を開いた上で、http://localhost:3000/ にアクセスします。

## 1. 初回アクセス時

- サーバログ: "use html"
- 開発者ツール: 200 `max-age=0`

## 2. リンクを踏む

- サーバログ: `if-modified-since`つきHTTP Request, "use cache: 1"
- 開発者ツール: 304 `max-age=10`

## 3. 即時にリンクを踏む

- サーバログ: 変化なし
- 開発者ツール: 200 `max-age=0`

fresh状態を使う場合は、ブラウザのネットワークでの情報は、キャッシュしたときの内容のようです。

## 4. 10秒以上後で再びリンクを踏む

- サーバログ: `if-modified-since`つきHTTP Request, "use cache: 2"
- 開発者ツール: 304 `max-age=20`

## 5. (続く20秒以内で)ブラウザのリロードボタンを押す

- サーバログ: `cache-control: max-age=0`および`if-modified-since`つきHTTP Request, "use cache: 3"
- 開発者ツール: 304 `max-age=30`

リロードでは、ブラウザは`cache-control: max-age=0`をHTTP Requestにつけ、中間にあるかもしれないsharedキャッシュにも検証して返すよう求めています。

## 6. (続く30秒以内で)SHIFTキーを押しながらブラウザのリロードボタンを押す(強制リロード)

- サーバログ: `cache-control: no-cache`つきHTTP Request, "use html"
- 開発者ツール: 200 `max-age=0`

強制リロードでは、ブラウザは`if-modified-since`といった検証用情報は送りません(つまり、強制リロードは、検証HTTP Requestではない)。
さらに`cache-control: no-cache`つきHTTP Requestを送ることで、中間にあるかもしれないsharedキャッシュにもキャッシュの更新を求めています。

## 7. 続き

実験サーバから返すヘッダをいろいろ書き換えて試すことができます。

たとえば、Webサーバコードの

```js
      "Cache-Control": `max-age=${10 * count}`,
```

を

```js
      "Cache-Control": `no-cache, max-age=${10 * count}`,
```

とすると、`max-age`の期間以内でも、リンクを踏むたびにブラウザから検証HTTP Requestがサーバへ送られるようになります。

あとは、検証HTTP Responseの`Date`レスポンスヘッダを抜いてみたり、新たな時刻の`Last-Modified`レスポンスヘッダをつけてみたりする、なども確認する価値があるでしょう。


# 参考

- RFC7231 HTTP/1.1 Semantics and Content: https://tools.ietf.org/html/rfc7231
- RFC7234 HTTP/1.1 Caching: https://tools.ietf.org/html/rfc7234
- RFC5861 HTTP Cache-Control Extensions for Stale Content: https://tools.ietf.org/html/rfc5861
- RFC8246 HTTP Immutable Responses: https://tools.ietf.org/html/rfc8246
- RFC7232 HTTP/1.1 Conditional Requests: https://www.rfc-editor.org/rfc/rfc7232.txt