简单聊天室:UDP通信实现
简单聊天室
在计算机网络编程中,实时通信是一个引人入胜的领域。今天我们将探讨如何利用UDP协议实现一个简单的聊天室系统。与TCP不同,UDP提供了一种轻量级、无连接的通信方式,特别适合实时性要求高的应用场景。
UDP vs TCP:为何选择UDP?
UDP(用户数据报协议)与TCP的主要区别在于其无连接特性。TCP像打电话——需要建立连接、保证顺序和可靠性;而UDP像寄明信片——发送出去就不管了,不保证对方一定能收到,也不保证顺序。
这种“不可靠”反而成了优势:
-
更低的开销:没有连接建立和维护的开销
-
更快的速度:没有确认和重传机制
-
广播/多播支持:可以向多个客户端同时发送消息
-
实时性:更适合音视频流、在线游戏等实时应用
核心设计思路
我们的简单聊天室将采用客户端-服务器架构,但UDP的服务器更像一个消息中转站:
客户端A --> 服务器 --> 客户端B、C、D...
当某个客户端发送消息时,服务器接收后将其广播给所有其他在线客户端。
实现步骤详解
1. 服务器端实现
服务器需要完成三个主要任务:
-
监听指定端口,接收客户端消息
-
维护在线客户端列表
-
将收到的消息转发给所有其他客户端
Route.hpp
#pragma once
#include
#include
#include
#include "InetAddr.hpp"
#include "Log.hpp"
using namespace LogModule;
class Route
{
private:
bool IsExist(InetAddr &peer)
{
for (auto &user : _online_user)
{
if (user == peer)
{
return true;
}
}
return false;
}
void AddUser(InetAddr &peer)
{
LOG(LogLevel::INFO) << "新增⼀个在线⽤⼾: " << peer.StringAddr();
_online_user.push_back(peer);
}
void DeleteUser(InetAddr &peer)
{
for (auto iter = _online_user.begin(); iter != _online_user.end();
iter++)
{
if (*iter == peer)
{
LOG(LogLevel::INFO) << "删除⼀个在线⽤⼾:" << peer.StringAddr()
<< "成功";
_online_user.erase(iter);
break;
}
}
}
public:
Route()
{
}
void MessageRoute(int sockfd, const std::string &message, InetAddr &peer)
{
if (!IsExist(peer))
{
AddUser(peer);
}
std::string send_message = peer.StringAddr() + "# " + message; //127.0.0.1 : 8080 #你好
// TODO
for (auto &user : _online_user)
{
sendto(sockfd, send_message.c_str(), send_message.size(), 0, (const struct sockaddr *)&(user.NetAddr()), sizeof(user.NetAddr()));
}
// 这个⽤⼾⼀定已经在线了
if (message == "QUIT")
{
LOG(LogLevel::INFO) << "删除⼀个在线⽤⼾: " << peer.StringAddr();
DeleteUser(peer);
}
}
~Route()
{
}
private:
// ⾸次给我发消息,等同于登录
std::vector _online_user; // 在线⽤⼾
};
UdpServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include "Log.hpp"
#include "InetAddr.hpp"
using namespace LogModule;
using func_t = std::function;
const int defaultfd = -1;
// 你是为了进⾏⽹络通信的!
class UdpServer
{
public:
UdpServer(uint16_t port, func_t func)
: _sockfd(defaultfd),
// _ip(ip),
_port(port),
_isrunning(false),
_func(func)
{
}
void Init()
{
// 1. 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "socket error!";
exit(1);
}
LOG(LogLevel::INFO) << "socket success, sockfd : " << _sockfd;
// 2. 绑定socket信息,ip和端⼝, ip(⽐较特殊,后续解释)
// 2.1 填充sockaddr_in结构体
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
// 我会不会把我的IP地址和端⼝号发送给对⽅?
// IP信息和端⼝信息,⼀定要发送到⽹络!
// 本地格式->⽹络序列
local.sin_port = htons(_port);
// IP也是如此,1. IP转成4字节 2. 4字节转成⽹络序列 -> in_addr_t
inet_addr(const char *cp);
// local.sin_addr.s_addr = inet_addr(_ip.c_str()); // TODO
local.sin_addr.s_addr = INADDR_ANY;
// 那么为什么服务器端要显式的bind呢?IP和端⼝必须是众所周知且不能轻易改变的!
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(2);
}
LOG(LogLevel::INFO) << "bind success, sockfd : " << _sockfd;
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 1. 收消息, client为什么要个服务器发送消息啊?不就是让服务端处理数据。
ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr *)&peer, &len);
if (s > 0)
{
InetAddr client(peer);
buffer[s] = 0;
// TODO
_func(_sockfd, buffer, client);
// LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" <<
peer_port << "]# " << buffer; // 1. 消息内容 2. 谁发的??
// 2. 发消息
// std::string echo_string = "server echo@ ";
// echo_string += buffer;
// sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr*)&peer, len);
}
}
}
~UdpServer()
{
}
private:
int _sockfd;
uint16_t _port;
// std::string _ip; // ⽤的是字符串⻛格,点分⼗进制, "192.168.1.1"
bool _isrunning;
func_t _func; // 服务器的回调函数,⽤来进⾏对数据进⾏处理
};
• 引⼊线程池,这⾥就不重复贴代码了
InetAddr.hpp
#pragma once
#include
#include
#include
#include
#include
#include
class InetAddr
{
public:
InetAddr(struct sockaddr_in &addr) : _addr(addr)
{
_port = ntohs(_addr.sin_port);
_ip = inet_ntoa(_addr.sin_addr);
}
std::string Ip() { return _ip; }
uint16_t Port() { return _port; };
std::string PrintDebug()
{
std::string info = _ip;
info += ":";
info += std::to_string(_port); // "127.0.0.1:4444"
return info;
}
const struct sockaddr_in &GetAddr()
{
return _addr;
}
bool operator==(const InetAddr &addr)
{
// other code
return this->_ip == addr._ip && this->_port == addr._port;
}
~InetAddr() {}
private:
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
};
• 在InetAddr中,重载⼀下 == ⽅便对⽤⼾是否是同⼀个进⾏⽐较
ServerMain.cc
// 单进程服务器
// std::unique_ptr usvr = std::make_unique(port, [&r]
(int sockfd, const std::string &message, InetAddr &peer)
{
// r.MessageRoute(sockfd, message, peer);
// });
// 进程池服务器
// 1. 路由服务
std::unique_ptr r = std::make_unique();
// 2. 线程池
auto tp = ThreadPool::GetInstance();
// 3. ⽹络服务器对象,提供通信功能
std::unique_ptr usvr = std::make_unique(port, [&r,
&tp](int sockfd, const std::string &message, InetAddr &peer)
{
task_t t = std::bind(&Route::MessageRoute, r.get(), sockfd, message,
peer);
tp->Enqueue(t); });
2. 客户端实现
客户端需要两个并行线程:
-
发送线程:处理用户输入并发送到服务器
-
接收线程:监听服务器发来的消息
UdpClient.hpp
#include
#include
#include
#include
#include
#include /* See NOTES */
#include
#include
#include
#include "Thread.hpp"
#include "InetAddr.hpp"
void Usage(const std::string &process)
{
std::cout << "Usage: " << process << " server_ip server_port" << std::endl;
}
class ThreadData
{
public:
ThreadData(int sock, struct sockaddr_in &server) : _sockfd(sock),
_serveraddr(server)
{
}
~ThreadData()
{
}
public:
int _sockfd;
InetAddr _serveraddr;
};
void RecverRoutine(ThreadData &td)
{
char buffer[4096];
while (true)
{
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t n = recvfrom(td._sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr *)&temp, &len); // ⼀般建议都是要填的.
if (n > 0)
{
buffer[n] = 0;
std::cerr << buffer << std::endl; // ⽅便⼀会查看效果
}
else
break;
}
}
// 该线程只负责发消息
void SenderRoutine(ThreadData &td)
{
while (true)
{
// 我们要发的数据
std::string inbuffer;
std::cout << "Please Enter# ";
std::getline(std::cin, inbuffer);
auto server = td._serveraddr.GetAddr();
// 我们要发给谁呀?server
ssize_t n = sendto(td._sockfd, inbuffer.c_str(), inbuffer.size(), 0,
(struct sockaddr *)&server, sizeof(server));
if (n <= 0)
std::cout << "send error" << std::endl;
}
}
// ./udp_client server_ip server_port
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
return 1;
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 1. 创建socket
// udp是全双⼯的。既可以读,也可以写,可以同时读写,不会多线程读写的问题
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
std::cerr << "socket error: " << strerror(errno) << std::endl;
return 2;
}
std::cout << "create socket success: " << sock << std::endl;
// 2. client要不要进⾏bind? ⼀定要bind的!!但是,不需要显⽰bind,client会在⾸次发送数据的时候会⾃动进⾏bind
// 为什么?server端的端⼝号,⼀定是众所周知,不可改变的,client 需要 port,bind随机端⼝.
// 为什么?client会⾮常多.
// client 需要bind,但是不需要显⽰bind,让本地OS⾃动随机bind,选择随机端⼝号
// 2.1 填充⼀下server信息
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());
ThreadData td(sock, server);
Thread recver("recver", RecverRoutine, td);
Thread sender("sender", SenderRoutine, td);
recver.Start();
sender.Start();
recver.Join();
sender.Join();
close(sock);
return 0;
}
• UDP协议⽀持全双⼯,⼀个sockfd,既可以读取,⼜可以写⼊,对于客⼾端和服务端同样如此
• 多线程客⼾端,同时读取和写⼊
• 测试的时候,使⽤管道进⾏演⽰

关键技术细节
消息格式设计
虽然UDP不保证可靠性,但我们可以设计简单的消息格式来增强功能:
[消息类型]|[发送时间]|[用户名]|[消息内容]
例如:
MSG|2023-10-01 14:30:00|Alice|大家好!
补充参考内容
地址转换函数
本节只介绍基于IPv4的socket⽹络编程,sockaddr_in中的成员struct in_addr sin_addr表⽰32位 的IP地址
但是我们通常⽤点分⼗进制的字符串表⽰IP 地址,以下函数可以在字符串表⽰ 和in_addr表⽰之间转换;
字符串转in_addr的函数:

in_addr转字符串的函数:

其中inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此函数接⼝是 void *addrptr。
代码⽰例:

关于inet_ntoa
inet_ntoa这个函数返回了⼀个char*, 很显然是这个函数⾃⼰在内部为我们申请了⼀块内存来保存ip的结果. 那么是否需要调⽤者⼿动释放呢?

man⼿册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区. 这个时候不需要我们⼿动进⾏释
放.那么问题来了, 如果我们调⽤多次这个函数, 会有什么样的效果呢? 参⻅如下代码:

运⾏结果如下:

因为inet_ntoa把结果放到⾃⼰内部的⼀个静态存储区, 这样第⼆次调⽤时的结果会覆盖掉上⼀次的结果.
• 思考: 如果有多个线程调⽤ inet_ntoa, 是否会出现异常情况呢?
• 在APUE中, 明确提出inet_ntoa不是线程安全的函数;
• 但是在centos7上测试, 并没有出现问题, 可能内部的实现加了互斥锁;
• 同学们课后⾃⼰写程序验证⼀下在⾃⼰的机器上inet_ntoa是否会出现多线程的问题;
• 在多线程环境下, 推荐使⽤inet_ntop, 这个函数由调⽤者提供⼀个缓冲区保存结果, 可以规避线程安全问题;
多线程调⽤inet_ntoa代码⽰例如下
#include
#include
#include
#include
#include
#include
void *Func1(void *p)
{
struct sockaddr_in *addr = (struct sockaddr_in *)p;
while (1)
{
char *ptr = inet_ntoa(addr->sin_addr);
printf("addr1: %s
", ptr);
}
return NULL;
}
void *Func2(void *p)
{
struct sockaddr_in *addr = (struct sockaddr_in *)p;
while (1)
{
char *ptr = inet_ntoa(addr->sin_addr);
printf("addr2: %s
", ptr);
}
return NULL;
}
int main()
{
pthread_t tid1 = 0;
struct sockaddr_in addr1;
struct sockaddr_in addr2;
addr1.sin_addr.s_addr = 0;
addr2.sin_addr.s_addr = 0xffffffff;
pthread_create(&tid1, NULL, Func1, &addr1);
pthread_t tid2 = 0;
pthread_create(&tid2, NULL, Func2, &addr2);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return 0;
}
remove_if 样例
#include
#include
#include
#include
int main()
{
std::list> ls;
ls.push_back(std::make_shared(1));
ls.push_back(std::make_shared(2));
ls.push_back(std::make_shared(3));
ls.push_back(std::make_shared(4));
ls.push_back(std::make_shared(4));
ls.push_back(std::make_shared(4));
ls.push_back(std::make_shared(5));
ls.push_back(std::make_shared(6));
for (auto &v : ls)
{
std::cout << *v << std::endl;
}
std::cout << "aa: " << ls.size() << std::endl;
std::cout << "
";
// int a = 3;
int a = 4;
auto pos = remove_if(ls.begin(), ls.end(), [&a](const std::shared_ptr &elem) -> bool
{ return a == *elem; });
ls.erase(pos, ls.end());
std::cout << "aa: " << ls.size() << std::endl;
for (auto &v : ls)
{
std::cout << *v << std::endl;
}
return 0;
}
//remove_if()并不会实际移除序列[start, end)中的元素; 如果在⼀个容器上应⽤
remove_if(), 容器的⻓度并不会改变, 所有的元素都还在容器⾥⾯(但是逻辑上已经⽆法访问).
remove_if()将所有应该移除的元素都移动到容器尾部并返回⼀个分界的迭代器. 为了实际移除元
素, 你必须对容器⾃⾏调⽤erase()以擦除需要移除的元素
处理丢包问题
UDP不保证可靠传输,在聊天室中我们可以采取折中方案:
-
重要消息(如系统通知)可以重复发送
-
普通消息允许偶尔丢失
-
添加序列号检测是否有消息丢失
完整工作流程
-
客户端启动:输入用户名,连接到服务器
-
注册过程:客户端发送注册消息,服务器将其加入客户端列表
-
消息发送:用户输入消息,客户端发送到服务器
-
消息转发:服务器接收消息,转发给所有其他客户端
-
消息显示:客户端接收并显示消息
-
客户端退出:发送退出消息,服务器从列表移除
进阶优化建议
1. 加入房间功能
JOIN_ROOM|游戏大厅
LEAVE_ROOM|游戏大厅
2. 私聊功能
PRIVATE|Bob|你好,这是私密消息
3. 文件传输支持
通过将文件分片为多个UDP包发送,但需要考虑丢包重传机制。
4. NAT穿透支持
对于位于路由器后的客户端,需要实现STUN-like机制。
UDP聊天室的局限性
尽管UDP聊天室有诸多优点,但也需要注意:
-
消息大小限制:UDP单包通常不超过1472字节(考虑MTU)
-
安全性:UDP更容易遭受伪造攻击,应考虑加密
-
可靠性:不适合传输必须保证到达的数据
-
拥塞控制:大量数据可能导致网络拥塞
总结
通过UDP实现聊天室是一个绝佳的学习项目,它帮助我们理解:
-
UDP协议的无连接特性
-
实时通信的基本原理
-
网络编程中的并发处理
-
简单协议设计方法
虽然这个聊天室简单,但它包含了实时通信系统的核心要素。你可以在此基础上添加更多功能:表情支持、消息历史、语音聊天等。
UDP以其简洁高效的特点,在实时通信领域占据重要地位。从视频会议到在线游戏,许多我们日常使用的应用都依赖于UDP或类似的无连接协议。理解UDP通信的实现,是进入实时网络编程世界的重要一步。








