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()));
複製程式碼
之後在每次接收到遠端節點的訊息之後,如果當前節點區塊高度依然小於遠端節點,本地節點會繼續傳送區塊鏈同步請求,直到與遠端節點的區塊鏈同步。
:ASCjW4xpfr8kyVHY1J2PgvcgFbPYa1qX7F
群交流:795681763
原文:https://my.oschina.net/u/2276921/blog/1622015