天天看點

Web實時通信技術

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

一、傳統Web資料更新

傳統的Web資料更新,必須要重新整理網頁才能顯示更新的内容。這是浏覽器采用的是B/S架構,而B/S架構是基于HTTP協定的。HTTP協定的工作模式就是用戶端向伺服器發送一個請求,伺服器收到請求後傳回響應。是以這種工作模式是基于請求顯示資料的。

這樣的工作方式有其自身的好處,但是也會導緻很多問題。在Web應用越來越火的今天,經常會遇到需要伺服器主動發送資料到用戶端的需求,比如事件推送、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請求的數量。

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

Web實時通信技術

長輪詢有效的減少了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協定中的接口如下:

Web實時通信技術

除了繼承了Event事件具有的屬性外,MessageEvent還定義了其他屬性。其中data屬性所包含的内容就是傳輸的資料内容。而lastEventId可以存放一個事件辨別符,當用戶端和伺服器傳輸資料過程中斷開連接配接需要重連時,用戶端會将上一次傳輸資料的lastEventId作為請求頭中的一個特殊字段發送到伺服器,進而讓伺服器可以繼續上次斷開連接配接的部分發送消息。

SSE是HTML5規範中定義的,它可以實作維持一個HTTP連接配接,伺服器端單向向用戶端不斷發送資料。這個技術真正意義上實作了伺服器主動推送資料到用戶端。除此之外,SSE的用戶端和伺服器端代碼都特别簡潔,使用起來十分友善。

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

Web實時通信技術

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的浏覽器相容性清單:

Web實時通信技術

但是SSE隻能實作伺服器到用戶端單向的資料傳輸。有時我們的需求需要使用雙向資料傳輸,這時就需要使用WebSocket。

五、WebSocket

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

Web實時通信技術

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規範中定義的接口:

Web實時通信技術

WebSocket的伺服器端實作可以分為兩個部分,第一個部分是握手部分,主要負責HTTP協定的更新,第二個部分是資料傳輸部分。

WebSocket協定可以說是一個HTTP協定的更新版,這個更新過程需要通過用戶端和伺服器的一次握手來實作。下面是建立握手時用戶端向伺服器發送的請求封包頭執行個體:

Web實時通信技術

字段Upgrade:websocket和Connection:Upgrade部分完成了協定的更新,伺服器可以觸發響應事件,擷取這兩個字段的内容,比對符合要求後伺服器端進行握手處理。用戶端需要向伺服器端發送一個Sec-WebSocket-Key字段,這個字段的内容是用戶端産生的,相當于一個私鑰。伺服器端收到用戶端的請求頭後,如果确定是要使用WebSocket協定,就開始進行握手。伺服器端使用用戶端傳來的Sec-WebSocket-Key的值,與伺服器端存儲的一個全局唯一辨別符進行拼接,之後做SHA1處理和BASE64加密,并作為響應頭傳回給用戶端。伺服器端的GUID相當于公鑰。

下面是伺服器端傳回的響應封包頭,處理後的字元串在Sec-WebSocket-Accept字段中給出。用戶端必須受到101狀态碼,這個狀态碼表示切換協定,進而完成對協定的更新。

Web實時通信技術

總的來看,WebSocket隻有在建立握手連接配接的時候借用了HTTP協定的頭,連接配接成功後的通信部分都是基于TCP的連接配接,可以說WebSocket協定是HTTP協定的更新版。

WebSocket的資料幀格式如下:

Web實時通信技術

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

下面是浏覽器對WebSocket的支援情況:

繼續閱讀