目录
- UDP网络编程
- [一、V1 - 服务器](#一、V1 - 服务器)
-
- [1.1 完善服务器类的`Init`接口](#1.1 完善服务器类的
Init接口) - [1.2 完善服务器类的Start接口](#1.2 完善服务器类的Start接口)
- [1.3 完善客户端](#1.3 完善客户端)
- [1.4 添加客户端的IP和port信息并测试跨网络通信](#1.4 添加客户端的IP和port信息并测试跨网络通信)
- [1.5 netstat](#1.5 netstat)
- [1.1 完善服务器类的`Init`接口](#1.1 完善服务器类的
- [二、V2 - 翻译功能服务器](#二、V2 - 翻译功能服务器)
-
- [2.1 字典翻译层](#2.1 字典翻译层)
- [2.2 添加字典类](#2.2 添加字典类)
- [2.3 测试](#2.3 测试)
- [三、V3 - 服务器接入线程池](#三、V3 - 服务器接入线程池)
-
- [3.1 描述客户端信息](#3.1 描述客户端信息)
- [3.2 管理用户信息](#3.2 管理用户信息)
- [3.3 路由模块](#3.3 路由模块)
- [3.4 完善服务器](#3.4 完善服务器)
- [3.5 使用服务器注册服务连接线程池模块和路由模块](#3.5 使用服务器注册服务连接线程池模块和路由模块)
- [3.6 完善客户端](#3.6 完善客户端)
- [3.7 代码调用逻辑](#3.7 代码调用逻辑)

个人主页:矢望
个人专栏:C++、Linux系统编程、Linux网络编程、C语言、数据结构、Coze-AI、MySQL
UDP网络编程
一、V1 - 服务器
网络编程需要有客户端和服务端,还要有服务器类 。

Makefile:
bash
.PHONY:all
all:server_udp client_udp
server_udp:EchoServerMain.cc
g++ -o $@ $^ -std=c++11
client_udp:EchoClient.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf server_udp client_udp
服务器类在使用的时候,希望是有一个Init接口和Start接口,既然是服务器将来肯定是一个死循环。
服务器类:
cpp
class UdpServer
{
public:
UdpServer()
{}
void Init()
{}
void Start()
{}
~UdpServer()
{}
private:
};
1.1 完善服务器类的Init接口
第一步先要进行网络通信肯定先要创建Socket,这一步的本质就是打开网卡,属于系统特性。
创建Socket的接口是socket函数 :

| 参数 | 可选值(常用) | 作用(一句话) |
|---|---|---|
domain |
AF_INET(IPv4) AF_INET6(IPv6) AF_UNIX(本地进程通信) |
指定通信协议族,决定IP地址的格式和长度 |
type |
SOCK_STREAM(流式) SOCK_DGRAM(数据报) SOCK_RAW(原始套接字) |
指定通信语义 ,STREAM对应TCP(可靠有序),DGRAM对应UDP(不可靠无序) |
protocol |
0(让系统自动匹配) IPPROTO_TCP(强制TCP) IPPROTO_UDP(强制UDP) |
指定具体传输协议 ,通常填0由内核根据domain+type自动推导 |

如上图,这个函数的返回值是一个文件描述符表示创建成功,-1表示失败。
将来在网络通信的时候,第一个参数通常传AF_INET,第二个参数通常是SOCK_STREAM(字节流)、SOCK_DGRAM(数据报),第三个参数通常是0。使用的时候基本只有第二个参数会变一下,其它参数基本不变。
初步完善Init接口:
cpp
const static int defaultfd = -1;
enum
{
SUCCESS,
SOCKET_ERR,
};
class UdpServer
{
public:
UdpServer()
:_sockfd(defaultfd)
{}
void Init()
{
// 1、创建 socket, 本质是打开网卡 -- 系统特性
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
// 创建失败
LOG(LogLevel::FATAL) << "create socket fail!";
exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
}
// ...
private:
int _sockfd; // 文件描述符
};
如上,创建socket的代码完成了,由于要打印消息,所以把之前博客写过的日志模块引入进来了。
创建出socket之后,服务器需要有自己的ip和端口号,所以就需要这两个变量,ip为了让方便用户查看,使用string类型存储点分十进制形式的ip地址。
cpp
const static int defaultfd = -1;
const static uint16_t defaultport = 8888;
class UdpServer
{
public:
UdpServer(const std::string &ip, uint16_t port = defaultport)
: _sockfd(defaultfd)
, _ip(ip)
, _port(port)
{}
// ...
private:
int _sockfd; // 文件描述符
std::string _ip; // ip 地址
uint16_t _port; // 端口号
};
如上,服务器端的端口号必须是固定的,否则客户端可能就找不到服务端了。
编写EchoServerMain.cc测试,将来使用的时候希望用户传递ip和端口号,所以就是./server ip port。
cpp
static void Usage(const std::string &process)
{
std::cerr << "Usage:\n\t";
std::cerr << process << " server_ip server_port" << std::endl;
}
// ./server_udp ip port
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(10);
}
SET_CONSOLE_LOG_STRATEGY(); // 使用日志的控制台策略
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(server_ip, server_port);
usvr->Init();
usvr->Start();
return 0;
}
由于日志模块使用了C++17的内容,所以编译时就需要加-std=c++17选项。
编译运行:

创建好socket之后,第二步就是填充网络信息 ,也就是创建struct sockaddr_in结构体并填充信息。
使用struct sockaddr_in结构体,需要包含下面的三个头文件:

这个结构体专门用来存储一个网络通信端点的地址信息,即IP地址和端口号。

如上图,解析完成之后就是这样:
cpp
struct sockaddr_in {
sa_family_t sin_family; /* 地址族,通常是 AF_INET */
in_port_t sin_port; /* 端口号,网络字节序 */
struct in_addr sin_addr; /* IPv4 地址 */
unsigned char sin_zero[8]; /* 填充字节,使结构体大小与 sockaddr 一致 */
};
cpp
void Init()
{
// 1、创建 socket, 本质是打开网卡 -- 系统特性
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
// 创建失败
LOG(LogLevel::FATAL) << "create socket fail!";
exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
// 2、填充网络信息
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()); // ip
}
如上,bzero函数的作用是对指定内存区域全部清零,在<strings.h>头文件中 。
填充端口号时需要保证主机转网络h->n,而填充ip时需要做两件事,第一件是将字符串点分十进制风格的ip转化成四字节的ip地址,第二件是将ip由主机转网络h->n。有一个函数将ip转化的两件事都做了,就是inet_addr函数in_addr_t inet_addr(const char *cp);。
如果想要手动转化,可以通过位段法和移位法实现。
虽然现在填充了网络信息,但是并没有将ip和端口号信息设置到内存中。
所以第三步绑定socket信息,即填充ip和port到内核结构中 。


如上图,成功返回0,失败返回-1。
cpp
void Init()
{
// 1、创建 socket, 本质是打开网卡 -- 系统特性
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
// 创建失败
LOG(LogLevel::FATAL) << "create socket fail!";
exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
// 2、填充网络信息
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()); // ip
// 3、绑定 socket 信息
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind socket error!";
exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "bind socket success, ip: " << _ip << ", port: " << _port;
}
如上,这样就初步完善了初始化Init接口。
1.2 完善服务器类的Start接口
接下来就是启动服务器 ,服务器启动起来就是一个死循环,将来进行网络通信时,用户会给我们发消息,所以我们需要使用函数去接收它。这个接收函数叫做recvfrom 。


如上图,flags标志为0表示阻塞等待,标志为MSG_DONTWAIT表示非阻塞,通常设为0。后两个参数是输出型参数,用于获取对方的套接字信息,方便之后向对方发送数据。
返回值:成功返回实际接收到的字节数,失败返回 -1 并设置 errno;若连接被对方关闭返回 0。
cpp
void Start()
{
// 假设字符串传递
char inbuffer[1024];
while(true)
{
struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
socklen_t len = sizeof(peer);
// 获取用户发来的数据和socket信息
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer), 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
inbuffer[n] = '\0';
LOG(LogLevel::DEBUG) << "client say# " << inbuffer;
}
}
}
如上,这样就成功获取了用户发来的数据,但身为echo server(回声服务器),我们要把客户端发来的数据再给客户端发过去。
此时就需要另一个函数了,sendto。


如上,通过fd向客户端进行数据发送。
返回值:成功返回实际发送的字节数,失败返回 -1 并设置 errno。
cpp
void Start()
{
// 假设字符串传递
char inbuffer[1024];
while(true)
{
struct sockaddr peer; // 定义结构体变量,存储对方的socket信息
socklen_t len = sizeof(peer);
// 获取用户发来的数据和socket信息
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
inbuffer[n] = '\0';
LOG(LogLevel::DEBUG) << "client say# " << inbuffer;
std::string echo_str = "echo say# ";
echo_str += inbuffer;
// 向客户端发送数据
sendto(_sockfd, echo_str.c_str(), echo_str.size(), 0, (struct sockaddr*)&peer, len);
}
else
{
LOG(LogLevel::ERROR) << "recvfrom error";
}
}
}
如上,为什么在Init接口中填充网络信息时需要进行主机到网络的转换,而在这个再次发送的时候不需要呢?因为通过recvfrom获取用户的socket信息时,是从网络中获取的,即它就是网络序列(大端),所以再发送到网络的时候,直接使用就可以了。
析构函数:
cpp
~UdpServer()
{
close(_sockfd);
}
如上,服务端基本初步完善了,接下来就是客户端。
1.3 完善客户端
客户端要进行网络通信,首先也是需要创建socket。
cpp
static void Usage(const std::string &process)
{
std::cerr << "Usage:\n\t";
std::cerr << process << " server_ip server_port" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(3);
}
// 1、创建socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "create socket error!" << std::endl;
exit(2);
}
return 0;
}
如上图,由于客户端将来需要和服务端进行通信,所以它需要传递服务端的ip和port。客户端是如何知道服务端的ip和port呢?这个很简单,本来就知道,同一家公司产出的客户端一定要能够找到同一家公司的服务端,类似ip和port是被内置到客户端的。
创建好了socket,接下来是显示填充网络信息并绑定socket信息吗?并不是,客户端确实需要有自己的ip和port信息,但是不需要且不建议自己显示填充并绑定socket信息 。
如果每个公司的客户端都显示绑定固定端口的话,一个手机或电脑系统上的端口是有限的(0~65535),在这有限的端口中,如果两个客户端app的固定绑定端口一样,并且它们两个是敌对公司的,那么这家公司的客户端app运行起来之后,那一家客户端app在这台手机或者电脑上就运行不起来了,因为一个端口只能标识一个进程。
所以显示绑定会出现冲突问题,客户端的port只需要具有唯一性即可,具体多少不重要。那么如何设置客户端的ip和port呢?客户端一般会采用随机端口的方式,由操作系统自主选择!在客户端首次向服务端发送数据时,操作系统底层就会隐式自动帮客户端获取随机端口,然后绑定ip和port。
服务器的ip和port必须显示绑定,且不能随便改,不显示绑定或者改变了,客户端就找不到它了。
所以客户端创建好socket之后,就可以直接进行网络通信了!
cpp
// 1、创建socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "create socket error!" << std::endl;
exit(2);
}
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
struct sockaddr_in server; // 服务器 socket
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port); // 端口
server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // ip
while(true)
{
std::string message;
// 1、获取用户输入
std::cout << "Please Enter# ";
std::getline(std::cin, message);
// 2、client 发送数据给 server,首次发送即自动绑定ip和port
ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
if(n > 0)
{
// 发送成功,接收服务端返回的数据
char inbuffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t m = recvfrom(sockfd, &inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&temp, &len);
if(m > 0)
{
// 接收成功
inbuffer[m] = '\0';
std::cout << inbuffer << std::endl;
}
}
}
如上,想要给服务器发消息,首先需要填充服务器端的socket信息,然后发送消息。发送成功之后,接收服务器返回回来的消息,并打印。
编译运行:

上图中的127.0.0.1是本地环回IP,绑定这个IP之后,报文不会发送到网络中,只会在自己的协议栈中跑一次,它通常用于本地通信或者网络代码的测试。
但是我们不想只在当前主机测试,想要的是网络通信,接下来,我们绑定一下云服务器的公网ip试一试。

如上图,绑定出错了。
注意 :云服务器的公网IP是禁止被显示bind的,但这不等于云服务器的公网IP不会被使用。
在服务器开发的时候,不建议服务器显示bind IP地址,一台主机上会有很多的IP地址,有无线的、有线的等等,一旦服务器显示bind了IP地址,它就只能接收或发送自己bind的IP的报文了,其它IP上收到的报文,服务器就接收不到了 !
一台主机上将来会有三种IP:127.0.0.1、局域网IP、公网IP 。
所以最佳实践:bind任意地址,这样就可以收取所有IP上的报文了。
修改服务器的bind策略,改为绑定任意地址:
cpp
void Init()
{
// 1、创建 socket, 本质是打开网卡 -- 系统特性
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
// 创建失败
LOG(LogLevel::FATAL) << "create socket fail!";
exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
// 2、填充网络信息
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; // 最佳实践:bind 任意地址
// local.sin_addr.s_addr = inet_addr(_ip.c_str()); // ip
// 3、绑定 socket 信息
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind socket error!";
exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "bind socket success, ip: " << _ip << ", port: " << _port;
}
首先先使用之前的方案运行测试一下:

如上图,在服务器显示bind127.0.0.1的情况下,它只能收到来自这个ip发来的报文,其它的ip收到的报文,服务器是收不到的。
再换成任意地址绑定试一试,此时就把服务器类代码涉及到的ip成员变量去掉了,不需要它,Main.cc的代码也进行了修改。

如上,报文全部都可以收到了。所以云服务器公网的IP禁止被显示bind,但是客户端可以通过公网IP向服务器通信,也就是公网IP可以被使用。
如果你的云服务器主机客户端不能通过公网IP向服务端通信,请检查云服务器的安全组入方向规则有没有相关端口协议的信息,如果没有就添加上,因为云厂商的安全组默认拦截了所有入站流量。
1.4 添加客户端的IP和port信息并测试跨网络通信
添加信息很简单,因为recvfrom函数的输出型参数将客户端的socket信息带出来了,将信息提取出来就好。
port信息只需要网络转主机n->h,而ip信息需要先网络转主机n->h,然后4字节转成点分十进制信息。ip的这两步工作inet_ntoa已经做了。

cpp
void Start()
{
// 假设字符串传递
char inbuffer[1024];
while(true)
{
struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
socklen_t len = sizeof(peer);
// 获取用户发来的数据和socket信息
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
uint16_t client_port = ntohs(peer.sin_port);
std::string client_ip = inet_ntoa(peer.sin_addr);
// 客户端信息
std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]say# ";
inbuffer[n] = '\0';
LOG(LogLevel::DEBUG) << client_address << inbuffer;
std::string echo_str = "echo say# ";
echo_str += inbuffer;
// 向客户端发送数据
sendto(_sockfd, echo_str.c_str(), echo_str.size(), 0, (struct sockaddr*)&peer, len);
}
else
{
LOG(LogLevel::ERROR) << "recvfrom error";
}
}
}
编译之后发Windows一份,进行Windows、Linux跨网络通信:

如上图,成功进行了Windows和Linux跨网络通信!
不止如此,如果有一个客户端向服务端发消息,那么服务端会得到一个用户的socket信息,如果有100个,服务端就会得到100个socket信息,将来如果一个用户向服务端发消息,服务端可以选择向所有用户都转发一份,这样就实现了群聊的功能!如果一个用户向服务端发的消息带有目标客户端,服务器可以将消息转发给特定的客户端,这样就完成了私聊的功能!
1.5 netstat
netstat 是 Linux 下用于查看网络连接、路由表、接口统计等信息的经典工具。
| 选项 | 作用 |
|---|---|
-a (all) |
显示所有监听 和非监听的套接字(默认只显示非监听) |
-l (listening) |
仅显示正在监听的套接字(服务端常用) |
-n (numeric) |
以数字形式显示地址和端口(不解析域名和服务名,显示更快) |
-p (program) |
显示进程 PID 和程序名 (需要 root 权限) |
-t (tcp) |
仅显示 TCP 套接字 |
-u (udp) |
仅显示 UDP 套接字 |
-r (route) |
显示路由表 (等同于 route -n) |
-i (interfaces) |
显示网络接口统计信息 |
-s (statistics) |
显示各协议(IP/TCP/UDP/ICMP)的统计信息(收发包计数、错误等) |
二、V2 - 翻译功能服务器
上面我们的服务器代码写好了,但是服务器的工作就是网络通信的IO工作,服务器从网络中获取的数据应该交给上层处理,然后上层将处理完成的数据再交给服务器进行网络通信。
所以在服务器类内部将来会有一个回调接口回调上层的方法,然后获取处理好的数据,服务器再进行发送。
2.1 字典翻译层
将来这个服务器可以充当单词检索翻译功能,由客户端输入数据,服务器接收并交给业务处理层也就是翻译层翻译单词,并返回处理结果,由服务器将结果给客户端返回。因此回调函数的返回值和参数类型都是std::string类型。
cpp
using callback_t = std::function<std::string (std::string)>;
const static int defaultfd = -1;
const static uint16_t defaultport = 8888;
class UdpServer
{
public:
// UdpServer(const std::string &ip, uint16_t port = defaultport)
UdpServer(callback_t cb, uint16_t port = defaultport)
: _sockfd(defaultfd)
, _port(port)
, _cb(cb)
{}
// ...
private:
int _sockfd; // 文件描述符
// std::string _ip; // ip 地址,不需要,bind 任意地址
uint16_t _port; // 端口号
callback_t _cb; // 回调函数,将数据交给上层处理
};
如上,给服务器类添加回调函数。
修改服务器类的数据处理逻辑:
cpp
void Start()
{
// 假设字符串传递
char inbuffer[1024];
while(true)
{
struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
socklen_t len = sizeof(peer);
// 获取用户发来的数据和socket信息
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
uint16_t client_port = ntohs(peer.sin_port);
std::string client_ip = inet_ntoa(peer.sin_addr);
// 客户端信息
std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]say# ";
inbuffer[n] = '\0'; // 用户发来的数据
std::string result = _cb(inbuffer); // 将数据交给上层处理,并存储处理结果
// 向客户端发送处理好的数据
sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr*)&peer, len);
}
else
{
LOG(LogLevel::ERROR) << "recvfrom error";
}
}
}
如上,将收到的数据交给上层处理,服务端将处理好的数据发送给客户端。
2.2 添加字典类
cpp
#include <iostream>
#include <string>
#include <unordered_map>
#include <fstream>
#include "Logger.hpp"
using namespace NS_LOG_MODULE;
static const std::string default_dictpath = "./dict.txt"; // 字典文件路径
static const std::string sep = ": "; // 分隔符
class Dict
{
public:
// 初始化字典路径,自动加载字典
Dict(const std::string &path = default_dictpath)
: _dict_path(path)
{
DictLoad(); // 加载字典库
}
void DictLoad() // 从文件加载字典到内存
{
std::ifstream in(_dict_path); // 打开字典文件
if(!in.is_open()) // 判断文件是否打开成功
{
LOG(LogLevel::FATAL) << "open " << _dict_path << " error!";
exit(2);
}
std::string line;
while(std::getline(in, line)) // 逐行读取文件
{
auto pos = line.find(sep); // 查找分隔符位置
if(pos == std::string::npos) // 找不到分隔符
{
LOG(LogLevel::WARNING) << "format " << line << " error!";
continue;
}
LOG(LogLevel::INFO) << "load " << line << " success!";
// 分隔
std::string k = line.substr(0, pos);
std::string v = line.substr(pos + sep.size());
_dict.insert(std::make_pair(k, v)); // 插入哈希表
}
in.close(); // 关闭文件
LOG(LogLevel::INFO) << "Load Done...";
}
std::string TranslateWord(std::string word) // 翻译接口
{
if(_dict.count(word)) return _dict[word];
else return "Unknown";
}
private:
std::string _dict_path; // 记录字典文件路径
std::unordered_map<std::string, std::string> _dict; // 字典哈希 <单词, 翻译>
};
如上就是字典类的完整实现,字典文件如下截图:

2.3 测试
cpp
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(10);
}
SET_CONSOLE_LOG_STRATEGY(); // 使用日志的控制台策略
// 1、定义字典
Dict dict;
// 2、构建网络服务处理 IO 问题
uint16_t server_port = std::stoi(argv[1]);
// 3、绑定业务处理层和服务器层
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>([&dict](std::string word)->std::string{
return dict.TranslateWord(word);
} ,server_port);
usvr->Init();
usvr->Start();
return 0;
}
编译运行:

如上,客户端输入单词数据,服务器接收单词数据并交给单词翻译层处理,服务器再将处理完成的数据交给客户端,上图中客户端收到了数据!
三、V3 - 服务器接入线程池
引入线程池 ,现在的代码结构是这样的:

接下来,线程池的数据将从网络中来!
计划写一个聊天服务器,服务端将客户端的socket信息管理起来,然后收到客户端发的数据之后,将数据发给线程池,由线程池负责完成将数据转发给所有客户端的任务。
3.1 描述客户端信息
我们将来要将客户端信息管理起来,所以先写一个类来描述客户端。
cpp
class InetAddr
{
public:
InetAddr()
{}
InetAddr(const struct sockaddr_in &address)
: _address(address)
, _len(sizeof(_address))
{
_ip = inet_ntoa(_address.sin_addr); // 网络转主机 && 4字节转字符串点分十进制
_port = ntohs(_address.sin_port); // 网络主机
}
InetAddr(uint16_t port, const std::string &ip = "0.0.0.0")
: _ip(ip)
, _port(port)
{
bzero(&_address, sizeof(_address));
_address.sin_family = AF_INET;
_address.sin_addr.s_addr = inet_addr(ip.c_str()); // 主机转网络 && 字符串点分十进制转4字节
_address.sin_port = htons(port); // 主机转网络
_len = sizeof(_address);
}
struct sockaddr_in* GetNetAddr()
{
return &_address;
}
socklen_t GetLen()
{
return _len;
}
~InetAddr()
{}
private:
// net address
struct sockaddr_in _address;
socklen_t _len;
// host address
std::string _ip;
uint16_t _port;
};
如上,需要存储网络socket信息以及主机ip和port。于是准备了多个构造函数。ip为0.0.0.0代表bind`任意地址。
还实现了获取字节大小和套接字结构体地址的接口。
有了描述网络信息的类,接下来就可以修改服务器里面的一些代码了。
在服务器的初始化Init接口当中,第二步做的工作是填充服务端的网络信息,有了描述相关信息的类,再进行这些工作的时候就不需要那么臃肿了。
cpp
void Init()
{
// 1、创建 socket, 本质是打开网卡 -- 系统特性
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
// 创建失败
LOG(LogLevel::FATAL) << "create socket fail!";
exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
// 2、填充网络信息
InetAddr local(_port); // 填充服务器的网络信息
// 3、绑定 socket 信息
int n = bind(_sockfd, (struct sockaddr*)local.GetNetAddr(), local.GetLen());
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind socket error!";
exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "bind socket success, port: " << _port;
}
如上,直接使用InetAddr类的构造函数就填充好网络信息了,并且ip默认是0.0.0.0就是bind任意ip地址。在第三步绑定socket信息时,也调用类里面的接口。
3.2 管理用户信息
现在有了网络信息描述类,还需要将它们使用数据结构管理起来。我会将它再封装成一个用户管理类,这个类主要就是对用户信息进行增删查改。
cpp
class UserManager
{
public:
UserManager()
{}
void AddUser(const InetAddr &addr) // 增
{
if(SearchUser(addr))
return;
_users.push_back(addr);
}
void DelUser(const InetAddr &addr) // 删
{
for(auto iter = _users.begin(); iter != _users.end(); iter++)
{
if(*iter == addr)
{
_users.erase(iter);
break;
}
}
}
bool SearchUser(const InetAddr &addr) // 查
{
for(auto &user : _users)
{
if(user == addr)
{
return true;
}
}
return false;
}
void ModUser(const InetAddr &addr) // 改
{
DelUser(addr);
AddUser(addr);
}
~UserManager()
{}
private:
std::vector<InetAddr> _users;
};
如上,使用vector容器组织用户信息,在实现增删查改的时候用到了InetAddr类的==比较,所以在类中重载了方法。
cpp
bool operator==(const InetAddr &addr)
{
return (this->_ip == addr._ip) && (this->_port == addr._port);
}
3.3 路由模块
现在可以通过服务器获取数据,并且对用户信息也进行了管理,作为这个聊天服务器,接下来需要有一个模块做数据转发给用户的工作,也就是路由模块。
cpp
class Route
{
public:
Route()
:_uma(std::make_unique<UserManager>())
{}
void User_ADD(const InetAddr &addr) // 增
{
LockGuard lockguard(_lock);
_uma->AddUser(addr);
}
void OfflineUser(const InetAddr &addr) // 删
{
LockGuard lockguard(_lock);
_uma->DelUser(addr);
}
void BroadCast(int sockfd, const std::string message) // 服务器广播消息给所有用户
{
LockGuard lockguard(_lock);
auto users = _uma->Users();
for(auto user : users)
{
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)user.GetNetAddr(), user.GetLen());
}
}
~Route()
{}
private:
std::unique_ptr<UserManager> _uma;
Mutex _lock;
};
如上,路由模块也很简单,它连接了服务器和用户,可以将服务器收到的消息广播给用户。这个类也有增删用户的接口,只是复用了UserManager类的方法。这个模块可能被多个用户同时访问,所以可能会产生并发问题,因此添加互斥锁保护。
上面的广播函数的第一个参数将来是服务器的文件描述符,因为服务器收到的数据,将来也是通过服务端和客户端通信。
上面由于发送消息需要遍历用户列表,所以在UserManager类中添加了获取列表的接口Users:
cpp
std::vector<InetAddr>& Users()
{
return _users;
}
3.4 完善服务器
服务器启动之后,获取数据之后,接下来要做的就是检测当前通信的客户端是不是新用户,如果是就添加用户信息,然后进行转发信息的逻辑。
但是服务器只管IO,其余的工作交给其它层,所以服务器就需要注册其它层的服务,它需要UserManager类的新增用户的方法,需要Route类中的广播消息方法。
cpp
using handler_addr_t = std::function<void (const InetAddr&)>; // UserManager的新增用户接口
using handler_msg_t = std::function<void (int, std::string)>; // Route的广播消息接口
class UdpServer
{
public:
// ...
// 注册服务的接口
void RegisterService(handler_addr_t handler_addr, handler_msg_t handler_msg)
{
_handler_addr = handler_addr;
_handler_msg = handler_msg;
}
void Start()
{
// 假设字符串传递
char inbuffer[1024];
while(true)
{
struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
socklen_t len = sizeof(peer);
// 获取用户发来的数据和socket信息
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
inbuffer[n] = '\0';
// 1、检测新用户
InetAddr clientaddr(peer);
_handler_addr(clientaddr); // 回调UserManager函数检测注册新用户
// 2、转发消息
_handler_msg(_sockfd, inbuffer); // 回调Route函数广播消息
}
else
{
LOG(LogLevel::ERROR) << "recvfrom error";
}
}
}
// ...
private:
int _sockfd; // 文件描述符
// std::string _ip; // ip 地址,不需要,bind 任意地址
uint16_t _port; // 端口号
// 回调函数,将数据交给上层处理
handler_addr_t _handler_addr;
handler_msg_t _handler_msg;
};
如上,服务器提供了注册服务的方法,并将需要完成的工作交给回调函数处理,这样降低了耦合度。
3.5 使用服务器注册服务连接线程池模块和路由模块
cpp
using namespace NS_THREAD_POOL_MODULE;
using task_t = std::function<void ()>; // 线程池要完成的任务类型
// ./server_udp port
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(10);
}
SET_CONSOLE_LOG_STRATEGY(); // 使用日志的控制台策略
// 构建网络服务处理 IO 问题
uint16_t server_port = std::stoi(argv[1]);
// 线程池模块
auto thread_pool = ThreadPool<task_t>::Instance(); // 获取线程池单例对象
// 路由模块
Route r;
// 网络模块 - 服务器
UdpServer usvr(server_port);
usvr.Init(); // 初始化服务器
// 向服务器注册服务
usvr.RegisterService(
// ...);
usvr.Start(); // 启动服务器
return 0;
}
如上,向服务器注册服务的代码我没有写,因为它有几种写法。
第一种:
cpp
usvr.RegisterService(
// 注册UserManager的检测注册新用户函数
[&r](const InetAddr &addr) -> void {
r.User_ADD(addr);
},
// 注册Route的广播消息函数
[&r](int sockfd, std::string message) -> void {
r.BroadCast(sockfd, message);
});
如上,如果这样写,它也能运行,但是它没有用到线程池模块,只用到了路由模块,相当于单进程执行服务,单进程广播。
第二、三种:
cpp
usvr.RegisterService(
// 注册UserManager的检测注册新用户函数
[&r](const InetAddr &addr) -> void {
r.User_ADD(addr);
},
// 注册Route的广播消息函数
[&r, &thread_pool](int sockfd, std::string message) -> void {
// 给线程池设置任务类型
thread_pool->Enqueue([&r, &sockfd, &message](){
r.BroadCast(sockfd, message);
});
});
usvr.RegisterService(
// 注册UserManager的检测注册新用户函数
[&r](const InetAddr &addr) -> void {
r.User_ADD(addr);
},
// 注册Route的广播消息函数
[&r, &thread_pool](int sockfd, std::string message) -> void {
// 给线程池设置任务类型
auto task = std::bind(&Route::BroadCast, &r, sockfd, message);
thread_pool->Enqueue(task);
});
上面的两种写法都是一样的。
编译运行:

如上客户端收到了服务端的群发消息。
再试一下多个客户端的情况:

如上,两个客户端确实互相收到了对方发送的消息,但是由于客户端的收发逻辑是,每次成功发送才收一次数据,这就导致即使有很多客户端向服务端发消息,然后服务端就会向所有客户端广播消息,导致客户端的内核缓冲区积攒了很多的消息,但是由于客户端发送一次数据才接收一次数据,所以导致了读到的是之前的旧数据。
3.6 完善客户端
客户端现在是只有写一次才能读一次,这个逻辑是有缺陷的,所以也要把客户端多线程化。
基本框架:
cpp
using namespace NS_THREAD_MODULE;
static void Usage(const std::string &process)
{
std::cerr << "Usage:\n\t";
std::cerr << process << " server_ip server_port" << std::endl;
}
int sockfd;
std::string server_ip;
uint16_t server_port;
void SendMessage() // 发送数据
{}
void RecvMessage() // 接收数据
{}
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(3);
}
// 服务器端 ip + port
server_ip = argv[1];
server_port = std::stoi(argv[2]);
// 1、创建socket
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "create socket error!" << std::endl;
exit(2);
}
Thread Recver(RecvMessage); // 线程1:接收数据
Thread Sender(SendMessage); // 线程2:发送数据
Recver.Start();
Sender.Start();
Recver.Join();
Sender.Join();
return 0;
}
使用到了封装的线程类,其中一个线程执行接收数据函数,另一个线程执行发送数据函数。
完善接收和发送接口:
cpp
static void Online(InetAddr &serveraddr) // 用户上线通知消息
{
std::cout << "Please Set Your Nick Name# ";
std::getline(std::cin, nickname);
std::string online_message = nickname + "online!";
ssize_t n = sendto(sockfd, online_message.c_str(), online_message.size(), 0,
(struct sockaddr*)serveraddr.GetNetAddr(), serveraddr.GetLen());
(void)n;
}
void SendMessage() // 发送数据
{
// 填充服务器端网络信息
InetAddr server(server_port, server_ip);
Online(server); // 上线消息
while(true)
{
std::string message;
// 1、获取用户输入
std::cout << "Please Enter# ";
std::getline(std::cin, message);
// 设置用户名标识
message = nickname + "# " + message;
// 2、client 发送数据给 server,首次发送即自动绑定ip和port
ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0,
(struct sockaddr*)server.GetNetAddr(), server.GetLen());
(void)n;
}
}
void RecvMessage() // 接收数据
{
while(true)
{
char inbuffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t m = recvfrom(sockfd, &inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&temp, &len);
if(m > 0)
{
// 接收成功
inbuffer[m] = '\0';
std::cerr << inbuffer << std::endl; // 信息分离,交给标准错误
}
}
}
上面的收到的数据,打印时打印到了标准错误中,而这样就可以信息分流了。在客户端第一次发送消息时给了用户上线消息通知。
由于Online接口中使用ip:port区分用户,所以在InetAddr类中添加了ToString接口:
cpp
std::string ToString()
{
return "[" + _ip + ":" + std::to_string(_port) + "]";
}
用户名重名的很多,所以我们可以使用ip+port区分用户消息,客户端的SendMessage接口是向服务端发消息,所以客户端的带名称的信息最终会发给服务端,服务端同时会收到客户端的Socket信息,此时就获取到了客户端的ip和port信息 。
客户端也可以自己获取,如果客户端自己获取之后,它获取的是自己在自己局域网的ip和port,并不是在公网中的ip和port,这样与服务器看到的客户端ip和port不一致,所以依旧无法区分是哪个用户在发消息。
在服务器广播转发收到的消息中添加客户端的ip和port信息 。
UdpServer:
cpp
void Start()
{
// 假设字符串传递
char inbuffer[1024];
while(true)
{
struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
socklen_t len = sizeof(peer);
// 获取用户发来的数据和socket信息
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
inbuffer[n] = '\0';
// 1、检测新用户
InetAddr clientaddr(peer);
_handler_addr(clientaddr); // 回调UserManager函数检测注册新用户
// 2、转发消息
std::string tips = clientaddr.ToString(); // 新增
std::string message = tips + inbuffer; // tips + inbuffer(nickname + 用户消息)
_handler_msg(_sockfd, message); // 回调Route函数广播消息
}
else
{
LOG(LogLevel::ERROR) << "recvfrom error";
}
}
}
由于需要用到ip和port信息,所以在InetAddr类中添加了ToString接口:
cpp
std::string ToString()
{
return "[" + _ip + ":" + std::to_string(_port) + "]";
}
编译运行:

如上,首先创建了两个管道文件,然后让其中一个客户端的标准错误信息交给fifo1,另一个客户端的标准错误信息交给fifo2,因为标准错误信息就是客户端收到的服务器广播转发的消息。
成功完成简单聊天室的编写。
3.7 代码调用逻辑
整个代码的调用逻辑:
服务端:

客户端:

消息示例:
cpp
客户端A(nickname="Alice") 发送 "hello"
→ 服务端收到: "Alice# hello"
→ 服务端拼接地址: "[10.0.0.1:12345]Alice# hello"
→ 广播给所有在线用户
代码调用逻辑图:

总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~