[譯]理解 Node.js 事件驅動架構

原文地址:Understanding Node.js Event-Driven Architecturejavascript

大部分 Node 模塊,例如 http 和 stream,都是基於EventEmitter模塊實現的,因此它們擁有觸發監聽事件的能力。java

const EventEmitter = require('events');
複製代碼

事件驅動的世界中,對於大部分 Node.js 函數,經過回調的形式就是最簡單的,例如fs.readFile。在這個例子中,事件會被觸發一次(當 Node 已經準備好去調用回調函數時),而且回調函數將做爲事件處理函數。node

首先讓咱們看一下基本形式。數據庫

Node,當你準備好的時候 call 我

Node 控制異步事件最初的形式是經過回調函數。那是在好久之前,那時候 Javascript 尚未支持原生的 Promise 和 async/await 特性。數組

回調函數最初只是你傳遞到其餘函數的函數。由於 Javascript 中,函數是第一類對象,因此才讓這種行爲成爲可能。promise

回調函數不表明代碼就是異步調用的,理解這一點是很是重要的。一個函數調用回調函數時,既能夠經過同步,也能夠經過異步。bash

例如,下面的fileSize函數接受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); // 異步
  });
}
複製代碼

注意:這是一個可能會致使意料以外錯誤的壞實踐。設計函數時,回調函數調用最好只經過異步,或者只經過同步。app

讓咱們看一個用回調形式編寫,典型異步 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參數包括一個路徑和一個回調函數。該宿主函數讀取文件內容,並將它們分離到 lines 數組中,並將 lines 傳入回調函數中。

下面是一個使用案例。假如在同一目錄下,咱們有一個文件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('奇數的數量爲:', oddNumbers.length);
});
複製代碼

上面的代碼會讀取數字內容並轉化爲字符串數組,將它們解析爲數字,並找出奇數。

這裏只用了 Node 的回調函數形式。回調函數第一個參數是err錯誤對象,沒有錯誤時,返回null。宿主函數中,回調函數做爲最後一個參數傳入其中。在你的函數中,你應該老是這麼作。也就是將宿主函數的最後一個參數設置爲回調函數,而且將回調函數第一個參數設置爲錯誤對象。

現代 Javascript 對於回調函數的替代方式

現代的 Javascript 中,咱們擁有 Promise 對象。Promise 成爲異步 API 中回調函數的替代方案。在 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,而不是傳入回調函數。函數.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);
    });
  });
};
複製代碼

咱們讓函數返回了一個包裹fs.readFile異步調用的 Promise 對象。這個 Promise 對象暴露了兩個參數,分別是resolve函數和reject函數。

咱們可使用Promise的reject方法處理錯誤時的調用。也能夠經過resolve函數,處理正常獲取數據的調用。

在 Promise 已經被使用的狀況下,咱們須要作的事情只有爲回調函數添加一個默認值。咱們能夠在參數中使用一個簡單,默認的空函數:() => {}

經過 async/await 使用 Promise

當須要循環一個異步函數時,添加 Promise 接口讓你的代碼運行起來更簡單。若是使用回調函數,會變得很雜亂。

Promise 讓事情變得簡單,而 Generator(生成器)讓事情變得更簡單了。也就是說,更近代的運行異步代碼的方式,是經過使用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字段。在這個異步函數中,咱們經過await關鍵字,調用readFileAsArray函數,就像這個函數直接返回了行數同樣。而後,調用readFileAsArray的代碼就像同步同樣。

咱們執行異步函數,讓它能夠運做。這很是簡單而且更具可讀性。若是想要進行錯誤處理,咱們須要把異步調用包裹在try/catch語句中。

經過 async/await 特性,咱們不須要使用一些特殊的 API(例如.then 和.catch)。咱們只須要標記函數,並使用原生的 Javascript 代碼就能夠了。

只要函數支持 Promise 接口,咱們就可使用 async/await 特性。可是,在 async 函數中,咱們不能使用回調函數形式的代碼(例如 setTimeout)。

EventEmitter 模塊

在 Node 中,EventEmitter 是一個能夠加快對象之間通訊的模塊,也是 Node 異步事件驅動架構的核心。許多 Node 內建模塊也是繼承於 EventEmitter 的。

核心概念很是簡單:Emitter 對象觸發具名事件,這會致使事先註冊了監聽器的具名事件被調用。因此,一個 Emitter 對象擁有兩個基本特性:

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

咱們只須要建立一個繼承於 EventEmitter 的類,就可讓 EventEmitter 起做用了。

class MyEmitter extends EventEmitter {
  //
}
複製代碼

Emitter 對象是基於 EventEmitter 類的實例化對象:

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

在 Emitter 對象生命週期的任什麼時候刻,咱們均可以經過使用 emit 函數去觸發咱們想要的具名事件。

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

觸發事件是某些條件發生了的標誌。這個條件一般是 Emitter 對象中狀態的變化產生的。

咱們經過使用方法on添加監聽函數。每當 Emitter 對象觸發相關聯的事件時,這些函數將會被調用。

事件 !== 異步

讓咱們看一個例子:

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類是一個事件 Emitter。它定義了實例屬性execute。這個excute函數接收一個參數,也就是一個任務函數,並把這個函數包裹在 log 語句中。它在執行先後觸發了事件。

爲了可以看到執行的前後順序,咱們註冊了兩個事件,並經過一個任務去觸發它們。

下面代碼的輸出結果:

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

關於上面這個輸出信息,我想讓你注意的就是全部代碼是同步進行的,而不是經過異步。

  • 首先執行 "Before executing" 這一行。
  • begin事件觸發執行 "About to execute" 這一行。
  • 實際執行輸出 *** Executing task ***
  • end事件觸發執行 "Done with execute" 這一行。
  • 最後咱們獲得 "After executing"

就像老式的回調函數同樣,因此千萬不要認爲事件就意味着代碼是同步的或者是異步的。

這個概念很重要,由於若是咱們傳入一個異步taskFunc來進行execute,事件觸發順序就再也不精確。

咱們能夠經過setImmediate模擬這種狀況:

// ...

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

下面是輸出結果:

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

這樣是錯誤的。若是使用了異步調用,將會在調用了Done with executeAfter executing以後,纔會執行這一行代碼,這樣將再也不精確。

爲了在異步函數調用完成以後觸發事件,咱們須要經過基於事件的通訊,綁定回調函數(或者是 Promise)。下面這個例子作了示範。

使用事件,而不使用回調的一個好處就是咱們能夠經過註冊多個監聽器,對相同信號的事件進行屢次響應。若是經過回調完成相同的事情,咱們必須在單個回調中寫更多的邏輯代碼。對於應用程序,事件系統是一個在應用頂級構建功能的極好方式,這也容許咱們擴展多個插件。你也能夠認爲是一個狀態變化後,容許咱們自定義任務的鉤子點。

異步事件

讓咱們把剛纔同步的例子轉化爲異步,這樣可讓代碼更實用一些。

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打印asyncFunc運行的時間。它觸發了事件執行先後,正確的順序。而且也使用異步調用常規的標誌,去觸發error/data事件。

咱們經過調用異步函數fs.readFile測試withTime。咱們如今能夠經過監聽 data 事件,而沒必要使用回調來處理文件數據。

當執行這些代碼時,咱們如期地獲取到了正確順序,而且獲取了代碼執行所用的事件,這很是有用:

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

那咱們該如何作才能將回調函數和事件觸發器結合起來呢?若是asyncFunc也支持 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 語言自己,這無疑是一大成功。

事件參數和錯誤

在上面的例子中,兩個事件被觸發的時候,都附帶了額外的參數。

error 事件觸發時,附帶了錯誤對象。

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

data 事件觸發時,附帶了 data 數據。

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

咱們能夠在具名事件中附帶不少參數,全部的這些參數能夠在以前註冊的監聽器函數中訪問到。

例如,data 事件可用時,咱們註冊的監聽函數就能夠獲取到事件觸發時傳遞的參數。這個 data 對象就是asyncFunc暴露的。

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

一般error事件是比較特殊的一個。在基於回調函數的例子中,若是咱們沒有設置錯誤事件的監聽器,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 ''
複製代碼

而第二個 execute 調用會由於程序崩潰受到影響,而且永遠不會執行。

若是咱們註冊了一個特殊的error事件,node 進程的行爲將會改變。例如:

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

若是咱們像上面這樣作,來自第一個 execute 調用的錯誤將會被報告給事件,從而 node 進程就不會崩潰和退出了。另一個 execute 調用將會正常執行:

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

注意如今基於 promise 的 Node 的行爲將有所不一樣,只是會輸出一個警告,可是最終將會改變。

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 先被打印。

最後,若是你須要刪除某一個監聽器,你可使用removeListener方法。

這就是本次話題的全部內容。感謝你的閱讀!期待下一次!

相關文章
相關標籤/搜索