專欄的第四篇文章《Node.js的事件機制》。之前介紹了Node.js的模塊機制,本文將深入Node.js的事件部分。
## Node.js的事件機制
Node.js在其Github代碼倉庫([https://github.com/joyent/node](https://github.com/joyent/node))上有著一句短短的介紹:Evented I/O for V8 JavaScript。這句近似廣告語的句子卻道盡了Node.js自身的特色所在:基于V8引擎實現的事件驅動IO。在本文的這部分內容中,我來揭開這Evented這個關鍵詞的一切奧秘吧。
Node.js能夠在眾多的后端JavaScript技術之中脫穎而出,正是因其基于事件的特點而受到歡迎。拿Rhino來做比較,可以看出Rhino引擎支持的后端JavaScript擺脫不掉其他語言同步執行的影響,導致JavaScript在后端編程與前端編程之間有著十分顯著的差別,在編程模型上無法形成統一。在前端編程中,事件的應用十分廣泛,DOM上的各種事件。在Ajax大規模應用之后,異步請求更得到廣泛的認同,而Ajax亦是基于事件機制的。在Rhino中,文件讀取等操作,均是同步操作進行的。在這類單線程的編程模型下,如果采用同步機制,無法與PHP之類的服務端腳本語言的成熟度媲美,性能也沒有值得可圈可點的部分。直到Ryan Dahl在2009年推出Node.js后,后端JavaScript才走出其迷局。Node.js的推出,我覺得該變了兩個狀況:
1. 統一了前后端JavaScript的編程模型。
2. 利用事件機制充分利用用異步IO突破單線程編程模型的性能瓶頸,使得JavaScript在后端達到實用價值。
有了第二次瀏覽器大戰中的佼佼者V8的適時助力,使得Node.js在短短的兩年內達到可觀的運行效率,并迅速被大家接受。這一點從Node.js項目在Github上的流行度和NPM上的庫的數量可見一斑。
至于Node.js為何會選擇Evented I/O for V8 JavaScript的結構和形式來實現,可以參見一下2011年初對作者Ryan Dahl的一次采訪:[http://bostinno.com/2011/01/31/node-js-interview-4-questions-with-creator-ryan-dahl/](http://bostinno.com/2011/01/31/node-js-interview-4-questions-with-creator-ryan-dahl/)?。
### 事件機制的實現
Node.js中大部分的模塊,都繼承自Event模塊([http://nodejs.org/docs/latest/api/events.html](http://nodejs.org/docs/latest/api/events.html)?)。Event模塊(events.EventEmitter)是一個簡單的事件監聽器模式的實現。具有addListener/on,once,removeListener,removeAllListeners,emit等基本的事件監聽模式的方法實現。它與前端DOM樹上的事件并不相同,因為它不存在冒泡,逐層捕獲等屬于DOM的事件行為,也沒有preventDefault()、stopPropagation()、 stopImmediatePropagation() 等處理事件傳遞的方法。
從另一個角度來看,事件偵聽器模式也是一種事件鉤子(hook)的機制,利用事件鉤子導出內部數據或狀態給外部調用者。Node.js中的很多對象,大多具有黑盒的特點,功能點較少,如果不通過事件鉤子的形式,對象運行期間的中間值或內部狀態,是我們無法獲取到的。這種通過事件鉤子的方式,可以使編程者不用關注組件是如何啟動和執行的,只需關注在需要的事件點上即可。
~~~
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這些業務事件點即可,至于內部的流程如何,無需過于關注。
值得一提的是如果對一個事件添加了超過10個偵聽器,將會得到一條警告,這一處設計與Node.js自身單線程運行有關,設計者認為偵聽器太多,可能導致內存泄漏,所以存在這樣一個警告。調用:
~~~
emitter.setMaxListeners(0);
~~~
可以將這個限制去掉。
其次,為了提升Node.js的程序的健壯性,EventEmitter對象對error事件進行了特殊對待。如果運行期間的錯誤觸發了error事件。EventEmitter會檢查是否有對error事件添加過偵聽器,如果添加了,這個錯誤將會交由該偵聽器處理,否則,這個錯誤將會作為異常拋出。如果外部沒有捕獲這個異常,將會引起線程的退出。
### 事件機制的進階應用
#### 繼承event.EventEmitter
實現一個繼承了EventEmitter類是十分簡單的,以下是Node.js中流對象繼承EventEmitter的例子:
~~~
function Stream() {
events.EventEmitter.call(this);
}
util.inherits(Stream, events.EventEmitter);
~~~
Node.js在工具模塊中封裝了繼承的方法,所以此處可以很便利地調用。程序員可以通過這樣的方式輕松繼承EventEmitter對象,利用事件機制,可以幫助你解決一些問題。
#### 多事件之間協作
在略微大一點的應用中,數據與Web服務器之間的分離是必然的,如新浪微博、Facebook、Twitter等。這樣的優勢在于數據源統一,并且可以為相同數據源制定各種豐富的客戶端程序。以Web應用為例,在渲染一張頁面的時候,通常需要從多個數據源拉取數據,并最終渲染至客戶端。Node.js在這種場景中可以很自然很方便的同時并行發起對多個數據源的請求。
~~~
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通過異步機制使請求之間無阻塞,達到并行請求的目的,有效的調用下層資源。但是,這個場景中的問題是對于多個事件響應結果的協調并非被Node.js原生優雅地支持。為了達到三個請求都得到結果后才進行下一個步驟,程序也許會被變成以下情況:
~~~
api.getUser("username", function (profile) {
api.getTimeline("username", function (timeline) {
api.getSkin("username", function (skin) {
// TODO
});
});
});
~~~
這將導致請求變為串行進行,無法最大化利用底層的API服務器。
為解決這類問題,我曾寫作一個模塊(EventProxy,[https://github.com/JacksonTian/eventproxy](https://github.com/JacksonTian/eventproxy))來實現多事件協作,以下為上面代碼的改進版:
~~~
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也是一個簡單的事件偵聽者模式的實現,由于底層實現跟Node.js的EventEmitter不同,無法合并進Node.js中。但是卻提供了比EventEmitter更強大的功能,且API保持與EventEmitter一致,與Node.js的思路保持契合,并可以適用在前端中。
這里的all方法是指偵聽完profile、timeline、skin三個方法后,執行回調函數,并將偵聽接收到的數據傳入。
最后還介紹一種解決多事件協作的方案:Jscex([https://github.com/JeffreyZhao/jscex](https://github.com/JeffreyZhao/jscex)?)。Jscex通過運行時編譯的思路(需要時也可在運行前編譯),將同步思維的代碼轉換為最終異步的代碼來執行,可以在編寫代碼的時候通過同步思維來寫,可以享受到同步思維的便利寫作,異步執行的高效性能。如果通過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
~~~
此節感謝Jscex作者@老趙([http://blog.zhaojie.me/](http://blog.zhaojie.me/))的指正和幫助。
#### 利用事件隊列解決雪崩問題
所謂雪崩問題,是在緩存失效的情景下,大并發高訪問量同時涌入數據庫中查詢,數據庫無法同時承受如此大的查詢請求,進而往前影響到網站整體響應緩慢。那么在Node.js中如何應付這種情景呢。
~~~
var select = function (callback) {
db.select("SQL", function (results) {
callback(results);
});
};
~~~
以上是一句數據庫查詢的調用,如果站點剛好啟動,這時候緩存中是不存在數據的,而如果訪問量巨大,同一句SQL會被發送到數據庫中反復查詢,影響到服務的整體性能。一個改進是添加一個狀態鎖。
~~~
var status = "ready";
var select = function (callback) {
if (status === "ready") {
status = "pending";
db.select("SQL", function (results) {
callback(results);
status = "ready";
});
}
};
~~~
但是這種情景,連續的多次調用select發,只有第一次調用是生效的,后續的select是沒有數據服務的。所以這個時候引入事件隊列吧:
~~~
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方法,將所有請求的回調都壓入事件隊列中,并利用其執行一次就會將監視器移除的特點,保證每一個回調只會被執行一次。對于相同的SQL語句,保證在同一個查詢開始到結束的時間中永遠只有一次,在這查詢期間到來的調用,只需在隊列中等待數據就緒即可,節省了重復的數據庫調用開銷。由于Node.js單線程執行的原因,此處無需擔心狀態問題。這種方式其實也可以應用到其他遠程調用的場景中,即使外部沒有緩存策略,也能有效節省重復開銷。此處也可以用EventEmitter替代EventProxy,不過可能存在偵聽器過多,引發警告,需要調用setMaxListeners(0)移除掉警告,或者設更大的警告閥值。
## 參考:
* [http://nodejs.org/docs/latest/api/events.html](http://nodejs.org/docs/latest/api/events.html)
* [https://github.com/JacksonTian/eventproxy/blob/master/README.md](https://github.com/JacksonTian/eventproxy/blob/master/README.md)
* [https://github.com/JeffreyZhao/jscex/blob/master/README-cn.md](https://github.com/JeffreyZhao/jscex/blob/master/README-cn.md)