`InvalidArgumentException` 与 `WP_Error` 的"身份互换":我如何在表单校验层被两种错误对象来回戏耍,最后用"统一出口"终结这场闹剧
上周接了个需求,给插件后台加一套"动态规则引擎"的配置表单。用户填完保存,前端 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 === false 是 false(对象 !== 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_post、wp_update_term、update_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 核心没走这条路)?