精简版UDP网络编程:Socket套接字应用

目录

🌦️正文

1.预备知识

[1.1. IP地址概述](#1.1. IP地址概述)

[1.2. 端口号的作用](#1.2. 端口号的作用)

[1.3. 端口号与进程PID的关系](#1.3. 端口号与进程PID的关系)

[1.4. 传输层协议的选择](#1.4. 传输层协议的选择)

[1.5. 网络字节序与主机字节序](#1.5. 网络字节序与主机字节序)

[2. Socket 套接字](#2. Socket 套接字)

[2.1 Socket 常见 API](#2.1 Socket 常见 API)

[2.2 sockaddr 结构体](#2.2 sockaddr 结构体)

[2.3. 创建套接字](#2.3. 创建套接字)

[2.4. 绑定 IP 地址和端口号](#2.4. 绑定 IP 地址和端口号)

[2.5. 使用 INADDR_ANY 绑定任意可用 IP 地址](#2.5. 使用 INADDR_ANY 绑定任意可用 IP 地址)

[3.字符串回响 UDP 服务](#3.字符串回响 UDP 服务)

[1. 创建 server.hpp 文件 (服务器头文件)](#1. 创建 server.hpp 文件 (服务器头文件))

[2. 创建 server.cc 文件 (服务器源文件)](#2. 创建 server.cc 文件 (服务器源文件))

[3. 创建 client.hpp 文件 (客户端头文件)](#3. 创建 client.hpp 文件 (客户端头文件))

[4. 创建 client.cc 文件 (客户端源文件)](#4. 创建 client.cc 文件 (客户端源文件))

[5. 创建 Makefile](#5. 创建 Makefile)

[6. 远程 Bash 执行功能](#6. 远程 Bash 执行功能)

[7. 安全检查](#7. 安全检查)

[8. 服务器启动并执行命令](#8. 服务器启动并执行命令)

[9. 启动服务器](#9. 启动服务器)

[4. 多人聊天室(UDP协议)](#4. 多人聊天室(UDP协议))

[4.1 核心功能](#4.1 核心功能)

[4.2 程序结构](#4.2 程序结构)

[4.3 引入环形队列](#4.3 引入环形队列)

[4.4 引入用户信息](#4.4 引入用户信息)

[4.5 引入多线程](#4.5 引入多线程)

[4.6 客户端多线程化](#4.6 客户端多线程化)

[4.7 编译和运行](#4.7 编译和运行)


🌦️正文

1.预备知识

1.1. IP地址概述

在网络基础中,IP 地址是全球范围内标识主机的唯一标识符。我们利用 IP 地址来定位公网中的设备,进而实现跨越路由器进行远程通信------例如,从主机 A 发送信息到主机 Z。

然而,仅仅拥有 IP 地址只能帮助我们定位目标主机,但无法准确到达主机中的特定进程。为此,端口号的引入成为解决这一问题的关键。

主机内有多个进程在运行,实际的网络通信是发生在**不同主机的进程之间,**而并非主机与主机直接通信。因此,端口号成为实现进程间通信的必备工具。

1.2. 端口号的作用

端口号是一个 2 字节的整数,用于标识网络中的进程,其范围为 [0, 65535] 。它帮助定位到特定进程,确保通信数据能够准确无误地到达目标进程。

若把进程间通信视为一种形式的"消息传递",我们可以将网络通信看作是进程间通过网络进行的通信。在传统的进程间通信中,我们通过共享内存、管道等方式来实现;而在网络通信中,进程间的通信则通过端口号进行管理。

服务器的防火墙实际上就是通过端口号进行限制,只有开放的端口才能允许进程进行网络通信。

1.3. 端口号与进程PID的关系

端口号和进程 PID 都可以标识一个进程,但为什么不直接用 PID 而是使用端口号呢? 原因在于,进程 PID 属于操作系统内的进程管理范畴,而网络标准应该独立于操作系统的实现。因此,直接使用 PID 会使得网络标准与操作系统的管理过于耦合。

网络中的端口号与操作系统的 PID 是两个不同的概念。端口号标识的是网络通信中目标进程,而 PID 属于操作系统的内部管理信息,因此使用端口号作为进程标识更加灵活且符合网络独立性原则。

一个进程可以绑定多个端口号吗?一个端口号可以被多个进程绑定吗?

端口号的主要作用是与 IP 地址配合,标识网络中进程的唯一性。 若一个进程绑定多个端口号,它仍然可以保持唯一性,因为无论使用哪个端口号,信息都会被定向到该进程;然而,如果一个端口号被多个进程绑定,就会存在信息无法准确传递的问题,因为系统无法区分该端口号应该将数据交给哪个进程,从而导致通信的二义性。

因此,一个进程可以绑定多个端口号 ,但一个端口号不能被多个进程绑定。如果端口号已经被某个进程占用,其他进程在尝试绑定时会收到"端口已被占用"的错误提示。

操作系统如何根据端口号定位进程?

这个过程实现起来比较直接。操作系统通常会创建一张哈希表,维护端口号与进程 PID 之间的映射关系。当数据传输到目标主机时,操作系统根据目标端口号查找哈希表,准确定位到该端口号所对应的进程 PID,从而确保信息准确地交给对应的进程。

1.4. 传输层协议的选择

传输层有两种主流协议:TCP 和 UDP。它们各自有不同的特性,适用于不同的应用场景。

  • TCP 协议 :是一个有连接的协议保证可靠的数据传输,适用于要求高可靠性和准确性的应用场景,如网页浏览、文件传输等。

  • UDP 协议无连接、不可靠,但传输速度较快。适用于对实时性要求高、数据丢失可以容忍的场景,如视频直播、即时通讯等。

总结来说,如果你无法判断使用哪种协议,优先考虑 TCP 协议。如果在可靠性上没有特别要求,并且对传输速度有较高需求,可以选择 UDP。

1.5. 网络字节序与主机字节序

在计算机内部,数据存储有两种方式**:大端字节序和小端字节序** 。具体来说,大端字节序将数据的高权值字节存储在低地址,而小端字节序则将高权值字节存储在高地址。不同计算机可能采用不同的字节序存储方式,这就引发了网络通信中的问题。

为了解决不同字节序系统间的数据兼容性问题,**网络协议采用了统一的网络字节序(即大端字节序)。**无论发送方或接收方的字节序如何,都能通过协议统一处理,确保数据的正确传输。

解决方案:

  1. 解决方案 1:数据传输时添加字节序标志位,接收方根据标志位进行转换,但这种方式增加了额外的开销。

  2. 解决方案 2 :通过统一标准(如大端字节序)来处理数据存储和传输,从根本上解决兼容问题。

在 TCP/IP 协议中,采用了第二种方案,即统一使用大端字节序。这种方式大大简化了跨平台数据传输的复杂性,避免了因字节序不同而导致的错误。

可以使用相关库函数进行字节序转换,如下所示:

cpp 复制代码
#include <arpa/inet.h>

// 主机字节序转网络字节序
uint32_t htonl(uint32_t hostlong);
uint32_t htons(uint32_t hostshort);

// 网络字节序转主机字节序
uint32_t ntohl(uint32_t netlong);
uint32_t ntohs(uint32_t netshort);

2. Socket 套接字

2.1 Socket 常见 API

Socket 套接字提供了一些常用的接口,用于实现不同类型的网络通信。以下是一些常见的 API 接口:

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 创建 socket 文件描述符(TCP/UDP 服务器/客户端)
int socket(int domain, int type, int protocol);

// 绑定端口号(TCP/UDP 服务器)
int bind(int socket, const struct sockaddr* address, socklen_t address_len);

// 开始监听 socket(TCP 服务器)
int listen(int socket, int backlog);

// 接收连接请求(TCP 服务器)
int accept(int socket, struct sockaddr* address, socklen_t* address_len);

// 建立连接(TCP 客户端)
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

在这些 API 中,sockaddr 结构体频繁出现,它是网络通信的关键组成部分,也用于本地通信。

Socket 套接字通过文件描述符的方式来描述 sockaddr 结构体,使得其能够跨平台工作,无论是网络通信还是本地通信,都能有效利用相同的结构体来处理。

2.2 sockaddr 结构体

Socket 网络通信标准是 POSIX 通信标准的一部分,旨在实现跨平台的兼容性。这使得开发者可以**在不同平台上使用相同的通信标准。sockaddr**结构体是为了兼顾网络通信和本地通信而设计的。

socket 套接字通过 sockaddr 结构体来处理这两种通信方式。在此基础上,衍生出了两种常用的结构体:

  • sockaddr_in:用于网络通信,存储 IP 地址和端口信息。

  • sockaddr_un:用于本地通信,通过路径名来进行通信,类似于命名管道。

根据地址类型(16 位),可以判断是进行网络通信还是本地通信。对于网络通信,我们需要提供 IP 地址和端口号,而本地通信只需要提供一个路径名,通过文件读写进行通信。

在进行套接字编程时,socket 接口 的参数通常是 sockaddr* 类型,这意味着我们可以传入 &sockaddr_in 来进行网络通信,也可以传入 **&sockaddr_un**进行本地通信。传递时,只需要进行适当的强制类型转换,这是 C 语言中的多态应用,确保了接口的通用性。

为什么不使用 void* 作为参数?

在设计 POSIX 标准时,C 语言并没有支持 void* 类型。为了确保标准的兼容性,接口设计者避免使用 void*,从而使得该接口能够在后续语言不支持该类型时保持兼容。

有关 sockaddr_in 结构体的更详细信息,将在后续的代码实现部分进行讲解。

2.3. 创建套接字

在网络编程中,**socket()**函数用于创建一个套接字,这个套接字用于进程间的通信。在 UDP 通信中,我们通过此套接字发送和接收数据。

socket 函数说明

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 创建套接字
int socket(int domain, int type, int protocol);

参数解释

  • domain:选择通信的域(比如 AF_INET 代表 IPv4)

  • type:选择数据传输方式(SOCK_DGRAM 表示数据报通信,适用于 UDP ,SOCK_STREAM 适用于TCP)

  • protocol:通常设为 0,系统会根据 type 自动选择合适的协议(对于 SOCK_DGRAM,系统会自动选择 UDP)。

返回值

  • 成功时返回一个非负整数,即套接字的文件描述符。

  • 失败时返回 -1,并通过 errno 提供错误信息。

代码示例

cpp 复制代码
sock_ = socket(AF_INET, SOCK_DGRAM, 0);  // 创建 UDP 套接字
if (sock_ == -1) {
    std::cerr << "Socket creation failed: " << strerror(errno) << std::endl;
    exit(1);
}

2.4. 绑定 IP 地址和端口号

使用 bind() 函数将套接字与本地地址(IP 地址和端口)绑定。通过这个操作,服务器就能够接收来自指定 IP 地址和端口的请求。

bind 函数说明

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 绑定 IP 地址和端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数解释

  • sockfd :套接字描述符(由 socket() 创建)。

  • addr :指定通信的地址结构体,通常为 sockaddr_in 结构,包含 IP 地址和端口号。

  • addrlen :地址结构的大小。

返回值

  • 成功时返回 0。

  • 失败时返回 -1,并设置 errno

代码示例

cpp 复制代码
struct sockaddr_in local;
bzero(&local, sizeof(local));  // 清空结构体

local.sin_family = AF_INET;  // 设置为 IPv4
local.sin_port = htons(port_);  // 将主机字节序转换为网络字节序
local.sin_addr.s_addr = inet_addr(ip_.c_str());  // 将 IP 地址转换为网络字节序

if (bind(sock_, (const sockaddr*)&local, sizeof(local)) == -1) {
    std::cerr << "Binding IP and Port failed: " << strerror(errno) << std::endl;
    exit(1);
}

使用的是 sockaddr_in 结构体,要想使用该结构体,还得包含下面这两个头文件

cpp 复制代码
#include <netinet/in.h>
#include <arpa/inet.h>

sockaddr_in 结构体解释

cpp 复制代码
struct sockaddr_in {
    sa_family_t sin_family;        // 地址族,通常是 AF_INET
    in_port_t sin_port;            // 端口号,使用 htons 转换为网络字节序
    struct in_addr sin_addr;       // IP 地址,使用 inet_addr 转换为网络字节序
    unsigned char sin_zero[8];     // 填充,确保结构体大小与 sockaddr 匹配
};

首先来看看 16 位地址类型 ,转到定义可以发现它是一个宏函数 ,并且使用了 C语言 中一个非常少用的语法 ##(将两个字符串拼接);

cpp 复制代码
/* POSIX.1g specifies this type name for the `sa_family' member.  */
typedef unsigned short int sa_family_t;

/* This macro is used to declare the initial common members
   of the data types used for socket addresses, `struct sockaddr',
   `struct sockaddr_in', `struct sockaddr_un', etc.  */

#define	__SOCKADDR_COMMON(sa_prefix) \
  sa_family_t sa_prefix##family

当给 _SOCKADDR_COMMON 传入 sin 参数后,经过**##**字符串拼接、宏替换等操作后,会得到这样一个类型

cpp 复制代码
sa_family_t sin_family;

sa_family_t 是一个无符号短整数,占 16 位,sin_family 字段就是 16 位地址类型

接下来看看 端口号 ,转到定义,发现 in_port_t 类型是一个 16 位无符号整数,同样占 2 字节,正好符合 端口号 的取值范围 [0, 65535]

cpp 复制代码
/* Type to represent a port.  */
typedef uint16_t in_port_t;

最后再来看看 IP 地址 ,同样转到定义,发现 in_addr 中包含了一个 32 位无符号整数,占 4 字节,也就是 IP 地址 的大小

cpp 复制代码
/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
{
  in_addr_t s_addr;
};

了解完 sockaddr_in 结构体中的内容后,就可以创建该结构体了,再定义该结构体后,需要清空,确保其中的字段干净可用

将变量置为 0 可用使用 bzero 函数

cpp 复制代码
#include <cstrins> // bzero 函数的头文件

struct sockaddr_in local;
bzero(&local, sizeof(local));

获得一个干净可用的 sockaddr_in 结构体后,可以正式绑定 IP 地址端口号

注:作为服务器,需要确定自己的端口号,我这里设置的是 8888


主机字节序转换为网络字节序

  • htons():将 16 位的端口号从主机字节序转换为网络字节序。

  • inet_addr():将点分十进制的 IP 地址字符串转换为网络字节序的整数形式。

2.5. 使用 INADDR_ANY 绑定任意可用 IP 地址

在某些情况下,比如在云服务器或有多个网络接口的机器上,云服务器是不允许直接绑定公网 IP 的,解决方案是在绑定 IP 地址时,让其选择绑定任意可用 IP 地址。 服务器可以使用 INADDR_ANY 来绑定任意可用的 IP 地址。这意味着服务器会监听所有网络接口上的请求。

修改后的服务器头文件:

cpp 复制代码
class UdpServer {
public:
    // 构造函数
    UdpServer(uint16_t port = default_port) : port_(port) {}

    void InitServer() {
        // 创建 UDP 套接字
        sock_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (sock_ == -1) {
            std::cerr << "Socket creation failed: " << strerror(errno) << std::endl;
            exit(1);
        }

        struct sockaddr_in local;
        bzero(&local, sizeof(local));  // 清空结构体

        local.sin_family = AF_INET;  // 设置为 IPv4
        local.sin_port = htons(port_);  // 设置端口号
        local.sin_addr.s_addr = INADDR_ANY;  // 绑定任意可用 IP 地址

        // 绑定 IP 地址和端口号
        if (bind(sock_, (const sockaddr*)&local, sizeof(local)) == -1) {
            std::cerr << "Binding failed: " << strerror(errno) << std::endl;
            exit(1);
        }
    }

private:
    int sock_;
    uint16_t port_;
};

3.字符串回响 UDP 服务

  1. 核心功能

这个程序实现了客户端与服务器之间的基本通信。客户端将数据发送给服务器,服务器接收到后进行回显,类似于 echo 命令。整个过程通过 UDP 协议进行,服务器不保存任何连接状态,保证了高效的无连接数据传输。

  1. 程序结构

程序包含以下四个文件:

  • server.hpp:服务器端头文件,定义了 UdpServer 类。

  • server.cc:服务器端源文件,包含业务逻辑。

  • client.hpp:客户端头文件,定义了 UdpClient 类。

  • client.cc:客户端源文件,处理与服务器的通信。

1. 创建 server.hpp 文件 (服务器头文件)

cpp 复制代码
#pragma once

#include <iostream>
#include <string>

namespace nt_server {

    class UdpServer {
    public:
        UdpServer(const std::string& ip, uint16_t port);
        ~UdpServer();

        void InitServer();
        void StartServer();

    private:
        int sock_;             // 套接字描述符
        uint16_t port_;        // 端口号
        std::string ip_;       // 服务器IP地址
    };

}

2. 创建 server.cc 文件 (服务器源文件)

cpp 复制代码
#include "server.hpp"
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <iostream>
#include <cstdlib>

using namespace std;
using namespace nt_server;

UdpServer::UdpServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {}

UdpServer::~UdpServer() {}

void UdpServer::InitServer() {
    sock_ = socket(AF_INET, SOCK_DGRAM, 0);  // 创建UDP套接字
    if (sock_ == -1) {
        std::cerr << "Socket creation failed!" << std::endl;
        exit(1);
    }

    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(port_);
    local.sin_addr.s_addr = inet_addr(ip_.c_str());

    if (bind(sock_, (struct sockaddr*)&local, sizeof(local)) < 0) {
        std::cerr << "Binding failed!" << std::endl;
        exit(1);
    }
}

void UdpServer::StartServer() {
    char buffer[1024];
    struct sockaddr_in client;
    socklen_t len = sizeof(client);

    while (true) {
        ssize_t n = recvfrom(sock_, buffer, sizeof(buffer), 0, (struct sockaddr*)&client, &len);
        if (n < 0) {
            std::cerr << "Failed to receive message!" << std::endl;
            continue;
        }

        buffer[n] = '\0';
        std::cout << "Received: " << buffer << std::endl;

        // 将接收到的消息回传给客户端
        ssize_t sent = sendto(sock_, buffer, n, 0, (struct sockaddr*)&client, len);
        if (sent < 0) {
            std::cerr << "Failed to send message!" << std::endl;
        }
    }
}

3. 创建 client.hpp 文件 (客户端头文件)

cpp 复制代码
#pragma once

#include <iostream>
#include <string>

namespace nt_client {

    class UdpClient {
    public:
        UdpClient(const std::string& ip, uint16_t port);
        ~UdpClient();

        void InitClient();
        void StartClient();

    private:
        int sock_;             // 套接字描述符
        std::string server_ip_; // 服务器IP地址
        uint16_t server_port_; // 服务器端口号
    };

}

4. 创建 client.cc 文件 (客户端源文件)

cpp 复制代码
#include "client.hpp"
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <iostream>
#include <cstdlib>

using namespace std;
using namespace nt_client;

UdpClient::UdpClient(const std::string& ip, uint16_t port) : server_ip_(ip), server_port_(port) {}

UdpClient::~UdpClient() {}

void UdpClient::InitClient() {
    sock_ = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock_ == -1) {
        std::cerr << "Socket creation failed!" << std::endl;
        exit(1);
    }
}

void UdpClient::StartClient() {
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(server_port_);
    server_addr.sin_addr.s_addr = inet_addr(server_ip_.c_str());

    char message[1024];
    while (true) {
        std::cout << "Enter message: ";
        std::cin.getline(message, sizeof(message));

        ssize_t sent = sendto(sock_, message, strlen(message), 0, (struct sockaddr*)&server_addr, sizeof(server_addr));
        if (sent < 0) {
            std::cerr << "Failed to send message!" << std::endl;
            continue;
        }

        char buffer[1024];
        socklen_t len = sizeof(server_addr);
        ssize_t n = recvfrom(sock_, buffer, sizeof(buffer), 0, (struct sockaddr*)&server_addr, &len);
        if (n < 0) {
            std::cerr << "Failed to receive message!" << std::endl;
            continue;
        }

        buffer[n] = '\0';
        std::cout << "Server echoed: " << buffer << std::endl;
    }
}

5. 创建 Makefile

cpp 复制代码
.PHONY: all
all: server client

server: server.cc
    g++ -o $@ $^ -std=c++11

client: client.cc
    g++ -o $@ $^ -std=c++11

.PHONY: clean
clean:
    rm -rf server client

6. 远程 Bash 执行功能

我们将实现一个函数,可以执行客户端发送的 Bash 命令。我们将利用**popen 函数**来执行这些命令并返回结果。

远程命令执行函数

cpp 复制代码
#include <stdio.h>
#include <string>

std::string ExecCommand(const std::string& request) {
    FILE* fp = popen(request.c_str(), "r");
    if (fp == nullptr) {
        return "Command execution failed!";
    }

    std::string result;
    char buffer[1024];
    while (fgets(buffer, sizeof(buffer), fp) != nullptr) {
        result += buffer;
    }

    fclose(fp);
    return result;
}

7. 安全检查

为了防止执行危险命令,rmkill,我们需要进行安全检查,过滤掉不安全的命令。

cpp 复制代码
bool checkSafe(const std::string& command) {
    std::vector<std::string> unsafeCommands = {"kill", "rm", "shutdown", "mv"};
    for (const auto& unsafe : unsafeCommands) {
        if (command.find(unsafe) != std::string::npos) {
            return false;
        }
    }
    return true;
}

8. 服务器启动并执行命令

将安全检查与命令执行结合,在服务器端接收命令并执行。若命令安全,执行并返回结果;若命令不安全,返回错误信息。

cpp 复制代码
std::string ExecCommandWithSafety(const std::string& request) {
    if (!checkSafe(request)) {
        return "Unsafe command! Refused to execute.";
    }
    return ExecCommand(request);
}

9. 启动服务器

UdpServer 类中,我们将 ExecCommandWithSafety为回调函数传递进去,实现命令执行的功能。

cpp 复制代码
void UdpServer::StartServer() {
    char buffer[1024];
    struct sockaddr_in client;
    socklen_t len = sizeof(client);

    while (true) {
        ssize_t n = recvfrom(sock_, buffer, sizeof(buffer), 0, (struct sockaddr*)&client, &len);
        if (n < 0) {
            std::cerr << "Failed to receive message!" << std::endl;
            continue;
        }

        buffer[n] = '\0';
        std::cout << "Received: " << buffer << std::endl;

        std::string result = ExecCommandWithSafety(buffer);

        ssize_t sent = sendto(sock_, result.c_str(), result.size(), 0, (struct sockaddr*)&client, len);
        if (sent < 0) {
            std::cerr << "Failed to send message!" << std::endl;
        }
    }
}

4. 多人聊天室(UDP协议)

4.1 核心功能

这段程序实现了一个基于 UDP 协议的多人聊天室。所有参与聊天室的用户都可以发送和接收消息。服务器充当消息接收与转发的角色,将用户发送的消息广播给其他所有用户。

4.2 程序结构

聊天室的设计遵循生产者-消费者模型:

  • 生产者:负责接收消息并将其放入环形队列中。

  • 消费者:负责从环形队列中提取消息并广播给所有已知用户。

每个用户(客户端)都有一个独立的线程用于发送消息和接收消息。服务器则有两个线程:一个用于接收消息,一个用于广播消息。

4.3 引入环形队列

为了实现生产者-消费者模型,使用环形队列(RingQueue)来存储和管理消息。消息首先被生产者(接收线程)放入队列,然后消费者(广播线程)从队列中取出并发送给其他客户端。

环形队列类 RingQueue.hpp(简化示例)

cpp 复制代码
#pragma once
#include <queue>
#include <mutex>
#include <condition_variable>

template <typename T>
class RingQueue {
public:
    void Push(const T& item) {
        std::lock_guard<std::mutex> lock(mtx_);
        queue_.push(item);
        cond_.notify_one(); // 通知消费者线程
    }

    bool Pop(T* item) {
        std::unique_lock<std::mutex> lock(mtx_);
        cond_.wait(lock, [this] { return !queue_.empty(); });
        *item = queue_.front();
        queue_.pop();
        return true;
    }

private:
    std::queue<T> queue_;
    std::mutex mtx_;
    std::condition_variable cond_;
};

4.4 引入用户信息

为了区分不同用户,使用用户的 IP + Port 作为唯一标识符。在服务器上维护一个哈希表,保存每个用户的信息。这个表格用于存储用户地址(sockaddr_in 结构体)。

cpp 复制代码
std::unordered_map<std::string, sockaddr_in> userTable_;

每当有用户发送消息时,首先检查该用户是否已加入聊天室。如果是新用户,则将其添加到 userTable_ 中。

4.5 引入多线程

我们使用两个线程:

  1. 生产者线程:接收消息并将其放入环形队列。

  2. 消费者线程:从环形队列中获取消息并将其广播给所有用户。

服务器端多线程实现

cpp 复制代码
class UdpServer {
public:
    UdpServer(uint16_t port = default_port) : port_(port) {
        pthread_mutex_init(&mtx_, nullptr);
        producer_ = new Thread(1, std::bind(&UdpServer::RecvMessage, this));
        consumer_ = new Thread(2, std::bind(&UdpServer::BroadcastMessage, this));
    }

    void StartServer() {
        // 创建套接字、绑定等
        // 启动线程
        producer_->run();
        consumer_->run();
    }

    void RecvMessage() {
        char buff[1024];
        while(true) {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            ssize_t n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&peer, &len);
            if (n > 0) {
                buff[n] = '\0';
                std::string clientIp = inet_ntoa(peer.sin_addr);
                uint16_t clientPort = ntohs(peer.sin_port);
                std::string user = clientIp + "-" + std::to_string(clientPort);
                if (userTable_.count(user) == 0) {
                    userTable_[user] = peer; // 新用户加入
                }

                std::string msg = "[" + clientIp + ":" + std::to_string(clientPort) + "] " + buff;
                rq_.Push(msg); // 将消息推入队列
            }
        }
    }

    void BroadcastMessage() {
        while(true) {
            std::string msg;
            rq_.Pop(&msg); // 从队列中取出消息
            std::vector<sockaddr_in> userAddresses;
            {
                LockGuard lockguard(&mtx_);
                for (const auto& user : userTable_) {
                    userAddresses.push_back(user.second);
                }
            }

            // 广播消息
            for (const auto& addr : userAddresses) {
                sendto(sock_, msg.c_str(), msg.size(), 0, (const sockaddr*)&addr, sizeof(addr));
            }
        }
    }

private:
    int sock_;
    uint16_t port_;
    RingQueue<std::string> rq_;
    std::unordered_map<std::string, sockaddr_in> userTable_;
    pthread_mutex_t mtx_;
    Thread* producer_;
    Thread* consumer_;
};

4.6 客户端多线程化

客户端需要同时发送和接收消息,因此引入两个线程:

  1. 发送消息线程:负责向服务器发送消息。

  2. 接收消息线程:负责接收从服务器广播的消息。

客户端头文件

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Thread.hpp"

namespace nt_client {
    class UdpClient {
    public:
        UdpClient(const std::string& ip, uint16_t port)
            : server_ip_(ip), server_port_(port) {
            // 创建线程
            recv_ = new Thread(1, std::bind(&UdpClient::RecvMessage, this));
            send_ = new Thread(2, std::bind(&UdpClient::SendMessage, this));
        }

        ~UdpClient() {
            // 等待线程退出
            recv_->join();
            send_->join();

            delete recv_;
            delete send_;
        }

        void StartClient() {
            sock_ = socket(AF_INET, SOCK_DGRAM, 0);
            if (sock_ == -1) {
                std::cerr << "Socket creation failed!" << std::endl;
                exit(1);
            }

            bzero(&svr_, sizeof(svr_));
            svr_.sin_family = AF_INET;
            svr_.sin_addr.s_addr = inet_addr(server_ip_.c_str());
            svr_.sin_port = htons(server_port_);

            // 启动线程
            recv_->run();
            send_->run();
        }

        void SendMessage() {
            while (true) {
                std::string msg;
                std::cout << "Enter message: ";
                std::getline(std::cin, msg);
                ssize_t n = sendto(sock_, msg.c_str(), msg.size(), 0, (const struct sockaddr*)&svr_, sizeof(svr_));
                if (n == -1) {
                    std::cerr << "Send failed!" << std::endl;
                    continue;
                }
            }
        }

        void RecvMessage() {
            char buff[1024];
            while (true) {
                ssize_t n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&svr_, nullptr);
                if (n > 0) {
                    buff[n] = '\0';
                    std::cout << "Received: " << buff << std::endl;
                }
            }
        }

    private:
        std::string server_ip_;
        uint16_t server_port_;
        int sock_;
        struct sockaddr_in svr_;
        Thread* recv_;
        Thread* send_;
    };
}

4.7 编译和运行

确保在编译时链接 pthread 库:

cpp 复制代码
g++ -o server server.cc -std=c++11 -lpthread
g++ -o client client.cc -std=c++11 -lpthread

总结

通过引入环形队列、哈希表、多线程等技术,实现了一个基于 UDP 协议的多人聊天室。服务器通过 recv 接收消息,并通过环形队列管理消息,消费者线程将消息广播给所有用户。客户端通过多线程实现消息的实时发送和接收,确保了聊天室的高效通信。

相关推荐
科技小郑36 分钟前
吱吱企业通讯软件可私有化部署,构建安全可控的通讯办公平台
大数据·网络·安全·信息与通信·吱吱企业通讯
梅见十柒2 小时前
UNIX网络编程笔记:共享内存区和远程过程调用
linux·服务器·网络·笔记·tcp/ip·udp·unix
ajassi20003 小时前
开源 C++ QT Widget 开发(八)网络--Http文件下载
网络·c++·开源
计算机小手5 小时前
内网穿透系列十二:一款基于 HTTP 传输和 SSH 加密保护的内网穿透工具 Chisel ,具备抗干扰、稳定、安全特性
经验分享·网络协议·安全·docker·开源软件
云望无线图传模块5 小时前
突破视界的边界:16公里远距离无人机图传模块全面解析
网络·物联网·无人机
默默地离开6 小时前
从 HTTP/0.9 到 HTTP/2.0:一文看懂协议演进与性能优化
网络协议
key_Go7 小时前
05.《ARP协议基础知识探秘》
运维·服务器·网络·华为·arp
好名字更能让你们记住我8 小时前
Linux网络基础1(一)之计算机网络背景
linux·服务器·网络·windows·计算机网络·算法·centos
神一样的老师9 小时前
面向 6G 网络的 LLM 赋能物联网:架构、挑战与解决方案
网络·物联网·架构
Prejudices9 小时前
Linux查看有线网卡和无线网卡详解
linux·网络