目录
[一、什么是 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 的完整通信序列遵循严格的 "请求-响应" 业务闭环。客户端与服务端之间的时序交互流程具体可以划分为以下几个阶段:
-
客户端发起输入:用户在客户端键入待查询的英文单词字符串
-
客户端打包发送:客户端利用已创建的套接字,调用 sendto 将单词文本,服务端 IP 和端口信息,封装进 UDP 数据报并投递至网络
-
服务端接收请求:服务端进程在指定的端口上通过 recvfrom 接口保持阻塞等待。当网卡接收到该 UDP 数据报后,内核唤醒服务端进程读取字符串
-
服务端业务检索:服务端将接收到的字符串输入到核心词典业务模块中。该模块在内存数据集中查找该单词的中文翻译
-
服务端发送响应:完成检索后,服务端调用 sendto 将翻译结果(或未找到的错误提示)作为响应报文发送回客户端
-
客户端呈现结果:客户端在发送完请求后立即进入 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 的聊天室程序,并进一步引入线程池与并发处理机制,开始真正迈入高并发网络服务的世界
