欢迎光临
我们一直在努力

【Linux网络加餐(三)】学习全记录:UDP 回声服务器 + 策略模式日志模块

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.最终代码如下:

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux网络加餐(三)】学习全记录:UDP 回声服务器 + 策略模式日志模块
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址