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服務器已經從一個簡單的消息收發工具,進化成了一個具有實用翻譯功能的應用程序。這個過程讓我深刻體會到了模塊化設計和回調機制的強大之處。它們不僅讓我們的代碼更加清晰和易于維護,還極大地提高了代碼的可擴展性和復用性。
在未來的開發中,我計劃繼續優化這個服務器。比如,增加對更多語言的支持,或者讓服務器能夠同時處理多個客戶端的請求。另外,我還想嘗試將這個服務器部署到云平臺上,讓更多的用戶能夠使用這個翻譯服務。
如果你對這個項目感興趣,或者有任何建議和想法,歡迎隨時與我交流。讓我們一起在編程的世界里不斷探索,創造更多有趣的作品!
下期預告:群聊實現及補充收尾!!!😝