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

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

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


文章目录

  • 前言
  • [一. 套接字接口](#一. 套接字接口)
  • [二. UDP服务端](#二. UDP服务端)
  • [三. 服务端 + 线程池](#三. 服务端 + 线程池)
  • [四. 在线字典](#四. 在线字典)
  • [五. 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:

cpp 复制代码
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地址。

网络字节序一律使用大端字节序存储,因此对于端口要保证放入的是大端。

库中有一系列的接口来让我们传入的数据转化为网络字节序:

cpp 复制代码
   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地址,存储时需要存储的是整形,而不是字符串,库中也提供了相关的接口供我们使用:

cpp 复制代码
   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服务器:

  • 内存成员需要有IP和端口号,来进行绑定;
  • 并且需要将套接字存储起来,否则后续在不到套接字就会导致无法关闭对应的网络文件位置。
  • 此处在设计一个bool类型的变量,让用户可以控制时候打开服务器。

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

cpp 复制代码
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和端口号了。

此处对于数据得处理,为了简单我们仅加上前置告诉客户端都到了对应的数据即可:

cpp 复制代码
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_;
};

有以上方法用来解决数据的处理之后,我们就可以让循环中仅负责读取数据即可:

cpp 复制代码
    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();
        }
    }

服务器的类已经编写完成了,下一步就可以写服务器的程序了:

关于服务器的程序编写很简单,此处就不过多赘述了:

cpp 复制代码
#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存储服务端的信息,这样在后续进行通信的时候,就不需要重复的设置该结构体了:
cpp 复制代码
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结构体,存储服务端的信息。

上述所说不需要进行绑定,指的是用户不需要进行手动的绑定,有操作系统来进行绑定;因为用户并不知道操作系统中那些端口可以进行绑定,并且用户也不应该关心。

cpp 复制代码
    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()接口:

cpp 复制代码
    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;
    }

最后只要再实现服务端即可,为了简单,客户端向服务器发送数据后,当接收到服务端的信息后直接打印即可:

cpp 复制代码
#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的客户端,只是底层调用的接口有一些差异而已:

cpp 复制代码
#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; // 客户端用于通信的Socket
    WSADATA WSAData;

    if (WSAStartup(MAKEWORD(2, 2), &WSAData) != 0)
    {
        printf("init error");
        return -1;
    } // 初始化

    sock_Client = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); // 创建客户端用于通信的Socket

    SOCKADDR_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()函数进行修改即可:
cpp 复制代码
    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);   // 先任务队列中放入任务,让其他线程来处理任务
        }
    }

四. 在线字典

上面在进行编写任务的时候比较简单,就是对客户端的信息进行简单的收到返回。

现在我们希望实现一个在线字典,客户端发送一个单词过来,我们可以在字典中进行查找,将含义返回给用户。

  • 首先毫无疑问,我们需要实现一个类,来进行单词的读取,该类要从文件中读取出单词的汉语和英语并存储起来,然后向外提供一个接口可以进行查询;

字典类的实现也很简单,就是单纯的对文本读取,将读取到的数据放入哈希表,建立一一映射关系即可:

cpp 复制代码
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类进行修改,接入字典类即可,将字典类作为静态成员,方式每次创建:

cpp 复制代码
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,是否需要加入到哈希表中。

cpp 复制代码
    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_来保证将信息转化给所有人。

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

相关推荐
律宏阔4 小时前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔4 小时前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴4 小时前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
乱码三千5 小时前
如何优雅地直连无公网 IP 的远程 GPU 服务器
linux·人工智能·程序员
yunwei375 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei375 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei375 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化
GeW5 小时前
制造业高端化,为什么必须重估Linux和数据库?
linux
GeW5 小时前
工业场景中的Linux与数据库选型:高端化转型的技术底座
linux
yunwei375 小时前
eBPF 开发者教程: 简单的 XDP 负载均衡器
linux·后端·性能优化