[譯] 理解 NodeJS 中基於事件驅動的架構

理解 NodeJS 中基於事件驅動的架構

絕大部分 Node.js 對象,好比 HTTP 請求、響應以及「流」,都使用了 eventEmitter 模塊來支持監聽和觸發事件。javascript

事件驅動最簡單的形式是常見的 Node.js 函數回調,例如:fs.readFile。事件被觸發時,Node 就會調用回調函數,因此回調函數可視爲事件處理程序。html

讓咱們來探究一下這個基礎形式。前端

Node,在你準備好的時候調用我吧!

之前沒有原生的 promise、async/await 特性支持,Node 最原始的處理異步的方式是使用回調。java

回調函數從本質上講就是做爲參數傳遞給其餘函數的函數,在 JS 中這是可能的,由於函數是一等公民。node

回調函數並不必定異步調用,這一點很是重要。在函數中,咱們能夠根據須要同步/異步調用回調函數。react

例如,在下面例子中,主函數 fileSize 接收一個回調函數 cb 爲參數,根據不一樣狀況以同步/異步方式調用 cbandroid

function fileSize (fileName, cb) {
  if (typeof fileName !== 'string') {
    return cb(new TypeError('argument should be string')); // 同步
  }

  fs.stat(fileName, (err, stats) => {
    if (err) { return cb(err); } // 異步

    cb(null, stats.size); // 異步
  });
}複製代碼

請注意,這並非一個好的實踐,它也許會帶來一些預期外的錯誤。最好將主函數設計爲始終同步或始終異步地使用回調。ios

咱們再來看看下面這種典型的回調風格處理的異步 Node 函數:git

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 以一個文件路徑和回調函數 callback 爲參,讀取文件並切割成行的數組來當作參數調用 callback。github

這裏有一個使用它的示例,假設同目錄下咱們有一個 numbers.txt 文件中有以下內容:

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 的回調風格中的寫法是這樣的:回調函數的第一個參數是一個可能爲 null 的錯誤對象 err,而回調函數做爲主函數的最後一個參數傳入。 你應該永遠這麼作,由於使用者們極有多是這麼覺得的。

現代 JavaScript 中回調函數的替代品

在 ES6+ 中,咱們有了 Promise 對象。對於異步 API,它是 callback 的有力競爭者。再也不須要將 callback 做爲參數傳遞的同時處理錯誤信息,Promise 對象容許咱們分別處理成功和失敗兩種狀況,而且鏈式的調用多個異步方法避免了回調的嵌套(callback hell,回調地獄)。

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

做爲調用 callback 的替代品,咱們用 .then 函數來接受主方法的返回值,.then 中咱們能夠和以前在回調函數中同樣處理數據,而對於錯誤咱們用.catch函數來處理。

現代 JavaScript 中的 Promise 對象,使主函數支持 Promise 接口變得更加容易。咱們把剛剛的 readFileAsArray 方法用改寫一下以支持 Promise:

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

如今這個函數返回了一個 Promise 對象,該對象包含 fs.readFile 的異步調用,Promise 對象暴露了兩個參數:resolve 函數和 reject 函數。

reject 函數的做用就和咱們以前 callback 中處理錯誤是同樣的,而 resolve 函數也就和咱們正常處理返回值同樣。

剩下惟一要作的就是在實例中指定 reject resolve 函數的默認值,在 Promise 中,咱們只要寫一個空函數便可,例如 () => {}.

在 async/await 中使用 Promise

當你須要循環異步函數時,使用 Promise 會讓你的代碼更易閱讀,而若是使用回調函數,事情只會變得混亂。

Promise 是一個小小的進步,generator 是更大一些的小進步,可是 async/await 函數的到來,讓這一步變得更有力了,它的編碼風格讓異步代碼就像同步同樣易讀。

咱們用 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 函數,只是在定義 function 的時候前面加了 async 關鍵字。在 async 函數裏,使用關鍵字 await 使 readFileAsArray 函數好像返回普通變量同樣,這以後的編碼也好像 readFileAsArray 是同步方法同樣。

async 函數的執行過程很是易讀,而處理錯誤只須要在異步調用外面包上一層 try/catch 便可。

async/await 函數中咱們咱們不須要使用任何特殊 API(像: .then.catch\),咱們僅僅使用了特殊關鍵字,並使用普通 JavaScript 編碼便可。

咱們能夠在支持 Promise 的函數中使用 async/await 函數,可是不能在回調風格的異步方法中使用它,好比 setTimeout 等等。

EventEmitter 模塊

EventEmitter 是 Node.js 中基於事件驅動的架構的核心,它用於對象之間通訊,不少 Node.js 的原生模塊都繼承自這個模塊。

模塊的概念很簡單,Emitter 對象觸發已命名事件,使以前已註冊的監聽器被調用,因此 Emitter 對象有兩個主要特徵:

  • 觸發已命名事件
  • 註冊和取消註冊監聽函數

如何使用呢?咱們只須要建立一個類來繼承 EventEmitter 便可:

class MyEmitter extends EventEmitter {

}複製代碼

實例化前面咱們基於 EventEmitter 建立的類,便可獲得 Emitter 對象:

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

在 Emitter 對象的生命週期中的任何一點,咱們均可以用 emit 方法發出任何已命名的事件:

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

觸發一個事件即某種狀況發生的信號,這些狀況一般是關於 Emitter 對象的狀態改變的。

咱們使用 on 方法來註冊,而後這些監聽的方法將會在每個 Emitter 對象 emit 它們對應名稱的事件的時候執行。

事件 != 異步

讓咱們看一個例子:

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 類是一個 event emitter。它有一個 excute 方法,接收一個 taskFunc 任務函數做爲參數,並將此函數的執行包含在 log 語句之間,分別在執行以前和以後調用了 emit 方法。

執行結果以下:

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

咱們須要注意的是全部的輸出 log 都是同步的,在代碼裏沒有任何異步操做。

  • 第一步 「Before executing」;
  • 命名爲 begin 的事件 emit 輸出了 「About to execute」;
  • 內含方法的執行輸出了「*** Executing task ***」;
  • 另外一個命名事件輸出「Done with execute」;
  • 最後「After executing」。

如同以前的回調方式,events 並不意味着同步或者異步。

這一點很重要,假如咱們給 excute 傳遞異步函數 taskFunc,事件的觸發就再也不精確了。

可使用 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」出如今了「***Executing task***」以前(應該在後)。

當異步方法結束的時候 emit 一個事件,咱們須要把 callback/promise 與事件通訊結合起來,剛剛的例子證實了這一點。

使用事件驅動來代替傳統回調函數有一個好處是:在定義多個監聽器後,咱們能夠屢次對同一個 emit 作出反應。若是要用回調來作到這一點的話,咱們須要些不少的邏輯在同一個回調函數中,事件是應用程序容許多個外部插件在應用程序核心之上構建功能的一個好方法,你能夠把它們看成鉤子點來容許利用狀態變化作更多自定義的事。

異步事件

咱們把剛剛的例子修改一下,將同步改成異步方式,讓它更有意思一點:

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.timeconsole.timeEnd 來返回執行的時間,它 emit 了正確的序列在執行以前和以後,一樣 emit error/data 來保證函數的正常工做。

咱們給 withTime emitter 傳遞一個異步函數 fs.readFile 做爲參數,這樣就再也不須要回調函數,只要監聽 data 事件就能夠了。

執行以後的結果以下,正如咱們期待的正確事件序列,咱們獲得了執行的時間,這是頗有用的:

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

請注意咱們是如何將回調函數與事件發生器結合來完成的,若是 asynFunc 一樣支持 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);
    }
  }
}複製代碼

這真的看起來更易讀了呢!async/await 特性使咱們的代碼更加貼近 JavaScript 自己,我認爲這是一大進步。

事件參數及錯誤

在以前的例子中,咱們使用了額外的參數觸發了兩個事件。

error 事件使用了 error 對象。

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

data 事件使用了 data 對象。

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

咱們能夠在命名事件以後使用任何須要的參數,這些參數將在咱們爲命名事件註冊的監聽器函數內部可用。

例如:data 事件執行的時候,監聽函數在註冊的時候就會容許咱們的接收事件觸發的 data 參數,而 asyncFunc 函數也實實在在暴露給了咱們。

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

error 事件一般是特例。在咱們基於 callback 的例子中,若是沒用監聽函數來處理錯誤,Node 進程就會直接終止-。-

咱們寫個例子來展現這一點:

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

第一個 execute 函數的調用會觸發一個錯誤,Node 進程會崩潰而後退出:

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

第二個 excute 函數調用將受到以前崩潰的影響,可能並不會執行。

若是咱們註冊一個監聽函數來處理 error 對象,狀況就不同了:

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

加上了上面的錯誤處理,第一個 excute 調用的錯誤會被報告,但 Node 進程不會再崩潰退出了,其它的調用也會正常執行:

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

記住:Node.js 目前的表現和 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 方法。這個方法僅容許調用一次監聽器,而非每次觸發都調用。因此,這是一個 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」 在 「Length」 以前。

最後,想移除的話,用 removeListener 方法就好啦!

感謝閱讀,下次再會,以上。

若是以爲本文有幫助,點擊閱讀原文能夠看到更多關於 Node 和 JavaScript 的文章。

關於本文或者我寫的其它文章有任何問題,歡迎在 slack 找我,也能夠在 #questions room 向我提問。

做者在 PluralsightLynda 上有開設線上課程,最近的課程有React.js入門Node.js進階JavaScript全棧,有興趣的能夠試聽。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃

相關文章
相關標籤/搜索