NEO从源码分析看网络通信
2019獨角獸企業(yè)重金招聘Python工程師標準>>>
0x00 前言
NEO被稱為中國版的Ethereum,支持C#和java開發(fā),并且在社區(qū)的努力下已經(jīng)把SDK拓展到了js,python等編程環(huán)境,所以進行NEO開發(fā)的話是沒有太大語言障礙的。 比特幣在解決拜占庭錯誤這個問題時除了引入了區(qū)塊鏈這個重要的概念之外,還引入了工作量證明(PoW)這個機智的解決方案,通過數(shù)學意義上的難題來保證每個區(qū)塊創(chuàng)建都需要付出計算量。然而實踐已經(jīng)證明,通過計算來提供工作量證明,實在是太浪費:全世界所有的完全節(jié)點都進行同樣的計算,然而只有一個節(jié)點計算出的結(jié)果會被添加到區(qū)塊鏈中,其余節(jié)點計算消耗的電力就都白白浪費了。尤其,工作量證明存在一個51%的可能攻擊方案,就是說只要有人掌握了世界上超過50%的算力,那么他就可以對比特幣這個系統(tǒng)進行攻擊,重置區(qū)塊鏈。中本聰先生發(fā)明這個算力工作量證明方法的時候大概沒有料到會有人專門為了挖礦開發(fā)出ASIC礦機。 NEO在解決這些問題的時候提出了一個新的共享機制DBFT 全稱為 Delegated Byzantine Fault Tolerant。NEO將節(jié)點分為兩種,一種為普通節(jié)點,不參與共識,也就是不進行認證交易簽名區(qū)塊的過程。另一種是則是共識節(jié)點。顧名思義,就是可以參與共識的節(jié)點,這部分基礎概念可以參考官方文檔。 接下來我將會以一系列的博客來從源碼層面上對NEO進行分析。 而本文主要進行的是源碼層級的NEO網(wǎng)絡通信協(xié)議分析。
0x01 源碼概覽
本文分析的源碼位于這里,通過git命令下載到本地:
git clone https://github.com/neo-project/neo.git我是用的編譯器是VS2017社區(qū)版。打開neo項目之后可以看到項目根目錄文件結(jié)構(gòu):
- Consensus 共識節(jié)點間共識協(xié)議
- Core neo核心
- Cryptography 加密方法
- Implementations 數(shù)據(jù)存儲以及錢包的實現(xiàn)
- IO NEO的io類
- Network 用于p2p網(wǎng)絡通信的方法
- SmartContract NEO智能合約的相關(guān)類
整個項目代碼量不算很大,尤其是項目本身是C#高級語言編寫,所以代碼很容易讀懂。
0x02 消息
在NEO網(wǎng)絡中,所有的消息都以Message為單位進行傳輸,Message的定義在Message.cs文件中,其結(jié)構(gòu)如下:
- Magic 字段用來確定當前節(jié)點是運行在正式網(wǎng)絡還是在測試網(wǎng)絡,如果是0x00746e41則為正式網(wǎng),如果是0x74746e41則為測試網(wǎng)。
- _Command_命令的內(nèi)容是直接使用的字符串,所以沒有進行嚴格定義,在所有使用到的地方都是直接使用的字符串。這里給我的感覺是依賴特別嚴重,應該先定義好命令再在別的地方調(diào)用。雖然沒有明說都有哪些命令,但是對消息路由的代碼里我們可以找到所有使用到的命令:
源碼位置:neo/Network/RemoteNode.cs/OnMessageReceived
switch (message.Command){case "addr": case "block": case "consensus":case "filteradd":case "filterclear":case "filterload":case "getaddr":case "getblocks":case "getdata":case "getheaders":case "headers":case "inv":case "mempool":case "tx":case "verack":case "version":case "alert":case "merkleblock":case "notfound":case "ping":case "pong":case "reject":}以上源碼中的對命令的處理部分我都刪掉了,這個不是本小節(jié)討論重點。通過分析代碼可以知道,消息種類大致22種。 消息的具體內(nèi)容在序列化之后存在在Message里的payload字段中。
在所有的消息類型中有一類消息非常特殊,這就是與賬本相關(guān)的三種消息:賬目消息(Block),共識消息(Consensus)以及交易消息(Transaction)。這三中消息分別對應系統(tǒng)中的三個類:
- neo/Core/Block
- neo/Core/Transaction
- neo/Network.Payloads/ConsensusPayload
這三個類都實現(xiàn)了接口IInventory,我把inventory翻譯為賬本,把實現(xiàn)了IInventory接口的類成為賬本類,消息稱為賬本消息。IInventory接口定義了消息的哈希值Hash用來存放簽名、賬本消息類型InventoryType用來保存消息類型以及一個驗證函數(shù)verify用來對消息進行驗證,也就是說所有的賬本消息都需要包含簽名,并且需要驗證。 賬本消息的類型定義在InventoryType.cs文件中:
源碼位置:neo/Network/InventoryType.cs
/// 交易TX = 0x01,/// 區(qū)塊Block = 0x02,/// 共識數(shù)據(jù)Consensus = 0xe0對共識部分的消息感興趣的可以查看我的另一篇博客NEO從源碼分析看共識協(xié)議,本文僅僅關(guān)注于交易通信和普通節(jié)點的區(qū)塊同步。
每個RemoteNode內(nèi)部都有兩個消息隊列,一個高優(yōu)先級隊列和一個低優(yōu)先級隊列,高優(yōu)先級隊列主要負責:
- "alert"
- "consensus"
- "filteradd"
- "filterclear"
- "filterload"
- "getaddr"
- "mempool"
這幾個命令,其余的命令都由低優(yōu)先級隊列負責。 發(fā)送命令的任務由StartSendLoop方法負責,在這個方法中有一個while循環(huán),在每一輪循環(huán)中都會首先檢測高優(yōu)先級隊列是否為空,如果不為空則先發(fā)送高優(yōu)先命令,否則發(fā)送低優(yōu)先級任務,循環(huán)中的核心源碼如下:
源碼位置:neo/Netwotk/RemoteNode.cs/StartSendLoop
Message message = null;lock (message_queue_high){//高優(yōu)先級消息隊列不為空if (message_queue_high.Count > 0){message = message_queue_high.Dequeue();}}//若沒有高優(yōu)先級任務if (message == null){lock (message_queue_low){if (message_queue_low.Count > 0){//獲取低優(yōu)先級任務message = message_queue_low.Dequeue();}}}由于每個RemoteNode對象都只負責和一個相對應的遠程節(jié)點通信,所以接收消息的地方?jīng)]有設置消息緩存隊列。接收消息的循環(huán)就在調(diào)用StartSendLoop位置的下面,由于StartSendLoop本身是個異步方法,所以不會阻塞代碼的接收消息循環(huán)的執(zhí)行,在每次收到消息后,都會觸發(fā)OnMessageReceived方法,并將收到的message消息作為參數(shù)傳遞過去。在上文中也講了,這個OnMessageReceived方法其實是個消息的路由器來著,會根據(jù)消息類型的不同調(diào)用響應的處理函數(shù)。
0x03 新節(jié)點組網(wǎng)
節(jié)點是組成NEO網(wǎng)絡的基本單位,所以一切都從本地節(jié)點接入neo網(wǎng)絡開始講起。 NEO在Network文件夾下有一個LocalNode的類,這個類的主要工作是與p2p網(wǎng)絡建立并管理與遠程節(jié)點連接,通過其內(nèi)部的RemoteNode對象列表與遠程節(jié)點進行通信。 LocalNode在Start方法中創(chuàng)建了新的線程,在新線程中向預設的服務器請求網(wǎng)絡中節(jié)點的地址信息,之后將本地的服務器地址及端口發(fā)送到遠程服務器去以便別的節(jié)點可以找到自己。
源碼位置:neo/Network/LocalNode.cs/Start
Task.Run(async () =>{if ((port > 0 || ws_port > 0)&& UpnpEnabled&& LocalAddresses.All(p => !p.IsIPv4MappedToIPv6 || IsIntranetAddress(p))&& await UPnP.DiscoverAsync()){try{LocalAddresses.Add(await UPnP.GetExternalIPAsync()); //添加獲取到的網(wǎng)絡中節(jié)點信息if (port > 0)await UPnP.ForwardPortAsync(port, ProtocolType.Tcp, "NEO"); //向服務器注冊本地節(jié)點if (ws_port > 0)await UPnP.ForwardPortAsync(ws_port, ProtocolType.Tcp, "NEO WebSocket");}catch { }}connectThread.Start(); //開啟線程與網(wǎng)絡中節(jié)點建立連接poolThread?.Start();if (port > 0){listener = new TcpListener(IPAddress.Any, port); //開啟服務,監(jiān)聽網(wǎng)絡中的廣播信息listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);try{listener.Start(); //開啟端口,監(jiān)聽連接請求Port = (ushort)port;AcceptPeers(); //處理p2p網(wǎng)絡中的socket連接請求}catch (SocketException) { }}if (ws_port > 0){ws_host = new WebHostBuilder().UseKestrel().UseUrls($"http://*:{ws_port}").Configure(app => app.UseWebSockets().Run(ProcessWebSocketAsync)).Build();ws_host.Start();}});通過代碼可以看到,在成功獲取到節(jié)點信息并在服務器中注冊過之后,節(jié)點會開啟一個線程,并在線程中與這些節(jié)點建立連接,建立連接在LocalNode類中最終的接口是ConnectToPeerAsync方法,在ConnectToPeerAsync方法中根據(jù)接收到的遠程節(jié)點地址和端口信息新建一個TcpRemoteNode類的對象:
源碼位置:neo/Network/LocalNode.cs/ConnectToPeerAsync
//新建遠程節(jié)點對象TcpRemoteNode remoteNode = new TcpRemoteNode(this, remoteEndpoint);if (await remoteNode.ConnectAsync()){OnConnected(remoteNode);}TcpRemoteNode類繼承自RemoteNode,每個對象都代表著一個與自己建立連接的遠程節(jié)點,RemoteNode和LocalNode的關(guān)系大致可以這樣表示:
TcpRemoteNode的構(gòu)造函數(shù)在接收到遠程節(jié)點信息之后會與遠程節(jié)點建立socket連接并返回一個RemoteNode對象,所有的遠程節(jié)點對象都被保存在LocalNode中的遠程節(jié)點列表里。
獲取網(wǎng)絡節(jié)點的方式除了從NEO服務器獲取之外還有一個主動獲取的方式,那就是向所有的與本地節(jié)點建立連接的節(jié)點廣播網(wǎng)絡節(jié)點請求,通過獲取這些與遠程節(jié)點建立連接的節(jié)點列表來實時獲取整個網(wǎng)絡中的節(jié)點信息。這部分代碼在與遠程節(jié)點建立連接的線程中:
源碼位置:neo/Network/LocalNode.cs/ConnectToPeersLoop
lock (connectedPeers){foreach (RemoteNode node in connectedPeers)node.RequestPeers();}向遠程節(jié)點請求節(jié)點列表的RequestPeers方法在RemoteNode類中,這個方法通過向遠程節(jié)點發(fā)送指令“getaddr”來獲取。 由于RemoteNode的責任是與其對應的遠程節(jié)點進行通信,所以對“getaddr”這個遠程命令的解析和路由也是在RemoteNode類中進行。在RemoteNode接收到遠程節(jié)點信息后會觸發(fā)OnMessageReceived方法對收到的信息進行解析和路由:
源碼位置:neo/Network/RemoteNode.cs
/// <summary>/// 對接收信息進行路由/// </summary>/// <param name="message"></param>private void OnMessageReceived(Message message){switch (message.Command){case "getaddr":OnGetAddrMessageReceived();break;//代碼省略}}switch中對于別的命令的解析我都刪掉了,這里只關(guān)注“getaddr”命令。在收到“getaddr”命令后,會調(diào)用相應的處理函數(shù)OnGetAddrMessageReceived:
源碼位置:neo/Network/RemoteNode.cs/OnGetAddrMessageReceived
AddrPayload payload;lock (localNode.connectedPeers){const int MaxCountToSend = 200;// 獲取本地連接節(jié)點IEnumerable<RemoteNode> peers = localNode.connectedPeers.Where(p => p.ListenerEndpoint != null && p.Version != null);if (localNode.connectedPeers.Count > MaxCountToSend){Random rand = new Random();peers = peers.OrderBy(p => rand.Next());}peers = peers.Take(MaxCountToSend);payload = AddrPayload.Create(peers.Select(p => NetworkAddressWithTime.Create(p.ListenerEndpoint, p.Version.Services, p.Version.Timestamp)).ToArray());}EnqueueMessage("addr", payload);由于直接與遠程節(jié)點進行通信的是與其對應的本地的RemoteNode對象,而這些對象有需要獲取LocalNode中保存的信息,NEO源碼的處理方式是直接在創(chuàng)建RemoteNode對象的時候傳入LocalNode的引用,這里我感覺很不舒服,因為明顯有循環(huán)引用,盡管在這里功能上不會有什么問題。 因為每個節(jié)點既做為客戶端,又作為服務端,與本節(jié)點建立的網(wǎng)絡連接里,即存在自己主動發(fā)起的socket連接,也存在遠程節(jié)點將本節(jié)點作為服務端而建立的socket連接。 監(jiān)聽socket連接的任務在線程中不斷的執(zhí)行,每當接收到一個新的socket連接,當前節(jié)點會根據(jù)這個socket來創(chuàng)建一個新的TcpRemoteNode對象并保存在LocalNode的遠程節(jié)點列表中:
源碼位置:neo/Network/LocalNode.cs/AcceptPeers
TcpRemoteNode remoteNode = new TcpRemoteNode(this, socket);OnConnected(remoteNode);最后以三個節(jié)點的網(wǎng)絡拓撲為例:
0x04 區(qū)塊同步
新區(qū)快的生成與同步主要依靠共識完成后的廣播,但是對于新組網(wǎng)的節(jié)點應該如何獲取完整的區(qū)塊鏈呢?本小節(jié)將針對這個問題進行源碼的分析。
當一個新的RemoteNode對象創(chuàng)建之后,會開啟這個對象的protocal: 源碼位置:neo/Network/LocalNode.cs
private void OnConnected(RemoteNode remoteNode){lock (connectedPeers){connectedPeers.Add(remoteNode);}remoteNode.Disconnected += RemoteNode_Disconnected;//斷開連接通知remoteNode.InventoryReceived += RemoteNode_InventoryReceived;//賬單消息通知remoteNode.PeersReceived += RemoteNode_PeersReceived;//節(jié)點列表信息通知remoteNode.StartProtocol();//開啟通信協(xié)議}在協(xié)議開始執(zhí)行后,會向遠程節(jié)點發(fā)送一個 "version" 命令。在查詢這個 "version" 命令的響應方法的時候簡直把我嚇了一大跳,居然調(diào)用的是Disconnect而且傳的參數(shù)是true。本著“新連接建立之后的第一件事肯定不會是斷開連接”這個唯物主義價值觀,我又對代碼進行了一番研究,終于發(fā)現(xiàn)這個發(fā)送 “version” 的命令是直接由ReceiveMessageAsync方法獲取的,也就是不經(jīng)過那個消息路由。由于在兩個節(jié)點建立連接后。兩者做的第一件事都是發(fā)送 “version” 命令和自己的VersionPayload過去,所以在這個socket連接中節(jié)點接收到的第一條消息也都是“version”類型的消息。
源碼位置:neo/Network/RemoteNode.cs/StartProtocol
if (!await SendMessageAsync(Message.Create("version", VersionPayload.Create(localNode.Port, localNode.Nonce, localNode.UserAgent))))return; Message message = await ReceiveMessageAsync(HalfMinute);這里需要對這個VersionPayload進行下講解,這個VersionPayload里包含當前節(jié)點的狀態(tài)信息:
也就是說在連接建立后,當前節(jié)點就可以知道遠程節(jié)點當前的區(qū)塊鏈高度,如果自己當前的區(qū)塊鏈高度低于遠程節(jié)點,就會向遠程節(jié)點發(fā)送 "getblocks" 命令請求區(qū)塊鏈同步: 源碼位置:neo/Network/RemoteNode.cs/StartProtocol
if (missions.Count == 0 && Blockchain.Default.Height < Version.StartHeight) {EnqueueMessage("getblocks", GetBlocksPayload.Create(Blockchain.Default.CurrentBlockHash)); }因為區(qū)塊鏈有非常大的數(shù)據(jù)量,區(qū)塊鏈同步不可能直接一次完成,每次收到 “getblocks”的命令之后,每次發(fā)送500個區(qū)塊的哈希值:
源碼位置:neo/Network/RemoteNode.cs/OnGetBlocksMessageReceived
List<UInt256> hashes = new List<UInt256>();do{hash = Blockchain.Default.GetNextBlockHash(hash);if (hash == null) break;hashes.Add(hash);} while (hash != payload.HashStop && hashes.Count < 500);EnqueueMessage("inv", InvPayload.Create(InventoryType.Block, hashes.ToArray()));之后在每次接收到遠程節(jié)點的消息之后,如果當前節(jié)點區(qū)塊高度依然小于遠程節(jié)點,本地節(jié)點會繼續(xù)發(fā)送區(qū)塊鏈同步請求,直到與遠程節(jié)點的區(qū)塊鏈同步。
捐贈地址(NEO):ASCjW4xpfr8kyVHY1J2PgvcgFbPYa1qX7F
轉(zhuǎn)載于:https://my.oschina.net/u/2276921/blog/1622015
與50位技術(shù)專家面對面20年技術(shù)見證,附贈技術(shù)全景圖總結(jié)
以上是生活随笔為你收集整理的NEO从源码分析看网络通信的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C++ STL vector(向量)
- 下一篇: 基于Fork/Join框架实现对大型浮点