前端架構師破局技能,NodeJS 落地 WebSocket 實踐

楊成功發表於2021-12-07

本文從網路協議,技術背景,安全和生產應用的方向,詳細介紹 WebSocket 在 Node.js 中的落地實踐。

大綱預覽

本文介紹的內容包括以下方面:

  • 網路協議進化
  • Socket.IO?
  • ws 模組實現
  • Express 整合
  • WebSocket 例項
  • 訊息廣播
  • 安全與認證
  • BFF 應用

網路協議進化

HTTP 協議是前端最熟悉的網路通訊協議。我們通常的開啟網頁,請求介面,都屬於 HTTP 請求。

HTTP 請求的特點是:請求-> 響應。客戶端發起請求,服務端收到請求後進行響應,一次請求就完成了。也就是說,HTTP 請求必須由客戶端發起,服務端才能被動響應。

除此之外,發起 HTTP 請求之前,還需要通過三次握手建立 TCP 連線。HTTP/1.0 的特點是,每通訊一次,都要經歷 “三步走” 的過程 —— TCP 連線 -> HTTP 通訊 -> 斷開 TCP 連線

這樣的每一次請求都是獨立的,一次請求完成連線就會斷開。

HTTP1.1 對請求過程做了優化。TCP 連線建立之後,我們可以進行多次 HTTP 通訊,等到一個時間段無 HTTP 請求發起 TCP 才會斷開連線,這就是 HTTP/1.1 帶來的長連線技術。

但是即便如此,通訊方式依然是客戶端發起,服務端響應,這個根本邏輯不會變。

隨著應用互動的複雜,我們發現,有一些場景是必須要實時獲取服務端訊息的。

比如即時聊天,比如訊息推送,使用者並不會主動發起請求,但是當伺服器有了新訊息,客戶端需要立刻知道並且反饋給使用者。

HTTP 不支援服務端主動推送,但是這些場景又急需解決方案,於是早期出現了輪詢(polling)。輪詢是客戶端定時向伺服器發起請求,檢測服務端是否有更新,如果有則返回新資料。

這種輪詢方式雖然簡單粗暴,但很顯然有兩個弊端:

  1. 請求消耗太大。客戶端不斷請求,浪費流量和伺服器資源,給伺服器造成壓力。
  2. 不能保證及時。客戶端需要平衡及時性和效能,請求間隔必然不能太小,因此會有延遲。

隨著 HTML5 推出 WebSocket,即時通訊場景終於迎來了根本解決方案。WebSocket 是全雙工通訊協議,當客戶端與服務端建立連線之後,雙方可以互相傳送資料,這樣的話就不需要客戶端通過輪詢這種低效的方式獲取資料,服務端有新訊息直接推送給客戶端即可。

傳統 HTTP 連線方式如下:

## 普通連線
http://localhost:80/test
## 安全連線
https://localhost:80/test

WebSocket 是另一種協議,連線方式如下:

## 普通連線
ws://localhost:80/test
## 安全連線
wss://localhost:80/test

但是 WebSocket 也不是完全脫離 HTTP 的,若要建立 WebSocket 連線,則必須要客戶端主動發起一個建立連線的 HTTP 請求,連線成功之後客戶端與服務端才能進行雙向通訊。

Socket.IO?

提起用 Node.js 實現 WebSocket,大家一定會想到一個庫:Socket.IO

沒錯,Socket.IO 是目前 Node.js 在生產環境中開發 WebSocket 應用最好的選擇。它功能強大,高效能,低延遲,並且可以一步整合到 express 框架中。

但是也許你不清楚,Socket.IO 並不是一個純粹的 WebSocket 框架。它是將 Websocket 和輪詢機制以及其它的實時通訊方式封裝成了通用的介面,以實現更高效的雙向通訊。

嚴格來說,Websocket 只是 Socket.IO 的一部分。

也許你會問:既然 Socket.IO 在 WebSocket 的基礎上做了那麼多的優化,並且非常成熟,那為什麼還要搭一個原生 WebSocket 服務?

首先,Socket.IO 不能通過原生的 ws 協議連線。比如你在瀏覽器試圖通過 ws://localhost:8080/test-socket 這種方式連線 Socket.IO 服務,是連線不上的。因為 Socket.IO 的服務端必須通過 Socket.IO 的客戶端連線,不支援預設的 WebSocket 方式連線。

其次,Socket.IO 封裝程度非常高,使用它可能不利於你瞭解 WebSocket 建立連線的原理。

因此,我們本篇就用 Node.js 中基礎的 ws 模組,從頭開始實現一個原生的 WebSocket 服務,並且在前端用 ws 協議直接連線,體驗一把雙向通訊的感覺!

ws 模組實現

ws 是 Node.js 下一個簡單快速,並且定製程度極高的 WebSocket 實現方案,同時包含了服務端和客戶端。

ws 搭建起來的服務端,瀏覽器可以通過原生 WebSocket 建構函式直接連線,非常便捷。ws 客戶端則是模擬瀏覽器的 WebSocket 建構函式,用於連線其他 WebSocket 伺服器進行通訊。

注意一點:ws 只能在 Node.js 環境中使用,瀏覽器中不可用,瀏覽器請直接使用原生 WebSocket 建構函式

下面開始接入,第一步,安裝 ws:

$ npm install ws

安裝好後,我們先搭建一個 ws 服務端。

服務端

搭建 websocket 伺服器需要用 WebSocketServer 建構函式。

const { WebSocketServer } = require('ws')
const wss = new WebSocketServer({
  port: 8080
})
wss.on('connection', (ws, req) => {
  console.log('客戶端已連線:', req.socket.remoteAddress)
  ws.on('message', data => {
    console.log('收到客戶端傳送的訊息:', data)
  })
  ws.send('我是服務端') // 向當前客戶端傳送訊息
})

把這段程式碼寫進 ws-server.js 然後執行:

$ node ws-server.js

這樣一個監聽 8080 埠的 WebSocket 伺服器就已經跑起來了。

客戶端

上一步建好了 WebSocket 伺服器,現在我們在前端連線並監聽訊息:

var ws = new WebSocket('ws://localhost:8080')

ws.onopen = function(mevt) {
  console.log('客戶端已連線')
}
ws.onmessage = function(mevt) {
  console.log('客戶端收到訊息: ' + evt.data)
  ws.close()
}
ws.onclose = function(mevt) {
  console.log('連線關閉')
}

將程式碼寫入 wsc.html 然後用瀏覽器開啟,看到列印如下:

可以看到,瀏覽器連線成功後,收到服務端主動推送過來的訊息,然後瀏覽器可以主動關閉連線。

Node.js 環境下我們看 ws 模組如何發起連線:

const WebSocket = require('ws')
var ws = new WebSocket('ws://localhost:8080')

ws.on('open', () => {
  console.log('客戶端已連線')
})
ws.on('message', data => {
  console.log('客戶端收到訊息: ' + data)
  ws.close()
})
ws.on('close', () => {
  console.log('連線關閉')
})

程式碼與瀏覽器的邏輯一摸一樣,只是寫法稍有些不同,注意區別。

需要特殊說明的一點,瀏覽器端監聽 message 事件的回撥函式,引數是一個 MessageEvent 的例項物件,服務端發來的實際資料需要通過 mevt.data 獲取。

而在 ws 客戶端,這個引數就是服務端的實際資料,直接獲取即可。

Express 整合

ws 模組一般不會單獨使用,更優的方案是整合到現有的框架中。這節我們將 ws 模組整合到 Express 框架。

整合到 Express 框架的優點是,我們不需要單獨監聽一個埠,使用框架啟動的埠即可,並且我們還可以指定訪問到某個路由,才發起 WebSocket 連線。

幸運的是這一切不需要手動實現,express-ws 模組已經幫我們做好了大部分的整合工作。

首先安裝,然後在入口檔案引入:

var expressWs = require('express-ws')(app)

和 Express 的 Router 一樣,express-ws 也支援註冊全域性路由和區域性路由。

先看全域性路由,通過 [host]/test-ws 連線:

app.ws('/test-ws', (ws, req) => {
  ws.on('message', msg => {
    ws.send(msg)
  })
})

區域性路由則是註冊在一個路由組下面的子路由。配置一個名為 websocket 的路由組並指向 websocket.js 檔案,程式碼如下:

// websocket.js
var router = express.Router()

router.ws('/test-ws', (ws, req) => {
  ws.on('message', msg => {
    ws.send(msg)
  })
})

module.exports = router

連線 [host]/websocket/test-ws 就可以訪問到這個子路由。

路由組的作用是定義一個 websocket 連線組,不同需求連線這個組下的不同子路由。比如可以將 單聊群聊 設定為兩個子路由,分別處理各自的連線通訊邏輯。

完整程式碼如下:

var express = require('express')
var app = express()
var wsServer = require('express-ws')(app)
var webSocket = require('./websocket.js')

app.ws('/test-ws', (ws, req) => {
  ws.on('message', msg => {
    ws.send(msg)
  })
})

app.use('/websocket', webSocket)

app.listen(3000)

實際開發中獲取常用資訊的小方法:

// 客戶端的IP地址
req.socket.remoteAddress
// 連線引數
req.query

WebSocket 例項

WebSocket 例項是指客戶端連線物件,以及服務端連線的第一個引數。

var ws = new WebSocket('ws://localhost:8080')
app.ws('/test-ws', (ws, req) => {}

程式碼中的 ws 就是 WebSocket 例項,表示建立的連線。

瀏覽器

瀏覽器的 ws 物件中包含的資訊如下:

{
  binaryType: 'blob'
  bufferedAmount: 0
  extensions: ''
  onclose: null
  onerror: null
  onmessage: null
  onopen: null
  protocol: ''
  readyState: 3
  url: 'ws://localhost:8080/'
}

首先非常關鍵的是四個監聽屬性,用於定義函式:

  • onopen:連線建立後的函式
  • onmessage:收到服務端推送訊息的函式
  • onclose:連線關閉的函式
  • onerror:連線異常的函式

其中最常用的是 onmessage 屬性,賦值為一個函式來監聽服務端訊息:

ws.onmessage = mevt => {
  console.log('訊息:', mevt.data)
}

還有一個關鍵屬性是 readyState,表示連線狀態,值為一個數字。並且每個值都可以用常量表示,對應關係和含義如下:

  • 0: 常量 WebSocket.CONNECTING,表示正在連線
  • 1: 常量 WebSocket.OPEN,表示已連線
  • 2: 常量 WebSocket.CLOSING,表示正在關閉
  • 3: 常量 WebSocket.CLOSED,表示已關閉

當然最重要的還有 send 方法用於傳送資訊,向服務端傳送資料:

ws.send('要傳送的資訊')

服務端

服務端的 ws 物件表示當前發起連線的一個客戶端,基本屬性與瀏覽器大致相同。

比如上面客戶端的四個監聽屬性,readyState 屬性,以及 send 方法都是一致的。不過因為服務端是 Node.js 實現,因此會有更豐富的支援。

比如下面兩種監聽事件的寫法效果是一樣的:

// Node.js 環境
ws.onmessage = str => {
  console.log('訊息:', str)
}
ws.on('message', str => {
  console.log('訊息:', mevt.data)
})

詳細的屬性和介紹可以查閱官方文件

訊息廣播

WebSocket 伺服器不會只有一個客戶端連線,訊息廣播的意思就是把資訊發給所有已連線的客戶端,像一個大喇叭一樣,所有人都聽得到,經典場景就是熱點推送。

那麼廣播之前,就必須要解決一個問題,如何獲取當前已連線(線上)的客戶端

其實 ws 模組提供了快捷的獲取方法:

var wss = new WebSocketServer({ port: 8080 })
// 獲取所有已連線客戶端
wss.clients

方便吧。再看 express-ws 怎麼獲取:

var wsServer = expressWebSocket(app)
var wss = wsServer.getWss()
// 獲取所有已連線客戶端
wss.clients

拿到 wss.clients 後,我們看看它到底是什麼樣子。經過列印,發現它的資料結構比想象到還要簡單,就是由所有線上客戶端的 WebSocket 例項組成的一個 Set 集合。

那麼,獲取當前線上客戶端的數量:

wss.clients.size

簡單粗暴的實現廣播:

wss.clients.forEach(client => {
  if (client.readyState === 1) {
    client.send('廣播資料')
  }
})

這是非常簡單,基礎的實現方式。試想一下如果此刻線上客戶有 10000 個,那麼這個迴圈多半會卡死吧。因此才會有像 socket.io 這樣的庫,對基礎功能做了大量優化和封裝,提高併發效能。

上面的廣播屬於全域性廣播,就是將訊息發給所有人。然而還有另一種場景,比如一個 5 人的群聊小組聊天,這時的廣播只是給這 5 人小團體發訊息,因此這也叫 區域性廣播

區域性廣播的實現要複雜一些,一般會揉合具體的業務場景。這就需要我們在客戶端連線時,對客戶端資料做持久化處理了。比如用 Redis 儲存線上客戶端的狀態和資料,這樣檢索分類更快,效率更高。

區域性廣播實現,那一對一私聊就更容易了。找到兩個客戶端對應的 WebSocket 例項互發訊息就行。

安全與認證

前面搭建好的 WebSocket 伺服器,預設任何客戶端都可以連線,這在生產環境肯定是不行的。我們要對 WebSocket 伺服器做安全保障,主要是從兩個方面入手:

  1. Token 連線認證
  2. wss 支援

下面說一說我的實現思路。

Token 連線認證

HTTP 請求介面我們一般會做 JWT 認證,在請求頭中帶一個指定 Header,將一個 token 字串傳過去,後端會拿這個 token 做校驗,校驗失敗則返回 401 錯誤阻止請求。

我們上面說過,WebSocket 建立連線的第一步是客戶端發起一個 HTTP 的連線請求,那麼我們在這個 HTTP 請求上做驗證,如果驗證失敗,則中段 WebSocket 的連線建立,不就可以了?

順著這個思路,我們來改造一下服務端程式碼。

因為要在 HTTP 層做校驗,所以用 http 模組建立伺服器,關掉 WebSocket 服務的埠。

var server = http.createServer()
var wss = new WebSocketServer({ noServer: true })

server.listen(8080)

當客戶端通過 ws:// 連線服務端時,服務端會進行協議升級,也就是將 http 協議升級成 websocket 協議,此時會觸發 upgrade 事件:

server.on('upgrade', (request, socket) => {
  // 用 request 獲取引數做驗證
  // 1. 驗證不通過判斷
  if ('驗證失敗') {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
    socket.destroy()
    return
  }
  // 2. 驗證通過,繼續建立連線
  wss.handleUpgrade(request, socket, _, ws => {
    wss.emit('connection', ws, request)
  })
})

// 3. 監聽連線
wss.on('connection', (ws, request) => {
  console.log('客戶端已連線')
  ws.send('服務端資訊')
})

這樣服務端認證新增完畢,具體的認證方法結合客戶端的傳參方式來定。

WebSocket 客戶端連線不支援自定義 Header,因此不能用 JWT 的方案,可用方案有兩種:

  • Basic Auth
  • Quary 傳參

Basic Auth 認證簡單說就是賬號+密碼認證,而且賬號密碼是帶在 URL 裡的。

假設我有賬號是 ruims,密碼是 123456,那麼客戶端連線是這樣:

var ws = new WebSocket('ws://ruims:123456@localhost:8080')

那麼服務端就會收到這樣一個請求頭:

wss.on('connection', (ws, req) => {
  if(req.headers['authorization']) {
    let auth = req.headers['authorization']
    console.log(auth)
    // 列印的值:Basic cnVpbXM6MTIzNDU2
  }
}

其中 cnVpbXM6MTIzNDU2 就是 ruims:123456 的 base64 編碼,服務端可以獲取到這個編碼來做認證。

Quary 傳參比較簡單,就是普通的 URL 傳參,可以帶一個短一點的加密字串過去,服務端獲取到該字串然後做認證:

var ws = new WebSocket('ws://localhost:8080?token=cnVpbXM6MTIzNDU2')

服務端獲取引數:

wss.on('connection', (ws, req) => {
  console.log(req.query.token)
}

wss 支援

WebSocket 客戶端使用 ws:// 協議連線,那 wss 是什麼意思?

其實非常簡單,和 https 原理一摸一樣。

https 表示安全的 http 協議,組成是 HTTP + SSL

wss 則表示安全的 ws 協議,組成是 WS + SSL

那為什麼一定要用 wss 呢?除了安全性,還有一個關鍵原因是:如果你的 web 應用是 https 協議,你在當前應用中使用 WebSocket 就必須是 wss 協議,否則瀏覽器拒絕連線。

配置 wss 直接在 https 配置中加一個 location 即可,直接上 nginx 配置:

location /websocket {
  proxy_pass http://127.0.0.1:8080;
  proxy_redirect off;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection upgrade;
}

然後客戶端連線就變成了這樣:

var ws = new WebSocket('wss://[host]/websocket')

BFF 應用

BFF 或許你聽說過,全稱是 Backend For Frontend,意思是為前端服務的後端,在實際應用架構中屬於前端和後端的一個 中間層

這個中間層一般是由 Node.js 實現,那麼它有什麼作用呢?

眾所周知,現在後端的主流架構是微服務,微服務情況下 API 會劃分的非常細,商品服務就是商品服務,通知服務就是通知服務。當你想在商品上架時給使用者發一個通知,可能至少需要調兩個介面。

這樣的話對前端其實是不友好的,於是後來出現了 BFF 中間層,相當於一個後端請求的中間代理站,前端可以直接請求 BFF 的介面,然後 BFF 再向後端介面請求,將需要的資料組合起來,一次返回前端。

那我們在上面講的一大堆 WebSocket 的知識,在 BFF 層如何應用呢?

我想到的應用場景至少有 4 個:

  1. 檢視當前線上人數,線上使用者資訊
  2. 登入新裝置,其他裝置退出登入
  3. 檢測網路連線/斷開
  4. 站內訊息,小圓點提示

這些功能以前是在後端實現的,並且會與其他業務功能耦合。現在有了 BFF,那麼 WebSocket 完全可以在這一層實現,讓後端可以專注核心資料邏輯。

由此可見,掌握了 WebSocket 在 Node.js 中的實踐應用,作為前端的我們可以破除內卷,在另一個領域繼續發揮價值,豈不美哉?

原始碼+答疑

本文所有的程式碼都是經過我親自實踐,為了便於小夥伴們查閱和試驗,我建了一個 GitHub 倉庫專門存放本文的完整原始碼,以及之後文章的完整原始碼。

倉庫地址在這裡:楊成功的部落格原始碼

歡迎大家查閱和試驗,如果碰到疑惑的地方,歡迎加我微信 ruidoc 諮詢,以及所有有關 WebSocket 實踐過程中的心得想法問題都歡迎和我交流~

相關文章