Callback Hell
异步I/O是Node.js的卖点,在处理高并发I/O的场景时确实卓有成效,但世上没有免费的晚餐(因为我知道某歌有免费午餐),作为交换,你必须付出改变既有编程习惯的代价。
比如,在其他编程环境中,这样的代码是很常见的
try {
Object v1 = doIO1(); // 第一个I/O操作
return doIO2(v1); // 第二个I/O操作,需要依赖之前的输出
}
catch (Exception e) {
... // 异常处理
}
但是对不起,在Node.js中,你只能放弃这种写法了(Generators除外),在不使用辅助lib的情况下,你需要这么写:
doIO1(function(err, v1) { // 第一个I/O操作
if (err) {
... // 错误处理
return
}
doIO2(v1, function(err, v2) { // 第二个I/O操作,需要依赖之前的输出
if (err) {
... // 错误处理
return
}
... // 最终结果,可通过函数调用或事件传递出去
});
});
最终,层层嵌套的回调函数会使得代码难以阅读和维护,这就是所谓的Callback Hell。
@jcoglan甚至认为采用了回调函数风格是Node.js的一项重大决策失误,详见 http://bit.ly/1hDGL4g (另,此文对指令式和函数式编程的观点令人印象深刻)
对于如何拆解Callback Hell,已有很多强文,请Google之。以下仅从个人体会出发,针对一些貌似大家提的不多的方面补充自己的看法。
Async.js遗忘的角落
为了解决这个问题,我开始使用Async.js
var doIO1AndIO2 = async.compose(doIO2, doIO1); // 组合两个异步函数
doIO1AndIO2(function(err, v2) {
if (err) {
... // 错误处理
return
}
... // 得到最终结果,处理之
});
很酷,对不对?Async.js真是一个伟大的lib,但是即便如此,仍然有它触及不到的角落…
假设我有一个同步函数do1
,它返回一个值,如果我想把do1
和doIO2
组合起来,怎么办?async.compose
和underscore.compose
都办不到,它们一个只适用于回调风格,另一个则只适用于返回值风格…
有人会说:切,doIO2(do1(), function(err, v2) {...
不就完了?是的,一般情况下这都是不错的选择。但如果我们希望最大程度地重用逻辑时,让函数保持独立,根据实际需要灵活组合就是更好的选择了。所以,将同步和异步函数组合起来使用的场景是可能存在的。
这时我们就需要一个异步版本的do1
:
// 伪异步版do1
function do1AndCallback(cb) {
...
cb(null, v1);
}
// 然后组合之
var do1AndIO2 = async.compose(doIO2, do1AndCallback);
但是(又来了),既然我们说过希望函数能够灵活组合,那么do1
就可能需要和其他同步函数进行组合,所以我们既需要一个同步版的do1
,也需要一个伪异步版的do1
,即do1AndCallback
,嗯,坏味道…
请记住这个瑕疵,我们回头再来讨论。
第二条路:Promises
另一个解决Callback Hell的途径是Promises,这是一套异步编程模式,引用@jcoglan的观点:Promise更具有函数式编程的特点,因为它让我们重新聚焦在value上。
- Promise有不少优秀的实现,以下的例子均基于其中的一个:Q
让我们以Promise风格,重写上文中的例子。
首先假设我们已经将doIO1
、doIO2
重写为Promised版本(就是为了让下面的代码更好看,具体实现请参考Q文档)
在此前提下,上述例子可以重写如下:
var useResult = function (v2) { ... }; // 得到最终结果,处理之
var handleError = function (err) { ... }; // 统一处理throw或callback传递出来的异常
doIO1().then(doIO2).then(useResult, handleError);
哇!很像命令行的pipe吧:doIO1 | doIO2 | useResult
。我们只是描述了数据(value)的流向,而不必关注数据到底是如何成功地由1转到2的。这就是函数式编程的特点,现在稍微可以理解 @jcoglan 的说法了吧?
我并不打算详细介绍Promise,就此打住,具体请参考http://www.promisejs.org,以及其实现,Q。
总体来说,采用Promise风格的异步编程,可以使得:
- 异步过程成为value,可以被传递、被返回(还是那句话:更函数式)
- 函数的组合更为方便,可以灵活定义串行、并行处理的流程
- Exception更容易统一处理
同步异步的混搭
简单了解Promise以后,让我们来看看它是否能解决前文提到的同步异步混搭问题。
事情比想象中还要简单:
Q.fcall(do1).then(doIO2).then(useResult, handleError);
现在,我们不再需要两个版本的do1
,按照需要组合使用即可
// 组合同步函数
do1And2 = _.compose(do2, do1);
// 异步 + 同步串行
doSthAsync().then(do1).then(do2).then...
// 并行
Q.all([
Q.fcall(doSthSync),
doSthAsync()
]).then...
当然,例子中的函数我写得很随意,实际上,函数能否组合在一起,需要考虑它们的参数列表及返回值是否匹配。
最后
通过以上对比,我们可以了解到,Promise风格的异步编程要比回调风格更容易写出简练的代码,可以将我们从复杂的异步控制流中解放出来,重新专注在程序的核心价值,数据(Value),以及数据的流动、转换上。
这里选取的同步+异步函数组合场景,我在实践中确有应用,同时也觉得比较适合用来展示Promise编程的魅力,在此分享给大家。
最后的最后,前面提到的Generators,是一项在Node.js 0.11.x后提供的特性。
简单讲,就是让我们可以像写阻塞性代码(如文章开头那段Java代码)那样调用异步过程。请参考: https://github.com/visionmedia/co#readme