摘要
TCP是最常用的传输层协议之一。作为一种字节流协议,需要应用层提供分包协议和方案。
ECMAScript 2015 (ES6) 引入Buffer类作为Node.js API的一部分,使其具备了处理二进制流的能力,正好适用于TCP
流这样的场景。本文使用Node.js实现了TCP解包方案,并给出基于mocha的测试用例。
粘包及拆包的原理
TCP使用“流”的方式传输,传输的数据是无结构的字节流,没有分界线。通信层并不了解上层数据的具体含义,需要应用层自己界定数据包的边界。
如果要发送的数据比较多,整段的数据流会分若干次发送,这就是了“拆包”。分拆的大小受限于:
- TCP发送缓冲区剩余空间大小;
- MSS大小(最大报文长度);
- MTU大小(最大传输单元);
为提高每次发送数据的效率,引入了连续发送多段数据的机制,这就是“粘包”。包括以下情况:
- 要发送的数据很小,将多次数据写入发送缓冲区,一次性发送出去;
- 接收端应用层没有及时读取接收缓冲区的数据,一次性读取多段数据;
以上情况有可能同时存在。重复多次发送同样的数据,可能会有不同的分包拆包现象。
表现形式
报文协议的方案
在设计报文协议时,有以下3种思路:
- 每个数据包封装为固定长度,不够的通过通过补0填充;
- 使用分割符作为数据包之间的边界;
- 将消息分为消息头和消息体,并在消息头中申明消息的长度
第一种实现起来简单,但效率最低;
第二种需要应用层扫一遍已经收到的数据,性能上不够高效。有可能消息体中本身就存在分隔符,这时还要做特殊处理。FTP/SMTP/POP3都是典型例子,它们都使用\n来作为数据包结束的标志;
第三种的典型例子是HTTP协议,用\r\n\r\n来分割消息头和消息体,并在消息头中申明消息体的长度。
解包源码
这里使用上述的第三种思路,在内容前面插入4个字节,申明“有效数据”的长度(不包含包头),单位为字节。报文格式为“xxxx有效数据”。在实际应用场景中,包的构造会更加复杂,会在开头和结尾再追加标识位、类别、checksum校验位等,这里不做讨论。以下是解包的代码:
let unpack = (packs) => {
let res = [];
let totalResBuff = Buffer.alloc(0); // 当前正在取包的内容
let totalResByteLen = 0; // 当前正在取的包的完整包体长度
let body = Buffer.alloc(0); // 当前正在取的有效数据
let bodyByteLen = 0; // 当前正在取的有效数据的长度
// 对接收到的数据进行处理
let readData = (resBuffer) => {
let resBuffLen = resBuffer.length;
// 如果这是收到的第一个数据包,读取前四个字节,获取body的完整长度
if (totalResBuff.byteLength === 0) {
// 有效数据 长度
bodyByteLen = new DataView(resBuffer.buffer.slice(0, 4)).getInt32(0, true);
// 完整包体 长度
totalResByteLen = bodyByteLen + 4;
}
// 对拆包和粘包进行处理
let remaining = resBuffLen; //剩余未解析的数据长度
while (remaining > 0){ //只要还有剩余的数据没取完,就继续获取
console.log('resBuffLen:'+resBuffLen); //接受到的数据长度
console.log('start:'+(resBuffLen-remaining)); //从resBuffer中开始读取数据的位置
console.log('curBuffLen:'+totalResBuff.byteLength); //当前包已解析的内容长度
console.log('totalResByteLen:'+totalResByteLen); //当前包包的总长度
// 如果数据包的长度小于剩余的长度,说明发生粘包,取完这个数据包的内容之后,还得继续获取剩余的数据
if (totalResByteLen<remaining) {
let totalRemaining = totalResByteLen - totalResBuff.byteLength; //这个数据包还剩下的没有取到的数据长度
// 获取这个数据包的内容
totalResBuff = Buffer.concat([totalResBuff, resBuffer.slice(resBuffLen-remaining, totalRemaining)]);
// 这个数据包的内容获取完成,开始处理数据
body = totalResBuff.slice(4, totalResByteLen);
decodeBody(body);
// 这个包已经处理完,获取剩余的字节数,作为下个包开始取的位置
remaining = remaining - totalRemaining;
// 清空当前的包,获取下一个包的大小
totalResBuff = Buffer.alloc(0);
body = Buffer.alloc(0);
bodyByteLen = new DataView(resBuffer.buffer.slice(resBuffLen-remaining, resBuffLen-remaining+4)).getInt32(0, true);
totalResByteLen = bodyByteLen + 4;
continue;
} else {
// 当前剩余的都是同一个包的内容,直接获取到结束
totalResBuff = Buffer.concat([totalResBuff, resBuffer.slice(resBuffLen-remaining, resBuffLen)]);
remaining = 0;
}
// 当前已经取到的内容还不够完整的数据包长度,继续等SVR返回剩余的包
if (totalResBuff.byteLength < totalResByteLen) {
return;
}
// 这里已经取到完整的数据包,从包里获取内容进行处理
body = totalResBuff.slice(4, totalResByteLen);
decodeBody(body);
// 当前包所有数据已经处理完,清空数据
totalResBuff = Buffer.alloc(0);
totalResByteLen = 0;
body = Buffer.alloc(0);
bodyByteLen = 0;
}
}
// 解析包内容
let decodeBody = (body) => {
res.push(body.toString('utf-8'));
}
// 通过遍历packs数组,模拟实际传输过程中连续接收数据
for(pack of packs) {
readData(pack);
}
return res;
}
这里我们传入一个数组“packs”来模拟多次收到的数据。对收到的数据调用readData函数进行处理,分包之后把完整的数据包送到decodeBody处理。
测试用例
对上图所示的5种情况写了5个测试用例,并使用测试框架mocha进行单元测试,完整的代码在这里。下载到本地之后,运行npm install安装一下mocha,再执行npm
test就能看到测试结果。(如果修改了报文协议,用例中构造发送数据的部分也需要做相应的修改。)
本文未经许可禁止转载,如需转载关注微信公众号【工程师加一】并留言。