Как стать автором
Обновить

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

Время на прочтение10 мин
Количество просмотров10K
Автор оригинала: Ivan Kuznetsov
Содержание

  1. Blockchain на Go. Часть 1: Прототип
  2. Blockchain на Go. Часть 2: Proof-of-Work
  3. Blockchain на Go. Часть 3: Постоянная память и интерфейс командной строки
  4. Blockchain на Go. Часть 4: Транзакции, часть 1
  5. Blockchain на Go. Часть 5: Адреса
  6. Blockchain на Go. Часть 6: Транзакции, часть 2
  7. Blockchain на Go. Часть 7: Сеть

Вступление


В предыдущей части мы построили блокчейн с 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
Теги:
Хабы:
Всего голосов 11: ↑7 и ↓4+3
Комментарии2

Публикации

Истории

Работа

Go разработчик
123 вакансии

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань