Blockchain на Go. Часть 3: Постоянная память и интерфейс командной строки

Содержание

  1. Blockchain на Go. Часть 1: Прототип
  2. Blockchain на Go. Часть 2: Proof-of-Work
  3. Blockchain на Go. Часть 3: Постоянная память и интерфейс командной строки

Вступление

В предыдущей части мы построили блокчейн с PoW системой и возможностью майнинга. Наша реализация всё ближе к полностью функциональному блокчейну, но ей все ещё не хватает некоторых важных функций. Сегодня мы начнем хранить блокчейн в базе данных, после этого сделаем интерфейс командной строки для операций с блокчейном. По сути, блокчейн — это распределенная база данных. Мы пока опустим «распределенная» и сосредоточимся на «база данных».

Выбор базы данных

Пока что, у нас нет базы данных в реализации, мы просто создаем блоки при запуске программы и храним их в памяти. Мы не можем повторно использовать или поделиться с другими нашим блокчейном, поэтому нам нужно сохранить его на диске.

Какая база данных нам нужна? На самом деле, подойдет любая. В Биткоин Paper ничего не сказано про конкретную базу данных, так что выбор остается за разработчиком. Bitcoin Core , который был первоначально опубликован Сатоши Накамото и который в настоящее время является эталонной реализацией Bitcoin, использует LevelDB (хотя он был представлен клиенту только в 2012 году). А мы будем использовать…

BoltDB

Потому что:

  1. Она простая и минималистичная
  2. Она реализована на Go
  3. Ей не требуется запуск сервера
  4. Она позволяет строить необходимые нам структуры данных

Из 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 использует две «корзины» для хранения данных.

  1. blocks хранит метаданные, описывающие все блоки в цепи
  2. chainstate сохраняет состояние цепи, которое представляет собой все непотраченные выходы транзакций и некоторые метаданные

Также блоки хранятся как отдельные файлы на диске. Это сделано для повышения производительности: чтение одного блока не требует загрузку всех (или некоторых) в память. Это мы не будет реализовывать.

В blocks пары key->value это:

  1. 'b' + 32-байтовый хэш блока -> запись индекса блока
  2. 'f' + 4-байтовый номер файла -> запись информации о файле
  3. 'l' -> 4-байтовый номер файла: номер использованного файла для последнего блока
  4. 'R' -> 1-байтовый boolean : находимся ли мы в процессе переиндексации
  5. 'F' + 1-байтовая длина имени флага + строка имени флага -> 1 байт boolean: различные флаги, которые могут быть включены или выключены
  6. 't' + 32-байтовый хеш транзакции -> запись индекса транзакции

В chainstate пары key->value это:

  1. 'c' + 32-байтовый хеш транзакции -> запись о непотраченном выходе транзакции для этой транзакции
  2. 'B' -> 32-байтовый хеш блока: хеш блока, до которого база данных представляет собой неизрасходованные выходы транзакции

(Подробное пояснение можно найти здесь)

Так как у нас пока что нет транзакций, то мы сделаем только корзину blocks. Кроме того, как было сказано выше, мы будем хранить всю базу данных в одном файле, без хранения блоков в отдельных файлах. Поэтому нам не нужно ничего, связанное с файловыми номерами. Поэтому пары key->value, которые мы будем использовать, это:

  1. 32-байтовый хэш блока -> структура блока (сериализованная)
  2. ‘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 и добавляет к нему генезис-блок. Мы хотим сделать следующее:

  1. Открыть БД файл
  2. Проверить, сохранен ли там блокчейн
  3. Если он там есть:
    1. Создать новый экземпляр Blockchain
    2. Установить кончик(tip) экземпляра Blockchain на хэш последнего сохраненного в БД блока

  4. Если нет существующего блокчейна
    1. Создать генезис блок
    2. Сохранить в БД
    3. Сохранить хэш генезиса как хэш последнего последнего блока
    4. Создать новый экземпляр 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

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *