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

【Linux网络】UDP 程序怎么继续封装?从 UdpSocket、回调到网络字典服务器

封面

🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

在这里插入图片描述


🏠博主简介 在这里插入图片描述

文章目录

  • 前言
  • 一、为什么要封装 UDP Socket
  • 二、实现 `UdpSocket`
    • 2.1 类的基本结构
    • 2.2 创建与关闭
    • 2.3 绑定本地地址
    • 2.4 封装 `RecvFrom`
    • 2.5 封装 `SendTo`
  • 三、封装通用 UDP 服务器
    • 3.1 回调函数表示业务逻辑
    • 3.2 `UdpServer` 完整实现
  • 四、实现英译汉字典
    • 4.1 准备词典文件
    • 4.2 加载词典
    • 4.3 把翻译函数交给服务器
  • 五、通用 UDP 客户端
    • 5.1 服务器入口单独放置
    • 5.2 Makefile
  • 六、IPv4 地址为什么需要转换
    • 6.1 字符串与二进制形式
    • 6.2 `inet_pton` 的返回值
    • 6.3 `inet_ntop` 由调用者提供缓冲区
  • 七、`inet_ntoa` 的静态缓冲区问题
    • 7.1 返回指针指向哪里
    • 7.2 多线程程序更适合 `inet_ntop`
  • 八、封装后仍然要注意的错误
    • 8.1 不要忽略错误
    • 8.2 接收内容不一定是文本
    • 8.3 UDP 响应可能丢失
    • 8.4 回调输出参数必须被赋值
    • 8.5 对象生命周期不能靠猜
  • 总结

前言

前一篇已经写完 UDP 回显程序。回显业务很简单:服务器收到什么就返回什么。问题是,socket、bind、recvfrom、sendto 全部和业务代码写在一起,程序一旦换成查字典、计算表达式或消息转发,就会反复复制同一套网络代码。

这一篇继续整理代码结构:先把 UDP Socket 的常用操作封装成一个类,再把服务器的“收到请求以后做什么”抽象成回调。网络层只负责收发,业务层负责生成响应。最后补充 inet_pton、inet_ntop 和 inet_ntoa,把 IPv4 地址在字符串与二进制形式之间的转换问题讲清楚。


一、为什么要封装 UDP Socket

回显客户端和服务器都要做几件重复工作:

  • 创建 UDP Socket;
  • 填写 sockaddr_in;
  • 转换端口和 IP;
  • 调用 recvfrom;
  • 调用 sendto;
  • 程序结束时关闭文件描述符。

如果每个程序都从头写一遍,不仅代码重复,还容易漏掉字节序转换、地址长度初始化和错误判断。

封装的目标不是把系统调用藏得看不见,而是给这些固定步骤一个稳定入口。调用者仍然要知道底层发生了什么,否则遇到 bind 失败、地址格式错误或接收阻塞时,仍然无法排查。

封装减少的是重复代码,不是对底层接口的理解。Socket 类负责“怎样收发”,业务代码负责“收到以后做什么”。

整理后的工程结构如下:

udp_dict/
├── UdpSocket.hpp
├── UdpSocket.cc
├── UdpServer.hpp
├── UdpServer.cc
├── UdpClient.hpp
├── UdpClient.cc
├── Dict.hpp
├── dict.txt
└── makefile

UdpSocket 只封装 UDP 系统调用,UdpServer 和 UdpClient 描述通信流程,Dict 负责业务。三个层次分开以后,排查问题时也能迅速判断是地址、网络还是词典数据出了问题。

二、实现 UdpSocket

2.1 类的基本结构

下面的声明保存为 UdpSocket.hpp:

#pragma once

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cerrno>
#include <cstring>
#include <iostream>
#include <string>

class UdpSocket {
public:
UdpSocket() = default;

UdpSocket(const UdpSocket&) = delete;
UdpSocket& operator=(const UdpSocket&) = delete;

~UdpSocket() {
Close();
}

bool Create();
bool Bind(uint16_t port, const std::string& ip = "0.0.0.0");

bool RecvFrom(std::string* message,
std::string* peer_ip = nullptr,
uint16_t* peer_port = nullptr);

bool SendTo(const std::string& message,
const std::string& peer_ip,
uint16_t peer_port);

void Close();

private:
int sockfd_ = –1;
};

Socket 文件描述符代表内核资源。若对象被默认复制,两个对象会保存同一个描述符,析构时就可能重复关闭。因此这里直接删除拷贝构造和赋值。

2.2 创建与关闭

从这一节开始的成员函数实现保存到 UdpSocket.cc,文件开头先包含头文件:

#include "UdpSocket.hpp"

bool UdpSocket::Create() {
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ < 0) {
std::cerr << "socket error: "
<< strerror(errno) << std::endl;
return false;
}
return true;
}

void UdpSocket::Close() {
if (sockfd_ >= 0) {
close(sockfd_);
sockfd_ = –1;
}
}

关闭后把描述符重置为 -1,可以避免析构函数再次关闭同一个编号。要注意,文件描述符会被操作系统复用;错误地关闭两次,第二次关闭的可能已经是后来打开的另一个文件。

2.3 绑定本地地址

bool UdpSocket::Bind(uint16_t port, const std::string& ip) {
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(port);

if (ip == "0.0.0.0") {
local.sin_addr.s_addr = htonl(INADDR_ANY);
} else {
int ret = inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
if (ret != 1) {
std::cerr << "invalid bind ip: " << ip << std::endl;
return false;
}
}

int ret = bind(sockfd_,
reinterpret_cast<sockaddr*>(&local),
sizeof(local));
if (ret < 0) {
std::cerr << "bind error: "
<< strerror(errno) << std::endl;
return false;
}
return true;
}

默认绑定 0.0.0.0,也就是 INADDR_ANY。如果调用者确实要限制本地接口,再传入具体地址。

inet_pton 返回 1 表示转换成功,返回 0 表示字符串不是合法地址,返回 -1 表示地址族等参数有问题。这里要求结果必须为 1。

2.4 封装 RecvFrom

bool UdpSocket::RecvFrom(std::string* message,
std::string* peer_ip,
uint16_t* peer_port) {
char buffer[10240];
sockaddr_in peer{};
socklen_t len = sizeof(peer);

ssize_t n = recvfrom(sockfd_,
buffer,
sizeof(buffer),
0,
reinterpret_cast<sockaddr*>(&peer),
&len);
if (n < 0) {
std::cerr << "recvfrom error: "
<< strerror(errno) << std::endl;
return false;
}

message->assign(buffer, static_cast<std::size_t>(n));

if (peer_ip != nullptr) {
char ip[INET_ADDRSTRLEN] = {0};
if (inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip)) == nullptr) {
return false;
}
*peer_ip = ip;
}

if (peer_port != nullptr) {
*peer_port = ntohs(peer.sin_port);
}

return true;
}

这里没有再手动补 \\0,而是使用 std::string::assign(buffer, n) 按实际字节数构造字符串。这样即使数据中间包含 \\0,也不会在第一个零字节处提前结束。

IP 和端口都是可选输出参数。服务器需要回复客户端时会取出它们;有些场景只关心消息内容,就可以传 nullptr。

2.5 封装 SendTo

bool UdpSocket::SendTo(const std::string& message,
const std::string& peer_ip,
uint16_t peer_port) {
sockaddr_in peer{};
peer.sin_family = AF_INET;
peer.sin_port = htons(peer_port);

int ret = inet_pton(AF_INET, peer_ip.c_str(), &peer.sin_addr);
if (ret != 1) {
std::cerr << "invalid peer ip: " << peer_ip << std::endl;
return false;
}

ssize_t n = sendto(sockfd_,
message.data(),
message.size(),
0,
reinterpret_cast<sockaddr*>(&peer),
sizeof(peer));
if (n < 0) {
std::cerr << "sendto error: "
<< strerror(errno) << std::endl;
return false;
}

return static_cast<std::size_t>(n) == message.size();
}

对 UDP 来说,一次 sendto 对应一份数据报。成功返回已经交给内核的字节数,失败返回 -1。返回成功并不等于对方应用程序一定已经收到,因为 UDP 不提供端到端确认。

三、封装通用 UDP 服务器

3.1 回调函数表示业务逻辑

服务器的网络流程固定不变:接收请求、处理请求、发送响应。真正变化的是中间的“处理请求”。

可以用 std::function 表示这个处理函数:

#include <functional>
#include <string>

using Handler = std::function<void(
const std::string& request,
std::string* response)>;

它既可以接普通函数,也可以接仿函数和 Lambda。服务器只依赖这个统一签名,不依赖具体业务类。

3.2 UdpServer 完整实现

服务器是模板式的请求—响应框架,可以直接放在 UdpServer.hpp 中:

#pragma once

#include "UdpSocket.hpp"

#include <cstdint>
#include <functional>
#include <iostream>
#include <string>

using Handler = std::function<void(
const std::string&,
std::string*)>;

class UdpServer {
public:
UdpServer(uint16_t port, Handler handler)
: port_(port), handler_(std::move(handler)) {}

bool Init() {
if (!socket_.Create()) {
return false;
}
return socket_.Bind(port_);
}

void Start() {
for (;;) {
std::string request;
std::string peer_ip;
uint16_t peer_port = 0;

bool ok = socket_.RecvFrom(
&request,
&peer_ip,
&peer_port);
if (!ok) {
continue;
}

std::string response;
handler_(request, &response);

socket_.SendTo(response, peer_ip, peer_port);

std::cout << "[" << peer_ip << ":" << peer_port << "] "
<< "request: " << request
<< ", response: " << response
<< std::endl;
}
}

private:
uint16_t port_;
Handler handler_;
UdpSocket socket_;
};

这段循环里没有任何“查字典”的代码。它只把请求交给 handler_,然后发送 response。

服务器框架不应该知道业务细节。只要回调签名不变,回显、翻译和其他请求—响应业务都能复用同一套网络代码。

四、实现英译汉字典

4.1 准备词典文件

先准备一个简单文本文件 dict.txt:

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

这里约定英文和中文之间使用 ": " 分隔。真实词典还会遇到多义词、词性和编码等问题,当前只验证网络业务分层。

4.2 加载词典

#pragma once

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

class Dict {
public:
explicit Dict(const std::string& path)
: path_(path) {
Load();
}

std::string Translate(const std::string& word) const {
auto it = dict_.find(word);
if (it == dict_.end()) {
return "Unknown";
}
return it->second;
}

private:
void Load() {
std::ifstream in(path_);
if (!in.is_open()) {
std::cerr << "open " << path_ << " failed" << std::endl;
return;
}

const std::string separator = ": ";
std::string line;

while (std::getline(in, line)) {
if (line.empty()) {
continue;
}

std::size_t pos = line.find(separator);
if (pos == std::string::npos) {
continue;
}

std::string key = line.substr(0, pos);
std::string value = line.substr(pos + separator.size());
dict_[key] = value;
}
}

private:
std::string path_;
std::unordered_map<std::string, std::string> dict_;
};

std::getline 不会保留行尾换行符。找到分隔符以后,左边作为单词,右边作为翻译,放进 unordered_map。

读取失败时当前代码只打印错误,词典对象仍然存在,但查询都会返回 Unknown。如果这是正式服务,可以让构造函数抛出异常,或者给类增加 IsValid() 状态,阻止服务器带着空词典启动。

4.3 把翻译函数交给服务器

#include "Dict.hpp"
#include "UdpServer.hpp"

#include <cstdint>
#include <iostream>

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

uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
Dict dict("./dict.txt");

UdpServer server(
port,
[&dict](const std::string& request,
std::string* response) {
*response = dict.Translate(request);
});

if (!server.Init()) {
return 2;
}

server.Start();
return 0;
}

Lambda 捕获 dict 的引用。server.Start() 运行期间,dict 仍然位于 main 的作用域中,所以引用有效。

若以后把服务器放到独立线程,或者让回调生命周期脱离当前作用域,就要重新检查对象生命周期,不能机械地用引用捕获。

五、通用 UDP 客户端

客户端同样可以复用 UdpSocket:

#pragma once

#include "UdpSocket.hpp"

#include <cstdint>
#include <string>

class UdpClient {
public:
UdpClient(std::string server_ip, uint16_t server_port)
: server_ip_(std::move(server_ip)),
server_port_(server_port) {}

bool Init() {
return socket_.Create();
}

bool Send(const std::string& request) {
return socket_.SendTo(request, server_ip_, server_port_);
}

bool Receive(std::string* response) {
return socket_.RecvFrom(response);
}

private:
std::string server_ip_;
uint16_t server_port_;
UdpSocket socket_;
};

字典客户端入口:

#include "UdpClient.hpp"

#include <cstdint>
#include <iostream>
#include <string>

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

uint16_t port = static_cast<uint16_t>(std::stoi(argv[2]));
UdpClient client(argv[1], port);

if (!client.Init()) {
return 2;
}

for (;;) {
std::string word;
std::cout << "请输入要查询的单词# ";

if (!(std::cin >> word)) {
break;
}

if (!client.Send(word)) {
break;
}

std::string result;
if (!client.Receive(&result)) {
break;
}

std::cout << word << " 的意思是:"
<< result << std::endl;
}

return 0;
}

客户端没有显式调用 bind。第一次发送时,操作系统会选择本地地址和临时端口完成隐式绑定。收到响应时,仍然通过同一个 Socket 读取。

5.1 服务器入口单独放置

前面的服务器启动代码保存为 UdpServer.cc,客户端入口保存为 UdpClient.cc。不要把两个 main 函数放进同一个文件,也不要让业务头文件包含入口文件。

5.2 Makefile

.PHONY: all clean

all: udpclient udpserver

udpclient: UdpClient.cc UdpSocket.cc
g++ -o $@ $^ -std=c++17

udpserver: UdpServer.cc UdpSocket.cc
g++ -o $@ $^ -std=c++17

clean:
rm -f udpclient udpserver

编译并运行:

make
./udpserver 8888

另一个终端启动客户端:

./udpclient 127.0.0.1 8888

dict.txt 使用相对路径打开,因此服务器应在工程目录下启动。若从其他工作目录执行,需要传入正确的词典路径,或者在程序中根据可执行文件位置构造路径。

网络服务启动成功但所有查询都返回 Unknown 时,先检查词典文件是否真正打开,而不是先怀疑 UDP 收发。

六、IPv4 地址为什么需要转换

6.1 字符串与二进制形式

人习惯写点分十进制地址:

192.168.1.10

sockaddr_in::sin_addr 保存的却是 32 位网络地址。程序需要在这两种表示之间转换。

早期常见接口有:

in_addr_t inet_addr(const char* cp);
char* inet_ntoa(struct in_addr in);

新代码更推荐:

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

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

其中:

  • p 表示 presentation,也就是人可读字符串;
  • n 表示 network,也就是网络二进制形式;
  • pton 表示字符串转网络地址;
  • ntop 表示网络地址转字符串。

这组接口同时支持 IPv4 和 IPv6,地址族由第一个参数决定。

6.2 inet_pton 的返回值

sockaddr_in server{};
server.sin_family = AF_INET;

int ret = inet_pton(AF_INET,
"192.168.1.10",
&server.sin_addr);

返回值含义:

  • 1:转换成功;
  • 0:输入字符串不是当前地址族的合法地址;
  • -1:调用失败,并设置 errno。

因此不能只判断“是否小于 0”,否则地址格式错误时返回 0 会被误认为成功。

6.3 inet_ntop 由调用者提供缓冲区

char ip[INET_ADDRSTRLEN] = {0};

const char* result = inet_ntop(
AF_INET,
&peer.sin_addr,
ip,
sizeof(ip));

if (result != nullptr) {
std::cout << ip << std::endl;
}

IPv4 缓冲区可以使用 INET_ADDRSTRLEN,IPv6 使用 INET6_ADDRSTRLEN。缓冲区由调用者管理,接口不会返回一块需要释放的动态内存。

七、inet_ntoa 的静态缓冲区问题

7.1 返回指针指向哪里

inet_ntoa 的原型是:

char* inet_ntoa(struct in_addr in);

返回值不是 malloc 出来的内存,调用者不能 free。传统实现把结果放在函数内部可复用的存储区,再把地址返回。

于是下面的代码存在隐患:

in_addr first_addr{};
in_addr second_addr{};

inet_pton(AF_INET, "127.0.0.1", &first_addr);
inet_pton(AF_INET, "192.168.1.10", &second_addr);

char* first = inet_ntoa(first_addr);
char* second = inet_ntoa(second_addr);

std::cout << first << std::endl;
std::cout << second << std::endl;

第二次调用可能覆盖第一次调用留下的内容。即使某个平台使用线程局部缓冲区,使两个线程之间不直接共享,也不应该长期保存这个返回指针。

7.2 多线程程序更适合 inet_ntop

inet_ntop 的结果写到调用者提供的缓冲区中,每个线程使用自己的局部数组即可:

void PrintAddress(const in_addr& addr) {
char ip[INET_ADDRSTRLEN] = {0};
if (inet_ntop(AF_INET, &addr, ip, sizeof(ip)) != nullptr) {
std::cout << ip << std::endl;
}
}

这比依赖某个 C 库版本如何实现 inet_ntoa 更稳妥。

新代码优先使用 inet_pton 和 inet_ntop:返回值更清楚,支持 IPv4/IPv6,并且输出缓冲区由调用者控制。

八、封装后仍然要注意的错误

8.1 不要忽略错误

为了让示例短一些,有些代码会用 assert(socket.Create())。但网络错误属于运行时情况,不适合只依赖断言。发布版本关闭断言以后,失败路径可能直接消失。

8.2 接收内容不一定是文本

std::string 能保存二进制字节,但打印时仍要考虑不可见字符。当前字典协议发送的是普通文本,所以可以直接输出。若传输二进制结构,应明确序列化格式,不能假设两台机器的结构体布局完全相同。

8.3 UDP 响应可能丢失

客户端执行 Send 后立刻阻塞等待 Receive。如果请求或响应丢失,客户端会一直等待。正式协议需要设置接收超时、重试次数或请求编号。

8.4 回调输出参数必须被赋值

服务器创建空 response 后交给回调。如果某个分支忘记写入,客户端会收到空数据报。业务函数应保证每条路径都给出明确响应。

8.5 对象生命周期不能靠猜

Lambda 捕获引用时,被捕获对象必须比回调活得久;Socket 包装类删除拷贝后,不能随意按值传递;若以后增加移动构造,也要把旧对象的描述符重置为 -1。

总结

本文在回显程序基础上完成了三层整理:

  • UdpSocket 封装创建、绑定、接收、发送和关闭;
  • UdpServer 使用回调隔离网络层和业务层;
  • Dict 负责加载词典并完成查询,服务器框架不依赖具体业务。
  • 同时补充了 IPv4 地址转换:inet_pton 把点分十进制字符串转成网络地址,inet_ntop 把网络地址转回字符串。inet_ntoa 的返回内容可能位于可复用的内部存储区,不适合长期保存,尤其不应让并发代码依赖它的实现细节。

    下一篇继续复用回调式服务器,但业务不再是“一个请求得到一个响应”,而是维护在线用户并把消息转发给多个客户端,完成简单 UDP 聊天室。

    本文相关的字典服务器与 Socket 工程可以在代码仓库中查看:Socket 编程代码。

    往期博客分享 【Linux】线程安全进阶:单例模式、可重入函数与死锁排查 【Linux网络】Socket 编程前要搞懂什么?从 IP、端口到字节序与 sockaddr,一次理清网络通信基础 【Linux网络】UDP 服务器怎么写?从 socket、bind 到 recvfrom/sendto,手写完整回显服务器

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Linux网络】UDP 程序怎么继续封装?从 UdpSocket、回调到网络字典服务器
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!