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

半桔个人主页
🔥 个人专栏 : 《Linux手册》《手撕面试算法》《C++从入门到入土》

🔖只要你行动,你的脑中自然会开始浮现计划,脚踏实地的感觉也会带给你自信。​ -松浦弥太郎-


文章目录

  • 前言
  • [一. 思路](#一. 思路)
  • [二. 定制协议](#二. 定制协议)
  • [三. 序列化与反序列化](#三. 序列化与反序列化)
    • [3.1 构建Request](#3.1 构建Request)
    • [3.2 构建Response](#3.2 构建Response)
  • [四. 计算类](#四. 计算类)
  • [五. 服务端](#五. 服务端)

前言

TCP 作为 TCP/IP 协议族核心,凭借可靠连接特性支撑工业指令交互、物联网数据上报等场景,但其面向字节流传输特性易导致 "粘包""拆包"问题 ------ 接收端无法识别报文边界,可能引发设备误操作或数据解析错误。

本文将围绕 "确保报文完整" 设计封装与解包逻辑:封装时通过给数据加自定义标识(如长度字段、校验位),解包时按规则提取完整报文并处理异常,来解决 TCP 字节流与业务报文的适配问题。

一. 思路

TCP是操作系统网络模块的一部分,用户与服务端建立通信之后,可以直接通过write接口将数据交给操作系统,由操作系统将数据何时发送到网络中,一次发送多大

接收方通过read接口将数据拿上来,但是拿上来的数据中含有几个完整的报文是不确定的。因此我们有必要设置数据的起始和结束位置,来保证接收方进行报文解析的时候,可以判断是否是一个完整的报文。

本篇文章我们将实现一个基于TCP协议的网络版本计算器。

现在我们有三种实现方案:

  1. 使用形如 "1 + 1"的字符串进行发送和读取;
  2. 使用结构体,设置两个结构体RequestResponse,通过互传结构体来实现通信;
  3. 使用字符串 + 结构体的形式,通信的时候使用字符串来交流,将字符串拿上来后将其转化为结构体的信息进行信息的读取。

对于方案一:如果对字符串进行封装确定可以达到通信的效果,即通过特殊的方式来划分一个报文,但是在解析的时候就比较麻烦,并且代码耦合度较高,不推荐;

对于方案二:每一次发送一个结构体,这种方法是不可靠的,因为在不同电脑上结构体的内存对其方式可能是不一样的。

所以我们将这两种方法进行整合,通过字符串进行通信,将读取到的字符串转化为结构体。

  • 通过这种方式进行通信,就要求我们将结构体转换为字符串,同样将字符串转化为结构体,该过程被称为序列化反序列化 ;示意图如下:

关于序列化和反序列化有现成的工具:json和protobuf可以使用,但是为了更深的理解,此处我们手动来实现序列化和反序列化。

二. 定制协议

现在我们想要保证接收方在接收到数据之后能够判断读取上来的数据中是否存在一个完成的报文,因此现在我们需要进行协议的定制:定制一个报文的起始和结束标志

我们用特殊字符 + 特殊报头来进行定制:我们在每个报文前面添加一个报头,该报头中存储有效载荷的长度,报头与有效载荷间用特殊字符分开,每个报文间也用特殊符号分割。

比如,我们使用的特殊符号是 | ,则1 + 1会被定制为5|1 + 1|来发送给对方,其中5指的是1 + 1的总长度,包括空格。

现在我们先来实现一下将一个字符串添加报头的功能:在添加报头的时候很简单,就是添加长度以及分割符即可:

cpp 复制代码
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;
}

接下来就需要实现,将一个报文中的有效载荷分离出来,还需要判断获取到的字符串是否含有一个完成的报文,具体实现方法如下:

  1. 先找第一个分隔符,看报头是否完成;
  2. 报头如果完成就可以获得有效载荷的长度了;
  3. 计算出该完整报文的长度,与字符串长度比较,判断是否可以含有一个完整的报文结构。
cpp 复制代码
// 将报头与有效载荷分离
// 要能够判断是否是一个完整的报文
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结构体的编写,首先要能够存储问题,因此需要三个成员:两个操作数和一个运算符

为了方便,我们只支持整形计算:

cpp 复制代码
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_;    
};

先一步就是进行序列化和反序列化:

cpp 复制代码
    // 序列化 , 将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还需要提供三个接口让外界获取这两个操作数和对应的操作符,方便后续进行计算:

cpp 复制代码
    int Get_first() const
    {
        return data1_;
    }

    int Get_second() const
    {
        return data2_;
    }
    
    char Get_op() const
    {
        return op_;
    }

3.2 构建Response

  • Response包含两个操作,需要告诉对方计算的答案,以及答案是否合法(即如果对象进行了除零操作,就告诉他操作不合法);依次需要两个私有成员
cpp 复制代码
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类似:

cpp 复制代码
    // 序列化
    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));
    }

为了方便后续打印操作,我们需要对输出运算符进行重载,当然需要设为友元函数:

cpp 复制代码
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;
}

四. 计算类

下一步我们需要结合RequestResponse来进行计算,我们也封装一个类,通过重载调用运算符来实现将获取的字符串提取 + 解包 + 反序列化获得Request + 计算 + 序列化Reponse + 封装返回

具体操作如下:

  1. 解包
  2. 将字符串转Request
  3. 计算Request
  4. 将计算结果转为Response
  5. 序列化Response + 加报头

计算操作并不难,就是根据操作符进行计算,再调用我们上面的接口来序列化以及反序列化:

cpp 复制代码
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中会进行字符串的判断,如果有完整报文,就会提取出来,如果没有就会返回空串。
cpp 复制代码
    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());
            }   
        }
    }

以上就是整个序列化和反序列化以及客户端的接口的实现了。

相关推荐
ZeroNews内网穿透4 小时前
新版发布!“零讯”微信小程序版本更新
运维·服务器·网络·python·安全·微信小程序·小程序
<但凡.5 小时前
Linux 修炼:进程控制(一)
linux·运维·服务器·bash
✎﹏赤子·墨筱晗♪6 小时前
Ansible Playbook 入门指南:从基础到实战
linux·服务器·ansible
太阳伞下的阿呆6 小时前
Http与WebSocket
websocket·网络协议·http
乌萨奇也要立志学C++7 小时前
【Linux】进程概念(六):进程地址空间深度解析:虚拟地址与内存管理的奥秘
linux·运维
GUIQU.7 小时前
【QT】嵌入式开发:从零开始,让硬件“活”起来的魔法之旅
java·数据库·c++·qt
西阳未落10 小时前
C++基础(21)——内存管理
开发语言·c++·面试
月殇_木言11 小时前
Linux 线程
linux
超级大福宝11 小时前
使用 LLVM 16.0.4 编译 MiBench 中的 patricia遇到的 rpc 库问题
c语言·c++