今天早些时候发那篇小组件的文章之前,我先探了一下站点的 XML-RPC,结果被它的写侧连咬两次,值得单独记一篇。
一句 deprecation 提示,位置和长度都不对
站上跑 PHP 8.4,Typecho bundled 的 IXR 库用的是 xml_set_object()——PHP 8.4 把它标了 deprecated。于是每个 XML-RPC 响应体的最前面多出一段:
<br /><b>Deprecated</b>: Function xml_set_object() is deprecated since 8.4 ...<br /><?xml version="1.0" ...这已经很烦了,但真正致命的是 var/IXR/Server.php 的 output():
$cleanXml = ...; // 不含 warning 的纯 XML
$this->sendHeaders($length = strlen($cleanXml)); // Content-Length 按纯 XML 算
echo $cleanXml; // 可 warning 早就写进输出缓冲了Content-Length 是按不含 warning 的长度算的,而 warning 已经先落进缓冲区。线上这条站点的固定前缀是 228 字节,实测 blogger.getUsersBlogs 的 Content-Length = 648 = 228(warning)+ 420,也就是说:
> HTTP 客户端按 Content-Length 读,读到的 XML 永远比实际少 228 字节——结尾的 </methodResponse> 之类直接被切掉。
标准 XML-RPC 客户端在这里是整包解析失败,不是「多个前缀所以要 sanitize 一下」。
于是短回复会被整个吃掉
把上面的减法式子推一步就明白了:送达的 XML 字节数 = Content-Length − 228。
- XML 长于 228 字节:只是尾巴缺一截。
XmlRpcClient里每个嵌套解析函数都catch XmlPullParserException,把截断之前收集到的成员/条目返回出去,所以登录、文章列表大部分还能用(列表一页 20 条实测能拿回 17–19 条,缺的那几条用count=1&page=<全局序号>单条补齐,单条回复约 2KB 不会被切)。 - XML 不足 228 字节:一个字节都读不到。 HTTP 200,写入其实成功了,客户端看到的是空。
正好落在这第二档的,就是两个写方法:metaWeblog.newPost 只回一个数字 cid(约 140 字节),metaWeblog.editPost 只回 <boolean>1</boolean>。
newPost 认不回新编号的后果当年在站点上留了一堆副本:Typecho 的 Widget\Contents\Post\Edit::execute() 只在 if (!empty($this->request->cid)) 时才去加载已有那篇,没有 cid 就 have() 为 false,publish() INSERT 第二行。用户报的「编辑老文章一经发布变成了新文章,旁边多出一篇重复的」就是这个。
App 侧当时加了三层兜底:先认站点回传的 cid;拿不到就立刻 metaWeblog.getRecentPosts count=1 取最新一篇、标题能对上就当成本篇编号;连这步也认不回来就在 Toast 里明说「站点没有回传这篇的编号」,提醒从列表继续改、别再点一次发布。并且专门做了「重复文章清理」那一页来扫历史遗留。
第二道坑:metaWeblog.editPost 根本不 UPDATE
发那篇小组件的文章时,我先用 metaWeblog.newPost 发出去(cid 1089,这次回复能读到),然后把 Markdown 换成 HTML 再 editPost。
参数顺序我按 xmlrpc-tiny-response 那套记忆写的,结果站点回了一个新 cid 1090,1089 一字未变——因为 metaWeblog.editPost 的签名是 (postId, user, pass, struct, publish),没有 blogId,而 metaWeblog.newPost 是 (blogId, user, pass, struct, publish) 有。我统一给前缀加了 blogId,整串参数往后挤了一位:postId 收到 1、content 收到那个 int,于是插出一篇「未命名文档」空文。
把顺序改对、struct 里 title/description/categories/dateCreated 全都到位,第二次调用依然返回 1090,依然是一篇新文章。看 var/Widget/XmlRpc.php:
public function mwEditPost(int $postId, string $userName, string $password, array $content, bool $publish = true): int {
$content['postId'] = $postId;
return $this->mwNewPost(1, $userName, $password, $content, $publish);
}
// mwNewPost 里:
if (isset($content['postId'])) { $input['cid'] = $content['postId']; }链路看着是对的,cid 明明传进去了,PostEdit 最终走的还是 INSERT。
结论:在这台站点上,XML-RPC 的 editPost 等于「另插一篇」,拿到 cid 也救不了。 那篇最后是靠 blogger.deletePost 删掉旧的那篇、只留新的一篇才干净的。
顺带三条参数形状,别再靠记忆:
| 方法 | 真实参数 |
|---|---|
metaWeblog.newPost | (blogId, user, pass, struct, publish) |
metaWeblog.editPost | (postId, user, pass, struct, publish) —— 无 blogId |
blogger.deletePost | (blogId, postId, user, pass, publish) —— postId 在前,顺序错了直接 faultCode 403「无法登录, 密码错误」(它把 user 当 postId、pass 当 user 去校验了) |
metaWeblog.deletePost 在 Typecho 的方法表里其实指向同一个 bloggerDeletePost,所以两种叫法参数一样。
更基础的一处:Markdown 发过去是字面的
var/Widget/XmlRpc.php 里写的是:
$input['markdown'] = $this->options->xmlrpcMarkdown;这台站点的 xmlrpcMarkdown 选项是关的 ⇒ 正文按 HTML 存。我第一版直接把 Markdown 发上去,页面段落还在,但 ## 小标题、代码围栏、| 表格 | 全部原样显示——发完 curl /index.php/archives/<cid>/ grep 到字面的 ## 前面那几版 才发现。
所以要么发之前自己转成 HTML(我是这么做的),要么在站点侧把那个选项打开。发出去之后一定要回读:mt.getRecentPostTitles 看最新 cid,/feed/search/<关键词>/ 数同一篇出现几次——返回了一个数字不代表它改的是你以为的那一篇。
真正的解法在服务器侧
App 那三层兜底治的是症。把这 228 字节消掉的办法只有一个:让 warning 不进响应体。
宝塔面板里 PHP 8.4 的配置,把 display_errors 设成 Off(或者从 error_reporting 里去掉 E_DEPRECATED),warning 就不再写进缓冲区,Content-Length 与实际送达一致,小回复立刻读得到,标准客户端也不会再被截断。
这个改动的收益很直接:newPost / editPost 的返回值才真正读得懂,客户端不必靠「猜最新一篇」来认编号。
记下来的
- 一个 deprecation 提示能坏整条协议,是因为它出现在长度头之外。凡是手写协议,先确认
Content-Length算的是不是同一份字节。 - 参数顺序这种事,读一遍服务端源码只要三十秒,靠记忆调一次就要在站上留一条垃圾数据。
cdn.jsdelivr.net/gh/typecho/typecho@1.2.1/var/Widget/XmlRpc.php直接能拉到,比任何博客答案都准。 - 任何写操作之后都回读。这台站点教我的第三条了。
评论 0
还没有评论,来抢沙发