socket 套接字 - UDP [ 中 ]

socket 套接字 - UDP [ 中 ] socket 套接字 - UDP [ 上 ]https://blog.csdn.net/Small_entreprene/article/details/147465441?fromshareblogdetailsharetypeblogdetailsharerId147465441sharereferPCsharesourceSmall_entreprenesharefromfrom_linkUDP 服务器的搭建在之前的博客中我们已经完成了一个功能完整的 UDP 服务器基础架构。通过UdpServer类的实现我们能够轻松创建一个 UDP 服务器。该服务器会监听指定端口接收客户端发送的消息并通过回调函数对消息进行处理最后将处理结果返回给客户端。代码解析在UdpServer.hpp文件中我们定义了UDP服务器的核心逻辑。我们通过socket系统调用创建套接字并使用bind将套接字与指定端口绑定。在Start方法中服务器进入消息循环不断接收客户端的消息并调用回调函数处理消息。void Start() { _isrunning true; while (_isrunning) { char buffer[1024]; struct sockaddr_in peer; socklen_t len sizeof(peer); ssize_t s recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)peer, len); if (s 0) { InetAddr client(peer); buffer[s] 0; std::string result _func(buffer, client); sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr*)peer, len); } } }这一部分的代码实现了服务器的基础功能但此时的服务器功能比较单一只能对消息进行简单的回显处理。回调机制的引入在最初的版本中服务器的功能是固定的只能对消息进行简单的回显处理。这存在一个很大的局限性 —— 服务器的功能是固定的如果想增加新的功能就必须修改服务器的内部代码。我思考了一下如果我想要在将来给服务器增加新的功能比如翻译功能、计算功能或者其他什么功能那是不是每次都要修改服务器的内部代码呢这显然不符合我们追求的模块化、可扩展的设计理念。于是我灵机一动想出了一个好办法 ——引入回调机制。这个想法其实来源于我们平时使用的很多软件库它们通过回调函数允许用户自定义行为。在我们的 UDP 服务器中我定义了一个回调函数类型using func_t std::functionstd::string(const std::string )这个函数类型表示我们的回调函数将接收一个字符串作为输入并返回一个字符串作为输出。然后我在UdpServer类的构造函数中增加了一个func_t类型的参数这样在创建服务器的时候就可以传入我们想要的处理逻辑了。在服务器的消息循环中每当我接收到客户端发送的消息时我就可以直接调用这个回调函数将消息交给它处理然后把处理结果发送回客户端。std::string result _func(buffer); // 调用回调函数进行处理 sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr *)peer, len);这样一来我们的 UDP 就服务器变得非常灵活了。只要实现一个符合func_t类型的回调函数就可以给服务器增加新的功能而不用再去修改服务器的核心代码了。翻译功能的实现有了回调机制之后我就可以开始实现翻译功能了。这个功能的想法其实来源于我平时学习英语的时候经常会遇到不认识的单词需要查字典。我就想要是能有个服务器可以让我把不认识的单词发给它它就能直接返回单词的中文意思那该多好啊于是我开始构思这个翻译功能的实现。首先我需要一个字典来存储单词和对应的中文翻译。我决定用一个简单的文本文件来作为字典文件文件的每一行就是一个单词和它的翻译中间用特定的分隔符隔开比如apple: 苹果。然后我创建了一个Dict类来管理这个字典。这个类有一个方法LoadDict用来从文件中加载字典数据。在加载的时候我会逐行读取文件内容然后按照分隔符把单词和翻译分开存到一个unordered_map中方便后续查询。bool LoadDict() { std::ifstream in(_dict_path); if (!in.is_open()) { LOG(LogLevel::DEBUG) 打开字典: _dict_path 错误; return false; } std::string line; while (std::getline(in, line)) { auto pos line.find(sep); if (pos std::string::npos) { LOG(LogLevel::WARNING) 解析: line 失败; continue; } std::string english line.substr(0, pos); std::string chinese line.substr(pos sep.size()); if (english.empty() || chinese.empty()) { LOG(LogLevel::WARNING) 没有有效内容: line; continue; } _dict.insert(std::make_pair(english, chinese)); LOG(LogLevel::DEBUG) 加载: line; } in.close(); return true; }接着我实现了一个Translate方法它接收一个单词作为输入然后在字典中查找对应的翻译。如果找到了就返回翻译结果如果没有找到就返回 None。std::string Translate(const std::string word, InetAddr client) { auto iter _dict.find(word); if (iter _dict.end()) { LOG(LogLevel::DEBUG) 进入到了翻译模块, [ client.Ip() : client.Port() ]# word -None; return None; } LOG(LogLevel::DEBUG) 进入到了翻译模块, [ client.Ip() : client.Port() ]# word - iter-second; return iter-second; }最后在main函数中我创建了Dict对象并调用LoadDict方法加载字典。然后我创建了UDP服务器对象并将Dict的Translate方法作为回调函数传递给服务器。int main(int argc, char *argv[]) { if(argc ! 2) { std::cerr Usage: argv[0] port std::endl; return 1; } uint16_t port std::stoi(argv[1]); Enable_Console_Log_Strategy(); Dict dict; dict.LoadDict(); std::unique_ptrUdpServer usvr std::make_uniqueUdpServer(port, [dict](const std::string word, InetAddrcli)-std::string{ return dict.Translate(word, cli); }); usvr-Init(); usvr-Start(); return 0; }这样当客户端发送一个单词给服务器时服务器就会调用Translate方法查找单词的翻译并将结果返回给客户端。网络地址的封装在实现翻译功能的过程中我遇到了一个小问题。我想在服务器的日志中记录每个客户端的 IP 地址和端口号这样我就可以知道是谁发来的单词。但是我发现每次处理客户端消息的时候都要从sockaddr_in结构体中提取IP和端口号然后再转换为字符串格式这样显得有点麻烦。问题的提出在早期的代码中每次收到客户端的消息后我们需要手动从sockaddr_in结构体中提取 IP 地址和端口号并将其转换为便于打印和记录的字符串形式。例如int peer_port ntohs(peer.sin_port); std::string peer_ip inet_ntoa(peer.sin_addr);这种做法存在以下问题代码重复每次处理客户端消息时都需要重复这段提取和转换代码导致代码冗余增加了维护成本。可读性差直接操作sockaddr_in结构体的成员变量使得代码的可读性降低对于不熟悉网络编程的开发者来说理解起来有一定难度。扩展性差如果后续需要增加与网络地址相关的其他功能例如地址验证、地址转换等这种分散的处理方式会使代码难以扩展和维护。封装InetAddr类为了解决上述问题我决定封装一个InetAddr类来管理网络地址信息。这个类的构造函数接收一个sockaddr_in结构体然后在内部将IP地址和端口号提取出来并转换为方便使用的格式。InetAddr(struct sockaddr_in addr) : _addr(addr) { _port ntohs(_addr.sin_port); _ip inet_ntoa(_addr.sin_addr); }然后我为这个类提供了Port和Ip两个方法用来获取端口号和IP地址。uint16_t Port() {return _port;} std::string Ip() {return _ip;}封装后的优势通过封装InetAddr类我们获得了以下优势代码简化在处理客户端消息时只需创建一个InetAddr对象即可方便地获取客户端的IP地址和端口号无需重复编写提取和转换代码。例如封装前int peer_port ntohs(peer.sin_port); std::string peer_ip inet_ntoa(peer.sin_addr);封装后InetAddr client(peer); std::string ip client.Ip(); uint16_t port client.Port();提高可读性封装后的代码更加直观和清晰开发者可以更容易地理解代码的意图减少了理解成本。增强扩展性如果后续需要增加与网络地址相关的功能只需在InetAddr类中进行扩展而无需修改其他业务逻辑代码大大提高了代码的可维护性和可扩展性。回调机制的优化在最初的设计中我的回调函数只接收一个参数那就是客户端发送的消息。但是在实现翻译功能的时候我发现我还想在回调函数中使用客户端的IP地址和端口号比如在日志中记录这些信息。变化动机增加信息利用率在最初的回调机制中回调函数只能获取到客户端发送的消息内容但无法获取到发送该消息的客户端的网络地址信息。这意味着在处理消息时我们无法根据客户端的地址进行个性化的处理或记录限制了功能的灵活性和丰富度。满足功能需求以翻译功能为例我们希望能够记录是哪个客户端发送了哪个单词进行查询这需要在回调函数中同时获取消息内容和客户端地址信息。此外像访问统计、基于客户端地址的权限控制等功能的实现也都需要在回调函数中获取客户端的地址信息。优化过程于是我决定对回调机制进行优化让回调函数可以接收更多的参数。我修改了回调函数的类型定义让它可以接收一个InetAddr对象作为第二个参数。using func_t std::functionstd::string(const std::string, InetAddr);然后在服务器的Start方法中当调用回调函数的时候我将InetAddr对象作为参数传递进去。InetAddr client(peer); buffer[s] 0; std::string result _func(buffer, client);这样在回调函数中我就可以同时获取到客户端发送的消息以及客户端的网络地址信息了。代码注释与详细解释UdpServer.hpp文件#pragma once #include iostream #include string #include functional #include strings.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include Log.hpp #include InetAddr.hpp using namespace LogModule; // 定义回调函数类型用于处理接收到的消息 // 回调函数接收两个参数消息内容和客户端地址返回处理结果 using func_t std::functionstd::string(const std::string, InetAddr); // 定义默认的无效套接字文件描述符值 const int defaultfd -1; // UDP 服务器类 class UdpServer { public: // 构造函数初始化服务器端口和消息处理回调函数 UdpServer(uint16_t port, func_t func) : _sockfd(defaultfd), // 初始化套接字文件描述符为默认值 _port(port), // 设置服务器端口 _isrunning(false), // 初始化运行状态为停止 _func(func) // 设置消息处理回调函数 { } // 初始化服务器创建套接字并绑定端口 void Init() { // 1. 创建套接字 // 使用 socket 函数创建一个 UDP 套接字 // AF_INET 表示使用 IPv4 地址族 // SOCK_DGRAM 表示使用 UDP 协议 _sockfd socket(AF_INET, SOCK_DGRAM, 0); if (_sockfd 0) { // 如果创建套接字失败记录致命错误日志并退出程序 LOG(LogLevel::FATAL) socket error!; exit(1); } // 记录创建套接字成功的日志 LOG(LogLevel::INFO) socket success, sockfd : _sockfd; // 2. 绑定套接字信息IP 和端口 // 2.1 填充 sockaddr_in 结构体用于指定绑定的地址信息 struct sockaddr_in local; bzero(local, sizeof(local)); // 清零结构体避免未定义行为 local.sin_family AF_INET; // 设置地址族为 IPv4 // 将本地端口号转换为网络字节序大端字节序 local.sin_port htons(_port); // 设置本地 IP 地址为 INADDR_ANY表示监听所有网络接口上的连接 // 这样服务器可以接收来自任何 IP 地址的客户端请求 local.sin_addr.s_addr INADDR_ANY; // 调用 bind 函数将套接字绑定到指定的地址和端口 int n bind(_sockfd, (struct sockaddr *)local, sizeof(local)); if (n 0) { // 如果绑定失败记录致命错误日志并退出程序 LOG(LogLevel::FATAL) bind error; exit(2); } // 记录绑定成功的日志 LOG(LogLevel::INFO) bind success, sockfd : _sockfd; } // 启动服务器进入消息处理循环 void Start() { _isrunning true; // 设置服务器运行状态为正在运行 while (_isrunning) { char buffer[1024]; // 用于存储接收到的消息缓冲区 struct sockaddr_in peer; // 用于存储发送端的地址信息 socklen_t len sizeof(peer); // 发送端地址结构体的长度 // 1. 接收消息 // 使用 recvfrom 函数接收 UDP 消息 // 参数包括套接字文件描述符、缓冲区、缓冲区大小、消息标志、发送端地址结构体指针和地址结构体长度指针 ssize_t s recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)peer, len); if (s 0) { // 创建 InetAddr 对象封装发送端的地址信息 InetAddr client(peer); // 在缓冲区末尾添加字符串终止符确保数据以 C 风格字符串形式存储 buffer[s] 0; // 调用回调函数处理消息并获取处理结果 // 回调函数接收消息内容和客户端地址作为参数 std::string result _func(buffer, client); // 2. 发送响应消息 // 使用 sendto 函数将处理结果发送回客户端 // 参数包括套接字文件描述符、消息内容、消息长度、消息标志、发送端地址结构体指针和地址结构体长度 sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr *)peer, len); } } } // 析构函数 ~UdpServer() { } private: int _sockfd; // 套接字文件描述符 uint16_t _port; // 服务器端口号 bool _isrunning; // 服务器运行状态标志 func_t _func; // 消息处理回调函数 };回调函数的定义与使用通过定义func_t作为回调函数类型并在UdpServer类中使用实现了将消息处理逻辑与服务器通信逻辑的分离。这样用户可以通过传入不同的回调函数轻松地为服务器增加不同的功能。网络地址的封装通过InetAddr类对网络地址信息进行封装使得在处理客户端消息时能够更加方便地获取和使用客户端的IP地址和端口号。Dict.hpp文件#pragma once #include iostream #include fstream #include string #include unordered_map #include Log.hpp #include InetAddr.hpp // 定义字典文件的默认路径为当前目录下的 dictionary.txt 文件 const std::string defaultdict ./dictionary.txt; // 定义字典文件中单词和翻译之间的分隔符为 : const std::string sep : ; // 引入 LogModule 命名空间便于使用日志功能 using namespace LogModule; class Dict { public: // 构造函数初始化字典文件路径默认为 defaultdict Dict(const std::string path defaultdict) : _dict_path(path) { } // 加载字典文件的方法 bool LoadDict() { // 打开字典文件 std::ifstream in(_dict_path); // 如果文件打开失败输出错误日志并返回 false if (!in.is_open()) { LOG(LogLevel::DEBUG) 打开字典: _dict_path 错误; return false; } // 定义一个字符串变量用于逐行读取文件内容 std::string line; // 循环逐行读取文件 while (std::getline(in, line)) { // 查找分隔符在当前行中的位置 auto pos line.find(sep); // 如果未找到分隔符说明该行格式不符合要求输出警告日志并跳过该行 if (pos std::string::npos) { LOG(LogLevel::WARNING) 解析: line 失败; continue; } // 提取分隔符前的部分作为单词 std::string english line.substr(0, pos); // 提取分隔符后的部分作为翻译 std::string chinese line.substr(pos sep.size()); // 如果单词或翻译为空说明内容无效输出警告日志并跳过该行 if (english.empty() || chinese.empty()) { LOG(LogLevel::WARNING) 没有有效内容: line; continue; } // 将单词和翻译存入字典中 _dict.insert(std::make_pair(english, chinese)); // 输出调试日志记录加载的单词和翻译 LOG(LogLevel::DEBUG) 加载: line; } // 关闭文件 in.close(); // 返回 true表示字典加载成功 return true; } // 翻译方法根据输入的单词和客户端地址返回翻译结果 std::string Translate(const std::string word, InetAddr client) { // 在字典中查找输入的单词 auto iter _dict.find(word); // 如果未找到该单词输出调试日志并返回 None if (iter _dict.end()) { LOG(LogLevel::DEBUG) 进入到了翻译模块, [ client.Ip() : client.Port() ]# word -None; return None; } // 如果找到该单词输出调试日志并返回对应的翻译 LOG(LogLevel::DEBUG) 进入到了翻译模块, [ client.Ip() : client.Port() ]# word - iter-second; return iter-second; } // 析构函数 ~Dict() { } private: // 字典文件的路径 std::string _dict_path; // 使用 unordered_map 存储单词和翻译的键值对键为单词值为翻译 std::unordered_mapstd::string, std::string _dict; };dictionary.txtapple: 苹果 banana: 香蕉 cat: 猫 dog: 狗 book: 书 pen: 笔 happy: 快乐的 sad: 悲伤的 hello: : 你好 run: 跑 jump: 跳 teacher: 老师 student: 学生 car: 汽车 bus: 公交车 love: 爱 hate: 恨 hello: 你好 goodbye: 再见 summer: 夏天 winter: 冬天字典文件的加载在LoadDict方法中通过读取字典文件并解析每一行的内容将单词及其对应的翻译存储到unordered_map中实现了字典数据的快速加载和高效查询。翻译功能的实现Translate方法通过在字典中查找指定的单词返回对应的翻译结果。如果找不到则返回“None”。InetAddr.hpp文件#pragma once #include iostream #include string #include sys/socket.h #include sys/types.h #include arpa/inet.h #include netinet/in.h // 网络地址和主机地址之间进行转换的类 class InetAddr { public: // 构造函数接收一个 sockaddr_in 结构体作为参数 // 该结构体通常由 socket API 返回包含网络地址信息 InetAddr(struct sockaddr_in addr) : _addr(addr) { // 使用 ntohs 将网络字节序的端口号转换为主机字节序 // 网络字节序通常是大端字节序big-endian而主机字节序可能是小端little-endian _port ntohs(_addr.sin_port); // 使用 inet_ntoa 将 4 字节的网络字节序 IP 地址转换为点分十进制的字符串形式 _ip inet_ntoa(_addr.sin_addr); } // 获取端口号的方法 uint16_t Port() { return _port; } // 获取 IP 地址字符串的方法 std::string Ip() { return _ip; } // 析构函数目前无特殊清理操作 ~InetAddr() {} private: // 存储原始的 sockaddr_in 结构体包含完整的网络地址信息 struct sockaddr_in _addr; // 存储转换后的 IP 地址字符串格式为 xxx.xxx.xxx.xxx std::string _ip; // 存储转换后的端口号为主机字节序 uint16_t _port; };网络地址信息的封装构造函数接收一个sockaddr_in结构体并从中提取出IP地址和端口号将其转换为便于使用的格式。通过Port和Ip方法可以方便地获取客户端的端口号和IP地址。测试代码UdpServer.cc#include iostream #include memory #include Dict.hpp // 翻译的功能 #include UdpServer.hpp // 网络通信的功能 // 测试用的默认消息处理函数 // 用于演示服务器的基本功能将接收到的消息前面加上 hello, 后返回 std::string defaulthandler(const std::string message) { std::string hello hello, ; hello message; return hello; } // 程序入口函数 int main(int argc, char *argv[]) { // 检查命令行参数是否正确需要提供端口号作为参数 if (argc ! 2) { std::cerr Usage: argv[0] port std::endl; return 1; } // 获取命令行参数中的端口号 uint16_t port std::stoi(argv[1]); // 启用控制台日志策略以便在控制台输出日志信息 Enable_Console_Log_Strategy(); // 创建字典对象用于提供翻译功能 Dict dict; // 加载字典文件准备翻译所需的数据 dict.LoadDict(); // 创建 UDP 服务器对象并指定端口号和消息处理回调函数 // 这里使用 lambda 表达式捕获字典对象以便在回调函数中调用其翻译方法 std::unique_ptrUdpServer usvr std::make_uniqueUdpServer(port, [dict](const std::string word, InetAddr cli) - std::string { return dict.Translate(word, cli); }); // 初始化服务器包括创建套接字和绑定端口等操作 usvr-Init(); // 启动服务器进入消息接收和处理循环 usvr-Start(); return 0; }测试代码UdpClient.cc#include iostream #include string #include cstring #include netinet/in.h #include arpa/inet.h #include sys/types.h #include sys/socket.h // UDP 客户端程序入口 int main(int argc, char *argv[]) { // 检查命令行参数是否正确需要提供服务器 IP 和端口号 if (argc ! 3) { std::cerr Usage: argv[0] server_ip server_port std::endl; return 1; } // 获取服务器 IP 和端口号 std::string server_ip argv[1]; uint16_t server_port std::stoi(argv[2]); // 1. 创建 UDP 套接字 int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { std::cerr socket error std::endl; return 2; } // 2. 填写服务器地址信息 struct sockaddr_in server; memset(server, 0, sizeof(server)); // 初始化内存为零 server.sin_family AF_INET; // 设置地址族为 IPv4 server.sin_port htons(server_port); // 将端口号转换为网络字节序 server.sin_addr.s_addr inet_addr(server_ip.c_str()); // 设置服务器 IP 地址 // 3. 与服务器进行通信的循环 while (true) { std::string input; std::cout Please Enter# ; std::getline(std::cin, input); // 从标准输入获取用户输入 // 发送消息到服务器 int n sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)server, sizeof(server)); (void)n; // 忽略发送返回值实际应用中应检查发送是否成功 // 接收服务器返回的消息 char buffer[1024]; struct sockaddr_in peer; socklen_t len sizeof(peer); int m recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)peer, len); if (m 0) { buffer[m] 0; // 确保接收缓冲区以 null 字符结尾 std::cout buffer std::endl; // 输出服务器返回的消息 } } return 0; }测试结果总结与展望通过这一系列的改进我们的 UDP 服务器已经从一个简单的消息收发工具进化成了一个具有实用翻译功能的应用程序。这个过程让我深刻体会到了模块化设计和回调机制的强大之处。它们不仅让我们的代码更加清晰和易于维护还极大地提高了代码的可扩展性和复用性。在未来的开发中我计划继续优化这个服务器。比如增加对更多语言的支持或者让服务器能够同时处理多个客户端的请求。另外我还想尝试将这个服务器部署到云平台上让更多的用户能够使用这个翻译服务。如果你对这个项目感兴趣或者有任何建议和想法欢迎随时与我交流。让我们一起在编程的世界里不断探索创造更多有趣的作品下期预告群聊实现及补充收尾