深度理解区块链原理:用 Node.js 手写区块链02 去中心化 P2P 网络与数据持久化

大家都知道,区块链的特性有很多。比如去中心化、透明性、可溯源性、不可篡改性、安全性、共识机制等等。要说其中哪一点最重要?当然都很重要,但如果一定要进行排名的话,去中心化毫无疑问是要排在第一位的。

我们在上一课中实现的区块链比较简单,仅仅实现了两个特性:数据的不可篡改性和安全性。这节课我们来完成另一个区块链中极为重要的特性:去中心化。这一个特性需要我们来实现另外两个功能: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,然后添加两个方法,分别是 saveChainToFileloadChainFromFile

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,邀你进群学习。

相关推荐
终末圆3 分钟前
MyBatis XML映射文件编写【后端 18】
xml·java·开发语言·后端·算法·spring·mybatis
_.Switch9 分钟前
Python Web 架构设计与性能优化
开发语言·前端·数据库·后端·python·架构·log4j
libai11 分钟前
STM32 USB HOST CDC 驱动CH340
java·前端·stm32
2401_8576009513 分钟前
心理教育辅导系统的设计与Spring Boot实现
java·spring boot·后端
北飞的山羊35 分钟前
【计算机网络】详解UDP套接字&网络字节序&IP地址&端口号
linux·服务器·网络·后端·计算机网络·udp·信息与通信
☼YJLH☾42 分钟前
第十章,XML
xml·java·后端·intellij-idea
Java搬砖组长43 分钟前
html外部链接css怎么引用
前端
GoppViper1 小时前
uniapp js修改数组某个下标以外的所有值
开发语言·前端·javascript·前端框架·uni-app·前端开发
拜见老天師1 小时前
SpringBoot中对数据库连接配置信息进行加密处理
数据库·spring boot·后端
丶白泽1 小时前
重修设计模式-结构型-适配器模式
前端·设计模式·适配器模式