NodeJS的异步编程风格 http://www.infoq.com/cn/news/2011/09/nodejs-async-code
NodeJS运行环境因其支持Javascript语言和异步编程受到开发社区越来越多的关注。从GitHub上的访问量来看,NodeJS项目的关注度在最近几个月已经超过了Ruby及RoR。作为一个新鲜的平台,开发人员开始尝试去接触并运用于实际工作中,比如LinkedIn、Yammer、GitHub、淘宝等企业已经在生产环境中部署了NodeJS应用。不过,在学习NodeJS的过程中,从同步编程到异步编程风格的转换是开发人员面临的一个主要问题,我们如何去适应呢?技术社区在讨论这种转变,专家Marc Fasel也撰写了精彩的文章来阐述该问题,本文尝试结合Marc Fasel的指导思想和笔者的实践经验来介绍一些NodeJS的异步编程风格,希望对NodeJS的初学者有所启发。
第一个例子,读取目录信息
说起NodeJS的异步编程,我们必须提到回调函数(callback),纵览NodeJS的API文档,满眼的回调函数说明,在其他的编程语言中,也会存在一些异步的回调函数模型,但是没有NodeJS这样的大范围应用。这些回调函数应用在异步函数中,作为其参数,当异步函数触发某事件时(如http响应返回)即调用该回调函数做进一步操作。NodeJS也提供了一些传统的同步函数,即应用程序必须等待该函数返回,才会执行后面的代码。而异步函数则不同,应用程序在调用异步函数后会立即返回,执行后面的代码,至于异步函数的处理则交给回调函数来做。例如,在NodeJS中存在两个获取目录信息的函数,分别是同步的readdirSync()和异步的readdir()。看下面的代码片段(源于Marc Fasel,略作改动):
//同步
filenames = fs.readdirSync(".");
for (i = 0; i < filenames.length; i++) {
console.log(filenames[i]);
}
console.log('Current uid: ' + process.getuid());//异步
fs.readdir(".", function (err, filenames) {
var i;
for (i = 0; i < filenames.length; i++) {
console.log(filenames[i]);
}
});
console.log('Current uid: ' + process.getuid());
请注意看,在同步函数的代码中,没有什么特别之处,应用程序会按顺序打印当前目录包含的文件名,然后再打印当前进程的用户ID,其实际运行结果也如我们所料。而在异步函数的代码中,我们把打印文件名的代码放在了readdir函数参数里的回调函数中,这样当readdir获取目录信息之后就调用该回调函数打印文件名。但是应用程序在调用了异步函数fs.readdir(".", function (err, filenames) )之后,会立即执行后面打印进程用户ID的代码,不会停下来等待readdir函数返回。这就是异步与同步的差别,实际的运行结果也与之前不同,异步函数的执行和回调函数的处理总需要一些时间,所以在很大程度上应用程序会首先打印出进程用户ID,再打印出文件名。在通常的测试环境中,结果也是这样。从这个例子中,我们可以学到两点:一是在异步编程中,需要把依赖于异步函数(需要其执行结果或者达到某种状态)的代码放在对应的回调函数中;二是异步函数后面的代码会立即执行,所以在编程时需要通盘考虑,以免出现意外之外的运行结果。
第二个例子,统计所有文件字节数
刚才的例子是一个简单的顺序执行逻辑,如果异步函数包含在循环中会是什么样子?就会出现若干异步函数在并发运行的情况,开发人员需要这些异步函数共同完成一项任务的话,如何协作? 看到这里,读者的脑海里可能会马上浮现出其他编程语言中线程并发的代码。现在来看第二个NodeJS示例,计算当前目录中所有文件占用的总字节数。该例子用到的是同步函数statSync()和异步函数stat(),它们可以获取文件的基本信息。先来看看各自的代码片段(源于Marc Fasel,略作改动):
//同步
filenames = fs.readdirSync(".");
for (i = 0; i < filenames.length; i ++) {
stats = fs.statSync("./" + filenames[i]);
totalBytes += stats.size;
}
console.log(totalBytes);//异步
count = filenames.length;
for (i = 0; i < filenames.length; i++) {
fs.stat("./" + filenames[i], function (err, stats) {
totalBytes += stats.size;
count--;
if (count === 0) {
console.log(totalBytes);
}
});
}
同步函数的例子符合开发人员的传统编程风格,清晰明了。在for循环中,statSync被依次调用,占用字节数也顺序累加,循环结束后打印出统计结果。如果换成异步函数stat()会怎样?在上一个例子中我们讲到,把依赖异步调用结果的代码放到回调函数中,我们也正是这样做的。但是仅做到这一步还不够。对比同步和异步的例子,会发现多了一些有关count的语句。如果我们把这些语句先注释掉,同时按照同步编程的逻辑将打印结果的代码放到循环后面,运行结果就是很可能输出的字节数为totalBytes的初始值。因为按照异步函数的原理,for循环依次调用stat()之后,会立即执行后面的代码即打印结果,此时若干个异步函数很可能还没完成。这就是我们需要count语句的原因。在多个相同异步函数协作的情况下,代码需要引入计数变量来检测所有异步函数的退出。在正确的异步代码中,count在for循环之前设置为目录下文件的数量,即回调函数调用的次数。当回调函数被调用时(即某个文件的基本信息已经获取),totalBytes累加该文件的字节数,同时count减一,表示该文件已经被统计在内。由于多个异步函数在并发运行,难以判断谁先返回。所以在这里加入了一个if判断语句,如果此时count等于0,那么意味着所有的文件(回调函数)都累加完毕,那么当前的回调函数就是最后执行的,它负责输出总字节数。这种代码手法类似于其他语言中的线程协作的例子,相比之下,Javascript语言的闭包特性使得NodeJS的异步编程更容易,示例代码中的回调函数可以访问函数之外的count变量和totalBytes,无需特殊处理。从这个例子中,我们可以学到一点:并发运行的相同异步函数如果协作完成任务,需要添加计数代码判断执行状态,并且把所有异步函数完成后执行的代码放在判断条件的语句块里。
第三个例子,访问网站内容
在讨论第三个例子之前,我们先来看一下NodeJS的事件触发机制。NodeJS引擎中许多对象都有预定的事件,如用户在发送http请求之后获得的http.ServerRequest对象就有data和end两个事件,其中data指接收到响应信息正文中的一部分时会触发此事件,end指完全接收完信息后都会触发一次。开发人员如果想处理响应,则需要注册回调函数,如下列代码片段:
response.on('data', function (chunk) {……});
response.on('end',function(){……});
第三个例子的使用场景是:访问某网站,分析其页面内容,然后根据判断条件来决定继续访问下一页并做同样的分析还是在本页面停止(即退出应用)。首先采用NodeJS的HTTP模块编写第一次访问页面并分析内容的代码,代码框架如下:
var hostRequest = http.request(requestOptions,function(response) {
response.on('data', function (chunk) {
responseHTML = responseHTML + chunk;//累加响应正文
});response.on('end',function(){
console.log(responseHTML);
//分析页面内容
……
});});
hostRequest.end();
从上面的代码中,我们可以看到NodeJS编程的常见风格,就是异步函数套异步函数。在回调函数里,利用异步函数传入的参数做业务处理,往往还需要在内部继续定义回调函数,这样做的好处是可以利用闭包特性来访问外部的变量等。下面我们来看end事件的回调函数,其中要分析页面内容,如果需要访问下一页的话,上面的代码是可以复用的,毕竟功能是一样的,都是访问页面然后分析,那么如何重用呢?在传统的编程中,开发人员会想到使用一个while循环,根据判断条件调用break语句退出,可能的代码如下:
//错误的代码
while(true){
hostRequest = http.request(requestOptions,function(response) {
response.on('data', function (chunk) {
responseHTML = responseHTML + chunk;
});response.on('end',function(){
console.log(responseHTML);
//分析页面内容
……
if(canStop){
break;
}
});
});
hostRequest.end();
}
这种直接按照传统思路编写的代码是完全错误的。感兴趣的读者可以尝试运行该段代码,NodeJS会抛出“溢出”之类的错误。究其原因,这段代码沿用了同步顺序执行的老办法,而实际在运行中,while循环会瞬间产生大量的http请求,而不会等待每个循环中设置的回调函数返回。而且,在end事件对应的回调函数中调用的break语句并不会影响while循环,因为它处于回调函数中,“函数套函数”的编程风格有时会让开发人员误把内部函数的代码当成了外部函数的内容。
现在让我们比较一下第二、三个例子之间的区别。第二个例子是开发人员希望并发运行异步函数,而第三个例子则要求顺序执行异步函数,为的是复用代码。NodeJS在HTTP模块提供的都是异步函数,不像是File System模块那样提供了同步和异步的函数对。在这种情况下,我们该怎么办呢?
熟悉Javascript异步编程的读者可能会从第二个例子中得到启发,想到用setInterval()再加上状态变量来定时判断当前页面的http响应是否处理完毕,而NodeJS的确提供了Timers模块,我们来看一下代码片段:
var previousFinished = true;
var intervalId= setInterval(FindPageItems,1000);function FindPageItems(){
if(previousFinished == false) {
//myLog("wait for ready");
return;
}
previousFinished = false;hostRequest = http.request(requestOptions,function(response) {
response.on('data', function (chunk) {
responseHTML = responseHTML + chunk;
});
response.on('end',function(){
console.log(responseHTML);
//分析页面内容
……
if(canStop){
clearInterval(intervalId);
return;
}
previousFinished = true;
});
});
hostRequest.end();
}
如上图所示,我们把异步执行的代码放在一个命名函数FindPageItems()中,设置变量previousFinished为状态位,并通过setInterval()函数设定FindPageItems函数每隔一段时间(在这里是1秒)就调用一次。其中FindPageItems函数里面一开始首先判断上一次函数调用是否完成,如果没有完成则直接返回,保证函数的顺序执行,不并发。如果上次调用完成,则把previousFinished设为false(未完成状态),然后开始执行后面的代码,发送http请求,并在返回响应后分析处理数据。在end事件对应的回调函数中判断是否可以结束程序运行,如果是则调用clearInterval()函数取消定时器,结束程序运行。如果否,则表示需要继续访问下一页,那么设置previousFinished为true(已完成)。这样本次函数完成之后,定时器会在1秒之后调用下一次函数。这种通过定时器实现的办法对于熟悉Javascript异步编程的开发人员可能比较习惯,其有利有弊。好处是定时器设置比较灵活,对于运行时间较长的应用(第三个例子在请求页面时可能需要若干秒),如果没有其他并发任务要完成,那么用户只能等待,开发人员可以通过函数开头的判断条件语句块来输出一些状态信息,即让函数在快速返回之前做一些友好处理。坏处是,设置定时器增加了一些辅助代码,而且对下次函数的有效调用不是即时性的,需要等待一段时间。
除了上面的定时器解决办法,还有一种更贴近NodeJS异步编程的写法。让我们来看下面的代码片段:
FindPageItems();
function FindPageItems(){
hostRequest = http.request(requestOptions,function(response) {
response.on('data', function (chunk) {
responseHTML = responseHTML + chunk;
});
response.on('end',function(){
console.log(responseHTML);
//分析页面内容
……
if(canStop){
return;
}
FindPageItems();
});
});
hostRequest.end();
}
上面的代码没有使用定时器,而是在end事件的回调函数中判断如果可以满足条件,则通过return结束程序,如果需要再次执行,则直接调用FindPageItems()函数。这种写法代码更简洁,但是可能会让刚接触NodeJS的开发人员感觉不太舒服。乍一看,还以为是递归调用呢。实际上,由于NodeJS的异步函数机制,end事件对应的回调函数独立于FindPageItems函数之外运行。如前所说,“函数套函数”的写法既可以帮助开发人员充分利用闭包带来的好处,又可能在某种程度上造成编程风格难以适应。从这个例子中,我们可以学到几点:对于异步函数的顺序循环处理(目的是代码复用)可以通过定时器机制或者事件回调函数等方法来实现,但不能采用传统的循环语句模式;“函数套函数”的方式需要开发人员对代码结构有清晰的理解,以免造成代码编写错误,如在内部异步函数中试图影响外部函数的执行等问题。
小结
通过上面的几个示例,我们可以看到NodeJS的异步编程风格比较新颖,一方面是由于Javascript的“函数套函数”风格,另一方面,这些内嵌的函数经常是异步执行的,两者交汇在一起就会造成一些编程思路上的转变。对于刚接触NodeJS的开发人员,需要逐步适应这种变化,理解其异步函数以及内嵌的机制,并在实际开发中尝试使用这种编程风格,最终达到融会贯通。本文尝试讨论了NodeJS的编程风格,并初步总结了需要注意的地方,欢迎读者发表意见。
总结的技巧包括:
- 在异步编程中,需要把依赖于异步函数(需要其执行结果或者达到某种状态)的代码放在对应的回调函数中。
- 异步函数后面的代码会立即执行,所以在编程时需要通盘考虑,以免出现意外之外的运行结果。
- 并发运行的相同异步函数如果协作完成任务,需要添加代码判断执行状态,并且需要把所有异步函数完成后执行的代码放在判断条件的语句块里。
- 对于异步函数的顺序循环处理(目的是代码复用)可以通过定时器机制或者事件回调函数等方法来实现,但不能采用传统的循环语句模式。
- “函数套函数”(通常是异步函数)的方式需要开发人员对代码结构有清晰的理解,以免造成代码编写错误,如在内部异步函数中试图影响外部函数的执行等问题。