使用 WebSocket 構建實時性應用

jkest發表於2017-12-25

WebSocket 技術已經逐漸成熟,在生產環境下也已經帶給我們非常多的便利。本文首先會努力闡明 WebSocket 的基本原理,然後會結合實際敘述如何使用它。


WebSocket 不會完全取代 HTTP

首先需要明確的是 WebSocket 的定位。WebSocket 是建立在 HTTP 基礎上,為客戶端與服務端之間提供文字和二進位制資料的全雙工通訊的技術。這裡有幾個地方需要注意:

  • 建立在 HTTP 基礎上。WebSocket 需要在建立了 HTTP 連線之後,客戶端才能發起 WebSocket “握手”請求,握手成功後客戶端與服務端才能進行 WebSocket 通訊
  • 提供文字(Text)和二進位制資料(Binary Data)兩種資料的傳輸
  • 全雙工通訊。一旦確立 WebSocket 通訊連線,無論是客戶端還是瀏覽器,任意一方都可以想對方傳送訊息,即實現了伺服器想客戶端推送資料的功能

WebSocket 是為了滿足基於 Web 的日益增長的實時通訊需求而產生的。在傳統的 Web 中,要實現實時通訊(比如網頁版 QQ),常用的方式是通過輪詢。在特定的時間間隔,由瀏覽器向伺服器傳送 HTTP 請求,然後將最新的資料返回給瀏覽器。這樣的方法最明顯的缺點就是需要不斷的傳送請求,不僅佔用了寬頻,也佔用伺服器 CPU 資源(沒有資訊也要接受請求)。

如此看來,WebSocket 的出現相比於純 HTTP 實現實時通訊至少有兩個優勢:

  • 實時性提高,只要 WebSocket 連線建立,就可以一直保持連線狀態,傳送訊息時不需要額外的連線建立過程,通訊的實時性大大提高
  • 總資料流量降低,WebSocket 的首部相比於 HTTP 首部的體積要小的多,尤其是傳送訊息次數較多,這會節省下相當客觀的資料流量

然而,WebSocket 在實現高效實時通訊的過程卻也不再享有在一些本由瀏覽器提供的服務和優化,如狀態管理、壓縮、快取等。以後瀏覽器廠商是不是會針對 WebSocket 作出一些服務和優化不得而知,但至少現在 WebSocket 還完全不足以撼動 HTTP 的地位。總的來說,WebSocket 彌補了 HTTP 在某些通訊領域的短板,但絕不可能完全取代 HTTP。


WebSocket API

WebSocket 原本是 HTML5 標準的一部分,隨其發展壯大,現在已逐漸變成了獨立的協議標準。在客戶端,HTML5 提供了一套非常簡潔的 API 供我們使用。

構造方法

WebSocket(url: string, protocols?: string[] | string)
複製程式碼
  • url 表示要連線的 URL。注意協議名是 ws 或者 wss
  • protocols 可以是一個單個的協議名字字串或者包含多個協議名字字串的陣列。這些字串用來表示子協議,這樣做可以讓一個伺服器實現多種 WebSocket 子協議(例如你可能希望通過制定不同的協議來處理不同型別的互動)。如果沒有制定這個引數,它會預設設為一個空字串。

原型方法

一共就只有兩個。

close(code?: number, reason?: string): void
複製程式碼

關閉WebSocket連線或停止正在進行的連線請求。如果連線的狀態已經是closed,這個方法不會有任何效果。

  • code 表示關閉連線的狀態號,表示連線被關閉的原因。如果這個引數沒有被指定,預設的取值是 1000 ,即正常連線關閉。更多取值檢視 CloseEvent 頁面。
  • reason 一個描述性的字串,表示連線被關閉的原因。
send(data: string | ArrayBuffer | Blob): void
複製程式碼

通過WebSocket連線向伺服器傳送資料。

  • data 表示要傳送到伺服器的資料,可以是 String、ArrayBuffer 或者 Blob 型別。String 型別相當於是文字資料,ArrayBuffer 和 Blob 是二進位制資料。

事件

事件遵從原生 Javascript 的兩種寫法:

  • onevent = handler
  • addEventListener(event, handler)

支援的事件有 openmessagecloseerror。注意 message 事件在接收到所有資料時出發。

屬性

WebSocket 物件的屬性用來描述通訊細節和狀態。

  • binaryType: string 表示被傳輸二進位制的內容的型別。取值應當是 'blob' 或者 'arraybuffer'

    var ws = new WebSocket('wss://example.com/socket');
    ws.binaryType = "arraybuffer"; // 強制將接收的二進位制資料轉為 ArrayBuffer 型別
    
    ws.onmessage = function(msg) {
      if(msg.data instanceof ArrayBuffer) {
        processArrayBuffer(msg.data);
      } else {
        processText(msg.data);
      }
    }
    複製程式碼
  • bufferedAmount: number 呼叫 send() 方法將多位元組資料加入到佇列中等待傳輸,但是還未發出。該值會在所有佇列資料被髮送後重置為 0。而當連線關閉時不會設為0。如果持續呼叫 send(),這個值會持續增長。只讀。

  • protocol: string 一個表明伺服器選定的子協議名字的字串。這個屬性的取值為構造器傳入的 protocols 引數或者之一。

  • readyState: number 連線的當前狀態。取值是 Ready state constants之一。 只讀。

  • url: string 傳入構造器的URL。它必須是一個絕對地址的URL。只讀。

最簡單的一個例子

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

// Connection opened
socket.addEventListener('open', function (event) {
  socket.send('Hello Server!');
});

// Listen for messages
socket.addEventListener('message', function (event) {
  console.log('Message from server', event.data);
});
複製程式碼

然而以上只是在客戶端的 WebSocket 實現,如果沒有服務端的配合,WebSocket 是不能進行通訊的。關於 WebSocket 的在服務端的實現有多種方案,下一節會在 Node.js 上進行實現。

以上部分是個人簡單總結,如果想詳細瞭解其 API 可以查閱 MDN 上的文件


WebSocket 的基本使用

使用 WebSocket 其實並不複雜,本節將結合 socket.io 模組進行說明。接下來看看如何構建一個最簡單的 WebSocket 應用。

第一步,安裝依賴庫:

npm install socket.io express
複製程式碼

第二步,構建客戶端 /public/index.html:

<body>
  <script src="/socket.io/socket.io.js"></script>
  <script>
    const socket = io('http://localhost:3231');
    socket.on('connect', () => {
      socket.emit('greet', 'Hello, websocket!');
      socket.on('uppergreet', data => console.log(data));
    });
  </script>
</body>
複製程式碼

注意上面引入 socket.io 的方式,不用懷疑路徑是否正確,因為 socket.io 會自動引入相應客戶端部分程式碼,並且暴露一個 io 全域性變數。

第三步,構建服務端 server.js:

const express = require('express');
const app = express();
const http = require('http').Server(app);
const io = require('socket.io')(http);
const port = process.env.PORT || 3000;

app.use(express.static(__dirname + '/public'));

io.on('connection', socket => {
  socket.on('greet', data => socket.emit('uppergreet', data.toUpperCase()));
});

http.listen(port, () => console.log('listening on port ' + port));
複製程式碼

最後,執行 node server.js 並用瀏覽器訪問 http://localhost:3000,開啟控制檯將會看到輸出:

HELLO, WEBSOCKET!
複製程式碼

可以看到,使用 socket.io 構建 WebSocket 應用相比上一節提到的 WebSocket API 在寫法上還是有比較大的區別的。

除了本節提到的 socket.io 模組,在 Node.js 上還有多種實現,比如 websocketnodejs-websocket,他們不僅實現了服務端的 WebSocket 通訊協議,同時也對上一節提到的客戶端的 WebSocket API 進行了或多或少的封裝,功能完善了很多,在實際工作中已經得到了廣泛應用,個人比較傾向於 socket.io 庫。


WebSocket 的適用場景

WebSocket 適用於需要高效實時通訊的場景,比如網頁聊天,對戰遊戲等。本節將使用 socket.io 模組進行說明,在 socket.io 的官網上有多個典型使用場景,這裡我們使用官網給出的較為簡單的一個畫板案例進行說明。 socket.io 官網還給出一個使用 jQuery 開發的聊天應用示例,我根據其最終效果實現了一個 react 版本

畫板案例的關鍵之處是多個客戶端可以同時在一張畫板上進行繪畫,繪畫結果所有客戶端都能實時看到,(這個場景是不是和你畫我猜的遊戲類似?一人畫圖,多個使用者能實時看到畫圖結果),這裡使用 WebSocket 是非常合適的。

首先是服務端的程式碼 index.js,和上一節的程式碼非常類似:

const express = require('express');
const app = express();
const http = require('http').Server(app);
const io = require('socket.io')(http);
const port = process.env.PORT || 3000;

app.use(express.static(__dirname + '/public'));

io.on('connection', socket => {
  socket.on('drawing', data => socket.broadcast.emit('drawing', data));
});

http.listen(port, () => console.log('listening on port ' + port));
複製程式碼

接下來執行如下命令:

node index.js
複製程式碼

然後開啟兩個瀏覽器視窗都訪問 http://localhost:3000,然後在一個瀏覽器內進行繪畫,另外一個瀏覽器也能實時看到結果。

可以看到服務端程式碼非常簡潔,在這裡關鍵步驟就是 connection 事件的處理方法,在該方法內部 socket 會監聽 drawing 事件,在監聽到 drawing 事件之後的回撥函式中,再次廣播傳送 drawing 事件,廣播傳送的意思就是向所有連上服務端的客戶端傳送。

然後是客戶端的關鍵程式碼 /public/main.js:

var socket = io();
socket.on('drawing', onDrawingEvent);

var canvas = document.getElementsByClassName('whiteboard')[0];
canvas.addEventListener('mousedown', onMouseDown, false);
canvas.addEventListener('mouseup', onMouseUp, false);
canvas.addEventListener('mouseout', onMouseUp, false);
canvas.addEventListener('mousemove', throttle(onMouseMove, 10), false);
複製程式碼

上面程式碼中的 onDrawingEventmouseUponMouseMove 方法內部都呼叫了一個關鍵的 drawLine 方法:

function drawLine(x0, y0, x1, y1, color, emit){
  // ... 繪製 canvas ...

  if (!emit) return;

  socket.emit('drawing', {
    // ... 繪製 canvas 的狀態資訊 ...
  });
}
複製程式碼

drawLine 方法根據傳入的 emit 位來判斷是否要向伺服器傳送 drawing 事件,如果是客戶端本身進行的繪製,則會向服務端傳送 drawing 事件,否則不會。

以上就是實現一個多人同時繪畫的實時應用的整體思路,可以看到當我們使用 WebSocket 進行通訊,尤其是使用類似 socket.io 這種封裝程度很高的工具庫時,通訊過程是非常簡潔的,我們把大部分精力放在業務邏輯中即可。


WebSocket 技術的發展已經讓開發者無須瞭解協議內部即可實現實時通訊邏輯,尤其是配合使用 Node.js 作為服務端時,類似 socket.io 的工具庫為服務端和客戶端都提供了非常方便的呼叫方法,上手非常快。如果在工作中需要構建高效實時應用,WebSocket 將會是不二選擇。


參考資料

相關文章