1. 程式人生 > >go語言實現最小區塊鏈教程7-網路

go語言實現最小區塊鏈教程7-網路

1 介紹 Introduction

到目前為止,我們構建了一個含有以下特徵的區塊鏈:匿名、安全、以及隨機產生地址;區塊鏈資料儲存;PoW系統;可靠的交易記錄儲存方式。這些特徵都非常關鍵,但是這還不夠。能夠讓這些特徵昇華的,並且讓加密貨幣變得可能的,是網路(network)。這樣的區塊鏈實現如果只能在單一的電腦上面執行有什麼用?這些基礎加密特性有什麼有,如果僅有一個使用者?網路讓這些機制工作併發揮作用。

你可以將這些區塊鏈的特徵視為基本準則,你會發現與人類希望共同生活和成長的準則類似。一種社會性的安排。區塊鏈網路是遵從同樣準則的程式社群,也正是遵從這些準則讓這個網路變得活力四射。同樣的,當人們分享共同的想法,他們將變得更強,然後可以一起構建更美好的生活。如果其中有人遵從不一樣的準則,他們將在一個隔離的社會(國家、社群等)裡生活。同樣的,如果有一個區塊鏈節點執行不一樣的規則,它們將形成一個單獨的網路。

這個非常重要:沒有一個有共識的網路或者大部分節點有共識的網路,這些規則就毫無用處。

免責宣告:不幸的是,我沒有足夠的時間去實現一個真正的P2P網路原型。在這篇文章當中,我會證明一個最平常的情景,會涉及不同型別的節點。提高這個場景然後將它實現為一個真正的P2P 網路會是各位讀者一個絕好的挑戰和實踐!我也不能保證除了本文所實現的情景以外的其它情景能夠解決問題。對不起!
這部分介紹了關鍵的程式碼變化,因此在這裡解釋所有的程式碼不是特別有意義。可以參看這個頁面看與上一部分文章之間的程式碼變動。

2 區塊鏈網路 Blockchain Network

區塊鏈網路是去中心化的,這意味著沒有伺服器提供服務,然後客戶端通過服務區獲取或處理資料。在區塊鏈網路當中分佈著不同的節點(nodes),每一個節點都是網路上完整(full-fledged)的節點。一個節點就是所有:即是節點也是伺服器。這個非常重要,必須牢牢記住,因為這與普通的 web 應用完全不同。

區塊鏈網路是一個 P2P (Peer-to-Peer)網路,這意味著各個節點與其它節點之間直接連線。它的拓撲結構是平的,在節點規則當中沒有層級關係。下面是它的示意圖:

這樣的網路當中的節點更難以實現,因為它們需要執行很多的操作。每一個節點都要與其它不同的節點之間互動,它需要獲得其它節點的狀態,並與自己的狀態對比,如果發現自己的狀態不是最新的還需要更新自己的狀態。

3 節點角色 Node Roles

除了要全能以外,區塊鏈節點可以在網路中扮演不同的角色。如下所示:

  1. 礦機。
    這樣的節點執行在超強效能或者專門的硬體(比如ASIC)上,它們的唯一目標就是儘快挖到新的區塊。礦機僅在採用PoW機制的區塊鏈上能夠工作,因為挖礦本身實際上就是求解PoW謎題。打個比方,在採用PoS(Proof-of-Stake)機制的區塊鏈上,並沒有挖礦。
  2. 全能節點。
    這些節點驗證由礦機挖到的區塊並確認交易記錄。為了做這件事情,它們必須要有區塊鏈的整個網站拷貝。這樣的節點也履行一些日常的操作,比如幫助其它節點去發現彼此。一個網路必須要有很多全能節點,這個非常重要,因為是這些節點在做這樣的決定:它們決定著一個區塊或者交易記錄是否合法有效。
  3. 簡單支付驗證 SPV.
    簡單支付驗證(Simplified Payment Verification)。承擔簡單支付驗證的節點並不儲存區塊鏈的完整拷貝,但是它們依然可以驗證交易記錄(並不是所有的,是一個子集,舉個例子,比如發往特定地址的的)。一個SPV 節點依賴於一個全能節點獲取資料,一個全能節點會有很多個SPV節點連線。SPV節點讓錢包應用變得可能:你不用下載整個區塊鏈,但是依然可以驗證你的交易記錄。

4 網路簡化 Network simplification

為了在我們的區塊鏈中實現網路,我們需要簡化一些東西。問題在於我們並沒有很多臺電腦用來模擬一個擁有很多節點的網路。我們原本可以使用虛擬機器或者 Docker 來解決這個問題,但是這會讓事情變得更加複雜:我們的目的原本關注於區塊鏈的實現,但是你必須又要提供虛擬機器或者 Docker的應對措施。因此,我們要在單機上同時執行多個擁有不同地址的節點。為了取得這樣的效果,我們將不同的埠視為不同的節點,以代替IP 地址。舉了例子,會有以下不同地址的節點:127.0.0.1:3000, 127.0.0.1:3001, 127.0.0.1:3002等。我們將呼叫埠節點ID 並用 NODE_IDenvironment 變數來設定它們。這樣,你可以開啟多個命令列視窗,設定不同的NODE_IDs 就有不同的節點執行。

這個方法也要求不同的區塊鏈和錢包檔案。它們現在依賴於節點 ID並被命名為 blockchain_3000.db, blockchain_30001.db and wallet_3000.db, wallet_30001.db等。

5 實現 Implementation

那麼問題來了,當我們下載比特幣核心並首次執行會發生什麼?它必須連線到一些節點去下載區塊鏈的最先狀態。假如你的電腦不被有些比特幣識別,那麼這個讓你下載區塊鏈的節點又在哪裡呢?

在比特幣核心中硬編碼一個節點地址會是一個錯誤:節點會被攻擊或者關機,導致新的節點無法加入網路。相應的,在比特幣核心當中,有硬編碼的 DNS seeds 。它們不是節點,是知道一些節點IP地址的DNS 伺服器。當你開始一個全新的比特幣核心是,它會連線到其中的一個 種子(seed)然後獲得一份全能節點的列表,然後從它們那裡下載區塊鏈。

在我們的實現當中,目前也是集中式的。我們會有以下三個節點:

  1. 中央節點。這是其它所有節點都會連線的,也是與其它節點互動資料的節點。
  2. 一個挖礦節點。這個節點會在記憶體池儲存新的交易記錄,並且當有足夠的交易記錄時,會挖一個新的區塊。
  3. 一個錢包節點。這個節點用於在錢包之前轉移幣。不像SPV節點,它儲存一份完整的區塊鏈拷貝。

6 情景 The Scenario

這篇文章的目的是實現以下場景:

  1. 中央節點建立一個區塊鏈。
  2. 其它節點(錢包節點)連線到中央節點並下載區塊鏈。
  3. 還有一個挖礦節點將連到中央節點並從中央節點下載區塊鏈。
  4. 錢包節點建立交易記錄。
  5. 挖礦節點接收交易記錄並將它儲存在自己的記憶體池當中。
  6. 當記憶體池中的交易記錄足夠多時,礦工便開始挖新的區塊。
  7. 當一個新的區塊被挖出來,它將會被髮送到中央節點。
  8. 錢包節點會與中央節點同步。
  9. 錢包節點的使用者會檢查它們的支付是否成功。

這就是比特幣看起來的樣子。即便我們沒有準備構建一個實際的P2P網路,但是我們所實現的也是一個實際的、主要以及最重要的比特幣案例。

7 版本 version

節點之間都過訊息的方式進行溝通交流。當一個新節點執行,它會從一個DNS種子獲得一些已有節點的地址,便向它們傳送版本訊息,在我們的實現中看起來像下面的樣子:

type version struct {
    Version    int
    BestHeight int
    AddrFrom   string
}

我們只有一個區塊鏈版本,所以在 Version 結構體的欄位中並沒有儲存任何重要資訊。BestHeight 欄位儲存節點的區塊鏈的長度。AddFrom 儲存傳送者的地址。

當一個節點收到一個版本資訊時它應該幹什麼?它將以自己的版本資訊進行迴應。某種意義上,這就像握手:雙方之間沒有一方的主動示意互動便不可能。但這又不僅僅是禮貌:版本用於尋找一個更長的區塊。當一個節點收到一個版本資訊它將檢查節點的區塊鏈是否比BestHeigh 欄位中的值長。假如不是,節點會要求與下載缺失的區塊。

為了收到訊息,我們需要一個伺服器:

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)
}

意味著假如目前的節點不是中央節點,它必須向中央節點發送版本資訊並確認它的區塊鏈是否過時了。

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

    request := append(commandToBytes("version"), payload...)

    sendData(addr, request)
}

Our messages, on the lower level, are sequences of bytes. First 12 bytes specify command name (“version” in this case), and the latter bytes will contain gob-encoded message structure. commandToBytes looks like this:

我們的訊息,在底層的角度看,是位元組序列。前面12個位元組指定了命令名字(“version”,在這個案例中),後面的位元組包含 gob-encoded 資訊結構體。commandToBytes 看起來是這樣的:

func commandToBytes(command string) []byte {
    var bytes [commandLength]byte

    for i, c := range command {
        bytes[i] = byte(c)
    }

    return bytes[:]
}

它建立了一個 12-byte 的緩衝,並用命令名去覆蓋它,讓剩下的位元組留空。相應的逆函式如下:

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()
}

OK,版本命令的處理內容如下:

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)
    }
}

首先,我們需要解碼 request 然後獲取 payload。這與其它所有的操作函式類似,所以在後面的片段當中我將省略這部分程式碼。

然後一個節點比較它的 BestHeight 與從資訊中得到的大小。假如節點的區塊鏈更長,它將回應自己的版本資訊;否則,它會發送 getblocks 訊息。

8 獲得區塊 getblocks

type getblocks struct {
    AddrFrom string
}

getblocks 意味著“給我看看你有什麼區塊”(在比特幣當中,要比這個要複雜得多)。需要注意的是,這並不是說“給我你所有的區塊”,而是給我一個區塊雜湊值的列表。這樣有助於降低網路負荷,因為區塊可以從不同的節點下載,而且我們也不願意從一個節點下載Gb級別以上的資料。

處理命令簡單如下:

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

在我們的簡單實現當中,handleGetBlocks 會返回所有的區塊雜湊值。

9 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)
        }
    }
}

如果區塊雜湊被轉移了,我們要將它們儲存在 blockInTransit 變數當中以便跟蹤下載的區塊。這允許我們從不同的節點下載區塊。當我們將區塊放到傳輸狀態以後,我向 inv 資訊的傳送者下達 getdata 命令然後更新 blockInTransit。在一個實際的P2P 網路當中,我們需要在不同的節點之間傳送區塊。

在我們的實現當中,我們從不用 inv 多個雜湊值。這是為什麼當 payload.Type == "tx"只是獲取了第一個雜湊值。然後我們檢查我們的記憶體池中是否已經有這個雜湊,如果沒有,下達 getdata 命令。

10 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)
    }
}

這個函式的處理方式非常簡單:如果它們需要一個區塊,返回一個區塊;如果它們需要一個交易記錄,返回交易記錄。注意,我們並不檢查實際上我們是否有這樣的區塊或者交易記錄。這是一個瑕疵:)

11 block and 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()
    }
}

當我們收到一個新的區塊,我們將它放到我們的區塊鏈。假如還有更多的區塊需要下