編程小白模擬簡易比特幣系統(十)


在上一篇文章 :編程小白模擬簡易比特幣系統(九)中,我們完成了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上,不足之處還請多多指正。

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