跳到主要内容基于 UDP 协议的群聊服务器开发(C++) | 极客日志C++
基于 UDP 协议的群聊服务器开发(C++)
基于 UDP 协议实现群聊服务器的设计与开发。采用分层架构将通信层与业务处理层解耦,利用 UDP 面向报文特性规避粘包问题。核心流程涵盖 Socket 创建、IP 端口绑定、数据收发及消息路由转发。服务器端通过回调机制处理客户端消息并广播,客户端使用多线程并发读写实现交互。代码包含服务器、客户端、地址封装及路由类,展示了 C++ Socket 网络编程的关键实现细节。
框架设计
创建核心文件: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;
};
1. 打开网络文件
socket 函数的声明:
- 功能:打开网络文件(套接字)。
- 参数 domain:确定 IP 地址类型,如 IPv4 还是 IPv6。
AF_INET: IPv4。AF_INET6: IPv6。
- 参数 type:确定数据的传输方式。
SOCK_STREAM: 流式套接字(TCP)。SOCK_DGRAM: 数据报套接字(UDP)。
- 参数 protocol:确定协议类型,如果前面 type 已经能确定了,这里传入 0 即可。
- 返回值:成功返回文件描述符。失败返回小于 0 的数。
代码示例:
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socketfd < 0) {
LOG(Level::ERROR) << "socket() fail";
exit(1);
} {
(Level::INFO) << << _socketfd;
}
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
else
LOG
"socket() success _socketfd:"
注:LOG 为自定义日志接口,功能类似 cout。
2. 绑定 IP 地址与端口号
sockaddr_in 结构
sockaddr_in 是用于 IPv4 网络编程的一个核心数据结构,用于存储套接字地址信息(IP 地址和端口号)。除此之外还有 sockaddr_in6(IPv6),sockaddr_un(本地通信),sockaddr(用来屏蔽底层实现)。
#include <netinet/in.h>
struct sockaddr_in {
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
struct in_addr {
in_addr_t s_addr;
};
- sin_family:设为
AF_INET,即 IPv4。
- sin_port:使用成员变量 _port,但需要使用函数 htons 转为网络字节序(大端)。
- sin_addr:IP 地址通常都是点分十进制的字符串,所以需要把 IP 转成 4 字节,然后 4 字节转成网络序列。库提供了 inet_addr 函数。这里直接设为 INADDR_ANY,表示本主机上的所有 IP 都绑定到服务器,外部客户端连接任意 IP 都能连接到该主机。
- 最后一个成员暂且用不着。
bind 函数的使用
- 功能:用来绑定端口。
- 参数 sockfd:要绑定的套接字描述符(由 socket() 函数创建)。
- 参数 sockaddr:指向地址结构体的指针,包含绑定的 IP 地址和端口号。
- 参数 addrlen:地址结构体的长度(单位:字节)。
- 返回值:0 成功。非 0 失败。
sockaddr_in sd;
bzero(&sd, sizeof(sd));
sd.sin_family = AF_INET;
sd.sin_port = htons(_port);
sd.sin_addr.s_addr = INADDR_ANY;
int n = bind(_socketfd, (const sockaddr *)&sd, sizeof(sd));
if (n != 0) {
LOG(Level::FATAL) << "bind fail";
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);
}
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;
};
3. 接收信息
UDP 协议数据的接收使用的是 recvfrom 函数。
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;
};
注意:UDP 协议无状态,服务器并不知道客户端什么时候退出,除非客户在退出时给服务器发一条特殊信息表明客户要退出。
现在为止服务器相关的通信接口和数据处理方法已经准备好了,接下来实现 UdpClient.cc 文件,即 main 函数,把服务器调用起来。
- 要给服务器设定端口号,需要从程序外部传入,即命令行参数。
- 创建数据处理的类(Route)。
- 创建服务器,把端口号和数据处理方法(即回调方法)传入,启动服务器。
还记得开头在 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));
}
}
现在创建 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 message, InetAddr client) {
rt.Handler(socketfd, message, 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() success _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;
}
}
thread td_read([&]() { Write(socketfd, addr); });
thread td_write([&]() { Read(socketfd); });
td_read.join();
td_write.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() {
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socketfd < 0) {
LOG(Level::ERROR) << "socket() fail";
exit(1);
} else {
LOG(Level::INFO) << "socket() success _socketfd:" << _socketfd;
}
sockaddr_in sd;
bzero(&sd, sizeof(sd));
sd.sin_family = AF_INET;
sd.sin_port = htons(_port);
sd.sin_addr.s_addr = INADDR_ANY;
int n = bind(_socketfd, (const sockaddr *)&sd, sizeof(sd));
if (n < 0) {
LOG(Level::FATAL) << "bind fail";
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) {
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 message, InetAddr client) {
rt.Handler(socketfd, message, 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() success _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_write.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;
};