文章目录
-
- [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 为什么要封装
前面的代码直接用的是系统调用(socket、bind、recvfrom、sendto),每次都要填一堆参数,很容易写错。封装成一个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是必须的,用来接收数据 - 后面的
ip和port是可选的(默认值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生命周期,构造时创建,析构时关闭
UdpServer和UdpClient再次封装,提供更高层的接口
代码组织:
- 全局字典对象,服务器启动时加载
- 业务函数和main分离,保持main的简洁
- 使用智能指针管理对象生命周期
8.2 容易混淆的点
-
回调函数的参数为什么用指针:因为要输出结果。如果用返回值,只能返回一个东西;用指针输出参数,可以同时输出多个结果,而且调用者提供的字符串可以复用。
-
为什么用
buf->assign(tmp, read_size)而不是*buf = tmp:tmp是局部变量,函数返回后就销毁了。必须用assign把内容复制到调用者提供的字符串里。 -
RecvFrom的可选参数怎么实现的 :默认参数值设为NULL,函数内部判断if (ip != NULL)再填充。这样调用者可以选择性地接收发送方地址。 -
封装的意义不是为了炫技:是为了减少重复代码、降低出错率、提高可维护性。系统调用的参数很复杂,封装后调用者不需要记住每