JavaScript 现代 Web 开发框架教程(八)
十四、Q
我是一个有想法的人。努力工作不是我的强项。—Q,星际迷航:航海家
JavaScript 是一种异步语言。无论是在浏览器中还是在服务器上,开发人员都可以指示 JavaScript 运行时“调度”代码在未来某个时间点运行。该特性通常用于延迟 CPU 密集型或长时间运行的操作的开始,让应用有时间完成其当前任务,然后再继续执行更耗费人力的任务。这个特性如此强大,以至于传统的同步语言如 Java、C#、PHP 和 Ruby 都纷纷效仿并采用了它。一些语言,比如 C#,已经实现了异步执行模型作为一种语言特性(通过async和await关键字);其他语言,如 PHP,支持与 React 等外部库的异步性(不要与 FaceBoook 的 JavaScript 库 React 混淆)。无论哪种情况,异步代码和同步代码都必然会相遇。
q 是一个 JavaScript 库,它封装了接口背后的异步行为,读起来很像同步代码,这是本章的主题。q 产生承诺,可以链接在一起的特殊对象,以消除嵌套的回调,传播值和错误,并通常在异步代码中管理流控制。然而,在深入探讨 Q 之前,有必要走一小段弯路,研究一下为什么异步代码难以编写和管理。
时机就是一切
同步代码非常容易阅读,因为计算机一次执行一条语句。由同步代码(例如,通过方法调用)生成的返回值在返回后立即可供调用代码使用。具有结构化异常处理特性的语言提供了try/catch/finally块,可以用来预测和处理出现的错误,这样微不足道的(或可恢复的)错误就不会对应用造成致命影响。但是结构化异常处理只适用于同步代码;它的行为类似于一个goto语句,导致代码“跳转”到应用中的某个其他点,并在该点继续执行语句。
异步代码的行为稍有不同。在 JavaScript 中,异步代码被安排在未来的某个时间点运行(有时就在当前正在执行的代码之后)。这打破了同步模型,因为未来的代码将只在当前堆栈展开后运行。那么,在异步代码中创建的返回值和错误也必须在将来代码实际运行时进行处理。
许多语言(包括 JavaScript)都用回调来解决这个问题,回调是作为异步代码的参数传递的函数,一旦代码运行,就调用这些函数来处理错误和“返回值”Node.js 运行时严重依赖 JavaScript 的调度功能,它甚至为所有回调函数指定了标准签名,以便正确处理和传播异步错误。
不幸的是,嵌套的异步代码会很快变得复杂。考虑清单 14-1 中的例子。
Listing 14-1. Asynchronous Node.js Example
// example-001/index.js
'use strict';
var fs = require('fs');
var path = require('path');
var playerStats = require('./player-stats');
function getPlayerStats(gamesFilePath, playerID, cb) {
// fs.readFile() is asynchronous
fs.readFile(gamesFilePath, {encoding: 'utf8'}, function (err, content) {
if (err) {
return cb(err);
}
var games = JSON.parse(content);
var playerGames = games.filter(function (game) {
return game.player === playerID;
});
// playerStats.calcBest() is asynchronous
playerStats.calcBest(playerGames, function (err, bestStats) {
if (err) {
return cb(err);
}
// playerStats.calcAvg() is asynchronous
playerStats.calcAvg(playerGames, function (err, avgStats) {
if (err) {
return cb(err);
}
cb(null, {best: bestStats, avg: avgStats});
});
});
});
}
var gamesFilePath = path.join(__dirname, 'games.json');
getPlayerStats(gamesFilePath, 42, function (err, stats) {
if (err) {
console.error(err);
return process.exit(1);
}
console.log('best:', stats.best);
console.log('avg: ', stats.avg)
});
在本例中,JavaScript 代码被调度了四次:
The declaration and invocation of getPlayerStats() The invocation of fs.readFile() The invocation of playerStats.calcBest() The invocation of playerStats.calcAvg()
很容易想象playerStats可能是一个对查询响应缓慢的外部服务。但是如果这段代码是同步的,如清单 14-2 所示,所有的事情都会被调度一次。每个函数和方法都将按顺序被调用,所有这些都被分组到一个try/catch块中以处理任何同步错误,并且统计数据将在收到时被写入控制台。
Listing 14-2. Synchronous Node.js Example
// example-002/index.js
'use strict';
var fs = require('fs');
var path = require('path');
var playerStats = require('./player-stats');
try {
var gamesFilePath = path.join(__dirname, 'games.json');
// fs.readFileSync() is synchronous
var content = fs.readFileSync(gamesFilePath, {encoding: 'utf8'});
var games = JSON.parse(content);
var playerGames = games.filter(function (game) {
return game.player === 42;
});
// playerStats.calcBestSync() is synchronous
console.log('best:', playerStats.calcBestSync(playerGames));
// playerStats.calcAvgSync() is synchronous
console.log('avg :', playerStats.calcAvgSync(playerGames));
} catch (e) {
console.error(e);
process.exit(1);
}
这个同步例子更容易理解,尽管每个语句在完成之前都会阻塞执行流程。异步回调驱动的代码虽然减轻了这个缺点,但仍然有许多严重的问题。
首先,没有回调签名必须遵守的真正规范的标准。Node.js 约定是被最广泛采用的,但是模块作者可以(并且确实)创建不遵循该标准的 API。当 JavaScript 模块包装或模拟现有的非 JavaScript API 时,通常会发生这种情况。为了熟悉 Node.js 约定,模块作者可能会决定模仿该 API 的回调签名模式。
第二,回调手动传播错误。每个回调都必须检查err对象,并决定如何处理它,或者将它转发给另一个执行相同操作的回调。结果往往是许多样板错误检查代码。在同步代码中,异常会自动在堆栈中向上传播,直到被一个catch块处理。
也很容易遗漏或不恰当地处理异步代码中出现的同步错误。在清单 14-3 中,try/catch块包装同步JSON.parse调用,然后在成功时传播解析的 JavaScript 对象,或者在解析失败时传播捕获的异常。
Listing 14-3. Improperly Invoking a Callback Within a try/catch Block
// example-003/improper-async-error-handling.js
'use strict';
var fs = require('fs');
var path = require('path');
function readJSONFile(filePath, cb) {
fs.readFile(filePath, function (err, buffer) {
try {
var json = JSON.parse(buffer.toString());
cb(null, json);
} catch (e) {
console.log('where did this error come from?', e.message);
cb(e);
}
});
}
var gamesFilePath = path.join(__dirname, 'games.json');
readJSONFile(gamesFilePath, function (err, json) {
if (err) {
return console.error('parsing json did not succeed :(');
}
console.log('parsing json was successful :)');
throw new Error('should never happen, right?');
});
假设games.json文件存在,并且有有效的 JSON 数据。在这个例子中,在数据被解析之后,回调将在try块中被调用。但是请注意当回调中抛出异常时会发生什么。这个异常会将堆栈展开回到try块中,并导致catch块捕获该异常,再次调用回调,并返回回调生成的完全相同的错误。这可能会产生意想不到的后果。如清单 14-4 所示,处理这个错误的适当方法是避免在try/catch块中调用回调。
Listing 14-4. Improperly Invoking a Callback Within a try/catch Block
// example-003/proper-async-error-handling.js
'use strict';
var fs = require('fs');
var path = require('path');
function readJSONFile(filePath, cb) {
fs.readFile(filePath, function (err, buffer) {
var json, err;
try {
json = JSON.parse(buffer.toString());
} catch (e) {
err = e;
}
if (err) {
console.log('where did this error come from?', e.message);
return cb(err);
}
cb(null, json);
});
}
var gamesFilePath = path.join(__dirname, 'games.json');
readJSONFile(gamesFilePath, function (err, json) {
if (err) {
return console.error('parsing json did not succeed :(');
}
console.log('parsing json was successful :)');
throw new Error('should never happen, right?');
});
最后,嵌套回调是一把双刃剑。一方面,每个回调都可以访问它自己的闭包和包围它的闭包中的数据;另一方面,嵌套很快导致复杂和紧密耦合的代码。程序的真正流程可能会变得模糊不清,产生一个不可维护的生态系统,容易滋生 bug。
承诺与回访
为了减轻异步回调带来的挑战,JavaScript 社区的成员起草了许多建议和规范,最终形成了 Promises/A+规范。该规范以及其他相关规范定义了一种方法,将异步操作封装在一个称为“promise”的特殊对象中,该对象可以与其他承诺相结合,创建一种异步链,通过该链可以传播值和错误,并在必要时进行处理。
根据 Promises/A+规范的定义,承诺用三种状态表示其异步操作:挂起、完成和拒绝。如果我们从回调的角度考虑这个问题,它将对应于清单 14-5 。
Listing 14-5. Callback Equivalents to Promise States
// invoking the function means the operation is "pending"
asyncFunction(function asyncCallback (err, asyncData) {
if (err) {
// if an error occurred the operation is "rejected"
}
// otherwise the operation is "fulfilled"
});
promise 也称为“thenable ”,因为它有一个接受两个可选回调的then()方法:第一个在 promise 的异步操作完成时调用,第二个在操作被拒绝时调用。完整签名显示在清单 14-6 中。
Listing 14-6. Thenable Method Signature
/**
* @param {Function} [onFulfilled]
* @param {Function} [onRejected]
* @returns {Promise}
*/
promise.then(onFulfilled, onRejected)
但是等等!承诺的全部意义不就是消除回调吗?不,承诺的要点是简化将异步操作链接在一起的过程,这个过程通常会将开发人员引向嵌套回调的道路。注意,promise 的then()方法实际上也返回了一个承诺。这个承诺也将根据原始承诺的回调中发生的情况要么被履行,要么被拒绝。通过利用这个特性,可以重写清单 14-7 中的玩家统计代码,以几乎消除嵌套。
Listing 14-7. Promises Reduce Nesting
// example-004/index.js
'use strict';
var fs = require('fs');
var path = require('path');
var playerStats = require('./player-stats');
var Q = require('q');
function getPlayerStats(gamesFilePath, playerID, cb) {
// load() returns a promise
playerStats.load(gamesFilePath, playerID)
.then(function (games) {
// Q.all() returns a promise
return Q.all([
// calcBest() returns a promise
playerStats.calcBest(games),
// calcAvg() returns a promise
playerStats.calcAvg(games)
]);
})
.done(function (allStats) {
cb(null, {best: allStats[0], avg: allStats[1]});
}, function (err) {
cb(err);
});
}
var gamesFilePath = path.join(__dirname, 'games.json');
getPlayerStats(gamesFilePath, 42, function (err, stats) {
if (err) {
console.error(err);
return process.exit(1);
}
console.log('best:', stats.best);
console.log('avg: ', stats.avg)
});
在清单 14-7 中,只有最后的done()调用接收解析和拒绝回调;所有其他调用只接收一个解析回调(来自playerStats模块的函数)。被连续调用的变量称为承诺链。如果这些中间的then()调用之一产生了错误,会发生什么呢?与异步回调模型不同,promises 将通过 promise 链自动传播错误,直到错误被处理(类似于结构化异常处理)。有一些特定的规则和用例可以改变这种行为,但一般来说,它完全按照人们的预期工作。
这个例子的其他有趣的方面将在后面解释(比如实现和拒绝回调的返回值如何影响承诺链)。然而,很明显,承诺可以减少回调嵌套和自动化错误传播,这是异步 JavaScript 代码中出现的两个主要问题。
阿 q 的承诺
q 是一个开源的 JavaScript 库,它实现了 Promises/A+规范,但它并不是开发者唯一可用的库。其他几个库,如 when.js 和 Bluebird,也提供了这些名称,这是一个值得注意的事实,因为该规范声明的目标是提供“一个可互操作的基础,所有 Promises/A+符合 promise 的实现都可以依赖这个基础来提供。”这意味着任何符合规范的 promise 库都可以与任何其他符合规范的库一起使用。开发者不必被迫在一系列竞争对手中做出选择。大多数有前途的库都提供辅助功能来补充核心的可移植接口。开发者可以根据需要自由选择和混合解决不同问题的 promise 库。(不幸的是,不符合 Promises/A+规范的库,比如 jQuery。延期,不会这样整合。)
q 是本章的主题,有几个强有力的理由:
- 它符合 Promises/A+规格。
- 它是由 Kris Kowal 写的,他是规范的一个贡献者。
- 它在 JavaScript 社区(客户端和服务器端)享有广泛的采用和支持。
- 谷歌支持的流行浏览器框架 AngularJS 大量借鉴了 q。
本章的其余部分将根据异步回调驱动代码上的承诺来研究 Q 的实现。
延期和承诺
虽然 Promises/A+规范定义了可命名对象的行为方式,但它并没有明确说明异步操作应该如何触发提供给可命名对象的回调。它只定义了表示承诺中异步操作状态的规则,以及值和错误如何通过承诺链传播。实际上,许多 promise 库使用一个名为 deferred 的对象来操纵 promise 的状态。延迟通常是首先创建的,连接起来处理异步操作的解析,然后生成一个承诺供以后调用代码使用。清单 14-8 展示了如何创建一个延期并返回它的承诺。
Listing 14-8. Creating a Deferred
var Q = require('q');
function asyncFunc() {
// create a deferred
var d = Q.defer();
// perform some async operation and
// manipulate the *deferred*
// return the deferred’s promise
return d.promise;
}
// the function returns a thenable!
asyncFunc().then(function () {
// success :)
}, function () {
// error :(
});
在清单中,14-8 Q.defer()被调用来创建一个延迟对象,当异步代码实际运行时,这个延迟对象将被用来在将来操纵承诺的状态(稍后会详细介绍)。这里重要的是被延迟的拥有一个承诺——一个从asyncFunc()返回的承诺,通过调用它的then()方法可以将回调附加到该承诺上。对asyncFunc()的实际调用和对返回承诺的状态更改的订阅都被安排在一起。然而asyncFunc()选择解决还是拒绝它的延期(从而改变承诺返回的状态)完全取决于开发者。
清单 14-9 是前面提到的虚构的playerStats模块的calcAvg()函数的简单实现。使用归约运算对一系列数字求和,然后除以序列长度(得出平均值)是一种同步运算。为了使其异步,代码被包装在 Node.js 函数process.nextTick()中,该函数调度代码在事件循环的下一次迭代中运行。(用setTimeout()或setImmediate()也可以完成同样的操作。)如果计算成功,则使用d.resolve()将承诺置于 resolved 状态,该状态接受一些要传递给任何附加到承诺的解析回调的值。同样,如果出现错误(例如,games数组的长度为零,产生一个被零除的错误),promise 通过d.reject()被置于拒绝状态。
Listing 14-9. Using a Deferred in the calcAvg() Implementation
// example-004/player-stats.js
var Q = require('q');
module.exports = {
// load: function (gamesFilePath, playerID) {...}
// calcBest: function (games) {...},
calcAvg: function (games) {
var stats = {
totalRounds: 0,
avgRoundsWon: 0,
avgRoundsLost: 0
};
var deferred = Q.defer();
process.nextTick(function () {
if (games.length === 0) {
deferred.reject(new Error('no games'));
return;
}
var wins = 0, losses = 0;
games.forEach(function (game) {
if (game.rounds === 0) return;
stats.totalRounds += game.rounds;
wins += game.won;
losses += game.lost;
});
stats.avgRoundsWon = (wins / stats.totalRounds * 100)
.toFixed(2) + '%';
stats.avgRoundsLost = (losses / stats.totalRounds * 100)
.toFixed(2) + '%';
deferred.resolve(stats);
});
return deferred.promise;
}
};
清单 14-10 展示了延迟和承诺如何被用来包装异步的回调驱动的 API。
Listing 14-10. Using a Deferred to Wrap an Asynchronous, Callback-Driven API
// example-005/callbackdb/database.js
'use strict';
module.exports = {
customer: {
// requires a callback
find: function (criteria, cb) {
cb(null, {
id: criteria.id,
name: 'Nicholas Cloud'
});
}
}
};
// example-005/callbackdb/find-customer-callback.js
var Q = require('q'),
db = require('./database');
function loadCustomer(customerID) {
var d = Q.defer();
// db.customer.find() is asynchronous
db.customer.find({id: customerID}, function (err, customer) {
if (err) {
return d.reject(err);
}
d.resolve(customer);
});
return d.promise;
}
loadCustomer(1001).then(function (customer) {
console.log('found', customer.id, customer.name);
}, function (err) {
console.error(err);
});
这种包装异步代码的模型非常普遍,事实上,Q 提供了许多方便的方法来减轻编写样板代码的负担。q 的延迟对象有一个makeNodeResolver()方法,当被调用时,创建一个伪回调,可以传递给任何标准的基于异步回调的函数。然而,当这个回调被调用时,它只是用适当的值或错误来改变 deferred 的状态,无论哪一个恰好被传递给回调。清单 14-11 展示了一个解析器如何取代手工编写的回调函数。
Listing 14-11. Making a Node Resolver Callback
// example-005/callbackdb/database.js
'use strict';
module.exports = {
customer: {
// requires a callback
find: function (criteria, cb) {
cb(null, {
id: criteria.id,
name: 'Nicholas Cloud'
});
}
}
};
// example-005/callbackdb/find-customer-makenoderesolver.js
var Q = require('q'),
db = require('./database');
function loadCustomer(customerID) {
var d = Q.defer();
// db.customer.find() is asynchronous
var deferredCallback = d.makeNodeResolver();
db.customer.find({id: customerID}, deferredCallback);
return d.promise;
}
loadCustomer(2001).then(function (customer) {
console.log('found', customer.id, customer.name);
}, function (err) {
console.error(err);
});
在这种情况下,调用loadCustomer()的客户端代码期望一个承诺,但是数据库 API 期望一个回调,所以makeNodeResolver()自然适合。如果反过来也是正确的——如果客户端代码期望将回调传递给loadCustomer()函数,但是数据库实际上返回了一个承诺——对数据库的承诺调用nodeify()方法将适当地调用回调。清单 14-12 中的承诺正是以这种方式实现的。
Listing 14-12. Passing a Traditional Asynchronous Callback to a Promise with nodeify()
// example-005/promisedb/database.js
'use strict';
var Q = require('q');
module.exports = {
customer: {
// returns a promise; does not use callbacks
find: function (criteria) {
return Q({
id: criteria.id,
name: 'Nicholas Cloud'
});
}
}
};
// example-005/promisedb/find-customer-nodeify.js
var Q = require('q'),
db = require('./database');
function loadCustomer(customerID, cb) {
// db.customer.find() returns a promise
db.customer.find({id: customerID})
.nodeify(cb);
/* equivalent to:
*
* db.customer.find({id: customerID}).then(function (customer) {
* cb(null, customer);
* }, function (err) {
* cb(err);
* });
*/
}
loadCustomer(3001, function (err, customer) {
if (err) {
return console.err(err);
}
console.log('found', customer.id, customer.name);
});
值和错误
用简单的值或错误来解决延迟问题通常可以满足大多数需求,但是 Promises/A+规范和 Q 的实现定义了许多 promise 解决规则,让开发人员可以进一步控制 promise 状态。
用承诺值解决延期
延迟的行为会根据传递给它们的resolve()方法的“值”而改变。如果该值是一个普通对象或原语,它将按原样传递给附属于延期者承诺的解析回调。如果“值”是另一个承诺,如清单 14-13 所示,第二个承诺的状态将被“转发”给第一个承诺的适当回调:如果第二个承诺被解析,第一个承诺的解析回调将接收它的值;如果它被拒绝,第一个的拒绝回调将收到它的错误。
Listing 14-13. Resolving a Deferred with a Promise
// example-006/index.js
'use strict';
var Q = require('q'),
airport = require('./airport'),
reservation = require('./reservation');
function findAvailableSeats(departingFlights) {
var d = Q.defer();
process.nextTick(function () {
var availableSeats = [];
departingFlights.forEach(function (flight) {
var openFlightSeats = reservation.findOpenSeats(flight);
availableSeats = availableSeats.concat(openFlightSeats);
});
// resolve the deferred with an object value
if (availableSeats.length) {
d.resolve(availableSeats);
} else {
d.reject(new Error('sorry, no seats available'));
}
});
return d.promise;
}
function lookupFlights(fromAirport, toAirport, departingAt) {
var d = Q.defer();
process.nextTick(function () {
var departingFlights = airport.findFlights(
fromAirport, toAirport, departingAt
);
// resolve the deferred with another promise
d.resolve(findAvailableSeats(departingFlights));
});
return d.promise;
}
lookupFlights('STL', 'DFW', '2015-01-10').then(function (seats) {
console.log('available seats:', seats);
}, function (err) {
console.error('sorry:', err);
});
因为第一个延迟最终取决于第二个承诺的解决或拒绝,所以只要第二个承诺也是未决的,它就将保持未决状态。一旦第二个承诺被解决或拒绝,被推迟的承诺也会跟着做,调用适当的回调。
在回调中转发值、错误和承诺
一旦解析或拒绝回调收到一个值或错误,就会发生几件事。如果已经到达承诺链的末尾(或者如果没有其他链接的承诺),通常客户端代码会对该值做一些事情或者记录错误。
然而,因为当调用then()时,变量总是返回另一个承诺,所以可以使用解析和拒绝回调来操作值和错误,然后将它们转发给新的承诺,由后面的回调来处理。
操作一个值非常简单。只需改变或转换传递给解析回调的值并返回它。在清单 14-14 中,一个数组在数据库承诺的解析回调中被操作,然后在值被适当过滤后被返回。
Listing 14-14. Returning a Value in a Resolution Callback
// example-007/index.js
'use strict';
var db = require('./database');
function findPalindromeNames() {
// db.customers.find() returns a promise
return db.customer.find().then(function (customers) {
// return a filtered array that will be forwarded
// to the next resolution callback
return customers.filter(function (customer) {
// filter customers with palindrome names
var name = customer.name.toLowerCase();
var eman = name.split('').reverse().join('');
return name === eman;
}).map(function (customer) {
// return only customer names
return customer.name;
});
});
}
findPalindromeNames().then(function (names) {
console.log(names);
});
解析回调也可以将错误转发到承诺链的下游。如果是,那么将调用下一个拒绝回调,并返回错误。在清单 14-15 中,如果用户提交了太多的猜测(对于一些虚构的竞赛),就会创建一个错误,并在一个名称的解析回调中抛出。该错误将传播到承诺链中的下一个拒绝回调。
Listing 14-15. Throwing an Error in a Resolution Callback
// example-008/index.js
'use strict';
var db = require('./database');
var MaxGuessError = require('./max-guess-error');
var MAX_GUESSES = 5;
function submitGuess(userID, guess) {
// db.user.find() returns a promise
return db.user.find({id: userID}).then(function (user) {
if (user.guesses.length === MAX_GUESSES) {
throw new MaxGuessError(MAX_GUESSES);
}
// otherwise update the user...
});
}
submitGuess(1001, 'Professor Plum').then(function () {
// won’t get called if there is an error
console.log('guess submitted');
}, function (maxGuessError) {
// oops, an error occurred!
console.error('invalid guess');
console.error(maxGuessError.toString());
});
回想一下,在传统的异步回调模型中,抛出的异常必须手动处理和解释(这意味着不可预测的异常通常会逃过审查)。q 自动处理这个;在可调用回调中抛出的任何异常都将被捕获并适当地传播,即使所有可调用回调都是异步执行的。
拒绝回电遵循类似的规则,但有一个心理扭曲。他们不接受价值观;相反,它们会收到错误,所以开发人员可能会合理地期望从拒绝回调返回错误会触发承诺链下游的下一个拒绝回调。但这是不正确的。在清单 14-16 中,链中的最后一个承诺将被解决,而不是被拒绝,即使从submitGuess()中的拒绝回调返回一个错误。
Listing 14-16. Returning an Error in a Rejection Callback
// example-009/index.js
'use strict';
var db = require('./database');
var NotFoundError = require('./not-found-error');
function submitGuess(userID, guess) {
// db.user.find() returns a promise
return db.user.find({id: userID}).then(function (user) {
/*
* database generates an error so this promise
* won’t be resolved
*/
}, function (err) {
var notFoundError = new NotFoundError(userID);
notFoundError.innerError = err;
return notFoundError;
});
}
submitGuess(1001, 'Colonel Mustard').then(function (value) {
/*
* oops, this promise was resolved, and
* value === notFoundError!
*/
console.log('guess submitted');
console.log(value);
}, function (notFoundError) {
/*
* you expect this promise to get rejected...
* but you are wrong
*/
console.error('an error occurred');
console.error(notFoundError);
});
这似乎违反直觉。如果从拒绝回调中返回一个错误,人们可能会合理地认为它会传播,但事实并非如此。然而,再一看,这开始有意义了,因为它允许开发人员处理不需要传播的错误,并且仍然通过返回一些值来优雅地解决承诺链。
如果清单 14-16 中的代码被修改为当数据库变得不可用时对猜测进行排队,那么即使产生了错误,解析承诺链也是有意义的,如清单 14-17 所示。
Listing 14-17. Muffling an Error in a Rejection Callback
// example-010/index.js
'use strict';
var db = require('./database');
var guessQueue = require('./guess-queue');
function submitGuess(userID, guess) {
// db.user.find() returns a promise
return db.user.find({id: userID}).then(function (user) {
/*
* database generates an error so this promise
* won’t be resolved
*/
}, function (err) {
console.error(err);
/*
* database is probably offline, queue for future
* processing
*/
return guessQueue.enqueue(userID, guess);
});
}
submitGuess(1001, 'Miss Scarlett').then(function (value) {
/*
* guess is queued when the database connection
* fails, so the error is suppressed
*/
console.log('guess submitted');
}, function (notFoundError) {
console.error('an error occurred');
console.error(notFoundError);
});
与解析回调一样,为了正确地将下一个承诺的状态设置为 rejected,必须在拒绝回调中抛出错误,如清单 14-18 所示。
Listing 14-18. Throwing an Error in a Rejection Callback
// example-011/index.js
'use strict';
var db = require('./database');
var NotFoundError = require('./not-found-error');
function submitGuess(userID, guess) {
// db.user.find() returns a promise
return db.user.find({id: userID}).then(function (user) {
/*
* database generates an error so this promise
* won’t be resolved
*/
}, function (err) {
/*
* error is *thrown*, not returned
*/
var notFoundError = new NotFoundError(userID);
notFoundError.innerError = err;
throw notFoundError;
});
}
submitGuess(1001, 'Mrs. Peacock').then(function (value) {
/*
* since error was thrown within the promise
* the promise will not be resolved
*/
}, function (notFoundError) {
/*
* the promise is rejected, as expected!
*/
console.error('an error occurred');
console.error(notFoundError);
});
正如延迟可以用其他承诺来解决一样,启用回调也可以返回承诺,当这些承诺被解决或拒绝时,将会影响回调链的状态。解决和拒绝回调都可能返回承诺。在清单 14-19 中,如果数据库调用成功,则返回第二个承诺,否则抛出异常。
Listing 14-19. Returning Another Promise in a Resolution Callback
// example-012/index.js
'use strict';
var db = require('./database');
var MaxGuessError = require('./max-guess-error');
var MAX_GUESSES = 5;
function submitGuess(userID, guess) {
// db.user.find() returns a promise
return db.user.find({id: userID}).then(function (user) {
if (user.guesses.length === MAX_GUESSES) {
throw new MaxGuessError(MAX_GUESSES);
}
// otherwise update the user
user.guesses.push(guess);
return db.user.update(user);
});
}
submitGuess(1001, 'Professor Plum').then(function () {
/*
* should be called with the database has
* finished updating the user
*/
console.log('guess submitted');
}, function (maxGuessError) {
console.error('invalid guess');
console.error(maxGuessError.toString());
});
将简单的价值观转化为承诺
q 可以将任何值转化为承诺,只需调用Q作为函数,并将值作为唯一的参数。清单 14-20 包装了一个简单的字符串,该字符串将被用作传递给承诺链中下一个解析处理程序的值。
Listing 14-20. Turning a Value into a Promise
// example-013/index.js
'use strict';
var Q = require('q');
Q('khan!').then(function (value) {
console.log(value); //khan!
});
这可能看起来微不足道,但是这是将现有的、带有实际返回值的同步代码包装到基于 promise 的 API 中的一种便捷方式。您可以不带值地调用Q,这将创建一个处于已解决状态的空承诺。
用另一个库的承诺调用Q,也会将该承诺包装在 Q 的接口中。当开发人员在处理另一个没有对等物的 promise 库时,希望使用 Q 的 promise 方法如nodeify()时,这非常有用。
报告进度
有时异步操作需要很长时间才能完成。在此期间,向客户端代码提供一些进度指示可能会有所帮助,无论是作为简单的指示器(例如,完成百分比),还是在数据可用时交付它们(例如,由 EventEmitter 引发的事件)。q 通过向then()添加第三个回调参数来扩充 Promises/A+规范,如清单 14-21 所示,该参数可用于在进度事件发生时捕获它们。
Listing 14-21. Q’s Thenable Method Signature
/**
* @param {Function} [onFulfilled]
* @param {Function} [onRejected]
* @param {Function} [onProgress]
* @returns {Promise}
*/
promise.then(onFulfilled, onRejected, onProgress)
虽然 Promises/A+规范没有为进度通知建立模式,但是 Q 仍然符合,因为它的所有名称仍然支持规定的then()方法签名。
正如实现和拒绝回调在延迟的被实现或拒绝时被调用一样,进度回调在延迟的notify()方法被调用时也被调用。该方法接受单个参数,然后将该参数传递给进度回调。在清单 14-22 中,一个长时间运行的异步操作跟踪它做一些工作的尝试次数(也许调用一个经常没有响应的 API)。每次尝试时,计数器都会递增,其值会传递给notify()方法。进度回调立即接收这些数据。一旦解决了延迟,承诺链就完成了,最后的done()回调被调用。
Listing 14-22. Notifying the Deferred’s Promise
<!-- example-014/index.html -->
<form>
<p>The UI thread should respond to text field input, even though many DOM elements are being added.</p>
<input type="text" placeholder="type something here" />
</form>
<div id="output"></div>
<script>
(function () {
var Q = window.Q;
var output = document.querySelector('#output');
function writeOutput(msg) {
var pre = document.createElement('pre');
pre.innerHTML = msg;
output.insertBefore(pre, output.firstChild);
}
function longAsync() {
var d = Q.defer();
var attempts = 0;
var handle = setInterval(function () {
// each time the scheduled code runs,
// send a notification with the attempt
// number
attempts += 1;
d.notify(attempts);
if (attempts === 1200) {
clearInterval(handle);
return d.resolve();
}
}, 0);
return d.promise;
}
// not using the rejection callback, only the
// resolution and progress callbacks
longAsync().then(function () {
writeOutput('(done)');
}, null, function (attempts) {
writeOutput('notification: ' + attempts);
});
}());
</script>
值得注意的是,虽然任何附加到可调用对象的解析或拒绝回调都将根据承诺链中已经发生的情况来调用,但只有那些在通知事件之前附加的进度回调才会实际接收到更新。考虑清单 14-23 中的代码和清单 14-24 中产生的控制台输出。
Listing 14-23. Notifying a Deferred’s Promise Before Progress Callback Is Added
// example-015/index.js
'use strict';
var Q = require('q');
function brokenPromise() {
var d = Q.defer();
process.nextTick(function () {
console.log('scheduled first');
d.notify('notifying');
d.resolve('resolving');
console.log('logging');
});
return d.promise;
}
var promise = brokenPromise();
process.nextTick(function () {
console.log('scheduled second');
promise.then(function (value) {
console.log(value);
}, null, function (progress) {
console.log(progress);
});
});
在清单 14-23 中,一个延迟被创建,然后被异步通知和解析。只有在 deferred 的方法被调用之后,回调才会被附加到then()。清单 14-24 中的控制台输出反映了代码作为 Node.js 脚本运行时发生的情况。
Listing 14-24. Console Output Without Notification
$ node index.js
scheduled first
logging
scheduled second
resolving
logging语句显示在notifying或resolving之前,因为当函数brokenPromise()中的预定代码实际运行时,没有回调附加到延期者的承诺上。在调用了brokenPromise()之后,更多的代码被安排来将一个解析回调和一个进度回调附加到承诺上。当预定的代码运行时,进度回调被完全忽略,而解析回调接收它的值。为什么呢?因为进度回调是在调用 deferred 的notify()方法后调度的代码中添加的。根据 Promises/A+规范,当新的回调被添加到一个可调用对象中时,解决和拒绝肯定会被传播,但是 Q 将通知视为“实时”事件,只传播到通知时附加的回调。
一切都结束了
为了进一步模仿同步代码约定,Q 提供了catch()和finally()方法,它们在同步的结构化异常处理中并行处理各自的对应方法。
catch()方法实际上是then(null, onRejection)的别名。和then()一样,catch()不会中断承诺链,但是它允许开发人员处理承诺链中任意点的错误。清单 14-25 中的代码使用catch()来拦截潜在的 HTTP 故障。因为catch()本身返回一个承诺,它的回调可以返回任何值(或者抛出另一个错误)以便在承诺链中稍后处理。
Listing 14-25. Catching Errors in a Promise Chain
// example-016/index.js
'use strict';
var Q = require('q');
var api = require('./api');
var InvalidTeamError = require('./invalid-team-error');
function loadTeamRoster(teamID) {
// api.get() returns a promise
return api.get('/team/' + teamID + '/roster')
.catch(function (err) {
/*
* throw a meaningful exception rather than
* propagate an HTTP error
*/
if (err.statusCode === 404) {
throw new InvalidTeamError(teamID);
}
});
}
loadTeamRoster(123).then(function (roster) {
console.log(roster);
}).catch(function (err) {
console.error(err.message);
});
finally()方法的行为类似于then(),但有一点需要注意:它可能不会改变它接收到的任何值或错误,尽管它可能会返回一个全新的承诺,并沿着承诺链进行传播。如果它没有返回任何东西,那么它收到的原始值或错误将被传递。
finally()方法的真正目的反映了try/catch块的finally部分的目的。它允许代码在执行线程继续执行之前清理资源。清单 14-26 展示了如何使用finally()块关闭数据库连接。不管连接或更新是否成功,finally()回调中的代码将一直运行,如果数据库句柄保持打开,就清理它。
Listing 14-26. Cleaning Up Resources in a Promise Chain
// example-017/index.js
'use strict';
var Q = require('q');
var db = require('./database');
var user = {
id: 1001,
name: 'Nicholas Cloud',
occupation: 'Code Monkey'
};
db.connect().then(function (conn) {
return conn.user.update(user)
.finally(function () {
if (conn.isOpen) {
conn.close();
}
});
});
当调用finally()时,它实际上并没有终止承诺链。但是在代码中可能会有这样的地方,当不再添加处理程序时,您希望在一系列异步操作中处理最终值或错误。这可以通过多种方式实现。终止一个链的最明显的方法是通过忽略由then()创建的最终承诺来简单地中止它。不幸的是,该承诺可能已经被承诺链中的上游代码拒绝。这意味着 Q 将保留 promise 链中产生的任何错误,如果将来添加了拒绝回调,这些错误将不会被处理。如果一个承诺链在没有拒绝回调的情况下被“终止”,如清单 14-27 所示,错误永远不会被报告——它消失在以太中——并且解决回调永远不会被执行。
Listing 14-27. Improperly Terminating a Promise Chain
// example-018/index01.js
'use strict';
var Q = require('q');
function crankyFunction() {
var d = Q.defer();
process.nextTick(function () {
d.reject(new Error('get off my lawn!'));
});
return d.promise;
}
// no rejection callback to display the error
crankyFunction().then(function (value) {
console.log('never resolved');
});
为了应对这一点,Q 创建的承诺也有一个done()方法,它不返回承诺,并在事件循环的未来回合中抛出任何未处理的错误,以通过其他方式解决。清单 14-28 展示了这种方法。
Listing 14-28. Terminating a Promise Chain with done()
// example-018/index02.js
crankyFunction().done(function (value) {
//...
});
即使没有提供拒绝回调,JavaScript 上下文仍然会终止,因为 Q 的done()方法会自动抛出一个未处理的错误。清单 14-29 中的控制台输出显示了如果crankyFunction()的承诺链被done()方法终止会发生什么。
Listing 14-29. Unhandled Error Thrown by done()
$ node index02.js
/.../node_modules/q/q.js:126
throw e;
^
Error: get off my lawn!
at /.../code/q/example-018/index02.js:7:14
at process._tickCallback (node.js:419:13)
at Function.Module.runMain (module.js:499:11)
at startup (node.js:119:16)
at node.js:906:3
带 Q 的流量控制
承诺链是扁平化异步的、基于回调的 API 的极好方法。它们还以自己的方式模拟开发人员在同步代码中熟悉的结构化异常处理模式。这些特性简化了基于承诺的代码中的流控制,但是可以利用一点创造性来为以下更复杂的流利用承诺,在这些流中,可以将许多异步操作“分组”并作为一个整体对待:
- 顺序流:独立的异步操作被调度和执行,一次一个,每个操作在前一个操作完成后开始。
- 并行流:独立、异步的操作同时被调度,并聚合所有结果。
- 管道流:一次执行一个相关的异步操作,每个操作都依赖于前一个操作中创建的值。
在每种类型的流中,拒绝一个操作通常会触发流的失败。顺序流关注的是副作用,而不是值(这意味着它实际上并不获取或创建将在以后使用的数据),尽管它可以在必要时聚合获取的数据。并行流聚集来自许多不同异步操作的数据,并在所有操作完成时交付其结果。管道流通过一系列操作传递一些数据,因此至少有一个操作将获取或创建数据,并且在流的末尾将有一些值要处理。
顺序流程
清单 14-30 中的函数代表了 web 应用共有的一系列步骤。他们更改用户的密码。当然,每个步骤都是高度简化的,但是三个基本步骤必须按顺序完成:
Change the actual password. Notify the user (probably via e-mail) that their password has been changed. Because our company is a Good Corporate Citizen, it forwards the password on to the National Security Agency (NSA). Listing 14-30. Functions Executed in a Sequential Flow
// example-019/index.js
function changePassword(cb) {
process.nextTick(function () {
console.log('changing password...');
cb(null);
});
}
function notifyUser(cb) {
process.nextTick(function () {
console.log('notifying user...');
var randomFail = Date.now() % 2 === 0;
cb(randomFail ? new Error('fail!') : null);
});
}
function sendToNSA(cb) {
process.nextTick(function () {
console.log('sending to NSA...');
cb(null);
});
}
每个操作的功能都是异步的,并且符合标准的 Node.js 回调模式。在清单 14-30 的例子中,changePassword()和sendToNSA()函数总是成功,但有趣的是,根据计算的值,notifyUser()函数有时成功,有时失败。
为了在一个连续的承诺流中编排这三个操作,它们首先以适当的执行顺序被添加到一个“步骤”数组中。通过调用不带任何参数的Q创建一个“空”承诺(lastPromise);这将是连续承诺链中第一个已解决的承诺。
在清单 14-31 中,代码遍历步骤数组,将每个步骤封装在一个承诺中。对于每次迭代,它调用lastPromise上的then()方法,并将结果——一个新生成的承诺——赋回给lastPromise变量。(这在一个循环中建立了一个承诺链。)
在每个解析回调中,代码通过将当前的“步骤”(清单 14-30 中定义的函数之一)传递给Q.denodeify(),将其转换为承诺。同样可以通过设置一个延迟并使用deferred.makeNodeResolver()来手动完成,如清单 14-11 所示,但是Q.denodeify()简化了这个过程。结果是一个承诺,它可以作为承诺链中的下一步从解析回调中返回。
Listing 14-31. Orchestrating a Sequential Flow with Promises
// example-019/index.js
var Q = require('q');
var steps = [changePassword, notifyUser, sendToNSA];
var lastPromise = Q();
steps.forEach(function (step) {
lastPromise = lastPromise.then(function () {
/*
* denodeify and invoke each function step
* to return a promise
*/
return Q.denodeify(step)();
});
});
lastPromise.done(function () {
console.log('all done');
}, function (err) {
console.error(err);
});
最后,解决和拒绝回调被附加到循环创建的最后一个承诺上。
当下一个预定循环执行时,第一步将开始。当它解决时,将调用链中的下一个承诺,依此类推,直到到达顺序流的末尾。如果在链中的任何一点发生错误,它将立即导致调用最终拒绝回调。(没有中间拒绝回调;当任何一个步骤失败时,顺序流应该失败。)如果所有步骤都解决了,最终的解决回调将输出控制台消息:all done。
平行流
应用通常会从各种来源获取数据,然后作为一个统一的整体发送给某个客户端。在清单 14-32 中,用户数据和美国各州的列表被同时获取,也许是为了一个网页,用户可以在这个网页上更改他或她的邮寄地址。
Listing 14-32. Functions Executed in a Parallel Flow
// example-20/index01.js
function getUser(id, cb) {
process.nextTick(function () {
cb(null, {id: id, name: 'nick'});
});
}
function getUSStates(cb) {
process.nextTick(function () {
cb(null, ['MO', 'IL' /*, etc.*/]);
});
}
因为这两个异步函数彼此无关,所以应该同时调度它们(而不是一个等待另一个完成),这是有意义的。Q 的效用函数all()接受一组要同时调度的承诺,但是由于清单 14-32 中的函数还不是承诺,它们必须用 Q 的一个函数调用方法进行转换。因为函数符合 Node.js 回调签名,清单 14-33 中的代码将每个函数传递给Q.nfcall() (node-function-call),后者将每个函数包装在承诺中,使用延迟来提供适当的回调。因为getUser()函数接受单个数据参数,所以在创建getUser()承诺时,用户 ID 必须作为第二个参数传递给Q.nfcall()。在内部调用时,q 会将用户 ID 作为第一个参数绑定到getUser()函数。
q 的all()函数本身返回一个承诺,这个承诺将用一个值数组来解析。数组中每个值的顺序位置将对应于传递给Q.all()的数组中承诺的顺序。在这种情况下,用户数据将占用索引 0,而美国州数组将占用索引 1。
如果任何承诺中出现错误,将调用聚合承诺的拒绝回调。
Listing 14-33. Orchestrating a Parallel Flow with Promises
// example-20/index01.js
var Q = require('q');
Q.all([
Q.nfcall(getUser, 123),
Q.nfcall(getUSStates)
]).then(function (results) {
console.log('user:', results[0]);
console.log('states:', results[1]);
}, function (err) {
console.error('ERR', err);
});
因为访问数组中的值是笨拙的,所以可以用spread()方法继续一个承诺,该方法的操作与then()相同,除了它将结果数组“分解”成实际的单个参数,如清单 14-34 所示。
Listing 14-34. Spreading Results
// example-20/index02.js
var Q = require('q');
Q.all([
Q.nfcall(getUser, 123),
Q.nfcall(getUSStates)
]).spread(function (user, states) {
console.log('user:', user);
console.log('states:', states);
}, function (err) {
console.error('ERR', err);
});
q 还提供了一个伴随函数Q.allSettled(),它的行为类似于Q.all(),但有一些关键的不同。首先,它将总是调用聚合承诺的解析回调。其次,每个值将是一个具有state属性的对象,该属性将报告创建该值的承诺的实际状态,以及依赖于state的值的下列属性之一:
value,如果承诺解决,它将包含由承诺创建的数据reason,包含承诺被拒绝时产生的任何错误
选择使用Q.all()还是Q.allSettled()将取决于应用代码的性质,但是两者都可以用来创建并行流。
管道流动
当一组数据需要根据一些顺序规则集进行转换时,管道流非常有用。管道和前面介绍的顺序流之间的区别在于,管道中的每一步都将数据传递给下一步,而顺序流主要关注的是创建一系列线性副作用。
清单 14-35 中的管道函数代表了一个简化的过滤系统,也许是为一个寻找客户人才的招聘机构。loadCandidates()函数将“获取”一个可能的候选列表,其他函数将负责根据一些标准来减少选择。注意filterBySkill()和groupByStates()实际上是工厂函数。它们接受一些配置参数(所需的技能和状态),然后返回一个函数,该函数接受要在管道中使用的 Node.js 回调。
Listing 14-35. Functions Executed in a Pipeline Flow
// example-021/index.js
function loadCandidates(cb) {
console.log('loadCandidates', arguments);
process.nextTick(function () {
var candidates = [
{name: 'Nick', skills: ['JavaScript', 'PHP'], state: 'MO'},
{name: 'Tim', skills: ['JavaScript', 'PHP'], state: 'TN'}
];
cb(null, candidates);
});
}
function filterBySkill(skill) {
return function (candidates, cb) {
console.log('filterBySkill', arguments);
candidates = candidates.filter(function (c) {
return c.skills.indexOf(skill) >= 0;
});
cb(null, candidates);
};
}
function groupByStates(states) {
var grouped = {};
states.forEach(function (state) {
grouped[state] = [];
});
return function (candidates, cb) {
console.log('groupByStates', arguments);
process.nextTick(function () {
candidates.forEach(function (c) {
if (grouped.hasOwnProperty(c.state)) {
grouped[c.state].push(c);
}
});
cb(null, grouped);
});
};
}
loadCandidates()函数被直接添加到steps数组中,但是filterBySkill()和groupByStates()函数是用它们的初始值调用的。
像串行和并行流一样,管道流使用承诺链来协调执行顺序。然而,在清单 14-36 中,每个步骤创建的结果——传递给每个承诺的解析回调的值——被放入一个数组中,并作为参数传递给序列中的下一个承诺。在并行流程示例中,Q.nfcall()用于调用每个步骤;在这个例子中,使用了Q.nfapply()(节点-功能-应用)。每个调用都模仿其本地 JavaScript 对应物(Function.prototype.call()和Function.prototype.apply()),这就是为什么使用数组将结果传递给每个步骤,而不是将结果作为直接参数传递。这是必要的,因为管道的第一步loadCandidates()不接受任何参数(除了回调)。向Q.nfapply()传递一个空数组可以确保函数被正确调用。
Listing 14-36. Orchestrating a Pipeline Flow with Promises
// example-021/index.js
var Q = require('q');
var steps = [
loadCandidates,
filterBySkill('JavaScript'),
groupByStates(['MO', 'IL'])
];
var lastPromise = Q();
steps.forEach(function (step) {
lastPromise = lastPromise.then(function (result) {
var args = [];
if (result !== undefined) {
args.push(result);
}
return Q.nfapply(step, args);
});
});
lastPromise.done(function (grouped) {
console.log('grouped:', grouped);
}, function (err) {
console.error(err);
});
当管道完成时,传递给最后一个异步回调的最后一个值将是传递给done()解析回调的值。如果任何异步操作产生错误,将调用拒绝回调。
对于清单 14-35 中的每个异步函数,每个回调都传递一个值。即使 Promises/A+规范规定只能将一个值作为解析参数传递,也可以将多个值传递给这些回调。q 通过将传递给异步函数回调的所有值打包成一个数组,然后传递给 promise 的 resolution 回调,缓解了这种差异。然后,这个数组需要被传递给Q.nfapply(),因为它包含了所有要用作下一个函数步骤的参数的数据。
摘要
回调是处理异步代码的标准机制。它们为开发人员提供了一种控制流机制,以便在事件循环的下一次循环之后“继续”执行。但是回调会很快变成嵌套的、复杂的、难以管理的。
使用像 Q 这样的 promise 库来封装异步操作,来“扁平化”代码,可以极大地改进代码库。q 能够自动传播值和错误,以异步方式链接回调,在长时间运行的异步操作中报告进度,并在承诺链结束时处理未处理的错误,这使它成为任何开发人员工具箱中的强大工具。
q 可以用来管理琐碎的、线性的程序流,但是稍加创新也可以适应更复杂的流。本章研究了顺序流、并行流和管道流,但是 Q 的实用方法在编排其他流时为开发人员提供了额外的灵活性。
相关资源
十五、Async.js
总是有新的东西,总是有我没想到的东西,有时并不可怕。—罗伯特·乔丹
协调软件流程可能很麻烦,尤其是当异步操作在不同时间完成时。第十六章展示了如何用承诺来解决这个问题。本章讨论 Async.js,这是一个回调驱动的 JavaScript 库,它提供了一套强大的函数来管理异步集合操作和控制流。
第十六章讲述了异步代码可能会出现问题的三种常见流程:顺序流程、并行流程和管道流程。为了用承诺来处理这些流,第十六章展示了如何用 Q 的助手方法来适应每个面向回调的任务,以便每个任务都可以方便地包装在承诺中。然而,Async.js 库包含了回调驱动的异步编程方法,但是这种方法避免了回调驱动的代码(如嵌套回调)带来的许多缺点。
许多 Async.js 控制流函数遵循类似的模式:
The first argument to each control flow function is typically an array of functions to be executed as tasks. Task function signatures will vary a bit based on the exact Async.js control flow function used, but they will always receive a Node.js-style callback as a last argument. The last argument to each control flow function is a final callback function to be executed when all tasks are complete. The final control flow function also receives a Node.js-style callback and may or may not receive additional arguments as well. Note
Node.js 样式的回调只是一个回调函数,它总是将错误作为它的第一个参数。当回调被调用时,要么将一个错误对象作为其唯一的参数传递,要么将null作为错误值传递,并将任何其他值作为附加参数传递。
清单 15-1 展示了这种模式通常是如何应用的。
Listing 15-1. Flow Control Function Pattern
var tasks = [
function (/*0..n args, */ cb) {/*...*/},
function (/*0..n args, */ cb) {/*...*/},
function (/*0..n args, */ cb) {/*...*/}
];
function finalCallback (err, result) {/*...*/};
async.someFlowControlFunction(tasks, finalCallback);
本章的其余部分将研究大量的控制流函数,以及它们与这个通用模式的不同之处。因为所有的流都以相似的方式组织任务和处理错误和值,所以通过对比可以更容易地理解每一个。
Note
Async.js 中 async 的含义与组织异步操作有关。库本身不保证任务函数异步执行。如果开发人员将 Async.js 与同步函数一起使用,每个函数都将同步执行。这条规则有一个半例外。async.memoize()函数(与控制流无关)使函数可缓存,因此后续调用不会实际运行该函数,而是返回缓存的结果。Async.js 强制每个后续调用都是异步的,因为它假设原始函数本身是异步的。
顺序流程
顺序流程是指一系列步骤必须按顺序执行的流程。一个步骤可能直到前一个步骤完成后才开始(除了第一个步骤),如果任何一个步骤失败,整个流程都会失败。清单 15-2 中的函数是更改一个虚构用户密码的步骤,与在第十六章中引入顺序流程的场景相同。然而,这些步骤略有不同。
首先,每一个都被包装在一个工厂函数中,该函数接受一些初始数据并返回一个基于回调的函数,以用作顺序流中的一个步骤。
第二,第一步(包装在changePassword()函数中的任务)实际上将新凭证作为操作结果传递给它的回调函数。顺序流中的步骤不需要生成结果,但是如果一个步骤确实将结果传递给了它的回调函数,那么它对序列中的其他步骤没有影响。如果某些(或所有)步骤依赖于前面步骤的结果,则需要管道流。(管道将在本章后面讨论。)
Listing 15-2. Sequential Steps
// example-001/async-series.js
'use strict';
var async = require('async');
var userService = require('./user-service');
var emailService = require('./email-service');
var nothingToSeeHere = require('./nothing-to-see-here');
function changePassword(email, password) {
return function (cb) {
process.nextTick(function () {
userService.changePassword(email, password, function (err, hash) {
// new credentials returned as results
cb(null,``{email: email, passwordHash: hash}
});
});
};
}
function notifyUser(email) {
return function (cb) {
process.nextTick(function () {
// the email service invokes the callback with
// no result
emailService.notifyPasswordChanged(email, cb);
});
};
}
function sendToNSA(email, password) {
return function (cb) {
process.nextTick(function () {
// the nothingToSeeHere service invokes the
// callback with no result
nothingToSeeHere.snoop(email, password, cb);
});
}
}
在清单 15-3 中,每个工厂函数都用它的初始数据执行,返回添加到一个steps数组中的任务函数。这个数组成为async.series()的第一个参数,后面是一个最终的回调函数,它接收序列执行过程中产生的任何错误,或者是序列中每一步产生的结果数组。如果生成了任何结果,它们将按照它们在steps数组中对应步骤的顺序存储。例如,changePassword()的结果将是results数组中的第一个元素,因为changePassword()作为第一个任务被调用。
Listing 15-3. Sequential Flow
// example-001/async-series.js
var email = 'user@domain.com';
var password = 'foo!1';
var steps = [
//returns function(cb)
changePassword(email, password),
//returns function(cb)
notifyUser(email),
//returns function(cb)
sendToNSA(email, password)
];
async.series(steps, function (err, results) {
if (err) {
return console.error(err);
}
console.log('new credentials:', results[0]);
});
因为这些步骤是异步的,所以不能像调用同步函数那样一次调用一个。但是 Async.js 在内部跟踪每个步骤的执行,只有在调用了前一个步骤的回调时才调用下一个步骤,因此创建了一个顺序流。如果顺序流中的任何步骤向其回调传递了一个错误,该系列将被中止,并且最后一个系列回调将因该错误而被调用。当出现错误时,results值将未定义。
本章中使用的工厂函数是向每个步骤传递初始数据的便捷方式,但它们不是必需的。为了支持 JavaScript 的本地函数绑定工具,工厂可以被删除,如清单 15-4 所示,但是当步骤被实际添加到数组中时,代码变得更加难以阅读。对于不需要初始数据或绑定的简单场景,匿名任务函数可以直接在steps数组中声明。(但是,以一种促进可读性和可维护性的方式命名和声明函数总是一个好主意。)
Listing 15-4. Series Steps with Argument Binding
function changePassword(email, password, cb) {/*...*/}
function notifyUser(email, cb) {/*...*/}
function sendToNSA(email, password, cb) {/*...*/}
var steps = [
changePassword.bind(null, email, password),
notifyUser.bind(null, email),
sendToNSA.bind(null, email, password)
];
在本章的剩余部分,我们将使用工厂函数而不是bind(),但是开发者可以自由选择他们觉得最自然的方法。
平行流
有时,并行运行独立的任务,然后在所有任务完成后汇总结果会很有帮助。JavaScript 是一种异步语言,因此它没有真正的并行性,但连续调度长时间的非阻塞操作将释放事件循环来处理其他操作(如浏览器环境中的 UI 更新,或服务器环境中的额外请求)。可以在事件循环的一个回合中调度多个异步任务,但是无法预测每个任务将在未来的哪个回合完成。这使得从每个任务中收集结果并将它们返回给调用代码变得困难。幸运的是,async.parallel()函数为开发人员提供了这样做的方法。
清单 15-5 展示了两个包装 jQuery GET 请求的函数。第一个获取给定userID的用户数据,第二个获取美国各州的列表。很容易想象,这些功能可能是用户个人资料页面的一部分,用户可以在该页面上更新电话号码、邮政地址等个人信息。当页面加载时,一次性获取这些信息是有意义的。不过,这是两个不同的 API 调用,所以即使它们被同时调度,结果也需要在将来的某个时间点进行处理。
Listing 15-5. Parallel Steps
// example-002/views/async-parallel.html
function getUser(userID) {
return function (cb) {
$.get('/user/' + userID).then(function (user) {
cb(null, user);
}).fail(cb);
};
}
function getUSStates(cb) {
$.get('/us-states').then(function (states) {
cb(null, states);
}).fail(cb);
}
在清单 15-6 中,Async.js 被导入到一个带有标准<script>标签的虚构网页中。使用async.parallel()函数调度任务,像async.series()一样,它接受一组要执行的任务函数和一个接收错误或聚合结果的最终回调函数。并行任务只是接受单个回调参数的函数,一旦任务函数中的异步操作完成,就应该调用这个回调参数。所有回调都符合 Node.js 回调约定。
清单 15-6 中的getUser()函数是一个工厂,它接受一个userID参数并返回一个接受常规 Node.js 风格回调的函数。因为getUSStates()没有实际参数,所以不需要包装在工厂函数中,而是直接使用。
这两个函数都使用 jQuery 的 AJAX API 获取数据。AJAX promises 将数据从成功的 AJAX 调用传递给传递给 promise 的then()方法的任何回调,而将错误传递给传递给 promise 的fail() method方法的任何回调。因为fail()回调的签名接受单个错误参数,所以从 Async.js 传递给每个任务的回调也可以用作对fail()的回调。
Listing 15-6. Parallel Flow
<!-- example-002/views/async-parallel.html -->
<h1>User Profile</h1>
<form>
<fieldset>
<div>
<label>First Name</label>
<input type="text" id="first-name" />
</div>
<div>
<label>US States</label>
<select id="us-states"></select>
</div>
</fieldset>
</form>
<script>
(function (async, $) {
function getUser(userID) {
return function (cb) {
$.get('/user/' + userID).then(function (user) {
cb(null, user);
}).fail(cb);
};
}
function getUSStates(cb) {
$.get('/us-states').then(function (states) {
cb(null, states);
}).fail(cb);
}
var userID = 1001;
async.parallel([
getUser(userID),
getUSStates
], function (err, results) {
if (err) {
return alert(err.message);
}
var user = results[0],
states = results[1];
$('#first-name').val(user.firstName);
// ...
$('#us-states').append(states.map(function (state) {
return $('<option></option>')
.html(state)
.attr('value', state);
}));
});
}(window.async, window.jQuery));
</script>
Async.js 库将遍历tasks数组中的每个任务,一个接一个地调度它们。当每个任务完成时,它的数据被存储,一旦所有任务完成,传递给async.parallel()的最后一个回调被调用。
结果按照传递给async.parallel()的任务的顺序排序,而不是按照任务实际解决的顺序。如果任何并行任务中出现错误,该错误将被传递给最终回调,所有未完成的并行任务一旦完成将被忽略,最终回调中的results参数将为undefined。
管道流动
当一系列任务中的每个任务都依赖于前一个任务的值时,就需要一个管道流(或瀑布)。清单 15-7 表示一个虚构的公司奖励计划的任务,其中计算用户的年龄(基于出生日期),如果用户的年龄达到一定的阈值,用户将获得现金奖励。
每个函数接收一些输入,然后将一些输出传递给它的回调函数。每个函数的输出成为系列中下一个函数的输入。
The getUser() factory function accepts a userID and returns another function that, when invoked, looks up a user record. It passes the user record to its callback. The calcAge() function accepts a user argument and invokes its callback with the calculated age of the user. The reward() function accepts a numeric age argument and invokes its callback with the selected reward if the age meets certain thresholds. Listing 15-7. Waterfall (Pipeline) Steps
// example-003/callback-waterfall
'use strict';
var db = require('./database');
function getUser(userID, cb) {
process.nextTick(function () {
// pass cb directly to find because
// it has the same signature:
// (err, user)
db.users.find({id: userID}, cb);
});
}
function calcAge(user, cb) {
process.nextTick(function () {
var now = Date.now(),
then = user.birthDate.getTime();
var age = (now - then) / (1000 * 60 * 60 * 24 * 365);
cb(null, Math.round(age));
});
}
function reward(age, cb) {
process.nextTick(function () {
switch (age) {
case 25: return cb(null, '$100');
case 35: return cb(null, '$150');
case 45: return cb(null, '$200');
default: return cb(null, '$0');
}
});
}
如果用嵌套的回调来组织,这个管道将会相当难看并且难以维护。如果奖励计划中增加了额外的步骤,就需要对代码进行梳理和重组,以适应流水线流程中的新步骤。捕获错误并通过回调传播它们也是手动进行的。清单 15-8 中的示例代码展示了在没有 Async.js 的情况下如何运行这些任务。
Listing 15-8. A Waterfall of Nested Callbacks
// example-003/callback-waterfall
function showReward(userID, cb) {
getUser(userID, function (err, user) {
if (err) {
return cb(err);
}
calcAge(user, function (err, age) {
if (err) {
return cb(err);
}
reward(age, cb);
});
})
}
showReward(123, function (err, reward) {
if (err) {
return console.error(err);
}
console.log(reward);
});
幸运的是,Async.js 使得组织一个既可维护又能优雅地处理错误的管道流变得相对容易。清单 15-9 中的代码使用async.waterfall()来组织要执行的一系列任务,然后提供一个最终回调来捕获管道任务引发的任何错误,或者在没有错误发生的情况下接收最终的reward值。
Listing 15-9. Waterfall (Pipeline) Flow
// example-003/async-waterfall.js
'use strict';
var async = require('async');
var db = require('./database');
function getUser(userID) {
// using a factory function to pass in
// the userID argument and return another
// function that will match the callback
// signature that async.waterfall expects
return function (cb) {
process.nextTick(function () {
// pass cb directly to find because
// it has the same signature:
// (err, user)
db.users.find({id: userID}, cb);
});
};
}
// the calcAge and reward functions
// do not change
async.waterfall([
getUser(1000),
calcAge,
reward
], function (err, reward) {
if (err) {
return console.error(err);
}
console.log('reward:', reward);
});
像async.series()和async.parallel()一样,在任何瀑布任务中传递给回调的错误将立即暂停管道并调用带有错误的最终回调。
重复使用管道
管道对于处理数据非常有帮助,以至于async.seq()会采用一系列函数,就像async.waterfall()一样,并将它们组合成一个单一的、可重用的管道函数,可以多次调用。当然,这可以通过使用闭包来包装async.waterfall()来手动完成,但是async.seq()是一个方便的函数,可以省去开发人员的麻烦。
清单 15-10 显示了一系列用于处理虚拟手机账单的函数。createBill()函数接受一个调用计划,并用该计划和正常月费率创建一个bill对象。carrierFee()在这个数额上追加一大块零钱,只是因为电话公司可以这么做。prorate()功能随后确定是否将一些金额记入用户的贷方(例如,如果用户在计费周期的中间开始了新的计划)。最后,govtExtortion()会在交付账单之前将计算好的税款附加到账单上。
Listing 15-10. Sequence (Pipeline) Steps
// example-004/async-seq.js
'use strict';
var async = require('async');
var dateUtil = require('./date-util');
function createBill(plan, cb) {
process.nextTick(function () {
var bill = {
plan: plan,
total: plan.billAmt
};
cb(null, bill);
});
}
function carrierFee(bill, cb) {
process.nextTick(function () {
bill.total += 10;
cb(null, bill);
});
}
function prorate(bill, cb) {
if (!bill.plan.isNew) {
return cb(null, bill);
}
process.nextTick(function () {
bill.plan.isNew = false;
var days = dateUtil().daysInMonth();
var amtPerDay = bill.plan.billAmt / days;
var prorateAmt = ((bill.plan.billDay - 1) * amtPerDay);
bill.total -= prorateAmt;
cb(null, bill);
});
}
function govtExtortion(bill, cb) {
process.nextTick(function () {
bill.total = bill.total * 1.08;
cb(null, bill);
});
}
用async.seq()创建管道与使用async.waterfall()非常相似,如清单 15-11 所示。主要区别在于,async.seq()并不立即调用这些步骤,而是返回一个pipeline()函数,该函数将用于稍后运行任务。pipeline()函数接受将被传递到第一步的初始参数,消除了在定义管道时工厂函数或绑定值到第一步的需要。此外,与大多数其他async函数不同,async.seq()是可变的(接受可变数量的参数)。它不接受像async.waterfall()这样的任务数组,而是接受每个任务函数作为参数。
在清单 15-11 中,pipeline()函数被创建,然后用两个参数调用:一个plan对象,它将被传递给createBill(),以及一个最终回调,为用户接收一个错误或最终bill对象。
Listing 15-11. Sequence (Pipeline) Flow
// example-004/async-seq.js
var pipeline = async.seq(
createBill,
carrierFee,
prorate,
govtExtortion
);
var plan = {
type: 'Lots of Cell Minutes Plan!+',
isNew: true,
billDay: 15,
billAmt: 100
};
pipeline(plan, function (err, bill) {
if (err) {
return console.error(err);
}
//bill = govtExtortion(prorate(carrierFee(createBill(plan))))
console.log('$', bill.total.toFixed(2));
});
环路流量
重复直到满足某种条件的流称为循环。Async.js 有几个循环函数,帮助协调要执行的异步代码和要在其中测试的条件。
当某些条件保持为真时循环
前两个函数async.whilst()和async.doWhilst(),类似于许多编程语言中众所周知的while和do/while循环结构。当某个条件评估为真时,每个循环运行。一旦条件评估为假,循环停止。
async.whilst()和async.doWhilst()功能几乎相同,除了async.whilst()在循环中的任何代码运行之前执行条件评估,而async.doWhilst()在执行条件评估之前执行循环的一次迭代。async.doWhilst()中的循环代码保证至少运行一次,而如果初始条件为假,则async.whilst()中的循环代码可能根本不会运行。
清单 15-12 显示了async.whilst()被用来调用一个 API 十次,以获得某个竞赛的随机“获胜者”。在循环运行之前,会检查一个姓名数组,以确定是否已经选出了 10 名获胜者。重复这个过程,直到数组的长度为 10。如果在循环中的一个 API 调用过程中出现错误,那么async.whilst()流将被终止,最后一个回调将被调用,并显示错误;否则,一旦循环条件评估为 false,将调用最终回调。
Listing 15-12. Looping While Some Condition Remains True
<!-- example-005/views/async-whilst.html -->
<h1>Winners!</h1>
<ul id="winners"></ul>
<script>
(function (async, $) {
function pickWinners(howMany, cb) {
var winners = [];
async.whilst(
// condition test:
// continue looping until we have enough winners
function () { return winners.length < howMany; },
// looping code
function (cb) {
$.get('/employee/random').done(function (employee) {
var winner = employee.firstName + ' ' + employee.lastName;
// avoid potential duplicates
if (winners.indexOf(winner) < 0) {
winners.push(winner);
}
cb(null);
}).fail(function (err) {
cb(err);
});
},
// final callback
function (err) {
// if there is an error just ignore it
// and pass back an empty array, otherwise
// pass the winners
cb(null, err ? [] : winners);
}
);
}
pickWinners(3, function (err, winners) {
$('ul#winners').append(winners.map(function (winner) {
return $('<li></li>').html(winner);
}));
});
}(window.async, window.jQuery));
</script>
清单 15-13 中的代码显示了使用async.doWhilst()代替async.whilst()循环的简短修改。请注意,参数的顺序已经改变。循环函数现在是async.doWhilst()的第一个参数,条件测试是第二个。这在结构上反映了do/while循环语法。
Listing 15-13. Looping Once and Then Continuing While Some Condition Remains True
<!-- example-005/views/async-dowhilst.html -->
<h1>Winners!</h1>
<ul id="winners"></ul>
<script>
(function (async, $) {
function pickWinners(howMany, cb) {
var winners = [];
async.doWhilst(
// looping code
function (cb) {
$.get('/employee/random').done(function (employee) {
var winner = employee.firstName + ' ' + employee.lastName;
// avoid potential duplicates
if (winners.indexOf(winner) < 0) {
winners.push(winner);
}
cb(null);
}).fail(function (err) {
cb(err);
});
},
// condition test is now the second function
// argument
function () { return winners.length < howMany; },
// final callback
function (err) {
// if there is an error just ignore it
// and pass back an empty array, otherwise
// pass the winners
cb(null, err ? [] : winners);
}
);
}
pickWinners(3, function (err, winners) {
$('ul#winners').append(winners.map(function (winner) {
return $('<li></li>').html(winner);
}));
});
}(window.async, window.jQuery));
</script>
循环,直到某个条件变为假
与async.whilst()和async.doWhilst()函数密切相关的是async.until()和async.doUntil()函数,它们遵循相似的执行模式,但不是在某些条件为真时执行循环,而是执行循环直到某些条件测试为假。
清单 15-14 中的代码展示了如何在浏览器中创建一个简单的 HTTP 心跳来测试 API 端点的可用性。Heartbeat()构造函数用async.until()创建一个循环,该循环将重复执行,直到_isStopped属性的值被设置为true. Heartbeat()为止,该函数公开了一个stop()方法,当该方法在对象创建后被调用时,将阻止循环继续。循环的每一轮都向服务器发出 HTTP 请求,如果请求成功,循环将isAvailable属性设置为true;如果失败,则将isAvailable设置为false。为了创建循环迭代之间的延迟,一个setTimeout()函数将回调调用包装在循环中,安排循环的未来迭代在稍后运行(在本例中是每三秒钟一次)。
Listing 15-14. Looping Until Some Condition Becomes False
<!-- example-006/views/async-until.html -->
<section id="output"></section>
<script>
(function (async, $) {
var output = document.querySelector('#output');
function write() {
var pre = document.createElement('pre');
pre.innerHTML = Array.prototype.join.call(arguments, ' ');
output.appendChild(pre);
}
function Heartbeat(url, interval) {
var self = this;
this.isAvailable = false;
this.isStopped = false;
this.writeStatus = function () {
write(
'> heartbeat [isAvailable: %s, isStopped: %s]'
.replace('%s', self.isAvailable)
.replace('%s', self.isStopped)
);
};
async.until(
// test condition
function () { return self.isStopped; },
// loop
function (cb) {
$.get(url).then(function () {
self.isAvailable = true;
}).fail(function () {
self.isAvailable = false;
}).always(function () {
self.writeStatus();
// delay the next loop by scheduling
// the callback invocation in the
// future
setTimeout(function () {
cb(null);
}, interval);
});
},
// final callback
function (/*err*/) {
self.isAvailable = false;
self.writeStatus();
}
);
}
Heartbeat.prototype.stop = function () {
this.isStopped = true;
};
var heartbeat = new Heartbeat('/heartbeat', 3000);
setTimeout(function () {
// 10 seconds later
heartbeat.stop();
}, 10000);
}(window.async, window.jQuery));
</script>
async.doUntil()函数的行为类似于async.doWhilst():它在评估测试条件之前首先运行循环。它的签名也交换了测试条件函数和循环函数的顺序。
重试循环
循环的一个常见用例是重试循环,在这种情况下,任务会被尝试给定的次数。如果任务失败,但没有达到重试限制,它会再次执行。如果达到重试限制,任务将中止。async.retry()函数通过为开发人员处理重试逻辑来简化这个过程。建立循环就像指定重试限制、要执行的任务以及处理错误或接收结果的最终回调一样简单。
清单 15-15 演示了一个简单的 API 调用,用于在某场音乐会或电影中预订座位。可用座位按从最优先到最不优先的顺序排列。执行限制是数组的长度。每次任务运行时,它都会移动数组,从集合中删除第一个(最可取的)座位。如果预订失败,它将继续这个过程,直到没有剩余的座位。
Listing 15-15. Retry Loop
<!-- example-007/views/async-retry -->
<section id="output"></section>
<script>
(function (async, $) {
var output = document.querySelector('#output');
function write() {
var pre = document.createElement('pre');
pre.innerHTML = Array.prototype.join.call(arguments, ' ');
output.appendChild(pre);
}
function reserve(name, availableSeats) {
console.log(availableSeats);
return function (cb) {
var request = {
name: name,
seat: availableSeats.shift()
};
write('posting reservation', JSON.stringify(request));
$.post('/reservation', request)
.done(function (confirmation) {
write('confirmation', JSON.stringify(confirmation));
cb(null, confirmation);
}).fail(function (err) {
cb(err);
});
};
}
var name = 'Nicholas';
var availableSeats = ['15A', '22B', '13J', '32K'];
async.retry(
availableSeats.length,
reserve(name, availableSeats),
function (err, confirmation) {
if (err) {
return console.error(err);
}
console.log('seat reserved:', confirmation);
}
);
}(window.async, window.jQuery));
</script>
每次任务运行时,它都会调用回调函数。如果任务成功并向回调传递了一个值,那么最后的async.retry()回调将使用该值被调用(在本例中为confirmation)。如果出现错误,将重复循环,直到达到循环限制。最后一个错误被传递给最终回调;除非手动累积,否则之前的误差会丢失。清单 15-16 展示了一种潜在的方法,通过收集数组中的错误,然后将数组本身作为err参数传递给回调函数。如果重试循环失败,最终回调的错误将是在循环的每一轮中生成的每个错误的数组。
Listing 15-16. Accumulating Errors in a Retry Loop
function reserve(name, availableSeats) {
var errors = [];
return function (cb) {
// ...
$.post('/reservation', body)
.done(function (confirmation) {
cb(null, confirmation);
}).fail(function (err) {
errors.push(err);
cb(errors);
});
};
}
无限循环
无限循环在同步编程中是个坏消息,因为它们会阻止 CPU 和任何其他代码的执行。但是异步无限循环没有这个缺点,因为像所有其他代码一样,它们被 JavaScript 调度器安排在事件循环的未来循环中。其他需要运行的代码可以“插嘴”并请求调度。
可以用async.forever()调度无限循环。该函数将任务函数作为第一个参数,将最终回调函数作为第二个参数。该任务将继续无限期运行,除非它向其回调传递一个错误。使用等待时间为 0 的setTimeout()或setImmediate()连续调度异步操作会在一个循环中产生几乎没有响应的代码,所以最好用更长的等待时间填充每个异步任务,至少几百毫秒。
清单 15-17 中的循环在无限循环的每一次循环中都发出一个 HTTP GET 请求,为用户的仪表板加载股票信息。每次 GET 请求成功时,股票信息被更新,循环在再次执行前等待三秒钟。如果在循环过程中出现错误,则使用错误调用最后一个回调,并终止循环。
Listing 15-17. Infinite Loop
<!-- example-008/views/async-forever.html -->
<ul id="stocks"></ul>
<script>
(function (async, $) {
$stockList = $('ul#stocks');
async.forever(function (cb) {
$.get('/dashboard/stocks')
.done(function (stocks) {
// refresh the stock list with new stock
// information
$stockList.empty();
$stockList.append(stocks.map(function (stock) {
return $('<li></li>').html(stock.symbol + ' $' + stock.price);
}));
// wait three seconds before continuing
setTimeout(function () {
cb(null);
}, 3000);
}).fail(cb);
}, function (err) {
console.error(err.responseText);
})
}(window.async, window.jQuery));
</script>
批量流动
本章介绍的最后一种控制流是批处理。批处理是通过将一些数据划分成块,然后一次对每个块进行操作来创建的。批处理有一些阈值,用于定义可以放入块中的数据量。在块上的工作开始后添加到批处理流中的数据被排队,直到工作完成,然后在新的块中被处理。
异步队列
异步队列是在批处理流中处理项目的一种方式。可以通过使用两个参数调用async.queue()来创建队列。第一个是任务函数,将为每个将被添加到队列中的数据项执行该函数。第二个是一个数字,表示队列将并发调度以处理数据的任务工作线程的最大数量。在清单 15-18 中,创建了一个队列来为添加到队列中的任何 URL 发出 HTTP 请求。当每个请求完成时,每个 HTTP 请求的结果将被添加到results散列中。任何时候可以运行的 HTTP 请求的最大数量是三。如果在三个请求正在进行的时候有额外的 URL 被添加到队列中,它们将被保留以供将来处理。当工人被释放时(当请求完成时),他们将根据需要被分配到排队的 URL。在给定的时间内,不会有超过三个 HTTP 请求正在进行。
Listing 15-18. Using Queue for Sequential Batches
// example-009/index.js
'use strict';
var async = require('async');
var http = require('http');
var MAX_WORKERS = 3;
var results = {};
var queue = async.queue(function (url, cb) {
results[url] = '';
http.get(url, function (res) {
results[url] = res.statusCode + ' Content-Type: ' + res.headers['content-type'];
cb(null);
}).on('error', function (err) {
cb(err);
});
}, MAX_WORKERS);
var urls = [ // 9 urls
'http://www.reddit.com/r/javascript
];
urls.forEach(function (url) {
queue.push(url, function (err) {
if (err) {
return console.error(err);
}
console.log('done processing', url);
});
});
队列将在其生命周期的某些点发出许多事件。可以将函数分配给队列对象上相应的事件属性,以处理这些事件。这些事件处理程序是可选的;无论有没有它们,队列都将正常运行。
当队列第一次达到活动工作者的最大数量时,它将调用分配给queue.saturated的任何函数。当队列正在处理所有项目并且没有其他项目排队时,它将调用分配给queue.empty的任何函数。最后,当所有的工人都完成并且队列为空时,任何分配给queue.drain的函数都会被调用。清单 15-19 中的函数处理每一个引发的事件。
Listing 15-19. Queue Events
// example-009/index.js
queue.saturated = function () {
console.log('queue is saturated at ' + queue.length());
};
queue.empty = function () {
console.log('queue is empty; last task being handled');
};
queue.drain = function () {
console.log('queue is drained; no more tasks to handle');
Object.keys(results).forEach(function (url) {
console.log(url, results[url]);
});
process.exit(0);
};
Note
empty和drained事件略有不同。当empty被触发时,尽管队列中没有剩余的项目,工人可能仍然是活动的。当drained被触发时,所有工人都停止工作,队列完全为空。
异步货物
async.cargo()函数类似于async.queue(),它将一些任务函数要处理的项目排队。然而,它们的不同之处在于工作负荷是如何划分的。async.queue()运行多个工作线程,直到达到最大并发限制——它的饱和点。async.cargo()一次运行一个工作线程,但是将队列中要处理的项目分成预定大小的有效负载。当 worker 被执行时,它将被赋予一个有效载荷。当它完成时,它将被给予另一个,直到所有的有效载荷被处理。那么,货物的饱和点是当满载的有效载荷准备好被处理时。工人启动后添加到货物中的任何物品都将被归入下一个待处理的有效负载中。
通过将任务函数作为第一个参数提供给async.cargo(),并将最大有效载荷大小作为第二个参数来创建货物。task 函数将接收一个要处理的数据数组(长度达到最大有效负载大小),并在操作完成后调用一个回调函数。
清单 15-20 中的代码展示了如何使用async.cargo()将一系列数据库更新打包到一个虚构的事务中,一次一个有效负载。task 函数遍历提供给它的“更新”对象,将每个对象转换成某个虚拟关系数据存储中的一个UPDATE查询。一旦所有的查询都被添加到事务中,事务就被提交,回调就被调用。
Listing 15-20. Using Cargo for Parallel Batches
// example-010/index-01.js
'use strict';
var async = require('async');
var db = require('db');
var MAX_PAYLOAD_SIZE = 4;
var UPDATE_QUERY = "UPDATE CUSTOMER SET ? = '?' WHERE id = ?;";
var cargo = async.cargo(function (updates, cb) {
db.begin(function (trx) {
updates.forEach(function (update) {
var query = UPDATE_QUERY.replace('?', update.field)
.replace('?', update.value)
.replace('?', update.id);
trx.add(query);
});
trx.commit(cb);
});
}, MAX_PAYLOAD_SIZE);
var customerUpdates = [ // 9 updates to be processed in payloads of 4
{id: 1000, field: 'firstName', value: 'Sterling'},
{id: 1001, field: 'phoneNumber', value: '222-333-4444'},
{id: 1002, field: 'email', value: 'archer@goodisis.com'},
{id: 1003, field: 'dob', value: '01/22/1973'},
{id: 1004, field: 'city', value: 'New York'},
{id: 1005, field: 'occupation', value: 'Professional Troll'},
{id: 1006, field: 'twitter', value: '@2cool4school'},
{id: 1007, field: 'ssn', value: '111-22-3333'},
{id: 1008, field: 'email', value: 'urmom@internet.com'},
{id: 1009, field: 'pref', value: 'rememberme=false&colorscheme=dark'}
];
customerUpdates.forEach(function (update) {
cargo.push(update, function () {
console.log('done processing', update.id);
});
});
货物对象与队列对象具有相同的事件属性,如清单 15-21 所示。主要区别在于,一旦添加了最大数量的有效载荷项目,就达到了货物的饱和极限,此时工人将开始工作。
可以根据需要将可选的函数处理程序分配给事件属性。
Listing 15-21. Cargo Events
// example-010/index-01.js
cargo.saturated = function () {
console.log('cargo is saturated at ' + cargo.length());
};
cargo.empty = function () {
console.log('cargo is empty; worker needs tasks');
};
cargo.drain = function () {
console.log('cargo is drained; no more tasks to handle');
};
Note
async.queue()和async.cargo()都调度任务函数在事件循环的下一个节拍运行。如果项目被同步地一个接一个地添加到队列或货物中,那么每个项目的阈值将按预期被应用;队列将节流最大数量的工作人员,货物将划分最大数量的要处理的项目。但是,如果项是异步添加到每个任务中的,如果项是在事件循环的下一个直接循环之后添加的,则任务函数可能会在低于其最大容量的情况下被调用。
清单 15-22 中的代码从customerUpdates数组中取出每个更新,并将其推送到 cargo,然后将下一次推送安排在 500 毫秒后,在事件循环的下一次循环中发生。因为 cargo 会立即调度它的任务,所以UPDATE查询每次会运行一个——可能两个——更新,这取决于完成一个任务和调度下一个任务需要多长时间。
Listing 15-22. Adding Items to Cargo Asynchronously
// example-010/index-02.js
(function addUpdateAsync() {
if (!customerUpdates.length) return;
console.log('adding update');
var update = customerUpdates.shift();
cargo.push(update, function () {
console.log('done processing', update.id);
});
setTimeout(addUpdateAsync, 500);
}());
要保证队列和货物都满足最大阈值,请将项目同步推送到彼此。
摘要
本章介绍了一些常见的同步控制流,并演示了如何使用 Async.js 来为异步代码调整这些模式。表 15-1 显示了每个流程和相应的 Async.js 函数。
表 15-1。
Flows and Corresponding Async.js Functions
| 流动 | Async.js 函数 | | --- | --- | | 连续的 | `async.series()` | | 平行的 | `async.parallel()` | | 管道 | `async.waterfall()`,`async.seq()` | | 环 | `async.whilst()` / `async.doWhilst()`,`async.until()` / `async.doUntil()` | | | `async.retry()`,`async.forever()` | | 一批 | `async.queue()`,`async.cargo()` |顺序和并行流程允许开发人员执行多个独立的任务,然后根据需要汇总结果。管道流可用于将任务链接在一起,其中每个任务的输出成为后续任务的输入。为了将异步任务重复给定的次数,或者根据某些条件,可以使用循环流。最后,批处理流可以将数据分成块,一批接一批地异步处理。
通过巧妙地组织异步函数任务,协调每个任务的结果,并将错误和/或任务结果交付给最终回调,Async.js 帮助开发人员避免嵌套回调,并将传统的同步控制流操作带入 JavaScript 的异步世界。