2.8 網路

尹成發表於2018-11-08

到目前為止,前面我們構造了擁有全部關鍵功能的區塊鏈,匿名、安全、隨機的地址;區塊鏈資料儲存;工作量證明系統;可靠的交易儲存。這些特性都很重要,但還不夠。能夠讓這些能力發出耀眼光芒的,讓加密貨幣成為可能,是網路。這樣實現的區塊鏈,只能執行在一臺計算機上的能有什麼卵用?那些基於密碼學的特性有什麼用,什麼時候又只會有一個人使用?是網路讓所有的機制執行起來且變得有用。 
可以把區塊鏈中的我當成規則,類似於人們在彼此的生活成長中而建立的規則。一種社會秩序。區塊鏈網路是一個遵從相同的規則的程式生態社群,也是因為遵從這些規則而賦予區塊鏈網路生命。類似地,當人們分享相同的想法時,就會變得更強也會一起建立更好的生活。有的人遵從不同的規則時,這些人就會被社會隔離(國家、公社,等等)。同樣,如果區塊鏈節點都使用遵守不同的規則,那麼它們只會在一個隔離的網路中生長。 
這點非常重要:不骨網路沒有大量的節點共享相同的規則,這些規則一點用也沒有。 
免責宣告:非常不幸,我沒有足夠的時間來實現真正的P2P網路。這篇文章我會闡明最常用的場景,涉及不同型別的節點。改善這一場景並使之成為P2P網路對你來說是一個非常不錯的挑戰和嘗試。並且,我不保證其它非本章的實現方式可以執行。抱歉! 
本篇文章的程式碼改動比較大,就不詳細解釋了。可以到這裡來看所有的改變。看他們的區別。

區塊鏈網路

區塊鏈網路是去中心化的,也就是說是沒有一箇中央伺服器作為伺服,也沒有客戶端向伺服器獲取或傳送資料。在區塊鏈網路中有節點,每一個節點都是該網路中完整的成員。一個節點就是一切,即是伺服器也是客戶端。記住這點很重要,和Web應用是不同的。 
區塊鏈網路是P2P(Peer-to-Peer)網路,也就是說節點之間是彼此直接相連的。這個拓撲非常龐大,因為節點角色之間沒有層級的之分。下圖是P2P網路的圖解: 

節點在這樣的網路是非常難實現的,因為他們必須執行很多操作。每個節點都肯定會和其它很多節點互動,也會請求其它節點的狀態,與自己的狀態比較,如果自己的狀態過期時就要更新狀態。

節點規則

儘管是成熟的,區塊鏈節點在網路中可以充當不同的角色:

1. 礦工

有些節點執行強大或特製的硬體(比如ASIC),它們的目的就是儘可能快地挖出新的區塊。礦機可能是僅有的在區塊鏈裡使用工作量證明的(程式),因為挖礦意味著要解釋PoW問題。而舉個例子,在權益證明(Proof-of-Stake)區塊鏈中是沒有挖礦的。

2. 全功能節點

這些節點驗證礦工挖出來的區塊和核實交易。為了完成這點,它們必須握有整個區塊鏈的副本。並且,這些節點執行路由操作,就像幫助其它節點互相發現。 
網路中擁有全功能節點非常重要,這些節點可以執行決策:它們能裁定是否區塊或者交易是合法的。

3. SPV

SPV代表Simplified Payment Verification,簡化交易驗證。這些節點並不會去儲存區塊鏈的完整副本,但是卻能夠核實交易(並不是全部,而是子集,比如,那些會傳送到特殊地址的交易)。SPV節點依賴全功能節點提供資料,也可以有多個SPV節點連線到一個全功能節點。SPV使得錢包應用成為可能:錢包不需要下載整個區塊鏈,但是能夠核實交易。

網路簡化

為了在我們的區塊鏈中實現網路,我們必須得簡化一點東西。問題在於我們沒有太多的電腦來模擬有多個節點的網路。我們過去是可以使用虛擬機器或者Docker來解決這個問題的,但是這會讓每件事都變得複雜,我們得解決虛擬機器或者Docker的問題,而我們的目標僅是集中精力到區塊鏈的實現上。所以,我們需要執行多個區塊鏈節點在一臺機器上,以此同時,我們還要讓它們有不同的地址。為了實現這點,我們使用埠作為節點的標識,而不是IP地址,等等。下面將會有節點擁有這些地址,127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002等等。我們會呼叫埠的節點id,然後用NODE_ID環境變數設定它們。因此,你可以開啟多個終端視窗,設定不同的NODE_ID,就可以執行不同的節點了。 
這波操作同樣需要不同的區塊鏈和錢包檔案。它們現在必須依賴節點id,並被命名像:blockchain_3000.db, blockchain_30001.db和wallet_3000.db, wallet_30001.db等等

實現

那麼,當下載時發生了什麼,是指下載Bitcoin Core然後第一次執行?答案是必須連線到一些節點下載區塊鏈最後的狀態。考慮到你們計算機並不知道全部或者部分的比特幣節點,到底這個節點是什麼呢。 
在Bitcoin Core裡使用硬編碼地址可能會出錯,節點會被攻擊或者關掉,會導致新的節點不能加入到網路中。相反,在Bitcoin Core中,有使用DNS seeds硬編碼。它們不是節點,而是存放了一些節點地址的DNS伺服器。當你開始執行一個純淨的Bitcoin Core時,它會連線到一個seed然後獲取上面記錄的所有節點列表,根據這個列表下載區塊鏈。 
不過,在我們的實現中,還是會中心化。會用到三個節點: 
1. 中心節點:這個節點會被其它節點連線。該節點會在其它節點之間傳送資料。 
2. 礦工節點:這個節點會儲存新的交易到快取池中,當有足夠的交易時,它就會挖出新的區塊。 
3. 錢包節點:這個節點會用來在錢包之間傳送錢幣。但是和SPV節點不同,它會儲存區塊鏈的完整副本。

場景

本篇的目標是實現下面的場景: 
1. 中心節點生成新的區塊鏈 
2. 錢包節點連線到中心節點然後下載區塊鏈 
3. 礦工節點連線到中心節點然後下載區塊鏈 
4. 錢包節點建立交易 
5. 礦工節點接收交易並把它快取在快取池中 
6. 當快取池中有足夠的交易時,礦工開始挖新的區塊 
7. 當新的區塊被挖出來時,會被髮送到中心節點。 
8. 錢包節點與中心節點同步 
9. 錢包使用者檢測他們支付是否成功 
這個場景看起來和比特幣很像。儘管我們沒有構建一個真正的P2P網路,我們準備實現一個真實的,比特幣的主要、最重要的使用案例。 
原文(略有刪改) 
本節的闡述會有重大的程式碼改變,如果在這裡講就有點麻煩了。請跳到這裡來看所有的改變。

版本

節點通過訊息的含義進行溝通。當新的節點執行時,它會從DNS種子獲取節點的資訊,然後向它們傳送版本資訊,在我們的實現中,版本的結構如下:

type version struct {
    Version    int
    BestHeight int
    AddrFrom   string
}

我們只有一個區塊鏈版本號,所有Version欄位不能含有任何重要的資訊。BestHeight存放節點的區塊鏈長度。AddFrom儲存傳送者的地址。 
節點接收版本訊息做什麼呢?它會回覆它自己的版本資訊。這是握手的一種型別,除了先去彼此打招呼別無其它的互動可能。但是這並不僅僅是有禮貌,版本用於找到更長的區塊鏈。當一個節點接收到版本資訊時,它會檢測是否節點的區塊鏈比BestHeight值要大。如果不是,節點就會請求下載缺失的區塊。 
為了能接收到訊息,我們要有一個伺服器:

var nodeAddress string
var knownNodes = []string{"localhost:3000"}
func StartServer(nodeID, minerAddress string) {
    nodeAddress = fmt.Sprintf("localhost:%s", nodeID)
    miningAddress = minerAddress
    ln, err := net.Listen(protocol, nodeAddress)
    defer ln.Close()
bc := NewBlockchain(nodeID)
if nodeAddress != knownNodes[0] {
        sendVersion(knownNodes[0], bc)
    }
for {
        conn, err := ln.Accept()
        go handleConnection(conn, bc)
    }
}

首先,在中央伺服器的地址上使用硬編碼,因為每一個新的節點都必須要知道從哪裡獲得初始化資料。minerAddress引數指定接收挖出新區塊的獎勵地址。

if nodeAddress != knownNodes[0] {
    sendVersion(knownNodes[0], bc)
}

就是說當前節點不是中央節點時,它就會傳送version訊息到中央節點判斷是否自已的區塊鏈是否過期了。

func sendVersion(addr string, bc *Blockchain) {
    bestHeight := bc.GetBestHeight()
    payload := gobEncode(version{nodeVersion, bestHeight, nodeAddress})
request := append(commandToBytes("version"), payload...)
sendData(addr, request)
}

訊息是底層的位元序列。前12位元組指定了命令名(在這裡的情況就是“version”),後面的位元組會包含gob編碼過的訊息結構。commandToBytes:

func commandToBytes(command string) []byte {
    var bytes [commandLength]byte
for i, c := range command {
        bytes[i] = byte(c)
    }
return bytes[:]
}

它建立了12位元組的快取區,使用命令名來填充,把餘留的位元組置空。上面是它的反向函式:

func bytesToCommand(bytes []byte) string {
    var command []byte
for _, b := range bytes {
        if b != 0x0 {
            command = append(command, b)
        }
    }
return fmt.Sprintf("%s", command)
}

當節點接收到命令時,它會執行bytesToCommand指令把命令名展開,然後使用正確的處理函式執行命令:

func handleConnection(conn net.Conn, bc *Blockchain) {
    request, err := ioutil.ReadAll(conn)
    command := bytesToCommand(request[:commandLength])
    fmt.Printf("Received %s command\n", command)
switch command {
    ...
    case "version":
        handleVersion(request, bc)
    default:
        fmt.Println("Unknown command!")
    }
conn.Close()
}

version處理函式如下:

func handleVersion(request []byte, bc *Blockchain) {
    var buff bytes.Buffer
    var payload verzion
buff.Write(request[commandLength:])
    dec := gob.NewDecoder(&buff)
    err := dec.Decode(&payload)
myBestHeight := bc.GetBestHeight()
    foreignerBestHeight := payload.BestHeight
if myBestHeight < foreignerBestHeight {
        sendGetBlocks(payload.AddrFrom)
    } else if myBestHeight > foreignerBestHeight {
        sendVersion(payload.AddrFrom, bc)
    }
if !nodeIsKnown(payload.AddrFrom) {
        knownNodes = append(knownNodes, payload.AddrFrom)
    }
}

首先要解碼請求,展開內部資訊。所有的處理函式都是相似的,後面會把篇幅省下來。 
然後節點會用它的BestHeight與訊息中的比較。如果節點區塊更長時,那麼它會回覆version訊息,相反,它會傳送getBlocks(獲取區塊)訊息。

getblocks

type getblocks struct {
    AddrFrom string
}

getblocks的意思是亮出你有的區塊(在比特幣中,會更復雜)。注意,不是扔你所有的區塊過來,相反它是請求區塊hash的列表。這麼做是為了降低網路負載,因為區塊可以從不同的節點下載,我們也不用到一個節點去下載上千兆的資料。 
處理這個命令比較簡單:

func handleGetBlocks(request []byte, bc *Blockchain) {
    ...
    blocks := bc.GetBlockHashes()
    sendInv(payload.AddrFrom, "block", blocks)
}

我們的實現中,它會返回所有區塊的hash

inv

type inv struct {
    AddrFrom string
    Type     string
    Items    [][]byte
}

比特幣中使用inv來向其它節點展示當前節點有哪些區塊或者交易。再說一遍,它並不包含所有的區塊和交易,只儲存有它們的hash值。Type欄位用來宣告這裡存的是區塊還是交易。 
處理inv就比較複雜些了:

func handleInv(request []byte, bc *Blockchain) {
    ...
    fmt.Printf("Recevied inventory with %d %s\n", len(payload.Items), payload.Type)
if payload.Type == "block" {
        blocksInTransit = payload.Items
blockHash := payload.Items[0]
        sendGetData(payload.AddrFrom, "block", blockHash)
newInTransit := [][]byte{}
        for _, b := range blocksInTransit {
            if bytes.Compare(b, blockHash) != 0 {
                newInTransit = append(newInTransit, b)
            }
        }
        blocksInTransit = newInTransit
    }
if payload.Type == "tx" {
        txID := payload.Items[0]
if mempool[hex.EncodeToString(txID)].ID == nil {
            sendGetData(payload.AddrFrom, "tx", txID)
        }
    }
}

當區塊的hash轉移好後,需要把它們儲存到blocksInTransit變數中來跟蹤下載過的區塊。這允許我們可以從不同的節點下載區塊。在區塊進入傳輸狀態後,傳送getData指令給inv的傳送者然後更新blocksInTransit。在真正的P2P網路中,得在不同的區塊之間傳辦理區塊。 
在實現中,還永不會傳送inv時帶上多個hash。這也是為什麼當payload.Type == “tx”時,只用到陣列中獲取第一個hash。然後檢測是否剛剛的txID是否存在,如果不存在,那麼傳送getdata指令獲取這個交易。

getdata

type getdata struct {
    AddrFrom string
    Type     string
    ID       []byte
}

getdata用於請求一個指定的區塊或交易,它只能帶有一個區塊或交易的id。

func handleGetData(request []byte, bc *Blockchain) {
    ...
    if payload.Type == "block" {
        block, err := bc.GetBlock([]byte(payload.ID))
sendBlock(payload.AddrFrom, &block)
    }
if payload.Type == "tx" {
        txID := hex.EncodeToString(payload.ID)
        tx := mempool[txID]
sendTx(payload.AddrFrom, &tx)
    }
}

這個getdata處理函式比較簡單。當請求的是區塊,則返回區塊;如果是交易,則返回交易。注意,這裡有個缺陷,就是沒有去檢測是否存在指定的區塊或者交易。

區塊和交易

type block struct {
    AddrFrom string
    Block    []byte
}
type tx struct {
    AddFrom     string
    Transaction []byte
}

就是這些訊息完成真正的資料傳送 
block的處理器很簡單:

func handleBlock(request []byte, bc *Blockchain) {
    ...
blockData := payload.Block
    block := DeserializeBlock(blockData)
fmt.Println("Recevied a new block!")
    bc.AddBlock(block)
fmt.Printf("Added block %x\n", block.Hash)
if len(blocksInTransit) > 0 {
        blockHash := blocksInTransit[0]
        sendGetData(payload.AddrFrom, "block", blockHash)
blocksInTransit = blocksInTransit[1:]
    } else {
        UTXOSet := UTXOSet{bc}
        UTXOSet.Reindex()
    }
}

當我們接收到新的區塊時,我們把它放到我們的區塊鏈中。如果有很多區塊需要下載,我們從前一個相同的下載過區塊的節點下載它們。當完成全部的區塊下載時,UTXO就需要更新了。 
備註:並不是要無條件相信,我們應該在把每一個傳來的區塊加入區塊鏈之前得驗證它們。 
備註:並不需要執行UTXOSet.Reindex()方法,應該用UTXOSet.Update(block),因為區塊鏈太大了,重置索引會花費太多時間。 
處理tx訊息的函式稍微複雜些:

func handleTx(request []byte, bc *Blockchain) {
    ...
    txData := payload.Transaction
    tx := DeserializeTransaction(txData)
    mempool[hex.EncodeToString(tx.ID)] = tx
if nodeAddress == knownNodes[0] {
        for _, node := range knownNodes {
            if node != nodeAddress && node != payload.AddFrom {
                sendInv(node, "tx", [][]byte{tx.ID})
            }
        }
    } else {
        if len(mempool) >= 2 && len(miningAddress) > 0 {
        MineTransactions:
            var txs []*Transaction
for id := range mempool {
                tx := mempool[id]
                if bc.VerifyTransaction(&tx) {
                    txs = append(txs, &tx)
                }
            }
if len(txs) == 0 {
                fmt.Println("All transactions are invalid! Waiting for new ones...")
                return
            }
cbTx := NewCoinbaseTX(miningAddress, "")
            txs = append(txs, cbTx)
newBlock := bc.MineBlock(txs)
            UTXOSet := UTXOSet{bc}
            UTXOSet.Reindex()
fmt.Println("New block is mined!")
for _, tx := range txs {
                txID := hex.EncodeToString(tx.ID)
                delete(mempool, txID)
            }
for _, node := range knownNodes {
                if node != nodeAddress {
                    sendInv(node, "block", [][]byte{newBlock.Hash})
                }
            }
if len(mempool) > 0 {
                goto MineTransactions
            }
        }
    }
}

第一件要做的事就是把新的交易放到快取池中(再強調一次,交易在被放到快取池前一定要核實),下一塊程式碼:

if nodeAddress == knownNodes[0] {
    for _, node := range knownNodes {
        if node != nodeAddress && node != payload.AddFrom {
            sendInv(node, "tx", [][]byte{tx.ID})
        }
    }
}

檢測是否當前的節點是中央節點,在我們的實現當中,中央節點並不會挖礦,相反,它只是把新的交易傳送給網路中的其它節點。 
接下來這塊程式碼只是給礦機節點用的,把它分成兩小片:

if len(mempool) >= 2 && len(miningAddress) > 0 {
miningAddress只有礦機節點才會被設定。當前的節點中有2個或多個交易在快取池中時,挖礦就開始。
for id := range mempool {
    tx := mempool[id]
    if bc.VerifyTransaction(&tx) {
        txs = append(txs, &tx)
    }
}
if len(txs) == 0 {
    fmt.Println("All transactions are invalid! Waiting for new ones...")
    return
}

首先,快取池中所有的交易都是核實過的。不合法的交易會被忽略掉,如果沒有合法的交易,挖坑就會中斷。

cbTx := NewCoinbaseTX(miningAddress, "")
txs = append(txs, cbTx)
newBlock := bc.MineBlock(txs)
UTXOSet := UTXOSet{bc}
UTXOSet.Reindex()
fmt.Println("New block is mined!")

核實過的交易正被放到區塊中,還有帶有獎勵的coinbase交易。當挖出區塊後,UTXO集合就會被重置索引。 
備忘:再一次說明,要用UTXOSet.Update而不是UTXOSet.Reindex

for _, tx := range txs {
    txID := hex.EncodeToString(tx.ID)
    delete(mempool, txID)
}
for _, node := range knownNodes {
    if node != nodeAddress {
        sendInv(node, "block", [][]byte{newBlock.Hash})
    }
}
if len(mempool) > 0 {
    goto MineTransactions
}

在交易被挖時,它就會從快取池中移除。其它被當前節點通知到的節點都會收到帶有新區塊hash的inv訊息。在收到訊息後,它們可以請求該剛被挖出的新區塊。

成果

現在演示上面定義的場景。 
首先,在第一個終端視窗中設定環境變數NODE_ID為3000(export NODE_ID=3000)。我們在下一段中會使用像NODE 3000或者NODE 3001這樣的標識,以便在大家能知道列印出的活動是哪個節點的。 
下面的分段title出是切到指定的視窗或開啟新視窗 
節點 3000 
建立新的錢包和新的區塊鏈

$ blockchain_go createblockchain -address CENTREAL_NODE

(這裡使用假的地址,這樣可以簡單明瞭些) 
然後,這個區塊鏈只包含有創世區塊。我們需要去儲存這個區塊然後在其它節點中使用它。創世區塊作為區塊鏈的標識(在比特幣中,創世區塊是硬編碼的)。

$ cp blockchain_3000.db blockchain_genesis.db

節點 3001 
下一步,開啟新的終端視窗,把node ID設定為3001。這個節點是錢包節點。用blockchain_go createwallet來生成幾個地址,定義這些地址為WALLET_1、WALLET_2、WALLET_3。 
節點 3000 
傳送一些幣到錢包地址中

blockchaingosendfromCENTREALNODEtoWALLET1amount10mineblockchaingosend−fromCENTREALNODE−toWALLET1−amount10−mine blockchain_go send -from CENTREAL_NODE -to WALLET_2 -amount 10 -mine

-mine指令是說區塊會在相同的節點中立馬挖出來。我們加個標記是因為在初始時網路中沒有礦機節點。 
執行這個節點:

$ blockchain_go startnode

這個節點會一直執行直到場景結束。 
節點 3001 
開始這個節點的區塊鏈,帶著上面說到的創世區塊。

$ cp blockchain_genesis.db blockchain_3001.db

執行節點:

$ blockchain_go startnode

它會去中央節點裡下載所有的區塊。檢測所有事情都好了之後,停止節點然後檢測餘額。

$ blockchain_go getbalance -address WALLET_1

Balance of ‘WALLET_1’: 10

$ blockchain_go getbalance -address WALLET_2Balance of ‘WALLET_2’: 10

當然,也可以檢測CENTRAL_NODE中央節點的餘額,因為3001節點已經有它自己的區塊鏈了:

$ blockchain_go getbalance -address CENTRAL_NODE

Balance of ‘CENTRAL_NODE’: 10 
節點 3002 
開啟新的視窗,設定ID為3002,然後生成錢包,這個是個礦機節點。初始化區塊鏈:

$ cp blockchain_genesis.db blockchain_3002.db

啟動節點

$ blockchain_go startnode -miner MINER_WALLET

節點 3001 
傳送幣:

blockchaingosendfromWALLET1toWALLET3amount1blockchaingosend−fromWALLET1−toWALLET3−amount1 
blockchain_go send -from WALLET_2 -to WALLET_4 -amount 1

節點 3002 
很快,轉到礦機節點後,可以看到它挖出了新的區塊。也檢測了中央節點的output。 
節點 3001 
選擇錢包節點,然後啟動:

$ blockchain_go startnode

它會下載新挖出的區塊。 
停下來,然後檢測餘額:

$ blockchain_go getbalance -address WALLET_1

Balance of ‘WALLET_1’: 9

$ blockchain_go getbalance -address WALLET_2

Balance of ‘WALLET_2’: 9

$ blockchain_go getbalance -address WALLET_3

Balance of ‘WALLET_3’: 1

$ blockchain_go getbalance -address WALLET_4

Balance of ‘WALLET_4’: 1

$ blockchain_go getbalance -address MINER_WALLET

Balance of ‘MINER_WALLET’: 10 
這裡就是全部了!

結論

這是我們這個系列文章的最後一篇了。應該要實現真正的P2P原型網路,但是確實沒有這麼多的時間。希望這篇文章能解答一些你關於比特幣技術的疑問,並且獲得新姿勢,你也可以自己去找到答案。還有很多有趣的知識藏在比特幣技術中。祝你開車愉快! 
P.S. 你可以開始通過實現addr訊息來完善網路,就如比特幣網路協議中描述的一樣。這是訊息非常重要,因為它可以讓節點互相發現彼此。我已經開始實現它了,但是還沒有完成。

相關文章