go語言實現最小區塊鏈教程7-網路
1 介紹 Introduction
到目前為止,我們構建了一個含有以下特徵的區塊鏈:匿名、安全、以及隨機產生地址;區塊鏈資料儲存;PoW系統;可靠的交易記錄儲存方式。這些特徵都非常關鍵,但是這還不夠。能夠讓這些特徵昇華的,並且讓加密貨幣變得可能的,是網路(network)。這樣的區塊鏈實現如果只能在單一的電腦上面執行有什麼用?這些基礎加密特性有什麼有,如果僅有一個使用者?網路讓這些機制工作併發揮作用。
你可以將這些區塊鏈的特徵視為基本準則,你會發現與人類希望共同生活和成長的準則類似。一種社會性的安排。區塊鏈網路是遵從同樣準則的程式社群,也正是遵從這些準則讓這個網路變得活力四射。同樣的,當人們分享共同的想法,他們將變得更強,然後可以一起構建更美好的生活。如果其中有人遵從不一樣的準則,他們將在一個隔離的社會(國家、社群等)裡生活。同樣的,如果有一個區塊鏈節點執行不一樣的規則,它們將形成一個單獨的網路。
這個非常重要:沒有一個有共識的網路或者大部分節點有共識的網路,這些規則就毫無用處。
免責宣告:不幸的是,我沒有足夠的時間去實現一個真正的P2P網路原型。在這篇文章當中,我會證明一個最平常的情景,會涉及不同型別的節點。提高這個場景然後將它實現為一個真正的P2P 網路會是各位讀者一個絕好的挑戰和實踐!我也不能保證除了本文所實現的情景以外的其它情景能夠解決問題。對不起!
這部分介紹了關鍵的程式碼變化,因此在這裡解釋所有的程式碼不是特別有意義。可以參看這個頁面看與上一部分文章之間的程式碼變動。
2 區塊鏈網路 Blockchain Network
區塊鏈網路是去中心化的,這意味著沒有伺服器提供服務,然後客戶端通過服務區獲取或處理資料。在區塊鏈網路當中分佈著不同的節點(nodes),每一個節點都是網路上完整(full-fledged)的節點。一個節點就是所有:即是節點也是伺服器。這個非常重要,必須牢牢記住,因為這與普通的 web 應用完全不同。
區塊鏈網路是一個 P2P (Peer-to-Peer)網路,這意味著各個節點與其它節點之間直接連線。它的拓撲結構是平的,在節點規則當中沒有層級關係。下面是它的示意圖:
這樣的網路當中的節點更難以實現,因為它們需要執行很多的操作。每一個節點都要與其它不同的節點之間互動,它需要獲得其它節點的狀態,並與自己的狀態對比,如果發現自己的狀態不是最新的還需要更新自己的狀態。
3 節點角色 Node Roles
除了要全能以外,區塊鏈節點可以在網路中扮演不同的角色。如下所示:
- 礦機。
這樣的節點執行在超強效能或者專門的硬體(比如ASIC)上,它們的唯一目標就是儘快挖到新的區塊。礦機僅在採用PoW機制的區塊鏈上能夠工作,因為挖礦本身實際上就是求解PoW謎題。打個比方,在採用PoS(Proof-of-Stake)機制的區塊鏈上,並沒有挖礦。 - 全能節點。
這些節點驗證由礦機挖到的區塊並確認交易記錄。為了做這件事情,它們必須要有區塊鏈的整個網站拷貝。這樣的節點也履行一些日常的操作,比如幫助其它節點去發現彼此。一個網路必須要有很多全能節點,這個非常重要,因為是這些節點在做這樣的決定:它們決定著一個區塊或者交易記錄是否合法有效。 - 簡單支付驗證 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)然後獲得一份全能節點的列表,然後從它們那裡下載區塊鏈。
在我們的實現當中,目前也是集中式的。我們會有以下三個節點:
- 中央節點。這是其它所有節點都會連線的,也是與其它節點互動資料的節點。
- 一個挖礦節點。這個節點會在記憶體池儲存新的交易記錄,並且當有足夠的交易記錄時,會挖一個新的區塊。
- 一個錢包節點。這個節點用於在錢包之前轉移幣。不像SPV節點,它儲存一份完整的區塊鏈拷貝。
6 情景 The Scenario
這篇文章的目的是實現以下場景:
- 中央節點建立一個區塊鏈。
- 其它節點(錢包節點)連線到中央節點並下載區塊鏈。
- 還有一個挖礦節點將連到中央節點並從中央節點下載區塊鏈。
- 錢包節點建立交易記錄。
- 挖礦節點接收交易記錄並將它儲存在自己的記憶體池當中。
- 當記憶體池中的交易記錄足夠多時,礦工便開始挖新的區塊。
- 當一個新的區塊被挖出來,它將會被髮送到中央節點。
- 錢包節點會與中央節點同步。
- 錢包節點的使用者會檢查它們的支付是否成功。
這就是比特幣看起來的樣子。即便我們沒有準備構建一個實際的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()
}
}
當我們收到一個新的區塊,我們將它放到我們的區塊鏈。假如還有更多的區塊需要下