新手入门贴:史上最全Web端即时通讯技术原理详解
前言
有关IM(InstantMessaging)聊天应用(如:微信,QQ)、消息推送技术(如:现今移动端APP标配的消息推送模块)等即时通讯应用场景下,大多数都是桌面应用程序或者native应用较为流行,而网上关于原生IM(相关文章请参见:《IM架构篇》、《IM综合资料》、《IM/推送的通信格式、协议篇》、《IM心跳保活篇》、《IM安全篇》、《实时音视频开发》)、消息推送应用(参见:《推送技术好文》)的通信原理介绍也较多,此处不再赘述。
而web端的IM应用,由于浏览器的兼容性以及其固有的“客户端请求服务器处理并响应”的通信模型,造成了要在浏览器中实现一个兼容性较好的IM应用,其通信过程必然是诸多技术的组合,本文的目的就是要详细探讨这些技术并分析其原理和过程。
更多资料整理
Web端即时通讯技术盘点请参见:
《Web端即时通讯技术盘点:短轮询、Comet、Websocket、SSE》
关于Ajax短轮询:
找这方面的资料没什么意义,除非忽悠客户,否则请考虑其它3种方案即可。
有关Comet技术的详细介绍请参见:
《Comet技术详解:基于HTTP长连接的Web端实时通信技术》
《WEB端即时通讯:HTTP长连接、长轮询(long polling)详解》
《WEB端即时通讯:不用WebSocket也一样能搞定消息的即时性》
《开源Comet服务器iComet:支持百万并发的Web端即时通讯方案》
有关WebSocket的详细介绍请参见:
《WebSocket详解(一):初步认识WebSocket技术》
《WebSocket详解(二):技术原理、代码演示和应用案例》
《WebSocket详解(三):深入WebSocket通信协议细节》
《Socket.IO介绍:支持WebSocket、用于WEB端的即时通讯的框架》
《socket.io和websocket 之间是什么关系?有什么区别?》
有关SSE的详细介绍文章请参见:
《SSE技术详解:一种全新的HTML5服务器推送事件技术》
更多WEB端即时通讯文章请见:
http://www.52im.net/forum.php?mod=collection&action=view&ctid=15
一、传统Web的通信原理
浏览器本身作为一个瘦客户端,不具备直接通过系统调用来达到和处于异地的另外一个客户端浏览器通信的功能。这和我们桌面应用的工作方式是不同的,通常桌面应用通过socket可以和远程主机上另外一端的一个进程建立TCP连接,从而达到全双工的即时通信。
浏览器从诞生开始一直走的是客户端请求服务器,服务器返回结果的模式,即使发展至今仍然没有任何改变。所以可以肯定的是,要想实现两个客户端的通信,必然要通过服务器进行信息的转发。例如A要和B通信,则应该是A先把信息发送给IM应用服务器,服务器根据A信息中携带的接收者将它再转发给B,同样B到A也是这种模式,如下所示:
二、传统通信方式实现IM应用需要解决的问题
我们认识到基于web实现IM软件依然要走浏览器请求服务器的模式,这这种方式下,针对IM软件的开发需要解决如下三个问题:
-
双全工通信:
即达到浏览器拉取(pull)服务器数据,服务器推送(push)数据到浏览器; -
低延迟:
即浏览器A发送给B的信息经过服务器要快速转发给B,同理B的信息也要快速交给A,实际上就是要求任何浏览器能够快速请求服务器的数据,服务器能够快速推送数据到浏览器; -
支持跨域:
通常客户端浏览器和服务器都是处于网络的不同位置,浏览器本身不允许通过脚本直接访问不同域名下的服务器,即使IP地址相同域名不同也不行,域名相同端口不同也不行,这方面主要是为了安全考虑。
即时通讯网注:关于浏览器跨域访问导致的安全问题,有一个被称为CSRF网络攻击方式,请看下面的摘录:
CSRF(Cross-site request forgery),中文名称:跨站请求伪造,也被称为:one click attack/session riding,缩写为:CSRF/XSRF。
你这可以这么理解CSRF攻击:攻击者盗用了你的身份,以你的名义发送恶意请求。CSRF能够做的事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账......造成的问题包括:个人隐私泄露以及财产安全。
CSRF这种攻击方式在2000年已经被国外的安全人员提出,但在国内,直到06年才开始被关注,08年,国内外的多个大型社区和交互网站分别爆出CSRF漏洞,如:NYTimes.com(纽约时报)、Metafilter(一个大型的BLOG网站),YouTube和百度HI......而现在,互联网上的许多站点仍对此毫无防备,以至于安全业界称CSRF为“沉睡的巨人”。
基于以上分析,下面针对这三个问题给出解决方案。
三、全双工低延迟的解决办法
解决方案3.1:客户端浏览器轮询服务器(polling)
这是最简单的一种解决方案,其原理是在客户端通过Ajax的方式的方式每隔一小段时间就发送一个请求到服务器,服务器返回最新数据,然后客户端根据获得的数据来更新界面,这样就间接实现了即时通信。优点是简单,缺点是对服务器压力较大,浪费带宽流量(通常情况下数据都是没有发生改变的)。
客户端代码如下:
createXHR(){
(
XMLHttpRequest
!=
){
new
}
if
typeof
'undefined'
if
typeof
"string"
var
"MSXML2.XMLHttp.6.0"
"MSXML2.XMLHttp.3.0"
"MSXML2.XMLHttp"
i,len;
(i=0,len=versions.length;i<len;i++){
{
ActiveXObject(versions[i]);
break
}
(ex)
{
}
}
new
}
{
new
"no
xhr object available"
}
function
method=method
||
;
null
var
xhr.onreadystatechange=
(){
(xhr.readyState==4){
(xhr.status>=200&&xhr.status<300||xhr.status==304){
}
{
"fail"
}
};
true
xhr.send(data);
setInterval(
(){
'http://localhost:8088/time'
'get'
},2000);
创建一个XHR对象,每2秒就请求服务器一次获取服务器时间并打印出来。
服务端代码(Node.js):
'http'
fs
= require(
);
function
(req.url==
){
res.end(
Date().toLocaleString());
'/'
fs.readFile(
,
,
(err,
file) {
(!err)
{
'Content-Type'
'text/html'
res.write(file,
);
}
);
,
(socket){
"客户端连接已经建立"
'close'
function
console.log(
);
结果如下:
解决方案3.2:长轮询(long-polling)
在上面的轮询解决方案中,由于每次都要发送一个请求,服务端不管数据是否发生变化都发送数据,请求完成后连接关闭。这中间经过的很多通信是不必要的,于是又出现了长轮询(long-polling)方式。这种方式是客户端发送一个请求到服务器,服务器查看客户端请求的数据是否发生了变化(是否有最新数据),如果发生变化则立即响应返回,否则保持这个连接并定期检查最新数据,直到发生了数据更新或连接超时。同时客户端连接一旦断开,则再次发出请求,这样在相同时间内大大减少了客户端请求服务器的次数。代码如下。(详细技术文章请参见《WEB端即时通讯:HTTP长连接、长轮询(long
polling)详解》)
客户端:
if
typeof
'undefined'
return
XMLHttpRequest();
else
(
ActiveXObject
!=
){
(
arguments.callee.activeXString!=
){
versions=[
,
,
],
for
try
new
arguments.callee.activeXString=versions[i];
;
catch
}
return
ActiveXObject(arguments.callee.activeXString);
else
throw
Error(
);
}
longPolling(url,method,data){
'get'
data=data
||
;
xhr=createXHR();
function
if
if
console.log(xhr.responseText);
else
console.log(
);
longPolling(url,method,data);
};
true
xhr.send(data);
longPolling(
,
);
在XHR对象的readySate为4的时候,表示服务器已经返回数据,本次连接已断开,再次请求服务器建立连接。
服务端代码:
http=require(
);
"fs"
server=http.createServer(
(req,res){
(req.url==
){
function
sendData(res);
};
(req.url==
){
"./lpc.html"
"binary"
function
if
res.writeHead(200,
{
:
});
"binary"
res.end();
});
'localhost'
sendData(res){
randomNum=Math.floor(10*Math.random());
if
res.end(
Date().toLocaleString());
02
04
06
08
10
12
14
16
18
20
22
createStreamClient(url,progress,done){
function
null
client=createStreamClient( , (data){
"Received:"
"Done,the
last data is:"
|
这里由于客户端收到的数据是分段发过来的,所以最好定义一个游标received,来获取最新数据而舍弃之前已经接收到的数据,通过这个游标每次将接收到的最新数据打印出来,并且在通信结束后打印出整个responseText。
服务端代码:
http=require(
);
"fs"
count=0;
function
if
'/stream'
res.setHeader(
,
);
timer=setInterval(
(){
},2000);
};
(req.url==
){
"./xhr-stream.html"
"binary"
function
if
res.writeHead(200,
{
:
});
"binary"
res.end();
});
'localhost'
sendRandomData(timer,res){
randomNum=Math.floor(10000*Math.random());
if
clearInterval(timer);
}
2
4
6
8
process(data){
var
function
|
客户端为了简单起见,定义对数据处理就是打印出来。
服务端代码:
http=require(
);
"fs"
count=0;
function
if
'/htmlfile'
res.setHeader(
,
);
timer=setInterval(
(){
},2000);
};
(req.url==
){
"./htmlfile-stream.html"
"binary"
function
if
res.writeHead(200,
{
:
});
"binary"
res.end();
});
'localhost'
sendRandomData(timer,res){
randomNum=Math.floor(10000*Math.random());
if
clearInterval(timer);
"<script
type=\"text/javascript\">parent.process('"
"')</script>"
}
"<script
type=\"text/javascript\">parent.process('"
"')</script>"
02
04
06
08
10
12
14
16
18
|
function
ifrDiv
= transferDoc.createElement( );
|
服务端传送给iframe的是这样子:
type=\"text/javascript\">callback.process('"+randomNum.toString()+"')</
>
这样就在iframe流的原有方式下避免了浏览器的加载状态。
解决方案3.4:SSE(服务器推送事件(Server-sent Events)
为了解决浏览器只能够单向传输数据到服务端,HTML5提供了一种新的技术叫做服务器推送事件SSE(关于该技术详细介绍请参见《SSE技术详解:一种全新的HTML5服务器推送事件技术》),它能够实现客户端请求服务端,然后服务端利用与客户端建立的这条通信连接push数据给客户端,客户端接收数据并处理的目的。从独立的角度看,SSE技术提供的是从服务器单向推送数据给浏览器的功能,但是配合浏览器主动请求,实际上就实现了客户端和服务器的双向通信。它的原理是在客户端构造一个eventSource对象,该对象具有readySate属性,分别表示如下:
- 0:正在连接到服务器;
- 1:打开了连接;
- 2:关闭了连接。
同时eventSource对象会保持与服务器的长连接,断开后会自动重连,如果要强制连接可以调用它的close方法。可以它的监听onmessage事件,服务端遵循SSE数据传输的格式给客户端,客户端在onmessage事件触发时就能够接收到数据,从而进行某种处理,代码如下。
客户端:
source=
EventSource(
);
'message'
function
console.log(e.data);
false
source.onopen=
(){
'connected'
}
function
console.log(err);
02
04
06
08
10
12
14
16
18
20
22
24
26
28
30
http=require( );
var
"fs"
var
function
"Content-Type" "tex"
'id:
' '\n'
};
(req.url== ){
"./sse.html" "binary" function
"binary"
|