[譯]理解 Node.js 事件驅動機制

痕跡絕陌路發表於2019-02-16

學習 Node.js 一定要理解的內容之一,文中主要涉及到了 EventEmitter 的使用和一些非同步情況的處理,比較偏基礎,值得一讀。

閱讀原文

大多數 Node.js 物件都依賴了 EventEmitter 模組來監聽和響應事件,比如我們常用的 HTTP requests, responses, 以及 streams。

const EventEmitter = require(`events`);

事件驅動機制的最簡單形式,是在 Node.js 中十分流行的回撥函式,例如 fs.readFile。 在回撥函式這種形式中,事件每被觸發一次,回撥就會被觸發一次。

我們先來探索下這個最基本的方式。

你準備好了就叫我哈,Node!

很久很久以前,在 js 裡還沒有原生支援 Promise,async/await 還只是一個遙遠的夢想,回撥函式是處理非同步問題的最原始的方式。

回撥從本質上講是傳遞給其他函式的函式,在 JavaScript 中函式是第一類物件,這也讓回撥的存在成為可能。

一定要搞清楚的是,回撥在程式碼中的並不表示非同步呼叫。 回撥既可以是同步呼叫的,也可以是非同步呼叫的。

舉個例子,這裡有一個宿主函式 fileSize,它接受一個回撥函式 cb,並且可以通過條件判斷來同步或者非同步地呼叫該回撥函式:

function fileSize (fileName, cb) {
  if (typeof fileName !== `string`) {
    // Sync
    return cb(new TypeError(`argument should be string`)); 
  }  
  fs.stat(fileName, (err, stats) => {
    if (err) {   
      // Async
      return cb(err); 
     } 
     // Async
    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(`
`);
    cb(null, lines);
  });
};

readFileAsArray 函式接受兩個引數:一個檔案路徑和一個回撥函式。它讀取檔案內容,將其拆分成行陣列,並將該陣列作為回撥函式的引數傳入,呼叫回撥函式。

現在設計一個用例,假設我們在同一目錄中的檔案 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);
});

這段程式碼將檔案內容讀入字串陣列中,回撥函式將其解析為數字,並計算奇數的個數。

這才是最純粹的 Node 回撥風格。回撥的第一個引數要遵循錯誤優先的原則,err 可以為空,我們要將回撥作為宿主函式的最後一個引數傳遞。你應該一直用這種方式這樣設計你的函式,因為使用者可能會假設。讓宿主函式把回撥當做其最後一個引數,並讓回撥函式以一個可能為空的錯誤物件作為其第一個引數。

回撥在現代 JavaScript 中的替代品

在現代 JavaScript 中,我們有 Promise,Promise 可以用來替代非同步 API 的回撥。回撥函式需要作為宿主函式的一個引數進行傳遞(多個宿主回撥進行巢狀就形成了回撥地獄),而且錯誤和成功都只能在其中進行處理。而 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 呼叫,當發生錯誤時,它會捕捉到錯誤並讓我們訪問到這個錯誤。

在現代 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(`
`);
      resolve(lines);
      cb(null, lines);
    });
  });
};

我們使該函式返回一個 Promise 物件,該物件包裹了 fs.readFile 的非同步呼叫。Promise 物件暴露了兩個引數,一個 resolve 函式和一個 reject 函式。

當有異常丟擲時,我們可以通過向回撥函式傳遞 error 來處理錯誤,也同樣可以使用 Promise 的 reject 函式。每當我們將資料交給回撥函式處理時,我們同樣也可以用 Promise 的 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 函式 —— 就是一個普通的函式宣告之前,加了個 async 關鍵字。在 async 函式內部,我們呼叫了 readFileAsArray 函式,就像把它的返回值賦值給變數 lines 一樣,為了真的拿到 readFileAsArray 處理生成的行陣列,我們使用關鍵字 await。之後,我們繼續執行程式碼,就好像 readFileAsArray 的呼叫是同步的一樣。

要讓程式碼執行,我們可以直接呼叫 async 函式。這讓我們的程式碼變得更加簡單和易讀。為了處理異常,我們需要將非同步呼叫包裝在一個 try/catch 語句中。

有了 async/await 這個特性,我們不必使用任何特殊的API(如 .then 和 .catch )。我們只是把這種函式標記出來,然後使用純粹的 JavaScript 寫程式碼。

我們可以把 async/await 這個特性用在支援使用 Promise 處理後續邏輯的函式上。但是,它無法用在只支援回撥的非同步函式上(例如setTimeout)。

EventEmitter 模組

EventEmitter 是一個處理 Node 中各個物件之間通訊的模組。 EventEmitter 是 Node 非同步事件驅動架構的核心。 Node 的許多內建模組都繼承自 EventEmitter。

它的概念其實很簡單:emitter 物件會發出被定義過的事件,導致之前註冊的所有監聽該事件的函式被呼叫。所以,emitter 物件基本上有兩個主要特徵:

  • 觸發定義過的事件

  • 註冊或者取消註冊監聽函式

為了使用 EventEmitter,我們需要建立一個繼承自 EventEmitter 的類。

class MyEmitter extends EventEmitter {
}

我們從 EventEmitter 的子類例項化的物件,就是 emitter 物件:

const myEmitter = new MyEmitter();

在這些 emitter 物件的生命週期裡,我們可以呼叫 emit 函式來觸發我們想要的觸發的任何被命名過的事件。

myEmitter.emit(`something-happened`);

emit 函式的使用表示發生某種情況發生了,讓大家去做該做的事情。 這種情況通常是某些狀態變化引起的。

我們可以使用 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 是一個事件觸發器,它有一個方法 —— execute,該方法接受一個引數,即具體要處理的任務函式,並在其前後包裹 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 是一個非同步函式,會發生什麼呢?

// ...

withLog.execute(() => {
  setImmediate(() => {
    console.log(`*** Executing task ***`)
  });
});

輸出結果變成了這樣:

Before executing
About to execute
Done with execute
After executing
*** Executing task ***

這樣就有問題了,非同步函式的呼叫導致 “Done with execute” 和 “After 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.time 和 console.timeEnd 報告該asyncFunc 所花費的時間。它在執行之前和之後都將以正確的順序觸發相應的事件,並且還會發出 error/data 事件作為處理非同步呼叫的訊號。

我們傳遞一個非同步的 fs.readFile 函式來測試一下 withTime emitter。 我們現在可以直接通過監聽 data 事件來處理讀取到的檔案資料,而不用把這套處理邏輯寫到 fs.readFile 的回撥函式中。

執行這段程式碼,我們以預期的順序執行了一系列事件,並且得到非同步函式的執行時間,這些是十分重要的。

About to execute
execute: 4.507ms
Done with execute

請注意,我們是將回撥與事件觸發器 emitter 相結合實現的這部分功能。 如果 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);
    }
  }
}

我認為這段程式碼比之前的回撥風格的程式碼以及使用 .then/.catch 風格的程式碼更具可讀性。async/await 讓我們更加接近 JavaScript 語言本身(不必再使用 .then/.catch 這些 api)。

事件引數和錯誤

在之前的例子中,有兩個事件被髮出時還攜帶了別的引數。

error 事件被觸發時會攜帶一個 error 物件。

this.emit(`error`, err);

data 事件被觸發時會攜帶一個 data 物件。

this.emit(`data`, data);

我們可以在 emit 函式中不斷的新增引數,當然第一個引數一定是事件的名稱,除去第一個引數之外的所有引數都可以在該事件註冊的監聽器中使用。

例如,要處理 data 事件,我們註冊的監聽器函式將訪問傳遞給 emit 函式的 data 引數,而這個 data 也正是由 asyncFunc 返回的資料。

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

error 事件比較特殊。在我們基於回撥的那個示例中,如果不使用監聽器處理 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 將會觸發 error 事件,由於沒有處理 error ,Node 程式隨之崩潰:

events.js:163
      throw er; // Unhandled `error` event
      ^
Error: ENOENT: no such file or directory, open ``

第二次執行呼叫將受到此崩潰的影響,並且可能根本不會被執行。

如果我們為這個 error 事件註冊一個監聽器函式來處理 error,結果將大不相同:

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 方法,這個方法發出的訊號只會呼叫一次監聽器。所以,這個方法常與 uncaughtException 一起使用。

監聽器的順序

如果針對一個事件註冊多個監聽器函式,當事件被觸發時,這些監聽器函式將按其註冊的順序被觸發。

// first
withTime.on(`data`, (data) => {
  console.log(`Length: ${data.length}`);
});

// second
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);

上述程式碼中,Charaters 資訊將首先被輸出。

最後,你可以用 removeListener 函式來刪除某個監聽器函式。

相關文章