编程小白模拟简易比特币系统(十)


在上一篇文章 :编程小白模拟简易比特币系统(九)中,我们完成了client与server节点的交互,但是调用API的时候,涉及到的过程我们没有说明,其实作为开发者,在API的设计上自由度很大,下面就介绍一下我的设计吧。

请大家继续往下看👇

相关概念

广播

回顾之前的P2P网络中的模拟场景,很多地方都使用到了广播:

在这里插入图片描述

  1. 启动节点A。A首先创建一个创世区块
  2. 创建钱包A1。调用节点A提供的API创建一个钱包,此时A1的球球币为0。
  3. A1挖矿。调用节点A提供的挖矿API,生成新的区块,同时为A1的钱包有了系统奖励的球球币。
  4. 启动节点B。节点B要向A同步信息,当前的区块链,当前的交易池,当前的所有钱包的公钥。
  5. 创建钱包B1、A2,调用节点A和B的API,要广播(通知每一个节点)出去创建的钱包(公钥),目前节点只有两个,因此A需要告诉B,A2的钱包。B需要告诉A,B1的钱包。
  6. A1转账给B1。调用A提供的API,同时广播交易
  7. 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请求所发生的事情。

  1. 假设先向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的同步完成。

  2. 假设先向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的不确定性,是我们随机生成的字符串,我们也是遍历逐个比较查找的,也许会存在更优的搜索策略。

并发问题

这个项目涉及到的并发问题:

  1. 节点同时在挖矿,或者同时挖出了矿(计算出了hash值)
  2. 节点同时发生许多交易
  3. 节点同时生成新钱包
  4. 某个节点被许多其他节点同时请求数据

项目中涉及到的只有在处理同步区块链信息的时候用到了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上,不足之处还请多多指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章