Nodejs 发送 TCP 消息的正确姿势

最近使用 NODE-RED 跟 TCP 打交道。NODE-RED 里内建了一个节点叫“tcp-out”,看文档呢使用这个节点可以很方便的把 payload 用 TCP 协议发送出去,但是事实上事情没有这么简单。其实当我第一次看到这个节点用法的时候我就觉得会有问题,果不其然。既然节点有问题,那么就干脆写代码吧,反正 NODE-RED 支持自定义 javascript function 。于是就花了点时间研究了下用 Nodejs 来发送 TCP 消息。.

问题

上面说了使用内建的节点“tcp-out”发送 TCP 消息会有问题。那么到底是什么问题呢?
“tcp-out” 节点只是简单的把 payload 字符串转成了 buffer 然后发送了出去。其实如果自己做测试,发送一个消息然后服务端接受一个消息一点问题都没有的。但是稍微有一些 socket 编程经验的人都知道,这么做在生产环境是有问题的。因为在真实的生产环境下,服务端都是会定义消息的结构的。比如我们这次对接的服务端就要求每个消息头部都需要带4字节的包头,来标识整个消息的长度。所以我们直接发送的消息服务端校验包头不通过会直接丢弃。
那么为什么要这么做呢?

粘包?

服务端这么做的原因是 TCP 服务端接收消息有可能出现“粘包”的问题。这时候肯定有同学会出来说了:TCP 是流式协议,根本没有包的概念怎么可能粘包呢?是的 ,这说的没错。本质上 TCP 作为流式协议根本不可能出现粘包的问题。但是如果从应用层开发者的角度来看,TCP 服务端在接受消息的时候确确实实会出现多个消息同时收到,或者收到1.x个消息的问题。站在应用层开发者的角度看,就是几个包(消息)黏在了一起。所以也没必要去咬文嚼字,毕竟大家多数都是应用层开发玩家。
那么为什么会有以上问题?让我们先回顾一下 OSI 网络模型:
TCP位于传输层(第四层),传输的单位叫 Segment(段);
下面是 IP 协议位于网络层,传输的单位叫 Packet (包);下面是 Datalink 数据链路层,单位是 frame (帧);
好了知道了以上知识,我们可以知道 TCP 是已 segment 单位来传输的。但是 segment 是有最大值限制的。在 TCP 协议中有个叫 MSS(Max Segment Size) 的东西。一般来说 MSS = MTU - 40 = 1460 字节。为什么是一般来说,因为 TCP 协议太复杂了。看上面又引入了一个 MTU 的概念,这里就不展开来说了,有兴趣大家可以自己研究一下 TCP,会大开眼界的。
好了,既然 segment 有最大值限制,那么很显然当我们一次发送的消息长度超过 MSS ,那么消息就会被拆分成多个 segment 来发送。既然有拆分那么显然就有合并。TCP 协议有个 TCP_NODELAY 算法,当传输大量长度短的数据的时候有可能会触发 TCP_NODELAY 算法。TCP_NODELAY 算法就会尝试把多个短消息合并成一个 segment 来发送。
那么如何解决上述问题呢?方法就是上面说的 ,在每个消息的开始的地方放一个固定长度的头部用来表示整个消息的长度。
Nodejs 发送 TCP 消息的正确姿势
服务端收到消息后,先截取4个字节的长度,读取里面的值获得整个消息的长度。然后 payload 长度 = 整个长度-4。然后使用这个长度截取对应的长度的数据。这样就得到了一个完整的消息。如果后面的长度不够了就等下一个消息到达后补齐对应长度的数据。如此循环以上操作,服务端就能解决这个问题了。

使用 Nodejs 发送 TCP 报文(消息)

好了上面铺垫了这么多 ,总算要开始写代码了。
如果你打开 Google 搜索 "nodejs 发送 tcp" 你会得到很多代码示例。但是大多数代码都是 demo 级别的。也就是都是简单的把所有的消息当做 payload 发送到服务端,然后服务端打印一下而已。这也是我写这篇文章的初衷,科普一下一个真正的 TCP 报文(消息)该怎么发送。
就以上面的结构为例:头部固定4字节表示整个消息的长度(4 + length(payload))。

const payloadString = 'hello , world .';
const headerLength = 4;
let socket = net.createConnection({ port: 8888, host: '127.0.0.1' });
socket.on('connect', () => {
  console.log('start send data .');
  let payloadBuff = Buffer.from(payloadString);
  let payloadLength = payloadBuff.length;
  let headerbuff = Buffer.allocUnsafe(4);
  headerbuff.writeUInt32BE(headerLength + payloadLength);
  socket.write(headerbuff);
  console.log('send header done');
  socket.write(payloadBuff);
  console.log('send payload done');
  
  console.log('send data done .');
});

其实代码也没几行。简单说一下就是,在发送 payload 之前,需要先分配一个 4 字节长度的 buffer,然后写入整个消息的长度,发送出去,紧接着发送真正的 payload 。这样就完成了一次 TCP 报文消息的发送。

总结

虽然题目叫 Nodejs 发送消息,但是代码却是寥寥几行。本文多数文字都是在描述 TCP 协议相关的东西。TCP是个伟大(复杂)的协议,要理解它不是件容易的事情,光是链接建立,链接关闭的过程都非常复杂。更别说它那些算法了(NODELAY,窗口算法,拥堵避免算法等等)。但是有时间的话还是可以花点时间研究下,这对于我们这些应用层开发者来说也是一件非常有意义的事。当你了解了 TCP 协议后,很多以前似懂非懂的问题都豁然开朗了。比如到底有没有粘包问题,应用层为什么要定义数据结构,同一个连接服务端会有并发问题吗?