最近看到nodejs,由於有一個處理裏面有好幾個異步操做,調入回調大坑,不由以爲很噁心,真的很討厭發明這種寫法的人,簡直反社會!!!遂轉載一篇解坑的文章,原文地址:http://www.infoq.com/cn/articles/nodejs-callback-hell/。javascript
Node.js須要按順序執行異步邏輯時通常採用後續傳遞風格,也就是將後續邏輯封裝在回調函數中做爲起始函數的參數,逐層嵌套。這種風格雖然能夠提升CPU利用率,下降等待時間,但當後續邏輯步驟較多時會影響代碼的可讀性,結果代碼的修改維護變得很困難。根據這種代碼的樣子,通常稱其爲」callback hell」或」pyramid of doom」,本文稱之爲回調大坑,嵌套越多,大坑越深。
坑的起源php
後續傳遞風格java
爲何會有坑?這要從後續傳遞風格(continuation-passing style–CPS)提及。這種編程風格最開始是由Gerald Jay Sussman和Guy L. Steele, Jr. 在AI Memo 349上提出來的,那一年是1975年,Schema語言的第一次亮相。既然JavaScript的函數式編程設計原則主要源自Schema,這種風格天然也被帶到了Javascript中。node
這種風格的函數要有額外的參數:「後續邏輯體」,好比帶一個參數的函數。CPS函數計算出結果值後並非直接返回,而是調用那個後續邏輯函數,並把這個結果做爲它的參數。從而實現計算結果在邏輯步驟之間的傳遞,以及邏輯的延續。也就是說若是要調用CPS函數,調用方函數要提供一個後續邏輯函數來接收CPS函數的「返回」值。
回調web
在JavaScript中,這個「後續邏輯體」就是咱們常說的回調(callback)。這種做爲參數的函數之因此被稱爲回調,是由於它通常在主程序中定義,由主程序交給庫函數,並由它在須要時回來調用。而將回調函數做爲參數的,通常是一個會佔用較長時間的異步函數,要交給另外一個線程執行,以便不影響主程序的後續操做。以下圖所示:
下面一個例子說明回調樣例的噁心之處:redis
module.exports = function (param, cb) {
asyncFun1(param, function (er, data) {
if (er) return cb(er);
asyncFun2(data,function (er,data) {
if (er) return cb(er);
asyncFun3(data, function (er, data) {
if (er) return cb(er);
cb(data);
})
})
})
}
像function(er,data)這種回調函數簽名很常見,幾乎全部的Node.js核心庫及第三方庫中的CPS函數都接收這樣的函數參數,它的第一個參數是錯誤,其他參數是CPS函數要傳遞的結果。好比Node.js中負責文件處理的fs模塊,咱們再看一個實際工做中可能會遇到的例子。要找出一個目錄中最大的文件,處理步驟應該是:npm
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]
}
})
})
})
}
對這個模塊的用戶來講,只須要提供一個回調函數function(er,filename),用兩個參數分別接收錯誤或文件名:編程
var findLargest = require('./findLargest')
findLargest('./path/to/dir', function (er, filename) {
if (er) return console.error(er)
console.log('largest file was:', filename)
})
介紹完CPS和回調,咱們接下來看看如何平坑。數組
解套平坑promise
編寫正確的併發程序歸根結底是要讓儘量多的操做同步進行,但各操做的前後順序仍能正確無誤。服務端的代碼通常邏輯比較複雜,步驟多,此時用嵌套實現異步函數的順序執行會比較痛苦,因此應該儘可能避免嵌套,或者下降嵌套代碼的複雜性,少用匿名函數。這通常有幾種途徑:
最簡單的是把匿名函數拿出來定義成單獨的函數,而後或者像原來同樣用嵌套方式調用,或者藉助流程控制模塊放在數組裏逐一調用;
用Promis;
若是你的Node版本>=0.11.2,能夠用generator。
咱們先介紹最容易理解的流程控制模塊。
流程控制模塊
Nimble是一個輕量、可移植的函數式流程控制模塊。通過最小化和壓縮後只有837字節,能夠運行在Node.js中,也能夠用在各類瀏覽器中。它整合了underscore和async一些最實用的功能,而且API更簡單。
nimble有兩個流程控制函數,.parallel和.series。顧名思義,咱們要用的是第二個,可讓一組函數串行執行的_.series。下面這個命令是用來安裝Nimble的:
npm install nimble
若是用.series調度執行上面那個解方程的函數,代碼應該是這樣的:
var flow = require('nimble');
(function calculate(i) {
if(i === l-1) {
variables[i] = res[i];
process.exit();
}else {
flow.series([
function (callback) {
calculateTail(res[i],res[i+1],function(tail) {
variables[i] = tail;
callback();
});
},
function (callback) {
calculateHead(res[i],res[i+1],function(head) {
res[i+1] = head;
callback();
});
},
function(callback){
calculate(i+1);
}]);
}
})(0);
.series數組參數中的函數會挨個執行,只是咱們的calculateTail和calculateHead都被包在了另外一個函數中。儘管這個用流程控制實現的版本代碼更多,但一般可讀性和可維護性要強一些。接下來咱們介紹Promise。
Promise
什麼是Promise呢?在紙牌屋的第一季第一集中,當琳達告訴安德伍德不能讓他作國務卿後,他說:「所謂Promise,就是說它不會受不斷變化的狀況影響。」
Promise不只去掉了嵌套,它連回調都去掉了。由於按照Promise的觀點,回調一點也不符合函數式編程的精神。回調函數什麼都不返回,沒有返回值的函數,執行它僅僅是由於它的反作用。因此用回調函數編程天生就是指令式的,是以反作用爲主的過程的執行順序,而不是像函數那樣把輸入映射到輸出,能夠組裝到一塊兒。
這裏用的Promis框架是著名的Q,能夠用npm install q安裝。雖然可用的Promis框架有不少,但在它們用法上都大同小異。咱們在這裏會用到其中的三個方法。
第一個負責將Node.js的CPS函數變成Promise。Node.js核心庫和第三方庫中有很是多的CPS函數,咱們的程序確定要用到這些函數,要解決回調大坑,就要從這些函數開始。這些函數的回調函數參數大多遵循一個相同的模式,即函數簽名爲function(err, result)。對於這種函數,能夠用簡單直接的Q.nfcall和Q.nfapply調用這種Node.js風格的函數返回一個Promise:
return Q.nfcall(FS.readFile, "foo.txt", "utf-8");
return Q.nfapply(FS.readFile, ["foo.txt", "utf-8"]);
也能夠用Q.denodeify或Q.nbind建立一個可重用的包裝函數,好比:
var readFile = Q.denodeify(FS.readFile);
return readFile("foo.txt", "utf-8");
var redisClientGet = Q.nbind(redisClient.get, redisClient);
return redisClientGet("user:1:id");
第二個是then方法,這個方法是Promise對象的核心部件。咱們能夠用這個方法從異步操做中獲得返回值(履約值),或拋出的異常(拒絕的理由)。then方法有兩個可選的參數,分別對應Promis對象的兩種執行結果。成功時調用的onFulfilled函數,錯誤時調用onRejected函數:
var promise = asyncFun()
promise.then(onFulfilled, onRejected)
Promise被解決時(異步處理已經完成)會調用onFulfilled 或onRejected 。由於只會有一種可能,因此這兩個函數中僅有一個會被觸發。儘管then方法的名字讓人以爲它跟某種順序化操做有關,而且那確實是它所承擔的職責的副產品,但你真的能夠把它看成unwrap來看待。Promise對象是一個存放未知值的容器,而then的任務就是把這個值從Promise中提取出來,把它交給另外一個函數。
var promise = readFile()
var promise2 = promise.then(readAnotherFile, console.error)
這個promise表示 onFulfilled 或 onRejected 的返回結果。既然結果只能是其中之一,因此不論是什麼結果,Promise都會轉發調用:
var promise = readFile()
var promise2 = promise.then(function (data) {
return readAnotherFile() // if readFile was successful, let's readAnotherFile
}, function (err) {
console.error(err) // if readFile was unsuccessful, let's log it but still readAnotherFile
return readAnotherFile()
})
promise2.then(console.log, console.error) // the result of readAnotherFile
由於then 返回的是Promise,因此promise能夠造成調用鏈,從而避免出現回調大坑:
readFile()
.then(readAnotherFile)
.then(doSomethingElse)
.then(...)
再來看一下那個找最大文件的例子用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)]
})
}
這時這個模塊的用法變成了:
var findLargest = require('./findLargest')
findLargest('./path/to/dir')
.then(function (er, filename) {
console.log('largest file was:', filename)
})
.fail(function(err){
console.error(err);
});
由於模塊返回的是Promise,因此客戶端也變成了Promise風格的,調用鏈中的全部異常均可以在這裏捕獲到。不過Q也有能夠支持回調風格函數的nodeify方法。
generators
generator科普
在計算機科學中,generator其實是一種迭代器。它很像一個能夠返回數組的函數,有參數,能夠調用,而且會生成一系列的值。然而generator不是把數組中的值都準備好而後一次性返回,而是一次yield一個,因此它所需的資源更少,而且調用者能夠立刻開始處理開頭的幾個值。簡言之,generator看起來像函數,但行爲表現像迭代器。
Generator也被稱爲半協程,是協程的一種特例(別協程弱),它老是把控制權交回給調用者(同時返回一個結果值),而不是像協程同樣跳轉到指定的目的地。若是要說得具體一點兒,就是雖然它們兩個均可以yield屢次,暫停執行並容許屢次進入,但協程能夠指定yield以後的去向,而generator不行,它只能把控制權交回給調用者。由於generator主要是爲了下降編寫迭代器的難度的,因此generator中的yield語句不是用來指明程序要跳到哪裏去的,而是用來把值傳回給父程序的。
2008年7月,Eich宣佈開始ECMAScript Harmony(即ECMAScript 6)項目,從2011年7月開始,ECMAScript Harmony草案開始按期公佈,預計到2014年12月正式發佈。generator就是在這一過程當中出如今ECMAScript中的,隨後不久就被引入了V8引擎中。
Node.js對generator的支持是從v0.11.2開始的,但由於Harmony還沒正式發佈,因此須要指明–harmony或–harmony-generator參數啓用它。咱們用node –harmony進入REPL,建立一個generator:
function* values() {
for (var i = 0; i < arguments.length; i++) { yield arguments[i]; }
}
注意generator的定義,用的是像函數可又不是函數的function*,循環時每次遇到yield,程序就會暫停執行。那麼暫停後,generator什麼時候會再次執行呢?在REPL中執行o.next():
>var o = values(1, 2, 3);
> o.next();
{ value: 1, done: false }
> o.next();
{ value: 2, done: false }
> o.next();
{ value: 3, done: false }
> o.next();
{ value: undefined, done: true }
>
第一次執行o.next(),返回了一個對象{ value: 1, done: false },執行到第四次時,value變成了undefined,狀態done變成了true。表現得像迭代器同樣。next()除了獲得generator的下一個值並讓它繼續執行外,還能夠把值傳給generator。有些文章提到了send(),不過那是老黃曆了,也許你看這篇文章的時候,本文中也有不少內容已通過時了,IT技術發展得就是這樣快。咱們再看一個例子,仍是在REPL中:
function* foo(x) {
yield x + 1;
var y = yield null;
return x + y;
}
再次執行next():
>var f = foo(2);
> f.next();
{ value: 3, done: false }
> f.next();
{ value: null, done: false }
> f.next(5);
{ value: 7, done: true }
注意最後的f.next(5),value變成了7,由於最後一個next將5壓進了這個generator的棧中,y變成了5。若是要總結一下,那麼generator就是:
yield能夠出如今任何表達式中,因此能夠隨時暫停執行,好比foo(yield x, yield y)或在循環中。
調用generator只是看起來像函數,但其實是建立了一個generator對象。只有調用next纔會再次啓動generator。next還能夠向generator對象中傳值。
generator返回的不是原始值,而是有兩個屬性的對象:value和done。當generator結束時,done會變成true,以前則一直是false。
但是generator和回調大坑有什麼關係呢?由於yield能夠暫停程序,next可讓程序再次執行,因此只需稍加控制,就能讓異步回調代碼順序執行。
用generator平坑 Node.js社區中有不少藉助generator實現異步回調順序化的庫,好比suspend、co等,不過咱們重點介紹的仍是Q。它提供了一個spawn方法。這個方法能夠當即運行一個generator,並將其中未捕獲的錯誤發給Q.onerror。