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

相关推荐
Lucis__2 小时前
C++相关概念与语法基础——C基础上的改进与优化
c语言·开发语言·c++
草莓熊Lotso2 小时前
《算法闯关指南:优选算法--滑动窗口》--14找到字符串中所有字母异位词
java·linux·开发语言·c++·算法·java-ee
IT成长日记3 小时前
【LVS入门宝典】LVS NAT模式实战指南:ip_forward、iptables与SNAT、DNAT规则配置详解
linux·运维·tcp/ip·负载均衡·lvs·nat
浙江赛思电子科技有限公司3 小时前
赛思金融授时服务器 从《捕风追影》纳秒困局到数字安全,赛思以全链路时钟同步方案夯实时序安全底座
服务器·科技·网络协议·安全·金融·信息与通信
---学无止境---3 小时前
九、内核数据结构之list
linux·数据结构·list
奔跑吧邓邓子3 小时前
【C++实战㊳】C++单例模式:从理论到实战的深度剖析
c++·单例模式·实战
error:(3 小时前
【Linux命令从入门到精通系列指南】apt 命令详解:Debian/Ubuntu 系统包管理的现代利器
linux·ubuntu·debian
bing_feilong3 小时前
树莓派4B+ubuntu20.04设置国内源
linux