Node.js通过其强大的事件驱动模型提供了可扩展性和性能,本篇文章的重点是理解该模型,以及它是如何不同于大部分Web服务器采用的传统线程模型的。了解事件模型至关重要,因为它可能迫使你改变设计应用程序的思维。然而,这些变化将是非常值得的,因为你通过使用Node.js获得了在速度上的提高。
本篇文章还包括用来把工作添加到Node.js事件队列的不同方法。你可以通过使用事件监听器或计时器添加工作,或者你也可以直接调度工作。在这篇文章中,你还将学习如何在自己的自定义模块和对象中实现事件。
1,了解Node.js事件模型
Node.js应用程序在一个单线程的事件驱动模型中运行。虽然Node.js在后台实现了一个线程池来工作,但应用程序本身不具备多线程的任何概念。"等等,性能和规模怎么样呢?"你可能会问。起初,单线程服务器似乎有悖常理,但一旦你理解了Node.js事件模型背后的逻辑,这一切就很好理解了。
(1)比较事件回调和线程模型
在传统的线程网络模型中,请求进入一个Web服务器,并被分配给一个可用的线程。对于该请求的处理工作继续在该线程上进行,直到请求完成并发出响应。
图1显示了处理GetFile和GetData两个请求的线程模型。GetFile请求打开文件,读取其内容,然后在一个响应中将数据发回。所有这些在相同的线程中按顺序发生。GetData请求连接到数据库,查询所需的数据,然后在响应中将数据发送出去。
现在思考Node.js事件模型的工作原理。Node.js不是在各个线程为每个请求执行所有的工作,反之,它把工作添加到一个事件队列中,然后有一个单独的线程运行一个事件循环把这个工作提取出来。事件循环抓取事件队列中最上面的条目,执行它,然后抓取下一个条目。当执行长期运行或有阻塞I/O的代码时,它不是直接调用该函数,而是把函数随同一个要比此函数完成后执行的回调一起添加到事件队列。当Node.js事件队列中的所有事件都被执行完成时,Node.js应用程序终止。
图2显示了Node.js如何处理GetFile和GetData请求。它将GetFile和GetData请求添加到事件队列。它首先提取出GetFile请求,执行它,并通过将Open()回调函数添加到事件队列完成它,然后它提取出GetData请求,执行它,并通过将Connect()回调函数添加到事件队列来完成它。这种情况持续下去直到没有任何回调函数要执行。请注意,在图二中,没个线程的事件并不一定遵循直接交错顺序。例如,脸接(connect)请求比读(read)请求需要更长的时间来完成,所以Send(file)在Query(db)之前调用。
(2)在Node.js中阻塞I/O
除非你遇到在I/O上阻塞的函数的问题,否则使用事件回调的Node.js事件模型还是不错的。阻塞I/O停止当前线程的执行并等待一个回应,知道收到回应才能继续。阻塞I/O的一些例子有:
- 读取文件
- 查询数据库
- 请求套接字
- 访问远程服务
Node.js使用事件回调来避免对阻塞I/D的等待。因此,执行阻塞I/O的任何请求都在后台不同的线程中执行。Node.js在后台实现线程池。当该快的I/O从时间队列中检索一个事件时,Node.js从线程池中获取一个线程,并在哪里执行功能,而不是主事件循环线程执行功能。这可用防止阻塞I/O阻碍事件队列中的其余事件。
在被阻塞的线程上执行的函数仍然可以把事件添加到要进行处理的事件队列中。例如,一个数据库查询调用通常通过回调函数来解析结果,并可能会在发送一个响应之前在时间队列上调度其他的工作。
图3显示了完整的Node.js事件模型,包括事件队列,事件循环和线程池。注意,事件循环要么在事件循环线程本身上执行功能,要么在一个单独的线程上执行功能,对于阻塞I\O,则采取后一种方式。
(3)会话示例
为了帮助你了解与传统的线程Web服务器相比,事件在Node.js中是如何工作的,请考虑在聚会上与一大群人进行不同的会话的例子。你充当Web服务器这部分,会话代表处理不同类型的Web请求的必要工作。你的会话被分成与不同的个体会话的若干个段。你结束了与一个人的会话,接着与另一个人会话,然后再回到第一个人,之后是第三个人,再回到第二个人,依次类推。
会话的例子效果很好,因为它与Web服务器的处理过程有很多相似之处。有些会话很快结束(例如,一个对在内存中的数据块的简单请求)。其他会话则被拆分成在人与人之间来回往返的几个片段(很像更复杂的服务器端的会话)。还有一些会话在等待其他人来回应,它的时间间隔很长(如对文件系统,数据库或远程服务的阻塞I/O请求)。
在会话示例中使用传统的Web服务器的线程模型初听起来很不错,因为每个线程都像你的一个"克隆"。线程/克隆可以来回与每个人会话,并且好像你同事可以有多个会话。但此模型存在如下两个问题。
第一个问题是,你受克隆数量的限制。如果你只有5个克隆,那么为了与第6个人会话,一个克隆必须完全完成其会话。第二个问题是,线程/克隆必须共享的CPU/大脑数量有个限值。这意味着,当其他克隆用脑时,共享相同的大脑的克隆不得不停止说话/听。你可以看到,如果克隆在其他克隆用脑时冻结,拥有它们真的没有多少好处。
Node.js事件模型的作用比传统的Web服务器模型更类似于现实生活中的会话。首先,Node.js应用程序在单个线程上运行,这意味着只有一个你,没有克隆。每当一个人问你一个问题,你都能尽快做出回应。你的交互完全是事件驱动的,你自然会从一个人转移到下一个人。因此,在同一时间你和别人之间可以有尽可能多的会话,只要你愿意。其次,你的大脑总是只关注你正在交谈的人,因为你不与克隆共享它。
那么如果有人问你一个你要思考一会儿才回答的问题,会发生什么情况呢?你可以在努力处理你的脑海里的问题的同时与聚会上的其他人继续交互。该处理可能会处理你与其它人进行交互的速度,但你仍然可以在处理长时间的思考时与几个人交流。这类似于Node.js如何使用后台线程池处理阻塞的I/O请求。Node.js将阻塞的请求移交给线程池中的线程,以便让它们对应用程序处理事件的影响微乎其微。
2,将工作添加到事件队列
当你创建Node.js应用和设计代码的时候,你需要记住在前面描述的事件模型。要利用事件模型的可扩展性和性能,你要确保把工作分解为可以作为一系列的回调来执行的块。
当你正确地设计代码时,可以使用事件模式来在事件队列上调度工作。在Node.js应用程序中,你可以使用下列方法之一传递回调函数来在事件队列中调度工作:
- 对阻塞I/O库调用之一做出调用,如写入文件或连接到一个数据库。
- 对内置的事件,如http.request或server.connection添加一个事件监听器。
- 创建自己的事件发射器并对它们添加自定义的监听器
- 使用process.nextTick选项来调度在事件循环的下一次循环中被提取出的工作。
- 使用定时器来调度在特定的时间数量或每隔一段时间后要做的工作。
(1)实现定时器
Node.js和JavaScript的一个有用的功能是将代码的执行延迟一段时间的能力。这对于你不想总是运行的清理或更新工作非常有用。在Node.js中你可以实现3种类型的定时器:超时时间,时间间隔和即时定时器。以下各节描述每种定时器以及如何在你的代码中实现它们。
用超时时间来延迟工作
超时定时器用于将工作延迟一个特定的时间数量。当时间到了时,回调函数执行,而定时器会消失。对于只需要执行一次的工作,你应该使用超时时间。
创建超时时间定时器使用Node.js中内置的setTimeout(callback,delayMilliSeconds,[args])方法。当你调用setTimeout()时,回调函数在delayMilliSeconds到期后执行。例如,下面的语句在1秒后执行myFunc():
setTimeout(myFunc,1000);
setTimeout()函数返回一个定时器对象的ID,你可以在delayMilliSeconds到期前的任何时候把此ID传递给clearTimeout(timeoutId)来取消超时时间函数。
例如:
myTimeout = setTimeout(myFunc,100000);
...
clearTimeout(myTimeout);
下图中的代码实现了调用simpleTimeout()函数的一系列简单超时时间,它输出自从超时时间被安排后经历的毫秒数。请注意,setTimeout()的调用次序是无关紧要的。
function simpleTimeout(consoleTimer){
console.timeEnd(consoleTimer);
}
console.time("twoSecond");
setTimeout(simpleTimeout,2000,"twoSecond");
console.time("oneSecond");
setTimeout(simpleTimeout,1000,"oneSecond");
console.time("fiveSecond");
setTimeout(simpleTimeout,5000,"fiveSecond");
下图的结果按照其中的延时结束的顺序出现。
用时间间隔执行定期工作
时间间隔定时器用于按定期的延迟时间间隔执行工作。当延迟时间结束时,回调函数被执行,然后再次重新调度为该延迟时间。对于必须定期进行的工作,你应该使用时间间隔。
你可以通过使用Node.js中内置的setInterval(callback,delayMilliSeconds,[args])方法创建时间间隔计时器。当你调用setInterval()时,每个delayMilliSeconds间隔到期后,回调函数执行。例如,下面的语句每秒执行一次myFunc():
setInterval(myFunc,1000);
setInterval()函数返回一个定时器对象的ID,你可以在delayMilliSeconds到期之前的任何时候把这个ID传递给clearInterval(intervalId)来取消超时时间函数。例如:
myInterval = setInterval(myFunc,1000);
...
clearInterval(myInterval);
下面清单中的代码实现了一系列在不同的时间间隔更新变量x,y和z值的简单时间间隔回调。
var x=0,y=0,z=0;
function displayValues(){
console.log("x=%d,y=%d,z=%d",x,y,z);
}
function updateX(){
x += 1;
}
function updateY(){
y += 1;
}
function updateZ(){
z +=1;
displayValues();
}
setInterval(updateX,500);
setInterval(updateY,1000);
setInterval(updateZ,2000);
下面是输出的结果
使用即时计时器立即执行工作
即时计时器用来在I/O事件的回调函数开始执行后,但任何超时时间或时间间隔事件被执行之前,立即执行工作。它们允许你把工作调度为在事件队列中的当前事件完成之后执行,你应该使用即时定时器为其他回调产生长期运行的执行段,以防止I/O事件饥饿。
你可以使用Node.js中内置的setImmediate(callback,[args])方法创建即时定时器。当你调用setImmediate()时,回调函数被放置在事件队列中,并在遍历事件队列循环的每次迭代中,在I/O事件有机会被调用后弹出一次。例如,下面的代码调度myFunc()来在遍历事件队列的下一个周期内执行:
setImmediate(myFunc(),1000);
setImmediate()函数返回一个定时器对象的ID,你可以在从事件队列提取出它前的任何时候把这个ID传递给clearImmediate(immediatteId)。例如:
myImmediate = setImmediate(myFunc(),1000);
...
clearImmediate(myImmediate);
从事件循环中取消定时器引用
当定时器事件回调是留在事件队列中的仅有事件时,通常你不会希望他们继续被调度。Node.js提供了一个非常有用的工具来处理这种情况。这个工具是在setInterval和setTimeout返回的对象中可用的unref()函数,它让你能够在这些事件是队列中仅有的事件时,通知时间循环不要继续。
例如,下面的代码取消myInterval时间间隔定时器引用:
myInterval = setInterval(myFunc);
myInterval.unref();
如果以后由于某种原因,你不想时间间隔函数是留在队列中的仅有事件时终止程序,就可用使用ref()函数来重新引用它:
myInterval.ref()
#当unref()与setTimeout定时器结合使用时,要用一个独立的定时器来唤醒事件循环。大量使用这些功能会对你的代码性能产生不利影响,所以你应该尽量少地创建它们。
(2)使用nextTick来调度工作
在事件队列上调度工作的一个非常有用的方法是使用process.nextTick(callback)函数。此函数调度要在事件循环的下一次循环中运行的工作。不像setImmediate()函数,nextTick()在I/O事件被触发之前执行,这可能会导致I/O事件的饥饿,所以Node.js通过默认值为1000的process.maxTickDepth来限制事件队列的每次循环可执行的nextTick()事件的数目。
下面的程序清单中的代码说明了使用阻塞I/O调用,定时器和nextTick()时,事件的顺序,请注意,阻塞调用fs.stat()首先执行,然后是两个setImmediate()调用,之后是两个nextTick()调用,再下面的图中的输出显示两个nextTick()调用在任何其他的调用之前执行,之后是第一个setImmediate()调用,接着是fs.stat()调用在任何其他的调用之前执行,之后是第一个setImmediate()调用,接着是第二个setImmediate()被调用,最后是fs.stat()被调用。
var fs = require("fs");
fs.stat("nexttick.js",function(err,stats){
if(stats){
console.log("nexttick.js Exists");
}
});
setImmediate(function(){
console.log("Immediate Timer 1 Executed");
});
setImmediate(function(){
console.log("Immediate Timer 2 Executed");
});
process.nextTick(function(){
console.log("Next Tick 1 Executed");
});
process.nextTick(function(){
console.log("Next Tick 2 Executed");
});
(3)实现事件发射器和监听器
将自定义事件添加到JavaScript对象
事件使用一个EventEmitter对象来发出。这个对象包含在events模块中。emit(eventName,[args])函数触发eventName事件,包括所提供的任何参数。下面的代码片段演示了如何实现一个简单的事件发射器:
var events = require('events');
var emitter = new events.EventEmitter();
emitter.emit("simpleEvent");
有时,你会想直接把事件添加到你的JavaScript对象。要做到这一点,就需要通过在对象实例中调用events.EventEmitter.call(this)来在对象中继承EventEmitter功能。你还需要把events.EventEmitter.prototype添加到对象的原型中。例如:
function MyObj(){
Events.EventEmitter.call(this);
}
MyObj.prototype.__proto__ = events.EventEmitter.prototype;
然后,你就可用直接从对象实例中发出事件。例如:
var myObj = new MyObj();
myObj.emit("someEvent");
把事件监听器添加到对象
一旦有了一个会发出事件的对象实例,你就可用为自己所关心的事件添加监听器。你可以通过使用下面的功能之一把监听器添加到EventEmitter对象。
- .addListener(eventName,callback):将回调函数附加到对象的监听器中。每当eventName事件被触发时,回调函数就被放置在事件队列中执行。
- .on(eventName,callback):同.addListener()
- .once(eventName,callback):只有eventName事件第一次被触发时,回调函数才被放置在事件队列中执行。
例如,要在前面定义的MyObject EventEmitter类的实例中增加一个监听器,你可以使用如下代码:
function myCallback(){
...
}
var myObject = new MyObj();
myObject.on("someEvent",myCallback);
从对象中删除监听器
监听器非常有用,并且是Node.js编程的重要组成部分。然而,它们会导致开销,你应该只在必要时使用它们。Node.js在EventEmitter对象上提供多个辅助函数来让你管理包含的监听器。
- .listeners(eventName):返回一个连接到eventName事件的监听器函数的数组。
- .setMaxListeners(n):如果多于n的监听器都加入到EventEmitter对象,就触发警报。它的默认值是10
- .removeListener(eventName,callback):将callback函数从EventEmitter对象的eventName事件中删除。
实现事件监听器和发射器事件
下述代码清单中的代码演示在Node.js中实现监听器和自定义事件发射器的过程。Account对象从EventEmitter类继承并提供了两种方法,即deposit(存款)和withdraw(取款),它们都发射balanceChanged事件。然后在第15~31行,3个回调函数的实现连接到Account对象实例的balanceChanged事件并显示各种形式的数据。
请注意,checkGoal(acc,goal)回调函数的实现有点不同于其他回调函数。这说明了如何在事件被触发时,将变量传递到该事件监听函数。
var events = require('events');
function Account(){
this.balance = 0;
events.EventEmitter.call(this);
this.deposit = function(amount){
this.balance += amount;
this.emit('balanceChanged');
};
this.withdraw = function(amount){
this.balance -= amount;
this.emit('balanceChanged');
};
}
Account.prototype.__proto__ = events.EventEmitter.prototype;
function displayBalance(){
console.log("Account balance: $%d",this.balance);
}
function checkOverdraw(){
if(this.balance < 0){
console.log("Account overdrawn!!!");
}
}
function checkGoal(acc,goal){
if(acc.balance > goal){
console.log("Goal Achieved!!!");
}
}
var account = new Account();
account.on("balanceChanged",displayBalance);
account.on("balanceChanged",checkOverdraw);
account.on("balanceChanged",function(){
checkGoal(this,1000);
});
account.deposit(220);
account.deposit(320);
account.deposit(600);
account.withdraw(1200);
3,实现回调
正如你在前面所看到的,Node.js事件驱动模型在很大程度上依赖于回调函数。起初,回调函数可能会有点难以理解,特别是如果你想从实现基本的匿名函数出发的话。本节讨论回调的3个具体实现:将参数传递给回调函数,在循环内处理回调函数参数,以及嵌套回调。
(1)向回调函数传递额外的参数
大部分回调函数参数都有传递给它们的自动参数,如错误或结果缓冲区。使用回调时,常见的一个问题是如何调用函数给它们传递额外的参数。做到这一点的方法是在一个匿名函数中实现该参数,然后用来自匿名函数的参数调用回调函数。
下面清单中的代码显示了如何调用回调函数的参数。有两个sawCar事件处理程序。请注意,sawCar仅发出make参数。第一个事件处理程序,在第16行,实现了logCar(make)回调处理程序。要为logColorCar()添加颜色,在第17~21行定义的事件处理程序用了一个匿名函数。随机选择的颜色被传递到logColorCar(make,color)调用。
var events = require('events');
function CarShow(){
events.EventEmitter.call(this);
this.seeCar = function(make){
this.emit('sawCar',make);
};
}
CarShow.prototype.__proto__ = events.EventEmitter.prototype;
var show = new CarShow();
function logCar(make){
console.log("Saw a "+make);
}
function logColorCar(make,color){
console.log("Saw a %s %s",color,make);
}
show.on("sawCar",logCar);
show.on("sawCar",function(make){
var colors = ['red','blue','black'];
var color = colors[Math.floor(Math.random()*3)];
logColorCar(make,color);
});
show.seeCar("Ferrari")
show.seeCar("Porsche")
show.seeCar("baicai")
(2)在回调中实现闭包
一个与异步回调有关的有趣问题是闭包。闭包是一个JavaScript的术语,它表示变量被绑定到一个函数的作用域,但不能绑定到它的父函数的作用域,当你执行一个异步回调时,父函数的作用域可能更改(例如,通过遍历列表并在每次迭代时改变值)。
如果某个回调函数需要访问父函数的作用域的变量,就需要提供闭包,使这些值在回调函数从事件队列被提取出时可以看到。实现这一点的一个基本方法是在函数内部块内部封装一个异步回调,并传入所需要的变量。
下面清单中的代码说明了如何实现为logCar()异步函数提供闭包的包装器函数。请注意,第7~12行的循环实现了一个基本的回调函数。然而,在后面的输出显示中,汽车的名字始终是被读取的最后一个条目,因为每次循环迭代时,message的值都会发生变化。
在第13~20行的循环实现了把消息作为msg参数传递的包装器函数,而msg值被附着在回调函数上。因此,后面所示的闭包输出显示了正确的消息。为了使回调真正地异步,要使得process.nextTick()方法来调度回调函数。
function logCar(logMsg,callback){
process.nextTick(function(){
callback(logMsg);
});
}
var cars = ["Ferrari","Porsche","Bugatti"];
for(var idx in cars){
var message = "Saw a "+cars[idx];
logCar(message,function(){
console.log("Normal Callback: "+message);
});
}
for (var idx in cars){
var message = "Saw a "+ cars[idx];
(function(msg){
logCar(msg,function(){
console.log("Closure Callback: " + msg);
});
})(message);
}
(3)链式回调
使用异步函数时,如果两个函数都在事件队列上,则你无法保证它们的运行顺序。解决这一问题的最佳方法是让来自异步函数的回调再次调用该函数,知道没有更多的工作要做,以执行链式回调。这样,异步函数永远不会在事件队列上超过一次。
下面清单中的代码是执行链式回调函数的一个非常基本的例子。条目列表被传递到函数logCars(),然后一步函数logCar()被调用,并且logCars()函数作为当logCar()完成时的回调函数。因此,同一时间只有一个版本的logCar()在事件队列上。
function logCar(car,callback){
console.log("Saw a %s",car);
if(cars.length){
process.nextTick(function(){
callback();
});
}
}
function logCars(cars){
var car = cars.pop();
logCar(car,function(){
logCars(cars);
});
}
var cars = ["Ferrari","Psrsche","Bugatti","baicai","qincai"];
logCars(cars);
4,小结
Node.js使用事件驱动模型来提供可扩展性和性能。在本篇文章中,主要讲了事件驱动模型和Web服务器的传统线程模型的差异。你也知道,当阻塞I/O被调用时,你可以把事件添加到事件队列中,并且你可以使用事件,定时器或nextTick()方法来调度活动。