欢迎光临
我们一直在努力

C++ 网络编程入门:从零实现 UDP 通信、网络字典与简易聊天室

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

在这里插入图片描述

🎬 个人主页:道尔柯南

❄专栏传送门:《C语言》《C++》《Linux操作系统》

昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。

文章目录

    • 一、前言:学完 C++,如何让程序真正实现网络通信?
    • 二、什么是 UDP?
      • 2.1 UDP 的基本概念
      • 2.2 UDP 与 TCP 的区别
      • 2.3 UDP 通信模型
    • 三、Socket 是什么?
      • 3.1 创建 UDP Socket
    • 四、理解网络地址结构体 sockaddr_in
      • 4.1 htons():将端口转换为网络字节序
      • 4.2 inet_addr():将 IPv4 字符串转换为地址
    • 五、为什么 UDP 服务器通常需要 bind()?
      • 5.1 bind() 的作用
      • 5.2 服务器绑定 INADDR_ANY
      • 5.3 为什么客户端通常不需要显式 bind()?
    • 六、实现第一个 UDP 程序:Echo 回显服务器
      • 6.1 Echo 服务器的工作流程
      • 6.2 服务器端核心代码
      • 6.3 recvfrom():接收数据
      • 6.4 sendto():向客户端发送响应
    • 七、实现 UDP 客户端
    • 八、把 UDP 程序封装成 C++ 类
      • 8.1 封装 UdpSocket
      • 8.2 为什么使用 RAII 管理资源?
    • 九、使用 nocopy 禁止对象拷贝
    • 十、从 Echo 到网络字典:把网络与业务分开
      • 10.1 使用 unordered_map 保存词典
      • 10.2 编写翻译函数
      • 10.3 从文件加载词典
    • 十一、使用回调函数实现通用 UDP 服务器
      • 11.1 什么是回调函数?
      • 11.2 使用 std::function 封装回调
      • 11.3 通用服务器的处理流程
    • 十二、实现简易 UDP 聊天室
      • 12.1 聊天室的基本需求
      • 12.2 如何识别一个用户?
      • 12.3 使用 vector 保存在线用户
      • 12.4 聊天室的消息路由
    • 十三、UDP 客户端的发送与接收可以分开吗?
      • 13.1 使用两个线程
    • 十四、地址转换函数与线程安全
      • 14.1 inet_ntoa() 的注意事项
      • 14.2 使用 inet_ntop()
    • 十五、网络程序中容易忽略的问题
      • 15.1 检查系统调用的返回值
      • 15.2 不要假设 UDP 数据一定送达
      • 15.3 注意端口号冲突
      • 15.4 不要把网络数据直接当成可信输入
      • 15.5 注意 UDP 数据报大小
    • 十六、知识点总结
    • 十七、写在最后
  • 结尾

一、前言:学完 C++,如何让程序真正实现网络通信?

在学习 C++ 的过程中,我们已经掌握了变量、函数、类、对象、STL 容器等基础知识。

但是,一个程序如果只能在本地运行,能不能与另一台计算机通信呢?例如:

  • 编写一个客户端,将输入的消息发送给服务器。
  • 编写一个服务器,收到消息后原样返回。
  • 实现一个网络字典,输入英文单词就能查询中文释义。
  • 实现一个简易聊天室,让多个用户通过网络发送消息。

这些功能都涉及网络编程。

在 Linux 环境下,C++ 网络编程通常会使用 Socket 接口。Socket 可以理解为应用程序进行网络通信时使用的一种编程接口。

本文将结合 UDP 网络编程的知识,从最基础的 Socket 创建开始,逐步实现 Echo 回显服务器、英译汉网络字典和简易聊天室,并介绍代码封装、回调函数以及 IP 地址转换等重要知识。

学习完本文,你将能够理解一个简单的 UDP 网络程序是如何工作的。

二、什么是 UDP?

2.1 UDP 的基本概念

UDP 的英文全称是 User Datagram Protocol,即用户数据报协议。

它是一种传输层协议,主要特点是通信过程简单、开销较小,不需要像 TCP 那样先建立连接。

在 UDP 通信中,发送方可以将数据报发送到指定的 IP 地址和端口号,接收方通过对应的 Socket 接收数据。

例如,我们可以把 UDP 通信理解为寄送明信片:

  • 发送方写好消息。
  • 指定接收方的地址。
  • 将消息发送出去。
  • 接收方收到后读取内容。

这个过程中不需要先建立一条持续的连接。

不过,UDP 不保证数据一定送达,也不保证多个数据报一定按发送顺序到达。因此,如果应用程序需要可靠传输,就需要自行设计确认、重传、排序等机制,或者选择 TCP。

2.2 UDP 与 TCP 的区别

对比项目UDPTCP
通信方式 无连接 面向连接
传输单位 数据报 字节流
可靠性 不保证可靠送达 提供可靠、有序的字节流传输
数据边界 保留数据报边界 不保留应用层消息边界
传输开销 通常较小 通常较大
常见场景 实时音视频、DNS、游戏通信 网页、文件传输、远程登录

需要注意,UDP 并不意味着一定比 TCP 快。实际性能取决于网络状况、数据大小、应用设计等因素。

2.3 UDP 通信模型

一个最简单的 UDP 通信模型如下:

UDP 客户端 UDP 服务器
| |
|——– 发送请求 —————->|
| |
| 接收并处理请求
| |
|<——- 返回响应 —————-|
| |
| 完成一次通信 |

UDP 不需要建立 TCP 式的连接,但客户端和服务器仍然需要知道对方的通信地址。

三、Socket 是什么?

Socket 是操作系统提供的一套网络通信编程接口。

在 Linux 下,常见的 Socket 相关函数包括:

  • socket():创建套接字。
  • bind():将套接字绑定到本地 IP 地址和端口。
  • sendto():发送 UDP 数据报。
  • recvfrom():接收 UDP 数据报。
  • close():关闭套接字。

这些函数构成了 UDP 通信的基础。

3.1 创建 UDP Socket

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>

int main()
{
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

if (sockfd < 0)
{
perror("socket");
return 1;
}

std::cout << "Socket 创建成功,fd = "
<< sockfd << std::endl;

close(sockfd);
return 0;
}

代码中的关键语句是:

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

其中:

  • AF_INET:使用 IPv4 地址族。
  • SOCK_DGRAM:使用数据报套接字,通常用于 UDP。
  • 0:让系统根据套接字类型选择合适的默认协议。

如果创建成功,函数返回一个非负文件描述符;失败则返回 -1。

Linux 中的 Socket 文件描述符可以理解为操作系统为该通信端点分配的一个编号,后续的发送、接收等操作都需要使用它。

由于上面的代码使用了 close(),还需要包含:

#include <unistd.h>

四、理解网络地址结构体 sockaddr_in

网络通信不仅需要 Socket,还需要告诉操作系统通信使用什么地址和端口。

在 IPv4 Socket 编程中,经常使用 sockaddr_in 结构体。

struct sockaddr_in addr;

它主要包含以下信息:

struct sockaddr_in
{
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
};

可以将它们理解为:

成员含义
sin_family 地址族,例如 AF_INET
sin_port 端口号,采用网络字节序
sin_addr IPv4 地址

例如,要表示本机的 127.0.0.1:8080:

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

sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");

这里有两个非常重要的函数。

4.1 htons():将端口转换为网络字节序

addr.sin_port = htons(8080);

htons 的含义是 host to network short,即将主机字节序的 16 位整数转换为网络字节序。

网络协议规定多字节整数使用网络字节序表示,因此设置端口时通常需要使用 htons()。

接收端需要将端口转换回主机字节序:

uint16_t port = ntohs(addr.sin_port);

4.2 inet_addr():将 IPv4 字符串转换为地址

addr.sin_addr.s_addr = inet_addr("127.0.0.1");

inet_addr() 可以将点分十进制的 IPv4 字符串转换为适合填入地址结构体的网络地址表示。

它属于较旧的接口。现代代码可以优先考虑 inet_pton():

inet_pton(AF_INET, "127.0.0.1",
&addr.sin_addr);

如果需要支持 IPv6,也可以使用 inet_pton(),并根据需要选择 AF_INET6。

五、为什么 UDP 服务器通常需要 bind()?

5.1 bind() 的作用

bind() 的主要作用是把 Socket 绑定到指定的本地地址和端口。

int ret = bind(
sockfd,
(struct sockaddr*)&addr,
sizeof(addr)
);

服务器通常需要使用固定、已知的端口提供服务。

例如,服务器监听 8080 端口,客户端只要知道服务器 IP 和端口,就能向这个服务发送数据。

如果服务器没有按预期绑定地址和端口,客户端就无法通过约定的服务端点正常访问它。

5.2 服务器绑定 INADDR_ANY

博客中的服务器代码使用了:

local.sin_addr.s_addr = INADDR_ANY;

INADDR_ANY 表示绑定到本机所有适用的 IPv4 网络接口地址。

这样,服务器通常不需要提前指定某一块网卡的 IP 地址,就可以接收发送到该端口的 UDP 数据报。

例如:

sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = INADDR_ANY;

随后调用:

bind(sockfd,
(struct sockaddr*)&local,
sizeof(local));

需要注意,绑定到 INADDR_ANY 不代表服务器可以绕过防火墙,也不意味着公网一定能够访问它。

5.3 为什么客户端通常不需要显式 bind()?

UDP 客户端也需要本地端口,但通常不必自己指定。

当客户端首次发送数据时,操作系统可以自动为 Socket 选择一个合适的本地 IP 地址和临时端口。

因此,最简单的 UDP 客户端通常只需要:

  • 创建 Socket。
  • 准备服务器地址。
  • 使用 sendto() 发送数据。
  • 使用 recvfrom() 接收响应。
  • 如果业务需要固定的客户端端口或特定的本地网络接口,客户端同样可以显式调用 bind()。

    六、实现第一个 UDP 程序:Echo 回显服务器

    Echo 的意思是回声。

    Echo 服务器的功能非常简单:客户端发送什么内容,服务器就原样返回什么内容。

    这个程序非常适合入门,因为它涵盖了 UDP 通信中最重要的操作。

    6.1 Echo 服务器的工作流程

    客户端 服务器
    | |
    |——– "Hello" ————>|
    | |
    | recvfrom() 接收
    | |
    | sendto() 返回
    | |
    |<——- "Hello" ————–|

    6.2 服务器端核心代码

    以下示例适用于 Linux,展示创建 Socket、绑定端口、接收请求和返回响应的基本流程。

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

    int main()
    {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
    perror("socket");
    return 1;
    }

    sockaddr_in local{};
    local.sin_family = AF_INET;
    local.sin_port = htons(8888);
    local.sin_addr.s_addr = INADDR_ANY;

    if (bind(sockfd, (sockaddr*)&local,
    sizeof(local)) < 0)
    {
    perror("bind");
    close(sockfd);
    return 1;
    }

    std::cout << "UDP Echo Server started"
    << std::endl;

    while (true)
    {
    char buffer[1024];

    sockaddr_in peer{};
    socklen_t len = sizeof(peer);

    ssize_t n = recvfrom(
    sockfd,
    buffer,
    sizeof(buffer) – 1,
    0,
    (sockaddr*)&peer,
    &len
    );

    if (n < 0)
    {
    perror("recvfrom");
    continue;
    }

    buffer[n] = '\\0';

    std::cout << "收到消息:" << buffer
    << std::endl;

    ssize_t sent = sendto(
    sockfd,
    buffer,
    static_cast<size_t>(n),
    0,
    (sockaddr*)&peer,
    len
    );

    if (sent < 0)
    {
    perror("sendto");
    }
    }

    close(sockfd);
    return 0;
    }

    这段代码的核心是 recvfrom() 和 sendto()。

    6.3 recvfrom():接收数据

    ssize_t n = recvfrom(
    sockfd,
    buffer,
    sizeof(buffer) – 1,
    0,
    (sockaddr*)&peer,
    &len
    );

    主要参数如下:

    • sockfd:用于接收数据的 Socket。
    • buffer:保存接收数据的缓冲区。
    • sizeof(buffer) – 1:最多接收的字节数,为字符串结尾预留空间。
    • 0:默认接收标志。
    • peer:记录发送方的地址。
    • len:传入地址结构体容量,返回实际地址长度。

    这里有一个容易忽略的细节:UDP 数据本身不一定以 '\\0' 结尾。

    因此,成功接收后,如果想把数据当作 C 风格字符串输出,就需要:

    buffer[n] = '\\0';

    本例的缓冲区预留了一个字节,所以不会因为追加字符串结束符而越界。

    另外,如果接收到的是二进制数据,就不应该把它当作普通字符串处理。

    6.4 sendto():向客户端发送响应

    sendto(
    sockfd,
    buffer,
    static_cast<size_t>(n),
    0,
    (sockaddr*)&peer,
    len
    );

    为什么这里可以直接使用 peer?

    因为 recvfrom() 已经记录了发送请求的客户端地址。

    服务器只要将响应发回这个地址,就能让客户端收到数据,而不必事先维护 TCP 式的连接。

    七、实现 UDP 客户端

    客户端需要知道服务器的 IP 地址和端口号。

    例如,服务器运行在本机,监听 8888 端口,那么客户端可以将目标地址设置为 127.0.0.1:8888。

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

    int main()
    {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
    perror("socket");
    return 1;
    }

    sockaddr_in server{};
    server.sin_family = AF_INET;
    server.sin_port = htons(8888);

    if (inet_pton(AF_INET, "127.0.0.1",
    &server.sin_addr) != 1)
    {
    std::cerr << "IP 地址格式错误"
    << std::endl;
    close(sockfd);
    return 1;
    }

    std::string message;

    while (std::getline(std::cin, message))
    {
    ssize_t n = sendto(
    sockfd,
    message.data(),
    message.size(),
    0,
    (sockaddr*)&server,
    sizeof(server)
    );

    if (n < 0)
    {
    perror("sendto");
    break;
    }

    char buffer[1024];

    sockaddr_in from{};
    socklen_t len = sizeof(from);

    ssize_t m = recvfrom(
    sockfd,
    buffer,
    sizeof(buffer) – 1,
    0,
    (sockaddr*)&from,
    &len
    );

    if (m < 0)
    {
    perror("recvfrom");
    break;
    }

    buffer[m] = '\\0';

    std::cout << "服务器返回:"
    << buffer << std::endl;
    }

    close(sockfd);
    return 0;
    }

    该客户端先发送消息,然后等待服务器返回结果。

    在这个最简单的示例中,客户端的接收操作可能一直等待。如果服务器没有启动、响应丢失或网络出现故障,程序就可能阻塞。真实项目中通常需要考虑接收超时、重试和错误处理。

    八、把 UDP 程序封装成 C++ 类

    如果所有网络操作都写在 main() 中,程序很快就会变得复杂。

    例如,Echo 服务器需要创建 Socket、绑定端口、接收消息和返回消息。网络字典也需要这些操作,聊天室同样需要。

    如果每实现一个功能,就复制一份底层网络代码,程序将很难维护。

    因此,可以把网络通信相关的操作封装到类中。

    8.1 封装 UdpSocket

    创建UdpSocket 类将 Socket 的创建、关闭、接收和发送统一封装起来。

    其核心接口可以设计为:

    class UdpSocket
    {
    public:
    bool Socket();
    bool Close();

    bool Bind(const std::string& ip,
    uint16_t port);

    bool RecvFrom(std::string* buf,
    std::string* ip,
    uint16_t* port);

    bool SendTo(const std::string& buf,
    const std::string& ip,
    uint16_t port);

    private:
    int fd_;
    };

    这样,上层业务代码不必每次都直接操作复杂的 sockaddr_in 结构体。

    例如:

    UdpSocket sock;
    sock.Socket();
    sock.Bind("0.0.0.0", 8888);

    接收消息时:

    std::string message;
    std::string client_ip;
    uint16_t client_port = 0;

    sock.RecvFrom(&message,
    &client_ip,
    &client_port);

    发送消息时:

    sock.SendTo("Hello",
    client_ip,
    client_port);

    以上是接口示意,实际实现还需要补全系统调用、错误处理以及资源管理逻辑。

    8.2 为什么使用 RAII 管理资源?

    Socket 属于需要显式释放的系统资源。

    如果程序创建 Socket 后忘记关闭,就可能造成文件描述符泄漏。

    C++ 中常用 RAII(资源获取即初始化)管理这类资源:在对象构造时获取资源,在对象析构时释放资源。

    例如:

    class UdpSocket
    {
    public:
    UdpSocket()
    : fd_(-1)
    {
    }

    ~UdpSocket()
    {
    if (fd_ >= 0)
    {
    close(fd_);
    }
    }

    private:
    int fd_;
    };

    这个例子只演示析构时关闭资源的思想。完整实现还需要保证 fd_ 初始化正确、避免重复关闭,并正确处理复制与移动语义。

    对于持有唯一 Socket 资源的对象,通常还应该禁止无意义的拷贝,避免两个对象误以为自己各自拥有同一个文件描述符。

    九、使用 nocopy 禁止对象拷贝

    再介绍一个 nocopy 类,用来禁止对象的拷贝构造和拷贝赋值。

    class nocopy
    {
    public:
    nocopy() = default;

    nocopy(const nocopy&) = delete;

    nocopy& operator=(const nocopy&) = delete;

    ~nocopy() = default;
    };

    其中最关键的是:

    nocopy(const nocopy&) = delete;

    以及:

    nocopy& operator=(const nocopy&) = delete;

    = delete 表示明确禁止调用相应函数。

    如果服务器类继承 nocopy:

    class UdpServer : public nocopy
    {
    // …
    };

    那么服务器对象通常就不能被拷贝构造或拷贝赋值。

    这样做的原因是,服务器对象内部持有 Socket 等资源,如果随意复制,可能导致资源所有权不明确、重复关闭等问题。

    需要注意,禁止拷贝不等于自动禁止移动。若需要控制移动语义,还应根据资源管理设计明确处理移动构造和移动赋值。

    十、从 Echo 到网络字典:把网络与业务分开

    Echo 服务器只会原样返回消息,但网络程序可以执行各种业务逻辑。

    例如,客户端输入 hello,服务器返回“你好”;输入 apple,服务器返回“苹果”。

    这个程序就是一个简单的英译汉网络字典。

    10.1 使用 unordered_map 保存词典

    C++ STL 中的 unordered_map 可以保存键值对。

    例如:

    #include <string>
    #include <unordered_map>

    std::unordered_map<std::string, std::string> dict{
    {"hello", "你好"},
    {"world", "世界"},
    {"apple", "苹果"},
    {"book", "书"},
    {"cat", "猫"}
    };

    其中:

    • 键是英文单词。
    • 值是对应的中文释义。

    查询单词时,可以使用 find():

    auto it = dict.find("hello");

    if (it == dict.end())
    {
    // 没有找到
    }
    else
    {
    // it->second 是对应的释义
    }

    平均情况下,unordered_map 的查找复杂度为 (O(1)),适合用于这种简单的词典查询。

    10.2 编写翻译函数

    我们可以把翻译功能独立出来:

    void Translate(const std::string& req,
    std::string* resp)
    {
    auto it = dict.find(req);

    if (it == dict.end())
    {
    *resp = "未查到该单词";
    return;
    }

    *resp = it->second;
    }

    这里使用了输入参数 req 和输出参数 resp。

    • req 表示客户端发送的请求。
    • resp 用来保存服务器生成的响应。

    这样,翻译函数就不必关心数据是从键盘输入的,还是通过网络接收的。

    它只需要负责完成单词查询。

    10.3 从文件加载词典

    如果把所有词条直接写在源代码中,添加新单词就需要修改代码并重新编译。

    更好的方式是将词典保存在文本文件中。

    例如,dict.txt:

    apple: 苹果
    banana: 香蕉
    cat: 猫
    dog: 狗
    book: 书
    hello: 你好
    world: 世界

    程序启动时读取文件,将词条加载到 unordered_map 中。

    #include <fstream>
    #include <string>
    #include <unordered_map>

    void LoadDict(
    const std::string& filename,
    std::unordered_map<std::string,
    std::string>& dict)
    {
    std::ifstream in(filename);

    if (!in.is_open())
    {
    return;
    }

    std::string line;

    while (std::getline(in, line))
    {
    auto pos = line.find(": ");

    if (pos == std::string::npos)
    {
    continue;
    }

    std::string key = line.substr(0, pos);
    std::string value =
    line.substr(pos + 2);

    dict[key] = value;
    }
    }

    这里使用了几个常见的 C++ 文件与字符串操作:

    • std::ifstream:读取文件。
    • std::getline():按行读取内容。
    • find():查找分隔符。
    • substr():截取字符串。

    这体现了一个重要的设计思想:将数据存储、网络通信和业务处理拆分开来。

    十一、使用回调函数实现通用 UDP 服务器

    如果 Echo 服务器和网络字典各自实现一套服务器代码,就会出现大量重复逻辑。

    它们实际上只有业务处理部分不同:

    • Echo 服务器:将请求原样返回。
    • 网络字典:查询单词并返回释义。
    • 聊天室:将消息转发给在线用户。

    那么,能否只写一套 UDP 服务器,让不同业务使用不同的处理函数?

    答案是回调函数。

    11.1 什么是回调函数?

    回调函数的核心思想是:把一个函数作为参数传给另一个函数或对象,在合适的时机再调用它。

    例如:

    void Process()
    {
    std::cout << "处理业务逻辑"
    << std::endl;
    }

    void Start(void (*callback)())
    {
    callback();
    }

    调用时:

    Start(Process);

    Start() 不需要知道业务处理的具体内容,只需要在适当的时候调用传入的函数。

    这种方式能够降低网络通信模块与业务逻辑之间的耦合。

    11.2 使用 std::function 封装回调

    课件中使用了 C++11 的 std::function:

    #include <functional>
    #include <string>

    using Handler =
    std::function<void(
    const std::string&,
    std::string*)>;

    这个类型表示:一个接收请求字符串和响应输出参数、返回 void 的可调用对象。

    它可以接收普通函数、函数对象,也可以接收符合签名要求的 Lambda 表达式。

    例如:

    void Translate(const std::string& req,
    std::string* resp)
    {
    *resp = "翻译结果:" + req;
    }

    服务器启动时,可以将它传入:

    server.Start("0.0.0.0",
    8888,
    Translate);

    当服务器收到消息时,调用:

    handler(req, &resp);

    服务器就能根据传入的业务函数生成响应。

    11.3 通用服务器的处理流程

    UDP 客户端
    |
    | 发送请求
    v
    +————–+
    | UDP 服务端 |
    +————–+
    |
    | recvfrom()
    v
    +————–+
    | 获取请求内容 |
    +————–+
    |
    | 调用回调函数
    v
    +————–+
    | 业务处理 |
    +————–+
    |
    | 生成响应
    v
    +————–+
    | sendto() |
    +————–+
    |
    v
    UDP 客户端

    通过这种设计,网络层只负责通信,业务层只负责处理请求。

    以后想增加新的功能时,通常只需要编写新的业务处理函数,而不必重新实现底层 UDP 通信流程。

    十二、实现简易 UDP 聊天室

    掌握 UDP 收发、地址封装和回调机制后,就可以进一步实现简易聊天室。

    12.1 聊天室的基本需求

    一个简易聊天室通常需要:

  • 记录当前在线用户。
  • 接收用户发送的消息。
  • 将消息转发给其他在线用户。
  • 在用户退出时移除对应记录。
  • UDP 本身没有连接建立和断开事件,因此服务器通常需要根据客户端发送的消息来维护在线状态。

    例如,用户首次发来消息时,服务器可以将该用户记录为在线用户;当用户发送约定的 QUIT 消息时,再将其移除。

    需要注意,这只是教学示例中的简化方案。真实聊天室还需要心跳、超时清理、身份验证等机制,否则客户端异常退出后,服务器可能一直保留过期的在线记录。

    12.2 如何识别一个用户?

    可以通过客户端的 IP 地址和端口号识别一个 UDP 通信端点。

    例如:

    192.168.1.10:50001
    192.168.1.11:50002
    192.168.1.12:50003

    这些地址分别代表不同的网络端点。

    可以将它们封装为 InetAddr 类,统一保存 IP、端口和底层地址结构。

    class InetAddr
    {
    public:
    std::string Ip() const
    {
    return ip_;
    }

    uint16_t Port() const
    {
    return port_;
    }

    private:
    std::string ip_;
    uint16_t port_;
    sockaddr_in addr_;
    };

    这里仅展示类的基本结构。实际实现需要在构造时正确初始化地址成员,并提供访问底层地址的接口。

    如果要比较两个端点,可以重载 operator==:

    bool operator==(const InetAddr& other) const
    {
    return ip_ == other.ip_
    && port_ == other.port_;
    }

    这样就可以通过比较 IP 和端口来判断两个地址对象是否代表同一个端点。

    但要注意,IP 和端口只能标识一个网络端点,并不等于用户的真实身份。实际应用不应仅凭地址就完成用户认证。

    12.3 使用 vector 保存在线用户

    聊天室可以使用 std::vector 保存在线用户列表:

    std::vector<InetAddr> online_users;

    当新用户到来时,先检查是否已经存在:

    bool IsExist(const InetAddr& peer)
    {
    for (const auto& user : online_users)
    {
    if (user == peer)
    {
    return true;
    }
    }

    return false;
    }

    如果不存在,就添加到列表中。

    收到聊天消息后,服务器遍历在线用户列表,并使用 sendto() 将消息发送给各个用户。

    在真正实现时,需要考虑是否将消息发送给发送者本人,以及用户退出后如何从列表中安全删除记录。

    对于规模较小的教学程序,使用 vector 足以展示核心思路。用户数量增多后,可以根据需求选择更合适的数据结构。

    12.4 聊天室的消息路由

    聊天室可以将接收到的消息整理成:

    192.168.1.10:50001# 大家好!

    然后将这条消息转发给其他在线用户。

    这里的关键不再是简单地把消息原样返回,而是根据消息发送者和在线用户列表决定消息的接收方。

    这一步通常称为消息路由。

    Route 类就承担了类似的职责:记录在线用户、检查用户是否存在、添加和删除用户,并将消息转发给在线用户。

    通过将路由逻辑独立封装,服务器的网络通信模块就不必了解每条消息应该如何分发。

    十三、UDP 客户端的发送与接收可以分开吗?

    在简单的 Echo 客户端中,程序通常先发送消息,再接收响应。

    但是,聊天室中可能随时有其他用户发送消息。如果客户端只有在自己发送消息之后才接收数据,就可能无法及时显示其他用户的消息。

    一种解决方法是将发送和接收分开。

    13.1 使用两个线程

    可以让:

    • 发送线程负责读取用户输入并调用 sendto()。
    • 接收线程负责调用 recvfrom() 并显示收到的消息。

    结构如下:

    UDP 客户端
    |
    +——-+——-+
    | |
    v v
    发送线程 接收线程
    | |
    sendto() recvfrom()
    | |
    v v
    服务器 显示消息

    客户端使用了类似的设计。

    UDP Socket 可以用于发送和接收数据。将发送和接收分别放在不同线程中,有助于实现更及时的消息显示。

    但多线程并不意味着所有问题都会自动解决。实际代码还需要考虑线程退出、资源释放、输入阻塞,以及共享数据的同步问题。

    十四、地址转换函数与线程安全

    在 UDP 编程中,除了 htons()、ntohs(),还经常接触 inet_ntoa()、inet_pton() 和 inet_ntop()。

    14.1 inet_ntoa() 的注意事项

    特别指出,inet_ntoa() 会返回一个指向内部静态存储区的指针。

    例如:

    char* p = inet_ntoa(addr.sin_addr);

    通常不需要手动释放这个指针,因为它不是通过调用方负责释放的动态内存获得的。

    但要注意,后续调用可能覆盖此前的转换结果,而且该接口不适合需要可靠线程安全行为的多线程代码。

    因此,现代程序更推荐使用 inet_ntop()。

    14.2 使用 inet_ntop()

    char ip[INET_ADDRSTRLEN];

    if (inet_ntop(AF_INET,
    &addr.sin_addr,
    ip,
    sizeof(ip)) != nullptr)
    {
    std::cout << ip << std::endl;
    }

    inet_ntop() 将二进制地址转换为可读字符串,并由调用方提供缓冲区。

    这样可以避免依赖共享的静态字符串缓冲区。

    需要注意,INET_ADDRSTRLEN 适用于 IPv4;IPv6 地址应使用对应的 IPv6 地址结构与缓冲区长度。

    十五、网络程序中容易忽略的问题

    完成基本功能后,还需要注意以下细节。

    15.1 检查系统调用的返回值

    创建 Socket、绑定地址、接收数据、发送数据都可能失败。

    例如:

    if (sockfd < 0)
    {
    perror("socket");
    return 1;
    }

    错误处理有助于快速定位端口占用、权限不足、地址格式错误等问题。

    15.2 不要假设 UDP 数据一定送达

    UDP 不提供可靠传输保证。即使 sendto() 成功返回,也不能证明对方应用程序已经收到并处理了数据。

    对于重要业务,可以增加应用层确认、超时重试和请求编号等机制。

    15.3 注意端口号冲突

    如果服务器端口已经被其他程序占用,bind() 可能失败。

    调试时应检查错误信息,并确认客户端连接的是正确的 IP 和端口。

    15.4 不要把网络数据直接当成可信输入

    客户端发送的数据可能格式错误、长度异常,甚至是恶意构造的数据。

    因此,服务器必须对接收内容进行校验。对于聊天室,还应考虑消息长度限制、身份验证、频率限制等安全问题。

    15.5 注意 UDP 数据报大小

    UDP 数据报有大小限制,而且较大的数据报可能被 IP 层分片,增加丢包风险。

    实际开发中应合理设计消息大小;如果业务需要传输大量数据,可以考虑拆分数据报,或者改用更合适的传输协议。

    十六、知识点总结

    学习完这篇文章,我们可以把 UDP 网络编程的核心知识归纳为以下几类。

    知识点核心作用
    socket() 创建网络通信端点
    bind() 绑定本地地址和端口
    sendto() 发送 UDP 数据报
    recvfrom() 接收数据报并获取发送方地址
    sockaddr_in 保存 IPv4 地址与端口信息
    htons()、ntohs() 转换端口的字节序
    inet_pton()、inet_ntop() 转换可读 IP 字符串与二进制地址
    unordered_map 保存网络字典中的词条
    std::function 统一表示不同类型的回调对象
    vector 保存简易聊天室的在线用户列表
    nocopy 禁止对象拷贝,帮助管理资源所有权
    多线程 将发送与接收等任务分开执行

    整个项目可以按照以下路线逐步实现:

  • 创建 Socket,理解文件描述符。
  • 编写 Echo 服务器与客户端,掌握数据报收发。
  • 理解地址结构体、端口绑定与地址转换。
  • 封装 UdpSocket,减少重复的底层代码。
  • 实现网络字典,将通信模块与业务模块分离。
  • 使用回调函数构建通用 UDP 服务器。
  • 扩展到聊天室,实现在线用户管理与消息路由。
  • 最后考虑多线程、异常处理和网络安全。
  • 十七、写在最后

    网络编程并不是把 sendto() 和 recvfrom() 学会就结束了。

    真正重要的是理解应用程序如何通过 Socket 与操作系统交互,如何使用 IP 地址和端口找到通信对象,以及如何在网络层与业务层之间建立清晰的代码结构。

    从 Echo 回显服务器到网络字典,再到聊天室,我们可以看到程序的网络通信方式并没有发生根本变化,变化的是服务器接收到请求之后执行的业务逻辑。

    这也正是代码封装与回调函数的价值:把通用的网络通信流程复用起来,让业务代码专注于真正需要解决的问题。

    对于 C++ 初学者,建议先亲手实现 Echo 服务器和客户端,确认能够正常收发数据;再逐步增加词典查询、在线用户管理和消息转发功能。通过不断扩展同一个小项目,就能把 Linux Socket、C++ 类设计、STL 和多线程知识真正联系起来。

    当你能够独立完成一个结构清晰、能够正常通信并具备基本错误处理的 UDP 小项目时,就已经迈出了从 C++ 语法学习走向实际网络开发的重要一步。


    结尾

    uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!

    道尔柯南

    C/C++ & Linux 底层探索者 | 一个正在努力学习的技术博主


    👀
    【关注】 跟随我一起深耕技术领域,见证每一次成长。

    ❤️
    【点赞】 让优质内容被更多人看见,让知识传递更有力量。

    ⭐
    【收藏】 把核心知识点存好,在需要时随时查、随时用。

    💬
    【评论】 分享你的经验或疑问,评论区一起交流避坑!

    不要忘记给博主“一键四连”哦!

    “今日目标达成!”

    结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主“一键四连”哦!

    赞(0)
    未经允许不得转载:171主机测评 » C++ 网络编程入门:从零实现 UDP 通信、网络字典与简易聊天室
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址