TCP粘包/拆包 の 原理、解包方案和测试用例

2018-02-01

摘要

TCP是最常用的传输层协议之一。作为一种字节流协议,需要应用层提供分包协议和方案。

ECMAScript 2015 (ES6) 引入Buffer类作为Node.js API的一部分,使其具备了处理二进制流的能力,正好适用于TCP
 流这样的场景。本文使用Node.js实现了TCP解包方案,并给出基于mocha的测试用例。


粘包及拆包的原理

TCP使用“流”的方式传输,传输的数据是无结构的字节流,没有分界线。通信层并不了解上层数据的具体含义,需要应用层自己界定数据包的边界。


如果要发送的数据比较多,整段的数据流会分若干次发送,这就是了“拆包”。分拆的大小受限于:


为提高每次发送数据的效率,引入了连续发送多段数据的机制,这就是“粘包”。包括以下情况:


以上情况有可能同时存在。重复多次发送同样的数据,可能会有不同的分包拆包现象。


表现形式

TCP粘包和拆包原理


报文协议的方案

在设计报文协议时,有以下3种思路:

  1. 每个数据包封装为固定长度,不够的通过通过补0填充;
  2. 使用分割符作为数据包之间的边界;
  3. 将消息分为消息头和消息体,并在消息头中申明消息的长度


第一种实现起来简单,但效率最低;

第二种需要应用层扫一遍已经收到的数据,性能上不够高效。有可能消息体中本身就存在分隔符,这时还要做特殊处理。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就能看到测试结果。(如果修改了报文协议,用例中构造发送数据的部分也需要做相应的修改。)




本文未经许可禁止转载,如需转载关注微信公众号【工程师加一】并留言。