新手入门贴:史上最全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也是这种模式,如下所示:

新手入门贴:史上最全Web端即时通讯技术原理详解 

二、传统通信方式实现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的方式的方式每隔一小段时间就发送一个请求到服务器,服务器返回最新数据,然后客户端根据获得的数据来更新界面,这样就间接实现了即时通信。优点是简单,缺点是对服务器压力较大,浪费带宽流量(通常情况下数据都是没有发生改变的)。

客户端代码如下:

01
03
05
07
09
11
13
15
17
19
21
23
25
27
29
31
33
35
37
39
41
createXHR(){
        (XMLHttpRequest !=){
            new }iftypeof 'undefined' iftypeof "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 ||;
       nullvar xhr.onreadystatechange=(){
            (xhr.readyState==4){
                (xhr.status>=200&&xhr.status<300||xhr.status==304){
                    }{
                    "fail"}
            };
        truexhr.send(data);
    setInterval((){
        'http://localhost:8088/time''get'},2000);

创建一个XHR对象,每2秒就请求服务器一次获取服务器时间并打印出来。

服务端代码(Node.js):

01
03
05
07
09
11
13
15
17
19
21
23
var 'http'fs = require();
var function(req.url==){
    res.end(Date().toLocaleString());
};
if'/'fs.readFile(, , (err, file) {
        (!err) {
            'Content-Type''text/html'res.write(file, );
            }
});
}
}).listen(8088,);
server.on(,(socket){
    "客户端连接已经建立"'close'functionconsole.log();
});


结果如下:
新手入门贴:史上最全Web端即时通讯技术原理详解 

解决方案3.2:长轮询(long-polling)

在上面的轮询解决方案中,由于每次都要发送一个请求,服务端不管数据是否发生变化都发送数据,请求完成后连接关闭。这中间经过的很多通信是不必要的,于是又出现了长轮询(long-polling)方式。这种方式是客户端发送一个请求到服务器,服务器查看客户端请求的数据是否发生了变化(是否有最新数据),如果发生变化则立即响应返回,否则保持这个连接并定期检查最新数据,直到发生了数据更新或连接超时。同时客户端连接一旦断开,则再次发出请求,这样在相同时间内大大减少了客户端请求服务器的次数。代码如下。(详细技术文章请参见《WEB端即时通讯:HTTP长连接、长轮询(long polling)详解》)

客户端:

01
03
05
07
09
11
13
15
17
19
21
23
25
27
29
31
33
35
37
39
41
function iftypeof 'undefined'return XMLHttpRequest();
        else (ActiveXObject !=){
            (arguments.callee.activeXString!=){
                versions=[,,
                            ],
                        fortrynew arguments.callee.activeXString=versions[i];
                        ;
                    catch 
                    }
            return ActiveXObject(arguments.callee.activeXString);
        elsethrow Error();
        }
    longPolling(url,method,data){
        'get'data=data || ;
        xhr=createXHR();
        functionififconsole.log(xhr.responseText);
                elseconsole.log();
                longPolling(url,method,data);
            };
        truexhr.send(data);
    longPolling(,);

在XHR对象的readySate为4的时候,表示服务器已经返回数据,本次连接已断开,再次请求服务器建立连接。

服务端代码:

01
03
05
07
09
11
13
15
17
19
21
23
25
http=require();
var "fs"server=http.createServer((req,res){
    (req.url==){
        functionsendData(res);
        };
    (req.url==){
        "./lpc.html""binary"functionif res.writeHead(200, {: });
                "binary"res.end();
            });
    'localhost'sendData(res){
    randomNum=Math.floor(10*Math.random());
    ifres.end(Date().toLocaleString());
    
02
04
06
08
10
12
14
16
18
20
22
createStreamClient(url,progress,done){
        var new xhr.open(,url,);
        functionvar if//console.log(xhr.responseText);
                received+=result.length;
                }ifdone(xhr.responseText);
            };
        nullreturn }
    client=createStreamClient(,(data){
        "Received:"},(data){
        "Done,the last data is:"})

这里由于客户端收到的数据是分段发过来的,所以最好定义一个游标received,来获取最新数据而舍弃之前已经接收到的数据,通过这个游标每次将接收到的最新数据打印出来,并且在通信结束后打印出整个responseText。

服务端代码:

01
03
05
07
09
11
13
15
17
19
21
23
25
27
29
http=require();
var "fs"count=0;
var functionif'/stream'res.setHeader(, );
        timer=setInterval((){
            },2000);
};
    (req.url==){
        "./xhr-stream.html""binary"functionif res.writeHead(200, {: });
                "binary"res.end();
            });
    'localhost'sendRandomData(timer,res){
    randomNum=Math.floor(10000*Math.random());
    ifclearInterval(timer);
        }
        
2
4
6
8
process(data){
            }
var function var "iframe"ifr.src = url;
    dataStream();


客户端为了简单起见,定义对数据处理就是打印出来。

服务端代码:

01
03
05
07
09
11
13
15
17
19
21
23
25
27
29
http=require();
var "fs"count=0;
var functionif'/htmlfile'res.setHeader(, );
        timer=setInterval((){
            },2000);
};
    (req.url==){
        "./htmlfile-stream.html""binary"functionif res.writeHead(200, {: });
                "binary"res.end();
            });
    'localhost'sendRandomData(timer,res){
    randomNum=Math.floor(10000*Math.random());
    ifclearInterval(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 var new "htmlfile"transferDoc.open();
            "<!DOCTYPE html><html><body><script  type=\"text/javascript\">" "document.domain='" "';" "<\/script><\/body><\/html>"transferDoc.close();
            ifrDiv = transferDoc.createElement();
            ifrDiv.innerHTML = ;
            setInterval( () {}, 10000);
        function alert(data);
        connect_htmlfile(,prograss);


服务端传送给iframe的是这样子:

1
<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事件触发时就能够接收到数据,从而进行某种处理,代码如下。

客户端:

01
03
05
07
09
source=EventSource();
    'message'functionconsole.log(e.data);
    falsesource.onopen=(){
        'connected'}
    functionconsole.log(err);
    
02
04
06
08
10
12
14
16
18
20
22
24
26
28
30
http=require();
var "fs"count=0;
var functionif'/evt'//res.setHeader('content-type', 'multipart/octet-stream');
        "Content-Type""tex" "t/event-stream""Cache-Control""no-cache"'Access-Control-Allow-Origin''*'"Connection""keep-alive"var functionifclearInterval(timer);
                }{
                'id: ' '\n'res.write(+ Date().toLocaleString() + );
            },2000);
};
    (req.url==){
        "./sse.html""binary"functionif res.writeHead(200, {: });
                "binary"res.end();
            });
    'localhost'
02
04
06
08
10
12
14
var functionvar new xhr.onreadystatechange=(){
            (xhr.readyState==4)
                (xhr.status==200){
                    }
            xhr.open(,);
    null};
    functionpolling();
    
02
04
06
08
10
12
14
16
http=require();
var "fs"server=http.createServer((req,res){
    (req.url==){
            'Content-Type''text/plain''Access-Control-Allow-Origin''http://localhost'res.end(Date().toString());
    if'/jsonp' 
    'localhost''connection'functionconsole.log();
});
server.on(,(){
    '服务器被关闭'
02
04
06
08
10
12
14
var functionvar new xdr.onload=(){
            };
        functionconsole.log();
        xdr.open(,);
        null};
    functionpolling();
    
02
04
06
08
10
12
function console.log(+data);
    function var "script"oScript.src=url;
        'type'"text/javascript"document.getElementsByTagName()[0].appendChild(oScript);
    setInterval((){
        'http://localhost:8088/jsonp?cb=callback'},1000);


服务端代码:

01
03
05
07
09
11
13
15
17
http=require();
var 'url'server=http.createServer((req,res){
    (/\/jsonp/.test(req.url)){
        urlData=url.parse(req.url,);
        methodName=urlData.query.cb;
        'Content-Type''application/javascript'//res.end("<script type=\"text/javascript\">"+methodName+"("+new Date().getTime()+");</script>");
        "("new ");"//res.end(new Date().toString());
    'localhost''connection'functionconsole.log();
});
server.on(,(){
    '服务器被关闭'
02
04
06
08
10
12
14
16
18
20
22
24
26
28
30
32
34
36
38
40
42
44
46
48
50
52
54
56
58
60
62
64
66
68
70
72
74
76
78
80
//握手成功之后就可以发送数据了
var 'crypto'WS = ;
var 'net'function var socket.on(, (msg) {
        (!key) {
            key = msg.toString().match(/Sec-WebSocket-Key: (.+)/)[1];
            'sha1''base64'socket.write();
            'Upgrade: WebSocket\r\n'socket.write();
            socket.write(+ key + );
            socket.write();
        else var console.log(msg);
            ifsocket.end();
                }{
                Opcode:1,
                    "接受到的数据为"}
}
    server.listen(8000,);
//按照websocket数据帧格式提取数据
function var //解析前两个字节的基本数据
        PayloadLength:e[i++]&0x7F
    //处理特殊长度126和127
    (frame.PayloadLength==126)
        ifi+=4, frame.length=(e[i++]<<24)+(e[i++]<<16)+(e[i++]<<8)+e[i++];
    if//获取掩码实体
        //对数据和掩码做异或运算
        (j=0,s=[];j<frame.PayloadLength;j++)
            }s=e.slice(i,frame.PayloadLength); //数组转换成缓冲区来使用
    new //如果有必要则把缓冲区转换成字符串来使用
    (frame.Opcode==1)s=s.toString();
    frame.PayloadData=s;
    return encodeData(e){
    s=[],o=Buffer(e.PayloadData),l=o.length;
    s.push((e.FIN<<7)+e.Opcode);
    //永远不使用掩码
    (l<126)s.push(l);
    ifelse 127, 0,0,0,0, (l&0xFF000000)>>6,(l&0xFF0000)>>4,(l&0xFF00)>>2,l&0xFF
        //返回头部分和数据部分的合并缓冲区
    Buffer.concat([Buffer(s),o]);
}

服务端通过监听data事件来获取客户端发送来的数据,如果是握手请求,则发送http 101响应,否则解析得到的数据并打印出来,然后判断是不是断开连接的请求(Opcode为8),如果是则断开连接,否则将接收到的数据组装成帧再发送给客户端。

客户端代码:

01
03
05
07
09
11
13
15
17
19
21
23
window.onload=(){
        ws=WebSocket();
        oText=document.getElementById();
        oSend=document.getElementById();
        oClose=document.getElementById();
        oUl=document.getElementsByTagName()[0];
        functionoSend.onclick=(){
                (!/^\s*$/.test(oText.value)){
                    }
             
        ws.onmessage=(msg){
          str=+msg.data+;
          };
        functionconsole.log();
            }
    <code style="white-space: pre-wrap; border-radius: 0px !important; border: 0px !important; bottom: auto !important; float: none !important; height: auto !important; left: auto !important; line-height: 1.8em !important; margin: 0px !important; outline: 0px !important; overflow: visible !important; padding: 0px !important; position: static !important; right: auto !important; top: auto !important; vertical-align: baseline !important; width: auto !important; box-sizing: content-box !important; font-family: Consolas, 'Bitstream Vera Sans Mono', 'Courier New', Courier, monospace !important; min-height: auto !important; background: none !important;" plain"="">}

客户端创建一个websocket对象,在onopen时间触发之后(握手成功后),给页面上的button指定一个事件,用来发送页面input当中的信息,服务端接收到信息打印出来,并组装成帧返回给日客户端,客户端再append到页面上。

客户结果如下:
新手入门贴:史上最全Web端即时通讯技术原理详解 

服务端输出结果:
新手入门贴:史上最全Web端即时通讯技术原理详解 

从上面可以看出,WebSocket在支持它的浏览器上确实提供了一种全双工跨域的通信方案,所以在各以上各种方案中,我们的首选无疑是WebSocket。

结束语

上面论述了这么多对于IM应用开发所涉及到的通信方式,在实际开发中,我们通常使用的是一些别人写好的实时通讯的库,比如socket.iosockjs,他们的原理就是将上面(还有一些其他的如基于Flash的push)的一些技术进行了在客户端和服务端的封装,然后给开发者一个统一调用的接口。这个接口在支持websocket的环境下使用websocket,在不支持它的时候启用上面所讲的一些hack技术。

从实际来讲,单独使用本文上述所讲的任何一种技术(WebSocket除外)达不到我们在文章开头提出的低延时,双全工、跨域的全部要求,只有把他们组合起来才能够很好地工作,所以通常情况下,这些库都是在不同的浏览器上采用各种不同的组合来实现实时通讯的。

下面是sockjs在不同浏览器下面采取的不同组合方式:

新手入门贴:史上最全Web端即时通讯技术原理详解 

从图上可以看出,对于现代浏览器(IE10+,chrome14+,Firefox10+,Safari5+以及Opera12+)都是能够很好的支持WebSocket的,其余低版本浏览器通常使用基于XHR(XDR)的polling(streaming)或者是基于iframe的的polling(streaming),对于IE6\7来讲,它不仅不支持XDR跨域,也不支持XHR跨域,所以只能够采取jsonp-polling的方式。

转自:http://www.blogjava.net/jb2011/archive/2016/07/12/431168.aspx