functions.phpのtrend_listコードを
プラグイン化してキャッシュを導入した話

WordPressテーマのfunctions.phpに書いていた人気記事ランキングのコードを外部プラグインとして分離し、キャッシュによるパフォーマンス改善を実施しました。この記事では、なぜプラグイン化したのか、どのような設計にしたのか、実装で詰まった点を共有します。

関連記事

1. なぜfunctions.phpから分離したのか

なぜ分離したのか ❌ 問題点 1 毎回DB集計 ページ表示の度に重いSQL 2 テーマ更新リスク 上書き・関数競合の危険 3 保守性の低下 依存関係が不明確 ✓ 解決策 1 キャッシュ導入 transient で結果保存 2 独立プラグイン化 テーマと分離管理 3 依存関係の整理 Adapter パターン採用

1.1. 毎回データベースを見る非効率さ

人気記事ランキングを表示するショートコード[trend_list]をfunctions.phpに実装していましたが、このコードはページ表示のたびにデータベースへ重い集計クエリを投げていました。

// 元のコード(簡略版)
function get_trend_ranking_records($days, $limit, ...) {
    global $wpdb;
    $query = "
        SELECT post_id, SUM(count) AS sum_count
        FROM {$trend_table}
        WHERE date BETWEEN '$date_before' AND '$date'
        GROUP BY post_id
        ORDER BY sum_count DESC
    ";
    return $wpdb->get_results($query);
}
Code language: PHP (php)

このクエリは複数のテーブルをJOINし、期間やカテゴリ条件でフィルタしながら集計します1。記事数が増えるほど重くなりますし、同じページ内で複数回呼ばれることもあります。

Cocoonテーマには一応transient(WordPressの一時保存機能)を使ったキャッシュ機構が含まれていましたが、functions.phpに書かれたコードは条件分岐が複雑で、どこまでキャッシュが効いているのか把握しづらい状態でした2

1.2. テーマ更新時のリスク

functions.phpに直接書いたコードは、テーマを更新すると上書きされるリスクがあります。子テーマを使えば回避できますが、それでもテーマ本体の関数と名前が衝突したり、内部仕様の変更で動かなくなったりする可能性があります。

独立したプラグインにすれば、テーマとは別のライフサイクルで管理できます。

2. プラグイン化の方針

プラグイン化の方針 キャッシュ優先設計 引数 → キャッシュキー生成 transient で結果保存・取得 依存関係の整理 Cocoon Adapter データフロー 1. ショートコード 実行 2. キャッシュ 確認 3. 取得 or 集計実行 4. テンプレート 描画

2.1. キャッシュ優先の設計

表示時には極力データベースを見ず、キャッシュから読み取ることを基本方針にしました。キャッシュが存在しない場合の挙動は設定で制御できるようにしています。

具体的には、ショートコードの引数(表示日数、件数、カテゴリなど)をもとにキャッシュキーを生成し、transientに保存した結果を返します3

class CacheKeyBuilder
{
    public function build($days, $count, $cats, $children, $post_type)
    {
        $cats_str = is_array($cats) ? implode(',', $cats) : 'all';
        return sprintf(
            'trend_cache_d%s_c%d_cat%s_ch%d_pt%s',
            $days,
            $count,
            $cats_str,
            $children ? '1' : '0',
            $post_type
        );
    }
}
Code language: PHP (php)

キャッシュキーには表示条件を含めるため、同じ条件のショートコードが複数箇所にあっても、一度計算すれば使い回せます。

2.2. 依存関係の整理

Cocoonテーマのアクセス集計機能に依存しているため、その部分をCocoonAdapterというクラスに隔離しました4。これにより、Cocoonの内部仕様が変わっても修正箇所が明確になります。

class CocoonAdapter
{
    public function getAccessTableName()
    {
        if (!defined('ACCESSES_TABLE_NAME')) {
            return null;
        }
        return ACCESSES_TABLE_NAME;
    }

    public function isAccessCountCacheEnabled()
    {
        if (!function_exists('is_access_count_cache_enable')) {
            return false;
        }
        return is_access_count_cache_enable();
    }
}
Code language: PHP (php)

依存チェックはDependencyCheckerで集約し、管理画面で状態を表示できるようにしました。

class DependencyChecker
{
    public function checkAll()
    {
        $results = [];
        $results['table_constant'] = defined('ACCESSES_TABLE_NAME');
        $results['table_exists'] = $this->adapter->tableExists();
        $results['cache_function'] = function_exists('is_access_count_cache_enable');
        return $results;
    }
}
Code language: PHP (php)

これで「Cocoonがインストールされているか」「アクセス集計テーブルが存在するか」を簡単に確認できます。

3. 事前計算の仕組み

事前計算の仕組み WP-Cron による定期更新 1時間ごとに自動実行 過去に使われた引数パターンを再計算 使用済み 引数記録 ショートコード実行時 Cron 起動 1時間ごと キャッシュ 再生成 全パターン更新 高速 表示 初回でも速い 💡 ポイント 手動でもキャッシュ再生成可能(設定画面から)

3.1. WP-Cronによる定期更新

キャッシュがない状態でページを開くと初回だけ遅くなります。これを避けるため、WP-Cron(WordPressの定期実行機能)を使って事前にキャッシュを生成する仕組みを追加しました。

class PrecomputeJob
{
    public function register()
    {
        if (!wp_next_scheduled('chiilabo_trend_precompute')) {
            wp_schedule_event(time(), 'hourly', 'chiilabo_trend_precompute');
        }
        add_action('chiilabo_trend_precompute', [$this, 'run']);
    }

    public function run()
    {
        $known_args = $this->queryArgsStore->getAllKnownArgs();
        foreach ($known_args as $args) {
            $this->trendService->getTrendEntries($args);
        }
    }
}
Code language: PHP (php)

1時間ごとに、過去に使われたショートコード引数の組み合わせを取得し、そのすべてについてキャッシュを再生成します5

3.2. 使用済み引数の記録

どの引数パターンを事前計算すればよいかは、ショートコードが実行されたときに記録しておきます。

class QueryArgsStore
{
    public function record($args)
    {
        $stored = get_option('chiilabo_trend_query_args', []);
        $key = $this->keyBuilder->build(
            $args['days'],
            $args['count'],
            $args['cats'],
            $args['children'],
            $args['post_type']
        );
        if (!in_array($key, $stored, true)) {
            $stored[] = $key;
            update_option('chiilabo_trend_query_args', $stored);
        }
    }
}
Code language: PHP (php)

これにより、実際にサイトで使われている引数だけを事前計算対象にできます。

4. 実装で詰まった点

実装で詰まった点 1. 初期化タイミング ❌ plugins_loaded テーマ読み込み前で失敗 ✓ after_setup_theme (priority 20) 2. ウィジェット展開 ショートコードが 文字列表示される ✓ do_shortcode 追加 3. 出力HTML互換 独自HTML → スタイル崩れ ✓ Cocoon関数に委譲 4. PHP互換性 PHP 8構文でエラー ✓ PHP 7.4互換記法

4.1. 初期化のタイミング

最初、プラグインをplugins_loadedフックで初期化していましたが、ショートコードが登録されず、[trend_list]がそのまま文字列として表示される不具合が出ました。

原因はCocoonテーマの読み込みタイミングです。Cocoonが定義するACCESSES_TABLE_NAMEという定数は、テーマのfunctions.phpが読み込まれた後でないと存在しません。plugins_loadedではまだテーマが読み込まれていないため、依存チェックで失敗してプラグインが起動を中断していました。

// 修正前
add_action('plugins_loaded', [$this, 'boot']);

// 修正後
add_action('after_setup_theme', [$this, 'boot'], 20);
Code language: JavaScript (javascript)

after_setup_themeのpriorityを20に設定することで、Cocoonのテーマ初期化(priority 10)より後に実行されるようにしました6

4.2. ウィジェットでの展開

ショートコードは本文中では動作しますが、ウィジェット領域では標準では展開されません。これを有効にするため、以下のフィルタを追加しました。

add_filter('widget_text', 'do_shortcode');
add_filter('widget_text_content', 'do_shortcode');
add_filter('widget_block_content', 'do_shortcode');
Code language: JavaScript (javascript)

これでテキストウィジェットやブロックウィジェット内でも[trend_list]が使えるようになります。

4.3. 出力HTMLの互換性

プラグイン側で独自にHTMLを組み立てると、Cocoonのスタイルが適用されず見た目が崩れます。最終的には、Cocoonの既存ショートコードpopular_entries_shortcodeに処理を委譲することで、DOM構造を完全に一致させました。

public function render($args)
{
    if (function_exists('popular_entries_shortcode')) {
        $cocoon_args = $this->convertToCocoonArgs($args);
        return popular_entries_shortcode($cocoon_args);
    }
    return '<p>人気記事が見つかりませんでした。</p>';
}
Code language: PHP (php)

これにより、既存のCSSがそのまま適用されます。

4.4. PHP互換性

本番サーバーで構文エラーが出ました。原因は、コンストラクタプロパティ昇格(PHP 8.0以降)やreadonlyキーワード(PHP 8.1以降)を使っていたことです。

// エラーになったコード
public function __construct(
    private readonly CacheStore $cacheStore,
    private readonly RankingRepository $repository
) {}

// 修正後
private $cacheStore;
private $repository;

public function __construct($cacheStore, $repository)
{
    $this->cacheStore = $cacheStore;
    $this->repository = $repository;
}
Code language: PHP (php)

配布用のプラグインでは、広いバージョンで動作するよう、新しい文法は避ける必要があります7

5. 設定画面の実装

管理画面に「設定 > Trend List」というページを追加し、以下の項目を設定できるようにしました8

  • Cache TTL: キャッシュの有効期間(秒単位)
  • Allow query on cache miss: キャッシュがないときにデータベースクエリを許可するか

さらに、依存関係のステータスと、キャッシュの最終更新時刻、次回のCron実行予定時刻を表示しています。

echo '<p><strong>依存関係の状態:</strong></p>';
$deps = $this->dependencyChecker->checkAll();
echo '<ul>';
echo '<li>ACCESSES_TABLE_NAME 定数: ' . ($deps['table_constant'] ? 'OK' : 'NG') . '</li>';
echo '<li>アクセステーブル存在: ' . ($deps['table_exists'] ? 'OK' : 'NG') . '</li>';
echo '<li>キャッシュ関数: ' . ($deps['cache_function'] ? 'OK' : 'NG') . '</li>';
echo '</ul>';
Code language: PHP (php)

また、手動でキャッシュを再生成するボタンも追加しました。

if (isset($_POST['regenerate_cache'])) {
    $this->precomputeJob->run();
    update_option('chiilabo_trend_last_precompute', time());
    echo '<div class="notice notice-success"><p>キャッシュを再生成しました。</p></div>';
}
Code language: PHP (php)

これで、サイト運営者が必要に応じてキャッシュをリセットできます。

5.1. ビルドと配布

プラグインをZIPファイルにするためのbuild.shを作成しました。このスクリプトは、プラグインディレクトリをそのまま圧縮し、dist/に出力します。

#!/bin/sh
set -eu

PLUGIN_DIR="chiilabo-cocoon-trend-list-plugin"
VERSION=$(grep -m 1 'Version:' "$PLUGIN_DIR/chiilabo-cocoon-trend-list.php" | awk '{print $2}')
OUTPUT_DIR="dist"
ZIP_NAME="$PLUGIN_DIR-$VERSION.zip"

mkdir -p "$OUTPUT_DIR"
(cd "$(dirname "$PLUGIN_DIR")" && zip -r "$OUTPUT_DIR/$ZIP_NAME" "$(basename "$PLUGIN_DIR")")
echo "Built: $OUTPUT_DIR/$ZIP_NAME"
Code language: PHP (php)

バージョン番号はプラグインファイルのヘッダーコメントから自動で読み取ります。ビルドスクリプト自体がバージョンを書き換えることはせず、あくまで参照透過な操作にしています。

5.2. テストの工夫

プラグイン開発ではPHPUnitを使った単体テストを導入しました。WordPressの関数に依存する部分は、アダプタークラスを経由させることで、テスト時にモックへ差し替えやすくしています。

class CacheKeyBuilderTest extends TestCase
{
    public function testBuild()
    {
        $builder = new CacheKeyBuilder();
        $key = $builder->build('7', 5, ['1', '2'], true, 'post');
        $this->assertStringContainsString('d7', $key);
        $this->assertStringContainsString('c5', $key);
        $this->assertStringContainsString('cat1,2', $key);
    }
}
Code language: PHP (php)

WordPress本体を起動せずに、ロジック部分だけをテストできます。

6. 実際の効果

プラグイン化とキャッシュ導入により、以下の改善が見込まれます9

  • クエリ回数の削減: 目標は現状比30%以上
  • ページ生成時間の短縮: 目標は現状比20%以上

実際の計測は、既存のパフォーマンス計測プラグインchiilabo-perf-profilerと突き合わせて確認する予定です。

6.1. 今後の課題

現時点では、キャッシュの事前生成が「過去に使われた引数」に基づいているため、新しい引数パターンが登場すると初回だけ遅くなります10。サイトの規模によっては、よく使われる引数パターンを事前に定義しておく仕組みも検討できます。

また、Cocoonテーマの更新で内部仕様が変わる可能性があるため、依存部分を注視し続ける必要があります11

  1. WordPressのクエリ最適化では、クロステーブルJOINやDISTINCT、GROUPなど一時テーブルを生成する操作は避け、複雑な処理はデータベースではなくPHP側で行うことが推奨されています。 – Best practices for database queries – WordPress VIP Documentation
  2. Transientは一時的なデータ保存機構ですが、有効期限前でも消える可能性があります。有効期限は最大時間を示すだけで、最小時間の保証はありません。そのため、コードは常にキャッシュミスを想定したフォールバック処理を実装する必要があります。 – Transients – Common APIs Handbook | Developer.WordPress.org
  3. Transientのキー名は172文字以下にする必要があり、空白文字やパス区切り文字(/ \)、引用符(’ “)を含めることはできません。また、プレフィックスを付けて他のプラグインとの競合を避けることが推奨されます。 – Everything You Need to Know About WordPress Transients
  4. $wpdb->prepare()を使用する際、WordPress 6.2以降ではテーブル名に%iプレースホルダーを使用することで、テーブル名を安全にエスケープできます。これによりSQLインジェクション耐性が向上します。 – wpdb::prepare() – Method | Developer.WordPress.org
  5. WP-Cronはページ訪問時に実行されるため、低トラフィックサイトでは実行頻度が不足し、高トラフィックサイトでは過剰実行によるサーバー負荷増大の問題があります。また、ページキャッシュを使用するとPHPが実行されず、WP-Cronが全く動作しない可能性があります。プロフェッショナルな運用では、デフォルトWP-Cronを無効化し、サーバーレベルの実Cronジョブを設定することが推奨されます。 – Fix WP-Cron Performance Issues in WordPress – ACF
  6. WordPressのアクションフックは優先度(priority)順に実行されます。数値が小さいほど早く実行され、同じ優先度の場合は登録順に実行されます。テーマやプラグインの依存関係を考慮する場合、適切な優先度設定が重要です。 – The Correct Way to Configure WordPress Cron – SpinupWP
  7. WordPress 6.7以降はPHP 7.4以上が必須で、PHP 8.3が完全サポート、PHP 8.4がベータサポートとなっています。PHP 7.0と7.1のサポートは終了しました。PHP 8.0以降では後方互換性のない変更が多数あるため、ステージング環境でのテストとPHPCompatibilityツールによる互換性チェックが推奨されます。 – PHP Compatibility and WordPress Versions – Make WordPress Core
  8. WordPressの設定保存では、チェックボックスはhidden inputとcheckboxの組み合わせで実装しないと、OFFの値が正しく保存されない場合があります。また、get_option()でデフォルト値とマージして読み込むことで、設定の堅牢性が向上します。 – How to Troubleshoot the WordPress Cron – Better Notifications for WP
  9. データベース最適化の効果測定には、Query MonitorプラグインやEXPLAIN文を使用した実行計画の確認が有効です。適切なインデックスの追加、クエリの簡素化、キャッシュの活用により、300〜900%のパフォーマンス向上が報告されています。 – SQL Query Optimization for Faster WordPress Sites
  10. Transientは期限切れ後も自動削除されず、データベースに残り続ける可能性があります。大量の期限切れTransientはデータベース肥大化を引き起こすため、WP-OptimizeやAdvanced Database Cleanerなどのプラグインで定期的にクリーンアップすることが推奨されます。 – The Art of the WordPress Transient: Performance, Persistence, and Database Bloat
  11. memcached等のオブジェクトキャッシュを使用する環境では、Transientはデータベースではなくメモリに保存されます。この場合、デプロイやメンテナンス時に全てのTransientが消去される可能性があるため、コードは常にキャッシュミスを正常動作として扱う必要があります。 – Understand and Use Transients in WordPress | Pressable