《Nodejs開發加密貨幣》之八:一個精巧的p2p網絡實現

關於

《Nodejs開發加密貨幣》,是一個加密貨幣產品的詳細開發文檔,涉及到使用Nodejs開發產品的方方面面,從前端到後臺、從服務器到客戶端、從PC到移動、加密解密、區款鏈等各個環節。代碼完全開源、文章免費分享。 相關資源見 http://ebookchain.org

QQ交流羣: 185046161

前言

加密貨幣都是去中心化的應用,去中心化的基礎就是P2P網絡,其作用和地位不言而喻,無可替代。

事實上,P2P網絡不是什麼新技術。但是,使用Nodejs開發的P2P網絡,確實值得圍觀。這一篇,我們就來看看Ebookcoin的點對點網絡是如何實現的。

源碼

主要源碼地址:

peer.js: https://github.com/Ebookcoin/ebookcoin/blob/master/modules/peer.js

transport.js: https://github.com/Ebookcoin/ebookcoin/blob/master/modules/transport.js

router.js: https://github.com/Ebookcoin/ebookcoin/blob/master/helpers/router.js

類圖

peer-class.png

流程圖

peer-activity.png

解讀

基於http的web應用,抓住路由的定義、設計與實現,是快速弄清業務邏輯的簡單方法。目前,分析的是modules文件夾下的各個模塊文件,這些模塊基本都是獨立的Express微應用,在開發和設計上相互獨立,各不衝突,邏輯清晰,這爲學習分析,提供了便利。

1.路由擴展

任何應用,只要提供Web訪問能力或第三方訪問的Api,都需要提供從地址到邏輯的請求分發功能,這就是路由。Ebookcoin是基於http協議的Express應用,Express底層基於Nodejs的connect模塊,因此其路由設計簡單而靈活。

前面,在入門部分,已經講到對路由的分拆調用,這裏是其簡單實現。先看看helper/router.js吧。

// 27行
var Router = function () {
    var router = require('express').Router();

    router.use(function (req, res, next) {
        res.header("Access-Control-Allow-Origin", "*");
        res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
        next();
    });

    router.map = map;

    return router;
}
...

這段代碼定義了一個Express路由器Router,並擴展了兩個功能:

  • 允許任何客戶端調用。其實,就是設置了跨域請求,選項Access-Control-Allow-Origin設置爲*,自然任何IP和端口的節點都可以訪問和被訪問。
  • 添加了地址映射方法。該方法的主要內容如下:
// 3行
function map(root, config) {
    var router = this;
    Object.keys(config).forEach(function (params) {
        var route = params.split(" ");
        if (route.length != 2 || ["post", "get", "put"].indexOf(route[0]) == -1) {
            throw Error("wrong map config");
        }
        router[route[0]](route[1], function (req, res, next) {
            root[config[params]]({"body": route[0] == "get" ? req.query : req.body}, function (err, response) {
                if (err) {
                    res.json({"success": false, "error": err});
                } else {
                    return res.json(extend({}, {"success": true}, response));
                }
            });
        });
    });
}

該方法,接受兩個對象作爲參數:

  • root: 定義了所要開放Api的邏輯函數;
  • config: 定義了路由和root定義的函數的對應關係。

其運行的結果,就相當於:

router.get('/peers', function(req, res, next){
    root.getPeers(...);
})

這裏關鍵的小技巧是,在js代碼中,對象也是hash值,root.getPeers() 與 root‘getPeers’ 是一致的。不過後者可以用字符串變量代替,更加靈活,有點像ruby裏的元編程。這是腳本語言的優勢(簡單的字符串拼接處理)。

擴展一下,在類似sails的框架(基於express)裏,很多都是可以使用類似config.json的文件直接配置的,包括路由。參考這個函數,很容易理解和實現。

2.節點路由

很輕鬆就能在peer.js裏找到上述map方法的使用:

// 3行
Router = require('../helpers/router.js')

// 25
private.attachApi = function () {
    var router = new Router();

    router.use(function (req, res, next) {
        if (modules) return next();
        res.status(500).send({success: false, error: "Blockchain is loading"});
    });

    // 34行
    router.map(shared, {
        "get /": "getPeers",
        "get /version": "version",
        "get /get": "getPeer"
    });

    router.use(function (req, res) {
        res.status(500).send({success: false, error: "API endpoint not found"});
    });

  // 44行
    library.network.app.use('/api/peers', router);
    library.network.app.use(function (err, req, res, next) {
        if (!err) return next();
        library.logger.error(req.url, err.toString());
        res.status(500).send({success: false, error: err.toString()});
    });
};

上面代碼的34行,可以直觀想象到,會有類似/version的路由出現,44行是express應用,這裏就是將定義好的路由放在/api/peers前綴之下,可以確信peer.js文件提供了下面3個公共Api地址:

http://ip:port/api/peers/

http://ip:port/api/peers/version

http://ip:port/api/peers/get

當然,是不是可以直接這麼調用,要看具體對應的函數是否還有其他的參數要求,比如:/api/peers/get,按照restful的api設計原則,可以理解爲是獲得具體某個節點信息,那麼總該給個id之類的限定條件吧。看源碼:

// 455行
library.scheme.validate(query, {
        type: "object",
        properties: {
            ip_str: {
                type: "string",
                minLength: 1
            },
            port: {
                type: "integer",
                minimum: 0,
                maximum: 65535
            }
        },
        required: ['ip_str', 'port']
    }, function (err) {
        ...
        // 480行
        private.getByFilter({
            ...
        });
    });

這裏,在具體運行過程中,library就是app.js裏傳過來的scope,該參數包含的scheme代表了一個z_schema實例。

z_schema是一個第三方組件,具體請看參考鏈接。該組件提供了json數據格式驗證功能。上述代碼的意思是:對請求參數query進行驗證,驗證規則是:object類型,屬性ip_str要求長度不小於1的字符串,屬性port要求0~65535之間的整數,並且都不能空(必需)。

這就說明,我們應該這樣請求http://ip:port/api/peers/get?ip_str=0.0.0.0&port=1234,不然會返回錯誤信息。回頭看看getPeers方法的實現,沒有required字段,對應可以直接訪問http://ip:port/api/peers/

看480行,上面的地址,都會調用private.getByFilter(),並由它從sqlite數據庫裏查詢數據表peers。這裏涉及到  dblite第三方組件  (請看參考鏈接),對請求操作sqlite數據庫進行了簡單封裝。

3.節點保存

大多數應用,讀數據相對簡單,難在寫數據。上面的代碼,都是get請求,可以查尋節點及其信息。我們自然會問,查詢的信息從哪裏來?初始的節點在哪裏?節點變更了,怎麼辦?

(1)初始化節點

從現實角度考慮,在一個P2P網絡中,一個孤立的節點,在沒有其他任何節點信息的情況下,僅僅靠網絡掃描去尋找其他節點,將是一件很難完成的事情,更別提高效和安全了。

因此,在運行軟件之前,初始化一些節點供聯網使用,是最簡單直接的解決方案。這個在配置文件config.json裏,有直接體現:

// config.json 15"peers": {
        "list": [],
        "blackList": [],
        "options": {
                "timeout": 4000
        }
},
...

list的數據格式爲:

[
    {
        ip: 0.0.0.0,
        port: 7000
    },
    ...
]

當然,也可以在啓動的時候,通過參數--peers 1.2.3.4:70001, 2.1.2.3:7002提供(代碼見app.js47行)。

(2)寫入節點

寫入節點,就是持久化,或者保存到數據庫,或者保存到某個文件。這裏保存到sqlite3數據庫裏的peers表了,代碼如下:

// peer.js 347行
Peer.prototype.onBlockchainReady = function () {
    async.eachSeries(library.config.peers.list, function (peer, cb) {
        library.dbLite.query("INSERT OR IGNORE INTO peers(ip, port, state, sharePort) VALUES($ip, $port, $state, $sharePort)", {
            ip: ip.toLong(peer.ip),
            port: peer.port,
            state: 2, //初始狀態爲2,都是健康的節點
            sharePort: Number(true)
        }, cb);
    }, function (err) {
        if (err) {
            library.logger.error('onBlockchainReady', err);
        }

        private.count(function (err, count) {
            if (count) {
                private.updatePeerList(function (err) {
                    err && library.logger.error('updatePeerList', err);
                    library.bus.message('peerReady');
                })
                library.logger.info('Peers ready, stored ' + count);
            } else {
                library.logger.warn('Peers list is empty');
            }
        });
    });
}

這段代碼的意思是,當區塊鏈(後面篇章分析)加載完畢的時候(觸發事件),依次將配置的節點寫入數據庫,如果數據庫已經存在相同的記錄就忽略,然後更新節點列表,觸發節點加載完畢事件。

這裏對數據庫Sqlite的插入操作,插入語句是library.dbLite.query("INSERT OR IGNORE INTO peers,有意思的是IGNORE操作字符串,是sqlite3支持的(見參考),當數據庫有相同記錄的時候,該記錄被忽略,繼續往下執行。

執行成功,就會調用library.bus.message('peerReady'),進而觸發peerReady事件。該事件的功能就是:

(3)更新節點

事件onPeerReady函數,如下:

// peer.js 374行
Peer.prototype.onPeerReady = function () {
    setImmediate(function nextUpdatePeerList() {
        private.updatePeerList(function (err) {
            err && library.logger.error('updatePeerList timer', err);
            setTimeout(nextUpdatePeerList, 60 * 1000);
        })
    });

    setImmediate(function nextBanManager() {
        private.banManager(function (err) {
            err && library.logger.error('banManager timer', err);
            setTimeout(nextBanManager, 65 * 1000)
        });
    });
}

兩個setImmediate函數的調用,一個循環更新節點列表,一個循環更新節點狀態。

第一個循環調用

看看第一個循環調用的函數updatePeerList

private.updatePeerList = function (cb) {
    // 53行
    modules.transport.getFromRandomPeer({
        api: '/list',
        method: 'GET'
    }, function (err, data) {
        ...
        library.scheme.validate(data.body, {
                    ...
                    // 124行
                    self.update(peer, cb);
                });
            }, cb);
        });
    });
};

看53行,我們知道,程序通過transport模塊的.getFromRandomPeer方法,逐個隨機的驗證節點信息,並將其做刪除和更新處理。如此一來,各種調用關係更加清晰,看流程圖更加直觀。.getFromRandomPeer的代碼:

// transport.js 474行
Transport.prototype.getFromRandomPeer = function (config, options, cb) {
    ...

    // 481行
    async.retry(20, function (cb) {
        modules.peer.list(config, function (err, peers) {
            if (!err && peers.length) {
                var peer = peers[0];

                // 485行
                self.getFromPeer(peer, options, cb);
            } else {
                return cb(err || "No peers in db");
            }
        });
   ...
};

代碼很簡單,重要的是理解async.retry的用法(下篇技術分享,詳細學習),該方法就是要重複調用第一個task函數20次,有正確返回結果就傳給回調函數。這裏,只要查到一個節點,就會傳給485行的getFromPeer函數,該函數是檢驗處理現存節點的核心函數,代碼如下:

// transport.js 500行
Transport.prototype.getFromPeer = function (peer, options, cb) {
    ...
    var req = {
        // 519行: 獲得節點地址
        url: 'http://' + ip.fromLong(peer.ip) + ':' + peer.port + url,
        ...
    };

    // 532行: 使用`request`組件發送請求
    return request(req, function (err, response, body) {
        if (err || response.statusCode != 200) {
            ...
            if (peer) {
                if (err && (err.code == "ETIMEDOUT" || err.code == "ESOCKETTIMEDOUT" || err.code == "ECONNREFUSED")) {

                    // 542行: 對於無法請求的,自然要刪除
                    modules.peer.remove(peer.ip, peer.port, function (err) {
                    ...
                    });
                } else {
                    if (!options.not_ban) {

                        // 549行: 對於狀態碼不是200的,比如304等禁止狀態,就要更改其狀態
                        modules.peer.state(peer.ip, peer.port, 0, 600, function (err) {
                        ...
                        });
                    }
                }
            }
            cb && cb(err || ('request status code' + response.statusCode));
            return;
        }

        ...
        if (port > 0 && port <= 65535 && response.headers['version'] == library.config.version) {
            // 595行: 一切問題都不存在
            modules.peer.update({
                ip: peer.ip,
                port: port,
                state: 2, // 598行: 看來健康的節點狀態爲2
                ...
    });
}

這裏最重要的是532行,request第三方組件的使用,請看參考鏈接。官方定義爲簡單的http客戶端,功能足夠強大,可以模擬瀏覽器訪問信息,經常被用來做測試。

第二個循環調用

第二個循環調用的函數很簡單,就是循環更改stateclock字段,主要是將禁止的狀態state=0,修改爲1,如下:

// 142行
private.banManager = function (cb) {
    library.dbLite.query("UPDATE peers SET state = 1, clock = null where (state = 0 and clock - $now < 0)", {now: Date.now()}, cb);
}

綜上,整個P2P網絡的讀寫和更新都已經清楚,回頭再看活動圖和類圖,就更加明朗了。

最後,補充一下數據庫裏,節點表格peers的字段信息: 

id,ip,port,state,os,sharePort,version,clock

總結

本篇,重點閱讀了peer.js文件,學習了一個使用Nodejs開發的P2P網絡架構,其特點是:

  • 產品提供初始節點列表,保障了初始化節點快速完成,不至於成爲孤立節點;
  • 節點具備跨域訪問能力,任何節點之間都可以自由訪問;
  • 節點具備自我更新能力,定期查詢和更新死掉的節點,保障網絡始終暢通;

一旦達到一定的節點數量,就會形成一個互聯互通的不死網絡。搭建在這種網絡上的服務,會充滿怎樣的誘惑?加密貨幣爲什麼會被認爲是下一代互聯網?這加起來不足千行的代碼,可以給我們足夠多的遐想空間。

這部分代碼,涉及到dblite,request,z_schema等第三方組件,以及Ebookcoin自行實現的事件處理方法library.bus(在app.js文件的行),都很簡單,不再分享或贅述,請自行查閱。本篇涉及的代碼中,關於回調的設計很多,值得總結和研究。async組件,被反覆使用,有必須彙總一下,請關注後續的技術分享。

鏈接

本系列文章即時更新,若要掌握最新內容,請關注下面的鏈接

本源文地址: https://github.com/imfly/bitcoin-on-nodejs

電子書閱讀: http://bitcoin-on-nodejs.ebookchain.org

參考

z_schema組件: https://github.com/Ebookcoin/z_schema

dblite組件: https://github.com/Ebookcoin/dblite

request組件: http://github.com/request/request

SQL As Understood By SQLite: https://www.sqlite.org/lang_conflict.html

發佈了34 篇原創文章 · 獲贊 5 · 訪問量 18萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章