本文翻譯自:Understanding Node.js Event-Driven Architecture
許多Node.js模塊(諸如Http requests皮服、responses、streams等)內(nèi)置了EventEmitter模塊降淮,因此這些模塊可以通過emit和listen實(shí)現(xiàn)事件的觸發(fā)和監(jiān)聽丙笋。
事件驅(qū)動的本質(zhì)是:以類似回掉函數(shù)的方式,實(shí)現(xiàn)流行的Node.js函數(shù)的調(diào)用(諸如 fs.readFile)。按照這種說法奴潘,當(dāng)Node.js的"callback函數(shù)"準(zhǔn)備就緒后,事件一旦被處罰影钉,"回調(diào)函數(shù)"將作為事件的處理程序画髓。
讓我們一起探索最基本的實(shí)現(xiàn)形式。
Node當(dāng)你準(zhǔn)備好了平委,調(diào)用我
Node處理異步最原始的方法機(jī)會是回調(diào)函數(shù)奈虾,那是在很久以前,Node還沒有內(nèi)置promises和async/await的特性。
回調(diào)函數(shù)作為就是傳遞給其它函數(shù)的參數(shù)肉微。這對Javascript是可行的匾鸥,因?yàn)楹瘮?shù)是第一類對象。
回調(diào)函數(shù)并不意味著異步調(diào)用浪册,這對于理解回調(diào)函數(shù)是至關(guān)重要的扫腺。在方法體中,調(diào)用回調(diào)函數(shù)既可以是同步也可以是異步調(diào)用村象。
例如下面的函數(shù)fileSize笆环,它接收回調(diào)函數(shù)作為參數(shù)并且根據(jù)不同的條件以同步或是異步的方式調(diào)用該回調(diào)函數(shù)。
const readFileAsArray = function(file, cb) {
fs.readFile(file, function(err, data) {
if (err) {
return cb(err);
}
const lines = data.toString().trim().split('\n');
cb(null, lines);
});
};
readFileAsArray有兩個參數(shù):文件路徑和回調(diào)函數(shù)厚者。readFileArray讀取文件的內(nèi)容躁劣,并把行內(nèi)容切開成數(shù)組,最后把得到的數(shù)組傳遞給回調(diào)函數(shù)中库菲。
下面將是我們應(yīng)用回調(diào)函數(shù)的例子账忘。假設(shè)在相同的路徑下存在一個numbers.txt文件,文件的內(nèi)容如下:
10
11
12
13
14
15
如果我們要計算這個文件有多少奇數(shù)熙宇,我們可以使用下面readFileAsArray函數(shù)中的代碼:
readFileAsArray('./numbers.txt', (err, lines) => {
if (err) throw err;
const numbers = lines.map(Number);
const oddNumbers = numbers.filter(n => n%2 === 1);
console.log('Odd numbers count:', oddNumbers.length);
});
上面的代碼實(shí)現(xiàn)了:讀取文件中的內(nèi)容鳖擒,把內(nèi)容轉(zhuǎn)換為數(shù)組,計算數(shù)組中的奇數(shù)烫止。
這段代碼是典型的Nodejs回調(diào)函數(shù)蒋荚。在回調(diào)函數(shù)中遵循錯誤優(yōu)先的原則,錯誤信息的參數(shù)可以為空馆蠕,回調(diào)函數(shù)的結(jié)果作為回調(diào)函數(shù)第二個參數(shù)期升。開發(fā)者都應(yīng)該遵循這條原則,因?yàn)榧俣ㄆ渌拇a都是按照這種原則互躬。
現(xiàn)代Javascript對回調(diào)函數(shù)的改進(jìn)
在現(xiàn)代的Javascript中播赁,我們有了promise對象。Promise可以看作是對回調(diào)函數(shù)作異步調(diào)用的優(yōu)化接口吼渡。不再通過將回調(diào)函數(shù)的結(jié)果和回調(diào)函數(shù)可能出現(xiàn)的錯誤作為回調(diào)函數(shù)的參數(shù)容为。Promise對象允許代碼分別處理函數(shù)成功返回結(jié)果和函數(shù)出現(xiàn)的錯誤,通過鏈條的方式處理多個異步回調(diào)函數(shù)寺酪,避免出現(xiàn)回調(diào)嵌套的地獄舟奠。
如果readFileAsArray支持promise,我們就可以寫出下面的代碼:
readFileAsArray('./numbers.txt')
.then(lines => {
const numbers = lines.map(Number);
const oddNumbers = numbers.filter(n => n%2 === 1);
console.log('Odd numbers count:', oddNumbers.length);
})
.catch(console.error);
通過調(diào)用.then函數(shù)獲取異步函數(shù)的結(jié)果房维,而不是將結(jié)果放進(jìn)回調(diào)函數(shù)中沼瘫。 .catch函數(shù)可以獲取回調(diào)函數(shù)的異常信息。
由于新Promise對象的出現(xiàn)咙俩,讓現(xiàn)代的Javascript代碼很方便的支持promise接口耿戚。
下面的代碼就是對回調(diào)函數(shù)進(jìn)行異步調(diào)用的另一種封裝湿故,通過promise對象實(shí)現(xiàn)readFileAsArray函數(shù)的一部調(diào)用:
const readFileAsArray = function(file, cb = () => {}) {
return new Promise((resolve, reject) => {
fs.readFile(file, function(err, data) {
if (err) {
reject(err);
return cb(err);
}
const lines = data.toString().trim().split('\n');
resolve(lines);
cb(null, lines);
});
});
};
在fs.readFile函數(shù)外面包裹這Promise對象。通過Promise對象暴露出的兩個參數(shù)(一個resolve函數(shù)膜蛔,另一個reject函數(shù))實(shí)現(xiàn)函數(shù)的封裝坛猪。
當(dāng)我們想引用異步函數(shù)中的錯誤信息,可以調(diào)用reject函數(shù)皂股。當(dāng)我們想使用異步函數(shù)返回的數(shù)據(jù)墅茉,可以調(diào)用resolve函數(shù)。
通過async/await 調(diào)用promise對象
通過Promise對象異步回調(diào)的接口呜呐,可以使代碼在需要異步回調(diào)時變的非常簡單就斤,但是隨著回調(diào)的增多,代碼也會顯得很凌亂蘑辑。
Promise對象對異步回調(diào)優(yōu)化了一點(diǎn)洋机,Generator函數(shù)在Promise對象的基礎(chǔ)上又又優(yōu)化了一點(diǎn)。對異步函數(shù)調(diào)用最友好的方式還要數(shù)async洋魂,通過async函數(shù)我們可以以同步編程的方式绷旗,實(shí)現(xiàn)異步調(diào)用。這種編程方式的出現(xiàn)使得異步代碼的可讀性有了質(zhì)的提升副砍。
下面的代碼就是如何使用async/await調(diào)用readFileAsArray函數(shù):
async function countOdd () {
try {
const lines = await readFileAsArray('./numbers');
const numbers = lines.map(Number);
const oddCount = numbers.filter(n => n%2 === 1).length;
console.log('Odd numbers count:', oddCount);
} catch(err) {
console.error(err);
}
}
countOdd();
我們先是創(chuàng)建async函數(shù)衔肢,就是在普通函數(shù)前添加async關(guān)鍵字。在async函數(shù)內(nèi)部豁翎,我們調(diào)用readFileAsArray就像它返回行變量一樣角骤。為了實(shí)現(xiàn)這個功能,我們使用關(guān)鍵字await谨垃。之后,我們繼續(xù)執(zhí)行代碼硼控,就好像readFileAsArray調(diào)用的時同步函數(shù)一樣馒索。
這樣對異步回調(diào)的處理跺涤,使的代碼變的很簡單而且易讀。為了獲取代碼中錯誤信息,我們需要在代碼外面包裹一層try/catch节吮。
通過Async/await的新特性,我們不在需要在代碼寫一些特殊的接口(像 .then 和 .catch)爬早。我們只不過是在一些純Javascript代碼的基礎(chǔ)上氯夷,使用一些函數(shù)標(biāo)記。
我們可以對任何封裝promise對象的函數(shù)使用async/await關(guān)鍵字撼短。然而我們不能使用在回調(diào)式的異步函數(shù)上(如setTimeout)再膳。
EventEmitter模塊
EventEmitter以Node.js異步事件驅(qū)動為內(nèi)核,實(shí)現(xiàn)促進(jìn)Node.js中對象間的通信曲横。Node.js許多內(nèi)置模塊都是繼承自EventEmitter喂柒。
EventEmitter的代碼很簡單:事件的觸發(fā)對象觸發(fā)已經(jīng)注冊的監(jiān)聽其不瓶。因此,事件觸發(fā)對象通常有兩個特點(diǎn):
- 觸發(fā)已經(jīng)注冊的事件
- 注冊事件或移除注冊事件監(jiān)聽器
對象繼承EventEmitter灾杰,就可以使用EventEmitter蚊丐。
class MyEmitter extends EventEmitter {
}
通過實(shí)例化已經(jīng)繼承EventEmitter的類生成觸發(fā)事件的對象。
const myEmitter = new MyEmitter();
在事件觸發(fā)對象整個生命周期中艳吠,我們通過觸發(fā)事件名麦备,觸發(fā)任何我們想要作用的事件監(jiān)聽器。
myEmitter.emit('something-happened');
觸發(fā)事件監(jiān)聽器是新條件出現(xiàn)昭娩,這些新條件通常是事件觸發(fā)對象內(nèi)部狀態(tài)改變的信號凛篙。
我們通過on函數(shù)注冊事件監(jiān)聽器,每當(dāng)對象觸發(fā)事件監(jiān)聽器的事件名题禀,這些監(jiān)聽事件將會執(zhí)行鞋诗。
事件并不就是異步
讓我們看一個示例代碼:
const EventEmitter = require('events');
class WithLog extends EventEmitter {
execute(taskFunc) {
console.log('Before executing');
this.emit('begin');
taskFunc();
this.emit('end');
console.log('After executing');
}
}
const withLog = new WithLog();
withLog.on('begin', () => console.log('About to execute'));
withLog.on('end', () => console.log('Done with execute'));
withLog.execute(() => console.log('*** Executing task ***'));
WithLog是事件觸發(fā)對象,在對象內(nèi)部定義一個execute函數(shù)迈嘹。這個函數(shù)接收一個參數(shù)削彬,這個任務(wù)函數(shù)是一個打印函數(shù)。在這個任務(wù)函數(shù)執(zhí)行前后都觸發(fā)事件秀仲。
為了看清楚事件執(zhí)行的先后順序融痛,我們注冊了相應(yīng)名字的事件監(jiān)聽器,最后我們執(zhí)行WithLog對象中execute函數(shù)神僵。
下面是函數(shù)執(zhí)行的結(jié)果:
Before executing
About to execute
*** Executing task ***
Done with execute
After executing
我想讓大家注意到的是這里的輸出內(nèi)容都是同步的雁刷。在這段代碼中沒有任何異步的行為發(fā)生。
- 輸出第一行是"Before executing"
- 以begin命名的事件輸出"About execute"
- 通過參數(shù)傳遞的函數(shù)輸出"Executing task"
- 以begin命名的事件輸出"Done with execute"
- 最后輸出"After executing"
就像古老的回調(diào)函數(shù)保礼,不假設(shè)事件是同步還是異步執(zhí)行沛励。
我們可以假設(shè)下面的測試用例(在withLog對象中的execute函數(shù)是setImmediate):
// ...
withLog.execute(() => {
setImmediate(() => {
console.log('*** Executing task ***')
});
});
現(xiàn)在函數(shù)輸出將會是下面:
Before executing
About to execute
Done with execute
After executing
*** Executing task ***
在異步回調(diào)函數(shù)后,執(zhí)行"Done with execute"和"After executing"將不在正確炮障。
我們需要回調(diào)函數(shù)(或promises對象)與事件驅(qū)動的對象相結(jié)合目派,實(shí)現(xiàn)在異步調(diào)用后執(zhí)行事件觸發(fā)。上面的例子就很好的說明這一點(diǎn)胁赢。
事件相對于傳統(tǒng)回調(diào)函數(shù)還有另一個優(yōu)勢企蹭,程序可以通過定義不同的監(jiān)聽對象,實(shí)現(xiàn)多次觸發(fā)相同的函數(shù)智末。如果通過回調(diào)函數(shù)實(shí)現(xiàn)相同的功能谅摄,則需要在函數(shù)中些許多邏輯。事件是實(shí)現(xiàn)在程序核心基礎(chǔ)上系馆,通過外部插件構(gòu)建函數(shù)的好方法送漠。你可以把事件想象成勾子點(diǎn),通過勾子點(diǎn)狀態(tài)的變化定制一些函數(shù)由蘑。
異步的事件
我們將同步的示例轉(zhuǎn)換為異步將會更有利于我們理解螺男,
const fs = require('fs');
const EventEmitter = require('events');
class WithTime extends EventEmitter {
execute(asyncFunc, ...args) {
this.emit('begin');
console.time('execute');
asyncFunc(...args, (err, data) => {
if (err) {
return this.emit('error', err);
}
this.emit('data', data);
console.timeEnd('execute');
this.emit('end');
});
}
}
const withTime = new WithTime();
withTime.on('begin', () => console.log('About to execute'));
withTime.on('end', () => console.log('Done with execute'));
withTime.execute(fs.readFile, __filename);
WithTime類執(zhí)行異步函數(shù)(asyncFunc)棒厘,在異步函數(shù)中通過console.time和console.timeEnd輸出時間,并在異步函數(shù)執(zhí)行的前后觸發(fā)相應(yīng)的事件下隧。如果異步函數(shù)拋出異常奢人,將會觸發(fā)error/data事件。
我們使用fs.readFile函數(shù)作為測試用例中的異步函數(shù)淆院。通過事件監(jiān)聽何乎,我們就可以代替回掉函數(shù)實(shí)現(xiàn)異步調(diào)用。
代碼執(zhí)行后土辩,相應(yīng)事件按順序觸發(fā)并且得到異步函數(shù)的執(zhí)行時間支救。下面是我們得到的運(yùn)行結(jié)果:
About to execute
execute: 4.507ms
Done with execute
請注意上面的例子是我們通過回調(diào)函數(shù)和事件相結(jié)合完成的。如果我們讓回調(diào)函數(shù)支持promise對象拷淘,我們就可以通過async/await實(shí)現(xiàn)相同的功能各墨。
class WithTime extends EventEmitter {
async execute(asyncFunc, ...args) {
this.emit('begin');
try {
console.time('execute');
const data = await asyncFunc(...args);
this.emit('data', data);
console.timeEnd('execute');
this.emit('end');
} catch(err) {
this.emit('error', err);
}
}
}
我不知道你們是怎么認(rèn)為的,在我看來启涯,這種實(shí)現(xiàn)方式要比基于回調(diào)函數(shù)的代碼或是.then/.catch的代碼要更加易讀贬堵。
async/await的功能使我們盡可能接近JavaScript語言本身,我認(rèn)為這是一個巨大的勝利结洼。
事件參數(shù)和錯誤
在上面的例子中黎做,有兩個事件觸發(fā)時還傳了額外的參數(shù)。
異常事件觸發(fā)異常對象
this.emit('error', err);
數(shù)據(jù)事件觸發(fā)數(shù)據(jù)對象
this.emit('data', data);
我們可以在觸發(fā)事件函數(shù)中松忍,事件名后添加盡可能多的參數(shù)蒸殿,所有這些參數(shù)將會傳遞到事件監(jiān)聽器上。
例如下面的數(shù)據(jù)事件:我們注冊的事件監(jiān)聽器鸣峭,將會獲取我們觸發(fā)事件時傳遞進(jìn)去的參數(shù)(事件名除外)宏所。data對象就是異步函數(shù)asyncFunc返回的數(shù)據(jù)。
withTime.on('data', (data) => {
// do something with data
});
在我們使用回掉函數(shù)實(shí)現(xiàn)異步調(diào)用的例子中摊溶,如果我們不去監(jiān)聽異常事件爬骤,程序?qū)顺觥?/p>
為了說明這一點(diǎn),在原示例的基礎(chǔ)上更扁,添加調(diào)用產(chǎn)生異常的函數(shù)盖腕。
class WithTime extends EventEmitter {
execute(asyncFunc, ...args) {
console.time('execute');
asyncFunc(...args, (err, data) => {
if (err) {
return this.emit('error', err); // Not Handled
}
console.timeEnd('execute');
});
}
}
const withTime = new WithTime();
withTime.execute(fs.readFile, ''); // BAD CALL
withTime.execute(fs.readFile, __filename);
上面例子中赫冬,WithTime類第一次執(zhí)行將會產(chǎn)生異常浓镜。程序?qū)罎⒉⑼顺觥?/p>
events.js:163
throw er; // Unhandled 'error' event
^
Error: ENOENT: no such file or directory, open ''
WithTime類第二次執(zhí)行將因?yàn)槌绦虻谋罎ⅲ艿接绊懢⒀幔瑥亩荒鼙粓?zhí)行膛薛。
如果在代碼中注冊異常監(jiān)聽器,node程序的生命周期將會發(fā)生變化补鼻。如下例:
withTime.on('error', (err) => {
// do something with err, for example log it somewhere
console.log(err)
});
如果按照上面的方法哄啄,第一次執(zhí)行execute函數(shù)產(chǎn)生的異常將會被捕獲雅任,node的生命周期也不會終止。這樣就不會影響代碼繼續(xù)向下執(zhí)行咨跌, 在程序控制端將會輸出:
{ Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT', syscall: 'open', path: '' }
execute: 4.276ms
值得注意沪么,程序現(xiàn)在的行為,與以promise對象為基礎(chǔ)的函數(shù)的行為不相同锌半。僅僅輸出一個警告禽车,但是程序的正常運(yùn)行并不會受到影響。
UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: ENOENT: no such file or directory, open ''
DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
通過觸發(fā)在全局定義的uncaughtException事件監(jiān)聽器刊殉,是另外一種捕獲異常的方式殉摔。然后,使用全局事件監(jiān)聽器捕獲異常是不明智的選擇记焊。
一般建議避免使用全局uncaughtException事件監(jiān)聽器逸月,但是如果你必須要這么做(報告發(fā)生了異常或是清理緩存)遍膜,這時必須要終止程序碗硬。像如下代碼:
process.on('uncaughtException', (err) => {
// something went unhandled.
// Do any cleanup and exit anyway!
console.error(err); // don't do just that.
// FORCE exit the process too.
process.exit(1);
});
但是,如果在同一時間發(fā)生多次觸發(fā)異常事件捌归。這就意味著uncaughtException事件監(jiān)聽器將會被多次觸發(fā)肛响,這對于清理緩存(清除內(nèi)存),將會是災(zāi)難惜索。一個例子是對數(shù)據(jù)庫關(guān)閉的操作可能會被多次觸發(fā)特笋。
EventEmitter模塊暴露一個once方法。即使多次通過once觸發(fā)事件監(jiān)聽器巾兆,這個方法只會讓事件監(jiān)聽器觸發(fā)一次猎物。因此,這是一個使用uncaughtException事件監(jiān)聽器很實(shí)用的方法角塑,因?yàn)榈谝淮斡|發(fā)出現(xiàn)異常時蔫磨,我們就會做一些數(shù)據(jù)庫或內(nèi)存的清理,然后退出程序圃伶。
監(jiān)聽事件的次序
如果對同一事件注冊多個監(jiān)聽器堤如,那么這些監(jiān)聽器的調(diào)用將會按順序進(jìn)行。
// ?????
withTime.on('data', (data) => {
console.log(`Length: ${data.length}`);
});
// ?????
withTime.on('data', (data) => {
console.log(`Characters: ${data.toString().length}`);
});
withTime.execute(fs.readFile, __filename);
上面代碼的執(zhí)行后窒朋,輸出的結(jié)果是"Length"行在"Characters"行之前搀罢,因?yàn)槲覀兌x的事件監(jiān)聽器將會按順序執(zhí)行。
如果你定義新的事件監(jiān)聽器侥猩,我們要想做這個事件監(jiān)聽器最先被觸發(fā)榔至,可以使用prependListener方法。
// ?????
withTime.on('data', (data) => {
console.log(`Length: ${data.length}`);
});
// ?????
withTime.prependListener('data', (data) => {
console.log(`Characters: ${data.toString().length}`);
});
withTime.execute(fs.readFile, __filename);
上面的代碼將會使"Characters"的結(jié)果將會被最先輸出欺劳。
最后唧取,如果需要刪除事件監(jiān)聽器铅鲤,我們可以使用removeListener方法。
這就是我關(guān)于這個主題的所有闡述枫弟,非常感謝您的閱讀邢享,期待下次與你相遇。