200行Go实现一个以太坊代理服务器

1,131 阅读6分钟
原文链接: zhuanlan.zhihu.com

完整代码请戳 xiaoyao1991/manspreading

看完标题你可能会问,我为什么需要这样一个代理服务器?Geth不是用的好好的么?老钱我最近遇到这么一件事:由于大部分以太坊网络上的geth节点在运行时不会去更改一些参数的默认值,其中有一条参数--maxpeers用来限制一个节点最多能接受多少peers,这个参数的默认值是25,也就是说节点最多只能接受25个peer的连接,第26个peer想要和这个节点建立连接时会被拒绝。老钱所在的研究组前阵子发现以太坊主网上大部分节点的peer数都已经满了,很难连接上这些满了的节点,只能排着队等它的peer自己drop了,空出一个位子,才能连上。过不久如果我改了geth里的代码,想要重新build一下,就需要杀死进程,然后重新连接,如果运气不好,断开之后本来我的位子又被别人占了,那就又得等。。。

这时候如果能有一个简单的“占座”代理服务器,能保持一直连接着远程的一个节点,占着一个peer座位,在这个代理服务器后面,我可以随时杀掉我的geth进程,重新build,重新连接到代理服务器,不用担心位子有没有被抢,就再好不过了。老钱决定自己来写这样一个代理,顺便研读一下geth的代码,学习一下geth中P2P消息的细节。

以太坊官方的wiki上的这篇教程提供了一个很好的例子作为切入口。教程里展示了如何自己实现一个自定义协议的P2P Server。我们的proxy正是一个这样的P2P server,它需要模拟一个真正的Geth来与远程的节点(上游节点)交互。不过我们不需要实现整套eth协议,只需要处理几种情况(与节点建立连接前的握手协议,新区块诞生时的广播),剩余的情况下,代理服务器可以把网络包直接relay交给上下游(下游节点即代理服务器背后的geth节点)。

基本思路就是这样,下面来看看代码。先来看看需要定义哪些结构体:

// statusData is the network packet for the status message.
// 这个结构是握手协议中网络包的格式,从go-ethereum/eth/protocol.go中直接复制过来
type statusData struct {
   ProtocolVersion uint32
   NetworkId       uint64
   TD              *big.Int
   CurrentBlock    common.Hash
   GenesisBlock    common.Hash
}

// newBlockData is the network packet for the block propagation message.
// 这个结构是新区快广播中网络包的格式,从go-ethereum/eth/protocol.go中直接复制过来
type newBlockData struct {
   Block *types.Block
   TD    *big.Int
}

// 这个结构是对一个peer连接的封装
type conn struct {
   p  *p2p.Peer          // peer的一些基本信息
   rw p2p.MsgReadWriter  // 这是geth中对连接的封装,可以从这个rw中读取来自peer的msg,也可以通过这个rw给peer写msg
}

// 这个结构构成了我们的代理服务器
type proxy struct {
   lock           sync.RWMutex
   upstreamNode   *discover.Node  // 上游节点
   upstreamConn   *conn           // 上游节点的连接
   downstreamConn *conn           // 下游节点
   upstreamState  statusData      // 上游节点的基本信息,包括最新区块是什么,最新的totalDifficulty是什么
   srv            *p2p.Server     // P2P server实例
}

接着我们来配置一个简单的P2P Server:

var pxy *proxy

var upstreamUrl = flag.String("upstream", "", "upstream enode url to connect to")
var listenAddr = flag.String("listenaddr", "127.0.0.1:36666", "listening addr")

func init() {
   flag.Parse()
}

func main() {
   // 新建一个key。因为以太坊网络中每个节点都有一个唯一的标识,标识的格式是
   // enode://<key>@<ip>:<port>
   // 所以这里必须给代理服务器也建立一个key
   nodekey, _ := crypto.GenerateKey()
   fmt.Println("Node Key Generated")

   // 从命令行argument拿到上游节点的enode标识
   node, _ := discover.ParseNode(*upstreamUrl)
   
   // 新建一个proxy实例
   pxy = &proxy{
      upstreamNode: node,
   }

   config := p2p.Config{
      PrivateKey:     nodekey,
      MaxPeers:       2,  // 代理服务器最多承载2个peer,一个是上游节点,一个是下游节点
      NoDiscovery:    true,
      DiscoveryV5:    false,
      Name:           common.MakeName(fmt.Sprintf("%s/%s", ua, node.ID.String()), ver),
      BootstrapNodes: []*discover.Node{node},
      StaticNodes:    []*discover.Node{node},
      TrustedNodes:   []*discover.Node{node},
     
      // 代理服务器支持的协议,这里我们将新建一个自定义协议。
      Protocols: []p2p.Protocol{newManspreadingProtocol()},

      ListenAddr: *listenAddr,
      Logger:     log.New(),
   }
   config.Logger.SetHandler(log.StdoutHandler)

   // 新建P2P Server实例
   pxy.srv = &p2p.Server{Config: config}

   // 让我们的proxy永远跑着
   var wg sync.WaitGroup
   wg.Add(2)
   err := pxy.srv.Start()  // 启动P2P Server
   wg.Done()
   if err != nil {
      fmt.Println(err)
   }
   wg.Wait()
}

接下来就是关键的自定义协议部分了:

func newManspreadingProtocol() p2p.Protocol {
   return p2p.Protocol{
      // 这部分是用来标识协议的元信息,延用eth自己的元信息,不然上下游节点会不认识我们的协议
      Name:    eth.ProtocolName,
      Version: eth.ProtocolVersions[0],
      Length:  eth.ProtocolLengths[0],

      // 这个是协议的核心,每当代理收到新的信息时都会跑这个handle方法
      Run:     handle,
      NodeInfo: func() interface{} {
         fmt.Println("Noop: NodeInfo called")
         return nil
      },
      PeerInfo: func(id discover.NodeID) interface{} {
         fmt.Println("Noop: PeerInfo called")
         return nil
      },
   }
}

接下来来看协议的核心handle方法:

func handle(p *p2p.Peer, rw p2p.MsgReadWriter) error {
   fmt.Println("Run called")

   for {
      fmt.Println("Waiting for msg...")
      msg, err := rw.ReadMsg()  // 从连接中读取新的msg
      fmt.Println("Got a msg from: ", fromWhom(p.ID().String()))
      if err != nil {
         fmt.Println("readMsg err: ", err)

         // 如果读取失败,并且错误是EOF的话,那就说明对方已经断开连接
         // 我们需要把相应的conn给重置
         if err == io.EOF {
            pxy.lock.Lock()
            if p.ID() == pxy.upstreamNode.ID {
               pxy.upstreamConn = nil
            } else {
               pxy.downstreamConn = nil
            }
            pxy.lock.Unlock()
         }

         return err
      }
      fmt.Println("msg.Code: ", msg.Code)

      // 如果读取的信息是一个握手信息
      if msg.Code == eth.StatusMsg {
         var myMessage statusData
         err = msg.Decode(&myMessage)
         if err != nil {
            fmt.Println("decode statusData err: ", err)
            return err
         }

         // 如果是上游节点发来的握手信息,我们就注册这个上游节点,并把它的最新status给保存下来
         // 其中最重要的是Current Block和TD,下游节点会通过这两个值来决定是否需要同步
         // 如果是下游节点发来的握手信息,我们也注册这个节点
         pxy.lock.Lock()
         if p.ID() == pxy.upstreamNode.ID {
            pxy.upstreamState = myMessage
            pxy.upstreamConn = &conn{p, rw}
         } else {
            pxy.downstreamConn = &conn{p, rw}
         }
         pxy.lock.Unlock()

         // 发回一个握手消息包,注意,我们发回的握手消息包和收到的上游节点的握手消息包一致,因为我们“代理”了这个上游节点嘛
         err = p2p.Send(rw, eth.StatusMsg, &statusData{
            ProtocolVersion: myMessage.ProtocolVersion,
            NetworkId:       myMessage.NetworkId,
            TD:              pxy.upstreamState.TD,
            CurrentBlock:    pxy.upstreamState.CurrentBlock,
            GenesisBlock:    myMessage.GenesisBlock,
         })

         if err != nil {
            fmt.Println("handshake err: ", err)
            return err
         }
      } else if msg.Code == eth.NewBlockMsg {  // 如果收到的是上游节点发来的新区块广播的消息
         var myMessage newBlockData
         err = msg.Decode(&myMessage)
         if err != nil {
            fmt.Println("decode newBlockMsg err: ", err)
         }

         // 把代理的status更新为最新的TD和CurrentBlock,以便下游节点同步
         pxy.lock.Lock()
         if p.ID() == pxy.upstreamNode.ID {
            pxy.upstreamState.CurrentBlock = myMessage.Block.Hash()
            pxy.upstreamState.TD = myMessage.TD
         } 
         pxy.lock.Unlock()

         // 由于我们已经把msg解包了,需要重新打包一下才能relay给下游节点
         size, r, err := rlp.EncodeToReader(myMessage)
         if err != nil {
            fmt.Println("encoding newBlockMsg err: ", err)
         }
         // 把新区块的消息传递给下游节点
         relay(p, p2p.Msg{Code: eth.NewBlockMsg, Size: uint32(size), Payload: r})
      } else {
         // 其余的消息,一律不解包,直接传递给另一边的节点
         relay(p, msg)
      }
   }

   return nil
}

最后来看传递消息包的方法:

func relay(p *p2p.Peer, msg p2p.Msg) {
   var err error
   pxy.lock.RLock()
   defer pxy.lock.RUnlock()
   
   // 如果是上游节点发来的消息,直接通过conn封装中的连接发给下游节点,反之亦然
   if p.ID() != pxy.upstreamNode.ID && pxy.upstreamConn != nil {
      err = pxy.upstreamConn.rw.WriteMsg(msg)
   } else if p.ID() == pxy.upstreamNode.ID && pxy.downstreamConn != nil {
      err = pxy.downstreamConn.rw.WriteMsg(msg)
   } else {
      fmt.Println("One of upstream/downstream isn't alive: ", pxy.srv.Peers())
   }

   if err != nil {
      fmt.Println("relaying err: ", err)
   }
}

到这里,一个基本的“占座”代理服务器就写好了。再也不用担心因为座位被抢了~

完整代码请戳 xiaoyao1991/manspreading


作者简介:

老钱(BS'14, MS'18), UIUC计算机科学系硕士。师承Zcash Andrew Miller。3年互联网工作经验。目前担任NAD Grid(nadgrid.com)技术合伙人。NAD Grid互联能源世界。