再谈协议--定制协议

2024-09-06 01:36
文章标签 定制 协议 再谈

本文主要是介绍再谈协议--定制协议,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

目录

1 协议

2 自定义协议

3 常用的序列化和反序列化方法


1 协议

协议是一种约定,这是我们前面的理解。

在我们之前使用 socket 进行 udp或者tcp通信时,我们默认都是按照字符串或者说字节流的方式来发送和读取的,可是如果我们要传输一些结构化的数据,该怎么办呢?

就比如我们使用qq进行聊天,可能我们发送一条消息,实际上发送到网络中的不止这条消息本身,还有消息发出时间,用户昵称,用户头像,等等,可以看成是多个字符串数据,那么这些字符串难道一条一条发出去吗? 这是不可能的。如果我们拆来,一个字符串当成一个报文发送,那么会出现很多问题,首先,接收方在收到这一批报文之后,要进行分离,分出来哪个是头像,哪个是昵称,哪个是消息本身等,如果某一个数据再转发过程中丢失了,我们怎么知道,以及丢失之后怎么处理,是让发送方把所有的数据再发一遍还是只发丢失的数据? 还有就是,我们的接收方可能同时会接收到很多的消息,那么就意味着接受方还要有能力能够将消息与对应的其他的数据匹配起来。总之,这样一来,出错的概率会变大,接收方处理数据的工作量也会变大

为了降低出错的概率,以及减少匹配的成本,我们其实是将这些数据作为一个整体发送出去。 怎么将多个数据变成一个数据呢?我们将这多个数据使用特定的结构或者说格式变成一个数据。为什么不直接使用结构体这样的类型呢?客户端和服务端或者通信双方的主机的结构体的内存对齐的规则可能会存在差异,那么收发双方就可能会出现错误的解析,我们当然可以同时一般也是这样先使用一个结构体来保存各个数据,将各个数据以结构体或者类对象的形式先保存在自己的缓冲区中,但是当要发送到网络中的时候,就需要我们将结构体中的数据要先转化转化为一个与内存对齐无关的数据,或者说我们就需要把结构化的数据结构中的各个字段整合成一个有特定结构(比如成员之间设置分隔符)的字节流或者说一个字符串,然后将这个字符串整体发送出去,这就是一个多合一的转换的过程。我们也成这个过程为 序列化

那么相应的,接收方收到这个特定结构的字符串之后,也需要将这个字符串还原为我们的结构体或者将各个数据分离出来,这个分离的工作就叫做 反序列化。

当然通信的数据不一定都是字符串,也可能是整数,结构体或者对象等,但是我们有能力将其转换为一个特定格式的字符换或者字节流。

所以我们的业务结构化数据(原始数据)在发送到网络中的时候,首先要经过序列化再发送到网络,而接收方收到的就是序列化之后的数据,那么也要先进行反序列化再去使用。

而我们的业务的结构化的数据的序列化和反序列化其实就是一种我们自己定义的 业务协议 的一部分。目的就是把结构化的数据转化为序列化的数据。

同时,我们还考虑一个问题,就是每一个之间如何区分? 因为我们将数据序列化之后,发送到对方的内核接收缓冲区之后,特别是tcp通信时,由于TCP是面向字节流的,他接收到数据之后将tcp的报头去掉之后就将有效载荷放到了接收缓冲区,不会做一些关于报文与报文之间边界的区分,我们怎么保证一次读取到一个完整的报文?像我们之前的直接使用一个 char buffer[1024] 的缓冲区来读取,那么如果一个报文的长度超过了1024个字节呢?这时候不就出问题了? 同理,如果我们的缓冲区中堆积了超过一个报文,那么我们怎么保证我们读取到的是一个完整的报文而不是一个报文还多出半截呢?

协议的表现就是报头,那么我们解决上述问题的方案其实也不难,就是在报头中添加字段来标识当前报文的长度,而报头部分我们也需要有固定的格式和规则,以便我们在接受的时候能够明显将自己定义的应用层协议的报头和有效载荷分离出来,或者说让我们能够很方便的获取报头中的某些重要字段。

2 自定义协议

我们还是那之前写的tcp服务器和客户端的代码来做示例,不过尽量简化,让其单纯充当服务器的收发消息的功能。而后重点我们放在应用层的协议上。假设今天我们模拟实现一个最简单的网络版本的计算机。

首先我们要将一个定制一个协议,协议约定,客户端发送请求时使用 request 类,而服务端的响应使用 response 类。对于请求而言,我们需要的数据就是两个操作数和一个操作符,剩下的我们先不管。而对于相应而言我们需要 一个 exitcode 来判断运算是否出错,以及一个 result 运算结果,我们规定exitcode为0时运算结果才有效,至于具体数值是多少代表什么错误我们也可以在协议中定义出来。那么他的细节我们先不管,我们从服务器和客户端的逻辑入手,来慢慢填充我们的协议的内容。

    void start(){// 死循环从监听套接字中获取新连接while (true){struct sockaddr_in client;socklen_t len = sizeof client;int sock = accept(_listensock, (struct sockaddr *)&client, &len);if (sock == -1){cerr<<"accept error"<<endl;exit(ACCEPT_ERR);}elsecout << "accept a new link , sock :" << sock << endl;uint16_t clientport = ntohs(client.sin_port);string clientip = inet_ntoa(client.sin_addr);if(fork()==0) //子进程{if(fork()>0) exit(0);GET(sock,clientport,clientip); //服务端逻辑}wait(nullptr);}}

对于服务器而言,他需要就不是直接执行业务逻辑了,他要做的事有以下五步:1 读取数据 2 将读到的请求反序列化 3 处理业务逻辑 4 对结果Response序列化 5 发送响应给客户端。

void GET(int sock, uint16_t clientport, string clientip) // 主逻辑
{while (1){string inbuffer; // 用来保存所有从内核缓冲区read的数据,提取完一个完整报文之后就删除该报文,剩下的还是要保留// 1读取一个完整的请求string request;ReadOneText(sock, request, inbuffer);cout<<"RECV:"<<request<<endl;// 2反序列化获得一个结构化的请求?Request rq;rq.Deserialize(request);// 3 业务逻辑Response res;Handler(rq, res);// 4相应序列化string outbuffer;outbuffer = res.Serialize();cout<<"outbuffer:"<<outbuffer<<endl;outbuffer = EnHeader(outbuffer);cout<<"Send out : "<<outbuffer<<endl;// 5返回相应int n = write(sock, outbuffer.c_str(), outbuffer.size());if (n <= 0)exit(0);(void)n;}
}

enum ExitCode
{NORMAL,  //0表示正常};class Response
{
public:string Serialize(){}void Deserialize(string inbuffer){}public:ExitCode _exitcode;int _result;
};class Request
{
public:string Serialize(){}void Deserialize(string inbuffer){}
public:int _x;char _op;int _y;
};

那么这样一来,我们就将我们的软件分为了三层,第一层是服务器不断获取新连接,第二层是GET,负责IO和协议相关工作,第三层才是业务的逻辑。

1 读取数据

读取数据我们要做的就是保证读到一个完整的请求,那么我们就需要定制协议来明确报文与报文之间的边界,同时我们也要能够将序列化之后的有效载荷 和报头分离出来。这里我们就做一个最简单的报头,就是只包含有效载荷长度,然后报头和有效载荷之间用 "\r\n" 这样的特殊字符来分割。

#define SEP "\r\n"
#define SEPLEN strlen(SEP)

那么在这里我们首先就要完成报头的添加和提取有效载荷的工作,这里我们还是要写两个函数,解耦。

string EnHeader(const string& inbuffer) //添加报头
{int len = inbuffer.size();string out = to_string(len);out+=SEP;out+=inbuffer;out+=SEP; //报文结尾也要用分隔符来与其他报文做分割return out; 
}
string DelHeader(const string& inbuffer) //去报头
{//去报头的前提我们已经拿到一个完整的请求了,所以我们认为传过来的 inbuffer 是一个完整的报文//报文格式(没有-):length-SEP-有效载荷-SEP ,那么有效载荷就是两个SEP之间的数据//当然,这样一来我们的协议其实要规定好要传输的数据不能包含SEP这样的特殊字段string out;int left = inbuffer.find(SEP); //从前往后找第一个SEP,返回的是第一个SEP的其实的位置 int right = inbuffer.rfind(SEP);  //从后往前找第二个SEP,返回的是第二个SEP的起始位置out=inbuffer.substr(left + SEPLEN,right-left-SEPLEN);  //right-left是两个SEP的起始位置之间的字符个数,所以还要减去SEPLEN return out;
}

既然有了添加报头和去报头的逻辑,那么我们也就知道了每一个报文的格式。那么我们读取的时候也就很方便了。

读取的逻辑很简单,首先我们要将内核缓冲区的数据都读到我们自己的缓冲区中,然后我们读取缓冲区的第一个报文的报头,将第一个报文的长度获取,然后判断当前的缓冲区中的字符的个数是不是比我们的第一个报文长或者相等,如果相等,就说明第一个报文已经都早缓冲区中了,那么我们就可以利用拿到的报文长度再加上这些分割字段的长度,来将第一个请求完整提取出来。当然如果当前的缓冲区的长度小于我们计算出来的报文长度,那么我们就需要继续去内核缓冲区中读取新的数据到我们的缓冲区中,接着就是一样的逻辑了,搞成一个循环就行。

void ReadOneText(int sock, string &outbuffer, string &inbuffer)
{while (1){char buffer[1024];bzero(buffer, sizeof buffer);int n = read(sock, buffer, sizeof buffer);if (n > 0){buffer[n] = 0;      // 说明从内核缓冲区中读到了新的数据,那么这时候就可以再判断一下现在能不能读到一个完整报文inbuffer += buffer; // 读取到的数据加到缓冲区中cout<<"buffer:"<<buffer<<endl;// 先提取报文的有效载荷长度int n = inbuffer.find(SEP); // 找到第一个SEP,那么前面的就是我们的第一个报文的报头if (n == string::npos)continue; // 说明不够,那么就等读取新的数据到缓冲区// 说明能读到一个报文的报头string lenstring = inbuffer.substr(0, n);cout<<"lenstring:"<<lenstring<<endl;int len = atoi(lenstring.c_str());// 然后判断缓冲区的长度能不能放下第一个完整的报文int TotalLen = len+2*SEPLEN + lenstring.size();cout<<"TotalLen:"<<TotalLen<<" -- inbuffer.size:"<<inbuffer.size()<<endl;if (TotalLen > inbuffer.size()) // 说明第一个报文不完整,等读到新的数据再来判断continue;// 到这里说明缓冲区中有一个完整的报文了string Text = inbuffer.substr(0, TotalLen); // 提取完整报文cout<<"Text"<<Text<<endl;// 去报头outbuffer = DelHeader(Text);// 读走之后要将该报文从缓冲区中移除cout<<"outbuffer:"<<outbuffer<<endl;inbuffer.erase(inbuffer.begin(), inbuffer.begin() + TotalLen);cout<<"一次读取完之后 inbuffer:"<<inbuffer<<endl;return ;}else if (n == 0) // 没有新内容{continue;}else // n<0说明读取出错{exit(READ_ERR);}}
}

2 对请求反序列化

在这里我们就只需要将Request类的序列化和反序列化的代码完成就行了。

我们可以定义一下Request 的序列化之后的格式,比如 X\r\nOp\r\nY ,我们使用\r\n将三个成员分割开来,而后我们进行反序列化的时候也是通过这些分隔符将其分离出来,转换为特定的类型填充到Request的成员中。

class Request
{
public:string Serialize(){//_x\r\n_op\r\n_ystring out;out+=to_string(_x);out+=SEP;out+=_op;out+=SEP;out+=to_string(_y); }void Deserialize(string inbuffer){//_x\r\n_op\r\n_yint sep1 = inbuffer.find(SEP);_x = stoi(inbuffer.substr(0,sep1));     //_x_op = inbuffer[sep1+SEPLEN];            //_opint sep2 = inbuffer.rfind(SEP);_y=stoi(inbuffer.substr(sep2+SEPLEN));  //_y}
public:int _x;char _op;int _y;
};

3 业务逻辑

业务逻辑就很简单了,判断一下 _op ,然后判断有没有除0或者模0,再填充返回值。

void Handler(const Request &rq, Response &res)
{res._exitcode = NORMAL ;switch (rq._op){case '+':{res._result = rq._x+rq._y;break;}case '-':{res._result = rq._x - rq._y;break;}case '*':{res._result = rq._x*rq._y;break;}case '/':{if(rq._y==0){res._exitcode = DIV_ZERO;}elseres._result = rq._x/rq._y;break;}case '%':{if(rq._y==0){res._exitcode = MOD_ZERO;}elseres._result = rq._x%rq._y;break;}default:{res._exitcode = UNKNOWN_OP;break;}}
}

4 响应的序列化

序列化的规则我们设置为和Request一样,毕竟是在一个协议,我们还是统一一下格式比较好。

    string Serialize(){// _exitcode\r\n_resultstring out = to_string(_exitcode);out += SEP;out += to_string(_result);return out;}void Deserialize(string inbuffer){int sep = inbuffer.find(SEP);int code = stoi(inbuffer.substr(0, sep));switch (code){case 0:{_exitcode = NORMAL;break;}case 1:{_exitcode = DIV_ZERO;break;}case 2:{_exitcode = MOD_ZERO;break;}case 3:{_exitcode = UNKNOWN_OP;break;}}_result = stoi(inbuffer.substr(sep + SEPLEN));}

5 发送响应

这里倒是没什么特殊的了,我们直接使用write进行发送就可以了,当然更规范一点,可以设计一个接口来进行响应的发送。

那么服务端的逻辑就写完了,接下来就是客户端的逻辑。客户端逻辑其实就是反着的,1 从键盘读取数据构建Request  2 序列化  3 发送给服务器 4 接收服务器的响应 5反序列化得到响应并处理  

void ClientHandler(int sock,string severip,uint16_t severport)
{//简单点,就规定输入必须是 _x _op _y,中间带一个空格astring inbuffer;while(1){//1 Request rq; cin>>rq._x;cin>>rq._op;cin>>rq._y;cout<<"cin :"<<rq._x<<rq._op<<rq._y<<endl;//2string Send = EnHeader(rq.Serialize());//3 cout<<"Send:"<<Send<<endl;int n = write(sock,Send.c_str(),Send.size());if(n==-1){cout<<"Write error"<<endl;exit(WRITE_ERR);}(void)n;//4string outbuffer;ReadOneText(sock,outbuffer,inbuffer);//5cout<<"RECV:"<<outbuffer<<endl;Response res;res.Deserialize(outbuffer);if(res._exitcode != NORMAL) {protocolerr(res._exitcode); //设计一个打印错误类型的函数}elsecout<<"the result is : "<<res._result<<endl;}
}

我们把代码中的打印提示信息的代码注释掉,看一下测试结果,中间过程如果大家想看,可以将代码中的cout等提示语句也执行。

当我们读取不正确时,缓冲区中会有残缺报文,那么是会影响到我们后续的读取的,所以我们一定要注意。

3 常用的序列化和反序列化方法

我们上面定制的协议只能够解决简单的场景,可扩展性很差,代码很矬。 序列化和序列化如果要真正适应多数场景,起始是十分复杂的,而在实际中,我们一般也不会去自己写序列化和反序列化的逻辑,一是写起来太复杂,二是有现成的方案比我们自己写出来的要好。

目前常见的序列化和反序列化的方案有: json,protobuf,xml,我们C/C++编程一般用前两个,最常用的还是json,简单易上手。

下面介绍一些 json 的简单的使用

首先我们要在自己的机器上安装 cpp的json库,
 

 sudo yum install -y jsoncpp-devel

有的云服务器可能自带了

在程序中我们也要包含头文件,他的头文件是一个多级目录的

#include<jsoncpp/json/json.h>

1 首先要定义一个Json::Value 类型的对象,它是一种万能对象,可以接受任意类型。同时 json 是一种kv的数据存储结构,我们设置keyh和value就行了。然后使用重载的方括号往里面放我们要序列化的数据就行了。

2 序列化我们要使用 Json::FastWriter 类型定义一个对象,然后调用他的write方法,返回值就是我们的序列化之后的 string 。

比如我们的Response的序列化就可以这样写

        Json::Value root;root["exitcode"] = exitcode;root["result"] = _result;Json::FastWriter wr;string out = wr.write(root);

反序列化的时候也很简单,

1 定义一个Json::Value对象,定义Json::Reader对象,使用Reader的parse方法,将我们的序列化的字符串和定义的Value对象传进去。那么就把解析出来的内容放到了Value对象中

2 第二步就是通过方括号将我们之前放进去的值提取出来,提取的时候我们还要使用函数来指明它的类型,比如 asInt ,

        Json::Value root;Json::Reader rd;rd.parse(inbuffer,root);int exitcode = root["exitcode"].asInt();//将 int 匹配成我们的枚举类型  ..._result = root["result"].asInt();

Json库是第三方库,所以我们在编译的时候需要加上 -ljsoncpp 选项

以上知识Json 的最基础的用法,Json实际上是非常强大的,比如我们在序列化的时候,可以使用Json::StyleWriter 来进行个性化的格式的序列化等。大家可以自行在网上查阅资料学习一下。

这篇关于再谈协议--定制协议的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1140627

相关文章

最新Spring Security实战教程之表单登录定制到处理逻辑的深度改造(最新推荐)

《最新SpringSecurity实战教程之表单登录定制到处理逻辑的深度改造(最新推荐)》本章节介绍了如何通过SpringSecurity实现从配置自定义登录页面、表单登录处理逻辑的配置,并简单模拟... 目录前言改造准备开始登录页改造自定义用户名密码登陆成功失败跳转问题自定义登出前后端分离适配方案结语前言

Qt 中集成mqtt协议的使用方法

《Qt中集成mqtt协议的使用方法》文章介绍了如何在工程中引入qmqtt库,并通过声明一个单例类来暴露订阅到的主题数据,本文通过实例代码给大家介绍的非常详细,感兴趣的朋友一起看看吧... 目录一,引入qmqtt 库二,使用一,引入qmqtt 库我是将整个头文件/源文件都添加到了工程中进行编译,这样 跨平台

SpringBoot定制JSON响应数据的实现

《SpringBoot定制JSON响应数据的实现》本文主要介绍了SpringBoot定制JSON响应数据的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们... 目录前言一、如何使用@jsonView这个注解?二、应用场景三、实战案例注解方式编程方式总结 前言

Java如何接收并解析HL7协议数据

《Java如何接收并解析HL7协议数据》文章主要介绍了HL7协议及其在医疗行业中的应用,详细描述了如何配置环境、接收和解析数据,以及与前端进行交互的实现方法,文章还分享了使用7Edit工具进行调试的经... 目录一、前言二、正文1、环境配置2、数据接收:HL7Monitor3、数据解析:HL7Busines

【Linux】应用层http协议

一、HTTP协议 1.1 简要介绍一下HTTP        我们在网络的应用层中可以自己定义协议,但是,已经有大佬定义了一些现成的,非常好用的应用层协议,供我们直接使用,HTTP(超文本传输协议)就是其中之一。        在互联网世界中,HTTP(超文本传输协议)是一个至关重要的协议,他定义了客户端(如浏览器)与服务器之间如何进行通信,以交换或者传输超文本(比如HTML文档)。

【Go】go连接clickhouse使用TCP协议

离开你是傻是对是错 是看破是软弱 这结果是爱是恨或者是什么 如果是种解脱 怎么会还有眷恋在我心窝 那么爱你为什么                      🎵 黄品源/莫文蔚《那么爱你为什么》 package mainimport ("context""fmt""log""time""github.com/ClickHouse/clickhouse-go/v2")func main(

2024.9.8 TCP/IP协议学习笔记

1.所谓的层就是数据交换的深度,电脑点对点就是单层,物理层,加上集线器还是物理层,加上交换机就变成链路层了,有地址表,路由器就到了第三层网络层,每个端口都有一个mac地址 2.A 给 C 发数据包,怎么知道是否要通过路由器转发呢?答案:子网 3.将源 IP 与目的 IP 分别同这个子网掩码进行与运算****,相等则是在一个子网,不相等就是在不同子网 4.A 如何知道,哪个设备是路由器?答案:在 A

Modbus-RTU协议

一、协议概述 Modbus-RTU(Remote Terminal Unit)是一种基于主从架构的通信协议,采用二进制数据表示,消息中的每个8位字节含有两个4位十六进制字符。它主要通过RS-485、RS-232、RS-422等物理接口实现数据的传输,传输距离远、抗干扰能力强、通信效率高。 二、报文结构 一个标准的Modbus-RTU报文通常包含以下部分: 地址域:单个字节,表示从站设备

PDFQFZ高效定制:印章位置、大小随心所欲

前言 在科技编织的快节奏时代,我们不仅追求速度,更追求质量,让每一分努力都转化为生活的甜蜜果实——正是在这样的背景下,一款名为PDFQFZ-PDF的实用软件应运而生,它以其独特的功能和高效的处理能力,在PDF文档处理领域脱颖而出。 它的开发,源自于对现代办公效率提升的迫切需求。在数字化办公日益普及的今天,PDF作为一种跨平台、不易被篡改的文档格式,被广泛应用于合同签署、报告提交、证书打印等各个

网络原理之TCP协议(万字详解!!!)

目录 前言 TCP协议段格式 TCP协议相关特性 1.确认应答 2.超时重传 3.连接管理(三次握手、四次挥手) 三次握手(建立TCP连接) 四次挥手(断开连接)  4.滑动窗口 5.流量控制 6.拥塞控制 7.延迟应答 8.捎带应答  9.基于字节流 10.异常情况的处理 小结  前言 在前面,我们已经讲解了有关UDP协议的相关知识,但是在传输层,还有