在上一章 拓扑 (Topology) 中,我们学习了一个节点 (Node)是如何维护一张关于整个 ZeroTier 网络的“地图”的。通过这张地图,节点可以找到网络中的其他对等节点 (Peer),并与它们建立虚拟的连接关系。
我们现在知道,Peer 对象代表了“与谁通信”以及“如何安全通信”。但是,所有这些虚拟的连接,最终都必须通过真实、物理的互联网线路来传输数据。
这里就引出了我们旅程的最后一站,也是最接近物理现实的一个问题:当你的笔记本电脑决定要给服务器发送一个数据包 (Packet)时,它究竟应该通过哪个具体的网络接口(Wi-Fi还是有线网?)、哪个IP地址(IPv4还是IPv6?)来发送呢?
为了管理这些具体的、物理的连接路由,ZeroTier 引入了 物理路径 (Path) 的概念。
什么是物理路径 (Path)?
Path 对象代表了一条可以通过真实互联网连接到另一个对等节点 (Peer)的具体物理路由。你可以把它想象成你的通讯录里,一个联系人名下的某一个具体的电话号码。
让我们用一个简单的比喻来理解:
- 对等节点 (Peer):是你通讯录里的一个联系人,比如“公司服务器”。
- 物理路径 (Path):是这个联系人名下的一个具体联系方式。例如:
Path 1: 办公室的有线网络IP地址 (198.51.100.5:9993)Path 2: 办公室的Wi-Fi IPv4地址 (198.51.100.6:9993)Path 3: 办公室的Wi-Fi IPv6地址 ([2001:db8::1]:9993)
一个节点 (Node)可能同时拥有多种网络连接方式,因此一个Peer可能会对应多个可用的Path。ZeroTier 的智能之处就在于,它会像一个聪明的拨号助理,持续地监控所有这些“电话号码”的通话质量(如延迟、丢包率),并总是选择最快、最可靠的一条来发送数据。
graph TD
Peer["Peer 对象 (公司服务器)"] --> Path1["Path 1<br>(有线 IPv4: 198.51.100.5)"];
Peer --> Path2["Path 2<br>(Wi-Fi IPv4: 198.51.100.6)"];
Peer --> Path3["Path 3<br>(Wi-Fi IPv6: [2001:db8::1])"];
Path 对象的核心职责
Path 对象本质上是一个轻量级的数据结构,它的核心职责是记录和评估一条物理路由的状态。
- 身份标识: 每个
Path对象都由一个本地套接字(我们从哪个网卡发出)和一个远端InetAddress(IP地址和端口)唯一确定。 - 状态追踪: 它记录了关于这条路径的关键性能指标:
_lastIn/_lastOut: 最后一次在该路径上接收或发送数据包的时间戳。这用于判断路径是否还“活着”。_latency: 这条路径的估计往返延迟(单位是毫秒)。
- 发送执行者: 当对等节点 (Peer)对象最终选定了一个
Path来发送数据时,Path对象的send()方法就是最终的执行者。它负责将数据包交给底层的网络接口,从指定的本地套接字发送到目标IP地址。
代码中的 Path
Path 类的定义非常简洁,可以在 node/Path.hpp 文件中找到。它主要包含了一些状态变量和几个核心方法。
// 文件: node/Path.hpp (简化版)
class Path
{
private:
volatile int64_t _lastOut; // 最后发送时间
volatile int64_t _lastIn; // 最后接收时间
int64_t _localSocket; // 使用的本地套接字
InetAddress _addr; // 远端的 IP 地址和端口
volatile unsigned int _latency; // 估计的延迟 (ms)
public:
// 构造函数
Path(const int64_t localSocket, const InetAddress &addr);
// 通过此路径发送数据
bool send(const RuntimeEnvironment *RR, void *tPtr, const void *data, unsigned int len, int64_t now);
// 检查路径是否还存活
inline bool alive(const int64_t now) const;
// 更新延迟测量值
inline void updateLatency(const unsigned int l, int64_t now);
// ... 其他辅助方法
};
_lastOut和_lastIn: 这两个时间戳是判断路径活跃度的关键。如果一条路径很久没有收到任何数据包,它就会被认为是“死的”。_localSocket和_addr: 这两个变量唯一地定义了一条路径。_latency: 这个值由对等节点 (Peer)通过发送ECHO包并测量响应时间来不断更新。send(): 这是Path最直接的功能。它封装了向物理网络发送数据的最终操作。
Path 的生命周期:发现、评估与淘汰
Path 不是静态配置的,而是由 ZeroTier 动态发现和管理的。这个过程展示了 ZeroTier P2P 网络强大的自适应能力。
场景:两条路径的发现之旅
假设你的笔记本电脑(A)和一台服务器(B)都加入了同一个 ZeroTier 网络。A 有 Wi-Fi,B 有有线网络。它们之间是如何发现并建立直接路径的呢?
sequenceDiagram
participant Node_A as 节点 A (你的电脑)
participant Peer_B as B 的 Peer 对象 (在 A 中)
participant Planet as 行星服务器 (中继)
participant Node_B as 节点 B (服务器)
Node_A->>Planet: 我想和 B 通信 (通过中继)
Planet->>Node_B: A 给你发消息了 (源 IP 是 A 的 Wi-Fi 公网 IP)
%% 路径发现的第一步
Node_B->>Node_B: 注意到数据包来自 A 的公网 IP:Port
Node_B->>Node_A: 回复 HELLO (直接发往 A 的公网 IP:Port)
%% 路径在 A 侧被创建
Node_A->>Peer_B: 我收到一个来自 B 的直接 HELLO!
Peer_B->>Peer_B: 创建一个新的 Path 对象 (B -> A)
%% A 也开始尝试联系 B
Node_A->>Node_B: 我也给你发个 HELLO 吧 (直接发往 B 的公网 IP)
Node_B->>Peer_B: 我也收到一个来自 A 的直接 HELLO!
Peer_B->>Peer_B: (在 B 中) 创建一个新的 Path 对象 (A -> B)
%% 路径评估
Peer_B->>Peer_B: 定期通过新 Path 发送 ECHO 包
Peer_B->>Peer_B: 根据 ECHO 回复更新 Path 的延迟
- 初次接触: A 和 B 通过行星服务器 (Planet)进行中继通信。当 B 收到 Planet 转发来的 A 的数据包时,它可以看到数据包的“真实”来源 IP 地址(即 A 的 Wi-Fi 出口的公网 IP 地址)。
- 路径学习: B 会认为这个地址是一个潜在的、可以直接联系到 A 的
Path。于是,B 会尝试直接向这个地址发送一个HELLO包。 - 路径创建: 当 A 收到这个直接从 B 发来的
HELLO包时,A 的对等节点 (Peer)对象(为 B 创建的那个)就会意识到:“哇,我发现了一条直达 B 的新路!” 它会立即创建一个新的Path对象来代表这条新发现的物理路由。 - 双向探测: 同样的过程也会在 A 尝试联系 B 时发生。
- 评估与选择: 一旦
Path被创建,Peer对象就会开始定期通过它发送ECHO探测包,以测量其延迟和稳定性。当需要发送真正的业务数据时,Peer的getAppropriatePath()方法就会在所有已知的、活跃的Path中,选择延迟最低、质量最好的那一个。 - 淘汰: 如果一条
Path长时间没有任何数据往来(alive()返回false),Peer对象最终会将其从自己的路径列表中移除,释放资源。
深入代码:Peer 如何管理 Path
一个 Peer 对象内部维护着一个 Path 列表。让我们看看 Peer.hpp 中这个列表是如何定义的:
// 文件: node/Peer.hpp
class Peer
{
// ...
private:
// 一个 PeerPath 结构体数组,用来存储所有已知的路径
struct _PeerPath {
int64_t lr; // 最后收到数据包的时间
SharedPtr<Path> p; // 指向 Path 对象的智能指针
// ...
} _paths[ZT_MAX_PEER_NETWORK_PATHS];
Mutex _paths_m; // 保护路径列表的锁
// ...
};
这个 _paths 数组就是我们比喻中的“联系人名下的电话号码列表”。Peer 对象的所有路径管理和选择逻辑,都是围绕着操作这个数组展开的。
当 Peer::getAppropriatePath() 被调用时,它会执行类似下面的伪代码逻辑:
// Peer::getAppropriatePath() 的概念性伪代码
function getAppropriatePath(现在的时间):
bestPath = null
bestQuality = 无穷差
// 遍历所有已知的路径
for each path_entry in this._paths:
path = path_entry.p
if (path is not null) and (path.alive(现在的时间)):
// 计算当前路径的质量(延迟越低,质量越好)
currentQuality = path.quality(现在的时间)
if (currentQuality < bestQuality):
bestQuality = currentQuality
bestPath = path
return bestPath // 返回质量最好的那个路径
深入代码:Path::send() 的最终使命
当最佳路径被选定后,数据包最终会通过 Path::send() 方法被发送出去。这个方法的实现非常直接,位于 Path.cpp 中。
// 文件: node/Path.cpp
bool Path::send(const RuntimeEnvironment *RR, void *tPtr, const void *data, unsigned int len, int64_t now)
{
// 调用 Node 提供的底层发包函数
if (RR->node->putPacket(tPtr, _localSocket, _addr, data, len)) {
// 如果发送成功,更新本路径的最后发送时间
_lastOut = now;
return true;
}
return false;
}
send() 方法的工作就是调用节点 (Node)提供的 putPacket 回调函数。这个回调函数是 ZeroTier 核心与操作系统网络功能之间的桥梁。Path::send 告诉它:“请从 _localSocket 这个出口,把 data 发送到 _addr 这个目的地。”
至此,一个虚拟网络中的数据包,经过层层封装、路由和决策,终于踏上了它在物理互联网上的真实旅程。
总结与回顾
在本章中,我们抵达了 ZeroTier 核心概念之旅的终点站:物理路径 (Path)。
Path代表了连接两个节点的具体物理路由,即一个 IP 地址和端口的组合。它就像一个联系人的特定电话号码。- 一个对等节点 (Peer)可以拥有多个
Path,ZeroTier 会动态地发现、评估和选择其中最优的一条进行通信。 - 这种对物理路径的智能管理,是 ZeroTier 实现高性能、低延迟和强大网络适应性的关键。