【Linux网络】UDP Socket 编程全解析:从回显服务到通用字典服务,从零实现工业级代码


🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:


文章目录

  • 前言:
  • [一. UDP 协议核心特性与编程模型](#一. UDP 协议核心特性与编程模型)
    • [1.1 UDP 协议三大核心特性](#1.1 UDP 协议三大核心特性)
    • [1.2 UDP Socket 编程核心流程](#1.2 UDP Socket 编程核心流程)
    • [1.3 核心前置知识点](#1.3 核心前置知识点)
    • [1.4 核心 API 详解(在后续的一些图片中可能也还会有)](#1.4 核心 API 详解(在后续的一些图片中可能也还会有))
  • [二. 前置基础设施:工具类实现](#二. 前置基础设施:工具类实现)
    • [2.1 互斥锁封装 Mutex.hpp](#2.1 互斥锁封装 Mutex.hpp)
    • [2.2 线程安全日志系统 logger.hpp](#2.2 线程安全日志系统 logger.hpp)
  • [三. V1 版本:UDP Echo 回显服务实现](#三. V1 版本:UDP Echo 回显服务实现)
    • [3.1 服务端实现](#3.1 服务端实现)
      • [3.1.1 服务端头文件 UdpEchoServer.hpp(下面的图示中有的代码里做的优化还没有,会在后面的图示中陆续体现出来)](#3.1.1 服务端头文件 UdpEchoServer.hpp(下面的图示中有的代码里做的优化还没有,会在后面的图示中陆续体现出来))
      • [3.1.2 服务端主函数 UdpEchoServer.cpp](#3.1.2 服务端主函数 UdpEchoServer.cpp)
    • [3.2 客户端实现](#3.2 客户端实现)
    • [3.3 代码编译与运行测试](#3.3 代码编译与运行测试)
      • [3.3.1 编译 Makefile](#3.3.1 编译 Makefile)
      • [3.3.2 运行测试](#3.3.2 运行测试)
    • [3.4 核心细节解析(附上一个网络实验)](#3.4 核心细节解析(附上一个网络实验))
  • [四. V2 版本:UDP 在线英译汉字典服务实现](#四. V2 版本:UDP 在线英译汉字典服务实现)
    • [4.1 需求分析](#4.1 需求分析)
    • [4.2 词典文件 Dict.txt](#4.2 词典文件 Dict.txt)
    • [4.3 字典类实现 Dictionary.hpp](#4.3 字典类实现 Dictionary.hpp)
    • [4.4 通用 UDP 服务端实现 UdpServer.hpp](#4.4 通用 UDP 服务端实现 UdpServer.hpp)
    • [4.5 字典服务端主函数 DictServer.cpp](#4.5 字典服务端主函数 DictServer.cpp)
    • [4.6 字典客户端实现 DictClient.cpp](#4.6 字典客户端实现 DictClient.cpp)
    • [4.7 编译与运行测试](#4.7 编译与运行测试)
  • [五. 进阶:通用 UDP 服务端 / 客户端封装](#五. 进阶:通用 UDP 服务端 / 客户端封装)
    • [5.1 基础套接字封装 udp_socket.hpp](#5.1 基础套接字封装 udp_socket.hpp)
    • [5.2 通用服务端封装 udp_server.hpp](#5.2 通用服务端封装 udp_server.hpp)
    • [5.3 通用客户端封装 udp_client.hpp](#5.3 通用客户端封装 udp_client.hpp)
    • [5.4 基于封装的极简字典服务实现](#5.4 基于封装的极简字典服务实现)
  • [六. UDP 编程核心考点与踩坑指南](#六. UDP 编程核心考点与踩坑指南)
    • [6.1 核心面试考点](#6.1 核心面试考点)
    • [6.2 高频踩坑指南](#6.2 高频踩坑指南)
  • 结尾:

前言:

在 Linux 网络编程体系中,TCP 与 UDP 是传输层两大核心协议。TCP 凭借面向连接、可靠传输的特性,成为文件传输、HTTP 通信、金融交易等场景的首选;而 UDP 以无连接、低延迟、轻量高效的设计,在直播推流、实时游戏、DNS 解析、物联网数据上报等实时性优先的场景中,有着不可替代的地位。很多初学者对 UDP 的认知,往往停留在「不可靠传输」的标签上,却没有真正吃透其编程模型、核心 API 的设计细节,以及工业级代码的封装思想。本文将从 UDP 协议的核心特性出发,从零实现V1 版本 UDP 回显服务、V2 版本在线英译汉字典服务,再到通用型 UDP 服务端 / 客户端封装,逐行拆解代码实现,把 UDP Socket 编程的所有核心细节、设计思想、踩坑指南讲透。读完本文,你不仅能独立完成 UDP 服务开发,更能理解底层设计逻辑,写出可维护、高扩展的工业级 UDP 代码。


一. UDP 协议核心特性与编程模型

在动手写代码之前,我们必须先搞懂 UDP 协议的本质特性,以及 UDP Socket 编程的完整流程,这是所有代码实现的理论基础。

1.1 UDP 协议三大核心特性

UDP(User Datagram Protocol,用户数据报协议)是传输层协议,和 TCP 同属网络分层模型的传输层,但其核心设计与 TCP 完全相反,三大核心特性如下:

特性 详细说明 与 TCP 的核心差异
无连接 通信前无需建立连接,知道对方的 IP 和端口即可直接发送数据,不存在三次握手、四次挥手的过程 TCP 必须先通过三次握手建立连接,才能传输数据
不可靠传输 不提供确认应答、超时重传、序列号、乱序重排等机制,只保证把数据尽力发送出去,不保证数据一定到达、不重复、按序到达 TCP 通过一系列机制保证数据可靠、不丢失、不重复、按序交付
面向数据报 数据以独立的报文为单位传输,收发次数严格匹配,报文之间有明确的边界,发送端一次发一个报文,接收端必须完整接收整个报文 TCP 面向字节流,数据无边界,发送端发 1000 字节,接收端可以分多次读取,需要上层自行处理数据边界

重要提醒:不可靠是 UDP 的特性,而非缺点。UDP 舍弃了可靠性保障,换来了极致的低延迟和极小的头部开销(UDP 头部仅 8 字节,TCP 头部最少 20 字节),这也是实时场景选择 UDP 的核心原因。

1.2 UDP Socket 编程核心流程

UDP 是无连接的协议,因此其编程模型比 TCP 简单很多,服务端和客户端的核心流程如下:

服务端核心流程

  • 创建 Socket 文件描述符 :调用socket()函数,创建一个基于 IPv4、数据报类型的 UDP 套接字;
  • 绑定地址与端口 :调用bind()函数,将套接字与固定的 IP 地址、端口号绑定,让客户端知道请求的目标地址;
  • 循环接收与发送数据 :调用recvfrom()阻塞等待客户端数据,收到数据后执行业务处理,再调用sendto()将处理结果回发给客户端;
  • 关闭套接字:服务停止时,调用close()关闭套接字。

客户端核心流程

  • 创建 Socket 文件描述符 :同服务端,调用socket()创建 UDP 套接字;
  • 填充服务端地址信息 :定义sockaddr_in结构体,填充服务端的 IP、端口,作为数据发送的目标;
  • 循环发送与接收数据 :调用sendto()向服务端发送数据,再调用recvfrom()等待服务端的响应
  • 关闭套接字 :通信结束时,调用close()关闭套接字。

1.3 核心前置知识点

在代码实现前,必须先吃透这几个高频踩坑的核心知识点:

  • 服务端必须显式 bind,客户端无需显式 bind
    • 服务端的端口号必须固定且众所周知,否则客户端无法发起请求,因此必须显式调用bind()绑定端口;
    • 客户端只需要保证端口唯一即可,具体端口号无关紧要。如果客户端显式 bind 固定端口,多个客户端在同一台机器运行时会出现端口冲突,因此操作系统会在客户端首次调用sendto()时,自动为其绑定一个随机的可用端口,无需用户手动处理。
  • INADDR_ANY 的核心作用
    • 定义:INADDR_ANY是值为0.0.0.0的宏,代表绑定本机所有网卡的 IP 地址;
    • 优势 1:如果服务器有多张网卡(电信、联通、内网),绑定INADDR_ANY后,无论客户端访问哪个 IP,都能收到数据包;
    • 优势 2:云服务器的公网 IP 是通过云厂商 NAT 网关映射的,并非直接配置在服务器网卡上,直接绑定公网 IP 会报错,INADDR_ANY是云服务器开发的唯一正确选择。
  • 网络字节序转换
    • TCP/IP 协议规定网络数据流必须采用大端字节序,而我们常用的 x86、ARM 架构主机都是小端序;
    • 端口号(16 位)、IP 地址(32 位)在填入套接字结构体前,必须从主机序转换为网络序,接收时再从网络序转换为主机序;
    • 核心转换函数:htons()(主机序转网络序,16 位端口号)、htonl()(主机序转网络序,32 位 IP 地址)、ntohs()ntohl()
    • IP转换我们在这篇博客中用到了:inet_addr()inet_ntoa(),一个是从点分十进制转到4字节IP,还有一个是从4字节IP转到点分十进制,我们可以从下图中理解一下其中的原理


1.4 核心 API 详解(在后续的一些图片中可能也还会有)

我们先把 UDP 编程最核心的 4 个 API 的参数、返回值、注意事项讲清楚,后续代码实现会反复用到:

c 复制代码
// 1. 创建套接字
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
  • domain:地址族,AF_INET表示 IPv4,AF_INET6表示 IPv6;
  • type:套接字类型,SOCK_DGRAM表示数据报套接字(UDP),SOCK_STREAM表示流式套接字(TCP);
  • protocol:协议编号,UDP 场景固定填 0,系统会自动匹配 UDP 协议;
  • 返回值:成功返回非负文件描述符,失败返回 - 1 并设置 errno。
c 复制代码
// 2. 绑定地址与端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfdsocket () 返回的文件描述符;
  • addr:填充好的地址结构体指针,需要强转为通用struct sockaddr*类型;
  • addrlen:地址结构体的长度;
  • 返回值:成功返回 0,失败返回 - 1 并设置 errno。
c 复制代码
// 3. 接收数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
  • sockfd:socket 文件描述符;
  • buf:接收数据的缓冲区;
  • len:缓冲区的最大长度;
  • flags:接收标志,常规场景填 0,代表阻塞接收;
  • src_addr:输出型参数,用于存储发送端(客户端)的地址信息;
  • addrlen:输入输出型参数,传入时是 src_addr 的长度,返回时是实际写入的地址长度;
  • 返回值:成功返回实际接收的字节数,失败返回 - 1 并设置 errno。
c 复制代码
// 4. 发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
  • sockfd:socket 文件描述符;
  • buf:待发送的数据缓冲区;
  • len:待发送数据的长度;
  • flags:发送标志,常规场景填 0;
  • dest_addr:目标接收端的地址结构体;
  • addrlen:地址结构体的长度;
  • 返回值:成功返回实际发送的字节数,失败返回 - 1 并设置 errno。

二. 前置基础设施:工具类实现

工业级的网络代码,不会把所有逻辑耦合在主流程中,我们先实现 2 个基础工具类,为后续的服务开发提供支撑:线程安全互斥锁、高性能日志系统。这两个代码我们之前写过了,这里就简单介绍并且回顾一下,大家如果想的话这里其实还可以扩展一个禁止拷贝的基类,就跟我们上面那个图中的差不多。

2.1 互斥锁封装 Mutex.hpp

多线程环境下,日志打印、数据收发都涉及临界资源的访问,我们封装 POSIX 互斥锁,并通过 RAII 机制实现锁的自动管理,避免手动加解锁导致的死锁问题。

cpp 复制代码
#ifndef MUTEX_HPP
#define MUTEX_HPP

#include <iostream>
#include <pthread.h>

// 互斥锁封装类:提供加锁/解锁及获取原始锁的接口
class Mutex
{
public:
    // 构造函数:初始化互斥锁
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }
    // 析构函数:销毁互斥锁
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }
    // 加锁操作
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }
    // 解锁操作
    void UnLock()
    {
        pthread_mutex_unlock(&_lock);
    }
    // 获取原始互斥锁指针,用于需要原生 pthread_mutex_t 的接口
    pthread_mutex_t* Origin()
    {
        return &_lock;
    }
private:
    pthread_mutex_t _lock;  // POSIX 互斥锁
};

// RAII 风格的锁守卫类:构造时加锁,析构时解锁,自动管理锁的生命周期
class LockGuard
{
public:
    // 构造函数:接收一个 Mutex 指针,并立即加锁
    LockGuard(Mutex* lockptr) : _lockptr(lockptr)
    {
        _lockptr->Lock();
    }
    // 析构函数:自动解锁
    ~LockGuard()
    {
        _lockptr->UnLock();
    }
private:
    Mutex* _lockptr;  // 指向被管理的互斥锁
};

#endif

代码解析

  • Mutex类:封装了 POSIX 互斥锁的初始化、销毁、加锁、解锁操作,提供面向对象的接口;
  • LockGuard类:经典 RAII 实现,对象创建时自动加锁,离开作用域时析构自动解锁,即使代码抛出异常,也能保证锁被释放,彻底杜绝死锁风险;
  • 线程安全:后续日志系统、多线程服务都会基于这两个类保证临界资源的安全访问。

2.2 线程安全日志系统 logger.hpp

工业级服务必须有完善的日志系统,用于问题排查、运行状态监控。我们实现一个支持控制台 / 文件双输出、多等级日志、线程安全、自动格式化的日志系统,基于策略模式设计,方便后续扩展。

cpp 复制代码
#ifndef LOGGER_HPP
#define LOGGER_HPP

#include <fstream>
#include <iostream>
#include <ctime>
#include <cstdio>
#include <memory>
#include <sstream>
#include <string>
#include <filesystem>
#include <unistd.h>
#include "Mutex.hpp"

namespace LogModule
{
    // 1. 获取时间
    std::string GetTimeStamp()
    {
        time_t currentTime = time(nullptr); // 默认获取当前时区的时间
        // 我们希望把这个时间转换成年-月-日 时:分:秒
        struct tm dataTime;
        
        // 使用线程安全的版本 localtime_r,防止在多线程并发获取时间时
        // 因为共享静态全局变量而导致的时间数据覆盖错乱。
        localtime_r(&currentTime, &dataTime);

        char dataTimeStr[128];
        // 使用 snprintf 保证缓冲区不溢出,%02d 确保时间位宽不足时自动补0(如09秒)
        snprintf(dataTimeStr, sizeof(dataTimeStr), "%4d-%02d-%02d %02d:%02d:%02d", 
                 dataTime.tm_year + 1900, // tm_year 是从1900年开始计算的偏移量
                 dataTime.tm_mon + 1,     // tm_mon 范围是 [0, 11],需加1修正
                 dataTime.tm_mday,
                 dataTime.tm_hour,
                 dataTime.tm_min,
                 dataTime.tm_sec
                );
        
        return dataTimeStr;
    }

    // 2. 日志等级 -- 枚举类型(整数)转换成字符串类型
    // 使用 enum class 强类型枚举,避免命名污染,提高类型检查的严谨性
    enum class LogLevel
    {
        DEBUG,
        INFO,
        WARNING,
        ERROR,
        FATAL
    };

    /**
     * @brief 辅助函数:将枚举常量映射为可读字符串
     * 解决强类型枚举无法直接通过 std::cout 打印的问题
     */
    std::string LogLevel2String(LogLevel level)
    {
        switch(level)
        {
        case LogLevel::DEBUG:
            return "DEBUG";
        case LogLevel::INFO:
            return "INFO";
        case LogLevel::WARNING:
            return "WARNING";
        case LogLevel::ERROR:
            return "ERROR";
        case LogLevel::FATAL:
            return "FATAL";
        default:
            return "UNKNOWN";
        }
    }

    // 3. 刷新策略
    // 基类: 策略模式
    // 设计意图:将"日志消息的生成"与"日志消息的输出去向"解耦,方便后续扩展网络、数据库等输出端
    class LogStrategy
    {
    public:
        // 虚析构函数:确保通过基类指针释放子类对象时,子类的资源(如文件句柄)能被正确释放
        virtual ~LogStrategy() = default; // 不在这里析构
        // 纯虚函数:定义统一的刷新接口规范
        virtual void SyncLog(const std::string &message) = 0; // 强制子类对其进行重写
    };

    // 策略1: 控制台日志策略
    // 子类
    class ConsoleLogStrategy: public LogStrategy
    {
    public:
        ConsoleLogStrategy(){}
        ~ConsoleLogStrategy(){}
        void SyncLog(const std::string &message) override // 检查重写的错误
        {
            // 显示器在多线程下是"临界资源",加锁防止多线程输出字符交织(Interleaving)
            LockGuard logGuard(&_mutex);
            std::cout << message << std::endl;
        }
    private:
        Mutex _mutex;
    };

    const static std::string gdefaultlogdir = "./log/";
    const static std::string gdefaultlogfilename = "log.txt";

    // 策略2:文件类日志策略
    // 子类
    class FileLogStrategy: public LogStrategy
    {
    public:
        // 构造函数:初始化路径并利用 C++17 库确保目录环境就绪
        FileLogStrategy(const std::string &logdir = gdefaultlogdir, const std::string &logfilename = gdefaultlogfilename)
            :_logdir(logdir),
            _logfilename(logfilename)
        {
            // 创建目录前加锁,防止多线程同时执行判断与创建操作引发的竞态条件
            LockGuard lockGuard(&_mutex);
            if(std::filesystem::exists(_logdir))
            {
                return;
            }
            else 
            {
                try 
                {
                    // 递归创建目录(mkdir -p),若权限不足或磁盘满会抛出异常
                    std::filesystem::create_directories(_logdir);
                } 
                catch (std::filesystem::filesystem_error &e) 
                {
                    std::cerr << e.what() << std::endl;
                }
            }
        }
        ~FileLogStrategy(){}

        void SyncLog(const std::string &message) override
        {
            // 文件 I/O 是昂贵的临界资源操作,加锁保证单条日志写入的原子性
            LockGuard logGuard(&_mutex);
            std::string target = _logdir + _logfilename;
            
            // 使用 std::ios::app (append) 追加模式,保证新旧日志共存而不被覆盖
            std::ofstream out(target, std::ios::app); // 追加
            if(!out.is_open()) // 打开文件
            {
                return;
            }
            out << message << "\n"; // 流式写入并换行
            out.close(); // 关闭文件流,触发缓冲区刷新
        }
    private:
        std::string _logdir;
        std::string _logfilename;
        Mutex _mutex;
    };

    /**
     * @brief Logger 类:日志系统的中央控制器
     * 内部嵌套了 LogMessage 类来实现精妙的 RAII 自动刷新机制
     */
    class Logger 
    {
    public:
        Logger()
        {
            UseConsoleLogStrategy(); // 默认策略
        }
        void UseConsoleLogStrategy()
        {
            _strategy = std::make_unique<ConsoleLogStrategy>();
        }
        void UseFileLogStrategy()
        {
            _strategy = std::make_unique<FileLogStrategy>();
        }
        ~Logger(){};
    
        // 内部类:负责单条日志的组装和析构刷新
        class LogMessage
        {
        public:
            // 构造函数:预组装日志"前缀"部分
            LogMessage(LogLevel level, const std::string &filename, int line, Logger &self)
                : _currenttime(GetTimeStamp())
                , _loglevel(LogLevel2String(level))
                , _pid(getpid())
                , _filename(filename)
                , _line(line)
                , _logger(self) // 保存引用,以便在析构时找到所属的 Logger 进行刷新
            {
                std::stringstream ss;
                ss << "[" << _currenttime << "] "
                   << "[" << _loglevel << "] "
                   << "[" << _pid << "] "
                   << "[" << _filename << "] "
                   << "[" << _line << "] "
                   << "- ";
                
                _loginfo = ss.str(); // 此时前缀已拼入缓冲区
            }

            /**
             * @brief 核心设计:RAII 机制触发刷新
             * 当 LOG(...) << "msg"; 这行语句执行完毕,临时对象生命周期结束,
             * 在析构函数中调用策略接口,保证日志在写完即刻、必然被刷出。
             */
            ~LogMessage()
            {
                if(_logger._strategy)
                {
                    // 走到尽头了,调用刷新策略刷新出来
                    _logger._strategy->SyncLog(_loginfo);
                }
            }

            // 用模版重载 << 运算符:接纳各种类型(int, string, double等)
            template <typename T>
            LogMessage& operator << (const T& info)
            {
                std::stringstream ss;
                ss << info; // 自动完成类型转换
                _loginfo += ss.str(); // 追加到内容主体中
                return *this; // 返回引用支持链式调用,如 LOG << a << b << c;
            }   
        private:
            std::string _currenttime;
            std::string _loglevel;
            int _pid;
            std::string _filename;
            int _line;
            std::string _loginfo;

            Logger &_logger; // 外部类引用:用于访问具体刷新策略
        };

        /**
         * @brief 重载仿函数 operator()
         * 这是桥梁:将宏参数传入,并返回一个持有 Logger 权限的临时消息对象
         */
        LogMessage operator() (LogLevel level, const std::string filename, int line)
        {
            return LogMessage(level, filename, line, *this);
        }
    private:
        // 使用 unique_ptr 配合策略基类实现运行时多态
        std::unique_ptr<LogStrategy> _strategy; // 策略
    };

    // 定义一个全局模块的Logger对象, 方便后续的使用
    Logger logger;

// 定义宏:捕获编译器内置变量 __FILE__ 和 __LINE__,简化用户调用 API
#define LOG(level) logger(level, __FILE__, __LINE__)

// 便捷切换输出目的地的宏定义
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()
}
#endif

代码核心设计解析

  • 策略模式 :将日志输出的逻辑抽象为LogStrategy基类,控制台和文件输出作为子类实现,后续要扩展网络日志、数据库日志,只需新增子类即可,完全符合开闭原则;
  • RAII 自动刷新LogMessage内部类在析构时自动调用输出接口,保证LOG(INFO) << "hello world";这行代码执行完毕后,日志必然被输出,无需手动调用刷新函数;
  • 线程安全 :所有临界资源(控制台、文件)的访问都通过互斥锁保护,同时使用线程安全的localtime_r函数,彻底解决多线程环境下的日志乱码、数据覆盖问题;
  • 便捷调用:通过宏定义简化调用,自动捕获文件名、行号,无需手动传入,使用方式和主流日志框架完全一致。

三. V1 版本:UDP Echo 回显服务实现

掌握了基础工具和核心 API 后,我们先实现 UDP 编程的 Hello World------Echo 回显服务。该服务的核心需求是:客户端发送任意字符串,服务端收到后原封不动回显给客户端,完整覆盖 UDP 服务端 / 客户端的全流程开发。

3.1 服务端实现

我们将服务端封装为UdpEchoServer类,分为头文件声明和主函数实现两部分,完全基于前面的工具类开发。

3.1.1 服务端头文件 UdpEchoServer.hpp(下面的图示中有的代码里做的优化还没有,会在后面的图示中陆续体现出来)

cpp 复制代码
#ifndef __UDP__ECHOSERVER__HPP
#define __UDP__ECHOSERVER__HPP

// --- 网络编程常用头文件 ---
#include <cstdint>
#include <string>
#include <strings.h>       // 包含 bzero 等内存清零操作函数
#include <sys/socket.h>    // 提供 socket、bind、recvfrom、sendto 等核心网络系统调用
#include <netinet/in.h>    // 提供 struct sockaddr_in 等网络地址结构体及宏 (如 INADDR_ANY)
#include <arpa/inet.h>     // 提供网络字节序与主机字节序、IP格式互相转换的函数 (如 htons, inet_ntoa)
#include <sys/types.h>
#include "logger.hpp"      // 引入自定义的日志模块

using namespace LogModule;

class UdpEchoServer{
public:
    // 构造函数:初始化服务器监听的端口
    // 初始化列表将文件描述符 _socketfd 设为 -1 (代表无效状态),防止出现随机值导致的未定义行为
    // UdpEchoServer(const std::string &ip, uint16_t port)
    UdpEchoServer(uint16_t port)
        : _socketfd(-1)
        // , _ip(ip)
        , _port(port)
    {}

    void Init()
    {
        // 1. 创建socket, 系统概念
        // AF_INET: 指定使用 IPv4 协议族
        // SOCK_DGRAM: 指定使用面向数据报的 UDP 协议
        // 0: 让操作系统自动匹配前面的协议类型
        _socketfd = socket(AF_INET, SOCK_DGRAM, 0);
        if(_socketfd < 0)
        {
            // 在实际服务端开发中,套接字创建失败属于严重错误,通常需要退出进程
            LOG(LogLevel::FATAL) << "create socketfd error";
        }
        LOG(LogLevel::INFO) << "create socketfd success: " << _socketfd;

        // 2. bind (将创建好的套接字与具体的 IP 和 端口 绑定)
        // 准备一个用于保存本地网络属性信息的 IPv4 结构体
        struct sockaddr_in local;
        socklen_t len = sizeof(local);
        // 必须清零,防止内存中原有的脏数据干扰内核的网络解析
        bzero(&local, len);
        local.sin_family = AF_INET; // 明确地址家族为 IPv4
        local.sin_port = htons(_port); // 这里需要本机转网络 (Host to Network Short,保证端口号是大端存储)
        // 如果server 显示的bind了一个具体IP地址,那么它一般就只能收到发给这个IP地址的报文
        // local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 点分十进制 -> 4字节IP,网络序列的
        // INADDR_ANY (宏定义本质是 0):表示让服务器监听本机上所有可用网卡的 IP,云服务器部署必备
        local.sin_addr.s_addr = INADDR_ANY; // 任意IP地址

        // 调用 bind 系统调用。注意:需要将特定协议的 local 结构体指针强转为通用的 sockaddr 指针
        int n = bind(_socketfd, (struct sockaddr*)&local, len);
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socketfd error";
        }
        LOG(LogLevel::INFO) << "bind socketfd success: " << _socketfd;
    }

    void Start()
    {
        // 用于接收网络数据的缓冲区
        char inbuffer[1024];
        // 服务器的本质是一个常驻系统的死循环,持续不断地提供服务
        while(true)
        {
            // perr (也就是 peer) 是一个输出型参数,用于保存给你发消息的客户端的地址信息,方便一会儿给它回信
            struct sockaddr_in perr;
            socklen_t len = sizeof(perr);
            // 1. 读取网络数据
            // 注意第三个参数: sizeof(inbuffer) - 1 是为了给 C 语言的字符串结束符 '\0' 预留空间
            ssize_t n = recvfrom(_socketfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&perr, &len);
            if(n < 0)
            {
                LOG(LogLevel::WARNING) << "recvfrom error";
                break; // 发生读取错误时退出当前循环
            }
            // 安全处理:将接收到的纯网络字节流手动添加结束符,当做字符串来处理
            inbuffer[n] = 0;

            // 我们从peer里面拿到的肯定是网络序列,我们这里打印观察需要的是主机序列
            // inet_ntoa: 将底层的 4 字节整数 IP (Network) 转换回人们能看懂的点分十进制字符串 (ASCII)
            std::string clientIp = inet_ntoa(perr.sin_addr);
            // ntohs: 将网络大端字节序 (Network) 转换回本机的小端字节序 (Host Short)
            uint16_t clientPort = ntohs(perr.sin_port);
            LOG(LogLevel::INFO) << "get a message: " << inbuffer
                                       << ", client addr: " << clientIp << ":" << clientPort;

            // 处理数据 (当前的业务逻辑是极简的 Echo 回显机制)
            std::string echo_str = "Server say: ";
            echo_str += inbuffer;

            // 2. 发送网络数据
            // 这个len是个输入输出型参数
            // 重点技巧:刚才 recvfrom 时,内核已经帮我们把客户端的信息按网络字节序填入 perr 中了
            // 所以现在直接原封不动地强转传入 sendto 即可,非常闭环
            ssize_t m = sendto(_socketfd, echo_str.c_str(), echo_str.size(),  0, (struct sockaddr*)&perr, len);
            // 强转为 (void),这是一个 C++ 编程习惯,用于向编译器声明并压制 "变量 m 定义了但未使用" 的警告
            (void)m;
        }
    }

    // 析构函数:负责资源的清理工作
    ~UdpEchoServer() 
    {
      if (_socketfd >= 0) 
      {
        // 调用 close 关闭文件描述符,将网络资源交还给操作系统 (通常需包含 <unistd.h>)
        close(_socketfd);
        LOG(LogLevel::INFO) << "socket closed, sockfd: " << _socketfd;
      }
    }

private:
    int _socketfd;        // 服务器本身的 socket 句柄
    // std::string _ip;   // 可以不需要,因为上方已经使用了通用的 INADDR_ANY
    uint16_t _port;       // 服务器监听的端口号
};
#endif





3.1.2 服务端主函数 UdpEchoServer.cpp

cpp 复制代码
// 引入我们刚才封装好的服务端核心类头文件
#include "UdpEchoServer.hpp"

// 这是一个辅助的手册函数,当用户命令行参数输入不对时,提示正确的使用方法
void Usage(const std::string &name)
{
    // 注意小细节:虽然这里的打印信息依然写着 "ip port",
    // 但根据下面的逻辑,我们现在只需要用户传入 port 就可以了。
    std::cerr << "Usage: " << name << " ip port" << std::endl;
}

// ./UdpEchoServer 8080
// 我们不直接绑定固定IP
// argc: 命令行参数的个数; argv: 存放所有参数的字符串数组
int main(int argc, char *argv[])
{
    // 1. 参数校验机制
    // 因为程序名本身(如 "./UdpEchoServer")算第 1 个参数,
    // 再加上我们现在只需要用户传入 1 个端口号,所以正确的 argc 必须等于 2。
    if(argc != 2)
    {
        // 参数不对,调用用法提示函数。argv[0] 就是程序执行时的名字。
        Usage(argv[0]);
        exit(0); // 退出程序
    }

    // 2. 解析参数
    // 因为服务端底层已经改为了 INADDR_ANY (绑定任意可用 IP),
    // 所以这里不再需要从命令行读取指定的 IP 地址了,这行代码正式下岗。
    // std::string server_ip = argv[1];

    // argv[1] 拿到的是用户输入的端口号字符串 (如 "8080")
    // 网络端口是数字,所以必须通过 std::stoi (String TO Integer) 将字符串转为 16位无符号整型
    uint16_t server_port = std::stoi(argv[1]);

    // 初始化和启动
    // 3. 实例化我们写的服务端对象,并把解析好的端口号交给他
    UdpEchoServer usvr(server_port);
    
    // 4. 调用 Init() 完成底层的套接字创建 (socket) 和绑定 (bind)
    usvr.Init();
    
    // 5. 调用 Start() 让服务器进入死循环,开始阻塞等待接收客户端的数据 (recvfrom)
    usvr.Start();
    
    // 程序正常情况下会在 Start() 的死循环中一直运行,不会走到这里
    return 0; 
}

3.2 客户端实现

客户端无需封装,直接实现主流程即可,核心逻辑是从标准输入读取用户输入,发送给服务端,再打印服务端的回显响应。

cpp 复制代码
// 客户端我们就不封装了,也不使用日志了
#include <cstdlib>
#include <cstring>         // 提供 memset 函数
#include <iostream>
#include <string>
// --- 网络编程与系统调用必备头文件 ---
#include <sys/socket.h>    // 提供 socket、recvfrom、sendto 等核心网络接口
#include <netinet/in.h>    // 提供 struct sockaddr_in 等网络地址结构体
#include <arpa/inet.h>     // 提供网络字节序与IP格式转换函数 (如 htons, inet_addr)
#include <sys/types.h>

// 辅助函数:当用户启动参数输入错误时,提示正确的命令行用法
void Usage(const std::string &name)
{
    std::cerr << "Usage: " << name << " server_ip server_port" << std::endl;
}

// ./UdpEchoClient 1900.0.0.1 8080
int main(int argc, char *argv[])
{
    // 参数校验:需要程序名、目标服务器IP、目标服务器端口,共3个参数
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

    // 得到我们的服务端IP和Port
    std::string server_ip = argv[1];
    // std::stoi (String TO Integer): 将字符串形式的端口号转换为 16位无符号整型
    uint16_t server_port = std::stoi(argv[2]);

    // 1. 创建 sockfd
    // AF_INET: 指定 IPv4 协议族; SOCK_DGRAM: 指定面向数据报的 UDP 协议
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "create client socketfd error" << std::endl;
        exit(1);
    }

    // 2. 构建目标服务器socket信息
    // 自己一定需要自己的IP和端口号。
    // 但是,client不能自己显示的bind port,一般客户端都是由OS自己选择IP和Port,
    // 尤其是Port,client的port要让OS随机选择
    // 客户端port,是多少不重要,唯一才重要
    // 服务器port,是多少很重要,唯一是基础
    // client不能自己显示的bind port, 但是必须bind,由OS自己完成,Port随机
    struct sockaddr_in server;
    socklen_t len = sizeof(server);
    // 我们服务端用了bzero,这里就用用memset (严谨的内存清零操作,防止脏数据)
    memset(&server, 0, len);
    server.sin_family = AF_INET;
    // htons: 主机字节序转网络字节序 (保证端口号是大端模式发出去的)
    server.sin_port = htons(server_port);
    // inet_addr: 将点分十进制的IP字符串转为网络字节序的4字节整数IP
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());

    // 3. 发送数据和读取数据
    std::string inbuffer;
    while(true)
    {
        // 获取用户键盘输入 (注意:std::cin 遇到空格会截断,如果需要发送带空格的句子,实际工程中常改用 std::getline)
        std::cout << "Please Enter# ";
        std::cin >> inbuffer;

        // 1. 发送数据
        // 【核心机制触发点】:当客户端第一次成功调用 sendto 时,
        // 操作系统底层会悄悄为当前 sockfd 分配一个空闲的本地端口,并进行隐式 bind。
        ssize_t n = sendto(sockfd, inbuffer.c_str(), inbuffer.size(), 0, (struct sockaddr*)&server, len);
        // 压制编译器关于 n 未使用的警告
        (void)n;

        // 2. 接收数据
        // temp 用于存放给你回信的网络节点(在这里就是服务器)的地址信息
        struct sockaddr_in temp;
        socklen_t tempLen = sizeof(temp);
        char buffer[1024];
        
        // 阻塞等待服务器的回信
        // 注意第三个参数 sizeof(buffer) - 1 是为了给结束符留出空间
        ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&temp, &tempLen);
        if(m > 0)
            // 安全处理:网络收到的是纯粹的字节流数据,我们手动追加 C 风格字符串结束符 '\0'
            buffer[m] = 0;
        
        // 打印服务器处理并返回的结果
        std::cout << buffer << std::endl;
    }
}
  • 其实我们输入得用getline,这样可以读整行,我这里就不改了

3.3 代码编译与运行测试

3.3.1 编译 Makefile

shell 复制代码
CXX = g++
CXXFLAGS = -Wall -std=c++17
TARGETS = udpEchoServer udpEchoClient

all: $(TARGETS)

udpEchoServer: UdpEchoServer.cpp
	$(CXX) $(CXXFLAGS) -o udpEchoServer UdpEchoServer.cpp

udpEchoClient: UdpEchoClient.cpp
	$(CXX) $(CXXFLAGS) -o udpEchoClient UdpEchoClient.cpp -static

clean:
	rm -f $(TARGETS)

.PHONY: all clean

执行make命令即可完成编译,生成UdpEchoServerUdpEchoClient两个可执行文件。

3.3.2 运行测试

  • 启动服务端:
bash 复制代码
./udpEchoServer 8080

服务端启动后,会打印创建 socket、绑定成功的日志,进入阻塞等待状态。

  • 启动客户端:
bash 复制代码
./udpEchoClient 127.0.0.1 8080

客户端启动后,输入任意字符串,即可收到服务端的回显响应,服务端也会打印客户端的地址和消息内容。

  • 端口监听验证:

执行netstat -uanp命令,即可看到服务端监听的 8080 端口,验证服务启动成功。

3.4 核心细节解析(附上一个网络实验)

  • 缓冲区末尾补 0recvfrom返回的是实际接收的字节数,我们手动在inbuffer[n] = 0,是为了把缓冲区的内容当做 C 风格字符串处理,避免打印时出现乱码;
  • 异常处理策略:创建 socket、绑定端口属于致命错误,直接退出进程;而单次收发数据失败,仅打印警告日志,继续处理后续请求,保证服务的可用性;
  • inet_ntoa的使用:该函数将网络序的 4 字节 IP 地址转换为点分十进制字符串,注意该函数返回的是静态缓冲区地址,不是线程安全的,多线程环境下推荐使用inet_ntop函数。

四. V2 版本:UDP 在线英译汉字典服务实现

V1 版本的回显服务,把网络通信和业务逻辑耦合在了一起,实际工业级开发中,必须实现网络通信与业务逻辑的解耦 。接下来我们实现 V2 版本 ------ 在线英译汉字典服务,通过回调函数将业务逻辑与网络通信分离,同时实现字典文件的加载、解析与翻译功能。

4.1 需求分析

  • 服务端启动时,加载本地Dict.txt词典文件,将英文单词与对应的中文翻译、例句存入哈希表;
  • 客户端发送英文单词,服务端收到后查询词典,返回对应的中文翻译与例句;
  • 单词不存在时,返回「未知」提示;
  • 网络通信层与翻译业务层完全解耦,服务端可通过更换回调函数,快速适配其他业务场景。

4.2 词典文件 Dict.txt

词典文件采用 单词: 翻译 - 例句 的格式,示例如下(大家还可以自己去扩展一些):

bash 复制代码
apple: 苹果 - I eat an apple every day. / 我每天吃一个苹果。
banana: 香蕉 - The monkey is eating a banana. / 猴子正在吃香蕉。
cat: 猫 - My cat likes to sleep on the sofa. / 我的猫喜欢在沙发上睡觉。
dog: 狗 - She takes her dog for a walk every morning. / 她每天早上带她的狗去散步。
book: 书 - This book is very interesting. / 这本书非常有趣。
pen: 笔 - May I use your pen? / 我可以用一下你的笔吗?
happy: 快乐的 - She looks very happy today. / 她今天看起来很快乐。
sad: 悲伤的 - He felt sad when he lost his watch. / 他丢了手表时感到很悲伤。
run: 跑 - I run fast in the park. / 我在公园里跑得很快。
jump: 跳 - The child can jump high. / 这个孩子能跳得很高。
teacher: 老师 - Our teacher is very kind. / 我们的老师非常和蔼。
student: 学生 - He is a hardworking student. / 他是一个勤奋的学生。
car: 汽车 - His car is very fast. / 他的汽车非常快。
bus: 公交车 - I go to school by bus. / 我乘公交车上学。
love: 爱 - I love my family. / 我爱我的家人。
hate: 恨 - I hate getting up early. / 我讨厌早起。
hello: 你好 - Hello, nice to meet you! / 你好,很高兴认识你!
goodbye: 再见 - Goodbye, see you tomorrow! / 再见,明天见!
summer: 夏天 - Summer is my favorite season. / 夏天是我最喜欢的季节。
winter: 冬天 - It is very cold in winter. / 冬天非常冷。
milk: 牛奶 - I drink a glass of milk every night. / 我每晚喝一杯牛奶。
rice: 米饭 - We eat rice for dinner. / 我们晚饭吃米饭。
fish: 鱼 - The fish swims in the river. / 鱼儿在河里游动。
bird: 鸟 - A little bird sings in the tree. / 一只小鸟在树上唱歌。
desk: 书桌 - I do my homework on the desk. / 我在书桌上写作业。
chair: 椅子 - Please sit on this chair. / 请坐在这把椅子上。
tall: 高的 - The tree is very tall. / 这棵树很高。
short: 矮的;短的 - He is short and thin. / 他又矮又瘦。
walk: 走 - We walk to school together. / 我们一起走路去上学。
sing: 唱歌 - She likes to sing songs. / 她喜欢唱歌。
doctor: 医生 - The doctor helps sick people. / 医生帮助生病的人。
nurse: 护士 - The nurse is very gentle. / 这位护士十分温柔。
bike: 自行车 - I ride my bike on weekends. / 我周末骑自行车。
train: 火车 - We will take a train to travel. / 我们要坐火车去旅行。
smile: 微笑 - You have a beautiful smile. / 你的笑容很美。
angry: 生气的 - My mom is angry with me. / 妈妈在生我的气。
spring: 春天 - Flowers bloom in spring. / 春天百花盛开。
autumn: 秋天 - Autumn is cool and comfortable. / 秋天凉爽又舒适。
water: 水 - We need to drink enough water. / 我们需要喝足够的水。
bread: 面包 - I have bread for breakfast. / 我早餐吃面包。

4.3 字典类实现 Dictionary.hpp

该类负责词典文件的加载、解析、单词查询,完全独立于网络通信,可单独复用。

cpp 复制代码
#ifndef __DICTIONARY__HPP
#define __DICTIONARY__HPP

// --- 标准库与第三方头文件 ---
#include <fstream>         // 提供文件输入输出流 (std::ifstream),用于读取字典文件
#include <iostream>
#include <string>
#include <unordered_map>   // 提供哈希表数据结构,用于实现内存中的极速键值对查找 (O(1) 复杂度)
#include "logger.hpp"      // 引入自定义的日志模块
using namespace LogModule;

// --- 全局配置常量 ---
const std::string gdefaultfilename = "./Dict.txt"; // 默认加载的字典配置文件路径
const std::string gsep = ": ";                     // 字典文件中 Key 和 Value 之间的分隔符 (冒号加空格)

class Dictionary 
{
private:
    // 私有方法:负责在对象初始化时,将磁盘文件中的字典数据加载到内存中
    void LoadConfig()
    {
        // 尝试以只读模式打开指定的字典文件
        std::ifstream in(_dictfilename);
        if(!in.is_open())
        {
            // 如果文件不存在或权限不足,对于字典服务来说是致命错误,直接退出进程
            LOG(LogLevel::FATAL) << "open fail";
            exit(1);
        }
        LOG(LogLevel::INFO) << "open success";

        std::string line;
        // 按行读取文件内容,只要没读到文件末尾 (EOF),就一直循环读取
        while(std::getline(in, line))
        {
            // apple: 苹果 - I eat an apple every day. / 我每天吃一个苹果。
            
            // 核心切割逻辑:寻找分隔符 ": " 的位置
            auto pos = line.find(gsep);
            if(pos == std::string::npos) // 没有找到
            {
                // 容错处理:如果这一行格式不对(缺少分隔符),只打警告日志,跳过它继续加载下一行
                LOG(LogLevel::WARNING) << "load fail";
                continue;
            }
            
            // 提取 Key (英文单词):
            // pos 的值恰好等于前半部分字符串的长度,所以 substr(0, pos) 完美截取了单词
            std::string key = line.substr(0, pos);
            
            // 提取 Value (中文翻译及例句):
            // 从 (分隔符的起始下标 + 分隔符本身的长度) 开始截取,一直截取到这行的末尾
            std::string value = line.substr(pos + gsep.size());
            
            // 将切割好的键值对存入内存中的哈希表
            _dictmp.insert({key, value});
        }
        // 释放文件句柄资源
        in.close();
    }

public:
    // 构造函数:默认使用全局的配置路径
    // 巧妙的设计:对象一被创建,就立刻自动调用 LoadConfig() 完成文件的加载和解析
    Dictionary(const std::string dictfilename = gdefaultfilename)
        : _dictfilename(dictfilename)
    {
        LoadConfig();
    }

    // 公共接口:提供在线翻译服务 (将收到的网络单词转换为对应的中文)
    std::string TransTrate(const std::string &word)
    {
        // 在哈希表中进行极速查找
        auto it = _dictmp.find(word);
        if(it == _dictmp.end())
        {
            // 如果查到了哈希表的末尾还没找到,说明字典里没有这个词
            return "未知";
        }
        // 找到了,返回对应的翻译内容 (迭代器的 second 就是 Value)
        return it->second;
    }

    // 析构函数:由于使用了 STL 容器 (string, unordered_map),它们会自动管理内存,所以这里为空即可
    ~Dictionary()
    {

    }

private:
    std::string _dictfilename; // 存放当前对象使用的字典文件路径
    std::unordered_map<std::string, std::string> _dictmp; // 核心数据结构:承载字典内容的内存哈希表
};
#endif

代码解析

  • 采用unordered_map存储单词与释义,查询时间复杂度 O (1),性能极高;
  • 加载文件时做了完善的异常处理,格式错误的行仅打印警告,不影响整体加载;
  • 完全独立于网络逻辑,可在任何 C++ 项目中单独使用,符合单一职责原则。

4.4 通用 UDP 服务端实现 UdpServer.hpp

我们对 V1 版本的服务端进行改造,通过回调函数实现网络通信与业务逻辑的解耦,服务端只负责数据的收发,具体的业务处理通过回调函数注入。

cpp 复制代码
#ifndef __UDP__SERVER__HPP
#define __UDP__SERVER__HPP

// --- 标准系统与网络编程头文件 ---
#include <cstdint>
#include <string>
#include <strings.h>       // 提供 bzero
#include <sys/socket.h>    // 提供 socket、bind、recvfrom、sendto
#include <netinet/in.h>    // 提供 sockaddr_in 结构体及 INADDR_ANY
#include <arpa/inet.h>     // 提供 htons、inet_ntoa 等转换函数
#include <sys/types.h>
#include <functional>      // 提供 std::function,用于支持回调函数机制 (核心解耦利器)
#include "logger.hpp"

using namespace LogModule;

// 参数就是获得的数据,返回值就是处理完数据的结果
// 【核心解耦设计】:定义回调函数类型 callback_t。
// 服务器只负责收发字符串,具体字符串怎么处理(例如:翻译单词、计算算术题),由外部传入的这个函数决定。
using callback_t = std::function<std::string(const std::string &)>;

class UdpServer{
public:
    // 构造函数:现在多了一个参数 cb,用于接收上层业务传递进来的具体处理逻辑
    UdpServer(callback_t cb, uint16_t port)
        : _socketfd(-1)
        , _port(port)
        , _cb(cb) // 保存业务层传入的回调函数
    {}

    void Init()
    {
        // 1. 创建socket, 系统概念
        // AF_INET: IPv4协议族; SOCK_DGRAM: 无连接的数据报服务(UDP); 0: 默认协议
        _socketfd = socket(AF_INET, SOCK_DGRAM, 0);
        if(_socketfd < 0)
        {
            LOG(LogLevel::FATAL) << "create socketfd error";
        }
        LOG(LogLevel::INFO) << "create socketfd success: " << _socketfd;

        // 2. bind (绑定网络信息到 socket)
        struct sockaddr_in local;
        socklen_t len = sizeof(local);
        bzero(&local, len); // 严谨操作:清空结构体内存,防止脏数据
        local.sin_family = AF_INET;
        local.sin_port = htons(_port); // 这里需要本机转网络 (保证端口大端传输)
        // 如果server 显示的bind了一个具体IP地址,那么它一般就只能收到发给这个IP地址的报文
        // local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 点分十进制 -> 4字节IP,网络序列的
        // 绑定 INADDR_ANY (0),监听本机所有网卡的请求,非常适合云服务器等多网卡环境
        local.sin_addr.s_addr = INADDR_ANY; // 任意IP地址

        // 执行系统调用 bind,强转为统一的 struct sockaddr* 指针
        int n = bind(_socketfd, (struct sockaddr*)&local, len);
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socketfd error";
        }
        LOG(LogLevel::INFO) << "bind socketfd success: " << _socketfd;
    }

    void Start()
    {
        char inbuffer[1024];
        while(true)
        {
            // perr 保存客户端的网络地址信息 (发件人是谁)
            struct sockaddr_in perr;
            socklen_t len = sizeof(perr);
            // 1. 读取网络数据
            // 阻塞等待客户端发来数据,sizeof(inbuffer)-1 是为了给结尾留一个 '\0' 的位置
            ssize_t n = recvfrom(_socketfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&perr, &len);
            if(n < 0)
            {
                LOG(LogLevel::WARNING) << "recvfrom error";
                break;
            }
            // 手动添加字符串结束符,将接收到的网络纯字节流转为安全的 C 风格字符串
            inbuffer[n] = 0;

            // 我们从peer里面拿到的肯定是网络序列,我们这里打印观察需要的是主机序列
            // inet_ntoa: 将 4 字节网络 IP 转成直观的点分十进制字符串
            std::string clientIp = inet_ntoa(perr.sin_addr);
            // ntohs: 将大端网络端口转回小端主机端口
            uint16_t clientPort = ntohs(perr.sin_port);
            LOG(LogLevel::INFO) << "get a message: " << inbuffer
                                       << ", client addr: " << clientIp << ":" << clientPort;

            // 处理数据
            // 【架构升级的精髓所在】:以前这里是写死的 "server say: " 字符串拼接
            // 现在网络层完全不用知道业务逻辑,直接呼叫上层传进来的 _cb 回调函数
            std::string result;
            if(_cb) // 安全检查:确保外部真的传了一个有效的函数进来
            {
                // 将网络接收到的请求 (inbuffer) 扔给业务层,获取处理结果 (result)
                result = _cb(inbuffer);
            }

            // 2. 发送网络数据
            // 这个len是个输入输出型参数
            // 把业务层返回的 result,通过 perr 记录的原路发回给客户端
            ssize_t m = sendto(_socketfd, result.c_str(), result.size(),  0, (struct sockaddr*)&perr, len);
            (void)m; // 压制编译器警告
        }
    }

    // 析构函数:释放系统资源
    ~UdpServer() 
    {
      if (_socketfd >= 0) 
      {
        close(_socketfd); // 关闭套接字
        LOG(LogLevel::INFO) << "socket closed, sockfd: " << _socketfd;
      }
    }
private:
    int _socketfd;       // 服务器 socket 文件描述符
    // std::string _ip;  // 可以不需要 (使用了 INADDR_ANY)
    uint16_t _port;      // 监听的端口

    // 保存外部传入的回调函数,作为网络层和业务层沟通的桥梁
    callback_t _cb;
};
#endif

4.5 字典服务端主函数 DictServer.cpp

整合字典类和通用 UDP 服务端,通过 lambda 表达式将翻译业务注入服务端,代码极其简洁优雅。

cpp 复制代码
// 引入智能指针头文件,用于自动管理对象内存
#include <memory>
#include "Dictionary.hpp"
#include "UdpServer.hpp"

// 辅助函数:提示用户如何正确使用命令行参数启动程序
void Usage(const std::string &name)
{
    // 提示:虽然这里的打印文案依然写着 "ip port",但实际上根据下方逻辑,目前只需要传端口号
    std::cerr << "Usage: " << name << " ip port" << std::endl;
}

// ./UdpEchoServer 8080
// 我们不直接绑定固定IP
int main(int argc, char *argv[])
{
    // 参数校验:程序启动名算第1个参数,端口号算第2个,所以 argc 必须等于 2
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }

    // 初始化日志系统:启用控制台日志输出策略,这样程序运行时的 LOG 信息才会打印在屏幕上
    ENABLE_CONSOLE_LOG_STRATEGY();

    // std::string server_ip = argv[1];
    // 获取传入的端口参数,并将字符串 (argv[1]) 转换为 16 位无符号整型
    uint16_t server_port = std::stoi(argv[1]);

    // 1. 创建一个在线字典服务
    // 【业务层】:实例化字典对象。使用 std::unique_ptr 智能指针管理,确保程序结束时资源自动释放
    std::unique_ptr<Dictionary> dict = std::make_unique<Dictionary>();

    // 2. 创建一个网络服务器
    // 【网络层与桥接】:实例化 UdpServer。
    // 核心亮点:通过 Lambda 表达式 (匿名函数) 实现了网络与业务的完美解耦。
    // [&dict]:以引用方式捕获外部的字典对象指针。
    // (const std::string &word)->std::string:定义了输入一个字符串,返回一个字符串的处理逻辑。
    // 运行机制:当底层的 UdpServer 收到网络数据时,它会拿着收到的字符串来调用这段 Lambda 代码,
    // 从而触发 dict->TransTrate(word) 进行翻译,再由 UdpServer 将翻译结果发回给客户端。
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>([&dict](const std::string &word)->std::string{
        return dict->TransTrate(word);
    }, server_port);

    // 3. 初始化和启动服务器
    // 执行底层 socket 创建和 INADDR_ANY 的 bind 绑定操作
    usvr->Init();
    // 启动服务器死循环,开始不间断地接待客户端的网络请求
    usvr->Start();
}

4.6 字典客户端实现 DictClient.cpp

客户端逻辑和回显服务客户端基本一致。

cpp 复制代码
// 客户端我们就不封装了,也不使用日志了
#include <cstdlib>
#include <cstring>         // 提供 memset 等内存操作函数
#include <iostream>
#include <string>
// --- 网络通信核心头文件 ---
#include <sys/socket.h>    // 提供 socket、sendto、recvfrom 等系统调用
#include <netinet/in.h>    // 提供 sockaddr_in 结构体及网络宏定义
#include <arpa/inet.h>     // 提供网络字节序与IP格式转换函数 (htons, inet_addr)
#include <sys/types.h>

// 辅助函数:当用户命令行参数输入不对时,提示正确的启动格式
void Usage(const std::string &name)
{
    std::cerr << "Usage: " << name << " server_ip server_port" << std::endl;
}

// ./UdpEchoClient 1900.0.0.1 8080 (注:1900 是无效 IP 段,本地测试通常用 127.0.0.1)
int main(int argc, char *argv[])
{
    // 客户端需要3个参数:程序名本身、目标服务器的IP、目标服务器的端口
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

    // 得到我们的服务端IP和Port
    std::string server_ip = argv[1];
    // std::stoi: 将传入的字符串形式的端口号转换为 16位无符号整数
    uint16_t server_port = std::stoi(argv[2]);

    // 1. 创建 sockefd (获取网卡/网络协议栈的访问凭证)
    // AF_INET: IPv4协议族; SOCK_DGRAM: 无连接的数据报服务(UDP)
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "create client socketfd error" << std::endl;
        exit(1);
    }

    // 2. 构建目标服务器socket信息 (提前写好信封上的"收件人地址")
    // 自己一定需要自己的IP和端口号。
    // 但是,client不能自己显示的bind port,一般客户端都是由OS自己选择IP和Port,
    // 尤其是Port,client的port要让OS随机选择
    // 客户端port,是多少不重要,唯一才重要
    // 服务器port,是多少很重要,唯一是基础
    // client不能自己显示的bind port, 但是必须bind,由OS自己完成,Port随机
    struct sockaddr_in server;
    socklen_t len = sizeof(server);
    // 我们服务端用了bzero,这里就用用memset (严谨:清零内存,防止残留脏数据干扰内核)
    memset(&server, 0, len);
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port); // 主机字节序转网络大端字节序
    server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 点分十进制字符串转网络4字节整数IP

    // 3. 发送数据和读取数据
    std::string inbuffer;
    while(true)
    {
        std::cout << "Please Enter# ";
        // 获取用户输入 (注意:cin 遇到空格会截断,如果是发带有空格的英文句子,通常改用 getline)
        std::cin >> inbuffer;

        // 1. 发送数据
        // 【核心细节】:对于客户端,正是在这里【第一次】调用 sendto 发送数据时,
        // 操作系统底层会察觉到这个 sockfd 还没有绑定端口,从而自动为它分配一个随机的空闲端口进行隐式 bind!
        ssize_t n = sendto(sockfd, inbuffer.c_str(), inbuffer.size(), 0, (struct sockaddr*)&server, len);
        // 压制编译器针对变量 n 未使用的警告
        (void)n;

        // 2. 接收数据
        // temp 用于接收给你回信的那个节点 (即服务器) 的网络地址信息
        struct sockaddr_in temp;
        socklen_t tempLen = sizeof(temp);
        char buffer[1024];
        
        // 阻塞等待服务器的处理结果 (例如翻译后的中文)
        // 注意 sizeof(buffer) - 1 是为了给最后的手动 '\0' 预留出安全的空间
        ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&temp, &tempLen);
        if(m > 0)
            // 安全处理:网络发来的全是纯字节,我们必须手动加上 C 风格字符串的结尾标识,防止打印时越界乱码
            buffer[m] = 0;
        
        // 打印最终结果
        std::cout << buffer << std::endl;
    }
}
  • 其实我们输入得用getline,这样可以读整行,我这里就不改了

4.7 编译与运行测试

在 Makefile 中新增编译目标,编译后启动服务端和客户端,输入英文单词即可获得翻译结果,服务端会打印完整的请求日志。该服务实现了网络与业务的完全解耦,若要实现其他业务(如计算器、天气查询),只需更换回调函数,无需修改服务端的网络通信代码,扩展性极强。

powershell 复制代码
.PHONY: all clean

CXX = g++
CXXFLAGS = -std=c++17 -Wall

all: DictServer DictClient

DictServer: DictServer.cpp UdpServer.hpp logger.hpp Mutex.hpp
	$(CXX) $(CXXFLAGS) -o DictServer DictServer.cpp

DictClient: DictClient.cpp
	$(CXX) $(CXXFLAGS) -o DictClient DictClient.cpp

clean:
	rm -f DictServer DictClient

五. 进阶:通用 UDP 服务端 / 客户端封装

为了进一步提高代码复用性,我们对 UDP 的核心操作进行更高层级的封装,实现UdpSocket基础类、UdpServer通用服务类、UdpClient通用客户端类,适配所有 UDP 业务场景。

5.1 基础套接字封装 udp_socket.hpp

cpp 复制代码
// udp_socket.hpp
#pragma once
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <cassert>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;

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

    // 创建套接字
    bool Socket() {
        fd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (fd_ < 0) {
            perror("socket create failed");
            return false;
        }
        return true;
    }

    // 关闭套接字
    bool Close() {
        if(fd_ >= 0) {
            close(fd_);
            fd_ = -1;
        }
        return true;
    }

    // 绑定地址与端口
    bool Bind(const std::string& ip, uint16_t port) {
        sockaddr_in addr;
        addr.sin_family = AF_INET;
        addr.sin_addr.s_addr = ip.empty() ? INADDR_ANY : inet_addr(ip.c_str());
        addr.sin_port = htons(port);
        int ret = bind(fd_, (sockaddr*)&addr, sizeof(addr));
        if (ret < 0) {
            perror("bind failed");
            return false;
        }
        return true;
    }

    // 接收数据,同时获取发送端IP和端口
    bool RecvFrom(std::string* buf, std::string* ip = NULL, uint16_t* port = NULL) {
        char tmp[1024 * 10] = {0};
        sockaddr_in peer;
        socklen_t len = sizeof(peer);
        ssize_t read_size = recvfrom(fd_, tmp, sizeof(tmp) - 1, 0, (sockaddr*)&peer, &len);
        if (read_size < 0) {
            perror("recvfrom failed");
            return false;
        }
        // 赋值输出参数
        buf->assign(tmp, read_size);
        if (ip != NULL) {
            *ip = inet_ntoa(peer.sin_addr);
        }
        if (port != NULL) {
            *port = ntohs(peer.sin_port);
        }
        return true;
    }

    // 发送数据到指定IP和端口
    bool SendTo(const std::string& buf, const std::string& ip, uint16_t port) {
        sockaddr_in addr;
        addr.sin_family = AF_INET;
        addr.sin_addr.s_addr = inet_addr(ip.c_str());
        addr.sin_port = htons(port);
        ssize_t write_size = sendto(fd_, buf.data(), buf.size(), 0,
                                    (sockaddr*)&addr, sizeof(addr));
        if (write_size < 0) {
            perror("sendto failed");
            return false;
        }
        return true;
    }

private:
    int fd_;
};

5.2 通用服务端封装 udp_server.hpp

基于UdpSocket实现通用服务端,支持通过回调函数注入任意业务逻辑,一行代码即可启动服务。

cpp 复制代码
// udp_server.hpp
#pragma once
#include "udp_socket.hpp"
#include <functional>

// 业务处理回调函数类型
typedef std::function<void (const std::string&, std::string* resp)> Handler;

class UdpServer {
public:
    UdpServer() {
        assert(sock_.Socket());
    }

    ~UdpServer() {
        sock_.Close();
    }

    // 启动服务
    bool Start(const std::string& ip, uint16_t port, Handler handler) {
        // 绑定端口
        if (!sock_.Bind(ip, port)) {
            return false;
        }
        printf("UdpServer start success, listen on %s:%d\n", ip.c_str(), port);
        // 事件循环
        for (;;) {
            // 接收请求
            std::string req;
            std::string remote_ip;
            uint16_t remote_port = 0;
            if (!sock_.RecvFrom(&req, &remote_ip, &remote_port)) {
                continue;
            }
            // 业务处理
            std::string resp;
            handler(req, &resp);
            // 返回响应
            sock_.SendTo(resp, remote_ip, remote_port);
            printf("[%s:%d] req: %s, resp: %s\n", remote_ip.c_str(), remote_port,
                   req.c_str(), resp.c_str());
        }
        sock_.Close();
        return true;
    }

private:
    UdpSocket sock_;
};

5.3 通用客户端封装 udp_client.hpp

cpp 复制代码
// udp_client.hpp
#pragma once
#include "udp_socket.hpp"

class UdpClient {
public:
    // 构造函数:指定服务端IP和端口
    UdpClient(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
        assert(sock_.Socket());
    }

    ~UdpClient() {
        sock_.Close();
    }

    // 接收响应
    bool RecvFrom(std::string* buf) {
        return sock_.RecvFrom(buf);
    }

    // 发送请求
    bool SendTo(const std::string& buf) {
        return sock_.SendTo(buf, ip_, port_);
    }

private:
    UdpSocket sock_;
    std::string ip_;
    uint16_t port_;
};

5.4 基于封装的极简字典服务实现

使用上述封装,实现字典服务仅需不到 30 行代码,极致简洁:

cpp 复制代码
// dict_server_simple.cpp
#include "udp_server.hpp"
#include <unordered_map>
#include <iostream>

std::unordered_map<std::string, std::string> g_dict;

// 翻译业务处理函数
void Translate(const std::string& req, std::string* resp) {
    auto it = g_dict.find(req);
    if (it == g_dict.end()) {
        *resp = "Unknown word!";
        return;
    }
    *resp = it->second;
}

int main(int argc, char* argv[]) {
    if (argc != 3) {
        printf("Usage: ./dict_server [ip] [port]\n");
        return 1;
    }
    // 初始化词典
    g_dict.insert({"apple", "苹果"});
    g_dict.insert({"banana", "香蕉"});
    g_dict.insert({"hello", "你好"});
    // 启动服务
    UdpServer server;
    server.Start(argv[1], atoi(argv[2]), Translate);
    return 0;
}

六. UDP 编程核心考点与踩坑指南

这部分是面试高频考点,也是实际开发中最容易踩坑的地方,结合前面的代码实现,做全面总结:

6.1 核心面试考点

  • UDP 和 TCP 的核心区别,以及各自的适用场景?

    • 核心区别围绕连接、可靠性、传输模式、头部开销、流控 / 拥塞控制展开,场景上 TCP 适用于可靠性优先的场景,UDP 适用于实时性优先的场景。
  • UDP 服务端必须调用 bind,客户端为什么不推荐显式 bind?

    • 服务端的端口必须固定,客户端只需保证端口唯一即可;显式 bind 固定端口会导致同一机器多个客户端运行时端口冲突,操作系统会在客户端首次 sendto 时自动分配随机可用端口,是最优解。
  • INADDR_ANY 的作用是什么?云服务器为什么不能直接绑定公网 IP?

    • INADDR_ANY代表绑定本机所有网卡 IP,无论客户端访问哪个 IP 都能收到数据包;云服务器的公网 IP 是通过 NAT 网关映射的,并非配置在服务器网卡上,直接绑定会报地址不可用错误。
  • UDP 编程中,哪些字段必须做网络字节序转换?为什么?

    • 端口号(16 位)、IP 地址(32 位)必须转换;因为 TCP/IP 协议规定网络数据流采用大端序,而 x86/ARM 主机多为小端序,不转换会导致接收端解析错误。
  • inet_ntoa 函数是线程安全的吗?为什么?

    • 不是线程安全的;该函数返回的字符串存在静态缓冲区中,多次调用会覆盖上一次的结果,多线程环境下会出现数据错乱,推荐使用线程安全的inet_ntop函数,该函数由调用者提供缓冲区。
  • UDP 的面向数据报特性,在编程中有什么注意事项?

    • UDP 是面向数据报的,收发次数严格匹配,recvfrom 必须一次性读完整个报文,否则剩余数据会被丢弃;因此接收缓冲区的大小必须大于最大报文长度,避免数据截断。
  • UDP 是全双工的吗?多线程环境下可以同时收发吗?

    • UDP 是全双工的,一个 socket 文件描述符可以同时进行收发操作;但多线程同时调用 recvfrom/sendto 时,需要注意临界资源保护,避免数据错乱。

6.2 高频踩坑指南

  • bind 端口失败,错误码 Address already in use

    • 原因:端口被其他进程占用,或进程退出后端口处于 TIME_WAIT 状态;
    • 解决:通过netstat -tunlp查看端口占用,更换端口,或设置端口复用 SO_REUSEADDR 选项。
  • 客户端能发送数据,但收不到服务端的响应

    • 原因 1:服务端 bind 了 127.0.0.1,仅能接收本机请求,需改为 INADDR_ANY;
    • 原因 2:服务端防火墙未开放对应端口,需配置防火墙规则;
    • 原因 3:云服务器安全组未开放端口,需在云厂商控制台配置安全组规则。
  • 打印的客户端端口号 / IP 地址错乱

    • 原因:没有做网络序到主机序的转换,直接打印了网络序的端口号 / IP;
    • 解决:端口号通过ntohs()转换,IP 地址通过inet_ntoa()转换后再打印。
  • recvfrom 打印的字符串乱码

    • 原因:没有在缓冲区末尾补 0,当做 C 风格字符串打印时,会读取缓冲区外的脏数据;
    • 解决:recvfrom返回后,执行buffer[n] = 0,n 为实际接收的字节数。
  • UDP 报文丢失

    • 原因 1:UDP 是不可靠协议,网络拥塞时会丢包;
    • 原因 2:socket 接收缓冲区满了,内核会丢弃后续报文;
    • 解决:通过setsockopt调大接收缓冲区,或在应用层实现确认应答、超时重传机制,保证可靠性。

结尾:

html 复制代码
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:本文从 UDP 协议的核心特性出发,从零实现了 V1 版本回显服务、V2 版本在线字典服务,再到通用型 UDP 服务端 / 客户端封装,逐行拆解了代码实现与设计思想,同时总结了面试高频考点与开发踩坑指南。UDP 虽然比 TCP 简单,但其无连接、轻量的特性,让它在实时性场景中有着不可替代的地位。想要写好工业级 UDP 代码,不仅要掌握 API 的使用,更要吃透底层协议特性、设计模式、线程安全、异常处理等核心细节。本文实现的代码框架,可直接用于实际项目开发,后续我们还会深入讲解 UDP 的可靠性传输设计、并发模型、超时重传、流量控制等进阶内容,带你彻底掌握 Linux UDP 网络编程。如果本文对你有帮助,欢迎点赞收藏,评论区一起交流学习!

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど

相关推荐
一只积极向上的小咸鱼3 小时前
Codex 在 VS Code + ModelArts 场景下的登录与配置总结
linux·运维·windows
飞鸿踏雪(蓝屏选手)7 小时前
137 ≤ Chrome 主密钥获取研究
c++·chrome·windows·网络安全·逆向分析
Waay7 小时前
Linux Shell 知识点考评(一):grep 文本搜索(附答案)
linux·运维·服务器
jamon_tan7 小时前
Linux下串口RAW模式设置
linux
woxihuan1234567 小时前
SQL删除数据时存在依赖关系_设置外键级联删除ON DELETE
jvm·数据库·python
东风破1378 小时前
DM8达梦共享存储集群DSC搭建步骤
数据库·学习·dm达梦数据库
碧海银沙音频科技研究院8 小时前
基于VMware虚拟机ubuntu开发博通BK7258方法
linux·运维·ubuntu
雪碧聊技术8 小时前
当数据库字段数大于Java实体类属性数时,MyBatis还能映射成功吗?一文详解
数据库·自动映射·mybatis映射机制·java实体类·宽容映射机制
Jetev8 小时前
如何确定SQL字段是否为空_使用IS NULL与IS NOT NULL
jvm·数据库·python