Dart XMPP 消息分片处理
前面写的都是消息进入业务层以后的处理。这次翻一下商业 IM 软件 Dart 客户端的接收代码。
长连接和普通 HTTP 接口不太一样,一次收到的数据未必够一条消息,也可能塞着好几条。XMPP 的多端转发还会在 message 里面再套一个 message,不能找到第一个结束标签就截出来。
一次接收,不对应一次发送
TCP 提供的是有序字节流,应用层写入的边界不保证成为接收端读取的边界。这个约束可以在 TCP 规范 RFC 9293 中找到。
比如,下面是一条用于说明分片的简化消息:
<message id="m1"><body>你好</body></message>
它可能分成这样两段到达:
第一次:<message id="m1"><body>你
第二次:好</body></message>
也可能两条消息一起出现在一次回调里。
如果每次收到字符串就直接当作完整 XML 解析,第一种情况会因为内容不完整而失败;如果只处理找到的第一条,第二种情况又容易漏掉后面的消息。
所以客户端需要保留尚未处理的内容,等边界完整以后再交给 XML 解析。
项目里的缓冲区怎样工作
当前客户端在 _handleData 中解码数据,再追加到 _buffer。_parseBuffer 从缓冲区寻找一条完整消息,处理后保留剩余部分,继续下一轮。
它的主要过程是:
收到数据并解码
→ 追加到字符串缓冲区
→ 找到 message 起点
→ 找到对应的最外层结束标签
→ 提取 XML 并处理
→ 保留剩余内容,继续查找
这里有两个不同的动作:先确定可以交给解析器的范围,再由 XML 解析器解释其中的字段。
当前这段缓冲逻辑主要围绕 <message> 工作,连接握手和其他响应还有自己的监听路径。因此不能把它当成整个 XMPP 流的统一解析器。
为什么不能找到第一个结束标签就停?
多端转发会让这个问题更明显。Message Carbons 的转发结构包含外层消息、forwarded 和内部原始消息,具体结构见 XEP-0280。
只保留层级关系,可以简化成:
<message>
<received xmlns="urn:xmpp:carbons:2">
<forwarded xmlns="urn:xmpp:forward:0">
<message xmlns="jabber:client">
<body>你好</body>
</message>
</forwarded>
</received>
</message>
这是结构示意,省略了真实投递所需的地址等属性。
如果遇到第一个 </message> 就停止,会在内部消息结束时截断。外面的 forwarded、received 和最外层消息还没结束,提取出来的 XML 当然不完整。
项目使用深度计数处理这个层级:遇到消息开始标签加一,遇到结束标签减一;深度回到零时,才认为最外层消息结束。
外层 message 开始:depth = 1
内层 message 开始:depth = 2
内层 message 结束:depth = 1
外层 message 结束:depth = 0,可以提取
这个办法解释了嵌套结构下的边界选择。不过它仍然是针对标签字符串的扫描,不能替代完整的 XML 词法处理。注释、CDATA、自闭合标签等输入,都需要有明确处理规则。
分片也可能发生在标签和字符中间
上面的例子特意保留了完整的 <message。实际检查边界时,还应该把它拆成:
第一次:<mes
第二次:sage id="m1"><body>你好</body></message>
当前实现没有找到 <message 时会清空缓冲区。按照这个分支推演,第一段的 <mes 会被丢掉,第二段到达后就拼不回原来的起点。
这里按代码顺序就能看出会丢数据,分片测试也要覆盖标签中间的位置。
更早还有一个字节层面的边界:UTF-8 字符可能由多个字节组成。当前入口对每次收到的数据单独调用 utf8.decode,如果字符字节恰好跨回调分开,解码阶段就可能失败,还没有机会进入 XML 缓冲。
Dart 提供 Utf8Decoder 的流转换和分块转换能力;无效输入默认会抛出异常,开启 allowMalformed 则会用替代字符处理。替代字符无法恢复被拆开的原始内容,不能把这个选项当作分块解码的解决方案。参见 Utf8Decoder 官方文档。
如果进一步整理接收链路,我会先处理字节到字符的连续解码,再处理 XML 元素边界。解码器和 XML 缓冲都需要保存没处理完的部分。
缓冲区上限限制的是什么?
缓冲不能无限增长,但上限也要说清楚。
当前实现有两处限制:入口会在字符串长度超过 2 * 1024 * 1024 时保留尾部;解析器在消息尚未完整、长度超过 16384 时会清空缓冲。
这两处比较的都是 Dart 字符串长度,不是原始网络数据的 UTF-8 字节数。字符串长度按 UTF-16 code unit 计算,不能直接把变量里的数值当成消息字节上限。参见 String.length。
此外,保留尾部也不保证保留了一条有效消息的起点。截断能控制内存,却可能破坏当前 XML;清空可以释放空间,也意味着尚未完成的消息被放弃了。
因此更完整的设计还需要决定:遇到超限,是报告协议错误、终止当前流,还是丢弃后重新寻找边界。不能只改大一个数字,就认为异常输入和大消息都处理好了。
怎样检查这类解析逻辑
我会先取一段确定有效的消息字节,对每一个位置分别切成前后两块,检查解析结果是否与一次性输入相同。再补充连续多块、多个完整消息连在一起、嵌套转发和中文字符等情况。