1. 再谈 “协议”
1.1 协议的概念
- “协议”本质就是一种约定,通信双方只要曾经做过某种约定,之后就可以使用这种约定来完成某种事情。而网络协议是通信计算机双方必须共同遵从的一组约定,因此我们一定要将这种约定用计算机语言表达出来,此时双方计算机才能识别约定的相关内容。
- 为了使数据在网络上能够从源到达目的,网络通信的参与方必须遵循相同的规则,我们将这套规则称为协议(protocol),而协议最终都需要通过计算机语言的方式表示出来。只有通信计算机双方都遵守相同的协议,计算机之间才能互相通信交流。
1.2 结构化数据的传输
(1)通信双方在进行网络通信时:
- 如果需要传输的数据是一个字符串,那么直接将这一个字符串发送到网络当中,此时对端也能从网络当中获取到这个字符串。
- 但是如果需要传输的是一些结构化的数据,此时就不能将这些数据一个个发送到网络当中。
(2)协议是一种 “约定”。socket api的接口,在读写数据时,都是按 “字符串” 的方式来发送接收的。如果我们要传输一些"结构化的数据" 怎么办呢?
- 比如现在要实现一个网络版的计算器,那么客户端每次给服务端发送的请求数据当中,就需要包括左操作数、右操作数以及对应需要进行的操作,此时客户端要发送的就不是一个简单的字符串,而是一组结构化的数据。类似如下的结构:
class cal
{int _x;int _y;char _op;
}
假设我定义了一个cal结构体对象d(10, 20, ‘+’)。我们不能直接把此结构体对象d(二进制序列)直接交给服务端。解决办法如下:
(3)我们可以把结构体序列化 + 反序列化进行处理即可
- 如上结构体对象c(10, 20, ‘+’)。我们将其按照一定的规则转成字符串10:20:+。然后再发送给服务端。
- 且你和服务端有个协议(约定):一共有三个区域,前两个是int,后一个是char,用:分割。
- 此时服务端接受数据后再按相同的规则把接收到的数据转化为结构体
上述过程中,我们把结构化数据转化成字符串或字节流序列叫做序列化。把你发过来的字符串按照一定要求转成服务器所要用到的对象叫做反序列化。
注意:
- 我们需要在定制协议的时候,序列化之后,需要将长度设置为4字节,并把长度放入序列化之后的字符串的开始之前。这就是自描述长度的协议。
- 此长度是一定要加上的。不然就好比如你给张三寄快递,张三收到了快递,但是你若不告诉张三有多少快递,张三就会一直担心快递有没有拿完。
综上可知:
- 客户端可以定制一个结构体,将需要交互的信息定义到这个结构体当中。客户端发送数据时先对数据进行序列化,服务端接收到数据后再对其进行反序列化,此时服务端就能得到客户端发送过来的结构体,进而从该结构体当中提取出对应的信息。
1.3 序列化和反序列化
(1)什么是序列化和反序列化:
- 序列化是将对象的状态信息转换为可以存储或传输的形式(字节序列)的过程。
- 反序列化是把字节序列恢复为对象的过程。
OSI七层模型中表示层的作用就是,实现设备固有数据格式和网络标准数据格式的转换。其中设备固有的数据格式指的是数据在应用层上的格式,而网络标准数据格式则指的是序列化之后可以进行网络传输的数据格式。
(2)序列化和反序列化的目的:
- 在网络传输时,序列化目的是为了方便网络数据的发送和接收,无论是何种类型的数据,经过序列化后都变成了二进制序列,此时底层在进行网络数据传输时看到的统一都是二进制序列。
- 序列化后的二进制序列只有在网络传输时能够被底层识别,上层应用是无法识别序列化后的二进制序列的,因此需要将从网络中获取到的数据进行反序列化,将二进制序列的数据转换成应用层能够识别的数据格式。
我们可以认为网络通信和业务处理处于不同的层级,在进行网络通信时底层看到的都是二进制序列的数据,而在进行业务处理时看得到则是可被上层识别的数据。如果数据需要在业务处理和网络通信之间进行转换,则需要对数据进行对应的序列化或反序列化操作。
2. 网络版计算器
在如下的代码演示中。服务器和客户端采用的是TCP网络程序(线程池版),对于服务端和客户端来说,就是固定的模式(创建套接字、绑定……)。重点还是在于网络版计算器的协议定制。
在编写网络版本计算器时先对套接字进行封装Socket.hpp:
#pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "Log.hpp"Log lg;
enum
{SocketErr = 2,BindErr,ListenErr,
};const int backlog = 10;class Sock
{
public:Sock(){}void Socket(){_sockfd = socket(AF_INET, SOCK_STREAM, 0);if(_sockfd < 0){lg(Fatal, "socker error, %s: %d", strerror(errno), errno);exit(SocketErr);}}void Bind(const uint16_t& port){sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(port);local.sin_addr.s_addr = INADDR_ANY;int n = bind(_sockfd, (sockaddr*)&local, sizeof(local));if(n < 0){lg(Fatal, "bind error, %s: %d", strerror(errno), errno);exit(BindErr);}}void Listen(){int n = listen(_sockfd, backlog);if(n < 0){lg(Fatal, "listen error, %s: %d", strerror(errno), errno);exit(ListenErr);}}int Accept(std::string& clientip, uint16_t& clientport){sockaddr_in peer;socklen_t len = 0;int newfd = accept(_sockfd, (sockaddr*)&peer, &len);if(newfd < 0){lg(Fatal, "accept error, %s: %d", strerror(errno), errno);return -1;}char buffer[64];inet_ntop(AF_INET, &peer.sin_addr, buffer, sizeof(buffer));clientip = buffer;clientport = ntohs(peer.sin_port);return newfd;}int Connect(const std::string& serverip, const uint16_t serverport){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(_sockfd, (sockaddr*)&server, sizeof(server));if(n < 0){lg(Warning, "connect error, %s: %d", strerror(errno), errno);return -1;}return 0;}void Close(){close(_sockfd);}int Getsock(){return _sockfd;}~Sock(){}private:int _sockfd;
};
2.1 TcpServer.hpp文件
给服务端封装成一个TcpServer类。此服务端主要完成如下工作:
(1)对服务器进行初始化(Init成员函数):
- 调用socket函数,创建套接字。
- 调用bind函数,为服务端绑定一个端口号。
- 调用listen函数,将套接字设置为监听状态。
(2)启动服务器(run成员函数):
- 初始化完服务器后就可以启动服务器了,不断调用accept函数,从监听套接字当中获取新连接。创建子进程来进行任务处理,将子进程变成孤儿进程后就可以不需要管。
TcpServer.hpp:
#pragma once
#include <functional>
#include "Log.hpp"
#include "Socket.hpp"
#include <signal.h>using func_t = std::function<std::string (std::string& package)>;class TcpServer
{
public:TcpServer(uint16_t port, func_t func):_port(port),callback_(func){}bool Init(){_listensock.Socket();_listensock.Bind(_port);_listensock.Listen();return true;}void run(){signal(SIGCHLD, SIG_IGN);signal(SIGPIPE, SIG_IGN);while(1){std::string clientip;uint16_t clientport;int sockfd = _listensock.Accept(clientip, clientport);if(sockfd < 0){continue;}lg(Info, "accept a new link, sockfd: %d, clientip: %s, clientport: %d", sockfd, clientip.c_str(), clientport);//提供服务pid_t id = fork();if(id == 0){_listensock.Close();std::string inbuffer_stream;while(1){char buffer[128];ssize_t n = read(sockfd, buffer, sizeof(buffer));if(n > 0){buffer[n] = 0;inbuffer_stream += buffer;lg(Debug, "debug:\n%s", inbuffer_stream.c_str());while (1){std::string info = callback_(inbuffer_stream);if(info.empty()){break;}lg(Debug, "debug, response:\n%s", info.c_str());lg(Debug, "debug:\n%s", inbuffer_stream.c_str());int m = write(sockfd, info.c_str(), info.size());if (m < 0){lg(Fatal, "write error, %s: %d", strerror(errno), errno);break;}}}else if(n == 0){break;}else{break;}}exit(0);}close(sockfd);}}~TcpServer(){}
private:Sock _listensock;uint16_t _port;func_t callback_;
};
2.2 网络计算器任务(ServerCal类):
- 服务端收到客户端的数据,一定是经过序列化后的字符串。我们需要调用read函数进行读取。不过我们不能保证一次性将序列化后的字符串全部读取过来,因为TCP是面向字节流的,有自己的一套发送机制。就比如我们要的是完整的字符串(len\n"x op y"\n)。没有读完就只能继续读。
- 读取后调用decode函数检测是不是已经具有了一个完整的报文,若不是则继续读取。
- 读取成功后,调用Deserialize反序列化函数把序列化后的字符串转为结构化的数据。
- 通过调用执行计算任务函数Calculator将发序列化后的数据进行计算。
- 将计算好的数据(结构化的数据)调用Serialize序列化将结构化的数据转为字符串。
- 根据协议规定,还需要给序列化后的数据添加报头长度,调用encode函数完成。
- 最后调用write函数把最终结果写回客户端。
#pragma once
#include <iostream>
#include "Protocol.hpp"enum
{Div_Zero = 1,Mod_Zero,Other_Oper
};
class ServerCal
{
public:ServerCal(){}Response CalculatorHelper(const Request& req){Response res(0, 0);switch (req._op){case '+':res._result = req._x + req._y;break;case '-':res._result = req._x - req._y;break;case '*':res._result = req._x * req._y;break;case '/':if(req._y == 0){res._code = Div_Zero;break;}res._result = req._x / req._y;break;case '%':if(req._y == 0){res._code = Mod_Zero;break;}res._result = req._x % req._y;break;default:res._code = Other_Oper;break;}return res;}std::string Calculator(std::string& package){std::string content;bool r = Decode(package, content); //解包if(!r){return "";}Request req;r = req.Deserialize(content); //反序列化if(!r){return "";}content = "";Response res = CalculatorHelper(req);res.Serialize(content);content = Encode(content);return content;}~ServerCal(){}
};
2.3 ServerCal.cpp文件
将上述两个头文件TcpServer.hpp和ServerCal.hpp包含在内,new一个TcpServer类,并且利用bind绑定Calculator函数的功能,这样服务器计算就会调用Calculator函数。
#include "TcpServer.hpp"
#include "ServerCal.hpp"int main()
{ServerCal cal;TcpServer* ts = new TcpServer(8080, std::bind(&ServerCal::Calculator, &cal, std::placeholders::_1));ts->Init();ts->run();return 0;
}
2.4 客户端clientTcp.cpp文件
(1)执行代码逻辑如下:
- 调用socket函数,创建套接字。
- 客户端初始化完毕后需要调用connect函数连接服务端,当连接服务端成功后,客户端就可以向服务端发起计算请求了。
- 定义Request类对象req,复用makeRequest函数将输入的字符串的数据填到结构体对象req的成员变量里
- 先调用serialize函数序列化,再调用encode函数添加长度报头,返回值string类型的package对象。
- 利用write函数将package的内容写到套接字里,发送到网络里。调用read函数从套接字中读取数据存到字符串echoPackage里,注意此时的字符串是服务端encode加上长度报头后的结果,我们需要复用decode进行解码。
- 最后调用deserialize反序列化函数完成字符串到结构化数据的转变。并输出退出码和最终运算结果。
(2)ClientCal.cpp:
#include "Socket.hpp"
#include "Protocol.hpp"const uint16_t port = 8080;
const std::string ip = "115.159.193.163";int main()
{Sock sockfd;sockfd.Socket();int c = sockfd.Connect(ip, port);if(c < 0){std::cerr << "Connect error..." << std::endl;return -1;}const std::string opers = "+-*/%-&^";srand(time(nullptr) ^ getpid());int cnt = 1;while(cnt <= 10){std::cout << "===============第" << cnt << "次测试....., " << "===============" << std::endl;int x = rand() % 100 + 1;usleep(1234);int y = rand() % 100;usleep(4321);char op = opers[rand() % opers.size()];Request req(x, y, op);req.DebugPrint();std::string package;req.Serialize(package); //序列化package = Encode(package);std::cout << package << std::endl;int m = write(sockfd.Getsock(), package.c_str(), package.size());if(m < 0){std::cerr << "write error..." << std::endl;}char inbuffer[128];ssize_t n = read(sockfd.Getsock(), inbuffer, sizeof(inbuffer));if (n > 0){inbuffer[n] = 0;std::string in = inbuffer;std::string content;bool r = Decode(in, content); //解包if(!r){std::cerr << "Decode error..." << std::endl;continue;}Response res(0, 0);res.Deserialize(content); //反序列化res.result();}std::cout << "=================================================" << std::endl;sleep(1);cnt++;}sockfd.Close();return 0;
}
2.5 协议定制Protocol.hpp文件
实现一个网络版的计算器,就必须保证通信双方能够遵守某种协议约定,因此我们需要设计一套简单的约定。数据可以分为请求数据和响应数据,因此我们分别需要对请求数据和响应数据进行约定。在实现时可以采用C++当中的类来实现,此时就需要一个请求Request类和一个响应Response类(最终结果)。
(1)请求类和响应类的成员变量定义如下:
- 请求结构体中需要包括两个操作数,以及对应需要进行的操作。
- 响应结构体中需要包括一个计算结果,除此之外,响应结构体中还需要包括一个状态字段,表示本次计算的状态,因为客户端发来的计算请求可能是无意义的。
(2)规定状态字段对应的含义:
- 状态字段为0,表示计算成功。
- 状态字段为1,表示出现错误。
(3)Request请求类和Reponse响应类的主题框架很相似,都有如下的两个函数:
-
序列化serialisze函数:
- 因为我们客户端不能直接把此结构化的数据发送给服务端,所以得通过序列化的方式将结构化的数据转成字符串的格式。
-
反序列化deserialisze函数:
- 当我们服务端收到客户端发来的数据(结构化的数据转为字符串),它也要通过反序列化的方式把字符串风格的信息转化成结构化的数据
注意:下面两个函数是全局函数,不独属于某个类内部成员函数。
(4)给序列化后的字符串添加长度字段的Encode函数:
当客户端把结构化的数据发送给服务端时,作为服务端,我必须得知道此字符串的长度大小,就比如你去取朋友给你寄的快递,你的朋友不告诉你有多少快递要取,你就一直不知道快递有没有拿完。因此,我们需要在序列化后的字符串前面带上长度。不过此长度的设定有两种方案:
- lenXXXXXXXX:定长4字节,将来对方发送数据,服务端先读取前4个字节(转换成有效字符串有多长)。此法可读性不好,中间出了问题不好调试。
- "len\n"XXXXXXXXXX\n:把长度定为字符串,中间用\r\n间隔序列化后的数据。先读长度,读完后另起一行再读后续的内容。推荐这种方法。
(5)整个序列化之后的字符串进行提取长度Decode函数:
- 此函数要确保序列化后的字符串必须具有完整的长度
- 必须具有和len相符合的有效载荷。如(9\n100 + 200\n)
- 我们才返回有效载荷和len。否则,decode就是一个检测函数
而针对于客户端和服务端,它们对数据处理的不同方式的需求导致了它们需要各自不同的协议,如下展开讨论。
(6)服务端的协议步骤:
①将客户端发来的整个序列化之后的字符串进行提取长度Decode函数
- 服务端调用read函数读取数据是不一定能够一次读完的,所以调用decode函数检测客户端序列化后的数据是否是一个完整的报文。
内部实现逻辑如下:
- 先确认是否是一个包含len的有效字符串
- 提取长度
- 确认有效载荷也是符合要求的
- 确认有完整的报文结构
- 将当前报文完整的从package中全部移除掉
- 正常返回
②Request请求类:反序列化Deserialize函数:
当我服务端调用Decode函数成功检测到读到的数据是一串完整的报文后,就要进行反序列化将字符串转为结构化的数据了。这里我们严格要求序列化后的字符串是类似于(10 + 20)的格式,便于我们后续进行反序列化。
内部实现逻辑如下:
- 正向找到第一和反向找到第二个空格的位置。若找不到直接返回false。
- 利用substr函数截取从下标0到第一个空格的字符串为port_x (第一个操作数)。
- 利用substr函数截取从第二个空格往前一格到结尾的字符串为port_y (第二个操作数)。
- 类似的,定义oper为计算的任务类型(+、-、*、/)。
- 将上述提取的数据转为内部成员(_x,_y,_op)即可。
③Response响应类:序列化serialize函数:
- 上述执行的反序列化函数是为了提取计算数和计算符号(如10 + 20),提取好后服务端要进行calculator计算了,计算后的返回值是一个结构化的数据。我们需要对此返回值进行序列化将结构化的数据转为字符串,才能有助于后续服务端把结果写回客户端。
- 注意:序列化后的字符串格式要如同(“_code, _result”)。
④添加报头长度Encode函数:
- 当把结果转为字符串后,还需要调用Encode函数来帮助我们添加此字符串的长度报头。
- 我的核心宗旨就是把传过来的字符串如(“_code _result”)转为(“len\n_code _result\n”)的格式。
注意:因为此函数对于客户端到后面也需要,执行逻辑和这相差无几。所以我们将其设计为全局函数,方便后续调用。
(7)客户端的协议步骤:
①Request请求类:序列化serialize函数:
- 当客户端把输入的字符串填充到Request结构体后,首先要做的就是serialize序列化将结构化的数据转为字符串。
代码实现逻辑如下:
- 利用to_string函数把传入的Request结构体的两个成员变量(计算数)转为字符串,并分别保存起来。
- 统一按照(10 + 20)的格式把这些字符串整合起来即可。
②添加报头长度Encode函数:
- 当序列化之后,就是要调用Encode函数添加报头长度。Encode函数实现逻辑如上已经讲解。
③整个序列化之后的字符串进行提取长度Decode函数:
- 后续客户端读取到由服务端返回的结果是带有长度报头的,这里我们需要调用Decode函数进行解码,同时也是在检测读取到的结果是否是一个完整的报文。
④Response响应类:反序列化deserialize函数:
当服务端把结果返回到客户端,然后Decode解码后,我们得到的结果是类似于(“0 100”)的字符串,下面就是调用deserialize反序列化函数将其转为结构化数据。
代码实现逻辑如下:
- 先找到空格的位置pos。
- 利用substr函数分别保存空格两边的字符串,分别用port_res 和port_code 保存。
- 最后将反序列化的结果写入到内部成员中,形成结构化数据。
如上函数和类整体实现代码(包含Json串):
#pragma once
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>//#define Myself 1const std::string blank_space_sep = " ";
const std::string protocol_sep = "\n";std::string Encode(std::string& content)
{std::string package = std::to_string(content.size());package += protocol_sep;package += content;package += protocol_sep;return package;
}//len\n"x op y"\n
bool Decode(std::string& package, std::string& content)
{size_t pos = package.find(protocol_sep);if(pos == std::string::npos){return false;}std::string len_str = package.substr(0, pos);size_t len = std::stoi(len_str);size_t total_len = len_str.size() + len + 2;if(total_len != package.size()){return false;}content = package.substr(pos + 1, len);// earse 移除报文 package.erase(0, total_len);package.erase(0, total_len);return true;
}class Request
{
public:Request(int x, int y, char op):_x(x),_y(y),_op(op){}Request(){}//len\n"x op y"\nbool Serialize(std::string& out) //序列化{
#ifdef Myselfstd::string s = std::to_string(_x);s += blank_space_sep;s += _op;s += blank_space_sep;s += std::to_string(_y);out = s;return true;
#elseJson::Value root;root["x"] = _x;root["y"] = _y;root["op"] = _op;Json::FastWriter w;out = w.write(root);return true;#endif}bool Deserialize(const std::string& in) // "x op y" 反序列化{
#ifdef Myselfsize_t left = in.find(blank_space_sep);if(left == std::string::npos){return false;}std::string port_x = in.substr(0, left);size_t right = in.rfind(blank_space_sep);if(right == std::string::npos){return false;}std::string port_y = in.substr(right + 1);if(left + 2 != right){return false;}_op = in[right - 1];_x = std::stoi(port_x);_y = std::stoi(port_y);return true;
#elseJson::Value root;Json::Reader r;r.parse(in, root);_x = root["x"].asInt();_y = root["y"].asInt();_op = root["op"].asInt();return true;#endif}void DebugPrint(){std::cout << "新请求构建完成: " << _x << _op << _y << "= ?" << std::endl;}~Request(){}// x op yint _x;int _y;char _op; // + - * / %
};class Response
{
public:Response(int result, int code):_result(result),_code(code){}bool Serialize(std::string& out){
#ifdef Myselfstd::string s = std::to_string(_result);s += blank_space_sep;s += std::to_string(_code);out = s;return true;#elseJson::Value root;root["result"] = _result;root["code"] = _code;Json::FastWriter w;out = w.write(root);return true;#endif}bool Deserialize(const std::string& in) // "x op y"{
#ifdef Myselfsize_t left = in.find(blank_space_sep);if(left == std::string::npos){return false;}std::string port_res = in.substr(0, left);std::string port_code = in.substr(left + 1);_result = std::stoi(port_res);_code = std::stoi(port_code);return true;#elseJson::Value root;Json::Reader r;r.parse(in, root);_result = root["result"].asInt();_code = root["code"].asInt();return true;#endif}void result(){std::cout << "result: " << _result << std::endl;std::cout << "是否可信: " << _code << std::endl;}~Response(){}int _result;int _code; // 0,可信,否则!0具体是几,表明对应的错误原因
};
3. Json序列化和反序列化
如上的网络版计算器我们是自己定制的协议,且全部都是手写的。Encode和Decode是我么必须要做的,不过针对于序列化和反序列化,我们可以采用别人的方案(xml、json、protobuf)。我们以json示例,如下进行演示。
3.1 安装json库
(1)使用如下命令:
sudo yum install -y jsoncpp-devel
(2)使用如下命令查看json的位置:
(3)Json的头文件:
#include <jsoncpp/json/json.h>
3.2 request类当中的json
3.2.1 request类的json序列化
bool Serialize(std::string& out) //序列化{
#ifdef Myselfstd::string s = std::to_string(_x);s += blank_space_sep;s += _op;s += blank_space_sep;s += std::to_string(_y);out = s;return true;
#else// 1、创建Value对象,万能对象// 2、json是基于kv的// 3、json有两套操作方法// 4、序列化的时候,会将所有的数据内容,转换为字符串Json::Value root;root["x"] = _x;root["y"] = _y;root["op"] = _op;Json::FastWriter w;out = w.write(root);return true;#endif}
Response类和上述原理相同。
3.2.2 request类的json反序列化
bool Deserialize(const std::string& in) // "x op y" 反序列化{
#ifdef Myselfsize_t left = in.find(blank_space_sep);if(left == std::string::npos){return false;}std::string port_x = in.substr(0, left);size_t right = in.rfind(blank_space_sep);if(right == std::string::npos){return false;}std::string port_y = in.substr(right + 1);if(left + 2 != right){return false;}_op = in[right - 1];_x = std::stoi(port_x);_y = std::stoi(port_y);return true;
#elseJson::Value root;Json::Reader r;r.parse(in, root);_x = root["x"].asInt();_y = root["y"].asInt();_op = root["op"].asInt();return true;#endif}
Response类和上述原理相同。
3.3 Makefile中的Json操作
(1)-D命令行定义宏:
先前若我们想要让代码执行json版的序列化和反序列化操作需要用到宏定义:
如果我们定义了此宏,那么后续的序列化和反序列化操作就使用自己的,如果没定义,则用json的。现在我们可以在Makefile中定义变量,利用-D选项。
-D:命令行定义宏。
目的:这样就不用把宏定义在源代码中(不用动源代码了),某种宏的定义会决定条件编译对相应代码进行裁剪。
(2)效果如下:
如果利用#进行注释:Method=#-DMY_SELF。此时Method是无内容的,此时代码中的 #ifdef MY_SELF 条件编译不起作用而会执行#else,此时就会执行json的序列化代码;
(3)Json::FastWriter与Json::StyledWriter两种显示风格:
通常 Json::FastWriter 传输数据量较少,使用Json::StyledWriter较多。两种风格打印的结果对比如下:
源码链接:网络版本计算器源码。