socket编程UDP

UDP Socket 编程


零、UDP Socket 编程接口速查

网络编程的基础工具就是几个系统调用和转换函数。这里集中列出 UDP 编程最常用的接口,后续所有代码都基于它们。

0.1 核心系统调用

函数 头文件 作用
socket() <sys/socket.h> 创建套接字------打开网卡文件,返回 fd
bind() <sys/socket.h> 将 socket 绑定到本地 IP + 端口
sendto() <sys/socket.h> UDP 发送数据到指定目标
recvfrom() <sys/socket.h> UDP 接收数据,同时获取发送方地址
close() <unistd.h> 关闭套接字,释放资源
socket()
c 复制代码
int socket(int domain, int type, int protocol);
  • domainAF_INET(IPv4)/ AF_UNIX(本地)
  • typeSOCK_DGRAM(UDP 数据报)/ SOCK_STREAM(TCP 字节流)
  • protocol:一般传 0,前两个参数已确定协议
  • 返回值:成功返回文件描述符(3, 4, 5...),失败返回 -1
bind()
c 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 将 socket 与本地地址绑定。服务器必须调用,客户端通常不显式调用(OS 隐式绑定)
  • addr 实际传入 struct sockaddr_in* 强转后的指针
  • 返回值:成功 0,失败 -1
sendto()
c 复制代码
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
  • UDP 发送:向 dest_addr 发送 buf 中的 len 字节
  • flags:一般传 0(阻塞发送)
  • 返回值:实际发送的字节数,失败返回 -1
  • 首次调用时,如果 socket 未 bind,OS 自动分配随机端口
recvfrom()
c 复制代码
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
  • UDP 接收:将数据读入 buf,同时用 src_addr 返回发送方地址
  • flags:一般传 0(阻塞接收)
  • addrlen:值-结果参数------传入 src_addr 的大小,传出实际填充长度
  • 返回值:实际接收的字节数,失败返回 -1

0.2 地址结构体

sockaddr_in(IPv4 地址)
cpp 复制代码
struct sockaddr_in {
    sa_family_t    sin_family;    // 地址族 = AF_INET
    in_port_t      sin_port;      // 端口号(网络字节序)
    struct in_addr sin_addr;      // IPv4 地址
    char           sin_zero[8];   // 填充占位,必须置 0
};

struct in_addr {
    uint32_t s_addr;              // 32 位 IPv4 地址(网络字节序)
};
  • sin_familyAF_INET,与 socket()domain 保持一致
  • sin_port必须用 htons() 转换
  • sin_addr.s_addr必须用 inet_addr()inet_pton() 填充,或用 INADDR_ANY
  • sin_zero:用 bzero()memset() 清零

0.3 字节序转换

函数 原型 作用
htons uint16_t htons(uint16_t) H ost to N etwork Short(16 位,用于端口号)
htonl uint32_t htonl(uint32_t) H ost to N etwork Long(32 位,用于 IP 地址)
ntohs uint16_t ntohs(uint16_t) N etwork to H ost Short
ntohl uint32_t ntohl(uint32_t) N etwork to H ost Long

记法: h = host(主机字节序)、n = network(网络字节序 = 大端)、s = short(16 位)、l = long(32 位)。

无论当前机器是大端还是小端,这些函数保证"该转就转,不该转不动"------大端机上传入什么就返回什么,小端机上做翻转。

知识点: 端口号是 16 位,用 htons / ntohs;IP 地址是 32 位,用 htonl / ntohlsendto / recvfrom 内部的数据已经是网络字节序,直接使用即可,不需要重复转换。

0.4 IP 地址转换

函数 作用 特点
inet_addr() 点分十进制字符串 → 32 位网络字节序整数 简单但不区分错误(255.255.255.255-1 冲突)
inet_ntoa() 32 位网络字节序整数 → 点分十进制字符串 返回静态缓冲区指针,非线程安全
inet_pton() 字符串 → 网络字节序二进制(支持 IPv4/IPv6) 现代替代,线程安全,推荐
inet_ntop() 网络字节序二进制 → 字符串 现代替代,线程安全,推荐
c 复制代码
// 旧式(简单但有限制)
struct sockaddr_in addr;
addr.sin_addr.s_addr = inet_addr("192.168.1.1");    // 字符串 → 网络序
printf("%s\n", inet_ntoa(addr.sin_addr));            // 网络序 → 字符串

// 新式(推荐,线程安全)
inet_pton(AF_INET, "192.168.1.1", &addr.sin_addr);   // 字符串 → 网络序
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &addr.sin_addr, ip_str, sizeof(ip_str)); // 网络序 → 字符串

0.5 头文件汇总

cpp 复制代码
#include <sys/socket.h>    // socket / bind / sendto / recvfrom / sockaddr
#include <netinet/in.h>    // sockaddr_in / in_addr / INADDR_ANY
#include <arpa/inet.h>     // htons / htonl / ntohs / ntohl / inet_addr / inet_ntoa
#include <unistd.h>        // close
#include <string.h>        // bzero / memset

一、V1 EchoServer


1.1 前言

网络基础中,我们走完了从"协议是什么"到"socket 编程预备知识"的整条知识链:协议分层、MAC/IP 地址、端口号、网络字节序、sockaddr 多态设计。现在把这些理论落成代码

第一个实战项目是 UDP Echo Server (V1 版本)------服务器收到客户端发来的消息,原封不动地回传过去。先写服务端,把服务器封装为一个 UdpServer 类:

  • Init() 初始化------打开网卡、填充地址信息、绑定端口
  • Start() 启动------死循环收包、回传

Echo Server 是网络编程的 "Hello World"。代码虽短,socket 编程的完整流程一步不少。


1.2 socket()------打开网卡文件

一切皆文件------Linux 把这个哲学贯彻到了网络编程的入口。

socket() 的返回值是一个 int。它和 open() 一样,成功返回一个文件描述符 ------345......(0/1/2 已被标准流占用)。网络在进程眼里,就是一个"文件"的读写。

UDP 服务器固定传 AF_INET + SOCK_DGRAMprotocol 直接填 0(参数详解参见零章)。拿到 fd 后保存在 _sockfd 中,后续所有操作都通过它。

知识点: socket 的本质是打开网卡文件 。内核为这个 socket 分配 struct file 对象,进程通过 fd 访问它。后面所有的收发操作,本质上都是在读写这个文件。


1.3 Init()------服务器初始化三步走

作为一个网络通信程序,服务器必须要有自己的网络信息:IP 地址和端口号。Init() 做的事情就是把这些信息"写入"刚才打开的网卡文件。

1.3.1 第一步:创建 socket
cpp 复制代码
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
    LOG(LogLevel::FATAL) << "Create Socket Error";
    exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "Create Socket Succeed, sockfd: " << _sockfd;

调用 socket(),拿到一个 fd。负数表示失败,直接退出。

1.3.2 第二步:填充网络信息(sockaddr_in)

现在有了"文件",但还没告诉内核"我是谁"。服务器必须要有固定的端口号------否则客户端不知道该往哪连。

怎么把信息告诉内核?思路是:先在栈上建立结构体填充好,再通过 bind 写入内核。 具体结构体 sockaddr_in 的字段已在零章列出,这里直接动手填:

为什么 socket() 已经填了 AF_INET,sockaddr_in 里还要有 sin_family

两者作用不同:socket()domain 告诉内核创建什么类型的 socket,而 sin_family 让后续的 bind()connect()独立判断 地址类型------不需要回溯去查 socket 当初的 domain。底层的 AF_INET 是一个宏,用 ## 做符号拼接(a##1a1),最终就是一个整数常量。

填充代码:

cpp 复制代码
struct sockaddr_in local;                       // 栈上的局部变量,还没设到内核
bzero(&local, sizeof(local));                   // 先清零(sin_zero 必须为 0)
local.sin_family = AF_INET;                     // 地址族
local.sin_port = htons(_port);                  // 端口号:主机序 → 网络序
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // IP:字符串 → 四字节整数 + 网络序

bzero 清零、htons 转端口、inet_addr 转 IP------这些转换函数的细节见零章字节序和 IP 转换小节。

知识点: 此时 local 还只是在用户栈 上的一块内存,并没有设置到内核中。要等到 bind() 这一步,才会真正把信息写入内核。

1.3.3 第三步:绑定(bind)
cpp 复制代码
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if (n < 0)
{
    LOG(LogLevel::FATAL) << "Bind Socket Error";
    exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "Bind Socket Succeed, port: " << _port;

bind() 把栈上的网络信息写入内核 ,让这个 socket 与特定的 IP + 端口关联。注意 (struct sockaddr*)&local 的强制转型------这就是网络基础 5.7 节讲的"多态"设计:sockaddr 是基类,sockaddr_in 是子类,函数接收基类指针,内部按需向下转型。

完整 Init() 代码与运行结果
cpp 复制代码
void Init()
{
    // 1. 创建 socket ------ 本质:打开网卡文件
    _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (_sockfd < 0)
    {
        LOG(LogLevel::FATAL) << "Create Socket Error";
        exit(SOCKET_ERR);
    }
    LOG(LogLevel::INFO) << "Create Socket Succeed, sockfd: " << _sockfd;

    // 2. 填充网络信息 ------ 先在栈空间建立结构体,填充
    struct sockaddr_in local;                      // 用户栈上的变量,还没设到内核
    bzero(&local, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port);
    local.sin_addr.s_addr = inet_addr(_ip.c_str());

    // 3. bind ------ 将网络信息写入内核
    int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
    if (n < 0)
    {
        LOG(LogLevel::FATAL) << "Bind Socket Error";
        exit(BIND_ERR);
    }
    LOG(LogLevel::INFO) << "Bind Socket Succeed, port: " << _port;
}
shell 复制代码
root@ALiServer:1.EchoServer# ./server_udp 127.0.0.1 8888
[2026-07-19 19:00:37.364074] [INFO] [940605] [EchoServer.hpp] [41]  - Create Socket Succeed, sockfd: 3
[2026-07-19 19:00:37.364407] [INFO] [940605] [EchoServer.hpp] [57]  - Bind Socket Succeed, port: 8888

sockfd 从 3 开始------0/1/2 已被标准输入输出错误流占用。Bind 成功后,服务器就"入驻"了 127.0.0.1:8888,等待客户端数据。


1.4 Start()------收发数据

Init() 解决了"我是谁",Start() 负责"我要干什么"------死循环收包、回传。

1.4.1 recvfrom------接收数据

收数据的时候,我们想知道两件事 :数据本身是什么(buf + len),以及数据是谁发的(src_addr + addrlen,用于后续回复)。flags 设为 0 表示阻塞读取------没数据来就一直等着。

cpp 复制代码
char inbuffer[128];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);           // 输入:告诉内核 peer 有多大
int n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
                 (struct sockaddr *)&peer, &len);
                                         // 输出:len 变成实际填充的地址长度
  • sizeof(inbuffer) - 1:给 \0 留一个位置,方便把收到的数据当字符串用
  • peer:输出参数,内核把发送方的地址信息填进去
  • len:值-结果参数------传入时是 peer 的大小,传出时是实际填充的长度
1.4.2 sendto------发送数据

发数据的时候要回答三个问题 :发送什么数据(buf + len)、发给谁(dest_addr + addrlen)、怎么发(flags = 0 阻塞发送)。UDP 是全双工的------同一个 socket 能发的同时也能收。

cpp 复制代码
inbuffer[n] = 0;  // recvfrom 不会自动加 \0,手动补上
std::string echo_string = "Server Echo# ";
echo_string += inbuffer;
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0,
       (struct sockaddr *)&peer, len);

知识点: 对方的 socket 信息(IP + 端口)在 recvfrom 阶段就已经拿到了------peer 里存的就是。所以 sendto 可以直接用,不需要额外查询。但注意 peer 中的 sin_portsin_addr网络字节序 (大端),sendto 可以直接用因为它本来就是网络序;但如果要打印或展示给用户看,需要用 ntohs() / inet_ntoa() 转回主机序。我们先实现通信,提取信息的事后面再展开。

1.4.3 完整 Start() 代码
cpp 复制代码
void Start()
{
    while (true)
    {
        char inbuffer[128];
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
                         (struct sockaddr *)&peer, &len);
        if (n > 0)
        {
            inbuffer[n] = 0;  // 手动添加字符串结尾
            LOG(LogLevel::DEBUG) << "Client Say# " << inbuffer;
            std::string echo_string = "Server Echo# ";
            echo_string += inbuffer;
            sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0,
                   (struct sockaddr *)&peer, len);
        }
        else
        {
            LOG(LogLevel::ERROR) << "Recvfrom Error";
        }
    }
}

Echo Server 完整数据流: recvfrom 收包 → 拼 "Server Echo# " 前缀 → sendto 回传。客户端发什么,服务器就回什么------这是最简单也最直观的"证明我能通信"。


1.5 客户端设计

服务端写完后,来实现客户端。客户端要做的事情更简单------不需要显式 bind,直接发数据然后收回应。

1.5.1 为什么客户端不显式 bind?

先反过来想:服务端为什么必须显式 bind? 服务端的端口号必须被所有客户端知道------客户端要找到服务器,需要固定的 IP + 端口号。如果服务端端口号随意变化,客户端就不知道该往哪发数据了。

那客户端呢?客户端的端口号具体是几不重要 ,只要具有唯一性------在同一台机器上能区分不同进程就可以了。事实上,如果客户端显式 bind 一个固定端口,反而会出问题:

  • 同一台机器上跑两个客户端实例 → 端口冲突 → bind 失败
  • 客户端退出后端口还处于等待回收状态 → 立刻重启 → bind 失败

所以客户端不显式 bind ,而是由 OS 自主选择随机端口

UDP 客户端在首次调用 sendto 发送数据时,OS 底层会自动分配一个未被占用的随机端口,隐式完成 bind。整个过程对客户端代码完全透明。

服务端 客户端
是否显式 bind? ✅ 必须 ❌ 不要(会引发端口冲突)
端口号要求 固定、已知 随机、唯一即可
谁分配端口? 程序员指定 OS 自动分配
不绑定会怎样? 客户端找不到服务器 OS 隐式绑定,不绑也能用

在大公司内部,服务器的端口号通常是被统一管理的------不是想用哪个就用哪个。

1.5.2 客户端需要什么信息

客户端不需要 bind 自己的地址,但必须知道服务端是谁。这就要求用户告诉客户端两样东西:

  • 服务端 IP: 数据发往哪台机器
  • 服务端端口: 数据发往哪个进程

市面上的客户端(微信、QQ、浏览器)之所以不需要你手动输入这些,是因为服务端的 IP 和端口内置在客户端代码里------它们本来就是一家公司的产品。

客户端的通信流程很简单:创建 socket → 填充服务端信息 → 获取用户输入 → 发送数据 → 接收回应。客户端可能同时与多个服务端通信(虽然本例只有一个),所以 recvfrom 最好保留第五、六个参数,以便确认回应来自哪个服务端。

1.5.3 完整客户端代码
cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

static void Usage(const std::string &process)
{
    std::cerr << "Usage: \n\t";
    std::cerr << process << " server_ip server_port" << std::endl;
}

// ./client_udp server_ip server_port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        return -1;
    }

    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    // 1. 创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "Create Socket Error" << std::endl;
        exit(2);
    }

    // 2. 填充服务端信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));                  // 清零,避免残留值干扰
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());

    while (true)
    {
        std::string message;
        // 3. 获取用户输入
        std::cout << "Please Enter# ";
        std::getline(std::cin, message);

        // 4. 发送数据给 server ------ 首次发送时 OS 自动 bind 随机端口
        ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0,
                           (struct sockaddr *)&server, sizeof(server));
        if (n > 0)
        {
            char inbuffer[128];
            struct sockaddr_in tmp;
            socklen_t len = sizeof(tmp);
            ssize_t m = recvfrom(sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
                                 (struct sockaddr *)&tmp, &len);
            if (m > 0)
            {
                inbuffer[m] = 0;  // recvfrom 不会自动加 \0
                std::cout << inbuffer << std::endl;
            }
        }
    }
    return 0;
}

知识点: 注意 recvfrom 保留了第五、六个参数 (struct sockaddr *)&tmp&len。虽然本例中 tmp 没有进一步使用,但保留这两个参数比填 NULL 更稳妥------后续如果需要验证回应是否来自期望的服务端,这些信息就在手边。

1.5.4 本地回环测试

先在同一台机器上用 127.0.0.1 测试------这是最快速的验证方式。

127.0.0.1本地环回地址(localhost)。数据包不会真正发送到网络上,只在本机的协议栈中跑一圈就回来了------本质上等同于进程间通信。本地环回经常用于网络代码的开发和测试阶段,用来快速验证代码逻辑是否正确。

shell 复制代码
# 终端 1:启动服务端
root@ALiServer:1.EchoServer# ./server_udp 8080
[2026-07-19 20:11:47.30066] [INFO] [948137] [EchoServer.hpp] [41]  - Create Socket Succeed, sockfd: 3
[2026-07-19 20:11:47.30300] [INFO] [948137] [EchoServer.hpp] [57]  - Bind Socket Succeed, port: 8080

# 终端 2:启动客户端
root@ALiServer:1.EchoServer# ./client_udp 127.0.0.1 8080
Please Enter# 你好
Server Echo# 你好
Please Enter# 1234
Server Echo# 1234
Please Enter# 2222
Server Echo# 2222

服务端日志同步确认:

shell 复制代码
[2026-07-19 20:11:57.825800] [DEBUG] [948137] [EchoServer.hpp] [71]  - Client Say# 你好
[2026-07-19 20:12:08.24495] [DEBUG] [948137] [EchoServer.hpp] [71]  - Client Say# 1234
[2026-07-19 20:12:09.735024] [DEBUG] [EchoServer.hpp] [71]  - Client Say# 2222

本地回环测试通过,说明客户端和服务端的代码逻辑都没有问题。


1.6 INADDR_ANY------服务器的最佳实践

1.6.1 公网 IP bind 失败

本地测试通过后,自然想试试跨网络------让服务器绑定公网 IP,接受外部客户端的请求:

shell 复制代码
root@ALiServer:1.EchoServer# ./server_udp <公网IP> 8080
[2026-07-19 20:16:57.677186] [INFO] [948928] [EchoServer.hpp] [41]  - Create Socket Succeed, sockfd: 3
[2026-07-19 20:16:57.677478] [FATAL] [948928] [EchoServer.hpp] [54]  - Bind Socket Error

直接 bind 失败。原因有两条:

  1. 云服务器的公网 IP 通常不绑定在 eth0 上。 以阿里云为例,公网 IP 是弹性 IP,通过 NAT 网关映射到内网 IP。操作系统根本不认这个 IP------ip addr 看不到它------自然无法 bind。
  2. 云厂商禁止用户显式 bind 公网 IP。 即使公网 IP 在网卡上,出于安全管控考虑,云平台也不允许这种做法。

但不显式 bind 公网 IP,不代表公网 IP 不会被使用。服务器身上挂着多个 IP 地址:

复制代码
127.0.0.1          ← lo 回环接口
172.24.18.130      ← eth0 内网 IP(真实存在于网卡上)
106.14.7.57        ← 弹性公网 IP(NAT 映射,不在本机)

如果显式 bind 公网 IP,服务器就只收目标地址是公网 IP 的报文------内网 IP 和回环地址发来的包全部忽略。这显然不是我们想要的。

1.6.2 INADDR_ANY:照单全收

最佳实践:不显式 bind 任何具体 IP,改用 INADDR_ANY

cpp 复制代码
#define INADDR_ANY  ((in_addr_t) 0x00000000)  // 即 0.0.0.0

0.0.0.0 不是一个真实 IP,它的语义是:"我不挑地址------任意网卡、任意 IP 的报文,只要端口号对得上,全部接收。"

不管请求是从公网 IP 进来的(NAT 网关转发后到达 eth0 内网 IP),还是从 127.0.0.1 进来的(本地测试),又或从内网 IP 进来的(同子网其他机器),INADDR_ANY 照单全收。

cpp 复制代码
// ❌ 旧写法:显式绑定指定 IP(云服务器上行不通)
local.sin_addr.s_addr = inet_addr(_ip.c_str());

// ✅ 最佳实践:绑定任意地址------不管哪个 IP,端口号对了就收
local.sin_addr.s_addr = INADDR_ANY;

改动之后,服务器构造函数也随之简化------不再需要 _ip 成员,只传端口号即可:

cpp 复制代码
UdpServer(uint16_t port = defaultport)
    : _port(port), _sockfd(defaultfd)
{
}

最佳实践总结: ① 服务器开发中,特别不建议显式 bind IP 地址 ------直接用 INADDR_ANY。② 端口号仍然必须显式 bind,否则客户端不知道连哪个端口。③ 不管请求从哪个 IP 进来、走哪张网卡,只要端口号对,照单全收。


1.7 补充知识

1.7.1 查看网络状态------netstat

程序跑起来后,怎么确认端口确实被占用了?用 netstat

shell 复制代码
# 查看所有 UDP 进程,数字格式显示(不做 DNS 反向解析)
netstat -anup

# 查看所有 TCP 进程
netstat -antp

参数含义:

参数 含义
-a 显示所有连接和监听端口
-n 以数字形式显示地址和端口(不做域名反查,更快)
-u 只显示 UDP
-t 只显示 TCP
-p 显示进程名和 PID

一条典型的 TCP 输出:

shell 复制代码
tcp  0  0  172.24.18.130:22  121.224.20.223:46593  ESTABLISHED  964863/sshd: root@p
  • 172.24.18.130:22:本机内网 IP 的 22 端口
  • 121.224.20.223:46593:远端的 IP 和临时端口
  • ESTABLISHED:连接已建立
  • 964863/sshd:PID 964863 的 sshd 进程

能通过 SSH 连上这台服务器,正是因为 22 端口上跑着 sshd 服务。端口本质上是"门牌号"------22 号房间住着 sshd,8080 号房间现在住着你的 UdpServer。

知识点: netstat -anup 是调试网络程序最常用的命令之一。程序跑不起来先别急着改代码,用 netstat 看一眼端口到底有没有被占用------是 bind 失败还是防火墙拦截,一行命令就能分清楚。

1.7.2 云服务器与安全组

前面第五章提到"公网 IP bind 失败",这里补充一个相关的实操细节:外部客户端要通过公网 IP 访问你的服务器,除了代码正确,还要在安全组和防火墙开放端口。

复制代码
外部客户端 → 公网 IP → 云安全组(入方向规则)→ 服务器防火墙 → 你的程序
  • 安全组: 在云控制台配置,默认不放行非标准端口
  • 服务器防火墙(iptables / firewalld): 在本机配置

调试时如果外部客户端连不上:

  1. 先用 netstat -anup 确认端口在监听
  2. 再去安全组确认入方向规则有对应端口
  3. 最后检查本地防火墙

三层检查一层都不能少。

1.7.3 Windows 下的 UDP Socket 编程

以上所有代码都在 Linux 下运行。Windows 下也要做 UDP socket 编程------核心接口完全一致

  • socket()bind()sendto()recvfrom() 的函数签名和 Linux 一模一样
  • 数据结构 sockaddr_inin_addr 也一样

唯一的差别在用户层的封装

  • Windows 需要先调用 WSAStartup() 初始化 Winsock 库,结束时调用 WSACleanup() 清理
  • 关闭 socket 用 closesocket() 而不是 close()
  • 头文件是 <winsock2.h>,还需要链接 ws2_32.lib

除此之外,核心逻辑代码原封不动就能跨平台。这也是 POSIX 标准的威力------操作系统不同,API 一致。


二、V2 DictServer

目前 EchoServer 的做法是:收到数据后,服务器自己处理数据(拼一个 "Server Echo#" 前缀),再发回去。

cpp 复制代码
// 当前做法:服务器自己处理
inbuffer[n] = 0;
std::string echo_string = "Server Echo# ";
echo_string += inbuffer;                           // 处理逻辑写在服务器里
sendto(_sockfd, echo_string.c_str(), ...);

但这不是好架构。网络通信程序应该分层

复制代码
┌──────────────────────────┐
│   上层:业务逻辑          │  ← 翻译、计算、查询......(与网络无关)
│   (处理数据)            │
├──────────────────────────┤
│   下层:网络传输          │  ← recvfrom / sendto(只负责收发)
│   (UdpServer)           │
└──────────────────────────┘
  • 下层(UdpServer): 只负责网络 I/O------收数据、发数据。不管数据内容是什么。
  • 上层(业务层): 只负责处理数据------收到字符串后做什么。不管数据怎么来、怎么去。

下层收包后把数据交给上层 ,上层处理完把结果交给下层,下层再发回去。这就是"关注点分离"。

V2 的 DictServer 将把这个架构落地:服务端不再自己处理数据,而是回调一个上层传入的处理函数------收到英文单词,查词典翻译成中文,再通过下层发回客户端。

网络层管运输,业务层管内容。各司其职,谁也别跨界。

2.1 词典文件 dict.txt

先用最简单的格式存储词典------每行一条记录,英文和中文用 : 分隔:

shell 复制代码
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
happy: 快乐的
sad: 悲伤的
run: 跑
jump: 跳
teacher: 老师
student: 学生
car: 汽车
bus: 公交车
love: 爱
hate: 恨
hello: 你好
goodbye: 再见
summer: 夏天
winter: 冬天

客户端不用做任何修改------仍然是输入内容、UDP 发送、接收回应。变化全在服务端:它不再"echo",而是查词典

2.2 架构关键:回调函数

要把网络层和业务层拆开,C/C++ 最直接的方式就是回调函数 。在 UdpServer 中新增一个成员,用于接收用户传来的"处理函数":

cpp 复制代码
using callback_t = std::function<std::string(std::string)>;

这个类型的语义是:输入一个 string(客户端发来的数据),输出一个 string(处理后的结果)。字典翻译、计算器、大小写转换......只要是"收字符串、回字符串"的业务,都符合这个签名。

回调函数的角色:网络层收到数据后,自己不处理,原样交给回调函数。回调函数处理完后,把结果还给网络层,网络层发回去。网络层不关心业务逻辑,业务层不关心数据怎么来。

2.3 UdpServer 改造

相比 V1 EchoServer,只改了三处:

cpp 复制代码
class UdpServer
{
public:
    // 改动 1:构造函数新增回调参数
    UdpServer(callback_t cb, uint16_t port = defaultport)
        : _cb(cb), _port(port), _sockfd(defaultfd)
    {
    }

    void Init() { /* 和 V1 完全一样,省略 */ }

    void Start()
    {
        char inbuffer[1024];
        while (true)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
                             (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                uint16_t client_port = ntohs(peer.sin_port);
                std::string client_ip = inet_ntoa(peer.sin_addr);
                std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]# ";

                inbuffer[n] = 0;
                LOG(LogLevel::DEBUG) << client_address << inbuffer;
                
                // 改动 2:处理数据 → 交给回调函数
                std::string result = _cb(inbuffer);

                // 改动 3:发送处理结果(而不是固定 Echo 前缀)
                sendto(_sockfd, result.c_str(), result.size(), 0,
                       (struct sockaddr *)&peer, len);
            }
            else
            {
                LOG(LogLevel::ERROR) << "Recvfrom Error";
            }
        }
    }

private:
    int _sockfd;
    uint16_t _port;
    callback_t _cb;  // 新增:业务回调
};
V1 EchoServer V2 DictServer
echo_string = "Server Echo# " + inbuffer result = _cb(inbuffer)
处理逻辑硬编码在服务器里 处理逻辑由外部注入
换业务 = 改服务器代码 换业务 = 换回调函数

知识点: _cb 的类型是 std::function<std::string(std::string)>。这是 C++ 的函数包装器,可以接收 lambda、函数指针、函数对象------只要签名匹配。正是这个设计让 UdpServer 从"Echo 专用"变成了"通用 UDP 服务器"。

2.4 Dict 字典类

Dict 类负责两件事:加载词典 + 查询翻译。两个核心成员:

cpp 复制代码
std::string _dict_path;                                  // 词典文件路径
std::unordered_map<std::string, std::string> _dict;      // O(1) 查询
2.4.1 加载词典(LoadDict)
cpp 复制代码
void LoadDict()
{
    std::fstream in(_dict_path);
    if (!in.is_open())
    {
        LOG(LogLevel::FATAL) << "Open " << _dict_path << " Error";
        exit(1);
    }

    std::string line;
    while (getline(in, line))
    {
        LOG(LogLevel::DEBUG) << "Load: " << line << " Succeed";

        auto pos = line.find(sep);                       // sep = ": "
        if (pos == std::string::npos)
        {
            LOG(LogLevel::ERROR) << "Format: " << line << " Error";
            continue;                                    // 跳过格式错误行
        }

        std::string k = line.substr(0, pos);             // 分隔符之前 = key
        std::string v = line.substr(pos + sep.size());   // 分隔符之后 = value
        _dict.insert(std::make_pair(k, v));
    }
}

逐行解析词典文件,用 : 分割英文和中文,插入 hash map。格式不对的行 continue 跳过,不中断整体加载。

2.4.2 查询翻译(Translate)
cpp 复制代码
std::string Translate(std::string word)
{
    auto iter = _dict.find(word);
    if (iter == _dict.end())
    {
        return "None";          // 查不到的单词返回 "None"
    }
    else
    {
        return iter->second;    // 命中 → 返回中文释义
    }
}

unordered_map::find 是 O(1) 平均复杂度,十万词条也是瞬间返回。

2.4.3 完整 Dict 类
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>
#include "Logger.hpp"

static const std::string default_path = "./dict.txt";
static const std::string sep = ": ";

using namespace NS_LOG_MODULE;

class Dict
{
private:
    void LoadDict()
    {
        std::fstream in(_dict_path);
        if (!in.is_open())
        {
            LOG(LogLevel::FATAL) << "Open " << _dict_path << " Error";
            exit(1);
        }

        std::string line;
        while (getline(in, line))
        {
            LOG(LogLevel::DEBUG) << "Load: " << line << " Succeed";

            auto pos = line.find(sep);
            if (pos == std::string::npos)
            {
                LOG(LogLevel::ERROR) << "Format: " << line << " Error";
                continue;
            }

            std::string k = line.substr(0, pos);
            std::string v = line.substr(pos + sep.size());
            _dict.insert(std::make_pair(k, v));
        }
    }

public:
    Dict(const std::string &path = default_path)
        : _dict_path(path)
    {
        LoadDict();
    }

    std::string Translate(std::string word)
    {
        auto iter = _dict.find(word);
        if (iter == _dict.end())
        {
            return "None";
        }
        else
        {
            return iter->second;
        }
    }

    ~Dict() {}

private:
    std::string _dict_path;
    std::unordered_map<std::string, std::string> _dict;
};

2.5 主函数:绑定上下两层

改造后的 ServerMain:创建 Dict 对象 → 用 lambda 把 Translate 包装成回调 → 注入 UdpServer:

cpp 复制代码
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        return USAGE_ERR;
    }

    // 1. 定义字典(上层:业务逻辑)
    Dict dict;

    // 2. 构建网络服务(下层:网络传输)
    uint16_t server_port = std::stoi(argv[1]);

    // 3. 绑定上下两层:lambda 捕获 dict,把 Translate 塞进 UdpServer
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(
        [&dict](std::string word) -> std::string {
            return dict.Translate(word);
        },
        server_port);

    usvr->Init();
    usvr->Start();
    return 0;
}

[&dict] 按引用捕获 Dict 对象,lambda 内部调用 dict.Translate(word)。UdpServer 完全不知道 Dictionary 的存在------它只看到一个 callback_t,调用它,拿到结果,发回去。

2.6 测试验证

shell 复制代码
# 服务端
root@ALiServer:2.DictServer# ./server_dict 12345
[2026-07-20 22:35:22.317245] [DEBUG] [984463] [Dict.hpp] [30]  - Load: apple: 苹果 Succeed
[2026-07-20 22:35:22.317347] [DEBUG] [984463] [Dict.hpp] [30]  - Load: banana: 香蕉 Succeed
# ...
[2026-07-20 22:35:22.317543] [DEBUG] [984463] [Dict.hpp] [30]  - Load: winter: 冬天 Succeed
[2026-07-20 22:35:22.317621] [INFO] [984463] [DictServer.hpp] [43]  - Create Socket Succeed, sockfd: 3
[2026-07-20 22:35:22.317644] [INFO] [984463] [DictServer.hpp] [60]  - Bind Socket Succeed, port: 12345
[2026-07-20 22:35:26.303573] [DEBUG] [984463] [DictServer.hpp] [79]  - [127.0.0.1:42341]# hello
[2026-07-20 22:35:27.378409] [DEBUG] [984463] [DictServer.hpp] [79]  - [127.0.0.1:42341]# apple
[2026-07-20 22:35:28.790237] [DEBUG] [984463] [DictServer.hpp] [79]  - [127.0.0.1:42341]# dog
[2026-07-20 22:35:31.952830] [DEBUG] [984463] [DictServer.hpp] [79]  - [127.0.0.1:42341]# echo

# 客户端
root@ALiServer:2.DictServer# ./client_dict 127.0.0.1 12345
Please Enter# hello
你好
Please Enter# apple
苹果
Please Enter# dog
狗
Please Enter# echo
None
  • hello你好 ✅ 字典命中
  • dog
  • echoNone ✅ 字典中没有,返回 "None"

知识点: 客户端代码一行没改 ------V1 EchoServer 的客户端直接拿来用。因为客户端只负责"发字符串、收字符串",服务端内部是 echo 还是查词典,客户端完全不需要知道。这就是分层带来的可替换性
EchoServer → DictServer 的设计演变: 不是重写,而是解耦 ------把数据处理从网络层剥离出去。V1 的 "Server Echo# " + inbuffer 变成了 _cb(inbuffer)。这一行改动,让服务器从"只能 Echo"变成了"注入什么回调就做什么"。


三、InetAddr------地址管理封装

3.1 为什么需要封装

回头看一眼当前 UdpServer 中和地址相关的代码:

cpp 复制代码
// Init() 中------填充本机地址
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;

// Start() 中------提取客户端地址
uint16_t client_port = ntohs(peer.sin_port);
std::string client_ip = inet_ntoa(peer.sin_addr);
std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]# ";

问题很明显:每次操作地址都要手动管理两种表示形式 ------主机字节序(人看的)和网络字节序(机器传的)。htons / ntohs / inet_addr / inet_ntoa 散落在各处,代码臃肿、容易出错。

另一个更根本的动机:要实现在线聊天室 ------服务端收到消息后,需要转发给所有在线客户端。这要求我们把所有客户端管理起来(放进容器),而"管理"的前提是先能干净地描述一个客户端。一个客户端的本质是什么?就是 IP + 端口。

先描述,再组织。描述一个客户端 = 一个 InetAddr 对象。

3.2 InetAddr 类设计

InetAddr 的核心思路:一个对象同时持有两种视角的地址

复制代码
┌─ InetAddr ──────────────────┐
│  主机视角(给人看)           │
│    _ip:    "127.0.0.1"      │
│    _port:  42341            │
│                              │
│  网络视角(给内核用)         │
│    _address: sockaddr_in     │
│    _len:     sizeof(...)     │
└──────────────────────────────┘

外界只需要创建 InetAddr 对象,之后用 GetNetAddress() 拿网络地址、用 ToString() 拿显示字符串------不再手写字节序转换。

3.2.1 正向构造:主机地址 → 网络地址

已知 IP 字符串和端口号(服务端 Init 时描述自己,或客户端描述目标服务器):

cpp 复制代码
InetAddr(const std::string &ip = defaultip, uint16_t port = defaultport)
    : _ip(ip), _port(port)
{
    bzero(&_address, sizeof(_address));
    _address.sin_addr.s_addr = inet_addr(_ip.c_str());  // 字符串 → 四字节网络序
    _address.sin_family = AF_INET;
    _address.sin_port = htons(_port);                    // 主机序 → 网络序
    _len = sizeof(_address);
}
3.2.2 反向构造:网络地址 → 主机地址

已知 struct sockaddr_in(服务端 recvfrom 后拿到客户端的地址):

cpp 复制代码
InetAddr(const struct sockaddr_in &address)
    : _address(address), _len(sizeof(address))
{
    _ip = inet_ntoa(_address.sin_addr);    // 四字节网络序 → 字符串
    _port = ntohs(_address.sin_port);      // 网络序 → 主机序
}

知识点: 两个构造函数的方向正好相反------正向是"我要连谁",反向是"谁在连我"。一个类覆盖了地址操作的两种典型场景。

3.2.3 辅助接口
cpp 复制代码
socklen_t Len() { return _len; }                       // 给 bind/sendto 用的地址长度
struct sockaddr_in *GetNetAddress() { return &_address; } // 给 bind/sendto 用的地址指针
std::string ToString() { return "[" + _ip + ":" + std::to_string(_port) + "]"; } // 日志用的显示字符串
bool operator==(const InetAddr &addr) { return (addr._ip == this->_ip) && (addr._port == this->_port); } // 方便容器判断是否存在
完整 InetAddr 代码
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

static const std::string defaultip = "0.0.0.0";
static const uint16_t defaultport = 8080;

class InetAddr
{
public:
    // 正向构造:主机地址 → 网络地址("我要连谁")
    InetAddr(const std::string &ip = defaultip, uint16_t port = defaultport)
        : _ip(ip), _port(port)
    {
        bzero(&_address, sizeof(_address));
        _address.sin_addr.s_addr = inet_addr(_ip.c_str());
        _address.sin_family = AF_INET;
        _address.sin_port = htons(_port);
        _len = sizeof(_address);
    }

    // 反向构造:网络地址 → 主机地址("谁在连我")
    InetAddr(const struct sockaddr_in &address)
        : _address(address), _len(sizeof(address))
    {
        _ip = inet_ntoa(_address.sin_addr);
        _port = ntohs(_address.sin_port);
    }

    socklen_t Len() { return _len; }
    struct sockaddr_in *GetNetAddress() { return &_address; }
    std::string ToString() { return "[" + _ip + ":" + std::to_string(_port) + "]"; }
    bool operator==(const InetAddr &addr) { return (addr._ip == this->_ip) && (addr._port == this->_port); }
    ~InetAddr() {}

private:
    // 网络视角(给内核用)
    struct sockaddr_in _address;
    socklen_t _len;

    // 主机视角(给人看)
    std::string _ip;
    uint16_t _port;
};

3.3 改造 UdpServer

引入 InetAddr 后,Init 和 Start 的代码大幅简化:

cpp 复制代码
// Init() ------ 改造前 vs 改造后
// 之前:手动 bzero + 逐字段填充
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;

// 之后:一行搞定
InetAddr local("0.0.0.0", _port);
// bind 也变干净了
int n = bind(_sockfd, (struct sockaddr *)local.GetNetAddress(), local.Len());

// Start() ------ 改造前 vs 改造后
// 之前:手动提取 IP 和端口
uint16_t client_port = ntohs(peer.sin_port);
std::string client_ip = inet_ntoa(peer.sin_addr);
std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]# ";

// 之后:构造函数自动完成转换
InetAddr client_address(peer);
LOG(LogLevel::DEBUG) << client_address.ToString() << inbuffer;
改造前 改造后
bzero + 逐字段赋值 + htons + inet_addr InetAddr local("0.0.0.0", _port)
ntohs + inet_ntoa + 手拼字符串 InetAddr client(peer) + .ToString()
字节序转换散落各处 封装在构造函数和接口内部

知识点: 这不是新功能,而是重构------用封装消除重复代码。InetAddr 把地址相关的所有脏活(字节序转换、格式拼装)收敛到一个类里,UdpServer 只需要用对象。

3.4 InetAddr 完善

实际 V3 代码中的 InetAddr 比第三章初版多了几个关键接口,先补全(后续 ChatServer 会用到):

cpp 复制代码
// 新增:相等比较(用于 unordered_set 去重)
bool operator==(const InetAddr &addr) const
{
    return (addr._ip == this->_ip) && (addr._port == this->_port);
}

// 新增:获取主机字节序的 IP 和 Port(用于 hash 计算)
std::string Ip() const { return _ip; }
uint16_t Port() const { return _port; }

有了 operator==Ip()/Port() getter,InetAddr 就满足 unordered_set 的全部要求了。


四、V3 ChatServer------在线聊天室

聊天室的核心数据流:客户端发消息 → 服务器转发给所有在线用户。现在把它落成代码。

4.1 总体架构

复制代码
┌─── 客户端 A ───┐     ┌─── 客户端 B ───┐
│  sender 线程    │     │  sender 线程    │
│  receiver 线程  │     │  receiver 线程  │
└────┬───────┬────┘     └────┬───────┬────┘
     │ send  │ recv          │ send  │ recv
     ▼       ▲               ▼       ▲
  ┌──────────────────────────────────────┐
  │            UdpServer                 │
  │  recvfrom → handler_addr            │
  │           → handler_msg             │
  └──────────┬─────────┬────────────────┘
             │         │
    ┌────────▼──┐  ┌──▼──────────┐
    │  Route    │  │ ThreadPool  │
    │  (加锁)    │  │  (并发)     │
    │  Client   │  │  Enqueue    │
    │  Manager  │  │  BroadCast  │
    └───────────┘  └─────────────┘

三大模块各司其职:

  • UdpServer: 只收不发业务------收包后回调上层
  • Route: 线程安全地管理用户 + 广播
  • ThreadPool: 并发执行广播任务,不让主线程阻塞

4.2 ClientManager------管理在线用户

每个在线用户就是一个 InetAddr 对象。用 unordered_set 存储,增删查全是 O(1)。

4.2.1 Hash 仿函数

unordered_set 需要 hash 函数。对 InetAddr 做 hash:

cpp 复制代码
struct InetAddrHash
{
    ssize_t operator()(const InetAddr &addr) const noexcept
    {
        return std::hash<std::string>{}(addr.Ip()) ^
               (std::hash<uint16_t>{}(addr.Port()) << 1);
    }
};

IP 的 hash 值和 Port 的 hash 值做 XOR,Port 左移 1 位避免对称冲突。

4.2.2 完整 ClientManager
cpp 复制代码
class ClientManager
{
public:
    using ClientSet = std::unordered_set<InetAddr, InetAddrHash>;
    ClientManager() {}

    void AddClient(const InetAddr &addr)    { _clients.insert(addr); }
    void DelClient(const InetAddr &addr)    { _clients.erase(addr); }
    bool SearchClient(const InetAddr &addr) { return _clients.find(addr) != _clients.end(); }
    ClientSet &Clients()                    { return _clients; }
    ~ClientManager() {}

private:
    ClientSet _clients;
};

正常项目中客户端应该有用户名、状态等字段,这里为了聚焦网络层,直接用 InetAddr 代表一个用户。后续扩展只需把 ClientSet 的 value 类型换成 User 类即可。

4.3 Route------线程安全的路由层

ClientManager 自身不是线程安全的------遍历 _clients 时如果有新用户插入,迭代器会失效。Route 在 ClientManager 外面包了一层锁:

cpp 复制代码
class Route
{
public:
    Route() : _cmpr(std::make_unique<ClientManager>()) {}

    // 登记新客户端(首次发包时调用)
    void CheckClient(const InetAddr &addr)
    {
        LockGuard lockguard(_mutex);
        _cmpr->AddClient(addr);
    }

    // 下线客户端
    void OfflineClient(const InetAddr &addr)
    {
        LockGuard lockguard(_mutex);
        _cmpr->DelClient(addr);
    }

    // 广播消息给所有在线客户端
    void BroadCast(int sockfd, std::string message)
    {
        LockGuard lockguard(_mutex);
        auto clients = _cmpr->Clients();
        for (auto &client : clients)
        {
            sendto(sockfd, message.c_str(), message.size(), 0,
                   (struct sockaddr *)client.GetNetAddress(), client.Len());
        }
    }
    ~Route() {}

private:
    std::unique_ptr<ClientManager> _cmpr;
    Mutex _mutex;
};
方法 作用 场景
CheckClient 新用户插入 set 客户端首次发包时登记
OfflineClient 用户从 set 移除 客户端断开时清理
BroadCast 遍历 set,逐个 sendto 收到消息后转发全员

知识点: 锁保护的是"用户列表"这一临界资源------遍历和插入不能同时进行。LockGuard 是 RAII 风格的锁守卫,构造时加锁、析构时解锁。

4.4 UdpServer 再进化------三回调

DictServer(V2)用一个 callback_t 解耦了数据处理。ChatServer(V3)更进一步------需要三个回调

cpp 复制代码
using _handler_addr_t    = std::function<void(const InetAddr &)>;       // 处理"谁来了"
using _handler_msg_t     = std::function<void(int sockfd, std::string)>; // 处理"说了什么"
using _handler_offline_t = std::function<void(const InetAddr &)>;       // 处理"谁走了"
  • _handler_addr:收到包后先调用------检查发送方是否新用户,是则登记
  • _handler_msg:然后调用------把消息交给上层(Route + ThreadPool)处理
cpp 复制代码
class UdpServer
{
public:
    // 注册两个回调------由上层(main 函数)传入
    void RegisterService(_handler_addr_t handler_addr, _handler_msg_t handler_msg, _handler_offline_t handler_offline)
    {
        _handler_addr = handler_addr;
        _handler_msg = handler_msg;
        _handler_offline = handler_offline;
    }

    void Start()
    {
        char inbuffer[1024];
        while (true)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
                             (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                InetAddr client_address(peer);
                inbuffer[n] = 0;
                LOG(LogLevel::DEBUG) << client_address.ToString() << inbuffer;
                std::string echo_string = client_address.ToString() + inbuffer;

                _handler_addr(peer);                    // 回调 1:登记用户
                // 检测离线消息
                std::string msg_str = inbuffer;
                if (msg_str.find("offline!") != std::string::npos)
                {
                    _handler_offline(peer);             // 回调 2:移除用户
                }
                _handler_msg(_sockfd, echo_string);     // 回调 3:处理消息
            }
        }
    }

private:
    int _sockfd;
    uint16_t _port;
    _handler_addr_t _handler_addr;
    _handler_msg_t _handler_msg;
    _handler_offline_t _handler_offline;
};

V1:硬编码 Echo → V2:单回调解耦 → V3:三回调(登记 / 消息 / 离线),每一代都在降低耦合。

4.5 主函数------三大模块组装

cpp 复制代码
using task_t = std::function<void()>;

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        return USAGE_ERR;
    }
    uint16_t server_port = std::stoi(argv[1]);

    // 1. 创建线程池(单例,全局共享)
    auto thread_pool = ThreadPool<task_t>::Instance();

    // 2. 创建路由模块(管理用户 + 广播)
    Route r;

    // 3. 创建网络模块
    UdpServer upsr(server_port);
    upsr.Init();

    // 4. 注册回调------绑定 Route 和 ThreadPool
    upsr.RegisterService(
        // 回调 1:登记新用户(直接操作,无需线程池)
        [&r](const InetAddr &addr) {
            r.CheckClient(addr);
        },
        // 回调 2:消息广播(投递到线程池,异步并发)
        [&r, &thread_pool](int sockfd, std::string msg) {
            thread_pool->Enqueue([&r, sockfd, msg]() {
                r.BroadCast(sockfd, msg);
            });
        },
        // 回调 3:用户离线(直接操作,无需线程池)
        [&r](const InetAddr &addr) {
            r.OfflineClient(addr);
        }
    );

    upsr.Start();
    return 0;
}

关键设计决策:为什么 CheckClient 不放进线程池,而 BroadCast 要放?

  • CheckClient: unordered_set::insert 极快(O(1)),直接在主线程完成,不占用线程池资源
  • BroadCast: 遍历所有用户 + N 次 sendto,耗时随在线人数线性增长 → 投递到线程池,主线程立即回去收下一个包

知识点: 即使不用线程池,单个进程轮询也能跑通------模块之间的耦合度已经做得很好了。线程池是"锦上添花"的并发优化,不是"没它不行"的必需品。

4.6 客户端改造------收发分离

V1/V2 的客户端收发是串行的:发一条 → 等回应 → 再发下一条。聊天室不能这样------你发消息的同时,别人也在发,你需要随时能收到

方案:拆成两个线程。

cpp 复制代码
// 接收线程:死循环等 recvfrom,收到就打印,sockfd 关闭时退出
Thread recever([&]() {
    while (true)
    {
        char inbuffer[1024];
        struct sockaddr_in tmp;
        socklen_t len = sizeof(tmp);
        ssize_t m = recvfrom(sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
                             (struct sockaddr *)&tmp, &len);
        if (m > 0)
        {
            inbuffer[m] = 0;
            std::cerr << inbuffer << std::endl;  // stderr,方便重定向分离
        }
    }
});

// 发送线程:先取昵称 → 发上线通知 → 循环读输入 → 发消息
Thread sender([&]() {
    InetAddr server(server_ip, server_port);
    std::cout << "Please Set Your Nick Name# ";
    std::string nickname;
    std::getline(std::cin, nickname);
    std::string online_message = nickname + " online!";
    sendto(sockfd, online_message.c_str(), online_message.size(), 0,
           (struct sockaddr *)server.GetNetAddress(), server.Len());

    while (true)
    {
        std::string message;
        std::cout << "Please Enter# ";
        std::getline(std::cin, message);
        if (message == "quit")                          // 输入 quit 退出
        {
            std::string offline_message = nickname + " offline!";
            sendto(sockfd, offline_message.c_str(), offline_message.size(), 0,
                   (struct sockaddr *)server.GetNetAddress(), server.Len());
            break;
        }
        message = nickname + "#" + message;  // 格式:昵称#内容
        sendto(sockfd, message.c_str(), message.size(), 0,
               (struct sockaddr *)server.GetNetAddress(), server.Len());
    }
});

recever.Start();
sender.Start();

sender.Join();      // 等待发送线程退出(用户输入 quit)
recever.Stop();     // pthread_cancel 强制终止接收线程
recever.Join();     // 等待接收线程退出

接收线程用 stderr 输出消息,发送线程用 stdin 读取输入------两条流互不干扰。实际测试时可以用 2>fifo 把 stderr 重定向到文件,实现"一个终端输入、另一个终端看消息"的效果。

4.7 测试验证

开 4 个终端模拟多人聊天:

shell 复制代码
# ========== 终端 A:服务端 ==========
root@ALiServer:3.ChatServer# ./server_chat 12345
[DEBUG] [1049210] [ThreadPool.hpp] [143]  - ThreadPool Create Success: 5Threads
[INFO] [1049210] [ThreadPool.hpp] [71]  - ThreadPool Singleton Created And Started
[INFO] [1049210] [UdpServer.hpp] [58]  - Create Socket Succeed, sockfd: 3
[INFO] [1049210] [UdpServer.hpp] [70]  - Bind Socket Succeed, port: 12345
# 后续每收到一条消息,就有一个线程处理:
[DEBUG] - [127.0.0.1:56728]usr1 online!         → New-Thread-5 Process Task...
[DEBUG] - [127.0.0.1:41749]usr2 online!         → New-Thread-1 Process Task...
[DEBUG] - [127.0.0.1:56728]usr1#hello i'm usr1  → New-Thread-2 Process Task...
[DEBUG] - [127.0.0.1:41749]usr2#hello i'm usr2  → New-Thread-3 Process Task...
#...
[DEBUG] [1049210] [UdpServer.hpp] [87]  - [127.0.0.1:33253]usr1 offline!
[INFO] [1049210] [ThreadPool.hpp] [54]  - New-Thread-4 Process Task...
[DEBUG] [1049210] [UdpServer.hpp] [87]  - [127.0.0.1:49893]usr2 offline!
[INFO] [1049210] [ThreadPool.hpp] [54]  - New-Thread-5 Process Task...

# ========== 终端 B:usr1 客户端 ==========
root@ALiServer:3.ChatServer# ./client_chat 127.0.0.1 12345 2>fifo1
Please Set Your Nick Name# usr1
Please Enter# hello i'm usr1
Please Enter# how are you, usr2
Please Enter# ok, goodbye
Please Enter# quit

# ========== 终端 C:usr1 的消息流(stderr 重定向到 fifo) ==========
root@ALiServer:3.ChatServer# cat < fifo1
[127.0.0.1:33253]usr1 online!
[127.0.0.1:49893]usr2 online!
[127.0.0.1:33253]usr1#hello i'm usr1
[127.0.0.1:49893]usr2#hello i'm usr2
[127.0.0.1:33253]usr1#how are you, usr2
[127.0.0.1:49893]usr2#i'm fine
[127.0.0.1:33253]usr1#ok, goodbye
[127.0.0.1:49893]usr2#goodbye

# ========== 终端 D:usr2 客户端 ==========
root@ALiServer:3.ChatServer# ./client_chat 127.0.0.1 12345 2>fifo2
Please Set Your Nick Name# usr2
Please Enter# hello i'm usr2
Please Enter# i'm fine
Please Enter# goodbye
Please Enter# quit

# usr2 的 fifo2 同样能看到 usr1 的消息

5 个线程并发处理:usr1 和 usr2 的每条消息都由不同线程执行广播。2>fifo 技巧把收消息和发消息的终端分开------一个窗口专注输入,另一个窗口专注看消息。

4.8 完整消息处理流程

V3 ChatServer 由多个模块协作完成。先看文件清单:

文件 角色
UdpServer.hpp 网络层------收包、三回调分发
InetAddr.hpp 地址封装------双视角 + hash 支持
ClientManager.hpp 用户容器------unordered_set 增删查
Route.hpp 路由层------加锁保护用户列表 + 广播
ThreadPool.hpp 线程池------单例 + 任务队列 + 工作线程
Thread.hpp 线程封装------Start / Join / Stop
Mutex.hpp / Cond.hpp 同步原语------锁 + 条件变量
ChatServerMain.cc 主入口------组装三大模块
ChatClientMain.cc 客户端------双线程收发
4.8.1 用户上线流程
复制代码
客户端 sender 线程                          服务端 UdpServer
─────────────────                          ─────────────────
std::getline → "usr1"                      recvfrom 阻塞等待...
sendto("usr1 online!")  ─────────────────→ 收到包
                                           │
                                           ├─ InetAddr client(peer)
                                           │   反向构造:sockaddr_in → IP + Port
                                           │
                                           ├─ _handler_addr(peer)
                                           │   → Route::CheckClient
                                           │   → LockGuard 加锁
                                           │   → ClientManager::AddClient
                                           │   → unordered_set::insert  ← O(1)
                                           │   → LockGuard 析构解锁
                                           │
                                           ├─ 检测 "offline!"?  否,跳过
                                           │
                                           └─ _handler_msg(sockfd, msg)
                                               → ThreadPool::Enqueue(task)
                                               → 工作线程取出 task
                                               → Route::BroadCast(sockfd, msg)
                                               → LockGuard 加锁
                                               → for (client : clients)
                                               →   sendto(client, msg)
                                               → LockGuard 析构解锁
                                               → 所有在线用户收到广播

首次上线时 CheckClient 调用 unordered_set::insert。如果用户已在 set 中,insert 自动忽略------同一个客户端发第二条消息时不会重复登记。

4.8.2 普通消息流程
复制代码
usr1 输入 "hello" →
  sender: message = "usr1#hello"
  sendto → 服务端 recvfrom

服务端:
  _handler_addr(peer)          → insert (已存在,无操作)
  检测 "offline!" → 否
  _handler_msg(sockfd, msg)    → Enqueue → 工作线程 → BroadCast
                                  → sendto 给 usr1, usr2, usr3...
4.8.3 离线流程
复制代码
usr1 输入 "quit" →
  sender: sendto("usr1 offline!")
  sender 线程 break → 退出 while → 线程结束
  sender.Join() 返回

  main 线程:
    recever.Stop()    → pthread_cancel → recvfrom 中断 → 接收线程取消
    recever.Join()    → 返回
    进程正常退出

服务端:
  收到 "usr1 offline!"
  _handler_addr(peer)          → insert (已存在,无操作)
  检测 "offline!" → 是!
  _handler_offline(peer)       → Route::OfflineClient → DelClient → erase
  _handler_msg(sockfd, msg)    → BroadCast "usr1 offline!" 给所有剩余用户
4.8.4 线程模型
复制代码
主线程(服务端)               ThreadPool 工作线程
─────────────────              ──────────────────
recvfrom() 阻塞                线程 1: 空闲
收到包 ──→ Enqueue(task) ──→   线程 1: BroadCast → sendto × N
recvfrom() 继续...             线程 2: BroadCast → sendto × N
                               线程 3: 空闲等待
                               线程 4: BroadCast → sendto × N
                               线程 5: 空闲等待
  • 主线程只做收包 + 分发,不阻塞
  • 线程池 5 个工作线程并发执行广播
  • 每个广播任务遍历在线用户列表,逐个 sendto

主线程收包速度不受广播耗时影响------Enqueue 是 O(1) 操作,投递完立刻回去 recvfrom

4.8.5 模块依赖图

#mermaid-svg-8VRTV6AQIHIxj0E8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8VRTV6AQIHIxj0E8 .error-icon{fill:#552222;}#mermaid-svg-8VRTV6AQIHIxj0E8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8VRTV6AQIHIxj0E8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .marker.cross{stroke:#333333;}#mermaid-svg-8VRTV6AQIHIxj0E8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8VRTV6AQIHIxj0E8 p{margin:0;}#mermaid-svg-8VRTV6AQIHIxj0E8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster-label text{fill:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster-label span{color:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster-label span p{background-color:transparent;}#mermaid-svg-8VRTV6AQIHIxj0E8 .label text,#mermaid-svg-8VRTV6AQIHIxj0E8 span{fill:#333;color:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .node rect,#mermaid-svg-8VRTV6AQIHIxj0E8 .node circle,#mermaid-svg-8VRTV6AQIHIxj0E8 .node ellipse,#mermaid-svg-8VRTV6AQIHIxj0E8 .node polygon,#mermaid-svg-8VRTV6AQIHIxj0E8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .rough-node .label text,#mermaid-svg-8VRTV6AQIHIxj0E8 .node .label text,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape .label,#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-8VRTV6AQIHIxj0E8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .rough-node .label,#mermaid-svg-8VRTV6AQIHIxj0E8 .node .label,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape .label,#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape .label{text-align:center;}#mermaid-svg-8VRTV6AQIHIxj0E8 .node.clickable{cursor:pointer;}#mermaid-svg-8VRTV6AQIHIxj0E8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .arrowheadPath{fill:#333333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8VRTV6AQIHIxj0E8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8VRTV6AQIHIxj0E8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster text{fill:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster span{color:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8VRTV6AQIHIxj0E8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape p,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape .label rect,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8VRTV6AQIHIxj0E8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8VRTV6AQIHIxj0E8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8VRTV6AQIHIxj0E8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-8VRTV6AQIHIxj0E8 .net>*{fill:#e1f5fe!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .net span{fill:#e1f5fe!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .sync>*{fill:#fff3e0!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .sync span{fill:#fff3e0!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .logic>*{fill:#e8f5e9!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .logic span{fill:#e8f5e9!important;} ChatServerMain
ThreadPool
Route
UdpServer
InetAddr
三回调
Mutex
ClientManager
InetAddrHash
Cond
Thread
ChatClientMain

V3 ChatServer 设计总结: InetAddr 描述用户 → ClientManager 管理集合 → Route 加锁保护 → ThreadPool 并发广播 → UdpServer 三回调解耦(登记 / 消息 / 离线)。一个 UDP 包从网卡到达,经 InetAddr 转换 → UdpServer 收包 → 三回调分发 → Route 加锁 → ClientManager 查改 → ThreadPool 并发广播 → sendto 发回网卡。输入 quit 即发送离线通知并退出,服务端自动清理用户。9 个文件,每个只做一件事。


相关推荐
GitLqr12 小时前
玩转 WebSocket:从原理到 Flutter 实战
websocket·网络协议·flutter
微硬创新14 小时前
耐达讯自动化 CC-Link IE 转 PROFINET网关在污水厂智能化升级实战
人工智能·物联网·网络协议·自动化·信息与通信
TlSfoward18 小时前
抓包代理链路下的 TLS 指纹变化分析 TLSFOWARD抓包工具
数据库·爬虫·网络协议·搜索引擎·php
2501_916007471 天前
深入理解HTTPS对称与非对称加密机制及Charles抓包实践
网络协议·http·ios·小程序·https·uni-app·iphone
2501_916008891 天前
HTTPS 抓包遇到证书绑定怎么办,使用 TraceEagle 解除 App 证书校验
网络协议·计算机网络·http·网络安全·ios·adb·https
不是光头 强1 天前
接口性能优化报告
网络协议·性能优化·rpc
treesforest1 天前
你以为只是一串数字?你的IP地址,正在悄悄暴露这些信息
网络·网络协议·tcp/ip·网络安全·ip归属地查询·ip风控
拾光Ծ1 天前
【Linux网络】网络通信实战:UDP & TCP Socket编程实现Echo Server
linux·网络·网络协议·tcp/ip·udp·线程池
派葛穆1 天前
s7-200smart-tcp/ip通讯
网络协议·tcp/ip