Содержание
В предыдущей части мы построили блокчейн с PoW системой и возможностью майнинга. Наша реализация всё ближе к полностью функциональному блокчейну, но ей все ещё не хватает некоторых важных функций. Сегодня мы начнем хранить блокчейн в базе данных, после этого сделаем интерфейс командной строки для операций с блокчейном. По сути, блокчейн — это распределенная база данных. Мы пока опустим «распределенная» и сосредоточимся на «база данных».
Пока что, у нас нет базы данных в реализации, мы просто создаем блоки при запуске программы и храним их в памяти. Мы не можем повторно использовать или поделиться с другими нашим блокчейном, поэтому нам нужно сохранить его на диске.
Какая база данных нам нужна? На самом деле, подойдет любая. В Биткоин Paper ничего не сказано про конкретную базу данных, так что выбор остается за разработчиком. Bitcoin Core , который был первоначально опубликован Сатоши Накамото и который в настоящее время является эталонной реализацией Bitcoin, использует LevelDB (хотя он был представлен клиенту только в 2012 году). А мы будем использовать…
Потому что:
Из BoltDB README:
BoltDB — это хранилище «ключ-значение», что значит, что нет таблиц, как в реляционных СУБД ( MySQL, PostgreSQL и тд), нет рядов и столбцов. Вместо этого, данные хранятся в парах «ключ-значение»( как в Golang map). Пары хранятся в «корзинах», которые предназначены для группировки похожих пар (подобно таблицах в реляционных СУБД). Таким образом, чтобы получить значение, надо знать корзину и ключ.
Важной вещью про BoltDB является то, что здесь нет типов данных: ключи и значения — это байтовые массивы. Так как мы храним Go структуры ( в частности
До того, как мы начнем реализовывать персистентную логику, мы должны решить, как будем хранить наши данные в базе. И для этого мы будем использовать способ, который используем Bitcoin Core.
Если по-простому, то Bitcoin Core использует две «корзины» для хранения данных.
Также блоки хранятся как отдельные файлы на диске. Это сделано для повышения производительности: чтение одного блока не требует загрузку всех (или некоторых) в память. Это мы не будет реализовывать.
В
Так как у нас пока что нет транзакций, то мы сделаем только корзину
Это всё, что нам необходимо знать для реализации механизма постоянства ( персистентности).
Как сказано ранее, в BoltDB значения могут быть лишь
Давайте реализуем метод
Здесь всё просто: в начале, мы объявляем буфер, где будут храниться сериализованные данные, затем инициализируем
Теперь нам нужна функция десериализации, которая получает на вход массив байтов и возвращает
Вот и всё, что нам надо для сериализации.
Начнем с функции
В коде это выглядит так:
Разберем код по частям.
Это стандартный способ открытия BoltDB файла. Обратите внимание, что он не вернет ошибку, если файла нет.
В BoltDB операции с базой данных выполняются в рамках транзакции. Есть два типа транзакций: read-only и read-write. Здесь мы открываем read-write транзакцию
Это ядро функции. Здесь мы получаем корзину, хранящую наши блоки: если она существует, то мы читаем ключ
Также заметьте новый способ создания
Мы не храним все блоки, вместо этого мы храним только кончик цепи. Также мы храним соединение с БД, потому что мы хотим открыть его один раз и держать его открытым во время работы программы. Вот так структура
Следующее, что мы хотим изменить — это метод
Рассмотрим код по кусочкам:
Это другой (read-only) тип транзакций BoltDB. Здесь мы получаем хэш последнего блока из БД, чтобы использовать его для майнинга хэша нового блока.
После майнинга нового блока мы сохраняем сериализованное представление в БД и обновляем ключ
Готово! Это было не сложно, не так ли?
Все новые блоки теперь хранятся в базе данных, поэтому мы можем переоткрыть блокчейн и добавить в него новый блок. Но после реализации этого мы теряем одну полезную особенность: мы не можем напечатать блоки, потому что больше не храним их в массиве. Давайте это исправим.
BoltDB позволяет пройтись по всем ключам в корзине, но все ключи хранятся в порядке сортировки по байтам, а мы хотим, чтобы блоки печатались в порядке, в котором они помещены в блокчейн. Также, так как мы не хотим грузить все блоки в память( наш блокчейн может быть очень огромным), то мы будем их читать один за одним. Для этой цели нам нужен итератор по блокчейну:
Итератор будет создаваться каждый раз, как мы хотим перебирать блоки в блокчейне и он будет хранить хеш блока текущей итерации и соединение с БД. Из-за последнего итератор логически привязан к блокчейну (это экземпляр
Обратите внимание, что итератор сначала указывает на кончик блокчейна, поэтому блоки будут получены сверху донизу, от самого нового до самого старого. По факту, выбор кончика означает «голосование» за блокчейн. У блокчейна может быть несколько ветвей и самая длинная из них считается основной. После получения кончика ( это может быть любой блок в блокчейне) мы можем воссоздать весь блокчейн и найти его длину, и работу, необходимую для её построения. Этот факт также означает, что кончик является своего рода идентификатором блокчейна.
Вот и все про БД!
Пока что наша реализация не предоставляет нам никакого интерфейса для взаимодействия с программой: мы просто выполняли
Все, связанные с командной строкой, операции будут обработаны структурой
«Входная точка» структуры — это функция
Мы используем стандартный пакет flag для парсинга аргументов командной строки.
Для начала, мы создаем две подкоманды
Затем мы проверим команду, указанную пользователем, и распарсим связанную подкоманду.
Дальше мы проверяем, какую подкоманду мы распарсили, и запускаем связанную функцию.
Этот код похож на тот, что был раньше. Разница лишь в том, что сейчас мы используем
Также не забудем изменить функцию
Заметим, что новый
Вот и всё! Проверим, что всё работает так, как мы ожидаем:
(звук открывания пивной банки)
Оригинальная статья
Первая часть цикла статей
Исходники
Bitcoin Core Data Storage
BoltDB
encoding/gob
flag
- 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