目录
[1 协议](#1 协议)
[2 自定义协议](#2 自定义协议)
[3 常用的序列化和反序列化方法](#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);
}
else
cout << "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_y
string 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_y
int sep1 = inbuffer.find(SEP);
_x = stoi(inbuffer.substr(0,sep1)); //_x
_op = inbuffer[sep1+SEPLEN]; //_op
int 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;
}
else
res._result = rq._x/rq._y;
break;
}
case '%':
{
if(rq._y==0)
{
res._exitcode = MOD_ZERO;
}
else
res._result = rq._x%rq._y;
break;
}
default:
{
res._exitcode = UNKNOWN_OP;
break;
}
}
}
4 响应的序列化
序列化的规则我们设置为和Request一样,毕竟是在一个协议,我们还是统一一下格式比较好。
string Serialize()
{
// _exitcode\r\n_result
string 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,中间带一个空格a
string inbuffer;
while(1)
{
//1
Request rq;
cin>>rq._x;
cin>>rq._op;
cin>>rq._y;
cout<<"cin :"<<rq._x<<rq._op<<rq._y<<endl;
//2
string 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;
//4
string outbuffer;
ReadOneText(sock,outbuffer,inbuffer);
//5
cout<<"RECV:"<<outbuffer<<endl;
Response res;
res.Deserialize(outbuffer);
if(res._exitcode != NORMAL)
{
protocolerr(res._exitcode); //设计一个打印错误类型的函数
}
else
cout<<"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 来进行个性化的格式的序列化等。大家可以自行在网上查阅资料学习一下。