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

原文連接:Understanding Nodejs Event-driven Architecture 前端

做者:Samer Bunanode

翻譯:野草數據庫

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

Node中的絕大多數對象,好比HTTP請求,響應,流,都是實現了EventEmitter模塊,因此它們能夠觸發或監聽事件。promise

const EventEmitter = require('events');

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

讓咱們更深刻地探究一下回調形式。架構

Node的回調

Node處理異步事件最開始使用的是回調。好久以後(也就是如今),原生JavaScript有了Promise對象和async/await特性來處理異步。app

回調函數其實就是做爲函數參數的函數,這個概念的實現得益於JavaScript語言中的函數是第一類對象。異步

但咱們必需要搞清楚,回調並不意味着異步。函數的回調能夠是同步的,也能夠是異步的。async

好比,下例中的主函數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('\n');
    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('\n');
      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方法。

【譯者注】若是你看到這裏,那麼謝謝你耐心地看完了本文。是否是有着滿滿的疑惑,不是講事件驅動架構嗎,怎麼看完一臉懵逼?很巧,我第一次看完這篇文章的時候也是這種感覺,直到如今我也沒很理解題目與文章內容的聯繫。不過,反正我看完有點收穫,關於異步,事件等等。但願你也有點收穫吧,至少也花了時間閱讀了。

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

相關文章
相關標籤/搜索