Series lập trình blockchain với Go
Lời mở đầu
Qua hai bài viết trước của series, chúng ta đã lập trình được một blockchain đơn giản với khả năng mining để tạo block mới sử dụng thuật toán Proof of Work. Tuy nhiên, thông tin tất cả các block vẫn chưa được lưu trữ cố định và sẽ biến mất hoàn toàn khi chương trình kết thúc. Chương trình blockchain cũng chưa có một giao diện câu lệnh (CLI) để người dùng có thể tương tác một cách dễ dàng hơn. Trong phần tiếp theo này, chúng ta sẽ add thêm tính năng lưu trữ thông tin blockchain trên hard disk và 1 giao diện câu lệnh (CLI) cho blockchain của chúng ta
Lưu trữ data
Lựa chọn Database (DB)
Như đã nói, blockchain của chúng ta hoàn toàn lưu thông tin các block đã đào được trên memory và khi chương trình kết thúc thì tất cả các thông tin này sẽ không được lưu trữ. Một blockchain thật sự cần có data được lưu trữ trên hard disk để có tái sử dụng thông tin các block đã đào được, chia sẻ thông tin với những người sử dụng khác. Với Bitcoin thì DB được sử dụng là LevelDB, một Key-Value DB viết bằng C++ của Google. Chúng ta cũng sẽ sử dụng 1 Key-Value datatabase, tuy nhiên là một database được viết hoàn toàn bằng Go: Badger. Lí do để sử dụng Badger:
- Được viết hoàn toàn bằng Go, có thể được import vào chương trình 1 cách dễ dàng như một thư viện
- Đơn giản, dễ dùng, không cần có DB server để có thể hoạt động
- Dù là Key-Value, nhưng đáp ứng đủ nhu cầu cho bài toán của chúng ta ở đây
Với Badger thì sẽ không có data type mà cả Key và Value sẽ có định dạng là các mảng byte
. Do vậy, để có thể lưu các cấu trúc dữ liệu và đọc lại chúng từ Badger, chúng ta cần phải có công cụ để serialize
và deserialize
chúng. Ta sẽ dùng thư viện encoding/gob, một thư viện trong standard library của Go, để giải quyết vấn đề này.
Cấu trúc dữ liệu
Trước khi lập trình các logic code để lưu dữ liệu, chúng ta cần xác định dữ liệu của chúng ta cần lưu gồm có các thông tin gì và sẽ được lưu với cấu trúc như thế nào. Bitcoin hiện tại lưu dữ liệu vào hai phần chính như sau:
- blocks lưu trữ metadata mô tả về tất cả các blocks của blockchain
- chainstate lưu trữ trạng thái hiện tại của blockchain, bao gồm tất cả những transaction chưa được xử lý kèm theo một số metadata của chúng
Mỗi block sẽ được lưu trữ là một riêng biệt để tối ưu hoá performance. Và khi lưu trữ vào LevelDB thì blocks sẽ là những cặp Key - Value như sau:
- 'b' + 32-byte block hash -> thông tin block index
- 'f' + 4-byte file number -> thông tin về file information
- 'l' -> 4-byte file number -> Số thứ tự của file block cuối cùng được tạo
- 'R' -> 1-byte boolean -> flag thể hiện xem chain có đang được reindex hay không
- 'F' + 1-byte flag name length + flag name string -> 1 byte boolean: các thể loại flag có thể mang giá trị on hay of
- 't' + 32-byte transaction hash -> thông tin về transaction index
In chainstate, the key -> value pairs are:
- 'c' + 32-byte transaction hash -> thông tin về unspent transaction output (UTXOS của một transaction
- 'B' -> 32-byte block hash -> hash của block, lưu trữ các thông tin unspent transaction output (UTXOS của block này
Tuy nhiên, với blockchain đơn giản của chúng ta, do chưa có transactions và tất cả thông tin blocks sẽ được lưu chung vào một file nên chỉ cần 2 cặp Key-Value như sau:
- 32-byte block-hash -> Structure của block (đã được serialized)
- 'l' -> Hash của block mới nhất trong chain
Serialize và deserialize
Do các cặp Key-Value trong Badger đều được lưu dưới dạng []byte
mà chúng ta muốn lưu trữ thông tin block dưới dạng các struct Block
vào DB nên ta sẽ sử dụng thư viện encoding/gob để thực hiện công việc serialize và deserialize
Đầu tiên là method Serialize
cho 1 struct Block
.
// block.go
// Serialize serialize block to byte data
func (b *Block) Serialize() []byte {
var result bytes.Buffer
encoder := gob.NewEncoder(&result)
err := encoder.Encode(b)
if err != nil {
log.Panic(err)
}
return result.Bytes()
}
Code của method khá đơn giản, đầu tiên ta khai báo 1 buffer để lưu thông tin block đã được serialized, sử dụng gob encoder để encode thông tin block, sau đó trả về dưới dạng 1 byte array.
Tiếp theo là function DeserializeBlock
, tuy nhiên đây sẽ không phải là method cho một struct Block
mà là 1 function riêng
// block.go
// DeserializeBlock deserialize block from byte data
func DeserializeBlock(d []byte) *Block {
var block Block
decoder := gob.NewDecoder(bytes.NewReader(d))
err := decoder.Decode(&block)
if err != nil {
log.Panic(err)
}
return &block
}
Lưu trữ Data
Trước tiên, ta phải thêm Badger vào dependencies vào project blockchain của chúng ta:
$ dep ensure -add github.com/dgraph-io/badger
Sau đó, trong code, badger có thể được import và sử dụng như các thư viện standard khác của Go
import (
"log"
"github.com/dgraph-io/badger"
)
Ta cũng tạo một file mới db_util.go
chứa các function liên quan đến xử lý DB. Đầu tiên là function để khởi tạo một instance của Badger:
/ InitDB create database instance
func InitDB(dbDir string) *badger.DB {
opts := badger.DefaultOptions
var dbRootPath string
if runtime.GOOS == "windows" {
dbRootPath = "C:/tmp"
} else {
dbRootPath = "/tmp"
}
dbPath := filepath.FromSlash(dbRootPath + "/" + dbDir)
opts.ValueDir = dbPath
opts.Dir = dbPath
db, err := badger.Open(opts)
if err != nil {
log.Panic(err)
}
return db
}
Function này chỉ đơn giản là khởi tạo một instance Badger với thiết lập địa chỉ lưu trữ database được lấy từ tham số của hàm.
Để sử dụng Badger trong code của Blockchain của chúng ta, trước mắt chúng ta khai báo 2 biến constant, một là folder chứa data và một là prefix cho tất cả các Key của dữ liệu của chúng ta.
// blockchain.go
const dbDirectory = "gochain"
const dbPrefix = "gc_"
Việc tiếp theo là sửa lại function NewBlockchain
trong file blockchain.go
. Về mặt logic, function này phải làm được những bước sau:
- Tạo một instance DB
- Kiểm tra xem đã có thông tin blockchain trong chưa?
- Nếu đã có thông tin blockchain:
- Tạo mới 1
Blockchain
instance. - Thiết lập để tip của
Blockchain
instance này trỏ đến hash của block mới nhất được lưu trong DB.
- Tạo mới 1
- Nếu không có thông tin blockchain:
- Tạo block genesis.
- Lưu block trong DB.
- Lưu hash của genesis block trong DB với vai trò là hash của block mới nhất.
- Tạo mới 1
Blockchain
instance với tip trỏ trến block genesis.
Nếu thể hiện bằng code thì sẽ như sau:
// blockchain.go
// NewBlockchain initiate a new chain
func NewBlockchain() *Blockchain {
db := InitDB(dbDirectory)
isDataExist := CheckPrefixExist(dbPrefix, db)
var tip []byte
err := db.Update(func(txn *badger.Txn) error {
if isDataExist {
item, err := txn.Get([]byte(dbPrefix + "l"))
if err != nil {
return err
}
tip, err = item.Value()
if err != nil {
return err
}
} else {
genesis := NewGenesisBlock()
err := txn.Set(append([]byte(dbPrefix), genesis.Hash...), genesis.Serialize())
if err != nil {
return err
}
err = txn.Set([]byte(dbPrefix+"l"), genesis.Hash)
if err != nil {
return err
}
tip = genesis.Hash
}
return nil
})
if (err != nil) {
log.Panic(err)
}
return &Blockchain{tip, db}
}
Với Badger thì tất cả các xử lý với DB phải được thực hiện trong một transaction, với đoạn code ở trên thì ta đã sử dụng một transaction Read-Update của Badger:
err := db.Update(func(txn *badger.Txn) error {
...
}
Trong body của transaction Read-Update này ta có thể thực hiện các thao tác đọc và ghi dữ liệu dưới dạng Key-Value. Có thể thấy là với mỗi lần đọc ghi, ta đều gán thêm dbPrefix
vào phía trước của Key:
item, err := txn.Get([]byte(dbPrefix + "l"))
...
err := txn.Set(append([]byte(dbPrefix), genesis.Hash...),
...
Function CheckPrefixExist
dùng để check xem có dữ liệu blockchain hay chưa được implement ở trong file db_util
như sau
// db_util.go
// CheckPrefixExist check if there is data for this prefix in the database
func CheckPrefixExist(prefix string, db *badger.DB) bool {
var isPrefixExist bool
err := db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false // Vì chỉ cần check key nên ta chỉ get key, không lấy giá trị value nhằm tăng performance
it := txn.NewIterator(opts) // Tạo 1 Iterator mới
defer it.Close()
prefix := []byte(dbPrefix)
// Check lần lượt xem có key nào có prefix ứng với prefix đã truyền vào không, nếu có thì break
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
isPrefixExist = true
break
}
return nil
})
if (err != nil) {
log.Panic(err)
}
return isPrefixExist
}
Vì ở đây chỉ cần đọc dữ liệu nên ta sử dụng transaction Read Only của Badger
err := db.View(func(txn *badger.Txn) error {
...
})
Ngoài ra, để check xem liệu trong DB đã tồn tại Key có prefix như prefix đã định chưa thì ta sử dụng tính năng Iterator của Badger để duyệt lần lượt các key trong DB.
Quay lại đoạn code của NewBlockchain
ta thấy cách khởi tạo 1 Blockchain
instance cũng đã có thay đổi:
return &Blockchain{tip, db}
Chúng ta không lưu toàn bộ thông tin blocks vào một trường của struct nữa mà chỉ lưu hash của block mới nhất (tip). Ngoài ra, một DB connection cũng được lưu, trong khi chương trình đang chạy, connection này sẽ luôn được mở và đi kèm với Blockchain
instance. ```Blockchain`` struct hiện tại sẽ trở thành như sau:
type Blockchain struct {
tip []byte
db *badger.DB
}
Tiếp theo, chúng ta cũng phải sửa AddBlock
method, một block mới sẽ được lưu vào DB thay vì chỉ là add 1 phần tử vào array như trước:
// blockchain.go
// AddBlock add new block
func (bc *Blockchain) AddBlock(data string) {
var lastHash []byte
err := bc.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(dbPrefix + "l"))
if err != nil {
log.Panic(err)
}
lastHash, err = item.Value()
if err != nil {
return err
}
return nil
})
newBlock := NewBlock(data, lastHash)
err = bc.db.Update(func(txn *badger.Txn) error {
err = txn.Set(append([]byte(dbPrefix), newBlock.Hash...), newBlock.Serialize())
if err != nil {
return err
}
err = txn.Set([]byte(dbPrefix + "l"), newBlock.Hash)
if err != nil {
return err
}
bc.tip = newBlock.Hash
return nil
})
}
Ta sẽ đọc hash của block mới nhất từ DB, sau đó, dùng hash này để tạo một block mới (mining), sau đó serialize thông tin của block mới, lưu vào DB. Ta cũng update giá trị của Key dbPrefix + "l"
là hash của block mới này và thiết lập tip của instance Blockchain
là hash này.
Sau vài đoạn code dài, vậy là chúng ta đã hoàn thành việc thay đổi cơ chế lưu dữ liệu của blockchain từ tạm thời (array trên bộ nhớ) sang lưu trên 1 DB trên hard disk. Tiếp theo sẽ là việc đọc thông tin ra như thế nào.
Đọc thông tin của blockchain
Toàn bộ các blocks của chain đã được lưu trên DB, nên dù khi ta có dừng chương trình và sau đó chạy lại ta vẫn có thể mở lại kết nối với blockchain đã lưu và add thêm block mới được. Tuy nhiên, vì không phải làm một array nữa, nên ta sẽ không dễ dàng in toàn bộ các blocks chain ra được nữa.
Badger có cung cấp các Iterator method để ta có duyệt các cặp Key-Value được lưu trong DB, tuy nhiên chúng sẽ duyệt theo thứ tự giá trị của các Key chứ không theo thứ tự data được lưu vào. Do vậy, chúng ta sẽ tự lập trình 1 Iterator để có thể duyệt thông tin block theo thứ tự được add vào. Dưới đây là struct BlockchainIterator
ta sẽ dùng để làm công việc duyệt block:
// blockchain.go
type BlockchainIterator struct {
currentHash []byte
db *badger.DB
}
Mỗi lần ta muốn duyệt blocks trong blockchain, ta sẽ tạo instance BlockchainIterator
như trên và instance này sẽ lưu hash của block hiện tại và 1 kết nối tới DB. Do đó, 1 Iterator sẽ luôn đi kèm với 1 blockchain và sẽ được tạo bởi 1 method của Blockchain
:
// blockchain.go
// Iterator create an iterator for a blockchain instance
func (bc *Blockchain) Iterator() *BlockchainIterator {
bci := &BlockchainIterator{bc.tip, bc.db}
return bci
}
Vì 1 Iterator ban đầu sẽ trở đến tip một blockchain, nên blocks sẽ được duyệt từ mới nhất đến cũ nhất. Tiếp theo, ta sẽ tạo method Next
cho BlockchainIterator
, method trả về block trước đó của block hiện tại trong blockchain
// blockchain.go
// Next iterate to next element
func (i *BlockchainIterator) Next() *Block {
var block *Block
err := i.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(append([]byte(dbPrefix), i.currentHash...))
if err != nil {
return err
}
encodedBlock, err := item.Value()
if err != nil {
return err
}
block = DeserializeBlock(encodedBlock)
return nil
})
if err != nil {
log.Panic(err)
}
i.currentHash = block.PrevBlockHash
return block
}
Khi này, đoạn code để hiển thị thông tin toàn bộ blocks sẽ trở thành như sau:
bc := NewBlockchain()
bci := bc.Iterator()
for {
block := bci.Next()
fmt.Printf("Prev block's hash: %x\n", block.PrevBlockHash)
fmt.Printf("Current block's hash: %x\n", block.Hash)
fmt.Printf("Current block's data: %s\n", block.Data)
pow := InitProofOfWork(block)
fmt.Printf("PoW: %s\n", strconv.FormatBool(pow.Validate()))
fmt.Println()
if len(block.PrevBlockHash) == 0 {
break
}
}
Vậy là các tính năng cơ bản với DB cho blockchain đơn giản của chúng ta đã được hoàn thành xong! Tiếp theo sẽ là giao diện CLI
CLI Interface
Từ trước đến giờ, chương trình của chúng ta chưa hỗ trợ việc tương tác với chương trình, các thao tác đều được hard-coded vào thẳng chương trình. Ta sẽ lập trình giao diện CLI, để người dùng có thể add block mới và xem toàn bộ thông tin các blocks từ command line.
Ta có thể sử dụng standard library của Go để thực hiện các tính năng này, tuy nhiên để việc lập trình được nhanh và dễ dàng hơn ta sẽ sử dụng thư viện urfave/cli.
$ dep ensure -add github.com/urfave/cli
Và import nó ở trong file main
// main.go
import (
"fmt"
"log"
"os"
"strconv"
"github.com/urfave/cli"
)
Sử dụng thư viện này cũng khá đơn giản, hàm main
của chúng ta sẽ trở thành như sau:
// main.go
func main() {
app := cli.NewApp()
bc := NewBlockchain()
defer bc.db.Close()
app.Commands = []cli.Command{
{
Name: "addblock",
Aliases: []string{"ab"},
Usage: "add a new block to the chain",
Flags: []cli.Flag{
cli.StringFlag{
Name: "data, d",
Usage: "data for the new block",
},
},
Action: func(c *cli.Context) error {
blockData := c.String("data")
if blockData == "" {
cli.ShowCommandHelpAndExit(c, "addblock", 1)
}
bc.AddBlock(blockData)
return nil
},
},
{
Name: "printchain",
Aliases: []string{"p"},
Usage: "add a new block to the chain",
Action: func(c *cli.Context) error {
bci := bc.Iterator()
for {
block := bci.Next()
...
if len(block.PrevBlockHash) == 0 {
break
}
}
return nil
},
},
}
err := app.Run(os.Args)
if err != nil {
log.Panic(err)
}
}
Sử dụng urfave/cli
, ta tạo entry point cho chương trình của mình:
// main.go
app := cli.NewApp()
Sau đó, add các command mà ta muốn chương trình hỗ trợ vào entry point này. Mỗi command sẽ là 1 instance của struct cli.Command
. Vì chương trình sẽ có 2 command là tạo block mới và xem tất cả các block đã có nên list commands truyền vào sẽ có 2 phần tử như đoạn code ở dưới. Mỗi command này sẽ thực hiện đoạn code trong anonymous function được gán vào trường Action
của struct cli.Command
. Ngoài ra với command addblock
thì cần phải truyền data của block mới vào cho chương trình nên ta cần định nghĩa thêm 1 flag riêng để có thể thực hiện việc này bằng cách sử dụng trường Flags
. Bạn đọc có thể tham khảo thêm ý nghĩa các trường khác và cách dùng ở trong tài liệu của urfave/cli
.
app.Commands = []cli.Command{
{
Name: "addblock",
Flags: ...
Action: func(c *cli.Context) error {
...
}
},
{
Name: "printchain",
...
Action: func(c *cli.Context) error {
...
}
},
}
Bây giờ, ta sẽ thử chạy chương trình lần đầu tiên với không 1 command nào được truyền vào.
$ ./gochain
NAME:
Gochain - A blockchain in Golang
USAGE:
gochain [global options] command [command options] [arguments...]
VERSION:
0.0.0
DESCRIPTION:
A blockchain in Golang
COMMANDS:
addblock, ab add a new block to the chain
printchain, p print all blocks in the chain
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help
--version, -v print the version
Ta có thể thấy khi chạy lần đầu tiên, genesis block sẽ được đào và add vào blockchain và khi không có command nào được truyền vào, nhờ thư viên urfave/cli
ta có một giao diện hướng dẫn rất đẹp và trực quan.
Tiếp theo thử add một block mới vào blockchain xem sao
$ ./gochain addblock -d "Linh Dep Trai"
Mining the block containing "Linh Dep Trai"
0000a37bd73cf8687a03f9df39b7d4b2dde7d8cb7d409428fc48b6ff8d53c0ed
Done adding new block to chain!
$./gochain addblock -d "Linh Dep Trai 2"
Mining the block containing "Linh Dep Trai 2"
0000f2db38fddfbb37f4819a2bf8fd013f31c55dcb413365ca3c1327ea8b1ad2
Done adding new block to chain!⏎
Ta có thể xem lại thông tin của các blocks vừa được add bằng command printchain
$ ./gochain printchain
Prev block's hash: 0000a37bd73cf8687a03f9df39b7d4b2dde7d8cb7d409428fc48b6ff8d53c0ed
Current block's hash: 0000f2db38fddfbb37f4819a2bf8fd013f31c55dcb413365ca3c1327ea8b1ad2
Current block's data: Linh Dep Trai 2
PoW: true
Prev block's hash: 0000fc08f8dd06ab30990f649bcd8ebf9bd3ad4bc44c8716b98cff59e757ac09
Current block's hash: 0000a37bd73cf8687a03f9df39b7d4b2dde7d8cb7d409428fc48b6ff8d53c0ed
Current block's data: Linh Dep Trai
PoW: true
Prev block's hash:
Current block's hash: 0000fc08f8dd06ab30990f649bcd8ebf9bd3ad4bc44c8716b98cff59e757ac09
Current block's data: Genesis Block
PoW: true
Data của blockchain đã được lưu tại folder /tmp/gochain
như trong setting khi khởi tạo Badger:
$ ls /tmp/gochain/
000000.vlog 000006.sst MANIFEST
Kết luận
Kết thúc phần này, chương trình blockchain của chúng ta đã có một giao diện CLI khá ổn với data được lưu trữ trên hard disk, có thể được ghi và đọc lại nhiều lần giữa các lần chạy. Phần tiếp theo chúng ta sẽ add thêm chức năng xử lý transaction, tính năng quan trọng của một blockchain hoàn chỉnh.