完整代码请戳 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互联能源世界。