目录

一、什么是 DictServer

1. 为什么需要网络服务

2. DictServer介绍

二、整体结构

1. 工作流程

2. 为什么使用 UDP

三、词典数据模块

1. Dictionary 类的设计

四、网络与业务解耦

五、服务端与客户端组装

1. 服务端组装

2. 客户端组装

六、程序测试

1. 编译运行

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

#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 类实现 

#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 网络模块像搭积木一样组合在一起即可

#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++ 客户端代码

#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 的文本文件,并写入以下测试数据

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

1. 编译运行

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

(1) 编译源文件

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

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

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

(2) 启动服务端

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

./server

运行输出实例:

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

(3) 启动客户端

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

./client 127.0.0.1 8080

2. 查询测试

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

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

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

(2) 查询未收录单词

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

(3) 观察服务端日志

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

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

总结

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

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

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

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

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

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

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

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

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

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐