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