目录
一、数据在网络传输中遇到的部分问题
1. 序列化与反序列化
2. 如何保证接收端读取一个完整的报文
二、实现一个简单的网络计算器
1. 客户端处理
1.1 请求结构体和返回结构体
1.2 解析输入的字符串
1.3 序列化
1.4 添加标识符和字符串长度
1.5 接收服务端返回的数据
2. 服务端处理
2.1 读取数据的完整请求
2.2 去掉报头,得到序列化请求
2.3 反序列化
2.4 计算
2.5 返回数据的序列化和添加报头
3. 客户端和服务端处理过程
3.1 客户端
3.2 服务端
4. 测试
三、序列化和反序列工具
1. json的安装
2. json的简单使用
2.1 头文件包含
2.2 json的序列化
2.3 json的反序列化
2.4 测试
四、再谈OSI七层模型
一、数据在网络传输中遇到的部分问题
在前面几章我们写了一个简单的tcp程序,在这些程序中可以完成网络通信。但是,在这个程序中,我们发送的一直都是“字符串”,在读写数据时,都是按照“字符串”的形式来发送和接收的。但是大家知道,在tcp协议中,其实是“面向字节流”的,这也就是说,使用tcp协议的程序,在发送和接收数据时都是通过“字节流”来接收发送的。当然,这并不能说明按照“字符串”形式收发数据有问题,在事实上,网络通信也确实可以看做是将数据当做一个大号的字符串来收发的,这没有问题。但是,以前的数据中,我们发送的都是“纯字符串”,即它本身就是字符串,无需进行任何处理,但如果将来我们要发送一些“结构化”的数据呢?
这个“结构化”的数据,大家可以将其简单看做一个对象。举个例子,大家在qq聊天中,发送信息时其实并不是单单将信息发送出去,还会有发送时间,你的昵称、头像等等内容,这些内容都是需要发送出去的。那么如何将这些数据发送出去呢?如果一个个的单独发送,那么在多个客户端同时向服务端发送数据时,服务端就无法区分这些数据是来源于那个客户端,进而导致错误的处理和返回数据。
1. 序列化与反序列化
因此,在tcp中发送数据时,是需要将“多个字符串”组合为“一个字符串”的,而这个组合过程,就被称之为“序列化”。在网络中,客户端将序列化后的数据通过网络发送到服务端,此时服务端接收到的一定是“序列字节流”,如果服务端需要使用这份数据,就需要将其重新拆分为“多个”字符串,而这个拆分的过程,就是“反序列化”。
通过这种方式,就可以将结构化的数据完整的发送给接收端。接收端再通过解析这一个单独的字符串并重新填回到结构体对象,就可以通过调用这个结构体对象来使用这份数据。
因此,序列化和反序列化,其实就是为了让发送端的数据能够更好的通过字节流的方式发送给接收端。
2. 如何保证接收端读取一个完整的报文
除了如何收发结构化数据,还有一个问题。在发送数据时,我们并不是按照发送端发一个,接收端接收一个处理完返回给接收端之后,接收端再发下一个数据这样的方式收发数据的。而是发送端一直发,并不会管接收端当前是否处理完数据。这里就有一个问题,假设接收端处理数据的速度比发送端发送数据的速度慢,那么就势必会导致发送端的数据被保存在接收端中,那么,接收端在提取数据时,如何保证只拿取客户端发送过来的一份数据,不多拿也不少拿呢?
大家在学习管道时应该都见过这种情况:管道的写端一直在向管道内写数据,读端在读取数据时,有时因为读取速度比较慢,所以可能会一次性将管道内写端写的多份数据一次性读走,这就导致读端拿出来的数据中包含多份不同的数据,也可能导致某些数据被截断,例如一份10kb的数据,读端只读走了5kb,导致数据不完整,无法进行正确的处理。这些问题都是字节流中可能出现的问题。
二、实现一个简单的网络计算器
为了让大家更好的理解序列化与反序列化和如何让接收端读取一个完整的报文,这里就实现一个简单的网络计算器,以解释这些问题。
在这个网络计算器中,客户端会将需要计算的数字和计算方法发送给服务端,服务端则需要对客户端发送过来的数据进行解析和处理,向客户端返回一个计算后的结果和计算是否成功的返回码。
在这里就不再重新写客户端和服务端了,而是直接使用前一章“tcp网络套接字”中写的去掉了守护进程化,使用多进程方式多端通信的tcp程序。如果大家没有对应的demo程序,可以看上一章直接拿取。
1. 客户端处理
1.1 请求结构体和返回结构体
在这里要形成两个结构体,第一个结构体保存客户端发送过来的信息,第二个结构体保存服务端返回给客户端的信息。为了方便,这里定义了四个宏,分别表示序列化时的标识符及其长度和添加报头时的标识符及其长度
#define SEP " "
#define SEP_LEN strlen(SEP)
#define LINE_SEP "\r\n"
#define LINE_SEP_LEN strlen(LINE_SEP)
class request//保存客户端发送的数据
{
public:
request()
:_x(0), _y(0), _op(0)
{}
request(int x, int y, char op)
:_x(x), _y(y), _op(op)
{}
public:
//按照x op y的方式解析数据
int _x;
int _y;
char _op;
};
class response//保存计算的结果
{
public:
response()
:_exitcode(0), _result(0)
{}
public:
int _exitcode;//退出码,表明计算是否成功
int _result;//计算结果
};
1.2 解析输入的字符串
由于在客户端输入的时候都是按照字符串的形式读取的,所以第一步,就是要将客户端输入的数据转换为数字和操作符,填入到对应的结构体中。解析方法很简单,在这里要求用户按照“1+1”的格式输入数据。因此,遍历字符串,遇到不是数字的字符就说明遇到操作符,将前面的字符放入left中,然后将操作符放入op中,继续遍历字符串,拿取剩下的数字。
request ParseLine(const std::string &line)
{
// 将输入的字符串“xopy",如“1+1”解析为数字和操作符填入到request的对象中
int status = 0; // 0表示操作符之前,1表示操作符,2表示操作符之后
int i = 0;
int cnt = line.size();
char op;
std::string left, right;
while (i < cnt)
{
switch (status)
{
case 0:
{
if (!isdigit(line[i])) // 不是数字
{
op = line[i++];
++status;
}
else
left.push_back(line[i++]);
break;
}
case 1:
++status;
break;
case 2:
right.push_back(line[i++]);
break;
}
}
return request(std::stoi(left), std::stoi(right), op);
}
1.3 序列化
将数据获取到结构体后,就要将结构体对象中保存的数据进行序列化,形成一个字符串。这里的序列化格式很简单,就是在操作符的左右加上一个空格:
bool serialize(std::string &out)//序列化
{
//序列化格式:"x op y"
out = "";
std::string x_str = std::to_string(_x);
std::string y_str = std::to_string(_y);
out += x_str;
out += SEP;
out += _op;
out += SEP;
out += y_str;
return true;
}
1.4 添加标识符和字符串长度
因为在未来可能会有多个客户端向同一个服务端发送数据,同时因为字节流的特性,服务端中保存的数据都会被存放在一起,导致服务端在拿取缓冲区内的数据时,可能多拿,也可能少拿,因此,我们要添加一个报头,用于告诉服务端拿取请求时,该请求的长度。处理方式一般是“定长”、“添加标识符”等方式。这里为了区分报头和有效载荷,就新添加了一个“\r\n”,用于分割报头与有效载荷
//修改方案:"x op y"->"content_len"\r\n"x op y"\r\n
std::string enLength(const std::string &text)//用于给序列化数据添加记录序列化数据的长度的字段
{
std::string send_str = std::to_string(text.size());//获取数据长度
send_str += LINE_SEP;
send_str += text;
send_str += LINE_SEP;
return send_str;
}
1.5 接收服务端返回的数据
当服务端把数据处理完后,客户端就需要接收服务端返回的数据。服务端返回的数据时序列化和添加了报头的,需要反序列化和去掉报头,这两个函数在服务端中讲,这里不过多赘述。
2. 服务端处理
2.1 读取数据的完整请求
在这里,我们在客户端和服务端之间是需要交换数据的,而这里的数据不再是简单的字符串,而是一份结构化数据。在服务端中,首先会接收到客户端发送过来的一份序列化数据,但是由于客户端会不断往服务端发送数据,而这些数据都会被保存在了同一个位置,因此,而在拿取序列化数据之前要解决的第一个问题,就是如何让服务端拿到一个“完整”的报文。当然,这个问题已经在客户端中解决了。所以在服务端这里就是如何解析客户端发过来的数据以拿到一个完整的请求。
准备一个recvRequest函数,该函数会通过recv接口读取发送端发送过来的请求,然后将该请求添加到inbuffer中,再遍历得到的字符串寻找标识符。找到后,根据我们在客户端中设置的发送格式可知,在标识符前面就是有效载荷的长度,拿取对应字符串并解析得到有效载荷的长度。拿到长度后就可以计算出整个字符串的长度,将该长度与inbuffer进行比对,如果inbuffer的长度小于总长度,循环往上继续拿取请求;如果大于,则说明此时inbuffer中必然存在一个完整的请求,拿出该请求即可:
bool recvRequest(int sock, std::string &inbuffer, std::string &text)//获取一个完整的请求
{
//请求的格式:"content_len"\r\n""text"\r\n
char buffer[1024];//存储发送端发送过来的数据
while (true)
{
ssize_t n = recv(sock, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
inbuffer += buffer;
int pos = inbuffer.find(LINE_SEP);
if(pos == std::string::npos)
continue;
int text_len = std::stoi(inbuffer.substr(0, pos));
int total_len = pos + 2 * LINE_SEP_LEN + text_len;
if(total_len > inbuffer.size())
continue;
text = inbuffer.substr(0, total_len);
inbuffer.erase(0, total_len);
break;
}
else
return false;
}
return true;
}
2.2 去掉报头,得到序列化请求
通过上面的步骤,我们就已经得到了一个完整的请求,但是这个请求中还存在一些多余的数据,就是报头和报头的标识符。因此准备一个deLength函数,用于通过报头中记录的有效载荷长度拿取有效载荷。拿取方式很简单,先查找请求中的报头标记位,找到后拿取标记位前面的数据,这些数据就是报头。然后再通过报头数据拿取有效载荷即可。
bool deLength(const std::string &package, std::string &text)//用于在接收端去掉记录序列化数据的长度字段
{
text = "";
int pos = package.find(LINE_SEP);//查找标记位
if(pos == std::string::npos)
return false;
int text_len = std::stoi(package.substr(0, pos));//拿取正文长度
text = package.substr(pos + LINE_SEP_LEN, text_len);//获取正文
return true;
}
2.3 反序列化
此时拿到的数据就是去除报头后的序列化数据,要使用这份数据还需要一步,就是反序列化。反序列化函数也写在request结构体中即可。
反序列化时,根据序列化的格式,分别从首尾两个方向找空格,找到后就可以区分出x、op和y三份数据了,将这三份数据填入到结构体的成员变量中即可。
bool deserialize(std::string &in)//反序列化
{
//将"x op y"转化为结构化的数据填入到对象的成员变量中
int left = in.find(SEP);//从头找第一个空格
int right = in.rfind(SEP);//从尾找第一个空格
if(left == std::string::npos || right == std::string::npos) return false;//有任意一方没有找到空格
if(left == right) return false;//找到的是同一个空格
if(right - (left + SEP_LEN) != 1) return false;//保证计算符号只有一个
std::string x_str = in.substr(0, left);//获取x
std::string y_str = in.substr(right + SEP_LEN);//获取y
if(x_str.empty() || y_str.empty()) return false;//防止x或y是空串
_x = std::stoi(x_str);
_y = std::stoi(y_str);//将字符串转换为数字
_op = in[left + SEP_LEN];//获取计算符号
return true;
}
2.4 计算
该程序是一个简单的网络计算器,所以当得到对应的数据后,就可以开始计算了:
bool cal(const request &req, response &resp)//进行计算任务
{
switch(req._op)
{
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 = DIV_ZERO;
else
resp._result = req._x / req._y;
break;
}
case '%':
{
if(req._y == 0)
resp._exitcode = MOD_ZERO;
else
resp._result = req._x % req._y;
break;
}
default:
resp._exitcode = OP_ERROR;
break;
}
return true;
}
2.5 返回数据的序列化和添加报头
计算结果得到后,同样的服务端也需要将数据返回给客户端。因此将返回数据的结构体中保存的数据进行序列化并添加报头返回。添加报头的函数在客户端中已经讲过,这里就不再展示。
bool serialize(std::string &out)//序列化
{
//序列化格式:_exitcode _result
out = "";
std::string ec_str = std::to_string(_exitcode);
std::string res_str = std::to_string(_result);
out += ec_str;
out += SEP;
out += res_str;
return true;
}
3. 客户端和服务端处理过程
3.1 客户端
客户端中收发数据的方式就与前几章有所不同,需要对数据进行一些特殊处理。首先是解析用户输入的数据,对其进行序列化形成一个字符串。然后再对其添加报头和标记位,此时就可以发送了。在接收数据时,需要进行反序列化,将对应数据填入一个结构体中。
void start()
{
//1. 向服务端发起连接请求
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_serverport);
server.sin_addr.s_addr = inet_addr(_serverip.c_str());
int n = connect(_sock, (struct sockaddr *)&server, sizeof(server));
if(n < 0)//连接失败
std::cerr << "socket connect error" << std::endl;
else//连接成功
{
std::string line;
std::string inbuffer;//保存剩下的数据
while(true)
{
//1. 写入数据
std::cout << "mycal# ";
std::getline(std::cin, line);
request req = ParseLine(line);//解析输入的字符串
std::string req_str;
req.serialize(req_str);//序列化
std::string context = enLength(req_str);//添加报头
send(_sock, context.c_str(), context.size(), 0);//发送数据
std::string package;//获取一份完整的数据
if(!recvRequest(_sock, inbuffer, package))
continue;
std::string text;
if(!deLength(package, text))//去掉报头等多余的内容
continue;
response resp;
resp.deserialize(text);//反序列化
std::cout << "exitcode: " << resp._exitcode << std::endl;
std::cout << "result: " << resp._result << std::endl;
}
}
}
3.2 服务端
服务端中为了方便,也是将收发和处理数据的过程单独拿了出来:
void handlerEntery(int sock, func_t func)
{
std::string inbuffer;//存储多个请求
while(true)
{
//1. 读取
//1.1 如何保证读到的消息是一个“完整”的请求
std::string req_str;//获取单个请求
if(!recvRequest(sock, inbuffer, req_str)) //用于读取一个完整的请求->"content_len"\r\n""text"\r\n
return;
std::string text_str;//获取序列化数据
if(!deLength(req_str, text_str))//去掉多余的内容,拿取序列化数据
return;
//2. 反序列化
//2.1 读取到的数据是序列化的数据,如何将其反序列化得到一个结构体对象
request req;
if(!req.deserialize(text_str)) //将获取的序列化数据反序列化后存储到req中
return;
//3. 处理数据
//3.1 将计算后的结果填入到一个结构体中,得到一个结构化相应
response resp;//存储计算后的结果
func(req, resp);//执行计算任务
//4. 对得到的结构化数据序列化
//4.1 将计算结果序列化,得到一个“完整”的字符串
std::string resp_str;//保存序列化结果
resp.serialize(resp_str);//将计算结果序列化
//5. 返回数据
//5.1 将序列化后的数据添加上报头后返回
std::string send_str = enLength(resp_str);//传入函数中添加报头
send(sock, send_str.c_str(), send_str.size(), 0);//最后一个参数为发送方式
}
}
4. 测试
完成了上面的各项准备后,就可以开始测试代码了。运行客户端和服务端:
可以看到,程序可以正常的运行。
三、序列化和反序列工具
在上面,协议定制、序列化和反序列化都是我们自己写的,但是大家可以发现,自己写序列化和反序列化很麻烦,而且自己写的序列化和反序列化代码还比较难看,那有没有什么工具,能帮助我们自动完成序列化和反序列化呢?答案是有的。
常用的序列化和反序列工具有三个,分别是“json”、“protobuf”和“xml”。这三个工具都可以支持序列化和反序列化。其中xml一般是java中使用的,而json和protobuf则经常用于C++中。json一般是对外使用比较多,protobuf则是对内,即内部的服务器等上使用比较多。
在这里,简单讲解一下json的使用,因为json比protobuf简单很多。
1. json的安装
要使用json,在linux中就要将这个组件安装,可以使用“sudo yum install -y jsoncpp-devel”命令安装。
2. json的简单使用
在json中是以kv格式来进行序列化的,要序列化的内容一般使用{}括起来,一般来讲,只要涉及到了字符串就必须要使用“”来将对应的内容括起来。当然,提取的时候也是以kv的格式提取。
2.1 头文件包含
在使用json时,需要包含<jsoncpp/json/json.h>头文件。大家应该可以看出来,这里的头文件是一条路径,原因是json在安装后默认将其进行了归类,放到了jsoncpp/json目录下,并不是直接存放/usr/include,而是在该目录下又划分了子目录:
因此要使用json,在头文件包含时就必须带上include目录下的子目录路径:
2.2 json的序列化
json使用起来其实和使用一个普通的类是一样的。首先创建对象,然后以kv的格式填值。在填充完后,json中提供了两种不同的序列化格式,这里使用FastWriter,该格式序列化后比较容易看懂。确定序列化格式后,就可以通过write接口获取到序列化后的字符串:
2.3 json的反序列化
json的反序列也很简单。首先定义一个Reader对象用于读取要序列化的内容,再定义一个Value对象用于保存序列化的内容。然后再通过对象拿取即可。这里因为数据是整形,所以要带上asInt()将其当做整形读取。
2.4 测试
在使用json时,因为json是一个动态库,所以在使用时要带上“-ljsoncpp”以表示需要使用该动态库。
为了方便看到序列化后的格式,可以在发送数据前打印一下序列化后的数据:
可以看到,json的FastWriter序列化方案就是将kv的映射关系直接填充到字符串中。
有了json和protobuf工具后,大家在未来就最好不要自己去手写序列化和反序列化,一方面是大家写的极大概率没有这两个工具中写得好;另一个方面就是在公司中需要进行序列化和反序列化时也一般是使用这两个工具或其他工具,具体看需求场景,但都不会要求大家自己手写序列化和反序列化。但是,在上面的代码中诸如规定用户输入数据的格式,对数据添加报头,解析报头等协议定制的内容是需要大家自己手写的。
虽然在上面的程序中,我们只写了一种协议,那就是“context_len\r\nx op y\r\n”,但是在一个程序中,并不是只能有一种协议的,大家可以通过在字符串中添加一个协议编号,例如“context_len\r\n协议编号\r\nx op y\r\n”,通过解析字符串,识别该字符串中所带的协议编号来采取不同的协议。
四、再谈OSI七层模型
大家应该都了解过OSI七层模型,在这七层模型里面,最后三层,即会话层、表示层和应用层都是属于用户层面的。而通过上面写的程序,我们也就可以更好的理解这三层了。
会话层,以前说是用于通信管理的。其实程序socket、bind、accept、connect等用户客户端和服务端通信的接口,就是属于会话层的内容。
表示层,用于网络数据格式的转换,其实就是指协议定制、序列化和反序列化等操作,让网络中的数据按照固有形式进行交互以让双方更好的读取和使用。
应用层是针对特定引用的协议,例如电子邮件协议、远程登录协议等。在上面写的程序中,指的就是调用cal函数进行计算工作。
因此我们要明白,虽然tcp/ip五层模型中将这三层压缩为了一个应用层,但是这三层中的内容在实现网络通信时都是必不可少的。