
🔥草莓熊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);
sockfd:socket ()返回的文件描述符;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(¤tTime, &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命令即可完成编译,生成UdpEchoServer和UdpEchoClient两个可执行文件。
3.3.2 运行测试
- 启动服务端:
bash
./udpEchoServer 8080
服务端启动后,会打印创建 socket、绑定成功的日志,进入阻塞等待状态。
- 启动客户端:
bash
./udpEchoClient 127.0.0.1 8080
客户端启动后,输入任意字符串,即可收到服务端的回显响应,服务端也会打印客户端的地址和消息内容。
- 端口监听验证:
执行netstat -uanp命令,即可看到服务端监听的 8080 端口,验证服务启动成功。

3.4 核心细节解析(附上一个网络实验)
- 缓冲区末尾补 0 :
recvfrom返回的是实际接收的字节数,我们手动在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 网络编程。如果本文对你有帮助,欢迎点赞收藏,评论区一起交流学习!
✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど
