道尔柯南头像
C++ 网络编程入门:从零实现 UDP 通信、网络字典与简易聊天室封面图

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

在这里插入图片描述

🎬 个人主页:道尔柯南

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

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

文章目录

一、前言:学完 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_addrIPv4 地址

例如,要表示本机的 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 客户端通常只需要:

  1. 创建 Socket。
  2. 准备服务器地址。
  3. 使用 sendto() 发送数据。
  4. 使用 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 聊天室的基本需求

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

  1. 记录当前在线用户。
  2. 接收用户发送的消息。
  3. 将消息转发给其他在线用户。
  4. 在用户退出时移除对应记录。

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禁止对象拷贝,帮助管理资源所有权
多线程将发送与接收等任务分开执行

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

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

十七、写在最后

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

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

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

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

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

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


结尾

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

道尔柯南

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


👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。
❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。
⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。
💬 【评论】 分享你的经验或疑问,评论区一起交流避坑!

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

“今日目标达成!”

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

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2502_90266547/article/details/167701834

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--