【网络编程】UDP 编程实战:从套接字到聊天室多场景计划构建

文章目录
- 前言
- 一. 套接字接口
- 二. UDP服务端
- 三. 服务端 + 线程池
- 四. 在线字典
- 五. UDP简单聊天室
- 六. 补充
前言
在互联网深度渗透的今天,网络编程已成为软件开发的核心能力之一。从实时互动的在线游戏,到物联网设备的远程通信,再到分布式系统的高效协作,网络协议的灵活运用直接决定了应用的性能与体验。
其中,UDP(用户数据报协议) 以其 无连接、低延迟、高吞吐量 的特性,在直播、实时监测、轻量化通信等场景中占据不可替代的地位 —— 比如游戏中的实时对战、物联网传感器的批量数据上报,都依赖 UDP 实现 “高效优先” 的通信。
本文围绕 UDP 网络编程 展开,以 “从基础到实战” 的递进逻辑,帮你拆解开发中的核心环节,本文分为5个部分:
- 套接字接口 :我们将从底层 API 入手,理解 UDP 如何创建套接字,绑定以及收发消息的,建立网络编程的基本认知;
- 搭建 UDP 服务端 :构建服务端的工作全流程,掌握 “单线程服务端” 的开发;
- 引入 线程池优化服务端 :面对高并发场景,学习通过线程池提升服务端性能,理解 “并发模型” 如何解决性能瓶颈;
- 实战项目落地 :通过 在线字典(演示 UDP 如何实现 “请求 - 响应” 式服务)和 UDP 简单聊天室(实践多客户端实时通信),将理论转化为可运行的应用,体会 UDP 在实际业务中的设计思路;
现在,就让我们从 “套接字接口” 开始,开启 UDP 网络编程的学习之旅吧。
一. 套接字接口
创建套接字:
int socket(int domain , int type , int protrol)
:
- 参数一:域/协议族,常用的有:
AF_INET
基于IPv4网络的通信,支持TCP和UDP;AF_INET6
基于IPv6的网络通信;AF_UNIX
用于本地进程间通信。本文实现UDP采用IPv4的协议; - 参数二:套接字种类,常用是有:
SOCK_DGRAM
关联UDP协议;SOCK_STREAM
关联TCP协议; - 参数三:协议,在参数一和二的基础上进一步锁定具体的传输规则,一般直接传0即可;
- 返回值,一个Socket描述符,类似于文件描述符,只不过是网络文件描述符,失败返回-1.
对于服务端要进行IP地址和端口号绑定才能进行使用;
绑定套接字:
int bind(int sockfd , const struct sockaddr* addr , socklen_t addren)
:
- 参数一:Socket描述符;
- 参数二:一个套接字结构体,内部存储要进行绑定的的IP和端口号;
- 参数三:第二个参数的大小;
- 返回值:成功返回0,失败返回-1。
以为是UDP通信,因此第二个参数我们使用的结构体是:struct sockaddr_in
:
struct sockaddr_in {
sa_family_t sin_family;
/* Address family */
unsigned short int sin_port;
/* Port number */
struct in_addr sin_addr;
/* Internet address */
/* Pad to size of `struct sockaddr'. */
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
sizeof(unsigned short int) - sizeof(struct in_addr)];
};
/* Internet address. */
struct in_addr {
__u32 s_addr;
};
- 其中
sin_famuliy
标识协议家族,使用与创建套接字时传入的第一个参数相同; unsighned short int
存储端口号;struct in_addr
存储IP地址。
网络字节序一律使用大端字节序存储,因此对于端口要保证放入的是大端。
库中有一系列的接口来让我们传入的数据转化为网络字节序:
uint32_t htonl(uint32_t hostlong);
// 转网络字节序,对uint32_t
uint16_t htons(uint16_t hostshort);
// 转网络字节序,对uint16_t
uint32_t ntohl(uint32_t netlong);
// 转主机字节序
uint16_t ntohs(uint16_t netshort);
// 转主机字节序
对于IP地址,存储时需要存储的是整形,而不是字符串,库中也提供了相关的接口供我们使用:
int inet_aton(const char *cp, struct in_addr *inp);
// 传入字符串和结构体,将字符串转为整数后填入到结构体中
in_addr_t inet_addr(const char *cp);
// 将字符串整数
char *inet_ntoa(struct in_addr in);
// 转化为字符串
接受信息:ssize_t recvfrom(int sockfd , void* buf , size_t len , int flag , struct sockaddr *src_addr , socklen_t *addrlen)
:
- 参数一:网络文件描述符;
- 参数二:输出型参数,将读取到的信息放到此处,参数三这是空间的大小;
- 参数四:选项,选择等待方式,其中0标识阻塞式等待;
- 参数5和6都是输出型参数,获取发送端的套接字结构体。
发送信息:
ssize_t sendto(int sockfd , const void* buf , size_t len , int flag , const struct sockaddr *dest_addr , socklen_t addrlen )
:参数与上面一样。
二. UDP服务端
在构建服务器代码中,统一使用一个日志类来对异常信息进行打印处理,关于日志类不是本文的重点,如果想要了解,可以在此处进行跳转——【工具分享】日志类该怎么设计?从级别划分到异步写入的全解析
使用一个类来实现UDP服务器:
- 内存成员需要有IP和端口号,来进行绑定;
- 并且需要将套接字存储起来,否则后续在不到套接字就会导致无法关闭对应的网络文件位置。
- 此处在设计一个
bool
类型的变量,让用户可以控制时候打开服务器。
初始化的时候需要外界将这些参数都传进行保存起来,但是并不在初始化时创建套接字,而是当用户运行时才进行创建。
const std::string defaultip = "0.0.0.0";
// 编写Udp服务器
class Server
{
public:
Server(uint16_t port, const std::string &ip = defaultip )
: port_(port), ip_(ip), sockfd_(-1), isrunning_(false)
{
}
private:
uint16_t port_;
// 保存服务端端口号
std::string ip_;
// 保存服务端IP
int sockfd_;
// 网络文件描述符
bool isrunning_;
};
注意:后续在进行bind()
绑定的时候,云服务器一般是禁止绑定公网IP的;因为云服务器上一般都多个网卡,可以通过多个IP来接收发送过来的消息,如果直接进行绑定就会导致其他IP都无法进行使用了。
所以一般用服务器的IP地址使用0.0.0.0,表示接收所用发送到这台主机上的数据,再进行端口号进行交付。一次服务端在进行传参的时候就只需要传端口号就行了。
运行云服务器:
- 创建套接字;
- 进行绑定;
- 死循环的接收外部信息。
在此之前我们需要思考以下接收到的信息如何进行处理?
如果我们直接让处理方法都在循环内完成,就会导致代码拓展性差,如果后续希望接入进程池就需要对代码进行重构,因此此处将对接收到的信息处理方法也单独封装一个类:
该类要能够处理接收到的信息,并且将处理完的信息返回到客户端上去,因此该类也需要有套接字,客户端的IP和端口号,以及要进行处理的信息,因为在进行接收数据的时候,就已经拿到了客户端得struct sockaddr_in
结构体了,因此直接传该结构体就可以,不用再使用IP和端口号了。
此处对于数据得处理,为了简单我们仅加上前置告诉客户端都到了对应的数据即可:
class Task
{
public:
Task(int sockfd , std::string message , struct sockaddr_in client)
:sockfd_(sockfd) , message_(message) , client_(client)
{
}
void operator()()
{
// 处理任务
std::string ret = "I have got your message : " + message_;
sendto(sockfd_, ret.c_str(), ret.size(), 0, (sockaddr *)&client_, sizeof(client_));
}
private:
int sockfd_;
struct sockaddr_in client_;
std::string message_;
};
有以上方法用来解决数据的处理之后,我们就可以让循环中仅负责读取数据即可:
std::string Recv(struct sockaddr_in *pclient)
{
char buffer[1024];
socklen_t len = sizeof(*pclient);
int n = recvfrom(sockfd_, buffer, sizeof(buffer) - 1, 0, (sockaddr *)pclient, &len);
if (n >
0)
buffer[n] = 0;
return buffer;
}
void Start()
{
isrunning_ = true;
// 创建套接字
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ <
0)
{
Log(Fatal) <<
"socket failed";
exit(Sockfd_Err);
}
// bind绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
inet_aton(ip_.c_str(), &
(local.sin_addr));
if (bind(sockfd_, (sockaddr *)&local, sizeof(local)) <
0)
{
Log(Fatal) <<
"bind failed";
exit(Bind_Err);
}
Log(Info) <<
"bind success , the server is running";
// while循环的接收任务
while(1)
{
struct sockaddr_in client;
std::string message = Recv(&client);
Task task(sockfd_ , message , client);
task();
}
}
服务器的类已经编写完成了,下一步就可以写服务器的程序了:
关于服务器的程序编写很简单,此处就不过多赘述了:
#include "server.hpp"
void Menu(char* argv[])
{
std::cout <<
"\r" << argv[0] <<
" [port] " << std::endl;
}
int main(int argc , char* argv[])
{
if(argc <
2)
{
Menu(argv);
exit(1);
}
uint16_t port = std::stoi(argv[1]);
Server server(port);
server.Start();
return 0;
}
紧接着服务器编写好了,还需要一个客户端来进行通信:
客户端我们也采用一个类来实现,其中类成员与服务端是一样的:
- 只不过我们可以使用一个
strcut sockaddr_in
存储服务端的信息,这样在后续进行通信的时候,就不需要重复的设置该结构体了:
class Client
{
public:
Client(const std::string &ip, uint16_t port)
: ip_(ip), port_(port), sockfd_(-1)
{
memset(&server_ , 0 , sizeof(server_));
}
private:
int sockfd_;
std::string ip_;
struct sockaddr_in server_;
uint16_t port_;
};
下一步对服务器进行初始化:
- 创建套接字;
- “不需要进行绑定“;
- 初始化
struct sockaddr_in
结构体,存储服务端的信息。
上述所说不需要进行绑定,指的是用户不需要进行手动的绑定,有操作系统来进行绑定;因为用户并不知道操作系统中那些端口可以进行绑定,并且用户也不应该关心。
void Init()
{
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ <
0)
{
Log(Fatal) <<
"socket failed";
exit(Socket_Err);
}
server_.sin_family = AF_INET;
server_.sin_port = htons(port_);
if (inet_aton(ip_.c_str(), &
(server_.sin_addr)) == 0)
{
Log(Error) <<
"invalid IP address: " << ip_ ;
close(sockfd_);
// 先关闭已创建的socket
exit(Ip_Err);
// 或其他错误码
}
}
最后就是用户进行发送和接收消息了,依旧是采用sendto()
和recvfrom()
接口:
void Send(std::string message)
{
// 进行发送消息
int n = sendto(sockfd_, message.c_str(), message.size(), 0, (sockaddr *)&server_, sizeof(server_));
}
std::string Recv()
{
char buffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
int n = recvfrom(sockfd_, buffer, sizeof(buffer) - 1, 0, (sockaddr *)&temp, &len);
if (n >
0)
buffer[n] = 0;
return buffer;
}
最后只要再实现服务端即可,为了简单,客户端向服务器发送数据后,当接收到服务端的信息后直接打印即可:
#include "client.hpp"
void Menu(char* argv[])
{
std::cout <<
"\r" << argv[0] <<
" [ip] " <<
" [port] " << std::endl;
}
int main(int argc , char* argv[])
{
if(argc <
3)
{
Menu(argv);
exit(1);
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
Client client(ip , port);
client.Init();
while(1)
{
std::string message;
std::cout <<
"Please Enter@";
std::cin >> message;
client.Send(message);
std::string reply = client.Recv();
std::cout <<
"server reply#" << reply << std::endl;
}
return 0;
}
以上就是服务端和客户端的全部实现了。
该服务端可以支持各个平台的客户端运行,下面贴一张Windows的客户端,只是底层调用的接口有一些差异而已:
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#define _CRT_SECURE_NO_WARNINGS
#include <winsock2.h>#include <stdio.h>#include <string.h>#include <stdbool.h>#pragma comment(lib, "ws2_32.lib")#define BUFFER_SIZE 1024 // 缓冲区大小#define SERVER_FAMILY AF_INET#define SERVER_PORT 8888#define SERVER_ADDR "175.178.50.213"static char s_receBuf[BUFFER_SIZE];// 发送数据的缓冲区static char s_sendBuf[BUFFER_SIZE];// 接受数据的缓冲区void config_server(SOCKADDR_IN* addr_server){addr_server->sin_family = SERVER_FAMILY;addr_server->sin_port = htons(SERVER_PORT);addr_server->sin_addr.S_un.S_addr = inet_addr(SERVER_ADDR);}int main(){SOCKET sock_Client;// 客户端用于通信的SocketWSADATA WSAData;if (WSAStartup(MAKEWORD(2, 2), &WSAData) != 0){printf("init error");return -1;} // 初始化sock_Client = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);// 创建客户端用于通信的SocketSOCKADDR_IN addr_server;// 服务器的地址数据结构config_server(&addr_server);SOCKADDR_IN sock;int len = sizeof(sock);while (true){printf("please input send data:");scanf("%s", s_sendBuf);sendto(sock_Client, s_sendBuf, strlen(s_sendBuf), 0, (SOCKADDR*)&addr_server, sizeof(SOCKADDR));// int last = recv(sock_Client, s_receBuf, strlen(s_receBuf), 0); // (调用recv和recvfrom都可以)int last = recvfrom(sock_Client, s_receBuf, sizeof(s_receBuf), 0, (SOCKADDR*)&sock, &len);printf("last:%d,%s\n", last, s_receBuf);if (last >0){s_receBuf[last] = '\0';// 给字符数组加一个'\0',表示结束了。不然输出有乱码if (strcmp(s_receBuf, "bye") == 0){printf("server disconnect\n");break;}else{printf("receive data:%s\n", s_receBuf);}}}closesocket(sock_Client);WSACleanup();return 0;}
三. 服务端 + 线程池
上面的程序是单线程的,当请求过多的时候可能会导致一些数据包丢失,因此为了让服务器更健壮一些,我们接入线程池来使用,关于线程池之前有一篇博客进行过详细剖析,没有看的可以看一下:多线程编程
接入线程池也很简单,只需要进行分工:
- 让主线程来接收信息,将信息全都放到任务队列中,让新线程从任务队列中取数据,并执行相应的方法即可。
- 此需要对
Start()
函数进行修改即可:
void Start()
{
// 先获取线程池,并让线程池运行起来
std::unique_ptr<thread_poll<Task>>&tp_ = thread_poll<Task>::GetInstance();tp_->run();isrunning_ = true;// 创建套接字sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd_ <0){Log(Fatal) <<"socket failed";exit(Sockfd_Err);}// bind绑定struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(port_);inet_aton(ip_.c_str(), &(local.sin_addr));if (bind(sockfd_, (sockaddr *)&local, sizeof(local)) <0){Log(Fatal) <<"bind failed";exit(Bind_Err);}Log(Info) <<"bind success , the server is running";// while循环的接收任务while(1){struct sockaddr_in client;std::string message = Recv(&client);Task task(sockfd_ , message , client);tp_->push(task);// 先任务队列中放入任务,让其他线程来处理任务}}
四. 在线字典
上面在进行编写任务的时候比较简单,就是对客户端的信息进行简单的收到返回。
现在我们希望实现一个在线字典,客户端发送一个单词过来,我们可以在字典中进行查找,将含义返回给用户。
- 首先毫无疑问,我们需要实现一个类,来进行单词的读取,该类要从文件中读取出单词的汉语和英语并存储起来,然后向外提供一个接口可以进行查询;
字典类的实现也很简单,就是单纯的对文本读取,将读取到的数据放入哈希表,建立一一映射关系即可:
class directory
{
public:
directory(const std::string file_name)
{
std::ifstream ifs(file_name.c_str());
std::string eng, ch;
std::string line;
while (std::getline(ifs, line))
{
int pos = line.find(':');
ch = line.substr(0 , pos) , eng = line.substr(pos + 1);
ch_toeng_[ch] = eng;
eng_toch_[eng] = ch;
}
}
std::string find(const std::string word)
{
if(ch_toeng_.count(word)) return ch_toeng_[word];
if(eng_toch_.count(word)) return eng_toch_[word];
return "NO Wold !!! ";
}
private:
std::unordered_map<std::string, std::string> ch_toeng_;// 汉译英std::unordered_map<std::string, std::string> eng_toch_;// 英译汉};
再对Task
类进行修改,接入字典类即可,将字典类作为静态成员,方式每次创建:
class Task
{
public:
Task(int sockfd , std::string message , struct sockaddr_in client)
:sockfd_(sockfd) , message_(message) , client_(client)
{
}
void operator()()
{
// 处理任务
std::string ret = dict.find(message_);
sendto(sockfd_, ret.c_str(), ret.size(), 0, (sockaddr *)&client_, sizeof(client_));
}
private:
int sockfd_;
struct sockaddr_in client_;
std::string message_;
static directory dict;
// 接入字典类
};
directory Task::dict = directory("words.txt");
通过以上增添就可以让我们的服务器支持单词的查找了。
五. UDP简单聊天室
将我们的UDP服务器修改成一个聊天室。
我们的服务器可以对先各个用户发送信息,但是想要实现聊天室的功能还要让一个人发送的消息,全部人都能看见才行。
那么我们就需要对所有进行通信过的客户端信息存储起来,当一个人先服务端发送消息的时候,服务端负责将消息发送给全部人。
我们在服务器中要添加一个新成员:std::unordered_map<std::string , sockaddr_in> all_client_
,将每一个用户存储起来,其中我们以用户的IP作为key
值,用户的sockaddr_in
结构体作为value
值。
在将信息进行转化之前,要先进行判断,判断ip
是否是新ip
,是否需要加入到哈希表中。
bool IsNewMember(const std::string& ip ,const sockaddr_in &client)
{
if(all_client_.count(ip)) return false;
all_client_[ip] = client;
return true;
}
并且在Task任务的参数中,不能在使用struct sockaddr_in
了,而要使用std::unordered_map<std::string , sockaddr_in> all_client_
来保证将信息转化给所有人。
class Task
{
public:
Task(int sockfd , std::string message , std::unordered_map<std::string, sockaddr_in>& client):sockfd_(sockfd) , message_(message) , client_(client){}void operator()(){for(auto&[ip_ , client] : client_) // 转发给所有人{sendto(sockfd_ , message_.c_str() , message_.size() , 0 ,(sockaddr*)&client , sizeof(client));}}private:int sockfd_;std::unordered_map<std::string, sockaddr_in>& client_;// 保存哈希表std::string message_;//static directory dict;};
以上就是所有服务端的改写,只不过我们的客户端是单执行流的,不能同时进行接收消息和发送消息,因此我们需要对客户端进行改写:
- 使用子进程来接收消息,而父进程来进行发送消息。
到目前为止:服务端客户端都已经实现了,可以模拟简单聊天室的功能了。
六. 补充
在服务端绑定端口号的时候也是有讲究的:
一般[0 , 1023]
是系统内定端口号,我们一般不能直接绑定,而只能使用1024之后的,但是1024之后也有一些端口号是专属端口号,也不能绑,一般在进行绑定的时候,使用8000以后的端口号。
在上面代码中,使用inet_ntoa
来获取struct sockaddr_in
中ip地址的,但是它的返回值是一个指针,也就是说该函数内部决定了应该将ip地址存储在哪,一般我们不建议使用inet_ntoa
,原因如下:
net_ntoa
内部通过 静态内存 存储转换后的 IP 字符串,每次调用都会复用同一块内存。若 连续调用(如同时转换源 IP 和目的 IP),后一次结果会覆盖前一次,导致最终获取的 IP 值错误。
我们一般更建议使用const char *inet_ntop(int af, const void *restrict src, char dst[restrict .size], socklen_t size);
这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题