粘包的产生
当客户端发送多个数据包给服务器时,服务器底层的tcp接收缓冲区收到的数据为粘连在一起的。这种情况的产生通常是服务器端处理数据的速率不如客户端的发送速率的情况。比如:客户端1s内连续发送了两个hello world!,服务器过了2s才接收数据,那一次性读出两个hello world!
tcp底层的安全和效率机制不允许字节数特别少的小包发送频率过高,tcp会在底层累计数据长度到一定大小才一起发送,比如连续发送1字节的数据要累计到多个字节才发送。
粘包处理
处理粘包的方式主要采用应用层定义收发包格式的方式,这个过程俗称切包处理,常用的协议被称为tlv协议(消息id+消息长度+消息内容)。
tlv
TLV(Type-Length-Value)是一种通信协议,用于在通信中传输结构化数据。它将数据分为三个部分:类型(Type)、长度(Length)和值(Value),每个部分都以固定的格式进行编码和解码。
但是我下边的格式并不是标准的tlv格式,而是采用的lv模式,即只包含length和value。
完善消息节点
class MsgNode { public: //这里的构造方法主要方便后续调用Send接口构造消息节点 MsgNode(char* msg, short data_len) : total_len(data_len + HEAD_LENGTH), cur_len(0) { _data = new char[total_len + 1]; memcpy(_data, &data_len, HEAD_LENGTH); memcpy(_data + HEAD_LENGTH, msg, data_len); _data[total_len] = '\0'; } //这里的构造方法则是用于在进行切包过程中构造处理数据的节点 MsgNode(short data_len) :total_len(data_len), cur_len(0) { _data = new char[total_len + 1]; } //Clear方法是用于清理节点的数据,避免多次构造析构节点 void Clear() { memset(_data, 0, total_len); cur_len = 0; } ~MsgNode() { delete[] _data; } private: friend class Session; //表示已经处理的数据长度 int cur_len; //表示处理数据的总长度 int total_len; //表示数据的首地址 char* _data; };
完善两个构造函数和添加Clear函数
1、第一个构造方法主要方便后续调用Send接口构造消息节点
2、第二个构造方法则是用于在进行切包过程中构造处理数据的节点
3、Clear方法是用于清理节点的数据,避免多次构造析构节点
session类完善
_recv_msg_node用于存放收到数据包中的数据
_b_head_parse表示头部是否解析完成
_recv_head_node用于存放接收到数据包中的头部信息
完善hand_read回调函数
void Session::handle_read(const boost::system::error_code& ec, size_t bytes_transferred, std::shared_ptr<Session> self_shared) { if (ec) { std::cout << "read error, error code: " << ec.value() << " read message: " << ec.message() << std::endl; Close(); server_->ClearSession(uuid); } else { PrintRecvData(data_, bytes_transferred); std::chrono::milliseconds dura(2000); std::this_thread::sleep_for(dura); //已经移动的字节数 int copy_len = 0; while (bytes_transferred) { //头部尚未解析完成 if (!_b_head_parse) { //收到的数据不足头部大小,这种情况很少发生 if (bytes_transferred + _recv_head_node->cur_len < HEAD_LENGTH) { memcpy(_recv_head_node->_data + _recv_head_node->cur_len, data_ + copy_len, bytes_transferred); _recv_head_node->cur_len += bytes_transferred; memset(data_, 0, MAX_LENGTH); sock_.async_read_some(boost::asio::buffer(data_, MAX_LENGTH), std::bind(&Session::handle_read, this, std::placeholders::_1, std::placeholders::_2, self_shared)); return; } //走到这里,说明收到的数据大于头部,可能是一个粘连的数据包,但是首先需要将头部节点两字节读完 //处理头部剩余未复制的长度 int head_remain = HEAD_LENGTH - _recv_head_node->cur_len; if (head_remain) { memcpy(_recv_head_node->_data + _recv_head_node->cur_len, data_ + copy_len, head_remain); //更新已处理的数据 copy_len += head_remain; /* * 这里不能更新头部节点的cur_len。 * 因为 * 1、当一次进来cur_len等于0,处理之后的偏移量copy_len就为2 * 2、当头部未读取完成,后续读取会修正为正确的偏移量(但是种情况很少发生) * 3、之后的读取头部信息都会发生覆盖 */ //_recv_head_node->cur_len += head_remain; bytes_transferred -= head_remain; } //获取头部数据 short data_len = 0; memcpy(&data_len, _recv_head_node->_data, HEAD_LENGTH); std::cout << "data_len is " << data_len << std::endl; if (data_len > MAX_LENGTH) { std::cout << "invalid data length is " << data_len << std::endl; server_->ClearSession(uuid); return; } //头部节点处理完成,就可以开始处理数据域的数据节点 _recv_msg_node = std::make_shared<MsgNode>(data_len); //消息长度小于头部规定长度,说明数据未收全,则先将消息放到接收节点中 if (bytes_transferred < data_len) { memcpy(_recv_msg_node->_data + _recv_msg_node->cur_len, data_ + copy_len, bytes_transferred); _recv_msg_node->cur_len += bytes_transferred; memset(data_, 0, MAX_LENGTH); sock_.async_read_some(boost::asio::buffer(data_, MAX_LENGTH), std::bind(&Session::handle_read, this, std::placeholders::_1, std::placeholders::_2, self_shared)); //表示头部处理完成,当下次进来的时候,就会直接跳过头部处理环节 _b_head_parse = true; return; } //走到这里表示消息长度大于头部规定长度,这里可能是一个完整包,也可能是多个粘连的包 memcpy(_recv_msg_node->_data + _recv_msg_node->cur_len, data_ + copy_len, data_len); _recv_msg_node->cur_len += data_len; copy_len += data_len; bytes_transferred -= data_len; _recv_msg_node->_data[_recv_msg_node->total_len] = '\0'; std::cout << "receive data is: " << _recv_msg_node->_data << std::endl; //调用send发送给客户端 Send(_recv_msg_node->_data, _recv_msg_node->total_len); //继续轮询处理下个未处理的数据,重置数据包和头部解析的情况 _b_head_parse = false; _recv_msg_node->Clear(); //说明这不是一个多个粘连的数据包 if (bytes_transferred <= 0) { memset(data_, 0, MAX_LENGTH); sock_.async_read_some(boost::asio::buffer(data_, MAX_LENGTH), std::bind(&Session::handle_read, this, std::placeholders::_1, std::placeholders::_2, self_shared)); return; } //走到这里说明这就是一个多个粘连的数据包 continue; } //走到这里就说明头部是已经解析完成的,是处理数据未收全的情况 int remain_msg = _recv_msg_node->total_len - _recv_msg_node->cur_len; //说明收到的数据仍然不足头部规定大小的情况 if (bytes_transferred < remain_msg) { memcpy(_recv_msg_node->_data + _recv_msg_node->cur_len, data_ + copy_len, bytes_transferred); _recv_msg_node->cur_len += bytes_transferred; memset(data_, 0, MAX_LENGTH); sock_.async_read_some(boost::asio::buffer(data_, MAX_LENGTH), std::bind(&Session::handle_read, this, std::placeholders::_1, std::placeholders::_2, self_shared)); return; } //走到这里说明收到的数据是大于等于头部规定大小的,接收到的数据可能是个完整的数据包,也可能多个粘连的数据包 memcpy(_recv_msg_node->_data + _recv_msg_node->cur_len, data_ + copy_len, remain_msg); _recv_msg_node->cur_len += remain_msg; bytes_transferred -= remain_msg; copy_len += remain_msg; _recv_msg_node->_data[_recv_msg_node->total_len] = '\0'; std::cout << "receive data is: " << _recv_msg_node->_data << std::endl; //处理完当前数据包的分割后,调用send接口向客户端发送回去 Send(_recv_msg_node->_data, _recv_msg_node->total_len); //继续轮询处理下个数据包,重置接收数据节点和头部解析情况 _b_head_parse = false; _recv_msg_node->Clear(); //说明数据包并不是粘连的 if (bytes_transferred <= 0) { memset(data_, 0, MAX_LENGTH); sock_.async_read_some(boost::asio::buffer(data_, MAX_LENGTH), std::bind(&Session::handle_read, this, std::placeholders::_1, std::placeholders::_2, self_shared)); return; } //走到这里说明数据包是粘连的 continue; } } }
这里hand_read函数的完善逻辑代码比较长,其中的注释给的比较详细,需要各位仔细读。但是逻辑可能头一两次读可能还是会有些蒙,多读几遍可能就会好得多。
这里还是得必要得说一下,我们都知道异步读写函数得回调函数中的参数bytes_transferred表示已经读取到的字节数,但是我们在这里还是需要对这些已经读到的数据进行处理。其中定义copy_len表示已经处理的字节数,bytes_transferred则表示为还未处理的数据(尽管已经被读取到了,但是还是尚未被处理,需要好好理解下)。
这里在session类中还定义了两个宏,MAX_LENGTH表示数据包的最大长度,就是1024*2字节。HEAD_LENGTH表示头部长度,就是2字节。
这里我也画了一个逻辑图供大家梳理这里的代码逻辑,希望能对大家理解有帮助。
粘包现象的测试
在session类中写一个打印函数,在每次触发读事件回调的时候调用下这个函数。这里打印的是tcp缓冲区的数据,boost asio从tcp已经是已经做了将tcp缓冲区的数据拿出来的,所以这里打印即可。
为了制造粘包现象,我们可以让服务器端隔2s处理一次读写,而客户端则不停的发送和读取就能制造出粘包现象了。下边是提供的客户端的代码。
#include <iostream> #include <boost/asio.hpp> #include <thread> using namespace std; using namespace boost::asio::ip; const int MAX_LENGTH = 1024 * 2; const int HEAD_LENGTH = 2; int main() { //测试粘包现象客户端 try { //创建上下文服务 boost::asio::io_context ioc; //构造endpoint tcp::endpoint remote_ep(address::from_string("127.0.0.1"), 1234); tcp::socket sock(ioc); boost::system::error_code error = boost::asio::error::host_not_found; sock.connect(remote_ep, error); if (error) { cout << "connect failed, code is " << error.value() << " error msg is " << error.message(); return 0; } thread send_thread([&sock] { for (;;) { this_thread::sleep_for(std::chrono::milliseconds(2)); const char* request = "hello world!"; size_t request_length = strlen(request); char send_data[MAX_LENGTH] = { 0 }; memcpy(send_data, &request_length, 2); memcpy(send_data + 2, request, request_length); boost::asio::write(sock, boost::asio::buffer(send_data, request_length + 2)); } }); thread recv_thread([&sock] { for (;;) { this_thread::sleep_for(std::chrono::milliseconds(2)); cout << "begin to receive..." << endl; char reply_head[HEAD_LENGTH]; size_t reply_length = boost::asio::read(sock, boost::asio::buffer(reply_head, HEAD_LENGTH)); short msglen = 0; memcpy(&msglen, reply_head, HEAD_LENGTH); char msg[MAX_LENGTH] = { 0 }; size_t msg_length = boost::asio::read(sock, boost::asio::buffer(msg, msglen)); std::cout << "Reply is: "; std::cout.write(msg, msglen) << endl; std::cout << "Reply len is " << msglen; std::cout << "\n"; } }); send_thread.join(); recv_thread.join(); } catch (std::exception& e) { std::cerr << "Exception: " << e.what() << endl; } return 0; }
现象如下图,测试环境Windows visual studio
完整服务端代码:codes-C++: C++学习 - Gitee.com
这里的echo服务器实现了粘包的处理,但是在不同的平台下仍存在收发数据异常的问题,其根本原因就是平台大小端的差异。