开篇介绍:
hello 大家,本篇博客,我们继续进一步加强我们对UDP网络通信的学习,实现一个简单的聊天室,就像是qq/wechat群一样。
在正式拆解代码前,咱们先明确核心目标:实现一个支持多客户端同时在线聊天的系统,客户端发送消息后,服务端能将消息转发给所有在线客户端;客户端输入"QUIT"可退出聊天室。整个实现基于UDP协议,搭配C++封装思想和线程池优化,兼顾易用性与性能。
一、基础概念铺垫:这些知识点必须先搞懂
在看代码前,咱们先扫清基础障碍。很多新手卡壳不是因为代码难,而是对底层概念不熟悉。这部分内容都是"入门级",没有超纲内容,放心食用。
1.1 为什么选UDP做聊天室?UDP协议核心特点
提到网络通信,大家可能先想到TCP(比如浏览网页、发邮件)。但咱们的聊天室却选了UDP,这是为什么?先搞懂UDP和TCP的核心差异:
| 特性 | UDP(用户数据报协议) | TCP(传输控制协议) | 聊天室场景适配性 |
|---|---|---|---|
| 连接性 | 无连接(发送数据前不用建立连接) | 面向连接(需三次握手建立连接) | 适配:客户端随时进出,无需维护连接状态 |
| 可靠性 | 不可靠(不保证数据送达、不排序、不重传) | 可靠(有确认、重传、排序、流量控制) | 适配:聊天消息数据量小,偶尔丢包无感知(大不了重发) |
| 开销 | 低(头部仅8字节,无需维护连接状态) | 高(头部20字节,需维护连接、处理确认包) | 适配:服务端可同时处理大量客户端,资源占用少 |
| 数据边界 | 保留边界(发一次对应收一次,不粘包) | 不保留边界(流式协议,可能粘包/拆包) | 适配:"一条消息"是天然边界,无需额外处理粘包 |
总结:聊天室的核心需求是"轻量、高效、支持多客户端",UDP的"无连接、低开销"特性完美匹配。虽然UDP不可靠,但对于"短消息、可容忍偶尔丢包"的场景,完全够用。这也是很多即时通讯工具(比如早期QQ)核心场景用UDP的原因。
1.2 Socket编程基础:什么是Socket?
Socket(套接字)是Linux中"网络通信的入口",本质是内核中的一个"文件描述符"(和咱们读文件的fd一样)。咱们通过Socket函数创建这个"入口",然后通过它发送/接收数据。
核心认知:所有网络通信,最终都是"进程通过Socket和内核交互"。比如客户端发消息,不是直接发给服务端,而是先写到内核的发送缓冲区,内核再通过网卡发送;服务端收消息,也是内核先从网卡读数据到接收缓冲区,进程再从缓冲区读取。
UDP的Socket类型是SOCK_DGRAM,创建Socket的函数原型:
cpp
int socket(int domain, int type, int protocol);
// domain:地址族,AF_INET表示IPv4
// type:Socket类型,SOCK_DGRAM表示UDP
// protocol:协议,0表示默认(由domain+type自动匹配)
// 返回值:成功返回Socket文件描述符(正数),失败返回-1
1.3 字节序问题:为什么要用到htons/ntohs?
这是新手最容易懵的点,咱们用"通俗例子"讲懂:
- 字节序定义:不同CPU存储多字节数据的顺序不同。比如数字0x1234(十进制4660),存储时:
-
小端序(大部分PC机,比如Intel CPU):低字节存低地址 → 0x34 0x12
-
大端序(网络字节序标准):高字节存低地址 → 0x12 0x34
-
问题核心:客户端和服务端可能用不同CPU(比如客户端是Intel小端,服务端是ARM大端),如果直接传数据,会出现"数据错乱"。比如客户端发端口8080(0x1F90),小端存储是0x90 0x1F,服务端按大端解析就成了0x901F(十进制36895),完全错了!
-
解决方案:网络中所有数据都用"大端序"传输。需要用到4个核心函数:
-
htons:host to network short → 16位(端口)主机序转网络序
-
htonl:host to network long → 32位(IP)主机序转网络序
-
ntohs:network to host short → 16位(端口)网络序转主机序
-
ntohl:network to host long → 32位(IP)网络序转主机序
记住:端口是16位(0-65535),用htons/ntohs;IP是32位(比如192.168.1.100),用htonl/ntohl。后面代码里会频繁用到,现在先有个印象。
1.4 地址结构体:sockaddr_in是什么?
网络通信需要"地址标识"(比如服务端IP+端口,客户端IP+端口),Linux用struct sockaddr_in结构体存储IPv4地址信息,原型简化如下:
cpp
struct sockaddr_in {
sa_family_t sin_family; // 地址族,必须是AF_INET(IPv4)
in_port_t sin_port; // 端口号,必须是网络字节序(用htons转换)
struct in_addr sin_addr; // IP地址结构体,存储32位IP(网络字节序)
char sin_zero[8];// 填充字段,必须置0
};
// IP地址结构体(sin_addr的定义)
struct in_addr {
in_addr_t s_addr; // 32位IP地址(网络字节序)
};
核心注意点:
-
sin_family必须设为AF_INET,否则内核无法识别地址类型;
-
sin_port必须用htons转换为网络字节序(大端);
-
sin_addr.s_addr:如果是服务端,设为INADDR_ANY(表示绑定所有网卡IP,即0.0.0.0);如果是客户端,设为目标服务端的IP(需用htonl或inet_pton转换)。
1.5 线程安全:为什么需要加锁?
咱们的聊天室服务端用了线程池,多个线程可能同时操作"客户端列表"(比如一个线程在遍历列表转发消息,另一个线程在删除退出的客户端)。这就会出现"线程安全问题"。
通俗例子:你和朋友同时操作一个Excel表格(客户端列表),你在遍历表格(转发消息),朋友突然删除了一行(客户端退出),你可能会读到"空行"或"错乱数据"。
解决方案:用"互斥锁(Mutex)"保护共享资源(比如客户端列表)。加锁后,同一时间只有一个线程能操作共享资源,其他线程必须等待锁释放。后面代码里的LockGuard就是"自动上锁/解锁"的封装,核心是RAII机制(资源获取即初始化),避免忘记解锁导致死锁。
1.6 线程池:为什么要用它?
服务端的核心逻辑是"接收客户端消息→转发给所有客户端"。如果用单线程处理,当一个客户端发送消息后,服务端在转发消息的过程中,无法接收其他客户端的消息(会阻塞在转发步骤)。
线程池的作用:主线程只负责"接收消息",收到消息后把"转发任务"交给线程池,主线程立刻回到"接收消息"状态。这样即使有多个客户端同时发消息,也能高效处理,不会阻塞。
咱们的代码用了"单例模式线程池"(整个程序只有一个线程池实例),不用手动创建多个线程池,避免资源浪费。
二、核心模块拆解:从"描述客户端"到"消息转发"
整个聊天室项目的代码结构很清晰,遵循"先描述,再组织"的设计思想:
-
InetAddr类:描述客户端(封装IP+端口,处理字节序转换);
-
RouteChat类:组织客户端(管理客户端列表,实现消息转发);
-
UdpServer类:UDP服务端核心(创建Socket、绑定端口、接收消息、回调处理);
-
服务端主函数:初始化线程池、创建服务端、启动服务;
-
客户端主函数:双线程设计(发送消息+接收消息)。
咱们逐个模块拆解,每个代码行都讲透。
2.1 模块1:InetAddr类------客户端地址的"优雅封装"
核心作用:封装struct sockaddr_in结构体,解决两个问题:① 字节序转换(网络序↔主机序);② 线程安全的IP转换(替代非线程安全的inet_ntoa函数)。
先看完整代码,再逐行解析:
cpp
#pragma once
#include<iostream>
#include <string>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstring>
#include "Log.hpp"
using namespace LogModule;
class InetAddr
{
public:
// 构造函数1:网络序→主机序(接收sockaddr_in,转换IP和端口)
InetAddr(const struct sockaddr_in& inetaddr)
:_inetaddr(inetaddr)
{
_port=ntohs(_inetaddr.sin_port); // 端口:网络序→主机序
// IP转换:用inet_ntop(线程安全)替代inet_ntoa(非线程安全)
char buff[100];
const char* ret_inet_ntop=inet_pton(AF_INET,&(_inetaddr.sin_addr),buff,sizeof(buff));
if(ret_inet_ntop==nullptr)
{
LOG(LogLevel::FATAL)<<"inet_ntop ip failed";
}
_ip=buff; // 转换后的IP字符串存入_ip
}
// 构造函数2:主机序→网络序(接收IP和端口,填充sockaddr_in)
InetAddr(std::string ip,int port)
{
bzero(&_inetaddr,sizeof(_inetaddr)); // 清空结构体,避免垃圾数据
_inetaddr.sin_family=AF_INET; // IPv4地址族
_inetaddr.sin_port=htons(port); // 端口:主机序→网络序
// IP转换:用inet_pton(线程安全)替代inet_addr(老旧函数)
int ret_inet_pton=inet_pton(AF_INET,ip.c_str(),&(_inetaddr.sin_addr));
if(ret_inet_pton==-1)
{
LOG(LogLevel::FATAL)<<"inet_pton ip failed";
}
_ip=ip; // 存储主机序IP
_port=port; // 存储主机序端口
}
~InetAddr(){}
// 获取主机序IP
std::string GetIp() const
{
return _ip;
}
// 获取主机序端口
int GetPort() const
{
return _port;
}
// 获取IP:端口字符串(比如"127.0.0.1:8080")
std::string StringAddr() const
{
return _ip + ":" + std::to_string(_port);
}
// 重载==:判断两个客户端是否是同一个(IP和端口都相同)
bool operator==(const InetAddr& client) const
{
return this->_ip==client.GetIp() && this->_port==client.GetPort();
}
// 获取原生sockaddr_in结构体(用于sendto/recvfrom)
struct sockaddr_in& GetInetAddr()
{
return _inetaddr;
}
// 获取sockaddr_in结构体大小(用于sendto/recvfrom参数)
socklen_t InetAddrLen()
{
return sizeof(_inetaddr);
}
private:
struct sockaddr_in _inetaddr; // 原生地址结构体(网络序)
std::string _ip; // 主机序IP字符串
int _port; // 主机序端口号
};
2.1.1 核心设计思路:为什么要封装这个类?
新手可能会问:直接用struct sockaddr_in不行吗?为什么要多此一举封装成类?原因有3个:
-
解决线程安全问题:原生的inet_ntoa函数会把IP存储在静态缓冲区,多线程同时调用会覆盖数据(比如线程1转IP为127.0.0.1,线程2转IP为192.168.1.100,线程1可能读到192.168.1.100)。而inet_ntop函数用自定义缓冲区(buff100),不会有这个问题。
-
简化操作:每次转换IP/端口都要写一堆代码(比如inet_ntop+错误处理),封装成类后,调用GetIp()、GetPort()就能直接获取主机序的IP和端口,不用重复写转换代码。
-
支持客户端比较:重载==运算符后,判断"两个客户端是不是同一个"只需
client1 == client2,不用手动比较IP和端口。
2.1.2 构造函数解析:两种场景,两种转换
InetAddr有两个构造函数,对应"两种字节序转换场景",这是类的核心:
场景1:服务端接收客户端消息(网络序→主机序)
服务端用recvfrom函数接收消息时,会得到客户端的struct sockaddr_in(里面的IP和端口是网络序)。此时用第一个构造函数:
cpp
InetAddr(const struct sockaddr_in& inetaddr)
:_inetaddr(inetaddr) // 初始化列表:直接赋值原生结构体
{
_port=ntohs(_inetaddr.sin_port); // 端口:网络序→主机序
// IP转换:inet_ntop(网络序二进制IP → 主机序字符串IP)
char buff[100];
const char* ret_inet_ntop=inet_ntop(AF_INET,&(_inetaddr.sin_addr),buff,sizeof(buff));
if(ret_inet_ntop==nullptr)
{
LOG(LogLevel::FATAL)<<"inet_ntop ip failed";
}
_ip=buff; // 存储转换后的字符串IP
}
关键细节:
-
inet_ntop函数参数:AF_INET(IPv4)、&_inetaddr.sin_addr(网络序二进制IP)、buff(输出缓冲区)、sizeof(buff)(缓冲区大小);
-
错误处理:如果转换失败(返回nullptr),打印FATAL日志(致命错误,程序会终止);
-
初始化列表:比在函数体内赋值更高效(直接初始化成员变量,避免二次赋值)。
场景2:客户端发送消息给服务端(主机序→网络序)
客户端知道服务端的"字符串IP"(比如"127.0.0.1")和"主机序端口"(比如8080),需要转换为网络序才能发送。此时用第二个构造函数:
cpp
InetAddr(std::string ip,int port)
{
bzero(&_inetaddr,sizeof(_inetaddr)); // 清空结构体(必须做,避免垃圾数据)
_inetaddr.sin_family=AF_INET; // IPv4地址族
_inetaddr.sin_port=htons(port); // 端口:主机序→网络序
// IP转换:inet_pton(主机序字符串IP → 网络序二进制IP)
int ret_inet_pton=inet_pton(AF_INET,ip.c_str(),&(_inetaddr.sin_addr));
if(ret_inet_pton==-1)
{
LOG(LogLevel::FATAL)<<"inet_pton ip failed";
}
_ip=ip; // 存储主机序IP
_port=port; // 存储主机序端口
}
关键细节:
-
bzero函数:把
_inetaddr结构体所有字节置0,避免残留垃圾数据影响后续操作; -
inet_pton函数返回值:1表示成功,0表示IP格式错误(比如"192.168.1.256"),-1表示其他错误(比如地址族不支持);
-
_ip和_port存储的是主机序,方便后续调用GetIp()、GetPort()获取直观值。
2.1.3 成员函数解析:实用接口,简化开发
类的公有成员函数都是"实用接口",不用重复写冗余代码:
1. GetIp()和GetPort()
cpp
std::string GetIp() const { return _ip; }
int GetPort() const { return _port; }
加const的原因:函数不修改成员变量,加const后,const对象也能调用(增大函数使用范围)。比如:
cpp
const InetAddr client(inetaddr);
client.GetIp(); // 加了const才能调用,否则编译报错
2. StringAddr()
cpp
std::string StringAddr() const
{
return _ip + ":" + std::to_string(_port);
}
作用:返回"IP:端口"格式的字符串(比如"127.0.0.1:8080"),用于日志打印或消息拼接(比如客户端发送的消息要带上自己的IP和端口)。
3. operator==重载
cpp
bool operator==(const InetAddr& client) const
{
return this->_ip==client.GetIp() && this->_port==client.GetPort();
}
核心用途:判断两个客户端是不是同一个。比如服务端要删除退出的客户端,需要遍历客户端列表,用if(client == 要删除的客户端)判断。
注意:不能直接访问client的私有成员(_ip、_port),必须用GetIp()、GetPort()接口(封装性原则)。
4. GetInetAddr()和InetAddrLen()
cpp
struct sockaddr_in& GetInetAddr() { return _inetaddr; }
socklen_t InetAddrLen() { return sizeof(_inetaddr); }
作用:返回原生的struct sockaddr_in结构体和它的大小,用于sendto/recvfrom函数(这两个函数需要原生地址结构体作为参数)。比如服务端转发消息时,需要调用sendto,此时用:
cpp
sendto(sockfd, message.c_str(), message.size(), 0,
(struct sockaddr*)&client.GetInetAddr(), client.InetAddrLen());
2.1.4 私有成员变量:清晰的职责划分
cpp
private:
struct sockaddr_in _inetaddr; // 原生地址结构体(网络序)
std::string _ip; // 主机序IP字符串
int _port; // 主机序端口号
职责划分很清晰:_inetaddr存储原生网络序数据(用于底层通信),_ip和_port存储主机序数据(用于上层逻辑,比如打印、比较)。这样分离后,上层逻辑不用关心字节序,底层通信不用关心字符串格式,代码更清晰。
2.2 模块2:RouteChat类------消息转发与客户端管理核心
核心作用:实现聊天室的"核心业务逻辑"------① 管理在线客户端列表(添加、删除、判断是否存在);② 接收客户端消息后,转发给所有在线客户端;③ 处理客户端退出(输入QUIT)。
完整代码:
cpp
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include "InetAddr.hpp"
#include "Log.hpp"
#include "Mutex.hpp"
using namespace MutexModule;
using namespace LogModule;
class RouteChat
{
private:
// 辅助函数:判断客户端是否已在列表中
bool Exist(const InetAddr& user) const
{
for(auto& client:_clients)
{
if(user==client)
{
return true;
}
}
return false;
}
// 辅助函数:添加客户端到列表
void AddClient(const InetAddr& client)
{
LOG(LogLevel::INFO) << "新增一个在线用户: " << client.StringAddr();
_clients.push_back(client);
}
// 辅助函数:从列表删除客户端
void DeleteClient(const InetAddr& client)
{
for(std::vector<InetAddr>::iterator it=_clients.begin();it<_clients.end();++it)
{
if(client==*it)
{
LOG(LogLevel::INFO) << "删除一个在线用户:" << client.StringAddr() << "成功";
_clients.erase(it);
break; // 找到后删除,终止循环(避免迭代器失效)
}
}
}
public:
RouteChat(){}
~RouteChat(){}
// 核心函数:接收消息后转发给所有在线客户端
void SendMessageToAll(int socketfd,const std::string& message,InetAddr& client)
{
LockGuard mutex(_mutex); // 上锁:保护客户端列表(线程安全)
// 步骤1:如果客户端不在列表,添加进去(首次发送消息即"进群")
if(!Exist(client))
{
AddClient(client);
}
// 步骤2:拼接消息(格式:IP:端口# 消息内容)
std::string send_message = client.StringAddr() + "# " + message;
// 步骤3:遍历客户端列表,转发消息给每个客户端
for(auto& _client:_clients)
{
int ret_sendto=sendto(socketfd,send_message.c_str(),send_message.size(),0,
(struct sockaddr*)&(_client.GetInetAddr()),_client.InetAddrLen());
if(ret_sendto<0)
{
LOG(LogLevel::ERROR)<<"sendto"<<_client.StringAddr()<<"failed!";
}
}
// 步骤4:如果消息是QUIT,删除客户端(退出聊天室)
if(message=="QUIT")
{
DeleteClient(client);
}
}
private:
std::vector<InetAddr> _clients; // 存储在线客户端列表
Mutex _mutex; // 互斥锁:保护_clients,避免线程安全问题
};
2.2.1 设计思路:为什么要单独封装这个类?
RouteChat类是"业务逻辑层",和"网络层"(UdpServer类)完全解耦。这样设计的好处:
-
代码复用:如果后续要做"UDP天气查询服务",只需替换RouteChat类,UdpServer类(网络层)不用改;
-
逻辑清晰:网络层只负责"收发消息",业务层只负责"处理消息",不会出现"网络代码和业务代码混在一起"的混乱情况;
-
便于维护:后续要修改消息转发逻辑(比如支持昵称、过滤敏感词),只需改RouteChat类,不用动UdpServer类。
2.2.2 私有辅助函数:客户端管理的"工具人"
Exist、AddClient、DeleteClient是私有辅助函数,只在类内部使用,职责单一:
1. Exist函数:判断客户端是否在线
cpp
bool Exist(const InetAddr& user) const
{
for(auto& client:_clients)
{
if(user==client) // 调用InetAddr的operator==重载
{
return true;
}
}
return false;
}
作用:遍历客户端列表(_clients),判断当前客户端是否已在列表中(即是否已在线)。首次发送消息的客户端会被添加到列表(相当于"进群")。
2. AddClient函数:添加客户端进群
cpp
void AddClient(const InetAddr& client)
{
LOG(LogLevel::INFO) << "新增一个在线用户: " << client.StringAddr();
_clients.push_back(client);
}
作用:将新客户端添加到列表,同时打印INFO日志(方便调试,知道谁进群了)。
3. DeleteClient函数:删除客户端退群
cpp
void DeleteClient(const InetAddr& client)
{
for(std::vector<InetAddr>::iterator it=_clients.begin();it<_clients.end();++it)
{
if(client==*it)
{
LOG(LogLevel::INFO) << "删除一个在线用户:" << client.StringAddr() << "成功";
_clients.erase(it);
break; // 必须break!避免迭代器失效
}
}
}
关键细节:erase(it)后,迭代器it会失效(vector的erase会删除元素并调整后续元素位置),所以必须break终止循环,否则继续++it会导致程序崩溃。
2.2.3 核心函数:SendMessageToAll------消息转发的"灵魂"
这是RouteChat类的核心函数,服务端收到客户端消息后,会回调这个函数实现"群发"。咱们逐行解析:
cpp
void SendMessageToAll(int socketfd,const std::string& message,InetAddr& client)
{
LockGuard mutex(_mutex); // 步骤0:上锁,保护_clients(线程安全)
// 步骤1:新客户端进群(首次发送消息)
if(!Exist(client))
{
AddClient(client);
}
// 步骤2:拼接消息(格式:IP:端口# 消息内容)
std::string send_message = client.StringAddr() + "# " + message;
// 步骤3:遍历客户端列表,转发消息给每个客户端
for(auto& _client:_clients)
{
int ret_sendto=sendto(socketfd,send_message.c_str(),send_message.size(),0,
(struct sockaddr*)&(_client.GetInetAddr()),_client.InetAddrLen());
if(ret_sendto<0)
{
LOG(LogLevel::ERROR)<<"sendto"<<_client.StringAddr()<<"failed!";
}
}
// 步骤4:处理客户端退出(消息为QUIT)
if(message=="QUIT")
{
DeleteClient(client);
}
}
步骤0:上锁------线程安全的关键
LockGuard mutex(_mutex);
LockGuard是互斥锁的RAII封装,作用:
-
构造LockGuard对象时,自动调用_mutex.lock()上锁;
-
函数执行完(LockGuard对象析构)时,自动调用_mutex.unlock()解锁;
-
避免忘记解锁导致死锁(比如函数中间return,手动解锁会遗漏)。
为什么要上锁?因为多个线程可能同时调用SendMessageToAll(比如线程池的多个线程同时处理不同客户端的消息),多个线程同时操作_clients列表(遍历、添加、删除)会导致数据混乱。
步骤1:新客户端进群
如果客户端是首次发送消息(不在_clients列表中),调用AddClient添加到列表。这就是"自动进群"逻辑------客户端不用专门发送"进群"指令,发送任意消息即可进群。
步骤2:拼接消息格式
std::string send_message = client.StringAddr() + "# " + message;
消息格式示例:"127.0.0.1:54321# 大家好"。这样其他客户端收到消息后,能清楚知道是谁发的。
步骤3:遍历列表,转发消息
用for循环遍历_clients列表,对每个客户端调用sendto函数转发消息。sendto函数原型:
cpp
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数解析:
-
sockfd:服务端的Socket文件描述符(用于发送消息);
-
buf:要发送的消息缓冲区(send_message.c_str());
-
len:消息长度(send_message.size());
-
flags:0表示默认发送(阻塞发送);
-
dest_addr:目标客户端的原生地址结构体((struct sockaddr*)&_client.GetInetAddr());
-
addrlen:目标客户端地址结构体的大小(_client.InetAddrLen())。
错误处理:如果sendto失败(返回-1),打印ERROR日志(非致命错误,不会终止程序,只是该客户端收不到消息)。
步骤4:处理客户端退出
如果客户端发送的消息是"QUIT",调用DeleteClient从列表中删除该客户端(相当于"退群")。后续服务端不会再给该客户端转发消息。
2.2.4 私有成员变量:核心资源的封装
cpp
private:
std::vector<InetAddr> _clients; // 在线客户端列表
Mutex _mutex; // 互斥锁
为什么用vector存储客户端?vector支持动态扩容,遍历方便,对于聊天室这种"客户端数量不多"的场景完全够用。如果是高并发场景(比如上万客户端),可以换成unordered_set(查找效率O(1)),但咱们的案例是"简单聊天室",vector足够。
2.3 模块3:UdpServer类------UDP服务端的"骨架"
核心作用:实现UDP服务端的"网络层逻辑"------① 创建Socket;② 绑定端口;③ 循环接收客户端消息;④ 调用回调函数(RouteChat::SendMessageToAll)处理消息。
完整代码:
cpp
#pragma once
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include<arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <strings.h>
#include <functional>
#include "Log.hpp"
#include "InetAddr.hpp"
using func_t = std::function<void(int,const std::string&,InetAddr&)>;
const int defaultsocketfd=-1;// 默认无效的Socket文件描述符
using namespace LogModule;
class UdpServer
{
public:
// 禁用拷贝构造和赋值运算符(避免多个对象共享Socketfd)
UdpServer(const UdpServer&) = delete;
UdpServer& operator=(const UdpServer&) = delete;
// 构造函数:初始化端口、回调函数、Socketfd、运行状态
UdpServer(int port,func_t func)
:_port(port)
,_func(func)
,_socketfd(defaultsocketfd)
,_isrunning(false)
{
Use_Monitor_Log();// 日志输出到显示器
}
// 析构函数:关闭Socket,释放资源
~UdpServer()
{
if (_socketfd != defaultsocketfd)
{
close(_socketfd);
_socketfd = defaultsocketfd;
LOG(LogLevel::INFO) << "UDP server socket closed";
}
}
// 初始化服务端:创建Socket + 绑定端口
void InitUdpServer()
{
// 步骤1:创建UDP Socket
_socketfd=socket(AF_INET,SOCK_DGRAM,0);
if(_socketfd<0)
{
LOG(LogLevel::FATAL)<<"socket failed";
exit(2);
}
LOG(LogLevel::INFO)<<"socket create success, fd:"<<_socketfd;
// 步骤2:初始化服务端地址结构体
struct sockaddr_in sever;
bzero(&sever,sizeof(sever));
sever.sin_family=AF_INET;
sever.sin_addr.s_addr=htonl(INADDR_ANY); // 绑定所有网卡IP(0.0.0.0)
sever.sin_port=htons(_port); // 端口:主机序→网络序
// 步骤3:绑定Socket和地址(IP+端口)
int ret_bind=bind(_socketfd,(struct sockaddr*)&sever,sizeof(sever));
if(ret_bind<0)
{
LOG(LogLevel::FATAL)<<"bind failed";
close(_socketfd); // 绑定失败,释放已创建的Socket
_socketfd = defaultsocketfd;
exit(2);
}
LOG(LogLevel::INFO)<<"bind success!!!";
}
// 启动服务端:循环接收消息,回调处理
void StartUdpServer()
{
_isrunning=true;
while(_isrunning)
{
// 步骤1:接收客户端消息
char buff[1024]={0};
struct sockaddr_in client;
socklen_t clientsize=sizeof(client);
bzero(&client,clientsize);
int ret_recvfrom=recvfrom(_socketfd,buff,sizeof(buff)-1,0,(struct sockaddr*)&client,&clientsize);
if(ret_recvfrom<0)
{
LOG(LogLevel::INFO)<<"sever recvfrom failed!!!";
continue; // 接收失败,继续等待下一条消息
}
buff[ret_recvfrom]='\0'; // 手动添加字符串结束符(recvfrom不会自动加)
// 步骤2:封装客户端地址(网络序→主机序)
InetAddr inaddrclient(client);
// 步骤3:调用回调函数处理消息(转发消息)
_func(_socketfd,buff,inaddrclient);
}
}
private:
int _socketfd;// Socket文件描述符
int _port;// 服务端绑定的端口(主机序)
func_t _func;// 消息处理回调函数
bool _isrunning;// 服务端运行状态
};
2.3.1 核心设计:禁用拷贝构造和赋值运算符
cpp
UdpServer(const UdpServer&) = delete;
UdpServer& operator=(const UdpServer&) = delete;
这是新手容易忽略的点,必须讲透:
Socket文件描述符(_socketfd)是内核分配的整数(比如3),多个UdpServer对象如果共享同一个_socketfd,会出现严重问题:比如对象A关闭了_socketfd,对象B还以为_socketfd有效,继续用它调用sendto/recvfrom,会导致系统调用失败(文件描述符已失效);更严重的是,若两个对象同时操作_socketfd,会出现资源竞争(比如同时调用recvfrom,可能导致消息接收错乱)。
禁用拷贝构造和赋值运算符后,就只能创建一个UdpServer对象,避免了上述问题。这是网络编程中"资源唯一持有"的常用设计思路------Socket文件描述符这种核心资源,应该由一个对象唯一管理,确保资源的正确创建与释放。
2.3.2 InitUdpServer函数解析:服务端的"初始化流程"
InitUdpServer是服务端的初始化入口,负责完成"创建Socket→绑定端口"的核心操作,这是UDP服务端启动的必经步骤,咱们逐步骤拆解关键细节:
步骤1:创建UDP Socket------获取网络通信"入口"
cpp
_socketfd=socket(AF_INET,SOCK_DGRAM,0);
if(_socketfd<0)
{
LOG(LogLevel::FATAL)<<"socket failed";
exit(2);
}
LOG(LogLevel::INFO)<<"socket create success, fd:"<<_socketfd;
关键说明:
-
参数含义:AF_INET指定IPv4地址族,SOCK_DGRAM指定UDP类型,0表示默认协议(由地址族+类型自动匹配UDP协议);
-
错误处理:socket创建失败返回-1,此时打印FATAL级日志并退出程序(exit(2),退出码2表示初始化失败),因为Socket是核心资源,创建失败后续无法继续;
-
日志打印:创建成功后输出Socket文件描述符(fd),方便调试时确认资源是否分配成功(Linux中fd从3开始,0是标准输入、1标准输出、2标准错误)。
步骤2:初始化服务端地址结构体------明确"监听地址"
cpp
struct sockaddr_in sever;
bzero(&sever,sizeof(sever));
sever.sin_family=AF_INET;
sever.sin_addr.s_addr=htonl(INADDR_ANY); // 绑定所有网卡IP(0.0.0.0)
sever.sin_port=htons(_port); // 端口:主机序→网络序
核心细节:
-
bzero初始化:必须清空结构体,避免内存中残留的垃圾数据覆盖有效字段(比如sin_family被乱值覆盖,内核无法识别地址类型);
-
INADDR_ANY的作用:表示绑定当前主机的所有网卡IP(即0.0.0.0),比如主机有内网IP 192.168.1.100、外网IP 203.0.113.50,客户端无论通过哪个IP都能连接到服务端,这是服务端的常用配置;若只绑定某个特定IP(比如htonl(inet_addr("192.168.1.100"))),则只有通过该IP才能访问;
-
端口转换:_port是主机序(比如8080),必须用htons转换为网络序,否则客户端可能连接失败(字节序错乱)。
步骤3:绑定Socket与地址------"入口"与"地址"关联
cpp
int ret_bind=bind(_socketfd,(struct sockaddr*)&sever,sizeof(sever));
if(ret_bind<0)
{
LOG(LogLevel::FATAL)<<"bind failed";
close(_socketfd); // 绑定失败,释放已创建的Socket
_socketfd = defaultsocketfd;
exit(2);
}
LOG(LogLevel::INFO)<<"bind success!!!"
关键注意点:
-
类型转换:bind函数的第二个参数是struct sockaddr*类型(通用地址结构体),而我们用的是struct sockaddr_in(IPv4专用地址结构体),因此需要强制转换(这是Linux网络编程的标准写法);
-
失败后资源释放:若bind失败(比如端口已被占用、权限不足),必须关闭之前创建的_socketfd,避免资源泄漏(文件描述符是有限资源,不关闭会导致可用fd减少);
-
bind的核心作用:将创建的Socket文件描述符与"IP+端口"绑定,告诉内核"这个Socket专门处理发送到该IP+端口的UDP数据报"。
2.3.3 StartUdpServer函数解析:服务端的"核心循环"
InitUdpServer完成初始化后,调用StartUdpServer启动服务端,该函数是一个无限循环(直到_isrunning设为false),核心职责是"持续接收客户端消息→调用回调函数处理",是服务端的"心脏"。
cpp
void StartUdpServer()
{
_isrunning=true;
while(_isrunning)
{
// 步骤1:接收客户端消息
char buff[1024]={0};
struct sockaddr_in client;
socklen_t clientsize=sizeof(client);
bzero(&client,clientsize);
int ret_recvfrom=recvfrom(_socketfd,buff,sizeof(buff)-1,0,(struct sockaddr*)&client,&clientsize);
if(ret_recvfrom<0)
{
LOG(LogLevel::INFO)<<"sever recvfrom failed!!!";
continue; // 接收失败,继续等待下一条消息
}
buff[ret_recvfrom]='\0'; // 手动添加字符串结束符(recvfrom不会自动加)
// 步骤2:封装客户端地址(网络序→主机序)
InetAddr inaddrclient(client);
// 步骤3:调用回调函数处理消息(转发消息)
_func(_socketfd,buff,inaddrclient);
}
}
步骤1:接收客户端消息------recvfrom函数详解
recvfrom是UDP接收消息的核心函数,用于从_socketfd接收数据,并获取发送方(客户端)的地址信息,参数解析:
-
buff:接收数据的缓冲区(大小1024字节,足够存储聊天短消息);
-
sizeof(buff)-1:接收的最大字节数,留1个字节给后续手动添加'\0'(避免字符串越界);
-
flags:0表示默认接收方式(阻塞接收,即没有消息时会等待,直到有消息到来或出错);
-
client:用于存储发送方(客户端)的地址信息(网络序),是输出参数;
-
clientsize:输入输出参数,输入时表示client结构体的大小,输出时表示实际接收的地址信息大小;
-
返回值:成功返回接收的字节数,失败返回-1(比如Socket被关闭)。
关键细节:
-
手动添加'\0':recvfrom只负责接收字节流,不会自动给字符串添加结束符,若不手动添加,后续操作buff(比如拼接消息、打印)可能会出现乱码(读取到缓冲区后的垃圾数据);
-
接收失败处理:若recvfrom失败(比如被信号中断),打印日志后用continue继续循环,而不是退出程序(服务端需要具备容错性,不能因一次接收失败就停止服务)。
步骤2:封装客户端地址------对接InetAddr类
用接收得到的struct sockaddr_in(client,网络序)创建InetAddr对象(inaddrclient),通过InetAddr的构造函数自动完成"网络序→主机序"的转换,后续处理(比如打印客户端IP、调用回调函数)直接使用InetAddr的接口,无需关注字节序细节,体现了封装的优势。
步骤3:调用回调函数------网络层与业务层解耦
_func是UdpServer的成员变量(类型为std::function),在创建UdpServer对象时传入(比如传入RouteChat::SendMessageToAll)。调用_func时,将_socketfd(用于转发消息)、接收的消息(buff)、客户端地址(inaddrclient)传入,完成"消息处理"的委托。
这种"回调函数"设计的核心好处是解耦:UdpServer(网络层)只负责接收消息,不关心消息如何处理(是转发、存储还是其他业务);业务逻辑(比如消息转发)由回调函数实现(业务层),若后续需要修改业务(比如添加消息过滤),只需修改回调函数,无需改动UdpServer类。
循环退出条件
_isrunning是服务端的运行状态标志,默认设为true,循环持续运行;若需要停止服务端(比如处理SIGINT信号,即Ctrl+C),只需将_isrunning设为false,循环即可退出,服务端优雅停止。
2.3.4 私有成员变量解析:资源的"清晰封装"
cpp
private:
int _socketfd;// Socket文件描述符
int _port;// 服务端绑定的端口(主机序)
func_t _func;// 消息处理回调函数
bool _isrunning;// 服务端运行状态
成员变量职责清晰,均为服务端核心资源/状态,且设为私有(通过公有成员函数操作),符合封装原则:
-
_socketfd:唯一的Socket文件描述符,由UdpServer对象唯一管理,负责网络数据的收发;
-
_port:服务端绑定的端口(主机序),存储原始端口值,方便调试时查看;
-
_func:消息处理回调函数,实现网络层与业务层的解耦,是UdpServer类的"扩展点";
-
_isrunning:服务端运行状态标志,用于控制核心循环的启动与停止,支持优雅退出。
2.4 模块4:线程池模块------高并发处理的"核心引擎"
之前提到,服务端用线程池优化"消息转发"流程(主线程接收消息,线程池处理转发),避免单线程阻塞。本模块实现"单例模式线程池"(整个程序唯一实例),核心是"预创建线程→任务队列→线程取任务执行"的生产者-消费者模型。
完整代码(单例模式+RAII封装):
cpp
#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include <thread>
#include <functional>
#include <mutex>
#include <condition_variable>
#include <atomic>
#include "Log.hpp"
using namespace LogModule;
using task_t = std::function<void()>; // 任务类型:无参无返回值的函数对象
class ThreadPool
{
public:
// 禁用拷贝构造和赋值运算符(单例模式核心:避免多实例)
ThreadPool(const ThreadPool&) = delete;
ThreadPool& operator=(const ThreadPool&) = delete;
// 单例模式:获取唯一实例(懒汉模式,线程安全)
static ThreadPool& GetInstance()
{
static ThreadPool instance; // C++11后,局部静态变量初始化是线程安全的
return instance;
}
// 初始化线程池:指定线程数量
void Init(size_t thread_num = 4)
{
if (_is_running) return; // 避免重复初始化
_is_running = true;
_thread_num = thread_num;
// 预创建thread_num个线程,每个线程执行ThreadFunc函数
for (size_t i = 0; i < _thread_num; ++i)
{
_threads.emplace_back(&ThreadPool::ThreadFunc, this);
LOG(LogLevel::INFO) << "ThreadPool create thread: " << _threads.back().get_id();
}
}
// 向任务队列添加任务(生产者接口)
void AddTask(task_t task)
{
if (!_is_running) return;
std::unique_lock<std::mutex> lock(_mutex); // 上锁,保护任务队列
_task_queue.push(task);
_cond.notify_one(); // 唤醒一个等待的线程(消费者)
}
// 销毁线程池(优雅退出)
void Stop()
{
if (!_is_running) return;
_is_running = false;
_cond.notify_all(); // 唤醒所有等待的线程,让它们退出
// 等待所有线程执行完
for (auto& thread : _threads)
{
if (thread.joinable())
{
thread.join();
LOG(LogLevel::INFO) << "ThreadPool thread exit: " << thread.get_id();
}
}
// 清空资源
_threads.clear();
while (!_task_queue.empty())
{
_task_queue.pop();
}
LOG(LogLevel::INFO) << "ThreadPool stopped";
}
// 析构函数:确保线程池销毁
~ThreadPool()
{
Stop();
}
private:
// 私有构造函数(单例模式核心:禁止外部创建实例)
ThreadPool() : _is_running(false), _thread_num(0) {}
// 线程函数(消费者接口):循环从任务队列取任务执行
void ThreadFunc()
{
while (_is_running)
{
task_t task;
std::unique_lock<std::mutex> lock(_mutex);
// 等待条件:任务队列非空 或 线程池停止
_cond.wait(lock, [this]() { return !_is_running || !_task_queue.empty(); });
// 若线程池停止且任务队列为空,退出线程
if (!_is_running && _task_queue.empty()) break;
// 取任务并执行
task = _task_queue.front();
_task_queue.pop();
lock.unlock(); // 解锁,避免执行任务时占用锁(提高并发)
task(); // 执行任务(调用回调函数,比如RouteChat::SendMessageToAll)
}
}
private:
std::vector<std::thread> _threads; // 存储线程池中的线程
std::queue<task_t> _task_queue; // 任务队列(生产者-消费者模型的核心)
std::mutex _mutex; // 保护任务队列的互斥锁
std::condition_variable _cond; // 条件变量:实现线程等待/唤醒
std::atomic<bool> _is_running; // 线程池运行状态(原子变量,避免线程安全问题)
size_t _thread_num; // 线程数量
};
2.4.1 核心设计思路:单例模式+生产者-消费者模型
- 单例模式:
-
原因:线程池是全局资源,若创建多个实例,会导致线程数量过多、资源竞争(比如多个任务队列),因此用单例模式确保整个程序只有一个线程池实例;
-
实现方式:私有构造函数(禁止外部创建)、禁用拷贝构造和赋值运算符(避免拷贝)、静态GetInstance函数(返回唯一实例,C++11局部静态变量初始化线程安全)。
- 生产者-消费者模型:
-
生产者:UdpServer的主线程(接收消息后,将"转发消息"任务添加到任务队列);
-
消费者:线程池中的线程(循环从任务队列取任务执行,即调用RouteChat::SendMessageToAll);
-
核心组件:任务队列(存储待执行的任务)、互斥锁(保护任务队列,避免多线程同时操作)、条件变量(实现线程的等待/唤醒,避免线程空轮询浪费CPU)。
2.4.2 核心函数解析
1. GetInstance函数:获取单例实例
cpp
static ThreadPool& GetInstance()
{
static ThreadPool instance; // 局部静态变量,仅初始化一次
return instance;
}
C++11标准规定,局部静态变量的初始化是线程安全的(即多个线程同时调用GetInstance时,不会重复初始化instance),无需额外加锁,实现简洁高效。
2. Init函数:初始化线程池
指定线程数量(默认4个),预创建线程并让每个线程执行ThreadFunc函数(消费者函数)。预创建线程的好处是避免"收到消息时再创建线程"的开销(线程创建/销毁成本较高),提高响应速度。
3. AddTask函数:添加任务(生产者接口)
UdpServer主线程接收消息后,调用AddTask将"转发消息"任务(封装成task_t类型)添加到任务队列:
-
上锁:用std::unique_lock(可手动解锁的互斥锁)保护任务队列,避免多个线程同时添加任务导致队列错乱;
-
唤醒线程:添加任务后,用_cond.notify_one()唤醒一个等待的线程(若所有线程都在执行任务,唤醒后线程会立即取新任务;若有线程等待,唤醒后会执行任务)。
4. ThreadFunc函数:线程执行函数(消费者接口)
线程池中的每个线程都会循环执行该函数,核心逻辑是"等待任务→取任务→执行任务":
cpp
void ThreadFunc()
{
while (_is_running)
{
task_t task;
std::unique_lock<std::mutex> lock(_mutex);
// 等待条件:任务队列非空 或 线程池停止
_cond.wait(lock, [this]() { return !_is_running || !_task_queue.empty(); });
// 若线程池停止且任务队列为空,退出线程
if (!_is_running && _task_queue.empty()) break;
// 取任务并执行
task = _task_queue.front();
_task_queue.pop();
lock.unlock(); // 解锁,避免执行任务时占用锁
task(); // 执行任务(比如转发消息)
}
}
关键细节:
-
条件变量等待:_cond.wait(lock, pred)会先判断pred(条件),若不满足则释放锁并阻塞线程;若满足或被唤醒,则持有锁继续执行。这里的条件是"线程池停止"或"任务队列非空",确保线程只在有任务时执行,无任务时阻塞(不浪费CPU);
-
解锁时机:取到任务后立即解锁,而不是执行完任务再解锁。因为任务执行可能耗时(比如转发消息给多个客户端),提前解锁能让其他线程(生产者/消费者)操作任务队列,提高并发效率;
-
优雅退出:当_is_running设为false(调用Stop函数)且任务队列为空时,线程退出,确保所有任务都执行完再停止线程。
5. Stop函数:优雅销毁线程池
设置_is_running为false,唤醒所有等待的线程(_cond.notify_all()),等待所有线程执行完后清空资源(线程列表、任务队列),避免资源泄漏。析构函数会自动调用Stop函数,确保程序退出时线程池被正确销毁。
2.4.3 原子变量_is_running的作用
_is_running用std::atomic<bool>定义(原子变量),原因是多个线程可能同时访问该变量(比如主线程调用Stop修改_is_running,线程池中的线程在ThreadFunc中读取_is_running)。原子变量能确保"读-改-写"操作的原子性,避免线程安全问题(比如主线程修改_is_running时,线程池线程读取到中间值)。
2.5 模块5:服务端主函数------程序的"入口与统筹"
主函数是服务端程序的入口,负责"统筹所有模块":初始化线程池、创建业务逻辑对象(RouteChat)、创建网络层对象(UdpServer)、启动服务端,还需处理信号(比如Ctrl+C优雅退出)。
完整代码:
cpp
#include <iostream>
#include <signal.h>
#include "UdpServer.hpp"
#include "RouteChat.hpp"
#include "ThreadPool.hpp"
// 全局变量:方便信号处理函数访问(实际项目中可封装成单例,此处简化)
UdpServer* g_server = nullptr;
// 信号处理函数:处理SIGINT(Ctrl+C),实现优雅退出
void SignalHandler(int signo)
{
if (signo == SIGINT)
{
LOG(LogLevel::INFO) << "recv SIGINT, server exiting...";
if (g_server != nullptr)
{
// 此处可扩展:设置g_server->_isrunning为false(需提供接口)
}
ThreadPool::GetInstance().Stop(); // 停止线程池
exit(0); // 退出程序
}
}
int main(int argc, char* argv[])
{
// 步骤1:解析命令行参数(获取服务端端口)
if (argc != 2)
{
std::cerr << "Usage: ./udp_server port" << std::endl;
exit(1);
}
int port = atoi(argv[1]);
if (port < 1024 || port > 65535)
{
std::cerr << "port must be between 1024 and 65535" << std::endl;
exit(1);
}
// 步骤2:注册信号处理函数(处理Ctrl+C)
signal(SIGINT, SignalHandler);
// 步骤3:初始化线程池(4个线程)
ThreadPool::GetInstance().Init(4);
// 步骤4:创建业务逻辑对象(RouteChat)
RouteChat route_chat;
// 步骤5:创建UdpServer对象,传入端口和回调函数(封装成任务添加到线程池)
UdpServer server(port, [&](int sockfd, const std::string& msg, InetAddr& client)
{
// 回调函数逻辑:将"转发消息"任务添加到线程池
ThreadPool::GetInstance().AddTask([=, &route_chat]()
{
route_chat.SendMessageToAll(sockfd, msg, client);
});
});
g_server = &server; // 赋值给全局变量,供信号处理函数使用
// 步骤6:初始化并启动服务端
server.InitUdpServer();
server.StartUdpServer();
// 步骤7:停止线程池(理论上不会执行到这里,除非StartUdpServer退出)
ThreadPool::GetInstance().Stop();
return 0;
}
2.5.1 核心流程解析
步骤1:命令行参数解析
主函数接收一个命令行参数(服务端端口),校验端口合法性(1024~65535,1024以下是特权端口,普通用户无法绑定),若参数错误则打印用法并退出。
步骤2:信号处理(优雅退出)
注册SIGINT信号(Ctrl+C触发)的处理函数SignalHandler,避免程序被强制终止时资源泄漏(比如线程池未停止、Socket未关闭)。处理逻辑:打印退出日志、停止线程池、退出程序。
步骤3:初始化线程池
调用ThreadPool::GetInstance().Init(4)初始化线程池,创建4个线程(可根据需求调整数量)。
步骤4:创建业务逻辑对象
创建RouteChat对象(负责消息转发和客户端管理),后续回调函数中会调用其SendMessageToAll方法。
步骤5:创建UdpServer对象(核心)
传入端口和回调函数,回调函数的核心逻辑是"将转发消息任务添加到线程池":
-
用lambda表达式封装回调函数,捕获RouteChat对象(route_chat)和必要参数(sockfd、msg、client);
-
调用ThreadPool::GetInstance().AddTask将"route_chat.SendMessageToAll(...)"封装成任务添加到任务队列,由线程池中的线程执行;
-
这样UdpServer主线程只需负责接收消息,添加任务后立即回到接收状态,不会被转发消息的耗时操作阻塞,实现高并发处理。
步骤6:初始化并启动服务端
调用server.InitUdpServer()完成Socket创建和端口绑定,调用server.StartUdpServer()启动核心循环(接收消息)。
2.6 模块6:客户端实现------双线程设计(发送+接收)
客户端核心需求:① 发送键盘输入的消息给服务端;② 接收服务端转发的其他客户端消息。由于发送和接收是独立操作(若单线程处理,发送时无法接收,接收时无法发送),因此采用"双线程设计":一个线程负责发送消息,一个线程负责接收消息。
完整代码:
cpp
#include <iostream>
#include <string>
#include <thread>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <atomic>
#include "InetAddr.hpp"
#include "Log.hpp"
using namespace LogModule;
std::atomic<bool> g_running = true; // 客户端运行状态(原子变量)
int g_sockfd = -1; // 客户端Socket文件描述符
InetAddr g_server_addr; // 服务端地址
// 接收消息线程函数:循环接收服务端转发的消息
void RecvThreadFunc()
{
while (g_running)
{
char buff[1024] = {0};
struct sockaddr_in server;
socklen_t server_len = sizeof(server);
int ret_recvfrom = recvfrom(g_sockfd, buff, sizeof(buff)-1, 0, (struct sockaddr*)&server, &server_len);
if (ret_recvfrom < 0)
{
LOG(LogLevel::ERROR) << "client recvfrom failed";
continue;
}
buff[ret_recvfrom] = '\0';
std::cout << buff << std::endl; // 打印接收的消息(其他客户端的消息)
}
}
// 发送消息线程函数:读取键盘输入,发送给服务端
void SendThreadFunc()
{
while (g_running)
{
std::string msg;
std::getline(std::cin, msg); // 读取键盘输入(支持空格)
// 发送消息给服务端
int ret_sendto = sendto(g_sockfd, msg.c_str(), msg.size(), 0,
(struct sockaddr*)&g_server_addr.GetInetAddr(), g_server_addr.InetAddrLen());
if (ret_sendto < 0)
{
LOG(LogLevel::ERROR) << "client sendto failed";
continue;
}
// 若输入QUIT,退出客户端
if (msg == "QUIT")
{
g_running = false;
break;
}
}
}
int main(int argc, char* argv[])
{
// 步骤1:解析命令行参数(服务端IP和端口)
if (argc != 3)
{
std::cerr << "Usage: ./udp_client server_ip server_port" << std::endl;
exit(1);
}
std::string server_ip = argv[1];
int server_port = atoi(argv[2]);
// 步骤2:创建UDP Socket
g_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (g_sockfd < 0)
{
LOG(LogLevel::FATAL) << "client socket failed";
exit(2);
}
// 步骤3:初始化服务端地址(主机序→网络序)
g_server_addr = InetAddr(server_ip, server_port);
// 步骤4:创建发送和接收线程
std::thread recv_thread(RecvThreadFunc);
std::thread send_thread(SendThreadFunc);
// 等待线程结束
send_thread.join();
recv_thread.join();
// 步骤5:关闭Socket,释放资源
close(g_sockfd);
LOG(LogLevel::INFO) << "client exit";
return 0;
}
2.6.1 核心设计思路:双线程解耦
客户端若用单线程处理,会出现"阻塞问题":比如调用std::getline读取键盘输入时,线程会阻塞(直到用户输入),此时无法接收服务端转发的消息;同理,调用recvfrom接收消息时,线程也会阻塞,无法发送消息。双线程设计将"发送"和"接收"两个操作分离,各自独立运行,避免相互阻塞。
2.6.2 核心函数解析
1. RecvThreadFunc:接收消息线程
循环调用recvfrom接收服务端转发的消息(服务端转发的是其他客户端的消息,格式为"IP:端口# 消息内容"),接收成功后打印消息到终端。若接收失败(比如Socket关闭),打印错误日志后继续循环(确保线程不退出,除非g_running设为false)。
2. SendThreadFunc:发送消息线程
循环读取键盘输入(用std::getline支持带空格的消息),调用sendto发送给服务端:
-
若输入"QUIT",将g_running设为false(通知接收线程退出),然后退出发送线程;
-
sendto的目标地址是g_server_addr(服务端地址),通过InetAddr的接口获取原生地址结构体和大小,无需关注字节序细节。
3. 主函数流程
解析命令行参数(服务端IP和端口)→ 创建UDP Socket → 初始化服务端地址 → 创建发送/接收线程 → 等待线程结束 → 关闭Socket。
关键细节:join()函数的作用是主线程等待子线程执行完再退出,避免子线程还在运行时主线程已退出(导致程序异常终止)。
三、服务端/客户端流程分析:完整链路梳理
3.1 服务端启动与运行流程
-
主函数解析端口参数,注册SIGINT信号处理函数;
-
初始化单例线程池(创建4个线程,等待任务);
-
创建RouteChat对象(业务逻辑核心);
-
创建UdpServer对象,传入端口和回调函数(回调函数负责将转发任务添加到线程池);
-
UdpServer初始化:创建Socket → 绑定端口(IP+端口);
-
启动服务端核心循环:持续接收客户端消息;
-
收到消息后,调用回调函数将"转发消息"任务添加到线程池;
-
线程池中的线程取任务执行:调用RouteChat::SendMessageToAll转发消息(添加新客户端→拼接消息→遍历转发→处理退出);
-
接收SIGINT信号(Ctrl+C),停止线程池,优雅退出。
3.2 客户端连接与通信流程
-
主函数解析服务端IP和端口参数;
-
创建UDP Socket;
-
初始化服务端地址(主机序→网络序);
-
创建发送线程(读取键盘输入发送给服务端)和接收线程(接收服务端转发的消息);
-
客户端发送第一条消息时,服务端将其添加到在线客户端列表(自动进群);
-
客户端发送消息:发送线程读取输入,调用sendto发送给服务端;
-
客户端接收消息:接收线程循环接收服务端转发的其他客户端消息,打印到终端;
-
客户端输入"QUIT":发送线程将g_running设为false,发送消息给服务端,服务端将其从客户端列表删除;
-
发送线程退出,接收线程退出,客户端关闭Socket,优雅退出。
3.3 消息转发完整链路
客户端A发送消息 "Hello" → 服务端主线程接收消息 → 回调函数将转发任务添加到线程池 → 线程池线程执行任务,调用RouteChat::SendMessageToAll → 拼接消息为 "客户端AIP:端口# Hello" → 遍历在线客户端列表,调用sendto转发给客户端B、C、D... → 客户端B、C、D的接收线程收到消息,打印到终端。
四、实战部署:从编译到运行
4.1 环境准备
-
操作系统:Linux(Ubuntu/CentOS等,UDP编程依赖Linux内核接口);
-
编译工具:g++(支持C++11及以上标准,线程池用到C++11的线程库);
-
依赖库:pthread(线程库,编译时需链接)。
4.2 文件清单
项目包含以下文件(按模块划分,结构清晰):
cpp
udp_chatroom/
├── Log.hpp // 日志模块(简化实现,支持终端输出)
├── Mutex.hpp // 互斥锁模块(LockGuard RAII封装)
├── InetAddr.hpp // 地址封装模块
├── RouteChat.hpp // 消息转发与客户端管理模块
├── ThreadPool.hpp // 单例线程池模块
├── UdpServer.hpp // UDP服务端模块
├── udp_server.cpp // 服务端主函数
└── udp_client.cpp // 客户端主函数
4.3 编译命令
- 编译服务端:
g++ -o udp_server udp_server.cpp -std=c++11 -pthread
- 编译客户端:
g++ -o udp_client udp_client.cpp -std=c++11 -pthread
参数说明:
-
-std=c++11:启用C++11标准(支持线程、lambda表达式、原子变量等);
-
-pthread:链接pthread线程库(Linux下使用std::thread必须链接该库)。
4.4 运行步骤
- 启动服务端(绑定端口8080):
./udp_server 8080
服务端输出日志:
cpp
INFO: socket create success, fd:3
INFO: bind success!!!
INFO: ThreadPool create thread: 140704123457280
INFO: ThreadPool create thread: 140704115064576
INFO: ThreadPool create thread: 140704106671872
INFO: ThreadPool create thread: 140704098279168
- 启动多个客户端(连接服务端IP 127.0.0.1,端口8080):
./udp_client 127.0.0.1 8080
- 测试功能:
-
客户端1输入 "大家好",客户端2、3会收到消息:"127.0.0.1:54321# 大家好";
-
客户端2输入 "Hello UDP",客户端1、3会收到消息:"127.0.0.1:54322# Hello UDP";
-
客户端3输入 "QUIT",客户端3退出,服务端日志输出 "删除一个在线用户:127.0.0.1:54323 成功"。
五、问题排查:常见问题与解决方法
5.1 服务端启动失败
问题1:bind failed(端口被占用)
现象:服务端日志输出 "FATAL: bind failed",退出码2。
解决方法:
-
查看端口占用情况:
netstat -anp | grep 8080(8080为绑定的端口); -
杀死占用端口的进程:
kill -9 进程ID; -
或更换服务端端口:
./udp_server 8081。
问题2:socket failed(权限不足)
现象:服务端日志输出 "FATAL: socket failed",退出码2。
解决方法:
-
Socket创建失败极少是权限问题,更多是系统资源不足(比如可用文件描述符耗尽);
-
查看系统最大文件描述符限制:
ulimit -n(默认一般是1024); -
临时提高限制:
ulimit -n 65535。
5.2 客户端无法连接服务端
问题1:sendto failed(连接拒绝)
现象:客户端日志输出 "ERROR: client sendto failed"。
解决方法:
解决方法:
-
检查服务端运行状态:客户端发送消息前,需先确认服务端已正常启动并完成端口绑定。在服务端主机执行
ps -ef | grep udp_server命令,查看服务端进程是否存在;若进程不存在,需重新启动服务端并观察启动日志,确保无"bind failed""socket failed"等报错(日志输出"bind success!!!"即为启动正常)。 -
核实服务端地址与端口配置:客户端代码中指定的服务端IP和端口需与服务端实际绑定信息完全一致。① 常见错误:将服务端局域网IP(如192.168.1.100)误写为公网IP,或端口号与服务端启动端口(默认8080,若启动时指定为8081则需对应修改)不匹配;② 验证方式:通过服务端启动日志确认绑定端口,通过
ifconfig(或ip addr)命令在服务端查看实际IP地址。 -
测试客户端与服务端网络连通性:UDP为无连接协议,无法直接通过"连接"验证,可通过以下方式排查网络链路:① 执行
ping 服务端IP(如ping 192.168.1.100),确认基础网络通畅(若ping不通,可能是防火墙拦截ICMP包或路由配置问题);② 用UDP工具验证端口可达性,或执行ss -u -a | grep 服务端端口(如ss -u -a | grep 8080),查看服务端是否处于"LISTEN"状态(UDP无严格LISTEN状态,需确认端口已被服务端占用)。 -
排查防火墙/安全组拦截问题:Linux系统默认防火墙(firewalld/iptables)可能拦截UDP端口流量,需分两端配置:① 服务端:开放绑定的UDP端口,执行
sudo firewall-cmd --add-port=8080/udp --permanent(firewalld)或sudo iptables -A INPUT -p udp --dport 8080 -j ACCEPT(iptables),配置后重启防火墙(firewalld需执行sudo firewall-cmd --reload);② 客户端:确保本地防火墙未拦截UDP出站流量,可临时执行sudo systemctl stop firewalld关闭防火墙测试(测试完成后建议重新开启并配置规则)。 -
检查客户端sendto函数参数正确性:这是代码层面最易出错的环节,需逐一验证参数:① Socket文件描述符(sockfd):确认客户端Socket创建成功(socket函数返回值>0),若创建失败需检查地址族(AF_INET)和类型(SOCK_DGRAM)是否正确;② 目标地址结构体(struct sockaddr_in):sin_family需设为AF_INET,sin_port需用htons函数转换为网络字节序(如htons(8080)),sin_addr.s_addr需正确赋值为服务端IP的网络序(用inet_pton函数转换,如inet_pton(AF_INET, "127.0.0.1", &sin_addr));③ 地址长度参数:需传入sizeof(struct sockaddr_in),避免因长度错误导致系统调用失败。
-
排查系统级资源限制:客户端所在主机可用文件描述符耗尽可能导致sendto失败(概率较低),执行
ulimit -n查看当前限制(默认多为1024),可临时执行ulimit -n 65535提高限制;此外,部分系统可能开启UDP发送速率限制,可查看系统日志(/var/log/messages)排查是否有相关拦截记录。 -
特殊场景:跨网段/公网连接问题:若客户端与服务端处于不同网段(如公网访问内网服务端),需确认服务端所在内网已配置端口映射(将公网IP+端口映射到服务端内网IP+端口),且公网防火墙已开放对应UDP端口;同时注意,部分运营商可能屏蔽常用UDP端口,可尝试更换端口(如8081、9090)测试。
完整代码:
最后给上完整的代码:
RouteChat.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include "InetAddr.hpp"
#include "Log.hpp"
#include "Mutex.hpp"
//OK,那么在本文件中,我们要实现一个基于UDPsocket通信的多人聊天室
//换句话来说就是可以有多个客户端去向服务端发送信息
//那么聊天聊天,还是多人聊天,想想看服务端要做什么呢???
//服务端要做的肯定就是将每个客户端发给它的消息再都发送给所有其他的客户端啊!!!
//可以把这个想成qq/微信群聊,那么新入群一个用户是不是就相当于是增加一个客户端啊
//而退群一个用户是不是就相当于是删除一个客户端啊!!!
//那么再想想,既然要像上面说的这样子,是不是服务端就得去记录这些客户端,并且能修改客户端
//那么,客户端是不是已经在InetAddr中被我们描述了!!!!!!!
//那么在本文件,也就是实现多人聊天的文件中,我们就得去将这些客户端都记录并组织起来!!!
//这就是先描述,再组织,这里我们采用vector进行管理!!!
//那么再想想,怎么知道每个客户端的信息(客户端发送给服务端的信息,ip,端口号等等)呢???
//tmd,不就是在服务端中的recvform函数中有着吗!!!!!!
//每个客户端每次发送信息给服务端,服务端都会进行回调函数的调用!!!
//所以,在本文件中,我们要实现哪些功能呢???
//1.组织客户端
//2.删除/添加客户端
//3.将服务端每次收到的信息都发送给其他客户端,删除、添加客户端的调用!!!核心功能
//那么上面这些就是我们本类中要实现的功能
//而第三个功能就是要在服务端进行回调的函数!!!
//还有一个问题,能不能多个线程都调用第三个功能????
//会不会造成线程不安全???会!!!
//所以,第三个功能的函数中要加锁!!!
//那么,明确了目标之后,代码就能好想多了
using namespace MutexModule;
using namespace LogModule;
class RouteChat
{
private:
//2.删除/添加客户端
bool Exist(const InetAddr& user) const//判断新发送消息的客户端是不是已经在管理数组中了(即代表该客户端之前已经有发送过消息给服务端了)
{
for(auto& client:_clients)
{
if(user==client)
{
return true;
}
}
return false;//遍历了都没有找到,就说明新发送消息的客户端不是已经在管理数组中
}
//添加新客户端
//要是新发送消息的客户端不是已经在管理数组中
//然后它有发送信息了,那不就是相当于进群吗??
//所以就肯定得把这个客户端加入管理数组中
void AddClient(const InetAddr& client)
{
LOG(LogLevel::INFO) << "新增一个在线用户: " << client.StringAddr();
_clients.push_back(client);
}
//删除新客户端
//要是有客户端发送消息表示它要退出这个聊天室
//那不就是相当于退群吗??
//所以就肯定得把这个客户端从管理数组中删除
//因为只要有客户端发送消息给服务端了,那么它就一定会先被加入管理数组中
//即使它第一次发的就是退出的消息
void DeleteClient(const InetAddr& client)
{
//还是先遍历管理数组,找到要退出的客户端在管理数组中的迭代器
//因为erase函数是需要传入迭代器的
for(std::vector<InetAddr>::iterator it=_clients.begin();it<_clients.end();++it)
{
if(client==*it)
{
//执行erase函数
LOG(LogLevel::INFO) << "删除一个在线用户:" << client.StringAddr() << "成功";
_clients.erase(it);
break;//终止循环,不做无用功!!!
}
}
}
public:
RouteChat()
{
//不用进行任何操作
}
~RouteChat()
{
}
//将服务端收到的每次信息都发送给其他客户端,删除、添加客户端的调用!!!核心功能
//而该功能就是要在服务端进行回调的函数!!!
//每个客户端每次发送信息给服务端,服务端都会进行回调函数的调用!!!
//还得获取到服务端的socketfd,因为我们要在本函数中去调用sendto函数向每一个在管理数组中的客户端发送信息
void SendMessageToAll(int socketfd,const std::string& message,InetAddr& client)
{
LockGuard mutex(_mutex);//上锁
//为什么要上锁呢???想一想,要是在一个线程执行遍历管理数组发送信息的时候
//另一个线程也进来了这个函数,然后这个线程就是要删除某个客户端
//那么一个线程还在遍历管理数组呢,那么另一个线程就把管理数组中的某个客户端删除
//那这不是纯扯淡吗,就会造成线程不安全,所以,要上锁!!!
//先检测新发送信息的客户端有没有在管理数组中
//没有的话,就把这个客户端加入管理数组中
if(!Exist(client))
{
AddClient(client);
}
//指明是哪个ip的哪个端口号发送的信息
std::string send_message = client.StringAddr() + "# " + message; // 127.0.0.1:8080# 你好
//然后就是把消息发生给管理数组中每个客户端
//那其实就是要给每个客户端都调用sendto函数
//依旧是要遍历管理数组
for(auto& _client:_clients)
{
//服务端要把每次收到的客户端发的消息再都发给管理数组中的其他所有客户端
int ret_sendto=sendto(socketfd,send_message.c_str(),send_message.size(),0,(struct sockaddr*)&(_client.GetInetAddr()),_client.InetAddrLen());
if(ret_sendto<0)
{
LOG(LogLevel::ERROR)<<"sendto"<<_client.StringAddr()<<"failed!";
}
}
//这个用户一定已经在线了
if(message=="QUIT")//隐式转换,QUIT设置为退出标志,那么就执行从管理数组中删除客户端的操作
{
DeleteClient(client);
}
}
private:
std::vector<InetAddr> _clients;//组织管理客户端的vector
Mutex _mutex;//锁,作用上面有说
};
UdpServer.hpp
cpp
#pragma once
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <strings.h>
#include <functional>
#include "Log.hpp"
#include "InetAddr.hpp"
using func_t = std::function<void(int,const std::string&,InetAddr&)>;
const int defaultsocketfd=-1;//默认socketfd
using namespace LogModule;
class UdpServer
{
public:
//禁用拷贝构造和赋值运算符(避免多个对象共享同一个socketfd)
UdpServer(const UdpServer&) = delete;
UdpServer& operator=(const UdpServer&) = delete;
//构造函数
//是服务端,那么就是接收信息,那么也是一个进程,那么是不是应该我们得给它分配端口号呢???
//再想想,服务端是不是要出名,独一的,所以它应该就要有单独的指定的端口号
//这样子那些客户端才能定位到这个服务端,这一点需要注意
UdpServer(int port,func_t func)
:_port(port)
,_func(func)
,_socketfd(defaultsocketfd)
,_isrunning(false)//刚开始本服务端肯定没有运行
{
Use_Monitor_Log();//显示器上打印日志
}
//析构函数,关闭打开的socketfd
~UdpServer()
{
if (_socketfd != defaultsocketfd)
{
close(_socketfd);
_socketfd = defaultsocketfd;
LOG(LogLevel::INFO) << "UDP server socket closed";
}
}
//创建服务端socket套接字并进行bind的函数
void InitUdpServer()
{
_socketfd=socket(AF_INET,SOCK_DGRAM,0);
if(_socketfd<0)
{
LOG(LogLevel::FATAL)<<"socket failed";
exit(2);
}
//创建struct sockaddr_in结构体,将本进程的ip啊(其实就是0.0.0.0)端口号啊传入
struct sockaddr_in sever;
bzero(&sever,sizeof(sever));
sever.sin_family=AF_INET;
sever.sin_addr.s_addr=htonl(INADDR_ANY);
sever.sin_port=htons(_port);
//进行bind绑定!!!
//服务端是需要bind绑定的,因为服务端必须要有一个确定的端口号,原因上面也说了
//是服务端,那么就是接收信息,那么也是一个进程,那么是不是应该我们得给它分配端口号呢???
//再想想,服务端是不是要出名,独一的,所以它应该就要有单独的指定的端口号
//这样子那些客户端才能定位到这个服务端,这一点需要注意
//不想服务端是不需要有什么单独的我们已知的端口号,因为它只是负责发送信息!!!
//所以它是不用显式bind的,在发送信息的时候,OS就会自动给它客户端进程随机端口号!!!
int ret_bind=bind(_socketfd,(struct sockaddr*)&sever,sizeof(sever));
if(ret_bind<0)
{
LOG(LogLevel::FATAL)<<"bind failed";
exit(2);
}
LOG(LogLevel::INFO)<<"bind success!!!";
}
//运行服务端网络通信的函数,其实就是调用recvform函数罢了
//那么有客户端给本服务端发送消息的话,因为服务端本质是死循环的去等待客户端发送来的信息
//所以不管来几个客户端,服务端都能照收不误
void StartUdpServer()
{
//代表开始运行本服务端,所以要把_isrunning进行修改
_isrunning=true;
//下面我来详细的说明一下这个循环的原理,其实也就是服务端如何去处理多个客户端发消息
//我们知道,服务端的处理,本质就是一个死循环,每次进行完一次循环,就会回到循环开头进行新的一次循环
//那么服务端要干的其实就是接收客户端发来的信息,然后进行回调函数的处理
//那么这也就是每次循环要做的事情
//那么想想看,要是有一个客户端给服务端发送信息了,然后同时又有一个其他的客户端给服务端发送信息呢???
//那么其实服务端就是先给一个客户端执行一次循环(即接收信息,执行回调函数)
//那么服务端得执行完了这一次循环之后,才能去对另一个客户端进行接收信息,执行回调函数,即执行新的一次循环
//所以啊,本质上,服务端就是一次循环处理一次客户端发送的信息!!!
//这个是非常关键的,在服务端没执行完上一次的循环之前,它是无法再去处理其他客户端的信息的
//因为只有执行完一次循环之后,服务端才会回到循环开头的代码去执行recv等等操作
//那么当然,因为cpu处理速度极快,所以每次循环所消耗的时间几乎相当于没有!!!
//但是也难免会出现耗时的情况(回调函数的执行),所以我们会引入线程池去让多线程处理
//从而大大的减少耗时
//所以在这里我想强调的核心本质就是:
//以 "单次循环处理单条客户端消息" 为最小单位,按顺序逐一处理所有客户端的消息,
//且必须完成上一轮循环才能处理下一轮。
while(_isrunning)//只要还在于运行,就一直运行下去,其实是个废话
{
//接收信息
char buff[1024]={0};//创建接收数据的字符串
struct sockaddr_in client;
socklen_t clientsize=sizeof(client);
bzero(&client,clientsize);
int ret_recvfrom=recvfrom(_socketfd,buff,sizeof(buff)-1,0,(struct sockaddr*)&client,&clientsize);
if(ret_recvfrom<0)
{
LOG(LogLevel::INFO)<<"sever recvfrom failed!!!";
}
else
{
//因为我们要把字符串最后一个字符设为0,而且我们刚开始也已经留出空间了
//再加上recvfrom函数返回值就是收到的字节数,所以直接操作就完事了
buff[ret_recvfrom]='\0';
}
//那么我们在这里就要进行词典翻译的函数的回调使用
//那么怎么知道是哪个客户端向本服务端发送数据呢???
//不要忘记了recvfrom函数的输出型参数哦
InetAddr inaddrclient(client);
//调用回调函数
//其实就是聊天室中实现的将信息发送给所有客户端的函数
_func(_socketfd,buff,inaddrclient);
//不用将处理后的结果发送给客户端
//因为这是聊天室,已经在回调函数中实现好了
//回调函数执行完成后,服务端立刻回到循环开头,再次调用 recvfrom 进入阻塞状态,
//等待下一个客户端(可能是之前的客户端,也可能是新客户端)的消息。
}
}
private:
int _socketfd;//socket套接字
int _port;//服务端进程端口号
func_t _func;//服务端处理任务的函数,回调函数
//int _ip;//本进程所在的主机ip地址,那么想想看,服务端要用指定本主机的哪个ip地址吗??
//不用,直接传入INADDR_ANY就行,因为是服务端,所以是肯定要接收一大堆客户端的访问,所以就要使用0.0.0.0
//这一点需要注意
bool _isrunning;//判断本服务端是否还运行,其实本质上是一直死循环待机,毕竟想拼多多啊之类的服务器,什么时候关过机呢!!
};
//至此一个简单的sever服务端就封装好了
UdpServer.cc
cpp
#include "UdpServer.hpp"
#include <iostream>
#include <functional>
#include "SingletonThreadPool.hpp"
#include "RouteChat.hpp"
using namespace SingletonThreadPoolModule;
// 命令行参数,因为我们会要求用户在调用服务端的时候就得传入服务端的端口号,
// 那么ip可以不用,因为我们使用0.0.0.0
// 所以就要求用户这么调用服务端进程:
int main(int argc, char *argv[]) // 二级指针哦
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
// std::string ip = argv[1];
uint16_t port = std::stoi(argv[1]);
// 那么想想看,对于服务端这个进程,它是不是要处理的很多,每次有客户端发送消息给服务端
// 服务端都得把收到的消息再给其他客户端发送过去一遍,那么要是又多个客户端同时发送呢
// 那么服务端不得忙成傻子,所以,应该让服务端多线程去执行这个任务
// 那么其实就是使用线程池去减轻压力
// 我们直接使用单例模式线程池就行,嘎嘎好使,统一,而且获取到的指针就已经是启动线程池了的
// 不用我们再去手动启动!!!
RouteChat r;
// 提前定义线程池任务类型(UDP聊天室通用约定:无参可调用对象)
using task_t = std::function<void()>;
auto tp = ThreadPool<task_t>::GetInc(); // 单例模式,因为是静态函数,所以要这么操作
// ========== 核心逻辑:创建UDP服务器并绑定消息处理回调 ==========
// 1. 智能指针管理UDP服务器对象(独占式,自动释放内存,避免泄漏)
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(
port, // 2. UdpServer构造参数1:服务器绑定的端口号(如8080)
// 2. UdpServer构造参数2:消息处理回调lambda(收到客户端消息时自动触发)
[&r, &tp](int sockfd, const std::string &message, InetAddr &peer)
{
// 捕获列表说明:
// &r:引用捕获RouteChat类实例(封装消息转发/客户端管理逻辑),避免拷贝大对象
// &tp:引用捕获线程池指针,避免拷贝线程池,操作真实的线程池对象
// 3. std::bind封装任务:把有参成员函数转为无参task_t,适配线程池接口
task_t t = std::bind(
&RouteChat::SendMessageToAll, // 要执行的核心函数:RouteChat类的消息路由方法(对应SendMessageToAll)
&r, // 指定函数调用者:RouteChat实例r的指针(绑定this指针)
sockfd, // 预绑定参数1:服务器socket描述符(用于sendto转发消息)
message, // 预绑定参数2:客户端发送的原始消息(如聊天内容/QUIT指令)
peer // 预绑定参数3:客户端地址对象(InetAddr,含IP/端口)
);
// 4. 提交任务到线程池:异步执行消息转发,主线程不阻塞
// 核心目的:主线程只收消息,转发逻辑交给线程池,提升多客户端并发能力
//那么每次服务端有收到消息,就会执行一次这个绑定的函数
//也就是每次都会把任务传入线程池中!!!
tp->EntryThreadPool(t);
});
/* 各核心部分注释总结:
1. 智能指针:
- std::unique_ptr<UdpServer>:独占式管理UdpServer对象,服务器退出时自动释放内存,避免手动new/delete的内存泄漏
- std::make_unique:C++14+安全创建方式,比直接new更能避免异常安全问题(如构造时抛异常导致内存泄漏)
2. UdpServer构造参数:
- port:服务器监听的端口,客户端通过该端口发送消息
- lambda回调:UdpServer收到消息(recvfrom)后触发,传入sockfd(服务器句柄)、message(客户端消息)、peer(客户端地址)
3. lambda捕获列表:
- &r:引用捕获RouteChat实例,操作真实的客户端管理/消息转发逻辑
- &tp:引用捕获线程池指针,将任务提交到真实的线程池
4. std::bind封装任务:
- 把Route::MessageRoute(有参成员函数)封装为无参task_t,适配线程池Enqueue的参数要求
- 预绑定所有参数,线程池执行t()时等价于调用r.MessageRoute(sockfd, message, peer)
5. 线程池提交任务:
- tp->Enqueue(t):将消息转发任务加入线程池队列,由空闲线程异步执行
- 主线程快速回到recvfrom接收下一条消息,避免阻塞,支持高并发
*/
usvr->InitUdpServer();
usvr->StartUdpServer();
return 0;
}
// 其实还是比较简单的
UdpClient.cc
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include "Thread.hpp"
#include "Mutex.hpp"
#include "InetAddr.hpp"
using namespace ThreadModule;
using namespace MutexModule;
int sockfd=-1;//创建全局变量,这样子其他线程才能使用main主线程中获取到的socketfd
std::string server_ip;//创建全局变量,这样子其他线程才能使用main主线程中获取到的
uint16_t server_port;//创建全局变量,这样子其他线程才能使用main主线程中获取到的
pthread_t id;
//一个线程运行sendto函数
void Send()
{
//填写服务器信息
//客户端必须要知道要发送信息的服务端的IP地址和端口号哦
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
Mutex _smutex;
while(true)
{
std::string input;
LockGuard smutex(_smutex);
{
std::cout << "Please Enter# ";
}
std::getline(std::cin, input);
int n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr*)&server, sizeof(server));
(void)n;
//如果有客户端发送的退出标志,那么就代表这个客户端不接收信息了(退群)
//那么就把这个客户端的接收来自服务端的信息的线程取消即可
if (input == "QUIT")
{
pthread_cancel(id);
break;
}
}
}
//一个线程运行recvfrom函数
void Recv()
{
//Mutex _rmutex;
while(true)//死循环
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(m > 0)
{
buffer[m] = 0;
std::cout << buffer << std::endl;
}
//网络通信是全双工,所以可以同时接发信息
}
}
//客户端,其实就是向服务端发送信息的进程
//那么前面也说了,要客户端也要创建一个socket哦
//然后客户端必须要知道要发送信息的服务端的IP地址和端口号哦
//那么这就需要我们外界在调用该客户端进程的时候就一起传入
//所以,就又需要命令行参数了!!!
// ./udpclient server_ip server_port
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
server_ip = argv[1];
server_port = std::stoi(argv[2]);//将端口号进行转换,从字符串转换为整型
// 1. 创建socket
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return 2;
}
// 2. 本地的ip和端口是什么?要不要和上面的"文件"关联呢?
// 问题:client要不要bind?需要bind.
// client要不要显式的bind?不要!!首次发送消息,
// OS会自动给client进行bind,OS知道IP,端口号采用随机端口号的方式
// 为什么?一个端口号,只能被一个进程bind,为了避免client端口冲突
// client端的端口号是几,不重要,只要是唯一的就行!
//客户端(无论 UDP/TCP)不需要显式调用 bind,首次发送数据时操作系统(OS)会自动完成 bind:
//OS 会自动为客户端套接字绑定「本机出口 IP + 随机未被占用的端口号」;
//显式 bind 反而容易引发端口冲突,违背客户端 "灵活接入" 的设计需求。
//服务端 vs 客户端:端口需求的本质差异
//角色 端口需求 绑定方式 原因
//服务端(Server) 必须用固定端口(如 80、8080) 显式 bind 客户端需要 "找到" 服务端,必须知道服务端的固定 IP + 固定端口(比如访问网站要连 80 端口)
//客户端(Client) 只需要唯一端口(具体数值无关) OS 自动 bind 服务端只需 "回包" 给客户端的临时 IP + 端口,客户端端口不用固定,唯一即可
//OS 会做 3 件事:
//选一个未被占用的随机端口(比如 45678);
//选客户端的出口 IP(比如本机连路由器的 IP 192.168.1.200);
//自动将这个 "192.168.1.200:45678" 绑定到客户端套接字上。
//服务端通过recvfrom拿到客户端的 "192.168.1.200:45678",就能精准回包,
//客户端端口是 45678 还是 56789 完全不重要。
//如果客户端显式 bind 一个固定端口(比如 9090):
//同一台机器启动多个客户端进程时,第二个进程 bind 9090 会失败(端口被第一个进程占用);
//因为要是我们的客户端代码显式bind的话,即显式将客户端进程绑定到我们指定的端口号的话
//那么如果同一个主机下启动多个客户端进程呢???是不是本质上就是运行同一段代码
//那么这些客户端进程是不是会都去申请我们指定的端口号呢???
//可是第一个客户端进程已经绑定走了,那么后面的又怎么可能申请成功呢???
//所以这就导致无法同一个主机下启动多个客户端进程
//所以啊,我们不应该显式将客户端进程绑定到我们指定的端口号
//应该让OS自己随机挑选空余的端口号,这样子才不会发生上面所说的问题
//这一点就是最重要的原因,需要知道!!!
//客户端完全不需要固定端口,这种做法只会增加 "端口冲突" 的风险,没有任何收益。
//OK,那么想一想,客户端是不是既要能给服务端发送信息,然后又能接收服务端发送过来的其他客户端发送的信息呢???
//肯定是的,但是呢,再想想,要是按照上面的代码的话,它可是阻塞等待的哦!!!
//什么???哪里有呢,那么不就是sendto函数吗,这个函数我们使用阻塞等待的
//换句话来说就是,要是一个客户端没有发送信息给服务端的话,即没有运行到sendto函数的话
//那么这个客户端就会一直阻塞等待,也就无法执行下面的recvfrom函数了,即无法接收到服务端发送来的信息
//换句话按照聊天室的逻辑来说就是,当一个客户端没有发送信息的话,那么这个客户端就收不到服务端发送给它的其他客户端发送的信息
//直到你这个客户端进行发送信息给服务端了,那么那么,这不是纯粹扯淡吗,怎么可以这样子
//所以,我们要怎么解决这个问题呢???其实很简单
//不要忘记了,我们客户端,本质上是一个进程
//而上面的sendto函数是阻塞,但是它阻塞的是主线程啊,而要是我们把recvfrom放在另一个线程呢???
//那么你一个线程里的sendto函数的阻塞能不能影响到另一个线程的recvfrom函数呢???显然是不能的!!!
//所以啊,为了避免上面说的那些情况,我们就可以创建两个线程
//一个线程运行sendto函数,一个线程运行recvfrom函数
//这就very nice了!!!
//设置完两个线程函数之后,我们就可以使用我们之前封装的线程类
//即回调函数
Thread sender(Send);
Thread recver(Recv);
//获取客户端中负责接收服务端信息的线程的pid
//用于
//如果有客户端发送的退出标志,那么就代表这个客户端不接收信息了(退群)
//那么就把这个客户端的接收来自服务端的信息的线程取消即可
id = recver.GetPid();
//启动两个线程
sender.Create_Start_Thread();
recver.Create_Start_Thread();
recver.JoinThread();
sender.JoinThread();
return 0;
}
InetAddr.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstring>
#include "Log.hpp"
// 那么这个文件就是把发送消息的客户端的IP地址和进程端口号
// 从网络字节序转换为正常的直观的字节序
// 并将它们进行返回
// 那么就是要封装一个类出来
// 那么其实仔细想一想就能知道,其实一个类,就代表一个客户端不是吗???肯定是的
// 所以再想想,要是有多个客户端发消息,那么我们怎么知道并管理这些客户端呢???
// 很简单,先描述,再组织,其实描述就是我们本文件实现的类!!!!!
// 这个需要好好理解
using namespace LogModule;
class InetAddr
{
public:
//将外界传入的struct sockaddr_in结构体中的ip地址和端口号进行转换并获取
//相当于是获取struct sockaddr_in结构体
// 网络转主机
InetAddr(const struct sockaddr_in& inetaddr)
:_inetaddr(inetaddr)
{
_port=ntohs(_inetaddr.sin_port);//从网络中拿到的!网络序列转换为符合当前主机的端口号风格
//那么对于ip地址的转换,即由网络字节序转换为主机字节序的话(string)
//如果是用inet_ntoa函数的话,那么不是多线程安全的
//man手册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区. 这个时候不需要我们手动进行释放.
//因为它返回的本质是一个静态指针,那么要是多线程同时的话
//就会出现不安全的情况,所以我们得换个线程安全的函数去将网络字节序的ip地址转换为主机字节序
//那么其实就是inet_ntop()函数
//因为 inet_ntoa 会将转换结果存放在自身内部的静态存储区,
//所以第二次调用该函数时,新的结果会覆盖掉上一次的结果;
//由此可思考:若有多个线程调用 inet_ntoa,是否会出现异常情况呢?
//而在 APUE 中,已明确指出 inet_ntoa 并非线程安全的函数。
//用inet_ntoa会导致多个线程同时修改静态缓冲区,出现IP地址显示错误的问题,而inet_ntop能彻底避免这个坑。
//inet_ntop 是 POSIX 标准的网络编程函数,
//核心作用是将网络字节序的二进制 IP 地址(如 in_addr/in6_addr)
//转换为人类可读的字符串格式(点分十进制 IPv4 / 冒号分隔 IPv6),
//也是替代非线程安全的 inet_ntoa 的最优选择。
//函数原型:
//const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
//参数 说明
//af 地址族:AF_INET(IPv4)/ AF_INET6(IPv6)
//src 输入参数:网络字节序的二进制 IP 地址(如 sockaddr_in中的struct sin_addr* 类型)
//dst 输出参数:自定义的缓冲区,用于存储转换后的字符串 IP(如点分十进制)
//size 缓冲区大小:IPv4 推荐用 INET_ADDRSTRLEN(16 字节),
// IPv6 用 INET6_ADDRSTRLEN(46 字节),那么其实直接使用sizeof(dst)就行,即我们接收数据的字符数组的大小
//返回值 成功返回 dst 指针;失败返回 NULL,并设置 errno
char buff[100];
const char* ret_inet_ntop=inet_ntop(AF_INET,&(_inetaddr.sin_addr),buff,sizeof(buff));
if(ret_inet_ntop==nullptr)
{
LOG(LogLevel::FATAL)<<"inet_ntop "<<"ip"<<"failed";
}
//此时buff字符串就是正常的ip地址
_ip=buff;//隐式转换
}
//根据外界传入的ip地址和端口号,在转换了之后填充到本类的struct sockaddr_in结构体中
//相当于是创建struct sockaddr_in结构体
// 主机转网络
InetAddr(std::string ip,int port)
{
bzero(&_inetaddr,sizeof(_inetaddr));//先对_inetaddr结构体全部初始化为0
_port=ntohs(port);//从网络中拿到的!网络序列转换为符合当前主机的端口号风格
_inetaddr.sin_family=AF_INET;
_inetaddr.sin_port=_port;
//对于将ip地址从主机字节序转换为网络字节序的操作,我们依旧是使用线程安全的函数
//那么其实就是inet_pton()函数
//inet_pton() 是 POSIX 标准的网络编程函数(全称 Internet Presentation to Numeric),
//核心作用是将人类可读的字符串格式 IP 地址(如 192.168.1.100/2001:db8::1)
//转换为网络字节序的二进制地址(in_addr/in6_addr 结构体),
//是 inet_ntop() 的 "反向操作",也是替代老旧 inet_addr() 函数的最优选择。
// #include <arpa/inet.h> // 必须包含的头文件
// int inet_pton(int af, const char *src, void *dst);
// 参数 说明
// af 地址族:- AF_INET:处理 IPv4 地址 - AF_INET6:处理 IPv6 地址
// src 输入:字符串格式的 IP 地址(如 "127.0.0.1")
// dst 输出:存储转换后二进制 IP 的缓冲区
// - IPv4:传 struct sin_addr* 类型,即sockaddr_in中的那一个结构体
// - IPv6:传 struct in6_addr* 类型
// 返回值 1:转换成功
// 0:src 不是有效的 IP 地址格式(如 192.168.1.256)
// -1:失败(如 af 不支持、dst 为空),并设置 errno
//那么运行完这个函数之后,传给该函数中的第三个参数,也就是struct sin_addr* 类型
//中的ip地址就会有了
int ret_inet_pton=inet_pton(AF_INET,_ip.c_str(),&(_inetaddr.sin_addr));
if(ret_inet_pton==-1)
{
LOG(LogLevel::FATAL)<<"inet_pton "<<"ip"<<"failed";
}
}
~InetAddr()
{}
//获取到客户端主机ip地址的函数
std::string GetIp() const//添加const,增大使用范围
{
return _ip;
}
//获取到客户端端口号函数
int GetPort() const
{
return _port;
}
//获取到客户端主机ip地址加端口号的函数
std::string StringAddr() const//不修改返回值的话,就加个const,增大使用范围
{
return _ip + ":" + std::to_string(_port);
}
//判断当前类的IP地址和端口号是不是和另一个相同
//其实就是判断两个客户端是不是同一个客户端
//用于客户端的删除
bool operator==(const InetAddr& client) const
{
return this->_ip==client.GetIp() && this->_port==client.GetPort();
//要注意不能直接和类变量的成员变量比较,应该使用获取函数去进行比较
//因为成员变量是私有的
}
//获取到客户端的存储ip端口号等等信息的结构体
struct sockaddr_in& GetInetAddr()//返回引用哦,直接使用
{
return _inetaddr;
}
socklen_t InetAddrLen()
{
return sizeof(_inetaddr);
}
private:
struct sockaddr_in _inetaddr;//存储客户端ip等信息的结构体
std::string _ip;//从网络字节序转换为正常的直观的字节序的客户端的ip地址
int _port;//从网络字节序转换为正常的直观的字节序的客户端的端口号
};
其实核心思路和我们上篇的翻译一样,都是回调函数的调用!!!
结语:UDP 聊天室的旅程,不止于 "简单"
敲下最后一行代码,看着终端里多个客户端窗口弹出彼此的消息,从 "Hello" 到 "QUIT",从陌生的 IP 地址到连成一片的对话,忽然觉得,这个 "简单" 的 UDP 聊天室,藏着太多值得回味的细节。它不是一个冰冷的功能实现,而是我们敲开网络编程大门的一把钥匙,是从 "懂理论" 到 "能落地" 的一次跨越,更是对 "封装、解耦、并发" 这些编程思想的沉浸式实践。
回头看整个过程,我们走的每一步都踩在 "解决真实问题" 的节点上。最开始,我们疑惑 "为什么选 UDP 做聊天室?"------ 不是因为 TCP 不好,而是因为我们读懂了场景:聊天室不需要绝对可靠的传输(丢一条消息无伤大雅),但需要轻量、高效,能让几十个客户端同时接入,UDP 的无连接、低开销特性恰好命中需求。就像早期 QQ 用 UDP 做核心通信,技术的选择从来不是 "最优解",而是 "最适配场景的解"。这让我想起一句话:"脱离场景谈技术,都是纸上谈兵。" 我们从不是为了用 UDP 而用 UDP,而是为了 "让聊天室跑起来、跑顺畅" 而选择 UDP。
然后是封装的魅力。InetAddr 类把晦涩的 sockaddr_in 结构体、字节序转换、IP 端口处理全部包裹起来,上层代码不用再纠结 "htons 和 ntohs 该怎么用",不用再担心 inet_ntoa 的线程安全问题;RouteChat 类把客户端管理、消息转发的业务逻辑抽离出来,和网络层的 UdpServer 彻底解耦,后续要加 "消息过滤""禁言功能",只需要修改 RouteChat,不用动网络层的一行代码;线程池的引入,则是从 "能跑" 到 "跑得快" 的升级 ------ 我们意识到单线程的瓶颈,于是用生产者 - 消费者模型让主线程专注接收消息,线程池负责转发,这不仅是并发的实践,更是对 "资源调度" 的理解:计算机的性能不是靠单点突破,而是靠合理分配。
过程中踩过的坑,现在想来都是最珍贵的收获。比如最开始忘记给客户端列表加锁,导致多线程操作时程序崩溃,这才真正理解 "线程安全不是口号,是每一次对共享资源的敬畏";比如用 inet_ntoa 转换 IP 时,多客户端同时发送消息会出现 IP 错乱,这才明白 "为什么要选线程安全的函数"------ 书本上的 "线程不安全" 四个字,远不如一次真实的 bug 来得刻骨铭心;比如客户端用单线程时,发消息就收不到消息,收消息就发不了消息,这才懂 "双线程解耦" 不是为了炫技,而是为了贴合用户的真实使用场景:没有人愿意发一条消息后,必须等别人回复才能继续输入。
我们常说 "编程是一门实践的艺术",这个聊天室就是最好的印证。从基础的 Socket 创建、bind 绑定,到复杂的线程池设计、回调函数解耦,每一个知识点都不是孤立的:字节序是网络通信的 "通用语言",结构体是数据组织的 "骨架",锁是并发的 "交通规则",封装是代码的 "模块化思维"...... 它们像一块块积木,我们亲手把它们搭成了一个能跑、能用、能扩展的系统。这个系统或许简陋,没有 GUI 界面,没有消息持久化,没有加密功能,但它完整地覆盖了网络编程的核心链路:从客户端到服务端的通信,从数据传输到业务处理,从单线程到多线程的优化。
更重要的是,我们透过这个小项目,看到了真实世界的网络应用缩影。为什么大厂的即时通讯工具要用 UDP?为什么服务端要做集群?为什么要设计线程池而不是无限创建线程?这些问题的答案,都藏在我们写的每一行代码里:UDP 的轻量对应着高并发,线程池的复用对应着资源节约,解耦的设计对应着易维护性。我们不是在写 "玩具代码",而是在复刻真实系统的 "最小模型"------ 今天我们能实现一个支持 10 个客户端的聊天室,明天就能通过优化,让它支持 100 个、1000 个客户端;今天我们用 vector 管理客户端,明天就能换成 unordered_set 提升查找效率;今天我们用纯文本传输,明天就能加协议头、做消息加密。
这个聊天室的终点,从来不是 "完成功能",而是 "打开一扇门"。它让我们从 "只会写单机程序",走到了 "能写网络程序";从 "只会处理单任务",走到了 "能处理并发任务";从 "写一次性代码",走到了 "写可扩展、可维护的代码"。网络编程的世界很大,TCP 的可靠传输、HTTP 的应用层协议、RPC 的远程调用...... 还有太多东西等着我们去探索,但这个 UDP 聊天室,是我们迈出的第一步,是我们亲手搭建的第一个 "网络世界"。
最后想说,编程的乐趣从来不是 "一次性写对代码",而是在调试中成长,在优化中精进,在看到自己的代码真正解决问题时,那份发自内心的喜悦。当你看到自己写的服务端能同时响应多个客户端,看到不同的 IP 地址在终端里聊成一片,你会明白:那些熬过的夜、调过的 bug、查过的手册,都变成了刻在脑子里的能力,变成了面对更复杂问题时的底气。
这个 "简单" 的 UDP 聊天室,是终点,更是起点。网络编程的路还很长,愿我们永远保持敲下第一行代码时的好奇,永远在解决问题中成长,永远能从一行行代码里,看到技术的温度和力量。下一个项目,我们再见;下一个难题,我们一起拆解。编程的路上,独行快,众行远,但只要不停下脚步,每一步都算数。