Оглавление
- Blockchain на Go. Часть 1: Прототип
- Blockchain на Go. Часть 2: Proof-of-Work
- Blockchain на Go. Часть 3: Постоянная память и интерфейс командной строки
Вступление
В
предыдущей части мы построили блокчейн с PoW системой и возможностью майнинга. Наша реализация всё ближе к полностью функциональному блокчейну, но ей все ещё не хватает некоторых важных функций. Сегодня мы начнем хранить блокчейн в базе данных, после этого сделаем интерфейс командной строки для операций с блокчейном. По сути, блокчейн — это распределенная база данных. Мы пока опустим «распределенная» и сосредоточимся на «база данных».
Выбор базы данных
Пока что, у нас нет базы данных в реализации, мы просто создаем блоки при запуске программы и храним их в памяти. Мы не можем повторно использовать или поделиться с другими нашим блокчейном, поэтому нам нужно сохранить его на диске.
Какая база данных нам нужна? На самом деле, подойдет любая. В
Биткоин Paper ничего не сказано про конкретную базу данных, так что выбор остается за разработчиком.
Bitcoin Core , который был первоначально опубликован Сатоши Накамото и который в настоящее время является эталонной реализацией Bitcoin, использует
LevelDB (хотя он был представлен клиенту только в 2012 году). А мы будем использовать…
BoltDB
Потому что:
- Она простая и минималистичная
- Она реализована на Go
- Ей не требуется запуск сервера
- Она позволяет строить необходимые нам структуры данных
Из
BoltDB README:
Bolt -это просто хранилище типа «ключ-значение», вдохновленное проектом Говарда Чу LMDB. Цель проекта — предоставить простую, быструю и надежную базу данных для проектов, для которых не требуется полноценный сервер базы данных, такой как Postgres или MySQL.
Так как Bolt предназначен для использования в качестве такого низкоуровневого элемента функциональности, простота является ключевой. API будет небольшим и ориентироваться только на получение значений и установке значений. Это всё!
Звучит идеально для наших нужд! Потратим минутку на обзор базы.
BoltDB — это хранилище «ключ-значение», что значит, что нет таблиц, как в реляционных СУБД ( MySQL, PostgreSQL и тд), нет рядов и столбцов. Вместо этого, данные хранятся в парах «ключ-значение»( как в Golang map). Пары хранятся в «корзинах», которые предназначены для группировки похожих пар (подобно таблицах в реляционных СУБД). Таким образом, чтобы получить значение, надо знать корзину и ключ.
Важной вещью про BoltDB является то, что здесь нет типов данных: ключи и значения — это байтовые массивы. Так как мы храним Go структуры ( в частности
Block
), то мы должны сериализовать их, то есть реализовать механизм по переводу структуры в байтовый массив и восстановлению её назад из массива. Мы будем использовать
encoding/gob для этого, хотя
JSON, XML, Protocol Buffers
тоже подходят. Мы используем
encoding/gob
, потому что это просто и это часть стандартной библиотеки Go.
Структура базы данных
До того, как мы начнем реализовывать персистентную логику, мы должны решить, как будем хранить наши данные в базе. И для этого мы будем использовать способ, который используем Bitcoin Core.
Если по-простому, то Bitcoin Core использует две «корзины» для хранения данных.
-
blocks
хранит метаданные, описывающие все блоки в цепи
-
chainstate
сохраняет состояние цепи, которое представляет собой все непотраченные выходы транзакций и некоторые метаданные
Также блоки хранятся как отдельные файлы на диске. Это сделано для повышения производительности: чтение одного блока не требует загрузку всех (или некоторых) в память. Это мы не будет реализовывать.
В
blocks
пары
key->value
это:
- 'b' + 32-байтовый хэш блока -> запись индекса блока
- 'f' + 4-байтовый номер файла -> запись информации о файле
- 'l' -> 4-байтовый номер файла: номер использованного файла для последнего блока
- 'R' -> 1-байтовый boolean : находимся ли мы в процессе переиндексации
- 'F' + 1-байтовая длина имени флага + строка имени флага -> 1 байт boolean: различные флаги, которые могут быть включены или выключены
- 't' + 32-байтовый хеш транзакции -> запись индекса транзакции
В
chainstate
пары
key->value
это:
- 'c' + 32-байтовый хеш транзакции -> запись о непотраченном выходе транзакции для этой транзакции
- 'B' -> 32-байтовый хеш блока: хеш блока, до которого база данных представляет собой неизрасходованные выходы транзакции
(
Подробное пояснение можно найти здесь)
Так как у нас пока что нет транзакций, то мы сделаем только корзину
blocks
. Кроме того, как было сказано выше, мы будем хранить всю базу данных в одном файле, без хранения блоков в отдельных файлах. Поэтому нам не нужно ничего, связанное с файловыми номерами. Поэтому пары
key->value
, которые мы будем использовать, это:
- 32-байтовый хэш блока -> структура блока (сериализованная)
- 'l' -> хэш последнего блока в цепи
Это всё, что нам необходимо знать для реализации механизма постоянства ( персистентности).
Сериализация
Как сказано ранее, в BoltDB значения могут быть лишь
[]byte
типа, и мы хотим хранить структуру
Block
в базе. Мы будем использовать
encoding/gob
для сериализации структур.
Давайте реализуем метод
Serialize
для
Block
(обработка ошибок для краткости опущена)
func (b *Block) Serialize() []byte {
var result bytes.Buffer
encoder := gob.NewEncoder(&result)
err := encoder.Encode(b)
return result.Bytes()
}
Здесь всё просто: в начале, мы объявляем буфер, где будут храниться сериализованные данные, затем инициализируем
gob
кодировщик и кодируем блок, результат возвращаем как массив байтов.
Теперь нам нужна функция десериализации, которая получает на вход массив байтов и возвращает
Block
. Это будет не метод, а независимая функция:
func DeserializeBlock(d []byte) *Block {
var block Block
decoder := gob.NewDecoder(bytes.NewReader(d))
err := decoder.Decode(&block)
return &block
}
Вот и всё, что нам надо для сериализации.
Персистентность
Начнем с функции
NewBlockchain
. Сейчас она создает новый экземпляр
Blockchain
и добавляет к нему генезис-блок. Мы хотим сделать следующее:
- Открыть БД файл
- Проверить, сохранен ли там блокчейн
- Если он там есть:
- Создать новый экземпляр
Blockchain
- Установить кончик(tip) экземпляра
Blockchain
на хэш последнего сохраненного в БД блока
- Если нет существующего блокчейна
- Создать генезис блок
- Сохранить в БД
- Сохранить хэш генезиса как хэш последнего последнего блока
- Создать новый экземпляр
Blockchain
c кончиком, указывающим на генезис блок
В коде это выглядит так:
func NewBlockchain() *Blockchain {
var tip []byte
db, err := bolt.Open(dbFile, 0600, nil)
err = db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
if b == nil {
genesis := NewGenesisBlock()
b, err := tx.CreateBucket([]byte(blocksBucket))
err = b.Put(genesis.Hash, genesis.Serialize())
err = b.Put([]byte("l"), genesis.Hash)
tip = genesis.Hash
} else {
tip = b.Get([]byte("l"))
}
return nil
})
bc := Blockchain{tip, db}
return &bc
}
Разберем код по частям.
db, err := bolt.Open(dbFile, 0600, nil)
Это стандартный способ открытия BoltDB файла. Обратите внимание, что он не вернет ошибку, если файла нет.
err = db.Update(func(tx *bolt.Tx) error {
...
})
В BoltDB операции с базой данных выполняются в рамках транзакции. Есть два типа транзакций: read-only и read-write. Здесь мы открываем read-write транзакцию
(db.Update(...))
, потому то мы планируем поместить генезис блок в БД.
b := tx.Bucket([]byte(blocksBucket))
if b == nil {
genesis := NewGenesisBlock()
b, err := tx.CreateBucket([]byte(blocksBucket))
err = b.Put(genesis.Hash, genesis.Serialize())
err = b.Put([]byte("l"), genesis.Hash)
tip = genesis.Hash
} else {
tip = b.Get([]byte("l"))
}
Это ядро функции. Здесь мы получаем корзину, хранящую наши блоки: если она существует, то мы читаем ключ
l
из нее, если не существует, то мы генерируем генезис блок, создаем корзину, сохраняем блок в ней и обновляем ключ
l
, хранящий хэш последнего блока в цепи.
Также заметьте новый способ создания
Blockchain
:
bc := Blockchain{tip, db}
Мы не храним все блоки, вместо этого мы храним только кончик цепи. Также мы храним соединение с БД, потому что мы хотим открыть его один раз и держать его открытым во время работы программы. Вот так структура
Blockchain
выглядит сейчас:
type Blockchain struct {
tip []byte
db *bolt.DB
}
Следующее, что мы хотим изменить — это метод
AddBlock
: добавление блоков в цепь теперь не такое простое, как добавление элемента в массив. С этого момента мы будем хранить блоки в БД:
func (bc *Blockchain) AddBlock(data string) {
var lastHash []byte
err := bc.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
lastHash = b.Get([]byte("l"))
return nil
})
newBlock := NewBlock(data, lastHash)
err = bc.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
err := b.Put(newBlock.Hash, newBlock.Serialize())
err = b.Put([]byte("l"), newBlock.Hash)
bc.tip = newBlock.Hash
return nil
})
}
Рассмотрим код по кусочкам:
err := bc.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
lastHash = b.Get([]byte("l"))
return nil
})
Это другой (read-only) тип транзакций BoltDB. Здесь мы получаем хэш последнего блока из БД, чтобы использовать его для майнинга хэша нового блока.
newBlock := NewBlock(data, lastHash)
b := tx.Bucket([]byte(blocksBucket))
err := b.Put(newBlock.Hash, newBlock.Serialize())
err = b.Put([]byte("l"), newBlock.Hash)
bc.tip = newBlock.Hash
После майнинга нового блока мы сохраняем сериализованное представление в БД и обновляем ключ
l
, который теперь сохраняет хэш нового блока.
Готово! Это было не сложно, не так ли?
Проверяя блокчейн
Все новые блоки теперь хранятся в базе данных, поэтому мы можем переоткрыть блокчейн и добавить в него новый блок. Но после реализации этого мы теряем одну полезную особенность: мы не можем напечатать блоки, потому что больше не храним их в массиве. Давайте это исправим.
BoltDB позволяет пройтись по всем ключам в корзине, но все ключи хранятся в порядке сортировки по байтам, а мы хотим, чтобы блоки печатались в порядке, в котором они помещены в блокчейн. Также, так как мы не хотим грузить все блоки в память( наш блокчейн может быть очень огромным), то мы будем их читать один за одним. Для этой цели нам нужен итератор по блокчейну:
type BlockchainIterator struct {
currentHash []byte
db *bolt.DB
}
Итератор будет создаваться каждый раз, как мы хотим перебирать блоки в блокчейне и он будет хранить хеш блока текущей итерации и соединение с БД. Из-за последнего итератор логически привязан к блокчейну (это экземпляр
Blockchain
, который хранит соединение с БД) и, таким образом, создается в методе
Blockchain
:
func (bc *Blockchain) Iterator() *BlockchainIterator {
bci := &BlockchainIterator{bc.tip, bc.db}
return bci
}
Обратите внимание, что итератор сначала указывает на кончик блокчейна, поэтому блоки будут получены сверху донизу, от самого нового до самого старого. По факту,
выбор кончика означает «голосование» за блокчейн. У блокчейна может быть несколько ветвей и самая длинная из них считается основной. После получения кончика ( это может быть любой блок в блокчейне) мы можем воссоздать весь блокчейн и найти его длину, и работу, необходимую для её построения. Этот факт также означает, что кончик является своего рода идентификатором блокчейна.
BlockchainIterator
делает лишь одну вещь: возвращает следующий блок из блокчейна.
func (i *BlockchainIterator) Next() *Block {
var block *Block
err := i.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
encodedBlock := b.Get(i.currentHash)
block = DeserializeBlock(encodedBlock)
return nil
})
i.currentHash = block.PrevBlockHash
return block
}
Вот и все про БД!
Интерфейс командной строки (CLI)
Пока что наша реализация не предоставляет нам никакого интерфейса для взаимодействия с программой: мы просто выполняли
NewBlockchain, bc.AddBlock
в
main
. Пора улучшить это! Мы хотим иметь такие команды:
blockchain_go addblock "Pay 0.031337 for a coffee"
blockchain_go printchain
Все, связанные с командной строкой, операции будут обработаны структурой
CLI
type CLI struct {
bc *Blockchain
}
«Входная точка» структуры — это функция
Run
func (cli *CLI) Run() {
cli.validateArgs()
addBlockCmd := flag.NewFlagSet("addblock", flag.ExitOnError)
printChainCmd := flag.NewFlagSet("printchain", flag.ExitOnError)
addBlockData := addBlockCmd.String("data", "", "Block data")
switch os.Args[1] {
case "addblock":
err := addBlockCmd.Parse(os.Args[2:])
case "printchain":
err := printChainCmd.Parse(os.Args[2:])
default:
cli.printUsage()
os.Exit(1)
}
if addBlockCmd.Parsed() {
if *addBlockData == "" {
addBlockCmd.Usage()
os.Exit(1)
}
cli.addBlock(*addBlockData)
}
if printChainCmd.Parsed() {
cli.printChain()
}
}
Мы используем стандартный пакет
flag для парсинга аргументов командной строки.
addBlockCmd := flag.NewFlagSet("addblock", flag.ExitOnError)
printChainCmd := flag.NewFlagSet("printchain", flag.ExitOnError)
addBlockData := addBlockCmd.String("data", "", "Block data")
Для начала, мы создаем две подкоманды
addblock
и
printchain
, затем добавим флаг
-data
к первому.
printchain
не требует никаких флагов.
switch os.Args[1] {
case "addblock":
err := addBlockCmd.Parse(os.Args[2:])
case "printchain":
err := printChainCmd.Parse(os.Args[2:])
default:
cli.printUsage()
os.Exit(1)
}
Затем мы проверим команду, указанную пользователем, и распарсим связанную подкоманду.
if addBlockCmd.Parsed() {
if *addBlockData == "" {
addBlockCmd.Usage()
os.Exit(1)
}
cli.addBlock(*addBlockData)
}
if printChainCmd.Parsed() {
cli.printChain()
}
Дальше мы проверяем, какую подкоманду мы распарсили, и запускаем связанную функцию.
func (cli *CLI) addBlock(data string) {
cli.bc.AddBlock(data)
fmt.Println("Success!")
}
func (cli *CLI) printChain() {
bci := cli.bc.Iterator()
for {
block := bci.Next()
fmt.Printf("Prev. hash: %x\n", block.PrevBlockHash)
fmt.Printf("Data: %s\n", block.Data)
fmt.Printf("Hash: %x\n", block.Hash)
pow := NewProofOfWork(block)
fmt.Printf("PoW: %s\n", strconv.FormatBool(pow.Validate()))
fmt.Println()
if len(block.PrevBlockHash) == 0 {
break
}
}
}
Этот код похож на тот, что был раньше. Разница лишь в том, что сейчас мы используем
BlockchainIterator
чтобы итерировать по блокам в блокчейне.
Также не забудем изменить функцию
main
соответственно:
func main() {
bc := NewBlockchain()
defer bc.db.Close()
cli := CLI{bc}
cli.Run()
}
Заметим, что новый
Blockchain
создается независимо от того, какие были переданы аргументы командной строки.
Вот и всё! Проверим, что всё работает так, как мы ожидаем:
$ blockchain_go printchain
No existing blockchain found. Creating a new one...
Mining the block containing "Genesis Block"
000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
Prev. hash:
Data: Genesis Block
Hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
PoW: true
$ blockchain_go addblock -data "Send 1 BTC to Ivan"
Mining the block containing "Send 1 BTC to Ivan"
000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13
Success!
$ blockchain_go addblock -data "Pay 0.31337 BTC for a coffee"
Mining the block containing "Pay 0.31337 BTC for a coffee"
000000aa0748da7367dec6b9de5027f4fae0963df89ff39d8f20fd7299307148
Success!
$ blockchain_go printchain
Prev. hash: 000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13
Data: Pay 0.31337 BTC for a coffee
Hash: 000000aa0748da7367dec6b9de5027f4fae0963df89ff39d8f20fd7299307148
PoW: true
Prev. hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
Data: Send 1 BTC to Ivan
Hash: 000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13
PoW: true
Prev. hash:
Data: Genesis Block
Hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
PoW: true
(
звук открывания пивной банки)
Ссылки
Оригинальная статья
Первая часть цикла статей
Исходники
Bitcoin Core Data Storage
BoltDB
encoding/gob
flag
комментарии (0)