【譯】理解Node事件驅動架構

野蠻的小小芬發表於2019-02-16

原文連結:Understanding Nodejs Event-driven Architecture

作者:Samer Buna

翻譯:野草

本文首發於前端早讀課【第958期】

Node中的絕大多數物件,比如HTTP請求,響應,流,都是實現了EventEmitter模組,所以它們可以觸發或監聽事件。

 const EventEmitter = require(`events`);

能體現事件驅動機制本質的最簡單形式就是函式的回撥,比如Node中常用的fs.readFile。在這個例子中,事件僅觸發一次(當Node完成檔案的讀取操作後),回撥函式也就充當了事件處理者的身份。

讓我們更深入地探究一下回撥形式。

Node的回撥

Node處理非同步事件最開始使用的是回撥。很久之後(也就是現在),原生JavaScript有了Promise物件和async/await特性來處理非同步。

回撥函式其實就是作為函式引數的函式,這個概念的實現得益於JavaScript語言中的函式是第一類物件。

但我們必須要搞清楚,回撥並不意味著非同步。函式的回撥可以是同步的,也可以是非同步的。

比如,下例中的主函式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);        // 非同步呼叫
  });
}

注意,這是不好的實踐,很容易出現意想不到的bug。設計主函式時,回撥函式的呼叫應該總是同步或者非同步的。

再看一個經典的非同步回撥例子:

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接收一個檔案路徑引數以及一個回撥函式。它讀取檔案內容,將內容拆分成陣列lines,然後呼叫回撥函式處理這個陣列。

舉個例項。假設有個numbers.txt檔案,內容如下:

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

這段程式碼讀取txt檔案中的數字成字元陣列,解析成數字,然後計算出奇數的個數。

此處的回撥函式用得恰到好處。主函式將回撥函式作為最後一個引數,而回撥函式的第一個引數是可為null的錯誤資訊引數err。這種引數傳遞方式是開發者預設的規則,你最好也遵守:將回撥作為主函式的最後一個引數,將錯誤資訊作為回撥函式的第一個引數。

Promise:回撥的取代者

如今,JavaScript有了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);

Promise用法使得我們可以直接在主函式的返回值上呼叫.then函式,而不是傳入一個回撥函式。.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);
    });
  });
};

現在這個函式返回一個包含fs.readFile非同步呼叫的Promise物件。Promise物件有兩個引數,resolve函式和reject函式。

當我們獲取了錯誤資訊需要回撥時,用reject處理資訊;反之,當我們獲取結果資料需要回撥時,用resolve來處理。

另外,回撥函式要指定一個預設值,以免直接用Promise介面呼叫,這裡我們指定為空函式()=>{}

Promise升級:結合async/await使用

當非同步遇到迴圈的時候,Promise介面會讓程式碼簡單很多。用回撥的話,程式碼容易混亂。處理非同步的最新特性是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關鍵詞的普通函式。函式內部,在readFileAsArray函式前面加上await關鍵詞,保證lines結果返回才執行下一行。

執行這個非同步函式countOdd,就能得到我們想要的結果。程式碼看起來簡單且更具可讀性。需要注意的是,我們需要用try/catch處理這個非同步呼叫,以免出錯。

有了async/await特性之後,我們不再需要像.then,.catch之類的特殊介面。我們僅僅標記一下函式,然後用純原生的程式碼寫書。

我們可以給所有支援Promise介面的函式新增async/await特性,不過,不包括非同步回撥的函式,比如setTimeout。

EventEmitter模組

EventEmitter是促進Node中物件之間交流的模組,它是Node非同步事件驅動機制的核心。Node中很多自帶的模組都繼承自事件觸發模組。

概念很簡單:觸發器觸發事件,該事件對應的監聽函式被呼叫。也就是說,觸發器有兩個特徵:

  • 觸發某個事件

  • 註冊/登出監聽函式

我們建立一個繼承EventEmitter模組的類:

class MyEmitter extends EventEmitter {

}

例項化該類,得到一個事件觸發器:


const myEmitter = new MyEmitter();

在事件觸發器的生命週期任何時候,我們都能利用emit函式觸發已有的事件。

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函式。該函式接收一個任務函式的引數,頭尾分別用列印語句列印提示資訊,並且在任務函式執行前後觸發事件。

為了弄清楚執行順序,我們註冊好事件的監聽函式,給定一個簡單的任務函式,然後執行程式碼。

執行的結果如下:

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

正如回撥一樣,不要想當然地認為事件一定是同步或者非同步的。

明白這點至關重要,如果給execute函式傳入非同步的taskFunc,事件觸發時機就不準確了。

我們可以藉助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.time console.timeEnd列印出非同步函式執行所需的時間,並且在函式執行前後觸發正確的事件。在非同步函式的回撥中,根據執行情況觸發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事件對應的是資料資訊:


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); // 未被處理
      }

      console.timeEnd(`execute`);
    });
  }
}

const withTime = new WithTime();

withTime.execute(fs.readFile, ``); // 不好的呼叫
withTime.execute(fs.readFile, __filename);

第一次呼叫會丟擲錯誤,node程式崩潰然後自動退出;

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

第二次呼叫受上一行的崩潰影響,根本就沒有機會執行。

如果我們註冊error事件的監聽函式,結果就不一樣。比如:

withTime.on(`error`, (err) => {
  // 處理錯誤資訊, 比如說列印出來
  console.log(err)
});

如有上述程式碼存在,第一次呼叫的錯誤會被報告,node程式不會像之前一樣崩潰退出。這也就意味著第二次呼叫正常進行:

{ 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.

處理error事件觸發的異常的另一種方式是註冊一個監聽全域性uncaughtException程式事件的函式,但這並不是個好主意。

一般情況下,建議避免使用uncaughtException。但如果非用不可(比如列印日誌或者清理工作之類的),必須在監聽函式中退出程式。

process.on(`uncaughtException`, (err) => { 
  // 還不夠
  console.error(err); 

  // 還需要強制推出程式
  process.exit(1);
});

問題是,如果同時有多個錯誤事件觸發,就會多次觸發uncaughtException事件註冊的監聽函式,多次清理工作可能會造成問題。比如,當異常事件觸發關閉資料庫的動作時。

EventEmitter模組暴露一個once方法,限制了事件觸發的監聽函式只能被呼叫一次。它很適用未捕獲異常的情況,因為只要第一次異常發生,我們就會開始清理,然後退出程式。

監聽函式的順序

如果給一個事件註冊了多個監聽函式,它們的呼叫是有序進行的。呼叫的順序跟註冊的順序保持一致。

// 第一個監聽函式
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方法。

【譯者注】如果你看到這裡,那麼謝謝你耐心地看完了本文。是不是有著滿滿的疑惑,不是講事件驅動架構嗎,怎麼看完一臉懵逼?很巧,我第一次看完這篇文章的時候也是這種感受,直到現在我也沒很理解題目與文章內容的聯絡。不過,反正我看完有點收穫,關於非同步,事件等等。希望你也有點收穫吧,至少也花了時間閱讀了。

野草,前端早讀課專欄作者。為社群持續輸出優秀前沿的前端技術文章翻譯,歡迎關注【野草】,也歡迎關注【前端早讀課】微信公眾號。

相關文章