この記事は 弁護士ドットコム Advent Calendar 2019 16日目の記事です。
はじめに
弁護士ドットコム というサービスのUXエンジニアをやっている白井と申します。
日々ユーザー体験を向上させるべく様々な開発を行っていますが、Webサイトの速度改善もその1つです。
今回はその一環で実施した、ブラウザキャッシュ戦略の再設計の事例についてご紹介します。
対象リソース
この記事では、以下のようなリポジトリで管理されているリソースを主な対象としています。
- JavaScript, JSX, TypeScriptなどのスクリプト類
- CSSなどのスタイルシート類
- 画像やフォント類
これらは頻繁に変更される傾向にあるため、ブラウザキャッシュを適切に制御する必要があります。
注意深く設計しないと、以下のような問題が発生する場合があります。
ブラウザキャッシュのよくある問題
問題① ユーザーのブラウザに古いキャッシュが残ったままになる
内容に変更があったにも関わらず、ユーザーのブラウザにキャッシュが残ってしまい、古いコンテンツが表示されてしまったり、ページ自体が正常に表示されなくなってしまったりするケースです。
また、自分の開発環境では正常に表示されてしまい、なかなか問題に気づけなかった…といった方も多いのではないでしょうか。
問題② ブラウザキャッシュが全く活用できていない
逆に、適切な設定をしていなかったために、ブラウザキャッシュを全く活用できていないケースも多いと思います。
リソースに変更がないにも関わらず毎回リソースを取得させてしまうのは、ユーザーにとってもWebサーバにとっても好ましい状況ではありません。
この記事では、これらの問題が発生しないブラウザキャッシュ制御方法について検討していきたいと思います。
今回の改善結果
弊社では Speed Index のユーザによる実測値をサイト表示速度のKPIとして採用しています。
Speed Index はWebページのファーストビューが表示されるまでにかかる時間を計測したものです。
対応を行った結果、 Speed Index に約14%の改善 が見られました。
本記事でご紹介する手法によってどのくらいサイト表示速度が改善されるかは、以下のようなWebサイトの特性に依存するため、一概には言えません。
- リソースのサイズや分割単位
- ユーザーの新規・再訪問比率
しかし、何らかの改善のヒントになれば幸いです。
そもそもブラウザキャッシュとは?
ご存知のとおり、Webページを閲覧する際にはHTML文書だけではなく、JavaScript・CSS・画像といった様々なリソースをサーバからネットワーク経由で取得する必要があります。
一度訪れたサイトであれば、リソースの大半は前回の訪問時から変更されていないことが多いと考えられます。その場合、変更されたリソースに限って取得すればWebサイトをより高速に表示することができます。
そのために、前回取得したリソースをブラウザ側で記憶しておく仕組みがブラウザキャッシュです。
ブラウザキャッシュを制御する仕組み
サーバ側からブラウザキャッシュを制御するためには、以下の2つの仕組みが必要です。
- ブラウザにリソースをキャッシュさせる仕組み
- ブラウザにキャッシュを破棄させる仕組み
①ブラウザにリソースをキャッシュさせる仕組み
特定のHTTPヘッダを付与することで、ブラウザに対し、そのリソースがキャッシュ可能か指示することができます。
代表的なHTTPヘッダには以下があります。
Cache-Control
Expires
Age
ETag
Last-Modified
詳しくは RFC 7234 を参照してください。
これらの指示内容に応じて、ブラウザはリソースをキャッシュして良いか判断します。そして、次の訪問時には以下のいずれかの挙動を示します。
- ブラウザキャッシュを使用し、リソースの再取得を行わない
- リクエスト自体が発生しませんので、最も高速な挙動となります。
- リソースに更新があるかどうか確認するリクエストを送り、更新がなければキャッシュを使用する
-
If-Modified-Since
やIf-None-Match
ヘッダ付きのリクエストを送信します。 - Webサーバはそれらの内容から更新の有無を判断し、更新がある場合のみ最新のリソースを含めたレスポンスを返します。更新がなければ、
304 Not Modified
レスポンスを返します。
-
- ブラウザキャッシュを使用せず、必ずリソースを再取得する
- リクエストとリソースの取得が必ず発生しますので、最も遅い挙動です。
Webサイトの表示を高速化するには 1. を目指すことになりますが、この場合 リクエスト自体が発生しないため、リソースに更新が発生してもブラウザが再取得してくれない という問題が生じます。
それを解消するのが次に紹介する仕組みです。
②ブラウザにキャッシュを破棄させる仕組み
リソースが更新された場合に最新のものを取得してもらうためには、**キャッシュ破棄 (Cache Busting)**と呼ばれる手法が必要です。
ブラウザキャッシュの制御においては、リソースを参照する側のURLを変更するという手法が一般的です。
たとえば、HTML内からCSSファイルへの参照があった場合、そのURLを最新のものに更新します。 (当然、HTMLの方はキャッシュさせないようにしておきます)
以下の例ではURLの一部が version=1
から version=2
に変わっているため、ブラウザに新しいリソースとして認識され、再度取得されるようになります。
<link rel="stylesheet" href="/css/main.css?version=1">
↓
<link rel="stylesheet" href="/css/main.css?version=2">
この手法には、細かく分けると以下のような種類があります。
- 実際に新しいファイルを作る
- リソースが更新される度に新しいファイル名 (URL) を発行する
- ファイルは同じだが、名前の一部を変える
- ファイル名の一部に何らかのパラメータを付与する
この「何らかのパラメータ」は通称キャッシュバスター (Cache Buster) とも呼ばれます。
キャッシュバスターを付与する方法には、以下のバリエーションがあります。
キャッシュバスターの「生成元」によるバリエーション
何に基づいてキャッシュバスターを生成するかについてはいくつかの方法があり、それぞれメリット・デメリットがあります。
キャッシュバスターの生成元 | キャッシュ破棄のタイミング | キャッシュが破棄される単位 | 説明 |
---|---|---|---|
・ビルド日時 ・デプロイ日時 |
デプロイ時 | 全てのリソース | Webサーバが複数台ある場合に同一時刻になるように注意が必要 |
・アプリケーションのバージョン番号 ・GitのコミットID |
デプロイ時 | 全てのリソース | |
・ファイルの更新日時 | リソース更新時 | 該当リソースのみ | デプロイ時のファイルコピーで更新日時が書き換わらないように注意が必要 |
・ファイル内容のハッシュ値 | リソース更新時 | 該当リソースのみ | ファイルごとにハッシュ値の計算が必要 |
詳しくは後述しますが、弊社では 「ファイル内容のハッシュ値」と「GitのコミットID」を併用する方式を採用しました。
キャッシュバスターを付与する「場所」によるバリエーション
一方、キャッシュバスターをリソースURLのどこに付与するかについても、以下の方法があります。
ファイル名に付与する
例: image-4d300b8f57bff89d8f4f87b5cc1b3de5.png
クエリパラメータとして付与する
例: image.png?version=4d300b8f57bff89d8f4f87b5cc1b3de5
ファイル名を毎回変更する場合、ローカル開発時に不要なファイルが蓄積していく原因にもなりますので、Webpack処理の際に先にクリーンナップ処理を行うなどの配慮が必要になります。
なお、デプロイごとにキャッシュバスターを更新する場合は、まとめてディレクトリ名に付与してしまうという方法もあります。
弁護士ドットコムにおけるブラウザキャッシュ戦略
一般論が続いたため、そろそろお腹いっぱいかと思いますが、ようやく本題です!
以前はどうだったか?
改善前の弁護士ドットコムでは、以下のような設計になっていました。
ブラウザにリソースをキャッシュさせる仕組み
HTTPヘッダ (Expires
ヘッダ) により、1年間のキャッシュを許可していました。
ブラウザにキャッシュを破棄させる仕組み
ファイル名にデプロイ日時ベースのキャッシュバスターをクエリパラメータとして付与していました。
- CSSファイルから
url()
関数で読み込まれる画像のURLについては、デプロイ時のWebpack処理によってタイムスタンプが付与されていました - その他のJavaScript・CSS・一部の画像については、PHPの関数により、デプロイ日時のタイムスタンプが付与されていました
しかし、この設計・実装には以下の課題がありました。
課題① デプロイの度にブラウザキャッシュが破棄されてしまう
弊社ではデプロイが完全に自動化されているため、エンジニア・デザイナーを問わず、Slackから簡単にデプロイを行うことができます。
そのため、デプロイは1日に数回、多い時には数十回行われる場合があります。
しかし、上記の設計における大きな課題の1つが、デプロイする度にすべてのリソースのブラウザキャッシュが破棄されてしまうということでした。
大半のリソースには変更がないにもかかわらず、その度にブラウザキャッシュが破棄されてしまっていました。
課題② サーバごとにキャッシュバスターが異なる
なんと、デプロイ日時ベースのキャッシュバスターを付けていたはずが、サーバごとにキャッシュバスターの値が異なっていました。
現在のデプロイ方式では、複数台あるアプリケーションサーバ間で、デプロイ日時に数秒〜数十秒のズレが生じてしまっています。
デプロイ日時ベースのキャッシュバスターを各サーバで生成していたため、このような現象が起こってしまっていました。
その結果、せっかくブラウザキャッシュを持っていても、前回と異なるサーバにアクセスするだけでキャッシュが破棄されてしまうという問題が起こっていました。
ついやってしまいがちかとは思いますが、なかなか気付きにくいタイプの問題かと思います。
どのように改善したか?
この仕組みを以下のように改善しました。
ブラウザにリソースをキャッシュさせる仕組み
従来通り、HTTPヘッダにより1年間のキャッシュを許可する設定を踏襲しました。
ブラウザにキャッシュを破棄させる仕組み
以下のロジックを採用しました。隙を生じぬ二段構え。
① 特定のリソースにはコンテンツベースのキャッシュバスターを付与する
具体的には、Webpackで処理する以下のリソースが対象となります。
- JavaScriptファイル (
.js
,.jsx
) - CSSファイル (
.css
,.scss
) - CSSファイルの
url()
関数で読み込まれる画像やフォント類 (.png
,.woff2
など)
これらのリソースの内容をハッシュ化した文字列をキャッシュバスターとして付与します。
こうすることで、内容が変更されるまでブラウザキャッシュが破棄されないようになります。
② それ以外のリソースにはコミットIDをキャッシュバスターとして付与する
一方、全てのリソースをWebpackで処理するわけではありません。例えば、以下のようなリソースは対象外です。
- (CSSから読み込まれない) 画像やフォント類
- Webpackを通さない、生のJavaScriptやCSSファイル類
これらについては、デプロイ時のGitのコミットIDをキャッシュバスターとして付与します。
デプロイごとにブラウザキャッシュが破棄されてしまいますが、重要なリソースの多くは何らかの形でWebpackで処理されていたため、問題なしと判断しました。
ようやく実装編
さて、前述の仕組みをどのように実装していったのか見ていきましょう。
Rails やLaravel などのフレームワークでは似たような仕組みが用意されていますが、諸事情により、今回は自前で仕組みを作っていきました。
概要
今回の実装は大きく2つの部分から構成されています。
① Webpackで manifest.json
ファイルを出力する
manifest.json
とは、サーバ内のリソースのパスと、キャッシュバスター付きのリソースのパスの対応関係を記述したJSONファイルです。
{
"/js/lawyer/mypage/pc.bundle.js": "/js/lawyer/mypage/pc.bundle.js?b91d959a9994e8c2fc51"
}
上記の例では、 ?b91d959a9994e8c2fc51
というクエリパラメータが付与されています。
② PHPコードから manifest.json
を読み込む
一方、HTML出力時にはPHPの関数で manifest.json
ファイルを読み込み、キャッシュバスター付きのURLに変換を行います。
<script src="<?= asset('/js/lawyer/mypage/pc.bundle.js') ?>"></script>
↓
<script src="/js/lawyer/mypage/pc.bundle.js?b91d959a9994e8c2fc51"></script>
指定されたパスが manifest.json
に存在しない場合は、一律でGitのコミットIDを付与します。
なお、PHPに限らず、任意のサーバサイド言語で同様の仕組みが実現可能かと思います。
Webpack側の実装
CSSの manifest.json
ファイルを出力するためのWebpack設定は以下のようなイメージになりました。 (一部パスなどを変更しているため、そのままでは動作しません)
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import ManifestPlugin from 'webpack-manifest-plugin';
export const config = {
entry: entries.css,
output: {
path: '../service/css'
},
module: {
rules: [
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
sourceMap: true,
},
},
{
loader: 'postcss-loader',
options: {
sourceMap: true,
},
},
{
loader: 'resolve-url-loader',
options: {
sourceMap: true,
root: '../service',
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
outputStyle: 'compressed',
},
},
],
},
{
test: /\.(gif|png|jpe?g|eot|wof|woff|woff2|ttf|svg)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[path][name].[ext]?[hash]', // CSSのurl()で読み込まれる画像やフォント類にコンテンツのハッシュ値を付与する
context: '../service',
publicPath: '/',
emitFile: false,
},
},
],
},
],
},
plugins: [
// manifest.json ファイルを出力する
new ManifestPlugin({
basePath: '/css/',
publicPath: '/css/',
filter: fileDesc => fileDesc.isInitial,
}),
new MiniCssExtractPlugin({
filename: '[name].css?[contenthash]', // 出力されるCSSのファイル名にコンテンツのハッシュ値を付与する
}),
],
};
これは以下のような処理を行なっています。
- CSSから読み込まれている画像やフォント類のハッシュ値を計算する
-
resolve-url-loader を使い、CSSの
url()
関数で参照されている画像やフォント類をWebpackで処理できるようにします。 - それらの画像やフォント類を file-loader で処理し、名前にコンテンツのハッシュ値を付与します。
- このプラグインは本来、ファイルシステム上にファイルを出力するプラグインですが、
emitFile: false
を指定し、出力を行わない設定にしています。 - このようにすることで、ファイル名の変更のみを行うことができます。
- このプラグインは本来、ファイルシステム上にファイルを出力するプラグインですが、
-
resolve-url-loader を使い、CSSの
- CSSファイルのハッシュ値を計算する
-
mini-css-extract-plugin を使ってCSSを個別のファイルとして出力します。
- その際、
filename
オプションに[contenthash]
を指定し、ファイル名にコンテンツのハッシュ値を付与します。
- その際、
-
mini-css-extract-plugin を使ってCSSを個別のファイルとして出力します。
-
manifest.json
ファイルを出力する-
webpack-manifest-plugin を利用し、目的の
manifest.json
ファイルを出力します。
-
webpack-manifest-plugin を利用し、目的の
ここで、1.の処理を忘れないようにご注意ください。CSSから読み込まれる画像やフォント類にも適切にキャッシュバスターを付与する必要があります。
これを忘れてしまうと、以下のような問題が発生します。
- キャッシュバスターを付与していなかったために、それらの画像やフォント類はブラウザキャッシュが破棄されないままになってしまった
- タイムスタンプベースのキャッシュバスターを付与していたため、CSSファイルの内容が毎回変わってしまい、コンテンツのハッシュ値が変わってしまった
PHP側の実装
そろそろ読むのも疲れてきたでしょうから省略します。 (書くのも疲れてきました)
manifest.json
ファイルを読み、指定されたパスに対応するものがあればそれを返し、なければGitのコミットIDを付与するだけです。
まとめ
今回は弁護士ドットコムにおけるブラウザキャッシュ活用の改善事例についてご紹介しました。
- ブラウザキャッシュを活用すると、2回目以降のサイト表示速度を高速化できる
- それには以下の2つの仕組みが必要である
- ブラウザにリソースをキャッシュさせる仕組み
- ブラウザにキャッシュを破棄させる仕組み
- リソースのコンテンツのハッシュ値をキャッシュバスターとして付与すると、最も長い期間ブラウザキャッシュを保持させることができる
よい良いユーザー体験を提供しようとする、全てのエンジニアの皆様の参考になれば幸いです。
おまけ:設計時にほかに検討したこと
Q. ローカルでの開発時はどうするの?
DIを使ってキャッシュバスターを生成するクラスを差し替え、メソッドが呼び出された時刻のタイムスタンプを返すようにしています。
こうすることで、常にキャッシュが破棄される状態になります。
Q. CDNを導入すれば良いのでは?
CDNで高速化されるのは1回目の読み込みであり、今回の改善対象は2回目以降の読み込みです。補完的な関係にあると考えています。
Q. Webpackのハッシュアルゴリズムに依存してしまうと、将来ほかのツールに移行できないのでは?
ファイルコンテンツベースのハッシュアルゴリズムであれば何でも良い (新旧のツールで同じハッシュ値になる必要がない) ため、問題ないと考えました。
もし、乗り換え先のツールにそういった機能がない場合でも、性能の劣化を許容すれば良さそうです。
Q. 各ファイルの更新時刻をWebサーバ (Apache/Nginx) に認識させて、 Last-Modified
ヘッダを出力すると簡単なのでは?
それが実現できればこんなに複雑な仕組みは必要なくなるため、実装上はシンプルになります。
しかし、以下の理由から今回考えた方式を採用しました。
If-Modified-Since
ヘッダ付きリクエストの発生
Last-Modified
ヘッダを出力させた場合、ブラウザはキャッシュが最新か確認するために If-Modified-Since
ヘッダ付きのリクエストを送信します。
(更新がない場合はレスポンスのボディが含まれないとはいえ) 通信が発生するよりはしない方が高速にブラウジングできるはずです。
デプロイ処理の運用コストの問題
デプロイ時のファイルコピーにより、ファイルの更新時刻が変わってしまうことはよくあります。
また、複数台のAppサーバ間で同一ファイルの更新時刻がズレてしまうとさらにカオスになります。 (弊社で実際にやらかしてしまっていたのは前述のとおりです。。)
これらの問題が発生しないようなデプロイ処理を作り込むことは可能ですが、アプリケーション開発者が普段意識しないレイヤですので、運用の中で意図せず壊れてしまう可能性があります。