UDP Socket 编程从入门到实践:打造回显服务器、字典服务与聊天室

        在网络编程领域,UDP(用户数据报协议)以其无连接、低延迟的特性,在实时通信场景中有着广泛应用。本文将以实战为导向,从基础的 UDP 回显服务器入手,逐步升级到英译汉字典服务,最终实现多线程聊天室,并深入解析 UDP 编程的核心原理与关键技术,帮助开发者快速掌握 UDP Socket 编程精髓。

一、UDP 编程基础:核心概念与地址转换

在开始编码前,我们需要先理解 UDP 编程的底层逻辑和必备工具,这是后续开发的基石。

1.1 UDP 协议核心特性

UDP 作为传输层协议,与 TCP 的 “可靠连接” 不同,它具有以下特点:

        无连接:通信前无需建立三次握手,直接发送数据报

        不可靠:不保证数据有序到达,也不重传丢失的数据包

        面向数据报:数据以固定大小的 “数据报” 为单位传输

        低延迟:省去连接建立和确认机制,适合实时场景(如聊天、直播)

1.2 地址转换函数:解决 “人类可读” 与 “机器可识别” 的矛盾

IP 地址在代码中以 32 位整数(网络字节序)存储,但人类习惯用 “点分十进制”(如 192.168.1.1)表示。以下函数是连接两者的关键工具:

功能函数说明
字符串转 IP(IPv4)inet_addr(const char *strptr)将点分十进制字符串转为in_addr_t(网络字节序)
字符串转 IP(通用)inet_pton(int family, const char *strptr, void *addrptr)支持 IPv4(AF_INET)和 IPv6(AF_INET6),更通用
IP 转字符串(IPv4)inet_ntoa(struct in_addr inaddr)in_addr转为点分十进制字符串,返回静态缓冲区地址
IP 转字符串(通用)inet_ntop(int family, const void *addrptr, char *strptr, size_t len)支持 IPv4/IPv6,需手动提供缓冲区,线程安全

注意点inet_ntoa返回的是静态缓冲区地址,多线程调用会导致数据覆盖,推荐在多线程场景使用inet_ntop

二、V1 版本:极简 UDP 回显服务器(Echo Server)

回显服务器是网络编程的 “Hello World”,功能很简单:接收客户端发送的消息,原样返回给客户端。通过这个案例,我们可以掌握 UDP 编程的基本流程。

2.1 核心设计:禁止拷贝与基础封装

为了避免对象拷贝导致的资源泄漏(如重复关闭 socket),首先定义一个nocopy基类,通过删除拷贝构造和赋值运算符实现 “禁止拷贝”:

// nocopy.hpp
#pragma once
class nocopy {
public:
    nocopy() = default;
    ~nocopy() = default;
    // 禁止拷贝构造和赋值
    nocopy(const nocopy&) = delete;
    const nocopy& operator=(const nocopy&) = delete;
};

2.2 服务器实现:三步完成 UDP 服务搭建

UDP 服务器的核心流程可总结为 “创建 socket → 绑定地址 → 循环收发数据”,具体代码如下:

// UdpServer.hpp
#pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "nocopy.hpp"

// 常量定义
const static uint16_t default_port = 8888;
const static int default_fd = -1;
const static int buffer_size = 1024;

class UdpServer : public nocopy {
public:
    UdpServer(uint16_t port = default_port) 
        : _port(port), _sock_fd(default_fd) {}

    // 1. 初始化:创建socket + 绑定地址
    void Init() {
        // 创建UDP socket(SOCK_DGRAM表示UDP)
        _sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sock_fd < 0) {
            perror("socket create failed");
            exit(1);
        }

        // 绑定地址(IP + 端口)
        struct sockaddr_in local_addr;
        memset(&local_addr, 0, sizeof(local_addr));
        local_addr.sin_family = AF_INET;         // IPv4
        local_addr.sin_port = htons(_port);      // 端口转为网络字节序
        local_addr.sin_addr.s_addr = INADDR_ANY; // 绑定所有网卡IP

        if (bind(_sock_fd, (struct sockaddr*)&local_addr, sizeof(local_addr)) < 0) {
            perror("bind failed");
            close(_sock_fd);
            exit(1);
        }
        std::cout << "Server init success, sock_fd: " << _sock_fd << std::endl;
    }

    // 2. 启动服务:循环收发数据
    void Start() {
        char buffer[buffer_size];
        while (true) { // 服务器常驻运行
            struct sockaddr_in client_addr;
            socklen_t client_addr_len = sizeof(client_addr);

            // 接收客户端消息
            ssize_t recv_len = recvfrom(
                _sock_fd, buffer, buffer_size - 1, 0,
                (struct sockaddr*)&client_addr, &client_addr_len
            );
            if (recv_len < 0) {
                perror("recvfrom failed");
                continue;
            }
            buffer[recv_len] = '\0'; // 确保字符串结束

            // 打印客户端信息与消息
            std::string client_ip = inet_ntoa(client_addr.sin_addr);
            uint16_t client_port = ntohs(client_addr.sin_port);
            std::cout << "[" << client_ip << ":" << client_port << "]# " << buffer << std::endl;

            // 回显消息给客户端
            sendto(
                _sock_fd, buffer, strlen(buffer), 0,
                (struct sockaddr*)&client_addr, client_addr_len
            );
        }
    }

    // 3. 析构:释放资源
    ~UdpServer() {
        if (_sock_fd != default_fd) {
            close(_sock_fd);
        }
    }

private:
    uint16_t _port;   // 服务端口
    int _sock_fd;     // UDP socket文件描述符
};

2.3 客户端实现:无需显式绑定的秘密

UDP 客户端无需像服务器那样 “绑定固定端口”,因为客户端只需随机端口即可(操作系统会自动分配)。客户端流程为 “创建 socket → 发送消息 → 接收回显”:

// UdpClient.cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

void Usage(const std::string& process) {
    std::cout << "Usage: " << process << " server_ip server_port" << std::endl;
}

int main(int argc, char* argv[]) {
    if (argc != 3) {
        Usage(argv[0]);
        return 1;
    }

    // 解析服务器地址
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    // 1. 创建UDP socket
    int sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock_fd < 0) {
        perror("socket create failed");
        return 1;
    }

    // 2. 填充服务器地址
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(server_port);
    server_addr.sin_addr.s_addr = inet_addr(server_ip.c_str());

    // 3. 循环发送与接收
    std::string message;
    char buffer[1024];
    while (true) {
        std::cout << "Please Enter# ";
        std::getline(std::cin, message);

        // 发送消息给服务器
        sendto(
            sock_fd, message.c_str(), message.size(), 0,
            (struct sockaddr*)&server_addr, sizeof(server_addr)
        );

        // 接收服务器回显
        socklen_t server_addr_len = sizeof(server_addr);
        ssize_t recv_len = recvfrom(
            sock_fd, buffer, 1023, 0,
            (struct sockaddr*)&server_addr, &server_addr_len
        );
        if (recv_len < 0) {
            perror("recvfrom failed");
            continue;
        }
        buffer[recv_len] = '\0';
        std::cout << "Server echo# " << buffer << std::endl;
    }

    close(sock_fd);
    return 0;
}

2.4 关键疑问:客户端为什么不需要显式 bind?

        服务器需要bind固定端口:因为客户端需要 “知道去哪里找服务器”(比如 HTTP 默认 80 端口)。

        客户端无需显式bind:操作系统会在客户端首次调用sendto时,自动分配一个随机端口并绑定。若手动bind固定端口,多客户端运行时会出现 “端口占用” 错误。

三、V2 版本:UDP 英译汉字典服务

在回显服务器基础上,我们将 “业务逻辑” 与 “网络通信” 解耦,通过回调函数实现字典查询功能,让代码更具扩展性。

3.1 字典核心:加载词库与查询

首先实现一个Dict类,负责从文件加载词库(dict.txt)并提供查询接口:

// Dict.hpp
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>

const std::string sep = ": "; // 词库文件分隔符(如"apple: 苹果")

class Dict {
public:
    Dict(const std::string& dict_path) : _dict_path(dict_path) {
        LoadDict(); // 构造时加载词库
    }

    // 翻译接口:查询不到返回"Unknown"
    std::string Translate(const std::string& word) {
        auto iter = _dict.find(word);
        return iter != _dict.end() ? iter->second : "Unknown";
    }

private:
    // 加载词库文件到哈希表
    void LoadDict() {
        std::ifstream dict_file(_dict_path);
        if (!dict_file.is_open()) {
            perror("dict file open failed");
            return;
        }

        std::string line;
        while (std::getline(dict_file, line)) {
            if (line.empty()) continue;
            // 分割"单词: 释义"
            size_t sep_pos = line.find(sep);
            if (sep_pos == std::string::npos) continue;

            std::string word = line.substr(0, sep_pos);
            std::string meaning = line.substr(sep_pos + sep.size());
            _dict.emplace(word, meaning);
        }
        dict_file.close();
        std::cout << "Dict loaded, total words: " << _dict.size() << std::endl;
    }

private:
    std::string _dict_path;                     // 词库文件路径
    std::unordered_map<std::string, std::string> _dict; // 哈希表存储词库
};

词库文件dict.txt格式如下:

apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
happy: 快乐的
hello: 你好

3.2 服务器改造:用回调函数解耦业务

修改UdpServer类,通过std::function定义回调函数类型,将 “网络通信” 与 “字典查询” 解耦:

// UdpServer.hpp(V2版本)
#pragma once
#include <iostream>
#include <string>
#include <functional> // 用于std::function
#include <sys/socket.h>
#include <netinet/in.h>
#include "nocopy.hpp"

// 定义回调函数类型:输入请求(单词),输出响应(释义)
using Callback = std::function<void(const std::string& req, std::string* resp)>;

class UdpServer : public nocopy {
public:
    // 构造时传入回调函数(业务逻辑)
    UdpServer(Callback callback, uint16_t port = 8888)
        : _callback(callback), _port(port), _sock_fd(-1) {}

    // 初始化(与V1一致,省略重复代码)
    void Init() { /* 同V1,创建socket + 绑定地址 */ }

    // 启动服务:接收请求 → 回调业务 → 发送响应
    void Start() {
        char buffer[1024];
        while (true) {
            struct sockaddr_in client_addr;
            socklen_t client_addr_len = sizeof(client_addr);

            // 1. 接收客户端请求(单词)
            ssize_t recv_len = recvfrom(
                _sock_fd, buffer, 1023, 0,
                (struct sockaddr*)&client_addr, &client_addr_len
            );
            if (recv_len < 0) continue;
            buffer[recv_len] = '\0';
            std::string req = buffer;

            // 2. 调用回调函数查询字典
            std::string resp;
            _callback(req, &resp);

            // 3. 发送释义给客户端
            sendto(
                _sock_fd, resp.c_str(), resp.size(), 0,
                (struct sockaddr*)&client_addr, client_addr_len
            );

            // 打印日志
            std::string client_ip = inet_ntoa(client_addr.sin_addr);
            uint16_t client_port = ntohs(client_addr.sin_port);
            std::cout << "[" << client_ip << ":" << client_port << "] query: " << req << " → " << resp << std::endl;
        }
    }

private:
    Callback _callback;  // 业务回调函数(字典查询)
    uint16_t _port;      // 服务端口
    int _sock_fd;        // UDP socket
};

3.3 主函数:组装服务与启动

在主函数中创建字典实例、定义回调函数,并启动服务器:

// Main.cpp
#include "UdpServer.hpp"
#include "Dict.hpp"
#include <memory>

// 全局字典实例(加载词库)
Dict g_dict("./dict.txt");

// 回调函数:调用字典查询
void DictCallback(const std::string& req, std::string* resp) {
    *resp = g_dict.Translate(req);
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cout << "Usage: " << argv[0] << " port" << std::endl;
        return 1;
    }
    uint16_t port = std::stoi(argv[1]);

    // 创建服务器实例(传入回调函数)
    std::unique_ptr<UdpServer> server = std::make_unique<UdpServer>(DictCallback, port);
    server->Init();
    server->Start();

    return 0;
}

3.4 测试效果

  1. 启动服务器:./dict_server 8888
  2. 启动客户端:./udp_client 127.0.0.1 8888
  3. 客户端输入 “apple”,服务器返回 “苹果”;输入 “test”,返回 “Unknown”。

四、V3 版本:多线程 UDP 聊天室

聊天室是 UDP 的典型应用场景,需要实现 “消息广播”(将一个客户端的消息发送给所有在线用户)和 “多线程并发”(处理多个客户端的消息)。

4.1 核心挑战与解决方案

        挑战 1:管理在线用户:需要记录所有在线客户端的地址(IP + 端口),用于消息广播。

        挑战 2:多线程安全:多个线程会同时读写 “在线用户列表”,需用互斥锁保护。

        挑战 3:并发处理消息:用线程池处理消息广播,避免主线程阻塞。

4.2 在线用户管理:线程安全的用户列表

首先扩展InetAddr类,增加地址比较和获取原生地址的接口,方便用户管理:

// InetAddr.hpp
#pragma once
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

class InetAddr {
public:
    InetAddr(struct sockaddr_in& addr) : _addr(addr) {
        _ip = inet_ntoa(addr.sin_addr);
        _port = ntohs(addr.sin_port);
    }

    // 获取IP和端口
    std::string Ip() const { return _ip; }
    uint16_t Port() const { return _port; }

    // 获取原生sockaddr_in地址(用于sendto)
    const struct sockaddr_in& GetAddr() const { return _addr; }

    // 重载==:判断是否为同一用户(IP + 端口相同)
    bool operator==(const InetAddr& other) const {
        return _ip == other._ip && _port == other._port;
    }

private:
    std::string _ip;               // 客户端IP
    uint16_t _port;                // 客户端端口
    struct sockaddr_in _addr;      // 原生地址结构
};

4.3 服务器改造:线程池 + 消息广播

引入线程池(ThreadPool,实现略)处理消息广播任务,用互斥锁保护在线用户列表:

// UdpServer.hpp(V3版本)
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include <functional>
#include <sys/socket.h>
#include <netinet/in.h>
#include "nocopy.hpp"
#include "InetAddr.hpp"
#include "ThreadPool.hpp"

// 任务类型:线程池执行的任务
using Task = std::function<void()>;

class UdpServer : public nocopy {
public:
    UdpServer(uint16_t port = 8888) : _port(port), _sock_fd(-1) {
        // 初始化互斥锁
        pthread_mutex_init(&_user_mutex, nullptr);
        // 初始化线程池(4个线程)
        ThreadPool<Task>::GetInstance()->Init(4);
    }

    // 初始化(创建socket + 绑定地址 + 启动线程池)
    void Init() {
        // 1. 创建socket(同V1)
        _sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sock_fd < 0) { perror("socket failed"); exit(1); }

        // 2. 绑定地址(同V1)
        struct sockaddr_in local_addr;
        memset(&local_addr, 0, sizeof(local_addr));
        local_addr.sin_family = AF_INET;
        local_addr.sin_port = htons(_port);
        local_addr.sin_addr.s_addr = INADDR_ANY;
        if (bind(_sock_fd, (struct sockaddr*)&local_addr, sizeof(local_addr)) < 0) {
            perror("bind failed"); close(_sock_fd); exit(1);
        }

        // 3. 启动线程池
        ThreadPool<Task>::GetInstance()->Start();
        std::cout << "Chat room server init success, port: " << _port << std::endl;
    }

    // 添加在线用户(线程安全)
    void AddOnlineUser(const InetAddr& user) {
        pthread_mutex_lock(&_user_mutex);
        // 避免重复添加同一用户
        for (const auto& u : _online_users) {
            if (u == user) {
                pthread_mutex_unlock(&_user_mutex);
                return;
            }
        }
        _online_users.push_back(user);
        std::cout << "User [" << user.Ip() << ":" << user.Port() << "] online, total: " << _online_users.size() << std::endl;
        pthread_mutex_unlock(&_user_mutex);
    }

    // 消息广播:将消息发送给所有在线用户
    void Broadcast(const std::string& message) {
        pthread_mutex_lock(&_user_mutex);
        for (const auto& user : _online_users) {
            sendto(
                _sock_fd, message.c_str(), message.size(), 0,
                (struct sockaddr*)&user.GetAddr(), sizeof(user.GetAddr())
            );
        }
        pthread_mutex_unlock(&_user_mutex);
    }

    // 启动服务:接收消息 → 加入线程池广播
    void Start() {
        char buffer[1024];
        while (true) {
            struct sockaddr_in client_addr;
            socklen_t client_addr_len = sizeof(client_addr);

            // 1. 接收客户端消息
            ssize_t recv_len = recvfrom(
                _sock_fd, buffer, 1023, 0,
                (struct sockaddr*)&client_addr, &client_addr_len
            );
            if (recv_len < 0) continue;
            buffer[recv_len] = '\0';
            InetAddr user(client_addr);

            // 2. 添加用户到在线列表
            AddOnlineUser(user);

            // 3. 构造广播消息(带用户标识)
            std::string message = "[" + user.Ip() + ":" + std::to_string(user.Port()) + "]# " + buffer;

            // 4. 将广播任务加入线程池
            Task broadcast_task = std::bind(&UdpServer::Broadcast, this, message);
            ThreadPool<Task>::GetInstance()->PushTask(broadcast_task);
        }
    }

    ~UdpServer() {
        pthread_mutex_destroy(&_user_mutex);
        if (_sock_fd != -1) close(_sock_fd);
    }

private:
    uint16_t _port;                  // 服务端口
    int _sock_fd;                    // UDP socket
    std::vector<InetAddr> _online_users; // 在线用户列表
    pthread_mutex_t _user_mutex;     // 保护用户列表的互斥锁
};

4.4 客户端改造:多线程收发

客户端需要同时 “发送消息” 和 “接收广播”,因此用两个线程分别处理:

// UdpClient.cpp(V3版本)
#include <iostream>
#include <string>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

// 线程数据:传递socket和服务器地址
struct ThreadData {
    int sock_fd;
    struct sockaddr_in server_addr;
};

// 接收线程:持续接收服务器广播
void* RecvThread(void* arg) {
    ThreadData* data = (ThreadData*)arg;
    char buffer[1024];
    while (true) {
        socklen_t server_addr_len = sizeof(data->server_addr);
        ssize_t recv_len = recvfrom(
            data->sock_fd, buffer, 1023, 0,
            (struct sockaddr*)&data->server_addr, &server_addr_len
        );
        if (recv_len < 0) continue;
        buffer[recv_len] = '\0';
        std::cout << "\n" << buffer << std::endl; // 打印广播消息
        std::cout << "Please Enter# "; // 提示用户输入
        fflush(stdout); // 刷新缓冲区,避免输出卡顿
    }
    return nullptr;
}

// 发送线程:从标准输入读取消息并发送
void* SendThread(void* arg) {
    ThreadData* data = (ThreadData*)arg;
    std::string message;
    while (true) {
        std::cout << "Please Enter# ";
        std::getline(std::cin, message);

        // 发送消息给服务器
        sendto(
            data->sock_fd, message.c_str(), message.size(), 0,
            (struct sockaddr*)&data->server_addr, sizeof(data->server_addr)
        );
    }
    return nullptr;
}

int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cout << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
        return 1;
    }

    // 解析服务器地址
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    // 创建UDP socket
    int sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock_fd < 0) { perror("socket failed"); return 1; }

    // 填充服务器地址
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(server_port);
    server_addr.sin_addr.s_addr = inet_addr(server_ip.c_str());

    // 准备线程数据
    ThreadData data = {sock_fd, server_addr};

    // 创建收发线程
    pthread_t recv_tid, send_tid;
    pthread_create(&recv_tid, nullptr, RecvThread, &data);
    pthread_create(&send_tid, nullptr, SendThread, &data);

    // 等待线程结束(实际不会结束,常驻运行)
    pthread_join(recv_tid, nullptr);
    pthread_join(send_tid, nullptr);

    close(sock_fd);
    return 0;
}

4.5 测试效果

  1. 启动服务器:./chat_server 8888
  2. 启动多个客户端:./chat_client 127.0.0.1 8888
  3. 任一客户端发送消息,所有客户端都会收到该消息(带发送者 IP 和端口)。

五、总结与扩展

本文通过三个递进的案例,覆盖了 UDP Socket 编程的核心知识点:

  1. 基础流程:创建 socket → 绑定地址(服务器)→ 收发数据(recvfrom/sendto)。
  2. 关键技术:地址转换函数、禁止拷贝、回调函数解耦、线程池、互斥锁。
  3. 最佳实践:服务器绑定INADDR_ANY、客户端不手动bind、多线程场景用inet_ntop

扩展方向

        可靠性优化:UDP 本身不可靠,可通过 “序列号”“重传机制” 实现可靠 UDP(如 QUIC 协议)。

        性能优化:增大接收缓冲区、使用内存池减少内存分配、优化线程池大小。

        功能扩展:聊天室添加用户昵称、消息撤回、文件传输等功能。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值