环境:Ubuntu + g++(C++17) 你将看到:一个能跑的"网络翻译软件"是怎么从 9 个文件里长出来的
适合谁:学过 C++ 基础语法,但没写过网络程序的你
先看看这东西到底是个啥
想象一个场景:你在电脑上输入一个英文单词,回车,屏幕上蹦出中文意思——只不过帮你翻译的那本"词典"不在你本机,而在网络另一头的服务器程序里。
跑起来是这样的,开两个终端:
# 终端一:服务器(词典在它手里)
./udpserver 8080
# 终端二:客户端(你在这里敲单词)
./udpclient 127.0.0.1 8080
Please Enter# apple
苹果
Please Enter# love
爱
Please Enter# computer
None
词典里有的词(apple、love……)返回中文,没收录的词返回 None。与此同时,服务器那一头还会记下"谁、在什么时候、查了什么词"。
这个小项目麻雀虽小,五脏俱全:网络通信、业务逻辑、日志系统、多线程锁、工程构建全都碰到了。下面咱们按功能模块拆开揉碎了聊。
一、先看全景:9 个文件是怎么分工的
把整个项目想成一家"翻译小店":
|
UdpClient.cc |
客户端 |
顾客,拿着小纸条来问单词 |
|
UdpServer.hpp |
服务器核心 |
前台接待员,只管收发纸条,不碰翻译内容 |
|
UdpServer.cc |
服务器入口 |
店长,开店前把"词典"和"前台"组装到一起 |
|
Dict.hpp |
词典业务 |
后厨翻译官,真正负责英译汉 |
|
dictionary.txt |
词典数据 |
翻译官手边那本词汇手册 |
|
InetAddr.hpp |
地址封装 |
把寄件人地址整理成好看的名片 |
|
Log.hpp |
日志系统 |
店里的监控摄像头,什么时间发生了什么都记下来 |
|
Mutex.hpp |
互斥锁 |
摄像头前的排队栏杆,保证两个人的记录不糊在一起 |
|
Makefile |
构建脚本 |
一键打包机,敲个 make 全搞定 |
数据流动的路线,记住这一条就够了:
键盘输入 → 客户端 sendto → 网络 → 服务器 recvfrom
→ 回调函数查词典 → sendto 回寄 → 客户端 recvfrom → 屏幕显示
二、网络通信模块:让两台机器说上话
2.1 服务器的一生:socket → bind → recvfrom → sendto
UDP 服务器的核心全在 UdpServer.hpp 里。它的生命周期简单得离谱,就干四件事:开张(socket)、租门面(bind)、等客人(recvfrom)、回话(sendto)。
先看构造函数和类型定义:
using func_t = std::function<std::string(const std::string&, InetAddr&)>;
class UdpServer
{
public:
UdpServer(uint16_t port, func_t func)
: _sockfd(defaultfd),
_port(port),
_isrunning(false),
_func(func)
{
}
注意这个 func_t,它是整个设计里最妙的一笔。UdpServer 收到消息后自己不处理,而是把字符串丢给一个外部传进来的函数。今天这个函数是"查词典",明天你换成"计算器""聊天室机器人",服务器代码一行都不用改。这就是回调,下面还会细讲。
1. 什么是回调函数?(生活比喻)
假设你要去一家餐厅吃饭:
同步调用(普通函数):你点完菜,就傻站在厨房门口,一直等到厨师把菜做好递给你,你才走。这期间你什么都干不了。
回调函数:你点完菜,告诉服务员:“这是我的手机号(注册回调),菜做好了打给我(触发回调),顺便告诉我菜名(参数)。” 然后你就回座位玩手机了(主程序继续执行)。等厨房做好了,服务员通过你的手机号找到你(执行回调),把菜端给你。
在这个比喻里:
-
你的手机号 = 函数指针/回调对象
-
厨师/服务员 = 调用者
-
菜名 = 回调函数的参数
在 C++ 里,回调函数就是:把一个函数(或可调用对象)作为参数传给另一个函数,当满足某个条件(比如收到网络数据)时,由那个函数来调用你传进去的函数。
2. 逐词拆解图中的这行代码
using func_t = std::function<std::string(const std::string&, InetAddr&)>;
这行代码定义了一个类型别名,名字叫 func_t。以后你想声明这种类型的回调函数,直接写 func_t my_callback; 就行了。
我们来看等号右边:
-
std::function:这是 C++11 引入的“万能函数容器”。它可以装下普通函数、Lambda 表达式、类的成员函数、仿函数等任何“长得像函数”的东西。它比传统的 C 语言函数指针强大得多。
-
<std::string(…)>:尖括号里描述的是这个函数的签名(Signature)。
-
第一个 std::string:表示被包装的函数返回值是 std::string。
-
(const std::string&, InetAddr&):表示被包装的函数接收两个参数。第一个是 const std::string&(只读的字符串),第二个是 InetAddr&(网络地址对象的引用)。
-
总结这行代码的意思:
func_t 代表一种函数类型,这种函数接收一个字符串和一个网络地址,并返回一个字符串。
3. 这个类型的回调在项目中是怎么用的?
结合你正在做的“UDP英译汉网络词典”项目,这个 func_t 简直就是为它量身定做的。
场景设定:
服务器收到客户端发来的一个英文单词(std::string),以及客户端的地址(InetAddr)。服务器需要调用业务逻辑,把这个词翻译成中文(返回 std::string),然后再把这个中文发回去。
典型的实现流程:
1. 定义业务逻辑(在 Dict.hpp 中):
class Dict {
public:
// 这就是一个符合 func_t 签名的函数!
std::string Translate(const std::string& word, InetAddr& client_addr) {
// 查词典,返回翻译结果
return "苹果";
}
};
2. 注册回调(在 UdpServer 中):
你的 UdpServer 类里会有一个 func_t _callback; 成员变量。
在 main 函数里,你把 Dict 对象的 Translate 方法“注册”给服务器:
Dict my_dict("./dictionary.txt");
UdpServer server(ip, port);
// 注册回调:以后收到数据,就调用 my_dict 的 Translate 方法
server.RegisterCallback(std::bind(&Dict::Translate, &my_dict, std::placeholders::_1, std::placeholders::_2));
3. 触发回调(在 UdpServer::Start() 循环中):
当 recvfrom 收到数据后,服务器不需要知道具体怎么翻译(那是业务层的事)。它只需要调用那个回调即可:
// 收到消息后
std::string result = _callback(buffer, peer_addr); // 触发回调,执行翻译
sendto(…, result, …); // 把翻译结果发回去
4.为什么要用 std::function 而不是 C 语言的函数指针?
在 C 语言里,回调通常写成这样:
typedef void (*func_ptr)(int);
它只能指向普通的全局函数,无法指向类的成员函数,也无法捕获局部变量(比如 Lambda 里的 [])。
而 std::function 是 C++ 的现代利器,它可以统一装下:
普通函数
类的静态成员函数
类的普通成员函数(配合 std::bind 或 Lambda)
Lambda 表达式(极其方便)
仿函数(重载了 operator() 的类)
这行代码的妙处在于: 它把“业务逻辑(翻译)”和“网络框架(收发数据)”彻底解耦了。服务器只管收发,具体怎么翻译,由你注册进去的回调函数说了算。这就叫面向接口编程。
开张:创建套接字
void Init()
{
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "socket error!";
exit(1);
}
LOG(LogLevel::INFO) << "socket success, sockfd : " << _sockfd;
socket 就像向操作系统申请一台对讲机:
-
AF_INET 表示用 IPv4;
-
SOCK_DGRAM 表示要"数据报"类型的对讲机——这就是 UDP(想要 TCP 就填 SOCK_STREAM);
-
返回值是一个整数叫文件描述符,成功时一般从 3 开始(0、1、2 被标准输入、标准输出、标准错误提前占了),失败返回 -1。
租门面:填地址 + bind
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(2);
}
}
struct sockaddr_in 就是服务器的"收件地址",要填三样东西。这里每个细节都有讲究:
bzero 先清零。 结构体里有些字段我们没填,不清零的话里面是随机垃圾值,可能埋雷。这是网络编程的肌肉记忆。
htons(_port):为什么端口号还要转换? 因为不同 CPU 存数字的字节顺序不一样(有的大端、有的小端),网络世界约定大家统一用大端传输。htons 就是"host to network short",把本机格式翻译成网络通用格式。不转的话,你传 8080,对方看到的可能是一个莫名其妙的端口。
INADDR_ANY:本店地址写"哪儿都行"。 这是新手最容易懵的地方,多说两句。
一台服务器往往有多块网卡(127.0.0.1 回环网卡、10.0.0.2 局域网网卡……)。如果 bind 时写死某个 IP,就等于告诉邮局"只收送到这个门的信",客户端换个 IP 访问就收不到了。填 INADDR_ANY(值就是 0.0.0.0)的意思是:本机所有网卡上的数据我都收。用 ss -uln 能看到它显示为:
UNCONN 0 0 0.0.0.0:8080 0.0.0.0:*
0.0.0.0 就是"不挑网卡"的意思。顺带一提,它的值是 0,0 怎么转字节序还是 0,所以这里不用再包 htonl。
为什么服务器必须 bind,客户端却不用?
因为服务器的门牌号必须固定且众所周知,客户端才知道去哪里找它——没人会三天两头换地址开店。客户端的端口是几号无所谓,操作系统第一次发消息时帮它随机挑一个空闲的就行,还能避免你同时开两个客户端时端口打架。
营业:收发循环
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);
}
}
}
recvfrom 这个函数很贪心,一次干两件事:把数据收进 buffer,同时把发送方的完整地址塞进 peer。这太重要了——服务器收到信的同时也拿到了"寄件人地址",待会儿回信用的就是它。UDP 服务器因此不用维护任何客户端名单,来一个人服务一个人,一万个客户端也不怕。
收到数据后做三件事:
InetAddr client(peer):把原始地址包装成一个好用的对象(2.3 节讲);
_func(buffer, client):把消息交给回调函数处理,拿回结果;
sendto(…):把结果照着 peer 地址寄回去。
2.2 两个必须知道的坑:报文边界和结尾的 \\0
UDP 会不会"粘包"? 这是面试高频题,在这个项目里你可以亲手体会。TCP 像一条河,数据是水,多次发送可能连成一片,接收方分不清哪段是哪句——那才叫粘包。UDP 不一样,它像寄快递,你 sendto 一次就是一个独立的包裹,recvfrom 一次收一个包裹,报文边界是操作系统帮你守住的。客户端发一行、服务器收一行,天然不会粘。
那中文乱码、尾部脏字符是怎么回事? 看这行:
buffer[s] = 0;
UDP 包裹里装的是纯数据,不会自带 C 字符串结尾的 '\\0'。recvfrom 返回的 s 是这次收到的字节数,比如 5,那 buffer 里只有前 5 个字节是有效的,后面全是上一次的残留或者垃圾值。直接当字符串打印,就会读出 苹果烫烫烫烫 这种鬼东西。手动在有效数据末尾补一个 0,字符串才知道自己在哪儿结束。
至于中文能正常显示,是因为客户端、服务器、终端都统一用 UTF-8,中文按字节原样搬运、原样显示,程序根本不需要"认识"中文。
长度也要留个心眼:收包长度传的是 sizeof(buffer) – 1(1023),故意留一个字节放 '\\0'。如果客户端发来 2000 字节,UDP 会直接把装不下的部分丢掉(截断),这是 UDP "尽力而为、不负责"的本性。
2.3 InetAddr:给裸地址套一件舒服的外套
recvfrom 给的 sockaddr_in 是给机器看的:端口是网络字节序的整数,IP 是 4 字节二进制。人想看的是 127.0.0.1 和 58050。InetAddr.hpp 就干这一件事:
class InetAddr
{
public:
InetAddr(struct sockaddr_in &addr) : _addr(addr)
{
_port = ntohs(_addr.sin_port); // 网络序 → 主机序
_ip = inet_ntoa(_addr.sin_addr); // 4字节IP → "127.0.0.1"
}
uint16_t Port() { return _port; }
std::string Ip() { return _ip; }
private:
struct sockaddr_in _addr;
std::string _ip;
uint16_t _port;
};
两个转换函数正好和发送时相反:
-
ntohs:network to host short,端口号翻译回本机格式;
-
inet_ntoa:把 4 字节网络地址转成点分十进制字符串(n 是 network,a 是 ASCII)。
注意成员 _addr 是按值拷贝的,不是引用。构造时就把这份地址快照存下来,之后就算外面的 peer 被下一个客户端覆盖了,这个对象里留的还是当时那位客人的地址,很稳妥。
2.4 店长登场:UdpServer.cc 怎么把零件拼起来
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;
if (!dict.LoadDict())
{
LOG(LogLevel::FATAL) << "字典加载失败,服务器退出";
return 2;
}
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;
}
店长开店分三步:把词汇手册翻好(加载词典)→ 雇好前台(创建服务器,并告诉它"收到单词交给词典翻译")→ 开门营业。
重点看这个 lambda。UdpServer 的构造函数需要一个"吃字符串、吐字符串"的函数,这里用一个匿名函数(lambda)就地写好:
[&dict](const std::string &word, InetAddr &cli) -> std::string {
return dict.Translate(word, cli);
}
-
[&dict] 表示按引用捕获外面的词典对象,lambda 内部就能直接用它;
-
服务器每收到一个单词,就回调一次这个函数。
为什么要绕这一层,不直接在服务器里 #include "Dict.hpp" 然后查词典?因为那样网络代码和业务代码就焊死了,以后想把"翻译"换成"计算平方",得动服务器的五脏庙。现在的分工是:服务器只懂快递,词典只懂翻译,中间用一个函数指针一样的东西连接,各自能独立生长。这就是解耦。
2.5 客户端:一个等回信的顾客
UdpClient.cc 的核心循环:
while(true)
{
std::string input;
std::cout << "Please Enter# " << std::flush;
if (!std::getline(std::cin, input)) // Ctrl+D:输入结束
break;
if (input.empty()) // 空行不发
continue;
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;
std::cout << buffer << std::endl;
}
}
客户端同样要 socket 申请对讲机,但只填服务器地址,不 bind:
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
inet_addr 把 "127.0.0.1" 直接转成网络字节序的 4 字节整数,所以不用再 htonl。之后第一次 sendto 时,操作系统会自动给客户端分配 IP 和随机端口——这就是前面说的"顾客不需要固定摊位"。
这里有两行是踩坑后长出来的"护栏",值得专门讲:
if (!std::getline(…)) break;:当你按 Ctrl+D(输入流结束),getline 会失败。如果不检查,循环里 input 一直是空的,程序就会原地疯狂空转、刷屏,实测能瞬间刷出几十 MB 的输出。
if (input.empty()) continue;:直接按回车会产生空字符串。更隐蔽的坑是——空字符串也会触发 sendto,发出一个 0 字节的 UDP 包裹;服务器那边 recvfrom 返回 0,代码判断 if (s > 0) 不成立,于是既不处理也不回复,客户端就会永远堵在 recvfrom 等一封永远不会来的回信。所以空行干脆不发。
另外 std::cout << … << std::flush 用 flush 而不是 endl:只需要让提示符立刻蹦出来,光标停在 # 后面等你打字,不需要换行。
三、词典业务模块:翻译功能是怎么实现的
3.1 词库长什么样:dictionary.txt
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
happy: 快乐的
sad: 悲伤的
run: 跑
jump: 跳
teacher: 老师
student: 学生
car: 汽车
bus: 公交车
love: 爱
hate: 恨
hello: 你好
goodbye: 再见
summer: 夏天
winter: 冬天
20 个词条,每行一条,格式简单粗暴:英文 + 冒号空格 + 中文。注意分隔符是 ": "(冒号带一个空格),解析时要按这两个字符切。
3.2 Dict.hpp:加载和查询
数据结构选型几乎不用想:英文到中文的一一对应,unordered_map 天选之子,查词平均 O(1):
using namespace LogModule;
class Dict
{
public:
Dict(const std::string &path = defaultdict) : _dict_path(path) {}
bool LoadDict()
{
std::ifstream in(_dict_path);
if (!in.is_open())
{
LOG(LogLevel::ERROR) << "打开字典: " << _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;
}
逐行读文件,然后一行里做"切蛋糕":
-
line.find(": ") 找到分隔符的位置;找不到返回 npos(可以理解成"无尽头"的哨兵值),这行是坏数据,记一条 WARNING 跳过,不让一颗老鼠屎搞崩整个启动过程;
-
substr(0, pos) 取冒号前半段英文;
-
substr(pos + sep.size()) 取冒号空格之后的全部内容当中文——所以中文释义里就算带空格也不怕;
-
两半任意一个是空的也跳过。
这种"加载时容错"的意识很重要:文件是可能被手改坏的,程序不能因为某一行格式错了就赌气不干。
查询部分:
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;
}
private:
std::string _dict_path;
std::unordered_map<std::string, std::string> _dict;
};
find 没查到时返回 end(),我们不抛异常、不崩溃,友好地返回字符串 "None",客户端照原样显示就行。同时利用传进来的 client 地址记一条日志——业务层居然知道是哪个客人在查词,这就是回调签名里带 InetAddr& 的价值。
还有个工程细节:LoadDict() 的返回值在 main 里是被认真检查的,词典文件丢了就 FATAL 退出。否则服务器"成功启动"却对所有单词都回 None,排查起来能让你怀疑人生。
四、日志模块:让程序学会"写日记"
服务器一跑就是死循环,出了问题靠什么还原现场?靠日志。这个项目没有用任何第三方库,自己手写了一个还挺像样的日志系统。
4.1 策略模式:日志想打哪儿就打哪儿
先看顶层设计。日志的去向可能是屏幕,也可能是文件,明天也许想发到网络。Log.hpp 用策略模式处理这种变化:定一个抽象基类当"接口",再派生出具体实现:
class LogStrategy
{
public:
~LogStrategy() = default;
virtual void SyncLog(const std::string &message) = 0;
};
屏幕策略的核心就两行(加锁 + 输出):
void SyncLog(const std::string &message) override
{
LockGuard lockguard(_mutex);
std::cout << message << gsep << std::flush;
}
文件策略则负责"确保目录存在 + 追加打开文件 + 写入":
if (std::filesystem::exists(_path)) return;
std::filesystem::create_directories(_path); // C++17,目录不存在就递归建
…
std::ofstream out(filename, std::ios::app); // app = 追加,不覆盖历史
out << message << gsep;
管理日志的 Logger 手里攥着一个可以随时替换的指针:
std::unique_ptr<LogStrategy> _fflush_strategy;
想换输出去向?让这个指针指向另一个策略对象就行,调用方完全无感。这就像店里的摄像头,今天接监视器、明天接录像机,摄像头本身不用换。
一条完整日志长这样:
[2026-09-13 17:27:41] [DEBUG] [952447] [Dict.hpp] [62] – apple->苹果
时间、等级、进程号、源文件、行号、正文,六要素齐全。时间戳用 localtime_r(线程安全版本,_r 就是 reentrant 可重入的意思),还有两个经典冷知识:tm_year 要加 1900、tm_mon 从 0 开始所以要加 1。
4.2 流式日志的魔法:为什么能 LOG(INFO) << "x" << 3?
这是整个日志系统最精巧的地方。用法大家都熟:
LOG(LogLevel::INFO) << "bind success, sockfd : " << _sockfd;
可这背后是怎么工作的?拆开看宏:
#define LOG(level) logger(level, __FILE__, __LINE__)
__FILE__ 和 __LINE__ 是编译器内建宏,自动变成本文件名和当前行号,所以每条日志都能精确定位。logger(…) 返回一个临时 LogMessage 对象,它在构造时先拼好日志的左半边(时间、等级那些固定信息):
LogMessage(LogLevel &level, std::string &src_name, int line_number, Logger &logger)
{
std::stringstream ss;
ss << "[" << _curr_time << "] "
<< "[" << Level2Str(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _src_name << "] "
<< "[" << _line_number << "] – ";
_loginfo = ss.str();
}
然后靠模板 operator<< 把右边的内容一块块攒进 _loginfo:
template <typename T>
LogMessage &operator<<(const T &info)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this; // 返回自己,才能链式 << a << b << c
}
最妙的是输出时机——在析构函数里:
~LogMessage()
{
if (_logger._fflush_strategy)
_logger._fflush_strategy->SyncLog(_loginfo);
}
临时对象在整条语句结束时析构,"啪"一下把攒好的完整日志交出去。你可以把它理解成:每次写日志都是领了一张便利贴,随手往上记内容,下班(析构)时自动把便利贴贴到公告栏上,全程不用你手动喊"提交"。
4.3 Mutex.hpp:RAII 风格的自动锁
为什么日志需要锁?如果两个线程同时打日志,它们的字符可能你一个我一个地交叉写,屏幕上就乱成粥了。所以"写"这个动作必须排队。
项目没有直接裸用 pthread 函数,而是先包一层 Mutex:
class Mutex
{
public:
Mutex() { pthread_mutex_init(&_mutex, nullptr); }
void Lock() { pthread_mutex_lock(&_mutex); }
void Unlock() { pthread_mutex_unlock(&_mutex); }
~Mutex() { pthread_mutex_destroy(&_mutex); }
private:
pthread_mutex_t _mutex;
};
再配一个 LockGuard:
class LockGuard
{
public:
LockGuard(Mutex &mutex) : _mutex(mutex) { _mutex.Lock(); }
~LockGuard() { _mutex.Unlock(); }
private:
Mutex &_mutex;
};
这就是 RAII(资源获取即初始化):进门时构造对象自动上锁,离开作用域时对象析构自动解锁。哪怕中间 return 甚至抛异常,锁都不会忘解——编译器帮你兜底。成员是引用 Mutex&,表示它只是"借用"外面那把锁,自己不拥有锁。
4.4 一个藏得很深的坑:日志为什么写不进文件?
这个项目里日志输出用的是:
std::cout << message << gsep << std::flush;
gsep 是 "\\r\\n"。新手容易忽略最后那个 std::flush。C++ 的输出是带缓冲的:在终端里跑通常是"行缓冲",遇到换行还能刷出来;可一旦你把输出重定向到文件(./udpserver 8080 > log.txt),就变成"全缓冲",内容会在缓冲区里攒着。实测中不加 flush 时,服务进程一直跑、日志文件却始终是 0 字节,连进程被杀掉日志都没落地。
std::flush 的作用就是手动按下"立刻送出"。要么 flush,要么用自带刷新功能的 std::endl(它等于换行 + flush,但频繁 flush 有性能开销,正式项目要权衡)。
五、构建模块:Makefile 这个小管家
.PHONY:all
all:udpclient udpserver
udpclient:UdpClient.cc
g++ -o $@ $^ -std=c++17 -pthread
udpserver:UdpServer.cc
g++ -o $@ $^ -std=c++17 -pthread
.PHONY:clean
clean:
rm -f udpclient udpserver
几个小白知识点:
-
Makefile 里缩进必须是 Tab,不能是空格,这是新手第一天必踩的坑;
-
$@ 代表冒号左边的目标名,$^ 代表右边所有依赖,两条规则展开后就是完整的 g++ 命令;
-
-std=c++17 不能省,代码用了 std::filesystem;
-
-pthread 告诉编译器启用 pthread 支持(互斥锁要用);
-
.PHONY 声明 all、clean 是"伪目标",意思是它们不是真实文件名,哪怕目录里恰好有个叫 clean 的文件,make clean 也照常执行。
六、踩坑小结:这些雷我们都替你趟过了
把开发中真实遇到的问题汇总成一张急救表:
|
服务器报 bind error |
端口被上次没关的进程占着 |
ss -ulnp | grep 端口 找到 PID,kill 掉 |
|
bash: ./udpserver: No such file |
还没编译,或刚 make clean 过 |
先 make |
|
敲 make 反而删文件 |
Makefile 第一个目标是 clean |
把 all 放到第一行 |
|
客户端按回车后永久卡死 |
发出 0 字节包,服务器不回复 |
空行 continue 跳过 |
|
Ctrl+D 后疯狂刷屏 |
没检查 getline 返回值 |
失败时 break |
|
单词后面跟乱码 |
UDP 数据不带 '\\0' |
buffer[s] = 0 |
|
重定向后日志文件是空的 |
输出缓冲没刷 |
<< std::flush |
|
改了 hpp 运行没变化 |
make 不追踪头文件依赖 |
make clean && make |
|
用 127.0.0.1 能通、换 IP 不通 |
bind 写死了单个 IP |
用 INADDR_ANY |
七、知识图谱:把这一课装进脑子
最后用一张图收个尾。别看代码不少,骨架其实非常清晰:
┌─────────────────────────────────────┐
│ UdpServer.cc(店长) │
│ 解析端口 → 加载词典 → 组装服务器 → 启动 │
└───────────────┬─────────────────────┘
│ 持有
┌───────────────▼─────────────────────┐
│ UdpServer.hpp(前台) │
│ socket → bind → recvfrom → sendto │
│ 自己不懂业务,只负责收发和调用回调 │
└───────┬─────────────────┬───────────┘
std::function 回调│ │ 使用
┌────────▼───────┐ ┌──────▼─────────┐
│ Dict.hpp │ │ InetAddr.hpp │
│ unordered_map │ │ ntohs/inet_ntoa│
│ 英译汉业务 │ │ 封装客户端地址 │
└───────┬────────┘ └────────────────┘
│ 读取
┌───────▼────────┐
│ dictionary.txt │
└────────────────┘
横切关注点(谁都能用):
┌──────────────┐ 内部加锁 ┌──────────────┐
│ Log.hpp │ ─────────▶ │ Mutex.hpp │
│ 策略模式+流式日志│ │ RAII LockGuard│
└──────────────┘ └──────────────┘
工程化:Makefile(all / clean,g++ -std=c++17 -pthread)
对端:UdpClient.cc(socket 不 bind,sendto 后 recvfrom 等回信)
需要在脑子里建立的几条主线:
网络四板斧:UDP 服务器就是 socket → bind → 循环(recvfrom/sendto);UDP 保留报文边界(无粘包)、不可靠、不连接,适合这种一问一答的小场景。
地址三要素:sockaddr_in 填 family、port(htons)、addr(INADDR_ANY 或 inet_addr);服务器固定 bind,客户端让 OS 自动分配。
数据安全意识:收到的数据手动补 '\\0',收包长度留一格;空输入、EOF 都要防。
设计思想:回调/std::function 让网络框架与业务解耦;策略模式让日志输出方式可替换;RAII 让资源(锁)自动释放。
工程素养:日志六要素 + flush;Makefile 默认目标、Tab 缩进、头文件依赖;端口占用怎么查怎么杀。
等这些在你脑子里串成线之后,下一步可以玩的升级也很明确:给客户端加接收超时(setsockopt(SO_RCVTIMEO))防止服务器不回复时傻等;给词典加"增删词条"网络命令;把单线程循环升级成多线程;甚至把 UDP 换成 TCP 对比着学。网络编程这扇门,你已经迈进来了。
八.最终的完整代码








网硕互联帮助中心



评论前必须登录!
注册