原文地址: http://cnodejs.org/topic/4f16442ccae1f4aa2700114b
node chat源码解读(一)
作为追随者们的饭前开胃菜,nodejs在它的网站上给出了一个不那么复杂的web系统——node chat,并且提供了源码下载。我们不妨从这道菜开始,品一品nodejs的葱姜蒜粉。
为避免成了事后诸葛亮,假设我们要做一个基于WEB的单机聊天室系统,我们可能用哪些方法?
- 客户端可能用JS定期轮巡查询最新的聊天信息,或者采用类似comet的“服务推”技术来实现聊天信息的及时展现;
- 后端呢,我假设你不会选择用C/C++等从底层开始写一个很强大的聊天室服务端;
- 那么我们用Java/PHP等作为服务端的动态语言,聊天信息的存储可能得用一个小型的数据库了,或者消息队列;session管理就用原生的本地文件存储;
- 在动态语言前端,我们还可能需要web server如nginx、apache等。
差不多就这些了。我假设你只用了不到一天的时间就搞定这些工作了,现在我们歇一会,来看看nodejs大人的做法和我们的方案有什么不一样。
客户端的实现暂时搁置一下,我们先看看后端的代码。
session管理
node chat的session管理比较简单,我们先从这里入手。在server.js中,有下列代码:
var sessions = {};
function createSession (nick) {
/** 新建session,新加入用户时调用 */
...
}
不难理解,node chat把session也放在一个变量名为sessions的对象列表中。每个session元素除了包含session id、用户nick之外,还有一个重要的属性timestamp来作为session过期的判断依据。
setInterval(function () {
var now = new Date();
for (var id in sessions) {
if (!sessions.hasOwnProperty(id)) continue;
var session = sessions[id];
if (now - session.timestamp > SESSION_TIMEOUT) {
session.destroy();
}
}
}, 1000);
我们看到node chat用一个定时器每隔1秒钟(1000 ms)遍历sessions列表,如果某个session元素的timestamp超过了session过期时间,则把它destroy掉(除了从sessions列表中把相应元素delete掉之外,destroy方法还负责向聊天室里广播一条“离开”的系统消息)。
消息管理
与我们采用小型数据库来存储聊天信息相比,node chat在server中通过channel对象维护了一个固定大小(MESSAGE_BACKLOG = 200)的消息队列messages:
var channel = new function () {
var messages = [],
callbacks = [];
this.appendMessage = function (nick, type, text) {
/** 消息压入,发言时调用 */
...
/** 队列满,头上的被踢出 */
while (messages.length > MESSAGE_BACKLOG)
messages.shift();
};
this.query = function (since, callback) {
/** 消息查询,按照发言时间先后顺序列出 */
...
};
};
看到这里,我们绝对不会漏掉另外一个不在我们设想中的队列——callbacks。直觉告诉我们,这个callbacks(回调函数队列)应该能够充分体现nodejs“事件机制”的核心思想。至于其中的究竟,我们在下一篇文章中结合前端的实现机制一起来介绍。
web server
与我们之前的传统设计一样,web server这一块通常都是采用开源的第三方软件来实现。我们一般不会太多地考虑它们的实现机制,而是直接拿来使用,顶多做一些优化而已。
有趣的是,nodejs相信自己就是一个足够优秀的web server,你瞧瞧,在server.js里紧接着它就来了这么一段代码:
fu.listen(Number(process.env.PORT || PORT), HOST);
....
fu.get("/who", function (req, res) {
var nicks = [];
for (var id in sessions) {
if (!sessions.hasOwnProperty(id)) continue;
var session = sessions[id];
nicks.push(session.nick);
}
res.simpleJSON(200, { nicks: nicks
, rss: mem.rss
});
});
不难理解,它是监听(listen)了本地的一个预定义端口(PORT = 8001);对于前端来的who请求(controller的概念出来了),它从sessions列表里找出当前在线的用户,并以JSON方式输出给前端。
作为源码解读的第一部分,我们的目标大概是完成了。我们不妨先小结一下,nodejs的实现与我们最初的设计之间的异同:
- 无论是nodejs还是PHP或者Java,单机版的web聊天室要实现的核心功能是一致的,即session管理以及消息管理;这是产品本身的特点决定的,与实现方式关系不大;
- nodejs的实现上,session与消息都维护在本地内存,不存在本地或者远程的磁盘/网络IO;反观传统设计,session和消息都需要用额外的存储介质;
- 注意到了没,nodejs前端没有任何web server?也就是说,在nodejs的世界里,程序即server。
node chat源码解读(二)
在上一篇文章里,我们阐述了node chat在服务端程序上的一些处理特点。这篇文章我们一起来分析一下前段与后端的交互机制。相信理解了这个机制之后,你对上一篇文章的遗留问题——消息的callbacks——的设计理念也就无师自通了。
同样地,我们从需求来入手。假设我们是web聊天室的用户,我们希望它能够给我们提供什么样的功能?我想大部分人的答案应该是信息的
及时传递。请注意这个“及时”,它要求我们在无状态的HTTP协议下发言,和我们在一群人中间七嘴八舌地说话达到同样的沟通效果。否则,我们可能碰到什么情况呢?
对明天的活动,我有一个初步的安排。在聊天室里发了出去,很久没有收到其他人的回答——而实际上,他们在一看见我的发言就都发表了自己的看法。
果真做成了这样,node chat就不能叫node chat了,顶多只是一个node bbs。用户对node chat的需求应该类似这样的:
- A说:“买火车票变容易了”;
- 马上,聊天室里的人就笑了,或者哭了;
- 没有丝毫延迟,A又问:“你们笑什么,哭什么?”
- ...
那么,node chat怎样基于HTTP协议设计这种零延迟的消息传递呢?通过阅读client.js的源码我们得知,它在前端采用了基于AJAX的long-polling的长连接模型来与服务端保持持续通信;而在客户端server.js里通过事件机制将A一个用户发送的消息实时地“推送”给其他用户。我们来看代码:
客户端client.js
/** client.js , line 265: */
function longPoll (data) {
...
/** 此处省去XX字,无非是对接收到的数据data进行处理并显示在屏幕上 */
...
$.ajax({ cache: false
, type: "GET"
, url: "/recv"
, dataType: "json"
, data: { since: CONFIG.last_message_time, id: CONFIG.id }
, error: function () {
addMessage("", "long poll error. trying again...", new Date(), "error");
transmission_errors += 1;
setTimeout(longPoll, 10*1000);
}
, success: function (data) {
transmission_errors = 0;
longPoll(data);
}
});
}
我们看到,客户端在document ready后进入第一次longPoll。一旦收到服务端返回的消息数据则对其处理,使得显示在屏幕上之后,立即进入下一次AJAX请求,通过/recv这个URL请求最新的数据。请注意这里传递给服务端的数据data,它包含两个属性,一个是since,代表当前客户端最后一次拿到数据的时间;另一个属性id,则是上一篇文章里我们讲的session id。还要注意客户端对AJAX返回值的处理机制:
- 如果请求数据错误,客户端在10s之后重新调用longPoll,类似错误后的重连;
- 如果请求数据正常,则马上进入下一次longPoll。
服务端server.js
fu.get("/recv", function (req, res) {
if (!qs.parse(url.parse(req.url).query).since) {
res.simpleJSON(400, { error: "Must supply since parameter" });
return;
}
var id = qs.parse(url.parse(req.url).query).id;
var session;
if (id && sessions[id]) {
session = sessions[id];
session.poke();
}
var since = parseInt(qs.parse(url.parse(req.url).query).since, 10);
channel.query(since, function (messages) {
if (session) session.poke();
res.simpleJSON(200, { messages: messages, rss: mem.rss });
});
});
我们看到,服务端在接收到recv请求后首先对session以及传入参数进行一些验证,然后后通过调用channel的query方法来请求最新的聊天数据;并且,通过传入callback函数,
希望将最新的聊天数据以JSON的方式返回给客户端。请注意,只有实际调用了res对象的simpleJSON方法才是真正地向客户端返回了数据。
请注意这里只是“希望”,也就是说channel对象的query方法有权拒绝这么做。是不是这样呢?我们到query方法里看个究竟:
/** channel对象的声明函数里 */
this.query = function (since, callback) {
var matching = [];
for (var i = 0; i since)
matching.push(message)
}
if (matching.length != 0) {
callback(matching);
} else {
callbacks.push({ timestamp: new Date(), callback: callback });
}
};
注意上边的代码里最后一个if判断。果然,query方法按照我们的设想做了:
- 如果匹配到了最新的消息(matching.length > 0),满足callback的需求,将结果输出;
- 否则,将callback压入callbacks队列,没有任何返回。
请注意第二种情况,也就是这次查询没有匹配到任何新消息——更通俗点讲,自从上次向客户端返回数据之后,聊天室里一直没人说话——客户端的这次请求是一直阻塞着的(因为只有调用了callback才能通过res.simpleJSON将结果返回给客户端)。从这个角度来分析,channel对象的callbacks队列实际上保存了所有阻塞住等待数据的客户端的列表。
那么,这种情况一直阻塞到什么时候呢?我想应该所有人都会不假思索地说:“当然是有人说或的时候!”没错,我们看channel对象的appendMessage方法:
this.appendMessage = function (nick, type, text) {
/** 构造消息对象m,略去 */
messages.push( m );
while (callbacks.length > 0) {
callbacks.shift().callback([m]);
}
while (messages.length > MESSAGE_BACKLOG)
messages.shift();
};
在将新的消息压入messages队列之后,服务端来检查channel对象的callbacks队列,并逐一通过调用callback函数,将最新的数据返回给客户端。
这简直是太聪明了!
我只能用上边这句赞叹来结束这篇文章了,再啰嗦就惹人厌了。相信你们也巴不得我闭嘴好让你自己洗洗品味这种设计的绝妙之处了。好的,我再啰嗦一句,你可以再阅读阅读下面两篇文章——相信我,这会让你们更深入地了解服务推的技术的:
- Comet:基于HTTP长连接的“服务器推”技术,IBM develerworks,2007年8月31日;
- Node 下 Http Streaming 的跨浏览器实现,[email protected],2011年1月21日。
node chat源码解读(三)
再优雅的代码本身也只是表象。表象之后,nodejs最核心的地方在哪里呢?我认为,可以从以下几个方面来概括:
程序即server
传统的web服务通常是把提供HTTP协议支持的前端server与背后承担业务逻辑的程序分开的。比如用PHP写成的wordpress只是程序,它的前段一定要架设一个nginx,或者apache,或者其他的web server才能提供服务。
这一模式显然有他与生俱来的优势——把协议层和应用层隔离开了。程序开发者只需要关心业务代码的开发,而不用或者很少去关心协议层的实现。而且,在大多数情况下,同样的应用层代码可以在不同的web server上正确地工作。同样地,协议层的web server开发者只需要专心实现好协议层的功能即可,而不需要也无法照顾业务层的功能。
这种模式使得隔离后的双方都更专注,催生了一批优秀的开源软件,如server端的nginx,apache和lighttp等;即便在需求复杂多变的应用端,也诞生了不计其数的优秀作品。
也正因为上述server与程序分离的原因,多次HTTP请求之间很难通过server建立高效的数据共享通道,对于读请求远多于写请求的系统来说,业务端程序需要反复地读取很少变动的数据,不仅性能不好,而且给后端存储带来了不必要的压力。尤其是这种分离直接导致了对连接池的支持乏力。
尽管后来在二者之间出现了一些借助共享内存实现的数据共享机制(如apc),甚至有些对性能有较高要求的团队通过给http server写module的方式定制化地满足了请求之间的数据共享,但双方配合方式没有实质性的改变。
而nodejs由于自身直接提供了http服务的module(或者说框架),因而开发者能很容易地写出一个协议层面的HTTP Server。将http server与程序逻辑写在一起是如此地容易,那么前端还要什么web server呢!
这一做法绝不是噱头。在这种方式下,数据能够很容易地在请求之间共享,对连接池的支持也似乎不是一件值得炫耀的事情了。
异步IO与事件机制
异步IO这一特性并没有在node chat中得到足够的体现,但却是nodejs不得不提的一个特性。这一特性把浪费在IO等待上的CPU时间充分地”压榨”了出来,在一定程度上能够降低程序的响应时间。
但是,异步总会使得程序更复杂,非必要情况下我们不建议使用。而nodejs中采用事件机制来降低由此带来的程序复杂度,不能不说是绝妙的组合。
如水所述,我们可以大致地概括出,在nodejs模式下,一个提供web服务的“
程序”的一些基本的设计思路:
- 在http server中用连接池来保护外部资源;
- 很少改变的数据作为“静态”变量“缓存”在http server层,通过定时器或者外部接口进行更新;
- 对外部资源的读写采用异步IO+事件绑定的方式实现CPU时间的零等待。