Node.js回調黑洞全解:Async、Promise 和 Generator

我們常常把這個問題叫做”回調黑洞”或”回調金字塔”:
doAsync1(function () {
  doAsync2(function () {
    doAsync3(function () {
      doAsync4(function () {
    })
  })
})
回調黑洞是一種主觀的叫法,就像嵌套太多的代碼,有時候也沒什麼問題。爲了控制調用順序,異步代碼變得非常複雜,這就是黑洞。有個問題非常合適衡量黑洞到底有多深:如果doAsync2發生在doAsync1之前,你要忍受多少重構的痛苦?目標不單單是減少嵌套層數,而是要編寫模塊化(可測試)的代碼,便於理解和修改。

在本文中,我們要編寫一個模塊,使用一一系列的工具和類庫來展示流控制是如何工作的。而且新版的Node帶來了新的有潛力的解決方案,我們也會一探究竟。

問題

假設,我們需要找出某個目錄中最大的文件。
var findLargest = require('./findLargest')
findLargest('./path/to/dir', function (er, filename) {
  if (er) return console.error(er)
  console.log('largest file was:', filename)
})
我們分步解決這個問題:
  • 讀取給定文件夾中的全部文件
  • 獲取每個文件的stats(狀態)
  • 確定那個文件是最大的(如果多個文件都是最大的,選其中一個)
  • 將最大文件的文件名傳給回調函數
任何環節只要發生錯誤,則調用回調函數,把錯誤傳遞給它。我們且僅且調用回調函數一次。

嵌套代碼

第一種解決方案是嵌套的,看起來沒什麼恐怖的,並且邏輯還是看得懂的。
var fs = require('fs')
var path = require('path')


module.exports = function (dir, cb) {
  fs.readdir(dir, function (er, files) { [1]
    if (er) return cb(er)
    var counter = files.length
    var errored = false
    var stats = []


    files.forEach(function (file, index) {
      fs.stat(path.join(dir,file), function (er, stat) { [2]
        if (errored) return
        if (er) {
          errored = true
          return cb(er)
        }
        stats[index] = stat [3]


        if (--counter == 0) { [4]
          var largest = stats
            .filter(function (stat) { return stat.isFile() }) [5]
            .reduce(function (prev, next) { [6]
              if (prev.size > next.size) return prev
              return next
            })
          cb(null, files[stats.indexOf(largest)]) [7]
        }
      })
    })
  })
}
  • 讀取目錄中的所有文件
  • 讀取每個文件的stats。讀取的過程是並行的,因此我們使用一個counter,來追蹤I/O結束。同時我們還是了一個布爾變量errored,來防止多次出錯導致多次調用回調函數(cb)
  • 收集每個文件的stats。注意我們在這裏設置了一個並行的數組(從files到stats)
  • 檢查並行調用都完成了
  • 過濾出普通文件(不包含鏈接、目錄等)
  • Reduce整個列表,獲取最大的文件
  • 根據stat獲取文件名,調用回調函數
這個解決方案還不錯,然而,它運用了某些技巧來管理並行處理,以及避免多次調用回調函數。我們之後再來處理這些問題,首先先把這段代碼拆分成更小的模塊吧。

模塊化

嵌套代碼的方案可以拆分成三個模塊單元:
  • 從目錄中讀取所有文件
  • 從這些文件中獲取stats
  • 處理stats和files,獲取最大的文件
第一模塊其實就是fs.readdir,我們就不爲其新寫一個函數了。不過,我們可以寫一個函數,給定一系列的文件路徑,返回這些文件的stat:

function getStats (paths, cb) {
  var counter = paths.length
  var errored = false
  var stats = []
  paths.forEach(function (path, index) {
    fs.stat(path, function (er, stat) {
      if (errored) return
      if (er) {
        errored = true
        return cb(er)
      }
      stats[index] = stat
      if (--counter == 0) cb(null, stats)
    })
  })
}
接下來,我們需要一個處理函數,對比stats和files,返回最大文件的文件名:
function getLargestFile (files, stats) {
  var largest = stats
    .filter(function (stat) { return stat.isFile() })
    .reduce(function (prev, next) {
      if (prev.size > next.size) return prev
      return next
    })
    return files[stats.indexOf(largest)]
}
組合到一起:
var fs = require('fs')
var path = require('path')


module.exports = function (dir, cb) {
  fs.readdir(dir, function (er, files) {
    if (er) return cb(er)
    var paths = files.map(function (file) { [1]
      return path.join(dir,file)
    })


    getStats(paths, function (er, stats) {
      if (er) return cb(er)
      var largestFile = getLargestFile(files, stats)
      cb(null, largestFile)
    })
  })
}

模塊化的解決方案使得代碼重用和測試更容易。核心的export方法也更容易理解。然而,我們還是手動管理並行的stat任務。讓我試試使用一些流控制的類庫,看看我們可以做些什麼?

Async

async模塊很流行,與Node的核心精神類似。我們來看看如何使用async來重構代碼:
var fs = require('fs')
var async = require('async')
var path = require('path')


module.exports = function (dir, cb) {
  async.waterfall([ [1]
    function (next) {
      fs.readdir(dir, next)
    },
    function (files, next) {
      var paths = 
       files.map(function (file) { return path.join(dir,file) })
      async.map(paths, fs.stat, function (er, stats) { [2]
        next(er, files, stats)
      })
    },
    function (files, stats, next) {
      var largest = stats
        .filter(function (stat) { return stat.isFile() })
        .reduce(function (prev, next) {
        if (prev.size > next.size) return prev
          return next
        })
        next(null, files[stats.indexOf(largest)])
    }
  ], cb) [3]
}
  • async.waterfall提供瀑布式的流控制。每個操作產生的數據可以傳遞給下一個函數,通過next這個回調函數
  • async.map允許我們並行對一系列的path調用fs.stat,並把一個結果數組傳遞給回調函數
  • 最後一步後會調用回調函數cb;如果在整個運行過程中出錯了也會調用cb,不會它只會被調用一次
async模塊保證回調只會被觸發一次。它同時還爲我們處理了錯誤,管理並行的任務。

Promise

Promise提供了錯誤處理和函數式編程。我們如何使用Promise來解決這個問題呢?讓我們使用Q模塊試試(當然也可以使用其他Promise類庫):
var fs = require('fs')
var path = require('path')
var Q = require('q')
var fs_readdir = Q.denodeify(fs.readdir) [1]
var fs_stat = Q.denodeify(fs.stat)


module.exports = function (dir) {
  return fs_readdir(dir)
    .then(function (files) {
      var promises = files.map(function (file) {
        return fs_stat(path.join(dir,file))
      })
      return Q.all(promises).then(function (stats) { [2]
        return [files, stats] [3]
      })
    })
    .then(function (data) { [4]      var files = data[0]
      var stats = data[1]      var largest = stats
        .filter(function (stat) { return stat.isFile() })
        .reduce(function (prev, next) {
        if (prev.size > next.size) return prev
          return next
        })
      return files[stats.indexOf(largest)]
    })
}
  • Node內核並不是promise化的,轉化之
  • Q.all同步執行所有stat,結果數組保持了原來的順序
  • 最後把files和stat傳遞給next函數
與之前的例子不一樣,promise鏈中拋出的錯誤會被處理。而且暴露出來的API也是Promise化的:

var findLargest = require('./findLargest')
findLargest('./path/to/dir')
  .then(function (er, filename) {
    console.log('largest file was:', filename)
  })
  .catch(console.error)

儘管上面是這樣設計的,但是你也沒必要暴露promise化的接口。很多promise類庫也提供了暴露node風格接口的方法。在Q中,我們可以使用nodeify函數來實現。

我們不會深入介紹Promise,如果大家想了解更多的話,閱讀這篇文章

Generator

本文一開始就說的,在這個領域來了一個新技術,在Node 0.11.2及以上版本中已經可以使用了——Generator!

Generator是爲JavaScript設計的一種輕量級的協程。它通過yield關鍵字,可以控制一個函數暫停或者繼續執行Generator函數有一個特別的語法function* (),藉助這種超能力,我們還可以暫停或者繼續執行異步操作,使用像promise或者”thunks”這樣的結構來寫出看起來像同步的代碼。

Thunk函數是一種這樣的函數,返回一個回調來調用它自己。回調函數與典型的node回調函數有着一致的參數(例如error是第一個參數)。閱讀這裏瞭解更多。

讓我看一個例子,如何使用Generator來做異步的控制: TJ Holowaychuk的類庫co。下面是我們尋找最大文件的程序:

var co = require('co')
var thunkify = require('thunkify')
var fs = require('fs')
var path = require('path')
var readdir = thunkify(fs.readdir) [1]
var stat = thunkify(fs.stat)


module.exports = co(function* (dir) { [2]  var files = yield readdir(dir) [3]
  var stats = yield files.map(function (file) { [4]
    return stat(path.join(dir,file))
  })
  var largest = stats
    .filter(function (stat) { return stat.isFile() })
    .reduce(function (prev, next) {
      if (prev.size > next.size) return prev
      return next
    })
  return files[stats.indexOf(largest)] [5]
})
  • 既然Node核心函數並不是thunk化的,我們thunk之
  • co接受一個Generator函數,在這個函數內部,可以使用yield關鍵字在任何地方暫停
  • Generator函數直到readdir返回了才繼續執行。結果賦值給files變量
  • co還能夠處理一系列並行的操作數組,結果按順序保存到stats這個數組中
  • 返回最終結果
我們可以把這個Generator函數包裝成一樣的回調API,就像本文一開始的那樣。Co還可以把任何的報錯返回給回調函數。在Generator中,可以使用try/catch來包裹yield語句,co利用了這一點:

try {
  var files = yield readdir(dir)
} catch (er) {
  console.error('something happened whilst reading the directory')
}

Co還能非常優雅地支持數組、對象、嵌套Generator和Promise等等。


也涌現出了一些其他Generator模塊。Q模塊報了一個優雅的Q.async方法,行爲與co使用Generator一致。

總結

在本文中,我們研究了很多種不同的方案,來處理回調黑洞。其實也就是程序的流程控制。我個人對Generator的方案非常感興趣。我很好奇像koa這樣的新框架可以帶來什麼。

想要本文使用到的代碼示例以及其他一些Generator例子?這裏有一個Github repo可以滿足你!本文首發於StrongLoop | Managing Node.js Callback Hell with Promises, Generators and Other Approaches

原文:Managing Node.js Callback Hell

轉自:http://zhuanlan.zhihu.com/FrontendMagazine/19750470
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章