【网络】自定义(应用层)协议——序列化和反序列化

我们接着上一篇:http://t.csdnimg.cn/Xt18d

我们之前写的代码都是在应用层 的,而TCP是在应用层下面一层的传输层

1.自定义协议(应用层)

1.1.应用层和传输层的关系

应用层和传输层的概述

  • 应用层 :位于网络协议的最高层,直接为用户提供服务。这一层的协议如HTTP、FTP、SMTP等,负责处理特定应用程序的数据传输和通信。
  • 传输层:位于应用层之下,网络层之上。它提供端到端的通信服务,确保数据在网络中的可靠传输。传输层协议如TCP和UDP,管理数据包的顺序和流量控制。

应用层和传输层的区别

  • 目的:应用层的目的是为了支持各种应用程序的特定需求,而传输层则致力于提供通用的通信服务。
  • 可靠性:应用层协议可以根据应用程序的需求提供可靠或不可靠的服务。而传输层协议,如TCP,保证数据包的顺序和完整性,提供可靠的端到端通信。
  • 数据传输方式:应用层协议如HTTP、FTP等,通常基于TCP或UDP传输数据。而传输层协议则负责管理这些数据包的发送和接收。
  • 服务类型:应用层协议服务于各种类型的应用,如网页浏览、电子邮件、文件传输等。而传输层协议主要为应用程序提供通用的数据传输服务。

应用层和传输层的关系

应用层和传输层在网络通信中是紧密相关的。传输层提供了一种可靠的数据传输机制,确保数据在网络中的正确传输,而**应用层协议定义了特定应用程序的数据格式和通信规则。**两者之间的协同工作使得应用程序能够有效地进行数据交换和通信。

例如,当我们使用浏览器访问一个网页时,HTTP(应用层协议)定义了请求和响应的格式,而TCP(传输层协议)确保了数据的可靠传输。两者共同作用,使得我们能够顺利地浏览网页。

我们今天定制的协议就是在应用层的协议(说白了就是自己写个代码),而TCP是传输层的协议

2.回顾问题

**服务器在调用网络接收函数进行TCP协议层接收缓冲区的数据拷贝到应用层时,有一个问题,如果客户端发送的报文很多怎么办?**接收缓冲区会堆积很多的报文,而这些报文都会黏到一起,服务器该怎么保证read的时候读取到的是一个完整的报文呢?

为了解决这个问题,就需要我们在应用层定制协议明确报文和报文的边界。

常见的解决方式有定长,特殊符号,自描述方式等,而我们今天所写的代码会将后两个方式合起来一块使用,我们会进行报文与报文之间添加特殊符号进行分隔,同时还会在报文前增加报头来表示报文的长度,以及在报头和报文的正文间增加特殊符号进行分隔,那么在读取的时候就可以以报头和报文之间的特殊符号作为依据先将报头读取上来,然后报头里存储的不是正文长度吗?那我们就再向后读取正文长度个字节,这样就完成了一个完整报文的读取。

2.序列化和反序列化

2.1.引言

我们先提供一个简单的例子来理解一下。

在我们之前写的TCP的服务器里面,我们发送的是字符串。假如我们现在需要发送结构化数据,那应该怎么办?

我们知道,tcp是面向字节流的,也就是其能够发送任意数据。也能够发送C语言结构体的二进制数据;但能发送,就代表我们可以这么干吗?

答案自然是不行!

不同平台,对结构体对齐的配置不同,大小端不同,其最终对我们字节流的解析也就不一样。如果采用直接发送结构体数据的方式来通信,适配性极低,我们的客户端和服务端都会被限制在当前的系统环境中运行;

可是,哪怕是同一个系统,其内部对大小端的配置也有可能改变!到时候我们的代码恐怕就无法运行了!

同理,在当初编写C语言通讯录的代码的时候,也不能采用直接将结构体数据写入文件的方式。后续代码升级、环境改变,都可能导致我们存在文件中的数据失效,这肯定是我们不希望看到的情况。

所以,为了解决这个问题,我们就应该将数据进行序列化之后再发送,客户端接收到信息后,进行反序列化解析出数据!

我们再举个例子理解一下

在定制协议的时候,一定是离不开序列化和反序列化的,这两个名词听起来高大上,实际啥也不是。在网络发送数据时,比如我要发头像URL,时间,昵称,消息等字段,如果我一个一个发送的话,效率很非常的低,并且接收的一方也会很痛苦,这么多数据接收方该如何分辨哪个是头像,哪个是时间,哪个是昵称,哪个是消息呢?

我们为什么不一次性直接全部发出去?为了实现这个功能,引入了序列化和反序列化。

2.1.序列化和反序列化

所谓序列化,就是将结构化的数据(可以暂时理解为c的结构体)转换成字符串(后面也叫报文)的方式,发送出去

cpp 复制代码
struct date
{
    int year;
    int month;
    int day;
};

比如上面这个日期结构体,我们要想将其序列化,就可以用一个很简单的方式拼接成一个字符串(序列化)

cpp 复制代码
year-month-day

客户端收到这个字符串(报文)之后,就可以通过查找分隔符-的方式,取出三个变量,将其转成int后存放回结构体(反序列化)

这样,我们就算是规定了一个序列化和反序列化的方式,也就是一个简单的协议

有点像下面这样子!

2.2.编码解码

这里还会出现另外一个问题,我要怎么知道我已经读取完毕了一个序列化后的数据呢?

cpp 复制代码
2000-12-10
10000-01-01

如上,假设有一天,我们的年变成了五位数;这时候,服务端要怎么知道自己是否读取完毕了一个完整的序列化数据呢?

这就需要我们做好规定,将前n字节作为标识长度的数据。接收到数据后,先取出前n个字节,读取道此次消息的长度m,再往后读取m个字节的数据,成功取出完整的字符串;

这个过程可以称作编码和解码的过程

为了区分标识长度的数据和实际需要的序列化内容,我们可以在之中加上分隔符\t;但这也需要我们确认,传输的数据本身不能带上\t,否则会产生一系列的问题

cpp 复制代码
10\t2000-12-10\t
11\t10000-01-01\t

以上的这一系列工作,都是协议定制 的一部分!我们给服务端和客户端规定了一个序列化和反序列化的方式,让二者通信规避掉了平台的限制。毕竟任何平台对字符串解码出来的数据都会是相同的!

定制协议,序列化和反序列化

下面就用一个计算器的服务,来演示一下吧😏

3.网络版计算器

因为本文的重心是对协议定制的演示,所以这里的计算器不考虑连续操作符的情况,

3.1 协议定制

要想实现一个计算器,我们首先要搞明白计算器有几个成员

cpp 复制代码
x+y
x/y
x*y
...

一般情况下,一个计算器只需要3个成员,分别是两个操作数和一个运算符,就能开始计算。所以我们需要将这里的三个字段设计成一个字符串,实现序列化;

比如我们应该规定序列化之后的数据应该是如下的,两个操作数和操作符之间应该要有空格

cpp 复制代码
a + b

再在开头添加上数据长度的标识

cpp 复制代码
数据长度\t公式\t

7\t10 + 20\t
8\t100 / 30\t
9\t300 - 200\t

对于服务端,我们需要返回两个参数:状态码和结果

cpp 复制代码
退出状态 结果

如果退出状态不为0,则代表出现错误,结果无效;只有退出结果为0,结果才是有效的。

同样的,也需要给服务器的序列化字符串添加上数据的长度

数据长度\t退出状态 结果\t

这样就搞定了一个计算器的自定义协议;

协议就是双方达成的约定!!

3.2.定义数据类型

依照如上的协议,先把请求和返回的成员变量写好

cpp 复制代码
class Request
{
public:
    int _x;
    int _y;
    char _ops;
};
cpp 复制代码
class Response//服务端必须回应
{
public:
	int _exitCode; //计算服务的退出码
	int _result;  // 结果
};  

这些成员变量都设置为公有,方便在task里面进行处理(否则就需要写get函数,很麻烦)

同时,最好还是把协议中的分隔符给定义出来,方便后续统一使用or更改

cpp 复制代码
#define CRLF "\t"   //分隔符
#define CRLF_LEN strlen(CRLF) //分隔符长度
#define SPACE " "   //空格
#define SPACE_LEN strlen(SPACE) //空格长度

#define OPS "+-*/%" //运算符

3.3 编码解码

编码的是往字符串的开头添加上长度和分隔符

cpp 复制代码
长度\t序列化字符串\t

解码就是将长度和分隔符去掉,只解析出序列化字符串

cpp 复制代码
序列化字符串

编码解码的整个过程在注释里面都写明了 为了方便请求和回应去使用,直接放到外头,不做类内封装

cpp 复制代码
//参数len为in的长度,是一个输出型参数。如果为0代表err
std::string decode(std::string& in,size_t*len)
{
    assert(len);//如果长度为0是错误的
    
    // 1.确认in的序列化字符串完整(分隔符)
    *len=0;

    size_t pos = in.find(CRLF);//查找\t第一次出现时的下标
    //查找不到,err
    if(pos == std::string::npos){
        return "";//返回空串
    }
   
    // 2.有分隔符,判断长度是否达标
    // 此时pos下标正好就是标识大小的字符长度
    std::string inLenStr = in.substr(0,pos);//从下标0开始一直截取到第一个\t之前

    //到这里我们要明白,我们这上面截取的是最开头的长度,也就是说,我们截取到的一定是个数字,这个是我们序列化字符的长度
    
    size_t inLen = atoi(inLenStr.c_str());//把截取的这个字符串转int,inLen就是序列化字符的长度

    //传入的字符串的长度 - 第一个\t前面的字符数 - 2个\t
    size_t left = in.size() - inLenStr.size()- 2*CRLF_LEN;//原本预计的序列化字符串长度
    if(left<inLen){//真实的序列化字符串长度和预计的字符串长度进行比较
        return ""; //剩下的长度(序列化字符串的长度)没有达到标明的长度
    }

    // 3.走到此处,字符串完整,开始提取序列化字符串
    std::string ret = in.substr(pos+CRLF_LEN,inLen);//从pos+CRLF_LEN下标开始读取inLen个长度的字符串------即序列化字符串

    *len = inLen;

    // 4.因为in中可能还有其他的报文(下一条)
    // 所以需要把当前的报文从in中删除,方便下次decode,避免二次读取
    size_t rmLen = inLenStr.size() + ret.size() + 2*CRLF_LEN;//长度+2个\t+序列字符串的长度
    in.erase(0,rmLen);//移除从索引0开始长度为rmLen的字符串

    // 5.返回
    return ret;
}

//编码不需要修改源字符串,所以const。参数len为in的长度
std::string encode(const std::string& in,size_t len)
{
    std::string ret = std::to_string(len);//将长度转为字符串添加在最前面,作为标识
    ret+=CRLF;
    ret+=in;
    ret+=CRLF;
    return ret;
}

这里有一些点需要提一下:

  • 1.string的find成员函数

在C++中,std::string 类的 find 成员函数用于在字符串中搜索子字符串或字符的首次出现。**如果找到了子字符串或字符,find 函数会返回子字符串或字符首次出现的位置(也就是一个下标,位置索引从0开始)。**如果没有找到,find 函数会返回一个特殊的常量 std::string::npos,这通常是一个非常大的值,表示"未找到"。

  • 2.string的substr成员函数

substr 成员函数用于获取字符串的一个子串。 它接受两个参数:起始位置和(可选的)子串的长度。如果省略长度参数,substr 会从起始位置一直截取到字符串的末尾。

函数原型大致如下:

cpp 复制代码
 string substr(size_t pos = 0, size_t len = npos) const; 
  1. pos 是子串的起始位置(从0开始计数)。
  2. len 是要截取的子串的长度(默认为 std::string::npos,表示截取到字符串末尾)。
    例如
cpp 复制代码
#include <iostream>  
#include <string>  
  
int main() {  
    std::string str = "Hello, world!";  
  
    // 使用 find 查找子字符串  
    size_t pos = str.find("world");  
    if (pos != std::string::npos) {  
        std::cout << "Found 'world' at position: " << pos << std::endl;  
    } else {  
        std::cout << "'world' not found." << std::endl;  
    }  
  
    // 使用 substr 截取子串  
    std::string substr = str.substr(7, 5); // 从索引7开始,截取5个字符  
    std::cout << "Substring: " << substr << std::endl; // 输出: Substring: world  
  
    return 0;  
}

在这个示例中,我们首先使用 find 查找子字符串 "world" 在 str 中第一次出现的位置,然后使用 substr 从该位置开始截取长度为5的子串(实际上在这个例子中,从索引7开始截取5个字符正好是整个 "world" 字符串)。

3.4 request

编码解码写好了,先来处理比较麻烦的请求部分;说麻烦吧,其实大多数也是c++的string操作,要熟练运用string的各类成员函数,才能很好的实现

3.4.1 构造

比较重要的是这个构造函数,我们需要将用户的输入转成内部的三个成员

cpp 复制代码
x+y
cpp 复制代码
用户可能输入x+y,x+ y,x +y,x + y等等格式

这里还需要注意,用户的输入不一定是标准的X+Y,里面可能在不同位置里面会有空格。为了统一方便处理,在解析之前,最好先把用户输入内的空格给去掉!

对于string而言,去掉空格就很简单了,直接一个遍历搞定

cpp 复制代码
    // 删除输入中的空格
    void rmSpace(std::string& in)
    {
        std::string tmp;
        for(auto e:in)
        {
            if(e!=' ')
            {
                tmp+=e;
            }
        }
        in = tmp;
    }

完整的构造如下

cpp 复制代码
class Request
{
public:
    // 将用户的输入转成内部成员
    // 用户可能输入x+y,x+ y,x +y,x + y等等格式
    // 提前修改用户输入(主要还是去掉空格),提取出成员
    Request(std::string in,bool* status)
        :_x(0),_y(0),_ops(' ')
    {
        rmSpace(in);//删除空格

        // 这里使用c的字符串,因为有strtok
        char buf[1024];

        // 打印n个字符,多的会被截断
        snprintf(buf,sizeof(buf),"%s",in.c_str());//将报文存到buf里面去,方便使用strtok

        char* left = strtok(buf,OPS);//left变成从字符串in的最开始到第一个运算符的这段字符串------即左操作数
        if(!left){//找不到
            *status = false;
            return;
        }
        
        //right变成从第一个运算符开始,到第二个运算符中间的这段字符串------即右操作数
        char*right = strtok(nullptr,OPS);//在上次寻找的基础上继续寻找
        if(!right){//找不到
            *status = false;
            return;
        }

        // x+y, strtok会将+设置为\0
        char mid = in[strlen(left)];//截取出操作符
        //这是在原字符串里面取出来,buf里面的这个位置被改成\0了

        _x = atoi(left);
        _y = atoi(right);
        _ops = mid;
        *status=true;
    }

public:
    int _x;
    int _y;
    char _ops;
};

这里要带大家复习一下这个strtok函数

strtok 函数是 C 语言标准库中的一个函数,用于分割字符串。它基于一组分隔符来查找字符串中的"标记"(tokens),并返回一个指向找到的标记的指针。strtok 函数会修改原始字符串,将找到的标记之后的所有分隔符替换为字符串结束符 \0,以便返回指向标记的指针实际上是指向原始字符串中的一个子字符串。

这里有一个使用 strtok 函数的简单例子,假设我们有一个由逗号分隔的字符串,我们想要分割这个字符串并打印出每一个部分

cpp 复制代码
#include <stdio.h>  
#include <string.h>  
  
int main() {  
    char str[] = "This,is,a,sample,string";  
    char delim[] = ",";  
    char *token;  
  
    // 首次调用strtok需要传入字符串和分隔符集  
    token = strtok(str, delim);  
  
    // 继续调用strtok时,第一个参数必须是NULL  
    // 以便strtok知道它应该继续从上次停止的地方开始查找  
    while( token != NULL ) {  
        printf( " %s\n", token );  
  
        token = strtok(NULL, delim);  
    }  
  
    return 0;  
}

在这个例子中,strtok 首先被调用时传入了需要分割的字符串 str 和分隔符集 delim(在这个例子中是逗号)。strtok 会查找 str 中的第一个非分隔符字符,并返回指向该字符的指针。然后,它会继续查找,直到遇到下一个分隔符或字符串结束符 \0,并在找到的分隔符处将原始字符串中的字符替换为 \0。这样,返回的指针就指向了一个以 \0 结尾的子字符串(即一个标记)。

在后续的调用中,strtok 的第一个参数必须是 NULL,以指示它应该继续从上次停止的地方(即上一个找到的标记之后)开始查找。这个过程会一直重复,直到 strtok 找不到更多的标记,此时它会返回 NULL

注意:由于 strtok 会修改原始字符串,因此如果你需要保留原始字符串的副本,你应该在调用 strtok 之前先复制它。

3.4.2 序列化

解析出成员以后,我们要做的就是对成员进行序列化,将其按指定的位置摆成一个字符串。这里采用了输出型参数的方式来序列化字符串,也可以改成用返回值的方式来操作。

这里需要注意的是,操作符本身就是char不能使用to_string来操作,会被转成ascii码,不符合我们的需求

cpp 复制代码
// 序列化 (入参应该是空的)
void serialize(std::string& out)
{
    // x + y
    out.clear(); // 序列化的入参是空的
    out+= std::to_string(_x);
    out+= SPACE;
    out+= _ops;//操作符不能用tostring,会被转成ascii
    out+= SPACE;
    out+= std::to_string(_y);
    // 不用添加分隔符(这是encode要干的事情)
}

3.4.3 反序列化

注意,思路不能搞错了。刚开始我认为request的反序列化应该针对的是服务器的返回值,实际并非如此!

在客户端和服务端都需要使用request,客户端进行序列化,服务端对接收到的结果利用request进行反序列化。request只关注于对请求的处理,而不处理服务器的返回值。

cpp 复制代码
// 反序列化
bool deserialize(const std::string &in)
{
    // x + y 需要取出x,y和操作符
    size_t space1 = in.find(SPACE); //第一个空格
    if(space1 == std::string::npos) //没找到
    {
        return false;
    }

    size_t space2 = in.rfind(SPACE); //第二个空格
    if(space2 == std::string::npos)  //没找到
    {
        return false;
    }

    // 两个空格都存在,开始取数据
    std::string dataX = in.substr(0,space1);
    std::string dataY = in.substr(space2+SPACE_LEN);//默认取到结尾
    
    std::string op = in.substr(space1+SPACE_LEN,space2 -(space1+SPACE_LEN));
    if(op.size()!=1)
    {
        return false;//操作符长度有问题
    }

    //没问题了,转内部成员
    _x = atoi(dataX.c_str());
    _y = atoi(dataY.c_str());
    _ops = op[0];
    return true;
}

3.5 response

3.5.1 构造

返回值的构造比较简单,因为是服务器处理结果之后的操作;这些成员变量都设置为了公有,方便后续修改。

cpp 复制代码
class Response//服务端必须回应
{
    Response(int code=0,int result=0)
        :_exitCode(code),_result(result)
    {}

public:
	int _exitCode; //计算服务的退出码
	int _result;  // 结果
};

3.5.2.序列化

cpp 复制代码
class Response//服务端必须回应
{
    Response(int code=0,int result=0)
        :_exitCode(code),_result(result)
    {}

    // 入参是空的
    void serialize(std::string& out)
    {
    // code ret
        out.clear();
        out+= std::to_string(_exitCode);
        out+= SPACE;
        out+= std::to_string(_result);
        out+= CRLF;
    }


public:
	int _exitCode; //计算服务的退出码
	int _result;  // 结果
};

3.5.3 反序列化

响应的反序列化只需要处理一个空格,相对来说较为简单

cpp 复制代码
class Response//服务端必须回应
{
    Response(int code=0,int result=0)
        :_exitCode(code),_result(result)
    {}

    //序列化
    void serialize(std::string& out)
    {
    // code ret
        out.clear();
        out+= std::to_string(_exitCode);
        out+= SPACE;
        out+= std::to_string(_result);
        out+= CRLF;
    }
    
    // 反序列化
    bool deserialize(const std::string &in)
    {
        // 只有一个空格
        size_t space = in.find(SPACE);//寻找第一个空格的下标
        if(space == std::string::npos)//没找到
        {
            return false;
        }

        std::string dataCode = in.substr(0,space);
        std::string dataRes = in.substr(space+SPACE_LEN);

        _exitCode = atoi(dataCode.c_str());
        _result = atoi(dataRes.c_str());
        return true;
    }


public:
	int _exitCode; //计算服务的退出码
	int _result;  // 结果
};

3.6.完整的序列化代码

我们将上面的进行整合

Serialization.hpp

cpp 复制代码
#pragma
#define CRLF "\t"               // 分隔符
#define CRLF_LEN strlen(CRLF)   // 分隔符长度
#define SPACE " "               // 空格
#define SPACE_LEN strlen(SPACE) // 空格长度
#define OPS "+-*/%"             // 运算符

#include <iostream>
#include <string>
#include <cstring>

class Request//客户端使用的
{
public:
    // 将用户的输入转成内部成员
    // 用户可能输入x+y,x+ y,x +y,x + y等等格式
    // 提前修改用户输入(主要还是去掉空格),提取出成员
    Request(std::string in, bool *status)
        : _x(0), _y(0), _ops(' ')
    {
        rmSpace(in); // 删除空格

        // 这里使用c的字符串,因为有strtok
        char buf[1024];

        // 打印n个字符,多的会被截断
        snprintf(buf, sizeof(buf), "%s", in.c_str()); // 将报文存到buf里面去,方便使用strtok

        char *left = strtok(buf, OPS); // left变成从字符串in的最开始到第一个运算符的这段字符串------即左操作数
        if (!left)
        { // 找不到
            *status = false;
            return;
        }

        // right变成从第一个运算符开始,到第二个运算符中间的这段字符串------即右操作数
        char *right = strtok(nullptr, OPS); // 在上次寻找的基础上继续寻找
        if (!right)
        { // 找不到
            *status = false;
            return;
        }

        // x+y, strtok会将+设置为\0
        char mid = in[strlen(left)]; // 截取出操作符
        // 这是在原字符串里面取出来,buf里面的这个位置被改成\0了

        _x = atoi(left);
        _y = atoi(right);
        _ops = mid;
        *status = true;
    }

    // 删除输入中的空格
    void rmSpace(std::string &in)
    {
        std::string tmp;
        for (auto e : in)
        {
            if (e != ' ')
            {
                tmp += e;
            }
        }
        in = tmp;
    }

    // 序列化 (入参应该是空的,会返回一个序列化字符串)
    void serialize(std::string &out)//这个是客户端在发送消息给服务端时使用的,在这之后要先编码,才能发送出去
    {
        // x + y
        out.clear(); // 序列化的入参是空的
        out += std::to_string(_x);
        out += SPACE;
        out += _ops; // 操作符不能用tostring,会被转成ascii
        out += SPACE;
        out += std::to_string(_y);
        // 不用添加分隔符(这是encode要干的事情)
    }
    //序列化之后应该要编码,去加个长度

    // 反序列化(解开
    bool deserialize(const std::string &in)//这个是服务端接收到客户端发来的消息后使用的,在这之前要先解码
    {
        // x + y 需要取出x,y和操作符
        size_t space1 = in.find(SPACE);  // 第一个空格
        if (space1 == std::string::npos) // 没找到
        {
            return false;
        }

        size_t space2 = in.rfind(SPACE); // 第二个空格
        if (space2 == std::string::npos) // 没找到
        {
            return false;
        }

        // 两个空格都存在,开始取数据
        std::string dataX = in.substr(0, space1);
        std::string dataY = in.substr(space2 + SPACE_LEN); // 默认取到结尾

        std::string op = in.substr(space1 + SPACE_LEN, space2 - (space1 + SPACE_LEN));
        if (op.size() != 1)
        {
            return false; // 操作符长度有问题
        }

        // 没问题了,转内部成员
        _x = atoi(dataX.c_str());
        _y = atoi(dataY.c_str());
        _ops = op[0];
        return true;
    }

public:
    int _x;
    int _y;
    char _ops;
};

class Response // 服务端必须回应
{
    Response(int code = 0, int result = 0)
        : _exitCode(code), _result(result)
    {
    }

    // 序列化
    void serialize(std::string &out)//这个是服务端发送消息给客户端使用的,使用之后要编码
    {
        // code ret
        out.clear();
        out += std::to_string(_exitCode);
        out += SPACE;
        out += std::to_string(_result);
        out += CRLF;
    }

    // 反序列化
    bool deserialize(const std::string &in)//这个是客户端接收服务端消息后使用的,使用之前要先解码
    {
        // 只有一个空格
        size_t space = in.find(SPACE);  // 寻找第一个空格的下标
        if (space == std::string::npos) // 没找到
        {
            return false;
        }

        std::string dataCode = in.substr(0, space);
        std::string dataRes = in.substr(space + SPACE_LEN);

        _exitCode = atoi(dataCode.c_str());
        _result = atoi(dataRes.c_str());
        return true;
    }

public:
    int _exitCode; // 计算服务的退出码
    int _result;   // 结果
};

具体用法就是

客户端发送的消息是使用Request来进行序列化和反序列化的

  1. 客户端发消息时:客户端先序列化,再编码
  2. 服务端收消息时,服务端先解码,再反序列化

服务端发送的消息是使用Response来进行序列化和反序列化的

  1. 服务端发消息时:客户端先序列化,再编码
  2. 客户端收消息时,服务端先解码,再反序列化

3.7.客户端修改

之前写的客户端,并没有进行序列化操作,所以我们需要添加上序列化操作,并对服务器的返回值进行反序列化。这期间需要加上一系列判断;

为了限制篇幅,下面只贴出来客户端的GetService函数,详情参考注释。

cpp 复制代码
// 获取服务
void GetService()
{
    char buff[1024];
    std::string who = server_ip_ + "-" + std::to_string(server_port_);
    while (true)
    {
        // 由用户输入信息
        std::string msg;
        std::cout << "Please Enter >> ";
        std::getline(std::cin, msg);

        // 1.创建一个request(分离参数)
        bool reqStatus = true;
        Request req(msg, &reqStatus); // 构造过程中会将1+2里面的1,+,2分开来
        if (!reqStatus)
        {
            std::cout << "make req err!" << std::endl;
            continue;
        }

        // 2.序列化和编码
        std::string package;                       // 空字符串
        req.serialize(package);                    // package变成序列化字符串
        package = encode(package, package.size()); // 对序列化字符串进行编码,即在前面加个长度

        // 3.发送给服务器
        ssize_t s = write(sock_, package.c_str(), package.size());
        if (s > 0) // 写入成功
        {

            //4. 接收来自服务器的信息
            ssize_t n = read(sock_, buff, sizeof(buff) - 1);
            if (n > 0) // 正常通信
            {
                buff[n] = '\0';
                std::cout << "Client get: " << buff << " from " << who << std::endl;

                // 5.解码和反序列化
                std::string echoPackage = buff;
                Response resp;
                size_t len = 0;

                std::string tmp = decode(echoPackage, &len); // 客户端收到服务端的信息后要先解码,只有解码后才能进行反序列化
                if (len > 0)                                 // 解码成功
                {
                    echoPackage = tmp;
                    if (resp.deserialize(echoPackage)) // 反序列化并判断
                    {
                        printf("ECHO [exitcode: %d] %d\n", resp._exitCode, resp._result);
                    }
                    else
                    {
                        std::cerr << "server echo deserialize err!" << std::endl;
                    }
                }
                else
                {
                    std::cerr << "server echo decode err!" << std::endl;
                }
            }
            else if (n == 0)
            {
                // 读取到文件末尾(服务器关闭了)
                std::cout << "Server " << who << " quit!" << std::endl;
                close(sock_); // 关闭文件描述符
                break;
            }
            else
            {
                // 读取异常
                std::cerr << "Read Fail!" << strerror(errno) << std::endl;
                close(sock_); // 关闭文件描述符
                break;
            }
        }

        else if (s <= 0) // 写入失败
        {
            break;
        }
    }
}

这里特别注意第1,2,5点

3.8 服务端

这里也需要修改

cpp 复制代码
const uint16_t default_port = 8877;       // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP

class TcpServer
{
public:
    TcpServer(const uint16_t port = default_port, const std::string ip = default_ip)
        : ip_(ip), port_(port)
    {
    }
    ~TcpServer()
    {
    }

    bool InitServer()
    {
        listensock_.Socket();
        listensock_.Bind(port_);
        listensock_.Listen();
    }
    void Start()
    {
        while (true)
        {
            std::string clientip;
            uint16_t clientport;

            int sockfd = listensock_.Accept(&clientip, &clientport); // 这里会返回一个新的套接字
            if (socket < 0)
                continue;

            // 提供服务
            if (fork() == 0)
            {
                listensock_.Close();
                // 通过sockfd使用提供服务

                std::string inbuf;
                while (1)
                {
                    
                    char buf[1024];
                    // 1.读取客户端发送的信息
                    ssize_t s = read(sockfd, buf, sizeof(buf) - 1);
                    if (s == 0)
                    { // s == 0代表对方发送了空消息,视作客户端主动退出
                        printf("client quit: %s[%d]", clientip.c_str(), clientport);
                        break;
                    }
                    else if (s < 0)
                    {
                        // 出现了读取错误,打印错误后断开连接
                        printf("read err: %s[%d] = %s", clientip.c_str(), clientport, strerror(errno));
                        break;
                    }
                    else // 2.读取成功
                    {

                        buf[s] = '\0'; // 手动添加字符串终止符
                        if (strcasecmp(buf, "quit") == 0)
                        { // 客户端主动退出
                            break;
                        }

                        // 3.开始服务
                        inbuf = buf;//inbuf接手收到的信息
                        size_t packageLen = inbuf.size();
                        // 3.1.解码和反序列化客户端传来的消息
                        std::string package = decode(inbuf, &packageLen); // 解码
                        if (packageLen == 0)
                        {
                            printf("decode err: %s[%d] status: %d", clientip.c_str(), clientport, packageLen);
                            continue; // 报文不完整或有误
                        }
                        
                        printf("package: %s[%d] = %s", clientip.c_str(), clientport, package.c_str());
                        
                        Request req;
                        bool deStatus = req.deserialize(package); // 使用Request的反序列化,packsge内部各个成员已经有了数值
                        if (deStatus)                             // 获取消息反序列化成功
                        {
                            // 3.2.获取结构化的相应
                            Response resp = Caculater(req);//将计算任务的结果存放到Response里面去

                            // 3.3.序列化和编码响应
                            std::string echoStr;
                            resp.serialize(echoStr);//序列化
                            
                            echoStr = encode(echoStr, echoStr.size());//编码
                            
                            // 3.4.写入,发送返回值给客户端
                            write(sockfd, echoStr.c_str(), echoStr.size());
                        }
                        else // 客户端消息反序列化失败
                        {
                            printf("deserialize err: %s[%d] status: %d", clientip.c_str(), clientport, deStatus);
                            continue;
                        }
                    }
                }
                exit(0);//子进程退出
            }
            close(sockfd); //
        }
    }

private:
    uint16_t port_;
    Sock listensock_; // 专门用来listen的
    std::string ip_;  // ip地址
};

特别注意:3.1------3.4全部

如下是计算器服务的代码

cpp 复制代码
Response Caculater(const Request& req)
{
    Response resp;//构造函数中已经指定了exitcode为0
    switch (req._ops)
    {
    case '+':
        resp._result = req._x + req._y;
        break;
    case '-':
        resp._result = req._x - req._y;
        break;
    case '*':
        resp._result = req._x * req._y;
        break;
    case '%':
    {
        if(req._y == 0)
        {
            resp._exitCode = -1;//取模错误
            break;
        }
        resp._result = req._x % req._y;//取模是可以操作负数的
        break;
    }
    case '/':
    {
        if(req._y == 0)
        {
            resp._exitCode = -2;//除0错误
            break;
        }
        resp._result = req._x / req._y;//取模是可以操作负数的
        break;
    }
    default:
        resp._exitCode = -3;//操作符非法
        break;
    }

    return resp;
}

3.9.测试

接下来就是测试的时候

完美啊!!!!!

我们故意输入错误的信息,它也会识别出来。

到现在我们对这个自定义协议是有有点理解了吧!!!

相关推荐
九河云2 小时前
AWS账号注册费用详解:新用户是否需要付费?
服务器·云计算·aws
Lary_Rock2 小时前
RK3576 LINUX RKNN SDK 测试
linux·运维·服务器
幺零九零零3 小时前
【计算机网络】TCP协议面试常考(一)
服务器·tcp/ip·计算机网络
热爱跑步的恒川3 小时前
【论文复现】基于图卷积网络的轻量化推荐模型
网络·人工智能·开源·aigc·ai编程
云飞云共享云桌面4 小时前
8位机械工程师如何共享一台图形工作站算力?
linux·服务器·网络
一坨阿亮5 小时前
Linux 使用中的问题
linux·运维
音徽编程6 小时前
Rust异步运行时框架tokio保姆级教程
开发语言·网络·rust
幺零九零零7 小时前
【C++】socket套接字编程
linux·服务器·网络·c++
wclass-zhengge7 小时前
Docker篇(Docker Compose)
运维·docker·容器
李启柱7 小时前
项目开发流程规范文档
运维·软件构建·个人开发·设计规范