大家都知道,区块链的特性有很多。比如去中心化、透明性、可溯源性、不可篡改性、安全性、共识机制等等。要说其中哪一点最重要?当然都很重要,但如果一定要进行排名的话,去中心化毫无疑问是要排在第一位的。
我们在上一课中实现的区块链比较简单,仅仅实现了两个特性:数据的不可篡改性和安全性。这节课我们来完成另一个区块链中极为重要的特性:去中心化。这一个特性需要我们来实现另外两个功能:P2P 网络和区块数据持久化。
设计技术方案
首先我们来设计一下技术方案。
区块数据持久化存储与加载
我们之前的区块链,数据只是存储在内存中,当区块链程序停止运行或者重启,都会导致数据丢失。所以我们需要将区块数据存储到本地,这样在启动区块链时也能够从本地的文件中恢复区块数据。
这一步有很多技术选型,比如使用 LevelDB 或者 RocksDB 这种键值存储数据库,也可以使用 MongoDB 这种文档存储数据库,在经过深层次的数据模型规划后,还可以选择关系型数据库 PostgreSQL 或者 MySQL。大型区块链还可以使用 Cassandra 或者 Amazon DynamoDB 这种分布式数据库。如果追求性能,还可以使用像 Redis 或者 Memcached 这类内存数据库。或者直接点使用区块链专用的数据库 BigchainDB。
但是我们是一个教学为目的的区块链,所以不需要设计的这么复杂,安装数据库环境也比较繁琐,所以这一步我们使用一种简单粗暴的方式,就是直接用一个 JSON 文件来存储。在实际项目中,你可以结合自身的业务类型,替换成最适合的数据库。
P2P 网络
首先我们要来设计一下具体的技术方案。
第一是 P2P 网络。P2P 网络也就是 Peer to Peer,它是保证区块链去中心化的必要因素。简单来说,传统的 Web2 是一堆节点连接一个特殊的中心化节点进行通信,重要的数据都存储在这个特殊的节点上面,也就是我们所谓的服务器。一旦服务器上的数据丢失,或者服务器出现故障,那么我们所有人的资产都会受到损失,因为数据就是资产。而 P2P 网络架构是每个节点彼此相连,没有特殊的中心化节点,每个人的数据都是一致的,任何一个节点下线都不影响整个服务的运作,除非世界上所有的节点同时下线。
我们怎么样去设计 P2P 网络呢?实际上一套完善稳定的 P2P 网络非常复杂。这里我们只设计其中最关键的组件:节点发现和节点同步。
节点发现
所谓的节点发现是指多个节点之间如何发现对方。因为世界上的 IP 有 20 多亿个,每个 IP 又可以有 6 万多个端口。如果使用枚举,从这么多 IP 和端口中找到对方,无异于大海捞针。所以我们要有一些方案来保证节点间可以互相识别和连接。
主流的技术方案有很多,比如 Kademlia DTH,也就是分布式哈希表、Gossip 协议、mDNS,也就是多播 DNS、UPnP、STUN/TURN/ICE 等等。
但是这些协议相对复杂,直接使用封装好的库也很难理解技术细节。这里我们使用最常见的种子节点方式,自定义一套简单的协议,支持节点间同步节点列表和区块数据。
种子节点 Seed Node 也叫引导节点 Bootstrap Node,它们是用来给新节点提供一个可靠起点的节点,作为新节点与其他活跃节点建立连接的桥梁。也是节点发现过程中的重要角色。种子节点一般是直接硬编码到程序中的。新节点先来连接种子节点,再从种子节点中获取其他节点的信息,进行连接,在这个过程中也会把自己节点的数据同步给种子节点。
节点同步
这里我们需要同步的数据有两类,一个是区块数据,一个是节点数据。区块数据可以继续持久化到本地文件中。节点数据可以用一个 Map 数据结构存储到程序的内存中。每当节点之间相互连接的时候,都会去同步数据。
传输协议
传输协议是定义了我们的数据如何在多个节点之间传输,常用的一些协议包括 HTTP、TCP、UDP 和自定义协议。简单起见,我们这里可以直接使用最万能的 TCP 作为传输协议。因为 HTTP 协议太重了,性能也很差,请求响应模型也不适合这个场景。UDP 虽然传输速度快,但是不能保证可靠性和数据的顺序。TCP/IP 协议具备可靠性、面向连接、可以确保数据准确无误地传输,很适合我们的场景。
消息格式
消息格式是指我们在多个节点之间交换数据的结构。常见的方案有三类。
第一类是以 Google 的 Protocol Buffer 为代表的协议缓冲。它提供了统一的方式来定义结构化数据和高效序列化数据的能力。
第二类是使用通用的数据描述格式来自定义数据结构,常见的有 JSON 和 XML,但是 JSON 更加灵活和流行。优点是灵活、可读性高、支持跨语言,缺点是结构很臃肿,传输体积大。
第三类是使用二进制协议,它和第二类是相反的。优点是结构紧凑,传输体积小。缺点是可读性很差。
这里我们可以直接使用 JSON 作为传输的数据结构,因为我们是以教学为目的,可读性是最重要的。
代码实现
在写代码之前,我们可以先对上一节课中的代码进行一些优化。
比如区块链原来的高度 height 属性,我们可以通过直接返回 chain 的长度。
ts
// 区块链高度,直接使用chain.length
get height(): number {
return this.chain.length;
}
区块数据持久化存储与加载
然后实现区块链数据的持久化和加载。因为要访问文件系统,所以需要使用 Nodejs 的 fs 模块,因为要在 TypeScript 中使用 Nodejs 的原生模块,所以这里我们要安装 @types/node
,然后添加两个方法,分别是 saveChainToFile
和 loadChainFromFile
。
typescript
// 保存区块链到文件
constructor(
dataPath: string = 'blockchain.json',
) {
// ...
// 系统崩溃时,保存区块链到文件
process.on('exit', () => this.saveChainToFile());
}
addBlock(newBlock: Block): void {
// ...
this.saveChainToFile();
}
saveChainToFile(): void {
try {
// 保存之前校验链是否被篡改
if (this.isChainValid() === false) {
console.error('Blockchain is not valid, not saving to file');
return;
}
const jsonContent = JSON.stringify(this.chain, null, 2);
fs.writeFileSync(this.dataPath, jsonContent, 'utf8');
} catch (error) {
console.error('Error saving the blockchain to a file', error);
}
}
// 从文件加载区块链
loadChainFromFile(): void {
try {
if (fs.existsSync(this.dataPath)) {
const fileContent = fs.readFileSync(this.dataPath, 'utf8');
const loadedChain = JSON.parse(fileContent);
this.chain = loadedChain.map((blockData: any) => {
const block = new Block(blockData.index, blockData.timestamp, blockData.data, blockData.previousHash);
block.nonce = blockData.nonce;
block.hash = block.calculateHash();
return block;
});
this.height = this.chain.length;
// 加载之后校验链是否被篡改
if (this.isChainValid() === false) {
console.error('Blockchain is not valid after loading from file');
process.exit(1);
}
}
} catch (error) {
console.error('Error loading the blockchain from a file', error);
}
}
不过我们的例子中并没有考虑并发的问题,假设本地只能同时有一个区块链实例。也没有考虑 JSON 文件的读写性能,在实际的项目中,我们需要使用其他数据库方案来存储区块数据。但是这样并不会影响我们理解它的原理。
支持命令行参数
通常来说我们会支持从命令行中传入参数来设置文件的位置,比如:npx tsx ./src/main.ts --dataPath=./blockchain.json
。
我们来实现这个功能。
Nodejs 默认会把命令行参数设置到 process.env.argv
上面,但是处理起来相对麻烦。为了处理命令行参数,我们会使用 yargs
这个库,它具有很多强大的功能,比如自动生成帮助命令、设置段命令、智能解析、自定义校验等等。
安装 npm i yargs
,然后在 main.ts
中使用它。
tsx
import yargs from 'yargs';
// 解析命令行参数
const argv = yargs
.option('dataPath', {
alias: 'd',
description: 'The path to save the blockchain data file',
type: 'string',
})
.help()
.alias('help', 'h')
.argv;
// 如果命令行参数提供了dataPath,使用它;否则使用默认值
const blockchainDataPath = argv.dataPath || 'blockchain.json';
const myBlockchain = new Blockchain(blockchainDataPath);
// ...
现在我们可以通过 npx tsx ./src/main.ts -d ./xxx
的方式来运行我们的区块链了。
P2P 网络
在区块链中是不存在中心化服务器的,我们的数据传输是通过 P2P 网络进行的。这也是区块链去中心化的核心优势。
我们定义一个 P2PServer 的类,这个类会使用 Nodejs 的 net 模块监听本地的 TCP 端口,同时支持其他节点进行连接和同步数据。
我们先来实现一个简单的版本。
ts
import { Blockchain } from "./Blockchain";
import net from 'net'
export class P2PServer {
blockchain: Blockchain;
constructor(blockchain: Blockchain) {
this.blockchain = blockchain;
}
// 启动 P2P 服务器
listen(
port: number = 12315,
host: string = "localhost",
): void {
const server = net.createServer((socket) => {
console.debug('New peer connected')
// 处理接收到的消息
socket.on('data', (data) => {
const message = data.toString();
console.debug('Received message: ', message);
});
// 处理连接错误
socket.on('error', (error) => {
console.error('Connection error: ', error);
});
// 处理连接关闭
socket.on('close', () => {
console.debug('Connection closed');
});
})
server.listen(port, host, () => {
console.debug(`Listening for P2P connections on: ${host}:${port}`);
});
// 保持程序活跃
this.keepAlive();
}
// 保持程序活跃
keepAlive(): void {
setInterval(() => {
console.debug('Keeping the program alive');
}, 1000 * 60 * 60);
}
// 广播消息
broadcast(message: string): void {
console.debug('Broadcasting message: ', message);
}
}
然后修改 main.ts
文件的内容,创建 P2P 服务并启动:
ts
const myP2PServer = new P2PServer(myBlockchain);
myP2PServer.listen();
接下来我们要实现的两个功能是节点发现和数据同步。这两个功能比较复杂,代码量在 250 行左右。我直接把完整代码贴在这里,其中包含了详细的注释:
ts
import { Block } from "./Block";
import { Blockchain } from "./Blockchain";
import net from 'net'
type Message = {
type: 'blockchain_request' | 'blockchain_response' | 'peers_request'
| 'peers_response' | 'keep_alive',
data: any
}
export class P2PServer {
blockchain: Blockchain;
port: number;// P2P 服务器端口
host: string;// P2P 服务器地址
peers: Map<string, net.Socket> = new Map();// 节点列表
seedPeers: string[];// 种子节点列表
constructor(blockchain: Blockchain,
port: number = 12315,
host: string = "localhost",
seedPeers: string[] = [
'localhost:12315',
],
) {
this.blockchain = blockchain;
this.port = port;
this.host = host;
this.seedPeers = seedPeers;
}
// 启动 P2P 服务器
listen(): void {
const server = net.createServer(
socket => this.handleConnection(socket)
)
server.listen(this.port, this.host, () => {
console.debug(`Listening for P2P connections on: ${this.host}:${this.port}`);
// 连接到种子节点
this.connectToSeedPeers();
});
// 保持程序活跃
this.keepAlive();
}
// 处理接收到的消息
handleMessage(socket: net.Socket, message: Message): void {
switch (message.type) {
case 'blockchain_request':
console.debug('Received blockchain request');
this.sendBlockchain(socket);
break;
case 'blockchain_response':
this.handleBlockchainResponse(message.data);
break;
case 'peers_request':
this.sendPeers(socket, message.data);
break;
case 'peers_response':
this.handlePeersResponse(message.data);
break;
case 'keep_alive':
console.debug('Received keep alive message');
break;
default:
console.error('Unknown message type: ', message.type);
}
}
// 请求整个区块链数据
requestBlockchain(socket: net.Socket): void {
const message: Message = {
type: 'blockchain_request',
data: null
};
this.sendToSocket(socket, message);
}
// 发送整个区块链到请求节点
sendBlockchain(socket: net.Socket): void {
const message: Message = {
type: 'blockchain_response',
data: this.blockchain.chain,
};
this.sendToSocket(socket, message);
}
// 处理接收到的区块链
handleBlockchainResponse(data: Block[]): void {
console.debug('Received blockchain response');
const newChain = data;
if (newChain.length > this.blockchain.chain.length && this.blockchain.isChainValid(newChain)) {
console.debug('Received blockchain is longer and valid. Replacing current blockchain with received blockchain');
this.blockchain.chain = newChain;
this.blockchain.saveChainToFile();
} else {
console.debug('Received blockchain is not longer or invalid. Not replacing current blockchain');
}
}
// 请求更新的节点列表
requestPeers(socket: net.Socket): void {
const message: Message = {
type: 'peers_request',
data: {
host: this.host,
port: this.port
}
};
this.sendToSocket(socket, message);
}
// 发送节点列表到请求节点
sendPeers(socket: net.Socket, data: any): void {
this.peers.set(`${data.host}:${data.port}`, socket);
const peersArray = Array.from(this.peers.keys());
const message: Message = {
type: 'peers_response',
data: peersArray
};
this.sendToSocket(socket, message);
}
// 处理接收到的节点列表
handlePeersResponse(data: string[]): void {
console.debug('Received peers response: ', data);
data.forEach(peerAddress => {
if (!this.peers.has(peerAddress) && this.seedPeers.indexOf(peerAddress) === -1) {
const [host, port] = peerAddress.split(':');
this.connectToPeer(host, parseInt(port));
}
});
}
// 保持程序活跃
keepAlive(): void {
setInterval(() => {
console.debug('Keeping the program alive');
// 每隔 1 分钟向所有节点发送 keep_alive 消息
this.broadcast(JSON.stringify({
type: 'keep_alive',
data: null
}));
}, 1000 * 60);
// 5s 打印一次节点列表和区块高度
setInterval(() => {
console.debug('Peers: ', Array.from(this.peers.keys()));
console.debug('Blockchain height: ', this.blockchain.height);
}, 5000);
}
// 广播消息,当一个节点接收到新的区块时,广播给其他节点
broadcast(message: string): void {
console.debug('Broadcasting message: ', message);
this.peers.forEach((peer) => {
peer.write(message + '\n');
});
}
// 连接到其他节点
connectToPeer(host: string, port: number): void {
if (this.peers.has(`${host}:${port}`)) {
console.debug(`Already connected to peer: ${host}:${port}`);
return;
}
if (host === this.host && port === this.port) {
console.debug(`Can not connect to self: ${host}:${port}`);
return;
}
const socket = net.createConnection(port, host, () => {
this.handleConnection(socket);
});
this.setupSocketEventHandlers(socket);
console.debug('Adding peer: ', `${host}:${port}`)
this.peers.set(`${host}:${port}`, socket);
}
// 尝试连接到种子节点
connectToSeedPeers(): void {
this.seedPeers.forEach(peerAddress => {
const [host, port] = peerAddress.split(':');
// 如果自己就是种子节点,不连接自己
if (host === this.host && port === this.port.toString()) {
return;
}
this.connectToPeer(host, parseInt(port));
});
}
// 处理新连接,每个新连接都会向对方请求区块链数据和节点列表
private handleConnection(socket: net.Socket) {
console.debug('New peer connected')
this.setupSocketEventHandlers(socket);
this.requestBlockchain(socket);
this.requestPeers(socket);
}
// 设置 socket 事件处理程序,针对 data、error、close 事件作出处理
private setupSocketEventHandlers(socket: net.Socket) {
socket.on('data', (data) => this.handleSocketData(socket, data));
socket.on('error', (error) => this.handleSocketError(socket, error));
socket.on('close', () => this.handleSocketClose(socket));
}
// 处理接收到的数据,处理粘包问题,并反序列化消息
private handleSocketData(socket: net.Socket, data: Buffer) {
const messages = data.toString().split('\n').filter(messageStr => messageStr);
messages.forEach(messageStr => {
const message: Message = JSON.parse(messageStr);
console.debug('Received message: ', message);
this.handleMessage(socket, message);
});
}
// 处理 socket 错误
private handleSocketError(socket: net.Socket, error: Error) {
console.error('Connection error: ', error);
this.removePeer(socket);
}
// 处理 socket 关闭
private handleSocketClose(socket: net.Socket) {
console.debug('Connection closed');
this.removePeer(socket);
}
// 发送消息到 socket,序列化消息
private sendToSocket(socket: net.Socket, message: Message) {
const messageStr = JSON.stringify(message) + '\n';
console.debug('Sending message: ', messageStr);
socket.write(messageStr);
}
// 从节点列表中移除节点
private removePeer(socket: net.Socket) {
const key = `${socket.remoteAddress}:${socket.remotePort}`;
this.peers.delete(key);
}
}
其中有一个点需要注意。我们封装的 sendToSocket 方法对数据进行了特殊的处理。
连续朝流里面写消息的时候,会导致消息粘连,这种种情况也叫 TCP 粘包。所以我们在写消息的时候有个技巧,每次都在消息的最后面增加一个换行符,这样在接到消息时可以根据换行符对消息进行正确的分离,这样可以确保所有的消息都是可以被正常解析的。这个作为分隔符的换行符一般也叫终结标志。因为我们的数据结构比较简单,而且是直接使用 JSON 作为传输。有些传输协议为了压缩数据体积,会使用更紧凑的传输协议,也会涉及到更复杂的分帧/解帧的逻辑。
main.ts
中是启动区块链的主文件。
ts
import { Block } from "./Block";
import { Blockchain } from "./Blockchain";
import yargs from 'yargs';
import { P2PServer } from "./P2PServer";
// 解析命令行参数
const argv = yargs
.option('dataPath', {
alias: 'd',
description: 'The path to save the blockchain data file',
type: 'string',
})
.option('port', {
alias: 'p',
description: 'The port to listen for P2P connections',
type: 'number',
})
.option('host', {
alias: 'h',
description: 'The host to listen for P2P connections',
type: 'string',
})
.option('peers', {
alias: 'ps',
description: 'The seed peers to connect to',
type: 'array',
})
.help()
.alias('help', 'h')
.argv;
// 如果命令行参数提供了dataPath,使用它;否则使用默认值
const blockchainDataPath = argv.dataPath || 'blockchain.json';
const p2pPort = argv.port || 12315;
const p2pHost = argv.host || 'localhost';
const seedPeers = argv.peers || ['localhost:12315'];
const myBlockchain = new Blockchain(blockchainDataPath);
const myP2PServer = new P2PServer(myBlockchain, p2pPort, p2pHost, seedPeers);
myP2PServer.listen();
然后我们对 P2P 网络进行测试,先开启一个 Terminal,运行以下命令启动第一个区块链节点:
shell
npx tsx ./src/main.ts -d ./data/blockchain.json
再开启一个 Terminal,运行以下命令启动第二个区块链节点:
shell
npx tsx ./src/main.ts -d ./data/blockchain1.json -p 10000
再打开第三个 Terminal,启动第三个区块链节点:
shell
npx tsx ./src/main.ts -d ./data/blockchain2.json -p 10001
可以在控制台看到对应的节点输出日志:
text
Peers: [ 'localhost:10000', 'localhost:10001' ]
同时也可以看到 blockchain1.json 和 blockchain2.json 这两个文件中会同步区块链数据。
到这里我们基本上已经实现了区块链的去中心化能力。
这里面的技术点还是蛮多的,比如处理网络分区、处理数据冲突等等,这些细节我们暂时都没有去做处理。
除此之外还有很多细节可以补充。比如当区块链数据过大时,同步数据应该是分段传输,而不是一次性传输。心跳机制中还应当定期检查节点的活跃性,超过一定时间没收到来自节点的 keep alive 要移除不活跃的节点。同时还应该支持设置 keep alive 的时间。还应当支持 IP 白名单和黑名单等功能。
如果对区块链技术感兴趣的人比较多的话,我后续会继续更新区块链开发相关的内容。
文中代码托管在 GitHub,欢迎 Star: github.com/luzhenqian/...
如果你对本文内容感兴趣,欢迎添加作者微信:LZQ20130415
,邀你进群学习。