新手入門:史上最全Web端即時通信技術原理詳解

前言

有關IM(InstantMessaging)聊天應用(如:微信,QQ)、消息推送技術(如:現今移動端APP標配的消息推送模塊)等即時通信應用場景下,大多數都是桌面應用程序或者native應用較爲流行,而網上關於原生IM(相關文章請參見:《IM架構篇》、《IM綜合資料》、《IM/推送的通訊格式、協議篇》、《IM心跳保活篇》、《IM安全篇》、《實時音視頻開發》)、消息推送應用(參見:《推送技術好文》)的通訊原理介紹也較多,此處再也不贅述。javascript


而web端的IM應用,因爲瀏覽器的兼容性以及其固有的「客戶端請求服務器處理並響應」的通訊模型,形成了要在瀏覽器中實現一個兼容性較好的IM應用,其通訊過程必然是諸多技術的組合,本文的目的就是要詳細探討這些技術並分析其原理和過程。php

更多資料整理

Web端即時通信技術盤點請參見:html

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=15java

1、傳統Web的通訊原理

瀏覽器自己做爲一個瘦客戶端,不具有直接經過系統調用來達到和處於異地的另一個客戶端瀏覽器通訊的功能。這和咱們桌面應用的工做方式是不一樣的,一般桌面應用經過socket能夠和遠程主機上另一端的一個進程創建TCP鏈接,從而達到全雙工的即時通訊。
瀏覽器從誕生開始一直走的是客戶端請求服務器,服務器返回結果的模式,即便發展至今仍然沒有任何改變。因此能夠確定的是,要想實現兩個客戶端的通訊,必然要經過服務器進行信息的轉發。例如A要和B通訊,則應該是A先把信息發送給IM應用服務器,服務器根據A信息中攜帶的接收者將它再轉發給B,一樣B到A也是這種模式,以下所示:

新手入門貼:史上最全Web端即時通信技術原理詳解_1.png 
git

2、傳統通訊方式實現IM應用須要解決的問題

咱們認識到基於web實現IM軟件依然要走瀏覽器請求服務器的模式,這這種方式下,針對IM軟件的開發須要解決以下三個問題:
github

  • 雙全工通訊:
    即達到瀏覽器拉取(pull)服務器數據,服務器推送(push)數據到瀏覽器;
  • 低延遲:
    即瀏覽器A發送給B的信息通過服務器要快速轉發給B,同理B的信息也要快速交給A,實際上就是要求任何瀏覽器可以快速請求服務器的數據,服務器可以快速推送數據到瀏覽器;
  • 支持跨域:
    一般客戶端瀏覽器和服務器都是處於網絡的不一樣位置,瀏覽器自己不容許經過腳本直接訪問不一樣域名下的服務器,即便IP地址相同域名不一樣也不行,域名相同端口不一樣也不行,這方面主要是爲了安全考慮。


即時通信網注:關於瀏覽器跨域訪問致使的安全問題,有一個被稱爲CSRF網絡攻擊方式,請看下面的摘錄:
web

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爲「沉睡的巨人」。chrome


基於以上分析,下面針對這三個問題給出解決方案。
json

3、全雙工低延遲的解決辦法

解決方案3.1:客戶端瀏覽器輪詢服務器(polling)

這是最簡單的一種解決方案,其原理是在客戶端經過Ajax的方式的方式每隔一小段時間就發送一個請求到服務器,服務器返回最新數據,而後客戶端根據得到的數據來更新界面,這樣就間接實現了即時通訊。優勢是簡單,缺點是對服務器壓力較大,浪費帶寬流量(一般狀況下數據都是沒有發生改變的)。

客戶端代碼以下:
跨域

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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<len;i++){
                 try {
                     new ActiveXObject(versions[i]);
                     arguments.callee.activeXString=versions[i];
                     break ;
                 } catch (ex) {
 
                 }
             }
         }
         return new ActiveXObject(arguments.callee.activeXString);
        } else {
             throw new Error( "no xhr object available" );
         }
     }
     function polling(url,method,data){
        method=method || 'get' ;
        data=data || null ;
        var xhr=createXHR();
         xhr.onreadystatechange= function (){
             if (xhr.readyState==4){
                 if (xhr.status>=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):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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( '服務器被關閉' );
});


結果以下:
新手入門貼:史上最全Web端即時通信技術原理詳解_2.png 

解決方案3.2:長輪詢(long-polling)

在上面的輪詢解決方案中,因爲每次都要發送一個請求,服務端無論數據是否發生變化都發送數據,請求完成後鏈接關閉。這中間通過的不少通訊是沒必要要的,因而又出現了長輪詢(long-polling)方式。這種方式是客戶端發送一個請求到服務器,服務器查看客戶端請求的數據是否發生了變化(是否有最新數據),若是發生變化則當即響應返回,不然保持這個鏈接並按期檢查最新數據,直到發生了數據更新或鏈接超時。同時客戶端鏈接一旦斷開,則再次發出請求,這樣在相同時間內大大減小了客戶端請求服務器的次數。代碼以下。(詳細技術文章請參見《WEB端即時通信:HTTP長鏈接、長輪詢(long polling)詳解》)

客戶端:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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<len;i++){
                     try {
                         new ActiveXObject(versions[i]);
                         arguments.callee.activeXString=versions[i];
                         break ;
                     } catch (ex) {
 
                     }
                 }
             }
             return new ActiveXObject(arguments.callee.activeXString);
         } else {
             throw new Error( "no xhr object available" );
         }
     }
     function longPolling(url,method,data){
         method=method || 'get' ;
         data=data || null ;
         var xhr=createXHR();
         xhr.onreadystatechange= function (){
             if (xhr.readyState==4){
                 if (xhr.status>=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' );

在XHR對象的readySate爲4的時候,表示服務器已經返回數據,本次鏈接已斷開,再次請求服務器創建鏈接。

服務端代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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());
     }
}

在服務端經過生成一個在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數據流方式,代碼以下。

客戶端代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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。

服務端代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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端即時通信技術原理詳解_4.png 
能夠看到每次傳過來的數據流都進行了處理,同時打印出了整個最終接收到的完整數據。這種方式間接實現了客戶端請求,服務端及時推送數據給客戶端。

3.3.2 基於iframe的數據流

因爲低版本的IE不容許在XHR的readyState爲3的時候獲取其responseText屬性,爲了達到在IE上使用這個技術,又出現了基於iframe的數據流通訊方式。具體來說,就是在瀏覽器中動態載入一個iframe,讓它的src屬性指向請求的服務器的URL,實際上就是向服務器發送了一個http請求,而後在瀏覽器端建立一個處理數據的函數,在服務端經過iframe與瀏覽器的長鏈接定時輸出數據給客戶端,可是這個返回的數據並非通常的數據,而是一個相似於<script type=\"text/javascript\">parent.process('"+randomNum.toString()+"')</script>腳本執行的方式,瀏覽器接收到這個數據就會將它解析成js代碼並找到頁面上指定的函數去執行,其實是服務端間接使用本身的數據間接調用了客戶端的代碼,達到實時更新客戶端的目的。

客戶端代碼以下:

1
2
3
4
5
6
7
8
9
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' );


客戶端爲了簡單起見,定義對數據處理就是打印出來。

服務端代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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( "<script type=\"text/javascript\">parent.process('" +randomNum.toString()+ "')</script>" );
     }
     res.write( "<script type=\"text/javascript\">parent.process('" +randomNum.toString()+ "')</script>" );
}

服務端定時發送隨機數給客戶端,並調用客戶端process函數。

在IE5中測試結果以下:
新手入門貼:史上最全Web端即時通信技術原理詳解_5.png 
能夠看到實如今低版本IE中客戶端到服務器的請求-推送的即時通訊。

3.3.3 基於htmlfile的數據流通訊

又出現新問題了,在IE中,使用iframe請求服務端,服務端保持通訊鏈接沒有所有返回以前,瀏覽器title一直處於加載狀態,而且底部也顯示正在加載,這對於一個產品來說用戶體驗是很差的,因而谷歌的天才們又想出了一中hack方式。就是在IE中,動態生成一個htmlfile對象,這個對象ActiveX形式的com組件,它實際上就是一個在內存中實現的HTML文檔,經過將生成的iframe添加到這個內存中的HTMLfile中,並利用iframe的數據流通訊方式達到上面的效果。同時因爲HTMLfile對象並非直接添加到頁面上的,因此並無形成瀏覽器顯示正在加載的現象。代碼以下。

客戶端:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
function connect_htmlfile(url, callback) {
             var transferDoc = new ActiveXObject( "htmlfile" );
             transferDoc.open();
             transferDoc.write(
                             "<!DOCTYPE html><html><body><script  type=\"text/javascript\">" +
                             "document.domain='" + document.domain + "';" +
                             "<\/script><\/body><\/html>" );
             transferDoc.close();
             var ifrDiv = transferDoc.createElement( "div" );
             transferDoc.body.appendChild(ifrDiv);
             ifrDiv.innerHTML = "<iframe src='" + url + "'><\/iframe>" ;
             transferDoc.callback=callback;
             setInterval( function () {}, 10000);
         }
         function prograss(data) {
             alert(data);
         }
         connect_htmlfile( 'http://localhost:8088/htmlfile' ,prograss);


服務端傳送給iframe的是這樣子:

1
< script type=\"text/javascript\">callback.process('"+randomNum.toString()+"')</ script >


這樣就在iframe流的原有方式下避免了瀏覽器的加載狀態。

解決方案3.4:SSE(服務器推送事件(Server-sent Events)

爲了解決瀏覽器只可以單向傳輸數據到服務端,HTML5提供了一種新的技術叫作服務器推送事件SSE(關於該技術詳細介紹請參見《SSE技術詳解:一種全新的HTML5服務器推送事件技術,它可以實現客戶端請求服務端,而後服務端利用與客戶端創建的這條通訊鏈接push數據給客戶端,客戶端接收數據並處理的目的。從獨立的角度看,SSE技術提供的是從服務器單向推送數據給瀏覽器的功能,可是配合瀏覽器主動請求,實際上就實現了客戶端和服務器的雙向通訊。它的原理是在客戶端構造一個eventSource對象,該對象具備readySate屬性,分別表示以下:

  • 0:正在鏈接到服務器;
  • 1:打開了鏈接;
  • 2:關閉了鏈接。


同時eventSource對象會保持與服務器的長鏈接,斷開後會自動重連,若是要強制鏈接能夠調用它的close方法。能夠它的監聽onmessage事件,服務端遵循SSE數據傳輸的格式給客戶端,客戶端在onmessage事件觸發時就可以接收到數據,從而進行某種處理,代碼以下。

客戶端:

01
02
03
04
05
06
07
08
09
10
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.onerror= function (err){
         console.log(err);
     }


服務端:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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' );

注意:這裏服務端發送的數據要遵循必定的格式,一般是id:(空格)數據(換行符)data:(空格)數據(兩個換行符),若是不遵循這種格式,實際上客戶端是會觸發error事件的。這裏的id是用來標識每次發送的數據的id,是強制要加的。

結果以下:
新手入門貼:史上最全Web端即時通信技術原理詳解_6.png 

以上就是比較經常使用的客戶端服務端雙向即時通訊的解決方案,下面再來看如何實現跨域。

4、跨域解決辦法

關於跨域是什麼,限於篇幅所限,這裏不作介紹,網上有不少詳細的文章,這裏只列舉解決辦法。

解決方案4.1:基於XHR的COSR(跨域資源共享)

CORS(跨域資源共享)是一種容許瀏覽器腳本向出於不一樣域名下服務器發送請求的技術,它是在原生XHR請求的基礎上,XHR調用open方法時,地址指向一個跨域的地址,在服務端經過設置'Access-Control-Allow-Origin':'*'響應頭部告訴瀏覽器,發送的數據是一個來自於跨域的而且服務器容許響應的數據,瀏覽器接收到這個header以後就會繞過日常的跨域限制,從而和平時的XHR通訊沒有區別。該方法的主要好處是在於客戶端代碼不用修改,服務端只須要添加'Access-Control-Allow-Origin':'*'頭部便可。適用於ff,safari,opera,chrome等非IE瀏覽器。跨域的XHR相比非跨域的XHR有一些限制,這是爲了安全所須要的,主要有如下限制:

  • 客戶端不能使用setRequestHeader設置自定義頭部;
  • 不能發送和接收cookie;
  • 調用getAllResponseHeaders()方法總會返回空字符串。


以上這些措施都是爲了安全考慮,防止常見的跨站點腳本攻擊(XSS)和跨站點請求僞造(CSRF)。

客戶端代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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);


服務端代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
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( '服務器被關閉' );
});

注意服務端須要設置頭部Access-Control-Allow-Origin爲須要跨域的域名。

這裏爲了測試在端口8088上監聽請求,而後讓客戶端在80端口上請求服務,結果以下:
新手入門貼:史上最全Web端即時通信技術原理詳解_7.png 

解決方案4.2:基於XDR的CORS

對於IE8-10,它是不支持使用原生的XHR對象請求跨域服務器的,它本身實現了一個XDomainRequest對象,相似於XHR對象,可以發送跨域請求,它主要有如下限制:

  • cookie不會隨請求發送,也不會隨響應返回;
  • 只能設置請求頭部信息中的Content-Type字段;
  • 不能訪問響應頭部信息;
  • 只支持Get和Post請求;
  • 只支持IE8-IE10。


客戶端請求代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
var polling= function (){
         var xdr= new XDomainRequest();
         xdr.onload= function (){
             console.log(xdr.responseText);
         };
         xdr.onerror= function (){
             console.log( 'failed' );
         };
         xdr.open( 'get' , 'http://localhost:8088/cors' );
         xdr.send( null );
     };
     setInterval( function (){
         polling();
     },1000);


服務端代碼和同上,在IE8中測試結果以下:
新手入門貼:史上最全Web端即時通信技術原理詳解_8.png 

解決方案4.3:基於JSONP的跨域

這種方式不須要在服務端添加Access-Control-Allow-Origin頭信息,其原理是利用HTML頁面上script標籤對跨域沒有限制的特色,讓它的src屬性指向服務端請求的地址,實際上是經過script標籤發送了一個http請求,服務器接收到這個請求以後,返回的數據是本身的數據加上對客戶端JS函數的調用,其原理相似於咱們上面所說的iframe流的方式,客戶端瀏覽器接收到返回的腳本調用會解析執行,從而達到更新界面的目的。

客戶端代碼以下:

01
02
03
04
05
06
07
08
09
10
11
12
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);


服務端代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
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("<script type=\"text/javascript\">"+methodName+"("+new Date().getTime()+");</script>");
         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( '服務器被關閉' );
});

注意這裏服務端輸出的數據content-type首部要設定爲application/javascript,不然某些瀏覽器會將其當作文本解析。

結果以下:
新手入門貼:史上最全Web端即時通信技術原理詳解_9.png 

5、WebSocket

在上面的這些解決方案中,都是利用瀏覽器單向請求服務器或者服務器單向推送數據到瀏覽器這些技術組合在一塊兒而造成的hack技術,在HTML5中,爲了增強web的功能,提供了websocket技術,它不只是一種web通訊方式,也是一種應用層協議。它提供了瀏覽器和服務器之間原生的雙全工跨域通訊,經過瀏覽器和服務器之間創建websocket鏈接(其實是TCP鏈接),在同一時刻可以實現客戶端到服務器和服務器到客戶端的數據發送。關於該技術的原理,請參見:《WebSocket詳解(一):初步認識WebSocket技術》、《WebSocket詳解(二):技術原理、代碼演示和應用案例》、《WebSocket詳解(三):深刻WebSocket通訊協議細節》,此處就不在贅述了,直接給出代碼。在看代碼以前,須要先了解websocket整個工做過程。

首先是客戶端new 一個websocket對象,該對象會發送一個http請求到服務端,服務端發現這是個webscoket請求,會贊成協議轉換,發送回客戶端一個101狀態碼的response,以上過程稱之爲一次握手,通過此次握手以後,客戶端就和服務端創建了一條TCP鏈接,在該鏈接上,服務端和客戶端就能夠進行雙向通訊了。這時的雙向通訊在應用層走的就是ws或者wss協議了,和http就沒有關係了。所謂的ws協議,就是要求客戶端和服務端遵循某種格式發送數據報文(幀),而後對方纔可以理解。

關於ws協議要求的數據格式官網指定以下:
新手入門貼:史上最全Web端即時通信技術原理詳解_10.png 

其中比較重要的是FIN字段,它佔用1位,表示這是一個數據幀的結束標誌,同時也下一個數據幀的開始標誌。opcode字段,它佔用4位,當爲1時,表示傳遞的是text幀,2表示二進制數據幀,8表示須要結束這次通訊(就是客戶端或者服務端哪一個發送給對方這個字段,就表示對方要關閉鏈接了)。9表示發送的是一個ping數據。mask佔用1位,爲1表示masking-key字段可用,masking-key字段是用來對客戶端發送來的數據作unmask操做的。它佔用0到4個字節。Payload字段表示實際發送的數據,能夠是字符數據也能夠是二進制數據。

因此無論是客戶端和服務端向對方發送消息,都必須將數據組裝成上面的幀格式來發送。

首先來看服務端代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
//握手成功以後就能夠發送數據了
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<frame.PayloadLength;j++)
             s.push(e[i+j]^frame.MaskingKey[j%4]);
     } else s=e.slice(i,frame.PayloadLength); //不然直接使用數據
     //數組轉換成緩衝區來使用
     s= new Buffer(s);
     //若是有必要則把緩衝區轉換成字符串來使用
     if (frame.Opcode==1)s=s.toString();
     //設置上數據部分
     frame.PayloadData=s;
     //返回數據幀
     return frame;
}
//對發送數據進行編碼
function encodeData(e){
     var s=[],o= new Buffer(e.PayloadData),l=o.length;
     //輸入第一個字節
     s.push((e.FIN<<7)+e.Opcode);
     //輸入第二個字節,判斷它的長度並放入相應的後續長度消息
     //永遠不使用掩碼
     if (l<126)s.push(l);
     else if (l<0x10000)s.push(126,(l&0xFF00)>>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]);
}

服務端經過監聽data事件來獲取客戶端發送來的數據,若是是握手請求,則發送http 101響應,不然解析獲得的數據並打印出來,而後判斷是否是斷開鏈接的請求(Opcode爲8),若是是則斷開鏈接,不然將接收到的數據組裝成幀再發送給客戶端。

客戶端代碼:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
window.onload= 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.onclick= function (){
                 if (!/^\s*$/.test(oText.value)){
                     ws.send(oText.value);
                 }
             };
 
         };
         ws.onmessage= function (msg){
           var str= "<li>" +msg.data+ "</li>" ;
           oUl.innerHTML+=str;
         };
         ws.onclose= function (e){
             console.log( "已斷開與服務器的鏈接" );
             ws.close();
         }
     }

客戶端建立一個websocket對象,在onopen時間觸發以後(握手成功後),給頁面上的button指定一個事件,用來發送頁面input當中的信息,服務端接收到信息打印出來,並組裝成幀返回給日客戶端,客戶端再append到頁面上。

客戶結果以下:
新手入門貼:史上最全Web端即時通信技術原理詳解_11.png 

服務端輸出結果:
新手入門貼:史上最全Web端即時通信技術原理詳解_12.png 

從上面能夠看出,WebSocket在支持它的瀏覽器上確實提供了一種全雙工跨域的通訊方案,因此在各以上各類方案中,咱們的首選無疑是WebSocket。

結束語

上面論述了這麼多對於IM應用開發所涉及到的通訊方式,在實際開發中,咱們一般使用的是一些別人寫好的實時通信的庫,好比socket.iosockjs,他們的原理就是將上面(還有一些其餘的如基於Flash的push)的一些技術進行了在客戶端和服務端的封裝,而後給開發者一個統一調用的接口。這個接口在支持websocket的環境下使用websocket,在不支持它的時候啓用上面所講的一些hack技術。

從實際來說,單獨使用本文上述所講的任何一種技術(WebSocket除外)達不到咱們在文章開頭提出的低延時,雙全工、跨域的所有要求,只有把他們組合起來纔可以很好地工做,因此一般狀況下,這些庫都是在不一樣的瀏覽器上採用各類不一樣的組合來實現實時通信的。

下面是sockjs在不一樣瀏覽器下面採起的不一樣組合方式:

新手入門貼:史上最全Web端即時通信技術原理詳解_13.png 

從圖上能夠看出,對於現代瀏覽器(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

相關文章
相關標籤/搜索