こんにちは、システム開発チームです。
WordPressで投稿保存時に特定の処理を実行する処理を構築していたところ、「処理が2回実行される」という現象に遭遇しました。
最初は「コードのどこかでミスったかな?」と思ったのですが、調べてみるとWordPressのsave_postフックの仕様が原因でした。今回はその原因とトランジェントAPIを使った解決方法を共有します。
実装していたのは、投稿保存時にログを記録して外部APIにデータを送信する処理でした
function custom_post_save_handler( $post_ID, $post, $update ) {
// ログを記録
write_log( $post_ID, 'Post saved' );
// 外部APIにデータを送信
send_to_external_api( $post_ID, $post );
}
add_action( 'save_post', 'custom_post_save_handler', 10, 3 );
実装後、いざ固定ページを更新したら処理が2回走ってることが判明。。。
色々調べた結果、save_postフックって1回の保存操作で複数回発火することがあるんですね。
主に以下の3つのパターンがあります
1. メタデータの更新で再帰的に呼ばれる
カスタムフィールドとかのメタデータを保存する時、WordPress内部でwp_update_post()が呼ばれて、それがまたsave_postをトリガーするみたいな状態に。
2. 投稿ステータスが変わる時
下書き→公開みたいなステータス変更の時も、複数回発火することがあります。
3. リビジョンの作成
リビジョン作成時にも発火しますが、これはwp_is_post_revision()でチェックできるので対処しやすい。
処理の流れを追跡したら、このような感じで2回実行されてました
ユーザーが「更新」ボタンをクリック
↓
save_post フック発火(1回目)
→ 処理実行(ログ記録、API送信)
↓
カスタムフィールドの保存処理が走る
↓
内部で wp_update_post() が実行される
↓
save_post フック発火(2回目)← ここで重複!
→ 処理再実行
いくつか対処法を検討してみました。
global $processed_posts;
if ( in_array( $post_ID, $processed_posts ) ) {
return;
}
$processed_posts[] = $post_ID;
ダメな理由: リクエスト単位でしか有効ではないので、Ajaxとかで動かすと意味なくなる可能性がある。
function my_function( $post_ID ) {
remove_action( 'save_post', 'my_function', 10 );
// 処理
}
add_action( 'save_post', 'my_function', 10 );
ダメな理由: 他のプラグインとかからdo_action('save_post')が実行されたら対応できない。トリッキーすぎる。
if ( did_action( 'save_post' ) > 1 ) {
return;
}
ダメな理由: 複数の投稿を連続で保存すると、2つ目以降が全部スキップされてしまう(致命的)。
最終的に、WordPressのトランジェントAPIを使う方法に落ち着きました。
理由としてはシンプルですが
トランジェントは、WordPress標準の有効期限付き一時データ保存機能です。要は「時限式のキャッシュ」みたいなものですね。
// データを保存(10秒間有効)
set_transient( 'my_key', 'my_value', 10 );
// データを取得(ないか期限切れならfalse)
$value = get_transient( 'my_key' );
// 削除(期限前に消したい時)
delete_transient( 'my_key' );
(シンプルで使いやすい)
最終的なコードがこちらです
function custom_post_save_handler( $post_ID, $post, $update ) {
// リビジョンと下書きは除外
if ( wp_is_post_revision($post_ID) ||
in_array($post->post_status, ['auto-draft', 'draft'], true) ) {
return;
}
// 投稿IDごとにユニークなキーを作る
$transient_key = 'post_processed_' . $post_ID;
// 既に処理済みならスキップ
if ( get_transient( $transient_key ) ) {
return;
}
// 処理済みフラグを10秒間セット
set_transient( $transient_key, true, 10 );
// 実際の処理
write_log( $post_ID, 'Post saved' );
send_to_external_api( $post_ID, $post );
}
add_action( 'save_post', 'custom_post_save_handler', 10, 3 );
1. ユニークなキーを作る
$transient_key = 'post_processed_' . $post_ID;
投稿IDを含めることで、複数の投稿を同時編集した場合でも問題ありません。
2. 処理済みかチェック
if ( get_transient( $transient_key ) ) {
return;
}
トランジェントがあれば(= 10秒以内に処理済み)、即座に処理を中断します。
3. フラグを立てる
set_transient( $transient_key, true, 10 );
実際の処理を実行する前にフラグを立てるのがポイント。この後にsave_postが再発火しても、上のチェックで弾けます。
トランジェントはwp_optionsテーブルにこのような感じで保存されます
-- 値
option_name: _transient_post_processed_123
option_value: 1
-- 有効期限(UNIXタイムスタンプ)
option_name: _transient_timeout_post_processed_123
option_value: 1699350010
期限が来るとget_transient()の時に自動削除されますし、WordPressのクリーンアップ処理でも掃除されるので、ゴミが溜まる心配はありません。
重複防止だけではなくて、色々な場面で活躍します。
$cache_key = 'api_response_' . $user_id;
$response = get_transient( $cache_key );
if ( false === $response ) {
$response = fetch_from_api( $user_id );
set_transient( $cache_key, $response, HOUR_IN_SECONDS );
}
return $response;
$limit_key = 'api_call_' . $user_id;
if ( get_transient( $limit_key ) ) {
return new WP_Error( 'rate_limit', 'ちょっと待ってね' );
}
set_transient( $limit_key, true, MINUTE_IN_SECONDS );
// API処理
$cache_key = 'statistics_' . date('Y-m-d');
$stats = get_transient( $cache_key );
if ( false === $stats ) {
$stats = calculate_heavy_statistics();
set_transient( $cache_key, $stats, DAY_IN_SECONDS );
}
return $stats;
WordPressのsave_postフックは便利ですが、複数回発火する仕様があるので注意が必要です。
save_postは1回の保存で複数回トリガーされることがある同じ問題で困ってる人の参考になれば嬉しいです!