WebSockets實戰:在 Node 和 React 之間進行實時通訊

翻譯:瘋狂的技術宅javascript

原文:blog.logrocket.com/websockets-…html

img

Web 爲了支持客戶端和服務器之間的全雙工(或雙向)通訊已經走過了很長的路。這是 WebSocket 協議的主要目的:經過單個 TCP 套接字鏈接在客戶端和服務器之間提供持久的實時通訊。前端

WebSocket 協議只有兩個議程:1)打開握手,2)幫助數據傳輸。一旦服務器和客戶端握手成功,他們就能夠隨意地以較少的開銷相互發送數據。java

WebSocket 通訊使用WS(端口80)或WSS(端口443)協議在單個 TCP 套接字上進行。根據 Can I Use,撰寫本文時除了 Opera Mini 以外幾乎全部的瀏覽器支持 WebSockets 。node

現狀

從歷史上看,建立須要實時數據通信(如遊戲或聊天應用程序)的 Web 應用須要濫用 HTTP 協議來創建雙向數據傳輸。儘管有許多種方法用於實現實時功能,但沒有一種方法與 WebSockets 同樣高效。 HTTP 輪詢、HTTP流、Comet、SSE —— 它們都有本身的缺點。react

HTTP 輪詢

解決問題的第一個嘗試是按期輪詢服務器。 HTTP 長輪詢生命週期以下:git

  1. 客戶端發出請求並一直等待響應。
  2. 服務器推遲響應,直到發生更改、更新或超時。請求保持「掛起」,直到服務器有東西返回客戶端。
  3. 當服務器端有一些更改或更新時,它會將響應發送回客戶端。
  4. 客戶端發送新的長輪詢請求以偵聽下一組更改。

長輪詢中存在不少漏洞 —— 標頭開銷、延遲、超時、緩存等等。github

HTTP 流式傳輸

這種機制減小了網絡延遲的痛苦,由於初始請求無限期地保持打開狀態。即便在服務器推送數據以後,請求也永遠不會終止。 HTTP 流中的前三步生命週期方法與 HTTP 輪詢是相同的。web

可是,當響應被髮送回客戶端時,請求永遠不會終止,服務器保持鏈接打開狀態,並在發生更改時發送新的更新。json

服務器發送事件(SSE)

使用 SSE,服務器將數據推送到客戶端。聊天或遊戲應用不能徹底依賴 SSE。 SSE 的完美用例是相似 Facebook 的新聞 Feed:每當有新帖發佈時,服務器會將它們推送到時間線。 SSE 經過傳統 HTTP 發送,而且對打開的鏈接數有限制。

這些方法不只效率低下,維護它們的代碼也使開發人員感到厭倦。

WebSocket

WebSockets 旨在取代現有的雙向通訊技術。當涉及全雙工實時通訊時,上述現有方法既不可靠也不高效。

WebSockets 相似於 SSE,但在將消息從客戶端傳回服務器方面也很優秀。因爲數據是經過單個 TCP 套接字鏈接提供的,所以鏈接限制再也不是問題。


實戰教程

正如介紹中所提到的,WebSocket 協議只有兩個議程。讓咱們看看 WebSockets 如何實現這些議程。爲此我將分析一個 Node.js 服務器並將其鏈接到使用 React.js 構建的客戶端上。

議程1:WebSocket在服務器和客戶端之間創建握手

在服務器級別建立握手

咱們能夠用單個端口來分別提供 HTTP 服務和 WebSocket 服務。下面的代碼顯示了一個簡單的 HTTP 服務器的建立過程。一旦建立,咱們會將 WebSocket 服務器綁定到 HTTP 端口:

const webSocketsServerPort = 8000;
const webSocketServer = require('websocket').server;
const http = require('http');
// Spinning the http server and the websocket server.
const server = http.createServer();
server.listen(webSocketsServerPort);
const wsServer = new webSocketServer({
  httpServer: server
});
複製代碼

建立 WebSocket 服務器後,咱們須要在接收來自客戶端的請求時接受握手。我將全部鏈接的客戶端做爲對象保存在代碼中,並在收請從瀏覽器發來的求時使用惟一的用戶ID。

// I'm maintaining all active connections in this object
const clients = {};

// This code generates unique userid for everyuser.
const getUniqueID = () => {
  const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
  return s4() + s4() + '-' + s4();
};

wsServer.on('request', function(request) {
  var userID = getUniqueID();
  console.log((new Date()) + ' Recieved a new connection from origin ' + request.origin + '.');
  // You can rewrite this part of the code to accept only the requests from allowed origin
  const connection = request.accept(null, request.origin);
  clients[userID] = connection;
  console.log('connected: ' + userID + ' in ' + Object.getOwnPropertyNames(clients))
});
複製代碼

那麼,當接受鏈接時會發生什麼?

在發送常規 HTTP 請求以創建鏈接時,在請求頭中,客戶端發送 *Sec-WebSocket-Key*。服務器對此值進行編碼和散列,並添加預約義的 GUID。它迴應了服務器發送的握手中 *Sec-WebSocket-Accept*中生成的值。

一旦請求在服務器中被接受(在必要驗證以後),就完成了握手,其狀態代碼爲 101。若是在瀏覽器中看到除狀態碼 101 以外的任何內容,則意味着 WebSocket 升級失敗,而且將遵循正常的 HTTP 語義。

*Sec-WebSocket-Accept* 頭字段指示服務器是否願意接受鏈接。此外若是響應缺乏 *Upgrade* 頭字段,或者 *Upgrade* 不等於 websocket,則表示 WebSocket 鏈接失敗。

成功的服務器握手以下所示:

HTTP GET ws://127.0.0.1:8000/ 101 Switching Protocols
Connection: Upgrade
Sec-WebSocket-Accept: Nn/XHq0wK1oO5RTtriEWwR4F7Zw=
Upgrade: websocket
複製代碼

在客戶端級別建立握手

在客戶端,我使用與服務器中的相同 WebSocket 包來創建與服務器的鏈接(Web IDL 中的 WebSocket API 正在由W3C 進行標準化)。一旦服務器接受請求,咱們將會在瀏覽器控制檯上看到 WebSocket Client Connected

這是建立與服務器的鏈接的初始腳手架:

import React, { Component } from 'react';
import { w3cwebsocket as W3CWebSocket } from "websocket";

const client = new W3CWebSocket('ws://127.0.0.1:8000');

class App extends Component {
  componentWillMount() {
    client.onopen = () => {
      console.log('WebSocket Client Connected');
    };
    client.onmessage = (message) => {
      console.log(message);
    };
  }
  
  render() {
    return (
      <div> Practical Intro To WebSockets. </div>
    );
  }
}

export default App;
複製代碼

客戶端發送如下標頭來創建握手:

HTTP GET ws://127.0.0.1:8000/ 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: vISxbQhM64Vzcr/CD7WHnw==
Origin: http://localhost:3000
Sec-WebSocket-Version: 13
複製代碼

如今客戶端和服務器經過相互握手進行了鏈接,WebSocket 鏈接能夠在接收消息時傳輸消息,從而實現 WebSocket 協議的第二個議程。

議程2:實時信息傳輸

內容修改的實時流。

我將編寫一個基本的實時文檔編輯器,用戶能夠將它們鏈接在一塊兒並編輯文檔。我跟蹤了兩個事件:

  1. **用戶活動:**每次用戶加入或離開時,我都會將消息廣播給全部鏈接其餘的客戶端。
  2. **內容更改:**每次修改編輯器中的內容時,都會向全部鏈接的其餘客戶端廣播。

該協議容許咱們用二進制數據或 UTF-8 發送和接收消息(注意:傳輸和轉換 UTF-8 的開銷較小)。

只要咱們對套接字事件onopenoncloseonmessage有了充分的瞭解,理解和實現 WebSockets 就很是簡單。客戶端和服務器端的術語相同。

在客戶端發送和接收消息

在客戶端,當新用戶加入或內容更改時,咱們用 client.send 向服務器發消息,以將新信息提供給服務器。

/* When a user joins, I notify the server that a new user has joined to edit the document. */
logInUser = () => {
  const username = this.username.value;
  if (username.trim()) {
    const data = {
      username
    };
    this.setState({
      ...data
    }, () => {
      client.send(JSON.stringify({
        ...data,
        type: "userevent"
      }));
    });
  }
}

/* When content changes, we send the current content of the editor to the server. */
onEditorStateChange = (text) => {
 client.send(JSON.stringify({
   type: "contentchange",
   username: this.state.username,
   content: text
 }));
};
複製代碼

咱們跟蹤的事件是:用戶加入和內容更改。

從服務器接收消息很是簡單:

componentWillMount() {
  client.onopen = () => {
   console.log('WebSocket Client Connected');
  };
  client.onmessage = (message) => {
    const dataFromServer = JSON.parse(message.data);
    const stateToChange = {};
    if (dataFromServer.type === "userevent") {
      stateToChange.currentUsers = Object.values(dataFromServer.data.users);
    } else if (dataFromServer.type === "contentchange") {
      stateToChange.text = dataFromServer.data.editorContent || contentDefaultMessage;
    }
    stateToChange.userActivity = dataFromServer.data.userActivity;
    this.setState({
      ...stateToChange
    });
  };
}
複製代碼

在服務器端發送和偵聽消息

在服務器中,咱們只需捕獲傳入的消息並將其廣播到鏈接到 WebSocket 的全部客戶端。這是臭名昭着的 Socket.IO 和 WebSocket 之間的差別之一:當咱們使用 WebSockets 時,咱們須要手動將消息發送給全部客戶端。 Socket.IO 是一個成熟的庫,因此它本身來處理。

const sendMessage = (json) => {
  // We are sending the current data to all connected clients
  Object.keys(clients).map((client) => {
    clients[client].sendUTF(json);
  });
}

connection.on('message', function(message) {
    if (message.type === 'utf8') {
      const dataFromClient = JSON.parse(message.utf8Data);
      const json = { type: dataFromClient.type };
      if (dataFromClient.type === typesDef.USER_EVENT) {
        users[userID] = dataFromClient;
        userActivity.push(`${dataFromClient.username} joined to edit the document`);
        json.data = { users, userActivity };
      } else if (dataFromClient.type === typesDef.CONTENT_CHANGE) {
        editorContent = dataFromClient.content;
        json.data = { editorContent, userActivity };
      }
      sendMessage(JSON.stringify(json));
    }
  });
複製代碼

將消息廣播到全部鏈接的客戶端。

img

瀏覽器關閉後會發生什麼?

在這種狀況下,WebSocket調用 close 事件,它容許咱們編寫終止當前用戶鏈接的邏輯。在個人代碼中,當用戶離開文檔時,會向其他用戶廣播消息:

connection.on('close', function(connection) {
    console.log((new Date()) + " Peer " + userID + " disconnected.");
    const json = { type: typesDef.USER_EVENT };
    userActivity.push(`${users[userID].username} left the document`);
    json.data = { users, userActivity };
    delete clients[userID];
    delete users[userID];
    sendMessage(JSON.stringify(json));
  });
複製代碼

該應用程序的源代碼位於GitHub上的 repo 中。

結論

WebSockets 是在應用中實現實時功能的最有趣和最方便的方法之一。它爲咱們提供了可以充分利用全雙工通訊的靈活性。我強烈建議在嘗試使用 Socket.IO 和其餘可用庫以前先試試 WebSockets。

編碼快樂!😊

歡迎關注前端公衆號:前端先鋒,獲取前端工程化實用工具包。

相關文章
相關標籤/搜索