(四)Node.js的事件機(jī)制

2018-02-24 16:10 更新

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

Node.js的事件機(jī)制

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

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

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

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

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

事件機(jī)制的實(shí)現(xiàn)

Node.js中大部分的模塊,都繼承自Event模塊(http://nodejs.org/docs/latest/api/events.html?)。Event模塊(events.EventEmitter)是一個(gè)簡(jiǎn)單的事件監(jiān)聽(tīng)器模式的實(shí)現(xiàn)。具有addListener/on,once,removeListener,removeAllListeners,emit等基本的事件監(jiān)聽(tīng)模式的方法實(shí)現(xiàn)。它與前端DOM樹(shù)上的事件并不相同,因?yàn)樗淮嬖诿芭荩饘硬东@等屬于DOM的事件行為,也沒(méi)有preventDefault()、stopPropagation()、 stopImmediatePropagation() 等處理事件傳遞的方法。

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

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

emitter.setMaxListeners(0);

可以將這個(gè)限制去掉。

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

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

繼承event.EventEmitter

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

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

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

多事件之間協(xié)作

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

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

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

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

為解決這類問(wèn)題,我曾寫作一個(gè)模塊(EventProxy,https://github.com/JacksonTian/eventproxy)來(lái)實(shí)現(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也是一個(gè)簡(jiǎn)單的事件偵聽(tīng)者模式的實(shí)現(xiàn),由于底層實(shí)現(xiàn)跟Node.js的EventEmitter不同,無(wú)法合并進(jìn)Node.js中。但是卻提供了比EventEmitter更強(qiáng)大的功能,且API保持與EventEmitter一致,與Node.js的思路保持契合,并可以適用在前端中。

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

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

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/)的指正和幫助。

利用事件隊(duì)列解決雪崩問(wèn)題

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

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

以上是一句數(shù)據(jù)庫(kù)查詢的調(diào)用,如果站點(diǎn)剛好啟動(dòng),這時(shí)候緩存中是不存在數(shù)據(jù)的,而如果訪問(wèn)量巨大,同一句SQL會(huì)被發(fā)送到數(shù)據(jù)庫(kù)中反復(fù)查詢,影響到服務(wù)的整體性能。一個(gè)改進(jìn)是添加一個(gè)狀態(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是沒(méi)有數(shù)據(jù)服務(wù)的。所以這個(gè)時(shí)候引入事件隊(duì)列吧:

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

參考:

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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)