主页 > 以太坊钱包imtoken安装 > 以太坊源码分析——p2p节点发现与协议运行

以太坊源码分析——p2p节点发现与协议运行

以太坊钱包imtoken安装 2023-02-16 05:19:07

摘要:前言负责以太坊底层节点之间的通信,主要包括底层节点的发现和上层协议的运行。 启动一个定时器,定时随机选择一个,向末端节点发送消息。 如果对方有响应,则检测成功。

前言

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.gologdist()方法

源码中,所有的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初始化一批dialTask​​s,并在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 分布式路由存储协议进行网络拓扑维护,将不同距离的对等节点放置在不同的桶中。