用 Go 構建一個區塊鏈 -- Part 7: 網路

Liu-ChengXu發表於2017-11-15

翻譯的系列文章我已經放到了 GitHub 上:blockchain-tutorial,後續如有更新都會在 GitHub 上,可能就不在這裡同步了。如果想直接執行程式碼,也可以 clone GitHub 上的教程倉庫,進入 src 目錄執行 make 即可。


引言

到目前為止,我們所構建的原型已經具備了區塊鏈所有的關鍵特性:匿名,安全,隨機生成的地址;區塊鏈資料儲存;工作量證明系統;可靠地儲存交易。儘管這些特性都不可或缺,但是仍有不足。能夠使得這些特性真正發光發熱,使得加密貨幣成為可能的,是網路(network)。如果實現的這樣一個區塊鏈僅僅執行在單一節點上,有什麼用呢?如果只有一個使用者,那麼這些基於密碼學的特性,又有什麼用呢?正是由於網路,才使得整個機制能夠運轉和發光發熱。

你可以將這些區塊鏈特性認為是規則(rule),類似於人類在一起生活,繁衍生息建立的規則,一種社會安排。區塊鏈網路就是一個程式社群,裡面的每個程式都遵循同樣的規則,正是由於遵循著同一個規則,才使得網路能夠長存。類似的,當人們都有著同樣的想法,就能夠將拳頭攥在一起構建一個更好的生活。如果有人遵循著不同的規則,那麼他們就將生活在一個分裂的社群(州,公社,等等)中。同樣的,如果有區塊鏈節點遵循不同的規則,那麼也會形成一個分裂的網路。

重點在於:如果沒有網路,或者大部分節點都不遵守同樣的規則,那麼規則就會形同虛設,毫無用處!

宣告:不幸的是,我並沒有足夠的時間來實現一個真實的 P2P 網路原型。本文我會展示一個最常見的場景,這個場景涉及不同型別的節點。繼續改進這個場景,將它實現為一個 P2P 網路,對你來說是一個很好的挑戰和實踐!除了本文的場景,我也無法保證在其他場景將會正常工作。抱歉!

本文的程式碼實現變化很大,請點選 這裡 檢視所有的程式碼更改。

區塊鏈網路

區塊鏈網路是去中心化的,這意味著沒有伺服器,客戶端也不需要依賴伺服器來獲取或處理資料。在區塊鏈網路中,有的是節點,每個節點是網路的一個完全(full-fledged)成員。節點就是一切:它既是一個客戶端,也是一個伺服器。這一點需要牢記於心,因為這與傳統的網頁應用非常不同。

區塊鏈網路是一個 P2P(Peer-to-Peer,端到端)的網路,即節點直接連線到其他節點。它的拓撲是扁平的,因為在節點的世界中沒有層級之分。下面是它的示意圖:

schematic representation
schematic representation

Business vector created by Dooder - Freepik.com

要實現這樣一個網路節點更加困難,因為它們必須執行很多操作。每個節點必須與很多其他節點進行互動,它必須請求其他節點的狀態,與自己的狀態進行比較,當狀態過時時進行更新。

節點角色

儘管節點具有完備成熟的屬性,但是它們也可以在網路中扮演不同角色。比如:

  1. 礦工
    這樣的節點執行於強大或專用的硬體(比如 ASIC)之上,它們唯一的目標是,儘可能快地挖出新塊。礦工是區塊鏈中唯一可能會用到工作量證明的角色,因為挖礦實際上意味著解決 PoW 難題。在權益證明 PoS 的區塊鏈中,沒有挖礦。

  2. 全節點
    這些節點驗證礦工挖出來的塊的有效性,並對交易進行確認。為此,他們必須擁有區塊鏈的完整拷貝。同時,全節點執行路由操作,幫助其他節點發現彼此。對於網路來說,非常重要的一段就是要有足夠多的全節點。因為正是這些節點執行了決策功能:他們決定了一個塊或一筆交易的有效性。

  3. SPV
    SPV 表示 Simplified Payment Verification,簡單支付驗證。這些節點並不儲存整個區塊鏈副本,但是仍然能夠對交易進行驗證(不過不是驗證全部交易,而是一個交易子集,比如,傳送到某個指定地址的交易)。一個 SPV 節點依賴一個全節點來獲取資料,可能有多個 SPV 節點連線到一個全節點。SPV 使得錢包應用成為可能:一個人不需要下載整個區塊鏈,但是仍能夠驗證他的交易。

網路簡化

為了在目前的區塊鏈原型中實現網路,我們不得不簡化一些事情。因為我們沒有那麼多的計算機來模擬一個多節點的網路。當然,我們可以使用虛擬機器或是 Docker 來解決這個問題,但是這會使一切都變得更復雜:你將不得不先解決可能出現的虛擬機器或 Docker 問題,而我的目標是將全部精力都放在區塊鏈實現上。所以,我們想要在一臺機器上執行多個區塊鏈節點,同時希望它們有不同的地址。為了實現這一點,我們將使用埠號作為節點識別符號,而不是使用 IP 地址,比如將會有這樣地址的節點:127.0.0.1:3000127.0.0.1:3001127.0.0.1:3002 等等。我們叫它埠節點(port node) ID,並使用環境變數 NODE_ID 對它們進行設定。故而,你可以開啟多個終端視窗,設定不同的 NODE_ID 執行不同的節點。

這個方法也需要有不同的區塊鏈和錢包檔案。它們現在必須依賴於節點 ID 進行命名,比如 blockchain_3000.db, blockchain_30001.db and wallet_3000.db, wallet_30001.db 等等。

實現

所以,當你下載 Bitcoin Core 並首次執行時,到底發生了什麼呢?它必須連線到某個節點下載最新狀態的區塊鏈。考慮到你的電腦並沒有意識到所有或是部分的比特幣節點,那麼連線到的“某個節點”到底是什麼?

在 Bitcoin Core 中硬編碼一個地址,已經被證實是一個錯誤:因為節點可能會被攻擊或關機,這會導致新的節點無法加入到網路中。在 Bitcoin Core 中,硬編碼了 DNS seeds。雖然這些並不是節點,但是 DNS 伺服器知道一些節點的地址。當你啟動一個全新的 Bitcoin Core 時,它會連線到一個種子節點,獲取全節點列表,隨後從這些節點中下載區塊鏈。

不過在我們目前的實現中,無法做到完全的去中心化,因為會出現中心化的特點。我們會有三個節點:

  1. 一箇中心節點。所有其他節點都會連線到這個節點,這個節點會在其他節點之間傳送資料。

  2. 一個礦工節點。這個節點會在記憶體池中儲存新的交易,當有足夠的交易時,它就會打包挖出一個新塊。

  3. 一個錢包節點。這個節點會被用作在錢包之間傳送幣。但是與 SPV 節點不同,它儲存了區塊鏈的一個完整副本。

場景

本文的目標是實現如下場景:

  1. 中心節點建立一個區塊鏈。
  2. 一個其他(錢包)節點連線到中心節點並下載區塊鏈。
  3. 另一個(礦工)節點連線到中心節點並下載區塊鏈。
  4. 錢包節點建立一筆交易。
  5. 礦工節點接收交易,並將交易儲存到記憶體池中。
  6. 當記憶體池中有足夠的交易時,礦工開始挖一個新塊。
  7. 當挖出一個新塊後,將其傳送到中心節點。
  8. 錢包節點與中心節點進行同步。
  9. 錢包節點的使用者檢查他們的支付是否成功。

這就是比特幣中的一般流程。儘管我們不會實現一個真實的 P2P 網路,但是我們會實現一個真是,也是比特幣最常見最重要的使用者場景。

版本

節點通過訊息(message)進行交流。當一個新的節點開始執行時,它會從一個 DNS 種子獲取幾個節點,給它們傳送 version 訊息,在我們的實現看起來就像是這樣:

type version struct {
    Version    int
    BestHeight int
    AddrFrom   string
}複製程式碼

由於我們僅有一個區塊鏈版本,所以 Version 欄位實際並不會儲存什麼重要資訊。BestHeight 儲存區塊鏈中節點的高度。AddFrom 儲存傳送者的地址。

接收到 version 訊息的節點應該做什麼呢?它會響應自己的 version 訊息。這是一種握手?:如果沒有事先互相問候,就不可能有其他交流。不過,這並不是處於禮貌:version 用於找到一個更長的區塊鏈。當一個節點接收到 version 訊息,它會檢查本節點的區塊鏈是否比 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 意為 “給我看一下你有什麼區塊”(在比特幣中,這會更加複雜)。注意,它並沒有說“把你全部的區塊給我”,而是請求了一個塊雜湊的列表。這是為了減輕網路負載,因為區塊可以從不同的節點下載,並且我們不想從一個單一節點下載數十 GB 的資料。

處理命令十分簡單:

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

在我們簡化版的實現中,它會返回 所有塊雜湊

inv

type inv struct {
    AddrFrom string
    Type     string
    Items    [][]byte
}複製程式碼

比特幣使用 inv 來向其他節點展示當前節點有什麼塊和交易。再次提醒,它沒有包含完整的區塊鏈和交易,僅僅是雜湊而已。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)
        }
    }
}複製程式碼

如果收到塊雜湊,我們想要將它們儲存在 blocksInTransit 變數來跟蹤已下載的塊。這能夠讓我們從不同的節點下載塊。在將塊置於傳送狀態時,我們給 inv 訊息的傳送者傳送 getdata 命令並更新 blocksInTransit。在一個真實的 P2P 網路中,我們會想要從不同節點來傳送塊。

在我們的實現中,我們永遠也不會傳送有多重雜湊的 inv。這就是為什麼當 payload.Type == "tx" 時,只會拿到第一個雜湊。然後我們檢查是否在記憶體池中已經有了這個雜湊,如果沒有,傳送 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)
    }
}複製程式碼

這個處理器比較地直觀:如果它們請求一個塊,則返回塊;如果它們請求一筆交易,則返回交易。注意,我們並不檢查實際上是否已經有了這個塊或交易。這是一個缺陷 :)

block 和 tx

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 集進行重新索引。

TODO:並非無條件信任,我們應該在將每個塊加入到區塊鏈之前對它們進行驗證。

TODO: 並非執行 UTXOSet.Reindex(), 而是應該使用 UTXOSet.Update(block),因為如果區塊鏈很大,它將需要很多時間來對整個 UTXO 集重新索引。

處理 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 只會在礦工節點上設定。如果當前節點(礦工)的記憶體池中有兩筆或更多的交易,開始挖礦:

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 集會被重新索引。

TODO: 提醒,應該使用 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
}複製程式碼

當一筆交易被挖出來以後,就會被從記憶體池中移除。當前節點所連線到的所有其他節點,接收帶有新塊雜湊的 inv 訊息。在處理完訊息後,它們可以對塊進行請求。

結果

讓我們來回顧一下上面定義的場景。

首先,在第一個終端視窗中將 NODE_ID 設定為 3000(export NODE_ID=3000)。為了讓你知道什麼節點執行什麼操作,我會使用像 NODE 3000NODE 3001 進行標識。

NODE 3000

建立一個錢包和一個新的區塊鏈:

$ blockchain_go createblockchain -address CENTREAL_NODE複製程式碼

(為了簡潔起見,我會使用假地址。)

然後,會生成一個僅包含創世塊的區塊鏈。我們需要儲存塊,並在其他節點使用。創世塊承擔了一條鏈識別符號的角色(在 Bitcoin Core 中,創世塊是硬編碼的)

$ cp blockchain_3000.db blockchain_genesis.db複製程式碼

NODE 3001

接下來,開啟一個新的終端視窗,將 node ID 設定為 3001。這會作為一個錢包節點。通過 blockchain_go createwallet 生成一些地址,我們把這些地址叫做 WALLET_1, WALLET_2, WALLET_3.

NODE 3000

向錢包地址傳送一些幣:

$ blockchain_go send -from CENTREAL_NODE -to WALLET_1 -amount 10 -mine
$ blockchain_go send -from CENTREAL_NODE -to WALLET_2 -amount 10 -mine複製程式碼

-mine 標誌指的是塊會立刻被同一節點挖出來。我們必須要有這個標誌,因為初始狀態時,網路中沒有礦工節點。

啟動節點:

$ blockchain_go startnode複製程式碼

這個節點會持續執行,直到本文定義的場景結束。

NODE 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_2
Balance of 'WALLET_2': 10複製程式碼

你還可以檢查 CENTRAL_NODE 地址的餘額,因為 node 3001 現在有它自己的區塊鏈:

$ blockchain_go getbalance -address CENTRAL_NODE
Balance of 'CENTRAL_NODE': 10複製程式碼

NODE 3002

開啟一個新的終端視窗,將它的 ID 設定為 3002,然後生成一個錢包。這會是一個礦工節點。初始化區塊鏈:

$ cp blockchain_genesis.db blockchain_3002.db複製程式碼

啟動節點:

$ blockchain_go startnode -miner MINER_WALLET複製程式碼

NODE 3001

傳送一些幣:

$ blockchain_go send -from WALLET_1 -to WALLET_3 -amount 1
$ blockchain_go send -from WALLET_2 -to WALLET_4 -amount 1複製程式碼

NODE 3002

迅速切換到礦工節點,你會看到挖出了一個新塊!同時,檢查中心節點的輸出。

NODE 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 網路原型繼續展開,但是我真的沒有這麼多時間。我希望本文已經回答了關於比特幣技術的一些問題,也給讀者提出了一些問題,這些問題你可以自行尋找答案。在比特幣技術中還有隱藏著很多有趣的事情!好運!

後記:你可以從實現 addr 訊息來開始改進網路,正如比特幣網路協議中所描述的(連結可以下方找到)那樣。這是一個非常重要的訊息,因為它允許節點來互相發現彼此。我已經開始實現了,不過還沒有完成!

連結:

  1. Source codes
  2. Bitcoin protocol documentation
  3. Bitcoin network

原文:Building Blockchain in Go. Part 7: Network

相關文章