钩子回调里抛出异常后,整个页面崩了但日志只记到 `shutdown`:我整理的 WordPress 异常传播"断点地图"与兜底捕获方案

插件开发 23 浏览 0 回复 返回上级

上周改一个支付回调插件,第三方支付通知过来时,我在 `woocommerce_payment_complete` 里抛了个 `RuntimeException` 想中断异常订单。结果前台返回 500,但 `debug.log` 里只有一条 PHP Fatal error: Uncaught RuntimeException...,后面跟着 WordPress 的 `shutdown` 动作栈,真正的业务上下文全丢了。更坑的是,异常没被我的 `try-catch` 包住——明明写在回调第一行。

WordPress 的钩子机制对异常并不友好。核心代码里大量使用 call_user_func_arraydo_action,异常一旦在某个钩子里抛出,会直接把执行流甩出 WordPress 的"轨道",导致三种典型症状:

症状一:异常被"吞"在嵌套钩子里

如果你的回调里又触发了另一个 do_action,而那个动作里抛了异常,错误栈会断在第二层,第一层你写的日志可能根本刷不到磁盘。我踩的坑是支付完成钩子 → 触发积分发放钩子 → 积分钩子调了外部 API 超时 → Exception 飞出来,但日志里只显示积分钩子那层的栈。

症状二:set_error_handler 和异常处理器"打架"

很多插件为了兼容老 PHP,会注册自己的错误处理器。WordPress 本身在 wp-includes/load.php 里也有 wp_debug_mode() 的设置。当异常穿过这些边界时,哪个处理器先接到、会不会转码成 E_ERROR,完全看加载顺序。我见过最诡异的情况是:异常被转成致命错误后,error_get_last() 里类型是 E_ERROR,但 Exception 对象已经没了,想抓具体消息没门。

症状三:REST 请求和 AJAX 的"静默死亡"

后台 AJAX 走 admin-ajax.php,REST 走 rest_api.php,这两个入口的异常处理逻辑不一样。AJAX 里异常如果没被截,直接 500,前端 jQuery.ajaxerror 回调,但响应体可能是 HTML 错误页;REST 接口从 WP 4.7 开始有 rest_handle_server_error,但只兜 WP_Error,你抛的 Exception 照样崩。

我现在的做法是在插件入口文件里加一个"地网"层,不依赖 WordPress 的钩子顺序:

// 插件主文件顶部,任何 hook 注册之前
add_action( 'plugins_loaded', function() {
    set_exception_handler( function( $e ) {
        // 先写一条结构化的,防止后面再崩
        error_log( sprintf(
            '[%s] %s | File: %s:%d | TraceHash: %s',
            get_class( $e ),
            $e->getMessage(),
            $e->getFile(),
            $e->getLine(),
            md5( $e->getTraceAsString() )
        ) );

        // 如果是已知业务异常,尝试给前端体面响应
        if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
            wp_send_json( [
                'code'    => 'internal_exception',
                'message' => wp_get_environment_type() === 'production' 
                    ? 'Server error' 
                    : $e->getMessage(),
                'trace_hash' => md5( $e->getTraceAsString() ),
            ], 500 );
        }

        // 别拦真正的致命错误,交给 WordPress 或 Sentry 之类的
        if ( $e instanceof Error ) {
            throw $e; // 重新抛出,保持原有行为
        }

        // 业务 Exception 已记录,优雅结束
        status_header( 500 );
        exit;
    } );
}, PHP_INT_MIN ); // 确保最早注册

几个实战细节:

1. shutdown 动作里补刀

异常处理器之后,WordPress 还会跑 shutdown。我在那再加一层保险,专门抓没被截住的致命错误,和异常记录做关联:

add_action( 'shutdown', function() {
    $error = error_get_last();
    if ( $error && in_array( $error['type'], [ E_ERROR, E_PARSE, E_COMPILE_ERROR ], true ) ) {
        // 看看是不是刚才那个异常的"遗骸"
        $trace_hash = md5( $error['message'] . $error['file'] . $error['line'] );
        // 可以在这里发告警,或者写一条更完整的日志
    }
} );

2. 给每个异步入口贴"标签"

AJAX、REST、Webhook、Cron 这几种场景,异常的处理策略完全不同。我在插件里定义了几个常量入口标识,异常处理器里根据标识决定是重试、告警还是直接忽略:

// webhook 入口文件顶部
define( 'MYPLUGIN_WEBHOOK_CONTEXT', 'stripe_webhook' );

// 异常处理器里
$context = defined( 'MYPLUGIN_WEBHOOK_CONTEXT' ) 
    ? MYPLUGIN_WEBHOOK_CONTEXT 
    : 'general';

if ( $context === 'stripe_webhook' ) {
    // Stripe 要求返回 2xx,否则重试。不能 500。
    http_response_code( 200 );
    echo json_encode( [ 'received' => true, 'processed' => false ] );
    exit;
}

3. 开发阶段用"异常快照"替代 var_dump

以前钩子里出问题就 var_dump 然后 die,但 WordPress 的钩子是树状结构,die 会断掉后面所有清理动作。现在我在开发环境注册一个"软异常"机制:

function myplugin_soft_throw( Exception $e ) {
    do_action( 'myplugin_captured_exception', $e );
    // 继续执行,不中断流程,但异常对象被收集到全局
    $GLOBALS['myplugin_soft_exceptions'][] = $e;
}

页面底部再用 admin bar 或者 footer 钩子把收集到的异常打印出来,既不断流,又能看到完整上下文。

最后说一个反直觉的点:register_shutdown_function 和 WordPress 的 shutdown 动作执行顺序不确定。如果你的异常处理器里用了 exitshutdown 可能不触发;如果不用 exit,执行流可能继续跑到下一个钩子,产生更诡异的状态。我目前的妥协是:业务 Exception 优雅结束,Error 重新抛出让原生机制处理,两边都不耽误。

你们是怎么处理钩子里的异常的?有没有遇到过异常被某个主题或者另一个插件的处理器"截胡"的情况?

评论0
回复 · 0
还没有回复
微信客服 微信客服