有关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
浏览器从诞生开始一直走的是客户端请求服务器,服务器返回结果的模式,即使发展至今仍然没有任何改变。所以可以肯定的是,要想实现两个客户端的通信,必然要通过服务器进行信息的转发。例如A要和B通信,则应该是A先把信息发送给IM应用服务器,服务器根据A信息中携带的接收者将它再转发给B,同样B到A也是这种模式,如下所示:
我们认识到基于web实现IM软件依然要走浏览器请求服务器的模式,这这种方式下,针对IM软件的开发需要解决如下三个问题:
即时通讯网注:关于浏览器跨域访问导致的安全问题,有一个被称为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为“沉睡的巨人”。
基于以上分析,下面针对这三个问题给出解决方案。
function createXHR(){
if(typeof XMLHttpRequest !='undefined'){
return new XMLHttpRequest();
}else if(typeof ActiveXObject !='undefined' ){
if(typeof arguments.callee.activeXString!="string"){
var versions=["MSXML2.XMLHttp.6.0","MSXML2.XMLHttp.3.0",
"MSXML2.XMLHttp"],
i,len;
for(i=0,len=versions.length;i=200&&xhr.status<300||xhr.status==304){
console.log(xhr.responseText);
}else{
console.log("fail");
}
}
};
xhr.open(method,url,true);
xhr.send(data);
}
setInterval(function(){
polling('http://localhost:8088/time','get');
},2000);
var http=require('http');
var fs = require("fs");
var server=http.createServer(function(req,res){
if(req.url=='/time'){
//res.writeHead(200, {'Content-Type': 'text/plain','Access-Control-Allow-Origin':'http://localhost'});
res.end(new Date().toLocaleString());
};
if(req.url=='/'){
fs.readFile("./pollingClient.html", "binary", function(err, file) {
if (!err) {
res.writeHead(200, {'Content-Type': 'text/html'});
res.write(file, "binary");
res.end();
}
});
}
}).listen(8088,'localhost');
server.on('connection',function(socket){
console.log("客户端连接已经建立");
});
server.on('close',function(){
console.log('服务器被关闭');
});
结果如下:
function createXHR(){
if(typeof XMLHttpRequest !='undefined'){
return new XMLHttpRequest();
}else if(typeof ActiveXObject !='undefined' ){
if(typeof arguments.callee.activeXString!="string"){
var versions=["MSXML2.XMLHttp.6.0","MSXML2.XMLHttp.3.0",
"MSXML2.XMLHttp"],
i,len;
for(i=0,len=versions.length;i=200&&xhr.status<300||xhr.status==304){
console.log(xhr.responseText);
}else{
console.log("fail");
}
longPolling(url,method,data);
}
};
xhr.open(method,url,true);
xhr.send(data);
}
longPolling('http://localhost:8088/time','get');
var http=require('http');
var fs = require("fs");
var server=http.createServer(function(req,res){
if(req.url=='/time'){
setInterval(function(){
sendData(res);
},20000);
};
if(req.url=='/'){
fs.readFile("./lpc.html", "binary", function(err, file) {
if (!err) {
res.writeHead(200, {'Content-Type': 'text/html'});
res.write(file, "binary");
res.end();
}
});
}
}).listen(8088,'localhost');
//用随机数模拟数据是否变化
function sendData(res){
var randomNum=Math.floor(10*Math.random());
console.log(randomNum);
if(randomNum>=0&&randomNum<=5){
res.end(new Date().toLocaleString());
}
}
可以看到返回的时间是没有规律的,并且单位时间内返回的响应数相比polling方式较少。
上面的long-polling技术为了保持客户端与服务端的长连接采取的是服务端阻塞(保持响应不返回),客户端轮询的方式,在Comet技术中(详细技术文章请参见《Comet技术详解:基于HTTP长连接的Web端实时通信技术》),还存在一种基于http-stream流的通信方式。其原理是让客户端在一次请求中保持和服务端连接不断开,然后服务端源源不断传送数据给客户端,就好比数据流一样,并不是一次性将数据全部发给客户端。它与polling方式的区别在于整个通信过程客户端只发送一次请求,然后服务端保持与客户端的长连接,并利用这个连接在回送数据给客户端。
这种方案有分为几种不同的数据流传输方式。
function createStreamClient(url,progress,done){
//received为接收到数据的计数器
var xhr=new XMLHttpRequest(),received=0;
xhr.open("get",url,true);
xhr.onreadystatechange=function(){
var result;
if(xhr.readyState==3){
//console.log(xhr.responseText);
result=xhr.responseText.substring(received);
received+=result.length;
progress(result);
}else if(xhr.readyState==4){
done(xhr.responseText);
}
};
xhr.send(null);
return xhr;
}
var client=createStreamClient("http://localhost:8088/stream",function(data){
console.log("Received:"+data);
},function(data){
console.log("Done,the last data is:"+data);
})
这里由于客户端收到的数据是分段发过来的,所以最好定义一个游标received,来获取最新数据而舍弃之前已经接收到的数据,通过这个游标每次将接收到的最新数据打印出来,并且在通信结束后打印出整个responseText。
var http=require('http');
var fs = require("fs");
var count=0;
var server=http.createServer(function(req,res){
if(req.url=='/stream'){
res.setHeader('content-type', 'multipart/octet-stream');
var timer=setInterval(function(){
sendRandomData(timer,res);
},2000);
};
if(req.url=='/'){
fs.readFile("./xhr-stream.html", "binary", function(err, file) {
if (!err) {
res.writeHead(200, {'Content-Type': 'text/html'});
res.write(file, "binary");
res.end();
}
});
}
}).listen(8088,'localhost');
function sendRandomData(timer,res){
var randomNum=Math.floor(10000*Math.random());
console.log(randomNum);
if(count++==10){
clearInterval(timer);
res.end(randomNum.toString());
}
res.write(randomNum.toString());
}
可以看到每次传过来的数据流都进行了处理,同时打印出了整个最终接收到的完整数据。这种方式间接实现了客户端请求,服务端及时推送数据给客户端。
可以看到实现在低版本IE中客户端到服务器的请求-推送的即时通信。
function connect_htmlfile(url, callback) {
var transferDoc = new ActiveXObject("htmlfile");
transferDoc.open();
transferDoc.write(
"
为了解决浏览器只能够单向传输数据到服务端,HTML5提供了一种新的技术叫做服务器推送事件SSE(关于该技术详细介绍请参见《SSE技术详解:一种全新的HTML5服务器推送事件技术》),它能够实现客户端请求服务端,然后服务端利用与客户端建立的这条通信连接push数据给客户端,客户端接收数据并处理的目的。从独立的角度看,SSE技术提供的是从服务器单向推送数据给浏览器的功能,但是配合浏览器主动请求,实际上就实现了客户端和服务器的双向通信。它的原理是在客户端构造一个eventSource对象,该对象具有readySate属性,分别表示如下:
var source=new EventSource('http://localhost:8088/evt');
source.addEventListener('message', function(e) {
console.log(e.data);
}, false);
source.onopen=function(){
console.log('connected');
}
source.οnerrοr=function(err){
console.log(err);
}
var http=require('http');
var fs = require("fs");
var count=0;
var server=http.createServer(function(req,res){
if(req.url=='/evt'){
//res.setHeader('content-type', 'multipart/octet-stream');
res.writeHead(200, {"Content-Type":"tex" +
"t/event-stream", "Cache-Control":"no-cache",
'Access-Control-Allow-Origin': '*',
"Connection":"keep-alive"});
var timer=setInterval(function(){
if(++count==10){
clearInterval(timer);
res.end();
}else{
res.write('id: ' + count + '\n');
res.write("data: " + new Date().toLocaleString() + '\n\n');
}
},2000);
};
if(req.url=='/'){
fs.readFile("./sse.html", "binary", function(err, file) {
if (!err) {
res.writeHead(200, {'Content-Type': 'text/html'});
res.write(file, "binary");
res.end();
}
});
}
}).listen(8088,'localhost');
以上就是比较常用的客户端服务端双向即时通信的解决方案,下面再来看如何实现跨域。
关于跨域是什么,限于篇幅所限,这里不做介绍,网上有很多详细的文章,这里只列举解决办法。
CORS(跨域资源共享)是一种允许浏览器脚本向出于不同域名下服务器发送请求的技术,它是在原生XHR请求的基础上,XHR调用open方法时,地址指向一个跨域的地址,在服务端通过设置'Access-Control-Allow-Origin':'*'响应头部告诉浏览器,发送的数据是一个来自于跨域的并且服务器允许响应的数据,浏览器接收到这个header之后就会绕过平常的跨域限制,从而和平时的XHR通信没有区别。该方法的主要好处是在于客户端代码不用修改,服务端只需要添加'Access-Control-Allow-Origin':'*'头部即可。适用于ff,safari,opera,chrome等非IE浏览器。跨域的XHR相比非跨域的XHR有一些限制,这是为了安全所需要的,主要有以下限制:
var polling=function(){
var xhr=new XMLHttpRequest();
xhr.onreadystatechange=function(){
if(xhr.readyState==4)
if(xhr.status==200){
console.log(xhr.responseText);
}
}
xhr.open('get','http://localhost:8088/cors');
xhr.send(null);
};
setInterval(function(){
polling();
},1000);
var http=require('http');
var fs = require("fs");
var server=http.createServer(function(req,res){
if(req.url=='/cors'){
res.writeHead(200, {'Content-Type': 'text/plain','Access-Control-Allow-Origin':'http://localhost'});
res.end(new Date().toString());
}
if(req.url=='/jsonp'){
}
}).listen(8088,'localhost');
server.on('connection',function(socket){
console.log("客户端连接已经建立");
});
server.on('close',function(){
console.log('服务器被关闭');
});
对于IE8-10,它是不支持使用原生的XHR对象请求跨域服务器的,它自己实现了一个XDomainRequest对象,类似于XHR对象,能够发送跨域请求,它主要有以下限制:
var polling=function(){
var xdr=new XDomainRequest();
xdr.οnlοad=function(){
console.log(xdr.responseText);
};
xdr.οnerrοr=function(){
console.log('failed');
};
xdr.open('get','http://localhost:8088/cors');
xdr.send(null);
};
setInterval(function(){
polling();
},1000);
function callback(data){
console.log("获得的跨域数据为:"+data);
}
function sendJsonp(url){
var oScript=document.createElement("script");
oScript.src=url;
oScript.setAttribute('type',"text/javascript");
document.getElementsByTagName('head')[0].appendChild(oScript);
}
setInterval(function(){
sendJsonp('http://localhost:8088/jsonp?cb=callback');
},1000);
var http=require('http');
var url=require('url');
var server=http.createServer(function(req,res){
if(/\/jsonp/.test(req.url)){
var urlData=url.parse(req.url,true);
var methodName=urlData.query.cb;
res.writeHead(200,{'Content-Type':'application/javascript'});
//res.end("");
res.end(methodName+"("+new Date().getTime()+");");
//res.end(new Date().toString());
}
}).listen(8088,'localhost');
server.on('connection',function(socket){
console.log("客户端连接已经建立");
});
server.on('close',function(){
console.log('服务器被关闭');
});
其中比较重要的是FIN字段,它占用1位,表示这是一个数据帧的结束标志,同时也下一个数据帧的开始标志。opcode字段,它占用4位,当为1时,表示传递的是text帧,2表示二进制数据帧,8表示需要结束此次通信(就是客户端或者服务端哪个发送给对方这个字段,就表示对方要关闭连接了)。9表示发送的是一个ping数据。mask占用1位,为1表示masking-key字段可用,masking-key字段是用来对客户端发送来的数据做unmask操作的。它占用0到4个字节。Payload字段表示实际发送的数据,可以是字符数据也可以是二进制数据。
所以不管是客户端和服务端向对方发送消息,都必须将数据组装成上面的帧格式来发送。
首先来看服务端代码:
//握手成功之后就可以发送数据了
var crypto = require('crypto');
var WS = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
var server=require('net').createServer(function (socket) {
var key;
socket.on('data', function (msg) {
if (!key) {
//获取发送过来的Sec-WebSocket-key首部
key = msg.toString().match(/Sec-WebSocket-Key: (.+)/)[1];
key = crypto.createHash('sha1').update(key + WS).digest('base64');
socket.write('HTTP/1.1 101 Switching Protocols\r\n');
socket.write('Upgrade: WebSocket\r\n');
socket.write('Connection: Upgrade\r\n');
//将确认后的key发送回去
socket.write('Sec-WebSocket-Accept: ' + key + '\r\n');
//输出空行,结束Http头
socket.write('\r\n');
} else {
var msg=decodeData(msg);
console.log(msg);
//如果客户端发送的操作码为8,表示断开连接,关闭TCP连接并退出应用程序
if(msg.Opcode==8){
socket.end();
server.unref();
}else{
socket.write(encodeData({FIN:1,
Opcode:1,
PayloadData:"接受到的数据为"+msg.PayloadData}));
}
}
});
});
server.listen(8000,'localhost');
//按照websocket数据帧格式提取数据
function decodeData(e){
var i=0,j,s,frame={
//解析前两个字节的基本数据
FIN:e[i]>>7,Opcode:e[i++]&15,Mask:e[i]>>7,
PayloadLength:e[i++]&0x7F
};
//处理特殊长度126和127
if(frame.PayloadLength==126)
frame.length=(e[i++]<<8)+e[i++];
if(frame.PayloadLength==127)
i+=4, //长度一般用四字节的整型,前四个字节通常为长整形留空的
frame.length=(e[i++]<<24)+(e[i++]<<16)+(e[i++]<<8)+e[i++];
//判断是否使用掩码
if(frame.Mask){
//获取掩码实体
frame.MaskingKey=[e[i++],e[i++],e[i++],e[i++]];
//对数据和掩码做异或运算
for(j=0,s=[];j>2,l&0xFF);
else s.push(
127, 0,0,0,0, //8字节数据,前4字节一般没用留空
(l&0xFF000000)>>6,(l&0xFF0000)>>4,(l&0xFF00)>>2,l&0xFF
);
//返回头部分和数据部分的合并缓冲区
return Buffer.concat([new Buffer(s),o]);
}
window.οnlοad=function(){
var ws=new WebSocket("ws://127.0.0.1:8088");
var oText=document.getElementById('message');
var oSend=document.getElementById('send');
var oClose=document.getElementById('close');
var oUl=document.getElementsByTagName('ul')[0];
ws.onopen=function(){
oSend.οnclick=function(){
if(!/^\s*$/.test(oText.value)){
ws.send(oText.value);
}
};
};
ws.onmessage=function(msg){
var str=""+msg.data+" ";
oUl.innerHTML+=str;
};
ws.onclose=function(e){
console.log("已断开与服务器的连接");
ws.close();
}
}
服务端输出结果:
从上面可以看出,WebSocket在支持它的浏览器上确实提供了一种全双工跨域的通信方案,所以在各以上各种方案中,我们的首选无疑是WebSocket。
从图上可以看出,对于现代浏览器(IE10+,chrome14+,Firefox10+,Safari5+以及Opera12+)都是能够很好的支持WebSocket的,其余低版本浏览器通常使用基于XHR(XDR)的polling(streaming)或者是基于iframe的的polling(streaming),对于IE6\7来讲,它不仅不支持XDR跨域,也不支持XHR跨域,所以只能够采取jsonp-polling的方式。
(本文同步发布于:http://www.52im.net/thread-338-1-1.html)
作者:Jack Jiang (点击作者姓名进入Github)