你可能不知道的瀏覽器實時通訊方案

本文主要探討現階段瀏覽器端可行的實時通訊方案,以及它們的發展歷史。php

這裏以sockjs做爲切入點,這是一個流行的瀏覽器實時通訊庫,提供了'類Websocket'、一致性、跨平臺的API,旨在瀏覽器和服務器之間建立一個低延遲、全雙工、支持跨域的實時通訊信道. 主要特色就是仿生Websocket,它會優先使用Websocket做爲傳輸層,在不支持WebSocket的環境回退使用其餘解決方案,例如XHR-Stream、輪詢.html

因此sockjs自己就是瀏覽器實時通訊方案的編年史, 本文也是按照由新到老這樣的順序來介紹這些解決方案.前端

相似sockjs的解決方案還有 socket.iogit

若是你以爲文章不錯,請不要吝惜你的點贊👍,鼓勵筆者寫出更精彩的文章程序員


目錄github




WebSocket

WebSocket其實不是本文的主角,並且網上已經有不少教程,本文的目的是介紹WebSocket以外的一些回退方案,在瀏覽器不支持Websocket的狀況下, 能夠選擇回退到這些方案.web

在此介紹Websocket以前,先來了解一些HTTP的基礎知識,畢竟WebSocket自己是借用HTTP協議實現的。跨域

HTTP協議是基於TCP/IP之上的應用層協議,也就是說HTTP在TCP鏈接中進行請求和響應的,瀏覽器會爲每一個請求創建一個TCP鏈接,請求等待服務端響應,在服務端響應後關閉鏈接:瀏覽器

後來人們發現爲每一個HTTP請求都創建一個TCP鏈接,太浪費資源了,能不能不要着急關閉TCP鏈接,而是將它複用起來, 在一個TCP鏈接中進行屢次請求。服務器

這就有了HTTP持久鏈接(HTTP persistent connection, 也稱爲HTTP keep-alive), 它利用同一個TCP鏈接來發送和接收多個HTTP請求/響應。持久鏈接的方式能夠大大減小等待時間, 雙方不須要從新運行TCP握手,這對前端靜態資源的加載也有很大意義:

Ok, 如今回到WebSocket, 瀏覽器端用戶程序並不支持和服務端直接創建TCP鏈接,可是上面咱們看到每一個HTTP請求都會創建TCP鏈接, TCP是可靠的、全雙工的數據通訊通道,那咱們何不直接利用它來進行實時通訊? 這就是Websocket的原理!

咱們這裏經過一張圖,通俗地理解一下Websocket的原理:

經過上圖能夠看到,WebSocket除最初創建鏈接時須要藉助於現有的HTTP協議,其餘時候直接基於TCP完成通訊。這是瀏覽器中最靠近套接字的API,能夠實時和服務端進行全雙工通訊. WebSocket相比傳統的瀏覽器的Comet(下文介紹)技術, 有不少優點:

  • 更強的實時性。基於TCP協議的全雙工通訊
  • 更高效。一方面是數據包相對較小,另外一方面相比傳統XHR-Streaming和輪詢方式更加高效,不須要重複創建TCP鏈接
  • 更好的二進制支持。 Websocket定義了二進制幀,相對HTTP,能夠更輕鬆地處理二進制內容
  • 保持鏈接狀態。 相比HTTP無狀態的協議,WebSocket只須要在創建鏈接時攜帶認證信息,後續的通訊都在這個會話內進行
  • 能夠支持擴展。Websocket定義了擴展,用戶能夠擴展協議、實現部分自定義的子協議。如部分瀏覽器支持壓縮等

它的接口也很是簡單:

const ws = new WebSocket('ws://localhost:8080/socket'); 

// 錯誤處理
ws.onerror = (error) => { ... } 

// 鏈接關閉
ws.onclose = () => { ... } 

// 鏈接創建
ws.onopen = () => { 
  // 向服務端發送消息
  ws.send("ping"); 
}

// 接收服務端發送的消息
ws.onmessage = (msg) => { 
  if(msg.data instanceof Blob) { 
  // 處理二進制信息
    processBlob(msg.data);
  } else {
    // 處理文本信息
    processText(msg.data); 
  }
}
複製代碼

本文不會深刻解析Websocket的協議細節,有興趣的讀者能夠看下列文章:


若是不考慮低版本IE,基本上WebSocket不會有什麼兼容性上面的顧慮. 下面列舉了Websocket一些常見的問題, 當沒法正常使用Websocket時,能夠利用sockjs或者socket.io這些方案回退到傳統的Comet技術方案.

  1. 瀏覽器兼容性。
  • IE10如下不支持
  • Safari 下不容許使用非標準接口創建鏈接
  1. 心跳. WebSocket自己不會維護心跳機制,一些Websocket實如今空閒一段時間會自動斷開。因此sockjs這些庫會幫你維護心跳
  2. 一些負載均衡或代理不支持Websocket。
  3. 會話和消息隊列維護。這些不是Websocket協議的職責,而是應用的職責。sockjs會爲每一個Websocket鏈接維護一個會話,且這個會話裏面會維護一個消息隊列,當Websocket意外斷開時,不至於丟失數據



XHR-streaming

XHR-Streming, 中文名稱‘XHR流’, 這是WebSocket的最佳替補方案. XHR-streaming的原理也比較簡單:服務端使用分塊傳輸編碼(Chunked transfer encoding)的HTTP傳輸機制進行響應,而且服務器端不終止HTTP響應流,讓HTTP始終處於持久鏈接狀態,當有數據須要發送給客戶端時再進行寫入數據

沒理解?不要緊,咱們一步一步來, 先來看一下正常的HTTP請求處理是這樣的:

// Node.js代碼
const http = require('http')

const server = http.createServer((req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/plain', // 設置內容格式
    'Content-Length': 11, // 設置內容長度
  })
  res.end('hello world') // 響應 
})
複製代碼

客戶端會當即接收到響應:


那麼什麼是分塊傳輸編碼呢?

在HTTP/1.0以前, 響應是必須做爲一整塊數據返回客戶端的(如上例),這要求服務端在發送響應以前必須設置Content-Length, 瀏覽器知道數據的大小後才能肯定響應的結束時間。這讓服務器響應動態的內容變得很是低效,它必須等待全部動態內容生成完,再計算Content-Length, 才能夠發送給客戶端。若是響應的內容體積很大,須要佔用不少內存空間.

HTTP/1.1引入了Transfer-Encoding: chunked;報頭。 它容許服務器發送給客戶端應用的數據能夠分爲多個部分, 並以一個或多個塊發送,這樣服務器能夠發送數據而不須要提早計算髮送內容的總大小

有了分塊傳輸機制後,動態生成內容的服務器就能夠維持HTTP長鏈接, 也就是說服務器響應流不結束,TCP鏈接就不會斷開.


如今咱們切換爲分塊傳輸編碼模式, 且咱們不終止響應流,看會有什麼狀況:

const http = require('http')

const server = http.createServer((req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/plain'
    // 'Content-Length': 11, // 🔴將Content-Length報頭去掉,Node.js默認就是使用分塊編碼傳輸的
  })
  res.write('hello world')
  // res.end() // 🔴不終止輸出流
})
複製代碼

咱們會發現請求會一直處於Pending狀態(綠色下載圖標),除非出現異常、服務器關閉或顯式關閉鏈接(好比設置超時機制),請求是永遠不會終止的。可是即便處於Pending狀態客戶端仍是能夠接收數據,沒必要等待請求結束:


基於這個原理咱們再來建立一個簡單的ping-pong服務器:

const server = http.createServer((req, res) => {
  if (req.url === '/ping') {
    // ping請求
    if (pendingResponse == null) {
      res.writeHead(500);
      res.write('session not found');
      res.end();
      return;
    }
    res.writeHead(200)
    res.end()

    // 給客戶端推流
    pendingResponse.write('pong\n');
  } else {
    // 保存句柄
    res.writeHead(200, {
      'Content-Type': 'text/plain',
    });
    res.write('welcome to ping\n');
    pendingResponse = res
  }
});
複製代碼

測試一下,在另外一個窗口訪問/ping路徑:

Ok! 這就是XHR-Streaming!


那麼Ajax怎麼接收這些數據呢? ①一種作法是在XMLHttpRequestonreadystatechange事件處理器中判斷readyState是否等於XMLHttpRequest.LOADING;②另一種作法是在xhr.onprogress事件處理器中處理。下面是ping客戶端實現:

function listen() {
  const xhr = new XMLHttpRequest();
  xhr.onprogress = () => {
    // 注意responseText是獲取服務端發送的全部數據,若是要獲取未讀數據,則須要進行劃分
    console.log('progress', xhr.responseText);
  }
  xhr.open('POST', HOST);
  xhr.send(null);
}

function ping() {
  const xhr = new XMLHttpRequest();
  xhr.open('POST', HOST + '/ping');
  xhr.send(null);
}

listen();
setInterval(ping, 5000);
複製代碼

慢着,不要高興得太早😰. 若是運行上面的代碼會發現onprogress並無被正常的觸發, 具體緣由筆者也沒有深刻研究,我發現sockjs的服務器源碼裏面會預先寫入2049個字節,這樣就能夠正常觸發onprogress事件了:

const server = http.createServer((req, res) => {
  if (req.url === '/ping') {
    // ping請求
    // ...
  } else {
    // 保存句柄
    res.writeHead(200, {
      'Content-Type': 'text/plain',
    });
    res.write(Array(2049).join('h') + '\n');
    pendingResponse = res
  }
});
複製代碼

最後再圖解一下XHR-streaming的原理:

總結一下XHR-Streaming的特色:

  • 利用分塊傳輸編碼機制實現持久化鏈接(persistent connection): 服務器不關閉響應流,鏈接就不會關閉
  • 單工(unidirectional): 只容許服務器向瀏覽器單向的推送數據

經過XHR-Streaming,能夠容許服務端連續地發送消息,無需每次響應後再去創建一個鏈接, 因此它是除了Websocket以外最爲高效的實時通訊方案. 但它也並非天衣無縫

好比XHR-streaming鏈接的時間越長,瀏覽器會佔用過多內存,並且在每一次新的數據到來時,須要對消息進行劃分,剔除掉已經接收的數據. 所以sockjs對它進行了一點優化, 例如sockjs默認只容許每一個xhr-streaming鏈接輸出128kb數據,超過這個大小時會關閉輸出流,讓瀏覽器從新發起請求.




EventSource

瞭解了XHR-Streaming, 就會以爲EventSource並非什麼新鮮玩意: 它就是上面講的XHR-streaming, 只不過瀏覽器給它提供了標準的API封裝和協議, 你抓包一看和XHR-streaming沒有太大的區別:


上面能夠看到請求的Accepttext/event-stream, 且服務端寫入的數據都有標準的約定, 即載荷須要這樣組織:

const data = `data: ${payload}\r\n\r\n`
複製代碼

EventSource的API和Websocket相似, 實例:

const evtSource = new EventSource('sse.php');

// 鏈接打開
evtSource.onopen = () => {}

// 接受消息
evtSource.onmessage = function(e) {
  // do something
  // ...
  console.log("message: " + e.data)

  // 關閉流
  evtSource.close()
}

// 異常
evtSource.onerror = () => {}
複製代碼

由於是標準的,瀏覽器調試也比較方便,不須要藉助第三方抓包工具:




HtmlFile

這是一種古老的‘祕術’😂,雖然咱們可能永遠都不會再用到它,可是它的實現方式比較有意思(相似於JSONP這種黑科技), 因此仍是值得講一下。

HtmlFile的另外一個名字叫作永久幀(forever-frame), 顧名思義, 瀏覽器會打開一個隱藏的iframe,這個iframe會請求一個分塊傳輸編碼的html文件(Transfer-Encoding: chunked), 和XHR-Streaming同樣,這個請求永遠都不會結束,服務器會不斷在這個文檔上輸出內容。這裏面的要點是現代瀏覽器都會增量渲染html文件,因此服務器能夠經過添加script標籤在客戶端執行某些代碼,先來看個抓包的實例:


從上圖能夠看出:

  • ① 這裏會給服務器傳遞一個callback,經過這個callback將數據傳遞給父文檔
  • ② 服務器每當有新的數據,就向文檔追加一個<script>標籤,script的代碼就是將數據傳遞給callback。利用瀏覽器會被下載邊解析HTML文檔的特性,新增的script會立刻被執行

最後仍是用流程圖描述一下:

除了IE六、7如下不支持,大部分瀏覽器都支持這個方案,當瀏覽器不支持XHR-streaming時,能夠做爲最佳備胎。




Polling

輪詢是最粗暴(或者說最簡單),也是效率最低下的‘實時’通訊方案,這種方式的原理就是按期向服務器發起請求, 拉取最新的消息隊列:

這種輪詢方式比較合適服務器的信息按期更新的場景,如天氣和股票行情信息。舉個例子股票信息每隔5分鐘更新一次,這時候客戶端按期輪詢, 且輪詢間隔和服務端更新頻率保持一致是一種理想的方式。

可是若是追求實時性,輪詢會致使一些嚴重的問題:

  • 資源浪費。好比輪詢的間隔小於服務器信息更新的頻率,這會浪費不少HTTP請求, 消耗寶貴的CPU時間和帶寬
  • 容易致使請求轟炸。好比當服務器負載比較高時,第一個請求還沒處理完成,這時候第2、第三個請求接踵而來,無用的額外請求對服務端進行了轟炸。



Long polling

還有一種優化的輪詢方法,稱爲長輪詢(Long Polling),sockjs就是使用這種輪詢方式, 長輪詢指的是瀏覽器發送一個請求到服務器,服務器只有在有可用的新數據時才響應

客戶端向服務端發起一個消息獲取請求,服務端會將當前的消息隊列返回給客戶端,而後關閉鏈接。當消息隊列爲空時,服務端不會當即關閉鏈接,而是等待指定的時間間隔,若是在這個時間間隔內沒有新的消息,則由客戶端主動超時關閉鏈接

另一個要點是,客戶端的輪詢請求只有在上一個請求鏈接關閉後纔會從新發起。這就解決了上文的請求轟炸問題。服務端能夠控制客戶端的請求時序,由於在服務端未響應以前,客戶端不會發送額外的請求(在超時期間內)。



擴展

  • WebRTC 這是瀏覽器的實時通訊技術,它容許網絡應用或者站點,在不借助中間媒介的狀況下,創建瀏覽器之間點對點(Peer-to-Peer)的鏈接,實現視頻流和(或)音頻流或者其餘任意數據的傳輸。
  • metetor DDP DDP(Distributed Data Protocol), 這是一個'有狀態的'實時通訊協議,這個是Meteor框架的基礎, 它就是使用這個協議來進行客戶端和服務端通訊. 他只是一個協議,而不是通訊技術,好比它的底層能夠基於Websocket、XHR-Streaming、長輪詢甚至是WebRTC
  • Server-Sent Events 教程 即EventSource
  • 程序員怎麼會不知道C10K 問題呢? - 池建強- Medium
相關文章
相關標籤/搜索