从堆栈里一眼认出"凶手":我整理的插件开发高频 Exception 速查手册与"三段式"定位法

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

写插件最烦的不是报错,是报错信息跟实际犯错的地方八竿子打不着。上周有个兄弟在群里甩了张图,Uncaught Error: Call to a member function get_items() on null,他盯着那行代码看了两小时,其实问题出在二十行之外的一个提前 return。这种"借尸还魂"式的异常,我踩过太多次了,整理了一份自己用的速查+定位流程,分享出来。

一、先认"脸":六种我每周都能见到的 Exception 真身

下面这六种,覆盖了插件开发里大概七成的情况,我把它们分成"自己作的"和"环境坑的"两类:

【自己作的】

Fatal error: Uncaught Error: Class 'MyPlugin\SomeClass' not found

九成九是 autoloader 没注册对,或者命名空间写劈叉了。我现在的习惯:先 var_dump(class_exists('MyPlugin\SomeClass')),false 的话直接去查 composer.json 的 psr-4 映射或者手动 spl_autoload_register 的回调路径。

Fatal error: Cannot declare class MyPlugin\Admin, because the name is already in use

经典重复加载。插件里如果用了 require_once 但文件里又套了 class_exists 判断,很容易搞出这种"我以为我防了但其实没防住"的情况。我的做法:所有类文件顶部统一加 if ( class_exists( __NAMESPACE__ . '\Admin' ) ) { return; },比依赖 _once 后缀靠谱。

Warning: array_merge(): Expected parameter 2 to be an array, null given

过滤钩子回调里最常见的"空值炸弹"。比如 apply_filters( 'myplugin_settings', [] ) 结果某个回调 return 了个 null。我现在写过滤钩回调必做类型检查:return is_array( $settings ) ? array_merge( $settings, $mine ) : $mine;

【环境坑的】

Fatal error: Maximum execution time of 30 seconds exceeded

插件里批量处理数据时必遇。WordPress 的 set_time_limit() 在 safe mode 或某些主机商会失效,别信它。我的保底方案:拆队列,用 wp_schedule_single_event 或者 Action Scheduler,单次处理不超过 50 条。

Warning: Cannot modify header information - headers already sent

BOM 头、?> 后面的空行、echo 调试语句没删——这三剑客我全中过。最阴的是某些编辑器自动加的 BOM,hexdump -C file.php | head 一眼能看到 EF BB BF 开头。

wpdb::prepare was called incorrectly

这货不是 Exception 是 notice,但值得单拎出来。$wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $id ) 这样写,如果 $table 是变量拼接的,就会触发。正确姿势:$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mytable WHERE id = %d", $id ),表名用 {$wpdb->prefix} 硬拼,只把值交给 prepare

二、三段式定位:从报错行到真凶的"逆推链"

我现在的调试流程固定三步,不瞎猜:

第一段:看"第一帧"还是"最后一帧"

PHP 的堆栈是从下往上长的,Fatal error 那行是"爆炸点",但原因往往在更下面。比如:

Fatal error: Uncaught Error: Call to undefined function myplugin_do_thing()
in /wp-content/plugins/myplugin/includes/class-worker.php:42
Stack trace:
#0 /wp-content/plugins/myplugin/includes/class-admin.php(88): MyPlugin\Worker->run()
#1 /wp-includes/class-wp-hook.php(324): MyPlugin\Admin->process_request('')
#2 /wp-includes/plugin.php(205): WP_Hook->apply_filters('', Array)

爆炸点在 Worker::run(),但 myplugin_do_thing() 为啥没定义?看 #1Admin::process_request,八成是那个方法在不该触发的时候被钩子拉起来了——比如 admin_init 里没判断 $_REQUEST['page'],导致每次后台请求都跑一遍,某些场景下依赖的函数还没加载。

第二段:grep 找"谁最后碰过它"

如果是变量变成预期外的类型(null、false、WP_Error),直接搜赋值链:

grep -rn "my_var\s*=" --include="*.php" .

重点看有没有分支没覆盖到。我见过最离谱的是一个 switch 少了 default,某个新加的 post type 没匹配上,变量就裸奔出去了。

第三段:隔离复现,二分法砍代码

把插件目录复制一份改个名字, deactivate 原插件,启用副本。然后注释掉一半钩子,报错还在就再注释一半,直到定位到最小复现单元。这比加 var_dump 到处喷干净得多,尤其 production 环境只能改文件不能动配置的时候。

三、两个我定制的"防呆"小工具

1. 开发模式的"严格壳"

我在插件入口加了个开关,开发时强制把 warning 抬成 exception,不让它们默默溜走:

if ( defined( 'MYPLUGIN_DEV' ) && MYPLUGIN_DEV ) {
    set_error_handler( function( $severity, $message, $file, $line ) {
        if ( $severity & E_WARNING || $severity & E_NOTICE ) {
            throw new ErrorException( $message, 0, $severity, $file, $line );
        }
        return false;
    });
}

这样 Trying to access array offset on value of type null 这种 PHP 7.4+ 的 notice 会直接炸出来,而不是变成某个后续逻辑里的"随机数"。

2. 自定义 Exception 带上下文

插件里抛异常时,我把当时的用户角色、当前钩子、请求参数一起打包:

class MyPlugin_Exception extends Exception {
    public function __construct( $message, $context = [] ) {
        $this->context = $context;
        parent::__construct( $message );
    }
    
    public function get_log_entry() {
        return sprintf(
            '[%s] %s | hook: %s | user: %d | context: %s',
            current_time( 'mysql' ),
            $this->getMessage(),
            current_filter(),
            get_current_user_id(),
            wp_json_encode( $this->context )
        );
    }
}

配合 error_log( $e->get_log_entry() ),production 环境下也能快速还原现场,不用跟用户要账号密码去复现。

四、一个最近的真实案例

上周接手的报错:Uncaught TypeError: Argument 1 passed to MyPlugin\Exporter::output() must be of the type array, string given

堆栈指向 Exporter::output() 的形参类型声明。但传进来的字符串是啥?顺着调用链往上,是 get_option( 'myplugin_export_config' ) 的返回值。去数据库一看,这 option 的值是序列化后的字符串 'a:0:{}'——但 get_option 没反序列化成功,原样返回了。

根因?另一个开发者在 update_option 之前手贱做了 maybe_serialize,导致双重序列化。get_option 只反序列化一次,外层还是字符串。

这种"类型对不上"的报错,真凶

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