无论是服务端还是客户端,当我们读取或者发送消息的时候,都需要考虑TCP底层的粘包/拆包机制。
1.TCP粘包/拆包问题
TCP是个“流”协议,所谓流,就是没有界限的一串数据。大家可以想想河里的流水,是连成一片的,其间并没有分界线。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。
1.1 TCP粘包/拆包问题说明
假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下4种情况。
(1)服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包;
(2)服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP粘包;
(3)服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP拆包;
(4)服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余内容D1_2和D2包的整包。
如果此时服务端TCP接收滑窗非常小,而数据包D1和D2比较大,很有可能会发生第五种可能,即服务端分多次才能将D1和D2包接收完全,期间发生多次拆包。
1.2 TCP粘包/拆包发生的原因
问题产生的原因有三个,分别如下。
(1)应用程序write写入的字节大小大于套接口发送缓冲区大小;
(2)进行MSS大小的TCP分段;
(3)以太网帧的payload大于MTU进行IP分片。
粘包问题的解决策略
由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下。
- (1)消息定长,例如每个报文的大小为固定长度200字节,如果不够,空位补空格;
- (2)在包尾增加回车换行符进行分割,例如FTP协议;
- (3)将消息分为消息头和消息体,消息头中包含表示消息总长度(或者消息体长度)的字段,通常设计思路为消息头的第一个字段使用int32来表示消息的总长度;
- (4)更复杂的应用层协议。
下图说明了接受端接收到数据的各种情况:
当然,接收到第一种情况是最理想的,也不须处理。
2.未考虑TCP粘包导致功能异常案例
在前面的时间服务器例程中,我们多次强调并没有考虑读半包问题,这在功能测试时往往没有问题,但是一旦压力上来,或者发送大报文之后,就会存在粘包/拆包问题。如果代码没有考虑,往往就会出现解码错位或者错误,导致程序不能正常工作。以Netty 入门示例为例。
2.1 示例
SERVER
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;
public class TimeServerHandler extends ChannelHandlerAdapter {
private int counter;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg)
throws Exception {
ByteBuf buf = (ByteBuf) msg;
byte[] req = new byte[buf.readableBytes()];
buf.readBytes(req);
String body = new String(req, "UTF-8").substring(0, req.length - System.getProperty("line.separator").length());
System.out.println("The time server receive order : " + body + " ; the counter is : " + ++counter);
String currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ?
new java.util.Date( System.currentTimeMillis()).toString() : "BAD ORDER";
currentTime = currentTime + System.getProperty("line.separator");
ByteBuf resp = Unpooled.copiedBuffer(currentTime.getBytes());
ctx.writeAndFlush(resp);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
ctx.close();
}
}
每读到一条消息后,就计一次数,然后发送应答消息给客户端。按照设计,服务端接收到的消息总数应该跟客户端发送的消息总数相同,而且请求消息删除回车换行符后应该为"QUERY TIME ORDER"。
Client
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;
public class TimeClientHandler extends ChannelHandlerAdapter {
private int counter;
private byte[] req;
public TimeClientHandler() {
req = ("QUERY TIME ORDER" + System.getProperty("line.separator")).getBytes();
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
ByteBuf message = null;
for (int i = 0; i < 100; i++) {
message = Unpooled.buffer(req.length);
message.writeBytes(req);
ctx.writeAndFlush(message);
}
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg)
throws Exception {
ByteBuf buf = (ByteBuf) msg;
byte[] req = new byte[buf.readableBytes()];
buf.readBytes(req);
String body = new String(req, "UTF-8");
System.out.println("Now is : " + body + " ; the counter is : " + ++counter);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
// 释放资源
ctx.close();
}
}
客户端跟服务端链路建立成功之后,循环发送100条消息,每发送一条就刷新一次,保证每条消息都会被写入Channel中。按照我们的设计,服务端应该接收到100条查询时间指令的请求消息。客户端每接收到服务端一条应答消息之后,就打印一次计数器。按照设计初衷,客户端应该打印100次服务端的系统时间。
运行结果:
服务端运行结果如下。
The time server receive order : QUERY TIME ORDER
QUERY TIME ORDER
…
QUERY TIME ORDER ; the counter is : 1
The time server receive order :
QUERY TIME ORDER
…
QUERY TIME ORDER ; the counter is : 2
服务端运行结果表明它只接收到了两条消息,第一条包含57条“QUERY TIME ORDER”指令,第二条包含了43条“QUERY TIME ORDER”指令,总数正好是100条。我们期待的是收到100条消息,每条包含一条“QUERY TIME ORDER”指令。这说明发生了TCP粘包。
客户端运行结果如下。
Now is : BAD ORDER
BAD ORDER;
the counter is : 1
按照设计初衷,客户端应该收到100条当前系统时间的消息,但实际上只收到了一条。这不难理解,因为服务端只收到了2条请求消息,所以实际服务端只发送了2条应答,由于请求消息不满足查询条件,所以返回了2条“BAD ORDER”应答消息。但是实际上客户端只收到了一条包含2条“BAD ORDER”指令的消息,说明服务端返回的应答消息也发生了粘包。由于上面的例程没有考虑TCP的粘包/拆包,所以当发生TCP粘包时,我们的程序就不能正常工作。
2.2 TCP粘包处理
设置数据包头
// TcpDataSplit.cpp : 定义控制台应用程序的入口点。
//
#include "stdafx.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_NETPACK_SIZE 10000
#define MAX_DATA_SIZE 4086
/* 数据包头类型 */
struct NetDataHeader_t
{
int nDataType; //数据包类型,标识对应的对象类型
int nDataSize; //数据包中szData真实数据的长度
};
/* 数据包类型 */
struct NetDataBase_t
{
NetDataHeader_t dataHeader; //数据包头
char szData[MAX_DATA_SIZE]; //真实数据
};
/**
其实NetDataBase_t是基础类型,由此我们可以延伸出很多子类型,
所以我们要清楚,每个类型的长度是不一样的,不都是sizeof(NetDataBase_t),
就是各个类型对象大小不一样,比如:
在派生结构体中,NetDataPeople_t和NetDataSchool_t是两个各异的结构体,
但他们都有相关的Header部分指明结构体类型和长度。
*/
struct NetDataPeople_t
{
NetDataHeader_t dataHeader;
int nAge;
char szName[10];
};
struct NetDataSchool_t
{
NetDataHeader_t dataHeader;
char szShoolName[20];
char szShoolAddress[30];
};
/**
处理整理好的对象。
*/
bool HandleNetPack(NetDataHeader_t* pDataHeader);
bool TcpDataSplit(const char* szRecNetData, int nRecSize)
{
/**
对于szLastSaveData, nRemainSize,为了简单,本例子只
作为静态变量使用,因此只限于一个socket的数据接收,
假如要同时处理多个socket数据,请放在对应容器里保存
*/
static char szLastSaveData[MAX_NETPACK_SIZE];
static int nRemainSize = 0;
static bool bFirst = true;
if (bFirst)
{
memset(szLastSaveData, 0, sizeof(szLastSaveData));
bFirst = false;
}
/* 本次接收到的数据拼接到上次数据 */
memcpy( (char*)(szLastSaveData+nRemainSize), szRecNetData, nRecSize );
nRemainSize = nRecSize + nRemainSize;
/* 强制转换成NetDataPack指针 */
NetDataHeader_t* pDataHead = (NetDataHeader_t*)szLastSaveData;
/**
核心算法
*/
while ( nRemainSize >sizeof(NetDataHeader_t) &&
nRemainSize >= pDataHead->nDataSize +sizeof(NetDataHeader_t) )
{
HandleNetPack(pDataHead);
int nRecObjectSize = sizeof(NetDataHeader_t) + pDataHead->nDataSize; //本次收到对象的大小
nRemainSize -= nRecObjectSize ;
pDataHead = (NetDataHeader_t*)( (char*)pDataHead + nRecObjectSize ); //移动下一个对象头
}
/* 余下数据未能组成一个对象,先保存起来 */
if (szLastSaveData != (char*)pDataHead)
{
memmove(szLastSaveData, (char*)pDataHead, nRemainSize);
memset( (char*)( szLastSaveData+nRemainSize), 0, sizeof(szLastSaveData)-nRemainSize );
}
return true;
}
/**
处理整理好的对象。
*/
bool HandleNetPack(NetDataHeader_t* pDataHeader)
{
//处理数据包
if (pDataHeader->nDataType == 1)
{
NetDataPeople_t* pPeople = (NetDataPeople_t*)pDataHeader;
printf("收到People对象,Age:%d, Name:%s\n", pPeople->nAge, pPeople->szName);
}
else if (pDataHeader->nDataType == 2)
{
NetDataSchool_t* pSchool = (NetDataSchool_t*)pDataHeader;
printf("收到School对象,SchoolName:%s, SchoolAddress:%s\n", pSchool->szShoolName, pSchool->szShoolAddress);
}
return true;
}
int _tmain(int argc, _TCHAR* argv[])
{
/* 本例子以两个对象作为接收到的数据 */
NetDataPeople_t people;
people.dataHeader.nDataSize = sizeof(people) - sizeof(NetDataHeader_t);
people.dataHeader.nDataType = 1;
people.nAge = 20;
sprintf(people.szName, "Jim"); //real data
NetDataSchool_t school;
school.dataHeader.nDataSize = sizeof(school) - sizeof(NetDataHeader_t);
school.dataHeader.nDataType = 2;
sprintf(school.szShoolName, "清华大学"); //real data
sprintf(school.szShoolAddress, "北京市北京路"); //real data
/* 将两个对象数据合并到一个地址里面以便重现粘包 */
char szSendData[sizeof(people)+sizeof(school)];
memcpy(szSendData, (char*)&people, sizeof(people));
memcpy(szSendData+sizeof(people), (char*)&school, sizeof(school));
//这里进行收数据操作,这里省略。。。
/**
特意设置粘包:
1.第一次只发送3个字节,还不足以构建包头
2.第二次发送10个字节,总共13个,但第一个对象大小是8+14=18;因此第一个对象people还没收满
3.第三次发送剩下的全部,第一个对象剩下的部分与第二个对象粘在一起,验证处理
*/
TcpDataSplit((char*)szSendData, 3);
TcpDataSplit((char*)szSendData+3, 10);
TcpDataSplit((char*)szSendData+13, sizeof(szSendData)-13);
getchar();
return 0;
}