前言
在网络编程的世界里,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、绑定端口、接收数据、发送数据。
整体流程很清晰:
-
服务器创建socket,绑定端口,进入循环等待客户端的数据。
-
客户端创建socket,从标准输入读取内容,发给服务器。
-
服务器收到数据后,打印一下,再原封不动发回去。
-
客户端收到回显,打印出来。
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和字典服务器复杂在哪?复杂在"多用户"和"广播"。之前的服务器都是"收一个请求,回一个响应",是一对一的模式。现在是"收一个人的消息,转发给所有人",是一对多的模式。
那怎么设计呢?核心思路是:
-
服务器维护一个"在线用户列表",每个用户用IP+端口来标识。
-
有新用户发消息过来,就把它加到列表里。
-
收到任何用户的消息,都遍历在线列表,给每个人转发一份。
-
用户发"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方法。它做三件事:
-
检查发消息的人在不在在线列表里,不在就加进去(首次发消息等同于登录)。
-
把消息加上发送者的标识,然后遍历在线列表,挨个转发。
-
如果消息是"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的特点,知道它适合什么、不适合什么,才能在合适的场景下用好它。