Node.js事件驅動

本文翻譯自:Understanding Node.js Event-Driven Architecturenode

event-drive.jpeg

許多Node.js模塊(諸如Http requests、responses、streams等)內置了EventEmitter模塊,所以這些模塊能夠經過emit和listen實現事件的觸發和監聽。數據庫

event-emmiter.png

事件驅動的本質是:以相似回掉函數的方式,實現流行的Node.js函數的調用(諸如 fs.readFile)。按照這種說法,當Node.js的"callback函數"準備就緒後,事件一旦被處罰,"回調函數"將做爲事件的處理程序。編程

讓咱們一塊兒探索最基本的實現形式。數組

Node當你準備好了,調用我

Node處理異步最原始的方法機會是回調函數,那是在好久之前,Node尚未內置promises和async/await的特性。promise

回調函數做爲就是傳遞給其它函數的參數。這對Javascript是可行的,由於函數是第一類對象。緩存

回調函數並不意味着異步調用,這對於理解回調函數是相當重要的。在方法體中,調用回調函數既能夠是同步也能夠是異步調用。bash

例以下面的函數fileSize,它接收回調函數做爲參數而且根據不一樣的條件以同步或是異步的方式調用該回調函數。app

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有兩個參數:文件路徑和回調函數。readFileArray讀取文件的內容,並把行內容切開成數組,最後把獲得的數組傳遞給回調函數中。異步

下面將是咱們應用回調函數的例子。假設在相同的路徑下存在一個numbers.txt文件,文件的內容以下:async

10
11
12
13
14
15
複製代碼

若是咱們要計算這個文件有多少奇數,咱們可使用下面readFileAsArray函數中的代碼:

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);
});
複製代碼

上面的代碼實現了:讀取文件中的內容,把內容轉換爲數組,計算數組中的奇數。

這段代碼是典型的Nodejs回調函數。在回調函數中遵循錯誤優先的原則,錯誤信息的參數能夠爲空,回調函數的結果做爲回調函數第二個參數。開發者都應該遵循這條原則,由於假定其餘的代碼都是按照這種原則。

現代Javascript對回調函數的改進

在現代的Javascript中,咱們有了promise對象。Promise能夠看做是對回調函數做異步調用的優化接口。再也不經過將回調函數的結果和回調函數可能出現的錯誤做爲回調函數的參數。Promise對象容許代碼分別處理函數成功返回結果和函數出現的錯誤,經過鏈條的方式處理多個異步回調函數,避免出現回調嵌套的地獄。

若是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);
複製代碼

經過調用.then函數獲取異步函數的結果,而不是將結果放進回調函數中。 .catch函數能夠獲取回調函數的異常信息。

因爲新Promise對象的出現,讓現代的Javascript代碼很方便的支持promise接口。

下面的代碼就是對回調函數進行異步調用的另外一種封裝,經過promise對象實現readFileAsArray函數的一部調用:

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函數外面包裹這Promise對象。經過Promise對象暴露出的兩個參數(一個resolve函數,另外一個reject函數)實現函數的封裝。

當咱們想引用異步函數中的錯誤信息,能夠調用reject函數。當咱們想使用異步函數返回的數據,能夠調用resolve函數。

經過async/await 調用promise對象

經過Promise對象異步回調的接口,可使代碼在須要異步回調時變的很是簡單,可是隨着回調的增多,代碼也會顯得很凌亂。

Promise對象對異步回調優化了一點,Generator函數在Promise對象的基礎上又又優化了一點。對異步函數調用最友好的方式還要數async,經過async函數咱們能夠以同步編程的方式,實現異步調用。這種編程方式的出現使得異步代碼的可讀性有了質的提高。

下面的代碼就是如何使用async/await調用readFileAsArray函數:

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();
複製代碼

咱們先是建立async函數,就是在普通函數前添加async關鍵字。在async函數內部,咱們調用readFileAsArray就像它返回行變量同樣。爲了實現這個功能,咱們使用關鍵字await。以後,咱們繼續執行代碼,就好像readFileAsArray調用的時同步函數同樣。

這樣對異步回調的處理,使的代碼變的很簡單並且易讀。爲了獲取代碼中錯誤信息,咱們須要在代碼外面包裹一層try/catch。

經過Async/await的新特性,咱們不在須要在代碼寫一些特殊的接口(像 .then 和 .catch)。咱們只不過是在一些純Javascript代碼的基礎上,使用一些函數標記。

咱們能夠對任何封裝promise對象的函數使用async/await關鍵字。然而咱們不能使用在回調式的異步函數上(如setTimeout)。

EventEmitter模塊

EventEmitter以Node.js異步事件驅動爲內核,實現促進Node.js中對象間的通訊。Node.js許多內置模塊都是繼承自EventEmitter。

EventEmitter的代碼很簡單:事件的觸發對象觸發已經註冊的監聽其。所以,事件觸發對象一般有兩個特色:

  • 觸發已經註冊的事件
  • 註冊事件或移除註冊事件監聽器

對象繼承EventEmitter,就可使用EventEmitter。

class MyEmitter extends EventEmitter {

}
複製代碼

經過實例化已經繼承EventEmitter的類生成觸發事件的對象。

const myEmitter = new MyEmitter();
複製代碼

在事件觸發對象整個生命週期中,咱們經過觸發事件名,觸發任何咱們想要做用的事件監聽器。

myEmitter.emit('something-happened');
複製代碼

觸發事件監聽器是新條件出現,這些新條件一般是事件觸發對象內部狀態改變的信號。

咱們經過on函數註冊事件監聽器,每當對象觸發事件監聽器的事件名,這些監聽事件將會執行。

事件並不就是異步

讓咱們看一個示例代碼:

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是事件觸發對象,在對象內部定義一個execute函數。這個函數接收一個參數,這個任務函數是一個打印函數。在這個任務函數執行先後都觸發事件。

爲了看清楚事件執行的前後順序,咱們註冊了相應名字的事件監聽器,最後咱們執行WithLog對象中execute函數。

下面是函數執行的結果:

Before executing
About to execute
*** Executing task ***
Done with execute
After executing
複製代碼

我想讓你們注意到的是這裏的輸出內容都是同步的。在這段代碼中沒有任何異步的行爲發生。

  • 輸出第一行是"Before executing"
  • 以begin命名的事件輸出"About execute"
  • 經過參數傳遞的函數輸出"Executing task"
  • 以begin命名的事件輸出"Done with execute"
  • 最後輸出"After executing"

就像古老的回調函數,不假設事件是同步仍是異步執行。

咱們能夠假設下面的測試用例(在withLog對象中的execute函數是setImmediate):

// ...

withLog.execute(() => {
  setImmediate(() => {
    console.log('*** Executing task ***')
  });
});
複製代碼

如今函數輸出將會是下面:

Before executing
About to execute
Done with execute
After executing
*** Executing task ***
複製代碼

在異步回調函數後,執行"Done with execute"和"After executing"將不在正確。

咱們須要回調函數(或promises對象)與事件驅動的對象相結合,實如今異步調用後執行事件觸發。上面的例子就很好的說明這一點。

事件相對於傳統回調函數還有另外一個優點,程序能夠經過定義不一樣的監聽對象,實現屢次觸發相同的函數。若是經過回調函數實現相同的功能,則須要在函數中些許多邏輯。事件是實如今程序核心基礎上,經過外部插件構建函數的好方法。你能夠把事件想象成勾子點,經過勾子點狀態的變化定製一些函數。

異步的事件

咱們將同步的示例轉換爲異步將會更有利於咱們理解,

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類執行異步函數(asyncFunc),在異步函數中經過console.time和console.timeEnd輸出時間,並在異步函數執行的先後觸發相應的事件。若是異步函數拋出異常,將會觸發error/data事件。

咱們使用fs.readFile函數做爲測試用例中的異步函數。經過事件監聽,咱們就能夠代替回掉函數實現異步調用。

代碼執行後,相應事件按順序觸發而且獲得異步函數的執行時間。下面是咱們獲得的運行結果:

About to execute
execute: 4.507ms
Done with execute
複製代碼

請注意上面的例子是咱們經過回調函數和事件相結合完成的。若是咱們讓回調函數支持promise對象,咱們就能夠經過async/await實現相同的功能。

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);
    }
  }
}
複製代碼

我不知道大家是怎麼認爲的,在我看來,這種實現方式要比基於回調函數的代碼或是.then/.catch的代碼要更加易讀。 async/await的功能使咱們儘量接近JavaScript語言自己,我認爲這是一個巨大的勝利。

事件參數和錯誤

在上面的例子中,有兩個事件觸發時還傳了額外的參數。

異常事件觸發異常對象

this.emit('error', err);
複製代碼

數據事件觸發數據對象

this.emit('data', data);
複製代碼

咱們能夠在觸發事件函數中,事件名後添加儘量多的參數,全部這些參數將會傳遞到事件監聽器上。

例以下面的數據事件:咱們註冊的事件監聽器,將會獲取咱們觸發事件時傳遞進去的參數(事件名除外)。data對象就是異步函數asyncFunc返回的數據。

withTime.on('data', (data) => {
  // do something with data
});

複製代碼

在咱們使用回掉函數實現異步調用的例子中,若是咱們不去監聽異常事件,程序將會退出。

爲了說明這一點,在原示例的基礎上,添加調用產生異常的函數。

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類第一次執行將會產生異常。程序將會崩潰並退出。

events.js:163
      throw er; // Unhandled 'error' event
      ^
Error: ENOENT: no such file or directory, open ''
複製代碼

WithTime類第二次執行將由於程序的崩潰,受到影響,從而不能被執行。

若是在代碼中註冊異常監聽器,node程序的生命週期將會發生變化。以下例:

withTime.on('error', (err) => {
  // do something with err, for example log it somewhere
  console.log(err)
});
複製代碼

若是按照上面的方法,第一次執行execute函數產生的異常將會被捕獲,node的生命週期也不會終止。這樣就不會影響代碼繼續向下執行, 在程序控制端將會輸出:

{ Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT', syscall: 'open', path: '' }
execute: 4.276ms
複製代碼

值得注意,程序如今的行爲,與以promise對象爲基礎的函數的行爲不相同。僅僅輸出一個警告,可是程序的正常運行並不會受到影響。

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.
複製代碼

經過觸發在全局定義的uncaughtException事件監聽器,是另一種捕獲異常的方式。而後,使用全局事件監聽器捕獲異常是不明智的選擇。

通常建議避免使用全局uncaughtException事件監聽器,可是若是你必需要這麼作(報告發生了異常或是清理緩存),這時必需要終止程序。像以下代碼:

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); }); 複製代碼

可是,若是在同一時間發生屢次觸發異常事件。這就意味着uncaughtException事件監聽器將會被屢次觸發,這對於清理緩存(清除內存),將會是災難。一個例子是對數據庫關閉的操做可能會被屢次觸發。

EventEmitter模塊暴露一個once方法。即便屢次經過once觸發事件監聽器,這個方法只會讓事件監聽器觸發一次。所以,這是一個使用uncaughtException事件監聽器很實用的方法,由於第一次觸發出現異常時,咱們就會作一些數據庫或內存的清理,而後退出程序。

監聽事件的次序

若是對同一事件註冊多個監聽器,那麼這些監聽器的調用將會按順序進行。

// प्रथम
withTime.on('data', (data) => {
  console.log(`Length: ${data.length}`);
});

// दूसरा
withTime.on('data', (data) => {
  console.log(`Characters: ${data.toString().length}`);
});

withTime.execute(fs.readFile, __filename);
複製代碼

上面代碼的執行後,輸出的結果是"Length"行在"Characters"行以前,由於咱們定義的事件監聽器將會按順序執行。

若是你定義新的事件監聽器,咱們要想作這個事件監聽器最早被觸發,可使用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"的結果將會被最早輸出。

最後,若是須要刪除事件監聽器,咱們可使用removeListener方法。

這就是我關於這個主題的全部闡述,很是感謝您的閱讀,期待下次與你相遇。

相關文章
相關標籤/搜索