socket套接字-UDP(中)
socket套接字-UDP(上)https://blog.csdn.net/Small_entreprene/article/details/147465441?fromshare=blogdetail&sharetype=blogdetail&sharerId=147465441&sharerefer=PC&sharesource=Small_entreprene&sharefrom=from_link
UDP服务器的搭建
在之前的博客中,我们已经完成了一个功能完整的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::function<std::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_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&dict](const std::string &word, InetAddr&cli)->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::function<std::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::function<std::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:// 构造函数,初始化字典文件路径,默认为 defaultdictDict(const std::string &path = defaultdict) : _dict_path(path){}// 加载字典文件的方法bool LoadDict(){// 打开字典文件std::ifstream in(_dict_path);// 如果文件打开失败,输出错误日志并返回 falseif (!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_map<std::string, std::string> _dict;
};
dictionary.txt
apple: 苹果
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_ptr<UdpServer> usvr = std::make_unique<UdpServer>(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; // 设置地址族为 IPv4server.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服务器已经从一个简单的消息收发工具,进化成了一个具有实用翻译功能的应用程序。这个过程让我深刻体会到了模块化设计和回调机制的强大之处。它们不仅让我们的代码更加清晰和易于维护,还极大地提高了代码的可扩展性和复用性。
在未来的开发中,我计划继续优化这个服务器。比如,增加对更多语言的支持,或者让服务器能够同时处理多个客户端的请求。另外,我还想尝试将这个服务器部署到云平台上,让更多的用户能够使用这个翻译服务。
如果你对这个项目感兴趣,或者有任何建议和想法,欢迎随时与我交流。让我们一起在编程的世界里不断探索,创造更多有趣的作品!
下期预告:群聊实现及补充收尾!!!😝