当前位置: 首页 > news >正文

【Linux网络编程】Socket - UDP

目录

V1版本 - Echo Server

初始化服务器

启动服务器

客户端

本地测试

网络测试

优化代码

V2版本 - Dict Server

服务器的调整

字典

网络模块与业务模块耦合

V3版本 - 简单聊天室

简单聊天室概述

消息转发模块

数据接收模块

重定向观察

补充细节


在这一篇文章中,主要学习UDP套接字相关接口

V1版本 - Echo Server

这里实现Echo Server,客户端给服务器发送一条消息,服务器响应回来一条消息。这里的日志使用我们之前自己完成的日志系统。

要如何使用一个服务器呢?第一步初始化服务器,第二步启动服务器。接下来我们就先来完成这两个成员函数。

int main()
{std::unique_ptr<UdpServer> svr_uptr = std::make_unique<UdpServer>();svr_uptr->InitServer();svr_uptr->Start();return 0;
}

初始化服务器

初始化服务器的第一步是创建套接字

创建套接字就是告诉OS:我要使用什么进行通信,如UDP,请分配资源。此时需要使用到系统调用socket。

#include <sys/types.h>
#include <sys/socket.h>int socket(int domain, int type, int protocol);

第一个参数叫域或者协议族。这个参数表明我们要进行本地通信(AF_UNIX),还是网络通信(AF_INET),还是域间通信。这3个是比较常见的。我们直接写成AF_INET即可。第二个参数表示要创建一个什么类型的套接字。SOCK_STREAM表示TCP类型套接字,SOCK_DGRAM表示UDP类型套接字。我们今天使用SOCK_DGRAM。第三个参数表明传输层协议类型。IPPROTO_TCP表示TCP协议,IPPROTO_UDP表示UDP协议。我们直接传入0即可,因为第一个参数选择网络通信,第二个参数选择UDP类型套接字,创建出来的套接字默认就是UDP套接字了。

返回值:成功时返回套接字描述符(非负整数),失败返回-1,并设置errno。实际上,这个返回值就是一个文件描述符,所以,使用socket创建套接字本质上就是创建一个文件。

const static int gsockfd = -1;class UdpServer
{
public:UdpServer():_sockfd(gsockfd){}void InitServer(){// 1. 创建socket}void Start(){}~UdpServer(){}
private:int _sockfd; // 套接字的文件描述符
};
#define Die(code) do {exit(code);}while(0)
void InitServer()
{// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(1);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;
}

当我们创建套接字失败,就直接输出日志,并让进程退出即可。我们运行一下上面的程序

可以看到,文件描述符就是3

初始化服务器的第二步是绑定IP地址和端口号

上面我们已经将套接字创建出来了,只是创建出了一个用于网络通信的UDP类型的套接字。我们知道,套接字 = IP地址 + 端口。当前只是将文件打开了,还没有给套接字指定IP地址和端口。绑定就是给套接字指定IP地址和端口。这里说的IP地址和端口是服务器这台主机的IP地址和端口。此时需要使用系统调用bind。

#include <sys/types.h>
#include <sys/socket.h>int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

第一个参数是套接字的文件描述符;第二个参数因为是网络通信,所以使用struct sockaddr_in,要使用这个类型需要包含头文件<netinet/in.h>、<arpa/inet.h>,填写好sockaddr_in类型的对象后,传入,即可将sockaddr_in类型对象里面的信息与套接字绑定;第三个参数是sockaddr_in对象的长度。返回值:0表示绑定成功,-1表示绑定失败,失败会设置errno。

所以,是将要绑定的IP地址和端口号放在结构体sockaddr_in类型的对象中的,然后传入bind就可以完成绑定了。我们来介绍一下sockaddr_in这个结构体。

sockaddr_in的第一个字段就是刚刚套接字的域或协议族,因为bind需要根据这个字段判断传入进来的是sockaddr_in,还是sockaddr_un。第二个字段,填端口号。第三个字段填IP地址。最后一个字段不用管,这部分空间是不使用的,只是为了保证结构体的完整性。

我们在创建套接字时,不是已经传入了域吗,为什么这里还要再传一次域呢?上面哪里是通过os的网络文件接口,告诉OS要创建一个网络套接字;下面是填充sockaddr_in。这两个必须是一样的,才能够保证绑定成功。因为sockaddr_in中有填充字段,所以一般建议将里面的内容全部清0之后,再进行填充。可以使用memset或bzero。

#include <string.h>void bzero(void *s, size_t n);

给服务器的类增加两个成员变量,表示这个服务端绑定的IP地址和端口号。

const static int gsockfd = -1;
const static std::string gdefaultip = "127.0.0.1"; // 本地主机的IP地址
const static uint16_t gdefaultport = 8080;#define Die(code) do {exit(code);}while(0)
#define CONV(v) (struct sockaddr*)(v) // 完成类型转换class UdpServer
{
public:UdpServer(const std::string &ip = gdefaultip, uint16_t port = gdefaultport):_sockfd(gsockfd),_ip(ip),_port(port){}void InitServer(){// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(1);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;// 2. 填充网络信息,并与套接字绑定// 2.1 填充网络信息struct sockaddr_in local;// 2.2 绑定int n = ::bind(_sockfd, CONV(&local), sizeof(local));}void Start(){}~UdpServer(){}
private:int _sockfd;     // 套接字对应的文件描述符uint16_t _port;  // 服务器未来的端口号std::string _ip; // 服务器所对应的IP地址
};

127.0.0.1是本地环回地址,是计算机网络中用于指向当前设备自身的特殊IP地址。它的核心作用是为设备提供一种内部通信机制,无需依赖物理网络接口。 

此时肯定是有问题的,因为我们并没有向sockaddr_in类型的对象local中填入任何信息,前面说了,我们是要让套接字对应的文件描述符与local中的IP地址和端口号进行绑定的。所以,我们是需要向local中填入服务器的IP地址和端口号的

注意:不是直接将成员变量中的_port和_ip填到local中就可以了。因为服务端不仅仅要接收来自客户端的数据,也是需要向客户端回消息的。而发消息,在报头中就回填写源IP地址和源端口号,并将报文发到网络中。_port和_ip都是在本地定义的,属于是本地序列,要进行网络传输需要先将它们转化为网络序列。对于IP地址和端口号转化的区别:

  • IP地址在本地通常是点分十进制的,需要先转为四字节的格式,再转为网络序列
  • 端口号在本地存储时,大端或小端是由当前主机决定的,而网络中传输的数据默认都是大端的,所以需要将端口号转为大端的。注意:即使当前主机就是大端机,也要转,因为未来这份代码可能会在小端机运行。

要将端口号转为网络序列,可以使用下面这几个函数:

#include<arpa/inet.h>
// 将 32 位(4 字节)无符号整数从主机字节序转换为网络字节序(大端序)
uint32_t htonl(uint32_t hostlong);
// 将 16 位(2 字节)无符号整数从主机字节序转换为网络字节序(大端序)
uint16_t htons(uint16_t hostshort);
// 将 16 位(2 字节)无符号整数从网络字节序转换回主机字节序
uint16_t ntohs(uint16_t netshort);
// 将 32 位(4 字节)无整数从网络字节序转换回主机字节序
uint32_t ntohl(uint32_t netlong);

要将IP地址转为网络序列,可以使用下面这几个函数:

#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>// 将点分十进制 IPv4 地址(如 "192.168.1.1")转换为 网络字节序
int inet_aton(const char *cp, struct in_addr *inp);
// 将点分十进制 IPv4 地址转换为 网络字节序 
in_addr_t inet_addr(const char *cp);
// 将点分十进制 IPv4 地址转换为 主机字节序
in_addr_t inet_network(const char *cp);
// 将网络字节序的 struct in_addr 转换为 点分十进制字符串
char *inet_ntoa(struct in_addr in);
// 将 网络号 和 主机号 组合成一个完整的 IP 地址
struct in_addr inet_makeaddr(in_addr_t net, in_addr_t host);
// 从 IP 地址中提取 主机部分(去除网络号)
in_addr_t inet_lnaof(struct in_addr in);
// 从 IP 地址中提取 网络部分(去除主机号)
in_addr_t inet_netof(struct in_addr in);

将IP地址转为网络序列,我们使用inet_addr;将端口号转为网络序列,我们使用htons

void InitServer()
{// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(1);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;// 2. 填充网络信息,并与套接字绑定// 2.1 填充网络信息struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET;local.sin_port = ::htons(_port);local.sin_addr = ::inet_addr(_ip.c_str());// 2.2 绑定int n = ::bind(_sockfd, CONV(&local), sizeof(local));
}

此时逻辑上是没有问题了,但是local.sin_addr处会报错,我们来看看sockaddr_in的定义

struct sockaddr_in
{__SOCKADDR_COMMON(sin_);in_port_t sin_port;			/* Port number.  */struct in_addr sin_addr;		/* Internet address.  *//* Pad to size of `struct sockaddr'.  */unsigned char sin_zero[sizeof(struct sockaddr)- __SOCKADDR_COMMON_SIZE- sizeof(in_port_t)- sizeof(struct in_addr)];
};

我们看上面3行就可以了,下面的不用管,都是填充字段。in_port_t是一个16位的整数,可in_addr是一个结构体啊。我们再来看一下这个结构体的内容。

typedef uint32_t in_addr_t;
struct in_addr
{in_addr_t s_addr;
};

会发现这个结构体中只有一个成员,是一个32位的整数。在C语言中,结构体只能被整体初始化,不能被整体赋值。就是这个原因导致的出错。我们再来看看sockaddr_in第一个成员是什么

#define	__SOCKADDR_COMMON(sa_prefix) \sa_family_t sa_prefix##family

会发现,是一个宏。##是拼接,将左右两侧拼接成一个符号。

void InitServer()
{// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(1);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;// 2. 填充网络信息,并与套接字绑定// 2.1 填充网络信息struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET;local.sin_port = ::htons(_port);local.sin_addr.s_addr = ::inet_addr(_ip.c_str());// 2.2 绑定int n = ::bind(_sockfd, CONV(&local), sizeof(local));if (n < 0){LOG(LogLevel::FATAL) << "bind: " << strerror(errno);Die(2);}LOG(LogLevel::INFO) << "bind success";
}

虽然我们还是不理解上面这份代码是在干嘛,但是我们是知道这份代码的写法的,后面会慢慢理解的。到这,初始化服务器就做完了。就是两步:创建套接字+绑定。

启动服务器

要表示服务器的运行状态,我们增加一个成员变量来表示服务器的运行状态。默认情况下是false的

bool _isrunning; // 服务器运行状态

服务器是不能停的,所以我们将其写成死循环。

void Start()
{_isrunning = true;while (true){}_isrunning = false;
}

我们当前写的是Echo Server,就是要接收来自客户端的消息,打印出消息后,再将消息发送回客户端。所以,主要有2个内容:接收来自客户端的消息、向客户端发消息。

接收来自客户端的消息

此时需要使用到系统调用recvfrom。

#include <sys/types.h>
#include <sys/socket.h>ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);

这个系统调用表示从指定的套接字sockfd处收消息,将收到的消息放在缓冲区buf中,其期望收len个字节的消息,flags表示收消息的方式,传入o即可,以阻塞的方式收。

接收消息后,未来可能需要回消息,所以服务器需要知道是谁给他发送的消息。网络如何标识另一端呢?IP+端口号。第五个参数传入一个sockaddr_in类型的对象,第六个参数传入这个对象的长度。两者都是输出型参数。就可以将客户端的IP地址和端口号写入到sockaddr_in类型的对象中,从而让服务端获取到客户端的IP地址和端口号。

返回值:若接收成功,返回值是接收到的消息的字节数,接收失败返回值是-1,并设置errno。

void Start()
{_isrunning = true;while (true){char inbuffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &len);if (n > 0){// 我们除了要打印出消息内容,还要打印出客户端的IP地址和端口号// 客户端的发的消息内容在inbuffer中,客户端的IP地址和端口号在peer中uint16_t clientport = ::ntohs(peer.sin_port);std::string clientip = ::inet_ntoa(peer.sin_addr);inbuffer[n] = '\0'; // 需要适配C/C++的接口// 拼接std::string clientinfo = clientip + ":" + std::to_string(clientport) + " # " + inbuffer;LOG(LogLevel::DEBUG) << clientinfo;}}_isrunning = false;
}

可以看到,recvfrom第一个参数传入的竟然是自己的套接字,不是要从客户端接收消息吗,为什么传入自己的套接字呢?服务端调用socket创建套接字后,通过bind绑定到特定端口,客户端发送的报文会到达该端口,操作系统将其放入套接字的接收缓冲区,recvfrom从服务端自己的套接字中读取数据,并提取客户端的地址信息。

现在,我们已经成功接收了来自客户端的消息,接下来要向客户端发消息了

向客户端发消息

向客户端发消息需要使用到系统调用sendto。

#include <sys/types.h>
#include <sys/socket.h>ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);

这个函数的参数类型是与recvfrom一样的。但是含义是有一些不一样的。第一个参数仍然传入服务端自己的套接字描述符,表示的是从这个套接字对应的端口发送数据。第五个参数,sockaddr_in类型的对象中填写的IP地址和端口号要填写客户端的IP地址和端口号,表示向这个客户端发送,因为一个服务端可能对应多个客户端,所以一定要标识清楚向哪一个客户端发送。

返回值:若发送成功,返回值是成功发送的字节数,发送失败返回值是-1,并设置errno。

读、写时用的时同一个套接字描述符。这与之前管道是不同的,管道只能从1端读,另一端写。全双工:通信双方可以同时进行数据的发送和接收,读和写使用同一个文件描述符。半双工:通信双方可以双向传输数据,但不能同时发送和接收,必须交替进行。UDP就是一种全双工,管道是一种特殊的半双工。

void Start()
{_isrunning = true;while (true){char inbuffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &len);if (n > 0){// 我们除了要打印出消息内容,还要打印出客户端的IP地址和端口号// 客户端的发的消息内容在inbuffer中,客户端的IP地址和端口号在peer中uint16_t clientport = ::ntohs(peer.sin_port);std::string clientip = ::inet_ntoa(peer.sin_addr);inbuffer[n] = '\0'; // 需要适配C/C++的接口// 拼接std::string clientinfo = clientip + ":" + std::to_string(clientport) + " # " + inbuffer;LOG(LogLevel::DEBUG) << clientinfo;std::string echo_string = "echo# ";echo_string += inbuffer;::sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, CONV(&peer), sizeof(peer));}}_isrunning = false;
}
int main()
{std::unique_ptr<UdpServer> svr_uptr = std::make_unique<UdpServer>();svr_uptr->InitServer();svr_uptr->Start();return 0;
}

我们将服务器的代码运行起来

这样,我们的服务器就在Linux上跑起来了。netstat是一个用于显示网络连接、路由表、接口统计等网络相关信息u是仅显示UDP连接,-a是显示所有连接和监听端口(包括TCP/UDP),-P显示进程信息(PID和程序名)

Proto表示服务类型,这是一个UDP的服务;Recv-Q、Send-Q,收、发消息的数量都是0;服务器的本地地址是127.0.0.1,端口号是8080;远端地址是0.0.0.0,端口号是*,表示允许任何一个远端向服务器发送消息。进程是1472841,名字是后面的。所以,网络服务就是一个进程,或者进程池,或者一堆进程,或者一堆线程。网络服务启动起来后,就会在OS中一直运行。我们无论什么时间点刷抖音都可以刷,因为抖音的服务器端的服务器进程一直在运行,由服务器的服务器进程持续提供网络服务。

要看到通信的过程,就需要完成客户端

客户端

客户端与服务器一般都是客户端主动,服务器被动,这种模式称为CS。客户端是要给服务器发消息的,所以需要知道服务器的IP地址和端口号。未来这样运行./client_udp serverip serverport。我们这里就直接传入了,实际上服务端肯定是知道服务器的IP地址和端口号的,因为是同一家公司的人写的。

客户端要完成的工作是:初始化客户端(创建套接字)、启动客户端(向服务端发送消息、接收来自服务端的消息)。我们这里写的简单一点,就不设计成类了。

// ./client_udp serverip serverport
int main(int argc, char* argv[])
{if(argc != 3){std::cerr << "Usage: " << argv[0] << " serverip serverport" << std::endl;exit(1);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);// 创建套接字int sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(sockfd < 0){std::cerr << "socket error" << std::endl;exit(2);}// 初始化客户端完成,开始通信return 0;
}

现在,完成了初始化客户端的工作,接下来就可以进行通信了。向服务端发送消息使用的是sendto,从服务端接收消息使用的是recvfrom。与服务端是一样的。要使用sendto向服务端发送消息,定要有服务器的套接字信息,所以在通信之前还需要完成填充服务器套接字的工作。另外,因为所有的源文件都需要退出码,并且都需要对sockaddr_in进行强转,所以可以将这些内容放在一个公共的.hpp中。

#define Die(code) do {exit(code);}while(0)
#define CONV(v) (struct sockaddr*)(v) // 完成类型转换enum
{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR
};
// ./client_udp serverip serverport
int main(int argc, char* argv[])
{if(argc != 3){std::cerr << "Usage: " << argv[0] << " serverip serverport" << std::endl;exit(USAGE_ERR);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);// 创建套接字int sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(sockfd < 0){std::cerr << "socket error" << std::endl;exit(SOCKET_ERR);}// 初始化客户端完成,开始通信// 填充服务端的套接字信息struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = ::htons(serverport);server.sin_addr.s_addr = ::inet_addr(serverip.c_str());// 通信while(true){// 向服务端发送消息std::cout << "Please Enter# ";std::string message;std::getline(std::cin, message);int n = ::sendto(sockfd, message.c_str(), message.size(), 0, CONV(&server), sizeof(server));// 接收来自服务端返回的消息struct sockaddr_in temp;socklen_t len = sizeof(temp);char buffer[1024];n = ::recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, CONV(&temp), &len);if(n > 0){buffer[n] = '\0';std::cout << buffer << std::endl;}}return 0;
}

这里recvfrom中,为什么还要知道是谁给这个客户端发送消息呢?因为一个客户端可能会对应多个服务端,所以需要知道。

会发现客户端和服务端最大的不同就是,服务端在创建完成套接字后,要为套接字绑定IP地址和端口号,而客户端不需要绑定,为什么?客户端必须也要有自己的IP地址和端口!但是客户端,不需要自己显示的调用bind!!而是,客户端首次sendto消息的时候,由OS自动进行bind

1. 如何理解客户端的自动绑定?客户端绑定就是填充自己的sockaddr_in,并与自己的套接字描述符绑定。未来还要将这个sockaddr_in发送给服务器。服务器才能知道客户端是谁。在服务器中,可能会有很多的进程、很多的端口号,一个端口号只能被一个进程绑定吗?是的,一个端口号只能被一个进程绑定,因为当OS收到报文时,是需要根据端口号查进程的,所以端口号与进程必须是一对一的。反过来,一个进程可以对应多个端口号。一个服务器上可能会有多个客户端,例如手机上会有微信、京东、抖音等,如果网络通信时都由客户端自己绑定端口号,若两个客户端要绑定同一个端口号,而一个端口号只能被一个客户端绑定,此时就会出现后绑定的客户端绑定失败,从而
无法启动。所以,客户端是由OS来绑定,OS会随机绑定一个端口号。所以,客户端的端口号是不固定的。

2. 如何理解服务端要显示绑定因为服务器的端口号必须稳定。服务器的端口号一般会内置到客户端当中。一个服务器可能对应几十万个服务端,所以他的端口号必须是所有客户端都知道,且不能随意更改的。0-1023叫知名端口号,因为这些端口号已经被某些服务绑定了。

本地测试

我们先来进行本地测试。因为我们现在服务端绑定的IP是本地主机,只需要让客户端在启动时,连接本地主机和服务端的端口号,就是本地测试。注意:这里说的本地主机,不是指Windows的本地主机,而是云服务器的本地,因为服务端和客户端都是在云服务器上启动的,使用的IP地址是云服务器自身的环回地址,只能接受来自同一台云服务器内部的连接(即客户端也必须运行在该云服务器上),所以是本地通信。

是需要让服务端先运行起来的。

可以看到,在本地主机上既有服务端进程,也有客户端进程。此时就成功完成了通信。

网络测试

我们让服务端运行起来时也输入自己的IP地址和端口号,并且不要绑定本地IP,而是绑定我们指定的IP,我们让其绑定云服务器的IP。相当于服务端在云服务器上运行。对于客户端:

1. 客户端也是运行在云服务器上,通过云服务器的公网IP向云服务器发送消息,此时就是网络通信

2. 让客户端运行在Windows上,此时客户端就不再是运行在云服务器上了,而是运行在本地,通过云服务器的公网IP向云服务器发送消息,此时就是网络通信

所以,当服务端运行在云服务器上并绑定了云服务器的IP地址,无论客户端运行在哪里,与服务端一样运行在云服务器上还是其他主机上,只要绑定了云服务器的IP地址,就是在向服务端发送消息,就是网络通信。

// ./server_udp localip localport
int main(int argc, char* argv[])
{if(argc != 3){std::cerr << "Usage: " << argv[0] << " localip localport" << std::endl;Die(USAGE_ERR);}std::string ip = argv[1];uint16_t port = std::stoi(argv[2]);ENABLE_CONSOLE_LOG();std::unique_ptr<UdpServer> svr_uptr = std::make_unique<UdpServer>(ip, port);svr_uptr->InitServer();svr_uptr->Start();return 0;
}

先来测试一下,修改完成之后能否进行本地通信



可以看到,是可以进行本地通信的。

接下来测试网络通信

会发现此时绑定云服务器的IP地址失败了。云服务器禁止用户绑定公网IP。实际上这个IP就不属于这个云服务器,是通过一些云产品虚拟出来的一个IP地址。虚拟机可以绑定任何IP。

一台服务器上,可能会有多个IP。例如一台服务器上可能会有多个网卡,或者通过虚拟技术弄出了多个IP。对于这些IP,无论是通过哪一个IP发送进来的报文,都是发送给这一个服务器的。如果进程只绑定了127.0.0.1,那么未来这个进程只会接收到这个IP地址的报文。如果客户端都是给这个进程对应的端口号发送的,但是使用了不同的IP地址,那么有些消息这个进程是看不到的。这样肯定是不好的。实际上,服务器不需要IP,只需要端口号即可。将服务器的IP地址设置为INADDR_ANY即可。表示无论使用服务端所在主机上的哪一个IP地址,都可以将报文交给指定的端口号所对应的进程。而云服务器的IP地址是属于当前主机的,所以当客户端向云服务器指定端口发送消息时,若服务端绑定的端口与客户端绑定的端口相同,那么服务端就可以收到消息。

const static int gsockfd = -1;
// const static std::string gdefaultip = "127.0.0.1"; // 本地主机的IP地址
const static uint16_t gdefaultport = 8080;class UdpServer
{
public:UdpServer(uint16_t port = gdefaultport):_sockfd(gsockfd),_port(port),_isrunning(false){}void InitServer(){// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(USAGE_ERR);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;// 2. 填充网络信息,并与套接字绑定// 2.1 填充网络信息struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET;local.sin_port = ::htons(_port);local.sin_addr.s_addr = INADDR_ANY;// 2.2 绑定int n = ::bind(_sockfd, CONV(&local), sizeof(local));if(n < 0){LOG(LogLevel::FATAL) << "bind: " << strerror(errno);Die(SOCKET_ERR);}LOG(LogLevel::INFO) << "bind success";}void Start(){_isrunning = true;while(true){char inbuffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &len);if(n > 0){// 我们除了要打印出消息内容,还要打印出客户端的IP地址和端口号// 客户端的发的消息内容在inbuffer中,客户端的IP地址和端口号在peer中uint16_t clientport = ::ntohs(peer.sin_port);std::string clientip = ::inet_ntoa(peer.sin_addr);inbuffer[n] = '\0'; // 需要适配C/C++的接口// 拼接std::string clientinfo = clientip + ":" + std::to_string(clientport) + " # " + inbuffer;LOG(LogLevel::DEBUG) << clientinfo;std::string echo_string = "echo# ";echo_string += inbuffer;::sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, CONV(&peer), sizeof(peer));}}_isrunning = false;}~UdpServer(){}
private:int _sockfd;     // 套接字uint16_t _port;  // 服务器未来的端口号// std::string _ip; // 服务器所对应的IP地址bool _isrunning; // 服务器运行状态
};
// ./server_udp localport
int main(int argc, char* argv[])
{if(argc != 2){std::cerr << "Usage: " << argv[0] << " localport" << std::endl;Die(USAGE_ERR);}// std::string ip = argv[1];uint16_t port = std::stoi(argv[1]);ENABLE_CONSOLE_LOG();std::unique_ptr<UdpServer> svr_uptr = std::make_unique<UdpServer>(port);svr_uptr->InitServer();svr_uptr->Start();return 0;
}

先来测试一下,修改完成之后能否在本地进行通信。



可以看到,是可以正常通信的。可以看到,服务端绑定的IP地址为全0,全0表示任意IP绑定。即无论是当前主机的哪一个IP地址收到报文,都发给8080这个端口号。

现在来看看网络通信。此时需要云服务器开放对应的端口才能完成通信。



可以看到,此时是可以正常进行通信的。此时我们的客户端和服务端都是在Linux上的。另外,我们可以将客户端的代码做一点修改,并放到Windows上,也是可以与Linux上的服务端通信的。

#include <iostream>
#include <cstdio>
#include <thread>
#include <string>
#include <cstdlib>
#include <WinSock2.h>
#include <Windows.h>#pragma warning(disable : 4996)#pragma comment(lib, "ws2_32.lib")std::string serverip = "47.113.120.114";  // 填写你的云服务器ip
uint16_t serverport = 8080; // 填写你的云服务开放的端口号int main()
{WSADATA wsd;WSAStartup(MAKEWORD(2, 2), &wsd);struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(serverport); //?server.sin_addr.s_addr = inet_addr(serverip.c_str());SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd == SOCKET_ERROR){std::cout << "socker error" << std::endl;return 1;}std::string message;char buffer[1024];while (true){std::cout << "Please Enter@ ";std::getline(std::cin, message);if (message.empty()) continue;sendto(sockfd, message.c_str(), (int)message.size(), 0, (struct sockaddr*)&server, sizeof(server));struct sockaddr_in temp;int len = sizeof(temp);int s = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)&temp, &len);if (s > 0){buffer[s] = 0;std::cout << buffer << std::endl;}}closesocket(sockfd);WSACleanup();return 0;
}



可以看到,此时Windows上的客户端就与Linux上的服务端完成了通信。并且因为Linux与Windows的网络部分的代码是一样的,所以它们的接口也基本上是一样的。这里说几点Windows上与Linux不同的点:

  • #pragma warning是屏蔽掉scanf等的不安全警告
  • #pragma comment:在Windows下,上面的这些接口是被定义在库ws2_32.lib中的,vs下是默认安装这个库的,要使用就需要先引入。第一个参数是lib表示引入的是库。
  • WSAStartup:对于使用到的库,需要告诉vs要使用哪一个版本,编译时vs就会到ws2_32.lib中找这个库了。
  • SOCKET就是一个整数
  • WSAcleanup:清理、释放库资源

现在,我们就完成了客户端与服务端之间的网络通信。只要将客户端的可执行程序发给其他主机,其他主机就可以通过客户端给服务端发送消息了。可以看到,客户端的IP地址就是客户端主机的IP地址了。这样,一个服务端可能会有多个服务端,所有客户端发送的消息,主机都能够拿到。

优化代码

我们来实现一个类,让这个类来完成IP地址、端口号的网络序列转为本地序列。

class InetAddr
{
private:void PortNet2Host() // 端口号网络序列转为本地序列{_port = ::ntohs(_net_addr.sin_port);}void IpNet2Host() // IP地址网络序列转为本地序列{char ipbuffer[64];const char* ip = ::inet_ntop(AF_INET, &_net_addr.sin_addr, ipbuffer, sizeof(ipbuffer));_ip = ipbuffer;}
public:InetAddr() {} // 默认构造// 构造函数传递进来的参数是sockaddr_in类型的参数时,将网络序列转为本地序列InetAddr(const struct sockaddr_in& addr):_net_addr(addr){PortNet2Host();IpNet2Host();}// 构造函数传递进来的参数是一个端口号时,就将本地序列转为网络序列InetAddr(uint16_t port):_port(port), _ip(""){_net_addr.sin_family = AF_INET;_net_addr.sin_port = htons(_port);_net_addr.sin_addr.s_addr = INADDR_ANY;}struct sockaddr* NetAddr() {return CONV(&_net_addr);}socklen_t NetAddrLen() {return sizeof(_net_addr);}std::string Ip() {return _ip;}uint16_t Port() {return _port;}
private:struct sockaddr_in _net_addr;std::string _ip;uint16_t _port;
};

将IP地址从网络序列转为本地序列可以使用两个函数:

char *inet_ntoa(struct in_addr in);const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

这两个函数的功能是一样的,都是将IP地址的网络序列转为点分十进制的IP地址。inet_ntoa返回的值是char*,不是字符串,C语言中是没有字符串类型的,那字符串在哪里呢?实际上,是会维护一段静态空间,返回的是指向静态空间的字符串指针。是线程不安全的。而inet_ntop需要我们自己传入一个缓冲区,所以是线程安全的,所以更加建议使用inet_ntop。

有了上面这个类之后,我们服务器类中的成员变量就不需要端口号了,只需要一个InetAddr对象

using namespace LogMoudule;const static int gsockfd = -1;
// const static std::string gdefaultip = "127.0.0.1"; // 本地主机的IP地址
const static uint16_t gdefaultport = 8080;class UdpServer
{
public:UdpServer(uint16_t port = gdefaultport):_sockfd(gsockfd),_addr(port),_isrunning(false){}void InitServer(){// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(USAGE_ERR);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;// 2. 填充网络信息,并与套接字绑定// 2.1 填充网络信息// struct sockaddr_in local;// bzero(&local, sizeof(local));// local.sin_family = AF_INET;// local.sin_port = ::htons(_port);// local.sin_addr.s_addr = INADDR_ANY;// 2.2 绑定int n = ::bind(_sockfd, _addr.NetAddr(), _addr.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind: " << strerror(errno);Die(SOCKET_ERR);}LOG(LogLevel::INFO) << "bind success";}void Start(){_isrunning = true;while(true){char inbuffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &len);if(n > 0){// 我们除了要打印出消息内容,还要打印出客户端的IP地址和端口号// 客户端的发的消息内容在inbuffer中,客户端的IP地址和端口号在peer中// uint16_t clientport = ::ntohs(peer.sin_port);// std::string clientip = ::inet_ntoa(peer.sin_addr);InetAddr cli(peer);inbuffer[n] = '\0'; // 需要适配C/C++的接口// 拼接// std::string clientinfo = clientip + ":" + std::to_string(clientport) + " # " + inbuffer;std::string clientinfo = cli.Ip() + ":" + std::to_string(cli.Port()) + " # " + inbuffer;LOG(LogLevel::DEBUG) << clientinfo;std::string echo_string = "echo# ";echo_string += inbuffer;::sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, CONV(&peer), sizeof(peer));}}_isrunning = false;}~UdpServer(){if(_sockfd > gsockfd)::close(_sockfd);}
private:int _sockfd;     // 套接字// uint16_t _port;  // 服务器未来的端口号// std::string _ip; // 服务器所对应的IP地址InetAddr _addr;bool _isrunning; // 服务器运行状态
};

这就是Echo Server,他虽然有通信的过程,但是他是没有业务的。服务端只是接收客户端发送的消息,并将消息再转发回去而已。

V2版本 - Dict Server

我们来写一个英汉互译的字典,之前客户端发过来的消息,服务端就是打印一下,并且再发送回去,现在要让服务器做一下翻译再发送回去。客户端传过来英文,返回中文。

服务器的调整

我们是要让服务器进行翻译,正常来说我们应该在服务器内部写翻译的代码,但是我们不这样做。我们在服务器内部新定义一个成员变量,这个成员变量是一个回调函数,我们在启动服务器时,不仅仅要告诉服务器的端口号,还要告诉服务器的业务是什么。

using func_t = std::function<std::string(const std::string&)>;const static int gsockfd = -1;
const static uint16_t gdefaultport = 8080;class UdpServer
{
public:UdpServer(func_t func, uint16_t port = gdefaultport):_sockfd(gsockfd),_addr(port),_isrunning(false),_func(func){}void InitServer(){// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(USAGE_ERR);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;// 2.2 绑定int n = ::bind(_sockfd, _addr.NetAddr(), _addr.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind: " << strerror(errno);Die(SOCKET_ERR);}LOG(LogLevel::INFO) << "bind success";}void Start(){_isrunning = true;while(true){char inbuffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &len);if(n > 0){inbuffer[n] = '\0';// 对接收到的数据进行业务处理,返回的是处理结果std::string result = _func(inbuffer);::sendto(_sockfd, result.c_str(), result.size(), 0, CONV(&peer), sizeof(peer));}}_isrunning = false;}~UdpServer(){if(_sockfd > gsockfd)::close(_sockfd);}
private:int _sockfd;     // 套接字InetAddr _addr;bool _isrunning; // 服务器运行状态// 业务:回调函数func_t _func;
};

可以看到,在这里,我们并不是让服务器直接去处理数据,而是在服务器内部让服务器去调用函数来处理数据,这个函数称为回调函数。作为服务器,它的任务就只是进行IO,意思就是服务器只进行数据的发送和接收。至于数据是什么,如何解释,不是这个服务器该做的事情。所以,这个服务器将来要做什么业务,完全是由这个回调函数决定的。func是回调函数,意思就是会出去,还会再回来。回调机制是下层软件调上层业务最常用的一种做法

字典

对于字典,我们写一个文件版的字典。提前保存一些单词和翻译的映射关系,保存到一个文件当中,未来字典类在启动时,都必须先加载字典,然后将字典内的中英文建立一张映射表,就可以通过英文找到中文了。将字典定义再文件dict.txt中

apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
happy: 快乐的
sad: 悲伤的
run: 跑
jump: 跳
teacher: 老师
student: 学生
car: 汽车
bus: 公交车
love: 爱
hate: 恨
hello: 你好
goodbye: 再见
summer: 夏天
winter: 冬天

接下来定义字典类,这个字典类中就提供加载和翻译的接口

const std::string gpath = "./"; // 字典文件所在路径
const std::string gdictname = "dict.txt"; // 字典名称
const std::string gsep = ": "; // 切割分隔符class Dictionary
{
private:// 切割字符串 bool SplitString(const std::string& line, std::string& key, std::string& value, const std::string& sep){auto pos = line.find(sep);if(pos == std::string::npos) return false;key = line.substr(0, pos);value = line.substr(pos + sep.size());if(key.empty() || value.empty()) return false;return true;}bool LoadDictionary() // 加载字典{std::string file = _path + _filename;std::ifstream in(file.c_str()); // 默认以只读的形式打开文件if(!in.is_open()){LOG(LogLevel::ERROR) << "open file " << file << " error";return false;}// 以行为单位读取文件,并对读取到的内容进行切割std::string line;while(std::getline(in, line)){std::string key;std::string value;if(SplitString(line, key, value, gsep)){_dictinoary.insert({key, value});}}in.close();return true;}
public:Dictionary(const std::string& path = gpath, const std::string& filename = gdictname): _path(path),_filename(filename){LoadDictionary();}// 翻译std::string Translate(const std::string& word){auto iter = _dictinoary.find(word);if(iter == _dictinoary.end()) return "None";return iter->second;}~Dictionary() {}
private:std::unordered_map<std::string, std::string> _dictinoary; // <英文, 中文>std::string _path;     // 字典文件所在路径std::string _filename; // 字典文件的名称
};

字典类只需要对外暴露翻译接口即可,因为服务器只会调用里面的翻译接口。

网络模块与业务模块耦合

现在我们的程序就是有两个模块的,一个网络,一个业务。通过将类当中的方法传递给另一个类,也就是将字典类中的翻译函数传递给服务器类,此时就能做模块间的耦合了,通过回调的方式实现耦合。将字典类中的翻译函数传递给服务器类也叫做将字典中的回调服务注册到UdpServer中。

// ./server_udp localport
int main(int argc, char* argv[])
{if(argc != 2){std::cerr << "Usage: " << argv[0] << " localport" << std::endl;Die(USAGE_ERR);}uint16_t port = std::stoi(argv[1]);ENABLE_CONSOLE_LOG();std::shared_ptr<Dictionary> dict_sptr = std::make_unique<Dictionary>();// 将字典中的回调服务注册到UdpServer中func_t f = std::bind(&Dictionary::Translate, dict_sptr.get(), std::placeholders::_1);std::unique_ptr<UdpServer> svr_uptr = std::make_unique<UdpServer>(f, port);svr_uptr->InitServer();svr_uptr->Start();return 0;
}


V3版本 - 简单聊天室

简单聊天室概述

一个服务端,可以对应多个客户端,如果有多个客户端都给服务端发送消息,对于消息,可以划分为消息内容和消息的发送者,将消息的发送者记录下来,并维护好,将来任何人发送消息,都将发送消息的这个人和他发的消息传播给其他所有的人,此时就是一个聊天室。群聊是这样,单聊就更加简单了。

我们从Echo Server修改,只需要引入一些新角色,对于服务端接收到的消息,将接收到的消息转发给所有人即可。但是,当前服务器是单进程的,如果这个服务器既要接收消息,又要转发消息,并且会有多个客户端向其发送消息,又要向多个客户端转发数据,而UDP一旦数据量一大,就可能来不及接收数据了。另外,UDP的套接字是全双工的,全双工的意思是使用一个文件描述符进行读和写。此时可以这样,在服务器内部再创建一个子进程,由父进程来接收消息,父进程将接收到的消息交给子进程,由子进程来完成消息转发。

因为父子进程是共享文件描述符表的,所以上述操作是可以完成的。但是,父进程将数据交给子进程就是进程间通信,太麻烦了。

此时可以使用一个线程池来完成。使用一个线程来收消息,如果收到的消息的发送者是新用户,就将这个用户的消息注册到消息转发的模块中,再将消息内容封装成一个任务放入到线程池中,由线程池来统一执行消息转发的模块。

数据接收模块我们已经有了,现在要完成消息转发模块。当然,消息转发模块还需要进行一些修改

消息转发模块

对于消息转发模块,就需要知道当前有那些在线用户,这里简单使用IP地址来区分用户的唯一性。此时需要定义两个类,一个类用于定义一个用户:User,一个类用于管理所有用户:UserManger

class UserInterface
{
public:virtual ~UserInterface() = default;virtual void SendTo(int sockfd, const std::string& message) = 0;virtual bool operator==(const InetAddr& u) = 0;
};// User类用于描述一个客户端对象
class User : public UserInterface
{
public:User(const InetAddr& id):_id(id) {}void SendTo(int sockfd, const std::string& message){LOG(LogLevel::DEBUG) << "send message to " << _id.Addr() << " info: " << message;// 给这个客户端对象发送消息int n = ::sendto(sockfd, message.c_str(), message.size(), 0, _id.NetAddr(), _id.NetAddrLen());}bool operator==(const InetAddr& u){return _id == u;}~User(){}
private:InetAddr _id;
};// UserManger用于管理所有在线用户,对用户消息进行路由 
class UserManger
{
public:UserManger(){}// 新增在线用户void AddUser(InetAddr& id){// 遍历在线列表,只有当在线列表中没有这个用户时,才新增for(auto& user : _online_user){if(*user == id){LOG(LogLevel::INFO) << id.Addr() << "用户已存在";return ;}}LOG(LogLevel::INFO) << " 新增该用户: " << id.Addr();_online_user.push_back(std::make_shared<User>(id));}// 删除在线用户void DelUser(InetAddr& in){}// 将消息转发给所有在线用户void Router(int sockfd, const std::string& message){for(auto& user : _online_user){user->SendTo(sockfd, message);}}~UserManger(){}
private:std::list<std::shared_ptr<UserInterface>> _online_user;
};

给InetAddr增加了两个接口。Addr是输出日志需要;==的运算符重载是因为新增用户时,对于已经存在的用户不需要新增,所以需要给类User和类lnetAddr重载==。这里的重载==只需要判断客户端的IP地址即可,因为我们是根据IP地址来标识一个客户端的。

std::string Addr()
{return _ip + ":" + std::to_string(_port);
}
bool operator==(const InetAddr& addr)
{return _ip == addr._ip;
}

可以看到,在消息转发接口Router中,其实就是让每个用户将消息发送给自己。所以,当新增用户时,传入用户InetAddr,即可将用户添加到在线用户列表中,当要发消息是,UserManager不做发送,而是调用每一个用户自己提供的公共的发送方法,这种设计模式叫做观察者模式,所有的用户就是观察者,User就是观察者,当某种事件发生了,观察者会加入到在线用户列表中;当未来有某种事件发生了,通知观察者,执行对应的方法。

在群聊中聊天要遍历所有的人,这也正是为什么群聊一般都有人数限制。

数据接收模块

数据接收模块,只关注消息转发模块的AddUser,当接收到的消息是这个发送者第一次发送时,就将其添加到在线用户列表中。然后数据接收模块将用户发送的消息包装成一个任务,让线程池中的线程去执行任务,所以,线程池只关心Router。数据接收模块和线程池中都有回调方法,所以只需要将两个方法分别注册,即可让它们执行消息转发模块中的方法。

新增用户

using adduser_t = std::function<void (InetAddr& id)>; // 新增用户的包装器const static int gsockfd = -1;
// const static std::string gdefaultip = "127.0.0.1"; // 本地主机的IP地址
const static uint16_t gdefaultport = 8080;class UdpServer
{
public:UdpServer(uint16_t port = gdefaultport):_sockfd(gsockfd),_addr(port),_isrunning(false){}// 向服务器注册服务void RegisterService(adduser_t adduser){_adduser = adduser;}void InitServer(){// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(USAGE_ERR);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;// 2.2 绑定int n = ::bind(_sockfd, _addr.NetAddr(), _addr.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind: " << strerror(errno);Die(SOCKET_ERR);}LOG(LogLevel::INFO) << "bind success";}void Start(){_isrunning = true;while(true){char inbuffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &len);if(n > 0){// 我们除了要打印出消息内容,还要打印出客户端的IP地址和端口号// 客户端的发的消息内容在inbuffer中,客户端的IP地址和端口号在peer中InetAddr cli(peer);inbuffer[n] = '\0'; // 需要适配C/C++的接口// 新增用户_adduser(cli);}}_isrunning = false;}~UdpServer(){if(_sockfd > gsockfd)::close(_sockfd);}
private:int _sockfd;     // 套接字// uint16_t _port;  // 服务器未来的端口号// std::string _ip; // 服务器所对应的IP地址InetAddr _addr;bool _isrunning; // 服务器运行状态// 新增用户adduser_t _adduser;
};
// ./server_udp localport
int main(int argc, char* argv[])
{if(argc != 2){std::cerr << "Usage: " << argv[0] << " localport" << std::endl;Die(USAGE_ERR);}uint16_t port = std::stoi(argv[1]);ENABLE_CONSOLE_LOG();// 用户管理模块std::shared_ptr<UserManger> um = std::make_shared<UserManger>();// 网络模块std::unique_ptr<UdpServer> svr_uptr = std::make_unique<UdpServer>(port);svr_uptr->RegisterService([&um](InetAddr& id){um->AddUser(id);});svr_uptr->InitServer();svr_uptr->Start();return 0;
}

就是定义一个新增用户的函数包装器,并在服务器类内部定义一个这个包装器类型,然后提供一个注册接口。现在,我们在使用服务器时,初始化服务器之前,还需要先注册。未来若想让服务器内部有多个服务,只需要对注册接口进行拓展即可。我们来测试一下,看看新增用户能否成功。



可以看到,新增用户是可以成功的。客户端发送出去后,没有Please Enter是因为当前服务端只会新增用户,不会给服务端回消息,所以客户端会阻塞在recvfrom处。

封装消息成为任务并交给线程池执行

收消息简单,而转发消息是需要遍历所有在线用户的,所以使用线程池来做。线程池不仅仅可以将整数放到线程池中,也可以把任务放到线程池中,这个任务可以是类,也可以是函数对象。我们使用单例模式的线程池。

namespace ThreadPoolMoudle
{using namespace LockMoudle;using namespace LogMoudule;using namespace ThreadModule;using namespace CondMoudle;// 用来做测试的线程方法void DefaultTest(){while(true){LOG(LogLevel::DEBUG) << "我是一个测试方法";sleep(1);}}// 线程池中默认创建5个线程const static int defaultnum = 5;// thread_t就是一个指向Thread对象的智能指针using thread_t = std::shared_ptr<Thread>;// 这个模板表示的是消息队列中存放类型Ttemplate<typename T>class ThreadPool{private:bool IsEmpty() { return _taskq.empty(); }void HandlerTask(std::string name){LOG(LogLevel::INFO) << "线程: " << name << ", 线程进入HandlerTask的逻辑";// LOG(LogLevel::INFO) << "线程进入HandlerTask的逻辑";while(true){// 1. 拿任务T t;{LockGuard lockguard(_lock);// 当任务队列为空,并且线程池正在运行时,才能让线程等待while(IsEmpty() && _isrunning) {// 队列为空,等待_wait_num ++;_cond.Wait(_lock);_wait_num --;}// 队列为空,并且线程池退出了,就让线程退出if(IsEmpty() && !_isrunning)break;t = _taskq.front();_taskq.pop();}// 2. 处理任务t(); // 规定:未来所有的任务处理,全部都必须提供()方法}LOG(LogLevel::INFO) << "线程: " << name << " 退出";}ThreadPool(const ThreadPool<T>&) = delete;ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;// 默认线程池是没有运行的ThreadPool(int num = defaultnum) : _num(num), _wait_num(0), _isrunning(false){// 创建出_num个线程对象for(int i = 0;i < _num;i ++){_threads.push_back(std::make_shared<Thread>(std::bind(&ThreadPool::HandlerTask, this, std::placeholders::_1)));LOG(LogLevel::INFO) << "构建线程" << _threads.back()->Name() << "对象...成功";}}public:// 创建对象时就调用这个函数创建static ThreadPool<T>* getInstance(){if(instance == nullptr){LOG(LogLevel::INFO) << "单例首次被执行, 需加载对象...";instance = new ThreadPool<T>();instance->Start();}return instance;}// 向任务队列放入一个线程void Equeue(T& in){LockGuard lockguard(_lock);if(!_isrunning) return ;_taskq.push(std::move(in));// 若有处于等待状态的线程,唤醒if(_wait_num > 0)_cond.Notify();}// 创建线程池void Start(){// 让_num个线程对象都启动if(_isrunning) return ;_isrunning = true;for(auto& thread_ptr : _threads){thread_ptr->Start();LOG(LogLevel::INFO) << "启动线程" << thread_ptr->Name() << " ...成功";}}// 停止线程池void Stop(){LockGuard lockguard(_lock);if(_isrunning){// 不能再向任务队列中放入任务_isrunning = false;// 唤醒所有线程,并将任务队列中剩余的任务处理完成,此时已经无法向任务队列中放入任务了,所以任务是有限的if(_wait_num > 0)_cond.NotifyAll();}}// 等待线程void Wait(){// 等待线程池中的所有线程for(auto& thread_ptr : _threads){thread_ptr->Join();LOG(LogLevel::INFO) << "回收线程" << thread_ptr->Name() << " ...成功";}}~ThreadPool() {}private:std::vector<thread_t> _threads; // 数组存放线程的指针int _num;                       // 线程池中线程个数int _wait_num;                  // 处于等待状态的线程数量bool _isrunning;                // 线程池是否正在运行std::queue<T> _taskq;           // 任务队列Mutex _lock;Cond _cond;static ThreadPool<T>* instance;};// 在类外对static的指针进行初始化template<typename T>ThreadPool<T>* ThreadPool<T>::instance = NULL;
}

在这里,对线程池进行了一点小小的修改,因为正常来说,我们需要让线程池启动,等待等等,这里为了方便,直接在获取单例对象的函数中让线程池启动。 

using task_t = std::function<void ()>; // 给线程池的任务
using adduser_t = std::function<void (InetAddr& id)>; // 新增用户的包装器
using route_t = std::function<void (int sockfd, const std::string& message)>; // 将消息转发给所有在线用户的包装器const static int gsockfd = -1;
// const static std::string gdefaultip = "127.0.0.1"; // 本地主机的IP地址
const static uint16_t gdefaultport = 8080;class UdpServer
{
public:UdpServer(uint16_t port = gdefaultport):_sockfd(gsockfd),_addr(port),_isrunning(false){}// 向服务器注册服务void RegisterService(adduser_t adduser, route_t route){_adduser = adduser;_route = route;}void InitServer(){// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(USAGE_ERR);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;// 2.2 绑定int n = ::bind(_sockfd, _addr.NetAddr(), _addr.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind: " << strerror(errno);Die(SOCKET_ERR);}LOG(LogLevel::INFO) << "bind success";}void Start(){_isrunning = true;while(true){char inbuffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &len);if(n > 0){// 我们除了要打印出消息内容,还要打印出客户端的IP地址和端口号// 客户端的发的消息内容在inbuffer中,客户端的IP地址和端口号在peer中InetAddr cli(peer);inbuffer[n] = '\0'; // 需要适配C/C++的接口std::string message = inbuffer;// 新增用户_adduser(cli);// 构建转发任务,推送给线程池,让线程池进行转发task_t task = std::bind(UdpServer::_route, _sockfd, message);ThreadPool<task_t>::getInstance()->Equeue(task);}}_isrunning = false;}~UdpServer(){if(_sockfd > gsockfd)::close(_sockfd);}
private:int _sockfd;     // 套接字// uint16_t _port;  // 服务器未来的端口号// std::string _ip; // 服务器所对应的IP地址InetAddr _addr;bool _isrunning; // 服务器运行状态// 新增用户adduser_t _adduser;route_t _route;
};
// ./server_udp localport
int main(int argc, char* argv[])
{if(argc != 2){std::cerr << "Usage: " << argv[0] << " localport" << std::endl;Die(USAGE_ERR);}uint16_t port = std::stoi(argv[1]);ENABLE_CONSOLE_LOG();// 用户管理模块std::shared_ptr<UserManger> um = std::make_shared<UserManger>();// 网络模块std::unique_ptr<UdpServer> svr_uptr = std::make_unique<UdpServer>(port);svr_uptr->RegisterService([&um](InetAddr& id){um->AddUser(id);},[&um](int sockfd, const std::string& message){um->Router(sockfd, message);});svr_uptr->InitServer();svr_uptr->Start();return 0;
}

实际上就是给注册接口新增加了一个函数,这个函数用于转发。来测试一下,一个客户端能否接收到其他客户端发过来的消息。


此时是有问题的,必须输入或直接回车,才能拿到消息,当有人发送消息时,并没有转发给全部人。因为客户端有问题现在的客户端有getline,只有按下回车才能接收消息。也就是客户端不发送消息时,是无法接收消息的,只有发消息时,才能接收消息。所以,客户端也要多线程让主线程获取用户输入,并向服务端发消息;让新线程来收消息。

int sockfd = -1;// 接收服务端发过来的消息
void *Recver(void* args)
{while(true){// 接收来自服务端返回的消息struct sockaddr_in temp;socklen_t len = sizeof(temp);char buffer[1024];int n = ::recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, CONV(&temp), &len);if(n > 0){buffer[n] = '\0';std::cout << buffer << std::endl;}}
}// ./client_udp serverip serverport
int main(int argc, char* argv[])
{if(argc != 3){std::cerr << "Usage: " << argv[0] << " serverip serverport" << std::endl;exit(USAGE_ERR);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);// 创建套接字sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(sockfd < 0){std::cerr << "socket error" << std::endl;exit(SOCKET_ERR);}// 初始化客户端完成,开始通信// 填充服务端的套接字信息struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = ::htons(serverport);server.sin_addr.s_addr = ::inet_addr(serverip.c_str());pthread_t tid;pthread_create(&tid, nullptr, Recver, nullptr);// 通信while(true){// 向服务端发送消息std::cout << "Please Enter# ";std::string message;std::getline(std::cin, message);int n = ::sendto(sockfd, message.c_str(), message.size(), 0, CONV(&server), sizeof(server));}return 0;
}

对Windows上的客户端也进行修改

#pragma warning(disable : 4996)#pragma comment(lib, "ws2_32.lib")std::string serverip = "47.113.120.114";  // 填写你的云服务器ip
uint16_t serverport = 8080; // 填写你的云服务开放的端口号#define CONV(v) (struct sockaddr*)(v) // 完成类型转换SOCKET sockfd = -1;// 接收服务端发过来的消息
void Recver()
{while (true){// 接收来自服务端返回的消息struct sockaddr_in temp;int len = sizeof(temp);char buffer[1024];int n = ::recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, CONV(&temp), &len);if (n > 0){buffer[n] = '\0';std::cout << buffer << std::endl;}}
}int main()
{WSADATA wsd;WSAStartup(MAKEWORD(2, 2), &wsd);struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(serverport); //?server.sin_addr.s_addr = inet_addr(serverip.c_str());sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd == SOCKET_ERROR){std::cout << "socker error" << std::endl;return 1;}// 创建线程std::thread t1(Recver);std::string message;while (true){std::cout << "Please Enter@ ";std::getline(std::cin, message);if (message.empty()) continue;sendto(sockfd, message.c_str(), (int)message.size(), 0, CONV(&server), sizeof(server));}closesocket(sockfd);WSACleanup();return 0;
}

现在来测试一下能否正常接收到别人的消息



图片中绿色框框内的就是当前客户端发送的消息,没有框出来的就是接收到的服务端发送过来的消息,可以看到,此时无论是自己发送的消息,还是其他客户端发送的消息,都是可以接收到的

重定向观察

我们上面虽然能够接收到其他在线用户发送的消息,但是界面非常不好看,因为输入输出在同一个窗口,现在我们利用重定向来调整一下。

上面的问题主要是都往显示器上打,导致输出混乱。我们现在让输出消息往标准输出打,而接收服务端的消息往标准错误打,然后对标准错误进行重定向,让其往管道中输入。

// 接收服务端发过来的消息
void *Recver(void* args)
{while(true){// 接收来自服务端返回的消息struct sockaddr_in temp;socklen_t len = sizeof(temp);char buffer[1024];int n = ::recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, CONV(&temp), &len);if(n > 0){buffer[n] = '\0';std::cerr << buffer << std::endl;}}
}



补充细节

1.消息转发模块中,增加用户、删除用户、转发消息,都会使用到在线用户列表。而这些函数是可能被同时调用的,所以,需要保证临界资源的安全。

// UserManger用于管理所有在线用户,对用户消息进行路由 
class UserManger
{
public:UserManger(){}// 新增在线用户void AddUser(InetAddr& id){LockGuard lockguard(_mutex);// 遍历在线列表,只有当在线列表中没有这个用户时,才新增for(auto& user : _online_user){if(*user == id){LOG(LogLevel::INFO) << id.Addr() << "用户已存在";return ;}}LOG(LogLevel::INFO) << " 新增该用户: " << id.Addr();_online_user.push_back(std::make_shared<User>(id));}// 删除在线用户void DelUser(InetAddr& in){}// 将消息转发给所有在线用户void Router(int sockfd, const std::string& message){LockGuard lockguard(_mutex);for(auto& user : _online_user){user->SendTo(sockfd, message);}}~UserManger(){}
private:std::list<std::shared_ptr<UserInterface>> _online_user;Mutex _mutex;
};

2. 打印接收到的消息时,将发送者的IP地址和端口号也顺便打印出来

void Start()
{_isrunning = true;while (true){char inbuffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &len);if (n > 0){// 我们除了要打印出消息内容,还要打印出客户端的IP地址和端口号// 客户端的发的消息内容在inbuffer中,客户端的IP地址和端口号在peer中InetAddr cli(peer);inbuffer[n] = '\0'; // 需要适配C/C++的接口std::string message = cli.Addr() + "# " + inbuffer;// 新增用户_adduser(cli);// 构建转发任务,推送给线程池,让线程池进行转发task_t task = std::bind(UdpServer::_route, _sockfd, message);ThreadPool<task_t>::getInstance()->Equeue(task);}}_isrunning = false;
}

此时是有问题的,当我们将客户端退出,重新启动后端口号是会发生变化的。因为服务器绑定的端口号并不是由我们自己指定的。所以,若只使用IP地址来标识一个客户端是不行的,此时会导致客户端退出后,再登录时,可以向服务端发消息,但是已经接收不到服务端发送回来的消息了。因为服务端是向老的端口号发送的。所以,应该使用IP地址和端口号共同标识一个客户端。

bool operator==(const InetAddr& addr)
{return _ip == addr._ip && _port == addr._port;
}

3.上面的代码是客户端启动后,要发一条消息才是上线,启动客户端到发消息期间,其他人发的消息是收不到的,应该要一启动客户端就能收到消息才对。此时只需要在客户端启动时,给服务器推送一条特定的消息即可。

int sockfd = -1;// 接收服务端发过来的消息
void *Recver(void* args)
{while(true){// 接收来自服务端返回的消息struct sockaddr_in temp;socklen_t len = sizeof(temp);char buffer[1024];int n = ::recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, CONV(&temp), &len);if(n > 0){buffer[n] = '\0';std::cerr << buffer << std::endl;}}
}// ./client_udp serverip serverport
int main(int argc, char* argv[])
{if(argc != 3){std::cerr << "Usage: " << argv[0] << " serverip serverport" << std::endl;exit(USAGE_ERR);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);// 创建套接字sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(sockfd < 0){std::cerr << "socket error" << std::endl;exit(SOCKET_ERR);}// 初始化客户端完成,开始通信// 填充服务端的套接字信息struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = ::htons(serverport);server.sin_addr.s_addr = ::inet_addr(serverip.c_str());pthread_t tid;pthread_create(&tid, nullptr, Recver, nullptr);// 客户端启动时,给服务器推送一条消息const std::string online = "...启动";int n = ::sendto(sockfd, online.c_str(), online.size(), 0, CONV(&server), sizeof(server));// 通信while(true){// 向服务端发送消息std::cout << "Please Enter# ";std::string message;std::getline(std::cin, message);int n = ::sendto(sockfd, message.c_str(), message.size(), 0, CONV(&server), sizeof(server));}return 0;
}

4.当客户端退出时,最好告诉所有人,并将信息从在线列表中删除

对于退出,可以定义一条特定的消息,如"quit",我们直接使用ctrl+c,并对信号进行处理。

int sockfd = -1;
struct sockaddr_in server;// 接收服务端发过来的消息
void *Recver(void* args)
{while(true){// 接收来自服务端返回的消息struct sockaddr_in temp;socklen_t len = sizeof(temp);char buffer[1024];int n = ::recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, CONV(&temp), &len);if(n > 0){buffer[n] = '\0';std::cerr << buffer << std::endl;}}
}void ClientQuit(int signo)
{const std::string quit = "QUIT";int n = ::sendto(sockfd, quit.c_str(), quit.size(), 0, CONV(&server), sizeof(server));exit(0);
}// ./client_udp serverip serverport
int main(int argc, char* argv[])
{if(argc != 3){std::cerr << "Usage: " << argv[0] << " serverip serverport" << std::endl;exit(USAGE_ERR);}signal(2, ClientQuit);std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);// 创建套接字sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(sockfd < 0){std::cerr << "socket error" << std::endl;exit(SOCKET_ERR);}// 初始化客户端完成,开始通信// 填充服务端的套接字信息// struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = ::htons(serverport);server.sin_addr.s_addr = ::inet_addr(serverip.c_str());pthread_t tid;pthread_create(&tid, nullptr, Recver, nullptr);// 客户端启动时,给服务器推送一条消息const std::string online = "...启动";int n = ::sendto(sockfd, online.c_str(), online.size(), 0, CONV(&server), sizeof(server));// 通信while(true){// 向服务端发送消息std::cout << "Please Enter# ";std::string message;std::getline(std::cin, message);int n = ::sendto(sockfd, message.c_str(), message.size(), 0, CONV(&server), sizeof(server));}return 0;
}

当我们在服务端输入ctrl + c时,让客户端给服务端发送一个特定的消息"QUIT",服务端只需要对这个特定的消息进行处理即可,同时退出客户端。我们先要完成UserManger类中的删除用户的函数

// 删除在线用户
void DelUser(InetAddr& in)
{auto pos = std::remove_if(_online_user.begin(), _online_user.end(), [&in](std::shared_ptr<UserInterface>& user){return *user == in;});_online_user.erase(pos, _online_user.end());
}
using task_t = std::function<void ()>; // 给线程池的任务
using adduser_t = std::function<void (InetAddr& id)>; // 新增用户的包装器
using route_t = std::function<void (int sockfd, const std::string& message)>; // 将消息转发给所有在线用户的包装器
using remove_t = std::function<void (InetAddr& id)>; // 删除在线用户const static int gsockfd = -1;
// const static std::string gdefaultip = "127.0.0.1"; // 本地主机的IP地址
const static uint16_t gdefaultport = 8080;class UdpServer
{
public:UdpServer(uint16_t port = gdefaultport):_sockfd(gsockfd),_addr(port),_isrunning(false){}// 向服务器注册服务void RegisterService(adduser_t adduser, route_t route, remove_t removeuser){_adduser = adduser;_route = route;_removeuser = removeuser;}void InitServer(){// 1. 创建socket_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if(_sockfd < 0){LOG(LogLevel::FATAL) << "socket: " << strerror(errno);Die(USAGE_ERR);}LOG(LogLevel::INFO) << "socket success, sockfd is: " << _sockfd;// 2.2 绑定int n = ::bind(_sockfd, _addr.NetAddr(), _addr.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "bind: " << strerror(errno);Die(SOCKET_ERR);}LOG(LogLevel::INFO) << "bind success";}void Start(){_isrunning = true;while(true){char inbuffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t n = ::recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &len);if(n > 0){// 我们除了要打印出消息内容,还要打印出客户端的IP地址和端口号// 客户端的发的消息内容在inbuffer中,客户端的IP地址和端口号在peer中InetAddr cli(peer);inbuffer[n] = '\0'; // 需要适配C/C++的接口std::string message;if(strcmp(inbuffer, "QUIT") == 0){// 移除观察者_removeuser(cli);message = cli.Addr() + "# " + "我走了,你们聊!";}else{// 新增用户_adduser(cli);message = cli.Addr() + "# " + inbuffer;}// 构建转发任务,推送给线程池,让线程池进行转发task_t task = std::bind(UdpServer::_route, _sockfd, message);ThreadPool<task_t>::getInstance()->Equeue(task);}}_isrunning = false;}~UdpServer(){if(_sockfd > gsockfd)::close(_sockfd);}
private:int _sockfd;     // 套接字// uint16_t _port;  // 服务器未来的端口号// std::string _ip; // 服务器所对应的IP地址InetAddr _addr;bool _isrunning; // 服务器运行状态// 新增用户adduser_t _adduser;route_t _route;remove_t _removeuser;
};
// ./server_udp localport
int main(int argc, char* argv[])
{if(argc != 2){std::cerr << "Usage: " << argv[0] << " localport" << std::endl;Die(USAGE_ERR);}uint16_t port = std::stoi(argv[1]);ENABLE_CONSOLE_LOG();// 用户管理模块std::shared_ptr<UserManger> um = std::make_shared<UserManger>();// 网络模块std::unique_ptr<UdpServer> svr_uptr = std::make_unique<UdpServer>(port);svr_uptr->RegisterService([&um](InetAddr& id){um->AddUser(id);},[&um](int sockfd, const std::string& message){um->Router(sockfd, message);},[&um](InetAddr& id){um->DelUser(id);});svr_uptr->InitServer();svr_uptr->Start();return 0;
}

5. 服务器一般是不想被拷贝的。可以将拷贝构造等设置成私有。也可以定义一个基类,让这个基类不允许拷贝,然后让服务器类去继承他

class nocopy
{
public:nocopy() {}nocopy(const nocopy &) = delete;const nocopy& operator=(const nocopy &) = delete;~nocopy() {}
};class UdpServer : public nocopy
{// ...
}

因为当前还没有讲任何协议,所以我们只能将发送的消息当成字符串。

聊天实际上就是观察者模型,我发消息,会将消息推送给所有的在线用户,在线用户就是关心这条消息的人,所有的在线用户,包括我,都是观察者,所谓观察者就是观察这个群聊里面有没有消息

消息转发的本质就是一个生产者消费者模型,因为线程池就是生产者消费者模型。我们之前说过,生产者不能只关注放入数据,还要关注数据如何来的;消费者不能只关注获取数据,还要关注如何处理数据。可以将消息转发,就一定可以将消息放入数据库。此时就需要再写一个类,继承Userlnterface,实现Userlnterface里面的方法,这些方法就写成访问数据库的,以观察者的形式,让其入观察者列表,这就是代码的可扩展性。

在这个聊天室中:

生产者:

  • 读取数据,获取任务
  • 将任务放入任务队列

消费者:

  • 从任务队列中获取任务
  • 基于观察者模式,将消息推送给所有人
http://www.lryc.cn/news/581556.html

相关文章:

  • 儿童趣味记忆配对游戏
  • 【CSS-15】深入理解CSS transition-duration:掌握过渡动画的时长控制
  • Java的各种各样的生命周期——思考历程
  • 字符函数和字符串函数(下)- 暴力匹配算法
  • ASP.NET Web Pages 安装使用教程
  • 随机森林算法详解:Bagging思想的代表算法
  • 【大模型入门】访问GPT_API实战案例
  • 8.2.1+8.2.2插入排序
  • 企业智脑:智能营销新纪元——自动化品牌建设与智能化营销的技术革命
  • 【Linux操作系统 | 第12篇】Linux磁盘分区
  • Dubbo 3.x源码(31)—Dubbo消息的编码解码
  • 我的LeetCode刷题指南:链表部分
  • 微服务基础:Spring Cloud Alibaba 组件有哪些?
  • 云原生 Serverless 架构下的智能弹性伸缩与成本优化实践
  • java easyExce 动态表头列数不固定
  • vue3 当前页面方法暴露
  • 0704-0706上海,又聚上了
  • 《前端路由重构:解锁多语言交互的底层逻辑》
  • 【Zotero】Zotero无法正常启动解决方案
  • 深度解析命令模式:将请求封装为对象的设计智慧
  • Flink ClickHouse 连接器数据写入源码深度解析
  • Gin Web 层集成 Viper 配置文件和 Zap 日志文件指南(下)
  • LoRaWAN的设备类型有哪几种?
  • 条件渲染 v-show与v-if
  • CICD[软件安装]:ubuntu安装jenkins
  • QtConcurrent入门
  • #渗透测试#批量漏洞挖掘#HSC Mailinspector 任意文件读取漏洞(CVE-2024-34470)
  • 2025.7.6总结
  • 智能网盘检测软件,一键识别失效链接
  • ipmitool 使用简介(ipmitool sel list ipmitool sensor list)