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

薛定諤的貓發表於2017-06-07

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

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

絕大部分 Node.js 物件,比如 HTTP 請求、響應以及“流”,都使用了 eventEmitter 模組來支援監聽和觸發事件。

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

事件驅動最簡單的形式是常見的 Node.js 函式回撥,例如:fs.readFile。事件被觸發時,Node 就會呼叫回撥函式,所以回撥函式可視為事件處理程式。

讓我們來探究一下這個基礎形式。

Node,在你準備好的時候呼叫我吧!

以前沒有原生的 promise、async/await 特性支援,Node 最原始的處理非同步的方式是使用回撥。

回撥函式從本質上講就是作為引數傳遞給其他函式的函式,在 JS 中這是可能的,因為函式是一等公民。

回撥函式並不一定非同步呼叫,這一點非常重要。在函式中,我們可以根據需要同步/非同步呼叫回撥函式。

例如,在下面例子中,主函式 fileSize 接收一個回撥函式 cb 為引數,根據不同情況以同步/非同步方式呼叫 cb

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); // 非同步
  });
}複製程式碼

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

我們再來看看下面這種典型的回撥風格處理的非同步 Node 函式:

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。

這裡有一個使用它的示例,假設同目錄下我們有一個 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前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章