目录
前言
TCP网络计算器的模拟实现
制定协议
协议protocol的整体代码
TCP网络计算器的服务端类TcpServer
TcpServer类的整体代码
TCP网络计算器的服务端
服务端CalServer.cc的整体代码
TCP网络计算器的客户端
客户端CalClient.cc的整体代码
对模拟实现的TCP网络计算器的测试
将咱们模拟实现的TCP网络计算器守护进程化
对变成守护进程的TCP网络计算器的测试
前言
在<<基于TCP协议的网络服务器的模拟实现>>一文中标题为前言的部分说过,该篇文章中实现的所有代码都是不完善的,原因之一是使用【read或者recv】和【write或者send】这些函数时太过于粗糙,即都没有通过协议保证收发信息时收发到的信息是一个完整的信息(如果不理解,请前往该篇文章中阅读更详细的内容)。光看文字似乎有些不能令人完全理解协议是如何保证收发到的信息都是完整的,那么本篇文章中,咱们就来通过制定协议实现一个网络计算器来演示一下具体过程。
TCP网络计算器的模拟实现
制定协议
首先咱们可以先制定一个协议。说一下,实际上在json库中已经有了成熟的协议来保证双方收发到的信息都是完整的,但使用该协议里的接口时,因为里面有很多封装,不便让人理解其底层做了什么,所以咱们这里先使用自己制定的方案,再使用库中的成熟的方案。
如何制定协议呢?首先我们要想到既然我们要实现出的东西是个计算器,那么首先要实现一个计算请求Request类和计算响应(Response)类,之后的工作模式就是让客户端向服务端发送计算请求类的对象,服务端收到并经过计算后,创建一个计算响应类的对象,然后将该响应对象发回给客户端,这样就完成了计算。
根据上面的理论,我们可以编写如下代码。
#pragma once
#include<iostream>
using namespace std;
#include <string.h>
class Request
{
public:
Request(){}
Request(int x, int y, char op):_x(x),_y(y),_op(op){}
~Request(){}
public:
int _x;//约定_x是左操作数
int _y;//约定_x是右操作数
char _op;//操作符,可以是 + - * % /
};
class Response
{
public:
Response(){}
Response(int result, int status):_result(result),_status(status){}
~Response(){}
public:
int _result;//计算结果
int _status;//计算结果的状态,如果是0表示计算结果有效;如果是非0,则表示结果无效,比如可以用1、2、3..等等表示无效的原因,比如可以用1表示发生了除0错误导致结果无效,可以用2表示用户传来的request对象中op并不是合法的操作符,而是@或者#等等胡乱的字符。
};
问题:走到这里,我们可以让客户端创建一个Request对象,然后将该对象发给服务端让服务端去计算吗?或者换句话说,可以直接在网络中传输结构化的对象吗?
答案:最好不要这样做。注意将结构化的对象转化成网络字节序后(虽然htons或者htonl等函数只能转化内置类型short int和long int,但结构化的对象中的成员最终一定也是这些类型的,即如果想转化,是一定可以转化的,就是需要设计方案),我们是可以在网络中传输该结构化的对象的,双方主机并不会因为使用的字节序列不一样导致接收不了信息,但问题在于:在不同主机上,相同的结构体的内存对齐的方式可能是不同的;并且在不同主机上,相同的结构体中的同一个成员的类型的大小也可能是不同的。也就是说,虽然双方使用同一个协议文件中定义的类,但可能导致服务端在创建Request类对象接收(或者说拷贝)客户端发来的Request类对象时(接收的原因是方便后序服务端能拿着该对象进行计算),服务端接收到的Request对象的值和客户端发来的Request对象的值不一样,这不就坑了吗?所以一般来说,是不能直接将结构化的数据传输到网络进而传输到对端的。
根据上面的问题和答案我们可知:同理,在服务端中,也是不能在计算完客户端发来的计算请求后、创建一个Response响应对象就直接将该对象发送给客户端的,因为有可能导致客户端接收到的结果和服务端发送出去的结果不一样。
问题:既然不能直接将结构化的数据发送给对端,那该怎样发送呢?
答案:我们在本地将结构化的数据转化成C语言字符串类型的数据就可以直接发送给对端了。
问题:为什么将结构化的数据转化成C语言字符串类型的数据就可以直接发送给对端了呢?
答案:首先C语言字符串是一个内置类型,其在所有主机下的性质都是完全一致的,不存在上面结构化的数据存在的问题(即不会发生接收到的信息和别人发来的信息不一致);其次,因为C语言字符串是每个字节表示一个值(字符),不存在大小端的问题,所以在网络中传输C语言字符串还不用将主机字节序转化成网络字节序。当然除了这些原因外,还有其他很多的原因能说明在网络中传输数据时将结构化的数据转化成C语言字符串类型的数据的好处。
问题:为什么上一段敢说C语言字符串不存在大小端问题呢?
答案:下图是<<套接字socket编程的基础知识点>>一文中的知识点。根据下图知识点我们可知,当某一端按照从低地址到高地址的顺序发送了4个字节的字符串“abc\0”给另一端时,即使双方所使用的字节序不一样,在另一端的内存中也是按照从低地址到高地址的顺序存储“abc\0”。同时字符串和整形或者其他多字节类型不一样,字符串中是每个字节表示一个值(字符),而不会出现下图中的需要用4个字节表示一个值的情况,双方如果都是从低地址到高地址使用该字符串,那么就都看到的是“abc\0”,双方如果都是从高地址到低地址使用该字符串,那么就都看到的是“\0cba”,所以上一段才敢说C语言字符串不存在大小端问题。
问题:那回到正题,该如何将结构化的数据转化成C语言字符串类型的数据呢?
答案:这就需要咱们根据网络计算器的工作步骤定制协议了,网络计算器的工作步骤如下。
- 对于客户端需要发送的Request类对象来说,有3个成员字段,int _x、int_y、char _op,其中_x和_y分别作为左操作数和右操作数,_op是计算操作符,比如+或者-等等。那么我们可以制定协议约定将该结构化的数据转化成一个像_xSPACE_opSPACE_y这样的C语言字符串(SPACE表示空格),然后将该字符串发给服务端。这是很容易做到的,to_string可以将整形_x转化成string类型,然后调用string类的成员函数operator+=就可以将3个字段拼接起来,最后调用string类型的成员函数c_str()就可以得到C语言字符串了。
- 对于服务端收到的来自于客户端的、变成了C语言字符串类型的Request类计算请求对象rq来说,因为目前rq是C语言字符串类型,而服务端没法通过字符串进行计算,所以需要将rq从C语言字符串类型转化成结构化类型Request。方法为:我们可以在服务端通过客户端发来的C语言字符串构造一个string类对象,然后通过string类的成员函数find和rfind,分别把_xSPACE_opSPACE_y中的两个SPACE的下标找到,然后通过string类的成员函数substr把_x、_y、_op这三个子串string截取出来,然后将string类的_x和_y通过atoi(_x.c_str())和atoi(_y.c_str())从string类转化成int类,然后通过这些转化出的整形值重构Request类型的对象,最后服务端就可以拿这个Request对象进行计算了。
- 服务端计算完毕后,需要将计算结果信息发给客户端,对于服务端需要发回给客户端的Response响应类对象来说,有2个成员字段,int _status、int _result,分别代表本次的计算结果是否有效(比如0是有效,非0表示遇到了除0等异常)和本次的计算结果。那么我们也可以制定协议约定将该结构化的数据转化成一个像_statusSPACE_result这样的C语言字符串(SPACE表示空格),然后将该字符串发给客户端。这是很容易做到的,to_string可以将整形_status转化成string类型,然后调用string类的成员函数operator+=就可以将2个字段拼接起来,最后调用string类型的成员函数c_str()就可以得到C语言字符串了。
- 对于客户端收到的来自于服务端的、变成了C语言字符串类型的Response类响应对象resp来说,因为目前resp是C语言字符串类型,而不是Response类型,所以如果客户端有构造Response类对象的需要,此时可以将resp从C语言字符串类型转化成结构化类型Response。方法为:我们可以在客户端通过服务端发来的C语言字符串构造一个string类对象,然后通过string类的成员函数find把_statusSPACE_result中的一个SPACE的下标找到,然后通过string类的成员函数substr把_status和_result这两个子串string截取出来,然后将string类的_status和_result通过atoi(_status.c_str())和atoi(_result.c_str())从string类转化成int类,然后将这些转化出的整形值赋值给Response类对象里对应的成员即可。
根据上面的步骤1、2、3、4,我们可以分析出需要编写出如下代码(即定制协议)来完成【C语言字符串】和【结构化数据Request、Response】之间的互相转化。(注意到这里协议的代码还不是最终版本)
#pragma once
#include<iostream>
using namespace std;
#include <string.h>
#define SPACE " " //注意#define是不需要分号 ; 结尾的,如果加了分号,则分号也会被算进宏替换的内容
#define SPACE_LEN strlen(SPACE)
class Request//客户端未来会将请求类Request对象经过序列化后发给服务端
{
public:
Request(){}
Request(int x, int y, char op):_x(x),_y(y),_op(op){}
~Request(){}
string Serialize()//未来哪个Request对象调用这个函数,就通过该Request对象的成员_x、_y、_op生成一个string对象
{
string s;
s += to_string(_x);
s += SPACE;
s += _op;
s += SPACE;
s += to_string(_y);
return s;
}
//10 + 1234
bool Deserialize(const string& message)//未来通过message字符串给调用这个函数的Request对象的成员_x、_y、_op赋值
{
size_t pos1 = message.find(SPACE);
if(pos1 == string::npos)
return false;
else
{
_x = atoi(message.substr(0, pos1).c_str());//atoi只能将C语言字符串转换成整形
_op = message[pos1+SPACE_LEN];
size_t pos2 = message.rfind(SPACE);
_y = atoi(message.substr(pos2+SPACE_LEN).c_str());
return true;
}
}
public:
int _x;//约定_x是左操作数
int _y;//约定_x是右操作数
char _op;//操作符,可以是 + - * % /
};
class Response//服务端未来会将应答类Response对象经过序列化后发送给客户端
{
public:
Response(){}
Response(int result, int status):_result(result),_status(status){}
~Response(){}
string Serialize()//未来哪个Response对象调用这个函数,就通过该Response对象的成员_result、_status生成一个string对象
{
string s;
s += to_string(_result);
s += SPACE;
s += to_string(_status);
return s;
}
//30 0
bool Deserialize(const string& message)//未来通过message字符串给调用这个函数的Response对象的成员_result、_status赋值
{
size_t pos1 = message.find(SPACE);
if(pos1 == string::npos)
return false;
else
{
_result = atoi(message.substr(0, pos1).c_str());//atoi只能将C语言字符串转换成整形
size_t pos2 = message.rfind(SPACE);
_status = atoi(message.substr(pos2+SPACE_LEN).c_str());
return true;
}
}
public:
int _result;//计算结果
int _status;//计算结果的状态,如果是0表示计算结果有效;如果是非0,则表示结果无效,比如可以用1、2、3..等等表示无效的原因,比如可以用1表示发生了除0错误导致结果无效,可以用2表示用户传来的request对象中op并不是合法的操作符,而是@或者#等等胡乱的字符。
};
问题:那走到这里,我们的协议定制完毕了吗?
答案:并没有,因为上面的协议还是存在问题的,目前的协议只保证了1、双方在收数据时收到的是C语言字符串类型,后序双方的本地都会将C语言字符串类型的数据转化成结构化的数据。2、双方在发数据时发的都是C语言字符串类型,双方的本地在发数据之前都会将结构化的数据转化成C语言字符串类型。
那这就和在<<基于TCP协议的网络服务器的模拟实现>>一文中编写的各种版本的服务器没有任何区别了(本文中只是多了一个将结构化数据转化成C语言字符串的步骤,这样在传输时传输的就是C语言字符串了;而该篇文章中传输的直接就是C语言字符串,不需要转化。二者没有区别),没有解决掉在该篇文章中标题为前言部分说过的代码不完善的问题,即目前协议并没有保证双方在收数据时收到的C语言字符串是一个完整的字符串,比如对方发的是“abcd”时,目前我可能只收到了“ab”,协议无法保证我能收数据完整。
问题:那如何进一步完善我们的协议呢?
答案:思路如下。
- 当前情景双方在发送数据时,数据类型都是C语言字符串,那么假设内容为abcd,在双方发送数据时,就需要通过定制协议给调用【write或者send】函数发送出去的数据添加一些用于分隔数据的信息,我们选择的格式为:content_lengthSEPabcdSEP,content_length表示C语言字符串的长度(或者说正文长度),比如在当前情景下就等于4(不算\0),SEP是一个宏,表示\r\n。为什么需要添加这些用于分隔数据的信息呢?对端在调用【read或者recv】函数接收我发过去的数据时,它怎么知道本次读取的信息是完整的还是不完整的呢?如果我不添加一些用于分隔数据的信息,那么对端就绝对无法知道;只有我定制协议比如约定一个完整数据的格式为content_lengthSEPabcdSEP,那么对方就有了一个判断是否是完整数据的标准,那么对方拿到我发的数据(当前是C语言字符串)后,自然就知道了是否是完整数据了。
- 根据上一段可知,当前情景双方在接收数据时就需要将接收到的数据和标准做比对,如果发现不是一个完整信息,则需要将信息A保存在任意一个用户层缓冲区buffer中,然后重新接收,并将重新接收的信息B拼凑在信息A的后面,往复循环直到有一个完整的信息,此时将完整的信息从缓冲区buffer中删除并交给用户层,意为该信息已经被读走了。
说一下,走到这里,我们才真正完成了序列化和反序列化的内容,即【先将结构化的数据转化成C语言字符串类型的数据,然后给C语言字符串类型的数据添加上用于分隔数据的信息】才是序列化的完整流程;即【先在添加了用于分隔数据的信息的C语言字符串中找出一个完整的数据,然后将该数据从C语言字符串类型转化成结构化的类型】才是反序列化的完整流程。
根据上面的思路,此时我们可以编写出如下代码。Encode就是用于在发送数据时添加分隔信息的函数;Decode就是在接收数据时将收到的数据和标准做比对的函数,说一下,当Decode在检测数据时发现据不完整就会直接返回一个string(“”)匿名对象,这样外部接收到该string后,调用string的empty函数发现返回值为true,那么就知道需要重新调用【read或者recv】函数接收数据了。
#define SEP "\r\n"
#define SEP_LEN strlen(SEP)
//length\r\n1234567\r\n
string Decode(string& buffer)
{
int pos = buffer.find(SEP);
if(pos == string::npos)
{
return"";
}
else
{
int length = atoi(buffer.substr(0, pos).c_str());
int content_len = buffer.size() - pos - 2*SEP_LEN;
if(content_len >= length)
{
//走到这里才能保证buffer中有一个完整的报文
string s(buffer.substr(pos+SEP_LEN, length));
buffer.erase(0, length+2*SEP_LEN+(pos-1));
return s;
}
else
{
return "";
}
}
}
string Encode(const string& buffer)
{
string s;
s += to_string(buffer.size());
s += SEP;
s += buffer;
s += SEP;
return s;
}
协议protocol的整体代码
经过上面的努力,这里我们终于获取了协议的整体代码,如下(以下是整个protocol.h的代码)。
#pragma once
#include<iostream>
using namespace std;
#include <string.h>
#define SPACE " " //注意#define是不需要分号 ; 结尾的,如果加了分号,则分号也会被算进宏替换的内容
#define SPACE_LEN strlen(SPACE)
#define SEP "\r\n"
#define SEP_LEN strlen(SEP)
class Request//客户端未来会将请求类Request对象经过序列化后发给服务端
{
public:
Request(){}
Request(int x, int y, char op):_x(x),_y(y),_op(op){}
~Request(){}
string Serialize()//未来哪个Request对象调用这个函数,就通过该Request对象的成员_x、_y、_op生成一个string对象
{
string s;
s += to_string(_x);
s += SPACE;
s += _op;
s += SPACE;
s += to_string(_y);
return s;
}
//10 + 1234
bool Deserialize(const string& message)//未来通过message字符串给调用这个函数的Request对象的成员_x、_y、_op赋值
{
size_t pos1 = message.find(SPACE);
if(pos1 == string::npos)
return false;
else
{
_x = atoi(message.substr(0, pos1).c_str());//atoi只能将C语言字符串转换成整形
_op = message[pos1+SPACE_LEN];
size_t pos2 = message.rfind(SPACE);
_y = atoi(message.substr(pos2+SPACE_LEN).c_str());
return true;
}
}
public:
int _x;//约定_x是左操作数
int _y;//约定_x是右操作数
char _op;//操作符,可以是 + - * % /
};
class Response//服务端未来会将应答类Response对象经过序列化后发送给客户端
{
public:
Response(){}
Response(int result, int status):_result(result),_status(status){}
~Response(){}
string Serialize()//未来哪个Response对象调用这个函数,就通过该Response对象的成员_result、_status生成一个string对象
{
string s;
s += to_string(_result);
s += SPACE;
s += to_string(_status);
return s;
}
//30 0
bool Deserialize(const string& message)//未来通过message字符串给调用这个函数的Response对象的成员_result、_status赋值
{
size_t pos1 = message.find(SPACE);
if(pos1 == string::npos)
return false;
else
{
_result = atoi(message.substr(0, pos1).c_str());//atoi只能将C语言字符串转换成整形
size_t pos2 = message.rfind(SPACE);
_status = atoi(message.substr(pos2+SPACE_LEN).c_str());
return true;
}
}
public:
int _result;//计算结果
int _status;//计算结果的状态,如果是0表示计算结果有效;如果是非0,则表示结果无效,比如可以用1、2、3..等等表示无效的原因,比如可以用1表示发生了除0错误导致结果无效,可以用2表示用户传来的request对象中op并不是合法的操作符,而是@或者#等等胡乱的字符。
};
//length\r\n1234567\r\n
string Decode(string& buffer)
{
int pos = buffer.find(SEP);
if(pos == string::npos)
{
return"";
}
else
{
int length = atoi(buffer.substr(0, pos).c_str());
int content_len = buffer.size() - pos - 2*SEP_LEN;
if(content_len >= length)
{
//走到这里才能保证buffer中有一个完整的报文
string s(buffer.substr(pos+SEP_LEN, length));
buffer.erase(0, length+2*SEP_LEN+(pos-1));
return s;
}
else
{
return "";
}
}
}
string Encode(const string& buffer)
{
string s;
s += to_string(buffer.size());
s += SEP;
s += buffer;
s += SEP;
return s;
}
TCP网络计算器的服务端类TcpServer
在<<基于TCP协议的网络服务器的模拟实现>>一文中标题为前言的部分说过,该篇文章中实现的所有代码都是不完善的;而在本文前言的部分中说过,本篇文章就是通过制定协议实现一个网络计算器来演示一下如何完善前面那篇文章中的代码,所以这里的TCP网络计算器的服务端类TcpServer可以直接将<<基于TCP协议的网络服务器的模拟实现>>一文中的多线程版本的服务端类TcpServer的整体代码(即Tcp_server.h)拿过来,然后稍作修改后就可以复用了。
哪些地方需要修改呢?如下:
- 将ThreadData类做修改,从左边改成右边即可。threadData类是线程函数的参数的类型,在之前的文章中我们需要sockaddr_in类型的成员是为了在线程函数中打印对端(客户端)的ip和port信息,但现在不需要了,所以将其删除了。为什么加入TcpServer*指针类型成员在下文中说。
- 给TcpServer类增加一个vector<func_t>_v成员,func_t是包装器类function<void(threadData*)>的别名;然后给TcpServer类加入成员函数excute和loadfunc。loadfunc函数就是将指定的func_t类型的函数加载进_v成员中,excute函数就是让某个线程执行存储在_v中的函数(是依次执行全部函数还是只执行指定函数又或者是其他方案可以由编码者控制,这里我们就选择依次执行全部函数)。注意在当前服务端类中,我们只需要提供计算服务即可,因为计算器本就不需要提供其他的服务,所以实际上是可以不加这些代码的,只需要在原来的文章中将service函数换成caculate函数即可,但我们依然这么做了,其目的是为了让服务端类TcpServer的可扩展性提高,下次如果哪里需要提供多种服务的TcpServer服务端类的代码时,在本篇的TcpServer类的代码的基础上稍作修改后就大概率可以直接拿过去用了。
- 线程函数只能有一个参数void*,而我们想传多个字段给新线程,所以我们可以把想传给新线程的参数都通过threadData类打包起来(即给threadData类多设置成几个成员),然后让threadData*作为线程函数的参数。由此可见,从上一段其实就可以看出为什么上上段中的threadData类需要一个TcpServer*的指针成员,是因为要在线程函数中调用TcpServer类的成员函数excute,所以需要一个TcpSever类对象的this指针。
- 在start函数的开头中加入代码signal(SIGPIPE, SIG_IGN);,防止客户端的套接字关闭时,服务线程在while循环中向客户端write写信息多次导致服务端收到SIGPIPE信号进而服务端被OS杀死。这一句代码就解决了在<<基于TCP协议的网络服务器的模拟实现>>一文中标题为前言的部分说过的代码的另一个不完善的问题。
TcpServer类的整体代码
根据上面理论,TcpServer类的整体代码如下。(以下是整个TcpServer.h的代码)
#pragma once
#include <iostream>
using namespace std;
#include <string.h> //提供bzero函数
#include <memory> //提供智能指针的库
#include <cstdlib> //提供atoi函数、exit函数
#include <unistd.h> //提供close
#include <signal.h> //提高signal函数
#include <sys/wait.h> //提供wait函数
#include <pthread.h> //线程库
#include <vector>
// 以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include <sys/types.h> //系统库,提供socket编程需要的相关的接口
#include <sys/socket.h> //系统库,提供socket编程需要的相关的接口
#include <netinet/in.h> //提供sockaddr_in结构体
#include <arpa/inet.h> //提供sockaddr_in结构体
class TcpServer; // 前置声明
struct threadData
{
int sockfd;
TcpServer *server;
};
using func_t = function<void(threadData *)>; // 这里using的功能和typedef一样
class TcpServer
{
private:
// 注意必须是静态函数,因为作为线程函数必须只能有一个void*类型的参数,而类内非静态成员函数都是有this指针的,所以需要用static修饰去掉this指针
static void *threadRoutine(void *args) // 不要把void和void*混淆了,前者表示函数没有参数;后者表示函数是有参数的,表示可以接收任意类型的地址
{
pthread_detach(pthread_self()); // 用于线程分离的函数,这样一来主线程就无需关心新线程的回收问题了(即主线程无需调用pthread_join函数阻塞等待回收新线程了),新线程退出时相关资源自动被释放(类似于进程中僵尸进程的自动释放)
threadData *td = (threadData *)args;
td->server->excute(td);
close(td->sockfd);// 服务套接字文件是用于在新线程中和客户端中的套接字文件通信的,新线程退出结束后,该服务套接字文件也就没用了,直接close避免文件描述符泄漏
delete td;
return nullptr;
}
public:
TcpServer(uint16_t port, string ip = "")
: _port(port), _ip(ip)
{}
void InitServer()
{
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
// bind,作用为将【ip和port和socket文件】和当前进程绑定,为什么要bind绑定呢?套接字sock文件用于通信,首先,如果想要网络通信,则必须通过网卡,所以你必须得指定从哪个网卡(ip)读
// 取数据送到socket文件,这就是bind ip的原因,送到哪个socket文件呢?这是bind sockfd的原因,数据读取完毕后,送到哪个端口(进程)呢?所以你必须指定一个端口号。
// 在TCP通信中,通信双方会在建立连接时交换各自的ip和port信息,比如客户端在调用connect函数发起连接请求的时候,OS会自动把客户端进程绑定的ip和port信息包含进连接请求中发给服务端进程,服务
// 端进程调用accept函数接收连接请求时如果成功接收,则通过传给accept函数的输出型参数就能知道客户端的ip和port了,同时如果成功接收,OS还会把服务端进程bind绑定的ip和port反馈发送给客户端进
// 程,客户端无需编写者调用什么函数接收这个包含服务端ip和port的信息,OS会自动保存的,OS会处理这些细节,使得网络通信可以顺利进行。
// 根据上一段的理论,服务端进程在调用accept函数接受客户端的连接请求时,OS会自动把服务端进程bind绑定的ip和port信息反馈发送给客户端进程,注意这个包含ip和port的反馈信息是先经过网络,再被客
// 户端进程接收的,而网络资源又是寸土寸金的,发送的数据越小越好,所以在服务端调用accept函数接收客户端的连接请求并向客户端发出反馈信息前,是需要将ip从字符ip转换成整形ip的,同时为了防止通信
// 的双方主机采用的字节序列不一致,所以在服务端调用accept函数前还需要将转化成整形的ip从主机序列转化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)。注意因为
// 服务端调用accept函数以接收客户端的连接请求并将包含了服务端ip和port的反馈信息发送给客户端时,是OS自动把服务端进程bind绑定的ip和port包含进发送的反馈信息中,所以如果想要服务端调用accept
// 函数发送包含服务端的ip和port的反馈信息时ip和port是网络序列,那在服务端进程bind绑定ip和port时,ip和port就应该是网络序列,所以在服务端bind绑定ip和port前,需要将这些信息转化成网络序列。
// 根据上上段的理论,客户端调用connect函数发起连接请求时会把请求信息先发送到网络中,然后另一端的服务端进程会从网络中获取到这个请求,注意因为客户端调用connect发起连接请求时,OS会自动把客户端
// 进程绑定的ip和port信息包含进发起的请求中,而网络资源又是寸土寸金的,发送的数据越小越好,所以在客户端调用connect发送连接请求前,是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机
// 采用的字节序列不一致,所以在客户端调用connect前还需要将转化成整形的ip从主机序列转化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)的。注意因为客户端调用connect函
// 数发送连接请求信息到网络时,是OS自动把客户端进程bind绑定的ip和port包含进发送的连接请求中,所以如果想要客户端调用connect发送连接请求时ip和port是网络序列,那在客户端进程bind绑定ip和port时,
// ip和port就应该是网络序列,这个对于客户端进程来说不必担心,对于客户端进程来说一般是不必显示调用bind函数绑定ip和port的,如果不显示调用bind函数,则客户端调用connect函数向服务端发起连接请求时,OS
// 会自动把当前机器的ip和随机分配的一个port给客户端进程进行bind,并且ip和port在绑定前也由OS自动转化成网络序列了。(关于为什么不必显示调用bind的原因在和UDP网络服务器的模拟实现相关的文章中说明过了)
// 说一下,bind绑定这些信息(即ip和port)时,是先把需要绑定的信息全填充到一个sockaddr类的对象中,之后bind只需要绑定这个sockaddr类的对象即可,由于该结构体当中还有部分选填字段,因此我们最好
// 在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
sockaddr_in local;
memset(&local, 0, sizeof local);
local.sin_family = AF_INET;
local.sin_addr.s_addr = (_ip.empty() == true ? INADDR_ANY : inet_addr(_ip.c_str()));
local.sin_port = htons(_port);
if (bind(_listen_sock, (sockaddr *)&local, sizeof local) < 0)
{
exit(1);
}
// TCP是面向连接的,正式通信前需要先建立连接
if (listen(_listen_sock, 20) < 0)
{
exit(1);
}
cout << "服务端初始化成功" << endl;
}
void loadFunc(func_t f)
{
_v.push_back(f);
}
void excute(threadData *data)
{
for (func_t &f : _v)
f(data);
}
void start()
{
signal(SIGPIPE, SIG_IGN);//防止客户端的套接字关闭时,服务端向客户端write写信息多次导致服务端收到SIGPIPE信号进而服务端被OS杀死
while (1)
{
// 监听成功后,这里需要从监听套接字中获取连接
sockaddr_in other_side;
socklen_t len = sizeof other_side;
int service_sock = accept(_listen_sock, (sockaddr *)&other_side, &len);
if (service_sock < 0)
{
cout << "获取监听套接字中的连接失败,即将重新获取" << endl;
continue;
}
// 获取连接成功,开始通信
// version 3 -- 多线程版本
cout << "获取连接成功" << endl;
// 开始创建新线程,让新线程在线程函数中帮主线程去收发信息(即通信)
// 说一下,如果想让新线程在线程函数中帮主线程去收发信息(即通信),那么是一定要把主线程获取到的服务端套接字文件对应的文件描述符service_sock作为参数传给新线程的线程函数的,
// 需要传的原因是:虽然所有线程共用一个文件描述符表,即任意一个线程打开的文件在所有线程中都可以访问,但现在的问题是因为线程之间并不共享局部变量,而service_sock是主线程中
// 创建的局部变量,所以新线程并不知道主线程创建的服务套接字文件对应的文件描述符service_sock是多少,所以新线程就不知道该服务套接字文件是哪一个,也就不知道该访问哪一个,所
// 以主线程需要将service_sock作为参数传给新线程的线程函数。除此之外,因为我们在类外设计出了service函数(并不是线程函数),期望让service函数去完成收发信息的功能,比如让新
// 线程在线程函数中再调用service函数去收发信息,而service函数还需要一个sockaddr_in类型的参数去在函数内打印客户端的ip和port信息,提示这是哪个客户端在给服务端发送信息,以此
// 方便调试、观察,所以除了需要将主线程中的局部变量service_sock作为参数传给新线程的线程函数,还要将主线程中的表示对端(即客户端)网络属性信息的局部变量 —— sockaddr_in类型
// 的other_side也作为参数传给新线程的线程函数,所以此时就需要设计一个可以表示这两个数据的结构体(或者说类),我们叫它threadData类。
// 注意在主线程中创建threadData类的变量时,必须在堆上创建,因为如果在栈上创建,则该对象就是一个局部变量,每次出了循环进入下一次循环时都会将该变量销毁,会将变量里的成员都设置
// 成随机值,假如在新线程的线程函数中,在线程函数还没有使用该变量前,该变量就因主线程进入下一次循环而被销毁了,那么后序在新线程的线程函数中通过这个已经销毁的变量的地址去访问该
// 变量肯定是不合法的,会解引用野指针报错,这是典型的线程安全问题;正确的方式是在堆上创建该变量,这时该对象就不会因为出了循环而被销毁,每次销毁的只不过是指向该变量的指针变量罢了,
// 指针变量被销毁对新线程来说是没有影响的,因为只要线程函数启动成功了,参数肯定是早就传递完毕的,即该指针变量的值早就被线程函数的void*类型的形参给拷贝成功了,所以也就不需要该指
// 针变量了。说一下,每个线程都有自己的线程ID,线程库会自动将新线程的ID存储在pthread_t变量中以便主线程能够跟踪它们,pthread_t变量仅用于在主线程中识别和管理新创建的线程,并不影
// 响新线程的任意行为,即新线程中并不需要使用该pthread_t类型的变量,所以即使该变量的值中途可能会随时发生变化,该变量对于新线程来说也是线程安全的,对于主线程来说,当前主线程代码
// 的逻辑也不需要使用该pthread_t类型的变量,所以即使该变量的值中途可能会随时发生变化,该变量对于主线程来说也是线程安全的,综上可以发现对于所有线程来说该变量都是线程安全的,所以
// 在下面创建pthread_t类型的变量时就不必从堆上开辟空间了。
// //根据上一段的理论,错误写法如下
// threadData data;
// data.sockfd = service_sock;
// data.x = other_side;
// data.server = this;
// 根据上一段的理论,正确写法如下
threadData *p = new threadData;
p->sockfd = service_sock;
p->server = this;
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void *)p); // 说一下,和子进程不太一样,新线程的线程函数执行完后新线程就自动退出了,不会继续向后执行代码;而在子进程中如果不手动调用exit函数,则子进程不会自动退出
// 注意在多线程模式下和多进程模式下有一点不一样。在多进程中,我们需要在子进程中关闭监听套接字文件对应的文件描述符,需要在父进程中关闭服务套接字文件对应的描述符,否则会导致
// 文件描述符泄漏;但在多线程模式下,是一定不能这样做的,因为所有的线程只组成了一个进程,每个进程只有一个文件描述符表,所以所有的线程共同使用同一个文件描述符表,也就是说如果
// 在主线程中close关闭了服务套接字文件对应的文件描述符,那么在新线程中就没有媒介去和客户端进程通信了,同理如果在新线程中close关闭了监听套接字文件对应的文件描述符,那在主线
// 程中也就没有媒介去接收客户端的连接请求了。
}
}
~TcpServer()
{
if (_listen_sock > 0)
close(_listen_sock);
}
private:
string _ip;// 一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
uint16_t _port;// port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
vector<func_t> _v;
};
TCP网络计算器的服务端
和<<基于TCP协议的网络服务器的模拟实现>>一文的tcp_server.cc相比,当前服务端进程里的主函数中(位于下图右边)只是多了一个步骤,就是需要调用loadfunc成员函数把你想让服务端进程提供的服务加载进服务端类的vector _v成员中。
被loadfunc成员函数加载进服务端类对象中的函数是需要用户在服务端进程的代码里进行编写的,如下图所示。该函数的执行流程为:
- (结合下图思考)在caculate函数中,我们拿到客户端发来的经过序列化的C语言字符串类型的计算请求对象后,先进行Decode(将序列化的数据进行反序列化的第一步),检查是否收到一个完整的计算请求信息,如果不完整,则需要将不完整的信息A保存在中string str1中,然后执行continue,重新开始循环,重新recv接收对端的信息,并将重新接收的信息B通过+=拼凑在信息A(即str1)的后面,往复循环直到Decode检测出有一个完整的信息,此时将完整的信息通过Decode的返回值交给string str2,并将完整的信息从str1中通过erase成员函数删除,意为该完整信息已经被str2读走了,后面再将str2这个string类型的计算请求信息转化成结构化数据rq(将序列化的数据进行反序列化的第二步)并将结构化数据rq传进caculate_help函数进行计算,计算后会通过caculate_help函数的返回值得到一个结构化的response类计算结果对象rp,然后再将结构化的计算结果对象rp转化成C语言字符串(进行序列化的第一步),然后再给C语言字符串添加上用于分隔数据的信息(进行序列化的第二步),然后就可以将序列化的C语言字符串类型的计算结果信息通过send发送给客户端了。
可以看到在上面的流程中,recv接收到的信息在我们定制的协议的保证下,一定是一个完整的信息(因为不完整时会重新调用recv函数),达到了我们制定协议的目的。
说一下有一个坑,千万注意在send或者write时,如果此时是要send发送C语言字符串类型的数据,并且C语言字符串类型的数据此时还存储在string对象s中,在算需要send出的数据的大小时(send需要这样的一个参数),千万不要通过sizeof(s.c_str()),因为这计算的是一个指针的大小,永远是8,而要通过sizeof(s.size()),否则会导致客户端发送出去的数据永远只有8字节,服务端也就只能收到8字节,一个最短的完整的数据都有10字节(比如 5\r\n1SPACE+SPACE1\r\n),所以服务端在Decode时检测是否有完整的数据时就会检测到没有完整数据,然后重新进行recv,防止客户端发给我的信息正在网络中传输导致我没有recv接收完全。问题来了,客户端在收到服务端的响应数据前,是不会重新send发送的;而我们也知道,服务端并不是因为客户端发给它的信息正在网络中传输导致它没有recv完全,它是recv完全了的,只不过客户端发送出去的数据本身就是不完整的。这就会导致双方进程都会在recv函数处永远阻塞,服务端进程永远在recv函数处阻塞等待它认为的(只是它认为,但实际上没有)正在网络中传输的剩余数据;客户端进程永远在recv函数处等待服务端的响应信息(因为服务端压根没有收到一个完整的信息,也就没法拿该信息完成计算,也就没法将计算结果返回给客户端,客户端就永远等不到这个结果信息,就永远在recv中阻塞了)
服务端CalServer.cc的整体代码
根据上面的理论,CalServer.cc的整体代码如下。
#include "TcpServer.h"
#include "protocol.h"
void usage(char *c)
{
cout << "usage: " << c << " port" << endl;
}
Response calculate_help(const Request &rq)
{
Response rp;
switch (rq._op)
{
case '+':
rp._result = rq._x + rq._y;
rp._status = 0;
break;
case '-':
rp._result = rq._x - rq._y;
rp._status = 0;
break;
case '*':
rp._result = rq._x * rq._y;
rp._status = 0;
break;
case '/':
if (rq._y == 0)
rp._status = 1;
else
{
rp._result = rq._x / rq._y;
rp._status = 0;
}
break;
case '%':
if (rq._y == 0)
rp._status = 1;
else
{
rp._result = rq._x % rq._y;
rp._status = 0;
}
break;
default:
rp._status = 1;
break;
}
return rp;
}
void calculate(threadData *x)
{
string str1;
while(1)
{
char buffer1[1024]; // 服务端recv接收到的客户端发来的经过序列化后的request请求信息就需要放在buffer1中(序列化后的信息的数据类型就是C语言字符串类型)
memset(buffer1,0,sizeof(buffer1));
ssize_t s = recv(x->sockfd, buffer1, sizeof buffer1, 0);
//这里的if else分支有两个作用。1、从代码逻辑上防止recv返回0时,即客户端套接字关闭时,服务端还继续send向客户端发送信息,避免服务端收到SIGPIPE信号导致OS将服务
//端进程杀死。2、服务端在主线程中将SIGPIPE信号的处理方式设置成忽略后,设置if else分支能够防止【提供服务的子进程或者新线程在当前这个while循环里出不来从而导致客
//户端关闭后为客户端提供服务的子进程和新线程却无法退出,一直占用资源】。
if(s > 0)
{
//先检查recv是否读取到了一整个报文,如果没有则就continue重新进入循环,即重新recv一次;如果读取到完整的报文,则不continue,而是计算完毕后将结果发回给客户端
str1 += buffer1;
string str2 = Decode(str1);
if(str2.empty() == true)
continue;
Request rq;
rq.Deserialize(str2);
Response rp = calculate_help(rq);
string str3 = Encode(rp.Serialize());//给需要发送的数据添加长度信息,以形成一个报文
send(x->sockfd, str3.c_str(), str3.size(), 0);
}
else
break;
}
}
// ./CalServer port
int main(int argc, char *argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(1);
}
unique_ptr<TcpServer> p(new TcpServer(atoi(argv[1])));
p->loadFunc(calculate);
p->InitServer();
p->start();
return 0;
}
TCP网络计算器的客户端
和<<基于TCP协议的网络服务器的模拟实现>>一文的tcp_server.cc相比,本文这里与服务端建立连接的代码都是完全一致的;区别在于本文这里在recv和send收发信息时,比如在recv接收服务端发来的响应数据时,会通过Decode函数检查是否读取到一个完整的数据;在send发数据时,会在数据中通过Encode函数填入用于分隔完整数据的信息,以让对端在接收数据时可以根据协议中定制的一个完整数据的标准格式判断是否为完整数据。这些send和recv的具体流程在服务端CalServer.cc的代码中已经说明过了,这里就不再赘述,直接上代码,如下。
说一下有一个坑,千万注意在send或者write时,如果此时是要send发送C语言字符串类型的数据,并且C语言字符串类型的数据此时还存储在string对象s中,在算需要send出的数据的大小时(send需要这样的一个参数),千万不要通过sizeof(s.c_str()),因为这计算的是一个指针的大小,永远是8,而要通过sizeof(s.size()),否则会导致客户端发送出去的数据永远只有8字节,服务端也就只能收到8字节,一个最短的完整的数据都有10字节(比如 5\r\n1SPACE+SPACE1\r\n),所以服务端在Decode时检测是否有完整的数据时就会检测到没有完整数据,然后重新进行recv,防止客户端发给我的信息正在网络中传输导致我没有recv接收完全。问题来了,客户端在收到服务端的响应数据前,是不会重新send发送的;而我们也知道,服务端并不是因为客户端发给它的信息正在网络中传输导致它没有recv完全,它是recv完全了的,只不过客户端发送出去的数据本身就是不完整的。这就会导致双方进程都会在recv函数处永远阻塞,服务端进程永远在recv函数处阻塞等待它认为的(只是它认为,但实际上没有)正在网络中传输的剩余数据;客户端进程永远在recv函数处等待服务端的响应信息(因为服务端压根没有收到一个完整的信息,也就没法拿该信息完成计算,也就没法将计算结果返回给客户端,客户端就永远等不到这个结果信息,就永远在recv中阻塞了)
客户端CalClient.cc的整体代码
根据上面的理论,CalClient.cc的整体代码如下。
#include<iostream>
using namespace std;
#include "protocol.h"//是咱们自己实现的协议
#include <unistd.h> //提供write / read
// 以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include <sys/types.h> //系统库,提供socket编程需要的相关的接口
#include <sys/socket.h> //系统库,提供socket编程需要的相关的接口
#include <netinet/in.h> //提供sockaddr_in结构体
#include <arpa/inet.h> //提供sockaddr_in结构体
void usage(char *c)
{
cout << "usage: " << c << "port" << endl;
}
// ./CalClient ip port
int main(int argc, char *argv[])
{
if (argc != 3)
{
usage(argv[0]);
exit(1);
}
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
//建立连接
sockaddr_in peer;
peer.sin_family = AF_INET;
peer.sin_port = htons(atoi(argv[2]));
peer.sin_addr.s_addr = inet_addr(argv[1]);
int x = connect(sockfd, (sockaddr*)&peer, sizeof(peer));
if (x < 0)
{
cout<<"connect失败"<<endl;
exit(1);
}
//连接建立成功,开始通信
cout<<"连接成功"<<endl;
while(1)
{
//开始向服务端发送信息
int x,y;
char op;
cout<<"输入左操作数#:";
cin>>x;
cout<<"输入右操作数#:";
cin>>y;
cout<<"输入操作符#:";
cin>>op;
Request rq(x, y, op);
string str = rq.Serialize();
string temp = Encode(str);//光序列化还不行,还要添加上长度报头才能形成一个报文
ssize_t size = send(sockfd, temp.c_str(), temp.size(), 0);//千万注意,算需要send出的数据的大小时,千万不要使用sizeof(temp.c_str()),因为这计算的是一个指针的大小,永远是8,这个可坑死我了,调试半天才发现。
if(size == -1)//注意send失败只会返回-1,send的返回值不可能为0
{
cout<<"send失败"<<endl;
exit(1);
}
//开始接收服务端的反馈信息
string str1;
while(1)
{
char c[1024];
ssize_t size2 = recv(sockfd, c, sizeof c, 0);
if(size2 == 0)
{
cout<<"recv失败,对端(即服务端)套接字关闭"<<endl;
exit(1);
}
//如果recv或者read没有失败,需要检查读取到的反馈信息是否是一整个报文
str1 += c;
string str2 = Decode(str1);
if(str2.empty() == true)
continue;
Response resp;
resp.Deserialize(str2);
cout<<"计算结果是:"<<resp._result<<','<<"结果是否有效:"<<resp._status<<" (0表示有效/非0表示无效)"<<endl;
break;
}
}
close(sockfd);
return 0;
}
对模拟实现的TCP网络计算器的测试
如下图,是可以正常运行的。
左下角显示有3个服务端线程,其中1个是负责监听连接和创建新线程的主线程,另外两个是提供服务的新线程。
将咱们模拟实现的TCP网络计算器守护进程化
问题:该怎么做呢?
答案:在<<基于TCP协议的网络服务器的模拟实现>>一文中标题为守护进程(包含终端、bash、前后台进程、进程组、会话的概念)的部分已经全部讲过了,并且那边在演示如何证明已经将一个进程变成了守护进程时,就是用的本篇文章中的TCP网络计算器的代码。
所以这里就不再讲解原理(如果想了解相关细节,请到该篇文章中去阅读),而是直接上代码。
首先把该篇文章中的用于让一个进程成为守护进程的函数的代码拿过来,如下。(以下是整个MyDaemon.h的代码)
#include<iostream>
using namespace std;
#include<signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
void myDaemon()
{
signal(SIGCHLD,SIG_IGN);//主动忽略CHLD信号,父进程就不需要进行进程等待以此回收子进程了
if( fork() > 0)
exit(0);
else//子进程走这里
{
//将子进程设置成守护进程
setsid();
//将标准输入、标准输出、标准错误这三个文件重定向到/dev/null文件,防止守护进程向显示器打印进而守护进程被关闭
int devnull = open("\dev\null", O_RDONLY | O_WRONLY);
dup2(devnull, 0);
dup2(devnull, 1);
dup2(devnull, 2);
//走到这里文件描述符表(即数组)的0、1、2号下标上就都是devnull文件的地址了,所以直接将devnull号(即3号)下标上的地址设置成nullptr即可
close(devnull);
}
}
然后在服务端CalServer.cc的代码中#include“MyDaemon”后,在main函数中的所有逻辑开始前调用上面的myDaemon函数即可将服务端进程变成守护进程了,代码如下。
#include "TcpServer.h"
#include "protocol.h"
#include "MyDaemon.h"
void usage(char *c)
{
cout << "usage: " << c << " port" << endl;
}
Response calculate_help(const Request &rq)
{
Response rp;
switch (rq._op)
{
case '+':
rp._result = rq._x + rq._y;
rp._status = 0;
break;
case '-':
rp._result = rq._x - rq._y;
rp._status = 0;
break;
case '*':
rp._result = rq._x * rq._y;
rp._status = 0;
break;
case '/':
if (rq._y == 0)
rp._status = 1;
else
{
rp._result = rq._x / rq._y;
rp._status = 0;
}
break;
case '%':
if (rq._y == 0)
rp._status = 1;
else
{
rp._result = rq._x % rq._y;
rp._status = 0;
}
break;
default:
rp._status = 1;
break;
}
return rp;
}
void calculate(threadData *x)
{
string str1;
while(1)
{
char buffer1[1024]; // 服务端recv接收到的客户端发来的经过序列化后的request请求信息就需要放在buffer1中(序列化后的信息的数据类型就是C语言字符串类型)
memset(buffer1,0,sizeof(buffer1));
ssize_t s = recv(x->sockfd, buffer1, sizeof buffer1, 0);
//这里的if else分支有两个作用。1、从代码逻辑上防止recv返回0时,即客户端套接字关闭时,服务端还继续send向客户端发送信息,避免服务端收到SIGPIPE信号导致OS将服务
//端进程杀死。2、服务端在主线程中将SIGPIPE信号的处理方式设置成忽略后,设置if else分支能够防止【提供服务的子进程或者新线程在当前这个while循环里出不来从而导致客
//户端关闭后为客户端提供服务的子进程和新线程却无法退出,一直占用资源】。
if(s > 0)
{
//先检查recv是否读取到了一整个报文,如果没有则就continue重新进入循环,即重新recv一次;如果读取到完整的报文,则不continue,而是计算完毕后将结果发回给客户端
str1 += buffer1;
string str2 = Decode(str1);
if(str2.empty() == true)
continue;
Request rq;
rq.Deserialize(str2);
Response rp = calculate_help(rq);
string str3 = Encode(rp.Serialize());//给需要发送的数据添加长度信息,以形成一个报文
send(x->sockfd, str3.c_str(), str3.size(), 0);
}
else
break;
}
}
// ./CalServer port
int main(int argc, char *argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(1);
}
myDaemon();//将服务器的服务端进程设置成守护进程
unique_ptr<TcpServer> p(new TcpServer(atoi(argv[1])));
p->loadFunc(calculate);
p->InitServer();
p->start();
return 0;
}
对变成守护进程的TCP网络计算器的测试
如下图,是可以正常运行的。
而且从左下角可以发现CalServer的确变成了守护进程,因为目前TTY是 ?、SID和PID相同、PPID也是1,这都符合守护进程的特征。注意并不是说具有这些特征就一定是守护进程,只是说守护进程也具有这些特征,这里能证明它是守护进程的主要原因是我们编码的逻辑就是让它变成守护进程,然后该进程因为我们设计的编码变得具备守护进程的特征,所以就能证明它已经成为了守护进程。