网络编程—Socket编程UDP

前言

在网络编程的世界里,UDP(User Datagram Protocol,用户数据报协议)是一个绕不开的话题。与TCP的"可靠、面向连接"不同,UDP走的是另一条路------++简单、高效、无连接++。虽然它不保证数据一定送达,但正是这种"轻装上阵"的设计,让它在实时音视频、游戏、DNS查询等场景中大放异彩。

本文将带你从零开始,一步步搭建UDP网络程序。我们会从最简单的Echo回显服务器起步,逐步演进到英译汉字典服务器,最后实现一个支持多用户的聊天室。一路上,你会看到代码如何从"能用"变得"好用",理解每一次重构背后的设计思想。

本文适合有一定C++基础、想系统学习Socket编程的同学。所有代码均基于Linux环境,可直接编译运行。


一、UDP协议基础

1.1 什么是UDP

UDP是传输层的两大核心协议之一(另一个是TCP)。它的定位很明确:提供一种无连接、不可靠、面向数据报的数据传输服务。

  • 什么叫"无连接"?就是发送数据之前不需要先建立连接,填好对方的IP和端口,直接发包就行。

  • 什么叫"不可靠"?就是发出去的包能不能到、到的顺序对不对,协议本身不做保证,丢了也不会重传。

  • 什么叫"面向数据报"?就是应用层交下来的数据,UDP会整条打包发出去,接收方也整条收上来,数据之间有明确的边界,不会像TCP那样粘包。

1.2 UDP vs TCP

很多同学学网络编程时,最容易混淆的就是TCP和UDP的区别。下面这张表从多个维度做了对比:

对比维度 TCP UDP
连接性 面向连接,三次握手建立连接 无连接,直接发包
可靠性 可靠,确认、重传、按序交付 不可靠,不确认、不重传、可能乱序
传输形式 面向字节流,无数据边界 面向数据报,有明确边界
首部开销 20字节(不含选项) 8字节
流量/拥塞控制 有,滑动窗口、慢启动等 无,发多快全看应用层
通信模式 只能一对一 支持一对一、一对多、多对多
传输效率 较低,建连和确认有开销 较高,额外开销少
典型场景 文件传输、网页浏览、登录注册 实时音视频、游戏、DNS、直播

打个比方:TCP就像打电话,先接通再说话,对方说"听到了"你才继续;UDP就像寄快递,填好地址直接发,对方收没收到你不一定知道,但胜在快、灵活。

1.3 特性

理解UDP的特性,是写好UDP程序的前提。

① 无连接 :发送方不需要知道对方在不在,也不需要提前打招呼。调用 sendto 直接把包发出去就完事了。这也意味着UDP服务器不需要 listen和accept ,创建好socket、bind好端口,就能直接收数据。

② 不可靠:UDP不提供确认机制、重传机制、流量控制。数据包丢了就是丢了,发送方不会知道,也不会重发。数据包到的顺序乱了,UDP也不会帮你排序。这些如果需要,都得应用层自己实现。

③ 面向数据报:应用层交给UDP多长的数据,UDP就发多长的一个包;接收方收到一个包,就能完整地取出来,不会出现"半个包"或者"两个包粘在一起"的情况。这一点和TCP的字节流模型形成鲜明对比。

④ 全双工:同一个socket既可以发数据也可以收数据。一个socket文件描述符,内核会为它维护接收缓冲区和发送缓冲区(虽然UDP的发送缓冲区更像一个"过路通道"),读写可以同时进行,互不干扰。后面我们写多线程客户端的时候,就会利用这个特性------一个线程负责读,一个线程负责写,共用同一个socket。


二、UDP Socket编程

2.1 核心API

写UDP程序,用到的系统调用其实不多。核心的就那么几个:

(1)socket() 创建套接字

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

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

这个函数的作用是创建一个socket文件描述符。参数的含义:

  • domain:地址族。IPv4用 AF_INET,IPv6用 AF_INET6。我们写的都是IPv4的程序,所以填AF_INET。

  • type:套接字类型。UDP是数据报套接字,填SOCK_DGRAM;TCP是流式套接字,填SOCK_STREAM。

  • protocol:协议编号。一般填0,让系统根据前两个参数自动选择合适的协议。

返回值:成功返回socket文件描述符(一个非负整数),失败返回-1。

(2)bind() 绑定地址信息

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

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

把一个IP地址和端口号绑定到socket上。参数的含义:

  • sockfd:socket文件描述符。

  • addr:要绑定的地址信息,注意参数类型是struct sockaddr*,实际传的是struct sockaddr_in的地址,需要强转。

  • addrlen:地址结构体的大小。

返回值:成功返回0,失败返回-1。

这里有个历史遗留的设计:sockaddr_in是IPv4的地址结构体,但bind函数的参数类型是通用的sockaddr*。所以调用的时候要做一次强制类型转换。

cpp 复制代码
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8888);       // 端口号,要转网络字节序
local.sin_addr.s_addr = INADDR_ANY; // IP地址,INADDR_ANY表示绑定所有网卡

bind(sockfd, (struct sockaddr*)&local, sizeof(local));

(3)recvfrom() 接收数据

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

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

从socket上接收一个数据报,同时获取发送方的地址。参数的含义:

  • sockfd:socket文件描述符。

  • buf:接收缓冲区的首地址。

  • len:缓冲区的大小。

  • flags:接收标志,一般填0表示阻塞接收。

  • src_addr:输出参数,用来带回发送方的地址信息。如果不关心是谁发的,可以传NULL。

  • addrlen:输入输出参数。调用时传src_addr结构体的大小,返回时内核会填上实际地址的长度。

返回值:成功返回实际收到的字节数,失败返回-1。如果对方关闭了连接(UDP其实没有连接的概念,这里指的是收到了ICMP不可达),返回0。

注意:addrlen这个参数很容易写错。它必须初始化为地址结构体的大小,不能随便传个0或者不初始化,否则结果会出错。

(4)sendto() 发送数据

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

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

向指定的地址发送一个数据报。参数的含义和recvfrom对应:

  • sockfd:socket文件描述符。

  • buf:要发送的数据的首地址。

  • len:要发送的数据长度。

  • flags:发送标志,一般填0。

  • dest_addr:目标地址。

  • addrlen:目标地址结构体的大小。

返回值:成功返回实际发送的字节数,失败返回-1。

2.2 地址转换函数

我们平时写IP地址用的是点分十进制的字符串,比如"192.168.1.1",但内核里存的是32位的整数(网络字节序)。这就需要一组函数在两种表示之间做转换。

(1)inet_addr() 字符串转网络序整数

cpp 复制代码
#include <arpa/inet.h>

in_addr_t inet_addr(const char *cp);

把点分十进制的IP字符串转换成32位网络字节序的整数。

cpp 复制代码
local.sin_addr.s_addr = inet_addr("192.168.1.1");

这个函数有个问题:出错时返回INADDR_NONE(也就是0xFFFFFFFF),

而"255.255.255.255"这个合法的广播地址转出来也是0xFFFFFFFF,所以没法区分。现在更推荐用inet_pton。

(2)inet_ntoa() 网络整数转字符串

cpp 复制代码
#include <arpa/inet.h>

char *inet_ntoa(struct in_addr in);

把32位网络字节序的IP地址转换成点分十进制的字符串。

cpp 复制代码
printf("client ip: %s\n", inet_ntoa(peer.sin_addr));

这个函数用起来很方便,但有个大坑------它返回的是一个指向静态缓冲区的指针。也就是说,函数内部自己申请了一块静态内存来存结果,每次调用都会覆盖上一次的结果。

(3)inet_pton() 新的转换函数

这两个是新版的地址转换函数,同时支持IPv4和IPv6,而且线程安全。

cpp 复制代码
#include <arpa/inet.h>

// 字符串转网络序
int inet_pton(int af, const char *src, void *dst);

// 网络序转字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

参数说明:

  • af:地址族,AF_INET或AF_INET6。

  • src:源地址。

  • dst:目标缓冲区。

  • size:目标缓冲区的大小。IPv4用INET_ADDRSTRLEN(16),IPv6用INET6_ADDRSTRLEN(46)。

这两个函数的好处是:缓冲区由调用者自己提供,每个线程用自己栈上的缓冲区,完全不会互相干扰。

2.3 inet_ntoa的陷阱

前面提到inet_ntoa返回的是静态缓冲区的指针,这在单线程下问题不大------用完了再调用,覆盖就覆盖了。但在多线程环境下,这就是个定时炸弹。

想象一下这个场景:线程1调用inet_ntoa得到了IP字符串的指针,还没来得及打印,线程2也调用了inet_ntoa,把静态缓冲区里的内容给覆盖了。等线程1再去打印的时候,打出来的就是线程2的结果。

APUE(《UNIX环境高级编程》)里明确指出,inet_ntoa不是线程安全的函数。不过在有些系统(比如CentOS 7)上,内部实现可能加了互斥锁,测试的时候不一定能复现问题。但这不代表它是安全的------换个系统、换个版本,可能就出bug了。

正确的做法:多线程环境下,一律用inet_ntop。缓冲区自己提供,用栈上的局部变量就行,安全又省心。

cpp 复制代码
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &peer.sin_addr, ip_str, sizeof(ip_str));
printf("client ip: %s\n", ip_str);

多线程环境下不要用 inet_ntoa,改用 inet_ntop。缓冲区自己提供,避免静态数据被覆盖。


三、V1版本:Echo回显服务器

3.1 设计思路

我们的第一个UDP程序,是一个最简单的Echo服务器------客户端发什么,服务器就原样返回什么。虽然简单,但它包含了UDP编程的所有基本要素:创建socket、绑定端口、接收数据、发送数据。

整体流程很清晰:

  1. 服务器创建socket,绑定端口,进入循环等待客户端的数据。

  2. 客户端创建socket,从标准输入读取内容,发给服务器。

  3. 服务器收到数据后,打印一下,再原封不动发回去。

  4. 客户端收到回显,打印出来。

3.2 基础工具类

在写服务器之前,我们先准备几个基础的工具类。

(1)nocopy.hpp 不可拷贝基类

服务器这种对象,语义上是不应该被拷贝的。我们写一个nocopy基类,把拷贝构造和赋值运算符删掉,服务器类继承它就天然不可拷贝了。

cpp 复制代码
#pragma once

class nocopy
{
public:
    nocopy() {}
    nocopy(const nocopy &) = delete;
    const nocopy& operator=(const nocopy &) = delete;
    ~nocopy() {}
};

这是C++里很常用的一个小技巧。把拷贝构造和赋值运算符设为delete,派生类就默认不可拷贝了。

(2)InetAddr.hpp 网络地址封装

sockaddr_in这个结构体用起来有点麻烦,每次取IP和端口都要调用转换函数。我们把它封装成一个InetAddr类,用起来会顺手很多。

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

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

    std::string Ip() { return _ip; }
    uint16_t Port() { return _port; }

    std::string PrintDebug()
    {
        std::string info = _ip;
        info += ":";
        info += std::to_string(_port); // "127.0.0.1:4444"
        return info;
    }

    InetAddr() {}

private:
    std::string _ip;
    uint16_t _port;
    struct sockaddr_in _addr;
};

这个类做的事情很简单:构造的时候把 sockaddr_in 里的IP和端口转成我们熟悉的格式存起来,对外提供几个获取方法。后面打印日志、调试的时候,直接调 PrintDebug() 就能拿到"IP:端口"格式的字符串。

(3)Comm.hpp 错误码定义

cpp 复制代码
#pragma once

enum {
    Usage_Err = 1,
    Socket_Err,
    Bind_Err
};

定义几个错误码,程序出错退出时用。简单但实用。

3.3 服务端实现

有了上面的工具类,服务器的实现就很清爽了。

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#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;

class UdpServer : public nocopy
{
public:
    UdpServer(uint16_t port = defaultport)
        : _port(port), _sockfd(defaultfd)
    {}

    void Init()
    {
        // 1. 创建socket
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            LogMessage(Fatal, "socket error, %d : %s\n", errno, strerror(errno));
            exit(Socket_Err);
        }
        LogMessage(Info, "socket success, sockfd: %d\n", _sockfd);

        // 2. 绑定端口和IP
        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)
        {
            LogMessage(Fatal, "bind error, %d : %s\n", errno, strerror(errno));
            exit(Bind_Err);
        }
    }

    void Start()
    {
        // 服务器永远不退出
        char buffer[defaultsize];
        for (;;)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0,
                                 (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                InetAddr addr(peer);
                buffer[n] = 0; // 补0,方便当字符串用
                std::cout << "[" << addr.PrintDebug() << "]# " << buffer << std::endl;

                // 原样发回去
                sendto(_sockfd, buffer, strlen(buffer), 0,
                       (struct sockaddr *)&peer, len);
            }
        }
    }

    ~UdpServer()
    {
        if (_sockfd > 0) close(_sockfd);
    }

private:
    uint16_t _port;
    int _sockfd;
};

代码不长,但信息量不小。我们挑几个关键点说一下。

(1)INADDR_ANY

绑定IP的时候,我们没有填具体的IP,而是用了INADDR_ANY。这是什么意思呢?

一台服务器可能有多块网卡,每块网卡有自己的IP地址。如果bind的时候指定了某个具体的IP,那这个socket就只能收到从这块网卡进来的数据。而用INADDR_ANY(值为0)的话,意思是"我不挑,所有网卡的数据我都收"。

写服务器的时候,几乎总是用INADDR_ANY。一来简单,不用关心服务器到底有几块网卡、IP是多少;二来灵活,服务器IP变了代码也不用改。云服务器上尤其不能直接bind公网IP------云服务器的公网IP往往是NAT映射出来的,内核里根本看不到那个IP,bind会直接失败。

(2)recvfrom

recvfrom的后两个参数是输出型的,用来带回对端的地址信息。len这个参数很容易被忽略------它必须在调用前初始化为结构体的大小,不能是0,也不能是随机值。因为内核会根据这个值判断缓冲区有多大,防止越界。

3.4 客户端实现

cpp 复制代码
#include <iostream>
#include <cerrno>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

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

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

    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    // 1. 创建socket
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0)
    {
        std::cerr << "socket error: " << strerror(errno) << std::endl;
        return 2;
    }
    std::cout << "create socket success: " << sock << std::endl;

    // 2. 填充服务器地址信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    server.sin_addr.s_addr = inet_addr(serverip.c_str());

    while (true)
    {
        // 从标准输入读取
        std::string inbuffer;
        std::cout << "Please Enter# ";
        std::getline(std::cin, inbuffer);

        // 发给服务器
        ssize_t n = sendto(sock, inbuffer.c_str(), inbuffer.size(), 0,
                           (struct sockaddr*)&server, sizeof(server));
        if (n > 0)
        {
            char buffer[1024];
            struct sockaddr_in temp;
            socklen_t len = sizeof(temp);
            // 收回显
            ssize_t m = recvfrom(sock, buffer, sizeof(buffer)-1, 0,
                                 (struct sockaddr*)&temp, &len);
            if (m > 0)
            {
                buffer[m] = 0;
                std::cout << "server echo# " << buffer << std::endl;
            }
            else break;
        }
        else break;
    }

    close(sock);
    return 0;
}

3.5 答疑

细心的同学可能已经发现了:服务器端调用了bind,但客户端没有。客户端也需要一个端口号来通信啊,那它的端口是哪来的?

答案是:客户端会在首次发送数据的时候,由操作系统自动分配一个随机端口

为什么服务器必须显式bind,而客户端不用?原因很简单:

  • 服务器的端口号必须是"众所周知"的、固定的。客户端要想连上服务器,得先知道服务器的IP和端口。如果服务器端口每次启动都变,客户端根本找不到它。

  • 客户端的端口号不需要固定。客户端数量很多,而且是主动发起连接的一方,只要自己有个端口能收发数据就行,具体是多少无所谓。让操作系统随机分配,还能避免端口冲突的问题。

当然,客户端也可以显式调用bind来指定端口。但一般没必要,除非有特殊需求(比如某些防火墙规则要求客户端用特定端口)。


四、V2版本:英译汉字典服务器

4.1 设计思想

Echo服务器虽然能跑,但业务逻辑和网络代码搅在一起------服务器收到数据直接就发回去了。如果我们想换个业务,比如做个翻译服务器、计算器服务器,就得改UdpServer类本身。这不符合"开闭原则"。

更好的做法是:把网络通信和业务处理分开。UdpServer只负责收数据、发数据,具体怎么处理数据,交给一个回调函数来做。这样一来,想换业务的时候,不用改服务器的代码,换个回调函数就行。

这就是我们V2版本的核心改进------用回调函数解耦网络层和业务层。

4.2 字典类实现

我们的业务是"英译汉"。先写一个Dict类,负责加载字典文件和提供翻译功能。

字典文件格式很简单,每行一个单词和对应的中文,用": "分隔:

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

Dict类的实现:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>

const std::string sep = ": ";

class Dict
{
private:
    void LoadDict()
    {
        std::ifstream in(_confpath);
        if (!in.is_open())
        {
            std::cerr << "open file error" << 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;

            std::string key = line.substr(0, pos);
            std::string value = line.substr(pos + sep.size());
            _dict.insert(std::make_pair(key, value));
        }

        in.close();
    }

public:
    Dict(const std::string &confpath) : _confpath(confpath)
    {
        LoadDict();
    }

    std::string Translate(const std::string &key)
    {
        auto iter = _dict.find(key);
        if (iter == _dict.end()) return std::string("Unknown");
        else return iter->second;
    }

    ~Dict() {}

private:
    std::string _confpath;
    std::unordered_map<std::string, std::string> _dict;
};

这个类做了两件事:构造时从文件加载字典,存到一个unordered_map里;对外提供一个Translate方法,查不到就返回"Unknown"。

4.3 带回调的服务器

现在我们改造UdpServer,让它支持回调函数。用C++11的std::function,可以兼容函数指针、仿函数、lambda,非常灵活。

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#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;

// 回调函数类型:收到请求,处理后填响应
using func_t = std::function<void(const std::string &req, std::string *resp)>;

class UdpServer : public nocopy
{
public:
    UdpServer(func_t func, uint16_t port = defaultport)
        : _func(func), _port(port), _sockfd(defaultfd)
    {}

    void Init()
    {
        // 1. 创建socket
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            LogMessage(Fatal, "socket error, %d : %s\n", errno, strerror(errno));
            exit(Socket_Err);
        }
        LogMessage(Info, "socket success, sockfd: %d\n", _sockfd);

        // 2. 绑定
        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)
        {
            LogMessage(Fatal, "bind error, %d : %s\n", errno, strerror(errno));
            exit(Bind_Err);
        }
    }

    void Start()
    {
        char buffer[defaultsize];
        for (;;)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0,
                                 (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                InetAddr addr(peer);
                buffer[n] = 0;
                std::cout << "[" << addr.PrintDebug() << "]# " << buffer << std::endl;

                std::string value;
                _func(buffer, &value); // 调用回调处理业务

                sendto(_sockfd, value.c_str(), value.size(), 0,
                       (struct sockaddr *)&peer, len);
            }
        }
    }

    ~UdpServer()
    {
        if (_sockfd > 0) close(_sockfd);
    }

private:
    uint16_t _port;
    int _sockfd;
    func_t _func; // 业务回调函数
};

和V1版本相比,变化不大。主要就是多了一个_func成员,构造的时候传进来。收到数据后,不再直接回显,而是调用_func来处理,然后把处理结果发回去。

就这么一点点改动,服务器的灵活性大大提升了。它不再是"只能做回显"的服务器,而是"能处理任何业务"的通用服务器框架。

4.4 主函数

cpp 复制代码
#include "UdpServer.hpp"
#include "Comm.hpp"
#include "Dict.hpp"
#include <memory>

void Usage(std::string proc)
{
    std::cout << "Usage : \n\t" << proc << " local_port\n" << std::endl;
}

Dict gdict("./dict.txt");

void Execute(const std::string &req, std::string *resp)
{
    *resp = gdict.Translate(req);
}

// ./udp_server 8888
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        return Usage_Err;
    }

    uint16_t port = std::stoi(argv[1]);

    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(Execute, port);
    usvr->Init();
    usvr->Start();

    return 0;
}

这里我们定义了一个全局的Dict对象,然后写了一个Execute函数作为回调。回调函数的逻辑很简单:把收到的请求当单词去查字典,把结果写到resp里。

如果你想换个业务,比如做个计算器,不用改UdpServer的一行代码,只需要换一个回调函数就行。这就是解耦的好处。


五、V2进阶:通用服务器封装

5.1 为什么要再封装一层

上面的版本已经不错了,但还可以做得更好。现在UdpServer类里既包含了socket的操作,又包含了服务器的循环逻辑。如果我们还想在别的地方单独用socket的收发功能呢?

答案是:把socket的基本操作再抽出来,做成一个独立的UdpSocket类。然后UdpServer基于UdpSocket来实现。这样分层更清晰,复用性也更强。

5.2 UdpSocket类

cpp 复制代码
#pragma once
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <cassert>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;

class UdpSocket {
public:
    UdpSocket() : fd_(-1) {}

    bool Socket() {
        fd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (fd_ < 0) {
            perror("socket");
            return false;
        }
        return true;
    }

    bool Close() {
        close(fd_);
        return true;
    }

    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_, (sockaddr*)&addr, sizeof(addr));
        if (ret < 0) {
            perror("bind");
            return false;
        }
        return true;
    }

    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,
                                     (sockaddr*)&peer, &len);
        if (read_size < 0) {
            perror("recvfrom");
            return false;
        }

        buf->assign(tmp, read_size);
        if (ip != NULL) {
            *ip = inet_ntoa(peer.sin_addr);
        }
        if (port != NULL) {
            *port = ntohs(peer.sin_port);
        }
        return true;
    }

    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,
                                    (sockaddr*)&addr, sizeof(addr));
        if (write_size < 0) {
            perror("sendto");
            return false;
        }
        return true;
    }

private:
    int fd_;
};

这个类把socket的创建、关闭、绑定、接收、发送都封装了一遍。接口用起来比原生的系统调用舒服多了------参数都是std::string,不用手动处理sockaddr_in那些结构体。

注意RecvFrom的设计:ip和port这两个参数是可选的,默认值为NULL。调用者如果不关心对端的地址,就不用传,函数内部会判断。

5.3 基于UdpSocket的通用服务器

有了UdpSocket,服务器类就变得非常简洁了。

cpp 复制代码
#pragma once
#include "udp_socket.hpp"
#include <functional>

typedef std::function<void (const std::string&, std::string* resp)> Handler;

class UdpServer {
public:
    UdpServer() {
        assert(sock_.Socket());
    }

    ~UdpServer() {
        sock_.Close();
    }

    bool Start(const std::string& ip, uint16_t port, Handler handler) {
        // 1. 绑定端口
        bool ret = sock_.Bind(ip, port);
        if (!ret) {
            return false;
        }

        // 2. 进入事件循环
        for (;;) {
            // 3. 读取请求
            std::string req;
            std::string remote_ip;
            uint16_t remote_port = 0;
            bool ret = sock_.RecvFrom(&req, &remote_ip, &remote_port);
            if (!ret) {
                continue;
            }

            // 4. 计算响应
            std::string resp;
            handler(req, &resp);

            // 5. 返回响应
            sock_.SendTo(resp, remote_ip, remote_port);
            printf("[%s:%d] req: %s, resp: %s\n",
                   remote_ip.c_str(), remote_port, req.c_str(), resp.c_str());
        }

        return true;
    }

private:
    UdpSocket sock_;
};

你看,现在UdpServer的代码里已经看不到socket、bind、recvfrom这些系统调用了。它只关心"收请求→处理→发响应"这个流程,具体怎么收、怎么发,全交给UdpSocket去做。

这就是封装的价值------每一层只关心自己的事情,上层不用了解下层的细节。代码的可读性、可维护性、可复用性都上去了。

5.4 字典服务器实现

基于上面的通用服务器,实现一个字典服务器简直是分分钟的事。

cpp 复制代码
#include "udp_server.hpp"
#include <unordered_map>
#include <iostream>

std::unordered_map<std::string, std::string> g_dict;

void Translate(const std::string& req, std::string* resp) {
    auto it = g_dict.find(req);
    if (it == g_dict.end()) {
        *resp = "未查到!";
        return;
    }
    *resp = it->second;
}

int main(int argc, char* argv[]) {
    if (argc != 3) {
        printf("Usage ./dict_server [ip] [port]\n");
        return 1;
    }

    // 1. 初始化字典数据
    g_dict.insert(std::make_pair("hello", "你好"));
    g_dict.insert(std::make_pair("world", "世界"));
    g_dict.insert(std::make_pair("c++", "最好的编程语言"));
    g_dict.insert(std::make_pair("bit", "特别NB"));

    // 2. 启动服务器
    UdpServer server;
    server.Start(argv[1], atoi(argv[2]), Translate);

    return 0;
}

整个程序加起来不到40行。初始化一下字典,写个翻译的回调,然后启动服务器,完事。

5.5 通用客户端

既然服务器封装了,客户端也顺手封装一下吧。

cpp 复制代码
#pragma once
#include "udp_socket.hpp"

class UdpClient {
public:
    UdpClient(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
        assert(sock_.Socket());
    }

    ~UdpClient() {
        sock_.Close();
    }

    bool RecvFrom(std::string* buf) {
        return sock_.RecvFrom(buf);
    }

    bool SendTo(const std::string& buf) {
        return sock_.SendTo(buf, ip_, port_);
    }

private:
    UdpSocket sock_;
    std::string ip_;
    uint16_t port_;
};

客户端更简单。构造的时候记下服务器的地址,然后提供SendTo和RecvFrom两个方法。因为客户端的目标地址是固定的,所以SendTo不用每次都传IP和端口。

对应的客户端主程序:

cpp 复制代码
#include "udp_client.hpp"
#include <iostream>

int main(int argc, char* argv[]) {
    if (argc != 3) {
        printf("Usage ./dict_client [ip] [port]\n");
        return 1;
    }

    UdpClient client(argv[1], atoi(argv[2]));
    for (;;) {
        std::string word;
        std::cout << "请输入您要查的单词: ";
        std::cin >> word;
        if (!std::cin) {
            std::cout << "Good Bye" << std::endl;
            break;
        }

        client.SendTo(word);
        std::string result;
        client.RecvFrom(&result);
        std::cout << word << " 意思是 " << result << std::endl;
    }

    return 0;
}

封装之后,上层代码读起来就像自然语言一样------创建客户端,发单词,收结果,打印。非常清爽。


六、V3版本:简单聊天室

6.1 需求分析与设计

有了前面的基础,我们来做个更有意思的东西------一个简单的聊天室。

需求很明确:

  • 多个客户端可以同时连接服务器

  • 任何一个客户端发消息,服务器都要转发给所有在线的客户端

  • 客户端能同时发消息和收消息

  • 客户端输入"QUIT"就退出,服务器把它从在线列表里移除

这个需求比之前的Echo和字典服务器复杂在哪?复杂在"多用户"和"广播"。之前的服务器都是"收一个请求,回一个响应",是一对一的模式。现在是"收一个人的消息,转发给所有人",是一对多的模式。

那怎么设计呢?核心思路是:

  1. 服务器维护一个"在线用户列表",每个用户用IP+端口来标识。

  2. 有新用户发消息过来,就把它加到列表里。

  3. 收到任何用户的消息,都遍历在线列表,给每个人转发一份。

  4. 用户发"QUIT",就把它从列表里删掉。

下面这张图展示了整体架构:

左边是客户端,有两个线程:读线程负责从服务器收消息并打印,写线程负责从标准输入读内容并发给服务器。右边是服务器,UdpServer负责接收消息,然后把转发任务推给线程池,线程池里的线程负责遍历在线用户列表并发送。

6.2 路由模块设计

转发消息、管理在线用户,这些逻辑我们单独放到一个Route类里。这样UdpServer只负责网络通信,Route负责业务逻辑,各司其职。

先改造一下InetAddr,增加一个==运算符重载,方便判断两个地址是不是同一个用户。

cpp 复制代码
bool operator==(const InetAddr& addr)
{
    return this->_ip == addr._ip && this->_port == addr._port;
}

const struct sockaddr_in& GetAddr()
{
    return _addr;
}

同时加一个GetAddr方法,把内部的sockaddr_in暴露出来,sendto的时候要用。

然后是Route类:

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include "InetAddr.hpp"
#include "Log.hpp"

using namespace LogModule;

class Route
{
private:
    bool IsExist(InetAddr &peer)
    {
        for (auto &user : _online_user)
        {
            if (user == peer)
            {
                return true;
            }
        }
        return false;
    }

    void AddUser(InetAddr &peer)
    {
        LOG(LogLevel::INFO) << "新增一个在线用户: " << peer.StringAddr();
        _online_user.push_back(peer);
    }

    void DeleteUser(InetAddr &peer)
    {
        for (auto iter = _online_user.begin(); iter != _online_user.end(); iter++)
        {
            if (*iter == peer)
            {
                LOG(LogLevel::INFO) << "删除一个在线用户:" << peer.StringAddr() << "成功";
                _online_user.erase(iter);
                break;
            }
        }
    }

public:
    Route() {}

    void MessageRoute(int sockfd, const std::string &message, InetAddr &peer)
    {
        // 新用户?加进来
        if (!IsExist(peer))
        {
            AddUser(peer);
        }

        // 构造要转发的消息:加上发送者信息
        std::string send_message = peer.StringAddr() + "# " + message;

        // 广播给所有在线用户
        for (auto &user : _online_user)
        {
            sendto(sockfd, send_message.c_str(), send_message.size(), 0,
                   (const struct sockaddr *)&(user.GetAddr()), sizeof(user.GetAddr()));
        }

        // 发QUIT就下线
        if (message == "QUIT")
        {
            LOG(LogLevel::INFO) << "删除一个在线用户: " << peer.StringAddr();
            DeleteUser(peer);
        }
    }

    ~Route() {}

private:
    std::vector<InetAddr> _online_user; // 在线用户列表
};

这个类的核心是MessageRoute方法。它做三件事:

  1. 检查发消息的人在不在在线列表里,不在就加进去(首次发消息等同于登录)。

  2. 把消息加上发送者的标识,然后遍历在线列表,挨个转发。

  3. 如果消息是"QUIT",就把这个用户从列表里删掉。

这里有个小细节:转发消息的时候,我们在消息前面加上了发送者的IP和端口。这样客户端收到消息,就知道是谁发的了。

6.3 服务器端改造

服务器端的改动不大。主要是回调函数的签名变了------因为转发的时候需要用到socket文件描述符,所以回调函数要把sockfd也带上。

cpp 复制代码
using func_t = std::function<void(int sockfd, const std::string&, InetAddr&)>;

// ...

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;
            _func(_sockfd, buffer, client); // 把sockfd也传给回调
        }
    }
}

主函数里,我们把Route对象和UdpServer对象组合起来:

cpp 复制代码
// 单进程服务器
// std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&r](int sockfd, const std::string &message, InetAddr& peer){
//     r.MessageRoute(sockfd, message, peer);
// });

这里用了lambda表达式作为回调。lambda捕获了Route对象的引用,收到消息时就调用Route的MessageRoute方法。

6.4 多线程客户端

客户端这边有个问题:之前的客户端是先发再收,发一条收一条。但在聊天室里,别人发的消息随时可能过来,不能等我们输入完了再去收。

怎么办?用多线程。一个线程专门负责发消息(从标准输入读,发给服务器),另一个线程专门负责收消息(从socket读,打印到屏幕)。

这就用到了UDP的全双工特性------同一个socket,两个线程同时读写,互不干扰。

cpp 复制代码
#include <iostream>
#include <cerrno>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "Thread.hpp"
#include "InetAddr.hpp"

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

class ThreadData
{
public:
    ThreadData(int sock, struct sockaddr_in &server)
        : _sockfd(sock), _serveraddr(server)
    {}

    ThreadData() {}

public:
    int _sockfd;
    InetAddr _serveraddr;
};

// 读线程:不断从服务器收消息并打印
void RecverRoutine(ThreadData &td)
{
    char buffer[4096];
    while (true)
    {
        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
        ssize_t n = recvfrom(td._sockfd, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr *)&temp, &len);
        if (n > 0)
        {
            buffer[n] = 0;
            std::cerr << buffer << std::endl; // 用stderr打印,方便和输入提示区分
        }
        else break;
    }
}

// 写线程:不断从标准输入读,发给服务器
void SenderRoutine(ThreadData &td)
{
    while (true)
    {
        std::string inbuffer;
        std::cout << "Please Enter# ";
        std::getline(std::cin, inbuffer);

        auto server = td._serveraddr.GetAddr();
        ssize_t n = sendto(td._sockfd, inbuffer.c_str(), inbuffer.size(), 0,
                           (struct sockaddr *)&server, sizeof(server));
        if (n <= 0)
        {
            std::cout << "send error" << std::endl;
        }
    }
}

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

    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    // 1. 创建socket
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0)
    {
        std::cerr << "socket error: " << strerror(errno) << std::endl;
        return 2;
    }
    std::cout << "create socket success: " << sock << std::endl;

    // 2. 填充服务器信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    server.sin_addr.s_addr = inet_addr(serverip.c_str());

    // 3. 启动两个线程
    ThreadData td(sock, server);
    Thread<ThreadData> recver("recver", RecverRoutine, td);
    Thread<ThreadData> sender("sender", SenderRoutine, td);

    recver.Start();
    sender.Start();

    recver.Join();
    sender.Join();

    close(sock);
    return 0;
}

这里有几个细节值得注意:

(1)为什么读线程用stderr打印?

因为写线程会用std::cout打印"Please Enter# "的提示。如果读线程也用std::cout,两个线程的输出可能会混在一起,看起来很乱。用std::cerr的话,在终端里可以通过重定向把它们分开,比如./client 127.0.0.1 8888 2>msg.log,这样收到的消息都进文件,屏幕上只显示输入提示。测试的时候也可以用管道来演示,更直观。

(2)为什么两个线程可以共用一个socket?

因为UDP的socket是全双工的。内核为每个socket维护了接收缓冲区和发送缓冲区,读操作只动接收缓冲区,写操作只动发送缓冲区,互不干扰。所以两个线程一个读一个写,完全没问题,不需要加锁。

6.5 线程池优化

上面的单进程版本,服务器收到消息后直接在主循环里转发。如果在线用户很多,遍历发送的过程就比较耗时,会影响接收新消息的速度。

怎么优化?可以引入线程池。服务器收到消息后,把转发任务封装成一个任务对象,丢到线程池里,由线程池里的线程去执行转发。这样主循环就能及时回来收下一个包,不会被阻塞。

cpp 复制代码
// 1. 路由服务
std::unique_ptr<Route> r = std::make_unique<Route>();

// 2. 线程池
auto tp = ThreadPool<task_t>::GetInstance();

// 3. 网络服务器对象
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&r, &tp](int sockfd, const std::string &message, InetAddr& peer){
    task_t t = std::bind(&Route::MessageRoute, r.get(), sockfd, message, peer);
    tp->Enqueue(t);
});

改动不大,就是把原来直接调用MessageRoute的地方,改成了用std::bind把函数和参数绑成一个任务对象,塞到线程池的任务队列里。

这里用了单例模式的线程池,具体的实现就不展开了。核心思想就是"生产者-消费者"模型:主线程是生产者,负责生产任务;线程池里的线程是消费者,负责执行任务。


七、关键技术点解析

7.1 网络字节序与主机字节序

写网络程序的时候,经常会看到htons、ntohs、htonl、ntohl这些函数。它们是干嘛的?

这就要说到"字节序"了。字节序指的是多字节数据在内存中的存储顺序。有两种常见的字节序:

  • 大端字节序(Big-Endian):数据的低位字节存在高地址,高位字节存在低地址。

  • 小端字节序(Little-Endian):数据的低位字节存在低地址,高位字节存在高地址。

不同的CPU架构,字节序可能不一样。x86架构的CPU一般是小端,而有些嵌入式系统是大端。

那网络传输的时候用哪种呢?TCP/IP协议规定:网络上传输的数据一律使用大端字节序,也叫"网络字节序"。

这就带来了一个问题:如果你的主机是小端的(比如x86),那你把数据发出去之前,得转成大端;收到数据之后,得从大端转成主机的字节序。不然对方解读出来的数值就是错的。

那几个函数就是干这个的:

  • htons:host to network short,主机字节序转网络字节序(16位)

  • ntohs:network to host short,网络字节序转主机字节序(16位)

  • htonl:host to network long,主机字节序转网络字节序(32位)

  • ntohl:network to host long,网络字节序转主机字节序(32位)

你看我们的代码里,设置端口号的时候都要调一下htons:

cpp 复制代码
local.sin_port = htons(_port);

取端口号的时候要调一下ntohs:

cpp 复制代码
_port = ntohs(_addr.sin_port);

IP地址也是一样,inet_addr返回的就是网络字节序的32位整数,所以不用再转了。

有同学可能会问:如果我的主机本来就是大端的,那这些函数岂不是白调用了?没关系。这些函数在大端机器上就是空实现,什么都不做。所以不管你的主机是什么字节序,该调就调,代码照样可移植。

7.2 UDP全双工的底层原理

前面多次提到UDP是全双工的,一个socket既能读也能写。那它底层是怎么实现的?

其实很简单:内核为每个socket维护了两个缓冲区------一个接收缓冲区,一个发送缓冲区。

  • 当你调用sendto的时候,数据被放到发送缓冲区,然后内核会把它封装成UDP报文,往下交给网络层,发出去。

  • 当网络上有数据到达这个socket对应的端口时,内核会把数据放到接收缓冲区里,等着应用层来读。

  • 你调用recvfrom的时候,就是从接收缓冲区里取数据。

这两个缓冲区是独立的,读操作和写操作各走各的,互不影响。所以一个线程读、一个线程写,完全没问题,不会冲突。

不过要注意,UDP的发送缓冲区和TCP的不太一样。TCP的发送缓冲区是真的"缓冲"------数据放进去之后,内核会慢慢发,丢了还会重传,应用层不用管。而UDP的发送缓冲区更像一个"通道",数据放进去,内核打包发出去就完事了,不会留存。所以UDP是"面向数据报"的,每个包独立发送,独立接收。

7.3 为什么客户端不需要显式bind

这个问题前面提过一句,这里再展开说说。

首先明确一点:客户端不是不需要bind,而是不需要"显式"bind。客户端的socket也是有端口号的,只是这个端口号是操作系统在第一次调用sendto的时候自动分配的。

为什么这么设计?因为客户端和服务器的定位不一样:

  • 服务器是"被动"的,等着别人来连。所以它的地址(IP+端口)必须是固定的、公开的,客户端才能找到它。所以服务器必须显式bind一个固定的端口。

  • 客户端是"主动"的,主动去连服务器。它只需要有个端口能收发数据就行,具体是多少无所谓。而且客户端数量很多,如果每个客户端都自己指定端口,很容易冲突。让操作系统随机分配,既方便又不会冲突。

那操作系统自动分配的端口范围是多少呢?一般是临时端口(ephemeral port),在Linux上可以通过/proc/sys/net/ipv4/ip_local_port_range查看,默认大概是32768到60999之间。

当然,客户端也可以显式bind。比如有些场景下,防火墙要求客户端必须用某个特定的端口才能访问外网,这时候就需要显式bind。但这种情况比较少见,一般客户端都让系统自动分配。

7.4 INADDR_ANY的作用

服务器bind的时候,IP地址填的是INADDR_ANY。这个宏的值是0,表示"任意地址"。

一台服务器可能有多块网卡,比如一块内网网卡、一块公网网卡,还有一个loopback网卡。每块网卡有自己的IP地址。

如果bind的时候指定了某个具体的IP,比如192.168.1.100,那这个socket就只能收到目的地址是192.168.1.100的数据包。从其他网卡进来的包,即使端口号对得上,也不会交给这个socket。

而如果bind的时候用INADDR_ANY,意思就是"我不挑,只要是发给我这个端口的,不管从哪块网卡进来的,我都收"。

写服务器程序的时候,几乎总是用INADDR_ANY。原因有几个:

  • 简单:不用关心服务器有几块网卡、IP是什么。

  • 灵活:服务器IP变了,代码不用改,重启一下就行。

  • 云服务器必需:很多云服务器的公网IP是NAT映射的,主机上的网卡根本没有这个公网IP。你要是bind公网IP,直接就报错了。用INADDR_ANY就没这个问题。


八、总结

到这里,我们从最简单的Echo服务器出发,一步步演进,最终实现了一个支持多用户的UDP聊天室。回顾一下整个过程:

  • V1 Echo服务器:掌握了UDP编程的基本流程------创建socket、bind、recvfrom、sendto。理解了INADDR_ANY的作用,以及客户端为什么不需要显式bind。

  • V2 字典服务器:引入了回调函数,把网络层和业务层解耦。服务器不再和具体业务绑定,变成了一个通用的框架。

  • V2 通用封装:把socket操作封装成UdpSocket类,服务器和客户端都基于它来实现。分层更清晰,代码复用性更好。

  • V3 聊天室:引入了路由模块管理在线用户,实现了消息广播。客户端用多线程实现了同时读写,利用了UDP的全双工特性。服务器端引入线程池优化了并发性能。

在这个过程中,我们还深入讨论了几个关键的技术点:网络字节序、地址转换函数、inet_ntoa的线程安全问题、UDP全双工的底层原理等等。这些知识点虽然散落在各个角落,但都是写好网络程序的基础。

当然,这个聊天室还很简陋。它没有用户认证、没有消息持久化、丢包了也不会重传。但作为学习UDP编程的练手项目,它足够了。如果你感兴趣,可以在这个基础上继续扩展,比如加上用户名、支持私聊、做个简单的可靠传输机制等等。

UDP编程的精髓就在于"简单"。它把复杂性都留给了应用层,给了你最大的灵活性。理解了UDP的特点,知道它适合什么、不适合什么,才能在合适的场景下用好它。