BeamDrop项目搭建流程记录
Last updated on
1.绪言
大概就是小学期程设,要求做一个大作业,然后手头已经做好的HardSeat本来想直接交上去的,但想了想,FastAPI+React,貌似和cpp没有半毛钱关系,于是决定开个新坑
让AI帮忙想了想idea,最终决定做一个局域网传输文件的东西,说不定以后还可以扩展,在这里再次挖个坑,看以后填不填
起名废不会取名,于是让哈基米取了英文名,然后拿着英文名去问ds,给了个文艺的中文名(
最终决定英文名叫BeamDrop,意为“光束坠落”,中文名叫做邻光,邻也就是局域网,光就是说传输得像光那样快,其实不然
2.从计网开始
首先我们需要知道,局域网内是如何实现传输的
2.1.物理层与数据链路层
2.1.1.概述
这里是底层的网络设施,要实现局域网传输,需要先在这一层打通
- 首先是传输介质:传播肯定是需要介质的,这里分有线和无线,有线就是双绞线即网线,无线就是WiFi,我们选择WiFi作为传输介质
- 网络适配器:也就是网卡,主机的
0101这样的数字信号,需要转化成网线中的电信号或者无线电波才能发送出去;同样外部传来的电信号和无线电波需要转为0101数字信号才能为计算机所识别。而网络适配器就承担着这个转换信号的职责 - 网络互联设备:主要有数据链路层的交换机和网络层的路由器,其中交换机就用于同一个子网内部的传输;然后路由器用于根据IP地址寻路,连接不同的子网,也就是说它负责和外部相连,比如看看AbelTomato的blog什么的
2.1.2.交换机
它工作在数据链路层上,依赖于本身持有的一个MAC地址表
所谓MAC地址,就是固定标记在你的电脑或者某台主机的网卡上的地址,当主机内部向外部发送数据包,经过网卡转发的同时也会带上这个MAC地址
而交换机的MAC地址表就记录着哪个端口连接哪个设备的硬件地址也就是MAC
具体来说,它的工作流程如下:
- 泛洪:当
A想要找B,但是此时交换机刚刚开机,什么都没有,这时就会广播信号到每一个连接的端口,也就是泛洪 - 学习:在转发的同时,交换机注意到
A是从端口1进来的,就记录下端口1对应A的MAC地址 - 单播:当
B接收到信号回应的时候,交换机也把对应的端口和B的MAC地址绑定在一起,这样后面A和B再想要通信,就可以直接建立通道,而无需再广播其他端口
2.1.3.路由器
核心工作就是以下两点:
- 路由:也就是找路,路由器通过各种路由算法,和全世界其他的路由器接头,拼凑出完整的网络地图,生成路由表
- 转发:也就是赶路,当一个数据包从路由器的某个输入端口进来时,路由器查询路由表,决定从哪个输出端口丢出去
2.1.4.BeamDrop如何
对于BeamDrop,它显然无法直接操纵物理层和数据链路层,所以它只能工作在以下两种环境:
- 处于同一个二层广播域,也就是连接同一个WiFi SSID且在同一个交换机下
- 或者通过路由可达
什么叫通过路由可达,就是说,如果此时两台主机不工作在同一个子网下,A找不到B,就把数据包发送给默认网关也就是路由器,路由器查询路由表,如果知道对方的IP地址在哪个端口,就转发出去
拆开来看,也就是同子网和跨子网
- 同子网
graph TB
subgraph 局域网段 192.168.1.0/24
A[设备A<br/>IP: 192.168.1.10<br/>掩码: /24]
B[设备B<br/>IP: 192.168.1.20<br/>掩码: /24]
SW[二层交换机<br/>无管理/管理型]
A ---|端口1| SW
B ---|端口2| SW
end
- 跨子网
graph TB
subgraph 网段A[局域网A 192.168.1.0/24]
A[设备A<br/>IP: 192.168.1.10/24<br/>网关: 192.168.1.1]
end
subgraph 网段B[局域网B 10.0.0.0/24]
B[设备B<br/>IP: 10.0.0.20/24<br/>网关: 10.0.0.1]
end
R[路由器<br/>接口1: 192.168.1.1/24<br/>接口2: 10.0.0.1/24<br/>路由转发]
A -->|网线| R
B -->|网线| R
举个例子,假设你在办公室连接了WiFi(子网192.168.1.0/24),同事在另一个楼层连的是另一个交换机出口(子网10.0.0.0/24),两个子网之间有个三层交换机或者路由器接通
graph TB
subgraph 办公网段[办公网段 192.168.1.0/24]
A[您的电脑<br/>IP: 192.168.1.5<br/>网关: 192.168.1.1]
end
subgraph 生产网段[生产网段 10.0.0.0/24]
B[同事电脑<br/>IP: 10.0.0.8<br/>网关: 10.0.0.1]
end
R[公司路由器<br/>LAN口: 192.168.1.1<br/>WAN口: 10.0.0.1<br/>路由转发]
A -->|连接LAN口| R
B -->|连接WAN口| R
这时在BeamDrop中,仍然能连上,因为两个子网之间路由可达
但是如果在没有路由器的纯二层局域网,比如两个设备之间直连同一台傻瓜交换机,则必须在同一个子网中才能通信
一句话总结,不限制在同一子网,只要能ping通IP,而且目标端口没有被防火墙拦截,就能连接
2.2.网络层
2.2.1.内核网络协议栈
简单来说,内核网络协议栈就是操作系统内核中负责处理网络数据包的一套核心代码
- 发送数据时:应用把一堆应用层文本(比如JSON)扔给它,协议栈负责把它套上HTTP头、TCP头、IP头、MAC头,最后扔给网卡发出去
- 接收数据时:网课抓到一堆电信号,协议栈负责一层层拆掉TCP/IP的外包装,检查有没有损坏,最后把干净的数据塞给应用层
既然叫做栈,它在结构上就是自上而下一层压一层的,严格对应TCP/IP的四层模型:
- Socket接口层:内核留给应用层,也就是你的窗口,和我们后面要讲到的套接字有关
- 传输层(TCP/UDP):负责保证传输(TCP三次握手、四次挥手、滑动窗口、拥塞控制)
- 网络层(IP):负责路由
- 链路层与驱动:最底层,负责把IP变成MAC地址,调动网卡驱动,把数据变成电信号或者光信号
2.2.2.套接字(Socket)
我们上面提到,操作系统内核会对应用层暴露接口从而让其能够间接控制传输层和网络层的行为,而这个接口就是套接字
套接字本质上就是内核网络协议栈在用户空间暴露的操作句柄
每一个Socket在内核中关联以下数据结构:
Socket fd ──→ { 本地IP : 本地端口 , 远端IP : 远端端口 , 协议(TCP/UDP) }2.2.3.BeamDrop中的Socket封装
在BeamDrop中,并没有在业务代码中到处调用send和recv,而是在network模块中封装了一层TcpConnection
原因在于,TCP是字节流协议,一次send不保证对端一次recv就能完整收到;同样,一次recv也不保证正好读到一个完整的业务信息
因此,在BeamDrop中,提供了两个关键的接口:
write_all(bytes):循环调用send,直到所有字节都写入TCP连接read_exact(bytes):循环调用recv,直到正好读取指定长度的字节
通过这种方式,上层协议模块无需直接关心TCP分包、粘包问题,而是可以基于读取固定长度Header,再读取指定长度Payload的模型实现应用层协议
2.3.传输层
2.3.1.什么叫传输层
一句话讲解,如果说网络层是主机到主机之间的连接,那传输层就负责进程到进程的数据传输
它通过端口,实现了多路复用和解复用
- 多路复用:其实也就是主机内部要发送出去的数据打包上车,不管是来自
8000端口的uvicorn进程,还是在5174上运行的React,其向外发出的数据通过传输层,打好来自哪个端口,要传输到哪个端口,如果是TCP,还要加上自己的IP地址的标签,然后一股脑丢给网络层 - 解复用:同理,这里就是接收数据,外部传输来的数据,由传输层协议通过端口分发到不同的进程上,但这里需要注意的是,两个协议的分发行为不一样
- 对于UDP,它不管你数据来自哪里,只针对目标端口进行分发,把全部数据都塞到一个套接字里
- 对于TCP,它会严格根据四元组 来判断,只要其中有一个不一样,就会将数据分配给不同的套接字传给应用层
2.3.2.为什么TCP
现在在传输层中,有两个协议,TCP和UDP
对于TCP,特点就是可靠
- 特点:面向连接、可靠传输、面向字节流、全双工
- 代价:慢,又是握手又是挥手,还有一堆控制算法
而UDP就是快
- 特点:无连接、不可靠传输、面向报文
- 代价:不可靠,容易丢包
然后我们回顾一下需求,文件传输,最需要的不是快,而是稳,毕竟谁也不想自己拿到的图片少了半张,所以我们必须得用TCP,哪怕它丢包的时候会传得很慢,导致应用层雪崩
emm这里要提到,虽然UDP可以通过在应用层补齐来实现可靠传输,但是属于过度设计,对现在来说,所以还是选择TCP
然后发送端通过TcpClient::connect(host, port)主动连接接收端,接收端通过TcpServer完成socket -> bind -> listen -> accept流程,等待客户端连接
连接建立之后,两端拿到的都是TcpConnection,后续所有的BeamDrop协议包都在这个TCP连接上发送
3.BeamDrop中传输的整体实现
3.1.总体传输模型
对于当前MVP阶段,BeamDrop采用最典型的Client/Server模型
sequenceDiagram
participant 发送端Client
participant 接收端Server
发送端Client->>接收端Server: 建立TCP连接
接收端Server-->>发送端Client: 连接确认
Note over 发送端Client,接收端Server: 数据传输(双向)
发送端Client->>接收端Server: 关闭连接(可选)
在代码上,主要对应这些模块:
| 层次 | 模块 | 作用 |
|---|---|---|
| CLI / 应用编排 | src/main.cpp | 解析 send / serve 命令,组合网络、协议、传输模块 |
| 网络层封装 | src/network/ | 创建 TCP socket、监听、连接、收发字节 |
| 协议层 | src/protocol/ | 把消息封装成 BeamDrop Packet,并从字节流解析回来 |
| 传输层业务 | src/transfer/ | 发送文件信息、分块发送文件内容、接收并校验 |
| 文件系统 | src/filesystem/ | 扫描文件、处理相对路径、创建目录 |
对于整体数据流,在发送端:
sequenceDiagram
participant 发送端
发送端->>发送端: 解析命令行参数
发送端->>发送端: 扫描文件
发送端->>接收端: 建立 TCP 连接
发送端->>接收端: 发送 HELLO
发送端->>接收端: 发送 FILE_INFO
loop 分块发送
发送端->>接收端: 发送 DATA
end
发送端->>接收端: 发送 FILE_END
发送端->>接收端: 发送 FINISH
在接收端
sequenceDiagram
participant 接收端
participant 发送端
接收端->>接收端: 监听端口
发送端->>接收端: 建立 TCP 连接
接收端->>接收端: accept 连接
发送端->>接收端: 发送 HELLO
接收端->>接收端: 读取 HELLO
loop 循环接收数据
发送端->>接收端: 发送 FILE_INFO / DATA / FILE_END
接收端->>接收端: 接收并处理
end
接收端->>接收端: 校验文件完整性
发送端->>接收端: 发送 FINISH
接收端->>接收端: 等待 FINISH 并结束
如果将两端整合在一起
sequenceDiagram
participant 发送端
participant 接收端
rect rgb(240, 248, 255)
Note over 发送端: 准备阶段
发送端->>发送端: 解析命令行参数
发送端->>发送端: 扫描文件
end
发送端->>接收端: 建立 TCP 连接
接收端->>接收端: accept 连接
发送端->>接收端: 发送 HELLO
接收端->>接收端: 读取 HELLO
发送端->>接收端: 发送 FILE_INFO
loop 分块传输
发送端->>接收端: 发送 DATA 分块
接收端->>接收端: 接收 DATA
end
发送端->>接收端: 发送 FILE_END
接收端->>接收端: 校验文件完整性
发送端->>接收端: 发送 FINISH
接收端->>接收端: 等待 FINISH,结束
3.2.TCP连接的建立
3.2.1.模块职责
| 模块 | 作用 |
|---|---|
src/main.cpp | CLI 入口,解析 serve / send 命令,编排流程 |
src/config/AppConfig.cpp | 读取配置文件,填充 host / port / save_dir / chunk_size 等 |
src/network/TcpServer.cpp | 服务端 TCP socket:创建、绑定、监听、accept |
src/network/TcpClient.cpp | 客户端 TCP socket:解析地址并 connect |
src/network/TcpConnection.cpp | 已建立连接的读写封装:send / recv 循环 |
src/protocol/PacketIO.cpp | 在 TCP 字节流上读写 BeamDrop Packet |
src/protocol/Serializer.cpp | 把 Packet 编码成二进制字节 |
src/protocol/PacketParser.cpp | 把二进制字节解析回 Packet |
src/transfer/Sender.cpp | 发送端发送 FILE_INFO / DATA / FILE_END |
src/transfer/Receiver.cpp | 接收端读取 FILE_INFO / DATA / FILE_END |
3.2.2.从main.cpp开始
int main(int argc, char* argv[]) {
if (argc <= 1) {
print_usage();
return 0;
}
const std::string_view command(argv[1]);
try {
if (command == "serve") {
return run_serve(argc, argv);
}
if (command == "send") {
return run_send(argc, argv);
}
if (command == "config") {
return run_config(argc, argv);
}
print_usage();
return 1;
} catch (const std::exception& error) {
std::cerr << "beamdrop error: " << error.what() << "\n";
return 1;
}
}也就是说,建立连接从命令分支开始:
beamdrop serve ... -> run_serve()
beamdrop send ... -> run_send()服务端和客户端分别启动
beamdrop serve --host 127.0.0.1 --port 9090
beamdrop send README.md --to 127.0.0.1:90903.2.3.服务端
3.2.3.1.启动流程
要运行BeamDrop,需要服务端即接收端先启动并开启监听,客户端即发送端才能识别并连接到,然后发送文件
所以我们先启动服务端,在src/main.cpp中,有服务端启动入口run_serve()
核心代码:
int run_serve(int argc, char* argv[]) {
const auto options = parse_serve_options(argc, argv);
const beamdrop::logger::Logger logger{options.log_file};
try {
beamdrop::network::TcpServer server{options.host, options.port};
std::cout << "beamdrop serve listening on " << options.host << ':' << options.port
<< ", save-dir=" << options.save_dir.string() << '\n';
auto connection = server.accept_one();
logger.info("serve accepted connection");
const auto hello = beamdrop::protocol::read_packet(connection);
...
}
}顺序如下:
flowchart TD
A[run_serve()] --> B[parse_serve_options()]
B --> C[Logger 初始化]
C --> D[TcpServer(host, port)]
D --> E[accept_one()]
E --> F[read_packet(connection) 读取 HELLO]
F --> G[Receiver 接收文件]
3.2.3.2.服务端参数解析:parse_serve_options()
我们一步一步来,先看最开始的这个parse_serve_options()
服务端接收文件,需要先明确主机IPhost,端口port,保存路径save_dir,以及日志路径log_file
所以有服务端选项ServeOptions
struct ServeOptions {
std::string host = "0.0.0.0";
std::uint16_t port = 9090;
std::filesystem::path save_dir = "received";
std::filesystem::path log_file = "logs/beamdrop.log";
};然后这里配置了默认值,如果你什么都不加,只运行
beamdrop serve就会默认配置成上面的值,注意这里的命令我简写了,一般来说需要先make出beamdrop.exe然后执行beamdrop.exe serve/send/config这样,或者说配置到系统PATH变量就可以直接beamdrop调用
然后逐步解析,如果传入配置:
beamdrop serve --config config/beamdrop.example.json就会执行:
config = beamdrop::config::load_config(argv[++index]);
options.host = config.server.host;
options.port = config.server.port;
options.save_dir = config.server.save_dir;
options.log_file = config.log.file;配置读取逻辑在src/config/AppConfig.cpp中:
AppConfig load_config(const std::filesystem::path& path) {
auto config = default_config();
const auto json = read_text_file(path);
const auto server = object_text(json, "server");
load_string_field(server, "host", config_server.host);
load_uint16_field(server, "port", config.server.port);
load_path_field(server, "save_dir", config.server.save_dir);
...
return config;
}然后对于ServerConfig,也有默认结构在include/beamdrop/config/AppConfig.hpp
struct ServerConfig {
std::string host = "0.0.0.0";
std::uint16_t port = 9090;
std::filesystem::path save_dir = "received";
};然后对于parse_serve_options整体的解析逻辑,大概就是这样:
for (int index = 2; index < argc; ++index) {
const std::string_view arg{argv[index]};
if (arg == "--config" && index + 1 < argc) {
config = beamdrop::config::load_config(argv[++index]);
options.host = config.server.host;
...
} else if (arg == "--host" && index + 1 < argc) {
options.host = argv[++index];
} else if (arg == "--port" && index + 1 < argc) {
...
} else if (arg == "--save-dir" && index + 1 < argc)
...
}因此,服务端在创建真正的socket之前,已经确定了像host,port这几个参数
3.2.3.3.服务端创建监听socket: TcpServer::TcpServer()
3.2.3.3.1.核心代码
在src/network/TcpServer.cpp中
TcpServer::TcpServer(const std::string& host, std::uint16_t port) {
detail::ensure_socket_runtime();
const auto handle = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (handle == detail::kInvalidSocket) {
throw detail::socket_error("socket creation failed");
}
int yes = 1;
setsockopt(handle, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&yes), sizeof(yes));
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(port);
if (inet_pton(AF_INET, host.c_str(), &address.sin_addr) != 1) {
detail::close_socket(handle);
throw std::runtime_error("Invalid bind host: " + host);
}
if (bind(handle, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0) {
detail::close_socket(handle);
throw detail::socket_error("bind failed");
}
if (listen(handle, 8) != 0) {
detail::close_socket(handle);
throw detail::socket_error("listen failed");
}
listen_handle_ = detail::to_native_handle();
}这个函数就是服务端TCP监听建立的核心,我们来逐步拆开
3.2.3.3.1.初始化socket运行环境: ensure_socket_runtime()
顺着Ctrl + Left,我们来看src/network/SocketPlatform.hpp
这里对于不同系统环境,定义是不同的
在Windows下:
class WinsockRuntime {
public:
WinsockRuntime() {
WSADATA data{};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) {
throw std::runtime_error("WSAStartup failed")
}
}
~WinsockRuntime() { WSACleanup(); }
};
inline void ensure_socket_runtime() {
static WinsockRuntime runtime;
(void)runtime;
}来拆解一下含义
首先Windows使用socket之前必须调用WSAStartup(),然后用了static保证进程内只初始化一次,并在退出程序时析构,调用WSACleanup()
在Linux或者Unix下,就只需要
inline void ensure_socket_runtime() {}就够了,因为POSIX socket不需要类似于Winsock的全局初始化
3.2.3.3.2.创建socket: socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
const auto handle = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);来看这里面参数的含义:
| 参数 | 含义 |
|---|---|
AF_INET | IPv4 |
SOCK_STREAM | 字节流 socket,也就是 TCP 类型 |
IPPROTO_TCP | 明确使用 TCP 协议 |
如果创建失败的话,就抛出异常:
if (handle == detail::kInvalidSocket) {
throw detail::socket_error("socket creation failed");
}需要注意,Windows下无效socket是INVALID_SOCKET,非Windows下是-1,同样在SocketPlatform.hpp中通过#ifdef来屏蔽平台差异
#ifdef _WIN32
using SocketHandle = SOCKET;
inline constexpr SocketHandle kInvalidSocket = INVALID_SOCKET;
#else
using SocketHandle = int;
inline constexpr SocketHandle kInvalidSocket = -1;
#endif3.2.3.3.3.设置端口复用: SO_REUSEADDR
int yes = 1;
setsockopt(handle, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&yes), sizeof(yes));在理解这一段代码之前,我们需要先认识一个事实,就是当端口断开连接之后,它并不能马上重新投入使用,而是会进入一个TIME_WAIT状态,这个状态大概会持续两分钟
然后痛点就来了,如果当你的服务端程序崩溃或者重启,想要再次重连相同端口,就会遇到这样的报错:Address already in use,意思是已被占用,此时也就是处于TIME_WAIT状态,然后你只能干等两分钟
带着情景,我们来重新看代码
int yes = 1;,没什么好说的,一个布尔开关,1表示开启,0表示关闭
setsockopt(...),这是系统调用,全称为 Set Socket Options,职责就是去改动Socket的各种底层属性,接下来逐个看参数:
handle:就是套接字文件描述符,告诉系统要设置哪一个SocketSOL_SOCKET:表示Socket Level,意为设置的选项是通用的Socket层级,而不是特定针对TCP的IPPROTO_TCP或者IPv4的IPPROTO_IPSO_REUSEADDR:这个就解决了我们上面的问题,意思就是Reuse Address,重用地址,允许套接字强制绑定一个正在被占用的端口,如果其为TIME_WAIT状态
然后重点看后面两个参数:
reinterpret_cast<const char*>(&yes), sizeof(yes)reinterpret_cast<const char*>(&yes):因为底层C语言的setsockopt原型在Windows和Linux上有点差异,Windows的系统API喜欢接收一个const char*类型的指针来指向配置值,而Linux喜欢const void*,这里是考虑到不同平台的兼容性,运用强制类型转换reinterpret_cast把int*转为char*sizeof(yes):底层是C语言接口,没有C++的泛型,所以当传入指针时,系统不知道这个指针指向的数据有多大,所以传入大小进去
它们合起来就做了一件事:把定义的开关变量yes安全递给操作系统的C语言内核,作为setsockopt的参数值,告诉操作系统,把SO_REUSEADDR这个socket选项打开
等价于SO_REUSEADDR = true;
3.2.3.3.4.组装监听地址: sockaddr_in
sockaddr_in address();
address.sin_family = AF_INET;
address.sin_port = htons(port);其中,对于sockaddr_in,它是IPv4的通讯录条目,在网络编程中,要让一个Socket去连接别人或者在本地监听,这里就是为了实现监听,需要获取目标IP地址和端口号,而这个sockaddr_in本质上是一个结构体类型,内部包装了这两样东西
具体地,在<netinet/in.h>头文件中(也可能不一样,我本机环境是<_ip_types.h>),有定义:
struct sockaddr_in {
short sin_family; // 地址族,必须是AF_INET
u_short sin_port; // 16位端口号(必须是网络字节序)
struct in_addr sin_addr; // 32位IPv4地址结构体
char sin_zero[8]; // 填充字节,单纯为了对齐内存,全为0
};然后address.sin_family = AF_INET;,前面就已经说到了,表示IPv4
address.sin_port = htons(port);把本机字节序转为网络字节序
为什么要转?因为现在主流的消费级CPU在内存里存多字节数据时,默认为小端序,也就是把低位字节放在低内存地址
但是网络协议TCP/IP当年规定,网线上传输的数据必须为大端序
htons,即Host to Network short,表示将本地主机的16位整数转成网络字节序,同理如果是32位的long,就叫htonl
3.2.3.3.5.把字符串host转成二进制IP: inet_pton()
if (inet_pton(AF_INET, host.c_str(), &address.sin_addr) != 1) {
detail::clost_socket(handle);
throw std::runtime_error("invalid bind host: " + host);
}inet_pton,即internet network presentation to network,把人类看得懂的IP地址转换成计算机底层网络传输所需要的二进制大整数
然后函数原型大概是这样:
int inet_pton(int af, const char *src, void *dst);af:地址族,AF_INET表示IPv4,AF_INET6表示IPv6src:源字符串dst:目的内存,指向接收二进制结果的结构体
返回值是1成功,0输入格式错误,-1系统错误
具体来说,执行结果类似:
"127.0.0.1" -> 0x7f000001
"0.0.0.0" -> 0x000000000.0.0.0 的含义是监听所有本机 IPv4 网卡地址
然后限制就是当前版本暂时只支持IPv4吧,后面可能会加IPv6
3.2.3.3.6.绑定地址和端口: bind()
if (bind(handle, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0) {
detail::close_socket(handle);
throw detail::socket_error("bind failed");
}现在我们拿到了需要监听的IP地址和端口号,但要想真正实现监听,需要将这两个东西绑定到Socket即套接字句柄上
如果没有bind,外界发来的数据包就不知道应该塞给哪个进程,bind之后,操作系统就知道发往这个IP和端口的数据,都接到handle这个套接字里面
在Windows下,函数原型就是
int bind(SOCKET s, const struct sockaddr *name, int namelen);我们前面提到,Windows的无效套接字是INVALID_SOCKET,然后Linux的就是文件描述符int,所以在标准的C/Linux原型下就是
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);底层就是个UINT_PTR,也就是无符号指针级别整数
中间就是通用地址结构体指针,因为我们前面定义的address是sockaddr_in类型,但是底层C系统调用bind是一套比较远古的接口,只认一个通用的基类结构体叫做sockaddr,所以这里必须要强制转换过去
3.2.3.3.7.开始监听: listen(handle, 8)
if (listen(handle, 8) != 0) {
detail::close_socket(handle);
throw std::runtime_error("listen failed");
}需要澄清的是,这个listen函数意思准确上来说并不是等连接,而是一种宣告
在调用listen()之前,handle只是一个普通的,既能发送也能接受的句柄
然后调用listen(handle, 8),就相当于向操作系统宣告,标记这个套接字为被动套接字,只负责监听
然后对于后面的参数8,这个叫做backlog
在TCP协议栈中,内核为每个正在处于监听状态的套接字维护两个队列:
- 半连接队列(SYN Queue):客户端发送
SYN过来,服务器返回SYN-ACK,但还没收到客户端的ACK,也就是处于三次握手的中间阶段 - 全连接队列(Accept Queue):三次握手已经完成,连接建立成功,正在排队等待
accept()处理
而那个8就是全连接队列的最大长度,意思就是同一时间最多只能建立8个连接,也就是并发限制
3.2.3.3.8.保存监听句柄
listen_handle_ = detail::to_native_handle(handle);然后BeamDrop中用std::intptr_t保存native handle,实现跨平台封装
inline std::intptr_t to_native_handle(SocketHandle handle) {
return static_cast<std::intptr_t>(handle);
}然后后面的accept_one()会重新把它转为平台的socket handle
inline SocketHandle to_socket_handle(std::intptr_t handle) {
return static_cast<SocketHandle>(handle);
}调用
detail::to_socket_handle(liten_handle_);为什么要这样转来转去的?原因还是Windows和Linux的类型不兼容问题,在Linux下是int,在Windows下是SOCKET,本质上是一个unsigned __int64
所以为了让跨平台框架在两边都能编译成功,必须要找一个万能容器
满足硬性条件:体积够大,能装下任何平台上的套接字,并且强转过去再过来的时候,没有精度损失
显然std::intptr_t作为64位的类型,满足这个条件
这样写就避免了这样的恶心的定义:
#ifdef _WIN32
SOCKET liten_handle_;
#else
int listen_handle_;
#endif3.2.3.4.服务端阻塞等待连接: accept_one()
来,接下来让我们回到run_serve()
在服务端监听套接字建立成功之后,就有
auto connection = server.accept_one();3.2.3.4.1.TcpConnection
其中connection是TcpConnection类型
class TcpConnection {
public:
using NativeHandle = std::intptr_t;
TcpConnection() = default;
explicit TcpConnection(NativeHandle handle);
~TcpConnection();
TcpConnection(const TcpConnection&) = delete;
TcpConnection operator=(const TcpConnection&) = delete;
TcpConnection(TcpConnection&& other) noexcept;
TcpConnection operator=(TcpConnection&& other) noexcept;
[[nodiscard]] bool valid() const noexcept;
void close() noexcept;
void write_all(std::span<const std::uint8_t> bytes) const;
[[nodiscard]] std::vector<std::uint8_t> read_exact(std::size_t size) const;
private:
NativeHandle handle_(-1);
};在这里讲一下类型吧,intptr_t其实就是一个有符号的整数类型,存在的唯一目的就是足够大,保证能装下任何一个有效的指针地址
然后uint8_t其实就是unsigned char,因为网络上传输的都是原生的字节,也就是八个比特,直接写char会导致语意不清,以为是文本字符,这里主要是明确类型
其中禁用了拷贝,但是允许移动语义,加一个noexcept防止它运行的时候偷偷调用拷贝
[[nodiscard]]是一个警告属性,用于标记这个函数必须接着返回值,如果直接调用类似connection.valid();,就会直接编译错误
然后std::span是C++20的新特性,简单来说,它就是一个非占有性的连续内存视图,它自己不分配内存,也不销毁内存,只是盯着一段已经存在的且连续的内存看
它的内部实质上就只是一个胖指针,包含两个东西
- 一个指向连续内存首部的指针
ptr - 一个表示元素数量的长度
size
在没有std::span之前,如果要写一个处理连续数组的函数,有两种写法:
void process(const int* arr, size_t size); // 不安全,size传错直接内存越界或者
void process(const std::vector<int>& arr); // 安全,但是如果用户手里如果是个C风格数组或者std::array,塞不进来而std::span终结了这种混乱
void process(std::span<const int> arr);
int c_arr[] = {1, 2, 3};
std::vector<int> vec = {4, 5, 6};
std::array<int, 3> cpp_arr = {7, 8, 9};
// 全都没问题
process(c_arr);
process(vec);
process(array);3.2.3.4.2.accept_one()
实现:
TcpConnection TcpServer::accept_one() const {
if (listen_handle_ == -1) {
throw std::runtime_error("cannot accept on closed TCP server");
}
sockaddr_in client_address{}; // IPv4
#ifdef _WIN32
int address_length = sizeof(client_address);
#else
socklen_t address_length = sizeof(client_address);
#endif
const auto client = accept(detail::to_socket_handle(listen_handle_),
reinterpret_cast<sockaddr*>(&client_address), &address_length);
if (client == detail::kInvalidSocket) {
throw detail::socket_error("accept failed");
}
return TcpConnection{detail::to_native_handle(client)};
}首先很显然的判断当前这个服务端的监听套接字到底有没有跑起来,没跑起来那玩个毛,直接抛异常
然后就是跨平台编程不得不品的一环#ifdef,原因还是老生常谈的类型不一样的问题,这里不多说了
然后是重点,这里用accept函数创建了一个client,不多bb,我们直接来看函数原型
我这里是Windows环境,所以返回值就是SOCKET,如果是Linux就是int
SOCKET accept(SOCKET s, struct sockaddr *addr, int *addrlen);需要注意,这个accept是阻塞的,除非把监听套接字设置成了非阻塞O_NONBLOCK,服务端会一直停在这里,直到有客户端连接
连接之前,服务端socket状态为LISTEN,run_serve()阻塞在accept_one()
客户端发起连接后,TCP三次握手完成,accept()函数返回一个新的套接字socket
listen_handle_表示继续监听socket,client表示这一个客户端连接socket
然后包装成
return TcpConnection{detail::to_native_handle(client)};这个返回的TcpConnection之后用于收发数据,需要注意的是,监听socket和连接socket不是同一个,TcpServer持有的是监听句柄,TcpConnection持有的是连接句柄
具体可以理解为,TcpServer持有的那个监听句柄,就像是酒店门口的迎宾,只负责迎接客人,但是不提供服务,像是点餐由服务员来做,做饭由厨师来做
3.2.4.客户端
3.2.4.1.启动流程
客户端启动入口在src/main.cpp的run_send(),来看核心代码
int run_send(int argc, char* argv[]) {
const auto options = parse_send_options(argc, argv);
const beamdrop::logger::Logger logger(options.log_file);
logger.info("send starting endpoint=" + options.endpoint.host + ':'
+ std::to_string(options.endpoint.port) + "path=" + path.text(options.path));
try {
std::vector<beamdrop::filesystem::FileEntry> entries;
for (const auto& path : options.paths) {
auto scanned = beamdrop::filesystem::scan_files(path);
entries.insert(entries.end(), scanned.begin(), scanned.end());
}
logger.info("send scanned file_count=" + std::to_string(entries.size()));
beamdrop::network::TcpClient client;
auto connection = client.connect(options.endpoint.host, options.endpoint.port);
logger.info("send connected endpoint=" + options.endpoint.host + ':'
+ std::to_string(options.endpoint.port));
beamdrop::protocol::Packet hello;
hello.header.type = beamdrop::protocol::PacketType::Hello;
const beamdrop::transfer::TransferManifest manifest{
static_cast<std::uint64_t>(entries.size()), total_entry_bytes(entries)
};
logger.info("send manifest file_count=" + std::to_string(manifest.file_count)
+ " total_bytes=" + std::to_string(manifest.total_bytes));
hello.payload = beamdrop::transfer::TransferManifestCodec::encode(manifest);
beamdrop::protocol::write_packet(connection, hello);
beamdrop::transfer::Sender sender{connection, print_progress, options.chunk_size};
sender.send_files(entries);
beamdrop::protocol::Packet finish;
finish.header.type = beamdrop::protocol::PacketType::Finish;
beamdrop::protocol::write_packet(connection, finish);
std::cout << "beamdrop send sent " << entries.size() << " file(s) to "
<< options.endpoint.host << ':' << options.endpoint.port << '\n';
logger.info("send completed file_count=" + std::to_string(entries.size()));
return 0;
} catch (const std::exception& error) {
logger.error(std::string{"send failed: "} + error.what());
throw;
}
}还是用图画出来流程:
flowchart TD
A[run_send] --> B[parse_send_options]
B --> C[scan_files]
C --> D[TcpClient::connect(host, port)]
D --> E[write_packet(HELLO)]
E --> F[Sender::send_files]
F --> G[write_packet(FINISH)]
3.2.4.2.客户端参数解析: parse_send_options()
同样的,有默认值:
struct SendOptions {
std::vector<std::filesystem::path> paths;
Endpoint endpoint{"", 0};
bool has_endpoint = false;
std::filesystem::path log_file = "logs/beamdrop.log";
std::size_t chunk_size = 1024 * 1024;
};对于用户,需要输入类似:
beamdrop send <path...> -- to <host:port>当然也有类似于--config, --log-file, --chunk-size的参数,但解析逻辑和前面类似,就不讲了
其中--to会由parse_endpoint()函数解析
Endpoint parse_endpoint(std::string& text) {
const auto separator = text.rfind(':');
if (seperator == std::string::npos || separator == 0 || separator + 1 >= text.size()) {
throw std::runtime_error("--to must be host:port format");
}
return Endpoint{text.substr(0, separator), parse_port(text.substr(separator + 1))};
}然后parse_port()会检查端口不能超过65535,同时将其转为std::uint16_t类型
std::uint16_t parse_port(const std::string& port) {
const auto value = std::stoul(text);
if (value > 65535) {
throw std::runtime_error("port out of range: " + text);
}
return static_cast<std::uint16_t>(value);
}3.2.4.3.客户端建立TCP连接: TcpClient::connect()
然后后面的就是
const beamdrop::logger::Logger logger{options.log_file};
logger.info("send starting endpoint=" + options.endpoint.host + ':'
+ std::to_string(options.endpoint.port) + " paths=" + paths_text(options.paths));建立日志,以及
std::vector<beamdrop::filesystem::FileEntry> entries;
for (const auto& path : options.paths) {
auto scanned = beamdrop::filesystem::scan_files(path);
entries.insert(entries.end(), scanned.begin(), scanned.end());
}
logger.info("send scanned file_count=" + std::to_string(entries.size()));读取文件,就不提了,重点是后面的
beamdrop::netword::TcpClient client;
auto connection = client.connect(options.endpoint.host, options.endpoint.port);client.connect()的具体实现在src/network/TcpClient.cpp,我们来看:
TcpConnection TcpClient::connect(const std::string& host, std::uint16_t port) const {
detail::ensure_socket_runtime();
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
addrinfo* result = nullptr;
const auto port_text = std::to_string(port);
if (getaddrinfo(host.c_str(), port_text.c_str(), &hints, &result) != 0) {
throw std::runtime_error("getaddrinfo failed for host " + host);
}
detail::SocketHandle connected = detail::kInvalidSocket;
for (addrinfo* item = result; item != nullptr; item = item->ai_next) {
const auto handle = socket(item->ai_family, item->ai_socktype, item->ai_protocol);
if (handle == detail::kInvalidSocket) {
continue;
}
if (::connect(handle, item->ai_addr, static_cast<int>(item->ai_addrlen)) == 0) {
connected = handle;
break;
}
detail::close_socket(handle);
}
freeaddrinfo(result);
if (connected == detail::kInvalidSocket) {
throw detail::socket_error("connect failed");
}
return TcpConnection(detail::to_native_handle(connected));
}我们接下来逐步把它拆开讲解
3.2.4.3.1.初始化socket环境
这里和server那边一样,都是要先初始化socket,确保Windows下WSAStartup()已经执行
detail::ensure_socket_runtime();3.2.4.3.2.准备地址解析参数: addrinfo hints
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;这个东西叫hints,顾名思义,就是在调用getaddrinfo去解析域名或者IP之前,先给系统提的硬性筛选条件
ai_family = AF_INET:只要IPv4ai_socktype = SOCK_STREAM:只要流式传输,提供一个面向连接的、可靠的字节流管道ai_protocol = IPPROTO_TCP:只要TCP协议
3.2.4.3.3.解析目标地址
addrinfo* result = nullptr;
const auto port_text = std::to_string(port);
if (getaddrinfo(host.c_str(), port_text.c_str(), &hints, &result) != 0) {
throw std::runtime_error("getaddrinfo failed for host " + host);
}一步步来看:
首先声明了一个result,这个result实际上是个指针的指针的接收者,当getaddrinfo成功之后,会在堆上动态分配一个链表,里面存着解析出来的各种IP地址信息,然后这个result就指向这个链表的头节点
然后将port转成string类型,便于后面处理
接下来就是解析目标地址函数getaddrinfo,来看看函数原型
int getaddrinfo(const char *nodename, const char *servname, const struct addrinfo *hints, struct addrinfo **res);前面两个参数就分别是主机IP和端口号,由于接口太老,只能转为C风格字符串也就是char*传进去
然后hints就是我们前面的配置,往里面传入这个addrinfo*类型的地址
最后可以看到那个**res,就代表它是指针的指针,我们这里通过对addrinfo*类型的result再取一次址得到,用于存储指向链表头指针的指针地址
总结一下getaddrinfo()的作用就是,把host + port解析成一个或者多个可以connect的sockaddr
为什么会有多个sockaddr?因为前面传入的host可能会解析出好几个不同的IP地址,然后getaddrinfo会把这些所有可能连得通的组合,塞进一个由addrinfo结构体串起来的单向链表里面,结构类似于这样:
flowchart LR
result[("result 指针")] -->|指向| node1
node1["addrinfo 节点 1<br>ai_family: AF_INET6<br>ai_socktype: SOCK_STREAM<br>ai_addr: [IPv6地址]<br>ai_next: ──────>"]
node1 -->|ai_next| node2
node2["addrinfo 节点 2<br>ai_family: AF_INET<br>ai_socktype: SOCK_STREAM<br>ai_addr: [IP地址A]<br>ai_next: ──────>"]
node2 -->|ai_next| node3
node3["addrinfo 节点 3<br>ai_family: AF_INET<br>ai_socktype: SOCK_STREAM<br>ai_addr: [IP地址B]<br>ai_next: nullptr"]
3.2.3.4.4.对解析结果逐个尝试socket + connect
detail::SocketHandle connected = detail::kInvalidSocket;
for (addrinfo* item = result; item != nullptr; item = item->ai_next) {
const auto handle = socket(item->ai_family, item->ai_socktype, item->ai_protocol);
if (handle == detail::kInvalidSocket) {
continue;
}
if (::connect(handle, item->ai_addr, static_cast<int>(item->ai_addrlen)) == 0) {
connected = handle;
break;
}
detail::close_socket(handle);
}每个节点item里面有三个最核心的字段:
ai_family/ai_socktype/ai_protocol:这是套接字的配置模板,其实也就是我们前面的hints,操作系统根据它们的值决定是创建一个IPv4的TCP socket,还是一个IPv6的UDP socketai_addr:指向一个底层的结构体sockaddr,里面塞满了真正的IP地址和端口号ai_next:链表的指针域,指向下一个备选方案
所以上面这段代码的逻辑就很清晰了,就是逐个遍历,谁先连到算谁的
循环体就是标准的单向链表遍历,这个不说了,看内部
首先对于当前节点,拿着这个配置去尝试建立套接字,如果建立失败了就还是kInvalidSocket,直接下一个
然后如果建立成功,就尝试连接,也就是后面的connect()
如果连接成功,就取当前这个,赋给connected,然后break跑路
如果没成功,就要记得close_socket()断开连接,不然白白耗费文件描述符
flowchart TD
A[开始遍历候选地址] --> B{是否还有未尝试的地址?}
B -->|是| C[创建 socket]
C --> D[调用 connect 连接该地址]
D --> E{连接是否成功?}
E -->|成功| F[保存 connected 状态并跳出循环]
E -->|失败| G[关闭这个 socket]
G --> B
B -->|否| H[结束]
F --> H
通过这一步,操作系统层面的连接已经建立
3.2.3.4.5.释放地址解析结果
freeaddrinfo(result);这里一定要记得释放getaddrinfo()分配的大链表内存
3.2.3.4.6.返回TcpConnection
if (connected == detail::kInvalidSocket) {
throw detail::socket_error("connect failed");
}
return TcpConnection{detail::to_native_handle(connected)};如果所有候选地址都失败,则抛出异常,成功则返回TcpConnection连接
3.2.4.4.TcpConnection
在继续后面的HELLO的发送之前,我们先看看TcpConnection背后数据的读写,在上面已经给出过TcpConnection的定义,我们这边不再说了,只具体看write_all()和read_exact()两个函数的具体实现
3.2.4.4.1.写数据: write_all()
在src/network/TcpConnection.cpp中有实现:
void TcpConnection::write_all(std::span<const std::uint8_t> bytes) const {
if (!valid()) {
throw std::runtime_error("cannot write to invalid TCP connection");
}
std::size_t sent_total = 0;
while (sent_total < bytes.size()) {
const auto remaining = bytes.size() - sent_total;
const int chunk_size = remaining > static_cast<std::size_t>(INT_MAX)
? INT_MAX
: static_cast<int>(remaining);
const int sent = send(detail::to_socket_handle(handle_),
reinterpret_cast<const char*>(bytes.data() + sent_total), chunk_size, 0);
if (sent <= 0) {
throw detail::socket_error("sent failed");
}
sent_total += static_cast<std::size_t>(sent);
}
}一句话总结,这段代码实现的实际上是一个应用层单向阻塞式可靠投递回路,它的每一行都在处理一件事:怎么在不稳定的网络系统调用,实现高层逻辑所需要的绝对发送
首先是惯例的前置检查,进入任何系统调用之前,先验证内核句柄handle_的有效性,防止调用无效套接字,引发操作系统的EBADF即错误文件描述符报错
随后是动态流控分块,对chunk_size大小进行限制
- API协议对齐:标准POSIX也就是在Linux平台下,
send函数的第三个参数是size_t类型,但是在Windows下是int,或者某些特殊平台的特殊包装下,输入参数会被限制在INT_MAX字节 - 大对象切片逻辑:所以这里的逻辑就是为了防止你一次性塞进去一个大于2GB也就是
INT_MAX字节的std::span导致数据溢出或者符号截断,这里就是典型的在应用层控制网络层行为,确保丢给send的尺寸永远在安全范围内
然后就是指针偏移和内核投递,也就是那个send
看send的函数原型:
int send(SOCKET s, const char *buf, int len, int flags);来具体看看这四个参数:
s:也就是套接字文件描述符,操作系统拿到这个东西之后,去进程的文件描述符表里面查对应的socket结构体,获取类似本地IP,端口,对端IP,端口以及连接专用的内核发送和接收的缓冲区buf:用户态缓冲区指针,表示数据在用户态内存中的首地址,这里实现了偏移,通过bytes.data() + sent_total实现类似底层滑动窗口的指针前移,每次成功发送 字节,用户态指针就向后移动 ,从而保证下次投递的绝对是未发送的数据len:要发送的字节数,但需要注意的是,这个数字是你期望发送的量,实际不一定能发送这么多。假如你需要发送64KB的数据,但实际上内核缓冲区只剩下10KB的容量,在阻塞模式下会卡住继续等待拷贝,在非阻塞模式下,直接返回10240即10KB,剩下的54KB动都不动flags:行为标志位,这里传0表示默认行为,有socket属性决定,但也有另外的参数MSG_NOSIGNAL:在Linux下,如果对方断开了连接,触发RST,继续调用send时,内核默认会向进程发送一个SIGPIPE信号,直接杀死程序,而这个参数就可以让send只返回错误码MSG_DONTWAIT:强制将此次send变为非阻塞MSG_OOB:发送带外数据,也就是紧急数据
上面提到了buf,这个就是缓冲区,这里需要讲一下,在网络编程中分为两个极其重要的缓冲区:
- 用户态缓冲区:在这里也就是通过
std::span<std::uint8_t> bytes传进来的那一大堆数据,存在于程序的内存空间里 - 内核态缓冲区:这是操作系统为每个TCP连接偷偷开辟的一块物理内存。具体来说,当调用
send的时候,会依次发生下面的几件事:- 搬运数据:也就是数据从用户态缓冲区到内核态缓冲区之间的转移
- 返回成功:只要内核态缓冲区能装下,操作系统就会回头告诉程序这里OK了,于是
send返回成功写入的字节数 - 网卡异步发送:需要注意,此时数据可能还没发送到网线上,但程序已经在继续往下跑了,网卡在后台将内核缓冲区里的数据切成一小块一小块发送出去
3.2.4.4.2.读数据: read_exact()
std::vector<std::uint8_t> TcpConnection::read_exact(std::size_t size) const {
if (!valid()) {
throw std::runtime_error("cannot read from invalid TCP connection");
}
std::vector<std::uint8_t> bytes(size);
std::size_t received_total = 0;
while (received_total < size) {
const auto remaining = size - received_total;
const int chunk_size = remaining > static_cast<std::size_t>(INT_MAX)
? INT_MAX
: static_cast<int>(remaining);
const int received = recv(detail::to_socket_handle(handle_),
reinterpret_cast<char*>(bytes.data() + received_total), chunk_size, 0);
if (received <= 0) {
throw detail::socket_error("recv failed");
}
received_total += static_cast<std::size_t>(received);
}
return bytes;
}这里的逻辑和write_all()类似,但关键点在于TCP传输是字节流,而不保留消息边界
也就是说,发送端一次send()的内容,接收端可能要用多次recv来完成,也可能一次recv拿到多个上层消息的一部分
因此在BeamDrop中,不直接说我要读一个包,而是
graph LR
A[开始] --> B[读取固定长度 Header]
B --> C{是否读取成功?}
C -- 否 --> F[报错/重试]
C -- 是 --> D[从 Header 解析 Payload 长度]
D --> E[精确读取 Payload 长度字节]
E --> G{是否读取完整?}
G -- 否 --> H[报错/重试]
G -- 是 --> I[结束]
然后read_exact(size)的作用就是,不读够size字节不返回
评论
评论会在审核后展示