在上一篇文章里,我们阐述了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, 101000);
}
, 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 的跨浏览器实现,qingdu@cnodejs.org,2011年1月21日。
8 回复
node chat的前后端交互机制设计的非常优雅,这篇文章介绍的也非常到位非常棒。
我在看这部分源码的时候一直有个问题:
我们知道,每当有新消息到达服务器的时候,node chat都会以这个新消息为参数,来将callbacks数组中的回调函数依次调用一遍,这样,每个用户都会接收到这条消息。
从node chat 的运行结果上看,好像每个callback都是与某一个用户相关联的,但是我并没有在代码中找到这种关联,node chat 怎么就知道哪一个callback是对应于哪一个用户的呢?
与“用户相关联”是HTTP协议自身保证的。客户端(浏览器)发一个HTTP请求过去,server给返回一些数据… 只不过node chat设计上当没有新数据的时候这个请求不会立刻返回,而是阻塞在那里而已,直到有新数据或者超时