《Node.js設計模式》基於ES2015+的回撥控制流

counterxing發表於2019-02-16

本系列文章為《Node.js Design Patterns Second Edition》的原文翻譯和讀書筆記,在GitHub連載更新,同步翻譯版連結

歡迎關注我的專欄,之後的博文將在專欄同步:

Asynchronous Control Flow Patterns with ES2015 and Beyond

在上一章中,我們學習瞭如何使用回撥處理非同步程式碼,以及如何解決如回撥地獄程式碼等非同步問題。回撥是JavaScriptNode.js中的非同步程式設計的基礎,但是現在,其他替代方案已經出現。這些替代方案更復雜,以便能夠以更方便的方式處理非同步程式碼。

在本章中,我們將探討一些代表性的替代方案,PromiseGenerator。以及async await,這是一種創新的語法,可在高版本的JavaScript中提供,其也作為ECMAScript 2017發行版的一部分。

我們將看到這些替代方案如何簡化處理非同步控制流的方式。最後,我們將比較所有這些方法,以瞭解所有這些方法的所有優點和缺點,並能夠明智地選擇最適合我們下一個Node.js專案要求的方法。

Promise

我們在前面的章節中提到,CPS風格不是編寫非同步程式碼的唯一方法。事實上,JavaScript生態系統為傳統的回撥模式提供了有趣的替代方案。最著名的選擇之一是Promise,特別是現在它是ECMAScript 2015的一部分,並且現在可以在Node.js中可用。

什麼是Promise?

Promise是一種抽象的物件,我們通常允許函式返回一個名為Promise的物件,它表示非同步操作的最終結果。通常情況下,我們說當非同步操作尚未完成時,我們說Promise物件處於pending狀態,當操作成功完成時,我們說Promise物件處於resolve狀態,當操作錯誤終止時,我們說Promise物件處於reject狀態。一旦Promise處於resolvereject,我們認為當前非同步操作結束。

為了接收到非同步操作的正確結果或錯誤捕獲,我們可以使用Promisethen方法:

promise.then([onFulfilled], [onRejected])複製程式碼

在前面的程式碼中,onFulfilled()是一個函式,最終會收到Promise的正確結果,而onRejected()是另一個函式,它將接收產生異常的原因(如果有的話)。兩個引數都是可選的。

要了解Promise如何轉換我們的程式碼,讓我們考慮以下幾點:

asyncOperation(arg, (err, result) => {
  if (err) {
    // 錯誤處理
  }
  // 正常結果處理
});複製程式碼

Promise允許我們將這個典型的CPS程式碼轉換成更好的結構化和更優雅的程式碼,如下所示:

asyncOperation(arg)
  .then(result => {
    // 錯誤處理
  }, err => {
    // 正常結果處理
  });複製程式碼

then()方法的一個關鍵特徵是它同步地返回另一個Promise物件。如果onFulfilled()onRejected()函式中的任何一個函式返回x,則then()方法返回的Promise物件將如下所示:

  • 如果x是一個值,則這個Promise物件會正確處理(resolve)x
  • 如果x是一個Promise物件或thenable,則會正確處理(resolve)x
  • 如果x是一個異常,則會捕獲異常(reject)x

注:thenable是一個具有then方法的類似於Promise的物件(Promise-like)。

這個特點使我們能夠鏈式構建Promise,允許輕鬆排列組合我們的非同步操作。另外,如果我們沒有指定一個onFulfilled()onRejected()處理程式,則正確結果或異常捕獲將自動轉發到Promise鏈的下一個Promise。例如,這允許我們在整個鏈中自動傳播錯誤,直到被onRejected()處理程式捕獲。隨著Promise鏈,任務的順序執行突然變成簡單多了:

asyncOperation(arg)
  .then(result1 => {
    // 返回另一個Promise
    return asyncOperation(arg2);
  })
  .then(result2 => {
    // 返回一個值
    return 'done';
  })
  .then(undefined, err => {
    // 捕獲Promise鏈中的異常
  });複製程式碼

下圖展示了鏈式Promise如何工作:

Promise的另一個重要特性是onFulfilled()onRejected()函式是非同步呼叫的,如同上述的例子,在最後那個then函式resolve一個同步的Promise,它也是同步的。這種模式避免了Zalgo(參見Chapter2-Node.js Essential Patterns),使我們的非同步程式碼更加一致和穩健。

如果在onFulfilled()onRejected()處理程式中丟擲異常(使用throw語句),則then()方法返回的Promise將被自動地reject,丟擲異常作為reject的原因。這相對於CPS來說是一個巨大的優勢,因為它意味著有了Promise,異常將在整個鏈中自動傳播,並且throw語句終於可以使用。

在以前,許多不同的庫實現了Promise,大多數時候它們之間不相容,這意味著不可能在使用不同Promise庫的thenable鏈式傳播錯誤。

JavaScript社群非常努力地解決了這個限制,這些努力導致了Promises / A +規範的建立。該規範詳細描述了then方法的行為,提供了一個可互相容的基礎,這使得來自不同庫的Promise物件能夠彼此相容,開箱即用。

有關Promises / A +規範的詳細說明,可以參考Promises / A + 官方網站

Promise / A + 的實施

JavaScript中以及Node.js中,有幾個實現Promises / A +規範的庫。以下是最受歡迎的:

真正區別他們的是在Promises / A +標準之上提供的額外功能。正如我們上述所說的那樣,該標準定義了then()方法和Promise解析過程的行為,但它沒有指定其他功能,例如,如何從基於回撥的非同步函式建立Promise

在我們的示例中,我們將使用由ES2015Promise,因為Promise物件自Node.js 4後即可使用,而不需要任何庫來實現。

作為參考,以下是ES2015Promise提供的API:

constructor(new Promise(function(resolve, reject){})):建立了一個新的Promise,它基於作為傳遞兩個型別為函式的引數來決定resolvereject。建構函式的引數解釋如下:

  • resolve(obj)resolve一個Promise,並帶上一個引數obj,如果obj是一個值,這個值就是傳遞的非同步操作成功的結果。如果obj是一個Promise或一個thenable,則會進行正確處理。
  • reject(err)reject一個Promise,並帶上一個引數err。它是Error物件的一個例項。

Promise物件的靜態方法

  • Promise.resolve(obj): 將會建立一個resolvePromise例項
  • Promise.reject(err): 將會建立一個rejectPromise例項
  • Promise.all(iterable):返回一個新的Promise例項,並且在iterable中所
    Promise狀態為reject時, 返回的Promise例項的狀態會被置為reject,如果iterable中至少有一個Promise狀態為reject時, 返回的Promise例項狀態也會被置為reject,並且reject的原因是第一個被rejectPromise物件的reject原因。
  • Promise.race(iterable):返回一個Promise例項,當iterable中任何一個Promiseresolve或被reject時, 返回的Promise例項以同樣的原因resolvereject

Promise例項方法

  • Promise.then(onFulfilled, onRejected):這是Promise的基本方法。它的行為與我們之前描述的Promises / A +標準相容。
  • Promise.catch(onRejected):這只是Promise.then(undefined,onRejected)的語法糖。

值得一提的是,一些Promise實現提供了另一種機制來建立新的Promise,稱為deferreds。我們不會在這裡描述,因為它不是ES2015標準的一部分,但是如果您想了解更多資訊,可以閱讀Q文件 (github.com/kriskowal/q…) 或When.js文件 (github.com/cujojs/when…) 。

Promisifying一個Node.js回撥風格的函式

JavaScript中,並不是所有的非同步函式和庫都支援開箱即用的Promise。大多數情況下,我們必須將一個典型的基於回撥的函式轉換成一個返回Promise的函式,這個過程也被稱為promisification

幸運的是,Node.js中使用的回撥約定允許我們建立一個可重用的函式,我們通過使用Promise物件的建構函式來簡化任何Node.js風格的API。讓我們建立一個名為promisify()的新函式,並將其包含到utilities.js模組中(以便稍後在我們的Web爬蟲應用程式中使用它):

module.exports.promisify = function(callbackBasedApi) {
  return function promisified() {
    const args = [].slice.call(arguments);
    return new Promise((resolve, reject) => {
      args.push((err, result) => {
        if (err) {
          return reject(err);
        }
        if (arguments.length <= 2) {
          resolve(result);
        } else {
          resolve([].slice.call(arguments, 1));
        }
      });
      callbackBasedApi.apply(null, args);
    });
  }
};複製程式碼

前面的函式返回另一個名為promisified()的函式,它表示輸入中給出的callbackBasedApipromisified版本。以下展示它是如何工作的:

  1. promisified()函式使用Promise建構函式建立一個新的Promise物件,並立即將其返回給呼叫者。
  2. 在傳遞給Promise建構函式的函式中,我們確保傳遞給callbackBasedApi,這是一個特殊的回撥函式。由於我們知道回撥總是最後呼叫的,我們只需將回撥函式附加到提供給promisified()函式的引數列表裡(args)。
  3. 在特殊的回撥中,如果我們收到錯誤,我們立即reject這個Promise
  4. 如果沒有收到錯誤,我們使用一個值或一個陣列值來resolve這個Promise,具體取決於傳遞給回撥的結果數量。
  5. 最後,我們只需使用我們構建的引數列表呼叫callbackBasedApi

大部分的Promise已經提供了一個開箱即用的介面來將一個Node.js風格的API轉換成一個返回Promise的API。例如,Q有Q.denodeify()和Q.nbind(),Bluebird有Promise.promisify(),而When.js有node.lift()。

順序執行

在一些必要的理論之後,我們現在準備將我們的Web爬蟲應用程式轉換為使用Promise的形式。讓我們直接從版本2開始,直接下載一個Web網頁的連結。

spider.js模組中,第一步是載入我們的Promise實現(我們稍後會使用它)和Promisifying我們打算使用的基於回撥的函式:

const utilities = require('./utilities');
const request = utilities.promisify(require('request'));
const mkdirp = utilities.promisify(require('mkdirp'));
const fs = require('fs');
const readFile = utilities.promisify(fs.readFile);
const writeFile = utilities.promisify(fs.writeFile);複製程式碼

現在,我們開始更改我們的download函式:

function download(url, filename) {
  console.log(`Downloading ${url}`);
  let body;
  return request(url)
    .then(response => {
      body = response.body;
      return mkdirp(path.dirname(filename));
    })
    .then(() => writeFile(filename, body))
    .then(() => {
      console.log(`Downloaded and saved: ${url}`);
      return body;
    });
}複製程式碼

這裡要注意的到的最重要的是我們也為readFile()返回的Promise註冊
一個onRejected()函式,用來處理一個網頁沒有被下載的情況(或檔案不存在)。 還有,看我們如何使用throw來傳遞onRejected()函式中的錯誤的。

既然我們已經更改我們的spider()函式,我們這麼修改它的呼叫方式:

spider(process.argv[2], 1)
  .then(() => console.log('Download complete'))
  .catch(err => console.log(err));複製程式碼

注意我們是如何第一次使用Promise的語法糖catch來處理源自spider()函式的任何錯誤情況。如果我們再看看迄今為止我們所寫的所有程式碼,那麼我們會驚喜的發現,我們沒有包含任何錯誤傳播邏輯,因為我們在使用回撥函式時會被迫做這樣的事情。這顯然是一個巨大的優勢,因為它極大地減少了我們程式碼中的樣板檔案以及丟失任何非同步錯誤的機會。

現在,完成我們唯一缺失的Web爬蟲應用程式的第二版的spiderLinks()函式,我們將在稍後實現它。

順序迭代

到目前為止,Web爬蟲應用程式程式碼庫主要是對Promise是什麼以及如何使用的概述,展示了使用Promise實現順序執行流程的簡單性和優雅性。但是,我們現在考慮的程式碼只涉及到一組已知的非同步操作的執行。所以,完成我們對順序執行流程的探索的缺失部分是看我們如何使用Promise來實現迭代。同樣,網路蜘蛛第二版的spiderLinks()函式也是一個很好的例子。

讓我們新增缺少的這一塊:

function spiderLinks(currentUrl, body, nesting) {
  let promise = Promise.resolve();
  if (nesting === 0) {
    return promise;
  }
  const links = utilities.getPageLinks(currentUrl, body);
  links.forEach(link => {
    promise = promise.then(() => spider(link, nesting - 1));
  });
  return promise;
}複製程式碼

為了非同步迭代一個網頁的全部連結,我們必須動態建立一個Promise的迭代鏈。

  1. 首先,我們定義一個空的Promiseresolveundefined。這個Promise只是用來作為Promise的迭代鏈的起始點。
  2. 然後,我們通過在迴圈中呼叫鏈中前一個Promisethen()方法獲得的新的Promise來更新Promise變數。這就是我們使用Promise的非同步迭代模式。

這樣,迴圈的結束,promise變數會包含迴圈中最後一個then()返回的Promise物件,所以它只有當Promise的迭代鏈中全部Promise物件被resolve後才能被resolve

注:在最後呼叫了這個then方法來resolve這個Promise物件

通過這個,我們已使用Promise物件重寫了我們的Web爬蟲應用程式。我們現在應該可以執行它了。

順序迭代模式

為了總結這個順序執行的部分,讓我們提取一個模式來依次遍歷一組Promise

let tasks = [ /* ... */ ]
let promise = Promise.resolve();
tasks.forEach(task => {
  promise = promise.then(() => {
    return task();
  });
});
promise.then(() => {
  // 所有任務都完成
});複製程式碼

使用reduce()方法來替代forEach()方法,允許我們寫出更為簡潔的程式碼:

let tasks = [ /* ... */ ]
let promise = tasks.reduce((prev, task) => {
  return prev.then(() => {
    return task();
  });
}, Promise.resolve());

promise.then(() => {
  //All tasks completed
});複製程式碼

與往常一樣,通過對這種模式的簡單調整,我們可以將所有任務的結果收集到一個陣列中,我們可以實現一個mapping演算法,或者構建一個filter等等。

上述這個模式使用迴圈動態地建立一個鏈式的Promise。

並行執行

另一個適合用Promise的執行流程是並行執行流程。實際上,我們需要做的就是使用內建的Promise.all()。這個方法創造了另一個Promise物件,只有在輸入中的所有Promiseresolve時才能resolve。這是一個並行執行,因為在其引數Promise物件的之間沒有執行順序可言。

為了演示這一點,我們來看我們的Web爬蟲應用程式的第三版,它將頁面中的所有連結並行下載。讓我們再次使用Promise更新spiderLinks()函式來實現並行流程:

function spiderLinks(currentUrl, body, nesting) {
  if (nesting === 0) {
    return Promise.resolve();
  }
  const links = utilities.getPageLinks(currentUrl, body);
  const promises = links.map(link => spider(link, nesting - 1));
  return Promise.all(promises);
}複製程式碼

這裡的模式在elements.map()迭代中產生一個陣列,存放所有非同步任務,之後便於同時啟動spider()任務。這一次,在迴圈中,我們不等待以前的下載完成,然後開始一個新的下載任務:所有的下載任務在一個迴圈中一個接一個地開始。之後,我們利用Promise.all()方法,它返回一個新的Promise物件,當陣列中的所有Promise物件都被resolve時,這個Promise物件將被resolve。換句話說,所有的下載任務完成,這正是我們想要的。

限制並行執行

不幸的是,ES2015Promise API並沒有提供一種原生的方式來限制併發任務的數量,但是我們總是可以依靠我們所學到的有關用普通JavaScript來限制併發。事實上,我們在TaskQueue類中實現的模式可以很容易地被調整來支援返回承諾的任務。這很容易通過修改next()方法來完成:

class TaskQueue {
  constructor(concurrency) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
  }

  pushTask(task) {
    this.queue.push(task);
    this.next();
  }

  next() {
    while (this.running < this.concurrency && this.queue.length) {
      const task = this.queue.shift();
      task().then(() => {
        this.running--;
        this.next();
      });
      this.running++;
    }
  }
}複製程式碼

不同於使用一個回撥函式來處理任務,我們簡單地呼叫Promisethen()

讓我們回到spider.js模組,並修改它以支援我們的新版本的TaskQueue類。首先,我們確保定義一個TaskQueue的新例項:

const TaskQueue = require('./taskQueue');
const downloadQueue = new TaskQueue(2);複製程式碼

然後,是我們的spiderLinks()函式。這裡的修改也是很簡單:

function spiderLinks(currentUrl, body, nesting) {
  if (nesting === 0) {
    return Promise.resolve();
  }
  const links = utilities.getPageLinks(currentUrl, body);
  // 我們需要如下程式碼,用於建立Promise物件
  // 如果沒有下列程式碼,當任務數量為0時,將永遠不會resolve
  if (links.length === 0) {
    return Promise.resolve();
  }
  return new Promise((resolve, reject) => {
    let completed = 0;
    let errored = false;
    links.forEach(link => {
      let task = () => {
        return spider(link, nesting - 1)
          .then(() => {
            if (++completed === links.length) {
              resolve();
            }
          })
          .catch(() => {
            if (!errored) {
              errored = true;
              reject();
            }
          });
      };
      downloadQueue.pushTask(task);
    });
  });
}複製程式碼

在上述程式碼中有幾點值得我們注意的:

  • 首先,我們需要返回使用Promise建構函式建立的新的Promise物件。正如我們將看到的,這使我們能夠在佇列中的所有任務完成時手動resolve我們的Promise物件。
  • 然後,我們應該看看我們如何定義任務。我們所做的是將一個onFulfilled()回撥函式的呼叫新增到由spider()返回的Promise物件中,所以我們可以計算完成的下載任務的數量。當完成的下載量與當前頁面中連結的數量相同時,我們知道任務已經處理完畢,所以我們可以呼叫外部Promiseresolve()函式。

Promises / A +規範規定,then()方法的onFulfilled()和onRejected()回撥函式只能呼叫一次(僅呼叫onFulfilled()和onRejected())。Promise介面的實現確保即使我們多次手動呼叫resolve或reject,Promise也僅可以被resolve或reject一次。

現在,使用PromiseWeb爬蟲應用程式的第4版應該已經準備好了。我們可能再次注意到下載任務如何並行執行,併發數量限制為2。

在公有API中暴露回撥函式和Promise

正如我們在前面所學到的,Promise可以被用作回撥函式的一個很好的替代品。它們使我們的程式碼更具可讀性和易於理解。雖然Promise帶來了許多優點,但也要求開發人員理解許多不易於理解的概念,以便正確和熟練地使用。由於這個原因和其他原因,在某些情況下,比起Promise來說,很多開發者更偏向於回撥函式。

現在讓我們想象一下,我們想要構建一個執行非同步操作的公共庫。我們需要做什麼?我們是建立了一個基於回撥函式的API還是一個面向PromiseAPI?還是兩者均有?

這是許多知名的庫所面臨的問題,至少有兩種方法值得一提,使我們能夠提供一個多功能的API

requestredismysql這樣的庫所使用的第一種方法是提供一個簡單的基於回撥函式的API,如果需要,開發人員可以選擇公開函式。其中一些庫提供工具函式來Promise化非同步回撥,但開發人員仍然需要以某種方式將暴露的API轉換為能夠使用Promise物件。

第二種方法更透明。它還提供了一個面向回撥的API,但它使回撥引數可選。每當回撥作為引數傳遞時,函式將正常執行,在完成時或失敗時執行回撥。當回撥未被傳遞時,函式將立即返回一個Promise物件。這種方法有效地結合了回撥函式和Promise,使得開發者可以在呼叫時選擇採用什麼介面,而不需要提前進行Promise化。許多庫,如mongoosesequelize,都支援這種方法。

我們來看一個簡單的例子。假設我們要實現一個非同步執行除法的模組:

module.exports = function asyncDivision(dividend, divisor, cb) {
  return new Promise((resolve, reject) => { // [1]
    process.nextTick(() => {
      const result = dividend / divisor;
      if (isNaN(result) || !Number.isFinite(result)) {
        const error = new Error('Invalid operands');
        if (cb) {
          cb(error); // [2]
        }
        return reject(error);
      }
      if (cb) {
        cb(null, result); // [3]
      }
      resolve(result);
    });
  });
};複製程式碼

該模組的程式碼非常簡單,但是有一些值得強調的細節:

  • 首先,返回使用Promise的建構函式建立的新承諾。我們在建構函式引數函式內定義全部邏輯。
  • 在發生錯誤的情況下,我們reject這個Promise,但如果回撥函式在被呼叫時作為引數傳遞,我們也執行回撥來進行錯誤傳播。
  • 在計算結果之後,我們resolve了這個Promise,但是如果有回撥函式,我們也會將結果傳播給回撥函式。

我們現在看如何用回撥函式和Promise來使用這個模組:

// 回撥函式的方式
asyncDivision(10, 2, (error, result) => {
  if (error) {
    return console.error(error);
  }
  console.log(result);
});

// Promise化的呼叫方式
asyncDivision(22, 11)
  .then(result => console.log(result))
  .catch(error => console.error(error));複製程式碼

應該很清楚的是,即將開始使用類似於上述的新模組的開發人員將很容易地選擇最適合自己需求的風格,而無需在希望利用Promise時引入外部promisification功能。

Generators

ES2015規範引入了另外一種機制,除了其他新功能外,還可以用來簡化Node.js應用程式的非同步控制流程。我們正在談論Generator,也被稱為semi-coroutines。它們是子程式的一般化,可以有不同的入口點。在一個正常的函式中,實際上我們只能有一個入口點,這個入口點對應著函式本身的呼叫。Generator與一般函式類似,但是可以暫停(使用yield語句),然後在稍後繼續執行。在實現迭代器時,Generator特別有用,因為我們已經討論瞭如何使用迭代器來實現重要的非同步控制流模式,如順序執行和限制並行執行。

Generators基礎

在我們探索使用Generator來實現非同步控制流程之前,學習一些基本概念是很重要的。我們從語法開始吧。可以通過在函式關鍵字之後附加*(星號)運算子來宣告Generator函式:

function* makeGenerator() {
  // body
}複製程式碼

makeGenerator()函式內部,我們可以使用關鍵字yield暫停執行並返回給呼叫者傳遞給它的值:

function* makeGenerator() {
  yield 'Hello World';
  console.log('Re-entered');
}複製程式碼

在前面的程式碼中,Generator通過yield一個字串Hello World暫停當前函式的執行。當Generator恢復時,執行將從下列語句開始:

console.log('Re-entered');複製程式碼

makeGenerator()函式本質上是一個工廠,它在被呼叫時返回一個新的Generator物件:

const gen = makeGenerator();複製程式碼

生成器物件的最重要的方法是next(),它用於啟動/恢復Generator的執行,並返回如下形式的物件:

{
  value: <yielded value>
  done: <true if the execution reached the end>
}複製程式碼

這個物件包含Generator yield的值和一個指示Generator是否已經完成執行的符號。

一個簡單的例子

為了演示Generator,我們來建立一個名為fruitGenerator.js的新模組:

function* fruitGenerator() {
  yield 'apple';
  yield 'orange';
  return 'watermelon';
}
const newFruitGenerator = fruitGenerator();
console.log(newFruitGenerator.next()); // [1]
console.log(newFruitGenerator.next()); // [2]
console.log(newFruitGenerator.next()); // [3]複製程式碼

前面的程式碼將列印下面的輸出:

{ value: 'apple', done: false }
{ value: 'orange', done: false }
{ value: 'watermelon', done: true }複製程式碼

我們可以這麼解釋上述現象:

  • 第一次呼叫newFruitGenerator.next()時,Generator函式開始執行,直到達到第一個yield語句為止,該命令暫停Generator函式執行,並將值apple返回給呼叫者。
  • 在第二次呼叫newFruitGenerator.next()時,Generator函式恢復執行,從第二個yield語句開始,這又使得執行暫停,同時將orange返回給呼叫者。
  • newFruitGenerator.next()的最後一次呼叫導致Generator函式的執行從其最後的yield恢復,一個返回語句,它終止Generator函式,返回watermelon,並將結果物件中的done屬性設定為true

Generators作為迭代器

為了更好地理解為什麼Generator函式對實現迭代器非常有用,我們來構建一個例子。在我們將呼叫iteratorGenerator.js的新模組中,我們編寫下面的程式碼:

function* iteratorGenerator(arr) {
  for (let i = 0; i < arr.length; i++) {
    yield arr[i];
  }
}
const iterator = iteratorGenerator(['apple', 'orange', 'watermelon']);
let currentItem = iterator.next();
while (!currentItem.done) {
  console.log(currentItem.value);
  currentItem = iterator.next();
}複製程式碼

此程式碼應按如下所示列印陣列中的元素:

apple
orange
watermelon複製程式碼

在這個例子中,每次我們呼叫iterator.next()時,我們都會恢復Generator函式的for迴圈,通過yield陣列中的下一個項來執行另一個迴圈。這演示瞭如何在函式呼叫過程中維護Generator的狀態。當繼續執行時,迴圈和所有變數的值與Generator函式執行暫停時的狀態完全相同。

傳值給Generators

現在我們繼續研究Generator的基本功能,首先學習如何將值傳遞迴Generator函式。這其實很簡單,我們需要做的只是為next()方法提供一個引數,並且該值將作為Generator函式內的yield語句的返回值提供。

為了展示這一點,我們來建立一個新的簡單模組:

function* twoWayGenerator() {
  const what = yield null;
  console.log('Hello ' + what);
}
const twoWay = twoWayGenerator();
twoWay.next();
twoWay.next('world');複製程式碼

當執行時,前面的程式碼會輸出Hello world。我們做如下的解釋:

  • 第一次呼叫next()方法時,Generator函式到達第一個yield語句,然後暫停。
  • next('world')被呼叫時,Generator函式從上次停止的位置,也就是上次的yield語句點恢復,但是這次我們有一個值傳遞到Generator函式。這個值將被賦值到what變數。生成器然後執行console.log()指令並終止。

用類似的方式,我們可以強制Generator函式丟擲異常。這可以通過使用Generator函式的throw方法來實現,如下例所示:

const twoWay = twoWayGenerator();
twoWay.next();
twoWay.throw(new Error());複製程式碼

在這個最後這段程式碼,twoWayGenerator()函式將在yield函式返回的時候丟擲異常。這就好像從Generator函式內部丟擲了一個異常一樣,這意味著它可以像使用try ... catch塊一樣進行捕獲和處理異常。

Generator實現非同步控制流

你一定想知道Generator函式如何幫助我們處理非同步操作。我們可以通過建立一個接受Generator函式作為引數的特殊函式來演示這一點,並允許我們在Generator函式內部使用非同步程式碼。這個函式在非同步操作完成時要注意恢復Generator函式的執行。我們將呼叫這個函式asyncFlow()

function asyncFlow(generatorFunction) {
  function callback(err) {
    if (err) {
      return generator.throw(err);
    }
    const results = [].slice.call(arguments, 1);
    generator.next(results.length > 1 ? results : results[0]);
  }
  const generator = generatorFunction(callback);
  generator.next();
}複製程式碼

前面的函式取一個Generator函式作為輸入,然後立即呼叫:

const generator = generatorFunction(callback);
generator.next();複製程式碼

generatorFunction()接受一個特殊的回撥函式作為引數,當generator.throw()如果接收到一個錯誤,便立即返回。另外,通過將在回撥函式中接收的results傳值回Generator函式繼續Generator函式的執行:

if (err) {
  return generator.throw(err);
}
const results = [].slice.call(arguments, 1);
generator.next(results.length > 1 ? results : results[0]);複製程式碼

為了說明這個簡單的輔助函式的強大,我們建立一個叫做clone.js的新模組,這個模組只是建立它本身的克隆。貼上我們剛才建立的asyncFlow()函式,核心程式碼如下:

const fs = require('fs');
const path = require('path');
asyncFlow(function*(callback) {
  const fileName = path.basename(__filename);
  const myself = yield fs.readFile(fileName, 'utf8', callback);
  yield fs.writeFile(`clone_of_${filename}`, myself, callback);
  console.log('Clone created');
});複製程式碼

明顯地,有了asyncFlow()函式的幫助,我們可以像我們書寫同步阻塞函式一樣用同步的方式來書寫非同步程式碼了。並且這個結果背後的原理顯得很清楚。一旦非同步操作結束,傳遞給每個非同步函式的回撥函式將繼續Generator函式的執行。沒有什麼複雜的,但是結果確實很令人意外。

這個技術有其他兩個變化,一個是Promise的使用,另外一個則是thunks

在基於Generator的控制流中使用的thunk只是一個簡單的函式,它除了回撥之外,部分地應用了原始函式的所有引數。返回值是另一個只接受回撥作為引數的函式。例如,fs.readFile()的thunkified版本如下所示:

function readFileThunk(filename, options) {
  return function(callback) {
    fs.readFile(filename, options, callback);
  }
}複製程式碼

thunkPromise都允許我們建立不需要回撥的Generator函式作為引數傳遞,例如,使用thunkasyncFlow()版本如下:

function asyncFlowWithThunks(generatorFunction) {
  function callback(err) {
    if (err) {
      return generator.throw(err);
    }
    const results = [].slice.call(arguments, 1);
    const thunk = generator.next(results.length > 1 ? results : results[0]).value;
    thunk && thunk(callback);
  }
  const generator = generatorFunction();
  const thunk = generator.next().value;
  thunk && thunk(callback);
}複製程式碼

這個技巧是讀取generator.next()的返回值,返回值中包含thunk。下一步是通過注入特殊的回撥函式呼叫thunk本身。這允許我們寫下面的程式碼:

asyncFlowWithThunk(function*() {
  const fileName = path.basename(__filename);
  const myself = yield readFileThunk(__filename, 'utf8');
  yield writeFileThunk(`clone_of_${fileName}`, myself);
  console.log("Clone created")
});複製程式碼

使用co的基於Gernator的控制流

你應該已經猜到了,Node.js生態系統會藉助Generator函式來提供一些處理非同步控制流的解決方案,例如,suspend是其中一個最老的支援PromisethunksNode.js風格回撥函式和正常風格的回撥函式的 庫。還有,大部分我們之前分析的Promise庫都提供工具函式使得GeneratorPromise可以一起使用。

我們選擇co作為本章節的例子。它支援很多型別的yieldables,其中一些是:

  • Thunks
  • Promises
  • Arrays(並行執行)
  • Objects(並行執行)
  • Generators(委託)
  • Generator函式(委託)

還有很多框架或庫是基於co生態系統的,包括以下一些:

  • Web框架,最流行的是koa
  • 實現特定控制流模式的庫
  • 包裝流行的API相容co的庫

我們使用co重新實現我們的Generator版本的Web爬蟲應用程式

為了將Node.js風格的函式轉換成thunks,我們將會使用一個叫做thunkify的庫。

順序執行

讓我們通過修改Web爬蟲應用程式的版本2開始我們對Generator函式和co的實際探索。我們要做的第一件事就是載入我們的依賴包,並生成我們要使用的函式的thunkified版本。這些將在spider.js模組的最開始進行:

const thunkify = require('thunkify');
const co = require('co');
const request = thunkify(require('request'));
const fs = require('fs');
const mkdirp = thunkify(require('mkdirp'));
const readFile = thunkify(fs.readFile);
const writeFile = thunkify(fs.writeFile);
const nextTick = thunkify(process.nextTick);複製程式碼

看上述程式碼,我們可以注意到與本章前面promisify化的API的程式碼的一些相似之處。在這一點上,有意思的是,如果我們使用我們的promisified版本的函式來代替thunkified的版本,程式碼將保持完全一樣,這要歸功於co支援thunkPromise物件作為yieldable物件。事實上,如果我們想,甚至可以在同一個應用程式中使用thunkPromise,即使在同一個Generator函式中。就靈活性而言,這是一個巨大的優勢,因為它使我們能夠使用基於Generator函式的控制流來解決我們應用程式中的問題。

好的,現在讓我們開始將download()函式轉換為一個Generator函式:

function* download(url, filename) {
  console.log(`Downloading ${url}`);
  const response = yield request(url);
  const body = response[1];
  yield mkdirp(path.dirname(filename));
  yield writeFile(filename, body);
  console.log(`Downloaded and saved ${url}`);
  return body;
}複製程式碼

通過使用Generatorco,我們的download()函式變得簡單多了。當我們需要做非同步操作的時候,我們使用非同步的Generator函式作為thunk來把之前的內容轉化到Generator函式,並使用yield子句。

然後我們開始實現我們的spider()函式:

function* spider(url, nesting) {
  cost filename = utilities.urlToFilename(url);
  let body;
  try {
    body = yield readFile(filename, 'utf8');
  } catch (err) {
    if (err.code !== 'ENOENT') {
      throw err;
    }
    body = yield download(url, filename);
  }
  yield spiderLinks(url, body, nesting);
}複製程式碼

從上述程式碼中一個有趣的細節是我們可以使用try...catch語句塊來處理異常。我們還可以使用throw來傳播異常。另外一個細節是我們yield我們的download()函式,而這個函式既不是一個thunk,也不是一個promisified函式,只是另外的一個Generator函式。這也毫無問題,由於co也支援其他Generators作為yieldables

最後轉換spiderLinks(),在這個函式中,我們遞迴下載一個網頁的連結。在這個函式中使用Generators,顯得簡單多了:

function* spiderLinks(currentUrl, body, nesting) {
  if (nesting === 0) {
    return nextTick();
  }
  const links = utilities.getPageLinks(currentUrl, body);
  for (let i = 0; i < links.length; i++) {
    yield spider(links[i], nesting - 1);
  }
}複製程式碼

看上述程式碼。雖然順序迭代沒有什麼模式可以展示。Generatorco輔助我們做了很多,方便了我們可以使用同步方式開書寫非同步程式碼。

看最重要的部分,程式的入口:

co(function*() {
  try {
    yield spider(process.argv[2], 1);
    console.log(`Download complete`);
  } catch (err) {
    console.log(err);
  }
});複製程式碼

這是唯一一處需要呼叫co(...)來封裝的一個Generator。實際上,一旦我們這麼做,co會自動封裝我們傳遞給yield語句的任何Generator函式,並且這個過程是遞迴的,所以程式的剩餘部分與我們是否使用co是完全無關的,雖然是被co封裝在裡面。

現在應該可以執行使用Generator函式改寫的Web爬蟲應用程式了。

並行執行

不幸的是,雖然Generator很方便地進行順序執行,但是不能直接用來並行化執行一組任務,至少不能僅僅使用yieldGenerator。之前,在種情況下我們使用的模式只是簡單地依賴於一個基於回撥或者Promise的函式,但使用了Generator函式後,一切會顯得更簡單。

幸運的是,如果不限制併發數的並行執行,co已經可以通過yield一個Promise物件、thunkGenerator函式,甚至包含Generator函式的陣列來實現。

考慮到這一點,我們的Web爬蟲應用程式第三版可以通過重寫spiderLinks()函式來做如下改動:

function* spiderLinks(currentUrl, body, nesting) {
  if (nesting === 0) {
    return nextTick();
  }
  const links = utilities.getPageLinks(currentUrl, body);
  const tasks = links.map(link => spider(link, nesting - 1));
  yield tasks;
}複製程式碼

但是上述函式所做的只是拿到所有的任務,這些任務本質上都是通過Generator函式來實現非同步的,如果在cothunk內對一個包含Generator函式的陣列使用yield,這些任務都會並行執行。外層的Generator函式會等到yield子句的所有非同步任務並行執行後再繼續執行。

接下來我們看怎麼用一個基於回撥函式的方式來解決相同的並行流。我們用這種方式重寫spiderLinks()函式:

function spiderLinks(currentUrl, body, nesting) {
  if (nesting === 0) {
    return nextTick();
  }
  // 返回一個thunk
  return callback => {
    let completed = 0,
      hasErrors = false;
    const links = utilities.getPageLinks(currentUrl, body);
    if (links.length === 0) {
      return process.nextTick(callback);
    }

    function done(err, result) {
      if (err && !hasErrors) {
        hasErrors = true;
        return callback(err);
      }
      if (++completed === links.length && !hasErrors) {
        callback();
      }
    }
    for (let i = 0; i < links.length; i++) {
      co(spider(links[i], nesting - 1)).then(done);
    }
  }
}複製程式碼

我們使用co並行執行spider()函式,呼叫Generator函式返回了一個Promise物件。這樣,等待Promise完成後呼叫done()函式。通常,基於Generator控制流的庫都有這一功能,因此如果需要,你總是可以將一個Generator轉換成一個基於回撥或基於Promise的函式。

為了並行開啟多個下載任務,我們只要重用在前面定義的基於回撥的並行執行的模式。我們應該也注意到我們將spiderLinks()轉換成一個thunk(而不再是一個Generator函式)。這使得當全部並行任務完成時,我們有一個回撥函式可以呼叫。

上面講到的是將一個Generator函式轉換為一個thunk的模式,使之能夠支援其他的基於回撥或基於Promise的控制流演算法,並可以通過同步阻塞的程式碼風格書寫非同步程式碼。

限制並行執行

現在我們知道如何處理非同步執行流程,應該很容易規劃我們的Web爬蟲應用程式的第四版的實現,這個版本對併發下載任務的數量施加了限制。我們有幾個方案可以用來做到這一點。其中一些方案如下:

  • 使用先前實現的基於回撥的TaskQueue類。我們只需要thunkify我們的Generator函式和其提供的回撥函式即可。
  • 使用基於PromiseTaskQueue類,並確保每個作為任務的Generator函式都被轉換成一個返回Promise物件的函式。
  • 使用asyncthunkify我們打算使用的工具函式,此外還需要把我們用到的Generator函式轉化為基於回撥的模式,以便於能夠被這個庫較好地使用。
  • 使用基於co的生態系統中的庫,特別是專門為這種場景的庫,如co-limiter
  • 實現基於生產者 - 消費者模型的自定義演算法,這與co-limiter的內部實現原理相同。

為了學習,我們選擇最後一個方案,甚至幫助我們可以更好地理解一種經常與協程(也和執行緒和程式)同步相關的模式。

生產者 - 消費者模式

我們的目標是利用佇列來提供固定數量的workers,與我們想要設定的併發級別一樣多。為了實現這個演算法,我們將基於本章前面定義的TaskQueue類改寫:

class TaskQueue {
  constructor(concurrency) {
    this.concurrency = concurrency;
    this.running = 0;
    this.taskQueue = [];
    this.consumerQueue = [];
    this.spawnWorkers(concurrency);
  }
  pushTask(task) {
    if (this.consumerQueue.length !== 0) {
      this.consumerQueue.shift()(null, task);
    } else {
      this.taskQueue.push(task);
    }
  }
  spawnWorkers(concurrency) {
    const self = this;
    for (let i = 0; i < concurrency; i++) {
      co(function*() {
        while (true) {
          const task = yield self.nextTask();
          yield task;
        }
      });
    }
  }
  nextTask() {
    return callback => {
      if (this.taskQueue.length !== 0) {
        return callback(null, this.taskQueue.shift());
      }
      this.consumerQueue.push(callback);
    }
  }
}複製程式碼

讓我們分析這個TaskQueue類的新實現。首先是在建構函式中。需要呼叫一次this.spawnWorkers(),因為這是啟動worker的方法。

我們的worker很簡單,它們只是用co()包裝的立即執行的Generator函式,所以每個Generator函式可以並行執行。在內部,每個worker正在執行在一個死迴圈(while(true){})中,一直阻塞(yield)到新任務在佇列中可用時(yield self.nextTask()),一旦可以執行新任務,yield這個非同步任務直到其完成。您可能想知道我們如何能夠限制並行執行,並讓下一個任務在佇列中處於等待狀態。答案是在nextTask()方法中。我們來詳細地看看在這個方法的原理:

nextTask() {
  return callback => {
    if (this.taskQueue.length !== 0) {
      return callback(null, this.taskQueue.shift());
    }
    this.consumerQueue.push(callback);
  }
}複製程式碼

我們看這個函式內部發生了什麼,這才是這個模式的核心:

  1. 這個方法返回一個對於co而言是一個合法的yieldablethunk
  2. 只要taskQueue類生成的例項中還有下一個任務,thunk的回撥函式會被立即呼叫。回撥函式呼叫時,立馬解鎖一個worker的阻塞狀態,yield這一個任務。
  3. 如果佇列中沒有任務了,回撥函式本身會被放入consumerQueue中。通過這種做法,我們將一個worker置於空閒(idle)的模式。一旦我們有一個新的任務來要處理,在consumerQueue佇列中的回撥函式會被呼叫,立馬喚醒我們這一worker進行非同步處理。

現在,為了理解consumerQueue佇列中的空閒worker是如何恢復工作的,我們需要分析pushTask()方法。如果當前有回撥函式可用的話,pushTask()方法將呼叫consumerQueue佇列中的第一個回撥函式,從而將取消對worker的鎖定。如果沒有可用的回撥函式,這意味著所有的worker都是工作狀態,只需要新增一個新的任務到taskQueue任務佇列中。

TaskQueue類中,worker充當消費者的角色,而呼叫pushTask()函式的角色可以被認為是生產者。這個模式向我們展示了一個Generator函式實際上可以跟一個執行緒或程式類似。實際上,生產者 - 消費者之間問題是研究程式間通訊和同步時最常見的問題,但正如我們已經提到的那樣,它對於程式和執行緒來說,也是一個常見的例子。

限制下載任務的併發量

既然我們已經使用Generator函式和生產者 - 消費者模型實現一個限制並行演算法,並且已經在Web爬蟲應用程式第四版應用它來限制中下載任務的併發數。 首先,我們載入和初始化一個TaskQueue物件:

const TaskQueue = require('./taskQueue');
const downloadQueue = new TaskQueue(2);複製程式碼

然後,修改spiderLinks()函式。和之前不限制併發的版本類似,所以這裡我們只展示修改的部分,主要是通過呼叫新版本的TaskQueue類生成的例項的pushTask()方法來限制並行執行:

function spiderLinks(currentUrl, body, nesting) {
  //...
  return (callback) => {
    //...
    function done(err, result) {
      //...
    }
    links.forEach(function(link) {
      downloadQueue.pushTask(function*() {
        yield spider(link, nesting - 1);
        done();
      });
    });
  }
}複製程式碼

在每個任務中,我們在下載完成後立即呼叫done()函式,因此我們可以計算下載了多少個連結,然後在完成下載時通知thunk的回撥函式執行。

配合Babel使用Async await新語法

回撥函式、PromiseGenerator函式都是用於處理JavaScriptNode.js非同步問題的方式。正如我們所看到的,Generator的真正意義在於它提供了一種方式來暫停一個函式的執行,然後等待前面的任務完成後再繼續執行。我們可以使用這樣的特性來書寫非同步程式碼,並且讓開發者用同步阻塞的程式碼風格來書寫非同步程式碼。等到非同步操作的結果返回後才恢復當前函式的執行。

Generator函式是更多的是用來處理迭代器,然而迭代器在非同步程式碼的使用顯得有點笨重。程式碼可能難以理解,導致程式碼易讀性和可維護性差。

但在不遠的將來會有一種更加簡潔的語法。實際上,這個提議即將引入到ESMASCript 2017的規範中,這項規範定義了async函式語法。

async函式規範引入兩個關鍵字(asyncawait)到原生的JavaScript語言中,改進我們書寫非同步程式碼的方式。

為了理解這項語法的用法和優勢為,我們看一個簡單的例子:

const request = require('request');

function getPageHtml(url) {
  return new Promise(function(resolve, reject) {
    request(url, function(error, response, body) {
      resolve(body);
    });
  });
}
async function main() {
  const html = await getPageHtml('http://google.com');
  console.log(html);
}

main();
console.log('Loading...');複製程式碼

在上述程式碼中,有兩個函式:getPageHtmlmain。第一個函式的作用是提取給定URL的一個遠端網頁的HTML文件程式碼。值得注意的是,這個函式返回一個Promise物件。

重點在於main函式,因為在這裡使用了asyncawait關鍵字。首先要注意的是函式要以async關鍵字為字首。意思是這個函式執行的是非同步程式碼並且允許它在函式體內使用await關鍵字。await關鍵字在getPageHtml呼叫之前,告訴JavaScript直譯器在繼續執行下一條指令之前,等待getPageHtml返回的Promise物件的結果。這樣,main函式內部哪部分程式碼是非同步的,它會等待非同步程式碼的完成再繼續執行後續操作,並且不會阻塞這段程式其餘部分的正常執行。實際上,控制檯會列印字串Loading...,隨後是Google主頁的HTML程式碼。

是不是這種方法的可讀性更好並且更容易理解呢? 不幸地是,這個提議尚未定案,即使通過這個提議,我們需要等下一個版本
ECMAScript規範出來並把它整合到Node.js後,才能使用這個新語法。 所以我們今天做了什麼?只是漫無目的地等待?不是,當然不是!我們已經可以在我們的程式碼中使用async await語法,只要我們使用Babel

安裝與執行Babel

Babel是一個JavaScript編譯器(或翻譯器),能夠使用語法轉換器將高版本的JavaScript程式碼轉換成其他JavaScript程式碼。語法轉換器允許例如我們書寫並使用ES2015ES2016JSX和其它的新語法,來翻譯成往後相容的程式碼,在JavaScript執行環境如瀏覽器或Node.js中都可以使用Babel

在專案中使用npm安裝Babel,命令如下:

npm install --save-dev babel-cli複製程式碼

我們還需要安裝外掛以支援async await語法的解釋或翻譯:

npm install --save-dev babel-plugin-syntax-async-functions babel-plugin-tranform-async-to-generator複製程式碼

現在假設我們想執行我們之前的例子(稱為index.js)。我們需要通過以下命令啟動:

node_modules/.bin/babel-node --plugins "syntax-async-functions,transform-async-to-generator" index.js複製程式碼

這樣,我們使用支援async await的轉換器動態地轉換原始碼。Node.js執行的實際是儲存在記憶體中的往後相容的程式碼。

Babel也能被配置為一個程式碼構建工具,儲存翻譯或解釋後的程式碼到本地檔案系統中,便於我們部署和執行生成的程式碼。

關於如何安裝和配置Babel,可以到官方網站 babeljs.io 查閱相關文件。

幾種方式的比較

現在,我們應該對於怎麼處理JavaScript的非同步問題有了一個更好的認識和總結。在下面的表格中總結幾大機制的優勢和劣勢:

值得一提的是,我們選擇在本章中僅介紹處理非同步控制流程的最受歡迎的解決方案,或者是廣泛使用的解決方案,但是例如Fibers( npmjs.org/package/fib… )和Streamline( npmjs.org/p ackage/streamline )也是值得一看的。

總結

在本章中,我們分析了一些處理非同步控制流的方法,分析了PromiseGenerator函式和即將到來的async await語法。

我們學習瞭如何使用這些方法編寫更簡潔,更具有可讀性的非同步程式碼。我們討論了這些方法的一些最重要的優點和缺點,並認識到即使它們非常有用,也需要一些時間來掌握。這就是這幾種方式也沒有完全取代在許多情況下仍然非常有用的回撥的原因。作為一名開發人員,應該按照實際情況分析決定使用哪種解決方案。如果您正在構建執行非同步操作的公共庫,則應該提供易於使用的API,即使對於只想使用回撥的開發人員也是如此。

在下一章中,我們將探討另一個與非同步程式碼執行相關的機制,這也是整個Node.js生態系統中的另一個基本構建塊:streams

相關文章