UDP socket 编程、sockaddr_in 与网络字节序、INADDR_ANY、pthread 互斥锁与 RAII、策略模式、std::function 回调、流式日志的实现原理。
1. 项目里有什么
包含 6 个源文件:
|
Mutex.hpp |
对 pthread 互斥锁的 C++ 封装,外加 RAII 锁守卫 LockGuard |
|
Log.hpp |
日志模块:日志等级、时间戳、流式输出、屏幕/文件两种刷新策略 |
|
UdpServer.hpp |
UDP 服务器主体:创建套接字、绑定、收发循环、调用回调处理业务 |
|
UdpServer.cc |
服务器 main:解析命令行端口号、注入业务回调函数、启动服务器 |
|
UdpClient.cc |
UDP 客户端:从键盘读消息发给服务器,阻塞等待并打印服务器的回复 |
|
Makefile |
一条命令编译两个可执行程序 |
最终程序的结果:
-
服务器:./udpserver 8080,监听本机所有网卡的 8080 端口,收到消息后交给一个可替换的回调函数处理,再把处理结果发回客户端。
-
客户端:./udpclient 127.0.0.1 8080,键盘输入一行就发一行,收到回复后打印。
-
当天的测试回调给每条消息加上 hello, 前缀,例如发 world 收到 hello, world。
2. 第一部分:互斥锁模块 Mutex.hpp
这是整个项目最底层的工具,日志模块依赖它保证多线程下输出不交叉。完整代码:
#pragma once
#include <iostream>
#include <pthread.h>
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
~Mutex()
{
pthread_mutex_destroy(&_mutex);
}
private:
pthread_mutex_t _mutex;
};
class LockGuard
{
public:
LockGuard(Mutex &mutex) : _mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex &_mutex;
};
}
2.1 为什么需要它
设想两个线程同时打日志,如果没有锁,两条日志的字符可能交错在一起,输出乱成一团。因此每条日志的"写出动作"必须是互斥的:同一时刻只允许一个线程写。
2.2 逐段解释
Mutex 类:对 POSIX 线程库四个原生 API 的面向对象包装:
-
构造函数调 pthread_mutex_init 初始化锁;
-
Lock() 调 pthread_mutex_lock 加锁(拿不到锁就阻塞);
-
Unlock() 调 pthread_mutex_unlock 解锁;
-
析构函数调 pthread_mutex_destroy 销毁锁,避免资源泄漏。
(void)n; 的作用是"显式忽略返回值",防止编译器因变量未使用而告警。
LockGuard 类:这是典型的 RAII(资源获取即初始化) 手法:
-
创建对象时自动加锁;
-
离开作用域时,对象析构自动解锁。
这样即使中间代码抛异常或提前 return,锁也一定会被释放,从根上杜绝"加了锁忘记解"的问题。使用时只有一行:
{
LockGuard lockguard(_mutex); // 进入作用域,加锁
// … 临界区代码 …
} // 离开作用域,自动解锁
注意 LockGuard 的成员是 Mutex &_mutex——引用类型,它不拥有锁,只是绑定外部那把锁,所以多个守卫可以管理同一把锁。
3. 第二部分:日志模块 Log.hpp(策略模式 + 流式输出)
这是最核心、踩坑最多的文件。它要实现两个能力:
一条日志包含时间、等级、进程 PID、源文件名、行号和正文;
日志可以选择打印到屏幕,也可以写入文件,未来还能扩展其他方式。
3.1 策略模式:抽象基类与两个具体策略
namespace LogModule
{
const std::string gsep = "\\r\\n";
class LogStrategy
{
public:
~LogStrategy() = default;
virtual void SyncLog(const std::string &message) = 0;
};
LogStrategy 是抽象策略:一个纯虚函数 SyncLog,含义是"把一条完整日志同步刷出去"。基类虚析构函数用 = default,保证通过基类指针销毁子类对象时能正确调用子类析构。
具体策略一:打印到屏幕
class ConsoleLogStrategy : public LogStrategy
{
public:
ConsoleLogStrategy()
{
}
void SyncLog(const std::string &message) override
{
LockGuard lockguard(_mutex);
std::cout << message << std::endl; // 真正输出到显示器
}
~ConsoleLogStrategy()
{
}
private:
Mutex _mutex;
};
具体策略二:写入文件
const std::string defaultpath = "./log";
const std::string defaultfile = "my.log";
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const std::string &path = defaultpath, const std::string &file = defaultfile)
: _path(path),
_file(file)
{
LockGuard lockguard(_mutex);
if (std::filesystem::exists(_path))
{
return;
}
try
{
std::filesystem::create_directories(_path);
}
catch (const std::filesystem::filesystem_error &e)
{
std::cerr << e.what() << '\\n';
}
}
void SyncLog(const std::string &message) override
{
LockGuard lockguard(_mutex);
std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file;
std::ofstream out(filename, std::ios::app);
if (!out.is_open())
{
return;
}
out << message << gsep;
out.close();
}
~FileLogStrategy()
{
}
private:
std::string _path;
std::string _file;
Mutex _mutex;
};
这里主要是记住以下几点:
-
默认日志路径 ./log、文件名 my.log,都可以通过构造参数覆盖;
-
构造时用 C++17 的 std::filesystem 判断目录是否存在,不存在就 create_directories 递归创建,并用 try/catch 捕获文件系统异常(比如权限不足);
-
写文件用 std::ios::app 追加模式,每次运行不会覆盖旧日志;
-
拼文件名时用三元表达式处理路径末尾有没有 /,最终拼成 ./log/my.log;
-
文件里换行不用 std::endl,而用全局常量 gsep = "\\r\\n"(CRLF)。
3.2 日志等级与时间戳
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
enum class 是 C++11 的强类型枚举,不会和整数隐式转换,使用时必须写 LogLevel::INFO。
std::string Level2Str(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";
}
}
枚举值无法直接输出,用 switch 转成字符串。
std::string GetTimeStamp()
{
time_t curr = time(nullptr);
struct tm curr_tm;
localtime_r(&curr, &curr_tm);
char timebuffer[128];
snprintf(timebuffer, sizeof(timebuffer),"%4d-%02d-%02d %02d:%02d:%02d",
curr_tm.tm_year+1900,
curr_tm.tm_mon+1,
curr_tm.tm_mday,
curr_tm.tm_hour,
curr_tm.tm_min,
curr_tm.tm_sec
);
return timebuffer;
}
主要记住:
-
time(nullptr) 拿到从 1970 年至今的秒数;
-
localtime_r 是 localtime 的线程安全版本(带 _r 后缀,结果写入调用者提供的 curr_tm),多线程程序必须用这个;
-
tm_year 存的是"距 1900 年的年数",所以要 +1900;tm_mon 从 0 开始,所以要 +1;
-
%02d 表示至少两位、不足补零,得到 2026-09-13 12:56:46 这样的定长时间串。
3.3 Logger 类与流式日志的核心技巧
class Logger
{
public:
Logger()
{
EnableConsoleLogStrategy();
}
void EnableFileLogStrategy()
{
_fflush_strategy = std::make_unique<FileLogStrategy>();
}
void EnableConsoleLogStrategy()
{
_fflush_strategy = std::make_unique<ConsoleLogStrategy>();
}
Logger 持有一个 std::unique_ptr<LogStrategy>,这就是策略模式的"持有一个可替换的策略"。想把日志从屏幕切到文件,只需让指针指向另一个具体策略对象。构造时默认使用屏幕策略。
内部类 LogMessage:一条"尚未写完"的日志
class LogMessage
{
public:
LogMessage(LogLevel &level, std::string &src_name, int line_number, Logger &logger)
: _curr_time(GetTimeStamp()),
_level(level),
_pid(getpid()),
_src_name(src_name),
_line_number(line_number),
_logger(logger)
{
std::stringstream ss;
ss << "[" << _curr_time << "] "
<< "[" << Level2Str(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _src_name << "] "
<< "[" << _line_number << "] "
<< "- ";
_loginfo = ss.str();
}
构造函数负责拼日志的左半部分(固定前缀):
[2026-09-13 12:56:46] [INFO] [771141] [UdpServer.hpp] [40] –
依次是时间、等级、进程 PID(getpid())、源文件名、行号。
template <typename T>
LogMessage &operator<<(const T &info)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this;
}
模板版 operator<< 负责日志的右半部分(可变正文):无论传入字符串、整数、文件描述符,都先用 stringstream 转成字符串再追加。返回自身引用,所以可以链式调用 << a << b << c。
// 专门接收 std::endl / std::flush 等流操纵符(函数模板无法被上面的模板 operator<< 推导)
// 换行由 SyncLog 统一追加, 这里直接忽略即可
LogMessage &operator<<(std::ostream &(*manip)(std::ostream &))
{
(void)manip;
return *this;
}
std::endl 不是数据而是函数,模板 operator<< 推导不出它的类型,必须用一个"函数指针参数"的非模板重载专门接住它。换行由最终的 SyncLog 统一加。
~LogMessage()
{
if (_logger._fflush_strategy)
{
_logger._fflush_strategy->SyncLog(_loginfo);
}
}
private:
std::string _curr_time;
LogLevel _level;
pid_t _pid;
std::string _src_name;
int _line_number;
std::string _loginfo;
Logger &_logger;
};
析构函数是整个流式日志的"扳机":一条日志语句写完、临时对象析构的瞬间,才把拼好的完整字符串真正交给策略输出。这也是为什么业务代码里不需要手动调用"打印"。
外层入口与宏:
// 这里故意写成返回临时对象
LogMessage operator()(LogLevel level, std::string name, int line)
{
return LogMessage(level, name, line, *this);
}
private:
std::unique_ptr<LogStrategy> _fflush_strategy;
};
Logger logger; // 全局日志对象
#define LOG(level) logger(level, __FILE__, __LINE__)
#define Enable_Console_Log_Strategy() logger.EnableConsoleLogStrategy()
#define Enable_File_Log_Strategy() logger.EnableFileLogStrategy()
}
以代码里的这句为例,走一遍完整生命周期:
LOG(LogLevel::INFO) << "bind success, sockfd : " << _sockfd;
LOG(LogLevel::INFO) 宏展开为 logger(INFO, __FILE__, __LINE__);
logger.operator()(…) 返回一个临时 LogMessage 对象,构造时拼好前缀;
两个 << 把正文追加进 _loginfo;
整条语句结束,临时对象析构,调用当前策略的 SyncLog 输出。
__FILE__ 和 __LINE__ 是编译器内置宏,自动展开成当前文件名和行号,所以每条日志能精确定位到代码行。
4. 第三部分:UDP 服务器 UdpServer.hpp(回调版)
4.1 头文件与类型别名
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
using namespace LogModule;
using func_t = std::function<std::string(const std::string&)>;
const int defaultfd = -1;
-
<functional>:为了用 std::function;
-
<strings.h>:提供 bzero;
-
<sys/socket.h>、<netinet/in.h>、<arpa/inet.h>:socket API、地址结构体、字节序/IP 转换函数;
-
func_t 是回调类型:吃一个字符串(客户端消息),吐一个字符串(处理结果)。任何签名匹配的普通函数、lambda、仿函数都能传进来;
-
defaultfd = -1:文件描述符的非法值,构造时先把 _sockfd 置为 -1。
4.2 构造函数
class UdpServer
{
public:
UdpServer(uint16_t port, func_t func)
: _sockfd(defaultfd),
// _ip(ip),
_port(port),
_isrunning(false),
_func(func)
{
}
只接收两个参数:端口号和业务回调。注意被注释掉的 _ip(ip)——这是当天我迭代的痕迹:早期版本构造函数是 UdpServer(const std::string &ip, uint16_t port),后来改用 INADDR_ANY 后不再需要指定 IP。
成员变量:
private:
int _sockfd;
uint16_t _port;
// std::string _ip;
bool _isrunning;
func_t _func; // 服务器的回调函数,用来进行对数据进行处理
4.3 Init():创建套接字 + 绑定
void Init()
{
// 1. 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "socket error!";
exit(1);
}
LOG(LogLevel::INFO) << "socket success, sockfd : " << _sockfd;
socket(AF_INET, SOCK_DGRAM, 0) 三个参数:
-
AF_INET:IPv4;
-
SOCK_DGRAM:数据报套接字,即 UDP(TCP 对应 SOCK_STREAM);
-
第三个参数 0:让内核按前两个参数自动选默认协议。
成功返回非负的文件描述符(实测从 3 开始,因为 0/1/2 已被标准输入输出错误占用),失败返回 -1。失败属致命错误,打 FATAL 日志后 exit(1)。
// 2. 绑定socket信息,ip和端口
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()); // TODO
local.sin_addr.s_addr = INADDR_ANY;
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(2);
}
LOG(LogLevel::INFO) << "bind success, sockfd : " << _sockfd;
}
地址结构体 sockaddr_in 的填充是网络编程的基本功:
|
sin_family |
AF_INET |
地址族,和 socket() 保持一致 |
|
sin_port |
htons(_port) |
端口必须转成网络字节序(大端) |
|
sin_addr.s_addr |
INADDR_ANY |
监听本机所有网卡 |
-
bzero 先把整个结构体清零,避免未赋值字段带着垃圾值;
-
htons = host to network short,把端口从主机字节序转网络字节序;
-
INADDR_ANY 的值就是 0(即 0.0.0.0),htonl(0) 仍是 0,所以这里不用再包一层转换;
-
bind 的第二个参数类型是通用的 struct sockaddr*,必须强转;失败时(最常见是端口被占用)exit(2)。
为什么服务器必须显式 bind,而客户端不用?
因为服务器的 IP + 端口必须是众所周知、固定不变的,客户端才能找到它;客户端端口由操作系统首次发送时随机分配即可。
4.4 Start():收发循环
void Start()
{
_isrunning = true;
while (_isrunning)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 1. 收消息
ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer) – 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
int peer_port = ntohs(peer.sin_port);
std::string peer_ip = inet_ntoa(peer.sin_addr);
buffer[s] = 0;
std::string result = _func(buffer);
// LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" << peer_port<< "]# " << buffer;
// 2. 发消息
sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr*)&peer, len);
}
}
}
逐步解释:
recvfrom:UDP 收数据的核心调用。它不仅把数据放进 buffer,还会把发送方的完整地址写进 peer,把地址长度写进 len。返回值是收到的字节数。
收包长度上限传 sizeof(buffer) – 1(1023),故意留一个字节。
if (s > 0):只有确实收到数据才处理。
识别发送方:peer.sin_port 是网络字节序,用 ntohs(network to host short)转回主机字节序;peer.sin_addr 是 4 字节网络序 IP,用 inet_ntoa 转成 "127.0.0.1" 这样的点分十进制字符串。
buffer[s] = 0:UDP 包不是 C 字符串,不会自动带 '\\0'。手动在有效数据末尾补零,才能安全地当字符串使用,否则会读出缓冲区后面的脏数据。
std::string result = _func(buffer);:把消息交给注入进来的回调函数处理。服务器本身完全不关心业务
sendto:把回调返回的结果发回 peer。因为回复地址直接用 recvfrom 带回的 peer,服务器不需要维护任何客户端列表,天然支持多个客户端。
UDP 服务器没有 TCP 的 listen、accept,整个生命周期就是这四个系统调用:socket –> bind –> recvfrom –> sendto。
4.5 析构函数
~UdpServer()
{
}
当前为空。文件描述符在进程退出时由操作系统回收;如果以后要做优雅退出,可以在这里 close(_sockfd)。
5. 第四部分:服务器入口 UdpServer.cc
#include <iostream>
#include <memory>
#include "UdpServer.hpp"
// 仅仅是用来进行测试的
std::string defaulthandler(const std::string &message)
{
std::string hello = "hello, ";
hello += message;
return hello;
}
// ./udpserver port
int main(int argc, char *argv[])
{
if(argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
uint16_t port = std::stoi(argv[1]);
Enable_Console_Log_Strategy();
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, defaulthandler);
usvr->Init();
usvr->Start();
return 0;
}
主要记住:
-
defaulthandler 就是一个符合 func_t 签名的普通函数:收到什么都在前面拼 hello, 。这个证明了回调机制——想换成"翻译""大小写转换""计算器",只需另写一个函数传入,UdpServer 一行都不用改。
-
命令行参数约定为一个端口号:argc != 2(程序名本身算 argv[0])时打印用法并退出。
-
std::stoi 把字符串转成整数,再隐式收窄为 uint16_t。
-
Enable_Console_Log_Strategy() 是宏,展开为对全局 logger 的调用,把日志输出切到屏幕(其实构造时默认就是屏幕策略,这里属于显式声明)。
-
用 std::make_unique<UdpServer>(port, defaulthandler) 创建对象:函数名作为函数指针传入,能隐式构造 std::function。
-
启动顺序固定:Init()(创建+绑定)→ Start()(进入死循环,正常情况下永不返回)。
6. 第五部分:UDP 客户端 UdpClient.cc
#include<iostream>
#include<string>
#include<cstdint>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<cstring>
//./udpclient serverip + severport
int main(int argc,char *argv[])
{
if(argc!=3)
{
std::cerr<<"Usage: "<<argv[0]<<" server_ip server_port"<<std::endl;
return 1;
}
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
int sockfd = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd<0)
{
std::cerr<<"socket error"<<std::endl;
return 1;
}
客户端同样先创建 UDP 套接字,但只填服务器地址:
// client要不要bind?需要bind。
// client要不要显示的bind?不要!!首次发送消息,OS会自动给client进行bind,
// OS知道IP,端口号采用随机端口号的方式
// 为什么?一个端口号只能被一个进程bind,为了避免client端口冲突
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
server.sin_port = htons(server_port);
这里的注释记录了一个重要知识点:客户端不显式 bind。首次 sendto 时操作系统会自动绑定本机 IP 和一个随机空闲端口。因为客户端不需要被别人"主动找到",随机端口还能避免多开客户端时的端口冲突。
inet_addr 把 "127.0.0.1" 这种点分十进制字符串直接转成网络字节序的 4 字节整数,所以不需要再套 htonl。
收发主循环:
while(true)
{
std::string input;
std::cout<<"Please Enter# "<<std::flush;
if(!std::getline(std::cin,input)) break;
if(input.empty()) continue;
int n = sendto(sockfd,input.c_str(),input.size(),0,(struct sockaddr*)&server,sizeof(server));
(void) n;
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int m = recvfrom(sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
if(m>0)
{
buffer[m] = 0;
std::cout<<buffer<<std::endl;
}
}
return 0;
}
逐行要点:
-
std::cout << "Please Enter# " << std::flush;:用 flush 而不是 endl,因为只需要提示立即显示、不需要换行(光标停在提示后面方便输入)。
-
if(!std::getline(std::cin,input)) break;:读取一整行。必须检查返回值——当输入结束(Ctrl+D 触发 EOF)时 getline 失败,若不退出会陷入死循环疯狂打印提示
-
if(input.empty()) continue;:直接按回车产生空行时跳过发送。空 UDP 包发出后服务器 s > 0 不成立、不回复,客户端会永远阻塞在 recvfrom。
-
sendto:把整行字符串发给 server,长度用 input.size()(不含 C 字符串结尾的 '\\0',网络上本来也不该发它)。
-
随后阻塞在 recvfrom 等服务器回复,收到后补 '\\0' 并打印。这就是"发一句、等一句"的问答模式。
7. 第六部分:Makefile 与编译方式
.PHONY:all
all:udpclient udpserver
udpclient:UdpClient.cc
g++ -o $@ $^ -std=c++17 -pthread
udpserver:UdpServer.cc
g++ -o $@ $^ -std=c++17 -pthread
.PHONY:clean
clean:
rm -f udpclient udpserver
-
$@ 代表目标名,$^ 代表所有依赖,所以两条规则分别等价于 g++ -o udpclient UdpClient.cc …;
-
-std=c++17:代码用了 std::filesystem,必须 C++17;
-
-pthread:代码用了 pthread 互斥锁。这是当天补上的选项,它同时处理预处理宏和链接库,比手写 -lpthread 更规范;
-
.PHONY 声明 all 和 clean 是伪目标,避免目录里恰好有同名文件时规则失效。
编译与清理:
make # 编译两个程序
make clean # 删除两个可执行文件
运行(两个终端):
# 终端 A:服务器
./udpserver 8080
# 终端 B:客户端
./udpclient 127.0.0.1 8080
8. 第七部分:迭代过程
阶段一:从绑定指定 IP 改为 INADDR_ANY
早期服务器构造函数接收 (ip, port),bind 时写:
local.sin_addr.s_addr = inet_addr(_ip.c_str());
这会导致服务器只接收发给某一个具体 IP 的包。后来改为:
-
构造函数只收 port;
-
local.sin_addr.s_addr = INADDR_ANY;
-
成员 _ip 注释保留;
-
main 的参数校验从 argc != 3 改为 argc != 2,启动方式从 ./udpserver 127.0.0.1 8080 变为 ./udpserver 8080。
阶段二:实测 INADDR_ANY 的含义
改完后用系统工具和两种地址实测:
UNCONN 0 0 0.0.0.0:8080 0.0.0.0:*
ss -uln 显示监听在 0.0.0.0:8080(-u UDP、-l listening、-n 数字格式),即所有网卡。客户端分别用回环地址和真实网卡 IP 都能通:
=== 用回环地址 127.0.0.1 访问 ===
Please Enter# server echo@via loopback
=== 用真实网卡IP 10.0.0.2 访问 ===
Please Enter# server echo@via lan ip
这澄清了"任意 IP"的两层含义:服务器绑定本机所有网卡(INADDR_ANY 的作用);UDP 本身不校验客户端来源,任何地址发来的包都接收。但跨机器能否连通还取决于路由可达性和防火墙/安全组是否放行 UDP 端口。
阶段三:识别发送方 IP 和端口
在 recvfrom 之后新增:
int peer_port = ntohs(peer.sin_port);
std::string peer_ip = inet_ntoa(peer.sin_addr);
并打印 DEBUG 日志
[127.0.0.1:37509]# hello world
[127.0.0.1:56592]# 我是客户端B
[127.0.0.1:33066]# 我是客户端A
[10.0.0.2:57611]# via lan ip
日志清楚显示:每个客户端的源端口由操作系统随机分配(37509、56592、33066……),服务器正是靠 peer 里的这个地址把回复准确发回对应客户端。
阶段四:重构为回调模式
最后一次重构把"业务处理"从服务器里剥离:
-
新增 using func_t = std::function<std::string(const std::string&)>;
-
构造函数增加 func_t func 参数,保存为成员 _func;
-
收发循环中写死的 "server echo@" 拼接被删除,改为 std::string result = _func(buffer); 后发回 result;
-
main 里定义测试函数 defaulthandler(加 hello, 前缀)并注入。
最终实测(部分输出):
hello, world
hello, UDP服务器
hello, 3.14
hello, !@#$%
至此,我们的网络框架(UdpServer)与业务逻辑(handler)彻底解耦。
9. 第八部分:实测汇总
编译:
![]()

服务器启动日志,多组测试结果:

10. 第九部分:知识点回顾
网络编程
UDP 四件套:socket(AF_INET, SOCK_DGRAM, 0) → bind → recvfrom → sendto,无连接、无 listen/accept。
sockaddr_in 三个必填字段:sin_family、sin_port(htons)、sin_addr.s_addr。
网络字节序是大端:端口用 htons/ntohs;inet_addr 和 inet_ntoa 内部已处理字节序。
INADDR_ANY(0.0.0.0)监听所有网卡;服务器必须显式 bind,客户端由 OS 在首次发送时自动 bind 随机端口。
recvfrom 同时带回对端地址,UDP 靠它支持多客户端、回复报文。
UDP 数据报不带 '\\0',收到后必须手动 buffer[s] = 0 才能当 C 字符串用。
C++ 与工程化
RAII:LockGuard 构造加锁、析构解锁;unique_ptr 管理策略对象和服务器对象。
策略模式:抽象基类 + 两个具体策略,运行期替换 _fflush_strategy。
流式日志原理:临时对象用 operator<< 累积字符串,析构时统一刷出;__FILE__/__LINE__ 靠宏注入。
std::endl 是函数模板,自定义流式类必须为流操纵符单独写重载。
std::function 回调实现网络框架与业务解耦。
std::filesystem::create_directories(C++17)建目录;localtime_r 是线程安全时间转换。
编译选项:C++17 配 -std=c++17,pthread 配 -pthread。
11.最终代码如下:










