在计算机技术体系中存在很多网络通信协议。那么通信协议的本质究竟是什么呢?其实,通信协议实质上就是一段数据,通信双方事先约定好按照规定的格式去编码和解码,最终达到传输消息的目的。而对通信协议的设计与处理其中隐藏着许多问题
从TCP协议说起
TCP是一种流式协议,所谓流式协议就是说协议的内容是流水一样的字节流,内容与内容之间没有明确的分界标志,需要我们人为地给这些内容划分边界。
如果没有人为地划分边界,那会发生什么问题呢?
举个例子,A和B进行TCP通信,A先后给B发送了两个大小分别是100字节和200字节的数据包,那么A是怎么收到数据包的呢?B可能先后收到100字节和200字节的数据包,也可能先后收到100、100和100的数据包...
作为发送方的A来说,A是知道如何划分这两个数据包的界限的,但是对于B来说,如果不人为规定将多少字节作为一个数据包,则B是不知道应该将收到的字节数据中的多少字节作为一个有效的数据包的。
这就引出了一个问题:粘包问题
如何解决粘包问题
什么是粘包问题?所谓粘包,就是连续向对端发送两个或者两个以上的数据包,对端在一次收取中收到的数据包数量可能大于1个,当大于1个时,可能是几个(包括一个)包加上某个包的部分,或者干脆几个完整的包在一起。当然,也可能收到的数据只是一个包的部分,这种情况一般也叫作半包。
无论是半包问题还是粘包问题,因为TCP是流式数据格式,所以其解决思路还是从收到的数据中把包与包的边界区分出来,一般有以下三种方法:
(1)固定包长的数据包。固定包长,即每个协议包的长度都是固定的。假如我们规定每个协议包的大小都是64B,每收满64B,接收端就取出来解析。这种通信协议格式简单,但灵活性较差。
(2)以指定的字符(串)为包的结束标志。这种协议包比较常见,即在字节流中遇到特殊的符号值就认为到一个包的末尾了。例如FTP或SMTP,在一个命令或者一段数据后面加上\r\n(即CRLF)表示一个包的结束。对端收到数据后,每遇到一个“\r\n”,就把之前的数据当作一个数据包。这种协议一般用于一些包含各种命令控制的应用中,其不足之处就是如果协议数据包的内容部分需要使用包结束标志字符,就需要对这些字符做转码或者转义操作,以免被接收方错误地当成包结束标志而误解析。
(3)包头+包体格式。这种格式的包一般分为两部分,即包头和包体,包头是固定大小的,且包头必须包含一个字段来说明接下来的包体有多大。例如:
struct MsgHeader
{
int32_t bodySize;
int32_t cmd;
};
这就是一个典型的包头格式,bodySize指定了这个包的包体是多大。由于包头的大小是固定的,所以对端先收取包头大小的字节内容,然后解析包头,根据包头中指定的包体大小收取包体,等包体收够了,就组装成一个完整的包来处理。
解包与处理
对于数据包的解析,不管是采用上面三种格式的哪一种,其处理流程都是一样的,这里以包头+包体这种格式为例子来说明
编码如下:
假设我们的包头格式如下:
//强制1字节对齐(保证不同操作系统,编译器下客户端与服务端sizeof值相同)
#pragma pack(push, 1)
//协议头
struct MsgHeader
{
int32_t bodySize; //包体大小
};
#pragma pack(pop)
那么流程代码如下:
//设置包的最大字节数限制为10MB
#define MAX_PACKAGE_SIZE 10 * 1024 * 1024
void TcpTask::onRead(const std::shared_ptr<TcpConnection>& conn, Buffer* pBuffer, TimeStamp receivTime)
{
while(true)
{
if(pBuffer->readableBytes() < (size_t)sizeof(MsgHeader))
{
//不够一个包头大小
return;
}
//取包头信息
MsgHeader header;
memcpy(&header, pBuffer->peek(), sizeof(MsgHeader));
//包头有错误,立即关闭连接
if(header.bodySize <= 0 || header.bodySize > MAX_PACKAGE_SIZE)
{
//客户端发送非法数据包,服务器主动关闭它
LOG_INFO("Illegal package, bodySize:lld, close TcpConnection, client:%s",
header.bodySize, conn->peerAddress().toIpPort().c_str());
conn->forceClose();
return;
}
//收到的数据不够一个完整的包
if(pBuffer->readableBytes() < (size_t)header.bodySize + sizeof(MsgHeader))
{
return;
}
//在判断完缓冲区的数据大小够整个包的大小时,才需要把整个包的大小从缓冲区中移除
pBuffer->retrieve(sizeof(MsgHeader));
//inbuf 用来存放当前要处理的包
std::string inbuf;
inbuf.append(pBuffer->peek(), header.bodySize);
pBuffer->retrieve(header.bodySize);
//解包和业务处理
if(!process(conn, inbuf.c_str(), inbuf.length()))
{
//客户端发送非法数据包,服务器主动关闭它
LOG_ERROR("process package error, close TcpConnection, client:%s", conn->peerAddress().toIpPort().c_str());
conn->forceClose();
return;
}
} //end while
}
以上代码基本符合解包的流程处理。还有一些细节问题需要强调:
(1)取包头时应该拷贝一份数据包头大小的数据出来,而不是从缓冲区直接将数据取出来(将取出来的数据从缓冲区中移除),这是因为若接下来根据包头中包体大小字段取包体时,若剩下数据不够一个包体大小,则我们还需要将这个包头数据放回缓冲区。为了避免这种不必要的麻烦,只有当判断完缓冲区的数据大小够整个包的大小时,才需要将整个包的大小从缓冲区中移除。
(2)通过包头得到包体大小时,我们一定要对bodySize的大小进行校验。这里一定要判断该字段的上下限,因为如果是一个非法的客户端发来的数据,其bodySize设置了较大的数值,则我们的逻辑会让我们一直缓存客户端发来的数据,这会导致一个严重的问题,服务器的内存资源将很快耗尽,当操作系统检测到我们的进程占用的内存达到一定的阈值就会杀死我们的进程,导致服务不能再正常对外服务。所以,对于非法的bodySize,直接关闭该连接即可,这也是一种服务器的自我保护措施。
(3)整个判断包头,包体的逻辑都放在一个循环中,这是必要的。如果没有这个while循环,则当我们一次性收到多个包时,只会处理一个,下次接着处理就需要等到新的一批数据来临时再次触发这个逻辑。这样造成的后果就是对端向我们发送了多个请求,我们最多只能应答一个,后面的应答得等到对端再次向我们发送数据才能进行。这就是对粘包逻辑的正确处理。