基於Tendermint的區塊鏈漂流瓶簡單實現
本文主要借demo介紹基於Tendermint的區塊鏈應用開發,這個demo很簡單,主要包含以下功能:
- 扔漂流瓶
- 撈漂流瓶
- 之後投放者和打撈者可以相互傳遞[加密]資訊
程式碼已上傳至github。
Tendermint
Tendermint幫我們實現了PBFT,相當於搭了一個共識框架,包含兩部分:
- Tendermint-core:PBFT共識演算法實現;
- Tendermint-abci:定義了應用須實現的介面和呼叫規則,還實現了與外部通訊的socket-server。官方的這部分原始碼可以看做是Go-abci,我們也可以根據需要編寫其它語言的xxx-abci。
可以將其類比為傳統應用的開發框架(如MVC),而我們要做的就是基於abci編寫具體的區塊鏈邏輯(為方便和清晰起見,本文用Go編寫具體邏輯,自然abci就用官方的了),這就實現了服務端;而使用者也需要一個客戶端用來與區塊鏈互動。
以上,Tendermint、服務端邏輯、客戶端,三者組成了一個完整的區塊鏈應用。
資料庫
在動手編碼之前,要考慮資料儲存的問題,選擇文字檔案還是Oracle呢?區塊鏈網路裡大部分是普通電子裝置,使用者亦是普通人,讓他們事先安裝大型資料庫顯然不現實,更不用說區塊鏈本身不會出現複雜操作資料的業務。另外由於全節點資料的完備性,用不著通過網路去其它裝置上查詢資料,很多資料庫自帶的網路服務也不需要(SPV這種,業務單一,完全可以單獨開放一個遠端介面)。而文字檔案、excel之類的,只適合人類使用,根本不能算作資料引擎。我們需要的是一個滿足基本CUID的高效的本地資料庫,目前大多區塊鏈使用LevelDB作為儲存引擎,這是C/C++編寫的本地kv資料庫,原作者也寫了Go實現的版本,其原理可參看 半小時學會LevelDB原理及應用 ,godoc地址:https://godoc.org/github.com/syndtr/goleveldb/leveldb。LevelDB總體上採用了LSM-Tree的設計思想(LSM-Tree的雖說是資料結構,但更偏重於設計思路)。
LevelDB同時只能被一個程序使用。另,以太坊的資料儲存於/chaindata目錄下,執行後其下會生成一坨.ldb檔案,而非網上常說的sst檔案,這可能是跟13年的一次版本更新有關,Release LevelDB 1.14。另:LevelDB的k-v模式(順序讀效率不高)不適合relationship。
為方便使用,可以封裝一些常用的資料庫操作。順便嘗試下提供新操作的幾種思路。
- 直接給leveldb.DB增加新方法:
// 給leveldb.DB增加Set方法 func (db *leveldb.DB) Set(key []byte, value []byte) { //... err := db.Put(key, value, nil) //... }
- 既然無法在外部修改leveldb.DB的方法集,那麼就在當前package建一個繼承leveldb.DB的struct,即內嵌一個leveldb.DB型別欄位, type GoLevelDB struct { *leveldb.DB } ,然後將上述程式碼的指標型別改為*GoLevelDB即可,很完美。不過,在封裝Get方法的時候出問題了:
func (db *GoLevelDB) Get(key []byte) []byte { //... //Go不支援過載,或者說Go只把方法名作為唯一簽名。 //這裡原意是呼叫的父類的Get方法,但該方法被當前類的Get方法覆蓋了,引數不一致導致編譯失敗 res, err := db.Get(key, nil) //... return res }
不支援過載,只能修改子類的方法名,蛋疼;或者改成如下方式。
-
type GoLevelDB struct { db *leveldb.DB }
和第2種的區別就是把is-a改為has-a,也不用擔心方法重名的問題。不過我私以為若Go支援過載,第2種方式會好一點,至少不會巢狀太多層。
服務端
abci定義瞭如下介面:
type Application interface { // Info/Query Connection Info(RequestInfo) ResponseInfo // Return application info SetOption(RequestSetOption) ResponseSetOption // Set application option Query(RequestQuery) ResponseQuery // Query for state // Mempool Connection CheckTx(tx []byte) ResponseCheckTx // Validate a tx for the mempool // Consensus Connection InitChain(RequestInitChain) ResponseInitChain // Initialize blockchain with validators and other info from TendermintCore BeginBlock(RequestBeginBlock) ResponseBeginBlock // Signals the beginning of a block DeliverTx(tx []byte) ResponseDeliverTx // Deliver a tx for full processing EndBlock(RequestEndBlock) ResponseEndBlock // Signals the end of a block, returns changes to the validator set Commit() ResponseCommit // Commit the state and return the application Merkle root hash }
很明顯,後面幾個方法參與了區塊鏈狀態的更迭,我們就來捋捋交易從客戶端提交到最終上鍊的過程(不精確):
- 節點a的客戶端發起一筆交易tx;
- 節點a服務端呼叫CheckTx方法校驗tx是否合法,若非法則丟棄,當做什麼事都沒發生過;
- 若合法,則將tx加入到本地mempool中,並向其它節點廣播tx;
- 其它節點接收到tx,同樣執行2-3步驟;
- 某輪決議開始,提議者蒐集mempool中的txs,併發起投票,達成共識後,各節點呼叫BeginBlock開始將它們打包;
- 呼叫DeliverTx執行每筆交易並將其記錄到區塊中(一筆交易執行一次DeliverTx);
- 呼叫EndBlock表示打包完成;
- 發起共識決議,提議者將新區塊廣播給其它驗證者;(共識決議在第5步完成)
- 其它驗證者接收到區塊後,呼叫DeliverTx執行每筆交易並校驗結果,若沒問題則廣播commit請求(預提交)和新區塊;
- 若節點收到超過2/3驗證者的commit請求,呼叫Commit方法,更新整個應用狀態。
假如將要打包的tx快取起來,我們就可以在DeliverTx、EndBlock、Commit三個方法中選擇其一實際執行tx,但是一般來說,交易執行都是放在DeliverTx,比較符合語義。EndBlock用於更新共識引數和Val集合,Commit用於更新整個應用狀態(apphash),需要注意的是,本次提交的apphash若與上次提交的不同,則會繼續產生新的區塊(不管有沒有新交易,就算設定consensus.create_empty_blocks=false,tendermint也會產生空區塊,可參看 Enable no empty blocks #308 。),這似乎是tendermint的有意設計,但不知為何。
另Query方法接收的RequestQuery型別引數有Path和Data兩個欄位,Path是string型別,Data是[]byte,應該是對應於Http的get、post。示例程式碼中我是通過正則表示式解析Path查詢各類資料,其實若是複雜查詢/結構化查詢,還是Data欄位比較實用。
正則表示式的所謂零寬斷言:只匹配位置,而不消費字元。下面舉個例子。如 \b\w*q[^u]\w*\b,它能匹配“Iraq,Benq”。因為[^u]總是匹配一個字元,所以如果q是單詞的最後一個字元的話,後面的[^u]將會匹配q後面的單詞分隔符(可能是空格,或者是句號或其它的什麼),接著後面的\w+\b將會匹配下一個單詞,於是\b\w*q[^u]\w*\b就能匹配整個Iraq fighting。如果在這個例子中,我們只想匹配到Iraq,那麼可以採用零寬負向先行斷言(?!exp)的方式,\b\w*q(?!u)\w*\b,它將不會消費Iraq後面的空格或逗號等字元,因此\w*也不會匹配到下一個單詞。參看 【詳細】正則表示式30分鐘入門教程 之位置指定和後向位置指定部分。
客戶端
demo採用命令列終端,基於cobra庫。
1 var rootCmd = &cobra.Command{ 2 Use: "dbcli", 3 //throw:丟;salvage:撈;reply:迴應。 ValidArgs要有定義Run[E],並與Args: cobra.OnlyValidArgs結合才起作用,表示引數值只能是預設值 4 //ValidArgs: []string{"throw", "salvage", "reply", "bbalj"}, 5 //Args主要是用來校驗引數的 6 //Args: cobra.OnlyValidArgs, //cobra.ExactArgs(0), 7 // RunE: func(cmd *cobra.Command, args []string) error { //args並不包含flag;os.Args是包含flag的 8 // }, 9 } 10 11 func main() { 12 if err := rootCmd.Execute(); err != nil { 13 fmt.Println(err) 14 os.Exit(-1) 15 } 16 }
原本我想實現互動模式(類似mysql>),但cobra似乎沒有提供相關方法,我們只好自己想辦法,需要注意的是需要自解析使用者輸入,比如使用者輸入有空格,該空格是分隔引數還是引數內部的,要做區分。原本打算參考cobra解析命令列的原始碼,發現實際解析使用的是spf13/pflag庫,而pflag只是加強了go標準庫flag,而flag庫也並沒有涉及到引數值本身的具體解析,這部分工作依靠的是oa庫,主要是oa.Args屬性,它依賴更底層的程式碼。
// 摘自go/src/os/proc.go // Args hold the command-line arguments, starting with the program name. var Args []string func init() { if runtime.GOOS == "windows" { // Initialized in exec_windows.go. return } Args = runtime_args() } func runtime_args() []string // in package runtimeView Code
如註釋所示,windows下是在exec_windows.go中實現,其它作業系統的實現沒找到,應該是使用其它語言編寫或直接呼叫的系統api。進exec_windows.go中,發現關鍵函式readNextArg:
1 // readNextArg splits command line string cmd into next 2 // argument and command line remainder. 3 func readNextArg(cmd string) (arg []byte, rest string) { 4 var b []byte 5 var inquote bool 6 var nslash int 7 for ; len(cmd) > 0; cmd = cmd[1:] { 8 c := cmd[0] 9 switch c { 10 case ' ', '\t': 11 if !inquote { 12 return appendBSBytes(b, nslash), cmd[1:] 13 } 14 case '"': 15 b = appendBSBytes(b, nslash/2) 16 if nslash%2 == 0 { 17 // use "Prior to 2008" rule from 18 // http://daviddeley.com/autohotkey/parameters/parameters.htm 19 // section 5.2 to deal with double double quotes 20 if inquote && len(cmd) > 1 && cmd[1] == '"' { 21 b = append(b, c) 22 cmd = cmd[1:] 23 } 24 inquote = !inquote 25 } else { 26 b = append(b, c) 27 } 28 nslash = 0 29 continue 30 case '\\': 31 nslash++ 32 continue 33 } 34 b = appendBSBytes(b, nslash) 35 nslash = 0 36 b = append(b, c) 37 } 38 return appendBSBytes(b, nslash), "" 39 }View Code
其中對雙引號做了處理,註釋中還提供了一個網址How Command Line Parameters Are Parsed,應該是關於這方面的演算法說明,日後再看。
序列化
當我們在說序列化的時候,我們在說什麼。序列化說白了就是資料轉化,或者說一一對應的對映關係。就記憶體場景來說,一個物件序列化為另一個物件,本質上它們都一樣,都是儲存在記憶體中的0、1序列,只是同一個東西不同的資料表達。比如將一個數值序列化(或者說轉化)成字串型別,或者將數值int32轉為數值int8,那麼記憶體中的儲存空間和儲存資料都不會一樣,字串還要看用的什麼編碼。再如我們將一個物件序列化為byte[],不同的方案會產生不同的結果。比如使用C指標將物理資料直接映射出來,或者以json方式序列化,或者protobuf序列化,會產生不同的byte[];反之亦然。
不管是json編碼還是二進位制編碼,物理上儲存的都是二進位制,json編碼包含於二進位制編碼,我們可以根據需要自定義二進位制編碼,一般是為了減少儲存佔用的空間。比如json編碼,對1、2等數值型別是按字串格式編碼(如utf8格式,1編碼的就是0x31,12佔兩個位元組0x310x32),而我們自定義二進位制,完全可以把12儲存在一個位元組裡面,該位元組值就是數值本身;就算不是數值,而是字串本身編碼,我們也可以在utf8編碼後再壓縮,類似gzip。
go中的序列化方式,可參看 Golang 序列化方式及對比,但是文中gob的測試程式碼其實可以改良下,將enc/dec兩個變數移到迴圈外,如此可在迴圈內複用,這將發揮gob上下文的優勢。
protobuf的變長編碼針對的是數值型別,so應該只對數值欄位多的型別有壓縮的意義。
go對字串是utf8編碼,基本不用擔心中文亂碼問題。
vscode-go開發環境
在國內,搭建Go開發環境都不會太順利,下面我就說說在vscode中搭建環境可能會遇到的問題和解決方法。
Go開發環境需要vscode安裝一些外掛,而專案中也有引用的類庫,這兩者都可能涉及到相關站點在牆外的情況,而我們也要分別設定代理。首先,給vscode本身設定代理,使得安裝外掛沒有問題;其次,在命令列視窗設定http_proxy,使得dep順利進行。也可以在vscode終端視窗設定http_proxy(vscode的終端就是個命令列互動環境,使用的還是作業系統的shell,本質上獨立於vscode),但博主發現似乎並不起作用。
在代理什麼都設定好後,vscode安裝外掛時仍可能遇到問題,比如檔案中已經存在的golang.org\x\tools目錄關聯的git原始碼網址不是外掛要求的原始碼網址,原因可能是之前手動到github裡下載的tools原始碼,將tools目錄移除重新跑一遍安裝外掛的步驟即可。
安裝goimports時可能會timeout等錯誤,參考 安裝goimports 解決。
專案方面,具體到我們這個demo,遵照tendermint官方文件,make get_tools。我是windows10系統,使用bash命令進入到自帶的Ubuntu子系統,就可以使用內建的make了。需要注意的是,若設定了系統變數GOPATH,且是以分號分隔的多個資料夾,那麼切換到Ubuntu後,由於linux系統是按冒號分隔的,所以它會把分號當做資料夾名的一部分,導致自動建立一些奇怪目錄。如果是其它windows系統,可以安裝mingw,定位到安裝目錄的bin目錄下,就可以使用mingw-make操作了(可以將mingw-make重新命名為make),可能會報錯:
process_begin: CreateProcess(NULL, env bash F:\Document\code\tendermint\tendermint\scripts\get_tools.sh, ...) failed. make (e=2): 系統找不到指定的檔案。
如果不是get_tools.sh的路徑問題,那就應該是bash衝突了(比如系統中安裝了git,同時把git目錄也配置到PATH下,實際定位的可能就是git的bash了)。
注意tendermint所需的最低Go版本。
我們要嚴格遵循Go的目錄規範,若將程式碼直接置於src\目錄下,則執行dep相關操作時,會丟擲“root project import: dep does not currently support using GOPATH/src as the project root”錯誤。需要在src\下再建一個目錄,把程式碼拷進這個子目錄再執行dep。Go遵循約定大於配置的原則,它在專案中引入所有依賴類庫的程式碼,而這些類庫也是放置於src目錄下,所以需要按子目錄分開。另關於依賴項搜尋Support vendor directory as $GOPATH/src/vendor #313 應該有參考價值,另可參看 dep init fails if in not in $GOPATH[...]/src/{somedir..} #148。
dep似乎會將GOPATH\src下的依賴也複製到vendor下,感覺是不是沒這必要。
經驗:最好在專案剛開始搭建就 dep init,否則在程式碼敲了一個階段後,已經import了多個外部依賴,當這時候再 dep init,如果出現錯誤,將不會生成Gopkg.toml,如果是因為版本問題導致的錯誤,你都沒辦法通過編輯Gopkg.toml的方式解決。比如我就遇到這種情況,dep init -gopath, -gopath表示先去本地GOPATH目錄找依賴庫,找不到再去網上拉取,結果我的本地庫版本不是master分支,而貌似dep預設的就是master,導致“v0.30.2: Could not introduce github.com/tendermint/[email protected], as it is not allowed by constraint master from project tuoxie/driftbottle.”這樣的錯誤提示(dep也是一根筋,它會把這個庫的所有release版本都比對一遍看滿不滿足constraint)。此時也不是沒辦法,我們可以把入口函式main所在檔案整個註釋掉,這樣dep就不會遍歷程式碼檔案,但仍然會生成Gopkg.toml,這個時候就可以手動編輯約束版本號了。
go install 不會把vendor目錄下的所有包無腦打包進exe檔案,而是會根據實際依賴打包,這樣也使得我們可以多個[子]專案使用同一個vendor,減小磁碟佔用和複用已下載的依賴包,而不必擔心exe檔案過大的問題。
目前vscode除錯go尚不能支援互動模式的命令列除錯,沒有如python那樣可以在launch.json設定console屬性[為externalTerminal]。
其它
作為區塊鏈最廣泛應用的數字貨幣已經不再像不久以前一樣能夠隨意撩撥投機者的神經,但這項技術在其它更實用的領域或許仍值得期待。比如區塊鏈的共識機制、區塊時間戳、防篡改特性,似乎天生是為智慧財產權保護打造的,然而迄今為止市面上尚未出現讓人眼前一亮的產品。前段時間看到一則新聞,說百度上線了一個保護圖片版權的區塊鏈專案“圖騰”,有興趣的同學可以去了解下。如果我要實現類似的智慧財產權鏈,會考慮檔案相似度判別、[使用代幣]支付版權費及支付策略(買斷or按次付款等)等等,交易媒介和交易標的都在鏈上,形成閉環。鏈上閉環可不受外部實體困擾,以區塊鏈二代的明星特性“智慧合約”為例,一旦與外部有所關聯,就無法保證合約的事務完整性,可參看我之前的觀點。
Tendermint裡有很多ethereum的影子,比如gas、db的封裝等,部分思路和程式碼應該是參考了ethereum的實現。
ethereum(以太坊)相關概念:
MPT:即Merkle Patricia Tree,是Merkle Tree 和Patricia Tree結合的產物。Patricia Tree又是Trie Tree的一種變化。參考資料:Trie原理以及應用於搜尋提示,以太坊MPT原理,你最值得看的一篇。這兩篇偏向於原理,若要了解具體細節,可看 乾貨 | Merkle Patricia Tree 詳解。
叔區塊
gas:一直很好奇以太坊是怎麼做到計算實際使用gas量的,特別是有控制跳轉語句的時候,最可靠的方式是實際執行時實時計算gas,那這個應該是由EVM實現的。具體可看 以太坊虛擬機器及交易的執行,以太坊智慧合約虛擬機器(EVM)原理與實現。
資料結構與儲存方式:以太坊原始碼情景分析之資料結構,[以太坊原始碼分析] II. 資料的呈現和組織,快取和更新
個人認為區塊鏈目前普遍存在的問題:
- 升級困難(側鏈?)
- 維護困難(當單節點故障時,只能依靠該節點自身能力處理,對於普通使用者來說,無疑是棘手的)
- 隨著時間的推移,資料量會變得越來越大,全節點將相應變少,最終形成某種意義上的中心化網路
更多資料:
以太坊原始碼深入分析(7)-- 以太坊Downloader原始碼分析
轉載請註明本文出處:https://www.cnblogs.com/newton/p/9611340.