本文从 Obsidian 撰写发布
# Typecho Restful 插件 + Obsidian 对接排错全记录
> 记录一次 "同样的插件、同样的 PHP 版本,一个站点能用,另一个站点不能用" 的完整排查过程。
> 最终根因:**PHP 8.1 的 Deprecated 警告污染了 JSON 响应体,导致客户端 `JSON.parse` 失败**。
---
## 一、问题现象
- 新站(Typecho 1.2/1.3 + PHP 8.2 + Restful 插件)对接 Obsidian 失败
- Obsidian 报错:
```
Request failed: /userList
SyntaxError: Unexpected token '<', "<br />
<b>"... is not valid JSON
at JSON.parse (<anonymous>)
```
- 老站用**一模一样的插件文件**、**同样的 PHP 版本**,Obsidian 能正常使用
---
## 二、初始错误日志
```
2026/10/07 09:51:08 [error] 5855#0: *1773 FastCGI sent in stderr:
"PHP message: PHP Deprecated: str_replace(): Passing null to parameter #3 ($subject)
of type array|string is deprecated
in /www/wwwroot/linjianziran.com/usr/plugins/Restful/Action.php on line 124"
while reading response header from upstream,
client: 182.247.151.182, server: www.linjianziran.com,
request: "GET /index.php/api/userList HTTP/2.0",
upstream: "fastcgi://unix:/tmp/php-cgi-82.sock:", host: "www.linjianziran.com"
```
关键信息:
- **PHP 8.2**
- **`str_replace()` 第 3 个参数 `$subject` 收到了 `null`**
- 位置:`Action.php` **第 124 行**
- 请求:`GET /index.php/api/userList`
---
## 三、逐步排查过程
### 步骤 1:确认服务端是否收到请求
```bash
curl -i "https://www.linjianziran.com/index.php/api/userList"
```
返回:
```
HTTP/2 400
access-control-allow-credentials: true
<br />
<b>Deprecated</b>: str_replace(): Passing null to parameter #3 ($subject) ... on line 124<br />
{"status":"error","message":"\u975e\u6cd5\u8bf7\u6c42\uff01","data":null}
```
分析:
- **HTTP 400** 而不是 404 → 路由注册成功
- 返回 JSON → Action 类被正确调用
- **`非法请求!`** → 触发了 `sendCORS()` 开头的 Origin 检查
### 步骤 2:定位 "非法请求!" 的来源
在 `Action.php` 里搜索 `非法请求`,只出现在 `sendCORS()`:
```php
$httpOrigin = $this->request->getServer('HTTP_ORIGIN');
if (!$httpOrigin) {
$this->throwError('非法请求!'); // ← 卡在这里
}
```
原因:**curl 默认不带 `Origin` 头**。
### 步骤 3:带上 Origin 再测
```bash
curl -i -H "Origin: app://obsidian.md" \
"https://www.linjianziran.com/index.php/api/userList"
```
返回:
```
HTTP/2 403
<br />
<b>Deprecated</b>: str_replace(): Passing null to parameter #3 ($subject) ... on line 124<br />
{"status":"error","message":"apiToken is invalid","data":null}
```
分析:
- **CORS 已过**(不再报"非法请求!")
- 新问题:**`apiToken is invalid`** → 请求没带 token
### 步骤 4:带上正确 token 再测
从 Typecho 后台 Restful 插件设置里查到 `apiToken` 是 `123456a`:
```bash
curl -i -H "Origin: app://obsidian.md" \
-H "token: 123456a" \
"https://www.linjianziran.com/index.php/api/userList"
```
返回:
```
HTTP/2 200
access-control-allow-credentials: true
<br />
<b>Deprecated</b>: str_replace(): Passing null to parameter #3 ($subject) ... on line 124<br />
{"status":"success","message":"","data":[{"uid":1,"mail":"...","screenName":"xige"}]}
```
分析:
- **HTTP 200,数据正确返回** → 服务端所有校验都过了
- **但响应体前面仍然挂着 HTML 警告** → 这就是 Obsidian 解析失败的根因
---
## 四、根因分析
### 1. 触发点:Action.php 第 124 行
```php
foreach ($reflectClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectMethod) {
$methodName = $reflectMethod->getName();
preg_match('/(.*)Action$/', $methodName, $matches);
if (isset($matches[1])) {
$routes[] = array(
'action' => $matches[0],
'name' => 'rest_' . $matches[1],
'shortName' => $matches[1],
'uri' => $prefix . $matches[1],
'description' => trim(str_replace(
array('/', '*'),
'',
substr($reflectMethod->getDocComment(), 0, strpos($reflectMethod->getDocComment(), '@'))
)),
);
}
}
```
问题代码:
```php
substr($reflectMethod->getDocComment(), 0, strpos($reflectMethod->getDocComment(), '@'))
```
- 如果某个 `xxxAction()` 方法**没有 docComment** → `getDocComment()` 返回 `false`
- 如果注释里**没有 `@`** → `strpos()` 返回 `false`
- `substr($doc, 0, false)` 在 PHP 8.1+ 会**产生 Deprecated 警告**
- 这个警告被**输出到 HTTP 响应体**,插在 JSON 前面
### 2. 为什么警告会污染 JSON
- PHP 的 `display_errors = On` 时,**警告会直接输出到响应体**,而不是只写日志
- Restful 插件的 `throwJson()` 只是 `echo json_encode(...)`,**不会清理之前的输出**
- 于是响应体变成:
```
<br />
<b>Deprecated</b>: str_replace(): ...<br />
{"status":"success","data":[...]}
```
- 任何调用 `JSON.parse()` 的客户端(浏览器 fetch、Obsidian、curl + jq)都会**因为第一个字符是 `<` 而失败**:
```
SyntaxError: Unexpected token '<', "<br />
<b>"... is not valid JSON
```
### 3. 为什么老站能用,新站不能用
| 项目 | 老站 | 新站 |
|------|------|------|
| Action.php 第 124 行 bug | 有(但之前修过或关了 display_errors) | 有 |
| PHP 版本 | 8.2 | 8.2 |
| **`display_errors`** | **Off** | **On** |
| 警告去向 | 写入日志文件 | **输出到响应体** |
| JSON 是否干净 | ✅ 干净 | ❌ 被污染 |
| Obsidian 能否解析 | ✅ 能 | ❌ 不能 |
**同样的代码 + 同样的 PHP 版本,`display_errors` 不同,行为完全不同。**
### 4. 为什么触发它的方法和请求无关
- `getRoutes()` 会**反射遍历所有 `xxxAction()` 方法**
- 只要**任何一个方法**没写完整注释(比如 `postArticleAction` 之类后加的方法)
- **所有 API 请求**都会触发警告
**请求 `userList`,却被 `postArticleAction` 的注释缺失干掉了。**
---
## 五、修复方案
### 方案 A:修改 Action.php(推荐,治本)
打开 `usr/plugins/Restful/Action.php`,找到 `getRoutes()` 方法,把循环体整段替换:
**原来代码**:
```php
foreach ($reflectClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectMethod) {
$methodName = $reflectMethod->getName();
preg_match('/(.*)Action$/', $methodName, $matches);
if (isset($matches[1])) {
$routes[] = array(
'action' => $matches[0],
'name' => 'rest_' . $matches[1],
'shortName' => $matches[1],
'uri' => $prefix . $matches[1],
'description' => trim(str_replace(
array('/', '*'),
'',
substr($reflectMethod->getDocComment(), 0, strpos($reflectMethod->getDocComment(), '@'))
)),
);
}
}
```
**修复后**:
```php
foreach ($reflectClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectMethod) {
$methodName = $reflectMethod->getName();
preg_match('/(.*)Action$/', $methodName, $matches);
if (isset($matches[1])) {
$doc = $reflectMethod->getDocComment() ?: '';
$pos = strpos($doc, '@');
$desc = ($pos === false) ? $doc : substr($doc, 0, $pos);
$routes[] = array(
'action' => $matches[0],
'name' => 'rest_' . $matches[1],
'shortName' => $matches[1],
'uri' => $prefix . $matches[1],
'description' => trim(str_replace(array('/', '*'), '', $desc)),
);
}
}
```
改动点:
1. `getDocComment() ?: ''` —— 保证不是 `false`
2. `$pos === false` 显式判断 —— 避免 `substr($doc, 0, false)`
3. 让 `str_replace` 第三个参数永远是字符串
保存即可,**不需要重启 PHP-FPM**。
### 方案 B:关闭 display_errors(治标)
```ini
; /www/server/php/82/etc/php.ini
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
```
```bash
systemctl restart php-fpm-82
```
或者宝塔面板:软件商店 → PHP 8.2 → 设置 → 配置修改 → `display_errors = Off` → 保存 → 重载 PHP。
**不推荐**:会把其他真实错误也藏起来,且 PHP 9 会让这类 warning 变成致命错误。
### 方案 C:给所有 Action 方法写完整注释(辅助)
```php
/**
* 获取用户列表
* @return void
*/
public function userListAction() { ... }
```
有 `/**` 和 `@`,`getDocComment()` 和 `strpos` 就不会返回 `false`,也就不会触发警告。
---
## 六、验证修复
改完代码后:
```bash
curl -i -H "Origin: app://obsidian.md" \
-H "token: 123456a" \
"https://www.linjianziran.com/index.php/api/userList"
```
**期望返回**(`{` 之前无任何 HTML):
```
HTTP/2 200
access-control-allow-credentials: true
{"status":"success","message":"","data":[{"uid":1,"mail":"...","screenName":"xige"}]}
```
响应体以 `{"status"` 开头,无 `<br />` `<b>Deprecated</b>`,即修复成功。
---
## 七、Obsidian 侧配置
| 配置项 | 值 |
|--------|-----|
| **Host** | `https://你的域名/index.php/api`(**必须带 `/index.php/api`,末尾不能有斜杠**) |
| **Token** | 与 Typecho 后台 Restful 插件设置里的 `apiToken` **完全一致** |
| **User** | 网络通畅后,下拉框自动加载 |
> 若站点配了 Nginx 伪静态,也可以用 `https://你的域名/api`,但要确认 rewrite 规则已生效。
---
## 八、经验总结
### 1. Deprecated 警告 ≠ 无害
```
Deprecated 警告 → 输出到响应体 → JSON 污染 → JSON.parse 失败 → API 完全不可用
```
警告本身不致命,但**污染响应体**会击穿下游所有严格解析客户端。
### 2. `display_errors` 是"同样代码不同行为"的元凶
| `display_errors` | 警告去向 | 影响 |
|------------------|----------|------|
| `On` | HTTP 响应体 | 污染 JSON,客户端崩溃 |
| `Off` | error_log 文件 | JSON 干净,客户端正常 |
**生产环境务必 `display_errors = Off`。**
### 3. 反射遍历的坑
`getRoutes()` 用反射扫描所有 `xxxAction()` 方法,**任何一个方法注释缺失,所有 API 请求都会报错**。触发点和请求目标无关,很难猜。
**所有被反射的方法都应写完整 docComment。**
### 4. `substr($x, 0, false)` 是 PHP 经典坑
- PHP 7:静默处理
- PHP 8.0:类型检查
- **PHP 8.1:Deprecated 警告**
- PHP 9:**将直接抛 TypeError**
嵌套使用 `substr` / `strpos` / `str_replace` 时,务必处理 `false` 返回值。
### 5. 请求先分层验证
排错时不要一上来就"改配置",按顺序验证:
```
1. 服务端是否收到请求(curl 基础测试)
2. 路由是否注册(HTTP 400 ≠ 404)
3. CORS 是否通过(带 Origin 头)
4. Token 是否匹配(带 token 头)
5. 响应体是否合法(响应开头必须是 '{' 或 '[')
6. 客户端解析是否成功(Obsidian 报错具体行号)
```
每一层单独定位,比"猜配置"高效得多。
### 6. 生产环境 API 加防御
在输出 JSON 之前,清掉之前的所有输出:
```php
if (ob_get_length()) {
ob_clean();
}
```
或在插件入口开 `ob_start()`,退出前 `ob_end_clean()` 再输出。
---
## 九、一句话总结
> **一个反射注释缺失,加上 `display_errors = On`,让 PHP 8.1 的 Deprecated 警告被当作 HTML 输出到 JSON 前面。浏览器看着没事,任何用 `JSON.parse` 的客户端全部崩溃。老站因为关过警告输出所以正常。改代码或关 `display_errors` 均可修复,改代码是治本。**
---
## 十、完整修复清单
- [ ] 修改 `usr/plugins/Restful/Action.php` 的 `getRoutes()` 方法(方案 A)
- [ ] 或/并且 关闭生产环境 `display_errors`(方案 B)
- [ ] 给所有 `xxxAction()` 方法补全 docComment(方案 C,辅助)
- [ ] 后台 Restful 设置里配置好 `origin` 域名列表
- [ ] 确认 `apiToken` 与 Obsidian 插件一致
- [ ] Obsidian Host 填 `https://域名/index.php/api`(**末尾无斜杠**)
- [ ] curl 验证响应体以 `{"status"` 开头
- [ ] Obsidian 重新添加站点测试
暂无评论
还没有评论,来说点什么吧