PBFTdemo代码分析(GO语言)

        因为硕士研究方向是共识算法中BFT相关,本科专业是计科,也不是网安,希望通过写博客,提高和记录自己对共识算法代码的理解与认识,仅仅是入门者,有误的部分请您斧正。

        PBFT是Practical Byzantine Fault Tolerance的缩写,意为实用拜占庭容错算法。该算法是Miguel Castro (卡斯特罗)和Barbara Liskov(利斯科夫)在1999年提出来的,解决了原始拜占庭容错算法效率不高的问题,将算法复杂度由指数级降低到多项式级,使得拜占庭容错算法在实际系统应用中变得可行。

        原理不再赘述,有很多很详细的博客。贴一张算法运行图在下面。

        

        PBFT项目demo来源:corgi-kx/blockchain_consensus_algorithm: 代码实现五种区块链共识算法 The code implements five blockchain consensus algorithmsicon-default.png?t=O83Ahttps://github.com/corgi-kx/blockchain_consensus_algorithm

项目代码结构

 

 

项目代码分析

main.go

package main

import (
	"log"
	"os"
)

const nodeCount = 4

var clientAddr = "127.0.0.1:8888"

var nodeTable map[string]string

func main() {
	genRsaKeys()
	nodeTable = map[string]string{
		"N0": "127.0.0.1:8000",
		"N1": "127.0.0.1:8001",
		"N2": "127.0.0.1:8002",
		"N3": "127.0.0.1:8003",
	}
	if len(os.Args) != 2 {
		log.Panic("输入的参数有误!")
	}
	nodeID := os.Args[1]
	if nodeID == "client" {
		clientSendMessageAndListen() //启动客户端程序
	} else if addr, ok := nodeTable[nodeID]; ok {
		p := NewPBFT(nodeID, addr)
		go p.tcpListen() //启动节点
	} else {
		log.Fatal("无此节点编号!")
	}
	select {}
}

导入的包        

        首先导入了log和os包,正好为了理解程序也在学习Go语言。这两个包都是标准库中的,log用于记录日志,输出信息,os用于与操作系统交互,命令行参数等

数据结构

        nodeCount 常量,代表本demo中的总节点数量是4个。

        clientAddr  string类型,用于存储客户端client的监听地址,127.0.0.1:8888也就是localhost的8888端口。

        nodeTable map类型,用于存储节点的ID与相应的监听地址。

函数

main()

        首先调用genRsakeys()函数,为当前节点创建公私钥,具体分析在下文rsa.go中。

        初始化map nodeTable,初始化节点与监听地址。

        然后是一段很经典的命令行参数判断:

  1. 根据命令行参数判断程序启动的模式:
    • 如果参数为 "client",则调用 clientSendMessageAndListen() 函数启动客户端程序。
    • 如果参数为节点编号(例如 "N0"、"N1" 等),则根据 nodeTable 中对应的地址创建一个 PBFT 实例,并在新的 goroutine 中启动节点的监听。
    • 如果参数既不是 "client" 也不是已知的节点编号,则输出错误信息并终止程序。
  2. 最后通过 select {} 使程序保持运行,避免程序在启动后立即退出。

在 Go 语言中,map 的访问操作返回两个值:第一个是对应键的值,第二个是一个布尔值,表示是否存在该键。所以这里用addr保存监听地址,用ok储存是否存在该节点。

client.go

package main

import (
	"bufio"
	"crypto/rand"
	"encoding/json"
	"fmt"
	"log"
	"math/big"
	"os"
	"strings"
	"time"
)

func clientSendMessageAndListen() {
	//开启客户端的本地监听(主要用来接收节点的reply信息)
	go clientTcpListen()
	fmt.Printf("客户端开启监听,地址:%s\n", clientAddr)

	fmt.Println(" ---------------------------------------------------------------------------------")
	fmt.Println("|  已进入PBFT测试Demo客户端,请启动全部节点后再发送消息! :)  |")
	fmt.Println(" ---------------------------------------------------------------------------------")
	fmt.Println("请在下方输入要存入节点的信息:")
	//首先通过命令行获取用户输入
	stdReader := bufio.NewReader(os.Stdin)
	for {
		data, err := stdReader.ReadString('\n')
		if err != nil {
			fmt.Println("Error reading from stdin")
			panic(err)
		}
		r := new(Request)
		r.Timestamp = time.Now().UnixNano()
		r.ClientAddr = clientAddr
		r.Message.ID = getRandom()
		//消息内容就是用户的输入
		r.Message.Content = strings.TrimSpace(data)
		br, err := json.Marshal(r)
		if err != nil {
			log.Panic(err)
		}
		fmt.Println(string(br))
		content := jointMessage(cRequest, br)
		//默认N0为主节点,直接把请求信息发送至N0
		tcpDial(content, nodeTable["N0"])
	}
}

//返回一个十位数的随机数,作为msgid
func getRandom() int {
	x := big.NewInt(10000000000)
	for {
		result, err := rand.Int(rand.Reader, x)
		if err != nil {
			log.Panic(err)
		}
		if result.Int64() > 1000000000 {
			return int(result.Int64())
		}
	}
}

导入的包

包的名称作用
bufio实现了带缓冲的 I/O 操作,提供了对输入输出的高效操作
crypto/rand

提供了加密级别的伪随机数生成器,用于生成安全的随机数。在代码中,用于生成安全的随机数作为消息的 ID。

encoding/json

实现了 JSON 数据的编码和解码,用于在 Go 中处理 JSON 数据,包括将数据结构序列化为 JSON 格式以及将 JSON 数据解析为数据结构。

fmt常用包,实现了格式化 I/O 操作,用于格式化输入输出,打印信息到标准输出等。
math/bigmath/big 包提供了大数(高精度数)的计算功能,用于处理大整数的运算,例如在代码中生成一个十位数的随机数。
strings

提供了对字符串的操作函数,包括字符串的拼接、分割、替换等操作。

time提供了时间的操作和处理功能,包括获取当前时间、时间格式化、定时器等功能。

数据结构

        本包没有声明数据结构

函数

clientSendMessageAndListen()

        首先通过 bufio.NewReader(os.Stdin) 创建了一个标准输入的读取器,用于读取用户输入。

        在一个循环中,不断读取用户输入的数据,并将其封装成一个请求对象 Request,包括时间戳、客户端地址、消息 ID 和消息内容。

        使用 json.Marshal 将请求对象转换为 JSON 格式的字节流,并打印输出。调用 jointMessage 函数将请求信息拼接成一个完整的消息内容。

        最后,将拼接好的消息内容通过 tcpDial 函数发送给指定的节点(在代码中默认发送至节点 "N0")。(本demo作者没有写viewchange部分,所以主节点一直就是N0)。

getRandom()

        很简单的思路,利用rand生成一个大于1000000000小于1000000000的数,作为随机的ID。

tcp.go

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net"
)

//客户端使用的tcp监听
func clientTcpListen() {
	listen, err := net.Listen("tcp", clientAddr)
	if err != nil {
		log.Panic(err)
	}
	defer listen.Close()

	for {
		conn, err := listen.Accept()
		if err != nil {
			log.Panic(err)
		}
		b, err := ioutil.ReadAll(conn)
		if err != nil {
			log.Panic(err)
		}
		fmt.Println(string(b))
	}

}

//节点使用的tcp监听
func (p *pbft) tcpListen() {
	listen, err := net.Listen("tcp", p.node.addr)
	if err != nil {
		log.Panic(err)
	}
	fmt.Printf("节点开启监听,地址:%s\n", p.node.addr)
	defer listen.Close()

	for {
		conn, err := listen.Accept()
		if err != nil {
			log.Panic(err)
		}
		b, err := ioutil.ReadAll(conn)
		if err != nil {
			log.Panic(err)
		}
		p.handleRequest(b)
	}

}

//使用tcp发送消息
func tcpDial(context []byte, addr string) {
	conn, err := net.Dial("tcp", addr)
	if err != nil {
		log.Println("connect error", err)
		return
	}

	_, err = conn.Write(context)
	if err != nil {
		log.Fatal(err)
	}
	conn.Close()
}

导入的包

        

包的名称作用
net提供了用于网络通信的基本接口和函数,包括创建网络连接、监听端口、进行数据传输等功能。
io/ioutil提供了一些实用的 I/O 操作函数,用于简化文件读写和数据流处理。

数据结构

        该包没有声明。

函数

clientTcpListen()

        这是客户端使用的TCP监听,首先建立了一个TCP监听对象,net.Listen:用于在指定网络和地址上监听连接请求。

PBFT原文说的他们使用的是IP组播的UDP实现的点对点通信。本项目作者使用的是TCP。

        defer listen.Close(),在函数结束时会关闭监听。然后进入一个for无限循环,不断接受客户端的连接请求。对每个连接,读取数据并输出到控制台。

tcpListen()

        这是节点用的TCP监听函数。(p *pbft):这部分是方法的接收器(receiver),它指定了方法附加到的类型,表示这个方法是与 pbft 结构体相关联的方法,可以通过结构体类型 pbft 的实例来调用这个方法。

        类似于 clientTcpListen(),通过 net.Listen("tcp", p.node.addr) 创建一个 TCP 监听器,监听节点地址的 TCP 连接。然后进入for无限循环,接受连接请求,并对每个连接读取数据后调用 p.handleRequest(b) 处理请求数据。

tcpDial()

        该函数用于使用 TCP 发送消息, 有两个参数,一个是建立连接的地址,一个是需要发送的内容。通过 net.Dial("tcp", addr) 连接到指定地址的 TCP 服务器。向连接中写入 context 中的数据,并关闭连接。

cmd.go

package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"log"
)

//<REQUEST,o,t,c>
type Request struct {
	Message
	Timestamp int64
	//相当于clientID
	ClientAddr string
}

//<<PRE-PREPARE,v,n,d>,m>
type PrePrepare struct {
	RequestMessage Request
	Digest         string
	SequenceID     int
	Sign           []byte
}

//<PREPARE,v,n,d,i>
type Prepare struct {
	Digest     string
	SequenceID int
	NodeID     string
	Sign       []byte
}

//<COMMIT,v,n,D(m),i>
type Commit struct {
	Digest     string
	SequenceID int
	NodeID     string
	Sign       []byte
}

//<REPLY,v,t,c,i,r>
type Reply struct {
	MessageID int
	NodeID    string
	Result    bool
}

type Message struct {
	Content string
	ID      int
}

const prefixCMDLength = 12

type command string

const (
	cRequest    command = "request"
	cPrePrepare command = "preprepare"
	cPrepare    command = "prepare"
	cCommit     command = "commit"
)

//默认前十二位为命令名称
func jointMessage(cmd command, content []byte) []byte {
	b := make([]byte, prefixCMDLength)
	for i, v := range []byte(cmd) {
		b[i] = v
	}
	joint := make([]byte, 0)
	joint = append(b, content...)
	return joint
}

//默认前十二位为命令名称
func splitMessage(message []byte) (cmd string, content []byte) {
	cmdBytes := message[:prefixCMDLength]
	newCMDBytes := make([]byte, 0)
	for _, v := range cmdBytes {
		if v != byte(0) {
			newCMDBytes = append(newCMDBytes, v)
		}
	}
	cmd = string(newCMDBytes)
	content = message[prefixCMDLength:]
	return
}

//对消息详情进行摘要
func getDigest(request Request) string {
	b, err := json.Marshal(request)
	if err != nil {
		log.Panic(err)
	}
	hash := sha256.Sum256(b)
	//进行十六进制字符串编码
	return hex.EncodeToString(hash[:])
}

导入的包

包的名称作用
crypto/sha256

提供了SHA256哈希算法的实现。在代码中,它被用来对消息进行摘要(getDigest 函数),生成一个固定长度的哈希值。

encoding/hex提供了十六进制编码和解码的功能。在代码中,它被用来将SHA256算法生成的字节数组转换成十六进制字符串。

数据结构

       Request 结构体,包含消息内容、时间戳和客户端地址。

与原文<REQUEST,o,t,c>格式一致

       

  PrePrepare 结构体:包含请求消息、摘要、序列号和签名。

与PBFT原文相比实际上少了当前视图号,因为这个demo的视图始终是N0(主节点),没有viewchange环节。

  Prepare 结构体:包含摘要、序列号、节点ID和签名。

  Commit 结构体:包含摘要、序列号、节点ID和签名。

  Reply 结构体:包含消息ID、节点ID和结果。

与原文结构相比缺了时间戳和视图号。

  Message 结构体:包含内容和ID。

  command 类型:一个字符串类型,用于表示不同的命令。

函数

jointMessage()

        将命令类型(如Request,Pre-prepare等)和消息合并处理成字节流形式,方便使用TCP进行通信。

splitMessage()

        将字节流分成命令类型和消息。便于节点区分命令类型。

getDigest()

        该函数主要是利用哈希算法获取消息的摘要。哈希方法是SHA256。

rsa.go

package main

import (
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/pem"
	"errors"
	"fmt"
	"log"
	"os"
	"strconv"
)

//如果当前目录下不存在目录Keys,则创建目录,并为各个节点生成rsa公私钥
func genRsaKeys() {
	if !isExist("./Keys") {
		fmt.Println("检测到还未生成公私钥目录,正在生成公私钥 ...")
		err := os.Mkdir("Keys", 0644)
		if err != nil {
			log.Panic()
		}
		for i := 0; i <= 4; i++ {
			if !isExist("./Keys/N" + strconv.Itoa(i)) {
				err := os.Mkdir("./Keys/N"+strconv.Itoa(i), 0644)
				if err != nil {
					log.Panic()
				}
			}
			priv, pub := getKeyPair()
			privFileName := "Keys/N" + strconv.Itoa(i) + "/N" + strconv.Itoa(i) + "_RSA_PIV"
			file, err := os.OpenFile(privFileName, os.O_RDWR|os.O_CREATE, 0644)
			if err != nil {
				log.Panic(err)
			}
			defer file.Close()
			file.Write(priv)

			pubFileName := "Keys/N" + strconv.Itoa(i) + "/N" + strconv.Itoa(i) + "_RSA_PUB"
			file2, err := os.OpenFile(pubFileName, os.O_RDWR|os.O_CREATE, 0644)
			if err != nil {
				log.Panic(err)
			}
			defer file2.Close()
			file2.Write(pub)
		}
		fmt.Println("已为节点们生成RSA公私钥")
	}
}

//生成rsa公私钥
func getKeyPair() (prvkey, pubkey []byte) {
	// 生成私钥文件
	privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
	if err != nil {
		panic(err)
	}
	derStream := x509.MarshalPKCS1PrivateKey(privateKey)
	block := &pem.Block{
		Type:  "RSA PRIVATE KEY",
		Bytes: derStream,
	}
	prvkey = pem.EncodeToMemory(block)
	publicKey := &privateKey.PublicKey
	derPkix, err := x509.MarshalPKIXPublicKey(publicKey)
	if err != nil {
		panic(err)
	}
	block = &pem.Block{
		Type:  "PUBLIC KEY",
		Bytes: derPkix,
	}
	pubkey = pem.EncodeToMemory(block)
	return
}

//判断文件或文件夹是否存在
func isExist(path string) bool {
	_, err := os.Stat(path)
	if err != nil {
		if os.IsExist(err) {
			return true
		}
		if os.IsNotExist(err) {
			return false
		}
		fmt.Println(err)
		return false
	}
	return true
}

//数字签名
func (p *pbft) RsaSignWithSha256(data []byte, keyBytes []byte) []byte {
	h := sha256.New()
	h.Write(data)
	hashed := h.Sum(nil)
	block, _ := pem.Decode(keyBytes)
	if block == nil {
		panic(errors.New("private key error"))
	}
	privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
	if err != nil {
		fmt.Println("ParsePKCS8PrivateKey err", err)
		panic(err)
	}

	signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed)
	if err != nil {
		fmt.Printf("Error from signing: %s\n", err)
		panic(err)
	}

	return signature
}

//签名验证
func (p *pbft) RsaVerySignWithSha256(data, signData, keyBytes []byte) bool {
	block, _ := pem.Decode(keyBytes)
	if block == nil {
		panic(errors.New("public key error"))
	}
	pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		panic(err)
	}

	hashed := sha256.Sum256(data)
	err = rsa.VerifyPKCS1v15(pubKey.(*rsa.PublicKey), crypto.SHA256, hashed[:], signData)
	if err != nil {
		panic(err)
	}
	return true
}

导入的包

包的名称作用
crypto/rsa提供RSA加密算法的实现
crypto/x509提供ASN.1 DER编码和X.509解析功能,在代码中是对私钥进行编码。
encoding/pem提供PEM(Privacy Enhanced Mail)格式编码和解码,在代码中是对私钥进行编码。
errors定义错误接口和相关错误类型
strconv提供字符串与基本数据类型之间的转换

数据结构

        无。

函数

genRsaKeys()      

        首先检查是否存在Keys目录,如果不存在则创建该目录,并为每个节点(编号0到4)创建子目录。在每个节点目录中,使用getKeyPair函数生成RSA公私钥对。私钥和公钥分别使用PEM格式编码,并写入对应的文件中。

getKeyPair()

   生成一个1024位的RSA私钥。私钥使用x509.MarshalPKCS1PrivateKey进行DER编码,然后使用pem.EncodeToMemory进行PEM编码。

         公钥从私钥中提取,使用x509.MarshalPKIXPublicKey进行DER编码,然后PEM编码。

生成的私钥的格式

isExist()

        很简单的函数,用来判断文件路径是否存在。

RsaSignWithSha256()

       对数据进行SHA-256哈希,然后使用私钥对哈希值进行PKCS#1 v1.5签名。实际上调用这个数字签名函数时,是对消息的摘要使用的,也就是已经进行过SHA256哈希的消息,不是太清楚作者在这里为什么又要进行一次哈希。

      签名结果返回一个字节切片。这个切片就是Request、Pre-prepare消息结构中的签名。

RsaVerySignWithSha256()

        与上面的函数对应,验证给定数据的签名是否有效。使用公钥对数据进行SHA-256哈希,然后使用rsa.VerifyPKCS1v15验证签名。

在PBFT原文中,只对很少发送的视图更改和新视图消息使用数字签名(RSA加密),并使用消息身份验证码(MAC)对所有其他消息进行身份验证。

pbft.go

导入的包

包的名称作用
sync提供同步原语,如互斥锁,用于控制对共享变量的并发访问。

数据结构

        node 结构体,表示一个节点,包含节点ID、监听地址、对应的RSA私钥和公钥。

        pbft 结构体,表示PBFT协议的实例,包含节点信息、请求序号、锁、临时消息池、准备确认计数、提交确认计数、是否已广播提交、是否已回复客户端等字段。结构体中的变量具体作用注释解释的很清楚。

type pbft struct {
	//节点信息
	node node
	//每笔请求自增序号
	sequenceID int
	//锁
	lock sync.Mutex
	//临时消息池,消息摘要对应消息本体
	messagePool map[string]Request
	//存放收到的prepare数量(至少需要收到并确认2f个),根据摘要来对应
	prePareConfirmCount map[string]map[string]bool
	//存放收到的commit数量(至少需要收到并确认2f+1个),根据摘要来对应
	commitConfirmCount map[string]map[string]bool
	//该笔消息是否已进行Commit广播
	isCommitBordcast map[string]bool
	//该笔消息是否已对客户端进行Reply
	isReply map[string]bool
}

函数

NewPBFT()

        就是上述pbft结构体的构造函数,用于初始化pbft实例,读取节点公私钥。

handleRequest()

        解析接收到的消息,调用splitMessage(),并根据命令类型(cmd)调用相应的处理函数。

handleClientRequest()

        处理客户端请求,生成消息摘要,将请求存入临时消息池,拼接成Pre-prepare消息,然后广播给各个节点。此阶段中,主节点给收到的消息分配一个编号n,接着广播一条<<PRE-PREPARE, v, n, d>,  m>消息给其他副本节点,并将请求记录到本地历史(log)中。说明:n主要用于对所有客户端的请求进行排序;v指视图编号;m指消息内容;d指消息摘要。

        从<PRE-PREPARE, v, n, d>可以看出,请求消息本身内容(m)是不包含在预准备的消息里面的,这样就能使预准备消息足够小。

handlePrePare()

        处理预准备消息,验证消息摘要和签名,如果验证通过,则存储消息并广播准备消息。

原文中节点接受pre-prepare消息的条件

  • 消息签名是否正确。
  • 判断n是否在区间[h, H]内。
  • d是否和当前已收到PRE-PPREPARE中的d相同(消息序号是否一致)
handlePrepare()

        处理准备消息,验证消息签名,如果收到足够多的准备消息,则广播提交消息。

准备谓词为true的条件

              

        即节点至少收到了2f个prepare的消息(包括自己),并且没有进行过commit广播,则进行commit广播。

handleCommit()

        处理提交消息,验证消息签名,如果收到足够多的commit消息,则将消息持久化到本地消息池,并回复client。

Commit阶段完成的条件

        如果节点至少收到了2f+1个commit消息(包括自己),并且节点没有回复过,并且已进行过commit广播,则提交信息至本地消息池。

sequenceIDAdd()

        确保自增请求序号。

broadcast()

        用于向其他节点广播消息。

setPrePareConfirmMap()和setCommitConfirmMap()

        用于为准备和提交确认计数设置值。

getPubKey()和getPivKey()

        用于根据节点ID读取对应的公钥和私钥。

                   
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值