文章目录
在上一篇文章 :编程小白模拟简易比特币系统(九)中,我们完成了client与server节点的交互,但是调用API的时候,涉及到的过程我们没有说明,其实作为开发者,在API的设计上自由度很大,下面就介绍一下我的设计吧。
请大家继续往下看👇
相关概念
广播
回顾之前的P2P网络中的模拟场景,很多地方都使用到了广播:
- 启动节点A。A首先创建一个创世区块
- 创建钱包A1。调用节点A提供的API创建一个钱包,此时A1的球球币为0。
- A1挖矿。调用节点A提供的挖矿API,生成新的区块,同时为A1的钱包有了系统奖励的球球币。
- 启动节点B。节点B要向A同步信息,当前的区块链,当前的交易池,当前的所有钱包的公钥。
- 创建钱包B1、A2,调用节点A和B的API,要广播(通知每一个节点)出去创建的钱包(公钥),目前节点只有两个,因此A需要告诉B,A2的钱包。B需要告诉A,B1的钱包。
- A1转账给B1。调用A提供的API,同时广播交易。
- A2挖矿记账。调用A提供的API,同时广播新生成的区块。
这里我们提到了广播这个概念,都是发生在调用API之后,总结起来也是三件事情需要广播:新钱包、新交易、新区块。广播就是通知所有与该节点相连的其他节点,实现起来也就是遍历接着挨个通知的过程,非常简单。
比特币的网络中,不是每一个节点与网络中的所有其他节点相连,那样广播的话得付出巨大的代价,还有网络的影响,事实上是与几个临近的节点相连,这几个节点继续通知与它们相近的节点,以此实现广播。
同步信息
当一个节点想要重新加入区块链网络时,需要与时俱进、同步消息,这样才能与大家的账本保持一致,但是有两种情况:①新节点加入时需要同步所有的区块链信息②以前的节点断网了很久重新加入时,可能设置了本地存储,因此只需要同步最近的消息即可。
因此我们采取的同步的策略为:先向server请求最新的区块,如果刚好就差这一个区块,则直接添加到自己节点上的区块链中,反之,再继续索要整个区块链的信息。
这个方法是对所有涉及到P2P区块消息进行的处理,请求最新区块或者整个区块链,都会进入这个方法,代码实现就是这样:
public synchronized void handleBlockChainResponse(String msg) {
//解析获取的区块链消息,并对内部按照index排序
List<Block> receiverBlockChain = JSON.parseArray(msg, Block.class);
Collections.sort(receiverBlockChain, new Comparator<Block>() {
@Override
public int compare(Block o1, Block o2) {
return o1.getIndex() - o2.getIndex();
}
});
//接收到的区块链中最后一块
Block latestBlockReceived = receiverBlockChain.get(receiverBlockChain.size() - 1);
//本地区块链最后一块
Block latestBlock = blockService.getLatestBlock();
//接收到的区块链最后一个区块index大于本地的最后一块index
if (latestBlockReceived.getIndex() > latestBlock.getIndex()) {
//接收的正好是本地区块的后一块
if (latestBlock.getHash().equals(latestBlockReceived.getPreviousHash())) {
//添加到本地
if (blockService.addBlock(latestBlockReceived)) {
//添加成功则向其他节点继续广播新区块的信息
broadcast(responseLatestBlockMsg());
}
//接收的区块虽然是一个,但不是本地区块的后一个,要广播消息,查询节点中最长的区块链
} else if (receiverBlockChain.size() == 1) {
broadcast(queryBlockChainMsg());
} else {
//获取到所有的区块链,对本节点进行更新
blockService.replaceChain(receiverBlockChain);
}
}
}
关于这个算法需要解释一下,我们以一个场景为例,整个网络中有ABCD四个节点,A有3个区块,B有4个,C有2个,D有3个,网络的连接情况是C和ABD有连接,C对于D来说是服务端,对于AB来说是客户端,AB是C的服务端,D是C的客户端,初始场景如下图,我们假设C需要同步信息:
一、假设C首先向B请求同步信息,注意我们的实现的策略是先请求最后一个区块,于是C请求到了B的第4个区块,进入第1个if(4>2),但是B的4不是C的2后面的区块(4的pervious hash不是2的hash),因此C要请求查询AB两个的区块链信息,下面我们分别讨论先向A和先向B请求所发生的事情。
-
假设先向A请求到了A的整个block chain,C进行处理信息。
①此时latestBlockReceived是A的3,latestBlock是B的2,刚好加到本地区块链(3的previous hash是2的hash),那么C的区块链成为了123,向D广播3,D进入这个方法不做处理(3不大于3)
②C继续向B请求,过程同上,C变成了1234。向D广播区块4,过程类似(4>3, 4的prehash是3,增加区块),C的同步完成。
-
假设先向B请求到了B的整个block chain,C进行处理信息。
①此时latestBlockReceived是B的4,latestBlock是C的2,不能添加到本地2的后面,而且size是4,因此,进入replaceChain方法(下面讲,看名字也知道这个方法是用来替换本地区块链的),C成为1234。
②C继续向A请求,latestBlockReceived是A的3,latestBlock是C的4,2<4,不进行操作,C同步完成。
至此完整的过程就结束了,对照上方的算法,相信大家可以理解,注意这里是C的同步,不需要考虑D的同步进行的怎样。
接下来就是替换区块链的算法,策略是逐个校验,合格直接替换本地区块链和已经打包的交易集合。
@Override
public void replaceChain(List<Block> newBlocks) {
if (isValidChain(newBlocks) && newBlocks.size() > blockChain.size()){
blockChain = newBlocks;
packedTransactionList.clear();
blockChain.forEach(block -> {
packedTransactionList.addAll(block.getTransactions());
});
}
}
//校验区块链,遍历区块链挨个校验区块
private boolean isValidChain(List<Block> chain) {
Block block;
Block lastBlock = chain.get(0);
int index = 1;
while (index < chain.size()){
block = chain.get(index);
if (!isValidNewBlock(block,lastBlock)){
return false;
}
lastBlock = block;
index++;
}
return true;
}
API设计
下面就结合代码给大家介绍下我API的设计:
-
查询区块链信息
@RequestMapping(value = "/chain",method = {RequestMethod.GET}) @ResponseBody public String getChainInfo(){ return blockService.getBlockChain(); }
在blockService中getBlockChain的实现,就是区块链直接转json,没什么好讲,很简单。
@Override public String getBlockChain() { return JSON.toJSONString(blockChain); }
-
创建钱包
@RequestMapping(value = "/createWallet",method = {RequestMethod.GET}) @ResponseBody public WalletVO createWallet(){ Wallet wallet = blockService.createWallet(); Wallet[] wallets = {Wallet.builder() .publicKey(wallet.getPublicKey()) .build()}; p2PService.broadcastNewWallet(JSON.toJSONString(wallets)); return convertFromWalletModel(wallet); }
blockService中createWallet的实现,使用之前提到的generateWallet方法。
@Override public Wallet createWallet(){ Wallet wallet = walletService.generateWallet(); String address = walletService.getWalletAddress(wallet.getPublicKey()); myWalletMap.put(address,wallet); return wallet; }
由于我们设计的钱包模型只有公私钥,前端API调用的时候还需要获取地址,因此我们新增了个View层的Object,需要把获得的钱包对象转化为视图层对象反馈给前端。
private WalletVO convertFromWalletModel(Wallet wallet){ WalletVO walletVO = new WalletVO(); BeanUtils.copyProperties(wallet,walletVO); walletVO.setAddress(walletService.getWalletAddress(wallet.getPublicKey())); walletVO.setHashPublicKey(walletService.hashPubKey(wallet.getPublicKey())); return walletVO; }
广播新产生的钱包
@Override public void broadcastNewWallet(String msg) { broadcast(JSON.toJSONString(new Message(RESPONSE_WALLET, msg))); }
-
发起一笔交易
@RequestMapping(value = "/newtx",method = {RequestMethod.POST}) @ResponseBody public Object startTx(@RequestParam(name = "sender")String sender, @RequestParam(name = "receiver")String receiver, @RequestParam(name = "amount")Integer amount){ Wallet senderWallet = blockService.findWallet(sender); Wallet receiverWallet = blockService.findWallet(receiver); if (senderWallet == null || receiverWallet == null){ return "钱包不存在"; } Transaction newTx = blockService.createTransaction(senderWallet, receiverWallet, amount); if (newTx == null){ return "交易创建失败"; }else { Transaction[] transactions = {newTx}; p2PService.broadcastNewTransaction(JSON.toJSONString(transactions)); } return newTx; }
广播新产生的交易
@Override public void broadcastNewTransaction(String msg) { broadcast(JSON.toJSONString(new Message(RESPONSE_TRANSACTION, msg))); }
-
查询钱包余额
@RequestMapping(value = "/getBalance",method = {RequestMethod.GET}) @ResponseBody public Object queryWallet(@RequestParam(name = "address")String walletAdd){ return blockService.getWalletBalance(walletAdd); }
-
挖矿
// public static final String CONTENT_TYPE_FORMED= "application/x-www-form-urlencoded"; @RequestMapping(value = "/mine",method = {RequestMethod.POST}, consumes = CONTENT_TYPE_FORMED) @ResponseBody public Object mine(@RequestParam(name = "address")String toAddress){ Wallet wallet = blockService.findWallet(toAddress); if (wallet == null){ return "钱包不存在"; } Block newBlock = blockService.mine(toAddress); if (newBlock == null){ return "挖矿失败"; } Block[] blocks = {newBlock}; String msg = JSON.toJSONString(blocks); p2PService.broadcastNewBlock(msg); return "产生新的区块"+JSON.toJSONString(newBlock); }
广播新产生的区块
@Override public void broadcastNewBlock(String msg) { broadcast(JSON.toJSONString(new Message(RESPONSE_BLOCK_CHAIN, msg))); }
项目总结
至此,整个项目基本上就介绍完全了,在API的设计和测试模拟场景的选择上具有很大的自由度,关于整个系统的实现也是很具有开放性的,那么我给出的这一种模拟仅仅供大家参考,另外关于Spring boot使用不当的地方,欢迎大家在下方评论探讨,下面我简单谈下我认为系统涉及到的问题和不足。
效率问题
1)查询钱包余额时
之前我们提到过,查询钱包余额时,是遍历该地址所有的UTXO,获得一个集合,接着遍历集合获取到钱包对应的余额,而查询UTXO时,又是遍历交易池,遍历区块链,那么一旦交易太多(交易池很大)或者区块链很长(每个区块都有上千笔交易),这将会导致严重的效率问题。因为账户的余额都是由之前的交易记录计算得到的,所以此时遍历会特别耗费时间,事实上,比特币系统中,有专门用来存储UTXO的集合,是随着系统实时更新的,我们这个系统模拟测试时也只有几笔交易,暂时这样设计,还有待改进。
2)某个节点需要广播时
网络中的某个节点,在发生了一些事件(如挖矿成功、生成新钱包、发生新的交易)时,都需要广播通知其他节点,那么当这个网络很庞大(拥有成千上万个节点)的时候,这样广播告知给所有节点,效率太低下,真实情况是广播通知临近的节点,这些节点收到消息后继续广播扩散消息。
3)校验区块链时
在区块同步区块链信息的时候,每次替换区块链的时候,都需要遍历区块链,逐个验证区块,根据每一个区块去更新已经打包的交易池,同样存在着区块链很长、节点很多时,导致效率低下的问题。
4)根据id查询某个交易时
由于交易id的不确定性,是我们随机生成的字符串,我们也是遍历逐个比较查找的,也许会存在更优的搜索策略。
并发问题
这个项目涉及到的并发问题:
- 节点同时在挖矿,或者同时挖出了矿(计算出了hash值)
- 节点同时发生许多交易
- 节点同时生成新钱包
- 某个节点被许多其他节点同时请求数据
项目中涉及到的只有在处理同步区块链信息的时候用到了synchronized
处理并发的问题,类似于线程池、乐观锁、悲观锁、并发式编程等等,相关的知识还有待了解。
设计问题
由于我们这个项目是简单的模拟,并非完全还原整个系统,因此还有许多与原系统差异较大的地方:
1)加密算法
关于密码学的问题,我们之前也介绍过,就是具体算法的实现,原系统是椭圆曲线算法,我们使用的RSA加密算法,仅仅是实现了一个加密的效果。
2)节点
原系统中节点的类型有很多,比如:全节点(存放所有的信息)、轻节点(钱包节点,只存放与自己有关的信息)、矿工节点(只负责挖矿)等等。我们这个系统也没有细分,可以简单理解为全节点,关于轻节点没有所有信息时,验证交易或区块的方法,原系统会有一个叫做markle tree的数据结构,我们还没有实现;以及同步数据的方法,原系统有一个dns seed,比特币客户端会硬编码许多域名地址,通过这些地址可以找到很多全节点,选择几个去连接,同步数据。
3)交易模型的创建
回顾之前的例子,我们这个系统目前只实现了单个UTXO消费的场景。
对于同时引用多个交易输出多个UTXO的场景还未实现,这和之前的问题相比还算好改进些,考虑输入输出中的Transaction改为Transaction集合,对签名也需要作出相应的修改,还是有希望实现的。
4)关于异常以及良好的前后端交互
基于当前的设计,我们可以看到,返回的数据是很不友好的,有些是json字符串,有些是直接return的中文字符串,那么对于前端,我们很希望收到统一的回复,在此可以设计一个commonReturnType类来实现后端返回消息的封装,来实现前后端良好的交互,后端返回状态以及对应的data供前端解析。
对于异常的处理,我们这里似乎有点粗鲁,直接在方法后throw了出去。这里可以考虑使用Spring @ExceptionHandler注解来做统一异常处理,在系统内部异常的设计上还待完善,可以使用自定义类继承Exception,以及配合enum类实现通用错误类型的定义(类似于钱包地址有误,余额不足等等),以此来区分系统异常与java内部异常。
5)关于前端配合调用API测试
测试的时候,每个节点都需要被调用API来实现相应的操作(创建钱包、挖矿、发起交易等),具体的前端设计需要根据测试场景的不同来做出调整,其实每个事件都可以认为是一个或多个ajax请求,简单举个例子,使用id为mine的button,控制一个节点挖矿,那么就可以写成这样:
$('#mine').on("click",function () {
$.ajax({
type:"POST",
url:"http://localhost:8090/block/mine",
contentType:"application/x-www-form-urlencoded",
data:{
//wallet是之前ajax获取得到的有效钱包地址
"address": wallet
},
xhrFields:{
withCredentials : true
},
success:function (data) {
//是用来展示新区块的文本区
$("#minestate").text(data)
},
error:function (data) {
alert("挖矿失败,原因为"+data);
}
});
});
至此,我们这个项目就算基本上完成了,整个项目已经放到了gitee上,不足之处还请多多指正。