Linux网络编程(五):基于UDP实现DictServer

目录

[一、什么是 DictServer](#一、什么是 DictServer)

[1. 为什么需要网络服务](#1. 为什么需要网络服务)

[2. DictServer介绍](#2. DictServer介绍)

二、整体结构

[1. 工作流程](#1. 工作流程)

[2. 为什么使用 UDP](#2. 为什么使用 UDP)

三、词典数据模块

[1. Dictionary 类的设计](#1. Dictionary 类的设计)

四、网络与业务解耦

五、服务端与客户端组装

[1. 服务端组装](#1. 服务端组装)

[2. 客户端组装](#2. 客户端组装)

六、程序测试

[1. 编译运行](#1. 编译运行)

[2. 查询测试](#2. 查询测试)

总结


一、什么是 DictServer

在上一篇博客中,我们成功实现了一个基础的 UDP Echo Server。虽然它打通了网络通信的基本链路,但"原样返回数据"的逻辑在实际生产中并没有实质性的业务价值。

本篇博客我们将对服务端的业务层进行扩展,编写一个具备实际应用能力的经典网络程序------基于 UDP 的网络词典服务器(DictServer)


1. 为什么需要网络服务

在步入网络编程之前,我们编写的大多数程序都是本地单机程序。例如,如果你在本地写一个背单词或者查词典的工具,你需要将成千上万条词汇数据硬编码在代码里,或者在本地存放一个巨大的文本数据库

这种单机架构存在以下几个致命的缺陷:

  • 数据孤岛与冗余:每一个用户想使用这个工具,都必须在自己的机器上下载一份完全相同的庞大数据库,极大地浪费了客户端的存储空间

  • 更新维护极难:一旦词典增加了新词或者修正了翻译错误,你必须要求所有用户更新整个客户端软件,数据无法做到实时同步

网络服务的核心价值,就在于解决数据的集中管理与高并发共享

通过将词典数据集中部署在一台服务器上,所有的查找逻辑都在服务器的内存或数据库中完成。客户端只需要通过网络发送一个极小的单词字符串,服务端处理完毕后再将翻译结果回传。这种 "轻客户端、重服务端" 的架构,是现代互联网几乎所有分布式服务的基础形态


2. DictServer介绍

DictServer ,即网络词典服务器。它的核心功能可以概括为应用层的 "键值对" 远程查询服务

  • 基本行为

    • 输入:客户端通过 UDP 套接字向服务器发送一个英文单词字符串(例如 "apple")

    • 处理:服务端接收到该请求后,在本地维护的词典数据集(如内存映射表或数据库)中进行检索

    • 输出:如果检索成功,服务端将该单词对应的中文翻译(例如 "苹果")打包回传给客户端;如果检索失败,则返回明确的错误提示信息(例如 "Not Found")

相比于之前的 Echo Server,DictServer 的本质变化在于服务端首次引入了真正的业务处理逻辑。通过这个项目,你将体会到网络 I/O 与具体业务逻辑是如何在工程中进行优雅解耦与协同工作的

二、整体结构

在明确了网络词典服务器的基本定义后,我们需要从架构层面设计其整体结构。该服务由客户端和服务端两个独立的进程组成,它们通过底层的 UDP 套接字进行非连接性的离散数据交互


1. 工作流程

DictServer 的完整通信序列遵循严格的 "请求-响应" 业务闭环。客户端与服务端之间的时序交互流程具体可以划分为以下几个阶段:

  1. 客户端发起输入:用户在客户端键入待查询的英文单词字符串

  2. 客户端打包发送:客户端利用已创建的套接字,调用 sendto 将单词文本,服务端 IP 和端口信息,封装进 UDP 数据报并投递至网络

  3. 服务端接收请求:服务端进程在指定的端口上通过 recvfrom 接口保持阻塞等待。当网卡接收到该 UDP 数据报后,内核唤醒服务端进程读取字符串

  4. 服务端业务检索:服务端将接收到的字符串输入到核心词典业务模块中。该模块在内存数据集中查找该单词的中文翻译

  5. 服务端发送响应:完成检索后,服务端调用 sendto 将翻译结果(或未找到的错误提示)作为响应报文发送回客户端

  6. 客户端呈现结果:客户端在发送完请求后立即进入 recvfrom 阻塞等待状态。收到服务端的响应报文后,客户端解析并格式化输出翻译结果,完成一次查询


2. 为什么使用 UDP

尽管 TCP 协议在传输层提供了高可靠性的保障,但在 DictServer 这种特定的业务场景下,UDP 协议往往是更具优势的技术选择,原因在于以下几个技术特质:

  • 契合业务特征:词典查询属于典型的离散型事务。客户端与服务端之间不需要建立长期的、持续的数据流通道,单次交互在发送完请求并收到响应后即告结束

  • 传输需求:常规的翻译业务,其数据量通常在几十字节到几百字节之间。这远远小于以太网标准。这意味着,无论是请求还是响应,都可以在一个独立的 UDP 数据报中完整容纳,规避了 TCP 协议中拆包与粘包问题

  • 低开销:UDP 协议报头仅占 8 个字节(TCP 报头至少 20 字节)。并且服务端不需要为所有客户端维护连接状态机和缓冲区。这种特性使得服务端能够以极高的吞吐量和极低的开销,同时响应海量客户端交织发送的查询请求

三、词典数据模块

为了实现业务逻辑与网络传输的解耦,我们首先构建一个独立的 Dictionary 类。该类专门负责本地词典文件的解析、内存数据的维护以及向外提供同步的查词接口


1. Dictionary 类的设计

为了确保词典在应对高并发查询时拥有极高的检索效率,我们选择基于哈希表结构作为底层存储方案。其平均查找时间复杂度为 O(1),能够保证在大数据量下依然具备稳定的响应速度

我们假定本地存储的词典文件名为 dict.txt,其内部的键值对采用典型的 "英文单词:中文翻译" 的行格式进行组织,例如:

复制代码
apple:苹果
banana:香蕉
computer:计算机
network:网络

以下是 Dictionary 类的完整声明与实现。它利用标准文件流逐行读取文本,并通过字符串切分算法将单词与翻译提取出来,存入内存哈希表中

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <unordered_map>
#include <fstream>
#include <sstream>

class Dictionary {
public:
    // 构造函数:指定本地词典文件路径
    Dictionary(const std::string& dict_path = "./dict.txt") 
        : _dict_path(dict_path) {}

    // 加载词典文件到内存哈希表中
    bool Load() {
        std::ifstream in(_dict_path);
        if (!in.is_open()) {
            std::cerr << "Open dictionary file failed: " << _dict_path << std::endl;
            return false;
        }

        std::string line;
        while (std::getline(in, line)) {
            if (line.empty()) continue;

            // 寻找分隔符 ':'
            auto pos = line.find(':');
            if (pos == std::string::npos) continue; // 忽略格式错误的行

            // 切分键值对
            std::string word = line.substr(0, pos);
            std::string translation = line.substr(pos + 1);

            if (!word.empty() && !translation.empty()) {
                _dict_map[word] = translation;
            }
        }

        in.close();
        std::cout << "Load dictionary success. Total words: " << _dict_map.size() << std::endl;
        return true;
    }

    // 查词接口:输入英文,返回中文翻译
    std::string Translate(const std::string& word) const {
        auto it = _dict_map.find(word);
        if (it == _dict_map.end()) {
            return "Not Found (该词未收录)";
        }
        return it->second;
    }

private:
    std::string _dict_path;                                  // 文件路径
    std::unordered_map<std::string, std::string> _dict_map;  // 内存哈希表
};

四、网络与业务解耦

在软件工程中,优秀的系统架构应当满足 "单一职责原则"。网络服务器类不应该感知具体的业务是什么(无论是做回显、查词典还是做计算)

为了达成这一目标,我们重新设计服务端类 DictServer。该类将转变为一个单纯的网络传输引擎 ,通过回调函数机制,将实际的数据处理权移交给外部调用者

基于回调函数的 DictServer 设计

  • 解耦逻辑:DictServer 只负责三件事:接收网络数据、调用注册的回调函数、将回调函数的输出结果发送回客户端

  • 业务定义:具体要对数据做什么处理,由主函数入口在实例化服务器时,将 Dictionary::Translate 方法打包成回调函数注册给 DictServer

DictServer 类实现

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

class DictServer {
public:
    // 定义回调函数的类型:接收标准的 string 请求,返回 string 响应
    using func_t = std::function<std::string(const std::string&)>;

    // 构造函数:传入端口、IP 以及业务处理回调函数
    DictServer(uint16_t port, func_t cb, const std::string& ip = "0.0.0.0")
        : _sockfd(-1), _port(port), _ip(ip), _cb(cb), _is_running(false) {}

    ~DictServer() {
        if (_sockfd >= 0) close(_sockfd);
    }

    // 初始化网络环境
    bool Init() {
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0) {
            std::cerr << "Create socket failed" << std::endl;
            return false;
        }

        struct sockaddr_in local;
        std::memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        if (inet_pton(AF_INET, _ip.c_str(), &local.sin_addr) <= 0) {
            return false;
        }

        if (bind(_sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
            std::cerr << "Bind failed" << std::endl;
            return false;
        }
        return true;
    }

    // 主服务事件循环
    void Start() {
        _is_running = true;
        char in_buffer[1024];

        std::cout << "DictServer running" << std::endl;

        while (_is_running) {
            struct sockaddr_in client_addr;
            socklen_t client_len = sizeof(client_addr);

            // 1. 接收客户端的原始请求数据(例如单词字符串)
            ssize_t n = recvfrom(_sockfd, in_buffer, sizeof(in_buffer) - 1, 0,
                                 (struct sockaddr*)&client_addr, &client_len);
            if (n < 0) continue;
            
            in_buffer[n] = '\0';
            std::string request = in_buffer;

            // 2. 核心解耦点:调用注册的回调函数处理数据,获取业务层响应
            // 此时 DictServer 内部并不知道这个 _cb 是在查词典,它只是执行并拿到结果
            std::string response = _cb(request);

            // 3. 将业务层处理完的结果发送回对端客户端
            sendto(_sockfd, response.c_str(), response.size(), 0,
                   (struct sockaddr*)&client_addr, client_len);
        }
    }

private:
    int _sockfd;
    uint16_t _port;
    std::string _ip;
    func_t _cb;            // 业务处理回调函数
    bool _is_running;
};

五、服务端与客户端组装

1. 服务端组装

通过上述设计,我们在主程序中只需要将 Dictionary 模块与 DictServer 网络模块像搭积木一样组合在一起即可

cpp 复制代码
#include "Dictionary.hpp"
#include "DictServer.hpp"
#include "../../Logger.hpp"

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " localport" << std::endl;
}

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }

    EnableConsoleLogStrategy();
    uint16_t port = std::stoi(argv[1]);

    Dictionary dict("dict.txt");
    DictServer dserver(port, [&dict](const std::string& word){
        return dict.Translate(word);
    });

    if (dserver.Init())
        dserver.Start();

    return 0;
}

2. 客户端组装

在完成服务端的网络引擎与词典业务解耦设计后,客户端的实现相对简单。客户端的核心职责是作为用户与远程服务之间的交互桥梁,其生命周期主要由输入请求、同步发送、阻塞接收、呈现结果四个线性步骤构成

以下是基于上述四个步骤构建的完整 C++ 客户端代码

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

// 打印正确的命令行启动范式
void Usage(const std::string& proc) {
    std::cout << "Usage:\n\t" << proc << " server_ip server_port\n" << std::endl;
}

int main(int argc, char* argv[]) {
    // 限制必须输入服务端的公网/局域网 IP 与开放的端口
    if (argc != 3) {
        Usage(argv[0]);
        return 1;
    }

    std::string server_ip = argv[1];
    uint16_t server_port = std::atoi(argv[2]);

    // 创建 UDP 套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        std::cerr << "Client: Create socket failed." << std::endl;
        return 2;
    }

    // 填充远端 DictServer 的网络地址
    struct sockaddr_in server_addr;
    std::memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(server_port); // 主机字节序转换为网络字节序
    if (inet_pton(AF_INET, server_ip.c_str(), &server_addr.sin_addr) <= 0) {
        std::cerr << "Client: Invalid server IP address format." << std::endl;
        close(sockfd);
        return 3;
    }

    std::string word;
    char buffer[1024];


    // 进入业务循环
    while (true) {
        // 输入请求
        std::cout << "DictClient# ";
        std::getline(std::cin, word);

        if (word.empty()) continue;
        if (word == "q" || word == "quit") {
            std::cout << "Client exit." << std::endl;
            break;
        }

        // 将请求内容发送至服务端
        ssize_t sent_bytes = sendto(sockfd, word.c_str(), word.size(), 0,
                                    (struct sockaddr*)&server_addr, sizeof(server_addr));
        if (sent_bytes < 0) {
            std::cerr << "Client: Send request failed." << std::endl;
            break;
        }

        // 阻塞等待服务端的查询响应
        struct sockaddr_in from_addr;
        socklen_t from_len = sizeof(from_addr);
        
        ssize_t received_bytes = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                                          (struct sockaddr*)&from_addr, &from_len);
        
        if (received_bytes > 0) {
            buffer[received_bytes] = '\0';
            // 打印结果
            std::cout << "Server Response -> " << buffer << "\n" << std::endl;
        } else {
            std::cerr << "Client: Receive response error." << std::endl;
            break;
        }
    }

    // 关闭套接字,交还文件描述符资源
    close(sockfd);
    return 0;
}

六、程序测试

编写完服务端与客户端的全部代码后,我们需要在真实的 Linux 环境中对其进行编译、部署并进行功能验证。通过这个测试,我们将直观地观察到 UDP 的通信特质以及应用层解耦的成果。

准备工作

在运行程序之前,首先在服务器可执行程序的同级目录下创建一个名为 dict.txt 的文本文件,并写入以下测试数据

bash 复制代码
apple:苹果
banana:香蕉
cat:猫
dog:狗
network:网络
linux:一种优雅的操作系统

1. 编译运行

使用 g++ 命令行工具分别对服务端和客户端进行编译

(1) 编译源文件

在终端中执行以下编译命令:

bash 复制代码
# 编译服务端(将网络引擎与主函数编译为可执行文件 server)
g++ -std=c++11 main.cc -o server

# 编译客户端(编译为可执行文件 client)
g++ -std=c++11 dict_client.cc -o client

(2) 启动服务端

首先拉起服务端程序,让其在后台或当前终端保持监听状态。这里我们让其监听 8080 端口

bash 复制代码
./server

运行输出实例:

此时,服务端已经成功加载了词典文件,并在 8080 端口上阻塞,等待客户端的请求

(3) 启动客户端

打开另一个新的 Linux 终端(如果在同一台机器上测试,目标 IP 填回环地址 127.0.0.1 即可):

cpp 复制代码
./client 127.0.0.1 8080

2. 查询测试

现在,我们可以通过客户端交互界面向服务器投递单词查询请求,并观察双端的联动表现。

(1) 客户端查询正常单词

我们在客户端连续输入词典中已收录的单词:

(2) 查询未收录单词

输入一个词典中不存在的单词:

(3) 观察服务端日志

回到服务端的终端窗口,你会发现网络引擎虽然不关心具体的业务逻辑,但在数据流动时它清晰地记录了每一次交互。更重要的是,你可以观察到客户端随机分配的临时端口

仔细观察可以发现,同一个客户端在连续的三次业务请求中,其源端口号固定为 49297(该数字由系统随机分配)。这证明虽然 UDP 是无连接的,但只要客户端的套接字生命周期未结束,内核为其隐式绑定的临时端口就会一直维持不变

总结

综上所述,我们已经基于 UDP 完成了第一个真正意义上的 "网络服务" 程序。相比上一节的 EchoServer,DictServer 已经开始具备了服务器程序最核心的结构:

接收请求 → 处理业务 → 返回响应

与此同时,我们也进一步理解了 UDP 服务端的工作模型:服务器并不需要维护复杂连接状态,而是围绕一个长期运行的事件循环,不断接收客户端请求并进行处理

而在工程实现上,无论是词典数据结构、日志输出,还是 UdpServer 类封装,本质上都在说明一件事:

真正的网络程序,不仅仅是 "会发消息",而是具备 "持续对外提供服务" 的能力

但目前我们的服务仍然存在一个明显问题:

一次请求只能对应一个客户端,客户端之间彼此完全隔离

那么,如果我们希望多个客户端能够同时在线、互相收发消息,真正形成一个 "多人通信系统" 呢

在下一篇中,我们将正式开始实现基于 UDP 的聊天室程序,并进一步引入线程池与并发处理机制,开始真正迈入高并发网络服务的世界

相关推荐
辣椒思密达7 小时前
住宅IP纯净度评估方法:黑名单、风险评分与历史行为检测
运维·服务器·网络
Terasic友晶科技7 小时前
答疑解惑|为DE25-Nano开发板配置Linux kernel时.config文件没有起作用是什么原因?
linux·服务器·fpga开发·linux kernel·de25-nano
爱写代码的小朋友8 小时前
基于多约束遗传算法的中小学排座位优化模型研究
linux·人工智能·算法
XiYang-DING8 小时前
【Java EE】TCP—延时应答
网络·tcp/ip·java-ee
程序员榴莲8 小时前
网络编程入门 Python Socket 实现一个简单的用户认证系统
服务器·网络·python
ZStack开发者社区8 小时前
全球化2.0 | ZStack亮相印尼云计算与数据中心大会 以新一代云底座助力数字印尼建设
服务器·云计算·gpu算力
甲方大人请饶命8 小时前
Java-网络编程和反射
网络
DFT计算杂谈8 小时前
VASP新手入门: IVDW 色散修正参数
linux·运维·服务器·python·算法
Oll Correct8 小时前
实验二十五:从IPv4向IPv6过渡所使用的隧道技术
网络·笔记