Web實時通信技術

本週在應用寶前端分享會上分享了Web實時通信技術,分享內容整理如下。

一、傳統Web數據更新

傳統的Web數據更新,必須要刷新網頁才能顯示更新的內容。這是瀏覽器採用的是B/S架構,而B/S架構是基於HTTP協議的。HTTP協議的工作模式就是客戶端向服務器發送一個請求,服務器收到請求後返回響應。所以這種工作模式是基於請求顯示數據的。

這樣的工作方式有其自身的好處,但是也會導致很多問題。在Web應用越來越火的今天,經常會遇到需要服務器主動發送數據到客戶端的需求,比如事件推送、Web聊天等。這些需求使用傳統的Web數據更新工作模式是無法實現的,因此就需要一項新的技術:Web實時通信技術。

二、短輪詢

第一種解決方法思路很簡單,既然需要客戶端發送請求服務器才能發送數據,那麼就可以讓客戶端不斷的向服務器發送數據,這樣就能實時的獲取服務器端的數據更新了。具體的實現方法很簡單,客戶端每隔一定時間就發送一個請求到服務器端。下面的圖可以清晰的反映出短輪詢過程中客戶端和服務器的工作流程:

下面看一下實現方法。

在服務器端我們模擬數據的發送,生成1-1000的隨機數,當數值小於800的時候模擬沒有數據的情況,大於800的時候模擬有數據的情況,並返回數據:

<?php$arr = array('title'=>'推送!','text'=>'推送消息內容');
$rand = rand(1,999);if(rand < 800){echo “”
}else{  echo json_encode($arr);
}?>

客戶端部分,定義了一個函數用來發送ajax請求到客戶端,然後每隔2s就發送以此請求:

<!doctype html><html>
<head>
<meta charset="utf-8">
<title>短輪詢ajax實現</title>
<script type="text/javascript" src="../jquery.min.js"></script>
</head>
<body>
<form id="form1" runat="server">
     <div id="news"></div>
    </form>
</body>
<script type="text/javascript">  function showUnreadNews()
    {
        $(document).ready(function() {
            $.ajax({
                type: "GET",
                url: "setInterval.php",
                dataType: "json",
                success: function(msg) {
                    $.each(msg, function(id, title) {
                        $("#news").append("<a>" + title + "</a><br>");
                    });
                }
            });
        });
    }
    setInterval('showUnreadNews()',2000);
</script>
</html>

運行程序我們可以在Chrome的network工具看到,每隔兩秒都會有一個請求從客戶端發往服務器,不管當時的服務器有沒有數據,都會立即返回請求。、

短輪詢雖然簡單,但是它的缺點也是顯而易見的。首先短輪詢建立了很多HTTP請求,而且其中絕大部分的請求是沒有用處的。而HTTP連接數過多過多會嚴重影響Web性能。其次,客戶端設置的請求發送時間間隔也不好掌控,時間間隔太短會造成大量HTTP的浪費,而時間間隔過長會使得客戶端不能即時收到服務器端的數據更新,失去了即時通信的意義。

三、長輪詢

針對上面短輪詢的種種問題,我們自然而然想到要減少HTTP請求的數量,才能讓實時通信性能更高。而長輪詢就能有效的減少HTTP請求的數量。

長輪詢的邏輯是,首先客戶端向服務器端發送一個請求,服務器端在收到請求後不馬上返回該請求,而是將請求掛起。一段時間後服務器端有數據更新時,再將這個請求返回客戶端,客戶端收到服務器端的響應數據後渲染界面,同時馬上再發送一個請求到服務器,如此循環,下面的圖描述了這個過程:

長輪詢有效的減少了HTTP連接。服務器端在有數據更新時才返回數據,客戶端收到數據再請求這一機制,較少了之間許多的無用HTTP請求。下面通過一個Demo來演示長輪詢的工作模式。

服務器端模擬數據更新,在客戶端發來請求後先掛起6s,模擬6s後纔有數據的情況:

<?php$arr = array('title'=>'推送','text'=>'推送消息內容');
$flag = 0;for($i=1;$i<=6;$i++){  if($i>5){   //i = 6時表示有數據了
    echo json_encode($arr);
  }else{
    sleep(1);
  }
}?>

這裏爲了演示的更清楚,添加了一個for循環,其實就是先將請求掛起6s。

客戶端發送一個ajax請求,並當收到服務器端數據後自動再發送一個請求到服務器:

<!doctype html><html>
<head>
<meta charset="utf-8">
<title>長輪詢ajax實現</title>
<script type="text/javascript" src="../jquery.min.js"></script>
</head>
<body>
<input type="button" id="btn" value="click">
<div id="msg"></div>
</body>
<script type="text/javascript">
$(function(){
        $("#btn").bind('click',{btn:$('#btn')},function(e){
            $.ajax({
                type: 'POST',
                dataType: 'json',               
                url: 'do.php',
                timeout: '20000',
                success: function(data,status){
                    $("#msg").append(data.title + ':' + data.text + "</br>");
                    e.data.btn.click(); 
                }
            });
        });
    });
</script>
</html>

長輪詢雖然有效的減少了HTTP請求,但是HTTP請求相比之下還是很多的,因爲每次數據的更新都需要建立一個HTTP請求。下面的技術就可以實現建立以此HTTP連接,服務器可以源源不斷的向客戶端發送數據。

四、SSE

MessageEvent是HTML5中新定義的一種事件基類,和原先的MouseEvent、UIEvent一樣。MessageEvent是專門爲數據傳輸定義的事件,Html5中的SSE和WebSocket都利用了這個事件。MessageEvent在HTML5協議中的接口如下:

除了繼承了Event事件具有的屬性外,MessageEvent還定義了其他屬性。其中data屬性所包含的內容就是傳輸的數據內容。而lastEventId可以存放一個事件標識符,當客戶端和服務器傳輸數據過程中斷開連接需要重連時,客戶端會將上一次傳輸數據的lastEventId作爲請求頭中的一個特殊字段發送到服務器,從而讓服務器可以繼續上次斷開連接的部分發送消息。

SSE是HTML5規範中定義的,它可以實現維持一個HTTP連接,服務器端單向向客戶端不斷髮送數據。這個技術真正意義上實現了服務器主動推送數據到客戶端。除此之外,SSE的客戶端和服務器端代碼都特別簡潔,使用起來十分方便。

SSE的邏輯就是首先客戶端發送請求,建立一個HTTP連接,之後服務器端和客戶端一直保持這個連接,服務器端可以單向向客戶端發送數據,見下圖:

SSE的實現方法很簡單,SSE是以事件流的形式發送數據的。在服務器端要先進行如下配置:

Content-Type:text/event-streamCache-Control:no-cacheConnections:keep-alive

如果還需要進行跨域,配置裏再添加:

Access-Control-Allow-Origin: *

其中text/event-stream是HTML5規範中爲SSE的事件流傳輸定義的一種流格式。

做好配置後,服務器第二個要做的事就是維護一個清單,清單內容就是要向客戶端發送的數據,下面是一段例子:

data: first event  
 event: push
data: second event

每一組事件流傳輸對應的數據,每個事件流之間使用換行符進行分割。這種格式發送過去之後會被進行解析,最後將各個部分進行組裝,客戶端按需進行讀取。每一個事件流可以爲其指定四個字段。

(1)retry字段

  SSE有一個自動重連機制,即客戶端和服務器之間的連接斷開後,隔一段時間客戶端就會自動重連。retry指定的就是這個重連時間,單位爲ms。

(2)event字段

SSE中有三個默認的事件,它們都繼承自MessageEvent事件類。其中open事件在連接建立時觸發,message事件在從客戶端接收到數據時觸發,close事件在連接關閉時觸發。如果傳輸事件時一個事件流沒有設定event字段的值,那麼客戶端就會監聽message默認事件;如果指定了event事件,那麼就會生成自定義的event事件,客戶端可以監聽自定義的event進行數據讀取。

(3)data字段

data字段包含的內容就是服務器要傳送給客戶端的數據,SSE只能傳送文本數據,且必須是UTF-8編碼的。由於這些事件都繼承自MessageEvent基類,因此可以通過event.data獲取服務器傳輸的數據。

(4)id字段

id字段是事件的唯一標識符,解析後會被傳入MessageEvent對應的lastEventId屬性字段中,從而記錄上次數據傳輸的位置。如果不指定id字段lastEventId字段就是一個空字符串。

至此服務器端任務完成,下面介紹客戶端的實現方法。

客戶端首先需要實例化一個EventSource對象。EventSource對象在HTML5中的接口定義如下:

首先需要爲EventSource對象傳入一個url,表明要請求的服務器地址。該對象有三個readyState狀態值,其中CONNECTING表示正在建立連接,OPEN表示連接處於打開狀態可以傳輸數據,CLOSED狀態表示連接中斷,並且客戶端並沒有嘗試重連。EventSource定義的默認事件句柄爲onopen、onmessage、onerror。其中的方法只有close(),用來關閉連接。

實例化好EventSource對象後,我們需要對事件進行監聽,從而獲取數據,最後可以通過close()方法關閉連接,整體邏輯的代碼如下:

var es = new EventSource(url);  
es.addEventListener("message", function(e){    console.log(e.data);
})
es.close();

下面是一個實現SSE的例子。

服務器使用node,代碼及註釋如下:

var http = require("http");var fs = require("fs");//創建服務器http.createServer(function (req, res) {  var index = "./index.html";  var fileName;  var interval;  var i = 1;  //設置路由
  if (req.url === "/"){
    fileName = index;
  }else{
    fileName = "." + req.url;
  }  if (fileName === "./stream") {    //配置頭部信息:注意類型爲專門爲sse定義的event-stream,並且不使用緩存
    res.writeHead(200, {"Content-Type":"text/event-stream", "Cache-Control":"no-cache", "Connection":"keep-alive"});    /*
      下面的代碼的輸出結果等價於:
      retry: 10000
      event: title
      data: News Begin
 
      data: ...
 
      ...
    */
    //上面可以看出,只有第一段是觸發事件connecttime,其他都是觸發默認事件message
    res.write("retry: 10000\n");    //定義連接斷開後客戶端重新發起連接的時間,ms制
    res.write("event: title\n");   //自定義的事件title
    res.write("data: News Begin! \n\n");    //每隔1s就在協議中新寫入一段數據來模擬服務器向客戶端發送數據
    interval = setInterval(function() {
      res.write("data: News" + i +"\n\n");
      i++;
    }, 1000);    //監聽close事件,當服務器關閉時停止向客戶端傳送數據
    req.connection.addListener("close", function () {
      clearInterval(interval);
    }, false);
  } else if (fileName === index) {
    fs.exists(fileName, function(exists) {      if (exists) {
        fs.readFile(fileName, function(error, content) {          if (error) {
            res.writeHead(500);
            res.end();
          } else {
            res.writeHead(200, {"Content-Type":"text/html"});
            res.end(content, "utf-8");
          }
        });
      } else {
        res.writeHead(404);
        res.end();
      }
    });
  } else {
    res.writeHead(404);
    res.end();
  }
}).listen(8888);console.log("Server running at http://127.0.0.1:8888/");

服務器端自定義了title事件,用來發送標題數據,其他的數據使用默認事件發送。

客戶端部分代碼及註釋如下:

<!DOCTYPE html><html lang="en">
<head>
  <title>Server-Sent Events Demo</title>
  <meta charset="UTF-8" />
  <script>    window.onload = function() {      var button = document.getElementById("connect");      var status = document.getElementById("status");      var output = document.getElementById("output");      var connectTime = document.getElementById("connecttime");      var source;      function connect() {
        source = new EventSource("stream");        //messsage事件:當收到服務器傳來的數據時觸發
        source.addEventListener("message", function(event) {
          output.textContent = event.data;  //每次收到數據後都更新時間
        }, false);        //自定義的事件title
        source.addEventListener("title", function(event) {
          connectTime.textContent = event.data;
        }, false);        //open事件:當客戶端和服務器完成連接時觸發
        source.addEventListener("open", function(event) {          //每次連接成功後更新按鈕功能和文本提示,再次點擊按鈕應爲關閉連接
          button.value = "Disconnect";
          button.onclick = function(event) {            //調用eventsource對象的close()方法關閉連接,並且爲其綁定新的事件connect建立連接
            source.close();
            button.value = "Connect";
            button.onclick = connect;
          };
        }, false);        //異常處理
      }      //調用,如果支持EVentSource則執行connect()方法僅從sse連接
      connect();
    }
  </script>
</head>
<body>
  <input type="button" id="connect" value="Connect" /><br />
  <span id="status"></span><br />
  <span id="connecttime"></span><br />
  <span id="output"></span>
</body>
</html>

客戶端監聽事件,不同的事件收到的數據進行不同的渲染。同時,都過爲按鈕綁定事件,調用close()等方法,實現SSE連接的打開與斷開。

SSE技術簡單方便,且是HTML5中定義內容,實現了服務器推送數據的技術,下圖是SSE的瀏覽器兼容性列表:

但是SSE只能實現服務器到客戶端單向的數據傳輸。有時我們的需求需要使用雙向數據傳輸,這時就需要使用WebSocket。

五、WebSocket

WebSocket也是HTML5中定義的。它是一個新的協議,實現了全雙工的通信模式,即客戶端和服務器端可以互相發送消息。WebSocket的實現首先需要客戶端和服務器端進行一次握手,此後就會建立起一個數據傳輸通道,通道存在期間客戶端和服務器端可以平等的互相發送數據。具體的邏輯圖如下:

WebSocket的服務器端實現比較複雜,但是各個後臺語言都已經有實現好的WebSocket庫。比如Node.js中的nodejs-websocket模塊和socket.io模塊。使用WebSocket技術可以實現很多功能,附件中就是藉助nodejs-websocket模塊編寫的彈幕效果。

WebSocket的客戶端實現比較便捷,首先需要實例化一個WebSocket對象,傳入要請求的服務器的url。這裏需要注意,協議名要指定爲ws或wss,如:

ws = new WebSocket("ws://localhost:8080");

客戶端可以通過調用send()方法進行數據的發送,通過調用close()方法關閉WebSocket連接。WebSocket也使用了MessageEvent接口,因此可以對消息事件進行監聽,默認的可以通過監聽message事件獲取數據。下面是WebSocket在HTML5規範中定義的接口:

WebSocket的服務器端實現可以分爲兩個部分,第一個部分是握手部分,主要負責HTTP協議的升級,第二個部分是數據傳輸部分。

WebSocket協議可以說是一個HTTP協議的升級版,這個升級過程需要通過客戶端和服務器的一次握手來實現。下面是建立握手時客戶端向服務器發送的請求報文頭實例:

字段Upgrade:websocket和Connection:Upgrade部分完成了協議的升級,服務器可以觸發響應事件,獲取這兩個字段的內容,匹配符合要求後服務器端進行握手處理。客戶端需要向服務器端發送一個Sec-WebSocket-Key字段,這個字段的內容是客戶端產生的,相當於一個私鑰。服務器端收到客戶端的請求頭後,如果確定是要使用WebSocket協議,就開始進行握手。服務器端使用客戶端傳來的Sec-WebSocket-Key的值,與服務器端存儲的一個全局唯一標識符進行拼接,之後做SHA1處理和BASE64加密,並作爲響應頭返回給客戶端。服務器端的GUID相當於公鑰。

下面是服務器端返回的響應報文頭,處理後的字符串在Sec-WebSocket-Accept字段中給出。客戶端必須受到101狀態碼,這個狀態碼錶示切換協議,從而完成對協議的升級。

總的來看,WebSocket只有在建立握手連接的時候借用了HTTP協議的頭,連接成功後的通信部分都是基於TCP的連接,可以說WebSocket協議是HTTP協議的升級版。

WebSocket的數據幀格式如下:

opcode存儲的是傳輸數據的類型,諸如文本、二進制數據等。數據傳輸時首先會對該部分的值進行判斷,然後進行對應的數據操作。數據存儲在Payload Data字段中。最後將幀結構解析爲一個鍵值對的對象。

下面是瀏覽器對WebSocket的支持情況:


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