网络数据传输:分包/粘包与 Buffer 拼接

本节目标

这是 Buffer 最有价值的实战场景。HTTP、WebSocket 都跑在 TCP 之上,而 TCP 只保证"字节有序到达",不保证"你发几次、我就收几次"。如果你连续 socket.write(A) 再 socket.write(B),对方可能一次收到 AB(粘包),也可能分两次 A、B(分包)。所以任何基于 TCP 的协议,都必须自己定义"一条消息的边界在哪"。

最常用、最可靠的方案是长度前缀协议:每条消息 = 4 字节大端长度 + 负载。接收方先读 4 字节知会"负载多长",再等够这么长才切出一条完整消息。

// 运行环境:Node.js 14+(本地回环 127.0.0.1,无需外网)
// 保存为 buf-l4.js,执行:node buf-l4.js
const net = require('net')

// 编码:4 字节大端长度头 + 负载
function encode(msg) {
  const body = Buffer.from(msg)
  const head = Buffer.alloc(4)
  head.writeUInt32BE(body.length, 0)      // 先声明"负载有多少字节"
  return Buffer.concat([head, body])      // 头部 + 负载
}

// 解析器:累积 Buffer,凑够一条就切出来(背靠背也能正确拆分)
class FrameParser {
  constructor() { this.buf = Buffer.alloc(0) }
  push(chunk) {
    this.buf = Buffer.concat([this.buf, chunk]) // 把新到的字节接在后面
    const out = []
    while (this.buf.length >= 4) {              // 至少够读长度头
      const len = this.buf.readUInt32BE(0)
      if (this.buf.length < 4 + len) break       // 负载还没收齐,等更多数据
      const body = this.buf.subarray(4, 4 + len).toString()
      out.push(body)
      this.buf = this.buf.subarray(4 + len)      // 消费掉这条,剩下留给下次
    }
    return out
  }
}

const server = net.createServer((socket) => {
  const parser = new FrameParser()
  socket.on('data', (chunk) => {
    const msgs = parser.push(chunk)   // 无论 TCP 怎么切,这里都能还原出完整消息
    for (const m of msgs) socket.write(encode('回声:' + m))
  })
})
server.listen(9123, () => {
  const client = net.createConnection({ port: 9123 }, () => {
    // 一次性把两条背靠背发出(刻意制造"粘包"场景)
    client.write(Buffer.concat([encode('你好'), encode('世界')]))
  })
  const cparser = new FrameParser()
  client.on('data', (chunk) => {
    const msgs = cparser.push(chunk)
    for (const m of msgs) console.log('收到:', m)
    if (msgs.length === 2) { client.end(); server.close() }
  })
})

跑起来你会看到 收到: 回声:你好 和 收到: 回声:世界 两条都被正确还原——哪怕发送时它们是粘在一起的。这正是上一节 HTTP 解析器用"空行 + Content-Length"做边界的本质:所有 TCP 协议都在解决"我的消息到哪结束"。

名词解释

课后练习

  1. 如果负载里恰好也包含"4 字节长度头"那样的字节,长度前缀协议会误判吗?
    • 答案:不会。因为长度头是协议层在消息最前面固定读取的,负载内容被当作"透明的字节"整体看待,内部长得像长度头也无所谓——这正是它优于"分隔符方案"的地方。
  2. 为什么必须用 Buffer.concat 累积,而不是直接 chunk.toString() 解析?
    • 答案:一个 chunk 可能只含"半条消息"(分包),直接 toString 解析会失败或残缺;累积起来等字节够了再切,才能保证每条消息完整。
  3. 改成"用 \\n 作分隔符"也能分帧,它和长度前缀各有什么优劣?
    • 答案:分隔符方案简单、人类可读(如文本行协议),但负载里若出现分隔符就需转义,且要先解码成字符串才能找分隔符;长度前缀对二进制友好、无需转义、性能更好,是使用最广的二进制协议方案。

总结

"粘包"这个词很容易让人误以为是 TCP 的缺陷,其实恰恰相反——它恰好证明 TCP 在忠实地做自己该做的事:把你的数据当作无边界的字节流可靠送达。边界,从来都是应用层协议的责任。这一节你手写的 FrameParser,和上一节 HTTP 解析器、后面 WebSocket 帧解码器,是同一个思想的三种面貌:在字节流上,用某种约定重新划出"一条消息"的边界。我强烈建议你把"长度前缀协议"作为默认武器——它简单、对二进制友好、不怕负载里出现特殊字节。当你以后碰到"为什么我收的数据总是错位/少一半/混在一起",第一反应就应该是:检查你的分帧逻辑,而不是怀疑网络。理解了这一节,你才真正跨过了"会用 socket"到"懂网络协议"的门槛。