,\n re.MULTILINE | re.IGNORECASE\n)\n\ndef _strip_php_noise(text: str) -> str:\n \"\"\"\n 从 stdout 中移除 PHP Deprecated/Warning/Notice/Strict Standards 行。\n 注意:Parse error / Fatal error 不会被移除——那些是真正的错误。\n \"\"\"\n return _PHP_NOISE_LINE_RE.sub('', text)\n```\n\n这个正则匹配了四种噪声类别:\n- `Deprecated`:废弃警告,如我们遇到的动态属性问题\n- `Warning`:一般警告\n- `Notice`:通知\n- `Strict Standards`:严格标准警告\n\n**刻意省略了 `Parse error` 和 `Fatal error`**。为什么?因为这两类错误意味着操作实际上已经失败了——可能是语法错误、内存耗尽、或其它不可恢复的问题。如果把这些也去掉,用户会看到“操作成功”的假象,但实际上数据是空的或错误的。这比“操作失败”更糟糕。\n\n### 第三层:解析兜底——即使退出码非零也尝试 JSON 解析\n\n前两层已经能覆盖 99% 的情况了,但总有一些奇葩主机让你防不胜防。比如:\n\n- 主机配置导致 `exit code` 为 1(非零)只是因为警告,但 stdout 里实际上有合法的 JSON\n- 我们用的 Fabric(paramiko)库会把 `exit code != 0` 视为 `res.ok = False`\n\n在这种情况下,如果直接检查 `res.ok` 然后跳过解析,就会错过那些实际上可用的数据。\n\n所以我们的策略是:**先尝试 JSON 解析,再检查退出码**:\n\n```python\nstdout_clean = _strip_php_noise(res.stdout or '').strip()\nplugins = None\n\nif stdout_clean:\n try:\n plugins = json.loads(stdout_clean)\n except json.JSONDecodeError:\n plugins = None\n\nif plugins is None:\n # 到这里才确认“没有 JSON 数据”——回退到错误处理\n if not res.ok:\n return error_response(res.stderr or res.stdout)\n```\n\n这个逻辑的核心是:\n1. 先清洗 stdout\n2. 尝试解析 JSON\n3. 如果解析成功,**不管退出码是多少**,都视为成功\n4. 只有解析失败时,才去检查退出码,决定是否报错\n\n这个顺序很重要。它把“数据可用性”放在了“命令执行状态”之前——因为对我们这个场景来说,只要 JSON 数据是完整的、可解析的,命令是否“理论上”成功并不重要。\n\n## 一次性修复所有调用点\n\n在代码中找到同一个模式的所有实例,然后一次性全部修复。这个原则来自我们之前处理 csh 可移植性 bug 时的教训——只修一个地方,其他地方迟早会冒出来咬你。\n\n在我们的代码库中,插件列表抓取功能存在于三个调用点:\n\n1. **`/api/fetch_plugins`**:跨站点插件仪表盘,用于在管理界面上展示所有站点的插件状态\n2. **`/api/site_plugins`**:单站点插件列表弹窗,查看某个站点的详细信息\n3. **`_do_fetch_pending_plugins_for_site`**:维护时段的待更新插件扫描,用于自动化更新提醒\n\n这三个地方原来都是直接调用 `c.run('wp plugin list --format=json')` 然后 `json.loads(res.stdout)`。我们把这三种调用全部改成了使用 `_wp_with_quiet_php` + `_strip_php_noise` + JSON-first 解析的组合模式。\n\n如果只修了第一个调用点,那么用户可能在仪表盘上看到正常数据,但在单站点弹窗里仍然报错——这种不一致性比全面报错更让人困惑。\n\n## 回归测试:18 个用例守住防线\n\n为了确保这次修复不会在未来被意外破坏,我们写了一个专门的测试文件 `tests/test_wp_cli_php_noise.py`,包含 18 个测试用例:\n\n- **噪声行移除测试**:验证 `_strip_php_noise` 能正确移除各种格式的 Deprecated/Warning/Notice/Strict Standards 行\n- **Parse error 保留测试**:确保 `Parse error` 和 `Fatal error` 不会被误删\n- **环境变量格式化测试**:验证 `_wp_with_quiet_php` 生成的 shell 命令格式正确\n- **shlex 转义测试**:确保特殊字符被正确处理\n- **WP-CLI 路径覆盖兼容性测试**:验证当用户指定自定义 wp 路径时,环境变量仍然被正确设置\n- **三个 API 端点存在性检查**:确保三个调用点都已被修改(通过检查代码中是否还有直接调用 `json.loads` 处理 WP-CLI 输出的情况)\n\n如果将来有人添加了第四个 API 端点,直接对 `c.run` 的原始输出做 `json.loads`,CI 就会失败。这就像给代码库装了一个“烟雾报警器”。\n\n## 总结:三个值得记住的原则\n\n这次折腾下来,我总结了三个原则,觉得以后遇到类似问题都能用上:\n\n### 1. “诊断全绿,生产全红” 这种不对称性很容易产生\n\n大多数诊断测试只检查两样东西:**命令是否执行成功(退出码)** 和 **输出里是否包含期望的子串**。但结构化输出(JSON、XML、CSV)的解析步骤对噪声极其敏感——一个多余的字符就能让整个解析失败。\n\n**建议**:在诊断测试里也加一个结构化输出的解析步骤。比如,如果你最终要解析 JSON,那就在诊断时也执行一次 JSON 解析,而不是只看子串。这样能提前暴露这类问题。\n\n### 2. 噪声抑制要分层,而且每层要独立\n\n- **源头抑制**(`error_reporting`):拦截最彻底,但可能被宿主配置覆盖\n- **输出过滤**(正则剥离):即使源头没拦住,也能在解析前清理\n- **解析兜底**(先 JSON 后检查退出码):即使前两层都失效,还能抢救有效数据\n\n这三层是独立的防御。一个宿主配置的怪癖可能绕过第一层,但第二层或第三层会兜住。**你无法预测所有宿主配置,所以分层才是答案。**\n\n### 3. 用正则区分“噪声”和“真正的失败”\n\n不是所有错误输出都应该被隐藏。我们用了一个枚举式的正则:\n- **可以安全移除的**:`Deprecated`、`Warning`、`Notice`、`Strict Standards`\n- **必须保留的**:`Parse error`、`Fatal error`\n\n“把所有警告都隐藏掉”这种做法太粗暴了——它会掩盖真正的失败。精确枚举 + 紧致的正则表达式才能画好这条线。\n\n## 写在最后\n\n如果你也在维护一个需要远程调用 PHP/WP-CLI 的工具,类似的问题很可能就潜伏在你的代码里。`error_reporting 抑制 + 噪声行剥离 + JSON 优先解析` 这个模式是一个可复用的模板,值得放进你的工具箱。\n\n下次遇到“诊断全绿但操作失败”的 bug,先别急着检查 SSH 连接或 WP-CLI 路径——看看 stdout 里是不是混进了不该有的东西。有时候,问题就藏在那些你习以为常的“小警告”里。","is_owner":false,"date":"2026/7/4","category":"编程开发"}