一、项目背景
前两个项目我们分别实现了 UDP 英译汉翻译服务器(单方向请求 - 响应)和 UDP 双向通信程序(一对一实时通信),本次将实现 UDP Socket 编程的综合实战项目 ------UDP 群聊聊天室,也是 UDP 无连接、全双工特性的典型应用
该项目基于 C/S 架构,采用 UDP 协议实现,核心功能是:多个客户端可同时连接服务端,任意一个客户端发送的消息,服务端会广播给所有在线的客户端,实现真正的 "群聊" 效果;同时支持客户端上线通知、下线通知、在线用户管理等功能
二、项目整体设计
1. 架构设计
- 服务端 :核心是消息广播 和在线用户管理,监听固定 UDP 端口,维护所有在线客户端的地址信息(IP + 端口);接收任意客户端的消息,若为上线消息则添加客户端到在线列表,若为下线消息则移除,普通消息则广播给所有在线客户端;
- 客户端 :基于上一个项目的多线程架构(发送线程 + 接收线程),实现用户输入消息发送、实时接收服务端广播的群聊消息,支持上线、下线、发送普通消息。
- 通信协议:UDP(无连接,无需维护客户端连接状态,适合广播;实时性高,适合群聊场景)
2. 核心设计理念
1. 服务端 "无连接" 的在线用户管理
UDP 是无连接的,服务端无法主动检测客户端的在线状态,因此采用 **"首次发消息即上线"** 的设计:
- 客户端启动后,首次发送的任意消息(包括普通消息)作为 "上线通知",服务端接收到后,将客户端的地址信息(struct sockaddr_in)添加到在线用户列表(vector);
- 客户端输入指定指令(如
quit)作为 "下线通知",服务端接收到后,将该客户端从在线用户列表中移除,并广播 "XX 客户端已下线"; - 若客户端异常退出(如直接关闭终端),服务端不会主动移除,可后续扩展 "心跳检测" 机制实现自动下线。
2. 服务端消息广播的实现
广播的核心是:服务端接收到任意客户端的消息后,遍历在线用户列表 ,调用sendto()将消息依次发送给列表中的每一个客户端(排除发送方自身,可选)
因为 UDP 是无连接的,服务端只需知道客户端的 IP 和端口,即可直接发送消息,无需提前建立连接,这也是 UDP 实现广播比 TCP 更简单的核心原因(TCP 广播需要为每个客户端维护连接,资源开销大)
3. 代码高内聚低耦合 ------ 模块化封装
为了提高代码的可维护性和可扩展,采用模块化封装思想,将服务端拆分为三个独立的模块,通过接口交互:
- Socket 通信模块(UdpServer 类):封装 UDP Socket 的创建、绑定、接收、发送接口,只负责网络通信,不关心业务逻辑;
- 路由管理模块(Route 类):封装在线用户管理、消息广播、上下线处理等核心业务逻辑,是服务端的 "大脑";
- 主逻辑模块 :整合 UdpServer 类和 Route 类,通过回调函数将网络通信和业务逻辑解耦,启动服务端。
4. 客户端复用多线程架构
客户端完全复用上一个项目的多线程架构:发送线程负责读取用户输入并发送到服务端,接收线程负责接收服务端广播的群聊消息并实时打印,无需做大的修改,只需适配服务端的消息格式。
3. 核心功能需求
服务端
- 绑定固定 UDP 端口,支持多个客户端同时连接;
- 维护在线用户列表,支持客户端上线添加、下线移除;
- 接收客户端的消息,区分普通消息、下线消息;
- 实现消息广播:将普通消息发送给所有在线客户端;
- 广播上下线通知:客户端上线 / 下线时,广播通知所有在线客户端;
- 处理网络异常,保证服务端稳定运行(不会因单个客户端异常而崩溃)。
客户端
- 通过命令行指定服务端 IP 和端口,启动后即可进入群聊;
- 多线程实现同时收发:发送线程读取用户输入,接收线程接收广播消息;
- 实时打印服务端广播的群聊消息、上下线通知;
- 支持输入
quit优雅下线,服务端广播下线通知; - 处理网络异常(如服务端关闭、网络中断),优雅退出;
- 消息输入友好:避免接收消息覆盖输入提示。
4. 消息格式设计
为了让群聊消息更易读,服务端对广播的消息做统一格式化,格式为:
[发送方IP:端口] 时间 # 消息内容
上下线通知格式为:
【系统通知】[客户端IP:端口] 已上线/已下线
示例:
【系统通知】[127.0.0.1:42356] 已上线
[127.0.0.1:42356] 2024-05-21 15:30:00 # 大家好,我是新用户!
[192.168.1.101:56789] 2024-05-21 15:31:00 # 欢迎!
【系统通知】[127.0.0.1:42356] 已下线
三、核心技术点解析
1. UDP 广播的核心实现
UDP 广播的本质是遍历在线用户列表,逐个发送消息,核心代码逻辑:
// 在线用户列表:存储客户端的地址结构
vector<struct sockaddr_in> online_users;
// 广播消息:msg为要广播的消息,sender为发送方地址(可选排除)
void Broadcast(int sockfd, const string& msg, const struct sockaddr_in& sender) {
for (auto& user : online_users) {
// 可选:排除发送方自身,避免自己收到自己发送的消息
if (memcmp(&user, &sender, sizeof(user)) == 0) {
continue;
}
sendto(sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&user, sizeof(user));
}
}
该方式为应用层广播,区别于网络层的 UDP 广播(如 255.255.255.255),应用层广播更灵活,可精准控制广播的对象(仅在线客户端)
2. 在线用户管理 ------vector + 地址比对
服务端通过std::vector<struct sockaddr_in>存储在线客户端的地址结构,核心操作:
- 上线添加 :接收到客户端消息后,先判断该客户端是否在列表中(通过
memcmp比对地址结构),若不在则添加,并广播上线通知; - 下线移除 :接收到客户端的
quit消息后,遍历列表,找到对应的客户端地址,通过erase移除,并广播下线通知; - 地址比对 :使用
memcmp比对两个struct sockaddr_in结构体的内存,完全一致则为同一个客户端,避免 IP / 端口不一致的情况。
3. 回调函数解耦网络层与业务层
服务端的 UdpServer 类(网络层)和 Route 类(业务层)通过C++11 的 std::function 回调函数解耦,UdpServer 类只负责:
- 创建 Socket、绑定端口;
- 循环接收客户端消息,获取发送方地址;
- 调用外部传入的回调函数,将 Socket 描述符、消息、发送方地址传递给业务层。
Route 类则负责处理具体的业务逻辑:在线用户管理、消息格式化、广播消息,无需关心网络通信的细节。这种设计让 UdpServer 类成为通用的 UDP 服务端框架,可适配任意 UDP 业务场景。
4. 客户端多线程与输出优化
客户端完全复用前一个项目的发送线程 + 接收线程架构,核心优化点:
- 接收线程打印广播消息时,先换行再打印,避免覆盖 "我:" 的输入提示;
- 打印完成后执行
fflush(stdout)刷新输出缓冲区,保证输入提示正常显示; - 接收到服务端的系统通知时,用特殊符号(如【】)标记,与普通消息区分。
5. 时间戳格式化 ------ctime
服务端为广播消息添加时间戳 ,使用 C 标准库的ctime()函数将系统时间转换为字符串格式,核心代码:
#include <time.h>
// 获取当前时间戳,格式:YYYY-MM-DD HH:MM:SS
string GetTime() {
time_t now = time(nullptr);
struct tm* t = localtime(&now);
char buffer[64] = {0};
snprintf(buffer, sizeof(buffer), "%04d-%02d-%02d %02d:%02d:%02d",
t->tm_year + 1900, t->tm_mon + 1, t->tm_mday,
t->tm_hour, t->tm_min, t->tm_sec);
return string(buffer);
}
四、完整代码实现(C++)
环境说明
- 系统:Linux(CentOS/Ubuntu/ 阿里云服务器);
- 编译器:g++(支持 C++11 及以上,客户端多线程需加
-pthread); - 编译命令:服务端
g++ -o server server.cpp -std=c++11,客户端g++ -o client client.cpp -std=c++11 -pthread; - 运行方式:先启动服务端,再启动多个客户端,支持本地 / 远程多客户端同时群聊。
1. 服务端代码(udp_chat_server.cpp)
整合 UdpServer 类和 Route 类,实现群聊核心功能:
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cerrno>
#include <cstdlib>
#include <vector>
#include <functional>
#include <ctime>
using namespace std;
// 全局常量
const uint16_t DEFAULT_PORT = 8888;
const int BUFF_SIZE = 4096;
const string QUIT_CMD = "quit";
const string SYS_NOTICE_PREFIX = "【系统通知】";
// 工具函数:获取格式化的当前时间戳
string GetCurrentTime() {
time_t now = time(nullptr);
struct tm* local_tm = localtime(&now);
if (local_tm == nullptr) {
return "未知时间";
}
char time_buff[64] = {0};
snprintf(time_buff, sizeof(time_buff), "%04d-%02d-%02d %02d:%02d:%02d",
local_tm->tm_year + 1900, local_tm->tm_mon + 1, local_tm->tm_mday,
local_tm->tm_hour, local_tm->tm_min, local_tm->tm_sec);
return string(time_buff);
}
// 工具函数:将sockaddr_in转换为IP:端口字符串
string AddrToString(const struct sockaddr_in& addr) {
char buff[64] = {0};
snprintf(buff, sizeof(buff), "%s:%d", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
return string(buff);
}
// 路由管理类:封装在线用户管理、消息广播、业务逻辑
class Route {
private:
vector<struct sockaddr_in> _online_users; // 在线用户列表
// 判断客户端是否已在线
bool IsOnline(const struct sockaddr_in& client) {
for (const auto& user : _online_users) {
if (memcmp(&user, &client, sizeof(user)) == 0) {
return true;
}
}
return false;
}
// 添加客户端到在线列表
void AddOnlineUser(const struct sockaddr_in& client) {
if (!IsOnline(client)) {
_online_users.push_back(client);
}
}
// 从在线列表移除客户端
void RemoveOnlineUser(const struct sockaddr_in& client) {
for (auto iter = _online_users.begin(); iter != _online_users.end(); ++iter) {
if (memcmp(&(*iter), &client, sizeof(*iter)) == 0) {
_online_users.erase(iter);
break;
}
}
}
// 广播消息:排除发送方
void BroadcastMsg(int sockfd, const string& msg, const struct sockaddr_in& sender) {
for (const auto& user : _online_users) {
if (memcmp(&user, &sender, sizeof(user)) == 0) {
continue; // 排除发送方,不自己收自己的消息
}
sendto(sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&user, sizeof(user));
}
}
public:
// 核心业务处理:处理客户端消息,实现上线、下线、广播
void HandleMsg(int sockfd, const string& msg, const struct sockaddr_in& client) {
string client_addr = AddrToString(client);
// 1. 客户端下线
if (msg == QUIT_CMD) {
RemoveOnlineUser(client);
string notice = SYS_NOTICE_PREFIX + "[" + client_addr + "] 已下线";
cout << notice << endl;
BroadcastMsg(sockfd, notice, client);
return;
}
// 2. 客户端上线(首次发消息)
if (!IsOnline(client)) {
AddOnlineUser(client);
string notice = SYS_NOTICE_PREFIX + "[" + client_addr + "] 已上线";
cout << notice << endl;
BroadcastMsg(sockfd, notice, client);
}
// 3. 普通消息,格式化后广播
string format_msg = "[" + client_addr + "] " + GetCurrentTime() + " # " + msg;
cout << format_msg << endl;
BroadcastMsg(sockfd, format_msg, client);
}
};
// UDP服务端类:通用网络通信框架,解耦业务逻辑
class UdpServer {
private:
int _sockfd;
uint16_t _port;
function<void(int, const string&, const struct sockaddr_in&)> _handler; // 业务处理回调函数
// 初始化Socket:创建+绑定
bool Init() {
// 创建UDP Socket
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0) {
cerr << "创建Socket失败:" << strerror(errno) << endl;
return false;
}
// 绑定IP和端口
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(_sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
cerr << "绑定端口" << _port << "失败:" << strerror(errno) << endl;
close(_sockfd);
return false;
}
return true;
}
public:
// 构造函数:传入端口和业务处理回调函数
UdpServer(uint16_t port = DEFAULT_PORT, function<void(int, const string&, const struct sockaddr_in&)> handler = nullptr)
: _port(port), _handler(handler), _sockfd(-1) {}
// 析构函数:关闭Socket
~UdpServer() {
if (_sockfd >= 0) {
close(_sockfd);
}
}
// 启动服务端:循环接收消息,调用回调函数处理
void Start() {
if (!Init() || _handler == nullptr) {
cerr << "服务端启动失败" << endl;
return;
}
cout << "UDP群聊聊天室服务端启动成功,监听端口:" << _port << endl;
cout << "等待客户端连接...(Ctrl+C退出)" << endl;
char buff[BUFF_SIZE] = {0};
struct sockaddr_in client;
socklen_t client_len = sizeof(client);
while (true) {
// 接收客户端消息
memset(buff, 0, sizeof(buff));
ssize_t recv_len = recvfrom(_sockfd, buff, sizeof(buff)-1, 0, (struct sockaddr*)&client, &client_len);
if (recv_len < 0) {
cerr << "接收消息失败:" << strerror(errno) << ",继续等待
c++枚举里面USAGE_ERR是什么意思
在 C++ 枚举中,USAGE_ERR 是一个自定义的枚举常量 ,并非 C++ 语言内置关键字,其核心含义是「用法错误 / 参数使用错误」,通常用于程序的错误码定义,专门标识「用户使用程序的方式不符合要求」(比如命令行参数输入错误)。
一、USAGE_ERR 的核心含义与使用场景
USAGE_ERR 是英文「Usage Error」的缩写,直译就是「用法错误」,最常见的使用场景是:
- 命令行程序中,用户输入的参数数量、格式、取值不符合程序要求(比如需要 2 个参数却只输了 1 个,端口号输入了非数字等)
- 函数调用时,传入的参数类型 / 范围 / 顺序不符合函数的使用规范
- 枚举中定义该常量,用于统一管理错误码,让程序的错误提示更清晰、可维护
二、典型示例:枚举中定义 USAGE_ERR
下面是实际开发中最常见的写法 ------ 用枚举封装所有错误码,USAGE_ERR 作为其中一个枚举值:
#include <iostream>
#include <string>
using namespace std;
// 定义错误码枚举:统一管理程序中所有可能的错误类型
enum ErrorCode {
SUCCESS = 0, // 成功(错误码通常0表示无错误)
USAGE_ERR = 1, // 用法错误(参数数量/格式不对)
PORT_ERR = 2, // 端口错误(端口号超出范围)
IP_ERR = 3, // IP地址错误(格式非法)
SOCKET_ERR = 4 // Socket创建错误
};
// 命令行参数校验函数:返回错误码枚举
ErrorCode CheckArgs(int argc, char* argv[]) {
// 要求参数数量为3(./程序名 IP 端口),否则返回USAGE_ERR
if (argc != 3) {
return USAGE_ERR;