欢迎光临
我们一直在努力

【Linux网络加餐(篇四)】手把手写一个 UDP 英译汉词典服务器:小白也能看懂的 C++ 网络编程实战

环境: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 对比着学。网络编程这扇门,你已经迈进来了。


    八.最终的完整代码

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux网络加餐(篇四)】手把手写一个 UDP 英译汉词典服务器:小白也能看懂的 C++ 网络编程实战
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址