----紅亞大學鏈:yjh、bjgpdn
在比特幣網絡中,節點之間需要經常的進行消息交換,以保證區塊鏈同步,比如向對方發送版本version消息,查看頂端區塊hash的getblock消息以及傳播區塊的block消息。在比特幣源碼解讀-P2P網絡一文中,我們已經說明了節點之間是如何連接,接收和發送消息的。本文着重講述這些消息的內容和意義及比特幣0.01源碼中如何處理收到的消息。
1. 信息交換的過程
1.1 消息的格式
消息=消息頭+數據,具體格式如下表:
其中Message start字段是固定的,在源碼中的定義是:
static const char pchMessageStart[4] = { 0xf9, 0xbe, 0xb4, 0xd9 };
1.2 消息的類型
1.3 消息交互過程
1.3.1 節點連接
當建立一個或多個節點後新節點將一條包含自身IP地址的addr消息發送給其相鄰接點。相鄰接點再將這條addr消息轉發給他們的相鄰節點,保證新節點被更多的節點所接受。新加入的節點還可以像相鄰節點發送getaddr節點消息來請求相鄰接點已知的的對等節點IP。
1.3.2 庫存清單交換
首先區塊鏈分爲全節點和非全節點,全節點是指存儲了比特幣完整區塊數據庫的節點,還有一些節點沒有能力去存儲這些節點,只能進行交易,只能做簡易驗證,且依賴全節點,這種叫做非全節點。
⼀個全節點連接到對等節點之後,第⼀件要做的事情就是構建完整的區塊鏈。該節點首先向相鄰節點發送version消息,該消息中包含BestHeight字段標示了自己的區塊高度。通過互相發送version消息,對等節點就可得知雙方的區塊數量。
對等節點還會發送getblocks消息,該消息中包含了本節點保存的區塊鏈頂端的區塊hash值,如果一個節點收到的hash在自己的區塊鏈中不屬於頂部,那麼就代表自己的鏈比較長。
擁有更長區塊鏈的節點會識別出其他節點需要補充的塊,從而發送庫存inv(inventory)消息,這個消息會包含塊的hash值,從而告知其他節點這些塊的存在。收到庫存消息的節點發現自己缺少塊,就會向周圍節點(未必是發送庫存節點)發送getdata消息,來請求具體某些塊的數據,從而補全自己。收到getdata消息的節點再把節點請求的塊數據發送出去。
2. 消息處理源碼解析
2.1 消息處理函數
比特幣源碼專門啓動一個線程來輪詢每個節點,處理他們的消息。
下面是比特幣0.01源碼中線程函數ThreadOpenConnections函數解讀:
loop
{
// 輪詢鏈接的節點用於消息處理
vector<CNode*> vNodesCopy;
CRITICAL_BLOCK(cs_vNodes)
vNodesCopy = vNodes;
foreach(CNode* pnode, vNodesCopy)
{
pnode->AddRef();
// Receive messages
TRY_CRITICAL_BLOCK(pnode->cs_vRecv)
ProcessMessages(pnode);
// Send messages
TRY_CRITICAL_BLOCK(pnode->cs_vSend)
SendMessages(pnode);
pnode->Release();
}
......
}
這段程序的邏輯主體就是循環遍歷vNodes節點鏈表,並對每個節點分別進行接收消息的處理和發送消息的處理。增加引用和釋放引用的目的是防止在消息處理的過程中,節點被丟進節點斷開連接池。下面分別對消息接收和消息發送進行講解。
2.2 接收消息的處理
下面是接收消息處理函數ProcessMessages內容的解析:
CDataStream& vRecv = pfrom->vRecv;
if (vRecv.empty())
return true;
首先取得節點的接收緩衝區中的信息到vRecv數據流中。
以下內容爲程序主體,位於loop循環中:
CDataStream::iterator pstart = search(vRecv.begin(),vRecv.end(),BEGIN(pchMessageStart),END(pchMessageStart));
// 刪除無效的消息: 就是在對應的消息開始前面還有一些信息
if (vRecv.end() - pstart < sizeof(CMessageHeader))
{
if (vRecv.size() > sizeof(CMessageHeader))
{
printf("\n\nPROCESSMESSAGE MESSAGESTART NOT FOUND\n\n");
vRecv.erase(vRecv.begin(), vRecv.end() - sizeof(CMessageHeader));
}
break;
}
if (pstart - vRecv.begin() > 0)
printf("\n\nPROCESSMESSAGE SKIPPED %d BYTES\n\n", pstart - vRecv.begin());
vRecv.erase(vRecv.begin(), pstart); // 移除消息開始信息和接收緩衝區開頭之間
使用search函數找到Message star開始的位置,刪除消息頭之前的無效信息。
CMessageHeader hdr;
vRecv >> hdr; // 指針已經偏移了
if (!hdr.IsValid())
{
printf("\n\nPROCESSMESSAGE: ERRORS IN HEADER %s\n\n\n", hdr.GetCommand().c_str());
continue;
}
string strCommand = hdr.GetCommand();
創建一個消息頭對象hdr,將vRecv中的序列化數據反序列化到hdr中,然後判斷消息頭是否合理,主要是判斷Messagestar字段是否正確,命令字段是否符合規範,消息大小是否過大。之後獲得命令字符串strCommand。值得注意的是vRecv的>>調用會隨着讀取數據而偏移讀取指針,這裏在讀取完頭部信息後,讀取指針就會指向數據域的開始。下次再開始時會直接讀取數據域。
unsigned int nMessageSize = hdr.nMessageSize;
if (nMessageSize > vRecv.size())
{
vRecv.insert(vRecv.begin(), BEGIN(hdr), END(hdr));
Sleep(100);
break;
}
如果消息的真正大小小於nMessageSize,插入一個消息頭,並退出循環
CDataStream vMsg(vRecv.begin(), vRecv.begin() + nMessageSize, vRecv.nType, vRecv.nVersion);
vRecv.ignore(nMessageSize);
將消息複製到vMsg中,並將vRecv的讀取指針向後偏移nMessageSize的長度(相當於讀取完)。
static map<unsigned int, vector<unsigned char> > mapReuseKey;
// 消息採集頻率進行處理
if (nDropMessagesTest > 0 && GetRand(nDropMessagesTest) == 0)
{
printf("dropmessages DROPPING RECV MESSAGE\n");
return true;
}
這裏有個消息採集頻率的問題,消息不是每次都會被處理,而是有個概率值。
以上是消息的預處理內容,主要針對消息的格式進行檢查。
下面是針對消息內容進行相應操作。
if (strCommand == "version")
{
// 節點對應的版本只能更新一次,初始爲0
if (pfrom->nVersion != 0)
return false;
int64 nTime;
CAddress addrMe; // 讀取消息對應的內容
vRecv >> pfrom->nVersion >> pfrom->nServices >> nTime >> addrMe;
if (pfrom->nVersion == 0)
return false;
// 更新發送和接收緩衝區中的對應的版本
pfrom->vSend.SetVersion(min(pfrom->nVersion, VERSION));
pfrom->vRecv.SetVersion(min(pfrom->nVersion, VERSION));
// 如果節點對應的服務類型是節點網絡,則對應節點的客戶端標記就是false
pfrom->fClient = !(pfrom->nServices & NODE_NETWORK);
if (pfrom->fClient)
{
// 如果不是節點網絡,可能僅僅是一些節點不要保存對應的完整區塊信息,僅僅需要區塊的頭部進行校驗就可以了
pfrom->vSend.nType |= SER_BLOCKHEADERONLY;
pfrom->vRecv.nType |= SER_BLOCKHEADERONLY;
}
// 增加時間樣本數據:沒有什麼用處,這函數好像壓根沒執行
AddTimeData(pfrom->addr.ip, nTime);
// 對第一個進來的節點請求block信息
static bool fAskedForBlocks;
if (!fAskedForBlocks && !pfrom->fClient)
{
fAskedForBlocks = true;
pfrom->PushMessage("getblocks", CBlockLocator(pindexBest), uint256(0));
}
}
else if (pfrom->nVersion == 0)
return false;
如果命令字符串的內容是”version”,說明這是version消息。本地存儲的其他節點的版本信息初試爲0,且只能更新一次。所以如果該節點的版本已經不是0了,則version消息無效,直接返回。之後更新發送和接收緩區的版本信息。設置節點客戶端標記,如果是網絡節點,則代表這些節點不是完整節點,可能是建議客戶端,則設置客戶端標記fClient爲true,然後將節點緩衝區類型設置爲僅區塊頭。
設置靜態局部標記 fAskedForBlocks,這麼寫保證了只對第一個連接到的節點發送getblock消息,推送getblock消息到該節點緩衝區。這個消息後續會講到。
另外節點在處理任何version外消息之前版本號必須不爲零。
else if (strCommand == "addr")
{
vector<CAddress> vAddr;
vRecv >> vAddr;
// Store the new addresses
CAddrDB addrdb;
foreach(const CAddress& addr, vAddr)
{
if (fShutdown)
return true;
// 將地址增加到數據庫中
if (AddAddress(addrdb, addr))
{
// Put on lists to send to other nodes
pfrom->setAddrKnown.insert(addr); // 將對應的地址插入到已知地址集合中
CRITICAL_BLOCK(cs_vNodes)
foreach(CNode* pnode, vNodes)
if (!pnode->setAddrKnown.count(addr))
pnode->vAddrToSend.push_back(addr);// 地址的廣播
}
}
}
如果命令字符串的內容是”addr”,則說明這是地址分享的消息,節點可通過該消息發送一個地址列表給其他節點。首先將序列化的數據反序列化到vAddr中,遍歷該地址數組。使用AddAddree函數將該地址加入到數據庫。並將該地址插入到這個節點的setAddrKonwn。這之後遍歷節點,把這個地址放入節點待發送地址列表,等待廣播。
else if (strCommand == "inv")
{
vector<CInv> vInv;
vRecv >> vInv;
CTxDB txdb("r");
foreach(const CInv& inv, vInv)
{
if (fShutdown)
return true;
pfrom->AddInventoryKnown(inv); // 將對應的庫存發送消息增加到庫存發送已知中
bool fAlreadyHave = AlreadyHave(txdb, inv);
if (!fAlreadyHave)
pfrom->AskFor(inv);// 如果不存在,則請求諮詢,這裏會在線程中發送getdata消息
else if (inv.type == MSG_BLOCK && mapOrphanBlocks.count(inv.hash))
pfrom->PushMessage("getblocks", CBlockLocator(pindexBest), GetOrphanRoot(mapOrphanBlocks[inv.hash]));
}
}
如果命令字符串的內容是”inv”,則說明這個是發送庫存的命令。擁有更長區塊鏈的對等節點塊的對等節點可以察覺出自己擁有更多區塊,它會識別出第一批可供分享的500個區塊並會把這些區塊hash值傳播出去。庫存CInv類的hash成員保存的就是區塊hash。輸入本地節點發現這個庫存是自己不知道,那麼就會將這個庫存存入到待請求列表。線程後續將會發送getblock消息來請求塊具體信息。
else if (strCommand == "getdata")
{
vector<CInv> vInv;
vRecv >> vInv;
foreach(const CInv& inv, vInv)
{
if (fShutdown)
return true;
printf("received getdata for: %s\n", inv.ToString().c_str());
if (inv.type == MSG_BLOCK)
{
// Send block from disk
map<uint256, CBlockIndex*>::iterator mi = mapBlockIndex.find(inv.hash);
if (mi != mapBlockIndex.end())
{
CBlock block;
block.ReadFromDisk((*mi).second, !pfrom->fClient);
pfrom->PushMessage("block", block);// 獲取數據對應的類型是block,則發送對應的塊信息
}
}
else if (inv.IsKnownType())
{
// Send stream from relay memory
CRITICAL_BLOCK(cs_mapRelay)
{
map<CInv, CDataStream>::iterator mi = mapRelay.find(inv); // 重新轉播的內容
if (mi != mapRelay.end())
pfrom->PushMessage(inv.GetCommand(), (*mi).second);
}
}
}
}
如果命令字符串的內容是”getdata”,則說明這是請求數據消息。該消息可以向其他節點請求某些數據信息。
將數據域信息反序列化到一個庫存數組。遍歷這個庫存數組,如果庫存的類型是MSG_BLOCK,代表請求的是塊具體數據。節點查看本地是否有這個庫存對應的塊信息,如果有,將會推送block消息,線程後續會將塊消息發送給請求節點。如果消息是其他已知類型,則發送轉播表中對應該庫存的內容。
else if (strCommand == "getblocks")
{
CBlockLocator locator;
uint256 hashStop;
vRecv >> locator >> hashStop;
//找到本地有的且在主鏈上的
CBlockIndex* pindex = locator.GetBlockIndex();
// 將匹配得到的塊索引之後的所有在主鏈上的塊發送出去
if (pindex)
pindex = pindex->pnext;
printf("getblocks %d to %s\n", (pindex ? pindex->nHeight : -1), hashStop.ToString().substr(0,14).c_str());
for (; pindex; pindex = pindex->pnext)
{
if (pindex->GetBlockHash() == hashStop)
{
printf(" getblocks stopping at %d %s\n", pindex->nHeight, pindex->GetBlockHash().ToString().substr(0,14).c_str());
break;
}
// Bypass setInventoryKnown in case an inventory message got lost
CRITICAL_BLOCK(pfrom->cs_inventory)
{
CInv inv(MSG_BLOCK, pindex->GetBlockHash());
// 判斷在已知庫存2中是否存在
if (pfrom->setInventoryKnown2.insert(inv).second)
{
pfrom->setInventoryKnown.erase(inv);
pfrom->vInventoryToSend.push_back(inv);// 插入對應的庫存發送集合中準備發送,在另一個線程中進行發送,發送的消息爲inv
}
}
}
}
如果命令字符串的內容是”getblock”,是庫存比較信息。對等節點之間互相發送該信息,當節點發現收到的getblock中的hash在自己的區塊鏈中不是頂端,則說明自己的區塊鏈比較長,於是向較短節點發送inv信息。
首先將數據反序列到locator和hashStop對象中,分別對應初始塊和結尾塊。本節點將從loactor所對應區塊索引還是向後遍歷鏈表,並生成inv,等待發送。
else if (strCommand == "tx")
{
vector<uint256> vWorkQueue;
CDataStream vMsg(vRecv);
CTransaction tx;
vRecv >> tx;
CInv inv(MSG_TX, tx.GetHash());
pfrom->AddInventoryKnown(inv);// 將交易消息放入到對應的已知庫存中
bool fMissingInputs = false;
// 如果交易能夠被接受
if (tx.AcceptTransaction(true, &fMissingInputs))
{
AddToWalletIfMine(tx, NULL);
RelayMessage(inv, vMsg);// 轉播消息
mapAlreadyAskedFor.erase(inv);
vWorkQueue.push_back(inv.hash);
// 遞歸處理所有依賴這個交易對應的孤兒交易
for (int i = 0; i < vWorkQueue.size(); i++)
{
uint256 hashPrev = vWorkQueue[i];
for (multimap<uint256, CDataStream*>::iterator mi = mapOrphanTransactionsByPrev.lower_bound(hashPrev);
mi != mapOrphanTransactionsByPrev.upper_bound(hashPrev);
++mi)
{
const CDataStream& vMsg = *((*mi).second);
CTransaction tx;
CDataStream(vMsg) >> tx;
CInv inv(MSG_TX, tx.GetHash());
if (tx.AcceptTransaction(true))
{
printf(" accepted orphan tx %s\n", inv.hash.ToString().substr(0, 6).c_str());
AddToWalletIfMine(tx, NULL);
RelayMessage(inv, vMsg);
mapAlreadyAskedFor.erase(inv);
vWorkQueue.push_back(inv.hash);
}
}
}
foreach(uint256 hash, vWorkQueue)
EraseOrphanTx(hash);
}
else if (fMissingInputs)
{
printf("storing orphan tx %s\n", inv.hash.ToString().substr(0, 6).c_str());
AddOrphanTx(vMsg); // 如果交易當前不被接受則對應的孤兒交易
}
}
如果命令字符串的內容是”tx”,則是交易信息。將交易信息反序列化到CTransaction類中。創建MSG_TX庫存,把交易hash填入該庫存。之後把庫存放入已知庫存表中。如果這筆交易信息符合規定(AcceptTransaction,主要是判斷交易內容合不合適是不是已花費什麼的),之後如果當前交易屬於本節點,則將當前交易加入到錢包中。這之後後還要轉播這條交易的庫存信息。
之後將這個交易放入工作棧中,從孤兒交易(溯源失敗的交易)池中遞歸檢驗所有依賴這個交易的孤兒交易,成功則刪除該孤兒交易(這個交易不再孤兒了)。如果這筆交易不能被接受,則將該交易放入孤兒交易池mapOrphanTransactions中。
else if (strCommand == "review")
{
CDataStream vMsg(vRecv);
CReview review;
vRecv >> review;
CInv inv(MSG_REVIEW, review.GetHash());
pfrom->AddInventoryKnown(inv)
if (review.AcceptReview())
{
RelayMessage(inv, vMsg);
mapAlreadyAskedFor.erase(inv);
}
}
如果命令字符串的內容是”review”,則是審查消息,將數據反序列化到CReview結構體中,如果這個審查消息的的檢查是正確的,則放入待轉發消息池。
else if (strCommand == "block")
{
auto_ptr<CBlock> pblock(new CBlock);
vRecv >> *pblock;
CInv inv(MSG_BLOCK, pblock->GetHash());
pfrom->AddInventoryKnown(inv);// 增加庫存
if (ProcessBlock(pfrom, pblock.release()))
mapAlreadyAskedFor.erase(inv);
}
如果命令字符串的內容是”block”,則代表是塊消息。首先將數據放入到一個CBlock結構體中。首先要把塊類型庫存存入節點庫存表,然後處理塊。
處理的主要過程是先檢查塊的格式,包含的交易是否正確。然後看這個塊的前置塊的索引是否在本地查得到,查不到就作爲孤兒塊保存,並向發來該塊的節點請求該塊的前置塊數據。如果這個塊的前置塊可以在塊索引中找到,但是該塊的塊索引卻不存在,則存儲該塊到數據庫。
else if (strCommand == "getaddr")
{
pfrom->vAddrToSend.clear();
//// need to expand the time range if not enough found
int64 nSince = GetAdjustedTime() - 60 * 60; // in the last hour 往前推一個小時
CRITICAL_BLOCK(cs_mapAddresses)
{
foreach(const PAIRTYPE(vector<unsigned char>, CAddress)& item, mapAddresses)
{
if (fShutdown)
return true;
const CAddress& addr = item.second;
if (addr.nTime > nSince)
pfrom->vAddrToSend.push_back(addr);
}
}
}
如果命令字符串的內容是”getaddr”,則代表是請求地址信息。遍歷自己的地址表,將時間爲一個小時內的地址信息保存該節點的待發送地址池,後續線程會將這個地址池的地址發出去給實體節點(本地節點只是實體的映射)。
else if (strCommand == "checkorder")
{
uint256 hashReply;
CWalletTx order;
vRecv >> hashReply >> order;
/// we have a chance to check the order here
// Keep giving the same key to the same ip until they use it
if (!mapReuseKey.count(pfrom->addr.ip))
mapReuseKey[pfrom->addr.ip] = GenerateNewKey();
// Send back approval of order and pubkey to use
CScript scriptPubKey;
scriptPubKey << mapReuseKey[pfrom->addr.ip] << OP_CHECKSIG;
pfrom->PushMessage("reply", hashReply, (int)0, scriptPubKey);
}
如果命令字符串的內容是”checkorder”,將數據反序列放入hashReply和order,如果mapReuseKey裏未存放過該地址,則創建一個新的鑰匙對,並將公鑰填入mapReuseKey表,之後以該公鑰創建一個公鑰腳本,推送reply消息給發來消息的節點。
else if (strCommand == "submitorder")
{
uint256 hashReply;
CWalletTx wtxNew;
vRecv >> hashReply >> wtxNew;
// Broadcast
if (!wtxNew.AcceptWalletTransaction())
{
pfrom->PushMessage("reply", hashReply, (int)1);
return error("submitorder AcceptWalletTransaction() failed, returning error 1");
}
wtxNew.fTimeReceivedIsTxTime = true;
AddToWallet(wtxNew);
wtxNew.RelayWalletTransaction();
mapReuseKey.erase(pfrom->addr.ip);
// Send back confirmation
pfrom->PushMessage("reply", hashReply, (int)0);
}
如果命令字符串的內容是”submitorder”,將數據反序列放入hashReply和wtxNew,判斷這條錢包交易是否能夠接受,可以則推送正確的確認信息,並將交易加入錢包再轉發。不然推送錯誤的確認信息。
else if (strCommand == "reply")
{
uint256 hashReply;
vRecv >> hashReply;
CRequestTracker tracker;
CRITICAL_BLOCK(pfrom->cs_mapRequests)
{
map<uint256, CRequestTracker>::iterator mi = pfrom->mapRequests.find(hashReply);
if (mi != pfrom->mapRequests.end())
{
tracker = (*mi).second;
pfrom->mapRequests.erase(mi);
}
}
if (!tracker.IsNull())
tracker.fn(tracker.param1, vRecv);
}
如果命令字符串的內容是”reply”,將數據反序列放入hashReply,代表確認消息。就是對自己之前發送消息的確認,如果在自己的消息請求表裏找到該確認消息的hash,代表該請求已被迴應,擦除該消息請求。
2.3 發送消息
下面是消息發送函數SendMessages內容的解析:
vector<CAddress> vAddrToSend;
vAddrToSend.reserve(pto->vAddrToSend.size());
// 如果發送的地址不在已知地址的集合中,則將其放入臨時地址發送數組中
foreach(const CAddress& addr, pto->vAddrToSend)
if (!pto->setAddrKnown.count(addr))
vAddrToSend.push_back(addr);
pto->vAddrToSend.clear();
// 如果臨時地址發送數組不爲空,則進行地址的消息的發送
if (!vAddrToSend.empty())
pto->PushMessage("addr", vAddrToSend);}
定義一個臨時的待發送地址池,遍歷節點的待發送地址中的地址,判斷這些地址是不是在該節點的已知地址集合中,是則將該地址放入臨時定義的待發送地址池vAddrToSend。然後清空節點的待發送地址池。如果臨時的待發送地址池非空,則向該節點推送addr消息。
vector<CInv> vInventoryToSend;
CRITICAL_BLOCK(pto->cs_inventory)
{
vInventoryToSend.reserve(pto->vInventoryToSend.size());
foreach(const CInv& inv, pto->vInventoryToSend)
{
// returns true if wasn't already contained in the set
if (pto->setInventoryKnown.insert(inv).second)
vInventoryToSend.push_back(inv);
}
pto->vInventoryToSend.clear();
pto->setInventoryKnown2.clear();
}
// 庫存消息發送
if (!vInventoryToSend.empty())
pto->PushMessage("inv", vInventoryToSend);
定義一個臨時的待發送庫存池,與上面類似,遍歷該節點的待發送庫存池,如果這條庫存不是該節點的已知庫存,則將庫存推送到臨時待發送庫存池。之後清空節點的待發送庫存池和已知庫存2池。這之後如果臨時帶發送庫存池不是空的,則需要向節點推送庫存inv消息。
vector<CInv> vAskFor;
int64 nNow = GetTime() * 1000000;
CTxDB txdb("r");
// 判斷節點對應的請求消息map是否爲空,且對應的請求map中的消息對應的最早請求時間是否小於當前時間
while (!pto->mapAskFor.empty() && (*pto->mapAskFor.begin()).first <= nNow)
{
const CInv& inv = (*pto->mapAskFor.begin()).second;
printf("sending getdata: %s\n", inv.ToString().c_str());
if (!AlreadyHave(txdb, inv))
vAskFor.push_back(inv);// 不存在才需要進行消息發送
pto->mapAskFor.erase(pto->mapAskFor.begin());// 請求消息處理完一條就刪除一條
}
if (!vAskFor.empty())
pto->PushMessage("getdata", vAskFor);
}
創建一個臨時的請求數據列表,以庫存來請求具體數據,遍歷該節點的庫存請求列表,逐個判斷,如果庫存的內容在數據庫中不存在,則將這條庫存放入臨時的庫存請求列表。這之後向該節點推送請求數據消息getdata。節點實體接收到該消息後將會根據庫存hash和類型返回相應信息。
消息處理處理部分結束。
review,checkorder與submitorder三個消息似乎是爲非全節點準備的,因爲看內容上來說是把一些工作託付給其他節點完成,說明這些工作自己完成不了。