之前的文章提到,异步编程带来的各种问题。本节将通过提供三种解决方案来解决这些问题。
解决方案有如下3种。
1.事件发布/订阅模式。
2.Promise/Deferred模式。
3.流程控制库。
4.3.1 事件发布/订阅模式
事件监听器模式是一种广泛用于异步编程的模式,是回调函数的事件化,又称发布/订阅模式。
Node自身提供的events模块是发布/订阅模式的一个简单实现。它具有addListener/on(),once(),removeListener(),removeAllListeners()和emit()等基本的事件监听模式的方法实现。
事件发布/订阅模式的操作极其简单,示例代码如下:
// 订阅
emitter.on("event1", function (message) {
console.log(message);
});
// 发布
emitter.emit('event1', "I am message!");
通过emit()发布事件后,消息会立即传递给当前事件的所有监听器执行。监听器可以很灵活地添加和删除,使得事件和具体处理逻辑之间可以很轻松地关联和解耦。
在Node中,emit()调用多半是伴随事件循环而异步触发的,所以说事件发布/订阅广泛应用于异步编程。
从另一个角度看,事件监听模式也是一种钩子机制,利用钩子导出内部数据或状态给外部的调用者。
Node中大多对象具有黑盒的特点,功能较少,如果不通过事件钩子的形式,无法获取对象在运行期间的中间值或内部状态。通过钩子可以关注组件是如何启动和执行的。
1) 继承events模块
实现一个继承EventEmitter的类是十分简单,以下代码是Node中Stream对象继承EventEmitter的样子。
var events = require('events');
function Stream() {
events.EventEmitter.call(this);
}
util.inherits(Stream, events.EventEmitter);
Node在util模块中封装了继承的方法,所以此处可以很便利地调用。开发者可以通过这样的方式轻松继承EventEmitter类,利用事件机制解决业务问题。在Node提供的核心模块中,有近半数都继承自EventEmitter。
2) 利用事件队列解决雪崩问题
在事件订阅/发布模式中,通常也有一个once()方法,通过它添加的监听器只能执行一次,在执行之后就会将它与事件的关联移除。这个特性常常可以帮助我们过滤一些重复性的事件响应。
下面介绍如何采用once()来解决雪崩问题。
所谓雪崩问题,就是在高访问量,大并发量的情况下缓存失效的情景,此时大量的请求同时涌入数据库中,数据库无法同时承担如此大的查询请求,进而影响到网站整体的响应速度。
如下是一条数据库查询语句的调用:
var select = function (callback) {
db.select("SQL", function (results) {
callback(results);
});
};
如果站点刚启动,此时缓存中是不存在数据的,而如果访问量巨大,同一句SQL会被发送到数据库中反复查询,会影响服务的整体性能。
这时可以引入事件队列,代码如下:
var proxy = new events.EventEmitter();
var status = "ready";
var select = function (callback) {
proxy.once("selected", callback);
if (status === "ready") {
status = "pending";
db.select("SQL", function (results) {
proxy.emit("selected", results);
status = "ready";
});
}
};
这里利用once()方法,将所有请求的回调都压入事件队列中,利用其执行一次就会将监视器移除的特点,保证每一个回调只会执行一次。对于相同的SQL语句,保证在同一个查询开始到结束的过程中永远只有一次。
4.3.2 Promise/Deferred模式
使用事件的方式时,执行流程需要被预先设定。即便是分支,也需要预先设定,这是由发布/订阅模式的运行机制所决定的。
下面为普通的Ajax调用:
$.get('/api', {
success: onSuccess,
error: onError,
complete: onComplete
});
在上面的异步调用中,必须严谨地设置目标。那么是否有一种先执行异步调用,延迟传递处理的方式呢?答案是Promise/Deferred模式。
Promise/Deferred模式在Javascript框架中最早出现于Dojo的代码中,被广为所知则来自于JQuery1.5版本,该版本几乎重写了Ajax部分,使得调用Ajax时可以通过如下的形式进行:
$.get(’/api’).success(onSuccess).error(onError).complete(onComplete);
这使得即使不调用success(),error()等方法,Ajax也会进行,这样的调用方式比预先传入回调让人觉得舒适一些。在原始的API中,一个事件只能处理一个回调,而通过Deferred对象,可以对事件加入任意的业务处理逻辑。
异步的广度使用使得回调,嵌套出现,但是一旦出现深度的嵌套,就会让编程的体验变得不愉快,而Promise/Deferred模式在一定程度上缓解了这个问题。
Promise/Deferred模式在2009年被KrisZyp抽象为一个提议草案,发布在CommonJS规范中。
CommonJS草案目前已经抽象出了Promise/A,Promise/B,Promise/C这些典型的异步Promise/Deferred模型,这使得异步操作可以以一种优雅的方式出现。
这里着重介绍Promise/A来介绍Promise/Deferred模式
该模式主要包含两部分,即Promise和Deferred。这里暂不提两者的区别是什么,先看看Promise/A的行为。
Promise操作只会处在3中状态的一种:未完成态,完成态和失败态。
Promise的状态只会出现从未完成态向完成态或失败态转化,不能逆转。完成态和失败态不能相互转化。
Promise的状态一旦转化,将不能被更改。
为了演示Promise/A提议,这里我们尝试通过继承Node的events模块来完成一个简单的实现,代码如下:
var Promise = function () {
EventEmitter.call(this);
};
util.inherits(Promise, EventEmitter);
Promise.prototype.then = function (fulfilledHandler,
errorHandler, progressHandler) {
if (typeof fulfilledHandler === 'function') {
this.once('success', fulfilledHandler);
}
if (typeof errorHandler === 'function') {
this.once('error', errorHandler);
}
if (typeof progressHandler === 'function') {
this.on('progress', progressHandler);
}
return this;
};
这里看到then()方法所做的事情是将回调函数存起来。为了完成整个流程,还需要触发执行这些回调函数的地方,实现这些功能的对象被称为Deferred,即延迟对象。示例代码如下:
var Deferred = function () {
this.state = 'unfulfilled';
this.promise = new Promise();
};
Deferred.prototype.resolve = function (obj) {
this.state = 'fulfilled';
this.promise.emit('success', obj);
};
Deferred.prototype.reject = function (err) {
this.state = 'failed';
this.promise.emit('error', err);
};
Deferred.prototype.progress = function (data) {
this.promise.emit('progress', data);
};
Deferred里的resolve和reject方法代表状态的成功与失败,即完成态和失败态。
Promise中的多异步协作
在Promise的介绍中说过,主要解决的是单个异步操作中存在的问题。回到我们的难点,当我们需要处理多个异步调用时,又该如何处理呢?
这里给出一个简单的原型实现,相关代码如下:
Deferred.prototype.all = function (promises) {
var count = promises.length;
var that = this;
var results = [];
promises.forEach(function (promise, i) {
promise.then(function (data) {
count--;
results[i] = data;
if (count === 0) {
that.resolve(results);
}
}, function (err) {
that.reject(err);
});
});
return this.promise;
};
对于多次文件的读取场景,以下面的代码为例,all()方法将两个独立的Promise重新抽象组合成一个新的Promise:
var promise1 = readFile("foo.txt", "utf-8");
var promise2 = readFile("bar.txt", "utf-8");
var deferred = new Deferred();
deferred.all([promise1, promise2]).then(function (results)
{
// TODO
}, function (err) {
// TODO
});
这里通过all()方法抽象多个异步操作。只有所有异步操作成功,这个异步操作才算成功,一旦其中一个异步操作失败,整个异步操作就失败。
4.3.3 流程控制库
前面介绍了最为流行的模式----事件发布/订阅和Promise/Deferred模式,这些经典的模式或者是写进规范里的解决方案,但一旦涉及模式或者规范,就需要为它们做较多的准备工作,这节主要讲非模式化的应用。
这里主要介绍下async,async长期占据NPM依赖榜的前三名,可以在Node开发中,流程控制是开发过程中的基本需求。async模块提供20多个方法用于处理异步的各种协作模式,这里我们介绍几种经典用法。
1)异步的串行执行
async提供了series()方法来实现一组任务的串行执行,代码如下:
async.series([
function (callback) {
fs.readFile('file1.txt', 'utf-8', callback);
},
function (callback) {
fs.readFile('file2.txt', 'utf-8', callback);
}
], function (err, results) {
// results => [file1.txt, file2.txt]
});
这段代码等价于:
fs.readFile('file1.txt', 'utf-8', function (err, content) {
if (err) {
return callback(err);
}
fs.readFile('file2.txt ', 'utf-8', function (err, data)
{
if (err) {
return callback(err);
}
callback(null, [content, data]);
});
});
这里每个callback()执行时会将结果保存起来,然后执行一下调用,知道结束所有调用。最终的回调函数执行时,队列里的异步调用保存的结果以数组的方式传入。这里的异常处理规则是一旦出现异常,就结束所有调用,并将异常传递给最终回调函数的第一个参数。
2)异步的并行执行
当我们需要通过并行来提升性能时,async提供了parallel()方法,用以并行执行一些异常操作。以下为读取两个文件的并行版本:
async.parallel([
function (callback) {
fs.readFile('file1.txt', 'utf-8', callback);
},
function (callback) {
fs.readFile('file2.txt', 'utf-8', callback);
}
], function (err, results) {
// results => [file1.txt, file2.txt]
});
上面代码等同于下面代码:
var counter = 2;
var results = [];
var done = function (index, value) {
results[index] = value;
counter--;
if (counter === 0) {
callback(null, results);
}
};
var hasErr = false;
var fail = function (err) {
if (!hasErr) {
hasErr = true;
callback(err);
}
};
fs.readFile('file1.txt', 'utf-8', function (err, content) {
if (err) {
return fail(err);
}
done(0, content);
});
fs.readFile('file2.txt', 'utf-8', function (err, data) {
if (err) {
return fail(err);
}
done(1, data);
});
通过async编写的代码既没有深度的嵌套,也没有复杂的状态判断,它的诀窍依然来自于注入的回调函数。parallel()方法对于异常的判断依然是一旦某个异步调用产生了异常,就会将异常作为第一个参数传入给最终的回调函数。只有所有异步调用都正常完成时,才会将结果以数组的方式传入。
3)异步调用的依赖处理
series()适合无依赖的异步串行执行,但当前一个的结果是后一个调用的输入时,series()方法就无法满足需求了。所幸,这种典型场景的需求,async提供了waterfall()方法来满足,相关代码如下:
async.waterfall([
function (callback) {
fs.readFile('file1.txt', 'utf-8', function (err, content) {
callback(err, content);
});
},
function (arg1, callback) {
// arg1 => file2.txt
fs.readFile(arg1, 'utf-8', function (err, content) {
callback(err, content);
});
},
function(arg1, callback){
// arg1 => file3.txt
fs.readFile(arg1, 'utf-8', function (err, content) {
callback(err, content);
});
}
], function (err, result) {
// result => file4.txt
});
这段代码等价于如下代码:
fs.readFile('file1.txt', 'utf-8', function (err, data1) {
if (err) {
return callback(err);
}
fs.readFile(data1, 'utf-8', function (err, data2) {
if (err) {
return callback(err);
}
fs.readFile(data2, 'utf-8', function (err, data3) {
if (err) {
return callback(err);
}
callback(null, data3);
});
});
});
最后总结这几种方案的区别:事件发布/订阅模式相对算是一种较为原始的方式,Promise/Deferred模式贡献了一个非常不错的异步任务模型的抽象。而上述的这些异步流程控制方法与Promise/Deferred模式的思路不同,Promise/Deferred的重头在于封装异步的调用部分,流程控制库则显得没有模式,将处理重点放置在回调函数的注入上。