前言
我們的專案是基於 ThinkJS + Vue 開發的,最近實現了一個多端實時同步資料的功能,所以想寫一篇文章來介紹下如何在 ThinkJS 的專案中利用 WebSocket 實現多端的實時通訊。ThinkJS 是基於 Koa 2 開發的企業級 Node.js 服務端框架,文章中會從零開始實現一個簡單的聊天室,希望讀者們能有所收穫。
WebSocket
WebSocket 是 HTML5 中提出的一種協議。它的出現是為了解決客戶端和服務端的實時通訊問題。在 WebSocket 出現之前,如果想實現實時訊息傳遞一般有兩種方式:
- 客戶端通過輪詢不停的向服務端傳送請求,如果有新訊息客戶端進行更新。這種方式的缺點很明顯,客戶端需要不停向伺服器傳送請求,然而 HTTP 請求可能包含較長的頭部,其中真正有效的資料可能只是很小的一部分,顯然這樣會浪費很多頻寬資源
- HTTP 長連線,客戶端通過 HTTP 請求連線到服務端後, 底層的 TCP 連線不會馬上斷開,後續的資訊還是可以通過同一個連線來傳輸。這種方式有一個問題是每個連線會佔用服務端資源,在收到訊息後連線斷開,就需要重新傳送請求。如此迴圈往復。
可以看到,這兩種實現方式的本質還是客戶端向服務端“Pull”的過程,並沒有一個服務端主動“Push”到客戶端的方式,所有的方式都是依賴客戶端先發起請求。為了滿足兩方的實時通訊, WebSocket 應運而生。
WebSocket 協議
首先,WebSocket 是基於 HTTP 協議的,或者說借用了 HTTP 協議來完成連線的握手部分。其次,WebSocket 是一個持久化協議,相對於 HTTP 這種非持久的協議來說,一個 HTTP 請求在收到服務端回覆後會直接斷開連線,下次獲取訊息需要重新傳送 HTTP 請求,而 WebSocket 在連線成功後可以保持連線狀態。下圖應該能體現兩者的關係:
在發起 WebSocket 請求時需要先通過 HTTP 請求告訴服務端需求將協議升級為 WebSocket。
瀏覽器先傳送請求:
GET / HTTP/1.1
Host: localhost:8080
Origin: [url=http://127.0.0.1:3000]http://127.0.0.1:3000[/url]
Connection: Upgrade
Upgrade: WebSocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==
複製程式碼
服務端回應請求:
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: WebSocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=
複製程式碼
在請求頭中核心的部分是 Connection 和 Upgrade ,通過這兩個欄位服務端會將 HTTP 升級為 WebSocket 協議。服務端返回對應資訊後連線成功,客戶端和服務端就可以正常通訊了。
隨著新標準的推進,WebSocket 已經比較成熟了,並且各個主流瀏覽器對 WebSocket 的支援情況比較好(不相容低版本 IE,IE 10 以下)
Socket.io
Socket.io 是一個完全由 JavaScript 實現、基於 Node.js、支援 WebSocket 協議的用於實時通訊、跨平臺的開源框架。它包括了客戶端的 JavaScript 和伺服器端的 Node.js,並且有著很好的相容性,會根據瀏覽器的支援情況選擇不同的方式進行通訊,如上面介紹的輪詢和 HTTP 長連線。
簡易聊天室
對於 WebSocket 目前 ThinkJS 支援了 Socket.io 並對其進行了一些簡單的包裝,只需要進行一些簡單的配置就可
以使用 WebSocket 了。
服務端配置
stickyCluster
ThinkJS 預設採用了多程式模型,每次請求會根據策略輸送到不同的程式中執行,關於其多程式模型可以參考《細談 ThinkJS 多程式模型》。而 WebSocket 連線前需要使用 HTTP 請求來完成握手升級,多個請求需要保證命中相同的程式,才能保證握手成功。這個時候就需要開啟 StickyCluster 功能,使客戶端所有的請求命中同一程式。修改配置檔案 src/config/config.js
即可。
module.exports = {
stickyCluster: true,
// ...
}
複製程式碼
新增 WebSocket 配置
在 src/config/extend.js
引入 WebSocket:
const websocket = require('think-websocket');
module.exports = [
// ...
websocket(think.app),
];
複製程式碼
在 src/config/adapter.js
檔案中配置 WebSocket
const socketio = require('think-websocket-socket.io');
exports.websocket = {
type: 'socketio',
common: {
// common config
},
socketio: {
handle: socketio,
messages: {
open: '/websocket/open', //建立連線時處理對應到 websocket Controller 下的 open Action
close: '/websocket/close', // 關閉連線時處理的 Action
room: '/websocket/room' // room 事件處理的 Action
}
}
}
複製程式碼
配置中的 message
對應著事件的對映關係。比如上述的例子,客戶端觸發 room 事件,服務端需要在 websocket controller 下的 roomAction
中處理訊息。
新增 WebSocket 實現
建立處理訊息的 controller 檔案。上面的配置是 /websocket/xxx
,所以直接在專案根目錄 src/controller
下建立 websocket.js 檔案。
module.exports = class extends think.Controller {
// this.socket 為傳送訊息的客戶端對應的 socket 例項, this.io 為Socket.io 的一個例項
constructor(...arg) {
super(...arg);
this.io = this.ctx.req.io;
this.socket = this.ctx.req.websocket;
}
async openAction() {
this.socket.emit('open', 'websocket success')
}
closeAction() {
this.socket.disconnect(true);
}
};
複製程式碼
這時候服務端程式碼就已經配置完了。
客戶端配置
客戶端程式碼使用比較簡單,只需要引入 socket.io.js 就可以直接使用了。
<script src="https://lib.baomitu.com/socket.io/2.0.1/socket.io.js"></script>
複製程式碼
引入後在初始化程式碼建立 WebSocket 連線:
this.socket = io();
this.socket.on('open', data => {
console.log('open', data)
})
複製程式碼
這樣一個最簡單的 WebSocket 的 demo 就完成了,開啟頁面的時候會自動建立一個 WebSocket 連線,建立成功後服務端會觸發 open 事件,客戶端在監聽的 open 事件中會接收到服務端返回的 websocket success 字串。
接下來我們開始實現一個簡單的聊天室。
簡易聊天室的實現
從剛才的內容中我們知道每個 WebSocket 連線的建立會有一個 Socket 控制程式碼建立,對應到程式碼中的 this.socket
變數。所以本質上聊天室人與人的通訊可以轉換成每個人對應的 Socket 控制程式碼的通訊。我只需要找到這個人對應的 Socket 控制程式碼,就能實現給對方傳送訊息了。
簡單來實現我們可以設定一個全域性變數來儲存連線到服務端的 WebSocket 的一些資訊。在 src/bootstrap/global.js 中設定全域性變數:
global.$socketChat = {};
複製程式碼
然後在 src/bootstrap/worker.js 中引入global.js,使全域性變數生效。
require('./global');
複製程式碼
然後在服務端 controller 增加 roomAction
和 messageAction
, messageAction
用來接收客戶端使用者的聊天資訊,並將資訊傳送給所有的客戶端成員。 roomAction
用來接收客戶端進入/離開聊天室的資訊。這兩個的區別是聊天訊息是需要同步到所有的成員所以使用 this.io.emit
,聊天室訊息是同步到所有除當前客戶端外的所有成員所以使用this.socket.broadcast.emit
module.exports = class extends think.Controller {
constructor(...arg) {
super(...arg);
this.io = this.ctx.req.io;
this.socket = this.ctx.req.websocket;
global.$socketChat.io = this.io;
}
async messageAction() {
this.io.emit('message', {
nickname: this.wsData.nickname,
type: 'message',
message: this.wsData.message,
id: this.socket.id
})
}
async roomAction() {
global.$socketChat[this.socket.id] = {
nickname: this.wsData.nickname,
socket: this.socket
}
this.socket.broadcast.emit('room', {
nickname: this.wsData.nickname,
type: 'in',
id: this.socket.id
})
}
async closeAction() {
const closeSocket = global.$socketChat[this.socket.id];
const nickname = closeSocket && closeSocket.nickname;
this.socket.disconnect(true);
this.socket.removeAllListeners();
this.socket.broadcast.emit('room', {
nickname,
type: 'out',
id: this.socket.id
})
delete global.$socketChat[this.socket.id]
}
}
複製程式碼
客戶端通過監聽服務端 emit 的事件來處理資訊
this.socket.on('message', data => {
// 通過socket的id的對比,判斷訊息的傳送方
data.isMe = (data.id === this.socket.id);
this.chatData.push(data);
})
this.socket.on('room', (data) => {
this.chatData.push(data);
})
複製程式碼
通過 emit 服務端對應的 action 來傳送訊息
this.socket.emit('room', {
nickname: this.nickname
})
this.socket.emit('message', {
message: this.chatMsg,
nickname: this.nickname
})
複製程式碼
根據傳送/接收訊息的type判斷訊息型別
<div class="chat-box">
<div v-for="(item, index) in chatData" :key="index">
<p v-if="item.type == 'in'" class="enter-tip">{{item.nickname}}進入聊天室</p>
<p v-if="item.type == 'out'" class="enter-tip">{{item.nickname}}離開聊天室</p>
<p v-else-if="item.type == 'message'" :class="['message',{'me':item.isMe}]">
{{item.nickname}}:{{item.message}}
</p>
</div>
</div>
複製程式碼
至此一個簡單的聊天室就完成了。
多節點通訊問題
剛才我們說了通訊的本質其實是 Socket 控制程式碼查詢使用的過程,本質上我們是利用全域性變數儲存所有的 WebSocket 控制程式碼的方式解決了 WebSocket 連線查詢的問題。但是當我們的服務端擴容後,會出現多個伺服器都有 WebSocket 連線,這個時候跨節點的 WebSocket 連線查詢使用全域性變數的方式就無效了。此時我們就就需要換一種方式來實現跨伺服器的通訊同步,一般有以下幾種方式:
訊息佇列
傳送訊息不直接執行 emit
事件,而是將訊息傳送到訊息佇列中,然後所有的節點對這條訊息進行消費。拿到資料後檢視接收方的 WebSocket 連線是否在當前節點上,不在的話就忽略這條資料,在的話則執行傳送的動作。
節點通訊
通過外部儲存服務例如 Redis 充當之前的“全域性變數”的角色,所有的節點建立 WebSocket 連線後都向 Redis 中註冊一下,告訴大家有個叫 “A” 傢伙的連線在 “192.168.1.1” 這。當 B 要向 A 傳送訊息的時候它去 Redis 中查詢到 A 的連線所處的節點後,通知 192.168.1.1 這個節點 B 要向 A 傳送訊息,然後節點會執行傳送的動作。
基於 Redis 的節點通訊實現
Redis 的 pub/sub 是一種訊息通訊模式:傳送者(pub)傳送訊息,訂閱者(sub)接收訊息。WebSocket 的一個節點接收到訊息後,通過 Redis 釋出(pub),其他節點作為訂閱者(sub)接收訊息再進行後續處理。
這次我們將在聊天室的 demo 上實現節點通訊的功能。
首先,在 websocket controller 檔案中增加介面呼叫
const ip = require('ip');
const host = ip.address();
module.exports = class extends think.Controller {
async openAction() {
// 記錄當前 WebSocket 連線到的伺服器ip
await global.rediser.hset('-socket-chat', host, 1);
}
emit(action, data) {
if (action === 'message') {
this.io.emit(action, data)
} else {
this.socket.broadcast.emit(action, data);
}
this.crossSync(action, data)
}
async messageAction() {
const data = {
nickname: this.wsData.nickname,
type: 'message',
message: this.wsData.message,
id: this.socket.id
};
this.emit('message', data);
}
async closeAction() {
const connectSocketCount = Object.keys(this.io.sockets.connected).length;
this.crossSync(action, data);
if (connectSocketCount <= 0) {
await global.rediser.hdel('-socket-chat', host);
}
}
async crossSync(action, params) {
const ips = await global.rediser.hkeys('-socket-chat').filter(ip => ip !== host);
ips.forEach(ip => request({
method: 'POST',
uri: `http://${ip}/api/websocket/sync`,
form: {
action,
data: JSON.stringify(params)
},
json: true
});
);
}
}
複製程式碼
然後在 src/controller/api/websocket
實現通訊介面
const Base = require('../base');
module.exports = class extends Base {
async syncAction() {
const {action, data} = this.post();
const blackApi = ['room', 'message', 'close', 'open'];
if (!blackApi.includes(action)) return this.fail();
// 由於是跨伺服器介面,所以直接使用io.emit傳送給當前所有客戶端
const io = global.$socketChat.io;
io && io.emit(action, JSON.parse(data));
}
};
複製程式碼
這樣就實現了跨服務的通訊功能,當然這只是一個簡單的 demo ,但是基本原理是相同的。
socket.io-redis
第二種 Redis (sub/pub) 的方式,socket.io 提供了一種官方的庫 socket.io-redis 來實現。它在 Redis 的 pub/sub 功能上進行了封裝,讓開發者可以忽略 Redis 相關的部分,方便了開發者使用。使用時只需要傳入 Redis 的配置即可。
// Thinkjs socket.io-redis 配置
const redis = require('socket.io-redis');
exports.websocket = {
...
socketio: {
adapter: redis({ host: 'localhost', port: 6379 }),
message: {
...
}
}
}
// then controller websocket.js
this.io.emit('hi', 'all sockets');
複製程式碼
HTTP 與 WebSocket 通訊
如果想通過非 socket.io 程式向 socket.io 服務通訊,例如:HTTP,可以使用官方的 socket.io-emitter庫。使用方式如下:
var io = require('socket.io-emitter')({ host: '127.0.0.1', port: 6379 });
setInterval(function(){
io.emit('time', new Date);
}, 5000);
複製程式碼
後記
整個聊天室的程式碼已經上傳到github,大家可以直接下載體驗聊天室示例。