主页 > 以太坊钱包imtoken安装 > 以太坊源码分析——p2p节点发现与协议运行
以太坊源码分析——p2p节点发现与协议运行
摘要:前言负责以太坊底层节点之间的通信,主要包括底层节点的发现和上层协议的运行。 启动一个定时器,定时随机选择一个,向末端节点发送消息。 如果对方有响应,则检测成功。
前言
P2p(peer to peer)负责以太坊底层节点之间的通信,主要包括底层节点的发现和上层协议的运行。
节点发现
节点发现功能主要涉及Server Table udp等几个数据结构以太坊轻节点,它们有自己的事件响应周期,节点发现功能是通过它们的配合完成的。 其中,每个以太坊客户端启动后,会在本地运行一个Server,将网络拓扑中相邻的节点视为Nodes,而Table是Nodes的容器,udp负责维护底层连接。这些结构如下
服务器
p2p/server.go type Server struct { PrivateKey *ecdsa.PrivateKey Protocols []protocol StaticNodes[] *discover.Node newTransport func(net.Conn) transport ntab disvocerTable ourHandshake *protoHandshake addpeer chan *conn ...... }
PrivateKey - 本节点的私钥,用于与其他节点建立时的握手协商
Protocols - 所有支持的上层协议
StaticNodes - 默认的静态Peer,当节点启动时,会先向它们发起连接并建立邻居关系
newTransport——下层传输层的实现,定义了握手过程中的数据加解密方式。 默认的传输层实现是用newRLPX()创建的rlpx,这不是本文的重点
ntab——典型的实现是Table,所有的peer都以Node的形式存储在Table中
ourHandshake - 与其他节点建立连接时的握手信息,包括本节点的版本号和支持的上层协议
addpeer - 连接握手完成后,连接进程通过该通道通知Server
服务器。 监听循环()
服务器的监听循环启动底层监听套接字。 当收到连接请求时,它会在 Accept 之后调用 setupConn() 以启动连接建立过程。
服务器。 跑步()
服务器的主要事件处理和功能实现循环
执行主动节点发现,详见后面的节点发现部分
握手后通道接收已完成第一阶段的连接。 这些连接的身份已经确认,但仍然需要验证
addpeer通道接收到已经完成第二阶段的连接,这些连接已经被验证,调用runPeer()在本节点和Peer的连接上运行协议
节点
Node唯一代表网络上的一个节点
p2p/discover/node.go type Node struct { IP net.IP UDP, TCP uint16 ID NodeID sha common.Hash }
IP——IP地址
UDP/TCP - 连接使用的UDP/TCP端口号
ID——在以太坊网络中唯一标识一个节点,本质上是一个椭圆曲线公钥(PublicKey),对应Server的PrivateKey。 一个节点的IP地址不一定是固定的,但ID是唯一的。
sha - 用于节点之间的距离计算
桌子
Table主要用于管理本节点与其他节点之间连接的建立、更新和删除
p2p/discover/table.go type Table struct { bucket [nBuckets]* bucket refreshReq chan chan struct{} ...... }
bucket - 所有的peer根据距离节点的远近被放入不同的bucket中,详见后续节点维护
refreshReq - 更新表请求通道
桌子。 环形()
Table的主事件循环主要负责控制刷新和重新验证过程。
refresh.C - 计时器(30 秒)以启动对等刷新过程
refreshReq - 接收其他线程下发给Table的Peer连接刷新通知,收到通知后开始更新。 具体见后面的更新邻居关系
revalidate.C - 重新检查已连接节点有效性的计时器,稍后查看活性检测
UDP
UDP负责节点间通信的底层消息控制,是Table运行的Kademlia协议的底层组件
type udp struct { conn conn addpending chan *pending gotreply chan reply *Table }
conn - 连接的底层监听端口
addpending - udp 用于接收挂起的通道。 使用场景是:当我们向其他节点发送数据包(packet)时,我们可能期望收到它的回复,pending用于记录这样一个尚未到达的回复。 比如我们发送一个ping包的时候,总是期望对方回复一个pong包。 这时候可以构造一个pending结构体以太坊轻节点,里面包含了期望的pong包的信息和对应的回调函数,将这个pending投递到udp的channel中。 UDP收到匹配的pong后执行预设的回调。
gotreply - 用于接收其他节点回复的UDP通道,配合上面的addpending,收到回复后,遍历现有的pending链表,看是否有匹配的pending。
表 - 与服务器中的 ntab 相同的表
UDP。 环形()
udp的处理周期,负责控制消息的向上提交和收发控制
addpending 接收其他线程投递的挂起需求
Gotreply 收到 udp.readLoop() 传递的待处理回复
UDP。 读循环()
udp底层接受包周期,负责接收其他节点的包
接受并解析其他节点发送的数据包,如果是回复数据包,则将其传递给udp.loop()
节点维护
以太坊使用 Kademlia 分布式路由存储协议进行网络拓扑维护。 理解这个协议,推荐阅读《易懂分布式》。 更多权威信息可以查看wiki。 总的来说,协议:
使用UDP进行节点间消息通信,有4种消息
ping - 用于检测其他节点的存在
store - 接收方收到消息后,将消息中的key/value对存储在本节点上
findnode - 接收方将它知道的最接近目标节点的 k 个节点返回给发送方
findvalue - 类似于findnode,不同的是如果接收方有一个对应目标节点的本地值,那么它会将这个值回复给发送方。
每个节点根据与邻居节点的距离(NodeID gap)被放入不同的桶中。
本文说的距离,均是指两个节点NodeID的距离,计算方式可见p2p/discover/node.go的logdist()方法
源码中,所有的bucket都是通过Table结构保存的,bucket结构如下
p2p/discover/table.go type bucket struct { entries []*Node replacemenets []*Node ips netutil.DistinctNetSet }
通过bond的节点存储在entries数组中,顺序是bond越新,越早通过再验证测试(Revalidate)的节点。
候选节点存储在 replacemenets 数组中。 如果条目数组已满,则后续节点将添加到数组中
节点可以在条目和替换之间转换。 如果条目节点的验证失败,它将被替换为最初位于替换数组中的节点。
活体检测(Revalidate)
有效性检测是利用ping报文进行检测操作。 Table.loop() 启动一个定时器(0~10s),定时随机选择一个bucket,并向其条目结束的节点发送ping消息。 如果对方回应pong,则检测成功。
举个栗子,假设某个bucket, entries最多保存2个节点,replacements最多保存4个节点。初始情况下entries=[A, B], replacements = [C, D, E],如果此时节点F加入网络,bond通过,由于entries已满,只能加入到replacements = [C, D, E, F]。 此时Revalidate定时器到期,则会对 B进行检测,如果通过,则entries=[B, A],如果不通过,则将随机选择replacements中的一项(假设为D)替换B的位置,最终entries=[A, D],replacements = [C, E, F]
更新邻居关系
table.loop()会周期性地(定时器超时)或不定期地(接收refreshReq)更新邻居关系(发现新的邻居),两者都会调用doRefresh()方法,这对于寻找距离自身和最近的节点很有用到三个随机节点。
节点查找
Table的lookup()方法用于实现节点查找目标节点。 它的实现是Kademlia协议,通过节点间的中继逐步接近目标。
邻居初始化
当一个节点启动时,它会首先发起一个到配置的静态节点的连接。 发起连接的过程称为拨号。 源代码通过创建一个 dialTask 来跟踪这个过程
拨号任务
dialTask 表示主动发起与其他节点的连接的任务
p2p/dial.go type dialTask struct { flags connFlag dest *discover.Node ...... }
Server启动时,会调用newDialState()根据预先配置的StaticNodes初始化一批dialTasks,并在Server.run()方法中启动这些任务。
Dial 进程需要知道目标节点 (dest) 的 IP 地址。 如果您不知道,则必须先使用 resolve() 来解析目标 IP 地址。 如何解决? 就是先使用Kademlia协议在网络中寻找目标节点。
得到目标节点的IP后,接下来就是建立连接,也就是通过dialTask.dial()建立连接
连接已建立
连接建立的握手过程分为两个阶段,在SetupConn()中实现
第一阶段是ECDH密钥建立:
sequenceDiagram Note left of Dialer: Calc token Note left of Dialer: Generate Random PrikeyNonce Note left of Dialer: Sign Dialer->>Receiver: AuthMsg Note right of Receiver: Calc token Note right of Receiver: Check Signature Note right of Receiver: Generate Random PrikeyNonce Receiver->>Dialer: AuthResp
第二阶段是协议握手,交换支持的上层协议
sequenceDiagram Dialer->>Receiver: protoHandshake Receiver->>Dialer: protoHandshake
如果两次握手都通过,dialTask会发送peer信息到Server的addpeer通道
sequenceDiagram participant Server.run() participant dialTask participant Remote Node dialTask->>Remote Node:EncHandshake Remote Node->>dialTask:EncHandshake dialTask->>Server.run(): posthandshake dialTask->>Remote Node:ProtoHandshake Remote Node->>dialTask:ProtoHandshake dialTask->>Server.run(): addpeer Note over Server.run(): go runPeer()
协议操作
协议操作不仅仅是指特定的协议。 准确的说应该是几个独立的协议同时运行在两个节点之间。 p2p节点发现中提到,节点之间建立连接时,会进行两次握手,在第二次握手中,节点之间会交换各自支持的协议。 最后,两个节点之间生效的协议是两个节点支持的协议的交集。
函数主要涉及Peer protoRW的数据结构,关系如图
同行
rw - 节点间连接的底层信息,例如使用的socket,对端节点支持的协议(能力)
running - 在节点之间有效运行的协议集群
Peer.run() 负责在连接建立后启动并运行上层协议。 它在具有自己的事件处理循环的独立 go 例程中运行。 此外,它还会额外创建2+n个go routine,其中2包括一个用于保活的pingLoop() go routine和一个用于接收协议数据的go routine,n为n个协议运行的go routine它,也就是说,每个协议调用自己的 Run( ) 方法在自己的 go routine 中运行
原RW
Run 每个协议自己的running入口以一个新的go routine的形式启动。
总结
P2p主要由底层节点发现和上层协议运行两部分组成。 节点发现负责管理以太坊网络中节点间连接的建立、更新和删除。 服务器是 p2p 功能的入口点。 表负责记录对端节点信息。 UDP负责底层。 沟通。 在底层基础上,多个独立的协议可以在节点之间运行。
以太坊使用 Kademlia 分布式路由存储协议进行网络拓扑维护,将不同距离的对等节点放置在不同的桶中。