云计算百科
云计算领域专业知识百科平台

Linux网络篇——UDP(一)之C++实现UDP英汉词典翻译服务器

牢蒯的源码在下面的仓库里,各位大佬可以自行取用,古法编程,难免有bug,大佬们可在评论区指出错误。

https://gitee.com/laotian666/linux_storage.git


0 前置知识:UDP网络编程核心接口与结构体

UDP属于无连接传输协议,通信双方不需要预先建立连接。本项目全部基于UDP套接字接口开发,在阅读项目代码前,先熟悉项目用到的系统结构体与系统调用。

0.1 sockaddr_in结构体

sockaddr_in是IPv4专用地址结构体,用于保存IP地址、端口、协议族信息。系统套接字接口的形参统一为通用结构体struct sockaddr,使用的时候必须做强制类型转换。

struct sockaddr_in
{
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
unsigned char sin_zero[8];
};

struct in_addr
{
uint32_t s_addr;
};

  • sin_family需要设置为AF_INET,代表使用IPv4协议。
  • sin_port存储端口号,端口必须为网络大端字节序,不能直接填写主机字节序数值。
  • sin_addr用来存储4字节二进制IP地址。
  • sin_zero是内存对齐占位字段,一般直接对整个结构体做清零处理。

特殊宏INADDR_ANY,可以让服务端绑定本机所有网卡,接收本机任意网卡上到来的客户端请求。将sockaddr_in传给bind、recvfrom、sendto接口时,必须强转为struct sockaddr*类型。

0.2 htons与ntohs字节序转换函数

网络传输统一使用大端字节序,家用计算机主机大多是小端模式,主机与网络之间需要进行字节序转换。

uint16_t htons(uint16_t hostshort);
uint16_t ntohs(uint16_t netshort);

  • htons:完成主机短整型向网络大端转换,填充服务端端口时调用。
  • ntohs:将网络大端端口转换为主机字节序,从recvfrom拿到客户端地址后调用,得到人类可读的端口数字。

端口是16位短整型,所以使用这一组接口,IP地址转换使用inet_ntop、inet_pton。

0.3 recvfrom系统调用

recvfrom是UDP接收数据的专用接口,接收报文的同时,内核会填充发送方的网络地址信息。

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);

  • sockfd是socket返回的套接字文件描述符。
  • buf是应用层接收缓冲区,len代表缓冲区最大可接收字节。
  • flags传0代表使用默认行为。
  • src_addr是输出参数,内核会把发送方的IP和端口写入该结构体。
  • addrlen属于输入输出参数,调用前传入结构体大小,调用完成内核会回写真实地址长度。

返回值大于0代表成功接收的字节数,返回-1代表调用出错。

⚠️重要:UDP报文不会自带字符串结束符\\0,拿到返回字节数之后,需要手动在缓冲区末尾补\\0,才可以当做C++字符串处理。

0.4 sendto系统调用

UDP发送数据的系统调用,因为UDP没有连接的概念,每次发送数据都必须显式指定目标客户端地址。

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);

  • sockfd为UDP套接字。
  • buf指向待发送数据缓冲区,len是发送字节长度。
  • flags参数填0。
  • dest_addr填写接收方的网络地址。
  • addrlen传入地址结构体占用字节大小。

调用成功返回实际发送字节数,失败返回-1。每调用一次sendto就会生成一个完整UDP数据报。

0.5 socket创建套接字

int socket(int domain, int type, int protocol);

本项目调用形式为socket(AF_INET, SOCK_DGRAM, 0)。

  • AF_INET:代表IPv4协议
  • SOCK_DGRAM:代表数据报,即UDP协议
  • 第三个参数填0自动匹配对应传输协议

调用成功返回套接字文件描述符,失败返回-1。

0.6 bind绑定端口

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

服务端程序需要调用bind,把socket和本机IP、端口绑定,操作系统才可以把对应端口收到的报文交给当前进程处理。

💡UDP客户端一般不需要手动bind,操作系统会自动分配临时端口。搭配INADDR_ANY可以监听本机全部网卡。

0.7 inet_ntop与inet_pton IP格式转换

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
int inet_pton(int af, const char *src, void *dst);

  • inet_ntop:把网络二进制IP转换成点分十进制字符串。
  • inet_pton:将点分十进制字符串IP转换成网络二进制格式。

❗重点:舍弃老旧接口inet_ntoa。inet_ntoa内部使用静态缓冲区,多线程环境调用会出现数据覆盖,线程不安全,项目中统一使用inet_ntop。

0.8 UDP完整通信流程

服务端执行流程:调用socket创建UDP套接字 → 填充sockaddr_in结构体执行bind绑定端口 → 循环调用recvfrom等待接收客户端报文 → 执行业务逻辑处理数据 → 调用sendto将结果回传给客户端。

客户端执行流程:调用socket创建UDP套接字 → 填充服务端地址结构体 → 调用sendto发送请求报文 → 调用recvfrom接收服务端应答。

✨UDP无连接特性,每次收发数据都需要携带对方地址信息,因此UDP一般使用recvfrom、sendto,而不使用简单的read、write接口。

1 项目整体介绍

本项目使用C++实现一个UDP版本英汉词典翻译服务器。客户端通过UDP发送英文单词,服务端读取本地词典文件完成单词查询,将中文释义返回客户端;查询不到单词则返回None。

项目采用分层解耦设计,将互斥锁、日志系统、网络地址封装、UDP网络服务、词典业务相互解耦。 用到的技术点包含:RAII思想、策略模式、std::function回调函数、UDP套接字编程、C++17文件系统库。

1.1 项目文件清单

  • Mutex.hpp:封装pthread互斥锁,实现RAII自动守卫锁
  • Log.hpp:日志组件,支持控制台输出、文件落盘两种输出策略
  • InetAddr.hpp:对sockaddr_in网络地址结构体封装,转换为可读IP与端口
  • UdpServer.hpp:UDP服务端封装,使用std::function回调解耦网络IO和业务逻辑
  • Dick.hpp:词典业务类,读取解析词典文件,提供单词翻译接口
  • Dictionary.txt:词典数据源,格式固定为英文: 中文
  • main.cc:程序入口,组装各个模块,启动UDP服务器
  • Makefile:编译脚本
  • 2 核心模块代码实现

    2.1 Mutex.hpp互斥锁模块

    原生pthread库锁容易出现忘记解锁造成死锁,这里做面向对象封装,并且实现RAII守卫锁,出作用域自动释放锁资源,避免忘记unlock。

    #ifndef __MUTEX_HPP__
    #define __MUTEX_HPP__
    #include <pthread.h>
    namespace MyMutex
    {
    class Mutex
    {
    public:
    Mutex()
    {
    pthread_mutex_init(&_mutex,nullptr);
    }
    void Lock()
    {
    int n = pthread_mutex_lock(&_mutex);
    (void)n;
    }
    void UnLock()
    {
    int n = pthread_mutex_unlock(&_mutex);
    (void)n;
    }
    ~Mutex()
    {
    pthread_mutex_destroy(&_mutex);
    }
    pthread_mutex_t* Get()
    {
    return &_mutex;
    }
    private:
    pthread_mutex_t _mutex ;
    };

    class GuardMutex
    {
    public:
    //❗修复原bug:必须传引用,不能值拷贝Mutex对象
    GuardMutex(Mutex& mutex)
    :_mutex(mutex)
    {
    _mutex.Lock();
    }
    ~GuardMutex()
    {
    _mutex.UnLock();
    }
    private:
    Mutex& _mutex ;
    };
    }
    #endif

    ❗原始代码bug:GuardMutex值传递Mutex对象,会拷贝锁对象,锁逻辑完全失效,本项目修复为引用传递。

    2.2 Log.hpp日志模块

    日志模块使用策略模式,抽象日志刷新基类,派生出控制台打印策略、文件写入策略。 每条日志自动携带:时间戳、日志等级、进程PID、源码文件名、代码行号。借助宏自动捕获__FILE__与__LINE__,使用流式<<写法输出日志。

    #pragma once
    #include<iostream>
    #include<string>
    #include"Mutex.hpp"
    #include<fstream>
    #include<cstdio>
    #include<memory>
    #include<unistd.h>
    #include<ctime>
    #include <filesystem>
    #include<sstream>
    namespace LogModule
    {
    const std::string gsep = "\\r\\n";
    using namespace MyMutex;

    //日志策略抽象基类
    class LogStrategy
    {
    public:
    ~LogStrategy() = default;
    virtual void SyncLog(const std::string& message) = 0;
    };

    //控制台输出策略
    class ConsoleLogStrategy: public LogStrategy
    {
    public:
    virtual void SyncLog(const std::string& message) override
    {
    GuardMutex lockGuard(_mutex);
    std::cout << message << "\\r\\n";
    }
    ~ConsoleLogStrategy() {}
    private:
    Mutex _mutex;
    };

    const std::string defaultpath = "./log";
    const std::string defaultfile = "my.log";
    //文件落盘输出策略
    class FileLogStrategy: public LogStrategy
    {
    public:
    FileLogStrategy(const std::string& path = defaultpath,const std::string& file = defaultfile)
    :_path(path)
    ,_file(file)
    {
    GuardMutex lock_guard(_mutex);
    if(std::filesystem::exists(_path))
    {
    return;
    }
    try
    {
    //C++17 创建目录
    std::filesystem::create_directories(_path);
    }
    catch(const std::filesystem::filesystem_error &e)
    {
    std::cout << e.what() << '\\n';
    }
    }
    virtual void SyncLog(const std::string& message) override
    {
    GuardMutex lockGuard(_mutex);
    std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file;
    std::ofstream out(filename,std::ios::app);
    if(!out.is_open())
    {
    return;
    }
    out << message << gsep;
    out.close();
    }
    ~FileLogStrategy() {}
    private:
    std::string _path;
    std::string _file;
    Mutex _mutex;
    };

    //日志等级,从低到高 DEBUG < INFO < WARNNING < ERROR < FATAL
    enum class LogLevel
    {
    DEBUG,
    INFO,
    WARNNING,
    ERROR,
    FATAL
    };

    inline std::string Level2Str(LogLevel level)
    {
    switch(level)
    {
    case LogLevel::DEBUG:
    return "DEBUG";
    case LogLevel::INFO:
    return "INFO";
    case LogLevel::WARNNING:
    return "WARNNING";
    case LogLevel::ERROR:
    return "ERROR";
    case LogLevel::FATAL:
    return "FATAL";
    default:
    return "Unknown!";
    }
    }

    inline std::string GetTimeStamp()
    {
    time_t curr = time(nullptr);
    struct tm curr_tm;
    localtime_r(&curr,&curr_tm);
    char buffer[128];
    snprintf(buffer,sizeof(buffer),"%4d-%02d-%02d %02d:%02d:%02d",
    curr_tm.tm_year + 1900,
    curr_tm.tm_mon + 1,
    curr_tm.tm_mday,
    curr_tm.tm_hour,
    curr_tm.tm_min,
    curr_tm.tm_sec
    );
    return buffer;
    }

    class Logger
    {
    public:
    Logger()
    {
    EnableConsoleLogStrategy();
    }
    //切换日志输出到文件
    void EnableFileLogStrategy()
    {
    _fflush_strategy = std::make_unique<FileLogStrategy>();
    }
    //切换日志输出到控制台
    void EnableConsoleLogStrategy()
    {
    _fflush_strategy = std::make_unique<ConsoleLogStrategy>();
    }

    //临时消息类,析构的时候真正输出日志
    class LogMessage
    {
    public:
    LogMessage(LogLevel level,std::string src_file,int line_number,Logger& logger)
    :_level(level)
    ,_src_file(src_file)
    ,_line_number(line_number)
    ,_pid(getpid())
    ,_logger(logger)
    ,_time(GetTimeStamp())
    {
    std::stringstream ss;
    ss << "[" << _time << "]"
    << "[" << Level2Str(_level) << "]"
    << "[" << _pid << "]"
    << "[" << _src_file << "]"
    << "[" << _line_number << "]"
    << "- ";
    _log_info = ss.str();
    }
    template<typename T>
    LogMessage& operator << (const T& info)
    {
    std::stringstream ss;
    ss << info;
    _log_info += ss.str();
    return *this;
    }
    //❗**关键:临时对象析构,才执行日志落地输出**
    ~LogMessage()
    {
    if(_logger._fflush_strategy)
    {
    _logger._fflush_strategy->SyncLog(_log_info);
    }
    }
    private:
    std::string _time;
    LogLevel _level;
    pid_t _pid;
    std::string _src_file;
    int _line_number;
    std::string _log_info;
    Logger& _logger;
    };

    LogMessage operator()(LogLevel level,std::string src_file,int line_number)
    {
    return LogMessage(level,src_file,line_number,*this);
    }
    ~Logger()
    {}
    private:
    std::unique_ptr<LogStrategy> _fflush_strategy;
    };

    extern Logger log;
    //宏自动填充__FILE__ __LINE__,简化调用
    #define LOG(level) log(level,__FILE__,__LINE__)
    #define Enable_File_Log_Strategy() log.EnableFileLogStrategy()
    #define Enable_Console_Log_Strategy() log.EnableConsoleLogStrategy()
    }

    日志使用示例:

    LOG(LogLevel::INFO) << "服务器启动成功";
    Enable_File_Log_Strategy();

    日志输出样例:

    [2026-10-24 16:20:10][INFO][14523][UdpServer.hpp][40]- 创建套接字成功,套接字值为:4

    💡核心原理:LogMessage是临时对象,执行完LOG(xxx) << "xxx";这一行,对象析构,才调用SyncLog完成日志输出。

    2.3 InetAddr.hpp网络地址封装

    将内核返回的sockaddr_in结构体进行封装,把网络字节序的端口、二进制IP转换成程序可读的字符串与uint16_t类型。

    ❗原始代码inet_ntoa使用静态缓冲区,多线程不安全,本项目替换为线程安全的inet_ntop。

    #pragma once
    #include<iostream>
    #include<sys/socket.h>
    #include<sys/types.h>
    #include<netinet/in.h>
    #include<arpa/inet.h>
    #include"Log.hpp"
    #include<string>
    #include<strings.h>
    #include<functional>

    class InetAddr
    {
    public:
    InetAddr(struct sockaddr_in& addr)
    :_addr(addr)
    {
    //网络端口转主机字节序
    _port = ntohs(_addr.sin_port);
    char ipbuf[INET_ADDRSTRLEN];
    //二进制ip转点分十进制字符串
    inet_ntop(AF_INET,&_addr.sin_addr,ipbuf,INET_ADDRSTRLEN);
    _ip = ipbuf;
    }
    std::string IP()
    {
    return _ip;
    }
    uint16_t Port()
    {
    return _port;
    }
    ~InetAddr()
    {}
    private:
    struct sockaddr_in _addr;
    std::string _ip;
    uint16_t _port;
    };

    2.4 UdpServer.hpp UDP服务端封装

    UDP服务器类,负责socket创建、bind绑定端口,循环执行recvfrom接收客户端报文。

    ✨设计亮点:使用std::function回调,彻底解耦网络IO与上层业务代码,网络模块不需要关心具体是词典翻译还是其他业务。

    #pragma once
    #include<iostream>
    #include<sys/socket.h>
    #include<sys/types.h>
    #include<netinet/in.h>
    #include<arpa/inet.h>
    #include"Log.hpp"
    #include<string>
    #include<strings.h>
    #include<functional>
    #include"InetAddr.hpp"
    using namespace LogModule;
    //回调函数签名:输入客户端字符串+客户端地址,返回处理完成要发送的字符串
    using func = std::function<std::string(const std::string&,InetAddr&)>;
    const int defaultfd = -1;

    class UdpServer
    {
    public:
    UdpServer(uint16_t port,func func)
    :_sockfd(defaultfd)
    ,_port(port)
    ,_isrunning(false)
    ,_func(func)
    {}
    void Init()
    {
    _sockfd = socket(AF_INET,SOCK_DGRAM,0);
    if(_sockfd < 0)
    {
    LOG(LogLevel::FATAL) << "创建套接字失败";
    exit(1);
    }
    LOG(LogLevel::INFO) << "创建套接字成功,套接字值为:" << _sockfd;

    struct sockaddr_in local;
    bzero(&local,sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port);
    //❗INADDR_ANY监听本机所有网卡
    local.sin_addr.s_addr = INADDR_ANY;

    int n = bind(_sockfd,(struct sockaddr*)&local,sizeof(local));
    if(n < 0)
    {
    LOG(LogLevel::FATAL) << "绑定失败";
    exit(2);
    }
    LOG(LogLevel::INFO) << "绑定成功,套接字为:" << _sockfd;
    }
    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);
    //❗UDP不会自带\\0,手动补字符串结束符
    buffer[s] = 0;
    //调用外部传入的回调函数,执行业务逻辑
    std::string res = _func(buffer,client);
    sendto(_sockfd,res.c_str(),res.size(),0,(struct sockaddr*)&peer,len);
    }
    }
    }
    ~UdpServer(){}
    private:
    int _sockfd;
    uint16_t _port;
    bool _isrunning;
    func _func;
    };

    2.5 Dick.hpp词典业务模块

    词典业务类,从本地文本文件读取词典内容,按英文: 中文分割,存入unordered_map哈希表做高速查询。对外提供Translate接口。

    ❗原始代码bug:DickLoad硬编码写死文件名,忽略构造函数传入的路径成员变量,本项目做修复。

    #pragma once
    #include<iostream>
    #include<string>
    #include<unordered_map>
    #include"Log.hpp"
    #include<fstream>
    #include"InetAddr.hpp"
    const std::string defaultdict = "./Dictionary.txt";
    const std::string gap = ": ";
    using namespace LogModule;

    class Dick
    {
    public:
    Dick(const std::string& path = ::defaultdict)
    :_dick_path(path)
    {}
    bool DickLoad()
    {
    //使用成员变量,支持自定义词典路径
    std::ifstream in(_dick_path);
    if(!in.is_open())
    {
    LOG(LogLevel::FATAL) << "字典:" << _dick_path << "打开失败";
    return false;
    }
    std::string line;
    while(std::getline(in,line))
    {
    auto pos = line.find(gap);
    if(pos == std::string::npos)
    {
    LOG(LogLevel::WARNNING) << "查找错误!";
    continue;
    }
    std::string english = line.substr(0,pos);
    std::string chinese = line.substr(pos + gap.size());
    if(english.empty() || chinese.empty())
    {
    LOG(LogLevel::WARNNING) << "没有有效内容" << line;
    continue;
    }
    _dick.insert(std::make_pair(english,chinese));
    LOG(LogLevel::INFO) << "载入: " << line << "成功";
    }
    in.close();
    return true;
    }
    //翻译接口:传入单词与客户端地址,返回释义,找不到返回"None"
    std::string Translate(const std::string& word,InetAddr& client)
    {
    auto iter = _dick.find(word);
    if(iter == _dick.end())
    {
    return "None";
    }
    LOG(LogLevel::DEBUG) << "已进入翻译模块," << "[" << client.IP() << ":" <<client.Port() << "]# " << word << iter->second;
    return iter->second;
    }
    ~Dick()
    {}
    private:
    std::string _dick_path;
    std::unordered_map<std::string,std::string> _dick;
    };

    2.6 Dictionary.txt词典数据

    ⚠️格式严格:分割符必须是冒号+空格 : ,要和代码中gap常量保持一致。

    apple: 苹果
    banana: 香蕉
    cat: 猫
    dog: 狗
    book: 书
    pen: 笔
    happy: 快乐的
    sad: 悲伤的
    run: 跑
    jump: 跳
    teacher: 老师
    student: 学生
    car: 汽车
    bus: 公交车
    love: 爱
    hate: 恨
    hello: 你好
    goodbye: 再见
    summer: 夏天
    winter: 冬天

    2.7 main.cc主函数入口

    组装全部模块,将词典翻译方法作为lambda回调传入UdpServer,启动服务器。补充全局log对象的定义,解决头文件extern声明链接报错。

    #include"UdpServer.hpp"
    #include"Dick.hpp"
    //❗全局log对象定义,解决链接问题
    namespace LogModule{
    Logger log;
    }

    int main()
    {
    Enable_File_Log_Strategy();

    Dick dict;
    if(!dict.DickLoad())
    {
    LOG(LogLevel::FATAL) << "词典加载失败";
    return 1;
    }

    uint16_t port = 8080;
    //lambda表达式作为回调,网络层收到报文就会执行这个lambda
    UdpServer server(port,[&dict](const std::string& word,InetAddr& client){
    return dict.Translate(word,client);
    });

    server.Init();
    server.Start();

    return 0;
    }

    2.8 Makefile编译脚本

    本项目依赖C++17标准,用到filesystem库,编译必须指定-std=c++17,链接线程库-lpthread。

    CC=g++
    CFLAGS=-std=c++17 -g -lpthread

    src=main.cc
    target=dick_server

    $(target):$(src)
    $(CC) $(src) -o $(target) $(CFLAGS)

    .PHONY:clean
    clean:
    rm -f $(target)
    rm -rf log

    3 编译运行与测试

    3.1 编译项目

    把所有头文件、源文件放到同一目录,执行make命令完成编译。

    make

    3.2 启动UDP词典服务端

    ./dick_server

    3.3 使用nc客户端测试

    打开新终端,使用nc工具进行UDP交互测试。

    nc -u 127.0.0.1 8080

    输入英文单词,服务端返回中文释义,不存在单词返回None。

    apple
    苹果
    dog
    狗
    test
    None

    4 项目执行流程

  • 程序启动,加载Dictionary.txt词典文件,将键值对存入unordered_map哈希表。
  • UdpServer完成socket创建、bind绑定8080端口,进入循环等待接收UDP报文。
  • 客户端发送英文单词,recvfrom拿到报文与客户端网络地址。
  • 调用lambda回调,进入Dick的Translate方法做哈希查询。
  • 查询结果返回网络层,sendto把结果发送回客户端。
  • 全程各个关键节点打印日志,日志可以输出控制台或者写入本地log目录。
  • 5 原始代码问题修复点

  • GuardMutex参数值拷贝锁对象,修改为引用传递,保证锁逻辑生效。
  • DickLoad函数硬编码默认词典路径,改为读取成员变量_dick_path。
  • 替换线程不安全的inet_ntoa为inet_ntop。
  • 补充全局Logger对象定义,解决多头文件extern声明链接报错。
  • 6 可拓展优化方向

  • 增加信号捕获,捕获SIGINT信号实现Ctrl+C优雅退出服务器。
  • 当前为单线程UDP服务器,可以引入线程池提升并发处理能力。
  • 实现词典热加载,不需要重启程序重新加载词典文本。
  • 日志模块优化,消除每次写日志反复打开关闭文件的IO开销,实现日志按大小分割。
  • 增加报文格式校验,过滤非法脏数据包。
  • 赞(0)
    未经允许不得转载:网硕互联帮助中心 » Linux网络篇——UDP(一)之C++实现UDP英汉词典翻译服务器
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!