本文结合ethereumJ源码,分析以太坊的节点发现协议(discv4)实现过程。discv4是以太坊用来发现公链P2P网络中的其它节点,组成K桶网络的协议。
ethereumJ从协议上看
- discv4协议,用于节点发现 ,使用udp协议,实现节点之间高成功率的穿透,来发现节点,建立连接的信道
- rplx协议,节点通信协议,使用tcp协议,为了信息的可靠传输,在已经建好的信道基础上,使用TCP协议传输区块、交易、日志等数据;
discv4涉及的源码包:org.ethereum.net.rlpx.discover
节点发现过程
节点发现流程说明:
- 本机节点开始运行时,生成本机节点nodeID,即localID,同时打开30303端口,监听节点发送协议网络(使用UDP);
- 从配置文件,加载20个引导节点信息,向这些节点循环发送ping报文,在线的引导节点将响应pong报文,将响应的引导节点加入k桶;
- 监听udp网络的同时,启动了两个任务:节点发现任务和节点刷新任务;
- 节点发现任务30秒循环一次,每次循环跑8遍,每遍以localId为目标节点TargetID,从k桶中获取距离TargetID最接近16个节点,循环向16个节点发送FindNode报文(包含TargetID);
- 收到FindNode命令的节点,也以TargetID为目标,从自己的K桶中找出距离最接近的16个节点,回传Neightbour报文;
- 本机节点收到Neightbour后,从报文取出新发现的节点,向新节点循环发送Ping报文,并将响应的节点加入k桶;
- 节点刷新任务和节点发现任务差不多,不一样的地方是,刷新任务的TargetID不是localId,而是随机生成的nodeId。还有刷新任务刷新速度更频繁,7.2秒循环一次;
- 就这样,不断发现和刷新节点,本地节点找到越来越多的邻居节点,组成K桶网络。
K桶
上图是一个由K桶组成的路由表。
K桶是一个存储结构,是由k-buckets(k桶)组成的路由表中,因为节点最大距离是256,所以路由表一共有256个k桶,每个桶中最多放k=16个节点。每个以太坊的节点内部都会有一个路由表,用来存储的它发现的网络节点。
存储规则如下:
本地节点通过节点发现协议寻找网络其它节点,每发现一个节点,都会计算该节点与本地节点之间的距离(0-255),根据这个距离将该节点存储于路由表中对应的K桶位置。
如果发现一个新节点,但是这时路由表的K桶满时,会采取最后发现策略,将对应K桶最后发现的节点取出,通过ping命令探测该节点是否在线,如果没有收到响应,新节点将取代它。
节点距离计算
- 节点Id:每个节点都会有一个nodeId,nodeId是这个节点用ECkey算法生成的密钥对中的公钥,公钥一共65个字节,去掉第一个字节(0x04)的64个字节(512位)就是nodeId。
- keccak256:是以太坊里的hash256摘要算法。
- XOR:即异或计算,两个二进制位进行XOR,不等为1,相等为0。
- 节点之间距离计算
distance(n₁, n₂) = keccak256(n₁) XOR keccak256(n₂)
计算n1和n2两个节点的距离,首先对n1和n2的NodeID(512位)用keccak256算法截取成一个256位的摘要,然后对这个两个摘要(256位)进行异或(XOR)计算,节点距离定义为此XOR值的1位最高位的位数。
例如:
取1的最高位数12,那么这两个节点的距离为14。
因为参与XOR的是256位数字,所有,两个节点的距离是1-256。
注意:这里的节点距离与机器的物理距离无关,这个距离仅仅是逻辑上的一种约定
报文协议
discv4为节点之间互相发现,定义了四种报文协议(命令)
- ping:探测命令,用于探测对方节点是否在线。
- pong :探测应答命令,用于响应ping报文
- findNode:节点查询命令,用于向对方节点请求查找邻居节点
- neighbours:节点查询应答命令,用于响应findNode报文,回传找到的邻居节点列表
ping报文
发送
- 本地节点会向所有新发现的节点发送ping
- ping发送后,假如15s内没有收到pong响应,将自动重发ping,最多发送三次,三次都没有收到响应,则视为ping失败处理
- ping失败处理,节点状态会从discovered变为dead、EvictCandidate变为NonActive
处理
- 接收到ping,则发送pong
pong报文
发送
- 一旦接收到ping,则发送pong
处理
- 一旦接收到对方节点发来的pong,则改变对方节点的状态为Alive或者Active
findNode报文
发送
- 从自己的k桶里面获取最接近目标nodeId的16个节点,并向这些节点发送findNode报文。
处理
- 接收findNode时,根据收到的targetId,从K桶里面查找最接近targetId的节点,把这些节点封装到Neighbours报文回传。
Neighbours报文
发送
- 接收到findNode,则发送Neighbours
处理
- 接收到Neighbours报文,读取报文的节点列表,如果发现是新节点,将加入nodeHandlerMap,节点状态从discovered开始,并发送ping命令。如果节点已经存在,则不处理。
节点生命周期
- discovered:发现状态。引导节点、从持久化文件加载的节点、从邻居节点收到的节点、接收到ping的节点,一开始都处于这个状态
- alive:在线状态,discovered状态的节点pong响应后,将进入这个状态,但是alive状态的节点还没有加入K桶,需要确认K桶是否有空间。
- active:活跃状态,active状态的节点正式处于K桶中。alive状态的节点在K桶有空间时可以转入active状态。
- evictcandidate:候选状态,在K桶满时,active状态的节点如果被新节点PK失败,将进入evictcandidate状态。
- noactive:alive和active状态的节点,在PK失败时会进入noactive状态。
- dead:节点在限定时间内没有返回pong信息,则进入dead状态,这是最终状态。
关键类图
配置类
配置类——SystemProperties
类路径:org.ethereum.config.SystemProperties
主要方法:
- getMyKey(),获取EDKey(本机节点的密钥)
- nodeId(),获取本机节点的nodeId
- externalIp(),获取本机的公网IP
- listenPort(),获取监听端口(默认30303)
- bindIp(),获取绑定本机的IP
处理类
入口类——UDPListener
类路径:org.ethereum.net.rlpx.discover.UDPListener
功能:
- 使用netty框架,打开30303的端口,启动UDP服务节点,监听节点发现协议。
- 启动节点发现执行类(DiscoveryExecutor)
- 加载引导节点,初始化K桶
- 加载节点管理类(NodeManager)
节点管理类——NodeManager
类路径:org.ethereum.net.rlpx.discover.NodeManager
功能:
- 节点的全局管理类,所有节点处理Map(nodeHandlerMap)、节点表(NodeTable)、引导节点(bootNodes)、节点持久化类(PeerSource)都在这里面
- UDPListener监听到的报文(ping、pong、findNode、neighbors)都传到这个类
节点处理类——NodeHandler
类路径:org.ethereum.net.rlpx.discover.NodeHandler
功能:
- 节点的真实处理类
- 记录节点的状态,处理节点状态的改变
- 处理报文(ping、pong、findNode、neighbors)
节点发现执行类——DiscoveryExecutor
类路径:org.ethereum.net.rlpx.discover.DiscoveryExecutor
功能:启动下面两个类
节点发现任务类——DiscoverTask
类路径:org.ethereum.net.rlpx.discover.DiscoverTask
功能:
使用findNode命令来发现邻居节点,30s执行一次,每次循环8遍,每遍都获取k桶最接近localId的16个节点,向这16个节点发送findNode信息(ethereumJ实际用了ALPHA=3个节点)
节点刷新任务类——RefreshTask
类路径:org.ethereum.net.rlpx.discover.RefreshTask
功能:
使用findNode命令刷新K桶,7.2s执行一次,每次循环8遍,和DiscoverTask的区别是,DiscoverTask使用的目标Id是localId,而RefreshTask使用的目标Id是随意Id,获取最接近随机Id的16个节点,向它们发送findNode信息。
节点持久化类——PeerSource
类路径:org.ethereum.db.PeerSource
功能:
- 节点启动时,从peers文件加载初始化节点
- 每隔60秒,把所有处理类节点持久化到peers文件
节点相关类
节点表类——NodeTable
类路径:org.ethereum.net.rlpx.discover.table.NodeTable
功能:
这个类就是一个K桶,里面有一个NodeBucket数组,数组大小正好是256,用来存储所有发现的节点(包括本机节点)。
节点桶类——NodeBucket
类路径:org.ethereum.net.rlpx.discover.table.NodeBucket
功能:
这个类代表K桶的一个桶,NodeBucket里面有一个List<NodeEntry>,最多包含16个NodeEntry。
节点实体类——NodeEntry
类路径:org.ethereum.net.rlpx.discover.table.NodeEntry
功能:这个类代表K桶里面的一个节点
属性:
- ownerId:本地节点的nodeId
- node:一个节点对象(包含id,ip,port)
- entryId:节点字符串
- distance:代表与本机节点的节点距离(值为0-256)
- modified:更新时间戳,用于getLastSeen()策略,在K桶满时,modified时间最大的节点将被取出。
节点类——Node
类路径:org.ethereum.net.rlpx.Node
功能:Node是比NodeEntry更纯粹意义的节点,NodeEntry代表K桶中的一个节点,而Node仅仅代表一个节点。
属性:
- nodeId:节点id
- id:节点ip
- port:端口号(默认30303)
- isFakeNodeId:是否虚拟nodeId,当节点没有nodeId时,会生成一个随机nodeId作为虚拟节点Id,这时isFakeNodeId值为true,当节点存在真实nodeId时,这个值为false。
报文类
discv4为四种报文协议定义了统一的报文格式:
packet = packet-header || packet-data
packet-header = mdc || signature || type
packet-data = data
对应的报文类在Message中定义。
报文基类——Message
类路径:org.ethereum.net.rlpx.Message
功能:定义基本的报文属性、编码、解码方法
属性:
- wire-实际发送的报文就是这个字段
wire = mdc || signature || type || data
- mdc
mdc = keccak256(signature || type || data)
- signature
signature = ECDSASignature(keccak256(payload))
payload = type || data
- data,有四种data(ping、pong、findnode、neighbors)
//PingMessage
data = version || from || to || expiration
//PongMessage
data = to || ping-mac || expiration
//FindNodeMessage
data = target-nodeId || expiration
//NeighborsMessage
data = nodes || expiration
nodes = node || node || node ...
node = host || udpport || tcpport || nodeId
- type
//PingMessage
type = 1
//PongMessage
type = 2
//FindNodeMessage
type = 3
//NeighborsMessage
type = 4
ping报文类——PingMessage
类路径:org.ethereum.net.rlpx.PingMessage
pong报文类——PongMessage
类路径:org.ethereum.net.rlpx.PongMessage
findNode报文类——FindNodeMessage
类路径:org.ethereum.net.rlpx.FindNodeMessage
neighbors报文类——NeighborsMessage
类路径:org.ethereum.net.rlpx.NeighborsMessage
报文疑问
- 所有发送的报文都包含签名,本来认为接收节点收到报文后,会验证签名,但是并不是这样,首先发送报文的节点没有发送节点的公钥(即nodeId),发送节点没有发送公钥(nodeId),那接收节点如何得到呢。这个过程正好是颠倒过来的,接收节点从签名和签名原文反推出公钥,从而得到nodeId,组装Node对象。
- 报文里面有expiration字段,值为报文发送时间90分钟后的时间戳,这个字段应该是用来做信息过期处理的,但是ethereumJ里面看不到任何相关的处理代码。
- 报文里的mac字段,设置应该是用来作为ping-pong或者findNode-Neighbors匹对的。比如pong报文发送的data里面包含了ping报文的mac,但是很奇怪,在代码实现里,收到pong的节点并没有去校验这个mac值是否和自己发送的ping匹配。