目次
この記事は、丁寧に学ぶフロントエンドアーキテクチャの第6章です。
いいね・ストックをよろしくお願いします!
はじめに
フロントエンドでは、レイテンシの削減が重要です。レイテンシの削減に中核的な役割を果たしているのがキャッシュです。本章では、フロントエンドアーキテクチャの観点からキャッシュ戦略の原則を解説します。
この記事では設計や保守性といった観点から解説するため、技術的にどのようにキャッシュが実現されるのかについては、他の記事に解説を譲ります。なお、技術的な詳細については、次の書籍が非常に優れています。
キャッシュの構造
キャッシュの定義
キャッシュとは、何らかの存在が生成したリソースを別の存在が利用するときに、利用者側にリソースを保存しておいて、リソースが必要になったときに保存しておいたリソースを再利用する最適化テクニックである
まずは、フロントエンドに登場する代表的なキャッシュをおさらいしましょう。
ブラウザ
Webアプリケーションはブラウザ上で実行されます。この際、ブラウザはダウンロードしてきた各種リソースを実行されている端末のローカルに保存します。
このようなブラウザのキャッシュの挙動は、Cache-Control
ヘッダーなどの技術で制御します。これはフロントエンド固有のキャッシュの仕組みです。
ブラウザキャッシュは次のような特性を持ちます。
- プライベートキャッシュ
- オフラインでも利用可能
- ユーザーの端末で動作する
CDN
CDNは、サーバーとブラウザの中間に存在するネットワーク上のキャッシュです。
ブラウザがWebアプリケーションにアクセスするとき、そのHTTPS通信はCDNのサーバーと接続し、一度内容を復号化します。そして、コンテンツの内容に応じて適切にキャッシュを行います。また、CDNのサーバーはオリジンサーバーともHTTPSで通信を行っており、適切にコンテンツの取得を行います。
CloudflareやFastlyのようなマネージドサービスと契約することで、ユーザーに近い場所のキャッシュサーバーが利用できます。
- 共有キャッシュ
- ネットワーク上に存在
- ユーザーに物理的に近いサーバーで動作する
レンダリングサーバー
CDNが契約と設定を行えばある程度自動的に動作してくれるマネージドなサービスであるのに対して、サーバー側でのキャッシュは、開発者がキャッシュのためのストレージを用意し、保存する処理を実行します。
- 共有キャッシュ
- サーバー上に存在
- オリジンで動作するため、ユーザーから遠い
バックエンドサーバー
本連載はフロントエンドアーキテクチャについて扱うため、バックエンドサーバーでのキャッシュについては扱いません。バックエンドサーバー内でも、DBのキャッシュなどを行ってレイテンシを改善することができます。
フロントエンドアーキテクチャとの対応関係
今回の記事で説明したキャッシュのレイヤー構造と、これまでの記事で解説してきた論理的なレイヤー構造の対応関係を考えます。
キャッシュは物理的な実装が重要
最大の違いは、キャッシュの方がより技術的・物理的な区分であるということです。
例えば、ローカルであるかネットワーク上にあるかは、キャッシュの重要な性質です。
キャッシュは、パフォーマンスを高めるためのものなので、どうしても物理的にどこで何が実行されているかを考慮する必要があります。したがって、キャッシュのレイヤー構造は、ViewやBFFなどと異なり、実装方法に強く依存します。
キャッシュレイヤーと論理レイヤーの違い
- ブラウザ、View、BFF、バックエンドはアプリケーションの保守性を高めることを目的とした論理的な区分である
- ブラウザ、CDN、レンダリングサーバー、バックエンドサーバーは、キャッシュを目的とした物理的な区分である
実装例
具体的なアーキテクチャの実現方法を例に解説します。次の3つのコンポーネントが、図のように連携します。
- Astroが動作するレンダリングサーバー
- Redis (レンダリングサーバーのキャッシュ)
- Cloudflare (CDN)
青く示した部分がキャッシュのレイヤーであり、物理的な境界(黒い点線)ごとに存在しています。
緑色に示した部分が論理構造のレイヤーです。キャッシュしかしないCDNが無視されていることが分かります。
物理的な境界だけを考慮すれば、Viewは分割されてしまっていることが分かりますね。
キャッシュの原則
キャッシュは物理的な構成に強く影響されるため、アーキテクチャやデータの性質ごとに最適なキャッシュ方法は異なります。これがキャッシュの難しさの原因の一つになっています。この記事では、キャッシュを設計するうえで考えるべきキャッシュの原則を説明し、開発者が適切な設計をできるように支援します。
ヒットしたときのメリットを高める
キャッシュの原則1
キャッシュをする際は、ヒットした際に十分なメリットを受けられるべきである。キャッシュのメリットとして代表的なのは次のようなものである。
- オフラインでも最低限の動作が可能になる
- レイテンシが削減される
- サーバー負荷が下がる
- ネットワークやコンピューティング料金が下がる
オフラインでの動作
ネットワーク上で分散して動作するアプリケーションは、全部がダウン、全部が正常、というわけにはいきません。ネットワークだけが壊れたり、特定のサーバーだけが壊れたりします。キャッシュがあると、一部のサーバーが壊れた場合でも最低限の動作を保証できる可能性があります。
例えばブラウザでWebアプリを提供する際に、何も言わずに接続できないのと、手元にある分で見られて、残りは「通信に失敗しました」と書いてくれてるのだと、後者の方がユーザー体験が優れています。
レイテンシ
キャッシュを使った場合、データのリクエストからレスポンスまでの時間を大幅に短縮できる可能性があります。このメリットが得られるかは、キャッシュされるデータを提供するコンポーネントと、データを利用するコンポーネント間の物理的な距離に依存します。例えば、同一サーバー上で動いている場合はある程度高速なデータ通信が期待できますが、ブラウザと遠く離れたサーバー間であればキャッシュの意味が大きくなるでしょう。
リソースのコスト
リソースのコストには、二種類あります。
- リソースの生成にかかる計算量、メモリ使用量、時間
- リソースの生成を行うインフラの利用料金
キャッシュにより、これらの料金を大幅に下げられる可能性があります。
コストが高いリソースであれば、キャッシュしておく価値があります。大きなデータであれば、通信帯域の利用の料金を支払う必要がある可能性もあります。
データ量
これは単純な話ですが、データ量が大きいリソースを選ぶ方が効果は高くなりやすいです。料金、ネットワークコスト、レイテンシ、もろもろで、巨大なデータは問題になります。
ヒット率を高める
キャッシュの原則2
キャッシュをする際は、適切にキャッシュされたデータが再利用されるべきである。再利用確率を高めるには、次のような特性を持ったリソースを選ぶべきである。
- 更新頻度が低い
- 利用頻度が高い
更新頻度
更新頻度が低いデータだと、キャッシュを再利用しても、実際のデータとの不整合が起こりづらいです。
データ量の大きいキャッシュの場合、データ全体をリクエストする代わりに、「データに変更があるか」だけをリクエストすることがあります。「変更なし」というメッセージを受け取るのと、実際のデータを受け取るのとを比較すると、ネットワークコストが大きく下がっていることが分かります。
更新頻度が高いと、キャッシュのTTLを短くする必要があり、結果再利用の回数が減ります。
利用頻度
利用頻度が高ければ、単純にキャッシュしたときに再利用される確率が高まります。
これだけでは単純すぎるので、もう少し詳しい条件を考察しましょう。
汎用性
汎用性が高いリソースは頻繁に再利用される可能性が高いです。逆に、特定の画面や特定の機能に特化したリソースは再利用の頻度が低いと考えられます。
もちろん、これは原則であり、直接的に利用頻度を決定するわけではありません。例えば、トップページを表示するための特化リソースと、ヘルプページ全般で使われる汎用リソースでは、そもそものページの利用頻度が違いすぎて特化リソースをキャッシュしたほうが効率がいいことは十分に考えられます。
対象となるユーザーの範囲
そのキャッシュを利用するユーザーのうち、保存したリソースを利用するユーザーがどのぐらいいるのか、というのも重要な性質です。
キャッシュには専用キャッシュと共有キャッシュがあります。専用キャッシュはブラウザキャッシュが代表的で、特定のユーザーのみの情報(専用リソース)が保存されます。共有キャッシュは複数のユーザーの情報(共有リソース)が保存されます。
専用キャッシュと共有キャッシュ
共有キャッシュに専用リソースを保存するべきではない。
- ヒット率が下がる
- キャッシュ事故により、関係ないユーザーにプライベートな情報が流出する可能性がある
しかし、専用キャッシュであれば、それを利用するユーザーは限られるため、その限られたユーザー専用のリソースを保存しても問題ないです。
共有リソースは共有キャッシュ、専用キャッシュのどちらに保存しても問題ない
キャッシュの観点からは、共有リソースであるほど再利用の可能性が高まります。
キャッシュの場所
キャッシュの原則3
どの場所で生成されたデータとどの場所で消費するか、物理的なレイヤー構造はどのようになっているかによって最適なキャッシュの配置場所がある程度機械的に決定できる
まずは、語彙を定義します。
供給者
ソフトウェアでは、View、BFF、バックエンドの各部分で、様々な出力が行われます。このようなキャッシュされる情報を生成するソフトウェアコンポーネントを供給者と呼びます。
消費者
消費者は、供給者が作成する何らかのリソースを使って、新しいリソースを作成するソフトウェアコンポーネントです。
例えば、BFFはバックエンドが提供する機能を組み合わせて、論理画面というデータ構造を作成します。また、ブラウザはストレージから提供された画像やCSSを組み合わせて、描画を行い、画面を作成します。
最適な場所
まず、キャッシュは供給者と消費者の中間のどこかに配置する必要があります。これは当然です。例えば、バックエンドの実行結果をブラウザにキャッシュしたところで、それを使うのがBFFならば、BFFはデータを再利用できません。
次に、キャッシュを二か所に作成することに意味はありません。
図のような状況を考えます。要素AがBからリソースを取得し、BがCからリソースを取得するとしましょう。リソースはCで供給され、Bでは消費されずAで消費されます。
AとBにキャッシュを配置したとします。キャッシュのTTLが同じであれば、最初の1回の取得でAとBのキャッシュに同じ時間だけ保持されるので、Bのキャッシュにはまったく意味がありません。このように、一番消費者側に近いキャッシュのTTLを長くすることで、それ以外の場所のキャッシュをすべて代替できます。
ただし、共有キャッシュと専用キャッシュを考慮すると複数個所のキャッシュが効果的になります。
複数のブラウザが共有リソースを利用することを考えます。供給者と消費者の関係は次のようになります。
ここで、もっとも消費者に近いブラウザと、もっとも消費者に近い分岐点であるCDNの二か所でキャッシュします。すると、ブラウザのキャッシュが切れるタイミングはまちまちであることから、CDNによってオリジンへのリソースの取得を行う頻度が効果的に下げられることが分かります。
適切なキャッシュの場所
- 供給者と消費者の中間に配置する
- なるべく消費者に近くする
- 分岐地点に配置する
リソースの結合
キャッシュの原則4
複数のリソースは、ブラウザに届くまでの過程で最終的に単一のHTMLに結合されるが、結合するほどキャッシュの特性が悪くなる。そのため、リソースを分離することで、効率の良いキャッシュを行うことができる。
結合したリソースのキャッシュ特性
例えば、比較的安定したライブラリをlib.js
に、自分で更新しているライブラリをapp.js
に保存したうえで、それらをバンドルしたbundle.js
を配信したとき、bundle.js
のキャッシュ可能期間は更新頻度が高いapp.js
によって決定されます。
他にも、ユーザー固有の情報を持っているヘッダーのユーザーアバターと、すべてのユーザー共有のボディをまとめたHTMLを返却した場合、そのHTML全体は専用リソースになります。
リソースの結合
結合されたリソースのキャッシュ特性は、結合の要素となるリソースの一番悪いものに影響される
リソースの分離
アプリケーションは、汎用的な機能を組み合わせて実現するものであるため、最終的には結合したリソースを配信せざるを得ません。しかし、リソースの結合はキャッシュにおいて不利であるので、できるだけリソースを分離したまま処理を続けたいです。
例えば、ユーザー固有の情報と共有の情報をそれぞれ別のAPIで提供し、ブラウザ側で合体させたDOMを生成することで、共有の情報を共有キャッシュに保存できます。
リソースの分離
キャッシュ特性の良いリソースと悪いリソースを別々のエンドポイント・パスで提供し、キャッシュしやすい情報のみをキャッシュすることで最適化ができる
キャッシュの制御
キャッシュの制御
キャッシュは、状態を複数の場所に分けて持つ仕組みです。情報の多重管理は余分な複雑さを持ち込み、保守性を下げます。そのため、十分に慎重に制御可能にするべきです。
削除
キャッシュはなるべく開発者が管理できるようにしましょう。したがって、ブラウザキャッシュよりもCDNキャッシュ、サーバー側でのキャッシュが望ましいです。
現時点の設計では1年間変わらないだろうと思っていたとしても、将来的な仕様変更で書き換えたくなる場合は十分に考えられます。そのような場合に、削除の手段が無ければソフトウェアの変更可能性が下がり、保守性に悪影響があります。
CDNならば、キャッシュのパージを指示できますし、サーバー側のストレージならば直接削除コマンドを送ることも可能です。
キャッシュ不整合によるバグ・不具合は、分散システム間の複雑な情報の混乱が原因であるため、非常に原因の特定及び解消が難しいです。したがって、管理画面から簡単に削除する機能を用意しておくことが、将来的な障害への対応力を改善させます。
依存関係の方向
そのため、バックエンドに近いレイヤーはフロントエンドに近いレイヤーから情報を読みに行ってはいけません。情報に不整合が発生した場合では、常にバックエンド側を正しいとして、フロントエンド側のデータの更新、破棄を行ってください。
キャッシュ戦略
開発者は、前節で説明した原則に基づき、アプリケーションに合わせてキャッシュを設計することになります。ここでは、設計の手順と方針を説明します。
キャッシュ設計の手順
キャッシュ設計の手順
- リソースを適切に識別し、供給者と消費者を明らかにする
- リソースの特性を明らかにし、キャッシュの効果とヒット率を考察する
- キャッシュ特性に応じてリソースの分離を行う
- リソースごとにキャッシュの設定を行う
バックエンド側の出力をキャッシュするメリット
- 汎用的であることから、利用頻度の高いものが多い
- リソースが分離されていることが多いので、良い特性を持つリソースに限ってキャッシュできる
- バックエンドのリソースの提供は料金に直結する場合が多い
ユーザー側の出力をキャッシュするメリット
- レイテンシが抑えられる
- バックエンドの負荷を抑えられる
ケーススタディ1:静的なリソースの配信
静的なリソースは、キャッシュ原則の理解を試す練習問題として最適です。
配置
画像やCSSなどのいわゆる静的なリソースはバックエンドやオブジェクトストレージで供給され、ブラウザで消費されます。
供給者と消費者の距離が離れているリソースです。
図から、分岐点であるCDNと、一番消費者側に近いブラウザにキャッシュするべきだと分かります。
リソース特性
キャッシュするリソースとしての特性を考えてみましょう。
- 更新頻度は低い。1日程度キャッシュしても致命的な問題にはならない
- 共有リソースである
- 画像は特にデータ量が大きい
このように、静的なリソースはキャッシュの原則からしてキャッシュ効果が高く、ヒット率も高いことが分かります。
ケーススタディ2:SSRとCSR
フロントエンドの設計戦略としてよく議論されるのが、SSR (Server Side Rendering) とCSR (Client Side Rendering) の違いです。この二種類の戦略は、キャッシュ原則の観点から説明できる点が多いです。
SSR
SSRは、サーバー側でHTMLの生成を行う戦略です。ブラウザからすると単に静的なHTMLが送られてくることになります。そのため、ViewやBFFの作業はすべてサーバー側で行います。レンダリングサーバーの役割が大きい戦略だと言えます。
CSR
CSRは、ブラウザ上で実行されるJavaScriptがHTMLを生成します。ブラウザ上のスクリプトがViewとなり、物理画面の生成を行います。実行されるのがブラウザ上なので、作成するのはHTMLというよりDOMです。
CSRのメリット
レイテンシの改善
CSRによってキャッシュのレイテンシが改善されます。なぜなら、キャッシュはなるべくユーザーに近いところで保存したほうが効率が良いからです。
CSRは、Viewをブラウザ上で行うため、ブラウザが通信で受け取るのはBFFの出力、論理画面です。さらに、これまで通り画像やCSSのキャッシュも行えるため、レイテンシの高いブラウザキャッシュの活用度が高いです。
最終的なHTMLよりも軽量で通信量も抑えられるうえ、バックエンド側に近いため比較的リソースの分離が容易であり、汎用性の高いリソースに限ったキャッシュもできます。
強力なキャッシュの分離
CSRでは、JavaScriptが必要なデータだけロードします。つまり、リソースを分離した状態で取得できるのです。ログイン画面全体だとキャッシュできませんが、ユーザー固有情報と最近のおすすめリストを別に取得すれば、おすすめリストだけはキャッシュできる可能性が高いです。
ブラウザでViewをやってしまうので論理画面レベルのAPIを呼ぶことになり、それらは汎用的なので利用頻度が高く、キャッシュ効率がいいです。
CDNの活用
CSRのWebページは、「すでに完成した最低限のHTML」と「個別の内容をもとにDOMを生成するスクリプト」から成り立っています。これは、共有リソースです。仮にこれがセンシティブ情報を含むページであっても、存在するのは「センシティブ情報を取得するスクリプト」であって、センシティブ情報そのものではないからです。したがって、CDNキャッシュが可能です。
SSRのメリット
初期描画が高速
CSRは、JavaScriptを読み込んで、JavaScriptがDOMを変更して描画されます。したがって、初期のコストが非常に大きいです。ユーザービリティにおいて、最初のコンテンツが描画されるまでの時間(FCP)は重要な基準です。HTMLを先に生成すれば、スクリプトを読み込む前に描画処理を始められます。
ユーザー端末のリソースを節約できる
ユーザー端末は開発者によるコントロールが困難です。スペックが低く、処理に時間がかかることも考えられます。また、モバイル端末であれば消費電力も大きな問題です。SSRすれば、重たい計算処理を抑えられます。
ブラウザが提供してくれているパフォーマンス改善の仕組みを利用できる
ブラウザは、ユーザー体験のために様々な方法で速度やユーザーの体感速度を改善しています。以下は、そのような機能のリストです。これらの技術は静的なHTMLで最大限効果を発揮できるようになっています。
-
img
要素のwidth
とheight
によるCLS対策 - Lazy Loading:必要になるまで読み込まない
- Progressive Loading:必要最低限のものから必要最低限な分量だけ読み込む
- PreloadおよびPrefetch:事前に必要になりそうなものを読み込む
- 自動的なキャッシュ
ケーススタディ3:Astro Server Component
本連載ではAstroを例に説明しているので、Astroでのキャッシュ戦略について説明します。
Astro 5.0からは、CSRとSSRの利点を簡単に両取りできる仕組みとして、Astro Server Componentが導入されました。
技術的な内容については、次の記事が非常に詳しいです。
使い方については、公式ドキュメントが読みやすいです。
この記事では、キャッシュ戦略の観点から説明します。
総合的には、SSRとCSRのメリットの主要な部分を両方受け取れるようにしたアーキテクチャだと言えます。
概要
サーバーコンポーネントは、次のように記述します。
---
import Avatar from '../components/Avatar.astro';
import GenericAvatar from '../components/GenericAvatar.astro';
---
<Avatar server:defer>
<GenericAvatar slot="fallback" />
</Avatar>
単にserver:defer
と指定するだけです。このように指定されたコンポーネントは、ページがリクエストされた段階ではレンダリングされません。代わりに、slot="fallback"
と指定されたコンポーネントがHTMLに含まれます。
生成されたHTMLには、クライアントサイドスクリプトが自動的に付与されます。そのスクリプトは、静的なHTML(今回だと<GenericAvatar>
など)の描画が終わり、JavaScriptの読み込みが始まったら、サーバーサイドに、<Avatar>
を描画するようにリクエストします。そして、Astroは<Avatar>
のレンダリングを行い、HTMLを返します。
最後に、クライアントサイドスクリプトがHTMLを文字列の置き換えによって<GenericAvatar>
から<Avatar>
に置き換えます。CSRと違って、HTMLの生成やDOMの変更が最低限になっているのが特徴です。
リソースの分離
ユーザーごとに変わるアバター画像をServer Islandsで処理する例が述べられています。これは、キャッシュの文脈からすれば専用リソースと共有リソースの分離です。Server Islandsにより、専用リソースを別のIslandsにすることでキャッシュ効率を高めています。
必要な部分だけ生成・転送
Server Islandsで動的に生成されるのは、指定された部分のみです。その他の部分は完全に静的なHTMLと同様に振舞います。全体をSSRする場合に比べてサーバーの負荷が下がります。
最初のレイテンシの改善
ブラウザはまず、静的なHTMLの描画を行います。この部分はシンプルなWebページとして高速に表示することができます。ブラウザ標準の最適化の恩恵も十分に受けられます。
ユーザー端末に負荷をかけない
Server Islandsではサーバー側でHTMLまで生成を行います。そして、クライアントのスクリプトは、生成されたHTMLの文字列をDOMに挿入します。HTML生成にかかる計算はサーバーで全て完結しており、DOMの操作も1度のみと最低限です。このような仕組みで、クライアント端末のスペックが低くても動作しやすいようになっています。
CDNの活用
Server Islandsが利用されている部分は、最初はユーザー個別の情報が含まれていません。CSRのときと同じ理由で、CDNを最大限活用できます。
おわりに
キャッシュは、その仕組みの単純さとは裏腹に、設計上は非常に難しく、考えることが多いと分かりました。
大規模ソフトウェアを実装する際は、この記事で解説したキャッシュの原則がフロントエンドに限らず活躍するはずです。