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

为什么80%的码农都做不了架构师?>>>   hot3.png

前言

有关IM(InstantMessaging)聊天应用(如:微信,QQ)、消息推送技术(如:现今移动端APP标配的消息推送模块)等即时通讯应用场景下,大多数都是桌面应用程序或者native应用较为流行,而网上关于原生IM(相关文章请参见:《IM架构篇》、《IM综合资料》、《IM/推送的通信格式、协议篇》、《IM心跳保活篇》、《IM安全篇》、《实时音视频开发》)、消息推送应用(参见:《推送技术好文》)的通信原理介绍也较多,此处不再赘述。


而web端的IM应用,由于浏览器的兼容性以及其固有的“客户端请求服务器处理并响应”的通信模型,造成了要在浏览器中实现一个兼容性较好的IM应用,其通信过程必然是诸多技术的组合,本文的目的就是要详细探讨这些技术并分析其原理和过程。

一、传统Web的通信原理

浏览器本身作为一个瘦客户端,不具备直接通过系统调用来达到和处于异地的另外一个客户端浏览器通信的功能。这和我们桌面应用的工作方式是不同的,通常桌面应用通过socket可以和远程主机上另外一端的一个进程建立TCP连接,从而达到全双工的即时通信。
浏览器从诞生开始一直走的是客户端请求服务器,服务器返回结果的模式,即使发展至今仍然没有任何改变。所以可以肯定的是,要想实现两个客户端的通信,必然要通过服务器进行信息的转发。例如A要和B通信,则应该是A先把信息发送给IM应用服务器,服务器根据A信息中携带的接收者将它再转发给B,同样B到A也是这种模式,如下所示:

【原创】新手入门:史上最全Web端即时通讯技术原理详解_第1张图片 

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

客户端代码如下:

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);

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

服务端代码(Node.js):

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('服务器被关闭');

});


解决方案3.2:长轮询(long-polling)
结果如下:
【原创】新手入门:史上最全Web端即时通讯技术原理详解_第2张图片 

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

客户端:

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());

    }

}

 

 

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

服务端代码:

 

在服务端通过生成一个在1到9之间的随机数来模拟判断数据是否发生了变化,当随机数在0到5之间表示数据发生了变化,直接返回,否则保持连接,每隔2秒再检测。

结果如下:
新手入门贴:史上最全Web端即时通讯技术原理详解_3.png 
可以看到返回的时间是没有规律的,并且单位时间内返回的响应数相比polling方式较少。

解决方案3.3:基于http-stream通信

上面的long-polling技术为了保持客户端与服务端的长连接采取的是服务端阻塞(保持响应不返回),客户端轮询的方式,在Comet技术中(详细技术文章请参见《Comet技术详解:基于HTTP长连接的Web端实时通信技术》),还存在一种基于http-stream流的通信方式。其原理是让客户端在一次请求中保持和服务端连接不断开,然后服务端源源不断传送数据给客户端,就好比数据流一样,并不是一次性将数据全部发给客户端。它与polling方式的区别在于整个通信过程客户端只发送一次请求,然后服务端保持与客户端的长连接,并利用这个连接在回送数据给客户端。

这种方案有分为几种不同的数据流传输方式。
 

3.3.1 基于XHR对象的streaming方式

这种方式的思想是构造一个XHR对象,通过监听它的onreadystatechange事件,当它的readyState为3的时候,获取它的responseText然后进行处理,readyState为3表示数据传送中,整个通信过程还没有结束,所以它还在不断获取服务端发送过来的数据,直到readyState为4的时候才表示数据发送完毕,一次通信过程结束。在这个过程中,服务端传给客户端的数据是分多次以stream的形式发送给客户端,客户端也是通过stream形式来获取的,所以称作http-streaming数据流方式,代码如下。

客户端代码:

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());

}

服务端通过计数器count将数据分十次发送,每次生成一个小于10000的随机数发送给客户端让它进行处理。

结果如下:
【原创】新手入门:史上最全Web端即时通讯技术原理详解_第3张图片 
可以看到每次传过来的数据流都进行了处理,同时打印出了整个最终接收到的完整数据。这种方式间接实现了客户端请求,服务端及时推送数据给客户端。
 

3.3.2 基于iframe的数据流

由于低版本的IE不允许在XHR的readyState为3的时候获取其responseText属性,为了达到在IE上使用这个技术,又出现了基于iframe的数据流通信方式。具体来讲,就是在浏览器中动态载入一个iframe,让它的src属性指向请求的服务器的URL,实际上就是向服务器发送了一个http请求,然后在浏览器端创建一个处理数据的函数,在服务端通过iframe与浏览器的长连接定时输出数据给客户端,但是这个返回的数据并不是一般的数据,而是一个类似于脚本执行的方式,浏览器接收到这个数据就会将它解析成js代码并找到页面上指定的函数去执行,实际上是服务端间接使用自己的数据间接调用了客户端的代码,达到实时更新客户端的目的。

客户端代码如下:

function process(data){

            console.log(data);

        }

var dataStream = function (url) {

    var ifr = document.createElement("iframe"),timer;

    ifr.src = url;

    document.body.appendChild(ifr);

};

dataStream('http://localhost:8088/htmlfile');


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

服务端代码:

var http=require('http');

var fs = require("fs");

var count=0;

var server=http.createServer(function(req,res){

    if(req.url=='/htmlfile'){

        res.setHeader('content-type', 'text/html');

        var timer=setInterval(function(){

            sendRandomData(timer,res);

        },2000);



    };

    if(req.url=='/'){

        fs.readFile("./htmlfile-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.toString());

    if(count++==10){

        clearInterval(timer);

        res.end("");

    }

    res.write("");

}

服务端定时发送随机数给客户端,并调用客户端process函数。

在IE5中测试结果如下:
【原创】新手入门:史上最全Web端即时通讯技术原理详解_第4张图片 
可以看到实现在低版本IE中客户端到服务器的请求-推送的即时通信。
 

3.3.3 基于htmlfile的数据流通信

又出现新问题了,在IE中,使用iframe请求服务端,服务端保持通信连接没有全部返回之前,浏览器title一直处于加载状态,并且底部也显示正在加载,这对于一个产品来讲用户体验是不好的,于是谷歌的天才们又想出了一中hack方式。就是在IE中,动态生成一个htmlfile对象,这个对象ActiveX形式的com组件,它实际上就是一个在内存中实现的HTML文档,通过将生成的iframe添加到这个内存中的HTMLfile中,并利用iframe的数据流通信方式达到上面的效果。同时由于HTMLfile对象并不是直接添加到页面上的,所以并没有造成浏览器显示正在加载的现象。代码如下。

客户端:

function connect_htmlfile(url, callback) {

            var transferDoc = new ActiveXObject("htmlfile");

            transferDoc.open();

            transferDoc.write(

                            "" +

                            "document.domain='" + document.domain + "';" +

                            "<\/script><\/body><\/html>");

            transferDoc.close();

            var ifrDiv = transferDoc.createElement("div");

            transferDoc.body.appendChild(ifrDiv);

            ifrDiv.innerHTML = "