Dart XMPP 消息分片处理

Aug 5·6 min
AI 生成的摘要
整理商业 IM 软件 Dart 客户端的接收缓冲:半条消息、嵌套 message 标签,以及标签和 UTF-8 字符恰好被拆开的情况。

前面写的都是消息进入业务层以后的处理。这次翻一下商业 IM 软件 Dart 客户端的接收代码。

长连接和普通 HTTP 接口不太一样,一次收到的数据未必够一条消息,也可能塞着好几条。XMPP 的多端转发还会在 message 里面再套一个 message,不能找到第一个结束标签就截出来。

一次接收,不对应一次发送

TCP 提供的是有序字节流,应用层写入的边界不保证成为接收端读取的边界。这个约束可以在 TCP 规范 RFC 9293 中找到。

比如,下面是一条用于说明分片的简化消息:

snippet
xml
<message id="m1"><body>你好</body></message>

它可能分成这样两段到达:

snippet
text
第一次:<message id="m1"><body>你
第二次:好</body></message>

也可能两条消息一起出现在一次回调里。

如果每次收到字符串就直接当作完整 XML 解析,第一种情况会因为内容不完整而失败;如果只处理找到的第一条,第二种情况又容易漏掉后面的消息。

所以客户端需要保留尚未处理的内容,等边界完整以后再交给 XML 解析。

项目里的缓冲区怎样工作

当前客户端在 _handleData 中解码数据,再追加到 _buffer_parseBuffer 从缓冲区寻找一条完整消息,处理后保留剩余部分,继续下一轮。

它的主要过程是:

snippet
text
收到数据并解码
  → 追加到字符串缓冲区
  → 找到 message 起点
  → 找到对应的最外层结束标签
  → 提取 XML 并处理
  → 保留剩余内容,继续查找

这里有两个不同的动作:先确定可以交给解析器的范围,再由 XML 解析器解释其中的字段。

当前这段缓冲逻辑主要围绕 <message> 工作,连接握手和其他响应还有自己的监听路径。因此不能把它当成整个 XMPP 流的统一解析器。

为什么不能找到第一个结束标签就停?

多端转发会让这个问题更明显。Message Carbons 的转发结构包含外层消息、forwarded 和内部原始消息,具体结构见 XEP-0280

只保留层级关系,可以简化成:

snippet
xml
<message>
  <received xmlns="urn:xmpp:carbons:2">
    <forwarded xmlns="urn:xmpp:forward:0">
      <message xmlns="jabber:client">
        <body>你好</body>
      </message>
    </forwarded>
  </received>
</message>

这是结构示意,省略了真实投递所需的地址等属性。

如果遇到第一个 </message> 就停止,会在内部消息结束时截断。外面的 forwardedreceived 和最外层消息还没结束,提取出来的 XML 当然不完整。

项目使用深度计数处理这个层级:遇到消息开始标签加一,遇到结束标签减一;深度回到零时,才认为最外层消息结束。

snippet
text
外层 message 开始:depth = 1
内层 message 开始:depth = 2
内层 message 结束:depth = 1
外层 message 结束:depth = 0,可以提取

这个办法解释了嵌套结构下的边界选择。不过它仍然是针对标签字符串的扫描,不能替代完整的 XML 词法处理。注释、CDATA、自闭合标签等输入,都需要有明确处理规则。

分片也可能发生在标签和字符中间

上面的例子特意保留了完整的 <message。实际检查边界时,还应该把它拆成:

snippet
text
第一次:<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;清空可以释放空间,也意味着尚未完成的消息被放弃了。

因此更完整的设计还需要决定:遇到超限,是报告协议错误、终止当前流,还是丢弃后重新寻找边界。不能只改大一个数字,就认为异常输入和大消息都处理好了。

怎样检查这类解析逻辑

我会先取一段确定有效的消息字节,对每一个位置分别切成前后两块,检查解析结果是否与一次性输入相同。再补充连续多块、多个完整消息连在一起、嵌套转发和中文字符等情况。

最后修改时间: Sep 15
cd ..