为什么要进行节点发现呢?
因为要加入一个p2p网络,并且与网络中的节点交互,需要知道这个p2p网络中的一些节点信息。节点发现,使本地节点得知其他节点的信息,进而加入到p2p网络中。节点发现就是一个寻找邻居节点的过程。
这里有一点跟去中心化违背的地方,就是节点第一次启动的时候,节点会与硬编码在以太坊源码中的bootnode
进行连接,这个bootnode
有一种中心化服务器的感觉,因为所有的节点加入几乎都先连接了它。然而,只有一个可以通信的节点,明显是不足够的。连接上bootnode
后,获取bootnode
部分的邻居节点,然后进行节点发现,获取更多的活跃的邻居节点。
以太坊的节点发现
Kademlia - wiki
kademlia
以太坊的节点发现基于类似的kademlia算法,源码中有两个版本,v4和v5。v4适用于全节点,通过discover.ListenUDP
使用,v5适用于轻节点通过discv5.ListenUDP
使用,这里主要介绍v4版本,较为简单的版本。
接下来是以太坊节点发现v4的源码分析部分,分为udp发现和k桶刷新。
0.索引
01.p2p服务开启节点发现
02.udp节点发现
03.k桶刷新
1.p2p服务开启节点发现
在p2p.server.go
中的Start
方法中:
if !srv.NoDiscovery {
cfg := discover.Config{
PrivateKey: srv.PrivateKey,
AnnounceAddr: realaddr,
NodeDBPath: srv.NodeDatabase,
NetRestrict: srv.NetRestrict,
Bootnodes: srv.BootstrapNodes,
Unhandled: unhandled,
}
ntab, err := discover.ListenUDP(conn, cfg)
if err != nil {
return err
}
srv.ntab = ntab
}
- 其中
discover.ListenUDP
方法即开启了节点发现的功能。discover.ListenUDP
方法创建了一个新的udp
对象(在discover/udp.go
中),用于节点发现,和Table
对象(在discover/table.go
中),用于维护k桶。func ListenUDP(c conn, cfg Config) (*Table, error) { tab, _, err := newUDP(c, cfg) ... return tab, nil }
2.udp节点发现
新建一个udp对象
首先要从newUDP
方法看起,newUDP
方法如下:
func newUDP(c conn, cfg Config) (*Table, *udp, error)
udp
源码中三个主要的过程:
- 1.
newTable
方法新建Table
对象,并且开启了k桶刷新(即更新路由表)的功能。这部分在下面的内容再做介绍。 - 2.
go udp.loop()
协程,循环的监听4个通道。-
case <-t.closing
,检测是否停止。 -
case p := <-t.addpending
,检测是否有添加新的待处理消息。 -
case r := <-t.gotreply
,检测是否接收到其他节点的回复消息。 -
case now := <-timeout.C
,检测是否延时。
-
- 3.
go udp.readLoop(cfg.Unhandled)
协程。- 循环接收其他节点发来的udp消息。
nbytes, from, err := t.conn.ReadFromUDP(buf)
- 处理接收到的udp消息。
t.handlePacket(from, buf[:nbytes])
- 循环接收其他节点发来的udp消息。
udp消息有4种:
-
ping
,用于判断远程节点是否在线。 -
pong
,用于回复ping
消息的响应。 -
findnode
,查找与给定的目标节点相近的节点。 -
neighbors
,用于回复findnode
的响应,与给定的目标节点相近的节点列表。
节点发现的过程
(假设节点A要进行节点发现,向节点B进行查询)
- 1.节点A向节点B发送
ping
消息,判断节点B是否在线。节点A加入与该ping
消息对应的pending
。(这里使用了t.addpending
通道。)
对应的方法:func (t *udp) ping(toid enode.ID, toaddr *net.UDPAddr) error
- 2.节点A收到节点B发来的
pong
消息,确认节点B在线。将pong
与pending
进行匹配。(这里使用了t.gotreply
通道。)
对应的方法:func (req *pong) handle(t *udp, from *net.UDPAddr, fromKey encPubkey, mac []byte) error
- 3.节点A向节点B发送
findnode
消息,想要获取与目标节点相近的节点。发送findnode
消息时,会先检测上次收到节点B的pong
消息是否超过24小时,超过则发送ping
消息,接收到pong
消息后再发送findnode
消息。同时也记录一个与findnode
消息对应的pending
。(这里使用了t.addpending
通道。)
对应的方法:func (t *udp) findnode(toid enode.ID, toaddr *net.UDPAddr, target encPubkey) ([]*node, error)
- 4.节点A收到节点B发来的
neighbors
消息,获取到几个与目标节点相近的节点。将neighbors
与pending
进行匹配。(这里使用了t.gotreply
通道。)
对应的方法:func (req *neighbors) handle(t *udp, from *net.UDPAddr, fromKey encPubkey, mac []byte) error
- 5.节点A向新获取的节点继续进行上述4个步骤,直到查找完成。
其中,节点B启动了loop
和readloop
两个单独的协程来处理节点A发送来的消息。
3.k桶刷新
以太坊的k桶设置:
alpha
为3
nBuckets
,k桶数量为17
bucketSize
,k桶中最多存16个节点
maxReplacements
,每个k桶的候选节点列表最多存10个节点
新建一个Table对象
newTable
方法如下:
func newTable(t transport, self *enode.Node, db *enode.DB, bootnodes []*enode.Node)(*Table, error)
Table
主要介绍三个方法:
- 1.
tab.setFallbackNodes(bootnodes)
方法,设置初始引导节点,验证其完整性,然后加入引导节点列表。
初始引导节点的作用,如开头所说,首次启动并且没有加入到此p2p网络的节点,要加入到网络中,必须知道网络中一些节点的信息。初始引导节点的作用便是如此:引导初始启动的节点加入到p2p网络中。 - 2.
tab.loadSeedNodes()
方法,从保留已知节点的数据库中随机的抽取30个节点,再加上引导节点列表中的节点,放置入k桶中。 - 3.
go tab.loop()
协程,刷新k桶。下面介绍。
k桶刷新的过程
也就是go tab.loop()
协程具体做了什么,如下:
主要介绍三个协程:
- 1.
go tab.doRefresh(refreshDone)
,刷新的协程。
doRefresh
主要的查找逻辑在lookup
里,lookup
会对3个异或距离较近的节点进行查询,查询方法用到的是udp.findnode
。将每一次的查询结果,根据距离范围,加入到k桶中,如果k桶未满。 - 2.
go tab.doRevalidate(revalidateDone)
,重新验证的协程。选取随机的k桶中的最后一个节点,使用udp
的ping
消息,如果ping
通了,将该节点移动在k通中的最前面。如果ping
不通,删除该节点,从replacements
候选节点列表中选取节点加入k桶。 - 3.
go tab.copyLiveNodes()
,节点入数据库的协程。将k桶中的节点存入数据库中,选取节点的条件是节点在k桶中存在5分钟以上。
4.总结
- 1.以太坊的节点发现分为两个部分,基于udp的节点发现和k桶的刷新维护机制。
- 2.基于udp的节点发现使用
ping
消息去确定远程节点是否在线,以及通过findnode
消息去查找到更多的远程节点。其中使用了两个独立的协程,loop
和readloop
,用于对接收到远程节点的udp消息进行处理。 - 3.k桶的刷新维护机制,也就是时时更新节点的路由表,每30分钟进行一次查找新的节点,每10秒进行一次k桶中节点的重新验证,每30秒进行一次k桶节点的入库操作。其中使用了三个独立的协程。
网友评论