`InvalidArgumentException` 与 `WP_Error` 的"身份互换":我如何在表单校验层被两种错误对象来回戏耍,最后用"统一出口"终结这场闹剧

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

上周接了个需求,给插件后台加一套"动态规则引擎"的配置表单。用户填完保存,前端 AJAX 返回 200,但数据死活写不进去。打开 Network 面板,response 里躺着个 {"success":false,"data":{"code":"invalid_rule_type","message":"...","data":null}}——这他妈是 WP_Error 的 JSON 形态,可我 catch 的是 InvalidArgumentException 啊?

问题出在三层校验的"对象漂移":

第一层:模型层的强类型守卫

// class RuleEngine/Validator.php
public function setType(string $type): void {
    $allowed = ['taxonomy', 'meta', 'date_range'];
    if (!in_array($type, $allowed, true)) {
        throw new InvalidArgumentException(
            sprintf('Rule type "%s" not in whitelist: %s', $type, implode(', ', $allowed))
        );
    }
    $this->type = $type;
}

这里抛的是原生 Exception,我预期在 Controller 里 catch 住,包装成标准响应。

第二层:WordPress 惯性的 `WP_Error` 渗透

但团队里另一个老哥写的权限预检, deep 进了 WordPress 的"方言区":

// 某处中间件,历史遗留
function validate_rule_context($context) {
    if (!isset($context['post_type'])) {
        return new WP_Error('missing_context', 'Context requires post_type');
    }
    // ... 其他检查
    return true;  // ← 注意:成功返回 true,失败返回对象
}

这函数的返回值类型是 bool|WP_Error——典型的 WordPress "混合双打"。调用方如果没做 is_wp_error() 判断,直接把返回值当 bool 用,逻辑能跑;但一旦出错,WP_Error 就会像特洛伊木马一样混进后续流程。

第三层:AJAX 出口的统一幻觉

我的 Controller 长这样,当时还觉得挺优雅:

public function save_rule() {
    try {
        $this->check_ajax_referer();
        $input = $this->sanitize_input($_POST);
        
        $validator = new Validator();
        $validator->setType($input['type']);  // 可能抛 InvalidArgumentException
        
        $context_ok = validate_rule_context($input['context']);  // 可能返 WP_Error
        if ($context_ok === false) {  // ← 致命疏忽:没处理 WP_Error 对象
            throw new RuntimeException('Context validation failed');
        }
        
        $this->repository->save($validator->toArray());
        wp_send_json_success();
        
    } catch (Exception $e) {  // ← 抓不到 WP_Error,它不是 Exception 的子类
        wp_send_json_error([
            'code'    => 'save_failed',
            'message' => $e->getMessage()
        ]);
    }
}

看出坑了吗?validate_rule_context 返回 WP_Error 时,$context_ok === falsefalse(对象 !== false),所以不会进 if 分支。WP_Error 被当成 truthy 值继续往下传,直到某个地方尝试把它当数组用,或者序列化入库时炸掉——但更多时候它会被静默忽略,因为 WP_Error 实现了 __toString(),在某些弱类型场景里能蒙混过关。

最骚的是,如果 WP_Error 一路活到 wp_send_json_success,它会被 wp_json_encode() 转成那个我开头看到的 JSON 结构,但 HTTP status 还是 200。前端拿到 success: false 以为是自己人,但我的 catch 块完全没触发,日志里干干净净。

我的"统一出口"改造

不想动太多历史代码,也不想强迫所有人改抛 Exception(WordPress 生态里 WP_Error 太根深蒂固)。最后搞了个"海关检查":

// class Http/ResponseFactory.php
public static function fromMixed($result, string $default_message = 'Operation failed'): void {
    if ($result instanceof WP_Error) {
        wp_send_json_error([
            'code'    => $result->get_error_code(),
            'message' => $result->get_error_message(),
            'data'    => $result->get_error_data(),
        ]);
        exit;  // wp_send_json_* 其实会 die,但显式写清楚防重构时漏掉
    }
    
    if ($result instanceof Exception) {
        wp_send_json_error([
            'code'    => 'exception_' . get_class($result),
            'message' => $result->getMessage(),
            'trace'   => defined('WP_DEBUG') && WP_DEBUG ? $result->getTraceAsString() : null,
        ]);
        exit;
    }
    
    if ($result === true || $result === null) {
        wp_send_json_success();
    }
    
    wp_send_json_success($result);
}

然后 Controller 里强制走这个出口,中间层爱抛啥抛啥:

public function save_rule() {
    try {
        $this->check_ajax_referer();
        $input = $this->sanitize_input($_POST);
        
        $validator = new Validator();
        $validator->setType($input['type']);
        
        $context_ok = validate_rule_context($input['context']);
        if (is_wp_error($context_ok)) {
            return ResponseFactory::fromMixed($context_ok);
        }
        
        $save_result = $this->repository->save($validator->toArray());
        // repository 也可能返 WP_Error(比如数据库唯一约束冲突)
        
        return ResponseFactory::fromMixed($save_result);
        
    } catch (Exception $e) {
        return ResponseFactory::fromMixed($e);
    }
}

关键改动:中间层结果不做布尔假设,一律 is_wp_error() 过检;异常和错误对象在最后一公里统一翻译。还加了个小工具函数防手滑:

function maybe_wp_error($thing, string $fallback_code = 'unknown_error'): ?WP_Error {
    if ($thing instanceof WP_Error) {
        return $thing;
    }
    if ($thing === false) {
        return new WP_Error($fallback_code, 'Operation returned false without explanation');
    }
    return null;
}

现在 false 这种"沉默失败"也会被显式包装,不会再有漏网之鱼。

踩坑后的"气味模式"总结

这次折腾让我对两种报错对象的"混用灾区"特别敏感:

1. WordPress 原生函数的返回值wp_insert_postwp_update_termupdate_option(某些场景)都是 int|false|WP_Error 三态,不能简单 if (!$result)

2. 团队代码的"风格断层":老 WordPress 开发者习惯 WP_Error,新写 PHP 的偏向 Exception,交接处最容易爆

3. AJAX 接口的"假 200"wp_send_json_error 不改 HTTP status(除非手动传 400/500),前端如果只看 status 不看 success 字段,会完美错过错误

你们项目里是怎么处理 WP_Error 和 Exception 的"双语环境"的?是彻底统一成一种,还是也搞了类似的"翻译层"?有没有更狠的招,比如让 WP_Error 实现 Throwable(PHP 7+ 其实可以,但 WordPress 核心没走这条路)?

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