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

发布于:2025-08-31 ⋅ 阅读:(26) ⋅ 点赞:(0)

目录

🌦️正文

1.预备知识

1.1. IP地址概述

1.2. 端口号的作用

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

1.4. 传输层协议的选择

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

2. Socket 套接字

2.1 Socket 常见 API

2.2 sockaddr 结构体

2.3. 创建套接字

2.4. 绑定 IP 地址和端口号

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

3.字符串回响 UDP 服务

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

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

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

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

5. 创建 Makefile

6. 远程 Bash 执行功能

7. 安全检查

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

9. 启动服务器

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

4.1 核心功能

4.2 程序结构

4.3 引入环形队列

4.4 引入用户信息

4.5 引入多线程

4.6 客户端多线程化

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 协议中,采用了第二种方案,即统一使用大端字节序。这种方式大大简化了跨平台数据传输的复杂性,避免了因字节序不同而导致的错误。

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

#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 接口:

#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 函数说明

#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 提供错误信息。

代码示例

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 函数说明

#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

代码示例

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 结构体,要想使用该结构体,还得包含下面这两个头文件

#include <netinet/in.h>
#include <arpa/inet.h>

sockaddr_in 结构体解释

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语言 中一个非常少用的语法 ##(将两个字符串拼接);

/* 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_ 参数后,经过 ## 字符串拼接、宏替换等操作后,会得到这样一个类型

sa_family_t sin_family;

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

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

/* Type to represent a port.  */
typedef uint16_t in_port_t;

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

/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
{
  in_addr_t s_addr;
};

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

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

#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 地址。这意味着服务器会监听所有网络接口上的请求。

修改后的服务器头文件:

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 协议进行,服务器不保存任何连接状态,保证了高效的无连接数据传输。

2. 程序结构

程序包含以下四个文件:

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

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

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

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

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

#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 文件 (服务器源文件)

#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 文件 (客户端头文件)

#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 文件 (客户端源文件)

#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

.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 函数来执行这些命令并返回结果。

远程命令执行函数

#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,我们需要进行安全检查,过滤掉不安全的命令。

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. 服务器启动并执行命令

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

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

9. 启动服务器

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

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(简化示例)

#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 结构体)。

std::unordered_map<std::string, sockaddr_in> userTable_;

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

4.5 引入多线程

我们使用两个线程:

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

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

服务器端多线程实现

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. 接收消息线程:负责接收从服务器广播的消息。

客户端头文件

#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 库:

g++ -o server server.cc -std=c++11 -lpthread
g++ -o client client.cc -std=c++11 -lpthread

总结

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


网站公告

今日签到

点亮在社区的每一天
去签到