目录
[3.1 服务端类的设计](#3.1 服务端类的设计)
[3.2 代码深入解读:套接字的创建与绑定](#3.2 代码深入解读:套接字的创建与绑定)
[3.3 数据收发的核心循环](#3.3 数据收发的核心循环)
[3.4 回调函数的设计思想](#3.4 回调函数的设计思想)
[3.5 服务端入口程序](#3.5 服务端入口程序)
前言
在网络编程的世界里,UDP(User Datagram Protocol,用户数据报协议)以其简洁高效的特性,在实时音视频传输、在线游戏、DNS查询等场景中占据着不可替代的地位。与TCP不同,UDP是无连接的、不可靠的传输协议,它不保证数据包的顺序和到达,但换来了更低的延迟和更小的开销。
一、UDP协议基础回顾
在动手写代码之前,有必要先回顾几个核心概念,它们将贯穿整个实现过程。
UDP的特点可以概括为"三无":
无连接:通信前不需要建立连接,直接发送数据
不可靠:不保证数据到达、不保证顺序、不保证不重复
无拥塞控制:发送端可以以任意速率发送数据
但也因此拥有了"三快":
建立连接快:省去三次握手
传输延迟低:没有确认重传机制
资源消耗小:不需要维护连接状态
UDP的数据传输单元称为"数据报",每个数据报都是独立的,最大长度理论上是65507字节(减去IP头和UDP头的开销)。在实际编程中,我们使用sendto()和recvfrom()这两个函数,它们会同时处理数据和对端地址信息。
UDP编程的核心流程可以概括为:
服务端:socket() → bind() → recvfrom()/sendto() → close()
客户端:socket() → sendto()/recvfrom() → close()
注意,客户端通常不需要显式调用bind(),系统会自动分配临时端口。服务端则必须bind()到一个众所周知的端口,这样客户端才知道往哪里发送数据。
二、日志模块设计
一个完善的网络程序离不开日志系统。我们先来设计一个轻量级的日志类,它将贯穿整个服务端的运行过程,帮助我们追踪程序的执行状态。
cpp
// Log.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstdio>
#include <cstdarg>
#include <ctime>
// 日志级别定义
enum LogLevel
{
Debug = 0,
Info,
Warning,
Error,
Fatal
};
// 获取当前时间的字符串表示
inline std::string GetTimestamp()
{
time_t now = time(nullptr);
char buffer[64];
struct tm* tm_info = localtime(&now);
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", tm_info);
return std::string(buffer);
}
// 日志级别转字符串
inline const char* LevelToString(LogLevel level)
{
switch(level)
{
case Debug: return "DEBUG";
case Info: return "INFO";
case Warning: return "WARN";
case Error: return "ERROR";
case Fatal: return "FATAL";
default: return "UNKNOWN";
}
}
// 日志类
class Log
{
public:
void operator()(LogLevel level, const char* format, ...)
{
// 低于当前级别的日志不输出
if(level < currentLevel_) return;
printf("[%s] [%s] ", GetTimestamp().c_str(), LevelToString(level));
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
printf("\n");
}
void SetLevel(LogLevel level) { currentLevel_ = level; }
private:
LogLevel currentLevel_ = Debug; // 默认输出所有级别
};
日志系统设计要点:
-
使用可变参数模板,支持类似printf的格式化输出
-
添加时间戳,便于追踪问题发生的时间
-
级别过滤机制,生产环境可以只输出警告以上级别
-
重载了operator(),使用时像函数调用一样简洁
为了方便全局使用,我们在头文件中声明一个全局日志对象:
cpp
extern Log lg;
三、UDP服务端完整实现
服务端是UDP通信的核心,它需要完成套接字创建、地址绑定、数据收发三个主要步骤。我们将这些功能封装到UdpServer类中。
3.1 服务端类的设计
cpp
// UdpServer.hpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <strings.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "Log.hpp"
enum
{
SOCKET_ERR = 1,
BIND_ERR
};
typedef std::function<const std::string(const std::string&)> func_t;
const int defaultsockfd = -1;
const std::string defaultip = "0.0.0.0";
const uint16_t defaultport = 8080;
const int size = 1024;
class UdpServer
{
public:
UdpServer(const uint16_t& port = defaultport, const std::string& ip = defaultip)
: sockfd_(defaultsockfd), ip_(ip), port_(port), isrunning_(false)
{}
void Init()
{
// 1. 创建套接字
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd_ < 0)
{
lg(Fatal, "socket create error, errno: %d, errstring: %s", errno, strerror(errno));
exit(SOCKET_ERR);
}
lg(Info, "socket create success, sockfd: %d", sockfd_);
// 2. 准备本地地址结构
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(ip_.c_str());
local.sin_port = htons(port_);
// 3. 绑定套接字
if(bind(sockfd_, (struct sockaddr*)&local, sizeof(local)) < 0)
{
lg(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
exit(BIND_ERR);
}
lg(Info, "bind success, errno: %d, errstring: %s", errno, strerror(errno));
}
void Run(func_t fun)
{
isrunning_ = true;
char inbuffer[size];
while(isrunning_)
{
// 接收数据
struct sockaddr_in client;
socklen_t len = sizeof(client);
ssize_t n = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0,
(struct sockaddr*)&client, &len);
if(n < 0)
{
lg(Warning, "recvfrom error, errno: %d, errstring: %s", errno, strerror(errno));
continue;
}
// 处理数据(调用用户自定义的回调函数)
inbuffer[n] = 0;
std::string info = inbuffer;
std::string echo_string = fun(info);
// 发送响应
sendto(sockfd_, echo_string.c_str(), echo_string.size(), 0,
(struct sockaddr*)&client, len);
}
}
~UdpServer()
{
if(sockfd_ > 0)
close(sockfd_);
}
private:
int sockfd_;
std::string ip_;
uint16_t port_;
bool isrunning_;
};
3.2 代码深入解读:套接字的创建与绑定
socket()调用解析:
cpp
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
AF_INET:指定使用IPv4协议族(Address Family)
SOCK_DGRAM:指定套接字类型为数据报套接字,这是UDP的标志
0:自动选择协议,对于SOCK_DGRAM就是UDP
socket()返回的是一个文件描述符,在Linux中一切皆文件,网络套接字也不例外。这个描述符将贯穿整个通信过程。
地址结构初始化:
cpp
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(ip_.c_str());
local.sin_port = htons(port_);
sockaddr_in是IPv4专用的地址结构,使用前必须用bzero清零,这是一个好习惯,可以避免残留数据导致的奇怪问题。
字节序转换的学问:
inet_addr():将点分十进制IP字符串(如"192.168.1.1")转换为网络字节序的32位整数
htons():Host TO Network Short,将16位端口号从主机字节序转换为网络字节序
为什么需要字节序转换?因为不同CPU架构的字节序可能不同------x86是小端序,而网络协议规定使用大端序(网络字节序)。inet_addr和htons帮我们解决了这个跨平台兼容性问题。
bind()调用:
cpp
bind(sockfd_, (struct sockaddr*)&local, sizeof(local));
bind将套接字与特定IP和端口关联。对于服务端,这是必须的------客户端需要知道往哪个端口发送数据。IP使用0.0.0.0表示监听本机所有网络接口,这意味着无论客户端从哪个网卡发来数据,服务端都能收到。
3.3 数据收发的核心循环
recvfrom()详解:
cpp
ssize_t n = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0,
(struct sockaddr*)&client, &len);
参数说明:
sockfd_:已绑定的套接字描述符
inbuffer:接收缓冲区指针
sizeof(inbuffer) - 1:缓冲区大小减1,为字符串结尾的\0预留空间
0:标志位,通常为0
&client:用于接收发送方地址信息的结构体
&len:地址结构体的长度,注意这是一个值-结果参数
recvfrom会将发送方的地址信息填入client结构体,这样我们就能知道数据来自哪里,也可以用这个地址将响应发回去。
sendto()详解:
cpp
sendto(sockfd_, echo_string.c_str(), echo_string.size(), 0,
(struct sockaddr*)&client, len);
与recvfrom对称,sendto需要指定目标地址。这里我们使用recvfrom返回的client地址,实现"从哪来回哪去"的应答模式。
3.4 回调函数的设计思想
服务端的Run()方法接受一个func_t类型的函数对象:
cpp
typedef std::function<const std::string(const std::string&)> func_t;
这使用了C++11的std::function,它可以包装普通函数、lambda表达式、函数对象等。这种设计将数据处理逻辑从网络框架中解耦出来,符合开闭原则------当需要不同的业务逻辑时,只需传入不同的回调函数,无需修改UdpServer类。
3.5 服务端入口程序
cpp
#include <iostream>
#include <memory>
#include "UdpServer.hpp"
const std::string Handler(const std::string& str)
{
std::string ret = "Server get a message: ";
ret += str;
std::cout << ret << std::endl;
return ret;
}
void Usage(std::string str)
{
std::cout << "\n\tUsage: " << str << " port[1024+]\n" << std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<UdpServer> svr(new UdpServer(port));
svr->Init();
svr->Run(Handler);
return 0;
}
Handler函数实现了最简单的回显逻辑:收到什么就返回什么,只是加了个前缀。实际项目中,这里可以替换为数据库操作、业务计算等复杂逻辑。
使用std::unique_ptr管理UdpServer对象的生命周期,体现了现代C++的RAII思想。
四、UDP客户端完整实现
客户端的结构比服务端简单很多,因为不需要bind,也不需要处理多客户端。
cpp
// client.cpp
#include <iostream>
#include <string>
#include <cstdio>
#include <strings.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
void Usage(std::string str)
{
std::cout << "\n\tUsage: " << str << " serverip serverport\n" << std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(0);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 准备服务端地址结构
struct sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(serverip.c_str());
server.sin_port = htons(serverport);
socklen_t len = sizeof(server);
// 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cout << "socket create error" << std::endl;
return 1;
}
std::string message;
char buffer[1024];
while(true)
{
// 从终端读取用户输入
std::cout << "Please Enter@ ";
std::getline(std::cin, message);
// 发送数据
sendto(sockfd, message.c_str(), message.size(), 0,
(struct sockaddr*)&server, len);
// 接收服务端响应
struct sockaddr_in temp;
socklen_t len1 = sizeof(temp);
ssize_t s = recvfrom(sockfd, buffer, 1023, 0,
(struct sockaddr*)&temp, &len1);
if(s > 0)
{
buffer[s] = 0;
std::cout << buffer << std::endl;
}
}
close(sockfd);
return 0;
}
客户端编程要点:
服务端地址必须在发送前设置好,这个地址在整个通信过程中保持不变
客户端没有调用bind(),操作系统会随机分配一个空闲端口
发送和接收交替进行,形成"请求-响应"模式
recvfrom的temp参数会接收到服务端的响应地址,在本场景中这个地址应该与server一致
如果本文对你有帮助,感谢点赞收藏。有任何问题也可以在评论区讨论交流。