新手入門:史上最全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端即時通訊技術原理詳解_1.png 

二、傳統通信方式實現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<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):

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)詳解》)

客戶端:

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的時候,表示服務器已經返回數據,本次連接已斷開,再次請求服務器建立連接。

服務端代碼:

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數據流方式,代碼如下。

客戶端代碼:

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端即時通訊技術原理詳解_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代碼並找到頁面上指定的函數去執行,實際上是服務端間接使用自己的數據間接調用了客戶端的代碼,達到實時更新客戶端的目的。

客戶端代碼如下:

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("<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對象並不是直接添加到頁面上的,所以並沒有造成瀏覽器顯示正在加載的現象。代碼如下。

客戶端:

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的是這樣子:

<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事件觸發時就能夠接收到數據,從而進行某種處理,代碼如下。

客戶端:

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

服務端:

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.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)。

客戶端代碼:

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('服務器被關閉');
});


注意服務端需要設置頭部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。


客戶端請求代碼:

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流的方式,客戶端瀏覽器接收到返回的腳本調用會解析執行,從而達到更新界面的目的。

客戶端代碼如下:

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("<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 

五、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字段表示實際發送的數據,可以是字符數據也可以是二進制數據。

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

首先來看服務端代碼:

//握手成功之後就可以發送數據了
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),如果是則斷開連接,否則將接收到的數據組裝成幀再發送給客戶端。

客戶端代碼:

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章