前言:已经理解网络协议,怎样让两台计算机真正通信?
在学习网络基础知识时,我们已经认识了 TCP /IP 五层模型:
应用层
|
传输层
|
网络层
|
数据链路层
|
物理层
AI代码解释
text
1
2
3
4
5
6
7
8
9
同时也知道,网络通信需要使用 IP 地址和端口号等信息定位通信端点。
例如:
服务器IP:192.168.1.10
服务器Port:8888
AI代码解释
text
1
2
但是,掌握这些概念之后,我们仍然需要解决一个实际问题:
如何编写一个 Linux 程序,让另一台计算机可以向它发送消息,并接收服务器返回的结果?
Linux 提供了 Socket 编程接口。
通过这些接口,应用程序可以使用操作系统 已经实现的网络协议栈,而不需要自己编写完整的 IP 和 UDP 协议。
本篇采用 C++11,按照三个阶段逐步实现:
V1:UDP Echo Server
|
v
V2:UDP DictServer
|
v
V2:通用UDP Socket封装
AI代码解释
text
1
2
3
4
5
6
7
首先实现最简单的回显服务器,再将服务器的通信功能与业务功能分离,最后封装可以重复使用的 UDP 网络编程组件。
一、为什么先学习UDP?
1.1 TCP和UDP都属于传输层协议
此前已经认识:
TCP:Transmission Control Protocol
UDP:User Datagram Protocol
AI代码解释
text
1
2
3
两者都属于传输层,但提供的服务不同。
TCP 提供面向连接的可靠字节流服务。
UDP 则提供无连接的数据报服务。
本篇先学习 UDP,因为它不要求像 TCP 那样先建立连接,基础编程流程比较直接。
1.2 UDP是面向数据报的
例如客户端准备发送:
hello
AI代码解释
text
1
可以通过一次:
sendto()
AI代码解释
cpp
运行
1
发送一个 UDP 数据报。
服务器通过:
recvfrom()
AI代码解释
cpp
运行
1
接收数据报。
要注意,UDP 并不保证数据报一定到达,也不保证到达顺序与发送顺序一致。
因此:
sendto 调用成功,不代表远端应用程序已经成功收到并处理消息。
1.3 UDP通信的基本过程
UDP Server UDP Client
socket() socket()
| |
v |
bind() |
| |
v |
recvfrom() <------ sendto() --------|
| |
v |
处理请求 |
| |
v |
sendto() --------> recvfrom() ------|
|
v
打印响应
AI代码解释
text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
观察这个流程,可以发现:
UDP 服务器通常需要:
socket()
bind()
recvfrom()
sendto()
AI代码解释
cpp
运行
1
2
3
4
UDP 客户端通常需要:
socket()
sendto()
recvfrom()
AI代码解释
cpp
运行
1
2
3
这里没有出现:
listen()
accept()
AI代码解释
cpp
运行
1
2
因为它们是我们以后学习 TCP 连接服务器时才会重点使用的接口。
二、socket():创建UDP套接字
2.1 函数原型
#include <sys/socket.h>
int socket(
int domain,
int type,
int protocol
);
AI代码解释
c
运行
1
2
3
4
5
6
7
三个参数分别用于确定:
地址族
Socket类型
具体协议
AI代码解释
text
1
2
3
2.2 第一个参数:AF_INET
AF_INET
AI代码解释
cpp
运行
1
表示使用 IPv4 地址族。
如果学习 IPv6,会接触到:
AF_INET6
AI代码解释
cpp
运行
1
但当前只讨论 IPv4 Socket 编程。
2.3 第二个参数:SOCK_DGRAM
SOCK_DGRAM
AI代码解释
cpp
运行
1
表示数据报类 型的 Socket。
对于常见的 IPv4 UDP 编程,可以写:
socket(AF_INET, SOCK_DGRAM, 0);
AI代码解释
cpp
运行
1
其中:
SOCK_DGRAM
AI代码解释
cpp
运行
1
对应 UDP 的数据报通信形式。
2.4 第三个参数:0
0
AI代码解释
cpp
运行
1
表示根据地址族和 Socket 类型选择默认协议。
因此:
int sockfd = socket(
AF_INET,
SOCK_DGRAM,
0
);
AI代码解释
cpp
运行
1
2
3
4
5
表示创建一个 IPv4 UDP Socket。
2.5 返回值
创建成功:
返回一个非负文件描述符
AI代码解释
text
1
创建失败:
返回-1,并设置errno
AI代码解释
text
1
例如:
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr
<< "socket error: "
<< strerror(errno)
<< std::endl;
return 1;
}
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
2.6 为什么Socket返回int?
这是 Linux 网络编程与文件 IO 之间的重要联系。
我们以前学习:
open()
AI代码解释
cpp
运行
1
会得到文件描述符。
例如:
int fd = open("log.txt", O_RDONLY);
AI代码解释
cpp
运行
1
现在:
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
AI代码解释
cpp
运行
1
也返回文件描述符。
因此可以理解为:
Linux 将 Socket 也纳入文件描述符管理体系。
应用程序通过 Socket 文件描述符,访问操作系统管理的网络通信对象。
当不再使用 Socket 时,需要:
close(sockfd);
AI代码解释
cpp
运行
1
释放相应资源。
三、bind():为什么服务器需要绑定IP和端口?
创建 Socket 后,我们获得了:
sockfd
AI代码解释
cpp
运行
1
但此时还没有给服务器明确指定用于接收请求的本地网络地址。
例如我们希望客户端访问:
服务器:192.168.1.10:8888
AI代码解释
text
1
那么服务器必须让操作系统知道:
这个Socket应该使用哪个本地端口接收数据?
AI代码解释
text
1
这就需要:
bind()
AI代码解释
cpp
运行
1
3.1 函数原型
#include <sys/socket.h>
int bind(
int sockfd,
const struct sockaddr* addr,
socklen_t addrlen
);
AI代码解释
c
运行
1
2
3
4
5
6
7
参数含义:
参数 作用
sockfd 需要绑定的 Socket
addr 本地 Socket 地址
addrlen 地址结构的长度
3.2 为什么使用sockaddr?
bind() 需要适配不同的地址族。
例如:
IPv4
IPv6
Unix Domain Socket
AI代码解释
text
1
2
3
因此采用:
struct sockaddr*
AI代码解释
cpp
运行
1
作为通用接口参数。
但在 IPv4 编程中,我们实际填写的是:
struct sockaddr_in
AI代码解释
cpp
运行
1
3.3 sockaddr_in的主要字段
struct sockaddr_in
{
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
/* 实现相关的填充字段 */
};
AI代码解释
c
运行
1
2
3
4
5
6
7
主要关注:
sin_family
AI代码解释
cpp
运行
1
地址族。
sin_port
AI代码解释
cpp
运行
1
端口号。
sin_addr
AI代码解释
cpp
运行
1
IPv4 地址。
3.4 填充服务器地址
struct sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = INADDR_ANY;
AI代码解释
cpp
运行
1
2
3
4
5
6
7
然后:
int ret = bind(
sockfd,
reinterpret_cast<struct sockaddr*>(&local),
sizeof(local)
);
AI代码解释
cpp
运行
1
2
3
4
5
注意:
填充结构体只是准备用户空间中的地址信息,只有调用 bind 后,才是请求内核为 Socket 设置对应的本地绑定。
3.5 为什么端口号要使用htons?
local.sin_port = htons(8888);
AI代码解释
cpp
运行
1
其中:
h:Host
to:转换到
n:Network
s:16位整数
AI代码解释
text
1
2
3
4
5
6
7
htons() 用于将16位端口号转换为网络字节序。
因为 Socket 地址结构中的端口字段采用网络字节序。
3.6 INADDR_ANY是什么意思?
local.sin_addr.s_addr = INADDR_ANY;
AI代码解释
cpp
运行
1
INADDR_ANY 对应 IPv4 未指定地址:
0.0.0.0
AI代码解释
text
1
它表示在绑定时不限定某一个具体的本地 IPv4 接口地址。
例如服务器有:
127.0.0.1
192.168.1.10
10.0.0.5
AI代码解释
text
1
2
3
等本地地址时:
INADDR_ANY
AI代码解释
cpp
运行
1
可以让 Socket 在对应端口上接收发往合适本地地址的数据报。
需要注意:
0.0.0.0是绑定时使用的通配地址,并不是远程客户端应该填写的服务器目标IP。
本机测试时,客户端可以使用:
127.0.0.1
AI代码解释
text
1
远程测试时,则需要填写服务器实际可到达的地址。
3.7 为什么云服务器常常使用INADDR_ANY?
云服务器可能通过云平台的网络地址转换或映射机制向外提供公网访问。
此时服务器操作系统内部实际配置的地址,不一定就是云平台显示的公网 IP。
如果直接尝试:
bind(sockfd, 公网IP);
AI代码解释
cpp
运行
1
但该公网 IP 并不属于操作系统本地接口,就可能出现:
Cannot assign requested address
AI代码解释
text
1
因此,对于需要监听多个本地地址的普通 UDP 服务,可以使用:
INADDR_ANY
AI代码解释
cpp
运行
1
再通过云平台安全组、防火墙和网络配置控制外部访问。
四、recvfrom():服务器怎样接收UDP消息?
4.1 函数原型
#include <sys/socket.h>
ssize_t recvfrom(
int sockfd,
void* buf,
size_t len,
int flags,
struct sockaddr* src_addr,
socklen_t* addrlen
);
AI代码解释
c
运行
1
2
3
4
5
6
7
8
9
10
参数:
参数 含义
sockfd 接收数据的 Socket
buf 接收缓冲区
len 接收缓冲区大小
flags 接收标志,基础实验使用0
src_addr 输出发送方的地址
addrlen 输入地址缓冲区大小,输出实际地址长度
4.2 为什么要返回发送方地址?
因为 UDP 是无连接的数据报协议。
同一个服务器 Socket 可以接收:
Client-A
Client-B
Client-C
AI代码解释
text
1
2
3
分别发送的数据报。
所以服务器不仅需要知道:
对方发了什么?
AI代码解释
text
1
还需要知道:
是谁发的?
AI代码解释
text
1
例如:
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
AI代码解释
cpp
运行
1
2
3
调用:
ssize_t n = recvfrom(
sockfd,
buffer,
sizeof(buffer),
0,
reinterpret_cast<struct sockaddr*>(&peer),
&len
);
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
执行成功以后:
peer
AI代码解释
cpp
运行
1
就保存了对应数据报的发送方地址信息。
后面回发消息时会使用它。
4.3 为什么addrlen传入指针?
&len
AI代码解释
cpp
运行
1
因为它是一个输入输出参数。
调用之前:
socklen_t len = sizeof(peer);
AI代码解释
cpp
运行
1
表示地址缓冲区的大小。
调用以后:
len
AI代码解释
cpp
运行
1
会保存实际地址长度信息。
所以不能随意传入一个没有正确初始化的长度变量。
4.4 recvfrom的返回值
成功时返回收到的数据字节数。
失败时返回:
-1
AI代码解释
text
1
这里有一个 UDP 编程的重要细节:
recvfrom返回0,在UDP中可以表示成功收到一个零长度数据报。
因此:
if (n > 0)
AI代码解释
cpp
运行
1
不能覆盖全部合法 UDP 数据报。
更准确的基本判断是:
if (n >= 0)
{
// 成功接收,包括长度为0的数据报
}
AI代码解释
cpp
运行
1
2
3
4
4.5 recvfrom会一直等待吗?
对于默认阻塞模式的 UDP Socket:
recvfrom()
AI代码解释
cpp
运行
1
在没有可用数据报时,通常会阻塞等待。
这意味着服务器没有收到数据时,不会自动不断循环执行后续业务代码。
只有收到数据、发生错误或满足其他返回条件时,调用才会返回。
五、sendto():服务器怎样回复客户端?
5.1 函数原型
ssize_t sendto(
int sockfd,
const void* buf,
size_t len,
int flags,
const struct sockaddr* dest_addr,
socklen_t addrlen
);
AI代码解释
c
运行
1
2
3
4
5
6
7
8
它与 recvfrom() 的关系非常直接。
recvfrom():
接收数据,并获得发送方地址
AI代码解释
text
1
sendto():
发送数据,并指定接收方地址
AI代码解释
text
1
5.2 为什么服务器回复时需要peer?
服务器前面已经通过:
recvfrom()
AI代码解释
cpp
运行
1
取得:
peer
AI代码解释
cpp
运行
1
其中包含客户端地址。
所以回复时可以写:
sendto(
sockfd,
buffer,
n,
0,
reinterpret_cast<struct sockaddr*>(&peer),
len
);
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
这样就可以把数据报发送回对应客户端。
5.3 为什么发送长度不一定使用strlen?
假设接收到的数据:
hello
AI代码解释
text
1
使用:
strlen(buffer)
AI代码解释
cpp
运行
1
似乎可以得到消息长度。
但 UDP 数据报不一定是 C 风格字符串。
例如一个数据报可能包含:
61 00 62
AI代码解释
text
1
其中存在:
\0
AI代码解释
text
1
如果使用 strlen(),就会错误地在第一个零字节停止计数。
因此对于通用 UDP 回显服务器,最好直接使用:
recvfrom()
AI代码解释
cpp
运行
1
返回的实际数据长度。
例如:
sendto(sockfd, buffer, static_cast<size_t>(n), ...);
AI代码解释
cpp
运行
1
这能够正确处理包含零字节的消息。
六、V1:设计一个Echo Server
现在已经认识了四个关键接口:
socket()
bind()
recvfrom()
sendto()
AI代码解释
cpp
运行
1
2
3
4
接下来正式实现:
UDP Echo Server。
Echo 的含义是:
回显
AI代码解释
text
1
也就是客户端发送什么,服务器就将相同内容返回给客户端。
例如:
Client发送:hello
Server回复:hello
AI代码解释
text
1
2
3
6.1 项目组织
先将工程划分为:
udp_echo/
├── nocopy.hpp
├── Comm.hpp
├── InetAddr.hpp
├── UdpServer.hpp
├── ServerMain.cc
├── UdpClient.cc
└── Makefile
AI代码解释
text
1
2
3
4
5
6
7
8
各文件职责:
文件 作用
nocopy.hpp 禁止网络对象发生不合理的复制
Comm.hpp 保存错误退出码等公共定义
InetAddr.hpp 封装IPv4地址与端口
UdpServer.hpp 封装UDP服务器
ServerMain.cc 启动服务器
UdpClient.cc 实现交互式客户端
Makefile 构建客户端与服务器
这里使用独立文件组织代码,主要是为了让通信逻辑和程序入口更加清晰。
七、nocopy.hpp:为什么网络对象通常禁止拷贝?
7.1 文件描述符不是普通数据
假设服务器对象中保存:
int _sockfd;
AI代码解释
cpp
运行
1
如果直接复制服务器对象:
UdpServer server1;
UdpServer server2 = server1;
AI代码解释
cpp
运行
1
2
3
就可能产生两个对象都认为自己负责管理同一个 Socket 文件描述符的问题。
例如:
server1._sockfd = 3
server2._sockfd = 3
AI代码解释
text
1
2
3
如果两个对象析构时都执行:
close(3);
AI代码解释
cpp
运行
1
就可能发生错误的资源管理。
因此,这类独占管理文件描述符的对象通常应该禁止复制。
7.2 实现nocopy类
nocopy.hpp:
#pragma once
class nocopy
{
public:
nocopy() = default;
nocopy(const nocopy&) = delete;
nocopy& operator=(const nocopy&) = delete;
~nocopy() = default;
};
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
其中:
= delete
AI代码解释
cpp
运行
1
表示禁止对应操作。
如果:
class UdpServer : public nocopy
AI代码解释
cpp
运行
1
那么:
UdpServer
AI代码解释
cpp
运行
1
就不能随意复制。
需要注意,这样做不等于自动实现了完整的移动语义。
这里只先保证服务器对象不会被简单复制。
八、Comm.hpp:公共错误码
Comm.hpp:
#pragma once
enum ExitCode
{
Usage_Err = 1,
Socket_Err,
Bind_Err
};
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
可以在不同错误位置返回不同退出码。
例如:
return Usage_Err;
AI代码解释
cpp
运行
1
表示命令行参数使用错误。
return Socket_Err;
AI代码解释
cpp
运行
1
表示创建 Socket 失败。
return Bind_Err;
AI代码解释
cpp
运行
1
表示地址绑定失败。
这样能够方便调用程序的 Shell 或其他程序区分错误类型。
九、InetAddr.hpp:封装IP地址与端口号
在服务器中:
recvfrom()
AI代码解释
cpp
运行
1
返回的地址类型是:
struct sockaddr_in
AI代码解释
cpp
运行
1
但我们通常希望直接打印:
127.0.0.1:50000
AI代码解释
text
1
而不是反复手动处理:
sin_addr
sin_port
AI代码解释
cpp
运行
1
2
所以可以设计一个:
InetAddr
AI代码解释
cpp
运行
1
类。
9.1 需要保存哪些数据?
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
AI代码解释
cpp
运行
1
2
3
4
5
分别保存:
可读IP字符串
主机字节序端口号
原始Socket地址结构
AI代码解释
text
1
2
3
9.2 InetAddr.hpp
#pragma once
#include <cstdint>
#include <string>
#include <arpa/inet.h>
#include <netinet/in.h>
class InetAddr
{
public:
explicit InetAddr(const struct sockaddr_in& addr)
: _addr(addr),
_port(ntohs(addr.sin_port))
{
char ipbuf[INET_ADDRSTRLEN] = {};
const char* result = inet_ntop(
AF_INET,
&_addr.sin_addr,
ipbuf,
sizeof(ipbuf)
);
_ip = result ? ipbuf : "Unknown";
}
std::string Ip() const
{
return _ip;
}
uint16_t Port() const
{
return _port;
}
std::string PrintDebug() const
{
return _ip + ":" + std::to_string(_port);
}
const struct sockaddr_in& GetAddr() const
{
return _addr;
}
private:
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
};
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
9.3 为什么使用ntohs?
回忆:
sin_port
AI代码解释
cpp
运行
1
采用网络字节序。
但我们希望得到便于程序处理的主机字节序端口。
所以:
_port = ntohs(addr.sin_port);
AI代码解释
cpp
运行
1
9.4 为什么需要将IP转成字符串?
因为:
addr.sin_addr
AI代码解释
cpp
运行
1
不是直接可读的:
127.0.0.1
AI代码解释
text
1
字符串。
需要进行地址表示转换。
这里使用:
inet_ntop()
AI代码解释
cpp
运行
1
完成 IPv4 二进制地址到文本表示的转换。
它允许调用者提供自己的输出缓冲区。
后面会详细讲解:
inet_addr()
inet_ntoa()
inet_pton()
inet_ntop()
AI代码解释
cpp
运行
1
2
3
4
之间的区别。
9.5 PrintDebug的作用
例如:
InetAddr addr(peer);
AI代码解释
cpp
运行
1
然后:
std::cout << addr.PrintDebug();
AI代码解释
cpp
运行
1
可能输出:
127.0.0.1:52341
AI代码解释
text
1
这样服务器就可以清楚地显示消息来自哪个网络端点。
十、UdpServer.hpp:实现回显服务器
服务器主要负责:
创建Socket
|
v
绑定本地端口
|
v
进入接收循环
|
v
收到客户端消息
|
v
将消息原样发送回客户端
AI代码解释
text
1
2
3
4
5
6
7
8
9
10
11
12
13
10.1 完整UdpServer.hpp
#pragma once
#include <cstdint>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "nocopy.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:
explicit UdpServer(uint16_t port = defaultport)
: _port(port),
_sockfd(defaultfd)
{
}
void Init()
{
// 1. 创建UDP Socket
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr
<< "socket error: "
<< std::strerror(errno)
<< std::endl;
std::exit(SocketError);
}
std::cout
<< "socket success, sockfd: "
<< _sockfd
<< std::endl;
// 2. 设置本地地址
struct sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
// 3. 将Socket绑定到本地端口
int ret = bind(
_sockfd,
reinterpret_cast<struct sockaddr*>(&local),
sizeof(local)
);
if (ret < 0)
{
std::cerr
<< "bind error: "
<< std::strerror(errno)
<< std::endl;
std::exit(BindError);
}
std::cout
<< "bind success, port: "
<< _port
<< std::endl;
}
void Start()
{
char buffer[defaultsize];
while (true)
{
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
// 4. 接收客户端数据报
ssize_t n = recvfrom(
_sockfd,
buffer,
sizeof(buffer),
0,
reinterpret_cast<struct sockaddr*>(&peer),
&len
);
if (n < 0)
{
if (errno == EINTR)
{
continue;
}
std::cerr
<< "recvfrom error: "
<< std::strerror(errno)
<< std::endl;
continue;
}
InetAddr addr(peer);
// 字符串显示使用实际长度
std::string message(buffer, static_cast<size_t>(n));
std::cout
<< "[" << addr.PrintDebug() << "]# "
<< message
<< std::endl;
// 5. 原样回显收到的字节
ssize_t sent = sendto(
_sockfd,
buffer,
static_cast<size_t>(n),
0,
reinterpret_cast<struct sockaddr*>(&peer),
len
);
if (sent < 0)
{
std::cerr
<< "sendto error: "
<< std::strerror(errno)
<< std::endl;
}
}
}
~UdpServer()
{
if (_sockfd >= 0)
{
close(_sockfd);
}
}
private:
enum
{
SocketError = 2,
BindError = 3
};
uint16_t _port;
int _sockfd;
};
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
10.2 为什么使用两个成员变量?
uint16_t _port;
int _sockfd;
AI代码解释
cpp
运行
1
2
其中:
_port
AI代码解释
cpp
运行
1
表示服务器希望使用的本地端口。
_sockfd
AI代码解释
cpp
运行
1
表示内核分配的 Socket 文件描述符。
它们并不是同一种东西。
例如:
端口号:8888
Socket FD:3
AI代码解释
text
1
2
3
端口号用于网络通信地址。
文件描述符用于当前进程操作 Socket 对象。
10.3 为什么Init和Start分开?
Init()
AI代码解释
cpp
运行
1
主要处理:
Socket创建
地址绑定
AI代码解释
text
1
2
而:
Start()
AI代码解释
cpp
运行
1
主要处理:
接收和回复消息
AI代码解释
text
1
这样可以把资源初始化与业务循环分开。
十一、ServerMain.cc:启动服务器
#include <iostream>
#include <string>
#include <cstdlib>
#include "UdpServer.hpp"
void Usage(const std::string& process)
{
std::cout
<< "Usage: "
<< process
<< " local_port"
<< std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
Usage(argv[0]);
return 1;
}
int value = std::stoi(argv[1]);
if (value <= 0 || value > 65535)
{
std::cerr << "invalid port" << std::endl;
return 1;
}
uint16_t port = static_cast<uint16_t>(value);
UdpServer server(port);
server.Init();
server.Start();
return 0;
}
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
服务器启动:
./udp_server 8888
AI代码解释
bash
1
表示:
创建UDP服务器
使用本地8888端口
AI代码解释
text
1
2
3
此后默认阻塞在:
recvfrom()
AI代码解释
cpp
运行
1
等待客户端发送数据报。
十二、UdpClient.cc:实现UDP客户端
客户端主要负责:
读取用户输入
|
v
向服务器发送消息
|
v
等待服务器回复
|
v
打印回显结果
AI代码解释
text
1
2
3
4
5
6
7
8
9
10
12.1 为什么客户端也要socket?
因为:
服务器需要网络通信端点
AI代码解释
text
1
客户端同样需要网络通信端点。
所以两者都必须建立相应 Socket。
12.2 客户端是否必须显式bind?
这个问题非常重要。
服务器端通常需要固定端口。
例如:
8888
AI代码解释
text
1
客户端必须知道向哪里发送数据。
但是客户端自身的端口通常不需要提前固定。
因此:
UDP客户端需要有本地端口,但一般不必主动显式调用bind。
当客户端首次进行需要本地绑定的操作时,操作系统可以自动为 Socket 选择本地地址和临时端口。
因此:
客户端需要本地端口
AI代码解释
text
1
不等于:
客户端必须自己调用bind()
AI代码解释
text
1
12.3 为什么客户端使用临时端口?
假设一台服务器同时被:
Client-A
Client-B
Client-C
AI代码解释
text
1
2
3
访问。
它们可以分别使用:
Client-A:50001
Client-B:50002
Client-C:50003
AI代码解释
text
1
2
3
4
5
等不同端口进行通信。
服务器则继续使用:
8888
AI代码解释
text
1
接收相应请求。
这样服务器就能通过接收到的数据报携带的地址信息区分不同客户端。
12.4 完整客户端代码
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#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 serverip = argv[1];
int port = std::stoi(argv[2]);
if (port <= 0 || port > 65535)
{
std::cerr << "invalid port\n";
return 1;
}
uint16_t serverport = static_cast<uint16_t>(port);
// 1. 创建UDP Socket
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
std::cerr
<< "socket error: "
<< std::strerror(errno)
<< std::endl;
return 2;
}
std::cout
<< "create socket success: "
<< sock
<< std::endl;
// 2. 准备服务器网络地址
struct sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
int ret = inet_pton(
AF_INET,
serverip.c_str(),
&server.sin_addr
);
if (ret != 1)
{
std::cerr << "invalid server IP\n";
close(sock);
return 1;
}
// 3. 交互式发送与接收
while (true)
{
std::string inbuffer;
std::cout << "Please Enter# ";
if (!std::getline(std::cin, inbuffer))
{
break;
}
if (inbuffer == "quit")
{
break;
}
// 发送UDP数据报
ssize_t n = sendto(
sock,
inbuffer.data(),
inbuffer.size(),
0,
reinterpret_cast<struct sockaddr*>(&server),
sizeof(server)
);
if (n < 0)
{
std::cerr
<< "sendto error: "
<< std::strerror(errno)
<< std::endl;
break;
}
// 等待服务器回显
char buffer[1024];
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
ssize_t m = recvfrom(
sock,
buffer,
sizeof(buffer),
0,
reinterpret_cast<struct sockaddr*>(&peer),
&len
);
if (m >= 0)
{
std::string response(
buffer,
static_cast<size_t>(m)
);
std::cout
<< "server echo# "
<< response
<< std::endl;
}
else
{
std::cerr
<< "recvfrom error: "
<< std::strerror(errno)
<< std::endl;
break;
}
}
close(sock);
return 0;
}
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
这里使用:
inet_pton()
AI代码解释
cpp
运行
1
转换 IPv4 字符串。
与较早的:
inet_addr()
AI代码解释
cpp
运行
1
相比,它提供了更明确的地址解析成功与失败判断方式。
十三、编写Makefile
CXX := g++
CXXFLAGS := -std=c++11 -Wall -Wextra
.PHONY: all clean
all: udp_server udp_client
udp_server: ServerMain.cc
$(CXX) $(CXXFLAGS) -o $@ $^
udp_client: UdpClient.cc
$(CXX) $(CXXFLAGS) -o $@ $^
clean:
rm -f udp_server udp_client
AI代码解释
makefile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
其中编译命令前使用 Tab。
进入项目目录:
make
AI代码解释
bash
1
生成:
udp_server
udp_client
AI代码解释
text
1
2
3
十四、运行V1 Echo Server
14.1 服务器终端
执行:
./udp_server 8888
AI代码解释
bash
1
可能输出:
socket success, sockfd: 3
bind success, port: 8888
AI代码解释
text
1
2
此时服务器开始等待数据。
14.2 客户端终端
执行:
./udp_client 127.0.0.1 8888
AI代码解释
bash
1
输入:
hello
AI代码解释
text
1
客户端可能输出:
Please Enter# hello
server echo# hello
AI代码解释
text
1
2
再次输入:
Linux UDP Socket
AI代码解释
text
1
可以得到:
server echo# Linux UDP Socket
AI代码解释
text
1
14.3 服务器输出
服务器可能显示:
[127.0.0.1:52341]# hello
[127.0.0.1:52341]# Linux UDP Socket
AI代码解释
text
1
2
注意:
52341
AI代码解释
text
1
只是示例临时端口。
实际端口由系统选择,不要求与示例一致。
14.4 为什么服务器知道客户端端口?
因为:
recvfrom()
AI代码解释
cpp
运行
1
在返回数据的同时,通过:
peer
AI代码解释
cpp
运行
1
提供发送方地址。
然后:
InetAddr addr(peer);
AI代码解释
cpp
运行
1
就可以提取:
客户端IP
客户端Port
AI代码解释
text
1
2
14.5 如何查看UDP Socket?
可以使用:
ss -lun
AI代码解释
bash
1
其中:
-u:UDP
-l:显示监听或相应接收状态的Socket
-n:数字形式显示地址与端口
AI代码解释
text
1
2
3
4
5
也可以执行:
ss -uan
AI代码解释
bash
1
查看更多 UDP Socket 信息。
例如可能看到:
0.0.0.0:8888
AI代码解释
text
1
表示对应 Socket 绑定在 IPv4 通配地址的 8888 端口。
十五、从Echo Server升级为网络字典服务器
现在已经实现:
Client发送内容
|
v
Server接收内容
|
v
Server原样返回
AI代码解释
text
1
2
3
4
5
6
7
但回显服务器只是在验证 UDP 通信链路。
如果希望服务器具有实际业务功能,可以让服务器接收到英文单词以后,返回对应的中文解释。
例如:
Client发送:apple
Server返回:苹果
AI代码解释
text
1
2
3
这种程序可以称为:
网络字典服务器(DictServer)。
15.1 为什么不直接修改Start中的业务代码?
例如:
void Start()
{
while (true)
{
recvfrom(...);
// 直接写字典查询
sendto(...);
}
}
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
这样虽然能够完成任务,但会导致:
UDP通信逻辑
AI代码解释
text
1
和:
字典查询逻辑
AI代码解释
text
1
紧密耦合。
以后如果要改成:
计算器服务器
AI代码解释
text
1
或者:
字符串处理服务器
AI代码解释
text
1
就需要不断修改 UdpServer。
因此我们希望:
让UdpServer只负责网络通信,具体如何处理请求由外部业务函数决定。
这就需要回调函数。
十六、V2:设计Dict类
首先准备本地字典文件。
dict.txt:
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
happy: 快乐的
sad: 悲伤的
run: 跑
jump: 跳
teacher: 老师
student: 学生
car: 汽车
bus: 公交车
love: 爱
hate: 恨
hello: 你好
goodbye: 再见
summer: 夏天
winter: 冬天
AI代码解释
text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这里使用:
英文: 中文
AI代码解释
text
1
组织数据。
注意冒号后面存在一个空格。
16.1 为什么需要加载到内存?
一种最直接的做法是:
收到单词
|
v
打开dict.txt
|
v
逐行查找
|
v
返回翻译
AI代码解释
text
1
2
3
4
5
6
7
8
9
10
但如果每收到一个请求就重新打开文件、遍历文件,就会产生重复的文件 IO。
对于这个小型字典,更合理的做法是:
服务器启动
|
v
一次性加载字典文件
|
v
使用内存中的哈希表进行查询
AI代码解释
text
1
2
3
4
5
6
7
16.2 使用unordered_map
定义:
std::unordered_map<
std::string,
std::string
> _dict;
AI代码解释
cpp
运行
1
2
3
4
其中:
Key:英文单词
Value:中文翻译
AI代码解释
text
1
2
3
例如:
_dict["apple"] = "苹果";
AI代码解释
cpp
运行
1
以后查询:
_dict.find("apple");
AI代码解释
cpp
运行
1
就可以找到对应翻译。
16.3 Dict.hpp完整实现
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>
class Dict
{
private:
void LoadDict()
{
std::ifstream in(_confpath);
if (!in.is_open())
{
std::cerr
<< "open dict file error: "
<< _confpath
<< std::endl;
return;
}
const std::string sep = ": ";
std::string line;
while (std::getline(in, line))
{
if (line.empty())
{
continue;
}
std::size_t 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)
);
}
}
public:
explicit Dict(const std::string& confpath)
: _confpath(confpath)
{
LoadDict();
}
std::string Translate(
const std::string& key
) const
{
auto iter = _dict.find(key);
if (iter == _dict.end())
{
return "Unknown";
}
return iter->second;
}
private:
std::string _confpath;
std::unordered_map<
std::string,
std::string
> _dict;
};
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
16.4 line.find(sep)是什么意思?
例如:
std::string line = "apple: 苹果";
AI代码解释
cpp
运行
1
调用:
auto pos = line.find(": ");
AI代码解释
cpp
运行
1
会找到:
冒号和空格
AI代码解释
text
1
在字符串中的起始位置。
然后:
line.substr(0, pos);
AI代码解释
cpp
运行
1
提取:
apple
AI代码解释
text
1
而:
line.substr(pos + sep.size());
AI代码解释
cpp
运行
1
提取:
苹果
AI代码解释
text
1
这样就把一行文本拆成:
Key
Value
AI代码解释
text
1
2
两个部分。
16.5 为什么使用find而不是operator[]?
因为:
_dict[key]
AI代码解释
cpp
运行
1
在 Key 不存在时,可能插入一个默认构造的值。
而查字典只需要查询,不应该因为查询一个不存在的单词就修改字典。
所以使用:
_dict.find(key);
AI代码解释
cpp
运行
1
更符合这里的业务语义。
【配图05:Dict读取dict.txt、解析分隔符并构建unordered_map】
(此处留白,后续插入图片)
十七、回调函数:怎样把通信与业务分离?
17.1 先定义业务函数
例如:
Dict gdict("./dict.txt");
void Execute(
const std::string& req,
std::string* resp
)
{
*resp = gdict.Translate(req);
}
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
其中:
req
AI代码解释
cpp
运行
1
表示客户端请求。
例如:
apple
AI代码解释
text
1
而:
resp
AI代码解释
cpp
运行
1
用于保存响应。
例如:
苹果
AI代码解释
text
1
17.2 为什么使用std::function?
C++11提供:
#include <functional>
AI代码解释
cpp
运行
1
可以定义:
using func_t = std::function<
void(const std::string&, std::string*)
>;
AI代码解释
cpp
运行
1
2
3
它表示一种可调用对象:
输入:请求字符串、响应字符串指针
返回:void
AI代码解释
text
1
2
3
例如:
void Execute(
const std::string& req,
std::string* resp
);
AI代码解释
cpp
运行
1
2
3
4
就符合这个调用形式。
17.3 UdpServer增加回调成员
func_t _func;
AI代码解释
cpp
运行
1
构造时传入:
UdpServer(func_t func, uint16_t port)
: _func(func),
_port(port),
_sockfd(-1)
{
}
AI代码解释
cpp
运行
1
2
3
4
5
6
当服务器收到请求:
std::string request;
AI代码解释
cpp
运行
1
就执行:
std::string response;
_func(request, &response);
AI代码解释
cpp
运行
1
2
3
这样 UdpServer 不需要知道:
字典的具体数据结构
文件怎样加载
单词怎样翻译
AI代码解释
text
1
2
3
4
5
它只需要按照约定调用业务函数即可。
17.4 V2服务端事件循环
核心代码变成:
void Start()
{
char buffer[1024];
while (true)
{
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(
_sockfd,
buffer,
sizeof(buffer),
0,
reinterpret_cast<struct sockaddr*>(&peer),
&len
);
if (n < 0)
{
continue;
}
std::string req(
buffer,
static_cast<size_t>(n)
);
std::string resp;
// 业务处理
_func(req, &resp);
// 发送处理结果
sendto(
_sockfd,
resp.data(),
resp.size(),
0,
reinterpret_cast<struct sockaddr*>(&peer),
len
);
}
}
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
对比 V1:
sendto(..., 原始消息, ...);
AI代码解释
cpp
运行
1
V2:
_func(req, &resp);
sendto(..., resp, ...);
AI代码解释
cpp
运行
1
2
3
最大的区别不是网络接口变了,而是:
服务器的业务处理方式变成了可替换的回调函数。
十八、回调版UdpServer.hpp
下面给出可直接用于字典实验的实现。
它沿用前面的 nocopy.hpp 和 InetAddr.hpp。
#pragma once
#include <cstdint>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <iostream>
#include <string>
#include <functional>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "nocopy.hpp"
#include "InetAddr.hpp"
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 = 8888)
: _port(port),
_sockfd(-1),
_func(func)
{
}
void Init()
{
_sockfd = socket(
AF_INET,
SOCK_DGRAM,
0
);
if (_sockfd < 0)
{
perror("socket");
std::exit(2);
}
struct sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(
_sockfd,
reinterpret_cast<sockaddr*>(&local),
sizeof(local)
) < 0)
{
perror("bind");
std::exit(3);
}
}
void Start()
{
char buffer[1024];
while (true)
{
struct 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)
{
if (errno == EINTR)
{
continue;
}
perror("recvfrom");
continue;
}
InetAddr addr(peer);
std::string req(
buffer,
static_cast<size_t>(n)
);
std::string resp;
_func(req, &resp);
std::cout
<< "[" << addr.PrintDebug() << "] "
<< req
<< " -> "
<< resp
<< std::endl;
if (sendto(
_sockfd,
resp.data(),
resp.size(),
0,
reinterpret_cast<sockaddr*>(&peer),
len
) < 0)
{
perror("sendto");
}
}
}
~UdpServer()
{
if (_sockfd >= 0)
{
close(_sockfd);
}
}
private:
uint16_t _port;
int _sockfd;
func_t _func;
};
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
十九、ServerMain.cc:把Dict作为业务处理器
#include <iostream>
#include <memory>
#include <string>
#include "UdpServer.hpp"
#include "Dict.hpp"
Dict gdict("./dict.txt");
void Execute(
const std::string& req,
std::string* resp
)
{
*resp = gdict.Translate(req);
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
std::cerr
<< "Usage: "
<< argv[0]
<< " local_port"
<< std::endl;
return 1;
}
int value = std::stoi(argv[1]);
if (value <= 0 || value > 65535)
{
std::cerr << "invalid port\n";
return 1;
}
uint16_t port =
static_cast<uint16_t>(value);
std::unique_ptr<UdpServer> server(
new UdpServer(Execute, port)
);
server->Init();
server->Start();
return 0;
}
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
19.1 为什么使用unique_ptr?
这里:
std::unique_ptr<UdpServer>
AI代码解释
cpp
运行
1
用于管理服务器对象生命周期。
这种管理方式可以避免使用裸 new 后遗漏对应的 delete。
而且 UdpServer 管理 Socket 文件描述符,不适合被随意复制。
19.2 为什么选择函数回调?
因为:
Execute
AI代码解释
cpp
运行
1
可以独立实现业务处理。
例如现在是:
*resp = gdict.Translate(req);
AI代码解释
cpp
运行
1
以后也可以替换为:
*resp = "server echo: " + req;
AI代码解释
cpp
运行
1
或者其他业务函数。
UDP 通信主体不需要因此改变。
19.3 为什么不能只学习socket调用,而忽略封装?
因为实际开发中:
通信框架
AI代码解释
text
1
与:
业务逻辑
AI代码解释
text
1
通常需要进行职责分离。
回调函数就是一种简单而实用的解耦方式。
二十、测试DictServer
20.1 编译
当前目录:
dict_server/
├── nocopy.hpp
├── InetAddr.hpp
├── Dict.hpp
├── UdpServer.hpp
├── ServerMain.cc
├── UdpClient.cc
└── dict.txt
AI代码解释
text
1
2
3
4
5
6
7
8
执行:
g++ ServerMain.cc \
-std=c++11 \
-Wall \
-Wextra \
-o dict_server
AI代码解释
bash
1
2
3
4
5
客户端可以复用 V1 的:
UdpClient.cc
AI代码解释
text
1
编译:
g++ UdpClient.cc \
-std=c++11 \
-Wall \
-Wextra \
-o udp_client
AI代码解释
bash
1
2
3
4
5
20.2 启动字典服务器
./dict_server 8888
AI代码解释
bash
1
20.3 启动客户端
./udp_client 127.0.0.1 8888
AI代码解释
bash
1
输入:
apple
AI代码解释
text
1
收到:
苹果
AI代码解释
text
1
输入:
banana
AI代码解释
text
1
收到:
香蕉
AI代码解释
text
1
输入:
hello
AI代码解释
text
1
收到:
你好
AI代码解释
text
1
输入不存在的单词:
computer
AI代码解释
text
1
返回:
Unknown
AI代码解释
text
1
20.4 为什么客户端不需要修改?
因为客户端仍然按照:
发送请求
|
v
等待响应
|
v
打印响应
AI代码解释
text
1
2
3
4
5
6
7
工作。
服务器只是把:
原样回显
AI代码解释
text
1
替换成:
英译汉查询
AI代码解释
text
1
所以客户端的基础 UDP 通信流程没有改变。
二十一、进一步封装:为什么需要UdpSocket类?
目前的服务器已经能够复用。
但观察:
UdpServer
AI代码解释
cpp
运行
1
可以发现它仍然直接使用:
socket()
bind()
recvfrom()
sendto()
close()
AI代码解释
cpp
运行
1
2
3
4
5
假设以后编写:
UDP聊天服务器
UDP监控服务
UDP消息转发服务
AI代码解释
text
1
2
3
4
5
每个程序都重复处理这些基础接口,就会增加代码量。
因此,可以进一步封装:
UdpSocket
AI代码解释
cpp
运行
1
它负责统一提供:
Socket
Bind
RecvFrom
SendTo
Close
AI代码解释
text
1
2
3
4
5
五种基础操作。
然后让:
UdpServer
UdpClient
AI代码解释
text
1
2
共同使用 UdpSocket。
项目结构变为:
应用程序
|
v
UdpServer / UdpClient
|
v
UdpSocket
|
v
Linux Socket API
AI代码解释
text
1
2
3
4
5
6
7
8
9
10
二十二、实现udp_socket.hpp
#pragma once
#include <cstdint>
#include <cerrno>
#include <cstdio>
#include <string>
#include <utility>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class UdpSocket
{
public:
UdpSocket()
: fd_(-1)
{
}
~UdpSocket()
{
Close();
}
UdpSocket(const UdpSocket&) = delete;
UdpSocket& operator=(const UdpSocket&) = delete;
bool Socket()
{
if (fd_ >= 0)
{
return false;
}
fd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (fd_ < 0)
{
perror("socket");
return false;
}
return true;
}
void Close()
{
if (fd_ >= 0)
{
close(fd_);
fd_ = -1;
}
}
bool Bind(
const std::string& ip,
uint16_t port
)
{
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
if (inet_pton(
AF_INET,
ip.c_str(),
&addr.sin_addr
) != 1)
{
return false;
}
int ret = bind(
fd_,
reinterpret_cast<sockaddr*>(&addr),
sizeof(addr)
);
if (ret < 0)
{
perror("bind");
return false;
}
return true;
}
bool RecvFrom(
std::string* buf,
std::string* ip = nullptr,
uint16_t* port = nullptr
)
{
char tmp[10240];
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(
fd_,
tmp,
sizeof(tmp),
0,
reinterpret_cast<sockaddr*>(&peer),
&len
);
if (n < 0)
{
perror("recvfrom");
return false;
}
buf->assign(tmp, static_cast<size_t>(n));
if (ip != nullptr)
{
char ipbuf[INET_ADDRSTRLEN] = {};
const char* result = inet_ntop(
AF_INET,
&peer.sin_addr,
ipbuf,
sizeof(ipbuf)
);
if (!result)
{
return false;
}
*ip = ipbuf;
}
if (port != nullptr)
{
*port = ntohs(peer.sin_port);
}
return true;
}
bool SendTo(
const std::string& buf,
const std::string& ip,
uint16_t port
)
{
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
if (inet_pton(
AF_INET,
ip.c_str(),
&addr.sin_addr
) != 1)
{
return false;
}
ssize_t n = sendto(
fd_,
buf.data(),
buf.size(),
0,
reinterpret_cast<sockaddr*>(&addr),
sizeof(addr)
);
if (n < 0)
{
perror("sendto");
return false;
}
return static_cast<size_t>(n) == buf.size();
}
private:
int fd_;
};
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
22.1 为什么RecvFrom使用输出参数?
bool RecvFrom(
std::string* buf,
std::string* ip = nullptr,
uint16_t* port = nullptr
);
AI代码解释
cpp
运行
1
2
3
4
5
其中:
buf
AI代码解释
cpp
运行
1
用于输出实际收到的数据。
ip
AI代码解释
cpp
运行
1
用于输出发送方 IP。
port
AI代码解释
cpp
运行
1
用于输出发送方端口。
这样调用者可以选择:
RecvFrom(&message);
AI代码解释
cpp
运行
1
只获得数据。
也可以:
RecvFrom(&message, &remote_ip, &remote_port);
AI代码解释
cpp
运行
1
同时获得发送方地址。
22.2 为什么需要buf->assign(tmp,n)?
因为:
recvfrom()
AI代码解释
cpp
运行
1
提供的是数据字节数。
所以:
buf->assign(tmp, n);
AI代码解释
cpp
运行
1
能够根据实际字节数构造字符串。
不要求接收到的数据必须以:
'\0'
AI代码解释
cpp
运行
1
结尾。
22.3 为什么SendTo需要IP和Port?
因为 UDP 可以直接通过:
sendto()
AI代码解释
cpp
运行
1
指定数据报的目的地址。
因此封装:
SendTo(message, ip, port);
AI代码解释
cpp
运行
1
可以让上层代码更直接地表达:
把这条消息发给哪个网络端点?
AI代码解释
text
1
22.4 这个封装有哪些边界?
这是一个用于学习的简化版本。
它还没有处理完整的 UDP 大数据报接收、截断检测、超时控制、重试策略和异常恢复。
例如:
char tmp[10240];
AI代码解释
cpp
运行
1
最多只能保存相应接收缓冲区允许的字节数。
如果实际 UDP 数据报大于缓冲区,普通 recvfrom() 可能只返回部分内容并丢弃剩余部分。
因此这种封装适合当前的小型文本实验,不等于完整通用的 UDP 网络库。
二十三、使用UdpSocket封装通用UdpServer
udp_server.hpp:
#pragma once
#include <cstdint>
#include <functional>
#include <iostream>
#include <string>
#include "udp_socket.hpp"
using Handler = std::function<
void(const std::string&, std::string* resp)
>;
class UdpServer
{
public:
UdpServer()
{
if (!sock_.Socket())
{
throw std::runtime_error(
"create UDP socket failed"
);
}
}
bool Start(
const std::string& ip,
uint16_t port,
Handler handler
)
{
if (!sock_.Bind(ip, port))
{
return false;
}
while (true)
{
std::string req;
std::string remote_ip;
uint16_t remote_port = 0;
if (!sock_.RecvFrom(
&req,
&remote_ip,
&remote_port
))
{
continue;
}
std::string resp;
// 业务回调
handler(req, &resp);
// 回复发送方
sock_.SendTo(
resp,
remote_ip,
remote_port
);
std::cout
<< "[" << remote_ip << ":"
<< remote_port << "] "
<< req << " -> " << resp
<< std::endl;
}
return true;
}
private:
UdpSocket sock_;
};
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
需要补充:
#include <stdexcept>
AI代码解释
cpp
运行
1
与直接使用系统调用的版本相比:
UdpServer
AI代码解释
cpp
运行
1
现在已经不需要直接调用:
recvfrom()
sendto()
AI代码解释
cpp
运行
1
2
因为这些操作都由:
UdpSocket
AI代码解释
cpp
运行
1
完成。
于是:
网络基础操作
AI代码解释
text
1
和:
服务器事件循环
AI代码解释
text
1
进一步分离。
二十四、使用UdpSocket封装UdpClient
udp_client.hpp:
#pragma once
#include <cstdint>
#include <stdexcept>
#include <string>
#include "udp_socket.hpp"
class UdpClient
{
public:
UdpClient(
const std::string& ip,
uint16_t port
)
: ip_(ip),
port_(port)
{
if (!sock_.Socket())
{
throw std::runtime_error(
"create UDP socket failed"
);
}
}
bool SendTo(const std::string& buf)
{
return sock_.SendTo(
buf,
ip_,
port_
);
}
bool RecvFrom(std::string* buf)
{
return sock_.RecvFrom(buf);
}
private:
UdpSocket sock_;
std::string ip_;
uint16_t port_;
};
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
这样客户端发送:
client.SendTo("apple");
AI代码解释
cpp
运行
1
接收:
std::string result;
client.RecvFrom(&result);
AI代码解释
cpp
运行
1
2
3
就不再需要每次手动构造:
sockaddr_in
AI代码解释
cpp
运行
1
24.1 为什么客户端保存服务器IP和Port?
因为在最简单的字典客户端中:
所有请求都发给同一个服务器
AI代码解释
text
1
所以可以在构造客户端对象时保存:
ip_
port_
AI代码解释
cpp
运行
1
2
以后直接调用:
SendTo(word);
AI代码解释
cpp
运行
1
即可。
二十五、封装版英译汉服务器与客户端
25.1 dict_server.cc
#include <iostream>
#include <string>
#include "udp_server.hpp"
#include "Dict.hpp"
int main(int argc, char* argv[])
{
if (argc != 3)
{
std::cout
<< "Usage: "
<< argv[0]
<< " ip port"
<< std::endl;
return 1;
}
Dict dict("./dict.txt");
UdpServer server;
server.Start(
argv[1],
static_cast<uint16_t>(std::stoi(argv[2])),
[&dict](
const std::string& req,
std::string* resp
)
{
*resp = dict.Translate(req);
}
);
return 0;
}
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
25.2 dict_client.cc
#include <iostream>
#include <string>
#include "udp_client.hpp"
int main(int argc, char* argv[])
{
if (argc != 3)
{
std::cout
<< "Usage: "
<< argv[0]
<< " server_ip server_port"
<< std::endl;
return 1;
}
UdpClient client(
argv[1],
static_cast<uint16_t>(std::stoi(argv[2]))
);
while (true)
{
std::string word;
std::cout << "请输入单词# ";
if (!(std::cin >> word))
{
break;
}
if (word == "quit")
{
break;
}
if (!client.SendTo(word))
{
std::cerr << "send failed\n";
break;
}
std::string result;
if (!client.RecvFrom(&result))
{
std::cerr << "recv failed\n";
break;
}
std::cout
<< word << " 的意思是:"
<< result
<< std::endl;
}
return 0;
}
AI代码解释
cpp
运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
25.3 编译
g++ dict_server.cc \
-std=c++11 \
-Wall \
-Wextra \
-o dict_server
AI代码解释
bash
1
2
3
4
5
g++ dict_client.cc \
-std=c++11 \
-Wall \
-Wextra \
-o dict_client
AI代码解释
bash
1
2
3
4
5
25.4 启动服务器
因为当前:
UdpSocket::Bind()
AI代码解释
cpp
运行
1
接收的是 IP 字符串,所以使用:
./dict_server 0.0.0.0 8888
AI代码解释
bash
1
25.5 启动客户端
./dict_client 127.0.0.1 8888
AI代码解释
bash
1
输入:
apple
AI代码解释
text
1
返回:
apple 的意思是:苹果
AI代码解释
text
1
输入:
teacher
AI代码解释
text
1
返回:
teacher 的意思是:老师
AI代码解释
text
1
二十六、为什么要逐步封装,而不是一开始就设计完整框架?
观察整个开发过程。
第一阶段:直接使用系统调用
socket
bind
recvfrom
sendto
AI代码解释
text
1
2
3
4
此阶段主要解决:
怎样让两台程序交换UDP数据报?
第二阶段:封装UdpServer
UdpServer
|
v
封装socket和bind
|
v
封装接收循环
AI代码解释
text
1
2
3
4
5
6
7
此阶段主要解决:
怎样把服务器的网络处理流程组织起来?
第三阶段:引入回调函数
UdpServer
|
v
Handler
|
v
Dict::Translate
AI代码解释
text
1
2
3
4
5
6
7
此阶段主要解决:
怎样把网络通信和具体业务解耦?
第四阶段:封装UdpSocket
业务程序
|
v
UdpServer / UdpClient
|
v
UdpSocket
|
v
Linux Socket API
AI代码解释
text
1
2
3
4
5
6
7
8
9
10
此阶段主要解决:
怎样复用客户端和服务器共同依赖的UDP基础操作?
因此,封装并不是为了增加类的数量。
它的目的是:
让不同层次的代码承担清晰的职责,减少重复并提高复用性。
总结
本文通过两个版本的 UDP 服务器,逐步完成了 Linux Socket 编程入门。
V1 实现:
Echo Server
AI代码解释
text
1
掌握:
socket()
bind()
recvfrom()
sendto()
close()
AI代码解释
cpp
运行
1
2
3
4
5
同时学习:
sockaddr_in
IP地址
Port
INADDR_ANY
网络字节序
AI代码解释
text
1
2
3
4
5
V2 实现:
DictServer
AI代码解释
text
1
通过:
std::unordered_map
AI代码解释
cpp
运行
1
保存英译汉字典。
再通过:
std::function
AI代码解释
cpp
运行
1
将网络通信与业务处理分离。
最后进一步封装:
UdpSocket
UdpServer
UdpClient
AI代码解释
text
1
2
3
建立了比较清晰的代码结构。
整个学习过程可以概括为:
Linux Socket API
|
v
UDP Echo Server
|
v
InetAddr地址封装
|
v
UDP客户端
|
v
Dict字典业务
|
v
回调函数解耦
|
v
UdpSocket通用封装
|
v
可复用的UDP服务器与客户端
AI代码解释
text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
下一篇将继续在这些基础上实现:
UDP多人聊天室。
并进一步学习如何维护在线用户、使用 Route 转发消息、引入线程池,以及如何让客户端同时收发消息。
————————————————
版权声明:本文为CSDN博主「星恒随风」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/Face_FeaR/article/details/169066266