以tcpServer的计算器服务为例,实现一个自定义协议
阅读本文之前,请先阅读 tcpServer
本文完整代码详见 Gitee
1.重谈tcp
注意,当下所对tcp的描述都是以简单、方便理解起见,后续会对tcp协议进行深入解读
1.1 链接
我们知道,tcp是面向连接的,客户端和服务端要先建立链接,才能开始通信
- 在链接过程中,tcp采用三次握手
- 在断线过程中,tcp采用四次挥手
举个日常生活中的栗子,帮助理解3次握手和4次挥手

1.2 信息发送
假如我们现在需要发送结构化数据,那应该怎么办?
我们知道,tcp是面向字节流的,也就是其能够发送任意数据。也能够发送C语言结构体的二进制数据;
- 但能发送,就代表我们可以这么干吗?
- 答案自然是不行!
不同平台,对结构体对齐的配置不同,大小端不同,其最终对我们字节流的解析也就不一样。如果采用直接发送结构体数据的方式来通信,适配性极低,我们的客户端和服务端都会被限制在当前的系统环境中运行;
可是,哪怕是同一个系统,其内部对大小端的配置也有可能改变!到时候我们的代码恐怕就无法运行了!
同理,在当初编写C语言通讯录的代码的时候,也不能采用直接将结构体数据写入文件的方式。后续代码升级、环境改变,都可能导致我们存在文件中的数据失效,这肯定是我们不希望看到的情况。
所以,为了解决这个问题,我们就应该将数据进行序列化之后再发送,客户端接收到信息后,进行反序列化解析出数据!
2.序列化和反序列化
2.1 简介
所谓序列化,就是将结构化的数据(可以暂时理解为c的结构体)转换成字符串的方式,发送出去
| 12
 3
 4
 5
 6
 
 | struct date{
 int year;
 int month;
 int day;
 };
 
 | 
比如上面这个日期结构体,我们要想将其序列化,就可以用一个很简单的方式拼接成一个字符串(序列化)
客户端收到这个字符串之后,就可以通过查找分隔符-的方式,取出三个变量,将其转成int后存放回结构体(反序列化)
这样,我们就算是规定了一个序列化和反序列化的方式,也就是一个简单的协议!
2.2 编码解码
这里还会出现另外一个问题,我要怎么知道我已经读取完毕了一个序列化后的数据呢?
如上,假设有一天,我们的年变成了五位数;这时候,服务端要怎么知道自己是否读取完毕了一个完整的序列化数据呢?
这就需要我们做好规定,将前n字节作为标识长度的数据。接收到数据后,先取出前n个字节,读取道此次消息的长度m,再往后读取m个字节的数据,成功取出完整的字符串;
为了区分标识长度的数据和实际需要的序列化内容,我们可以在之中加上分隔符\t;但这也需要我们确认,传输的数据本身不能带上\t,否则会产生一系列的问题
| 12
 
 | 10\t2000-12-10\t11\t10000-01-01\t
 
 | 
以上的这一系列工作,都是协议定制的一部分!我们给服务端和客户端规定了一个序列化和反序列化的方式,让二者通信规避掉了平台的限制。毕竟任何平台对字符串解码出来的数据都会是相同的!
下面就用一个计算器的服务,来演示一下吧😏
3.计算器服务
因为本文的重心是对协议定制的演示,所以这里的计算器不考虑连续操作符的情况,
3.1 协议定制
要想实现一个计算器,我们首先要搞明白计算器有几个成员
一般情况下,一个计算器只需要3个成员,分别是两个操作数和一个运算符,就能开始计算。所以我们需要将这里的三个字段设计成一个字符串,实现序列化;
比如我们应该规定序列化之后的数据应该是如下的,两个操作数和操作符之间应该要有空格
再在开头添加上数据长度的标识
| 12
 3
 4
 5
 
 | 数据长度\t公式\t
 7\t10 + 20\t
 8\t100 / 30\t
 9\t300 - 200\t
 
 | 
对于服务端,我们需要返回两个参数:状态码和结果
如果退出状态不为0,则代表出现错误,结果无效;只有退出结果为0,结果才是有效的。
同样的,也需要给服务器的序列化字符串添加上数据的长度
这样就搞定了一个计算器的自定义协议;
3.2 成员
依照如上的协议,先把请求和返回的成员变量写好
| 12
 3
 4
 5
 6
 
 | class Request{
 int _x;
 int _y;
 char _ops;
 };
 
 | 
| 12
 3
 4
 5
 
 | class Response{
 int _exitCode;
 int _result;
 };
 
 | 
这些成员变量都设置为公有,方便在task里面进行处理(否则就需要写get函数,很麻烦)
同时,最好还是把协议中的分隔符给定义出来,方便后续统一使用or更改
| 12
 3
 4
 5
 6
 
 | #define CRLF "\t"   #define CRLF_LEN strlen(CRLF)
 #define SPACE " "
 #define SPACE_LEN strlen(SPACE)
 
 #define OPS "+-*/%"
 
 | 
3.3 编码解码
对于请求和回应来说,编解码的操作是一样的,都是往字符串的开头添加上长度和分隔符
解码就是将长度和分隔符去掉,只解析出序列化字符串
编码解码的整个过程在注释里面都写明了😁为了方便请求和回应去使用,直接放到外头,不做类内封装
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 
 | std::string decode(std::string& in,size_t*len)
 {
 assert(len);
 
 *len=0;
 size_t pos = in.find(CRLF);
 
 if(pos == std::string::npos){
 return "";
 }
 
 
 std::string inLenStr = in.substr(0,pos);
 size_t inLen = atoi(inLenStr.c_str());
 size_t left = in.size() - inLenStr.size()- 2*CRLF_LEN;
 if(left<inLen){
 return "";
 }
 
 std::string ret = in.substr(pos+CRLF_LEN,inLen);
 *len = inLen;
 
 
 size_t rmLen = inLenStr.size() + ret.size() + 2*CRLF_LEN;
 in.erase(0,rmLen);
 
 return ret;
 }
 
 
 std::string encode(const std::string& in,size_t len)
 {
 std::string ret = std::to_string(len);
 ret+=CRLF;
 ret+=in;
 ret+=CRLF;
 return ret;
 }
 
 | 
3.4 request
编码解码写好了,先来处理比较麻烦的请求部分;说麻烦吧,其实大多数也是c++的string操作,要熟练运用string的各类成员函数,才能很好的实现
3.4.1 构造
比较重要的是这个构造函数,我们需要将用户的输入转成内部的三个成员
| 1
 | 用户可能输入x+y,x+ y,x +y,x + y等等格式
 | 
这里还需要注意,用户的输入不一定是标准的X+Y,里面可能在不同位置里面会有空格。为了统一方便处理,在解析之前,最好先把用户输入内的空格给去掉!
对于string而言,去掉空格就很简单了,直接一个遍历搞定
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 
 | void rmSpace(std::string& in)
 {
 std::string tmp;
 for(auto e:in)
 {
 if(e!=' ')
 {
 tmp+=e;
 }
 }
 in = tmp;
 }
 
 | 
完成的构造如下,这里涉及到C语言的函数strtok,要复习复习
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 
 | 
 
 Request(std::string in,bool* status)
 :_x(0),_y(0),_ops(' ')
 {
 rmSpace(in);
 
 char buf[1024];
 
 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;
 }
 
 char mid = in[strlen(left)];
 
 
 _x = atoi(left);
 _y = atoi(right);
 _ops = mid;
 *status=true;
 }
 
 | 
3.4.2 序列化
解析出成员以后,我们要做的就是对成员进行序列化,将其按指定的位置摆成一个字符串。这里采用了输出型参数的方式来序列化字符串,也可以改成用返回值的方式来操作。
这里需要注意的是,操作符本身就是char不能使用to_string来操作,会被转成ascii码,不符合我们的需求
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 
 | void serialize(std::string& out)
 {
 
 out.clear();
 out+= std::to_string(_x);
 out+= SPACE;
 out+= _ops;
 out+= SPACE;
 out+= std::to_string(_y);
 
 }
 
 | 
3.4.3 反序列化
注意,思路不能搞错了。刚开始我认为request的反序列化应该针对的是服务器的返回值,实际并非如此!
在客户端和服务端都需要使用request,客户端进行序列化,服务端对接收到的结果利用request进行反序列化。request只关注于对请求的处理,而不处理服务器的返回值。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 
 | bool deserialize(const std::string &in)
 {
 
 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(space2+SPACE_LEN);
 std::string op = in.substr(space1+SPACE_LEN,space2 -(space1+SPACE_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 构造
返回值的构造比较简单,因为是服务器处理结果之后的操作;这些成员变量都设置为了公有,方便后续修改。
| 12
 3
 
 | Response(int code=0,int result=0):_exitCode(code),_result(result)
 {}
 
 | 
3.5.2 序列化
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 
 | void serialize(std::string& out)
 {
 
 out.clear();
 out+= std::to_string(_exitCode);
 out+= SPACE;
 out+= std::to_string(_result);
 out+= CRLF;
 }
 
 | 
3.5.3 反序列化
响应的反序列化只需要处理一个空格,相对来说较为简单
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 
 | 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(space+SPACE_LEN);
 _exitCode = atoi(dataCode.c_str());
 _result = atoi(dataRes.c_str());
 return true;
 }
 
 | 
3.6 客户端
之前写的客户端,并没有进行序列化操作,所以我们需要添加上序列化操作,并对服务器的返回值进行反序列化。这期间需要加上一系列判断;
为了限制篇幅,下面只贴出来客户端的循环操作;详情参考注释。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 
 | string message;
 while (1)
 {
 message.clear();
 cout << "请输入你的消息# ";
 getline(cin, message);
 
 if (strcasecmp(message.c_str(), "quit") == 0)
 break;
 
 
 
 bool reqStatus = true;
 Request req(message,&reqStatus);
 if(!reqStatus){
 cout << "make req err!" << endl;
 continue;
 }
 
 string package;
 req.serialize(package);
 package = encode(package,package.size());
 
 ssize_t s = write(sock,package.c_str(), package.size());
 if (s > 0)
 {
 
 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;
 
 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里面传入的函数指针,就算是修改了服务器所进行的服务
| 12
 3
 
 | Task t(conet,senderIP,senderPort,CaculateService);
 _tpool->push(t);
 
 | 
如下是计算器服务的代码
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 
 | 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];
 
 ssize_t s = read(sockfd, buf, sizeof(buf) - 1);
 if (s == 0)
 {
 logging(DEBUG, "client quit: %s[%d]", clientIP.c_str(), clientPort);
 break;
 }
 else if(s<0)
 {
 
 logging(DEBUG, "read err: %s[%d] = %s", clientIP.c_str(), clientPort, strerror(errno));
 break;
 }
 
 buf[s] = '\0';
 if (strcasecmp(buf, "quit") == 0)
 {
 break;
 }
 
 inbuf = buf;
 size_t packageLen = inbuf.size();
 
 std::string package = decode(inbuf, &packageLen);
 if(packageLen==0){
 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();
 
 Response resp = Caculater(req);
 
 std::string echoStr;
 resp.serialize(echoStr);
 echoStr = encode(echoStr,echoStr.size());
 
 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语句,计算结果,并判断操作数是否有问题。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 
 | Response Caculater(const Request& req){
 Response resp;
 switch (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;
 break;
 }
 resp._result = req._x / req._y;
 break;
 }
 default:
 resp._exitCode = -3;
 break;
 }
 
 return resp;
 }
 
 | 
这样,我们的序列化处理就成功了!测试一下吧
4.测试
运行服务器,可以看到,服务器能成功处理客户端的计算,并返回结果

输入quit,服务器会打印信息,并退出服务
