Series lập trình blockchain với Go

  1. Block và blockchain sơ khai
  2. Proof of Work

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:

  1. Đượ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
  2. Đơn giản, dễ dùng, không cần có DB server để có thể hoạt động
  3. 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ụ để serializedeserialize 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:

  1. blocks lưu trữ metadata mô tả về tất cả các blocks của blockchain
  2. 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:

  1. 'b' + 32-byte block hash -> thông tin block index
  2. 'f' + 4-byte file number -> thông tin về file information
  3. 'l' -> 4-byte file number -> Số thứ tự của file block cuối cùng được tạo
  4. 'R' -> 1-byte boolean -> flag thể hiện xem chain có đang được reindex hay không
  5. '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
  6. 't' + 32-byte transaction hash -> thông tin về transaction index

In chainstate, the key -> value pairs are:

  1. 'c' + 32-byte transaction hash -> thông tin về unspent transaction output (UTXOS của một transaction
  2. '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:

  1. 32-byte block-hash -> Structure của block (đã được serialized)
  2. '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:
    1. Tạo mới 1 Blockchain instance.
    2. 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.
  • Nếu không có thông tin blockchain:
    1. Tạo block genesis.
    2. Lưu block trong DB.
    3. Lưu hash của genesis block trong DB với vai trò là hash của block mới nhất.
    4. 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.

Tham khảo