JavaScript非同步程式設計:非同步的資料收集方法

出版圈郭志敏發表於2013-08-13

我們先嚐試在不借助任何工具函式的情況下來解決這個問題。筆者能想到的最簡單的方法是:因前一個readFile的回撥執行下一個readFile,同時跟蹤記錄迄今已觸發的回撥次數,並最終顯示輸出。下面是筆者的實現結果。

Asyncjs/seriesByHand.js

var fs = require('fs');
process.chdir('recipes'); // 改變工作目錄
var concatenation = '';

fs.readdir('.', function(err, filenames) {
  if (err) throw err;

  function readFileAt(i) {
    var filename = filenames[i];
    fs.stat(filename, function(err, stats) {
      if (err) throw err;
      if (! stats.isFile()) return readFileAt(i + 1);

      fs.readFile(filename, 'utf8', function(err, text) {
        if (err) throw err;
        concatenation += text;
        if (i + 1 === filenames.length) {
          // 所有檔案均已讀取,可顯示輸出
          return console.log(concatenation);
        }
        readFileAt(i + 1);
      });
    });
  }
  readFileAt(0);
});

如你所見,非同步版本的程式碼要比同步版本多很多。如果使用filterforEach這些同步方法,程式碼的行數大約只有一半,而且讀起來也要容易得多。如果這些漂亮的迭代器存在非同步版本該多好啊!使用Async.js就能做到這一點!

何時丟擲亦無妨?

大家可能注意到了,在上面那個程式碼示例中筆者無視了自己在第1.4節中提出的建議:從回撥裡丟擲異常是一種糟糕的設計,尤其在成品環境中。不過,一個簡單如斯的示例直接丟擲異常則完全沒有問題。如果真的遇到程式碼出錯的意外情形,throw會關停程式碼並提供一個漂亮的堆疊軌跡來解釋出錯原因。

這裡真正的不妥之處在於,同樣的錯誤處理邏輯(即if(err) throw err)重複了多達3次!在4.2.2節,我們會看到Async.js如何幫助減少這種重複。

Async.js的函式式寫法

我們想把同步迭代器所使用的filterforEach方法替換成相應的非同步方法。Async.js給了我們兩個選擇。

  • async.filterasync.forEach,它們會並行處理給定的陣列。
  • async.filterSeriesasync.forEachSeries,它們會順序處理給定的陣列。

並行執行這些非同步操作應該會更快,那為什麼還要使用序列式方法呢?原因有兩個。

  • 前面提到的工作流次序不可預知的問題。我們確實可以先把結果儲存成陣列,然後再joining(聯接)陣列來解決這個問題,但這畢竟多了一個步驟。
  • Node及其他任何應用程式能夠同時讀取的檔案數量有一個上限。如果超過這個上限,作業系統就會報錯。如果能順序讀取檔案,則無需擔心這一限制。

所以現在先搞明白async.forEachSeries再說。下面使用了Async.js的資料收集方法,直接改寫了同步版本的程式碼實現。

Asyncjs/forEachSeries.js

var async = require('async');
var fs = require('fs');
process.chdir('recipes'); // 改變工作目錄
var concatenation = '';

var dirContents = fs.readdirSync('.');

async.filter(dirContents, isFilename, function(filenames) {
  async.forEachSeries(filenames, readAndConcat, onComplete);
});

function isFilename(filename, callback) {
  fs.stat(filename, function(err, stats) {
    if (err) throw err;
    callback(stats.isFile());
  });
}

function readAndConcat(filename, callback) {
  fs.readFile(filename, 'utf8', function(err, fileContents) {
    if (err) return callback(err);
    concatenation += fileContents;
    callback();
  });
}

function onComplete(err) {
  if (err) throw err;
  console.log(concatenation);
}

現在我們的程式碼漂亮地分成了兩個部分:任務概貌(表現形式為async.filter呼叫和async.forEachSeries呼叫)和實現細節(表現形式為兩個迭代器函式和一個完工回撥onComplete)。

filterforEach並不是僅有的與標準函式式迭代方法相對應的Async.js工具函式。Async.js還提供了以下方法:

  • reject/rejectSeries,與filter剛好相反;
  • map/mapSeries,1:1變換;
  • reduce/reduceRight,值的逐步變換;
  • detect/detectSeries,找到篩選器匹配的值;
  • sortBy,產生一個有序副本;
  • some,測試是否至少有一個值符合給定標準;
  • every,測試是否所有值均符合給定標準。

這些方法是Async.js的精髓,令你能夠以最低的程式碼重複度來執行常見的迭代工作。在繼續探索更高階的方法之前,我們先來看看這些方法的錯誤處理技術。

Async.js的錯誤處理技術

要怪就怪Node的fs.exists首開這一先河吧!而這也意味著使用了Async.js資料收集方法(filter/filterSeriesreject/rejectSeriesdetect/detectSeriessomeevery等)的迭代器均無法報告錯誤。

對於非布林型的所有Async.js迭代器,傳遞非null/undefined的值作為迭代器回撥的首引數將會立即因該錯誤值而呼叫完工回撥。這正是readAndConcat不用throw也能工作的原因。

Asyncjs/forEachSeries.js

function readAndConcat(filename, callback) {
  fs.readFile(filename, 'utf8', function(err, fileContents) {
    if (err) return callback(err);
    concatenation += fileContents;
    callback();
  });
}

所以,如果callback(err)確實是在readAndConcat中被呼叫的,則這個err會傳遞給完工回撥(即onComplete)。Async.js只負責保證onComplete只被呼叫一次,而不管是因首次出錯而呼叫,還是因成功完成所有操作而呼叫。

Asyncjs/forEachSeries.js

function onComplete(err) {
  if (err) throw err;
  console.log(concatenation);
}

Node的錯誤處理約定對Async.js資料收集方法而言也許並不理想,但對於Async.js的所有其他方法而言,遵守這些約定可以讓錯誤乾淨利落地從各個任務流向完工回撥。下一節會看到更多這樣的例子。

相關文章