WordPress.org のレビューで、「プラグインが wp-config.php に書き込むのは許可されない」と言われました。同意を取ってからでもだめ、という回答でした。
ページキャッシュのプラグインにとって、これはなかなか厳しい話です。define( 'WP_CACHE', true ); と advanced-cache.php の組み合わせが、WordPress が公式に用意しているページキャッシュの仕組みだからです。その入口である定数を、こちらから書けない。
書かずに成立させる方法を考えた結果、二段構えの設計になりました。定数がなくても動く標準モードと、定数があるときだけ使えるさらに速いドロップインモード。結果として、この形のほうが素直でした。実装を書いておきます。
二段構えにする
まず、全体の形から。
標準モードは、WP_CACHE が定義されていないときの既定の動作です。プラグインファイルが評価された直後に、キャッシュエンジンを require します。plugins_loaded より前、プラグインのファイルが読み込まれたその場です。
// prime-cache.php の冒頭付近
// WordPress のアクションフックは使わない。ファイル評価の流れでそのまま読む
if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
require_once __DIR__ . '/includes/page-cache.php';
}
ドロップインモードは、サイト所有者が自分で WP_CACHE を書いた場合です。このときは WordPress コアが wp-content/advanced-cache.php を読む時点で動きます。wp-settings.php の最初期、DB 接続直後のドロップイン読み込みタイミングで、WordPress がほぼ何も初期化していない段階です。
大事なのは、定数を書くかどうかを、プラグインが決めないことでした。書かなくても標準モードで動く。書けば速いほうに昇格する。設定画面には define( 'WP_CACHE', true ); の一行を表示しておいて、追加も削除も利用者が手でやる。プラグイン側は、定数があるかどうかを見るだけです。
どちらのモードでも、init などの WordPress のアクションは使っていません。キャッシュを返すのに WordPress の機能はいらないので、フックを待つ理由がないからです。
HIT のときに、何が動かないか
速さの差は、ここに出ます。
// キャッシュヒット時の中核。WordPress の関数は一切使わない純 PHP
readfile( $cache_file );
exit;
ドロップインモードでは、advanced-cache.php の時点で exit するので、その先の wp-settings.php が走りません。プラグイン群も、テーマも、WP_Query も、レンダリングも、何ひとつ動かない。
標準モードでは、WordPress のブート処理までは読み込まれます。ただ、プラグインファイルが読まれた時点で require して exit するので、テーマ・メインクエリ・テンプレートのレンダリングはスキップされます。
コアのブートぶんは余計に走りますが、重いのはテーマとクエリとレンダリングなので、体感としては十分に速い。定数を書けない制約の中では、ここが落としどころでした。
If-Modified-Since が一致した場合の 304 も、この HIT 経路で処理してしまいます。ボディを返さずに exit するだけです。
MISS のときは、出力バッファで書き出す
キャッシュがなければ、普通に WordPress にページを作らせます。作られた HTML を、出力バッファのコールバックで受け取って、ファイルに書き出します。
ob_start( 'my_cache_output_callback' );
function my_cache_output_callback( $html ) {
// 保存できる内容かを確認してから書く(条件は後述)
if ( my_should_store( $html ) ) {
my_atomic_write( $cache_file, $html );
my_atomic_write( $cache_file . '.gz', gzencode( $html, 6 ) );
}
return $html;
}
書き込みは、一時ファイルに書いてから rename します。rename は同一ファイルシステム内なら原子的なので、書きかけのファイルを他のリクエストが読んでしまう事故を避けられます。gzip 版も同時に作っておいて、対応するクライアントにはそちらを返します。
キャッシュキーの作り方
キーは、ディレクトリのパスとファイル名の組み合わせで表現しています。
ディレクトリ側は、ホスト名と URL パスです。ホスト名は小文字化し、ポートを除き、IDN は Punycode に正規化し、危険な文字は hex エンコードします。URL パスはクエリを除いた部分を、セグメント単位で hex エンコード。.. によるディレクトリトラバーサルは遮断します。
ここで一点、意図的にやっていないことがあります。rawurldecode をしません。デコードすると %2F と / が同じものになってしまい、別々の URL が同じキーに衝突します。デコードせずに扱うことで、その混同を避けています。
ファイル名側に、変化する条件を接尾辞として足していきます。
| 要素 | 接尾辞 |
|---|---|
| ステータス |
index(200)/ 404-index(404、有効時のみ) |
| スキーム | -https |
| モバイル |
-mobile(分離設定が有効なときのみ) |
| Vary Cookie | -vc_{md5 16桁} |
| クエリ文字列 | -qs_{md5 16桁} |
| gzip |
.html / .html.gz
|
クエリ文字列は、許可リスト方式にしました。許可したパラメータの値だけをキーに含め、utm_* のような計測用パラメータは既定で無視して、同じキーに集約します。こうしないと、SNS からの流入で ?utm_source=... が付くたびに別のキャッシュが作られて、ヒット率が落ちます。
md5 を 16 桁に切り詰めた理由
上の表で、md5 を 16 桁(64bit)にしています。ここは、短くするためではなく、意図があります。
もし全長のまま使うと、というより、桁を減らしすぎると、攻撃者が同じハッシュになる query や cookie の値を安価に作れてしまいます。作れると、他人が見るはずのバリアントに、自分が用意した内容を流し込める。キャッシュ汚染です。
64bit あれば、衝突する値を実用的なコストで生成するのが難しくなります。キャッシュキーのハッシュは、短くしたい気持ちが働く場所ですが、切り詰めすぎると汚染の入口になる、という話です。
言語の扱い
多言語サイトは、方式によって対応が分かれました。
パスに /ja/ /en/ のような言語プレフィックスが付くタイプは、パスがキーの一部なので、自然に別のバケットになります。何もしなくても分かれる。
cookie で言語を切り替えるタイプは、その cookie を Vary Cookie の設定に登録してもらう形で対応します。
Accept-Language ヘッダで出し分けるタイプには、対応していません。レスポンスに Vary: Accept-Language が付いている場合は、キャッシュを保存しないようにしています。ヘッダごとに内容が変わるものを、URL ベースのキーで保存すると、確実に事故るからです。対応できないものは、キャッシュしないと決めるほうが安全でした。
除外条件(WordPress ロード前)
ここが、実装の分量としてはいちばん多い部分です。まず、WordPress が読み込まれる前の早い段階で弾くもの。
-
GET/HEAD以外のメソッド(POSTを含む) -
Authorizationヘッダがある(Basic / Bearer / Digest) - Host が許可リストにない(設定ミス時は保存しない側に倒す)
-
wp-admin/wp-login.php/wp-cron.php/xmlrpc.php - WooCommerce の cart / checkout / my-account / wc-api / wc-ajax / add-to-cart
- バイパス cookie がある(ログイン、カートのハッシュ、セッション、コメント投稿者、投稿パスワード)
- HTML ではない拡張子(
.xml.json.css.js.map.txtなど) - 許可していないクエリ文字列を含む
- 設定による除外(URI / cookie / UA / referrer の正規表現)
- モバイルで、モバイルのキャッシュが無効
- http と https のスキーム不一致
管理画面や wp-login.php の判定は、正規表現に境界のアンカーを付けています。単純な部分一致だと、/blog/wp-admin-guide/ のような普通の記事 URL まで巻き込むので。
除外の判定でエラーが起きたときは、キャッシュしない側に倒します。設定の正規表現がコンパイルに失敗したときも同じです。判定できないものを、とりあえずキャッシュしてしまうと、ログイン中のページを配ってしまうような事故につながります。迷ったら配らない、が安全側でした。
除外条件(レンダリング後)
ページが作られたあと、保存してよいかを最後に確認します。
- ステータスが 200 ではない(404 は設定が有効なときだけ許可)
-
DONOTCACHEPAGE定数が true - レスポンスに
Set-Cookieが付いている -
Cache-Controlにprivateかno-store -
VaryがAccept-Encoding以外 - HTML に
</html>がない - 検索結果(
is_search()) - パスワード保護された記事
- 記事単位の無効化フラグ(投稿メタ)
- レスポンスが 255 バイト未満
Cache-Control の扱いは、少し細かい話があります。no-cache は許可しています。no-cache は「保存するな」ではなく「使う前に再検証しろ」という指示なので、保存自体は禁止していないからです。禁止しているのは private と no-store のほう。ここを一緒くたに扱うと、キャッシュできるページをかなり取りこぼします。
</html> の確認は、途中で切れた出力やリダイレクトを保存しないためです。不完全な HTML をキャッシュすると、その壊れたページが延々と配られます。
早見でまとめ
-
WP_CACHEを書けないなら、書かなくても動くモードを既定にして、定数があれば速い経路に昇格させる - 標準モードはプラグインファイル評価直後に
require。plugins_loadedより前、アクションは使わない - HIT は
readfile()+exit。ドロップインモードではwp-settings.phpごと走らない - MISS は出力バッファのコールバックで、一時ファイル +
renameの原子的書き込み。gzip 版も同時に作る - キーはホスト + パス(hex エンコード、
rawurldecodeしない)+ 条件の接尾辞 - クエリは許可リスト方式。
utm_*は無視して同じキーに集約しないとヒット率が落ちる - キーのハッシュを切り詰めすぎると、衝突を作られてキャッシュ汚染の入口になる
-
Accept-Language出し分けなど、URL ベースのキーで表現できないものは、保存しないと決める - 判定に失敗したら、キャッシュしない側に倒す
-
no-cacheは保存禁止ではない。禁止はprivateとno-store
書き込みを禁止されたときは、正直、詰んだと思いました。でも、書かずに成立させる方法を探したら、こちらのほうが利用者に何も要求しない形になりました。制約から入ったほうが、結果的に素直な設計になることもあるようです。役に立ったらストックして、キャッシュまわりを実装するとき見返してください。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。