以太坊p2p网络(四):以太坊P2P模块基于UDP的服务发现机制源码分析

以太坊的底层P2P模块承担了节点之间的通信和服务发现,新节点发现连接的功能,p2p网络部分主要分为两个部分:

  1. 节点发现, 怎么发现附近的其他节点;
  2. 节点连接,怎么去连接其他节点并互相通信。

以太坊使用UDP进行服务发现,通讯内容比较简单,所以没有加密。而使用TCP进行真正的数据传输和交互,这部分是使用加密连接进行传输的。

一、服务初始化过程

在上节中,最后p2p网络启动时调用runing.Start()函数,会调用到ethereum/p2p/server.go中的Start()函数。

ethereum/p2p/server.go中的Start()函数代码片段
	...
	
//Transport使用了newRLPX 使用了rlpx.go中的网络协议。
	if srv.newTransport == nil {		
		srv.newTransport = newRLPX
	}
	...
	
	//启动discover网络。 开启UDP的监听
		if !srv.NoDiscovery || srv.DiscoveryV5 {
		网络发现服务, 用UDP协议 , 这里的目的是生成一个udp协议地址字段, 设置一个UDP listen监听句柄
		addr, err := net.ResolveUDPAddr("udp", srv.ListenAddr)
		if err != nil {
			return err
		}
		conn, err = net.ListenUDP("udp", addr)
		if err != nil {
			return err
		}
		realaddr = conn.LocalAddr().(*net.UDPAddr)
		if srv.NAT != nil {
			if !realaddr.IP.IsLoopback() {
				go nat.Map(srv.NAT, srv.quit, "udp", realaddr.Port, realaddr.Port, "ethereum discovery")
			}
			// TODO: react to external IP changes over time.
			if ext, err := srv.NAT.ExternalIP(); err == nil {
				realaddr = &net.UDPAddr{IP: ext, Port: realaddr.Port}
			}
		}
	}
	...
		if !srv.NoDiscovery {
		cfg := discover.Config{
			PrivateKey:   srv.PrivateKey,
			AnnounceAddr: realaddr,
			NodeDBPath:   srv.NodeDatabase,
			NetRestrict:  srv.NetRestrict,
			Bootnodes:    srv.BootstrapNodes,
			Unhandled:    unhandled,
		}
		启动discover网络。 开启UDP的监听。主要进行节点发现
		ntab, err := discover.ListenUDP(conn, cfg)
		if err != nil {
			return err
		}
		srv.ntab = ntab
	}

只要没有关闭discovery服务发现功能,P2P服务就会调用discover.ListenUDP(conn, cfg) 去开启服务发现功能。

二、开启UDP监听

p2p服务通过discover.ListenUDP(conn, cfg)函数去调用newUDP函数开启后台协程进行UDP监听和服务发现。

func newUDP(c conn, cfg Config) (*Table, *udp, error) {
	udp := &udp{
		conn:        c,
		priv:        cfg.PrivateKey,
		netrestrict: cfg.NetRestrict,
		closing:     make(chan struct{}),
		gotreply:    make(chan reply),
		addpending:  make(chan *pending),
	}
	realaddr := c.LocalAddr().(*net.UDPAddr)
	if cfg.AnnounceAddr != nil {
		realaddr = cfg.AnnounceAddr
	}
	
	//拆分出一个ip端口的rpcEndpoint,其实就是UDP地址转成对应的TCP地址
	udp.ourEndpoint = makeEndpoint(realaddr, uint16(realaddr.Port))
	
	///下面传入udp结构创建一个table结构,table负责对P2P协议节点发现功能的逻辑
	tab, err := newTable(udp, PubkeyID(&cfg.PrivateKey.PublicKey), realaddr, cfg.NodeDBPath, cfg.Bootnodes)
	if err != nil {
		return nil, nil, err
	}
	udp.Table = tab
	
	//发送数据
	go udp.loop()
	
	//读取数据,并调用 handlePacket进行处理
	//如果有新的数据包到来,会通知到 unhandled上面, discover包会使用conn这个UDP 监听链接,不断读取数据包进行处理
	go udp.readLoop(cfg.Unhandled)
	return udp.Table, udp, nil
}

newTable函数调用的时候,会传递Bootnodes变量,这就是UDP服务的初始连接节点了,服务器启动默认从这些节点开始进行节点发现,进行扩散。

func newTable(t transport, ourID NodeID, ourAddr *net.UDPAddr, nodeDBPath string, bootnodes []*Node) (*Table, error) {
	// If no node database was given, use an in-memory one
	db, err := newNodeDB(nodeDBPath, nodeDBVersion, ourID)
	if err != nil {
		return nil, err
	}
	tab := &Table{
		net:        t,
		db:         db,
		self:       NewNode(ourID, ourAddr.IP, uint16(ourAddr.Port), uint16(ourAddr.Port)),
		refreshReq: make(chan chan struct{}),
		initDone:   make(chan struct{}),
		closeReq:   make(chan struct{}),
		closed:     make(chan struct{}),
		rand:       mrand.New(mrand.NewSource(0)),
		ips:        netutil.DistinctNetSet{Subnet: tableSubnet, Limit: tableIPLimit},
	}
	if err := tab.setFallbackNodes(bootnodes); err != nil {
		return nil, err
	}
	for i := range tab.buckets {
		tab.buckets[i] = &bucket{
			ips: netutil.DistinctNetSet{Subnet: bucketSubnet, Limit: bucketIPLimit},
		}
	}
	
	//设置随机种子
	tab.seedRand()
	//载所有的种子节点,其中包括参数bootnodes 里面的节点
	tab.loadSeedNodes()
	// Start the background expiration goroutine after loading seeds so that the search for
	// seed nodes also considers older nodes that would otherwise be removed by the
	// expiration.
	tab.db.ensureExpirer()
	go tab.loop()
	return tab, nil
}

三、节点连接

节点发现协议:

  • FindNode:节点查询协议,向目标节点询问其临近节点列表;
  • Neighbours:响应FindNode消息,当某节点接到其它节点发来的FindNode消息时,会回送Neighbours消息,其中携带了此节点的附近节点;
  • PingNode:用来查看节点是否存活。对于缺失NodeID的节点,也可用来询问其NodeID;
  • Pong:对PingNode消息的响应。

首先进行邻居节点的发现:
以太坊p2p网络(四):以太坊P2P模块基于UDP的服务发现机制源码分析_第1张图片

由于是UDP协议,所以没有连接的概念,需要不断的去ping来测试对端节点是否OK。,上面看到在newTable后面,创建了一个协程go tab.loop()。

func (tab *Table) loop() {
	var (
		revalidate     = time.NewTimer(tab.nextRevalidateTime())
		//30分钟进行刷新所有节点列表进行findnode
		refresh        = time.NewTicker(refreshInterval)
		copyNodes      = time.NewTicker(copyNodesInterval)
		revalidateDone = make(chan struct{})
		//监听doRefresh 是否完成
		refreshDone    = make(chan struct{})          
		waiting        = []chan struct{}{tab.initDone} // holds waiting callers while doRefresh runs
	)
	defer refresh.Stop()
	defer revalidate.Stop()
	defer copyNodes.Stop()

	// //先进行刷新,这也是启动p2p服务的第一次尝试连接其他节点
	go tab.doRefresh(refreshDone)

doRefresh()协程来尝试连接其他节点,这也是第一次去做节点刷新和连接。传入的参数refreshDone管道用来主协程和刷新协程进行通信。

func (tab *Table) doRefresh(done chan struct{}) {
	defer close(done)
	
	//加载所有种子节点,括tab.nursery 上设置的 Bootnodes初始启动节点。
	tab.loadSeedNodes()

	//根据最近的节点进行findNode查找邻近节点, 进行一次扩展, 查找其他节点的peers
	//查找距离自己最近的节点
	tab.lookup(tab.self.ID, false)

	for i := 0; i < 3; i++ {
		var target NodeID
		crand.Read(target[:])
		//查找距离target最近的节点
		tab.lookup(target, false)
	}
}

tab.lookup()函数主要进行距离某个节点距离的节点查找:

func (tab *Table) lookup(targetID NodeID, refreshIfEmpty bool) []*Node {
	var (
		target         = crypto.Keccak256Hash(targetID[:])
		asked          = make(map[NodeID]bool)
		seen           = make(map[NodeID]bool)
		reply          = make(chan []*Node, alpha)
		pendingQueries = 0
		result         *nodesByDistance
	)

	asked[tab.self.ID] = true

	for {
		tab.mutex.Lock()
		//调用closest来获取距离target最近的16个节点列表
		result = tab.closest(target, bucketSize)
		tab.mutex.Unlock()
		if len(result.entries) > 0 || !refreshIfEmpty {
			break
		}

		//完全一个节点都没有,一般不会,如果为空那么调用refresh来触发一次刷新
		<-tab.refresh()
		refreshIfEmpty = false
	}

	for {
	
		// 用alpha来控制每次并发协程数,避免量太大了,默认为3
		 //每次并发完成后,从reply管道读取一个,通过pendingQueries控制数量, 这个代表我总共有多少请求需要去问,当前正在进行的,如果没有了,就会break
		for i := 0; i < len(result.entries) && pendingQueries < alpha; i++ {
			n := result.entries[i]
			if !asked[n.ID] {
				asked[n.ID] = true
				pendingQueries++
				go tab.findnode(n, targetID, reply)
			}
		}
		if pendingQueries == 0 {
			// we have asked all closest nodes, stop the search
			break
		}
		// wait for the next reply
		for _, n := range <-reply {
			if n != nil && !seen[n.ID] {
				seen[n.ID] = true
				result.push(n, bucketSize)
			}
		}
		pendingQueries--
	}
	return result.entries
}

tab.closest()函数,获取某个节点附近的bucketSize个邻近节点,保存到result里面。tab.closest()函数里面调用result.push()函数,根据所有的节点对于target的距离进行排序。 按照从近到远的方式决定新节点的插入顺序。(队列中最大会包含16个元素)。 这样会导致队列里面的元素和target的距离越来越近。距离相对远的会被踢出队列。

func (h *nodesByDistance) push(n *Node, maxElems int) {
	ix := sort.Search(len(h.entries), func(i int) bool {
		return distcmp(h.target, h.entries[i].sha, n.sha) > 0
	})
	if len(h.entries) < maxElems {
		h.entries = append(h.entries, n)
	}
	if ix == len(h.entries) {
	} else {
		copy(h.entries[ix+1:], h.entries[ix:])
		h.entries[ix] = n
	}
}

lookup 最重要的就是上面的循环,遍历result列表的每一个节点,分别参加协程进行findnode的发送。

Table.findnode()函数调用udp里面的findnode()函数,查找可以连通最近的16个节点,再通过tab.add()函数加入到自己的桶中。

udp的findnode()函数首先会通过t.ping(toid, toaddr)去ping传入k桶中的节点,然后通过t.waitping(toid)等待返回。

udp的findnode()函数

func (t *udp) findnode(toid NodeID, toaddr *net.UDPAddr, target NodeID) ([]*Node, error) {
	if time.Since(t.db.lastPingReceived(toid)) > nodeDBNodeExpiration {
		t.ping(toid, toaddr)
		t.waitping(toid)
	}

udp中的ping函数调用sendPing()函数进行发送ping消息,并且等待接收pong消息并校验。

func (t *udp) ping(toid NodeID, toaddr *net.UDPAddr) error {
	return <-t.sendPing(toid, toaddr, nil)
}

func (t *udp) sendPing(toid NodeID, toaddr *net.UDPAddr, callback func()) <-chan error {
//发送一个ping的UDP消息给对方,并且等待pong结果返回,如果失败会返回非空,成功返回nil
	req := &ping{
		Version:    4,
		From:       t.ourEndpoint,
		To:         makeEndpoint(toaddr, 0), // TODO: maybe use known TCP port from DB
		Expiration: uint64(time.Now().Add(expiration).Unix()),
	}
	packet, hash, err := encodePacket(t.priv, pingPacket, req)
	if err != nil {
		errc := make(chan error, 1)
		errc <- err
		return errc
	}
	
	//先挂一个回调,这样发送完收到结果的时候,触发errC
	errc := t.pending(toid, pongPacket, func(p interface{}) bool {
		ok := bytes.Equal(p.(*pong).ReplyTok, hash)
		if ok && callback != nil {
			callback()
		}
		return ok
	})
	 //发送后会等待结果,如果成功,ping函数才会返回
    //在loop()里面会监听r.gotreply事件,从plist里面找到对应的连接然后在p.errc<-nil 
    //这样本函数才会返回nil
	t.write(toaddr, req.name(), packet)
	return errc
}

t.pending函数传入了一个匿名函数,当回调函数。完了直接调用t.write来发送一个UDP数据包给对方。之后等待在errC管道上。这个来做什么的呢?errc 是t.pending返回的管道。来看看代码:

func (t *udp) pending(id NodeID, ptype byte, callback func(interface{}) bool) <-chan error {
//ping或者findnode函数调用这里,设置一个ping返回调用,并等待errC
    //生成一个结果通知管道,生成一个pending结构放入t.addpending 里面,对方接到请求会调用callback,然后触发errC
    
	ch := make(chan error, 1)
	p := &pending{from: id, ptype: ptype, callback: callback, errc: ch}
	select {
	case t.addpending <- p:
		// loop will handle it
	case <-t.closing:
		ch <- errClosed
	}
	//返回这个ch给调用者去等待,而对于addpending的对端,
    //也就是loop,这会在获取到一个ptype类型后,会发送一个nil给这个errC的管道
    //这样做到阻塞的目的, 调用者只需要等待在这管道上即可

	return ch
}

调用write发送UDP报文后,会往 t.addpending 上发送一个pending消息,消息里面设置了一个管道ch,也就是errC,这样在udp的loop函数里面接到addpending消息后,会登记这个关系。
实际上pending顾名思义,就是等级等待事件,如果等待成功,loop协程会调用传入的callback函数,然后往管道errC发送一个结果。
来看一下loop的相关处理:

udp.go中的loop函数

func (t *udp) loop() {
...
select {
//ping 会设置一个pending的回调,将待回应的数据发到这个管道,然后才调用write发送数据
		case p := <-t.addpending:
			p.deadline = time.Now().Add(respTimeout)
			//放到plist里面,如果有reply到来,会触发t.gotreply来进行扫描调用p.callback,
			plist.PushBack(p)

//收到对方发送的数据包后,就是在readLoop里面不断读取数据包后,调用对应类型的数据包的handle函数
//后者会给gotreply 发送一个管道消息,带着对应的数据包
		case r := <-t.gotreply:
			var matched bool
			for el := plist.Front(); el != nil; el = el.Next() {
				p := el.Value.(*pending)
				if p.from == r.from && p.ptype == r.ptype {
					matched = true
					 //匹配成功,发送nil到管道,通知ping()函数返回
					if p.callback(r.data) {
						p.errc <- nil
						plist.Remove(el)
					}
					// Reset the continuous timeout counter (time drift detection)
					contTimeouts = 0
				}
			}
			 //给r.matched发送管道消息
			r.matched <- matched

主协程loop的时候,addpending会记录这个等待事件,如果gotreply管道上面有消息了,便会遍历所有等待协程,如果match, 就调用其callback然后触发管道。通知等待协程唤醒。

这样当udp.ping函数返回的时候,就是本节点以及收到对端的pong的时候了。

当发送ping包进行等待的时候,我们可以看到代码是: t.pending(toid, pongPacket, func(p interface{}) bool , 第二个参数代表我要等待对方发送pongPacket。
readLoop 协程来接收消息,在newUDP 函数里面。

func (t *udp) readLoop(unhandled chan<- ReadPacket) {
//死循环不断读取UDP包然后调用handlePacket 进行处理,数据的发送调用write函数 直接发出
	defer t.conn.Close()
	if unhandled != nil {
		defer close(unhandled)
	}

	buf := make([]byte, 1280)
	for {
		nbytes, from, err := t.conn.ReadFromUDP(buf)
		if netutil.IsTemporaryError(err) {
			log.Debug("Temporary UDP read error", "err", err)
			continue
		} else if err != nil {
			log.Debug("UDP read error", "err", err)
			return
		}
		//调用处理函数处理收到的package消息报文
		if t.handlePacket(from, buf[:nbytes]) != nil && unhandled != nil {
			select {
			case unhandled <- ReadPacket{buf[:nbytes], from}:
			default:
			}
		}
	}
}

当ReadFromUDP接到一个UDP报文后,会调用handlePacket处理函数处理这个package。

func (t *udp) handlePacket(from *net.UDPAddr, buf []byte) error {
 //处理一个readLoop 里面读取到的UDP数据包,先解码, 然后调用包对应的handle
	packet, fromID, hash, err := decodePacket(buf)
	if err != nil {
		log.Debug("Bad discv4 packet", "addr", from, "err", err)
		return err
	}
	//调用对应的packet类型的handle函数
	err = packet.handle(t, from, fromID, hash)
	log.Trace("<< "+packet.name(), "addr", from, "err", err)
	return err
}

package.handle 这个函数依赖于decodePacket 返回的数据包类型,有 ping,pong,findnode, neighbors, 也就是说,对端发送的这个包格式是什么,就调用对应的格式。

当我们发送给对方ping包,那么对方会发送pong回来。因此对应的处理函数就是pong.handle:

func (req *pong) handle(t *udp, from *net.UDPAddr, fromID NodeID, mac []byte) error {
//收到一个pongPacket包,调用handleReply函数,通知loop的t.gotreply 去处理,然后返回结果
	if expired(req.Expiration) {
		return errExpired
	}
	if !t.handleReply(fromID, pongPacket, req) {
		return errUnsolicitedReply
	}
	t.db.updateLastPongReceived(fromID, time.Now())
	return nil
}

handleReply函数用来通知loop协程去处理一个对方发给我们的包:

func (t *udp) handleReply(from NodeID, ptype byte, req packet) bool {
//收到一个ptype类型的包传入 t.gotreply通道
	matched := make(chan bool, 1)
	select {
	//调用当初等待在上面的回调函数,然后给回调函数上面的errC发送管道消息
	case t.gotreply <- reply{from, ptype, req, matched}:
		// loop will handle it
		return <-matched
	case <-t.closing:
		return false
	}
}

四、节点发现

在上面findnode()函数中,首先发送ping消息包查看节点直接是否连通,下面再发送 findnodePacket包,询问距离target 比较近的节点有哪些, 注意以太坊的P2P扩散都是查询比较近的节点。

	...
	nodes := make([]*Node, 0, bucketSize)
	nreceived := 0
	 //设置回应回调函数,等待类型为neighborsPacket的邻近节点包,如果类型对,就执行回调请求
	errc := t.pending(toid, neighborsPacket, func(r interface{}) bool {
		reply := r.(*neighbors)
		for _, rn := range reply.Nodes {
			nreceived++
			n, err := t.nodeFromRPC(toaddr, rn)
			if err != nil {
				log.Trace("Invalid neighbor node received", "ip", rn.IP, "addr", toaddr, "err", err)
				continue
			}
			nodes = append(nodes, n)
		}
		return nreceived >= bucketSize
	})
	//发送findnode报文,然后进行等待了
	t.send(toaddr, findnodePacket, &findnode{
		Target:     target,
		Expiration: uint64(time.Now().Add(expiration).Unix()),
	})
	//收到信息后会调用上面的回调函数,然后触发管道,然后返回nodes
	return nodes, <-errc
}

函数调用pending, 第二个参数neighborsPacket 代表我需要等待对方发送neighborsPacket 报文就可以返回。当收到报文后,会调用匿名callback函数处理数据。

udp.readLoop会循环读取UDP包,并且调用handlePacket处理函数处理这个package, 如果是findnode包的话,那么代表对方想要查询邻近节点,就会调用到findnode.handle 函数。
findnode.handle 用来给对方回复我的邻近节点。通过调用t.closest函数来获取邻近节点,然后发给对方。

func (req *findnode) handle(t *udp, from *net.UDPAddr, fromID NodeID, mac []byte) error {
	if expired(req.Expiration) {
		return errExpired
	}
	//判断是否已连接上了这个来源节点
	if !t.db.hasBond(fromID) {
		return errUnknownNode
	}
	target := crypto.Keccak256Hash(req.Target[:])
	t.mutex.Lock()
	//返回距离target最近的节点
	closest := t.closest(target, bucketSize).entries
	t.mutex.Unlock()
	
//由于UDP有最大报文数限制,所以能够发送的邻近节点数目是有限的,必须做拆包发送,这也是UDP协议发包时必须注意的点。
	p := neighbors{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, neighborsPacket, &p)
			p.Nodes = p.Nodes[:0]
			sent = true
		}
	}
	if len(p.Nodes) > 0 || !sent {
		t.send(from, neighborsPacket, &p)
	}
	return nil
}

收到对端发送给我们的neighborsPacket 后,会调用到neighbors.handle函数,我们需要处理对方发给我们的临接节点列表。通过handleReply来通知主协程loop,进行查找,找到对应的等待的pending,然后调用其回调函数,并且将结果放到对应的errC管道上面。这样也就跟上面的udp.findnode()函数接上了,udp.findnode()函数能得到返回并且拿到对方返回给我们的node列表。

通过上面的流程,discover模块完成了基于UDP的节点发现协议,查找到新的节点通过tab.add(node) ,将其加入到table.buckets对应的bucket里面,这样就增加了可用节点了。

通过流程可知,p2p网络启动首先调用server.go中的Start()函数,Start()函数通过udp监听连接,再调用discover.ListenUDP(conn, cfg)函数,再通过调用newUDP()函数进行节点的发现连接,newUDP()函数里面首先调用newTable()函数,newTable()函数里面再调用tab.loop()函数,tab.loop()函数不停的调用tab.doRefresh(refreshDone)进行节点的刷新。tab.doRefresh刷新函数首先调用tab.lookup(tab.self.ID, false)获取距离K桶中其他节点距离本节点最近的节点返回。再随机分别生成三个节点target,再调用tab.lookup(target, false)获取距离K桶中其他节点距离target节点最近的节点返回。不断的刷新K桶。最后K桶中存储的是不断靠近target的节点。
以太坊p2p网络(四):以太坊P2P模块基于UDP的服务发现机制源码分析_第2张图片

你可能感兴趣的:(区块链基础)