基于UDP协议的群聊服务器开发(C/C++)
目录
服务器
一、通信
打开网络文件
绑定IP地址与端口号
接收信息
二、数据处理
客户端
三、端口绑定
四、收发信息
五、源码
服务器
在服务器架构设计中,模块解耦是保障系统可维护性的核心准则。本方案采用分层架构将核心功能拆解为通信层与业务处理层两大模块。值得注意的是,当使用TCP协议时,开发者往往需要额外设计协议抽象层来解决其字节流特性导致的消息边界模糊问题(如粘包/拆包处理),并通过重传机制强化传输可靠性。而UDP的面向报文特性天然规避了消息边界问题,其无状态传输模型大幅简化了基础通信层的设计复杂度——这正是我们选择UDP构建轻量化实时群聊系统的关键原因。话不多说,我们直接开始!
框架设计
创建核心文件:UdpServer.hpp、UdpServer.cc、UdpClient.cc,当然不止于这些,在完成这些文件的过程中会延伸出更多文件,如果在这里写显得有些突兀。
- UdpServer.hpp:服务器相关的类以及类方法的实现——主要完成通信功能。
- UdpServer.cc:服务器主函数(main)的实现——对服务器接口的调用,即启动服务器。
- UdpClient.cc:客户端主函数(main)的实现——启动客户端,与服务器通信。
一、通信
上来直接创建一个class UdpServer类,而对于成员变量和成员函数的设定。我们得理一理进行通信需要完成什么,它完全是套路式的,模板化的。即:打开网络文件,绑定端口,收数据_处理数据_发数据。(针对UDP协议通信)
根据这三点我们设计成员函数:
- int _socketfd:网络文件描述符。
- uint16_t _port:端口号。对于IP地址我们不期望从外部传入,所以暂且不用设。
- 数据处理函数:这个成员到后文数据处理再设计。
对于成员函数
- void Init():完成打开网络文件,绑定端口。
- void Start():启动服务,完成收数据_处理数据_发数据,其中处理数据以回调的方式完成(为了让模块解耦,方便模块之间的拼接和替换)。
如下:
class UdpServer
{
public:UdpServer(uint16_t port): _socketfd(-1), _port(port){}void Init();void Start();
private:int _socketfd;uint16_t _port;//......
};
void Init ()
1.打开网络文件
socket的使用
socket函数的声明:
int socket(int domain, int type, int protocol);
功能:打开网络文件(套接字)。
- 参数domain:确定IP地址类型,如IPv4还是IPv6。
AF_INET
: IPv4。
AF_INET6:
IPv6。- 参数type:确定数据的传输方式。
SOCK_STREAM
: 流式套接字(TCP)。
SOCK_DGRAM
: 数据报套接字(UDP)。- 参数protocol:确定协议类型,如果前面type已经能确定了,这里传入0即可。
- 返回值:
- 成功:文件描述符。
- 失败:一个小于0的数。
代码示例:
// 打开网络文件 IPv4 数据包 udp
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socketfd < 0)
{LOG(Level::ERROR) << "socket() fail";exit(1);
}
else
{LOG(Level::INFO) << "socket() succee _socketfd:" << _socketfd;
}
说明:LOG是我写的一个打印日志的接口, 大家把它当作cout理解就行,当然需要日志源码的可以私信我。
2.绑定ip地址与端口号
sockaddr_in结构
首先我们需要了解sockaddr_in结构,IP地址和端口号等信息要包装在这个结构里面,然后使用bind函数绑定。
sockaddr_in是用于 IPv4 网络编程 的一个核心数据结构,用于存储套接字地址信息(IP地址和端口号),除此之外还有sockaddr_in6(IPv6),sockaddr_un(本地通信),sockaddr(用来屏蔽包括但不止于以上三种结构的底层实现)。
sockaddr_in结构如下:
#include <netinet/in.h>struct sockaddr_in {sa_family_t sin_family; // 地址族(Address Family)in_port_t sin_port; // 端口号(Port Number)struct in_addr sin_addr; // IPv4 地址(IP Address)char sin_zero[8]; // 填充字段(Padding)
};// IPv4 地址结构(嵌套在 sockaddr_in 中)
struct in_addr {in_addr_t s_addr; // 32位IPv4地址(网络字节序)
};
创建 sockaddr_in 对成员进行设定:
- sin_family:我们设为
AF_INET,即IPv4。
- sin_port:使用成员变量_port,但需要使用函数htons转为网络字节序(即大端)。
- sin_addr:IP地址通常都是点分十进制的字符串,所以需要把IP转成4字节,然后4字节转成网络序列,库提供了inet_addr函数,可以完成这个功能。不过这里我们把它直接设为INADDR_ANY,表示本主机上的所有IP都绑定到服务器,这样的话外部客户端连接任意IP都能连接到该主机。
- 最后一个成员暂且用不着,不用管。
bind函数的使用
bind声明
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
功能:用来绑定端口。
- 参数sockfd:要绑定的套接字描述符(由
socket()
函数创建)。- 参数sockaddr:指向地址结构体的指针,包含绑定的IP地址和端口号。
- 参数addrlen:地址结构体的长度(单位:字节)。
- 返回值:
- 0:成功。
- 非0:失败。
代码示例:
sockaddr_in sd;
bzero(&sd, sizeof(sd));//初始化为0
sd.sin_family = AF_INET;
sd.sin_port = htons(_port);
// sd.sin_addr.s_addr = inet_addr(_ip.c_str());
sd.sin_addr.s_addr = INADDR_ANY;
// 绑定ip地址与端口号
int n = bind(_socketfd, (const sockaddr *)&sd, sizeof(sd));
if (n != 0)
{LOG(Level::FATAL) << "bind fial";exit(1);
}
else
{LOG(Level::INFO) << "bind success";
}
由于后面会对sockaddr_in频繁操作,所以在这里封装一个InetAddr类放在InetAddr文件里,这里就不讲解具体的细节了,如下:
class InetAddr
{
public:InetAddr(){}InetAddr(sockaddr_in &peer): _addr(peer){_port = ntohs(peer.sin_port);//网络序列转为主机序列_ip = inet_ntoa(peer.sin_addr);//4字节转为点分十进制}InetAddr(uint16_t port, string ip): _port(port), _ip(ip){_addr.sin_family = AF_INET;_addr.sin_port = htons(_port);_addr.sin_addr.s_addr = inet_addr(_ip.c_str());}string tostring_port(){return to_string(_port);}string tostring_ip(){return _ip;}bool operator==(InetAddr addr){return _port == addr._port && _ip == addr._ip;}sockaddr_in &getaddr(){return _addr;}
private:uint16_t _port;string _ip;sockaddr_in _addr;
};
void start ()
3.接收信息
UDP协议数据的接收使用的是recvfrom函数,recvfrom函数的使用:
recvfrom函数声明:
ssize_t recvfrom(int sockfd, // 套接字描述符void *buf, // 接收数据的缓冲区size_t len, // 缓冲区最大长度int flags, // 标志位(通常设为0)struct sockaddr *src_addr, // 发送方的地址信息(可选)socklen_t *addrlen // 地址结构体的长度(输入输出参数) );
sockfd
UDP套接字的网络文件描述符(需已绑定端口)。 buf
指向接收数据的缓冲区,用于存储接收到的数据。 len
缓冲区的最大容量(单位:字节),防止数据溢出。 flags
控制接收行为的标志位,常用值:
0
(默认阻塞)、MSG_DONTWAIT
(非阻塞)。src_addr
指向 struct sockaddr
的指针,用于存储发送方的地址信息(IP和端口)。若不需要可设为NULL
。addrlen
输入时为 src_addr
结构体的长度,输出时为实际地址长度。需初始化为sizeof(struct sockaddr)
。
返回值 含义 >0
成功接收的字节数。 0
(仅TCP有意义,UDP一般不会返回0)。 -1
发生错误,检查 errno
获取具体原因
注:千万不要把时间花在记函数参数列表上,这么多函数你是记不了的,只需要看懂就行,函数的参数列表在编译器上通常都会有提示的。只需要把鼠标指针停留在对应的函数名上,如下:
代码示例:
while (true)
{// 收各个客户端发来的消息sockaddr_in client;socklen_t len = sizeof(client);char buffer[1024];int n = recvfrom(_socketfd, buffer, sizeof(buffer), 0, (sockaddr *)&client, &len);buffer[n] = '\0';//回调函数处理数据//......
}
到这里为止通信问题就解决了,只需要静等客户端发数据就行。接下来就是数据处理。
二、数据处理
别忘了我们要做的是群聊服务器,刚才我们不管三七二十一先把通信问题解决,这个的做法是很正确的,因为通信本来就是一个模板化的问题,其次它和其他模块是解耦的,在编写过程中并不用考虑数据怎么处理。
群聊服务器如何实现?
原理很简单,把一个客户发来的消息再发送给与它连接的所有客户。
我们需要做什么呢?
把与它连接的所有客户的IP和端口号(InetAddr)都存储起来,当有客户给它服务器发信息,服务器再把信息转发给所有客户。
这个功能我们单独做一个类Route,用来做消息路由,放在新建头文件Route.hpp里。
Route的实现很简单,只需要一个成员函数用来收发数据,一个成员变量用来存储与它连接的客户端信息。如下:
class Route
{
public:Route(){}void Handler(int socketfd,string message,InetAddr client);
private:vector<InetAddr> _data;
};
因为收数据的功能在通信模块已经做了,直接让它把网络文件描述符,数据和客户端信息传给Handler就行。其次做两个小函数,Push:把客户端信息插入数组,Pop:把客户端信息移除数组。
代码示例:
class Route
{
private:void Push(InetAddr peer){for (auto val : _data){//如果已经有了,就直接退出if (val == peer) return;}_data.push_back(peer);LOG(Level::INFO)<<peer.tostring_ip()<<'|'<<peer.tostring_port()<<" online";}bool Pop(InetAddr peer){//用户退出连接后,把它移除数组_data.erase(peer);return true;}public:Route(){}void Handler(int socketfd,string message,InetAddr client){Push(client);// 谁发的信息要知道吧?所以添加客户的信息string send_message = client.tostring_ip() + " | " + client.tostring_port() + ": ";send_message += message;// 发给所有在线的客户端for (auto val : _data){if(val == client) continue;sendto(socketfd, send_message.c_str(), send_message.size(), 0, (sockaddr *)&val.getaddr(), sizeof(val.getaddr()));}}
private:vector<InetAddr> _data;
};
sendto接口和recvfrom很类似,如下:
sendto的声明:
参数详解ssize_t sendto(int sockfd, // 套接字描述符const void *buf, // 待发送数据的缓冲区size_t len, // 数据长度(字节)int flags, // 控制标志(通常设为0)const struct sockaddr *dest_addr, // 目标地址(IP和端口)socklen_t addrlen // 目标地址结构体的长度 );
sockfd
UDP 套接字的描述符(无需提前连接)。 buf
指向待发送数据的缓冲区(如字符串、二进制数据)。 len
数据的实际长度(单位:字节)。 flags
控制发送行为的标志位,常用值:
0
(默认阻塞)、MSG_DONTWAIT
(非阻塞)。dest_addr
指向目标地址的结构体(如 sockaddr_in
),需强制转换为sockaddr*
。addrlen
目标地址结构体的长度(如 sizeof(struct sockaddr_in)
)。返回值
返回值 含义 >0
成功发送的字节数(可能与 len
不同,需检查是否完全发送)。-1
发送失败,检查 errno
获取具体错误原因。
这里有个很尴尬的事,服务器并不知道客户端什么时候退出,可以说是UDP协议的特点吧,所以Pop函数什么时候调用并不知道,除非客户在退出时给服务器发一条特殊信息表明客户要退出。这里先这样。
现在为止服务器相关的通信接口和数据处理方法已经准备好了,接下来实现UdpClient.cc文件,即main函数,把服务器调用起来。
主要实现以下几点:
- 要给服务器设定端口号,需要从程序外部传入,即命令行参数。
- 创建数据处理的类(Route)。
- 创建服务器,把端口号和数据处理方法(即回调方法)传入,启动服务器。
1,2比较简单,接下来讲解第3点。
还记得开头在UdpServer里我们缺少的成员变量数据处理函数吗?现在我们知道它是谁了,即:
- void Handler(int socketfd,string message,InetAddr client)
这里我们写规范一点,声明一个类型:
- using funcType = function<void(int, string, InetAddr)>;
然后添加成员变量funcType _func,并在构造函数的参数列表进行初始化。
最后在Start中调用_func函数(即回调),如下:
void Start()
{while (true){// 收各个客户端发来的消息sockaddr_in client;socklen_t len = sizeof(client);char buffer[1024];int n = recvfrom(_socketfd, buffer, sizeof(buffer), 0, (sockaddr *)&client, &len);buffer[n] = '\0';_func(_socketfd,string(buffer),InetAddr(client));}
}
可优化点:把_func当作任务,推入线程池。
现在创建UdpServer两个参数,一个是port(端口号),另一个是func(数据处理方法),对于func我们可以以lambda表达式的方式传入。如下:
int main(int argc, char *argv[])
{if (argc != 2){std::cerr << "Usage" << argv[0] << "port" << std::endl;return 1;}std::string port = argv[1];// 路由Route rt;// 通信unique_ptr<UdpServer> us = make_unique<UdpServer>(stoi(port), [&](int socketfd, string meassge, InetAddr client){ rt.Handler(socketfd, meassge, client); });us->Init();us->Start();return 0;
}
客户端
框架设计
客户端将来是要连接服务器的,所以需要传入服务器IP和端口,而且是从程序外部出入。即给main函数传入命令行参数。注意判断参数是否合法。
然后和服务器一样需要打开网络文件。如下:
int main(int argc, char *argv[])
{if (argc != 3){std::cerr << "Usage: " << argv[0] << " server_op server_port" << std::endl;return 1;}int socketfd = socket(AF_INET, SOCK_DGRAM, 0);if (socketfd < 0){LOG(Level::ERROR) << "socket() fail";exit(1);}else{LOG(Level::INFO) << "socket() succee _socketfd:" << socketfd;}//包装客户端信息InetAddr addr(std::stoi(argv[2]), argv[1]);//信息收发//......return 0;
}
三、端口绑定
客户端的实现可比服务器简单多了,因为它不需要我们手动绑定IP和端口号,系统帮我们做了。但要清楚我们是可以自己绑定的,不过会有很多问题,比如主机里有很多进程,可能端口号会绑重,让系统自动分配比较安全。
那服务器的端口号为什么不也让系统动态分配呢?我们自己绑多麻烦。其实是这样的,服务器是需要供给很多客户去使用,需要客户端填写服务器端口。所以服务器端口一定要明确,系统动态分配的话,在程序外部就无法知道服务器端口号了。
四、收发信息
收发信息是一个不断重复的操作,所以写成一个死循环,但要注意不要把收信息和发信息写在一起,要不然发信息阻塞时就收不到信息,收信息阻塞时也发不了信息。
所以它们应该并发地进行,即使用两个子线程。
代码示例:
void Write(int socketfd, InetAddr &addr)
{//提前发一条信息告诉服务器我已经上线string str="online";sendto(socketfd, str.c_str(), sizeof(str), 0, (const sockaddr *)&addr.getaddr(), sizeof(addr.getaddr()));while (true){std::string message;cout<<"Please Enter# ";std::getline(std::cin, message);sendto(socketfd, message.c_str(), sizeof(message), 0, (const sockaddr *)&addr.getaddr(), sizeof(addr.getaddr()));}
}
void Read(int socketfd)
{while (true){sockaddr_in sd;char buffer[1024];socklen_t len = sizeof(sd);int n = recvfrom(socketfd, buffer, sizeof(buffer) - 1, 0, (sockaddr *)&sd, &len);buffer[n] = '\0';std::cout << buffer << std::endl;}
}
main函数中
thread td_read([&](){Write(socketfd,addr);
});
thread td_write([&](){Read(socketfd);
});
td_read.join();
td_read.join();
到这里这个工程就完成了,下面是运行结果。
效果展示:
五、源码
UdpServer.hpp
// 用条件编译,防止头文件重复包含
#ifndef UDP_SERVER
#define UDP_SERVER
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <strings.h>
#include <string>
#include <assert.h>
#include <arpa/inet.h>
#include <functional>
#include "InetAddr.hpp"
#include "Log.hpp"
using namespace std;
using namespace my_log;
using funcType = function<void(int, string, InetAddr)>;
class task
{
public:task() {}task(funcType func, int socketfd, string message, InetAddr client): _func(func), _socketfd(socketfd), _message(message), _client(client){}void operator()(){assert(_socketfd != -1);_func(_socketfd, _message, _client);}private:funcType _func;int _socketfd;string _message;InetAddr _client;
};
class UdpServer
{
public:UdpServer(uint16_t port, const funcType &func): _socketfd(-1), _port(port), _func(func){}void Init(){// 打开网络文件 IPv4 数据包 udp_socketfd = socket(AF_INET, SOCK_DGRAM, 0);if (_socketfd < 0){LOG(Level::ERROR) << "socket() fail";exit(1);}else{LOG(Level::INFO) << "socket() succee _socketfd:" << _socketfd;}sockaddr_in sd;bzero(&sd, sizeof(sd));sd.sin_family = AF_INET;sd.sin_port = htons(_port);// sd.sin_addr.s_addr = inet_addr(_ip.c_str());sd.sin_addr.s_addr = INADDR_ANY;// 绑定ip地址与端口号int n = bind(_socketfd, (const sockaddr *)&sd, sizeof(sd));if (n < 0){LOG(Level::FATAL) << "bind fial";exit(1);}else{LOG(Level::INFO) << "bind success";}}void Start(){while (true){// 收各个客户端发来的消息sockaddr_in client;socklen_t len = sizeof(client);char buffer[1024];int n = recvfrom(_socketfd, buffer, sizeof(buffer), 0, (sockaddr *)&client, &len);buffer[n] = '\0';_func(_socketfd,string(buffer),InetAddr(client));}}~UdpServer(){}private:int _socketfd;uint16_t _port;funcType _func;
};
#endif
UdpServer.cc
#include <iostream>
#include <memory>
#include "UdpServer.hpp"
#include "InetAddr.hpp"
#include "Route.hpp"
int main(int argc, char *argv[])
{if (argc < 2){// LOG(Level::FATAL)<<"input error";std::cerr << "Usage" << argv[0] << "port" << std::endl;return 1;}std::string port = argv[1];// 路由Route rt;// 通信unique_ptr<UdpServer> us = make_unique<UdpServer>(stoi(port), [&](int socketfd, string meassge, InetAddr client){ rt.Handler(socketfd, meassge, client); });us->Init();us->Start();return 0;
}
UdpClient.cc
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <strings.h>
#include <arpa/inet.h>
#include <thread>
#include "InetAddr.hpp"
#include "Log.hpp"
using namespace my_log;
void Write(int socketfd, InetAddr &addr)
{string str="online";sendto(socketfd, str.c_str(), sizeof(str), 0, (const sockaddr *)&addr.getaddr(), sizeof(addr.getaddr()));while (true){std::string message;cout<<"Please Enter# ";std::getline(std::cin, message);sendto(socketfd, message.c_str(), sizeof(message), 0, (const sockaddr *)&addr.getaddr(), sizeof(addr.getaddr()));}
}
void Read(int socketfd)
{while (true){sockaddr_in sd;char buffer[1024];socklen_t len = sizeof(sd);int n = recvfrom(socketfd, buffer, sizeof(buffer) - 1, 0, (sockaddr *)&sd, &len);buffer[n] = '\0';std::cout << buffer << std::endl;}
}int main(int argc, char *argv[])
{if (argc != 3){std::cerr << "Usage: " << argv[0] << " server_op server_port" << std::endl;return 1;}int socketfd = socket(AF_INET, SOCK_DGRAM, 0);if (socketfd < 0){LOG(Level::ERROR) << "socket() fail";exit(1);}else{LOG(Level::INFO) << "socket() succee _socketfd:" << socketfd;}InetAddr addr((uint16_t)std::stoi(argv[2]), argv[1]);thread td_read([&](){Write(socketfd,addr);});thread td_write([&](){Read(socketfd);});td_read.join();td_read.join();return 0;
}
InteAddr.hpp
#pragma once
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
using namespace std;
class InetAddr
{
public:InetAddr(){}InetAddr(sockaddr_in &peer): _addr(peer){_port = ntohs(peer.sin_port);_ip = inet_ntoa(peer.sin_addr);}InetAddr(uint16_t port, string ip): _port(port), _ip(ip){_addr.sin_family = AF_INET;_addr.sin_port = htons(_port);_addr.sin_addr.s_addr = inet_addr(_ip.c_str());}string tostring_port(){return to_string(_port);}string tostring_ip(){return _ip;}bool operator==(InetAddr addr){return _port == addr._port && _ip == addr._ip;}sockaddr_in &getaddr(){return _addr;}private:uint16_t _port;string _ip;sockaddr_in _addr;
};
Route.hpp
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include "Log.hpp"
#include "InetAddr.hpp"
using namespace std;
using namespace my_log;
class Route
{
private:void Push(InetAddr peer){for (auto val : _data){if (val == peer) return;}_data.push_back(peer);LOG(Level::INFO)<<peer.tostring_ip()<<'|'<<peer.tostring_port()<<" online";}bool Pop(){return true;}public:Route(){}void Handler(int socketfd,string message,InetAddr client){Push(client);// 处理信息string send_message = client.tostring_ip() + " | " + client.tostring_port() + ": ";send_message += message;// 发给所有在线的客户端for (auto val : _data){if(val == client) continue;sendto(socketfd, send_message.c_str(), send_message.size(), 0, (sockaddr *)&val.getaddr(), sizeof(val.getaddr()));}}
private:vector<InetAddr> _data;
};