
🔥小叶-duck:个人主页
❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux系统从入门到实践》《Linux网络从入门到实践》
✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游
目录
[一、UDP 协议特性与套接字编程模型](#一、UDP 协议特性与套接字编程模型)
[1.1 UDP 协议三大关键特征](#1.1 UDP 协议三大关键特征)
[1.2 UDP 套接字编程核心流程](#1.2 UDP 套接字编程核心流程)
[1.3 核心前置知识点](#1.3 核心前置知识点)
[1.4 核心系统调用 API 详解](#1.4 核心系统调用 API 详解)
[2.1 互斥锁封装 Mutex.hpp](#2.1 互斥锁封装 Mutex.hpp)
[2.2 线程安全日志系统 Log.hpp](#2.2 线程安全日志系统 Log.hpp)
[三、UDP Echo 回显服务实现](#三、UDP Echo 回显服务实现)
[3.1 v1版本(只回显消息,不处理任务)](#3.1 v1版本(只回显消息,不处理任务))
[3.1.1 服务端实现](#3.1.1 服务端实现)
[服务端头文件 UdpEchoServer.hpp(后续会加上处理任务优化)](#服务端头文件 UdpEchoServer.hpp(后续会加上处理任务优化))
[服务端主函数 UdpEchoServer.cc](#服务端主函数 UdpEchoServer.cc)
[3.1.1 客户端实现](#3.1.1 客户端实现)
[3.2 v2版本(服务端对客户发送的消息进行处理再回显:回调机制)](#3.2 v2版本(服务端对客户发送的消息进行处理再回显:回调机制))
[3.2.1 服务端优化](#3.2.1 服务端优化)
[服务端头文件 UdpEchoServer.hpp](#服务端头文件 UdpEchoServer.hpp)
[服务端主函数 UdpEchoServer.cc](#服务端主函数 UdpEchoServer.cc)
[3.3 核心细节解析](#3.3 核心细节解析)
[四、UDP 在线英译汉字典服务实现](#四、UDP 在线英译汉字典服务实现)
[4.1 需求分析](#4.1 需求分析)
[4.2 词典文件 Dict.txt](#4.2 词典文件 Dict.txt)
[4.3 字典客户端实现 DictClient.cpp](#4.3 字典客户端实现 DictClient.cpp)
[4.4 v1版本(无法查看哪个客户端在使用翻译模块)](#4.4 v1版本(无法查看哪个客户端在使用翻译模块))
[4.4.1 UDP 服务端头文件实现 UdpServer.hpp](#4.4.1 UDP 服务端头文件实现 UdpServer.hpp)
[4.4.2 字典类实现 Dictionary.hpp](#4.4.2 字典类实现 Dictionary.hpp)
[4.4.3 服务端主函数 UdpServer.cpp](#4.4.3 服务端主函数 UdpServer.cpp)
[4.5 v2版本(优化:获取客户端的ip地址和端口号)](#4.5 v2版本(优化:获取客户端的ip地址和端口号))
[4.5.1 引入通用模块InetAddr(网络地址和主机地址之间进行转换)](#4.5.1 引入通用模块InetAddr(网络地址和主机地址之间进行转换))
[4.5.2 优化:UDP 服务端头文件 UdpServer.hpp](#4.5.2 优化:UDP 服务端头文件 UdpServer.hpp)
[4.5.3 优化:字典类 Dictionary.hpp](#4.5.3 优化:字典类 Dictionary.hpp)
[4.5.4 优化:服务端主函数 UdpServer.cpp](#4.5.4 优化:服务端主函数 UdpServer.cpp)
[五、进阶:通用 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 高频踩坑指南)
前言
在网络开发当中,UDP 是非常容易被忽视的传输层协议。不同于 TCP 面向连接、可靠传输的特性,UDP 以无连接、报文传输、低延迟的特点广泛应用在 DNS 解析、音视频直播、游戏通信等业务场景。很多开发者只了解 UDP 理论概念,却缺少完整的工程落地实践。
本文将从 UDP 协议本身的核心特性出发,梳理 UDP Socket 完整编程流程,拆解相关系统调用 API。从最简单的 Echo 回显服务入手,一步步完成服务端、客户端编码,编译调试与现象验证。
在掌握基础版本之后,我们继续迭代实现在线英译汉字典业务案例,完成业务需求落地。之后进一步做抽象封装,实现一套可复用的 UDP 服务端、客户端组件,简化后续业务开发。
一、UDP 协议特性与套接字编程模型
在正式开展手写 UDP 代码前,我们需要吃透 UDP 协议的底层设计特点,这是后续套接字代码实现的理论根基。
1.1 UDP 协议三大关键特征
UDP 全称用户数据报协议 ,工作在传输层,和 TCP 处于同一网络层级,但整体设计理念和 TCP 截然相反,主要具备三大特点:
| 特性 | 详细说明 | 与 TCP 的核心差异 |
|---|---|---|
| 无连接 | 通信双方无需预先建立连接,只要获取目标主机 IP 与端口信息就能够直接发送报文,不存在握手与挥手的交互过程 | TCP 通信前必须完成三次握手建立连接,连接就绪之后才能够传输业务数据 |
| 尽力交付 | 协议本身不实现应答确认、超时重传、序号排序等逻辑,只会尽可能把报文向外发送,无法保障数据一定送达、不会重复、顺序不乱 | TCP 内置整套可靠传输机制,可以确保数据不丢失、无重复、按先后顺序交给应用程序 |
| 面向报文 | 数据以完整报文作为最小传输单元,发送和接收操作一一绑定,报文自带边界;发送方送出一个完整报文,接收方就要一次性读取完整报文 | TCP 属于字节流模式,数据没有天然边界,发送的一段数据可以被拆成多段读取,报文边界需要业务代码手动处理 |
重要提示:不可靠是 UDP 的特性,而非缺点,而是它的设计取舍。UDP 放弃了可靠性保障逻辑,以此换取更低通信时延,协议头部仅有 8 字节,远小于 TCP 最少 20 字节的头部开销,这也是实时音视频、联机游戏等场景优先选用 UDP 的关键因素。
1.2 UDP 套接字编程核心流程
UDP 属于无连接协议,整套编程模型相比 TCP 要简洁不少,下面分别梳理服务端与客户端的完整执行流程。
服务端核心流程
- 创建 Socket 文件描述符: 调用
socket()系统调用,生成一个 IPv4 协议、数据报类型的 UDP 套接字。 - 绑定地址与端口: 执行
bind(),把套接字和指定 IP、端口进行绑定,对外暴露访问入口,方便客户端定位服务。 - 循环收发数据: 依靠
recvfrom()阻塞等待客户端发来报文,拿到数据后执行业务逻辑,再通过sendto()把处理后的应答回传给对端。 - 关闭套接字: 服务结束退出时,调用
close()释放套接字资源。
客户端核心流程
- 创建 Socket 文件描述符: 和服务端一致,通过
socket()得到 UDP 套接字。 - 组装服务端地址信息: 初始化
sockaddr_in结构体,填入服务端 IP 与端口,确定报文发送的目标地址。 - 循环完成数据交互: 调用
sendto()把业务数据提交给服务端,随后使用recvfrom()阻塞等待服务端返回的应答报文。 - 关闭套接字: 通信任务完成后,执行
close()释放套接字占用资源。
1.3 核心前置知识点
正式编码之前,需要把下面这些极易引发 bug 的关键知识点理解透彻。
- 服务端必须显式 bind,客户端不需要手动 bind
- 服务端 对外提供服务,端口号需要固定对外暴露 ,客户端才能够正常发起访问,因此必须主动调用
bind()完成端口绑定。 - 客户端 只要求端口本地唯一 ,具体端口数值没有硬性要求。如果客户端手动绑定固定端口,同一主机多实例运行时极易出现端口冲突。操作系统会在客户端第一次执行
sendto()的时候,自动分配一个空闲随机端口,开发者无需手动处理。
- 服务端 对外提供服务,端口号需要固定对外暴露 ,客户端才能够正常发起访问,因此必须主动调用

- INADDR_ANY 的核心作用
- 定义:
INADDR_ANY本质是值等于0.0.0.0的宏 ,作用是绑定本机全部网卡的 IP 地址。 - 优势 1:服务器搭载多块网卡的场景下,不管是内网网卡还是公网网卡,绑定该宏之后,任意网卡收到的 UDP 报文都可以被服务端接收处理。
- 优势 2:云服务器的公网 IP 依靠 NAT 网关映射得到,并不直接挂载在本机网卡上,如果直接绑定公网 IP 会触发报错。
INADDR_ANY是云环境下网络开发的正确选择。
- 定义:
这里我们直接展示一下没有使用INADDR_ANY, 在客户端访问服务端会存在什么问题:
这里展示的代码就是后面我们所要实现的,这里只是展示一下结果,并且这里的代码因为没有使用INADDR_ANY,是需要优化的,只是充当展示效果,后面我们会对代码进行详细的讲解。



- 网络字节序转换
- TCP/IP 协议规定网络传输统一使用大端字节序;而 x86、ARM 这类主流主机架构默认是小端存储。
- 在填充套接字结构体时,16 位端口、32 位 IP 地址都要由主机字节序转为网络字节序;收到对端数据之后,再把网络序还原为主机序。
- 常用转换接口:
htons()用于 16 位端口主机序转网络序;htonl()用于 32 位 IP 主机序转网络序;反向转换使用ntohs()、ntohl()。 - IP 地址格式转换本文会用到
inet_addr()、inet_ntoa(),前者把点分十进制 IP 转为 4 字节整数,后者完成反向转换,后续结合图示理解内部工作逻辑。


1.4 核心系统调用 API 详解
下面梳理 UDP 开发里四个最为核心的系统调用,讲解每个接口的入参含义、返回结果以及开发注意点,后续代码实现会反复用到:
1.socket(创建套接字)
cpp
#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错误码。
2.bind(绑定地址与端口)
cpp
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:socket 调用返回得到的文件描述符;addr:已经填充完毕的地址结构体指针,需要强制转换为通用struct sockaddr*;addrlen:传入地址结构体所占字节大小;- 返回值:成功返回 0;失败返回‑1,并填充
errno记录错误信息。
3.recvfrom(接收 UDP 报文)
cpp
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
sockfd:套接字文件描述符;buf:用于存放收到数据的缓冲区;len:缓冲区可容纳的最大字节数;flags:接收行为标志,普通阻塞读取场景直接填 0;src_addr:输出参数,用来保存对端发送方的地址信息;addrlen:输入输出参数,调用时传入 src_addr 缓冲区大小,函数返回后写入真实地址结构体长度;- 返回值:成功返回实际读到的数据字节数;失败返回‑1 并设置
errno。
4.sendto(发送 UDP 报文)
cpp
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
sockfd:套接字文件描述符;buf:待发送数据的缓冲区;len:本次希望发送的数据字节长度;flags:发送标志位,绝大多数业务场景传入 0;dest_addr:对端目标主机的地址结构体;addrlen:目标地址结构体的字节长度;- 返回值:成功返回实际发送出去的字节数量;失败返回‑1,
errno记录错误原因。
二、前置基础组件:公共工具模块开发
正式开发网络服务之前,工业项目不会把全部业务逻辑直接堆砌在主函数内。本章实现两套底层工具组件,为后续 UDP 服务提供基础支撑:线程安全互斥锁封装、高性能日志模块。这两套组件在之前章节已经完成编码,这里做回顾与要点解析;我们还可以额外拓展不可拷贝基类,用来约束资源管理类对象的拷贝行为。
2.1 互斥锁封装 Mutex.hpp
多线程并发场景中,日志输出、网络数据收发都会涉及共享临界资源访问。我们对 POSIX 原生互斥锁进行面向对象封装,依托 RAII 机制实现锁的自动化管理,规避手动加解锁遗漏引发死锁的各类隐患。
cpp
// 互斥锁的封装
#ifndef MUTEX_HPP
#define MUTEX_HPP
#include <iostream>
#include <pthread.h>
#include <string>
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_mutex, nullptr);
// std::cout << "mutex init success" << std::endl;
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
if (n != 0)
{
std::cerr << "pthread_mutex_lock false" << std::endl;
}
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
if (n != 0)
{
std::cerr << "pthread_mutex_unlock false" << std::endl;
}
}
~Mutex()
{
int n = pthread_mutex_destroy(&_mutex);
if (n != 0)
{
std::cerr << "pthread_mutex_destroy false" << std::endl;
}
else
{
// std::cout << "mutex destroy success" << std::endl;
}
}
pthread_mutex_t *GetMutex()
{
return &_mutex;
}
private:
pthread_mutex_t _mutex;
};
// RAII风格的互斥锁的封装(实现自动解锁)
class LockGuard
{
public:
LockGuard(Mutex &mutex) : _mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex &_mutex;
};
}
#endif
模块核心解析
Mutex类:对 POSIX 互斥锁做封装,统一完成锁的初始化、销毁、上锁、释放接口,提供面向对象调用形式。LockGuard类:RAII 经典实现,对象构造阶段自动获取锁,离开作用域触发析构就自动释放锁。即便函数内部抛出异常,锁依旧可以正常释放,从根源降低死锁发生概率。- 线程安全保障:后续日志模块、多线程网络服务,都会依托这两个封装类,保障临界资源的并发访问安全。
2.2 线程安全日志系统 Log.hpp
cpp
// 日志系统的封装
#ifndef LOG_HPP
#define LOG_HPP
#include <iostream>
#include <unistd.h>
#include "Mutex.hpp"
#include <string>
#include <filesystem> //C++17:提供文件操作
#include <ctime>
#include <fstream>
#include <filesystem>
#include <sstream> //流式格式化控制
using namespace MutexModule;
namespace LogModule
{
// 1. 获取时间
std::string GetTimeStamp()
{
time_t currtime = time(nullptr); // 默认获取当前时区的时间(获取到的是秒级时间戳)
struct tm curr_tm;
localtime_r(&currtime, &curr_tm);
char timebuffer[128];
snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",
curr_tm.tm_year + 1900, // 坑点修复:tm_year 表示的是自 1900 年起经过的年数,必须加上 1900
curr_tm.tm_mon + 1, // 坑点修复:tm_mon 范围是 [0, 11](0代表1月),必须加上 1
curr_tm.tm_mday, // 日([1, 31])
curr_tm.tm_hour, // 时
curr_tm.tm_min, // 分
curr_tm.tm_sec // 秒
);
return timebuffer; // 字符数组会自动隐式转换为 std::string 对象返回
}
// 2. 日志等级 -- 枚举类型(整数)转换成字符串类型
// 使用 enum class (强类型枚举) 而不是普通 enum
// 优势:1. 具有独立的作用域,避免命名冲突;2. 不允许隐式类型转换,更加类型安全
enum class LogLevel
{
DEBUG, // 调试信息:用于开发过程中输出详细状态,帮助定位问题。生产环境通常关闭
INFO, // 常规信息:记录程序的关键运行节点,用于了解系统正常运行的状态
WARNING, // 警告信息:出现了预期之外的情况,但系统仍能继续运行,需要引起关注
ERROR, // 错误信息:发生了运行时错误,导致当前操作失败,但主程序依然存活
FATAL // 致命错误:最高严重级别。出现了无法恢复的问题,程序即将崩溃或被迫退出
};
// 枚举类型(整数)转换成字符串类型
std::string LogLevel_to_String(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. 刷新策略
// 基类: 策略模式 (Strategy Pattern)
// 核心思想:将"日志的产生"与"日志的刷新目的地"解耦。
// 通过定义统一的接口,使得程序可以在运行时动态决定将日志输出到控制台、文件、数据库或网络。
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 // override:检查重写的错误
{
// 【核心原理】显示器(stdout)在多线程环境下属于"临界资源"。
LockGuard lockguard(_mutex); // 加锁
std::cout << message << std::endl;
}
private:
// 互斥锁:专门用于保护当前控制台输出的原子性,防止并发打印时消息错乱
Mutex _mutex;
};
// 定义全局默认文件路径与文件名常量
const static std::string defaultlogdir = "./Log";
const static std::string defaultlogfilename = "log.txt";
// 策略2:文件类日志策略
// 子类:继承自策略基类,实现将日志持久化到磁盘文件的逻辑
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const std::string &logdir = defaultlogdir, const std::string &logfilename = defaultlogfilename)
: _logdir(logdir),
_logfilename(logfilename)
{
LockGuard lockGuard(_mutex);
// 使用 C++17 的 <filesystem> 库进行跨平台路径检查
if (std::filesystem::exists(_logdir))
{
// 如果指定路径已存在则返回值为true,无需再创建直接返回
return;
}
else
{
// 异常捕捉处理
try
{
// 递归创建目录(类似于 Linux 命令 mkdir -p),如果路径中包含多级不存在的目录会一并创建
std::filesystem::create_directories(_logdir);
}
catch (std::filesystem::filesystem_error &e)
{
// 若创建异常(如权限不足、磁盘空间不足等),则进行捕获并输出错误信息
std::cerr << e.what() << std::endl;
}
}
}
// 析构函数:由于不涉及手动管理的堆内存或特殊文件句柄(使用局部变量流管理),故使用默认实现
~FileLogStrategy()
{
}
// 执行具体// 加锁保护:防止多线程同时写入同一文件导致内容交织(Interleaving)乱码 的日志落盘操作
void SyncLog(const std::string &message) override
{
// 加锁保护:进行文件操作刷新文件毋庸置疑是需要原子操作的,
// 防止多线程同时写入同一文件导致内容交织(Interleaving)乱码
LockGuard lockguard(_mutex); // 加锁
// 构造完整的目标文件路径
std::string target = _logdir + (_logdir.back() != '/' ? "/" : "") + _logfilename; // "./Log/" + "log.txt"
// 以"追加模式"(std::ios::app)打开文件流:
// 核心逻辑:保证每条新日志都写在文件末尾,不会覆盖已有日志内容 。
std::ofstream out(target, std::ios::app); // 追加
if (!out.is_open()) // 打开文件检查
{
return; // 如果因权限或路径问题打开失败,则放弃本次写入,防止程序崩溃
}
// 将消息流式写入文件,并手动添加换行符以符合日志排版规范
out << message << "\n"; // 流式写入
// 文件流离开作用域或显式调用 close 会自动触发刷新并关闭文件
out.close();
}
private:
std::string _logdir; // 存储目录路径
std::string _logfilename; // 存储文件名称
Mutex _mutex; // 用于保障当前策略类实例在多线程环境下的线程安全
};
// 4、Logger 类:日志系统的核心统筹管理者
class Logger
{
public:
Logger()
{
// 构造函数:初始化时默认开启控制台刷新策略,后续可对Logger对象调用对应函数进行策略切换
UseConsoleLogStrategy();
}
// 切换策略:动态更换为控制台输出策略
void UseConsoleLogStrategy()
{
_strategy = std::make_unique<ConsoleLogStrategy>();
}
// 切换策略:动态更换为文件输出策略
void UseFileLogStrategy()
{
_strategy = std::make_unique<FileLogStrategy>();
}
~Logger()
{
}
// 内部类LogMessage:代表未来单条日志消息的生命周期管理
class LogMessage
{
public:
// 构造函数:构建日志的"左半部分"(前缀信息)
// 包括时间、等级、PID、文件名和行号,并预置到流中
LogMessage(const LogLevel &loglevel, const std::string &filename, const int &line, Logger &logger)
: _currenttime(GetTimeStamp()),
_loglevel(loglevel),
_pid(getpid()),
_filename(filename),
_line(line),
_logger(logger)
{
std::stringstream ss;
// 像拼积木一样组装固定格式的前缀
ss << "[" << _currenttime << "] "
<< "[" << LogLevel_to_String(_loglevel) << "] "
<< "[" << _pid << "] "
<< "[" << _filename << "] "
<< "[" << _line << "] "
<< "- ";
_loginfo = ss.str(); // ss转成字符串由_loginfo接收
}
// LogMessage(传参) << "hello" << "XXXX" << 3 << 'a'
template <class T>
LogMessage &operator<<(const T &in)
{
std::stringstream ss;
ss << in; // stringstream 可自动处理 int, double, string 等类型转换
_loginfo += ss.str(); // 将内容追加到完整日志字符串中
return *this;
}
~LogMessage()
{
if (_logger._strategy)
{
_logger._strategy->SyncLog(_loginfo);
}
}
private:
std::string _currenttime; // 当前时间
LogLevel _loglevel; // 日志等级
pid_t _pid; // 当前进程ID
std::string _filename; // 文件名
int _line; // 代码行号
std::string _loginfo; // 存储整条待刷新的日志字符串,即上面参数的合并
Logger &_logger;
};
// 仿函数重载:作为"桥梁"连接宏调用与内部消息对象
LogMessage operator()(LogLevel level, const std::string filename, int line)
{
// 创建并返回匿名对象,开启后续的流式 << 操作
return LogMessage(level, filename, line, *this);
}
private:
std::unique_ptr<LogStrategy> _strategy; // 多态策略基类指针:决定日志去向
};
// 定义一个全局模块的Logger对象, 方便后续的使用
Logger logger;
#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";的写法,语句执行结束就自动完成输出,开发者不用手动调用 flush。 - 并发安全设计 :控制台、文件这类共享资源全部由互斥锁保护;时间获取使用可重入函数
localtime_r,解决多线程下日志时间错乱、内容互相覆盖的问题。 - 简洁调用体验:借助宏封装简化调用,自动捕获代码文件名、行号信息,调用习惯和主流开源日志库保持一致。
三、UDP Echo 回显服务实现
掌握了基础工具和核心 API 后,我们先实现 UDP 编程的 Hello World------Echo 回显服务。该服务的核心需求是:客户端发送任意字符串,服务端收到后原封不动回显给客户端,完整覆盖 UDP 服务端 / 客户端的全流程开发。
3.1 v1版本(只回显消息,不处理任务)
3.1.1 服务端实现
我们将服务端封装为UdpEchoServer类,分为头文件声明和主函数实现两部分,完全基于前面的工具类开发。
服务端头文件 UdpEchoServer.hpp(后续会加上处理任务优化)
cpp
#ifndef UDPSERVER_HPP
#define UDPSERVER_HPP
#include <iostream>
#include <cstring>
#include "Log.hpp"
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// 这俩头文件用于struct sockaddr_in结构体的创建
// 把"端口号"从主机序转成网络序:htons()
// 把"IP字符串"转换成"32位网络整数":inet_addr()
using namespace LogModule;
const int defaultfd = -1;
// 当我们显式在服务器端手动进行地址bind时,虽然可行,但是会出现以下问题:
// (1)服务器端bind内网ip或者本地环回(127.0.0.1)是可以成功的,但是公网ip无法bind
// (2)当服务器端bind内网ip但是客户端bind本地环回时,无法访问通信,反之亦然
// 我们要知道:本地环回只能被本机访问;而内网ip也只是私有地址,仅在局域网内有效
// 也就是说如果我们手动进行地址bind,就会导致对于远端客户端无法访问服务器端,那我们如何进行跨网络访问呢?
// 所以,对于服务器端我们就不建议手动bind特定的ip地址,因为这样首先我们所有的客户端都只能通过这个ip来访问服务端
// 假设一个服务端内置不只一个网卡呢是不是就不只一个ip地址了,这样就会受到限制。
// 但主要问题依然是无法进行跨网络访问
// 所以我们可以借助一个宏(INADDR_ANY:不限制特定的IP地址)
// INADDR_ANY 的作用:监听本机所有可用的IP地址(也就包括且不限于公网ip了),服务器会接受发送到本机任一有效IP的数据包,只要端口号匹配
// 这样客户端不管在哪,只要知道服务器端的外网ip和端口号,也就可以进行跨网络通信了
// UdpServer v2版本(跨网络通信)
class UdpServer
{
public:
UdpServer(uint16_t port) // 传入端口号,不需要传入指定ip地址
: _socketfd(defaultfd),
_port(port),
_isrunning(false)
{
}
void Init()
{
// 1、创建套接字
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socketfd < 0)
{
LOG(LogLevel::FATAL) << "socket created error";
exit(1);
}
LOG(LogLevel::INFO) << "socket created success, sockedfd: " << _socketfd;
// 2、绑定socket信息:ip地址和端口号(ip比较特殊,后续解释)
// 2.1填充sockaddr_in结构体
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 把"端口号"从主机序转成网络序
// local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_addr.s_addr = INADDR_ANY;
// INADDR_ANY 的作用:监听本机所有可用的IP地址(也就包括且不限于公网ip了),
// 服务器会接受发送到本机任一有效IP的数据包,只要端口号匹配
// 此时我们对网络文件已经绑定了IP地址和端口号了吗?没有!
// 这里和前面我们学习信号部分讲解信号保存的block表类似
// 上面所有的操作都是在Init这个函数内部中创建的变量进行修改,而这个变量是在栈上创建的
// 基于我们现在学习的知识来讲就是在虚拟地址空间的0到3GB其中的用户栈上开辟的,完全没有涉及到内核
// 所以,我们就需要调用一个系统调用陷入内核将我们所修改的数据传到内核中:bind()
int n = bind(_socketfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(2);
}
LOG(LogLevel::INFO) << "bind success, sockedfd: " << _socketfd;
}
void Start()
{
_isrunning = true;
while (_isrunning) // 死循环运行服务端
{
char buffer[1024]; // 信息缓冲区
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 1、接收客户端发来的信息
// ssize_t recvfrom(int sockfd, void buf[len], size_t len, int flags, struct sockaddr * src_addr, socklen_t *addrlen);
// struct sockaddr * src_addr:输出型参数,获取对应客户端的IP地址和端口号
// socklen_t *addrlen:输入输出型参数,所以要传入一个变量地址用于接收
// recvfrom返回值为实际获取到的字节数
ssize_t s = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
buffer[s] = 0;
// LOG(LogLevel::DEBUG) << "buffer: " << buffer; // 1、消息内容 2、谁发的?
// 此时我们已经实现了跨网络进行访问通信,那是不是我们就需要知道是哪个客户端在向我们服务端发送信息?
int peer_port = ntohs(peer.sin_port); // port从网络拿到的!网络序列 ------> 本地格式
std::string peer_ip = inet_ntoa(peer.sin_addr);
// ip从网络拿到的!4字节网络风格的IP ------> 点分十进制的字符串风格的IP
LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" << peer_port << "]# " << buffer;
// 2、接收到客户端消息进行回复(发送消息)
// ssize_t sendto(int sockfd, const void buf[len], size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
// const struct sockaddr *dest_addr:输入型参数,传入对应客户端的IP地址和端口号
// socklen_t addrlen::输入型参数
std::string echo_string = "server echo@ ";
echo_string += buffer;
sendto(_socketfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len);
}
}
}
~UdpServer()
{
}
private:
int _socketfd; // socket返回的文件描述符
uint16_t _port; // 端口号
// std::string _ip; // 不需要手动传入指定的ip地址
bool _isrunning; // 服务端不强制退出需要24*7小时一直运行->死循环
};
#endif
服务端主函数 UdpEchoServer.cc
cpp
#include "UdpServer.hpp"
#include <iostream>
#include <cstdio>
#include <memory>
// 跨网络通信
// ./udpserver port
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
ENABLE_CONSOLE_LOG_STRATEGY();
uint16_t port = std::stoi(argv[1]); // 字符串转成uint16_t,获取客户传入的端口号
// std::string ip = argv[1]; // 获取客户传入的IP地址
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port);
usvr->Init();
usvr->Start();
return 0;
}

3.1.1 客户端实现
客户端无需封装,直接实现主流程即可,核心逻辑是从标准输入读取用户输入,发送给服务端,再打印服务端的回显响应。
后续的优化也只是针对服务端的,所以后面就不再写一遍了。
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>
// ./udpclient ip port
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " ip port" << std::endl;
return 1;
}
// 1、创建socket套接字
int socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (socketfd < 0)
{
std::cerr << "socket created error" << std::endl;
return 2;
}
// 2、本地的ip地址和端口是什么?要不要和上面的"文件"相关联呢?
// 问题:client要不要bind?需要bind!
// client要不要显式bind?不要也不能!首次发送消息时,OS会自动给client进行bind,
// OS知道IP地址,端口号是采用随机端口号的方式生成
// 为什么这样?如果显式bind你怎么知道你传入的端口号其他客户端都不是这个,
// 前面讲了一个端口号必须只能由一个进程bind,不能bind多个进程!你要一个一个查吗?!
// 显然不现实,所以为了避免客户端的端口号冲突,就不允许客户端显式bind,而是由OS来随机分配空闲端口号
// 所以client端的端口号是多少,不重要,只要具有唯一性即可!
// 填写服务器信息
struct sockaddr_in server;
// bzero(&server, sizeof(server));
memset(&server, 0, sizeof(server)); // 也可以
server.sin_family = AF_INET;
server.sin_port = htons(std::stoi(argv[2]));
server.sin_addr.s_addr = inet_addr(argv[1]);
while (true)
{
// 1、发送消息给服务器端
std::string input;
std::cout << "Please Enter# ";
std::getline(std::cin, input); // 阻塞式发送消息
ssize_t n = sendto(socketfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));
(void)n;
// 2、接收服务器端回复的消息
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
buffer[s] = 0;
std::cout << buffer << std::endl;
}
if (!strcmp(input.c_str(), "quit"))
{
std::string result = "Client端退出运行";
std::cout << result << std::endl;
break;
}
}
return 0;
}
v1版本代码运行测试

3.2 v2版本(服务端对客户发送的消息进行处理再回显:回调机制)
3.2.1 服务端优化
服务端头文件 UdpEchoServer.hpp
cpp
#ifndef UDPSERVER_HPP
#define UDPSERVER_HPP
#include <iostream>
#include <cstring>
#include "Log.hpp"
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// 这俩头文件用于struct sockaddr_in结构体的创建
// 把"端口号"从主机序转成网络序:htons()
// 把"IP字符串"转换成"32位网络整数":inet_addr()
using namespace LogModule;
const int defaultfd = -1;
#include <functional>
// UdpServer v3版本(跨网络通信+处理信息)
using func_t = std::function<std::string(const std::string &)>;
class UdpServer
{
public:
UdpServer(uint16_t port, func_t func) // 传入端口号和信息处理函数
: _socketfd(defaultfd),
_port(port),
_func(func),
_isrunning(false)
{
}
void Init()
{
// 1、创建套接字
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socketfd < 0)
{
LOG(LogLevel::FATAL) << "socket created error";
exit(1);
}
LOG(LogLevel::INFO) << "socket created success, sockedfd: " << _socketfd;
// 2、绑定socket信息:ip地址和端口号(ip比较特殊,后续解释)
// 2.1填充sockaddr_in结构体
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 把"端口号"从主机序转成网络序
// local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_addr.s_addr = INADDR_ANY;
// INADDR_ANY 的作用:监听本机所有可用的IP地址(也就包括且不限于公网ip了),
// 服务器会接受发送到本机任一有效IP的数据包,只要端口号匹配
int n = bind(_socketfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(2);
}
LOG(LogLevel::INFO) << "bind success, sockedfd: " << _socketfd;
}
void Start()
{
_isrunning = true;
while (_isrunning) // 死循环运行服务端
{
char buffer[1024]; // 信息缓冲区
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 1、接收客户端发来的信息
ssize_t s = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
buffer[s] = 0;
int peer_port = ntohs(peer.sin_port); // port从网络拿到的!网络序列 ------> 本地格式
std::string peer_ip = inet_ntoa(peer.sin_addr);
// ip从网络拿到的!4字节网络风格的IP ------> 点分十进制的字符串风格的IP
LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" << peer_port << "]# " << buffer;
// // 2、接收到客户端消息进行回复(发送消息)
// std::string echo_string = "server echo@ ";
// echo_string += buffer;
// sendto(_socketfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len);
// 2、接收到客户端消息进行处理返回(发送消息)
std::string result = _func(buffer); // 进行回调函数处理
sendto(_socketfd, result.c_str(), result.size(), 0, (struct sockaddr *)&peer, len);
}
}
}
~UdpServer()
{
}
private:
int _socketfd; // socket返回的文件描述符
uint16_t _port; // 端口号
// std::string _ip; // 不需要手动传入指定的ip地址
bool _isrunning; // 服务端不强制退出需要24*7小时一直运行->死循环
func_t _func; // 服务器端的回调函数,用于对接收的消息进行处理
};
#endif
服务端主函数 UdpEchoServer.cc
cpp
#include "UdpServer.hpp"
#include <iostream>
#include <cstdio>
#include <memory>
// 跨网络通信+信息处理
// 处理函数(仅用于测试处理逻辑)
std::string default_handler(const std::string &message)
{
std::string result = message;
result += " 该信息也被处理...";
return result;
}
// 计算处理函数
std::string calculate_handler(const std::string &request)
{
// 解析请求格式:操作数1 运算符 操作数2
// 例如:"3 + 5" -> 3 + 5 = 8
std::stringstream ss(request);
double num1, num2;
char op;
ss >> num1 >> op >> num2;
// 检查解析是否成功
if (ss.fail())
{
return "Error: Invalid format! Please use: number operator number";
}
// 执行计算
double result = 0;
bool valid = true;
switch (op)
{
case '+':
result = num1 + num2;
break;
case '-':
result = num1 - num2;
break;
case '*':
result = num1 * num2;
break;
case '/':
if (num2 == 0)
{
return "Error: Division by zero!";
}
result = num1 / num2;
break;
default:
return "Error: Unsupported operator! Use +, -, *, /";
}
// 返回计算结果(保留两位小数)
char response[64];
snprintf(response, sizeof(response), "%.2f %c %.2f = %.2f", num1, op, num2, result);
return std::string(response);
}
// ./udpserver port
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
ENABLE_CONSOLE_LOG_STRATEGY();
uint16_t port = std::stoi(argv[1]); // 字符串转成uint16_t,获取客户传入的端口号
// std::string ip = argv[1]; // 获取客户传入的IP地址
// std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, default_handler);
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, calculate_handler);
usvr->Init();
usvr->Start();
return 0;
}
v2版本代码运行测试

3.3 核心细节解析
- 缓冲区末尾补 0 :
recvfrom返回的是实际接收的字节数,我们手动在inbuffer[n] = 0,是为了把缓冲区的内容当做 C 风格字符串处理,避免打印时出现乱码; - 异常处理策略:创建 socket、绑定端口属于致命错误,直接退出进程;而单次收发数据失败,仅打印警告日志,继续处理后续请求,保证服务的可用性;
inet_ntoa的使用:该函数将网络序的 4 字节 IP 地址转换为点分十进制字符串,注意该函数返回的是静态缓冲区地址,不是线程安全的,多线程环境下推荐使用inet_ntop函数。
四、UDP 在线英译汉字典服务实现
我们会发现上面实现的Echo回显服务,网络收发逻辑和业务处理代码深度耦合。实际工业级开发中,必须实现网络通信与业务逻辑的解耦 。接下来我们实现 UDP 在线英译汉字典服务,通过回调函数将业务逻辑与网络通信分离;同时实现词典文件读取解析,对外提供单词翻译能力。
4.1 需求分析
- 服务端启动时,加载本地
Dict.txt词典文件,将英文单词与对应的中文翻译、例句存入哈希表; - 客户端发送英文单词,服务端收到后查询词典,返回对应的中文翻译与例句;
- 单词不存在时,返回「未知」提示;
- 网络通信层与翻译业务层完全解耦,服务端可通过更换回调函数,快速适配其他业务场景。
4.2 词典文件 Dict.txt
词典文件采用 单词: 翻译 的格式,示例如下(大家还可以自己去扩展一些):
bash
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
happy: 快乐的
sad: 悲伤的
run: 跑
jump: 跳
teacher: 老师
student: 学生
car: 汽车
bus: 公交车
love: 爱
hate: 恨
hello: 你好
goodbye: 再见
summer: 夏天
winter: 冬天
4.3 字典客户端实现 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>
// ./udpclient ip port
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " ip port" << std::endl;
return 1;
}
// 1、创建socket套接字
int socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (socketfd < 0)
{
std::cerr << "socket created error" << std::endl;
return 2;
}
struct sockaddr_in server;
// bzero(&server, sizeof(server));
memset(&server, 0, sizeof(server)); // 也可以
server.sin_family = AF_INET;
server.sin_port = htons(std::stoi(argv[2]));
server.sin_addr.s_addr = inet_addr(argv[1]);
while (true)
{
// 1、发送消息给服务器端
std::string input;
std::cout << "Please Enter# ";
std::getline(std::cin, input); // 阻塞式发送消息
if (input == "quit")
{
LOG(LogLevel::DEBUG) << "Client端退出运行";
break;
}
ssize_t n = sendto(socketfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));
(void)n;
// 2、接收服务器端回复的消息
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
buffer[s] = 0;
std::cout << buffer << std::endl;
}
}
return 0;
}
4.4 v1版本(无法查看哪个客户端在使用翻译模块)
4.4.1 UDP 服务端头文件实现 UdpServer.hpp
cpp
#ifndef UDPSERVER_HPP
#define UDPSERVER_HPP
#include <iostream>
#include <cstring>
#include "Log.hpp"
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// 这俩头文件用于struct sockaddr_in结构体的创建
// 把"端口号"从主机序转成网络序:htons()
// 把"IP字符串"转换成"32位网络整数":inet_addr()
#include <functional>
#include "InetAddr.hpp"
using namespace LogModule;
const int defaultfd = -1;
using func_t = std::function<std::string(const std::string &)>;
class UdpServer
{
public:
UdpServer(uint16_t port, func_t func) // 传入端口号和信息处理函数
: _socketfd(defaultfd),
_port(port),
_func(func),
_isrunning(false)
{
}
void Init()
{
// 1、创建套接字
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_socketfd < 0)
{
LOG(LogLevel::FATAL) << "socket created error";
exit(1);
}
LOG(LogLevel::INFO) << "socket created success, sockedfd: " << _socketfd;
// 2、绑定socket信息:ip地址和端口号(ip比较特殊,后续解释)
// 2.1填充sockaddr_in结构体
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 把"端口号"从主机序转成网络序
// local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_addr.s_addr = INADDR_ANY;
// INADDR_ANY 的作用:监听本机所有可用的IP地址(也就包括且不限于公网ip了),
// 服务器会接受发送到本机任一有效IP的数据包,只要端口号匹配
int n = bind(_socketfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(2);
}
LOG(LogLevel::INFO) << "bind success, sockedfd: " << _socketfd;
}
void Start()
{
_isrunning = true;
while (_isrunning) // 死循环运行服务端
{
char buffer[1024]; // 信息缓冲区
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 1、接收客户端发来的信息
ssize_t s = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
buffer[s] = 0;
int peer_port = ntohs(peer.sin_port); // port从网络拿到的!网络序列 ------> 本地格式
std::string peer_ip = inet_ntoa(peer.sin_addr);
// ip从网络拿到的!4字节网络风格的IP ------> 点分十进制的字符串风格的IP
LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" << peer_port << "]# " << buffer;
// 2、接收到客户端消息进行处理返回(发送消息)
std::string result = _func(buffer); // 进行回调函数处理
sendto(_socketfd, result.c_str(), result.size(), 0, (struct sockaddr *)&peer, len);
}
}
}
~UdpServer()
{
}
private:
int _socketfd; // socket返回的文件描述符
uint16_t _port; // 端口号
// std::string _ip; // 不需要手动传入指定的ip地址
bool _isrunning; // 服务端不强制退出需要24*7小时一直运行->死循环
func_t _func; // 服务器端的回调函数,用于对接收的消息进行处理
};
#endif
4.4.2 字典类实现 Dictionary.hpp
cpp
#ifndef DICT_HPP
#define DICT_HPP
#include <iostream>
#include <unordered_map> //用于字典中英文映射
#include "Log.hpp"
#include <string>
#include <fstream> //文件操作
const std::string default_path = "./Dictionary.txt";
const std::string sep = ": "; // 定义分隔符,用于分隔中英文
using namespace LogModule;
// v1版本(无客户端ip端口号显示谁进入翻译模块)
class Dict
{
public:
Dict(const std::string &path = default_path) : _dict_path(path)
{
}
// 加载字典:打开文件获取每行字符串通过分隔符分隔中英文放入哈希表_dict
bool LoadDict()
{
std::fstream in(_dict_path);
if (!in.is_open())
{
LOG(LogLevel::DEBUG) << "打开字典" << _dict_path << "错误";
return false;
}
// 文件打开成功
// 循环获取每行的字符串
std::string line;
while (std::getline(in, line)) // 换行符作为分隔每次获取一行字符串
{
//"apple: 苹果"
// 通过分隔符sep将中英文进行分隔
auto pos = line.find(sep);
if (pos == std::string::npos)
{
// 若pos=npos说明没有找到分隔符": ",格式存在问题
LOG(LogLevel::WARNING) << "解析:" << line << "失败";
continue; // 继续往后获取
}
std::string english = line.substr(0, pos); // substr截断字符串范围:[0, pos)
std::string chinese = line.substr(pos + sep.size());
if (english.empty() || chinese.empty())
{
// 说明当前一行翻译存在缺漏
LOG(LogLevel::WARNING) << "没有有效内容:" << line;
continue;
}
// 将中英文映射到哈希表中
_dict.insert(std::make_pair(english, chinese));
LOG(LogLevel::DEBUG) << "加载:" << line;
}
// 最后关闭输入流
in.close();
return true;
}
// 获取翻译结果函数
std::string Translate(const std::string word)
{
LOG(LogLevel::DEBUG) << "进入到翻译模块";
auto iter = _dict.find(word);
if (iter == _dict.end())
{
// 查询的单词在字典中不存在
return "None";
}
return iter->second;
}
~Dict()
{
}
private:
std::string _dict_path; // 路径+文件名
std::unordered_map<std::string, std::string> _dict; // 使用哈希表映射文件中的中英文
};
#endif
代码解析:
- 采用
unordered_map存储单词与释义,查询时间复杂度 O (1),性能极高; - 加载文件时做了完善的异常处理,格式错误的行仅打印警告,不影响整体加载;
- 完全独立于网络逻辑,可在任何 C++ 项目中单独使用,符合单一职责原则。
4.4.3 服务端主函数 UdpServer.cpp
cpp
#include "UdpServer.hpp" //网络通信的功能
#include <iostream>
#include <cstdio>
#include <memory>
#include "Dict.hpp" //字典翻译的功能
// 需求
// 1.翻译系统,字符串当成英文单词,把英文单词翻译成为汉语
// 2.基于文件操作
// UdpServer 字典翻译 v1版本(无客户端ip端口号显示谁进入翻译模块)
// ./udpserver port
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
// 创建字典对象提供翻译功能
Dict dict;
// 加载字典
dict.LoadDict();
ENABLE_CONSOLE_LOG_STRATEGY();
uint16_t port = std::stoi(argv[1]); // 字符串转成uint16_t,获取客户传入的端口号
// std::string ip = argv[1]; // 获取客户传入的IP地址
// 传入lambda表达式作为回调函数
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&dict]
(const std::string &word){
return dict.Translate(word);
});
usvr->Init();
usvr->Start();
return 0;
}
v1版本代码运行测试


4.5 v2版本(优化:获取客户端的ip地址和端口号)

4.5.1 引入通用模块InetAddr(网络地址和主机地址之间进行转换)
cpp
#ifndef INETADDR_HPP
#define INETADDR_HPP
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// 网络地址和主机地址之间进行转换的类
class InetAddr
{
public:
// 构造函数的函数重载:
// 网络转本地:主要用于接收来自网络客户端消息后,解析获取本地ip地址和端口号
InetAddr(struct sockaddr_in &addr) : _addr(addr)
{
// 网络------>本地
_port = ntohs(_addr.sin_port);
_ip = inet_ntoa(_addr.sin_addr);
}
// 本地转网络:主要用于客户端已知服务端ip地址和端口号,填充获取struct sockaddr_in _addr,向服务端发送消息
InetAddr(uint16_t port, const std::string &ip) : _port(port), _ip(ip)
{
// 填充struct sockaddr_in _addr
_addr.sin_family = AF_INET;
// htons: Host to Network Short,将本地主机字节序转为网络字节序
_addr.sin_port = htons(port);
// inet_addr: 将字符串 IP 转换为 32 位网络字节序的数值
_addr.sin_addr.s_addr = inet_addr(ip.c_str());
}
// 获取主机字节序的端口号
uint16_t Port()
{
return _port;
}
// 获取点分十进制字符串 IP
std::string Ip()
{
return _ip;
}
// 获取指向底层 sockaddr 结构的指针,用于 sendto 等系统调用
struct sockaddr *Addr()
{
return (struct sockaddr *)&_addr;
}
// 获取底层结构体的大小,用于套接字系统调用时的长度参数
socklen_t AddrLen()
{
return sizeof(_addr);
}
// 将地址信息转化为易读的字符串格式,如 [127.0.0.1:8080]
// 常用于打印日志信息
std::string StringAddress()
{
return "[" + _ip + ":" + std::to_string(_port) + "]";
}
~InetAddr()
{
}
private:
uint16_t _port; // 客户端的端口号
std::string _ip; // 客户端的IP地址
struct sockaddr_in _addr; // 客户端的struct sockaddr_in
};
#endif
4.5.2 优化:UDP 服务端头文件 UdpServer.hpp
UDP 服务端头文件只需要优化 Start 函数即可,将 InetAddr 对象构建出来传入已知的 struct sockaddr_in peer 就可以获取到对应客户端的ip地址和端口号,在传入给回调参数进行函数回调,我们就能知道是哪个客户端在进行翻译模块了.
cpp
using func_t = std::function<std::string(const std::string &, InetAddr &)>;
// 优化:将客户端的struct sockaddr_in传到类InetAddr对象中,便于查看哪个客户端进入翻译模块
void Start()
{
_isrunning = true;
while (_isrunning) // 死循环运行服务端
{
char buffer[1024]; // 信息缓冲区
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 1、接收客户端发来的信息
ssize_t s = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
buffer[s] = 0;
// int peer_port = ntohs(peer.sin_port); // port从网络拿到的!网络序列 ------> 本地格式
// std::string peer_ip = inet_ntoa(peer.sin_addr);
// ip从网络拿到的!4字节网络风格的IP ------> 点分十进制的字符串风格的IP
InetAddr client(peer); // 传入客户端struct sockaddr_in,提取端口号和IP地址
// LOG(LogLevel::DEBUG) << "[" << client.Ip() << ":" << client.Port() << "]# " << buffer;
LOG(LogLevel::DEBUG) << client.StringAddress() << "# " << buffer;
// 2、接收到客户端消息进行处理返回(发送消息)
std::string result = _func(buffer, client); // 进行回调函数处理
// 回调函数传入InetAddr类对象,方便我们查看是哪个客户端进入翻译模块
sendto(_socketfd, result.c_str(), result.size(), 0, (struct sockaddr *)&peer, len);
}
}
}
4.5.3 优化:字典类 Dictionary.hpp
该类负责词典文件的加载、解析、单词查询,完全独立于网络通信,可单独复用。
字典类 Dictionary.hpp 也只需要优化 Translate 翻译函数即可:
cpp
// 获取翻译结果函数(传入类InetAddr对象,提取客户端的端口号和IP地址)
std::string Translate(const std::string word, InetAddr &client)
{
auto iter = _dict.find(word);
if (iter == _dict.end())
{
// 查询的单词在字典中不存在
// LOG(LogLevel::DEBUG) << "[" << client.Ip() << ":" << client.Port() << "]" << "进入到翻译模块: " << word << "->" << "None";
LOG(LogLevel::DEBUG) << client.StringAddress() << "进入到翻译模块: " << word << "->" << "None";
return "None";
}
// 日志打印是哪个客户端在使用翻译模块
// LOG(LogLevel::DEBUG) << "[" << client.Ip() << ":" << client.Port() << "]" << "进入到翻译模块: " << word << "->" << iter->second;
LOG(LogLevel::DEBUG) << client.StringAddress() << "进入到翻译模块: " << word << "->" << iter->second;
return iter->second;
}
4.5.4 优化:服务端主函数 UdpServer.cpp
整合字典类和通用 UDP 服务端,通过 lambda 表达式将翻译业务注入服务端,代码极其简洁优雅。
cpp
#include "UdpServer.hpp" //网络通信的功能
#include <iostream>
#include <cstdio>
#include <memory>
#include "Dict.hpp" //字典翻译的功能
// 需求
// 1.翻译系统,字符串当成英文单词,把英文单词翻译成为汉语
// 2.基于文件操作
#include "InetAddr.hpp"
// UdpServer 字典翻译 v2版本(显示哪个客户端进入翻译模块)
// ./udpserver port
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
// 创建字典对象提供翻译功能
Dict dict;
// 加载字典
dict.LoadDict();
ENABLE_CONSOLE_LOG_STRATEGY();
uint16_t port = std::stoi(argv[1]); // 字符串转成uint16_t,获取客户传入的端口号
// std::string ip = argv[1]; // 获取客户传入的IP地址
// 传入lambda表达式作为回调函数
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&dict]
(const std::string &word, InetAddr &client){
return dict.Translate(word, client);
});
usvr->Init();
usvr->Start();
return 0;
}
v2版本代码运行测试

五、进阶:通用 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,任意网卡收到的数据包都可以被 socket 接收。云服务器公网 IP 依靠 NAT 网关映射,并不是本机网卡上真实配置的 IP,直接绑定公网 IP 会报地址不可用错误。
-
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、端口信息显示乱值
原因:没有把网络字节序转回主机字节序,直接打印原始网络序数值。
解决:端口调用 ntohs ();IP 地址调用 inet_ntoa () /inet_ntop () 转换后输出。
- recvfrom 读取出来的字符串乱码
原因:接收缓冲区没有手动补
\0,当作 C 字符串打印会读取缓冲区后面的脏内存。解决:recvfrom 拿到实际接收字节 n,执行
buffer[n] = 0手动补字符串结束符。
- UDP 报文丢失
原因 1:UDP 本身不可靠,网络拥塞就会丢包。
原因 2:socket 内核接收缓冲区打满,内核直接丢弃新来报文。
解决:setsockopt 调大 socket 接收缓冲区;上层业务实现应答确认、超时重传机制,补足可靠性。
结束语
本文从 UDP 协议的核心特性出发,从零实现了 V1 版本回显服务、V2 版本在线字典服务,再到通用型 UDP 服务端 / 客户端封装,逐行拆解了代码实现与设计思想,同时总结了面试高频考点与开发踩坑指南。
UDP 虽然不像 TCP 那样拥有复杂的连接管理与可靠传输机制,但它的无连接、低延迟、面向报文的特性,使其在 DNS、直播、游戏等场景中不可替代。掌握 UDP 原生编程到通用封装的完整链路,才能在实际项目中根据业务特点灵活选型,而不是只会调用现成框架。后续我们还会深入讲解 UDP 的可靠性传输设计、并发模型、超时重传、流量控制等进阶内容,带你彻底掌握 Linux UDP 网络编程。