【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返回后,执行buffern = 0,n 为实际接收的字节数。
  • UDP 报文丢失

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

结尾:

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

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

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

相关推荐
艾莉丝努力练剑7 分钟前
【AI大模型接入SDK】LLM会话管理模块设计
网络·c++·人工智能·学习·大模型
小此方15 分钟前
「C++AI大模型接入SDK」(一) API接入与本地两种方式对比、API Key获取、API报文详解与简单API的构建
开发语言·c++·人工智能
风哥2号19 分钟前
数据库教程FGMT52‑PostgreSQL用户权限与安全管理
数据库·postgresql
考虑考虑6 小时前
docker compose V2版本新属性
运维·后端·自动化运维
风哥2号7 小时前
数据库教程FGMT27‑MySQL数据库基础知识与体系架构
数据库·mysql
AlanBruce7 小时前
摩尔信使MThings功能综述与应用使用指南
linux·自动化·plc·mthings·摩尔信使
小小的木头人7 小时前
Ubuntu Samba修改端口绕过445封禁挂载
运维·ubuntu
YangYang9YangYan7 小时前
2026 校招商品分析岗位 JD 拆解,核心指标、工具与面试考点
大数据·数据库·数据分析
老当益壮梁奶奶8 小时前
51单片机DS18B20温度传感器详解:单总线时序与C51驱动实现
笔记·stm32·单片机·嵌入式硬件·51单片机
西安栈上月明软件科技8 小时前
从业务黑话到本体图谱:OAG本体建模五步法(西安老系统AI化改造实战)
数据库·人工智能·架构