欢迎光临
我们一直在努力

UDP Socket 进阶:从 Echo 到字典服务器,学会 “解耦” 网络与业务

开篇:从 “回显” 到 “字典”,核心变在哪?

上一篇我们实现了 Echo 服务器 —— 网络层和业务层是 “绑死” 的:网络层收到数据后,直接把原数据发回去。但实际开发中,业务逻辑会复杂得多(比如查字典、查天气),如果每次改业务都要动网络代码,效率太低。

这篇的核心目标:用 “解耦” 的思想,把 UDP 服务器改造成字典服务—— 客户端输入英文单词,服务器返回中文翻译。你会学到:如何封装业务逻辑(字典加载与查询)、如何用 C++ 函数对象(std::function)分离网络层和业务层,以及如何封装 Socket 操作让代码更复用。

一、先搞懂:字典服务器的核心流程

字典服务器的逻辑比 Echo 稍复杂,但很清晰:

  • 服务器启动时,加载dict.txt(存 “apple: 苹果” 这类键值对)到内存(用unordered_map存储,查询更快);

  • 客户端发送英文单词(如 “apple”);

  • 服务器接收单词后,查内存中的字典,得到中文翻译(如 “苹果”);

  • 服务器把翻译结果发回客户端。

  • 整个流程中,网络层只负责 “收发数据”,业务层只负责 “查字典”,两者互不干扰 —— 这就是解耦的精髓。

    二、核心代码拆解:从字典类到解耦的服务器

    我们分三部分讲:字典业务类(Dict)、解耦的 UDP 服务器(UdpServer)、封装版 Socket(可选,提升代码复用性)。

    1. 第一步:封装字典业务 ——Dict类

    首先实现字典的 “加载” 和 “查询” 功能,这个类完全不涉及网络操作,纯业务逻辑。

    (1)dict.txt文件格式

    先准备一个简单的字典文件,每行是 “英文:中文”(注意冒号后有空格):

    apple: 苹果

    banana: 香蕉

    cat: 猫

    dog: 狗

    book: 书

    happy: 快乐的

    hello: 你好

    goodbye: 再见

    (2)Dict类代码实现

    #pragma once
    #include <iostream>
    #include <string>
    #include <fstream> // 用于读取文件
    #include <unordered_map> // 用于存储字典(哈希表,查询O(1))

    // 分隔符:dict.txt里是“英文: 中文”,所以分隔符是“: ”
    const std::string sep = ": ";

    class Dict {
    public:
    // 构造函数:传入字典文件路径,初始化时加载字典
    Dict(const std::string &confpath) : _confpath(confpath) {
    LoadDict(); // 加载字典到内存
    }

    // 核心方法:查询单词,返回翻译(未查到返回“Unknown”)
    std::string Translate(const std::string &key) {
    auto iter = _dict.find(key); // 哈希表查询
    if (iter == _dict.end()) {
    return "Unknown"; // 未找到
    }
    return iter->second; // 返回中文翻译
    }

    private:
    // 私有方法:加载字典文件到_unordered_map
    void LoadDict() {
    std::ifstream in(_confpath); // 打开文件
    if (!in.is_open()) { // 检查文件是否打开成功
    std::cerr << "open dict file error: " << _confpath << 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;
    }

    // 截取英文(key)和中文(value)
    std::string key = line.substr(0, pos); // 从0到pos的子串(英文)
    std::string value = line.substr(pos + sep.size()); // 分隔符后的子串(中文)
    _dict.insert(std::make_pair(key, value)); // 插入哈希表
    }

    in.close(); // 关闭文件
    std::cout << "load dict success! total words: " << _dict.size() << std::endl;
    }

    private:
    std::string _confpath; // 字典文件路径
    std::unordered_map<std::string, std::string> _dict; // 存储字典的哈希表
    };

    通俗解释:

    • LoadDict():把dict.txt的内容读到_dict里,就像把 “单词 – 翻译” 存到一本 “快速查询手册” 里,以后查单词不用再读文件,直接查手册(内存),速度快。

    • Translate():给一个英文单词(key),查手册,有就返回翻译,没有就返回 “Unknown”。

    • 为什么用unordered_map?因为它是哈希表,查询速度是 O (1)(瞬间查到),如果用vector,查询要遍历所有元素,单词多了会很慢。

    2. 第二步:解耦 UDP 服务器 —— 用std::function分离网络与业务

    上一篇的UdpServer是 “网络层 + 业务层” 绑定的(直接回显),这篇我们改造它:让UdpServer只负责 “收发数据”,业务逻辑(查字典)通过 “函数对象” 传进来 —— 以后想改业务(比如改成天气查询),只需要传一个新的函数,不用动UdpServer的代码。

    (1)改造后的UdpServer类核心代码

    #pragma once
    // 省略头文件(和上一篇类似,增加#include <functional>)
    #include "nocopy.hpp"
    #include "Log.hpp"
    #include "Comm.hpp"
    #include "InetAddr.hpp"

    const static uint16_t defaultport = 8888;
    const static int defaultfd = -1;
    const static int defaultsize = 1024;

    // 关键:定义函数对象类型func_t
    // 输入:客户端的请求(req,如“apple”)
    // 输出:服务器的响应(resp,如“苹果”)
    using func_t = std::function<void(const std::string &req, std::string *resp)>;

    class UdpServer : public nocopy {
    public:
    // 构造函数:传入业务逻辑函数(func)和端口
    UdpServer(func_t func, uint16_t port = defaultport)
    : _func(func), _port(port), _sockfd(defaultfd) {}

    // Init()方法:和上一篇完全一样(创建socket、绑定)
    void Init() {
    // 代码和上一篇相同,省略…
    }

    // Start()方法:改造业务逻辑调用
    void Start() {
    char buffer[defaultsize];
    for (;;) { // 死循环运行
    struct sockaddr_in peer;
    socklen_t len = sizeof(peer);

    // 1. 接收客户端请求(和上一篇一样)
    ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0,
    (struct sockaddr *)&peer, &len);
    if (n > 0) {
    buffer[n] = 0;
    InetAddr addr(peer);
    std::cout << "[" << addr.PrintDebug() << "]# " << buffer << std::endl;

    // 2. 调用业务逻辑函数(查字典),而不是直接回显
    std::string resp; // 存储响应结果
    _func(buffer, &resp); // 传入请求,获取响应(解耦的核心!)

    // 3. 发送响应给客户端(和上一篇一样)
    sendto(_sockfd, resp.c_str(), resp.size(), 0,
    (struct sockaddr *)&peer, len);
    }
    }
    }

    ~UdpServer() {
    if (_sockfd != defaultfd) {
    close(_sockfd); // 析构时关闭socket
    }
    }

    private:
    int _sockfd;
    uint16_t _port;
    func_t _func; // 存储业务逻辑函数(查字典、回显等)
    };

    解耦的核心:func_t和_func:

    • func_t是一个函数对象类型,它规定了 “业务函数” 的格式:必须接收const std::string &req(请求)和std::string *resp(响应的指针,用于输出结果)。

    • _func是UdpServer的成员变量,存储传入的业务函数。在Start()中,服务器收到请求后,不自己处理,而是调用_func(req, &resp),让业务函数生成响应 —— 这样网络层和业务层就完全分开了。

    3. 第三步:主函数 —— 组装服务器和业务逻辑

    有了Dict类和改造后的UdpServer,主函数的工作就是 “组装”:创建字典对象、定义业务函数、创建服务器并启动。

    #include "UdpServer.hpp"
    #include "Comm.hpp"
    #include "Dict.hpp"
    #include <memory> // 用于智能指针(可选,避免内存泄漏)

    // 全局字典对象:启动时加载dict.txt
    Dict gdict("./dict.txt");

    // 业务逻辑函数:符合func_t的格式
    void Execute(const std::string &req, std::string *resp) {
    // 调用Dict的Translate方法,把结果存入resp
    *resp = gdict.Translate(req);
    }

    // 主函数:解析参数,启动服务器
    int main(int argc, char *argv[]) {
    // 检查参数:需要传入端口号(如./udp_server 8888)
    if (argc != 2) {
    std::cout << "Usage: " << argv[0] << " local_port" << std::endl;
    return Usage_Err;
    }

    uint16_t port = std::stoi(argv[1]); // 解析端口号

    // 创建服务器:传入业务函数Execute和端口
    // 用智能指针(std::unique_ptr)管理服务器对象,自动释放内存
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(Execute, port);

    // 初始化并启动服务器
    usvr->Init();
    usvr->Start();

    return 0;
    }

    关键细节:

    • gdict是全局的字典对象:因为字典只需要加载一次(启动时),全局对象会在main前初始化,避免每次查询都重新加载文件。

    • Execute函数:就是把Dict的Translate方法包装成func_t格式 —— 输入req(英文单词),输出resp(中文翻译)。

    • 智能指针std::unique_ptr:避免手动delete服务器对象,防止内存泄漏,是 C++ 中推荐的做法。

    4. 可选:封装 Socket 操作 ——udp_socket.hpp

    文档里还提供了一个 “封装版” 的UdpSocket类,把socket、bind、recvfrom、sendto这些系统调用封装成类方法,让代码更简洁、复用性更高。

    核心封装代码示例:

    #pragma once
    #include <stdio.h>
    #include <string.h>
    #include <string>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>

    class UdpSocket {
    public:
    UdpSocket() : fd_(-1) {}

    // 创建socket
    bool Socket() {
    fd_ = socket(AF_INET, SOCK_DGRAM, 0);
    if (fd_ < 0) {
    perror("socket"); // 打印错误信息
    return false;
    }
    return true;
    }

    // 绑定IP和端口
    bool Bind(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);
    int ret = bind(fd_, (struct sockaddr*)&addr, sizeof(addr));
    if (ret < 0) {
    perror("bind");
    return false;
    }
    return true;
    }

    // 接收数据:输出buf(消息)、ip(发送方IP)、port(发送方端口)
    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,
    (struct sockaddr*)&peer, &len);
    if (read_size < 0) {
    perror("recvfrom");
    return false;
    }
    buf->assign(tmp, read_size); // 把接收的字节存入buf
    if (ip != NULL) {
    *ip = inet_ntoa(peer.sin_addr); // 转换IP为字符串
    }
    if (port != NULL) {
    *port = ntohs(peer.sin_port); // 转换端口为主机字节序
    }
    return true;
    }

    // 发送数据:输入buf(消息)、ip(接收方IP)、port(接收方端口)
    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,
    (struct sockaddr*)&addr, sizeof(addr));
    if (write_size < 0) {
    perror("sendto");
    return false;
    }
    return true;
    }

    // 关闭socket
    bool Close() {
    if (fd_ != -1) {
    close(fd_);
    fd_ = -1;
    }
    return true;
    }

    private:
    int fd_; // socket文件句柄
    };

    封装的好处:

    • 不用重复写struct sockaddr_in、字节序转换这些繁琐的代码;

    • 错误处理更统一(用perror打印错误,返回bool表示成功 / 失败);

    • 后续写其他 UDP 程序(如聊天室),可以直接用这个类,不用重新写 Socket 操作。

    三、动手运行:测试字典服务

    和上一篇的 Echo 服务器运行步骤类似,客户端可以复用上一篇的(因为客户端只负责收发字符串,不关心服务器的业务逻辑)。

    1. 准备文件

    • dict.txt:按前面的格式准备好单词和翻译;

    • 编译服务器:g++ ``main.cc`` UdpServer.cpp Dict.cpp -o udp_server -std=c++11(如果拆分了.cpp 文件);

    • 客户端用上一篇的udp_client。

    2. 运行测试

    • 启动服务器:./udp_server 8888,会看到load dict success! total words: 10(根据dict.txt的单词数而定);

    • 启动客户端:./udp_client ``127.0.0.1`` 8888;

    • 输入 “apple”,客户端会显示server echo# 苹果;输入 “test”,会显示server echo# Unknown。

    四、总结与思考

    这篇我们实现了一个 “可扩展” 的字典服务器,核心收获是:

  • 业务逻辑封装:用Dict类把 “加载字典” 和 “查询翻译” 封装起来,纯业务不沾网络;

  • 网络与业务解耦:用std::function让UdpServer只负责收发数据,业务逻辑通过函数对象传入,灵活可换;

  • Socket 封装:用UdpSocket类简化 Socket 操作,提升代码复用性。

  • 思考问题:

    如果想让多个客户端同时用字典服务,当前的服务器能应付吗?因为Start()是单循环,一次只能处理一个客户端的请求 —— 如果客户端多了,会有延迟。下一篇我们讲如何用 “线程池” 实现并发处理,还会实现一个支持多客户端聊天的 UDP 聊天室。

    赞(0)
    未经允许不得转载:171主机测评 » UDP Socket 进阶:从 Echo 到字典服务器,学会 “解耦” 网络与业务
    分享到: 更多 (0)

    评论 抢沙发

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