基于Java语言构建区块链(三)—— 持久化 & 命令行

blockchain

文章的主要思想和内容均来自:https://jeiwan.cc/posts/building-blockchain-in-go-part-3/

引言

上一篇 文章我们实现了区块链的工作量证明机制(Pow),尽可能地实现了挖矿。但是距离真正的区块链应用还有很多重要的特性没有实现。今天我们来实现区块链数据的存储机制,将每次生成的区块链数据保存下来。有一点需要注意,区块链本质上是一款分布式的数据库,我们这里不实现”分布式”,只聚焦于数据存储部分。

数据库选择

到目前为止,我们的实现机制中还没有区块存储这一环节,导致我们的区块每次生成之后都保存在了内存中。这样不便于我们重新使用区块链,每次都要从头开始生成区块,也不能够跟他人共享我们的区块链,因此,我们需要将其存储在磁盘上。

我们该选择哪一款数据库呢?事实上,在《比特币白皮书》中并没有明确指定使用哪一种的数据库,因此这个由开发人员自己决定。中本聪 开发的 Bitcoin Core 中使用的是LevelDB。原文 Building Blockchain in Go. Part 3: Persistence and CLI 中使用的是 BoltDB ,对Go语言支持比较好。

但是我们这里使用的是Java来实现,BoltDB不支持Java,这里我们选用 Rocksdb

RocksDB是由Facebook数据库工程团队开发和维护的一款key-value存储引擎,比LevelDB性能更加强大,有关Rocksdb的详细介绍,请移步至官方文档:https://github.com/facebook/rocksdb ,这里不多做介绍。

数据结构

在我们开始实现数据持久化之前,我们先要确定我们该如何去存储我们的数据。为此,我们先来看看比特币是怎么做的。

简单来讲,比特币使用了两个”buckets(桶)”来存储数据:

  • blocks. 描述链上所有区块的元数据.
  • chainstate. 存储区块链的状态,指的是当前所有的UTXO(未花费交易输出)以及一些元数据.

“在比特币的世界里既没有账户,也没有余额,只有分散到区块链里的UTXO。”

详见:《精通比特币》第二版 第06章节 —— 交易的输入与输出

此外,每个区块数据都是以单独的文件形式存储在磁盘上。这样做是出于性能的考虑:当读取某一个单独的区块数据时,不需要加载所有的区块数据到内存中来。

blocks 这个桶中,存储的键值对:

  • ‘b’ + 32-byte block hash -> block index record

    区块的索引记录

  • ‘f’ + 4-byte file number -> file information record

    文件信息记录

  • ‘l’ -> 4-byte file number: the last block file number used

    最新的一个区块所使用的文件编码

  • ‘R’ -> 1-byte boolean: whether we’re in the process of reindexing

    是否处于重建索引的进程当中

  • ‘F’ + 1-byte flag name length + flag name string -> 1 byte boolean: various flags that can be on or off

    各种可以打开或关闭的flag标志

  • ‘t’ + 32-byte transaction hash -> transaction index record

    交易索引记录

chainstate 这个桶中,存储的键值对:

  • ‘c’ + 32-byte transaction hash -> unspent transaction output record for that transaction

    某笔交易的UTXO记录

  • ‘B’ -> 32-byte block hash: the block hash up to which the database represents the unspent transaction outputs

    数据库所表示的UTXO的区块Hash(抱歉,这一点我还没弄明白……)

由于我们还没有实现交易相关的特性,因此,我们这里只使用 block 桶。另外,前面提到过的,这里我们不会实现各个区块数据各自存储在独立的文件上,而是统一存放在一个文件里面。因此,我们不要存储和文件编码相关的数据,这样一来,我们所用到的键值对就简化为:

  • 32-byte block-hash -> Block structure (serialized)

    区块数据与区块hash的键值对

  • ‘l’ -> the hash of the last block in a chain

    最新一个区块hash的键值对

(查看更加详细的解释:_Data_Storage))

序列化

RocksDB的Key与Value只能以byte[]的形式进行存储,这里我们需要用到序列化与反序列化库 Kryo,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package one.wangwei.blockchain.util;

import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;

/**
* 序列化工具类
*
* @author wangwei
* @date 2018/02/07
*/
public class SerializeUtils {

/**
* 反序列化
*
* @param bytes 对象对应的字节数组
* @return
*/
public static Object deserialize(byte[] bytes) {
Input input = new Input(bytes);
Object obj = new Kryo().readClassAndObject(input);
input.close();
return obj;
}

/**
* 序列化
*
* @param object 需要序列化的对象
* @return
*/
public static byte[] serialize(Object object) {
Output output = new Output(4096, -1);
new Kryo().writeClassAndObject(output, object);
byte[] bytes = output.toBytes();
output.close();
return bytes;
}
}

持久化

上面已经说过,我们这里使用RocksDB,我们先写一个相关的工具类RocksDBUtils,主要的功能如下:

  • putLastBlockHash:保存最新一个区块的Hash值
  • getLastBlockHash:查询最新一个区块的Hash值
  • putBlock:保存区块
  • getBlock:查询区块

注意:BoltDB 支持 Bucket 的特性,而RocksDB 不支持,所以需要我们自己使用Map来做一个映射。

RocksDBUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
package one.wangwei.blockchain.store;

import com.google.common.collect.Maps;
import one.wangwei.blockchain.block.Block;
import one.wangwei.blockchain.util.SerializeUtils;
import org.rocksdb.RocksDB;
import org.rocksdb.RocksDBException;

import java.util.Map;

/**
* 存储工具类
*
* @author wangwei
* @date 2018/02/27
*/
public class RocksDBUtils {

/**
* 区块链数据文件
*/
private static final String DB_FILE = "blockchain.db";
/**
* 区块桶前缀
*/
private static final String BLOCKS_BUCKET_KEY = "blocks";
/**
* 最新一个区块
*/
private static final String LAST_BLOCK_KEY = "l";

private volatile static RocksDBUtils instance;

public static RocksDBUtils getInstance() {
if (instance == null) {
synchronized (RocksDBUtils.class) {
if (instance == null) {
instance = new RocksDBUtils();
}
}
}
return instance;
}

private RocksDB db;

/**
* block buckets
*/
private Map<String, byte[]> blocksBucket;

private RocksDBUtils() {
openDB();
initBlockBucket();
}

/**
* 打开数据库
*/
private void openDB() {
try {
db = RocksDB.open(DB_FILE);
} catch (RocksDBException e) {
throw new RuntimeException("Fail to open db ! ", e);
}
}

/**
* 初始化 blocks 数据桶
*/
private void initBlockBucket() {
try {
byte[] blockBucketKey = SerializeUtils.serialize(BLOCKS_BUCKET_KEY);
byte[] blockBucketBytes = db.get(blockBucketKey);
if (blockBucketBytes != null) {
blocksBucket = (Map) SerializeUtils.deserialize(blockBucketBytes);
} else {
blocksBucket = Maps.newHashMap();
db.put(blockBucketKey, SerializeUtils.serialize(blocksBucket));
}
} catch (RocksDBException e) {
throw new RuntimeException("Fail to init block bucket ! ", e);
}
}

/**
* 保存最新一个区块的Hash值
*
* @param tipBlockHash
*/
public void putLastBlockHash(String tipBlockHash) {
try {
blocksBucket.put(LAST_BLOCK_KEY, SerializeUtils.serialize(tipBlockHash));
db.put(SerializeUtils.serialize(BLOCKS_BUCKET_KEY), SerializeUtils.serialize(blocksBucket));
} catch (RocksDBException e) {
throw new RuntimeException("Fail to put last block hash ! ", e);
}
}

/**
* 查询最新一个区块的Hash值
*
* @return
*/
public String getLastBlockHash() {
byte[] lastBlockHashBytes = blocksBucket.get(LAST_BLOCK_KEY);
if (lastBlockHashBytes != null) {
return (String) SerializeUtils.deserialize(lastBlockHashBytes);
}
return "";
}

/**
* 保存区块
*
* @param block
*/
public void putBlock(Block block) {
try {
blocksBucket.put(block.getHash(), SerializeUtils.serialize(block));
db.put(SerializeUtils.serialize(BLOCKS_BUCKET_KEY), SerializeUtils.serialize(blocksBucket));
} catch (RocksDBException e) {
throw new RuntimeException("Fail to put block ! ", e);
}
}

/**
* 查询区块
*
* @param blockHash
* @return
*/
public Block getBlock(String blockHash) {
return (Block) SerializeUtils.deserialize(blocksBucket.get(blockHash));
}

/**
* 关闭数据库
*/
public void closeDB() {
try {
db.close();
} catch (Exception e) {
throw new RuntimeException("Fail to close db ! ", e);
}
}
}

创建区块链

现在我们来优化 Blockchain.newBlockchain 接口的代码逻辑,改为如下逻辑:

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* <p> 创建区块链 </p>
*
* @return
*/
public static Blockchain newBlockchain() throws Exception {
String lastBlockHash = RocksDBUtils.getInstance().getLastBlockHash();
if (StringUtils.isBlank(lastBlockHash)) {
Block genesisBlock = Block.newGenesisBlock();
lastBlockHash = genesisBlock.getHash();
RocksDBUtils.getInstance().putBlock(genesisBlock);
RocksDBUtils.getInstance().putLastBlockHash(lastBlockHash);
}
return new Blockchain(lastBlockHash);
}

修改 Blockchain 的数据结构,只记录最新一个区块链的Hash值

1
2
3
4
5
6
7
8
9
public class Blockchain {

@Getter
private String lastBlockHash;

private Blockchain(String lastBlockHash) {
this.lastBlockHash = lastBlockHash;
}
}

每次挖矿完成后,我们也需要将最新的区块信息保存下来,并且更新最新区块链Hash值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* <p> 添加区块 </p>
*
* @param data
*/
public void addBlock(String data) throws Exception {
String lastBlockHash = RocksDBUtils.getInstance().getLastBlockHash();
if (StringUtils.isBlank(lastBlockHash)) {
throw new Exception("Fail to add block into blockchain ! ");
}
this.addBlock(Block.newBlock(lastBlockHash, data));
}

/**
* <p> 添加区块 </p>
*
* @param block
*/
public void addBlock(Block block) throws Exception {
RocksDBUtils.getInstance().putLastBlockHash(block.getHash());
RocksDBUtils.getInstance().putBlock(block);
this.lastBlockHash = block.getHash();
}

到此,存储部分的功能就实现完毕,我们还缺少一个功能:

检索区块链

现在,我们所有的区块都保存到了数据库,因此,我们能够重新打开已有的区块链并且向其添加新的区块。但这也导致我们再也无法打印出区块链中所有区块的信息,因为,我们没有将区块存储在数组当中。让我们来修复这个瑕疵!

我们在Blockchain中创建一个内部类 BlockchainIterator ,作为区块链的迭代器,通过区块之前的hash连接来依次迭代输出区块信息,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class Blockchain {

....

/**
* 区块链迭代器
*/
public class BlockchainIterator {

private String currentBlockHash;

public BlockchainIterator(String currentBlockHash) {
this.currentBlockHash = currentBlockHash;
}

/**
* 是否有下一个区块
*
* @return
*/
public boolean hashNext() throws Exception {
if (StringUtils.isBlank(currentBlockHash)) {
return false;
}
Block lastBlock = RocksDBUtils.getInstance().getBlock(currentBlockHash);
if (lastBlock == null) {
return false;
}
// 创世区块直接放行
if (lastBlock.getPrevBlockHash().length() == 0) {
return true;
}
return RocksDBUtils.getInstance().getBlock(lastBlock.getPrevBlockHash()) != null;
}


/**
* 返回区块
*
* @return
*/
public Block next() throws Exception {
Block currentBlock = RocksDBUtils.getInstance().getBlock(currentBlockHash);
if (currentBlock != null) {
this.currentBlockHash = currentBlock.getPrevBlockHash();
return currentBlock;
}
return null;
}
}

....
}

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 测试
*
* @author wangwei
* @date 2018/02/05
*/
public class BlockchainTest {

public static void main(String[] args) {
try {
Blockchain blockchain = Blockchain.newBlockchain();

blockchain.addBlock("Send 1.0 BTC to wangwei");
blockchain.addBlock("Send 2.5 more BTC to wangwei");
blockchain.addBlock("Send 3.5 more BTC to wangwei");

for (Blockchain.BlockchainIterator iterator = blockchain.getBlockchainIterator(); iterator.hashNext(); ) {
Block block = iterator.next();

if (block != null) {
boolean validate = ProofOfWork.newProofOfWork(block).validate();
System.out.println(block.toString() + ", validate = " + validate);
}
}

} catch (Exception e) {
e.printStackTrace();
}
}
}


/*输出*/

Block{hash='0000012f87a0510dd0ee7048a6bd52db3002bae7d661126dc28287bd6c23189a', prevBlockHash='0000024b2c23c4fb06c2e2c1349275d415efe17a51db24cd4883da0067300ddf', data='Send 3.5 more BTC to wangwei', timeStamp=1519724875, nonce=369110}, validate = true
Block{hash='0000024b2c23c4fb06c2e2c1349275d415efe17a51db24cd4883da0067300ddf', prevBlockHash='00000b14fefb51ba2a7428549d469bcf3efae338315e7289d3e6dc4caf589d79', data='Send 2.5 more BTC to wangwei', timeStamp=1519724872, nonce=896348}, validate = true
Block{hash='00000b14fefb51ba2a7428549d469bcf3efae338315e7289d3e6dc4caf589d79', prevBlockHash='0000099ced1b02f40c750c5468bb8c4fd800ec9f46fea5d8b033e5d054f0f703', data='Send 1.0 BTC to wangwei', timeStamp=1519724869, nonce=673955}, validate = true
Block{hash='0000099ced1b02f40c750c5468bb8c4fd800ec9f46fea5d8b033e5d054f0f703', prevBlockHash='', data='Genesis Block', timeStamp=1519724866, nonce=840247}, validate = true

命令行界面

CLI 部分的内容,这里不做详细介绍,具体可以去查看文末的Github源码链接。大致步骤如下:

配置

添加pom.xml配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<project>

...

<dependency>
<groupId>commons-cli</groupId>
<artifactId>commons-cli</artifactId>
<version>1.4</version>
</dependency>

...

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
<mainClass>one.wangwei.blockchain.cli.Main</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<!-- this is used for inheritance merges -->
<phase>package</phase>
<!-- 指定在打包节点执行jar包合并操作 -->
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>

...

</project>

添加shell脚本 blockchain.sh

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash

set -e

# Check if the jar has been built.
if [ ! -e target/blockchain-java-jar-with-dependencies.jar ]; then
echo "Compiling blockchain project to a JAR"
mvn package -DskipTests
fi

java -jar target/blockchain-java-jar-with-dependencies.jar "$@"
执行命令
1
2
3
4
5
6
7
8
9
10
11
12
13
# 进入工程根路劲
$ cd blockchain-java

# 打印帮助信息
$ ./blockchain.sh -h

# 添加区块
$ ./blockchain.sh -add "Send 1.5 BTC to wangwei"
$ ./blockchain.sh -add "Send 2.5 BTC to wangwei"
$ ./blockchain.sh -add "Send 3.5 BTC to wangwei"

# 打印区块链
$ ./blockchain.sh -print

总结

本篇我们实现了区块链的存储功能,接下来我们将实现地址、交易、钱包这一些列的功能。

资料

https://press.one/file/v?s=62076a14b3d0693cf88d19db7e2d6a5a8e05b3de0c77deee242615c5480f8818104c24b65500a8cac096dee3b2e6862cec266916ee6545dd38f3650a459c7b221&h=7732ed4bedb1bb10a4d24df75b719ac2d56ae76a1b5a8f06ccc2ca6dc2d3ce30&a=23fe9bfd7ceef4b44c2ce44dcac8e4a49caf8026&f=P1&v=2

王维 / Michael  Wang wechat
欢迎交流学习
请我喝杯咖啡吧~