原文地址: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,略作改动)(原程序还是无法运行,略作改动):
//同步 var fs = require('fs'),processID; filenames = fs.readdirSync("."); for (i = 0; i < filenames.length; i++) { console.log(filenames[i]); } processID = process.pid; console.log('Current uid: ' + processID + '\n\n'); //异步 fs.readdir(".", function (err, filenames) { var i; for (i = 0; i < filenames.length; i++) { console.log(filenames[i]); } }); processID = process.pid; console.log('Current uid: ' + processID);运行结果:
请注意看,在同步函数的代码中,没有什么特别之处,应用程序会按顺序打印当前目录包含的文件名,然后再打印当前进程的用户ID,其实际运行结果也如我们所料。而在异步函数的代码中,我们把打印文件名的代码放在了readdir函数参数里的回调函数中,这样当readdir获取目录信息之后就调用该回调函数打印文件名。但是应用程序在调用了异步函数fs.readdir(".", function (err, filenames) )之后,会立即执行后面打印进程用户ID的代码,不会停下来等待readdir函数返回。这就是异步与同步的差别,实际的运行结果也与之前不同,异步函数的执行和回调函数的处理总需要一些时间,所以在很大程度上应用程序会首先打印出进程用户ID,再打印出文件名。在通常的测试环境中,结果也是这样。从这个例子中,我们可以学到两点:一是在异步编程中,需要把依赖于异步函数(需要其执行结果或者达到某种状态)的代码放在对应的回调函数中;二是异步函数后面的代码会立即执行,所以在编程时需要通盘考虑,以免出现意外之外的运行结果。
第二个例子,统计所有文件字节数
刚才的例子是一个简单的顺序执行逻辑,如果异步函数包含在循环中会是什么样子?就会出现若干异步函数在并发运行的情况,开发人员需要这些异步函数共同完成一项任务的话,如何协作? 看到这里,读者的脑海里可能会马上浮现出其他编程语言中线程并发的代码。现在来看第二个NodeJS示例,计算当前目录中所有文件占用的总字节数。该例子用到的是同步函数statSync()和异步函数stat(),它们可以获取文件的基本信息。先来看看各自的代码片段(源于Marc Fasel,略作改动):
//同步 var fs = require('fs'),totalBytes = 0; filenames = fs.readdirSync("."); for (i = 0; i < filenames.length; i ++) { stats = fs.statSync("./" + filenames[i]); totalBytes += stats.size; } console.log(totalBytes+'\n'); //异步 count = filenames.length; totalBytes = 0; 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(){……});
var hostRequest = http.request(requestOptions,function(response) { response.on('data', function (chunk) { responseHTML = responseHTML + chunk;//累加响应正文 }); response.on('end',function(){ console.log(responseHTML); //分析页面内容 …… }); }); hostRequest.end();
//错误的代码 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(); }
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(); 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(); }