【Linux】UDP Socket编程实战(二):网络字典与回调设计

文章目录

    • [UDP Socket编程实战(二):网络字典与回调设计](#UDP Socket编程实战(二):网络字典与回调设计)
    • 一、从Echo到Dict:需求变了什么
      • [1.1 V1版本的问题](#1.1 V1版本的问题)
      • [1.2 解耦的思路:回调函数](#1.2 解耦的思路:回调函数)
    • 二、V2版本:引入回调机制
      • [2.1 定义回调函数类型](#2.1 定义回调函数类型)
      • [2.2 修改UdpServer的构造函数](#2.2 修改UdpServer的构造函数)
      • [2.3 修改Start()函数](#2.3 修改Start()函数)
    • 三、字典模块的实现
      • [3.1 字典文件的格式](#3.1 字典文件的格式)
      • [3.2 Dict类的设计](#3.2 Dict类的设计)
      • [3.3 LoadDict()的实现](#3.3 LoadDict()的实现)
        • [3.3.1 打开文件](#3.3.1 打开文件)
        • [3.3.2 逐行读取](#3.3.2 逐行读取)
        • [3.3.3 解析每一行](#3.3.3 解析每一行)
        • [3.3.4 插入到map](#3.3.4 插入到map)
    • 四、业务函数的实现
      • [4.1 全局字典对象](#4.1 全局字典对象)
      • [4.2 翻译函数](#4.2 翻译函数)
      • [4.3 启动服务器](#4.3 启动服务器)
    • 五、封装版UdpSocket详解
      • [5.1 为什么要封装](#5.1 为什么要封装)
      • [5.2 UdpSocket类的设计](#5.2 UdpSocket类的设计)
        • [5.2.1 Socket()方法](#5.2.1 Socket()方法)
        • [5.2.2 Bind()方法](#5.2.2 Bind()方法)
        • [5.2.3 RecvFrom()方法](#5.2.3 RecvFrom()方法)
        • [5.2.4 SendTo()方法](#5.2.4 SendTo()方法)
    • 六、通用服务器的设计
      • [6.1 UdpServer的封装版本](#6.1 UdpServer的封装版本)
      • [6.2 Start()方法](#6.2 Start()方法)
    • 七、通用客户端的设计
      • [7.1 UdpClient类](#7.1 UdpClient类)
      • [7.2 使用示例:字典客户端](#7.2 使用示例:字典客户端)
    • 八、本篇总结
      • [8.1 核心要点](#8.1 核心要点)
      • [8.2 容易混淆的点](#8.2 容易混淆的点)

UDP Socket编程实战(二):网络字典与回调设计

💬 开篇:上一篇实现了Echo Server,数据从客户端发出,服务器原封不动地回去。这个流程里,服务器只负责收发,不处理业务逻辑。但真实的网络服务不是这样的------收到请求后要查数据库、做计算、返回结果。这一篇我们把Echo Server改造成一个英译汉的网络字典,同时引入回调机制,让服务器代码和业务逻辑解耦。理解了这个模式,后面不管是做聊天室还是做游戏服务器,都是同样的思路。

👍 点赞、收藏与分享:这篇会讲清楚回调函数的设计、字典模块的实现,以及封装版UdpSocket的思路。如果对你有帮助,请点赞收藏!

🚀 循序渐进:从V1到V2的演进,从硬编码业务到回调解耦,从裸API到封装类,一步步理解网络服务的正确架构。


一、从Echo到Dict:需求变了什么

1.1 V1版本的问题

上一篇的Echo Server,核心逻辑是这样的:

cpp 复制代码
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, ...);
if (n > 0) {
    buffer[n] = 0;
    // 直接回显,没有任何处理
    sendto(_sockfd, buffer, strlen(buffer), 0, ...);
}

这种写法把"收数据"和"处理数据"耦合在一起了。如果现在要把Echo改成字典查询,难道要在Start()函数里写查字典的代码?那如果再要加一个计算器功能呢?Start()函数会越来越臃肿,维护起来很头疼。

1.2 解耦的思路:回调函数

正确的做法是:服务器只负责收发,具体怎么处理请求,交给业务层决定

怎么实现?通过回调函数(Callback)。服务器收到数据后,调用一个事先注册好的处理函数,把请求内容传进去,拿到处理结果,再发回给客户端。

cpp 复制代码
// 伪代码
收到请求 req
调用 业务处理函数(req, &resp)
发送响应 resp

这样,服务器代码完全不关心业务是什么。查字典也好,做计算也好,只要提供一个符合约定的函数,传给服务器就行。


二、V2版本:引入回调机制

2.1 定义回调函数类型

先定义一个函数类型,作为回调函数的约定:

cpp 复制代码
using func_t = std::function<void(const std::string &req, std::string *resp)>;

这个类型表示:回调函数接收两个参数------请求(req)和响应指针(resp)。函数内部根据请求内容填充响应。

为什么用std::function而不是函数指针?因为std::function能够兼容普通函数、成员函数、lambda、仿函数,更灵活。

2.2 修改UdpServer的构造函数

cpp 复制代码
class UdpServer : public nocopy
{
public:
    UdpServer(func_t func, uint16_t port = defaultport)
        : _func(func), _port(port), _sockfd(defaultfd)
    {
    }
    
private:
    func_t _func;     // 存储业务处理函数
    uint16_t _port;
    int _sockfd;
};

构造的时候传入一个处理函数,服务器把它保存下来。后面收到数据就调用这个函数。

2.3 修改Start()函数

cpp 复制代码
void Start()
{
    char buffer[defaultsize];
    for (;;)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 
                             0, (struct sockaddr *)&peer, &len);
        if (n > 0)
        {
            InetAddr addr(peer);
            buffer[n] = 0;
            std::cout << "[" << addr.PrintDebug() << "]# " << buffer << std::endl;
            
            std::string value;
            _func(buffer, &value);    // ← 这里调用业务处理函数
            
            sendto(_sockfd, value.c_str(), value.size(), 0, 
                   (struct sockaddr *)&peer, len);
        }
    }
}

关键变化就一行:_func(buffer, &value)。收到请求buffer后,调用业务函数处理,结果放到value里,然后发回去。

服务器代码完全不知道具体业务是什么,它只管调用。这就是回调模式的核心思想。


三、字典模块的实现

3.1 字典文件的格式

字典内容存在一个文本文件里,格式是"单词: 翻译",每行一对:

bash 复制代码
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书

为什么用文本文件而不是硬编码在代码里?因为字典内容可能很大,而且可能会更新。文本文件改完直接重启服务器就行,不用重新编译。

3.2 Dict类的设计

cpp 复制代码
const std::string sep = ": ";

class Dict
{
public:
    Dict(const std::string &confpath) : _confpath(confpath)
    {
        LoadDict();    // 构造的时候就加载字典
    }
    
    std::string Translate(const std::string &key)
    {
        auto iter = _dict.find(key);
        if(iter == _dict.end()) 
            return std::string("Unknown");
        else 
            return iter->second;
    }
    
    ~Dict() {}

private:
    void LoadDict();    // 从文件加载字典
    
    std::string _confpath;
    std::unordered_map<std::string, std::string> _dict;
};

核心就两个方法:

  • LoadDict():启动时从文件读取内容,填充到unordered_map
  • Translate():根据单词查翻译,查不到返回"Unknown"

为什么用unordered_map而不是map?因为查字典只需要O(1)的查找,不需要有序遍历。unordered_map是哈希表,查找更快。

3.3 LoadDict()的实现

cpp 复制代码
void LoadDict()
{
    std::ifstream in(_confpath);
    if(!in.is_open())
    {
        std::cerr << "open file error" << std::endl;
        return;
    }
    
    std::string line;
    while(std::getline(in, line))
    {
        if(line.empty()) continue;                      // 跳过空行
        
        auto pos = line.find(sep);                      // 找分隔符": "
        if(pos == std::string::npos) continue;          // 格式不对的行跳过
        
        std::string key = line.substr(0, pos);          // 单词
        std::string value = line.substr(pos + sep.size());  // 翻译
        
        _dict.insert(std::make_pair(key, value));
    }
    in.close();
}

逐行分析:

3.3.1 打开文件
cpp 复制代码
std::ifstream in(_confpath);
if(!in.is_open()) {
    std::cerr << "open file error" << std::endl;
    return;
}

ifstream打开文件。如果打开失败(比如文件不存在),打印错误后直接返回。这时候字典是空的,所有查询都会返回"Unknown"。

生产环境应该用日志而不是cerr,但这里简化处理了。

3.3.2 逐行读取
cpp 复制代码
std::string line;
while(std::getline(in, line))
{
    if(line.empty()) continue;
    // ...
}

std::getline每次读一行,直到文件结束。空行直接跳过,不处理。

3.3.3 解析每一行
cpp 复制代码
auto pos = line.find(sep);
if(pos == std::string::npos) continue;

std::string key = line.substr(0, pos);
std::string value = line.substr(pos + sep.size());

每行格式是"apple: 苹果"。用find找到分隔符": "的位置,然后:

  • substr(0, pos):从开头到分隔符之前,就是单词部分
  • substr(pos + sep.size()):从分隔符之后到结尾,就是翻译部分

如果找不到分隔符(比如某行写错了),find返回npos,这行就跳过。

3.3.4 插入到map
cpp 复制代码
_dict.insert(std::make_pair(key, value));

把键值对插入到哈希表。如果有重复的key(比如文件里apple出现了两次),insert只会插入第一个,后面的会被忽略。如果想覆盖,可以改成_dict[key] = value


四、业务函数的实现

4.1 全局字典对象

cpp 复制代码
Dict gdict("./dict.txt");

main函数外面定义一个全局的字典对象。为什么是全局?因为服务器是单例的,只有一个字典,全局最简单。

当然,更好的做法是用单例模式或者依赖注入,但这里演示用,全局变量够了。

4.2 翻译函数

cpp 复制代码
void Execute(const std::string &req, std::string *resp)
{
    *resp = gdict.Translate(req);
}

这个函数就是我们要传给服务器的回调函数。它接收请求(单词),调用字典的Translate方法,把结果写到resp指针指向的字符串里。

注意这里用的是指针而不是引用返回值。为什么?因为std::function的参数类型已经定死了,必须匹配。用指针输出是C++里常见的模式,调用者提供一个空的字符串,函数负责填充。

4.3 启动服务器

cpp 复制代码
int main(int argc, char *argv[])
{
    if(argc != 2) {
        Usage(argv[0]);
        return Usage_Err;
    }
    
    uint16_t port = std::stoi(argv[1]);
    
    std::unique_ptr<UdpServer> usvr = 
        std::make_unique<UdpServer>(Execute, port);
    
    usvr->Init();
    usvr->Start();
    
    return 0;
}

构造UdpServer的时候把Execute函数传进去,服务器收到请求就会调用它。这样整个流程就串起来了:

bash 复制代码
客户端发送: "apple"
    ↓
服务器收到,调用 Execute("apple", &resp)
    ↓
Execute 调用 gdict.Translate("apple")
    ↓
字典返回 "苹果"
    ↓
服务器发送: "苹果"
    ↓
客户端收到响应

五、封装版UdpSocket详解

5.1 为什么要封装

前面的代码直接用的是系统调用(socketbindrecvfromsendto),每次都要填一堆参数,很容易写错。封装成一个UdpSocket类之后,接口更清晰,使用更方便。

这个封装不是为了炫技,是为了实际工程中减少重复代码和降低出错概率。

5.2 UdpSocket类的设计

cpp 复制代码
class UdpSocket {
public:
    UdpSocket() : fd_(-1) {}
    
    bool Socket();      // 创建socket
    bool Close();       // 关闭socket
    bool Bind(const std::string& ip, uint16_t port);    // 绑定地址
    bool RecvFrom(std::string* buf, std::string* ip = NULL, uint16_t* port = NULL);
    bool SendTo(const std::string& buf, const std::string& ip, uint16_t port);
    
private:
    int fd_;
};

所有操作都返回bool,成功返回true,失败返回false。这样调用者可以统一用if(!sock.Bind(...))的模式检查错误。

5.2.1 Socket()方法
cpp 复制代码
bool Socket() {
    fd_ = socket(AF_INET, SOCK_DGRAM, 0);
    if (fd_ < 0) {
        perror("socket");
        return false;
    }
    return true;
}

封装了socket系统调用,失败时打印错误。注意这里用的是perror而不是手动拼接strerror(errno),更简洁。

5.2.2 Bind()方法
cpp 复制代码
bool Bind(const std::string& ip, uint16_t port) {
    sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr(ip.c_str());
    addr.sin_port = htons(port);
    
    int ret = bind(fd_, (sockaddr*)&addr, sizeof(addr));
    if (ret < 0) {
        perror("bind");
        return false;
    }
    return true;
}

把IP和端口作为参数传进来,内部自动填充sockaddr_in结构体。调用者不需要关心字节序转换、结构体填充这些细节。

5.2.3 RecvFrom()方法
cpp 复制代码
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");
        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;
}

这里的设计很巧妙:

  • 第一个参数buf是必须的,用来接收数据
  • 后面的ipport是可选的(默认值NULL),如果你不关心发送方地址,就不传

这样同一个函数既可以用于服务器(需要知道发送方地址),也可以用于客户端(不关心发送方地址)。

注意buf->assign(tmp, read_size)这一行。不能用buf = tmp,因为tmp是局部变量,返回后就销毁了。必须用assign把内容复制到输出参数指向的字符串里。

5.2.4 SendTo()方法
cpp 复制代码
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");
        return false;
    }
    return true;
}

接收目标IP、端口和要发送的数据。内部自动填充地址结构体,调用者只需要关心"发什么、发给谁",不需要操心字节序和类型转换。


六、通用服务器的设计

6.1 UdpServer的封装版本

基于UdpSocket,可以写一个通用的UdpServer类:

cpp 复制代码
typedef std::function<void (const std::string&, std::string*)> Handler;

class UdpServer {
public:
    UdpServer() {
        assert(sock_.Socket());
    }
    
    ~UdpServer() {
        sock_.Close();
    }
    
    bool Start(const std::string& ip, uint16_t port, Handler handler);
    
private:
    UdpSocket sock_;
};

构造函数里直接创建socket,析构函数里自动关闭。这是RAII(资源获取即初始化)的典型用法,避免忘记关闭fd导致泄漏。

6.2 Start()方法

cpp 复制代码
bool Start(const std::string& ip, uint16_t port, Handler handler) {
    bool ret = sock_.Bind(ip, port);
    if (!ret) {
        return false;
    }
    
    for (;;) {
        std::string req;
        std::string remote_ip;
        uint16_t remote_port = 0;
        
        bool ret = sock_.RecvFrom(&req, &remote_ip, &remote_port);
        if (!ret) {
            continue;    // 收失败了继续循环,不退出
        }
        
        std::string resp;
        handler(req, &resp);    // 调用业务处理函数
        
        sock_.SendTo(resp, remote_ip, remote_port);
        if (!sock_.SendTo(resp, remote_ip, remote_port)) {
    				perror("sendto");
		}

        
        printf("[%s:%d] req: %s, resp: %s\n", 
               remote_ip.c_str(), remote_port,
               req.c_str(), resp.c_str());
    }
    
    sock_.Close();
    return true;
}

流程和前面一样,只是把系统调用都换成了UdpSocket的方法。代码可读性大幅提升,一眼就能看懂在干什么。


七、通用客户端的设计

7.1 UdpClient类

cpp 复制代码
class UdpClient {
public:
    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_;
};

客户端的封装更简单。构造的时候就把服务器IP和端口存下来,后面发送数据直接调SendTo,不用每次都传IP和端口。

7.2 使用示例:字典客户端

cpp 复制代码
int main(int argc, char* argv[]) {
    if (argc != 3) {
        printf("Usage ./dict_client [ip] [port]\n");
        return 1;
    }
    
    UdpClient client(argv[1], atoi(argv[2]));
    
    for (;;) {
        std::string word;
        std::cout << "请输入您要查的单词: ";
        std::cin >> word;
        
        if (!std::cin) {
            std::cout << "Good Bye" << std::endl;
            break;
        }
        
        client.SendTo(word);
        
        std::string result;
        client.RecvFrom(&result);
        
        std::cout << word << " 意思是 " << result << std::endl;
    }
    
    return 0;
}

交互流程一目了然:输入单词 → 发送 → 接收 → 打印翻译。


八、本篇总结

8.1 核心要点

回调机制

  • std::function定义回调类型,兼容函数指针、lambda、仿函数
  • 服务器收到请求后调用回调函数处理,把网络层和业务层解耦
  • 业务函数只需要符合约定的签名,不关心网络细节

字典模块

  • unordered_map存储键值对,查询时间复杂度O(1)
  • 从文本文件加载内容,方便更新维护
  • 逐行解析,跳过空行和格式错误的行
  • 查不到的key返回统一的默认值

封装设计

  • UdpSocket封装系统调用,统一返回bool表示成功/失败
  • RecvFrom的可选参数设计,既适用于服务器也适用于客户端
  • RAII模式管理socket生命周期,构造时创建,析构时关闭
  • UdpServerUdpClient再次封装,提供更高层的接口

代码组织

  • 全局字典对象,服务器启动时加载
  • 业务函数和main分离,保持main的简洁
  • 使用智能指针管理对象生命周期

8.2 容易混淆的点

  1. 回调函数的参数为什么用指针:因为要输出结果。如果用返回值,只能返回一个东西;用指针输出参数,可以同时输出多个结果,而且调用者提供的字符串可以复用。

  2. 为什么用buf->assign(tmp, read_size)而不是*buf = tmptmp是局部变量,函数返回后就销毁了。必须用assign把内容复制到调用者提供的字符串里。

  3. RecvFrom的可选参数怎么实现的 :默认参数值设为NULL,函数内部判断if (ip != NULL)再填充。这样调用者可以选择性地接收发送方地址。

  4. 封装的意义不是为了炫技:是为了减少重复代码、降低出错率、提高可维护性。系统调用的参数很复杂,封装后调用者不需要记住每

相关推荐
去码头整点薯条982 小时前
python第五次作业
linux·前端·python
徐子元竟然被占了!!2 小时前
虚拟化技术
运维
凉、介2 小时前
静态路由探究
网络·笔记·操作系统·嵌入式
逐步前行2 小时前
STM32_内部结构
网络·stm32·嵌入式硬件
为什么不问问神奇的海螺呢丶2 小时前
n9e categraf docker 监控配置
运维·docker·容器
Kiyra2 小时前
从《守望先锋》2026前瞻,看大型分布式系统的“重构”与“并发挑战”
运维·服务器·重构
青树寒鸦2 小时前
wsl的docker备份mongo和迁移
运维·mongodb·docker·容器
niceffking2 小时前
linux系统编程-线程概述
linux·运维
明洞日记2 小时前
【图解软考八股034】深入解析 UML:识别标准建模图示
c++·软件工程·软考·uml·面向对象·架构设计