在这之前,我们已经将服务器端的代码部分做好了准备,现在万事俱备只欠客户端发起连接,而客户端在这里不准备那么多的封装了,与之前写的客户端相同,我们想要客户端以 ./cal_client ip port
的形式来创建客户端:
#include <iostream>
#include <string>
#include <memory>
#include <ctime>
#include "Socket.hpp"
#include "Log.hpp"
#include "Protocol.hpp"
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " serverip serverport\n"
<< std::endl;
}
using namespace socket_ns;
using namespace protocol_ns;
// ./tcp_client serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];// 服务器端ip
uint16_t serverport = std::stoi(argv[2]);// 服务器端port
return 0;
}
而Udp客户端构建对象就不需要这么麻烦了,因为我们早在最开始已经将Socket类进行了封装,这样我们就不需要在调用原生接口在客户端裸露式调用:
InetAddr serveraddr(serverip, serverport);// 通过ip和port构建InetAddr对象
std::unique_ptr<Socket> cli = std::make_unique<TcpSocket>();// 子类对象构造父类指针方便多态式调用
bool res = cli->BuildClientSocket(serveraddr);// 建立客户端Socket 链接
此时,客户端的Socket服务就已经构建完毕,网络通信已经做好准备,只要服务器通,客户端随时都可以发起连接。接着就是处理客户端的业务逻辑。
我们要知道客户端是要给服务器端发送请求并且获取相应的一个过程,获取成功之后将响应进行反序列化拿到最终的结果。为了方便测试,我们这里让客户端采用固定的提问方式不断对客户端发送请求获取响应并且解析,我们将构建请求以及接收响应封装为一个 Factory类。
其中,客户端请求让 x 为 1-10的随机数,y为 0-4的随机数,让他们进行模运算,并将计算构造为Request类,返回值为Request的指针:
class Factory
{
public:
Factory()
{
srand(time(nullptr) ^ getpid());
opers = "+/*/%^&|";
}
std::shared_ptr<Request> BuildRequest()
{
int x = rand() % 10 + 1;
usleep(x * 10);
int y = rand() % 5; // [0,1,2,3,4]
usleep(y * x * 5);
char oper = opers[rand() % opers.size()];
std::shared_ptr<Request> req = std::make_shared<Request>(x, y, oper);
return req;
}
std::shared_ptr<Response> BuildResponse()
{
return std::make_shared<Response>();
}
~Factory()
{
}
private:
std::string opers;// 操作数: +/-/*/\/% ...
};
为了方便测试,我们这里只启动一个客户端,这个客户端不停的给服务器发送数据,所以我们需要将待发送请求以及返回的响应放在while循环内不断发送获取解析。
我们想要积压一批数据,然后在一次性发送,这样就能测试服务器的功能是否有问题,是否能处理多批数据,所以在这里我们一次性构建五个请求让后在发送给服务器端,同样构建请求时需要对数据进行序列化和添加长度报头:
Factory factory;
std::string inbuffer;
while (res)
{
sleep(1);
// 构建请求
std::string str;
for (int i = 0; i < 5; ++i)// 一次性构建5个请求
{
auto req = factory.BuildRequest();
// 对请求进行序列化
std::string send_str;
req->Serialize(&send_str);
std::cout << "Serialize: \n"
<< send_str << std::endl;
// 添加长度报头
send_str = Encode(send_str);
std::cout << "Encode: \n"
<< send_str << std::endl;
str += send_str;
}
// "len"\r\n"{}"\r\n
cli->Send(str);// 发送请求
}
我们将求情发送之后,客户端就静静等待服务器端返回的响应,当然,与服务器端接收消息相同,客户端接收的每条应答一定就是完整的应答吗?不一定,所以我们将读取到的数据进行Decode(),这样我们对所有的应答进行解析,如果是一条完整的应答Decode接口就会返回一个response对象,对象里就是解析过后一条完整的应答内容:
while (res)
{
sleep(1);
// 构建请求
std::string str;
for (int i = 0; i < 5; ++i)
{
auto req = factory.BuildRequest();
// 对请求进行序列化
std::string send_str;
req->Serialize(&send_str);
std::cout << "Serialize: \n"
<< send_str << std::endl;
// 添加长度报头
send_str = Encode(send_str);
std::cout << "Encode: \n"
<< send_str << std::endl;
str += send_str;
}
// "len"\r\n"{}"\r\n
cli->Send(str);
// 读取应答
int n = cli->Recv(&inbuffer);
if (n <= 0)
break;
std::string package = Decode(inbuffer);
if (package.empty())
continue;
}
那么此后,我们获取的应答就一定是一条完整的应答,但是这个应答此时还是序列化状态,我们需要将其进行反序列化处理,最后输出响应结果即可:
while (res)
{
sleep(1);
// 构建请求
std::string str;
for (int i = 0; i < 5; ++i)
{
auto req = factory.BuildRequest();
// 对请求进行序列化
std::string send_str;
req->Serialize(&send_str);
std::cout << "Serialize: \n"
<< send_str << std::endl;
// 添加长度报头
send_str = Encode(send_str);
std::cout << "Encode: \n"
<< send_str << std::endl;
str += send_str;
}
// "len"\r\n"{}"\r\n
cli->Send(str);
// 读取应答
int n = cli->Recv(&inbuffer);
if (n <= 0)
break;
std::string package = Decode(inbuffer);
if (package.empty())
continue;
// 读到的package一定是一个完整的应答
auto resp = factory.BuildResponse();
// 反序列化
resp->Deserialize(package);
// 拿到了结构化的应答
std::cout << resp->_result << "[" << resp->_code << "]" << std::endl;
}
为了更好地体现服务器端对报文的处理是否正确,我们在TcpServerMain内的Service服务进行细微调整,前面,我们让客户端不断地对服务器端发出请求,那么服务器端的Service也要不断地去处理请求,并发送到客户端:
void ServiceHelper(socket_sptr sockptr, InetAddr client)
{
int sockfd = sockptr->SockFd();
LOG(DEBUG, "get a new link, info %s:%d, fd : %d", client.IP().c_str(), client.Port(), sockfd);
std::string clientaddr = "[" + client.IP() + ":" + std::to_string(client.Port()) + "]# ";
std::string inbuffer;
while (true)
{
sleep(5);
Request req;
// 1. 读取数据
int n = sockptr->Recv(&inbuffer);
if (n < 0)
{
LOG(DEBUG, "client %s quit", clientaddr.c_str());
break;
}
// 2. 分析数据
std::string package;
while (true)
{
sleep(1);
std::cout << "inbuffer" << inbuffer << std::endl;
package = Decode(inbuffer);
if (package.empty())
break;
std::cout << "-----------------------begin----------------------" << std::endl;
std::cout << "resq string:\n"
<< package << std::endl;
// 3.反序列化
req.Deserialize(package);
// 4. 业务处理
Response resp = _cb(req);
// 5. 对应答进行序列化
std::string send_str;
resp.Serialize(&send_str);
std::cout << send_str << std::endl;
// 6. 添加长度报头
send_str = Encode(send_str);
// 7. 发送到对端
sockptr->Send(send_str);
}
}
}
那么我们所有准备都已经做好了,接下来就是通信时刻:
这样网路版本计算器我们就实现完成了。在这个项目当中,我们发现,我们把从Tcp内读取的报文,可能读到半个,可能读到一个半,或者其他特殊不完整报文情况,这种情况我们称为 Tcp粘包问题。而我们使用Encode() 和 Decode() 接口就是为了解决tcp粘包问题的。
我们知道,我们在连接远程服务器的时候,实际上就是打开一个终端文件,如果有多个连接就会打开多个终端文件,我们从一台设备向另一台设备进行重定向的时候就是如此:
并且我们可以将消息发送到另外一个终端文件当中,使用如下命令进行重定向:
echo "message" >> /dev/pts/n #这里n指的是任何一个存在的终端文件
这里如果你是使用XShell来测试上面的命令,你很可能不会成功,因为版本升级的原因,但是我们能知道这个现象就行。总而言之,当我们连接Linux服务器的时候,会给我们打开一个终端文件,再启动bash命令行解释器。
首先是创建终端文件,其次bash被启动,而bash又作为所有进程的父进程,bash则会打开创建的终端文件。而一般终端文件与启动的bash会被打包称为一个 会话(具体在以后守护进程章节中看到),而每个会话都会有自己的 会话id(sid),而一般 会话的id是终端中的第一个进程的pid也就是bash:
但是,只要在当前终端下启动的任何服务(进程),都属于当前的会话!比如:
所以会话就像是bash进程中的管理容器,如果一个会话被销毁了,那么会话里的所有进程也都会终止,
但是今天,我想要一种不受会话影响的进程,也就是不受用户登录退出的影响,独立于会话之外的进程,比如我们的网络版计算器服务器端,我们不想让其受用户注册销毁的影响,所以我们可以编写代码将其变为 守护进程。
实际上这么做的意义就是创建一个新的会话,在Linux中给我们提供了 setsid()
接口:
setsid()会创建出一个新的会话,不过有一个要求:调用进程不能是进程组的组长。那么什么是进程组呢?很简单,每个进程组都有一个唯一的标识符,通常是进程组的组长(Leader)的进程ID,而组长就是他们之中第一个启动的进程:
所以,我们在程序中直接创建子进程,并且退出父进程,那么,那么当前进程就可以调用setid()接口了,这个进程也就独立出会话之外,成为一个全新的会话,我们称之为 守护进程(精灵进程)!使用类似一下代码:
if(fork() > 0) exit(0);
setsid();
当然,如果你嫌麻烦,大可不必写长点的代码,因为Linux早就给我们想好了,给我们提供了一个 Daemon()
接口:
Linux每个终端下都会存在一个null文件:/dev/null
,如果去读取这个文件,文件内是没有任何内容的,如果对该文件进行写,同样也不会保存任何信息,而是立刻丢弃。我们知道,当我们创建了守护进程,也就意味着脱离了原本的会话,所以也就没有原本的终端文件了,而如果我们要使网络计算器变为守护进程,而网络计算器中存在大量的IO操作,为了避免因为没有对应的终端文件进行IO而出错,我们可以将 0,1,2三个文件描述符全部重定向到 /dev/null 当中。
#include <iostream>
#include <unistd.h>
int main()
{
std::cout << "Pid is: " << getpid() << std::endl;
sleep(1);
daemon(0, 0);
while(true)
{
std::cout << "hello test" << std::endl;
sleep(1);
}
return 0;
}
以上是一个简单的测试样例,daemon内部会自动的fork并且退出父进程:
经过测试我们可以看到,hello.exe 的TTY,也就是终端文件变成了 “?”, 也就表示已经不属于当前的会话了,而SID同样与当前进程的SID不同,并且SID为守护进程的pid。如果我们查看守护进程的工作目录:
可以看到,守护进程当前工作目录实际上就是在根目录,如果我们同时查看该守护进程的文件fd就会发现:
由此可见,daemon接口的两个参数实际上是bool值类型的,第一个参数表示是否更改工作目录,第二个参数表示是否更改重定向,如果我们把daemon参数设置为daemon(0, 0):
将daemon参数设置为(1, 1)就会导致我们输出的内容还是在上一个会话下,并且Ctrl C
也无法终止进程(可使用 kill -9 process_pid 杀死进程),当我们查询进程工作目录时,也能发现其在当前的工作目录下,而fd也指向了第一个终端文件。
所以一般情况下,我们直接调用 daemon(0, 0)即可,但是我们网络版计算器不仅仅有许多的IO,还写了很多很重要的日志信息啊,这么设置守护进程,我们就无法在终端上看到日志信息了,不用担心,因为早在编写日志之初,我们就已经给日志设置为两个选择,1. 将信息打印到显示器上。2. 将日志信息打印到终端文件上。我们可以将其打印到日志文件当中:
随后启动服务器,将其变为一个守护进程,然后启动一个客户端连接服务器端:
我们之前定义的文件路径就是在当前目录下,而我们创建了守护进程,并且将工作目录改为了根目录,所以我们的log.txt文件只能出现在根目录了。
以上就是网络版计算器实现的全过程了,如果这三篇文章对您有所帮助的话,还望点赞支持~~