当前位置: 首页 > news >正文

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

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


请添加图片描述


半桔:个人主页

  个人专栏: 《网络编程》《手撕面试算法》《C++从入门到入土》

我们需要为自己设定的任务,不是拥有安全感,而是能够接受不安全感。 -艾伦·弗洛姆-

文章目录

  • 前言
  • 一. 套接字接口
  • 二. UDP服务端
  • 三. 服务端 + 线程池
  • 四. 在线字典
  • 五. UDP简单聊天室
  • 六. 补充

前言

在互联网深度渗透的今天,网络编程已成为软件开发的核心能力之一。从实时互动的在线游戏,到物联网设备的远程通信,再到分布式系统的高效协作,网络协议的灵活运用直接决定了应用的性能与体验。

其中,UDP(用户数据报协议) 以其 无连接、低延迟、高吞吐量 的特性,在直播、实时监测、轻量化通信等场景中占据不可替代的地位 —— 比如游戏中的实时对战、物联网传感器的批量数据上报,都依赖 UDP 实现 “高效优先” 的通信。

本文围绕 UDP 网络编程 展开,以 “从基础到实战” 的递进逻辑,帮你拆解开发中的核心环节,本文分为5个部分:

  1. 套接字接口 :我们将从底层 API 入手,理解 UDP 如何创建套接字,绑定以及收发消息的,建立网络编程的基本认知;
  2. 搭建 UDP 服务端 :构建服务端的工作全流程,掌握 “单线程服务端” 的开发;
  3. 引入 线程池优化服务端 :面对高并发场景,学习通过线程池提升服务端性能,理解 “并发模型” 如何解决性能瓶颈;
  4. 实战项目落地 :通过 在线字典(演示 UDP 如何实现 “请求 - 响应” 式服务)和 UDP 简单聊天室(实践多客户端实时通信),将理论转化为可运行的应用,体会 UDP 在实际业务中的设计思路;

现在,就让我们从 “套接字接口” 开始,开启 UDP 网络编程的学习之旅吧。

一. 套接字接口

创建套接字

int socket(int domain , int type , int protrol)

  1. 参数一:域/协议族,常用的有:AF_INET基于IPv4网络的通信,支持TCP和UDP;AF_INET6基于IPv6的网络通信;AF_UNIX用于本地进程间通信。本文实现UDP采用IPv4的协议;
  2. 参数二:套接字种类,常用是有:SOCK_DGRAM关联UDP协议;SOCK_STREAM关联TCP协议;
  3. 参数三:协议,在参数一和二的基础上进一步锁定具体的传输规则,一般直接传0即可;
  4. 返回值,一个Socket描述符,类似于文件描述符,只不过是网络文件描述符,失败返回-1.

对于服务端要进行IP地址和端口号绑定才能进行使用;
绑定套接字:

int bind(int sockfd , const struct sockaddr* addr , socklen_t addren)

  1. 参数一:Socket描述符;
  2. 参数二:一个套接字结构体,内部存储要进行绑定的的IP和端口号;
  3. 参数三:第二个参数的大小;
  4. 返回值:成功返回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)

  1. 参数一:网络文件描述符;
  2. 参数二:输出型参数,将读取到的信息放到此处,参数三这是空间的大小;
  3. 参数四:选项,选择等待方式,其中0标识阻塞式等待;
  4. 参数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服务器:

初始化的时候需要外界将这些参数都传进行保存起来,但是并不在初始化时创建套接字,而是当用户运行时才进行创建。

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,表示接收所用发送到这台主机上的数据,再进行端口号进行交付。一次服务端在进行传参的时候就只需要传端口号就行了。

运行云服务器:

  1. 创建套接字;
  2. 进行绑定;
  3. 死循环的接收外部信息。

在此之前我们需要思考以下接收到的信息如何进行处理?

如果我们直接让处理方法都在循环内完成,就会导致代码拓展性差,如果后续希望接入进程池就需要对代码进行重构,因此此处将对接收到的信息处理方法也单独封装一个类:

该类要能够处理接收到的信息,并且将处理完的信息返回到客户端上去,因此该类也需要有套接字,客户端的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_;
};

下一步对服务器进行初始化:

  1. 创建套接字;
  2. “不需要进行绑定“;
  3. 初始化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;}

三. 服务端 + 线程池

上面的程序是单线程的,当请求过多的时候可能会导致一些数据包丢失,因此为了让服务器更健壮一些,我们接入线程池来使用,关于线程池之前有一篇博客进行过详细剖析,没有看的可以看一下:多线程编程

接入线程池也很简单,只需要进行分工:

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);这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题

http://www.hskmm.com/?act=detail&tid=17450

相关文章:

  • AC自动机在线版本(alert命中报警)
  • week1 homework
  • Windows 10 C盘占用释放 - tfel
  • 运算符
  • 科学计算方法--矩阵分析记录
  • window.addEventListener(message,()={})中的回调函数无故被一直触发的问题 - broky
  • python+pillow+Image实现图片压缩到指定大小
  • 页面卡顿问题分析与解决方案总结复盘
  • Say 题选记(9.21 - 9.27)
  • 9月25日
  • 3D 高斯训练速度和消耗 - MKT
  • 完整教程:【PyTorch实战:文本分类】23、BERT文本分类实战指南:从原理到PyTorch落地
  • 常见进制
  • 9.25总结
  • Day08-C:\Users\Lenovo\Desktop\note\code\JavaSE\Basic\src\com\David\array-ArrayDemo01~07
  • yolov10_float16.tflite TO yolov10_int8.tflite
  • ansible注意的和错误代码分析
  • 用 Rust 和 Tesseract OCR 识别验证码
  • 基于寄存器地址amp;标准外设库的LED流水灯
  • 用 Swift 和 Tesseract OCR 实现验证码识别
  • Rust 和 Tesseract OCR 实现验证码识别
  • AI-Powered-ToDo-List
  • Netty:完成RPC服务(实战)
  • Python 在 Web 开发中的应用与趋势
  • LLM MOE的进化之路
  • 相交链表-leetcode
  • AtCoder ARC114 总结 (A-C)
  • 告别单张保存!PPT 图片无损批量提取,这 3 种方法亲测有效!
  • ?模拟赛(2) 赛后总结
  • 日总结 8