(四)Node.js的事件機制

2018-02-24 16:10 更新

專欄的第四篇文章《Node.js的事件機制》。之前介紹了Node.js的模塊機制,本文將深入Node.js的事件部分。

Node.js的事件機制

Node.js在其Github代碼倉庫(https://github.com/joyent/node)上有著一句短短的介紹:Evented I/O for V8 JavaScript。這句近似廣告語的句子卻道盡了Node.js自身的特色所在:基于V8引擎實現(xiàn)的事件驅(qū)動IO。在本文的這部分內(nèi)容中,我來揭開這Evented這個關(guān)鍵詞的一切奧秘吧。

Node.js能夠在眾多的后端JavaScript技術(shù)之中脫穎而出,正是因其基于事件的特點而受到歡迎。拿Rhino來做比較,可以看出Rhino引擎支持的后端JavaScript擺脫不掉其他語言同步執(zhí)行的影響,導(dǎo)致JavaScript在后端編程與前端編程之間有著十分顯著的差別,在編程模型上無法形成統(tǒng)一。在前端編程中,事件的應(yīng)用十分廣泛,DOM上的各種事件。在Ajax大規(guī)模應(yīng)用之后,異步請求更得到廣泛的認(rèn)同,而Ajax亦是基于事件機制的。在Rhino中,文件讀取等操作,均是同步操作進(jìn)行的。在這類單線程的編程模型下,如果采用同步機制,無法與PHP之類的服務(wù)端腳本語言的成熟度媲美,性能也沒有值得可圈可點的部分。直到Ryan Dahl在2009年推出Node.js后,后端JavaScript才走出其迷局。Node.js的推出,我覺得該變了兩個狀況:

  1. 統(tǒng)一了前后端JavaScript的編程模型。
  2. 利用事件機制充分利用用異步IO突破單線程編程模型的性能瓶頸,使得JavaScript在后端達(dá)到實用價值。

有了第二次瀏覽器大戰(zhàn)中的佼佼者V8的適時助力,使得Node.js在短短的兩年內(nèi)達(dá)到可觀的運行效率,并迅速被大家接受。這一點從Node.js項目在Github上的流行度和NPM上的庫的數(shù)量可見一斑。

至于Node.js為何會選擇Evented I/O for V8 JavaScript的結(jié)構(gòu)和形式來實現(xiàn),可以參見一下2011年初對作者Ryan Dahl的一次采訪:http://bostinno.com/2011/01/31/node-js-interview-4-questions-with-creator-ryan-dahl/?。

事件機制的實現(xiàn)

Node.js中大部分的模塊,都繼承自Event模塊(http://nodejs.org/docs/latest/api/events.html?)。Event模塊(events.EventEmitter)是一個簡單的事件監(jiān)聽器模式的實現(xiàn)。具有addListener/on,once,removeListener,removeAllListeners,emit等基本的事件監(jiān)聽模式的方法實現(xiàn)。它與前端DOM樹上的事件并不相同,因為它不存在冒泡,逐層捕獲等屬于DOM的事件行為,也沒有preventDefault()、stopPropagation()、 stopImmediatePropagation() 等處理事件傳遞的方法。

從另一個角度來看,事件偵聽器模式也是一種事件鉤子(hook)的機制,利用事件鉤子導(dǎo)出內(nèi)部數(shù)據(jù)或狀態(tài)給外部調(diào)用者。Node.js中的很多對象,大多具有黑盒的特點,功能點較少,如果不通過事件鉤子的形式,對象運行期間的中間值或內(nèi)部狀態(tài),是我們無法獲取到的。這種通過事件鉤子的方式,可以使編程者不用關(guān)注組件是如何啟動和執(zhí)行的,只需關(guān)注在需要的事件點上即可。

var options = {
    host: 'www.google.com',
    port: 80,
    path: '/upload',
    method: 'POST'
};
var req = http.request(options, function (res) {
    console.log('STATUS: ' + res.statusCode);
    console.log('HEADERS: ' + JSON.stringify(res.headers));
    res.setEncoding('utf8');
    res.on('data', function (chunk) {
        console.log('BODY: ' + chunk);
    });
});
req.on('error', function (e) {
    console.log('problem with request: ' + e.message);
});
// write data to request body
req.write('data\n');
req.write('data\n');
req.end();

在這段HTTP request的代碼中,程序員只需要將視線放在error,data這些業(yè)務(wù)事件點即可,至于內(nèi)部的流程如何,無需過于關(guān)注。

值得一提的是如果對一個事件添加了超過10個偵聽器,將會得到一條警告,這一處設(shè)計與Node.js自身單線程運行有關(guān),設(shè)計者認(rèn)為偵聽器太多,可能導(dǎo)致內(nèi)存泄漏,所以存在這樣一個警告。調(diào)用:

emitter.setMaxListeners(0);

可以將這個限制去掉。

其次,為了提升Node.js的程序的健壯性,EventEmitter對象對error事件進(jìn)行了特殊對待。如果運行期間的錯誤觸發(fā)了error事件。EventEmitter會檢查是否有對error事件添加過偵聽器,如果添加了,這個錯誤將會交由該偵聽器處理,否則,這個錯誤將會作為異常拋出。如果外部沒有捕獲這個異常,將會引起線程的退出。

事件機制的進(jìn)階應(yīng)用

繼承event.EventEmitter

實現(xiàn)一個繼承了EventEmitter類是十分簡單的,以下是Node.js中流對象繼承EventEmitter的例子:

function Stream() {
    events.EventEmitter.call(this);
}
util.inherits(Stream, events.EventEmitter);

Node.js在工具模塊中封裝了繼承的方法,所以此處可以很便利地調(diào)用。程序員可以通過這樣的方式輕松繼承EventEmitter對象,利用事件機制,可以幫助你解決一些問題。

多事件之間協(xié)作

在略微大一點的應(yīng)用中,數(shù)據(jù)與Web服務(wù)器之間的分離是必然的,如新浪微博、Facebook、Twitter等。這樣的優(yōu)勢在于數(shù)據(jù)源統(tǒng)一,并且可以為相同數(shù)據(jù)源制定各種豐富的客戶端程序。以Web應(yīng)用為例,在渲染一張頁面的時候,通常需要從多個數(shù)據(jù)源拉取數(shù)據(jù),并最終渲染至客戶端。Node.js在這種場景中可以很自然很方便的同時并行發(fā)起對多個數(shù)據(jù)源的請求。

api.getUser("username", function (profile) {
    // Got the profile
});
api.getTimeline("username", function (timeline) {
    // Got the timeline
});
api.getSkin("username", function (skin) {
    // Got the skin
});

Node.js通過異步機制使請求之間無阻塞,達(dá)到并行請求的目的,有效的調(diào)用下層資源。但是,這個場景中的問題是對于多個事件響應(yīng)結(jié)果的協(xié)調(diào)并非被Node.js原生優(yōu)雅地支持。為了達(dá)到三個請求都得到結(jié)果后才進(jìn)行下一個步驟,程序也許會被變成以下情況:

api.getUser("username", function (profile) {
    api.getTimeline("username", function (timeline) {
        api.getSkin("username", function (skin) {
            // TODO
        });
    });
});

這將導(dǎo)致請求變?yōu)榇羞M(jìn)行,無法最大化利用底層的API服務(wù)器。

為解決這類問題,我曾寫作一個模塊(EventProxy,https://github.com/JacksonTian/eventproxy)來實現(xiàn)多事件協(xié)作,以下為上面代碼的改進(jìn)版:

var proxy = new EventProxy();
proxy.all("profile", "timeline", "skin", function (profile, timeline, skin) {
    // TODO
});
api.getUser("username", function (profile) {
    proxy.emit("profile", profile);
});
api.getTimeline("username", function (timeline) {
    proxy.emit("timeline", timeline);
});
api.getSkin("username", function (skin) {
    proxy.emit("skin", skin);
});

EventProxy也是一個簡單的事件偵聽者模式的實現(xiàn),由于底層實現(xiàn)跟Node.js的EventEmitter不同,無法合并進(jìn)Node.js中。但是卻提供了比EventEmitter更強大的功能,且API保持與EventEmitter一致,與Node.js的思路保持契合,并可以適用在前端中。

這里的all方法是指偵聽完profile、timeline、skin三個方法后,執(zhí)行回調(diào)函數(shù),并將偵聽接收到的數(shù)據(jù)傳入。

最后還介紹一種解決多事件協(xié)作的方案:Jscex(https://github.com/JeffreyZhao/jscex?)。Jscex通過運行時編譯的思路(需要時也可在運行前編譯),將同步思維的代碼轉(zhuǎn)換為最終異步的代碼來執(zhí)行,可以在編寫代碼的時候通過同步思維來寫,可以享受到同步思維的便利寫作,異步執(zhí)行的高效性能。如果通過Jscex編寫,將會是以下形式:

var data = $await(Task.whenAll({
    profile: api.getUser("username"),
    timeline: api.getTimeline("username"),
    skin: api.getSkin("username")
}));
// 使用data.profile, data.timeline, data.skin
// TODO

此節(jié)感謝Jscex作者@老趙(http://blog.zhaojie.me/)的指正和幫助。

利用事件隊列解決雪崩問題

所謂雪崩問題,是在緩存失效的情景下,大并發(fā)高訪問量同時涌入數(shù)據(jù)庫中查詢,數(shù)據(jù)庫無法同時承受如此大的查詢請求,進(jìn)而往前影響到網(wǎng)站整體響應(yīng)緩慢。那么在Node.js中如何應(yīng)付這種情景呢。

var select = function (callback) {
        db.select("SQL", function (results) {
            callback(results);
        });
    };

以上是一句數(shù)據(jù)庫查詢的調(diào)用,如果站點剛好啟動,這時候緩存中是不存在數(shù)據(jù)的,而如果訪問量巨大,同一句SQL會被發(fā)送到數(shù)據(jù)庫中反復(fù)查詢,影響到服務(wù)的整體性能。一個改進(jìn)是添加一個狀態(tài)鎖。

var status = "ready";
var select = function (callback) {
        if (status === "ready") {
            status = "pending";
            db.select("SQL", function (results) {
                callback(results);
                status = "ready";
            });
        }
    };

但是這種情景,連續(xù)的多次調(diào)用select發(fā),只有第一次調(diào)用是生效的,后續(xù)的select是沒有數(shù)據(jù)服務(wù)的。所以這個時候引入事件隊列吧:

var proxy = new EventProxy();
var status = "ready";
var select = function (callback) {
        proxy.once("selected", callback);
        if (status === "ready") {
            status = "pending";
            db.select("SQL", function (results) {
                proxy.emit("selected", results);
                status = "ready";
            });
        }
    };

這里利用了EventProxy對象的once方法,將所有請求的回調(diào)都壓入事件隊列中,并利用其執(zhí)行一次就會將監(jiān)視器移除的特點,保證每一個回調(diào)只會被執(zhí)行一次。對于相同的SQL語句,保證在同一個查詢開始到結(jié)束的時間中永遠(yuǎn)只有一次,在這查詢期間到來的調(diào)用,只需在隊列中等待數(shù)據(jù)就緒即可,節(jié)省了重復(fù)的數(shù)據(jù)庫調(diào)用開銷。由于Node.js單線程執(zhí)行的原因,此處無需擔(dān)心狀態(tài)問題。這種方式其實也可以應(yīng)用到其他遠(yuǎn)程調(diào)用的場景中,即使外部沒有緩存策略,也能有效節(jié)省重復(fù)開銷。此處也可以用EventEmitter替代EventProxy,不過可能存在偵聽器過多,引發(fā)警告,需要調(diào)用setMaxListeners(0)移除掉警告,或者設(shè)更大的警告閥值。

參考:

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號