文章目录
- Tcp协议的通讯流程
- 一、协议定制与网络版计算器的实现
- 二、json的使用
- 总结
Tcp协议的通讯流程
上一篇文章我们讲解了如何实现Tcp服务器,Tcp的接口也用了,下面我们就看一下Tcp协议的通讯流程:
在服务端,我们首先要创建一个套接字,这个套接字被称为监听套接字,这个时候服务端处理关闭状态。有套接字后我们开始绑定服务端的ip和端口号,然后我们调用listen接口,一旦调用成功我们的服务器由close状态就变为监听状态,一旦变成监听状态看允许客户端来连我们的服务器了,只不过这个时候不获取客户端的链接。监听后我们就可以通过accept接口获取客户端的链接了,这个时候accept接口会给我们返回一个套接字,这个套接字才是真正用于和客户端通信的套接字。
客户端首先创建套接字,然后在发起链接请求的时候会帮我们绑定,connect这个接口是用来建立链接的,而我们只是向服务器发起建立链接的请求,中间三次握手的过程是由双方的操作系统自动来完成的。因为connect是链接建立的发起方,所以我们用accept获取链接的时候一定是链接已经成功建立了我们才能获取。
断开连接的过程:
如果客户端没有更多的请求了, 就调用close()关闭连接, 客户端会向服务器发送FIN段(第一次);
总结:
1.建立链接是双方操作系统自动完成的(三次握手)。
2.建立链接是为了双方维护链接而创建的数据结构,这个数据结构是有成本的,这个成本主要体现在创建要花时间和空间。
3.断开链接(四次挥手)的目的是将曾经建立好的链接信息释放掉
一、协议定制与网络版计算器的实现
之前我们说过,协议是一种约定。那么socket api的接口, 在读写数据时, 都是按 "字符串" 的方式来发送接收的. 如果我们要传输一些 "结构化的数据" 怎么办呢?结构化的数据我们可以理解为对象,就像我们的qq,发送消息的时候除了消息还有头像昵称时间,这些数据肯定是不能分开发送给服务器的,因为一旦用户基数很大的时候服务器根本无法区分哪个是哪个的消息。那么如何将这些数据打包成一个发送给服务器呢?要想将多个字节流变成一个字节流,这个过程就叫做序列化。当我们的服务器将这个数据收到后,我们还需要将这个数据恢复为原来没有整合的样子,此时恢复的过程就叫做反序列化。所以:业务结构数据在发送到网络的时候,先序列化再发送收到的一定是序列字节流,要先进行反序列化然后才能使用,这是实现网络协议的第一个细节。我们刚刚说数据发送到网络,但实际上还有一个细节我们没有提到,我们如何确定服务器会完整的收到我们发送的数据呢?这就是我们实现协议的第二个细节。
网络版计算器的实现:
之前的Tcp客户端和服务端我们直接拿过来用即可,还有log.hpp等文件,然后我们的Tcp服务端只保留多进程版本:
现在我们要修改的是,如何对数据做处理,首先我们先把函数名改为handerEnter表示处理进入的逻辑,我们将以前的任务文件改名为protocol,也就是协议定制:
准备工作做完后我们开始进行协议定制,首先我们的协议中包含请求与响应,请求就是客户让我们计算的内容,响应就是我们给出的结果和返回码:
pragma once
#include <iostream>
class Request
{
public:
public:
// "x op y"
int x;
int y;
char op;
};
class Response
{
public:
int exitcode; //返回码,0表示计算成功,非0表示计算失败
int result; //计算结果
};
然后我们写个构造函数初始化一下:
我们先默认初始化为0,然后开始编写服务器中的回调函数hander:
void handerEnter(int sock)
{
// 1.读取
// 1.1 如何保证读到的消息是一个完整的请求
// 2.对请求Request 反序列化
// 2.1 得到一个结构化的请求对象
Request req =
// 3. 计算机处理req.x req.y req.op
// 3.1 得到一个结构化的响应
Response resp;
// 4. 对响应Response进行序列化
// 4.1 得到了一个字符串
// 5.然后我们再发送响应
}
我们有5个大步骤,首先服务端收到客户端发来的请求(就是让服务器计算某个表达式),我们第一个要保证的是如何能完整的接收客户端发来的数据,而要保证这一点可以有三个步骤(1.数据定长2.特殊符号3.自描述)这三点我们后面会讲到,能保证第一步后我们再对这个请求做去掉报头,然后做反序列化操作,一旦进行反序列化操作我们就能拿到单个的x,y,op,然后我们利用回调函数开始做计算,这个函数我们在server.cc中实现直接传入start函数,计算出来结果后我们还要发回给客户端,所以还需要将结果进行序列化操作,序列化后得到一个“x op y”的字符串,我们将这个字符串发送给客户端即可。因为我们的第3步要得到一个响应,所以我们直接搞一个回调函数:
这个回调函数的第一个参数是一个输入型参数,就是客户端发给我们的请求经过反序列化的对象,第二个参数是一个输出型参数,未来我们处理req的时候得到req.x,req.y,req.op然后用他们做计算,将计算结果直接放到resp中。
void handerEnter(int sock, func_t func)
{
// 1.读取
// 1.1 如何保证读到的消息是一个完整的请求
// 2.对请求Request 反序列化
// 2.1 得到一个结构化的请求对象
Request req =
// 3. 计算机处理req.x req.y req.op
// 3.1 得到一个结构化的响应
Response resp;
func(req,resp); //req的处理结果全部放入了resp中
// 4. 对响应Response进行序列化
// 4.1 得到了一个字符串
// 5.然后我们再发送响应
}
有了这个回调函数,那么我们在server.cc中就直接可以根据处理好的req去填充resp:
那么如何保证读到的消息是一个完整的请求呢?因为tcp是面向字节流的,所以我们可以明确报文和报文的边界,明确报文和报文边界有三种方式:1.定长 2.特殊符号 3.自描述,如下图:
tcp内部是有自己的发送缓冲区和接收缓冲区的,所以我们只需要规定每个报文的大小的都是一样的,这样就明确了报文和报文边界,当然我们也可以在报文中加一个特殊的符号,用这些符号去表示报文边界,那么自描述是什么呢,其实就是我们可以规定报文的前四个字节是什么什么,后面是什么什么。(我们今天所演示的自描述就是添加报头,这个报头可以是符号也可以代表一些含义)
有了以上概念我们就可以写序列化与反序列化函数了,未来这两个函数一定是能让req和resp使用的,所以我们先给req这个类实现序列化与反序列化函数:
#define SEP " "
#define SEP_LEN strlen(SEP)
bool serialize(std::string *out)
{
*out = "";
std::string x_string = std::to_string(x);
std::string y_string = std::to_string(y);
*out += x_string;
*out += SEP;
*out += op;
*out += SEP;
*out += y_string;
return true;
}
首先我们的规定是序列化后的字符串是: "x op y"的形式,所以我们将分隔符定义为空格,然后定义空格符的长度,这样以后更改会很方便。首先对out指针做初始化,因为我们不能保证给我们传的字符串是空字符串,然后我们将x和y转化为string类型让out字符串先+x,再加分隔符,再加运算符,再加分隔符最后再加y即可,这样外部的out参数就得到了序列化的数据,然后我们返回true。
bool deserialize(const std::string& in)
{
auto left = in.find(SEP);
auto 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_string = in.substr(0,left);
std::string y_string = in.substr(right+SEP_LEN);
if (x_string.empty()) return false;
if (y_string.empty()) return false;
x = stoi(x_string);
y = stoi(y_string);
op = in[left+SEP_LEN];
return true;
}
反序列化也很简单,我们首先找到字符串中左边的空格和右边的空格,然后判断是否满足我们要求的格式,不满足直接返回false。满足后我们就获取序列化字符串中的x,y,op得到后保存在类内的成员变量中,然后返回true。
有了对请求的序列化和反序列化我们就可以计算了:
bool cal(const Request& req,Response& resp)
{
//req已经有结构化完成的数据了,可以直接使用
resp.exitcode = OK;
resp.result = OK;
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;
}
计算也很简单,首先将回应类中的返回码和结果初始化,然后利用switch判断运算符(注意:switch语句中是可以在case语句后面加大括号的,但是我们的break一定要写在大括号外面,否则就犯了C语言的错误),对于除0错误我们可以在协议头文件中设置一个错误码:
enum
{
OK = 0,
DIV_ZERO,
MOD_ZERO,
OP_ERROR
};
上面我们只是完成了请求的序列化和反序列化,我们还需要对结果的相应也做序列化与反序列化:
//"exitcode result"
bool serialize(std::string* out)
{
*out = "";
*out+= to_string(exitcode);
*out += SEP;
*out+= to_string(result);
return true;
}
bool deserialize(const std::string& in)
{
auto pos = in.find(SEP);
if (pos==string::npos)
{
return false;
}
exitcode = stoi(in.substr(0,pos));
result = stoi(in.substr(pos+SEP_LEN));
return true;
}
因为我们规定序列化的结果必须是"exitcode result"的形式,所以响应的序列化中只需要把返回码和空格和结果拼接起来即可。反序列化也很简单,就是先找到空格,然后通过空格分割出返回码和结果的字符串,然后将字符串转化为int赋值给响应类的私有成员即可,这样响应类中就保存了计算出的结果和返回码。
下面我们再写添加报头的函数,由于这个函数响应和请求都需要,所以我们直接写在协议头文件中:
#define SEP " "
#define SEP_LEN strlen(SEP)
#define LINE_SEP "\r\n"
#define LINE_SEP_LEN strlen(LINE_SEP)
// 自定义报头:"x op y" -> content_len\r\n"x op y"\r\n
// "exitcode result" -> content_len(正文长度)\r\n"exitcode result"\r\n
const string enLength(const std::string& text) //添加报头
{
string send_string = to_string(text.size());
send_string+=LINE_SEP;
send_string+=text;
send_string+=LINE_SEP;
return send_string;
}
我们规定一个完整的报文必须是:"x op y" -> content_len\r\n"x op y"\r\n这样的格式,就是正文长度 + \r\n(特殊符号)+正文 + \r\n,而这个函数给我们传的参数一定是正文"x op y"这样的字符串,所以我们只需要计算正文长度,然后将正文长度转化为字符串,将正文长度加上特殊符号加上正文加上特殊符号,这样就是我们规定的报文了,然后返回即可。
有了添加报头我们还需要去掉报头的函数:
bool deLength(const std::string& package,string* text)
{
auto pos = package.find(LINE_SEP);
if (pos==string::npos)
{
return false;
}
int len = stoi(package.substr(0,pos));
*text = package.substr(pos+LINE_SEP_LEN,len);
return true;
}
package参数就是一个完整报文,text是输出型参数,就是我们将去掉报头的结果放在text中返回给外界。去掉报头也很简单,先找到特殊符号,然后我们获取正文的长度,注意:我们规定完整的报文开头到第一个\r\n就是报文长度。有了报文长度后直接用substr函数就拿到了正文。拿到后返回true即可。
下面我们完善hander函数:
void handerEnter(int sock, func_t func)
{
while (true)
{
// 1.读取
// 1.1 如何保证读到的消息是一个完整的请求
std::string req_str;
//?????????
cout<<"处理前请求的完整报文:\n"<<req_str<<endl;
// 如果走到这里,那么可以保证req_str得到的是一个完整的请求
string ret;
if (!deLength(req_str, &ret))
{
return;
}
cout<<"去掉报头的请求报文:\n"<<ret<<endl;
// 2.对请求Request 反序列化
// 2.1 得到一个结构化的请求对象
Request req;
if (!req.deserialize(ret))
{
// 反序列化失败就return
return;
}
// 3. 计算机处理req.x req.y req.op
// 3.1 得到一个结构化的响应
Response resp;
func(req, resp);
// 4. 对响应Response进行序列化
// 4.1 得到了一个字符串
std::string resp_str;
resp.serialize(&resp_str);
cout<<"进行序列化后的响应的字符串:\n"<<resp_str<<endl;
// 5.然后我们再发送响应
// 5.1构建成为一个完整的报文
string send_string = enLength(resp_str);
cout<<"对序列化后的响应添加报头的字符串:\n"<<send_string<<endl;
send(sock, send_string.c_str(), send_string.size(), 0);
}
}
因为我们是多次给客户端发送响应,所以这里应该是个循环。然后我们除了没有保证读到的消息是一个完整的报文,其他的阶段都搞定了。假设我们已经读到一个完整的报文了,然后对请求做去掉报头操作,去掉报头后再进行反序列化操作,这样就拿到了x,y,op并且保存在req中,然后我们创建一个响应对象,调用回调函数将req中x,y,op的结果计算出来,把结果和返回码填到resp中。然后我们对resp中的返回码和结果进行序列化操作,序列化后添加报头就可以发送给客户端了,下面我们编写保证得到的是完整报文的函数:
bool recvPackage(int sock, string& inbuffer, string *text)
{
//因为是从sock中读取数据放到text中,所以为了保险先对text清空
(*text).clear();
char buffer[1024];
while (true)
{
ssize_t n = recv(sock,buffer,sizeof(buffer)-1,0);
if (n>0)
{
buffer[n] = 0;
inbuffer+=buffer;
//分析处理得到一个完整的报文
auto pos = inbuffer.find(LINE_SEP);
if (pos==string::npos)
{
//没找到分隔符,一定不是完整的报文,需要继续往inbuffer中读
continue;
}
int text_len = stoi(inbuffer.substr(0,pos));
int connumber_len = to_string(text_len).size();
int total_len = connumber_len + text_len + 2*LINE_SEP_LEN;
if (total_len > inbuffer.size())
{
continue;
}
//inbuffer.size()大于一个完整报文长度,那么inbuffer中至少有一个完整的报文
*text = inbuffer.substr(0,total_len);
inbuffer.erase(0,total_len);
//读到一个完整的报文就退出
break;
}
else
{
return false;
}
}
return true;
}
首先我们要从文件描述符中读到完整的报文放在text中,所以我们保险起见先将text清空。inbuffer参数是一个支持我们持续存放报文的字符串,因为我们读取是有可能读不到一个完整的报文的,所以这些暂时用不到的报文都会放在inbuffer中。所以我们保险起见先将text清空。然后我们定义一个
缓冲区,这个缓冲区会存放我们从文件描述符中读出来的数据,这里我们用recv接口读取,返回值与read一样都是读到的字节数。如果n==0说明客户端退出,这个时候我们返回false即可。如果n大于0,说明读取成功,然后我们在缓冲区最后一个位置加上\0然后把数据加到inbuffer中,注意是+=因为会持续的读取。然后我们在inbuffer中查找特殊字符\r\n,如果没找到那么一定不会有完整的报文,所以我们continue继续读取,读取到特殊符号后,我们首先保存正文长度和正文数字的长度(比如 4\r\nhoow\r\n),首先正文的长度是4,前面这个4的长度是1,1就是正文数字的长度。有了这两个长度我们就能计算一个完整报文的长度,完整报文 = 正文数组长度 + 2*特殊字符长度 + 正文长度。然后我们判断inbuffer中的长度是否大于完整报文的长度,如果不大于则说明inbuffer没有完整报文需要继续读取,如果有完整报文我们就用substr函数拿到完整报文,并且将inbuffer中刚刚拿掉的报文删除,这样就不会影响下一个报文。注意:我们读到一个报文就退出处理。
然后我们的hander方法就全部搞定了,注意在hander中定义一个inbuffer用来存放循环读取到的客户端报文。
服务端我们搞定后,现在去搞定客户端,让客户端发送请求:
void start()
{
struct sockaddr_in server;
bzero(&server,sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_serverport);
server.sin_addr.s_addr = inet_addr(_serverip.c_str());
//connet的时候操作系统会帮客户端bind 返回值等于0成功
if (connect(_sock,(struct sockaddr*)&server,sizeof(server))!=0)
{
cerr<<"socket connect error"<<endl;
}
else
{
string msg;
string inbuffer;
while (true)
{
cout<<"mycal>> ";
getline(cin,msg);
//将字符串中转换为一个请求
}
}
}
我们客户端肯定是输入要计算的字符串,那么我们如何将字符串转化为一个响应呢?其实很简单,我们规定只是"1+1" 或者 "10/12"这样的格式:
Request ParseLine(const string& line)
{
//规定表达式 "1+1" " "12/0"
int status = 0; //status是一个标志,0表示操作符之前,1表示操作符,2表示操作符之后
int i = 0;
int cnt = line.size();
string left,right;
char op;
while (i<cnt)
{
switch (status)
{
case 0:
{
if (!isdigit(line[i]))
{
op = line[i];
status = 1;
}
else
{
left.push_back(line[i++]);
}
}
break;
case 1:
i++;
status = 2;
break;
case 2:
right.push_back(line[i++]);
break;
}
}
return Request(stoi(left),stoi(right),op);
}
我们实现的原理很简单,首先大循环是遍历字符串,在遍历的过程中我们设置一个标志,标志为0代表这个字符属于左操作数,标志为1代表属于运算符,标志为2代表属于右操作数。然后我们通过switch语句去判断,最后拿到左操作数和右操作数和运算符,然后我们要想构造请求对象还必须给request类多加一个构造函数:
同样如果响应类也需要的话,我们也加一个。
有了构造函数我们直接匿名对象返回即可,这样我们就将一个字符串转换为请求对象了。
void start()
{
struct sockaddr_in server;
bzero(&server,sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_serverport);
server.sin_addr.s_addr = inet_addr(_serverip.c_str());
//connet的时候操作系统会帮客户端bind 返回值等于0成功
if (connect(_sock,(struct sockaddr*)&server,sizeof(server))!=0)
{
cerr<<"socket connect error"<<endl;
}
else
{
string msg;
string inbuffer;
while (true)
{
cout<<"mycal>> ";
getline(cin,msg);
//先有结构化的数据,然后序列化后发送给服务端
Request req = ParseLine(msg);
//得到序列化的结果
string req_s;
req.serialize(&req_s);
//给序列化的结果添加报头
string send_string = enLength(req_s);
send(_sock,send_string.c_str(),send_string.size(),0);
//读取服务器返回的序列化结果
string total_text;
if (!recvPackage(_sock,inbuffer,&total_text))
{
continue;
}
//走到这total_text中一定是一个完整的报文
Response resp;
string text;
if (!deLength(total_text,&text))
{
continue;
}
//走到这text中一定是一个去掉报头的报文
resp.deserialize(text);
cout<<"exitCode: "<<resp.exitcode<<endl;
cout<<"result: "<<resp.result<<endl;
}
}
}
然后我们从刚刚那一步开始,有了请求对象后我们需要对这个请求做序列化操作,序列化后添加报头,然后发送给服务器。同时我们还要接受服务器发给我们的相应,所以我们继续使用recvPackage函数读取一个完整的报文,如果我们读取失败那么就继续读取,直到读取成功我们将这个报文去除报头,然后进行反序列化操作这样resp中就保存了返回码和结果,然后我们将结果打印出来,下面我们运行起来演示一下:
可以看到我们的网页版计算器是没问题的,对于除0错误我们只关心错误码。这样我们就完成了网页版计算器。
二、json的使用
对于上面我们自己写的序列化和反序列化操作,实际上在日常生活中我们是不用的,一般都是用现成的,下面我们讲一下最简单的json c++版本的使用:
首先linux安装json:
yum install -y jsoncpp-devel
注意没有权限的用sudo提权。
class Request
{
public:
Request()
:x(0)
,y(0)
,op(0)
{}
Request(int _x,int _y,char _op)
:x(_x)
,y(_y)
,op(_op)
{
}
bool serialize(std::string *out)
{
Json::Value root;
root["first"] = x;
root["second"] = y;
root["oper"] = op;
Json::FastWriter writer;
*out = writer.write(root);
return true;
}
bool deserialize(const std::string& in)
{
Json::Value root;
Json::Reader read;
read.parse(in,root);
x = root["first"].asInt();
y = root["second"].asInt();
op = root["oper"].asInt();
return true;
}
public:
// "x op y"
int x;
int y;
char op;
};
json的使用非常简单,首先定义一个万能的Value,然后因为是做序列化操作,所以需要将我们的x,y,op和root中进行一个映射,json使用的是键值对方式,所以我们就让x和first做一个映射,y和second做一个映射,op同理,做好映射我们定义一个Fastwriter对象,注意writer就是json中用来序列化的,然后writer中的write函数可以直接将root进行序列化并且返回的是一个字符串。
反序列化同样定义万能的Value,然后json中reader是进行反序列化的,我们利用read中的parse函数,这个函数第一个参数是从哪个流进行反序列化,第二个参数就是我们的root。然后我们就可以将root中的x,y,op都拿出来,因为root中是字符串类型,而我们要的是整形,所以用asInt转化为整形,op并不会受影响,因为char的本质是ascll值。
response的序列化和反序列化同理,大家可以试试:
class Response
{
public:
Response()
:exitcode(0)
,result(0)
{
}
Response(int exitcode_,int result_)
:exitcode(exitcode_)
,result(result_)
{
}
bool serialize(std::string* out)
{
Json::Value root;
root["exitcode"] = exitcode;
root["result"] = result;
Json::FastWriter writer;
*out = writer.write(root);
return true;
}
bool deserialize(const std::string& in)
{
Json::Value root;
Json::Reader read;
read.parse(in,root);
exitcode = root["exitcode"].asInt();
result = root["result"].asInt();
return true;
}
public:
int exitcode; //返回码,0表示计算成功,非0表示计算失败
int result; //计算结果
};
下面我们运行起来看看json的效果:
可以看到换上json我们的代码也是没有问题的,我们在json和自己写的直接可以用条件编译,这样的话就可以想用哪个用哪个:
然后我们的makefile也可以修改一下,对于条件编译,我们可以直接编译的时候添加MYSELF,这样就会用我们自己写的序列化和反序列化代码。
红框的部分效果是一样的,只不过用LD变量会方便切换json和我们自己写的序列化和反序列化代码。
以上就是网页版计算器的全部内容了。
总结
自定义协议就是定义一个结构化的对象,有了结构化的对象未来客户端和服务端就可以来回进行发送和接收,约定在上面的代码中体现在规定必须是"x op y"这样的格式,这就是约定。注意:没有规定我们网络通信的时候只能有一种协议,实际上我们完全可以将上述代码中规定前面是正文长度替换为协议编码,通过协议编码就可以选择要执行哪种协议了。