開篇:從 “回顯” 到 “字典”,核心變在哪?
上一篇我們實現了 Echo 服務器 —— 網絡層和業務層是 “綁死” 的:網絡層收到數據后,直接把原數據發回去。但實際開發中,業務邏輯會復雜得多(比如查字典、查天氣),如果每次改業務都要動網絡代碼,效率太低。
這篇的核心目標:用 “解耦” 的思想,把 UDP 服務器改造成字典服務—— 客戶端輸入英文單詞,服務器返回中文翻譯。你會學到:如何封裝業務邏輯(字典加載與查詢)、如何用 C++ 函數對象(std::function
)分離網絡層和業務層,以及如何封裝 Socket 操作讓代碼更復用。
一、先搞懂:字典服務器的核心流程
字典服務器的邏輯比 Echo 稍復雜,但很清晰:
服務器啟動時,加載
dict.txt
(存 “apple: 蘋果” 這類鍵值對)到內存(用unordered_map
存儲,查詢更快);客戶端發送英文單詞(如 “apple”);
服務器接收單詞后,查內存中的字典,得到中文翻譯(如 “蘋果”);
服務器把翻譯結果發回客戶端。
整個流程中,網絡層只負責 “收發數據”,業務層只負責 “查字典”,兩者互不干擾 —— 這就是解耦的精髓。
二、核心代碼拆解:從字典類到解耦的服務器
我們分三部分講:字典業務類(Dict
)、解耦的 UDP 服務器(UdpServer
)、封裝版 Socket(可選,提升代碼復用性)。
1. 第一步:封裝字典業務 ——Dict
類
首先實現字典的 “加載” 和 “查詢” 功能,這個類完全不涉及網絡操作,純業務邏輯。
(1)dict.txt
文件格式
先準備一個簡單的字典文件,每行是 “英文:中文”(注意冒號后有空格):
apple: 蘋果banana: 香蕉cat: 貓dog: 狗book: 書happy: 快樂的hello: 你好goodbye: 再見
(2)Dict
類代碼實現
#pragma once
#include <iostream>
#include <string>
#include <fstream> // 用于讀取文件
#include <unordered_map> // 用于存儲字典(哈希表,查詢O(1))// 分隔符:dict.txt里是“英文: 中文”,所以分隔符是“: ”
const std::string sep = ": ";class Dict {
public:// 構造函數:傳入字典文件路徑,初始化時加載字典Dict(const std::string &confpath) : _confpath(confpath) {LoadDict(); // 加載字典到內存}// 核心方法:查詢單詞,返回翻譯(未查到返回“Unknown”)std::string Translate(const std::string &key) {auto iter = _dict.find(key); // 哈希表查詢if (iter == _dict.end()) {return "Unknown"; // 未找到}return iter->second; // 返回中文翻譯}private:// 私有方法:加載字典文件到_unordered_mapvoid LoadDict() {std::ifstream in(_confpath); // 打開文件if (!in.is_open()) { // 檢查文件是否打開成功std::cerr << "open dict file error: " << _confpath << 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;}// 截取英文(key)和中文(value)std::string key = line.substr(0, pos); // 從0到pos的子串(英文)std::string value = line.substr(pos + sep.size()); // 分隔符后的子串(中文)_dict.insert(std::make_pair(key, value)); // 插入哈希表}in.close(); // 關閉文件std::cout << "load dict success! total words: " << _dict.size() << std::endl;}private:std::string _confpath; // 字典文件路徑std::unordered_map<std::string, std::string> _dict; // 存儲字典的哈希表
};
通俗解釋:
LoadDict()
:把dict.txt
的內容讀到_dict
里,就像把 “單詞 - 翻譯” 存到一本 “快速查詢手冊” 里,以后查單詞不用再讀文件,直接查手冊(內存),速度快。Translate()
:給一個英文單詞(key),查手冊,有就返回翻譯,沒有就返回 “Unknown”。為什么用
unordered_map
?因為它是哈希表,查詢速度是 O (1)(瞬間查到),如果用vector
,查詢要遍歷所有元素,單詞多了會很慢。
2. 第二步:解耦 UDP 服務器 —— 用std::function
分離網絡與業務
上一篇的UdpServer
是 “網絡層 + 業務層” 綁定的(直接回顯),這篇我們改造它:讓UdpServer
只負責 “收發數據”,業務邏輯(查字典)通過 “函數對象” 傳進來 —— 以后想改業務(比如改成天氣查詢),只需要傳一個新的函數,不用動UdpServer
的代碼。
(1)改造后的UdpServer
類核心代碼
#pragma once
// 省略頭文件(和上一篇類似,增加#include <functional>)
#include "nocopy.hpp"
#include "Log.hpp"
#include "Comm.hpp"
#include "InetAddr.hpp"const static uint16_t defaultport = 8888;
const static int defaultfd = -1;
const static int defaultsize = 1024;// 關鍵:定義函數對象類型func_t
// 輸入:客戶端的請求(req,如“apple”)
// 輸出:服務器的響應(resp,如“蘋果”)
using func_t = std::function<void(const std::string &req, std::string *resp)>;class UdpServer : public nocopy {
public:// 構造函數:傳入業務邏輯函數(func)和端口UdpServer(func_t func, uint16_t port = defaultport) : _func(func), _port(port), _sockfd(defaultfd) {}// Init()方法:和上一篇完全一樣(創建socket、綁定)void Init() {// 代碼和上一篇相同,省略...}// Start()方法:改造業務邏輯調用void Start() {char buffer[defaultsize];for (;;) { // 死循環運行struct sockaddr_in peer;socklen_t len = sizeof(peer);// 1. 接收客戶端請求(和上一篇一樣)ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr *)&peer, &len);if (n > 0) {buffer[n] = 0;InetAddr addr(peer);std::cout << "[" << addr.PrintDebug() << "]# " << buffer << std::endl;// 2. 調用業務邏輯函數(查字典),而不是直接回顯std::string resp; // 存儲響應結果_func(buffer, &resp); // 傳入請求,獲取響應(解耦的核心!)// 3. 發送響應給客戶端(和上一篇一樣)sendto(_sockfd, resp.c_str(), resp.size(), 0, (struct sockaddr *)&peer, len);}}}~UdpServer() {if (_sockfd != defaultfd) {close(_sockfd); // 析構時關閉socket}}private:int _sockfd;uint16_t _port;func_t _func; // 存儲業務邏輯函數(查字典、回顯等)
};
解耦的核心:func_t
和_func
:
func_t
是一個函數對象類型,它規定了 “業務函數” 的格式:必須接收const std::string &req
(請求)和std::string *resp
(響應的指針,用于輸出結果)。_func
是UdpServer
的成員變量,存儲傳入的業務函數。在Start()
中,服務器收到請求后,不自己處理,而是調用_func(req, &resp)
,讓業務函數生成響應 —— 這樣網絡層和業務層就完全分開了。
3. 第三步:主函數 —— 組裝服務器和業務邏輯
有了Dict
類和改造后的UdpServer
,主函數的工作就是 “組裝”:創建字典對象、定義業務函數、創建服務器并啟動。
#include "UdpServer.hpp"
#include "Comm.hpp"
#include "Dict.hpp"
#include <memory> // 用于智能指針(可選,避免內存泄漏)// 全局字典對象:啟動時加載dict.txt
Dict gdict("./dict.txt");// 業務邏輯函數:符合func_t的格式
void Execute(const std::string &req, std::string *resp) {// 調用Dict的Translate方法,把結果存入resp*resp = gdict.Translate(req);
}// 主函數:解析參數,啟動服務器
int main(int argc, char *argv[]) {// 檢查參數:需要傳入端口號(如./udp_server 8888)if (argc != 2) {std::cout << "Usage: " << argv[0] << " local_port" << std::endl;return Usage_Err;}uint16_t port = std::stoi(argv[1]); // 解析端口號// 創建服務器:傳入業務函數Execute和端口// 用智能指針(std::unique_ptr)管理服務器對象,自動釋放內存std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(Execute, port);// 初始化并啟動服務器usvr->Init();usvr->Start();return 0;
}
關鍵細節:
gdict
是全局的字典對象:因為字典只需要加載一次(啟動時),全局對象會在main
前初始化,避免每次查詢都重新加載文件。Execute
函數:就是把Dict
的Translate
方法包裝成func_t
格式 —— 輸入req
(英文單詞),輸出resp
(中文翻譯)。智能指針
std::unique_ptr
:避免手動delete
服務器對象,防止內存泄漏,是 C++ 中推薦的做法。
4. 可選:封裝 Socket 操作 ——udp_socket.hpp
文檔里還提供了一個 “封裝版” 的UdpSocket
類,把socket
、bind
、recvfrom
、sendto
這些系統調用封裝成類方法,讓代碼更簡潔、復用性更高。
核心封裝代碼示例:
#pragma once
#include <stdio.h>
#include <string.h>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>class UdpSocket {
public:UdpSocket() : fd_(-1) {}// 創建socketbool Socket() {fd_ = socket(AF_INET, SOCK_DGRAM, 0);if (fd_ < 0) {perror("socket"); // 打印錯誤信息return false;}return true;}// 綁定IP和端口bool Bind(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);int ret = bind(fd_, (struct sockaddr*)&addr, sizeof(addr));if (ret < 0) {perror("bind");return false;}return true;}// 接收數據:輸出buf(消息)、ip(發送方IP)、port(發送方端口)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, (struct sockaddr*)&peer, &len);if (read_size < 0) {perror("recvfrom");return false;}buf->assign(tmp, read_size); // 把接收的字節存入bufif (ip != NULL) {*ip = inet_ntoa(peer.sin_addr); // 轉換IP為字符串}if (port != NULL) {*port = ntohs(peer.sin_port); // 轉換端口為主機字節序}return true;}// 發送數據:輸入buf(消息)、ip(接收方IP)、port(接收方端口)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, (struct sockaddr*)&addr, sizeof(addr));if (write_size < 0) {perror("sendto");return false;}return true;}// 關閉socketbool Close() {if (fd_ != -1) {close(fd_);fd_ = -1;}return true;}private:int fd_; // socket文件句柄
};
封裝的好處:
不用重復寫
struct sockaddr_in
、字節序轉換這些繁瑣的代碼;錯誤處理更統一(用
perror
打印錯誤,返回bool
表示成功 / 失敗);后續寫其他 UDP 程序(如聊天室),可以直接用這個類,不用重新寫 Socket 操作。
三、動手運行:測試字典服務
和上一篇的 Echo 服務器運行步驟類似,客戶端可以復用上一篇的(因為客戶端只負責收發字符串,不關心服務器的業務邏輯)。
1. 準備文件
dict.txt
:按前面的格式準備好單詞和翻譯;編譯服務器:
g++ ``main.cc`` UdpServer.cpp Dict.cpp -o udp_server -std=c++11
(如果拆分了.cpp 文件);客戶端用上一篇的
udp_client
。
2. 運行測試
啟動服務器:
./udp_server 8888
,會看到load dict success! total words: 10
(根據dict.txt
的單詞數而定);啟動客戶端:
./udp_client ``127.0.0.1`` 8888
;輸入 “apple”,客戶端會顯示
server echo# 蘋果
;輸入 “test”,會顯示server echo# Unknown
。
四、總結與思考
這篇我們實現了一個 “可擴展” 的字典服務器,核心收獲是:
業務邏輯封裝:用
Dict
類把 “加載字典” 和 “查詢翻譯” 封裝起來,純業務不沾網絡;網絡與業務解耦:用
std::function
讓UdpServer
只負責收發數據,業務邏輯通過函數對象傳入,靈活可換;Socket 封裝:用
UdpSocket
類簡化 Socket 操作,提升代碼復用性。
思考問題:
如果想讓多個客戶端同時用字典服務,當前的服務器能應付嗎?因為Start()
是單循環,一次只能處理一個客戶端的請求 —— 如果客戶端多了,會有延遲。下一篇我們講如何用 “線程池” 實現并發處理,還會實現一個支持多客戶端聊天的 UDP 聊天室。