一 概念理解
先前已经可以利用sock套接字通信了,但是数据如何处理就是我们应用层协议的内容了,之前都是发送一些字符串,但是实际上我们发送的消息可能是个结构化的数据。
那我们能不能直接发结构体呢? 可以但是会浪费空间,你想想我们平时写的作文有固定格式和缩进,但是对于网络来说这些缩进是浪费空间,所以我们序列化是为了压缩发送的数据大小。
将结构化的数据转为一个大字符串,称为序列化,然后发给服务端,服务端解析字符串(这就是反序列化),然后服务端构建响应,又序列化发给客户端。
我们是凭什么对一个结构体序列化,对字符串反序列的,就是双方约定好了一个格式,这样才能解析发来的数据,我们在下面约定的格式就是一种协议,而且是应用层协议。
之前我们只是用了一下socket接口,根本没有对数据做序列化和反序列,也没有对数据做处理,因为无场景。接下来我们实现一个网络版本的计算器,从中我们会设计序列化和反序列化,本质是设计一个应用层协议。
二 编码实现
计算器客户端和服务端实现放在两个.cc文件,通信客户端和服务端实现放在了头文件中。
1 makefile
makefile:一同编译client.cc和server.cc。
.PHONY:all
all:client server
client:CalculatorClient.ccg++ -o $@ $^ -std=c++11 -lpthread
server:CalculatorServer.ccg++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:rm -rf server client
2 封装系统调用
由于我们要进程调用和套接字相关的接口,所以就对这些接口做了封装。
class Sock
{public:int socket_;Log log_;
};
不用cout,而是用日志打印,日志打印是我们之前封装的模块,下面直接展示代码,使用的时候我们直接用log_调用仿函数就可以了。
enum ErrorLevel
{Info = 1,Warning,Fatal,Debug
};
//接收输出的文件
enum PMethod
{Screen = 1,//输出到屏幕OneFile ,//输出到一个文件上ClassFile//分类输出到多个文件中
};class Log
{
public:Log(int method = Screen):printmethod(method){;}string leveltostring(int level){switch (level){case Info:return "Info";case Warning:return "Warning";case Fatal:return "Fatal";case Debug:return "Debug";default: return "None"; }}//日志信息void operator()(int level, const char *format, ...){char leftbuffer[SIZE];time_t t = time(NULL);struct tm * ltime = localtime(&t);//默认部分 事件等级和时间snprintf(leftbuffer,sizeof(leftbuffer),"%s [%d %d %d %d:%d]",leveltostring(level).c_str(),ltime->tm_year+1900,ltime->tm_mon+1,ltime->tm_mday,ltime->tm_hour,ltime->tm_min);//可变部分char rightbuffer[SIZE];va_list s;va_start(s,format);vsnprintf(rightbuffer,sizeof(rightbuffer),format,s);// printf("%s %s\n",leftbuffer,rightbuffer);char Logbuffer[SIZE*2];snprintf(Logbuffer,sizeof(Logbuffer),"%s %s",leftbuffer,rightbuffer);LogPrint(level,Logbuffer);}void PrintOnefile( const char *filename,string& lbuffer){lbuffer+='\n';int fd = open(filename, O_CREAT|O_APPEND|O_WRONLY,0666);if(fd < 0)return;write(fd,lbuffer.c_str(),lbuffer.size()); }void PrintClassFile(int level,string& lbuffer){string filename = Logname;//将不同错误信息分流到对应的文件filename += ".";filename += leveltostring(level);PrintOnefile(filename.c_str(),lbuffer);}void LogPrint(int level,string lbuffer){switch(printmethod){case Screen://输出到屏幕cout<<lbuffer<<endl;break;case OneFile: //输出到一个文件上PrintOnefile(Logname,lbuffer);break;case ClassFile:PrintClassFile(level,lbuffer);break;}}
private:int printmethod;
};
之前创建套接字通信的时候,我们定义了许多和错误信息相关的宏,现在直接拿来用。
接下来才到封装的实现。
创建套接字
绑定
监听
‘
接收链接
int Accept(string* ip,uint16_t* port) // 名字不能为accept{// 获取链接struct sockaddr_in sock; // 头文件<netinet/in.h>bzero(&sock, sizeof(sock));socklen_t len = sizeof(sock);int socket = accept(socket_, (sockaddr *)&sock, &len);if (socket < 0){log_(ErrorLevel::Info, "accept err");exit(SOCKET_ERR);}else{*ip = inet_ntoa(sock.sin_addr);*port = ntohs(sock.sin_port);}return socket;}
connect,这个connect是客户端要用的,也是sock接口,就一同封装了。
Sock类中的套接字socket_含义由使用者来定义,可以是监听套接字,也可以是直接写的套接字。
三 服务端实现
我们是对服务端实现做了封装,封装在了该头文件中。
服务端main函数在该文件中。
我们在main函数中给服务类传端口号和可调用对象,服务端ip不用绑定,我这是云服务器,一绑定就会出错,然后初始化和启动服务端。
接下来看看服务器内部实现。成员如下,func_是接收可调用对象的。
1 初始化
初始化显然就是创建和监听套接字,显然我们此时的通信是基于tcp协议的。
2 初识start
暂时没链接时会链接失败,此时我们不能直接退出,要继续链接。
class Tcpserver;class ThreadData{public:ThreadData(std::string ip, uint16_t port,int socket,Tcpserver* ts):ip_(ip),port_(port),socket_(socket),ts_(ts){;}std::string ip_;uint16_t port_;int socket_;Tcpserver* ts_;}; class Tcpserver{public:using func_t = std::function<Response(const Request)>;Tcpserver(func_t func, uint16_t port): port_(port), func_(func){;}void start() // 接收链接,获取客户端端口号+ip,创建线程执行{while (true){string ClientIp;uint16_t clientport;int socket_ = sock_.Accept(&ClientIp, &clientport);if (socket_ < 0)continue;log_(ErrorLevel::Debug, "get a new client,client info: [%s:%d]",ClientIp.c_str(),clientport);创建线程,传递客户端端口号和ippthread_t id;ThreadData *td = new ThreadData(ClientIp, clientport, socket_, this);pthread_create(&id, nullptr, threadRoutine, td);}}private:func_t func_;Log log_;Sock sock_;uint16_t port_;};
我们给执行函数传了个ThreadData类对象。在threadRoutie掉用serverio函数,方便后续如果服务端收到请求要做其它处理,此时就将serverio函数换成其它函数即可。
static void* threadRoutine(void*arg){ThreadData* td = static_cast<ThreadData*>(arg);td->ts_->ServerIO(td->ip_,td->port_,td->socket_);}
我们知道如果ServerIO函数要和客户端通信,肯定需要客户端ip,端口和套接字,所以我们先把这些参数合并传给该函数。写完后,但是客户端还没写好,如何测试,我们可以用可以指令向服务器发起一个链接。
void ServerIO(std::string ip, uint16_t port,int socket){;}
可以测试目前代码是否可以跑通,也就是套接字创建是否会问题。
在实现ServerIO函数前,我们先定协议,先记住我们要在这个函数读客户端消息,处理数据并返回,这个大致步骤方便我们实现完协议来理解调用逻辑。
3 设计协议
数据的流动前面提过:客户端发起request,序列化转为字符串发给服务端,服务端收到后反序列化,处理返回responce,将结果序列化再发给客户端,客户端收到后再反序列化转为Response对象,客户端直接读写对象成员就可以获得结果了。
所以我们要将request和Response序列化和反序列化。封装在下面这个头文件中。
Request序列化
协议就是规定:我们首先规定Request请求就是x + y,两个操作数,一个操作符,然后序列化的字符串必须是"x + y",有时候我们想让字符串变成"x + y",操作符之间间隔增加一个空格,这里要体会一下不用宏的话,如果要变更格式有多麻烦。
#define SEP " "#define SEP_LEN strlen(SEP)
class Request{public:Request(){;}Request(int x, int y, char op): x_(x), y_(y), op_(op){;}// class -> string 序列化bool serialize(std::string *res){*res += std::to_string(x_);*res += SEP;*res += op_;*res += SEP;*res += std::to_string(y_);return true;}~Request(){;}int x_;int y_;char op_;};
反序列化
既然前面已经规定操作数之间,操作符和操作数之间是有间隔符的,那我们就用string的find接口查找间隔符SEP,把一个个操作数截取下来。
class util
{
public://"10 + 10"static void StringSplit(const std::string res,const std::string sep,std::vector<std::string>*vs){int pos = 0;while(pos != -1){int nextpos = res.find(sep.c_str(),pos);if(nextpos == -1)break;vs->push_back(res.substr(pos,nextpos - pos));pos = nextpos + sep.size();}截取最后一个操作数vs->push_back(res.substr(pos,-1));}
};
将"x + y"截取成"x","+","y"保存到vector中,此时我们可以更深刻的意识到协议就是规定。
string -> class "x + y" 反序列化bool Deserialize(const std::string &res){std::vector<std::string> vs;util::StringSplit(res, SEP, &vs);if (vs.size() != 3)return false;x_ = atoi(vs[0].c_str());if (vs[1].size() != 1)return false;op_ = vs[1][0];y_ = atoi(vs[2].c_str());}
做判断,操作符大小必须是1,操作符和操作数的大小和为3,这,都是基于我们的规定的来的。
Response 序列化
class Response{public:Response(){;}Response(int result, int exitcode): result_(result), exitcode_(exitcode){;}// class -> stringbool serialize(std::string *msg){*msg += std::to_string(result_);*msg += SEP;*msg += std::to_string(exitcode_);return true;}~Response(){;}int result_;int exitcode_;};
我们在序列化Respnse的时候规定了,必须是"结果 + 退出码",这是反序列的时候截取字符串的基础。
反序列化,还可以复用Reuest实现的截取字符串,封装的妙处总是在不经意中体现。
string -> classbool Deserialize(const std::string &res){std::vector<std::string> vs;if (vs.size() != 2)return false;util::StringSplit(res, SEP, &vs);result_ = atoi(vs[0].c_str());exitcode_ = atoi(vs[1].c_str());return true;}
可是设计了协议,怎么调用呢?调用顺序是什么呢? 我们前面说了,我们在threadRoutine内调用ServerIO函数来读写,所以接下来就在该函数内将上述实现用起来。
4 start完善
先读数据,既然是读数据,自然是从套接字中读取,有意思的是我们怎么保证一次读一个完整报文呢,也就是说我们怎么保证一次读出"x + y"这样的完整报文,我们调用read每次读固定大小,肯定会出现读取多个报文的情况,所以我们对协议做了修改,原先规定请求是"x + y",为了方便读取,我们添加了一个报头,5"\n"x + y",然后设计一个函数,保证能切割出一个完整报文给下一步执行。这个还和tcp面向字节流有些关系,导致tcp向上向下交付以字节为单位,而不是一个一个数据报交付,需要我们手动切割。
我们来看看函数内部实现。inbuffer保存了recv读到的所有数据。
这个是后面经常用的宏。
从socket读取数据,保存到inbuffer中,并解析出数据报放到package中int Readpackage(int socket, std::string *inbuffer, std::string *package){cout << "读取数据前:" << *inbuffer << endl;Log log_;char buffer[1024] = {0};int n = recv(socket, buffer, sizeof(buffer), 0);*inbuffer += buffer; 保存读到的数据cout << "读取数据中:" << endl<< *inbuffer;if (n < 0) // 出错后返回{return -1;}截取一个数据报 "5\n"x + y"\n"int pos = inbuffer->find(HEAD, 0);if (pos == -1){return -1;}// 截取的是记录着有效载荷长的字符串std::string Size = inbuffer->substr(0, pos);int lensize = atoi(Size.c_str()); 有效载荷长度完整报文长度int packagelen = lensize + 2 * HEAD_LEN + Size.size();if ((*inbuffer).size() < packagelen) 读取的数据不够一个数据报return 0;截取有效载荷*package = inbuffer->substr(pos + HEAD_LEN, lensize);inbuffer->erase(0, packagelen);cout << "读取数据后:" << endl<< *inbuffer;return lensize;}
此时我们回到外部函数逻辑中。
void ServerIO(std::string ip, uint16_t port, int socket){std::string inbuffer;while (true){1 读取数据std::string package;int n = Readpackage(socket,&inbuffer,&package);if (n < 0){ close(socket);exit(READ_ERR);}else if (n == 0)continue;到了这里就已经读取到了完整的数据报,先去除报头其实已经不用去除了,因为前面我们读取的就是一个有效载荷package = RemoveHead(package,n);2 将字符串反序列化Request rq;rq.Deserialize(package);3 处理一个请求,并返回结果Response rp = func_(rq);4 将结果序列化string send_string;rp.serialize(&send_string);添加报头发送,响应也要有报头send_string = AddHead(send_string);//发送到网络中write(socket,send_string.c_str(),send_string.size());}}
添加报头。
移除报头。
在第3步的时候我们用func回调了一个函数,这个函数是一开始外部传入的请求处理函数。
func_函数实现。
Response calculate(const Request &rq)
{Response rp(0,0);switch (rq.op_){case '+':rp.result_ = rq.x_ + rq.y_;break;case '-':rp.result_ = rq.x_ - rq.y_;break;case '*':rp.result_ = rq.x_ * rq.y_;break;case '/':if (rq.y_ == 0){rp.exitcode_ = 1;break;}rp.result_ = rq.x_ / rq.y_;break;case '%':if (rq.y_ == 0){rp.exitcode_ = 2;break;}rp.result_ = rq.x_ + rq.y_;break;default:rp.exitcode_ = 3;break;}return rp;
}
四 客户端编写
int main(int argc, char *argv[])
{if (argc != 3){exit(USAGE_ERR);}std::string ip = argv[1];uint16_t port = atoi(argv[2]);Log logs;Sock socks;socks.Socket();socks.Connect(ip, port);logs(ErrorLevel::Debug, "init success: sockt:%d", socks.socket_);std::string inbuffer;while (true){std::cout << "data1# ";Request rq;std::cin >> rq.x_;std::cout << "data2# ";std::cin >> rq.y_;std::cout << "data3# ";std::cin >> rq.op_;// 序列化std::string ret;rq.serialize(&ret);// 添加报头ret = AddHead(ret);// 开始发送send(socks.socket_, ret.c_str(), ret.size(), 0);// 开始读取std::string package;
START:int n = Readpackage(socks.socket_, &inbuffer, &package);if (n < 0){close(socks.socket_);exit(READ_ERR);}else if (n == 0)goto START;// 到了这里就已经读取到了完整的数据报,先去除报头package = RemoveHead(package, n);//将字符串反序列化Response rp;rp.Deserialize(package); cout<<"result: "<<rp.result_<<endl;cout<<"exitcode: "<<rp.exitcode_<<endl;}return 0;
}
首先获取到服务端ip和端口。
创建套接字,并且链接服务器。
开始获取构建一个请求,这个请求是关于x和y的计算,所以我们首先输入x和y的值,然后把操作数也输入进来,因为这个请求可能是x+y,或者x-y。
输入完后,要开始准备发送了。当然要先序列化了,把请求转成字符串,但是我们还要添加报头,这个报头是服务端读取一个完整报文的关键。
此时我们才可以发送,发送完我们还可以接收服务端的响应。
这个接收服务端的响应的实现就和服务端那边的实现差不多,都是调用Readpackage读取一个完整报文,然后就是去除报头,再反序列化,我们最终可以打印显示响应了,这个响应包括计算结果和错误码。
由于调用链比较长,我们增加了一些日志,一步步看看我们的序列化和反序列是否符合逻辑。