基于Nodejs的Tcp封包和解包的理解
作者:Jonny 发布时间:2024-05-11 10:13:43
我们知道,TCP是面向连接流传输的,其采用Nagle算法,在缓冲区对上层数据进行了处理。避免触发自动分片机制和网络上大量小数据包的同时也造成了粘包(小包合并)和半包(大包拆分)问题,导致数据没有消息保护边界,接收端接收到一次数据无法判断是否是一个完整数据包。那有什么方案可以解决这问题呢?
1、粘包问题解决方案及对比
很简单,既然消息没有边界,那我们在消息往下传之前给它加一个边界识别就好了。
发送固定长度的消息
使用特殊标记来区分消息间隔
把消息的尺寸与消息一块发送
第一种方案不够灵活;第二种有风险,如果数据内刚好有该特殊字符会出问题;第三种方案虽然要增加对消息头的解析,不过相对而言还是要安全一些。
2、分包与拆包
既然使用第三种方案,就必然涉及到封包和拆包的问题。
首先肯定需要定义数据包的结构,这类似Http包一样,有包头和包体。包头其实上是个大小固定的结构体,其中有个结构体成员变量表示包体的长度,其他的结构体成员可根据需要自己定义。根据包头长度固定以及包头中含有包体长度的变量就能正确的拆分出一个完整的数据包。包体则存放数据内容。
在发送端,需要进行封包。封包就是给一段数据加上包头,这样一来数据包就分为包头和包体两部分内容了。
在接受端,则需要进行拆包。主要流程如下:
1. 为每一个连接动态分配一个缓冲区,同时把此缓冲区和SOCKET关联.
2. 当接收到数据时首先把此段数据存放在缓冲区中.
3. 判断缓存区中的数据长度是否够一个包头的长度,如不够,则不进行拆包操作.
4. 根据包头数据解析出里面代表包体长度的变量.
5. 判断缓存区中除包头外的数据长度是否够一个包体的长度,如不够,则不进行拆包操作.
6. 取出整个数据包.这里的"取"的意思是不光从缓冲区中拷贝出数据包,而且要把此数据包从缓存区中删除掉.删除的办法就是把此包后面的数据移动到缓冲区的起始地址.
其中对于缓冲区的设计,主要由俩种:
1. 采用动态变化的缓冲区暂存,根据数据大小调整缓冲区大小。这个方案有个缺点,为了避免缓冲区不断增长,每次解析出一个完整包后需要将缓冲区残留的数据拷贝到缓冲区首部,这增加了系统负载。
2. 采用环形缓冲区,定义两个指针,分别指向有效数据的头和尾.在存放数据和删除数据时只是进行头尾指针的移动
3、网络字节序和本机字节序
定义了消息结构之后,发送端和接收端还需要统一字节序。我们知道,不同机器的本机字节序不同,绝大多数X86机器都是小端字节序,然后还是由少数机器是大端存储的。因此在数据流进行传输时,必须先统一字节序。一般约定在传输时采用网络字节序(大端),统一用unicode编码。
4、代码实现
了解以上知识之后,我们现在之后要做什么了。发送端按定义的协议规则封包,接受端把接收到的buffer放入缓冲区,当缓冲区内有完整包时开始拆包。封包拆包过程需要注意,读写超过一个字节的数据时需要按大端字节序读取。下面看node的代码实现(只提供核心实现片段):
1)发送端封包:
let head = new Buffer(4);
let jsonStr = JSON.stringify(json);
let body = new Buffer(jsonStr);
//超过一字节的大端写入
head.writeInt32BE(body.byteLength, 0);
let buffer = Buffer.concat([head, body]);
2)接收端收到buffer入缓冲区:
let dataReadStart = 0; //新数据的起始位置
let dataLength = buffer.length; // 要拷贝数据的长度
let availableLen = _bufferLength - _dataLen; // 缓冲区剩余可用空间
// buffer剩余空间不足够存储本次数据
if (availableLen < dataLength) {
let newLength = Math.ceil((_dataLen + dataLength) / _bufferLength) * _bufferLength;
let _tempBuffer = Buffer.alloc(newLength);
// 将旧数据复制到新buffer并且修正相关参数
if (_writePointer < _readPointer) { // 数据存储在旧buffer的尾部+头部的顺序
let dataTailLen = _bufferLength - _readPointer;
_buffer.copy(_tempBuffer, 0, _readPointer, _readPointer + dataTailLen);
_buffer.copy(_tempBuffer, dataTailLen, 0, _writePointer);
} else { // 数据是按照顺序进行的完整存储
_buffer.copy(_tempBuffer, 0, _readPointer, _writePointer);
}
_bufferLength = newLength;
_buffer = _tempBuffer;
_tempBuffer = null;
_readPointer = 0;
_writePointer = _dataLen;
//存储新到来的buffer
buffer.copy(_buffer, _writePointer, dataReadStart, dataReadStart + dataLength);
_dataLen += dataLength;
_writePointer += dataLength;
} else if (_writePointer + dataLength > _bufferLength) {
// 空间够用情况下,但是数据会冲破缓冲区尾部,部分存到缓冲区旧数据后,一部分存到缓冲区开始位置
// 缓冲区尾部剩余空间的长度
let bufferTailLength = _bufferLength - _writePointer;
// 数据尾部位置
let dataEndPosition = dataReadStart + bufferTailLength;
buffer.copy(_buffer, _writePointer, dataReadStart, dataEndPosition);
// data剩余未拷贝进缓存的长度
let restDataLen = dataLength - bufferTailLength;
buffer.copy(_buffer, 0, dataEndPosition, dataLength);
_dataLen = _dataLen + dataLength;
_writePointer = restDataLen
} else { // 剩余空间足够存储数据,直接拷贝数据到缓冲区
buffer.copy(_buffer, _writePointer, dataReadStart, dataReadStart + dataLength);
_dataLen = _dataLen + dataLength;
_writePointer = _writePointer + dataLength
}
3)取出缓冲区所有完整数据包(收到的buffer入缓冲区后)
let _dataHeadLen = 4;
timer && clearInterval(timer);
timer = setInterval(()=>{
// 缓冲区数据不够解析出包头
if (_dataLen < _dataHeadLen) {
console.log('数据长度小于包头规定长度,等待数据......')
clearInterval(timer);
}
// 解析包头长度
// 尾部最后剩余可读字节长度
let restDataLen = _bufferLength - _readPointer;
let dataLen = 0;
let headBuffer = Buffer.alloc(_dataHeadLen);
// 数据包为分段存储,不能直接解析出包头,先拼接
if (restDataLen < _dataHeadLen) {
// 取出第一部分头部字节
_buffer.copy(headBuffer, 0, _readPointer, _bufferLength)
// 取出第二部分头部字节
let unReadHeadLen = _dataHeadLen - restDataLen;
_buffer.copy(headBuffer, restDataLen, 0, unReadHeadLen)
dataLen = headBuffer.readUInt32BE(0);
} else {
_buffer.copy(headBuffer, 0, _readPointer, _readPointer + _dataHeadLen);
dataLen = headBuffer.readUInt32BE(0);;
}
// 数据长度不够读取,直接返回
if (_dataLen - _dataHeadLen < dataLen) {
log.info("缓冲区已有body数据长度小于包头定义body的长度,等待数据......")
clearInterval(timer);
} else { // 数据够读,读取数据包
let package = Buffer.alloc(dataLen);
// 数据是分段存储,需要分两次读取
if (_bufferLength - _readPointer < dataLen) {
let firstPartLen = _bufferLength - _readPointer;
// 读取第一部分,直接到字符尾部的数据
_buffer.copy(package, 0, _readPointer, firstPartLen + _readPointer);
// 读取第二部分,存储在开头的数据
let secondPartLen = dataLen - firstPartLen;
_buffer.copy(package, firstPartLen, 0, secondPartLen);
_readPointer = secondPartLen; //更新可读起点
} else { // 直接读取数据
_buffer.copy(package, 0, _readPointer, _readPointer + dataLen);
_readPointer += dataLen; //更新可读起点
}
_dataLen -= readData.length; //更新数据长度
// 已经读取完所有数据
if (_readPointer === _writePointer) {
clearInterval(timer)
}
//开始解包
callback(package);
}
}, 50);
4)拆包得到数据
let headBytes = 4;
let head = new Buffer(headBytes);
buffer.copy(head, 0, 0, headBytes);
let dataLen = head.readUInt32BE();
const body = new Buffer(dataLen);
buffer.copy(body, 0, headBytes, headBytes + dataLen)
let content = null;
try {
const str = body.toString('utf-8');
if(str === ''){
content = null;
}else{
content = JSON.parse(body);
}
} catch (e) {
log.error('head指定body长度有问题')
}
//传递给业务层
callback(content);
5、总结
从上面我们已经了解到了封包解包的一个过程。TCP是可靠传输的,同一时间在网络上只会有一个数据包,并且丢包会重传,因此不用担心丢包或者数据包乱序问题。UDP有消息保护边界,不需要进行拆包解包,然后其是非可靠传输,也需要解决其他一些问题,譬如丢包和数据包排序问题。
上面进行数据包结构设计时只是简单地加了一个包体长度,事实上在业务场景可以自由增加需要的字段,譬如协议版本,协议类型等等。
来源:https://juejin.im/post/5ba119735188255c4a711682


猜你喜欢
- 官方文档:http://www.layui.com/demo/layedit.html官网文档目前是不支持layedit扩展式的。自己手动实
- 作为主题的制作者, 除了实现功能, 展示界面, 还有责任使主题灵活多变, 以满足更多人不同的需求.可能一些朋友曾为选用双栏主题 (单侧边栏)
- 本文实例讲述了python实现搜索指定目录下文件及文件内搜索指定关键词的方法。分享给大家供大家参考。具体实现方法如下:#!/usr/bin/
- MatplotlibMatplotlib 是Python中类似 MATLAB 的绘图工具,熟悉 MATLAB 也可以很快的上手 Matplo
- 网上有很多提供在线按钮制作、文字标题制作、Logo制作服务的网站,它们可以非常方便了让大家轻松的获得效果出色的各类图标型的图片,下面就快来看
- 花式打印9*9乘法表#第一个计数器i = 1while i < 10: #第二个计数器 j = 1  
- sql语句有一个非常长的sql,用编辑器打开编写的时候太长了导致编写非常吃力,而且容易错乱,我想做的是把A,B,C三个变量赋值到sql中的字
- 关于截取字符串指定长度的自定义函数很多,各式各样!不过大多原理都是一个样,循环字符串判断每一个字符的asc码!我这里也有一个,示例函数如下:
- 关于iframe的使用iframe在同域时能自由操作iframe和父框架的内容(DOM),在跨域时可以实现页面跳转。<iframe i
- rss.asp格式的 下面代码保存为rss.asp 代码如下:<!--#include file="conn.as
- 一:概念 队列是一个”先进先出“
- 现将几种主要情况进行小结: 一、如何输入NULL值 如果不输入null值,当时间为空时,会默认写入"1900-01-01"
- 一、定位 oracle分两大块,一块是开发,一块是管理。开发主要是写写存储过程、触发器什么的,还有就是用Oracle的Develop工具做f
- 解析html是爬虫后的重要的一个处理数据的环节。一下记录解析html的几种方式。先介绍基础的辅助函数,主要用于获取html并输入解析后的结束
- 平时我们获取事件对象一般写法如下:function getEvent(event) { return event
- 前言手里有一点点公司的股票, 拿不准在什么时机抛售, 程序员也没时间天天盯着看,不如动手写个小程序, 把股票趋势每天早上发到邮箱里,用 py
- 最近在折腾Python Web,在测试的时候发现,本机可以正常访问,但外网无法通过公网IP访问页面。经过各种搜索,有大致三种解决方案。一、修
- 其实我们平时在深度学习中所说的卷积操作,在 opencv 中也可以进行,或者说是类似操作。那么它是什么操作呢?它就是图像的模糊(滤波)处理。
- 1、基础概念什么是网络编程?指在网络环境中,如何实现不在同一物理位置中的计算机之间进行数据通信如果要保证数据通信顺利完成,则需要先了解如下几
- 本文实例讲述了PHPExcel冻结(锁定)表头的简单实现方法。分享给大家供大家参考,具体如下:PHPExcel是一款功能比较强大的操作微软e