网站整体设计风格,做一个网站如何赚钱,利用网络挣钱的路子,绵阳seo本文首发于 慕雪的寒舍
以tcpServer的计算器服务为例#xff0c;实现一个自定义协议 阅读本文之前#xff0c;请先阅读 tcpServer 本文完整代码详见 Gitee 1.重谈tcp
注意#xff0c;当下所对tcp的描述都是以简单、方便理解起见#xff0c;后续会对tcp协议进行深入解读
…本文首发于 慕雪的寒舍
以tcpServer的计算器服务为例实现一个自定义协议 阅读本文之前请先阅读 tcpServer 本文完整代码详见 Gitee 1.重谈tcp
注意当下所对tcp的描述都是以简单、方便理解起见后续会对tcp协议进行深入解读
1.1 链接
我们知道tcp是面向连接的客户端和服务端要先建立链接才能开始通信
在链接过程中tcp采用三次握手在断线过程中tcp采用四次挥手
举个日常生活中的栗子帮助理解3次握手和4次挥手 1.2 信息发送 假如我们现在需要发送结构化数据那应该怎么办 我们知道tcp是面向字节流的也就是其能够发送任意数据。也能够发送C语言结构体的二进制数据
但能发送就代表我们可以这么干吗答案自然是不行
不同平台对结构体对齐的配置不同大小端不同其最终对我们字节流的解析也就不一样。如果采用直接发送结构体数据的方式来通信适配性极低我们的客户端和服务端都会被限制在当前的系统环境中运行
可是哪怕是同一个系统其内部对大小端的配置也有可能改变到时候我们的代码恐怕就无法运行了
同理在当初编写C语言通讯录的代码的时候也不能采用直接将结构体数据写入文件的方式。后续代码升级、环境改变都可能导致我们存在文件中的数据失效这肯定是我们不希望看到的情况。
所以为了解决这个问题我们就应该将数据进行序列化之后再发送客户端接收到信息后进行反序列化解析出数据
2.序列化和反序列化
2.1 简介
所谓序列化就是将结构化的数据可以暂时理解为c的结构体转换成字符串的方式发送出去
struct date
{int year;int month;int day;
};比如上面这个日期结构体我们要想将其序列化就可以用一个很简单的方式拼接成一个字符串序列化
year-month-day客户端收到这个字符串之后就可以通过查找分隔符-的方式取出三个变量将其转成int后存放回结构体反序列化
这样我们就算是规定了一个序列化和反序列化的方式也就是一个简单的协议 2.2 编码解码
这里还会出现另外一个问题我要怎么知道我已经读取完毕了一个序列化后的数据呢
2000-12-10
10000-01-01如上假设有一天我们的年变成了五位数这时候服务端要怎么知道自己是否读取完毕了一个完整的序列化数据呢
这就需要我们做好规定将前n字节作为标识长度的数据。接收到数据后先取出前n个字节读取道此次消息的长度m再往后读取m个字节的数据成功取出完整的字符串;
这个过程可以称作编码和解码的过程
为了区分标识长度的数据和实际需要的序列化内容我们可以在之中加上分隔符\t但这也需要我们确认传输的数据本身不能带上\t否则会产生一系列的问题
10\t2000-12-10\t
11\t10000-01-01\t以上的这一系列工作都是协议定制的一部分我们给服务端和客户端规定了一个序列化和反序列化的方式让二者通信规避掉了平台的限制。毕竟任何平台对字符串解码出来的数据都会是相同的
下面就用一个计算器的服务来演示一下吧
3.计算器服务
因为本文的重心是对协议定制的演示所以这里的计算器不考虑连续操作符的情况
3.1 协议定制
要想实现一个计算器我们首先要搞明白计算器有几个成员
xy
x/y
x*y
...一般情况下一个计算器只需要3个成员分别是两个操作数和一个运算符就能开始计算。所以我们需要将这里的三个字段设计成一个字符串实现序列化
比如我们应该规定序列化之后的数据应该是如下的两个操作数和操作符之间应该要有空格
a b再在开头添加上数据长度的标识
数据长度\t公式\t7\t10 20\t
8\t100 / 30\t
9\t300 - 200\t对于服务端我们需要返回两个参数状态码和结果
退出状态 结果如果退出状态不为0则代表出现错误结果无效只有退出结果为0结果才是有效的。
同样的也需要给服务器的序列化字符串添加上数据的长度
数据长度\t退出状态 结果\t这样就搞定了一个计算器的自定义协议
3.2 成员
依照如上的协议先把请求和返回的成员变量写好
class Request
{int _x;int _y;char _ops;
};class Response
{int _exitCode; //计算服务的退出码int _result; // 结果
}; 这些成员变量都设置为公有方便在task里面进行处理否则就需要写get函数很麻烦
同时最好还是把协议中的分隔符给定义出来方便后续统一使用or更改
#define CRLF \t //分隔符
#define CRLF_LEN strlen(CRLF) //分隔符长度
#define SPACE //空格
#define SPACE_LEN strlen(SPACE) //空格长度#define OPS -*/% //运算符3.3 编码解码
对于请求和回应来说编解码的操作是一样的都是往字符串的开头添加上长度和分隔符
长度\t序列化字符串\t解码就是将长度和分隔符去掉只解析出序列化字符串
序列化字符串编码解码的整个过程在注释里面都写明了为了方便请求和回应去使用直接放到外头不做类内封装
//参数len为in的长度是一个输出型参数。如果为0代表err
std::string decode(std::string in,size_t*len)
{assert(len);//如果长度为0是错误的// 1.确认in的序列化字符串完整分隔符*len0;size_t pos in.find(CRLF);//查找分隔符//查找不到errif(pos std::string::npos){return ;//返回空串} // 2.有分隔符判断长度是否达标// 此时pos下标正好就是标识大小的字符长度std::string inLenStr in.substr(0,pos);//提取字符串长度size_t inLen atoi(inLenStr.c_str());//转intsize_t left in.size() - inLenStr.size()- 2*CRLF_LEN;//剩下的字符长度if(leftinLen){return ; //剩下的长度没有达到标明的长度}// 3.走到此处字符串完整开始提取序列化字符串std::string ret in.substr(posCRLF_LEN,inLen);*len inLen;// 4.因为in中可能还有其他的报文下一条// 所以需要把当前的报文从in中删除方便下次decode避免二次读取size_t rmLen inLenStr.size() ret.size() 2*CRLF_LEN;in.erase(0,rmLen);// 5.返回return ret;
}//编码不需要修改源字符串所以const。参数len为in的长度
std::string encode(const std::string in,size_t len)
{std::string ret std::to_string(len);//将长度转为字符串添加在最前面作为标识retCRLF;retin;retCRLF;return ret;
}3.4 request
编码解码写好了先来处理比较麻烦的请求部分说麻烦吧其实大多数也是c的string操作要熟练运用string的各类成员函数才能很好的实现
3.4.1 构造
比较重要的是这个构造函数我们需要将用户的输入转成内部的三个成员
用户可能输入xyx yx y,x y等等格式这里还需要注意用户的输入不一定是标准的XY里面可能在不同位置里面会有空格。为了统一方便处理在解析之前最好先把用户输入内的空格给去掉
对于string而言去掉空格就很简单了直接一个遍历搞定 // 删除输入中的空格void rmSpace(std::string in){std::string tmp;for(auto e:in){if(e! ){tmpe;}}in tmp;}完成的构造如下这里涉及到C语言的函数strtok要复习复习 // 将用户的输入转成内部成员// 用户可能输入xyx yx y,x y等等格式// 提前修改用户输入主要还是去掉空格提取出成员Request(std::string in,bool* status):_x(0),_y(0),_ops( ){rmSpace(in);// 这里使用c的字符串因为有strtokchar buf[1024];// 打印n个字符多的会被截断snprintf(buf,sizeof(buf),%s,in.c_str());char* left strtok(buf,OPS);if(!left){//找不到*status false;return;}char*right strtok(nullptr,OPS);if(!right){//找不到*status false;return;}// xy, strtok会将设置为\0char mid in[strlen(left)];//截取出操作符//这是在原字符串里面取出来buf里面的这个位置被改成\0了_x atoi(left);_y atoi(right);_ops mid;*statustrue;}3.4.2 序列化
解析出成员以后我们要做的就是对成员进行序列化将其按指定的位置摆成一个字符串。这里采用了输出型参数的方式来序列化字符串也可以改成用返回值的方式来操作。
这里需要注意的是操作符本身就是char不能使用to_string来操作会被转成ascii码不符合我们的需求
// 序列化 入参应该是空的
void serialize(std::string out)
{// x yout.clear(); // 序列化的入参是空的out std::to_string(_x);out SPACE;out _ops;//操作符不能用tostring会被转成asciiout SPACE;out std::to_string(_y);// 不用添加分隔符这是encode要干的事情
}3.4.3 反序列化
注意思路不能搞错了。刚开始我认为request的反序列化应该针对的是服务器的返回值实际并非如此
在客户端和服务端都需要使用request客户端进行序列化服务端对接收到的结果利用request进行反序列化。request只关注于对请求的处理而不处理服务器的返回值。
// 反序列化
bool deserialize(const std::string in)
{// x y 需要取出xy和操作符size_t space1 in.find(SPACE); //第一个空格if(space1 std::string::npos){return false;}size_t space2 in.rfind(SPACE); //第二个空格if(space2 std::string::npos){return false;}// 两个空格都存在开始取数据std::string dataX in.substr(0,space1);std::string dataY in.substr(space2SPACE_LEN);//默认取到结尾std::string op in.substr(space1SPACE_LEN,space2 -(space1SPACE_LEN));if(op.size()!1){return false;//操作符长度有问题}//没问题了转内部成员_x atoi(dataX.c_str());_y atoi(dataY.c_str());_ops op[0];return true;
}3.5 response
3.5.1 构造
返回值的构造比较简单因为是服务器处理结果之后的操作这些成员变量都设置为了公有方便后续修改。 Response(int code0,int result0):_exitCode(code),_result(result){}3.5.2 序列化
// 入参是空的
void serialize(std::string out)
{// code retout.clear();out std::to_string(_exitCode);out SPACE;out std::to_string(_result);out CRLF;
}3.5.3 反序列化
响应的反序列化只需要处理一个空格相对来说较为简单
// 反序列化
bool deserialize(const std::string in)
{// 只有一个空格size_t space in.find(SPACE);if(space std::string::npos){return false;}std::string dataCode in.substr(0,space);std::string dataRes in.substr(spaceSPACE_LEN);_exitCode atoi(dataCode.c_str());_result atoi(dataRes.c_str());return true;
}3.6 客户端
之前写的客户端并没有进行序列化操作所以我们需要添加上序列化操作并对服务器的返回值进行反序列化。这期间需要加上一系列判断
为了限制篇幅下面只贴出来客户端的循环操作详情参考注释。
// 客户端发现的消息
string message;
while (1)
{message.clear();//每次循环开始都清空一下msgcout 请输入你的消息# ;getline(cin, message);//获取输入// 如果客户端输入了quit则退出if (strcasecmp(message.c_str(), quit) 0)break;// 向服务端发送消息// 1.创建一个request分离参数bool reqStatus true;Request req(message,reqStatus);if(!reqStatus){cout make req err! endl;continue;}// 2.序列化和编码string package;req.serialize(package);//序列化package encode(package,package.size());//编码// 3.发送给服务器ssize_t s write(sock,package.c_str(), package.size());if (s 0) // 写入成功{// 4.获取服务器的结果char buff[BUFFER_SIZE];size_t s read(sock, buff, sizeof(buff)-1);if(s 0){buff[s] \0;}std::string echoPackage buff;Response resp;size_t len 0;// 5.解码和反序列化std::string tmp decode(echoPackage, len);if(len 0)//解码成功{echoPackage tmp;if(resp.deserialize(echoPackage))//反序列化并判断{printf(ECHO [exitcode: %d] %d\n, resp._exitCode, resp._result);}else{cerr server echo deserialize err! endl;}}else{cerr server echo decode err! endl;}}else if (s 0) // 写入失败{break;}
}3.7 服务端
服务端无须修改代码需要修改的是task消息队列中处理的任务这就是之前做好封装的好处因为只需要修改task里面传入的函数指针就算是修改了服务器所进行的服务
// 提供服务通过线程池
Task t(conet,senderIP,senderPort,CaculateService);
_tpool-push(t);如下是计算器服务的代码
void CaculateService(int sockfd, const std::string clientIP, uint16_t clientPort)
{assert(sockfd 0);assert(!clientIP.empty());assert(clientPort 0);std::string inbuf;while(1){Request req;char buf[BUFFER_SIZE];// 1.读取客户端发送的信息ssize_t s read(sockfd, buf, sizeof(buf) - 1);if (s 0){ // s 0代表对方发送了空消息视作客户端主动退出logging(DEBUG, client quit: %s[%d], clientIP.c_str(), clientPort);break;}else if(s0){// 出现了读取错误打印日志后断开连接logging(DEBUG, read err: %s[%d] %s, clientIP.c_str(), clientPort, strerror(errno));break;}// 2.读取成功buf[s] \0; // 手动添加字符串终止符if (strcasecmp(buf, quit) 0){ // 客户端主动退出break;}// 3.开始服务inbuf buf;size_t packageLen inbuf.size();// 3.1.解码和反序列化客户端传来的消息std::string package decode(inbuf, packageLen);//解码if(packageLen0){logging(DEBUG, decode err: %s[%d] status: %d, clientIP.c_str(), clientPort, packageLen);continue;//报文不完整或有误}logging(DEBUG,package: %s[%d] %s,clientIP.c_str(), clientPort,package.c_str());bool deStatus req.deserialize(package); // 反序列化if(deStatus) // 获取消息反序列化成功{req.debug(); // 打印信息// 3.2.获取结构化的相应Response resp Caculater(req);// 3.3.序列化和编码响应std::string echoStr;resp.serialize(echoStr);echoStr encode(echoStr,echoStr.size());// 3.4.写入发送返回值给客户端write(sockfd, echoStr.c_str(), echoStr.size());}else // 客户端消息反序列化失败{logging(DEBUG, deserialize err: %s[%d] status: %d, clientIP.c_str(), clientPort, deStatus);continue;}}close(sockfd);logging(DEBUG, server quit: %s[%d] %d,clientIP.c_str(), clientPort, sockfd);
}其中有一个计算函数比较简单通过switch case语句计算结果并判断操作数是否有问题。
Response Caculater(const Request req)
{Response resp;//构造函数中已经指定了exitcode为0switch (req._ops){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 -1;//取模错误break;}resp._result req._x % req._y;//取模是可以操作负数的break;}case /:{if(req._y 0){resp._exitCode -2;//除0错误break;}resp._result req._x / req._y;//取模是可以操作负数的break;}default:resp._exitCode -3;//操作符非法break;}return resp;
}这样我们的序列化处理就成功了测试一下吧
4.测试
运行服务器可以看到服务器能成功处理客户端的计算并返回结果 输入quit服务器会打印信息并退出服务