死磕以太坊原始碼分析之p2p節點發現

mindcarver發表於2020-11-23

死磕以太坊原始碼分析之p2p節點發現

在閱讀節點發現原始碼之前必須要理解kadmilia演算法,可以參考:KAD演算法詳解

節點發現概述

節點發現,使本地節點得知其他節點的資訊,進而加入到p2p網路中。

以太坊的節點發現基於類似的kademlia演算法,原始碼中有兩個版本,v4和v5。v4適用於全節點,通過discover.ListenUDP使用,v5適用於輕節點通過discv5.ListenUDP使用,本文介紹的是v4版本。

節點發現功能主要涉及 Server Table udp 這幾個資料結構,它們有獨自的事件響應迴圈,節點發現功能便是它們互相協作完成的。其中,每個以太坊客戶端啟動後都會在本地執行一個Server,並將網路拓撲中相鄰的節點視為Node,而TableNode的容器,udp則是負責維持底層的連線。這些結構的關係如下圖:

image-20201123210628944

p2p服務開啟節點發現

在P2p的server.go 的start方法中:

if err := srv.setupDiscovery(); err != nil {
		return err
	}

進入到setupDiscovery中:

// Discovery V4
	var unhandled chan discover.ReadPacket
	var sconn *sharedUDPConn
	if !srv.NoDiscovery {
		...
		ntab, err := discover.ListenUDP(conn, srv.localnode, cfg)
		....
	}

discover.ListenUDP方法即開啟了節點發現的功能.

首先解析出監聽地址的UDP埠,根據埠返回與之相連的UDP連線,之後返回連線的本地網路地址,接著設定最後一個UDP-on-IPv4埠。到此為止節點發現的一些準備工作做好,接下下來開始UDP的監聽:

ntab, err := discover.ListenUDP(conn, srv.localnode, cfg)

然後進行UDP 的監聽,下面是監聽的過程:

監聽UDP

// 監聽給定的socket 上的發現的包
func ListenUDP(c UDPConn, ln *enode.LocalNode, cfg Config) (*UDPv4, error) {
	return ListenV4(c, ln, cfg)
}
func ListenV4(c UDPConn, ln *enode.LocalNode, cfg Config) (*UDPv4, error) {
	closeCtx, cancel := context.WithCancel(context.Background())
	t := &UDPv4{
		conn:            c,
		priv:            cfg.PrivateKey,
		netrestrict:     cfg.NetRestrict,
		localNode:       ln,
		db:              ln.Database(),
		gotreply:        make(chan reply),
		addReplyMatcher: make(chan *replyMatcher),
		closeCtx:        closeCtx,
		cancelCloseCtx:  cancel,
		log:             cfg.Log,
	}
	if t.log == nil {
		t.log = log.Root()
	}

	tab, err := newTable(t, ln.Database(), cfg.Bootnodes, t.log) // 
	if err != nil {
		return nil, err
	}
	t.tab = tab
	go tab.loop() //

	t.wg.Add(2)
	go t.loop() //
	go t.readLoop(cfg.Unhandled) //
	return t, nil
}

主要做了以下幾件事:

1.新建路由表

tab, err := newTable(t, ln.Database(), cfg.Bootnodes, t.log) 

新建路由表做了以下幾件事:

  • 初始化table物件
  • 設定bootnode(setFallbackNodes)
    • 節點第一次啟動的時候,節點會與硬編碼在以太坊原始碼中的bootnode進行連線,所有的節點加入幾乎都先連線了它。連線上bootnode後,獲取bootnode部分的鄰居節點,然後進行節點發現,獲取更多的活躍的鄰居節點
    • nursery 是在 Table 為空並且資料庫中沒有儲存節點時的初始連線節點(上文中的 6 個節點),通過 bootnode 可以發現新的鄰居
  • tab.seedRand:使用提供的種子值將生成器初始化為確定性狀態
  • loadSeedNodes:載入種子節點;從保留已知節點的資料庫中隨機的抽取30個節點,再加上引導節點列表中的節點,放置入k桶中,如果K桶沒有空間,則假如到替換列表中。

2.測試鄰居節點連通性

首先知道UDP協議是沒有連線的概念的,所以需要不斷的ping 來測試對端節點是否正常,在新建路由表之後,就來到下面的迴圈,不斷的去做上面的事。

go tab.loop()

定時執行doRefreshdoRevalidatecopyLiveNodes進行重新整理K桶。

以太坊的k桶設定:

const (
	alpha           = 3  // Kademlia併發引數, 是系統內一個優化引數,控制每次從K桶最多取出節點個數,ethereum取值3
  
	bucketSize      = 16 // K桶大小(可容納節點數)
  
	maxReplacements = 10 // 每桶更換列表的大小
	hashBits          = len(common.Hash{}) * 8 //每個節點ID長度,32*8=256, 32位16進位制
	nBuckets          = hashBits / 15       //  K桶個數
  )

首先搞清楚這三個定時器執行的時間:

refreshInterval    = 30 * time.Minute
revalidateInterval = 10 * time.Second
copyNodesInterval  = 30 * time.Second
doRefresh

doRefresh對隨機目標執行查詢以保持K桶已滿。如果表為空(初始載入程式或丟棄的有故障),則插入種子節點。

主要以下幾步:

  1. 從資料庫載入隨機節點和引導節點。這應該會產生一些以前見過的節點

    tab.loadSeedNodes()
    
  2. 將本地節點ID作為目標節點進行查詢最近的鄰居節點

    tab.net.lookupSelf()
    
    func (t *UDPv4) lookupSelf() []*enode.Node {
    	return t.newLookup(t.closeCtx, encodePubkey(&t.priv.PublicKey)).run()
    }
    
    func (t *UDPv4) newLookup(ctx context.Context, targetKey encPubkey) *lookup {
    	...
    		return t.findnode(n.ID(), n.addr(), targetKey)
    	})
    	return it
    }
    

    向這些節點發起findnode操作查詢離target節點最近的節點列表,將查詢得到的節點進行ping-pong測試,將測試通過的節點落庫儲存

    經過這個流程後,節點的K桶就能夠比較均勻地將不同網路節點更新到本地K桶中。

    unc (t *UDPv4) findnode(toid enode.ID, toaddr *net.UDPAddr, target encPubkey) ([]*node, error) {
    	t.ensureBond(toid, toaddr)
    	nodes := make([]*node, 0, bucketSize)
    	nreceived := 0
      // 設定回應回撥函式,等待型別為neighborsPacket的鄰近節點包,如果型別對,就執行回撥請求
    	rm := t.pending(toid, toaddr.IP, p_neighborsV4, func(r interface{}) (matched bool, requestDone bool) {
    		reply := r.(*neighborsV4)
    		for _, rn := range reply.Nodes {
    			nreceived++
          // 得到一個簡單的node結構
    			n, err := t.nodeFromRPC(toaddr, rn)
    			if err != nil {
    				t.log.Trace("Invalid neighbor node received", "ip", rn.IP, "addr", toaddr, "err", err)
    				continue
    			}
    			nodes = append(nodes, n)
    		}
    		return true, nreceived >= bucketSize
    	})
      //上面了一個管道事件,下面開始傳送真正的findnode報文,然後進行等待了
    	t.send(toaddr, toid, &findnodeV4{
    		Target:     target,
    		Expiration: uint64(time.Now().Add(expiration).Unix()),
    	})
    	return nodes, <-rm.errc
    }
    
  3. 查詢3個隨機的目標節點

    for i := 0; i < 3; i++ {
    		tab.net.lookupRandom()
    	}
    
doRevalidate

doRevalidate檢查隨機儲存桶中的最後一個節點是否仍然存在,如果不是,則替換或刪除該節點。

主要以下幾步:

  1. 返回隨機的非空K桶中的最後一個節點

    last, bi := tab.nodeToRevalidate()
    
  2. 對最後的節點執行Ping操作,然後等待Pong

    remoteSeq, err := tab.net.ping(unwrapNode(last))
    
  3. 如果節點ping通了的話,將節點移動到最前面

    tab.bumpInBucket(b, last)
    
  4. 沒有收到回覆,選擇一個替換節點,或者如果沒有任何替換節點,則刪除該節點

    tab.replace(b, last)
    
copyLiveNodes

copyLiveNodes將表中的節點新增到資料庫,如果節點在表中的時間超過了5分鐘。

這部分程式碼比較簡單,就伸展闡述。

if n.livenessChecks > 0 && now.Sub(n.addedAt) >= seedMinTableTime {
				tab.db.UpdateNode(unwrapNode(n))
			}

3.檢測各類資訊

go t.loop()

loop迴圈主要監聽以下幾類訊息:

  • case <-t.closeCtx.Done():檢測是否停止
  • p := <-t.addReplyMatcher:檢測是否有新增新的待處理訊息
  • r := <-t.gotreply:檢測是否接收到其他節點的回覆訊息

4. 處理UDP資料包

go t.readLoop(cfg.Unhandled)

主要有以下兩件事:

  1. 迴圈接收其他節點發來的udp訊息

    nbytes, from, err := t.conn.ReadFromUDP(buf)
    
  2. 處理接收到的UDP訊息

    t.handlePacket(from, buf[:nbytes])
    

接下來對這兩個函式進行進一步的解析。

接收UDP訊息

接收UDP訊息比較的簡單,就是不斷的從連線中讀取Packet資料,它有以下幾種訊息:

  • ping:用於判斷遠端節點是否線上。

  • pong:用於回覆ping訊息的響應。

  • findnode:查詢與給定的目標節點相近的節點。

  • neighbors:用於回覆findnode的響應,與給定的目標節點相近的節點列表


處理UDP訊息

主要做了以下幾件事:

  1. 資料包解碼

    packet, fromKey, hash, err := decodeV4(buf)
    
  2. 檢查資料包是否有效,是否可以處理

     packet.preverify(t, from, fromID, fromKey)
    

    在校驗這一塊,涉及不同的訊息型別不同的校驗,我們來分別對各種訊息進行分析。

    ①:ping

    • 校驗訊息是否過期
    • 校驗公鑰是否有效

    ②:pong

    • 校驗訊息是否過期
    • 校驗回覆是否正確

    ③:findNodes

    • 校驗訊息是否過期
    • 校驗節點是否是最近的節點

    ④:neighbors

    • 校驗訊息是否過期
    • 用於回覆findnode的響應,校驗回覆是否正確
  3. 處理packet資料

    packet.handle(t, from, fromID, hash)
    

    相同的,也會有4種訊息,但是我們這邊重點講處理findNodes的訊息:

func (req *findnodeV4) handle(t *UDPv4, from *net.UDPAddr, fromID enode.ID, mac []byte) {
...
}


我們這裡就稍微介紹下如何處理`findnode`的訊息:

```go
func (req *findnodeV4) handle(t *UDPv4, from *net.UDPAddr, fromID enode.ID, mac []byte) {
	// 確定最近的節點
	target := enode.ID(crypto.Keccak256Hash(req.Target[:]))
	t.tab.mutex.Lock()
	//最接近的返回表中最接近給定id的n個節點
	closest := t.tab.closest(target, bucketSize, true).entries
	t.tab.mutex.Unlock()
	// 以每個資料包最多maxNeighbors的塊的形式傳送鄰居,以保持在資料包大小限制以下。
	p := neighborsV4{Expiration: uint64(time.Now().Add(expiration).Unix())}
	var sent bool
	for _, n := range closest { //掃描這些最近的節點列表,然後一個包一個包的傳送給對方
		if netutil.CheckRelayIP(from.IP, n.IP()) == nil {
			p.Nodes = append(p.Nodes, nodeToRPC(n))
		}
		if len(p.Nodes) == maxNeighbors {
			t.send(from, fromID, &p)//給對方傳送 neighborsPacket 包,裡面包含節點列表
			p.Nodes = p.Nodes[:0]
			sent = true
		}
	}
	if len(p.Nodes) > 0 || !sent {
		t.send(from, fromID, &p)
	}
}

首先先確定最近的節點,再一個包一個包的發給對方,並校驗節點的IP,最後把有效的節點傳送給請求方。


涉及的結構體:

UDP

  • conn :介面,包括了從UDP中讀取和寫入,關閉UDP連線以及獲取本地地址。
  • netrestrict:IP網路列表
  • localNode:本地節點
  • tab:路由表

Table

  • buckets:所有節點都加到這個裡面,按照距離

  • nursery:啟動節點

  • rand:隨機來源

  • ips:跟蹤IP,確保IP中最多N個屬於同一網路範圍

  • net: UDP 傳輸的介面

    • 返回本地節點
    • 將enrRequest傳送到給定的節點並等待響應
    • findnode向給定節點傳送一個findnode請求,並等待該節點最多傳送了k個鄰居
    • 返回查詢最近的節點
    • 將ping訊息傳送到給定的節點,然後等待答覆

以下是table的結構圖:

image-20201112104254003


思維導圖

思維導圖獲取地址

image-20201123211034861

參考文件

http://mindcarver.cn/ ⭐️⭐️⭐️⭐️

https://github.com/blockchainGuide/ ⭐️⭐️⭐️⭐️

https://www.cnblogs.com/xiaolincoding/p/12571184.html

http://qjpcpu.github.io/blog/2018/01/29/shen-ru-ethereumyuan-ma-p2pmo-kuai-ji-chu-jie-gou/

https://www.jianshu.com/p/b232c870dcd2

https://bbs.huaweicloud.com/blogs/113684

https://www.jianshu.com/p/94d02a41a146

相關文章