`admin_init` 与 `wp_loaded` 之间那道"玻璃门":我是怎么用 `shutdown` 钩子做"临终遗言日志"的

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 76 浏览 1 回复

写插件久了,你会发现有些钩子像邻居家的猫——看着温顺,实际边界感极强。admin_initwp_loaded 就是典型,一个在后台门口晃悠,一个在前台后台都露面,但中间那道分界,我踩过不止一次。

先说个血案。去年做个数据同步插件,需要在 WordPress 完全跑起来之后、但页面还没渲染之前,抓一波当前用户的权限状态做预校验。我顺手挂在了 admin_init,本地测试丝滑上线,结果用户反馈偶尔拿到的是上一个人的权限缓存。

扒了半天,发现 admin_init 触发时,当前用户对象理论上已经就绪,但某些多站点环境下、或者用了对象缓存插件的场景,wp_get_current_user() 返回的居然是 WP_User 的空实例——ID 为 0,角色数组为空。不是每次都复现,是那种"一千次里漏三次"的幽灵 bug。

根源在哪?admin_init 的触发时机在 wp-admin/admin.php 里,确实在 require_wp_db()wp() 之后,但用户认证流程的某些边缘分支(比如从缓存恢复的 session)会延迟到更晚才真正落盘。而 wp_loaded 虽然更靠后,但它不分前台后台,你在里面写 admin-only 的逻辑,等于给前台每个请求都加了一层无谓的负重。

我现在的做法是分三级挂号:

// 第一级:只认证,不做事
add_action( 'set_current_user', function() {
    // 这里只标记"用户已确认",或者把 user_id 写进静态属性
    My_Plugin::note_user_ready( get_current_user_id() );
}, 20 ); // 注意优先级,某些插件会在 10 改用户

// 第二级:admin 专属,但用 wp_loaded 的后期阶段
add_action( 'wp_loaded', function() {
    if ( ! is_admin() ) return;
    // 真正的权限敏感操作放这里
    My_Plugin::run_permission_gate();
}, 20 ); // 默认 10,给自己留缓冲

// 第三级:"临终遗言",专门抓漏网之鱼
add_action( 'shutdown', function() {
    $errors = My_Plugin::get_deferred_errors();
    if ( empty( $errors ) ) return;
    
    // 写日志、发告警、或者给下一次请求埋个标记
    error_log( '[MyPlugin] Shutdown catch: ' . wp_json_encode( $errors ) );
} );

重点说 shutdown。这个钩子太容易被忽视了——它不管前面有没有 fatal error,只要 PHP 执行到了收尾阶段,它基本都会跑。我把它当成"临终遗言"收集器:前面逻辑里遇到非致命异常不敢直接抛的,先存进静态数组,shutdown 里统一处理。

shutdown 也有坑。如果你在它里面调用 wp_redirect, headers 可能已经发送完毕,直接白屏或者 "headers already sent"。我的 workaround 是:

add_action( 'shutdown', function() {
    if ( headers_sent() ) {
        // 退而求其次,用 JS 跳转或者只记日志
        echo '<script>location.href="' . esc_url( $fallback_url ) . '";</script>';
        return;
    }
    wp_safe_redirect( $fallback_url );
    exit; // shutdown 里 exit 是安全的,反正结束了
} );

调试这套生命周期的时候,我搭了个土办法"探照灯":在关键钩子入口插时间戳和内存占用,最后 shutdown 汇总输出。比 Xdebug 轻量,适合线上抓偶发问题:

class Hook_Profiler {
    private static $marks = [];
    
    public static function mark( $hook, $note = '' ) {
        self::$marks[] = [
            'hook'  => $hook,
            'time'  => microtime( true ),
            'mem'   => memory_get_usage(),
            'note'  => $note,
        ];
    }
}

// 用法:在感兴趣的钩子头部尾部各插一个
add_action( 'admin_init', function() {
    Hook_Profiler::mark( 'admin_init', 'start' );
}, PHP_INT_MIN );

add_action( 'admin_init', function() {
    Hook_Profiler::mark( 'admin_init', 'end' );
}, PHP_INT_MAX );

add_action( 'shutdown', function() {
    $report = array_map( function( $m ) {
        return sprintf( '[%s] %s @ %.4fs / %sKB',
            $m['hook'],
            $m['note'],
            $m['time'] - $_SERVER['REQUEST_TIME_FLOAT'],
            round( $m['mem'] / 1024 )
        );
    }, Hook_Profiler::$marks );
    
    error_log( implode( "\n", $report ) );
} );

有个细节:优先级 PHP_INT_MINPHP_INT_MAX 不是炫技,是为了确保你的探针真的挂在同钩子的最前和最后。WordPress 的 $wp_filter 是按优先级排序的,这两个极值能帮你框住整个钩子的执行窗口。

最后提一嘴 wp_die。很多教程教你用它做权限拦截,但 wp_die 内部会触发 wp_die_handler 过滤器,然后直接 die——你的 shutdown 钩子依然能跑,但如果 wp_die 是在某个嵌套钩子中间被调用的,外层钩子的后续回调会被截断,这个要心里有数。

你们有没有在 admin_initwp_loaded 之间踩过类似的"玻璃门"?或者 shutdown 还有什么更野的用法?

评论1
回复 · 1
风信子
风信子 超管 志愿先锋新站启章 · #1 ·
感谢分享!
微信客服 微信客服