【网络编程】TCP 粘包处理:手动序列化反序列化与报头封装的完整方案

文章目录
- 前言
- 一. 思路
- 二. 定制协议
- 三. 序列化与反序列化
- 3.1 构建Request
- 3.2 构建Response
- 四. 计算类
- 五. 服务端
前言
TCP 作为 TCP/IP 协议族核心,凭借可靠连接特性支撑工业指令交互、物联网数据上报等场景,但其面向字节流传输特性易导致 “粘包”“拆包”问题 —— 接收端无法识别报文边界,可能引发设备误操作或数据解析错误。
本文将围绕 “确保报文完整” 设计封装与解包逻辑:封装时通过给数据加自定义标识(如长度字段、校验位),解包时按规则提取完整报文并处理异常,来解决 TCP 字节流与业务报文的适配问题。
一. 思路
TCP是操作系统网络模块的一部分,用户与服务端建立通信之后,可以直接通过write
接口将数据交给操作系统,由操作系统将数据何时发送到网络中,一次发送多大。
接收方通过read
接口将数据拿上来,但是拿上来的数据中含有几个完整的报文是不确定的。因此我们有必要设置数据的起始和结束位置,来保证接收方进行报文解析的时候,可以判断是否是一个完整的报文。
本篇文章我们将实现一个基于TCP协议的网络版本计算器。
现在我们有三种实现方案:
- 使用形如 "1 + 1"的字符串进行发送和读取;
- 使用结构体,设置两个结构体
Request
和Response
,通过互传结构体来实现通信; - 使用字符串 + 结构体的形式,通信的时候使用字符串来交流,将字符串拿上来后将其转化为结构体的信息进行信息的读取。
对于方案一:如果对字符串进行封装确定可以达到通信的效果,即通过特殊的方式来划分一个报文,但是在解析的时候就比较麻烦,并且代码耦合度较高,不推荐;
对于方案二:每一次发送一个结构体,这种方法是不可靠的,因为在不同电脑上结构体的内存对其方式可能是不一样的。
所以我们将这两种方法进行整合,通过字符串进行通信,将读取到的字符串转化为结构体。
- 通过这种方式进行通信,就要求我们将结构体转换为字符串,同样将字符串转化为结构体,该过程被称为序列化和反序列化;示意图如下:
关于序列化和反序列化有现成的工具:json和protobuf可以使用,但是为了更深的理解,此处我们手动来实现序列化和反序列化。
二. 定制协议
现在我们想要保证接收方在接收到数据之后能够判断读取上来的数据中是否存在一个完成的报文,因此现在我们需要进行协议的定制:定制一个报文的起始和结束标志。
我们用特殊字符 + 特殊报头来进行定制:我们在每个报文前面添加一个报头,该报头中存储有效载荷的长度,报头与有效载荷间用特殊字符分开,每个报文间也用特殊符号分割。
比如,我们使用的特殊符号是 |,则1 + 1
会被定制为5|1 + 1|
来发送给对方,其中5指的是1 + 1
的总长度,包括空格。
现在我们先来实现一下将一个字符串添加报头的功能:在添加报头的时候很简单,就是添加长度以及分割符即可:
const std::string protocal_sep = "|";
const size_t protocal_sep_len = protocal_sep.size();// 添加报头
std::string Encode(const std::string &content)
{ // 1 + 1 -----> 5|1 + 1|std::string package = std::to_string(content.size()) + protocal_sep + content + protocal_sep;return package;
}
接下来就需要实现,将一个报文中的有效载荷分离出来,还需要判断获取到的字符串是否含有一个完成的报文,具体实现方法如下:
- 先找第一个分隔符,看报头是否完成;
- 报头如果完成就可以获得有效载荷的长度了;
- 计算出该完整报文的长度,与字符串长度比较,判断是否可以含有一个完整的报文结构。
// 将报头与有效载荷分离
// 要能够判断是否是一个完整的报文
bool Decode(std::string &package , std::string& content) // content是一个输出型参数
{size_t sep_pos = package.find(protocal_sep);if(sep_pos == std::string::npos) return false; // 不是完成的报文std::string len_str = package.substr(0 , sep_pos); size_t len = std::stoi(len_str);size_t message_len = len + len_str.size() + 2*protocal_sep_len; // 这个完整报文的长度if(message_len > package.size()) return false; // 不包含完成的报文,只包含一部分// 将报文从字符串中取下来content = package.substr(sep_pos + protocal_sep_len , len);package.erase(0 , message_len); return true;
}
三. 序列化与反序列化
3.1 构建Request
首先就是序列化与反序列化的结构体:我们采用两个结构体来实现,其中用Request
存储问题请求,用Response
来存放结果。
- 先进行
Request
结构体的编写,首先要能够存储问题,因此需要三个成员:两个操作数和一个运算符。
为了方便,我们只支持整形计算:
const std::string blank_space_sep = " ";
class Request
{
public:Request(const int &data1 , const char &op , const int &data2):data1_(data1) , op_(op) , data2_(data2){}
private:int data1_;char op_;int data2_;
};
先一步就是进行序列化和反序列化:
// 序列化 , 将Request转化为字符串std::string Serialize(){// 字符与字符间用空格隔开std::string content = std::to_string(data1_) + blank_space_sep + op_ + blank_space_sep + std::to_string(data2_);return content;}// 反序列化 , 将字符串转化为结构体Request(const std::string &content){// 1 + 1// 提取出数字和操作符size_t prev_date_pos = content.find(blank_space_sep);size_t back_data_pos = content.rfind(blank_space_sep); // 从后往前找data1_ = std::stoi(content.substr(0 , prev_date_pos));data2_ = std::stoi(content.substr(back_data_pos));op_ = content[prev_date_pos + 1];}
Request
还需要提供三个接口让外界获取这两个操作数和对应的操作符,方便后续进行计算:
int Get_first() const{return data1_;}int Get_second() const{return data2_;}char Get_op() const{return op_;}
3.2 构建Response
Response
包含两个操作,需要告诉对方计算的答案,以及答案是否合法(即如果对象进行了除零操作,就告诉他操作不合法);依次需要两个私有成员:
enum // 结果的状态信息
{Right = 1,Division_Zero,No_Operaotr,
};class Response
{
public:Response(const int& result , const int& mode):result_(result) , mode_(mode){} private:int result_; // 结果int mode_; // 状态
};
接下来依旧是进行序列化和反序列化,与上一个Request
类似:
// 序列化std::string Serialize(){std::string content = std::to_string(result_) + blank_space_sep + std::to_string(mode_);return content;}// 反序列化Response(const std::string &content){size_t blank_pos = content.find(blank_space_sep);result_ = std::stoi(content.substr(0 , blank_pos));mode_ = std::stoi(content.substr(blank_pos + 1));}
为了方便后续打印操作,我们需要对输出运算符进行重载,当然需要设为友元函数:
std::ostream& operator<<(std::ostream& out , const Response& rep)
{if(rep.mode_ == Right){out << "the answer is : " << rep.result_ ;}else if(rep.mode_ == Division_Zero){out << "incorrect operation : Division Zero !!!";}else if(rep.mode_ == No_Operaotr){out << "incorrect operation : No_Operaotr !!!" ;}return out;
}
四. 计算类
下一步我们需要结合Request
和Response
来进行计算,我们也封装一个类,通过重载调用运算符来实现将获取的字符串提取 + 解包 + 反序列化获得Request + 计算 + 序列化Reponse + 封装返回:
具体操作如下:
- 解包
- 将字符串转Request
- 计算Request
- 将计算结果转为Response
- 序列化Response + 加报头
计算操作并不难,就是根据操作符进行计算,再调用我们上面的接口来序列化以及反序列化:
class Calculator
{Response CalculatorHelper(const Request& rep){int result = 0 ;int node = Right; // 用1表示计算结果正确int first = rep.Get_first() , second = rep.Get_second();switch (rep.Get_op()){case '+':{result = first + second;break;}case '-':{result = first - second;break;}case '*':{result = first * second;break;}case '/':{if(second == 0) node = Division_Zero;else result = first / second;break;}case '%':{if(second == 0) node = Mode_Zero;else result = first % second;break;}default:node = No_Operaotr;break;}return Response(result , node);}public:std::string operator()(std::string &package){// 1.解包// 2.将字符串转Request// 3.计算Request// 4.将计算结果转为Response// 5.序列化Response + 加报头std::string content ;if(Decode(package , content) == false) return "" ; // 表示没有完整的报文,不需要进行计算// 此时content中是有效载荷,进行反序列化Request req(content);// 进行计算Response res = CalculatorHelper(req);return Encode(res.Serialize());}
};
五. 服务端
在上一篇博客---->【网络编程】TCP 服务器并发编程:多进程、线程池与守护进程实践中我们详细介绍了服务端的编写,此处只需要对线程执行的函数部分进行简单的修改即可:
- 因为可能存在不完整的报文,因此我们要将接收到的信息全部都存储起来;
- Cal中会进行字符串的判断,如果有完整报文,就会提取出来,如果没有就会返回空串。
void Service(int fd_){static Calculator cal;std::string message;char buffer[1024];while (1){memset(buffer, 0, sizeof(buffer));int n = read(fd_, buffer, sizeof(buffer) - 1);if(n > 0){buffer[n] = 0;message += buffer;std::string ret = cal(message);if(ret.size() == 0) continue;write(fd_ , ret.c_str() , ret.size());} }}
以上就是整个序列化和反序列化以及客户端的接口的实现了。