この記事は Angular Advent Calendar 2024 の23日目の投稿です。
✨🎄昨日は @dddsuzuki さんの AngularのスタンドアロンコンポーネントとSignalsを使用したアプリケーション設計 でした🎅✨

謎エラーと突然の遭遇

ある日システムのトラブル報告がありました。「私のPCだけ画面が開かなくて…他のPCではふつうに開くんですよ」

その人のブラウザを見たところAngularが Cannot access ‘di’ before initialization. という謎エラーを吐いていました。「私のPCだけ」と「謎エラー」からたぶんキャッシュだろうなと推測して、キャッシュクリアの操作を伝えて事なきを得ました。めでたしめでたし。

ここでふと考えました 🤔
よく分からない状況に陥ったらキャッシュクリアを勧める、これでいいのだろうかと。そもそもキャッシュについてきちんと学んだことはなく、調査すべきポイントも分からないまま終わってしまったことに不安を覚えました。

という訳で今年のAdvent Calendarではキャッシュの基礎を学びつつ、謎エラーの原因とAngularで取りうる対応について考えてみようと思います。


〜キャッシュの基礎を学ぶ〜

〜検証と考察〜

ブラウザ / CDN / オリジンサーバー

Webアプリケーションではサーバーから配布されたリソースがブラウザでレンダリングされます。この時のレスポンスは通信経路上のいくつかの場所でキャッシュされます。

クライアントに格納されるキャッシュはPrivate Cache、通信経路上のキャッシュはShared Cacheと分類されます。Webアプリケーションで想像しやすい構造は、リソースを配布するオリジンサーバーとクライアントのブラウザ(Private Cache)、その中間に位置するCDN(Shared Cache)といったところです。

Private Cache
ローカルキャッシュやプライベートキャッシュとも呼ばれることがあります。当記事では「ブラウザキャッシュ」と統一します。

Shared Cache
共有キャッシュや中間キャッシュとも呼ばれることがあります。またCDNのように設定や管理画面がありキャッシュ制御可能なものをマネージドキャッシュ、プロキシーのようにトラフィックをトンネルするのみでキャッシュ制御できないものをプロキシーキャッシュと細分化することがあります。当記事では「共有キャッシュ」と総称します。

Fresh / Stale / Validation

キャッシュはURLとリクエストメソッドをキーとして格納します。 キャッシュからリソースを取り出した時、そのリソースの生成時間(オリジンサーバーで生成された時)からの経過時間(Age)を算出して再利用可能か判断します。

リソースが新鮮で再利用可能な状態を「Fresh」、期限が切れて古くなった状態を「Stale」と呼びます。Freshなら再利用され、Staleなら新しいレスポンスで更新される、といった具合です。

キャッシュから取り出したリソースがStaleであれば、最新リソースを取得するリクエストが実行されます。この時、キャッシュに格納されたリソースがいつの時点のものなのか特定する条件をリクエストに付与すると、最新リソースとの一致が検証されます。条件付きのリクエストを「Conditional Request」、検証を「Validation」と呼びます。

キャッシュ破棄のタイミングは、削除操作やストレージ上限超過などそれぞれのキャッシュ機構に委ねられています。

ヒューリスティックキャッシュ

HTTP通信のリクエストとレスポンスから成り立つWebにおいて、リソースはキャッシュによって再利用されることが前提となっています。キャッシュについて特に指示がなければ、ブラウザやプロキシは経験則や独自のルールをもとにリソースをキャッシュに格納します。この仕組みを「ヒューリスティックキャッシュ」と呼びます。

Ageの算出方法や条件は仕様で定められていますが、キャッシュの有効期間は推定に基づくものでブラウザ間でばらつきがあり一貫性はありません。「なんとなくいい感じにキャッシュされている」ものの「古いコンテンツが表示されてしまう」場面に遭遇するかもしれません。

開発者である私たちは、HTTPヘッダ「Cache-Control」でヒューリスティックキャッシュのオプトアウトを宣言し、独自のキャッシュ戦略を立てることができます。

Cache-Controlヘッダ

キャッシュ指定といえば、過去にはExpiresヘッダやPragmaヘッダが一般的に用いられてきました。

# HTTP/1.0
Expires: Mon, 23 Dec 2024 00:00:00 GMT
Pragma: no-cache

Expiresという名前のとおり、HTTP/1.0ではリソースの有効期限という概念がベースとなっていました。

# HTTP/1.1
Cache-Control: max-age=100, no-cache

HTTP/1.1のCache-Controlヘッダの登場により、ディレクティブ(個々の指定)を組み合わせることで細やかなキャッシュ制御ができるようになりました。リソースの有効期限はAgeの算出による経過時間の概念に変わります。

Cache-Controlヘッダ登場後のExpiresヘッダやPragmaヘッダは後方互換性として作用し、併用した場合はCache-Controlヘッダが優先されます。

Cache-Control / privateディレクティブ

# Request
指定できない

# Response
HTTP/1.1 200 OK
Cache-Control: private

privateディレクティブは、ユーザー固有のパーソナライズされたコンテンツであることを示します。生年月日を埋め込んだマイページを静的コンテンツとして配布するようなケースでは、共有キャッシュのリソースが他のユーザーに再利用されるとセキュリティやプライバシーの問題となります。

privateディレクティブを付与するとブラウザキャッシュのみ格納され、共有キャッシュには格納されません。

Cache-Control / publicディレクティブ

# Request
指定できない

# Response
HTTP/1.1 200 OK
Cache-Control: public

publicディレクティブは、共有キャッシュに格納できるリソースであることを示します。

Authorizationヘッダが付与されたBasic認証やDigest認証付きのリクエストは、パーソナライズされたコンテンツであると見なされるため、デフォルトで共有キャッシュには格納されません。

publicディレクティブを付与すると、ブラウザキャッシュだけでなく共有キャッシュにも格納されます。

Cache-Control / immutableディレクティブ

# Request
指定できない

# Response
HTTP/1.1 200 OK
Cache-Control: max-age=31536000, immutable

immutableディレクティブは、オリジンサーバーで一定期間コンテンツが更新されないことを示します。

更新されないコンテンツの例として、WebフォントやCDNのパッケージ配信などが挙げられ、これらに対する頻繁なリクエストはサーバーに負荷をかけるだけのトラフィックとなり得ます。このようなケースにおいてimmutableディレクティブを用いると、不変であることを明示できます。具体的にはリロード時のValidationをスキップするなどが考えられますが、仕様ではクライアントの挙動まで定義されていません。

一部のブラウザではimmutableディレクティブを無視し、サブリソース(メインのHTMLから読み込まれるJavaScriptやCSS)をデフォルトでキャッシュから読み込む独自の方針を取っています。

Cache-Control / max-ageディレクティブ

# Request
GET / HTTP/1.1
Cache-Control: max-age=60

# Response
HTTP/1.1 200 OK
Cache-Control: max-age=60

max-ageディレクティブはレスポンスがFreshである期間を秒数で示します。

オリジンサーバーで 2024/12/23 00:00:00 に生成されたリソースは max-age=60 なら 2024/12/23 00:01:00 までFreshです。

# Response
HTTP/1.1 200 OK
Cache-Control: max-age=60
Age: 10

共有キャッシュで10秒間キャッシュが格納されると、レスポンスにAgeヘッダが付与されます。 ブラウザではAgeヘッダの10秒間を加味し、残り50秒の間はFreshとみなします。

# Response
HTTP/1.1 200 OK
Cache-Control: max-age=60
Age: 20

複数の共有キャッシュを経由する場合、それぞれのCDNでAgeヘッダが加算されていきます。 CDNが2つありそれぞれで10秒ずつキャッシュが格納されると、Ageヘッダは合計20秒になります。ブラウザではAgeヘッダの20秒間を加味し、残り40秒の間はFreshとみなします。

残り0秒になるとStaleとなり、最新のリソースを求めるリクエストが実行されます。 共有キャッシュにFreshなリソースがなければリクエストはオリジンサーバーに転送され、最新のリソースが返されます。

# Request
指定できない

# Response
HTTP/1.1 200 OK
Cache-Control: max-age=10, s-maxage=20

ブラウザキャッシュと共有キャッシュで別のmax-ageを指定したい場合、共有キャッシュ用のs-maxageディレクティブを指定できます。

共有キャッシュでは複数クライアントからのリクエストをひとつだけオリジンサーバーに転送し、そのレスポンスを複数クライアントに返す Request Collapse が行われます。

s-maxageディレクティブによって、ブラウザでは10秒、CDNでは20秒といった別の指定ができ、Request Collapseの効率的な運用を図ることができます。ただし通信経路上の共有キャッシュすべてに影響するため、CDNのみターゲットとしたCDN-Cache-Controlヘッダや、ベンダー固有のヘッダを用いるケースもあるようです。

Cache-Control / no-cacheディレクティブ

# Request
GET / HTTP/1.1
Cache-Control: no-cache

# Response
HTTP/1.1 200 OK
Cache-Control: no-cache

no-cacheディレクティブは、キャッシュから取り出したリソースが最新のものであるか検証する Validation を強制します。

# Response
HTTP/1.1 200 OK
Cache-Control: max-age=0, must-revalidate

no-cacheディレクティブと似た効果として、0秒でStaleとみなしValidationを強制する上記のようなディレクティブが付与されることがあります。

キャッシュには「オリジンサーバーに接続できない時はStaleであっても再利用して良い」という仕様があり、must-revalidateディレクティブはそれを打ち消す効果があります。must-revalidateディレクティブが付与されるとValidationが発生し、オリジンサーバーに接続できない時は 504 Gateway Timeout となります。共有キャッシュのみValidationを強制したい時はproxy-revalidateディレクティブを付与します。

If-Modified-Sinceヘッダ

If-Modified-Sinceヘッダは、リソースの生成時刻をValidationの検証材料として提供します。

まだキャッシュがない初回のリクエストで、以下のレスポンスが返ります。

# Response
HTTP/1.1 200 OK
Last-Modified: Mon, 23 Dec 2024 00:00:00 GMT

body: |
  <html>
    <body></body>
  </html>

ブラウザやCDNはLast-Modifiedヘッダの値を保存し、次回のリクエストでIf-Modified-Sinceヘッダに付与します。

# Request
GET / HTTP/1.1
If-Modified-Since: Mon, 23 Dec 2024 00:00:00 GMT

このリクエストに対するレスポンスとして、更新がなければ 304 Not Modified 、更新されていれば 200 OK で最新リソースが返ります。

# Response 更新されていない場合
HTTP/1.1 304 Not Modified
Last-Modified: Mon, 23 Dec 2024 00:00:00 GMT
# Response 更新された場合
HTTP/1.1 200 OK
Last-Modified: Mon, 23 Dec 2024 11:22:33 GMT

body: |
  <html>
    <body>Modified</body>
  </html>

If-Modified-SinceヘッダでConditional Requestを発生させレスポンスボディのない 304 を返すことで、サーバー負荷やネットワークのトラフィックを削減する効果があります。

If-None-Matchヘッダ

If-None-Matchヘッダは、リソースの識別子をValidationの検証材料として提供します。

まだキャッシュがない初回のリクエストで、以下のレスポンスが返ります。

# Response
HTTP/1.1 200 OK
ETag: "123123"

body: |
  <html>
    <body></body>
  </html>

ブラウザやCDNはETagヘッダの値を保存し、次回のリクエストでIf-None-Matchヘッダに付与します。

# Request
GET / HTTP/1.1
If-None-Match: "123123"

このリクエストに対するレスポンスとして、更新がなければ 304 Not Modified 、更新されていれば 200 OK で最新リソースが返ります。

# Response 更新されていない場合
HTTP/1.1 304 Not Modified
ETag: "123123"
# Response 更新された場合
HTTP/1.1 200 OK
ETag: "456456"

body: |
  <html>
    <body>Modified</body>
  </html>

If-Modified-SinceヘッダはHTTP/1.0で策定されたもので、If-None-MatchヘッダはHTTP/1.1の後続の策定です。両方のヘッダを併用した場合、If-None-Matchヘッダが優先されます。

  • すべてのキャッシュ機構でETagヘッダをサポートしている保証がない
  • Last-Modifiedヘッダはコンテンツの最終更新時刻として表示するなどメタ情報としての利用価値が高い

上記のような理由で、レスポンス(Last-Modifiedヘッダ、ETagヘッダ)とリクエスト(If-Modified-Sinceヘッダ、If-None-Matchヘッダ)に両方を含めるよう仕様で推奨されています。

Weak ETag / Strong ETag

ETagヘッダはリソースを一意に表すもので、生成方法はオリジンサーバーに委ねられています。フロントエンドの文脈では、ビルドで自動付与されるハッシュや、npmで発行したバージョンの採用も手段として考えられます。

# ビルドのハッシュを採用
$ npm run build
> bundle-123123-index.js

ETag: "123123"
# npmで発行したバージョンを採用
$ npm version patch
> v1.0.1

ETag: "1.0.1"

ETagヘッダは等価性があれば同じ値を使うことが許容されています。 例えば、テストコード追加でインクリメントしたバージョンは、リソース配布においてひとつ前のバージョンと等価であると言えます。

このような場合に W/ を付与し「Weak ETag」とマークすることができます。

$ git commit -m "テストコードを追加"

$ npm version patch
> v1.0.2

ETag: W/"1.0" # マイナーバージョンまで採用

どのようなコードの更新であっても厳密にETagヘッダを更新する場合は「Strong ETag」となります。

$ git commit -m "テストコードを追加"

$ npm version patch
> v1.0.2

ETag: "1.0.2"

Accept-Rangesヘッダを使ったメディアファイルの分割ダウンロードなど、バイト単位でリソースの完全一致が求められるケースではStrong ETagに利点があります。また共有キャッシュが分散しているケースでも、Strong ETagは最新リソースの同期を判定する材料として利用できます。

Varyヘッダ

Varyヘッダはレスポンスにバリエーションがあることを示します。

例えばオリジンサーバーで英語のコンテンツのみ配信している場合、そのコンテンツがキャッシュされます。

# Request
GET / HTTP/1.1

# Response
HTTP/1.1 200 OK
Content-Language: en

新たに日本語のコンテンツを配信し始めた時、ブラウザで英語のコンテンツが再表示されるかもしれません。キャッシュ格納のキーはURLとリクエストメソッドであるためです。

この問題は、Acceptヘッダ(画像などのMIMEタイプ)やAccept-Encodingヘッダ(圧縮アルゴリズム)でも発生します。共有キャッシュから特定の形式で圧縮されたリソースが配布されると、パースできないブラウザでページ表示が壊れてしまいます。

# Response
Vary: Accept-Language, Accept-Encoding

Varyヘッダを用いると、URLとリクエストメソッドに加えてディレクティブの値がキャッシュ格納のキーとして使われます。

キャッシュに関する指定は他にもたくさんあります。複雑化するWebの構成要素に対応するべく拡張仕様も策定され、環境とともにキャッシュの標準も進化しているようです。

RFC 9111 HTTP Caching

これらの指定を組み合わせて、ブラウザキャッシュあるいは共有キャッシュの挙動を想像することはとても大変な作業に感じます。まずはヒューリスティックキャッシュのオプトアウトとして、ETagヘッダやLast-Modifiedヘッダあたりからスモールスタートすれば良いのかなと思いました。

ブラウザのリロード

ブラウザを閲覧するユーザーは、ブックマークやQRコード、メールに記載されたリンクなど、さまざまなきっかけでページを開きます。その裏では、開発者の用意した設定やヒューリスティックキャッシュに基づいて、リソースが再利用されたり更新されたりといったことが行われています。

そしてユーザーは、データを更新したい、壊れたページ表示を直したい、など何らかの意図をもってブラウザのリロードボタンをクリックします。

この時、どのようなキャッシュの読み込みやリクエストが実行されるのでしょうか?
MDNの HTTPキャッシュ のページをGoogle Chrome v133で検証してみます。

ページを開いた時の読み込み

まずはMDNのサイトを開き、サイドメニューから辿って該当ページを開いてみます。このページは何度も訪れているため、ブラウザキャッシュからコンテンツが読み込まれました。

デベロッパーツールのNetworkタブでは、メインリソース(HTML)に「disk cache」と表示されています。

サブリソース(メインのHTMLから読み込まれるJavaScriptやCSS)には「memory cache」と表示されています。

ブラウザキャッシュから読み込まれたリソースはリクエストヘッダがなく「Provisional headers are shown.」というメッセージが表示されます。

「memory cache」はオンメモリのキャッシュです。「disk cache」は以下の場所にプロファイルごとに保存されていました。

# macOS v15

~/Library/Caches/Google/Chrome/
  ├── Default
  ├── Profile1
  └── Profile2 

リロードした時のリクエスト

リロードをすると、以下のリクエストが実行されました。

# メインリソース(HTTPリクエスト)
GET /ja/docs/Web/HTTP/Caching
Cache-Control: max-age=0
If-Modified-Since: Mon, 23 Dec 2024 00:00:00 GMT
If-None-Match: W/"ea66935..."
# サブリソース(キャッシュから読み込まれる)
GET https://developer.mozilla.org/static/js/main.cd4a21a6.js

Provisional headers are shown.

メインリソースのリクエストでは0秒でStaleになるmax-ageディレクティブを指定し、最新リソースの取得を試みます。

If-Modified-Since / If-None-MatchヘッダによるConditional Requestのため、共有キャッシュではValidaitonが行われます。Validationに成功すれば共有キャッシュのリソースが再利用され、成功しなければオリジンサーバーから最新のリソースを取得することが予想できます。

ハード再読み込みした時のリクエスト

スーパーリロードや強制リロードとも呼ばれるものです。 Chromeではデベロッパーツールを表示した時、リロードボタンの右クリックメニューとして現れます。

# メインリソース(HTTPリクエスト)
GET /ja/docs/Web/HTTP/Caching
Cache-Control: no-cache
Pragma: no-cache
# サブリソース(HTTPリクエスト)
GET https://developer.mozilla.org/static/js/main.cd4a21a6.js
Cache-Control: no-cache
Pragma: no-cache

メインリソース、サブリソースともに、no-cacheディレクティブを指定しValidationを強制しています。Pragmaヘッダは互換性のための指定です。

If-Modified-Since / If-None-Matchヘッダが存在しないため、共有キャッシュでのValidationは成立しません。つまりオリジンサーバーから最新リソースを取得する挙動となります。

デベロッパーツールのDisable Cacheオプション

デベロッパーツールには「Disable Cache」というチェックボックスが存在します。 開発中のリソースを頻繁に読み込む開発者のための機能で、チェックをオンにするとリロードがハード再読み込みとして動作します。

キャッシュバスティング

私の開発するシステムではフロントエンドのリリース頻度にばらつきがあります。 1週ごとにリリースする時もあれば、数ヶ月リリースしない時もある、といったところです。no-cacheディレクティブを指定してページを開くたびにValidationのリクエストが走るのは得策ではありません。かといってmax-ageディレクティブで1年先など指定してしまうと、その間のリリースに支障が出そうです。

このような場面で使われるのが キャッシュバスティング と呼ばれるパターンです。 キャッシュバスティングでは、URLの一部としてリビジョンの識別子を付与します。

# クエリ文字列で付与
app.js?v=1.0.0
app.js?hash=1234abc
app.js?date=20241223

# ファイル名で付与
app-1.0.0.js
app-1234abc.js
app-20241223.js

# パスで付与
/v1.0.0/app.js
/1234abc/app.js
/20241223/app.js

いずれもリビジョンを付け替えた時にURLが変わるため、最新リソースを取得し直す効果があります。またJSファイルとCSSファイルのリビジョンを同じタイミングで更新することで、新しいコンポーネントを追加したJSと古いCSSの組み合わせで表示が壊れるといった齟齬もなくなります。

クエリ文字列を使ったキャッシュバスティング

GET /app.js?utm_source=sns&utm_medium=cpc
GET /app.js?&utm_medium=cpc&utm_source=sns

クエリ文字列の順番を変えてもリクエスト上の意味は等価です。 ただしテキストとしては等価にならず、別のキャッシュとして格納されることがあります。またトラッキング識別子としても用いられ膨大なキャッシュエントリが生成されることから、クエリ文字列を除外するキャッシュのオプションを提供するCDNベンダーもあるようです。

Angularのoutput-hasing

AngularにはoutputHashingというビルド指定のオプションがあります。

output-hashing | angular.jp
Define the output filename cache-busting hashing mode.

だいぶ前のバージョンから存在する機能ですが、フロントエンド用のキャッシュ戦略オプションだったのかと気づきました。

// angular.json

"architect": {
  "build": {
    "configurations": {
      "production": {
        "outputHashing": "***"

Angular v19.0で試した結果は以下の通りです。

// "outputHashing": "none"

$ ng build

dist/{PROJECT}/browser
  ├── main.js
  ├── polyfills.js
  └── styles.css
// "outputHashing": "all"

$ ng build

dist/{PROJECT}/browser
  ├── main-L2RTHCZ5.js
  ├── polyfills-FFHMD2TL.js
  └── styles-5INURTSO.css

コードが変わらない限り、何回ビルドしてもファイル名のハッシュは変わりません。 スクリプトでハッシュ部分を読み取ってStrong ETagとして採用することもできそうです。

キャッシュ戦略を考察

戦略というほどでもないのですが、Angularが提供するキャッシュバスティングを利用するのがシンプルで良いのではないかと思いました。

angular.jsonではoutput-hasingを有効にしておき、メインリソースではハッシュ付きのファイルを参照します。

// angular.json

"architect": {
  "build": {
    "configurations": {
      "production": {
        "outputHashing": "all"
<script src="/assets/main-L2RTHCZ5.js"></script>
<script src="/assets/polyfills-FFHMD2TL.js"></script>
<link rel="stylesheet" href="/assets/styles-5INURTSO.css">

リリースの間隔が空くことを考慮して、キャッシュの生存期間はできるだけ長くします。 具体的に指定したい期間がなければ、HTTPヘッダーを圧縮する QPACKのプリセット から選びます。

# /assets/配下に対するResponse
Cache-Control: max-age=2592000

ETagには、フロントエンドでリリースのたびにインクリメントするバージョンを採用します。内容に関わらずファイルとETagの更新が連動することからStrong ETagになります。

$ npm version patch
> v1.0.1

ETag: "1.0.1"

このようにすると、ブラウザでETagヘッダの値を見て最新バージョンのリソースなのかチェックできそうです。

謎エラーはなぜ発生したのか

結局分かりませんでした。

ブラウザのハード読み込みは効果がなくキャッシュ削除で解決した状況から、ディスクキャッシュが破損していたのだろうと推測します。

ただ、基礎を学んだことで次のトラブルに向けて「ハード読み込みしたらレスポンスはどうなる?」「ETagの値は?」など見るべきポイントが押さえられるようになりました。Angularでの開発体験を通して無知の知から新たな学びを得ています。

✨🎄明日は @ver1000000 さんの投稿です!よろしくお願いします!🎅✨