Python从头实现以太坊系列索引:
一、Ping
二、Pinging引导节点
三、解码引导节点的响应
四、查找邻居节点
五、类-Kademlia协议
六、Routing
前几节讲到以太坊节点发现使用的是修改过的 Kademlia 会话协议。它的传输协议使用的是 UDP,UDP 跟 TCP 相比,因为不需要顺序发送和消息确认,所以可以做到低延迟,无阻塞,适合一些广播的场景,比如 DNS 或直播等。甚至 HTTP 未来也可能基于 UDP,HTTP-over-QUIC(QUIC 就是基于 UDP) 已经被 IETF 正式命名为 HTTP/3,UDP 的优点正在被越来越多的人发掘,未来可期。前面几节我们还讲到它的四种会话消息结构,分别是 Ping,Pong,FindNeighbor 和 Neighbors,这些消息在发送之前用 RLP(递归长度前缀)编码并用 ECDSA(椭圆曲线数字签名算法)进行签名,哈希算法使用的是 keccak256(即 sha3),这些代码也都实现了,现在还差路由部分,这就是我们这节课的主题。这节讲完,我们以太坊节点发现协议就完成了。
在开始之前,你可以到 https://github.com/HuangFJ/pyeth 查阅代码,或克隆到本地:
$ git clone https://github.com/HuangFJ/pyeth
$ cd pyeth
$ git checkout partfive
源代码文件包含:
├── app.py
├── priv_key
├── pyeth
│ ├── __init__.py
│ ├── constants.py
│ ├── crypto.py
│ ├── discovery.py
│ ├── packets.py
│ └── table.py
├── requirements.txt
跟上一次的代码版本(使用 git checkout fartfour
查看)比较:
├── priv_key
├── pyeth
│ ├── __init__.py
│ ├── crypto.py
│ └── discovery.py
├── requirements.txt
├── send_ping.py
可以看到我已经将 send_ping.py
改名为 app.py
,因为它的作用不再只是收发一次消息的代码而是一个完整应用程序入口了。在 pyeth/
目录中新增了 constants.py
、packets.py
和 table.py
三个源文件。constants.py
是我们协议使用的一些常量,另外我们将原来 discovery.py
的 Ping,Pong,FindNeighbor 和 Neighbors 四种消息结构移到了 packets.py
里,最后在 table.py
实现路由表结构。
代码变化还是蛮大的,而且相对于前面的几节,比较干,考验大家对 Python 这门语言是否熟练以及编程能力,至少会涉及以下一些知识:
- gevent 协程
- Actor 并发模型
- 嵌套函数
- 消息队列
- 异步/回调
请求和响应
如果是 TCP 的话,客户端要先 connect
到服务端,服务端要 accept
之后才建立到客户端的连接,之后双方通过这个连接建立会话。但是 UDP 是没有连接状态的,收发消息全部通过一个 socket,而且是异步的,为了建立会话必须确定消息来源并将 request 和 response 消息对应起来,这样在发送 request 消息之后,方可知道对端响应的确切 response。
因此,我用了 Actor 并发模型来实现这样逻辑。你可以把 Actor 理解为一个具体对象,这个对象跟外界是隔离的,它与外界联系的唯一途径就是通过消息,它内部有一个消息队列,一旦接收到外部信号,就会并发地执行过程并储存状态。
在 discovery.py
有一个 Pending
类,它相当于一个 Actor:
class Pending(Greenlet):
def __init__(self, node, packet_type, callback, timeout=K_REQUEST_TIMEOUT):
Greenlet.__init__(self)
self._node = node
self._packet_type = packet_type
self._callback = callback
self._timeout = timeout
self._box = Queue()
@property
def is_alive(self):
return self._box is not None
@property
def from_id(self):
return self._node.node_id
@property
def packet_type(self):
return self._packet_type
@property
def ep(self):
return self._node.endpoint.address.exploded, self._node.endpoint.udpPort
def emit(self, packet):
self._box.put(packet)
def _run(self):
chunks = []
while self._box is not None:
try:
packet = self._box.get(timeout=self._timeout)
chunks.append(packet)
except Empty:
# timeout
self._box = None
return None
except:
# die
self._box = None
raise
try:
if self._callback(chunks):
# job done
self._box = None
return chunks
except:
# die
self._box = None
raise
Pending
类继承自 gevent.Greenlet
协程类,有五个字段,_node
是响应节点,_packet_type
是响应包类型,_callback
是回调函数,_timeout
是超时时间,_box
是消息队列。它通过 emit
方法获取外部信号,并发执行 _run
方法内的过程。
我们将在发送 Ping
和 FindNeighbors
请求的时候用到这个类。因为发送 Ping
和 FindNeighbors
请求后,需要在 _timeout
时间内等待对端返回 Pong
和 Neighbors
响应并执行后续过程;但是如果超过这个时间没有回应,我们认为请求超时无效。所以在请求的地方,我们用 Pending(node, self, node, packet_type, callback).start()
异步启动了一个Actor,当 UDP Socket 接收到相应的消息的之后,我们就用 pending.emit(response)
把消息传给它处理,以响应之前的请求。
消息处理完之后 Actor 是结束退出还是继续等待是由回调函数 _callback
的返回值决定的,这个函数在请求的时候定义,如果返回 True
表示这次请求成功,Actor 可以结束退出了;如果返回 False
说明还得继续等待响应。之所以这样做是因为 Neighbors
消息大小有可能超过协议规定的最大包的大小限制,而必须拆成多个消息返回。在发送一个 FindNeighbors
请求之后可能会有得到多个 Neighbors
消息做为回应,我们必须在请求建立的时候对这个流程加以控制。
节点 Key 和 ID
Node
节点类在 packets.py
文件里面:
class Node(object):
def __init__(self, endpoint, node_key):
self.endpoint = endpoint
self.node_key = None
self.node_id = None
self.added_time = Node
self.set_pubkey(node_key)
def set_pubkey(self, pubkey):
self.node_key = pubkey
self.node_id = keccak256(self.node_key)
主要变化是将原来的 node
字段名称改成了 node_key
表示节点公钥,从变量名就可以认出来。新增了 node_id
表示节点 ID,它由 node_key
进行 keccak256
哈希运算得来,它是一个 256 bits 的大整数。node_key
和 node_id
都是 raw bytes 的形式。节点 ID 作为节点的指纹,一个地方用在计算节点的相近度,另一个地方用在消息请求与响应的来源节点的对应关系上。
服务器
discovery.py
的服务器类 Server
做了很大的变动:
class Server(object):
def __init__(self, boot_nodes):
# hold all of pending
self.pending_hold = []
# last pong received time of the special node id
self.last_pong_received = {}
# last ping received time of the special node id
self.last_ping_received = {}
# routing table
self.table = RoutingTable(Node(self.endpoint, pubkey_format(self.priv_key.pubkey)[1:]), self)
...
def add_table(self, node):
self.table.add_node(node)
def add_pending(self, pending):
pending.start()
self.pending_hold.append(pending)
return pending
def run(self):
gevent.spawn(self.clean_pending)
gevent.spawn(self.listen)
# wait forever
evt = Event()
evt.wait()
def clean_pending(self):
while True:
for pending in list(self.pending_hold):
if not pending.is_alive:
self.pending_hold.remove(pending)
time.sleep(K_REQUEST_TIMEOUT)
def listen(self):
LOGGER.info("{:5} listening...".format(''))
while True:
ready = select([self.sock], [], [], 1.0)
if ready[0]:
data, addr = self.sock.recvfrom(2048)
# non-block data reading
gevent.spawn(self.receive, data, addr)
def receive(self, data, addr):...
它新增了几个字段,pending_hold
是用来存储请求时建立的 Pending
对象的列表,当服务器接收到消息后会从这个列表里过滤相应的 Pending
对象。last_pong_received
记录每个对端节点最后发来的 pong 消息的时间。last_ping_received
记录每个对端节点最后发来的 ping 消息的时间。table
就是路由表 RoutingTable
对象。
原来的 listen_thread
方法改成了 run
,作为服务器的启动入口,它创建并执行 self.clean_pending
和 self.listen
协程,然后让主进程陷入等待。self.clean_pending
定时将已经结束或超时的 Pending
对象从 pending_hold
列表里面清除掉。self.listen
的变动是将消息的接收后处理用 gevent.spawn
改成了并行。此外还新增了 add_table
和 add_pending
两个方法,前者是在接收到 Neighbors
消息的时候将返回的节点添加到路由表;后者是将请求后创建的 Pending
对象添加到 pending_hold
列表。
服务器的四个消息接收处理方法已经全部实现,他们会执行一个共同的过程 handle_reply
,这个方法就是用于从 pending_hold
里过滤查找相应的 Pending
对象的,并把响应的消息传给它让原来的请求逻辑继续执行。这里特别需要强调的一个地方是打 LOGGER.warning
的那个地方,它反映一个问题,从 Neighbors
消息里面提取的一些节点,其自带的 key 和同一个端点(IP 地址和端口一样)返回的消息签名用的真实的 key 不一样,这点一直让我百思不得其解。
def handle_reply(self, addr, pubkey, packet_type, packet, match_callback=None):
remote_id = keccak256(pubkey)
is_match = False
for pending in self.pending_hold:
if pending.is_alive and packet_type == pending.packet_type:
if remote_id == pending.from_id:
is_match = True
pending.emit(packet)
match_callback and match_callback()
elif pending.ep is not None and pending.ep == addr:
LOGGER.warning('{:5} {}@{}:{} mismatch request {}'.format(
'',
binascii.hexlify(remote_id)[:8],
addr[0],
addr[1],
binascii.hexlify(pending.from_id)[:8]
))
接收 Pong 响应
def receive_pong(self, addr, pubkey, pong):
remote_id = keccak256(pubkey)
# response to ping
last_pong_received = self.last_pong_received
def match_callback():
# solicited reply
last_pong_received[remote_id] = time.time()
self.handle_reply(addr, pubkey, Pong.packet_type, pong, match_callback)
接收到 Pong
消息的时候,如果这个 Pong
有效(确实是我方节点请求的,且没有超时),我们会更新对端节点最后 Pong
响应时间。因为 Python 只有 lambda 表达式,没有匿名函数的概念,我们只能在这个函数里面定义一个嵌套函数 match_callback
当成回调对象传递。
接收 Ping 请求
def receive_ping(self, addr, pubkey, ping, msg_hash):
remote_id = keccak256(pubkey)
endpoint_to = EndPoint(addr[0], ping.endpoint_from.udpPort, ping.endpoint_from.tcpPort)
pong = Pong(endpoint_to, msg_hash, time.time() + K_EXPIRATION)
node_to = Node(pong.to, pubkey)
# sending Pong response
self.send_sock(pong, node_to)
self.handle_reply(addr, pubkey, PingNode.packet_type, ping)
node = Node(endpoint_to, pubkey)
if time.time() - self.last_pong_received.get(remote_id, 0) > K_BOND_EXPIRATION:
self.ping(node, lambda: self.add_table(node))
else:
self.add_table(node)
self.last_ping_received[remote_id] = time.time()
接收到 Ping
消息的时候,除了回应 Pong
消息之外,还会判断对端节点最后一次响应 Pong
回来的时间是否在 K_BOND_EXPIRATION
时间之前,是的话说明它要么不在我方节点的路由表里面要么它无法连接了,此时我们需要重新发送 Ping
跟它握手,如果 Ping
通的话,将它更新到我方节点的路由表;否则直接将它更新到我方节点的路由表。最后更新对端节点最后的响应 Ping
的时间。
接收 FindNeighbors 请求
def receive_find_neighbors(self, addr, pubkey, fn):
remote_id = keccak256(pubkey)
if time.time() - self.last_pong_received.get(remote_id, 0) > K_BOND_EXPIRATION:
# lost origin or origin is off
return
target_id = keccak256(fn.target)
closest = self.table.closest(target_id, BUCKET_SIZE)
# sent neighbours in chunks
ns = Neighbors([], time.time() + K_EXPIRATION)
sent = False
node_to = Node(EndPoint(addr[0], addr[1], addr[1]), pubkey)
for c in closest:
ns.nodes.append(c)
if len(ns.nodes) == K_MAX_NEIGHBORS:
self.send_sock(ns, node_to)
ns.nodes = []
sent = True
if len(ns.nodes) > 0 or not sent:
self.send_sock(ns, node_to)
接收 FindNeighbors
消息的时候,先判断对端节点最后 Pong
响应的时间是否在 K_BOND_EXPIRATION
之前,是的话直接丢弃不理,因为避免攻击,我方不能接受不在我方的节点路由表内的节点请求。否则调用 self.table.closest(target_id, BUCKET_SIZE)
方法获取和 target_id
相近的 BUCKET_SIZE
个节点,分批返回给请求节点。
接收 Neighbors 响应
def receive_neighbors(self, addr, pubkey, neighbours):
# response to find neighbours
self.handle_reply(addr, pubkey, Neighbors.packet_type, neighbours)
接收 Neighbors
消息的方法比较简单,因为主要的逻辑在请求的方法 find_neighbors
里面。
Ping 请求
def ping(self, node, callback=None):
ping = PingNode(self.endpoint, node.endpoint, time.time() + K_EXPIRATION)
message = self.wrap_packet(ping)
msg_hash = message[:32]
def reply_call(chunks):
if chunks.pop().echo == msg_hash:
if callback is not None:
callback()
return True
ep = (node.endpoint.address.exploded, node.endpoint.udpPort)
self.sock.sendto(message, ep)
return self.add_pending(Pending(node, Pong.packet_type, reply_call))
ping
方法是异步的。它创建了 PingNode
的消息包并发送出去之后,创建了一个 Pending
,把回调函数 reply_call
传给它,异步等待响应。
FindNeighbors 请求
def find_neighbors(self, node, target_key):
node_id = node.node_id
if time.time() - self.last_ping_received.get(node_id, 0) > K_BOND_EXPIRATION:
# send a ping and wait for a pong
self.ping(node).join()
# wait for a ping
self.add_pending(Pending(node, PingNode.packet_type, lambda _: True)).join()
fn = FindNeighbors(target_key, time.time() + K_EXPIRATION)
def reply_call(chunks):
num_received = 0
for neighbors in chunks:
num_received += len(neighbors.nodes)
if num_received >= BUCKET_SIZE:
return True
self.send_sock(fn, node)
ep = (node.endpoint.address.exploded, node.endpoint.udpPort)
# block to wait for neighbours
ret = self.add_pending(Pending(node, Neighbors.packet_type, reply_call, timeout=3)).get()
if ret:
neighbor_nodes = []
for chunk in ret:
for n in chunk.nodes:
neighbor_nodes.append(n)
return neighbor_nodes
而 find_neighbors
方法是同步的。它首先要做端点证明(Endpoint Proof)避免流量放大攻击,判断对端节点最后一次 Ping
请求的时间是否在 K_BOND_EXPIRATION
之前,是的话说明双方节点可能已经互相不在对方的节点路由表里面了,必须重新建立 ping-pong-ping
握手,避免消息被双方互相丢弃。这里 self.ping(node).join()
可以看到,发送 Ping
请求之后用 join()
阻塞等待这个 Pending
Greenlet 协程的结束。等待对端节点发来 Ping
请求的过程也是一样。发送 FindNeighbors
消息之后等待 Neighbors
响应的过程也是同步,这里用了 Greenlet 协程的 get()
阻塞等待协程结束并返回结果——和 target_key
相邻的节点。回调函数 reply_call
控制此次的 FindNeighbors
请求何时可以结束,这里是收集到 BUCKET_SIZE
个节点后结束。
路由表
在 table.py
文件里面定义了两个类——RoutingTable
和 Bucket
,RoutingTable
是路由表,Bucket
是存储节点的 k-桶:
class Bucket(object):
def __init__(self):
self.nodes = []
self.replace_cache = []
class RoutingTable(object):
def __init__(self, self_node, server):
self.buckets = [Bucket() for _ in range(BUCKET_NUMBER)]
self.self_node = self_node
self.server = server
# add seed nodes
for bn in self.server.boot_nodes:
self.add_node(bn)
gevent.spawn(self.re_validate)
gevent.spawn(self.refresh)
def lookup(self, target_key):
target_id = keccak256(target_key)
closest = []
while not closest:
closest = self.closest(target_id, BUCKET_SIZE)
if not closest:
# add seed nodes
for bn in self.server.boot_nodes:
self.add_node(bn)
asked = [self.self_node.node_id]
pending_queries = 0
reply_queue = Queue()
while True:
for n in closest:
if pending_queries >= KAD_ALPHA:
break
if n.node_id not in asked:
asked.append(n.node_id)
pending_queries += 1
gevent.spawn(self.find_neighbours, n, target_key, reply_queue)
if pending_queries == 0:
break
ns = reply_queue.get()
pending_queries -= 1
if ns:
for node in ns:
farther = find_farther_to_target_than(closest, target_id, node)
if farther:
closest.remove(farther)
if len(closest) < BUCKET_SIZE:
closest.append(node)
def refresh(self):
assert self.server.boot_nodes, "no boot nodes"
while True:
# self lookup to discover neighbours
self.lookup(self.self_node.node_key)
for i in range(3):
random_int = random.randint(0, K_MAX_KEY_VALUE)
node_key = int_to_big_endian(random_int).rjust(K_PUBKEY_SIZE / 8, b'\x00')
self.lookup(node_key)
time.sleep(REFRESH_INTERVAL)
def re_validate(self):
while True:
time.sleep(RE_VALIDATE_INTERVAL)
# the last node in a random, non-empty bucket
bi = 0
last = None
idx_arr = [i for i in range(len(self.buckets))]
random.shuffle(idx_arr)
for bi in idx_arr:
bucket = self.buckets[bi]
if len(bucket.nodes) > 0:
last = bucket.nodes.pop()
break
if last is not None:
LOGGER.debug('{:5} revalidate {}'.format('', last))
# wait for a pong
ret = self.server.ping(last).get()
bucket = self.buckets[bi]
if ret:
# bump node
bucket.nodes.insert(0, last)
else:
# pick a replacement
if len(bucket.replace_cache) > 0:
r = bucket.replace_cache.pop(random.randint(0, len(bucket.replace_cache) - 1))
if r:
bucket.nodes.append(r)
def add_node(self, node):...
def get_bucket(self, node):...
def closest(self, target_id, num):...
def find_neighbours(self, node, target_key, reply_queue):...
从路由表的构造函数可以看出,它一开始就创建了 BUCKET_NUMBER
个的 k-桶,接着把启动节点加进去,然后启动 re_validate
和 refresh
协程。re_validate
做的是持续随机挑选 k-桶,从 k-桶里挑出最少交互的节点重新 Ping
,查看节点是否在线。在线则将它重新更新到路由表,否则从 k-桶的 replace_cache
里面挑出一个节点替换它,如果有的话。refresh
做的是不断发现节点并填充路由表,它首先查找跟自己相近的节点,然后查找三个随机节点的相邻节点。
查找跟某个 target_key
相近的节点用 lookup
方法,这个方法叫做递归查找(Recursive Lookup)。它首先从路由表里面获取和 target_key
相邻最近的 BUCKET_SIZE
个节点放在 closest
列表里面,如果路由里面一个节点都没有,重新把启动节点加进去,最后 closest
总会有一些节点。接着遍历 closest
里面的节点,并向每个节点索取其和 target_key
更相近的节点;将返回结果的节点添加到路由表,然后遍历返回结果的节点,将它和 closest
里面的节点做对比,看谁离 target_key
更近,更近的留在 closest
里面。这个过程循环进行直到 closest
列表里面的所有节点都被问过了。
路由表是不直接存储节点的,它必须存在路由表相应的 k-桶里面。如何选择 k-桶?我们将某个节点的 ID 和当前服务器的节点 ID 做一下异或距离运算,然后看这个距离有多少个“前导零”。最后将 k-桶个数减去“前导零”的个数的结果做为这个节点所在 k-桶的索引编号,如果结果小于 0
,取 0
。这个逻辑在 get_bucket
方法里。
找到 k-桶后,如何将节点添加进去就按照 Kademlia 协议的规则:k-桶满了,把它加到 k-桶的 replace_cache
;k-桶没满,但是k-桶已经包含此节点,把它调到最前面,否则把它直接插到最前面。
启动服务器
如果你运行 python app.py
可以看到👇的输出:
2018-12-14 20:37:39.778 push (N 930cf49c) to bucket #14
2018-12-14 20:37:39.778 push (N 674085f6) to bucket #16
2018-12-14 20:37:39.778 push (N 009be51d) to bucket #16
2018-12-14 20:37:39.778 push (N 816ee7e3) to bucket #14
2018-12-14 20:37:39.778 push (N 3d1edcb0) to bucket #16
2018-12-14 20:37:39.778 push (N 29cca67d) to bucket #16
2018-12-14 20:37:39.786 listening...
2018-12-14 20:37:39.795 ----> 816ee7e3@13.75.154.138:30303 (Ping)
2018-12-14 20:37:39.796 ----> 930cf49c@52.16.188.185:30303 (Ping)
2018-12-14 20:37:39.796 ----> 29cca67d@5.1.83.226:30303 (Ping)
2018-12-14 20:37:40.142 <---- 816ee7e3@13.75.154.138:30303 (Pong)
2018-12-14 20:37:40.798 <-//- 930cf49c@52.16.188.185:30303 (Pong) timeout
2018-12-14 20:37:40.799 <-//- 29cca67d@5.1.83.226:30303 (Pong) timeout
2018-12-14 20:37:41.143 <-//- 816ee7e3@13.75.154.138:30303 (Ping) timeout
2018-12-14 20:37:41.145 ----> 816ee7e3@13.75.154.138:30303 (FN a3d334fa)
2018-12-14 20:37:41.507 <---- 816ee7e3@13.75.154.138:30303 (Ns [(N a3ba0512), (N a35e82d2), (N a1ae51f6), (N a153f900), (N a14a9593), (N a7b31894), (N a5d7d971), (N a5268243), (N af477601), (N adda7a78), (N b6ed28e7), (N b62a7422)] 1544791081)
2018-12-14 20:37:41.515 <---- 816ee7e3@13.75.154.138:30303 (Ns [(N b4114787), (N b8fb3e88), (N b83a8eb9), (N bf834266)] 1544791081)
2018-12-14 20:37:41.515 push (N a3ba0512) to bucket #7
2018-12-14 20:37:41.515 push (N a35e82d2) to bucket #8
2018-12-14 20:37:41.515 push (N a1ae51f6) to bucket #10
2018-12-14 20:37:41.516 push (N a5268243) to bucket #11
2018-12-14 20:37:41.516 push (N af477601) to bucket #12
2018-12-14 20:37:41.516 push (N adda7a78) to bucket #12
2018-12-14 20:37:41.517 push (N bf834266) to bucket #13
2018-12-14 20:37:41.518 ----> a3ba0512@35.236.159.118:30303 (Ping)
2018-12-14 20:37:41.607 <---- a3ba0512@35.236.159.118:30303 (Pong)
2018-12-14 20:37:41.615 <---- a3ba0512@35.236.159.118:30303 (Ping)
2018-12-14 20:37:41.615 ----> a3ba0512@35.236.159.118:30303 (Pong)
2018-12-14 20:37:41.615 a3ba0512@35.236.159.118:30303 unsolicited response Ping
2018-12-14 20:37:41.616 bump (N a3ba0512) in bucket #7
2018-12-14 20:37:41.800 <-//- 930cf49c@52.16.188.185:30303 (Ping) timeout
2018-12-14 20:37:41.801 <-//- 29cca67d@5.1.83.226:30303 (Ping) timeout
2018-12-14 20:37:41.801 ----> 930cf49c@52.16.188.185:30303 (FN a3d334fa)
2018-12-14 20:37:41.802 ----> 29cca67d@5.1.83.226:30303 (FN a3d334fa)
2018-12-14 20:37:42.617 <-//- a3ba0512@35.236.159.118:30303 (Ping) timeout
2018-12-14 20:37:42.618 ----> a3ba0512@35.236.159.118:30303 (FN a3d334fa)
2018-12-14 20:37:42.695 <---- a3ba0512@35.236.159.118:30303 (Ns [(N a3d1c129), (N a3d6aca8), (N a3c5cbb3), (N a3c42eff), (N a3cb0acd), (N a3ca35e8), (N a3c8dd80), (N a3cf1b5b), (N a3ceb7fb), (N a3f0ba70), (N a3f5b977), (N a3fbe63b)] 1544791082)
2018-12-14 20:37:42.703 <---- a3ba0512@35.236.159.118:30303 (Ns [(N a3e3f5f9), (N a3e82fb3), (N a3e864b4), (N a3ed88d1)] 1544791082)
2018-12-14 20:37:42.703 push (N a3d1c129) to bucket #2
2018-12-14 20:37:42.703 push (N a3d6aca8) to bucket #3
2018-12-14 20:37:42.704 push (N a3e864b4) to bucket #6
2018-12-14 20:37:42.705 push (N a3ed88d1) to bucket #6
2018-12-14 20:37:42.705 ----> a3d1c129@79.142.21.222:30303 (Ping)
2018-12-14 20:37:43.144 <---- a3d1c129@79.142.21.222:30303 (Pong)
2018-12-14 20:37:43.152 <---- a3d1c129@79.142.21.222:30303 (Ping)
2018-12-14 20:37:43.153 ----> a3d1c129@79.142.21.222:30303 (Pong)
2018-12-14 20:37:43.153 a3d1c129@79.142.21.222:30303 unsolicited response Ping
2018-12-14 20:37:43.153 bump (N a3d1c129) in bucket #2
2018-12-14 20:37:44.155 <-//- a3d1c129@79.142.21.222:30303 (Ping) timeout
2018-12-14 20:37:44.156 ----> a3d1c129@79.142.21.222:30303 (FN a3d334fa)
2018-12-14 20:37:44.577 <---- a3d1c129@79.142.21.222:30303 (Ns [(N a3d334fa), (N a3d3d431), (N a3d6aca8), (N a3d681d2), (N a3d571d4), (N a3d91e85), (N a3c1bd3f), (N a3c1ad80), (N a3c5cbb3), (N a3c42eff), (N a3cb0acd), (N a3ca35e8)] 1544791084)
2018-12-14 20:37:44.585 <---- a3d1c129@79.142.21.222:30303 (Ns [(N a3cae3f7), (N a3c80d3f), (N a3c8dd80), (N a3cf1b5b)] 1544791084)
2018-12-14 20:37:44.585 push (N a3d3d431) to bucket #0
2018-12-14 20:37:44.585 bump (N a3d6aca8) in bucket #3
2018-12-14 20:37:44.586 push (N a3d681d2) to bucket #3
2018-12-14 20:37:44.586 push (N a3d571d4) to bucket #3
2018-12-14 20:37:44.586 push (N a3d91e85) to bucket #4
2018-12-14 20:37:44.587 bump (N a3cf1b5b) in bucket #5
2018-12-14 20:37:44.588 ----> a3d3d431@27.10.110.49:30303 (Ping)
2018-12-14 20:37:44.803 <-//- 930cf49c@52.16.188.185:30303 (Ns) timeout
2018-12-14 20:37:44.803 <-//- 29cca67d@5.1.83.226:30303 (Ns) timeout
2018-12-14 20:37:44.805 ----> a3d6aca8@172.104.229.232:30303 (Ping)
2018-12-14 20:37:44.805 ----> a3d681d2@172.104.180.194:30303 (Ping)
2018-12-14 20:37:45.107 <---- a3d6aca8@172.104.229.232:30303 (Pong)
2018-12-14 20:37:45.115 <---- a3d6aca8@172.104.229.232:30303 (Ping)
2018-12-14 20:37:45.115 ----> a3d6aca8@172.104.229.232:30303 (Pong)
2018-12-14 20:37:45.115 a3d6aca8@172.104.229.232:30303 unsolicited response Ping
2018-12-14 20:37:45.115 bump (N a3d6aca8) in bucket #3
2018-12-14 20:37:45.590 <-//- a3d3d431@27.10.110.49:30303 (Pong) timeout
2018-12-14 20:37:45.806 <-//- a3d681d2@172.104.180.194:30303 (Pong) timeout
2018-12-14 20:37:46.117 <-//- a3d6aca8@172.104.229.232:30303 (Ping) timeout
2018-12-14 20:37:46.118 ----> a3d6aca8@172.104.229.232:30303 (FN a3d334fa)
2018-12-14 20:37:46.399 <---- a3d6aca8@172.104.229.232:30303 (Ns [(N a3d01efc), (N a3d05035), (N a3d08c94), (N a3d08bcf)] 1544791086)
2018-12-14 20:37:46.407 <---- a3d6aca8@172.104.229.232:30303 (Ns [(N a3d33fe5), (N a3d3bdc2), (N a3d3bea3), (N a3d3a940), (N a3d39ef5), (N a3d3d431), (N a3d264f5), (N a3d2e0fd), (N a3d2e293), (N a3d2cf5f), (N a3d1b2a3), (N a3d1a970)] 1544791086)
2018-12-14 20:37:46.407 push (N a3d01efc) to bucket #2
2018-12-14 20:37:46.408 push (N a3d05035) to bucket #2
...
最后
至此以太坊的节点发现协议部分已经实现,接下来的就是同步区块数据了,敬请关注后续教程。