前言
在疫情期間,上班族開啟了遠程辦公,體驗了各種遠程辦公軟件。老師做起了主播,學生們感受到了被釘釘支配的恐懼,歌手們開啟了在線演唱會,許多綜藝節目也變成了在線直播。在這全民互動直播的時期,我們來聊聊互動直播中的即時通訊技術在前端中的使用。
即時通訊技術
即時通訊(Instant Messaging,簡稱IM)是一個實時通信系統,允許兩人或多人使用網絡實時的傳遞文字消息、文件、語音與視頻交流。如何來實現呢,通常我們會使用服務器推送技術來實現。常見的有以下幾種實現方式。
輪詢(polling)
這是一種我們幾乎都用到過的的技術實現方案。客戶端和服務器之間會一直進行連接,每隔一段時間就詢問一次。前端通常采取setInterval或者setTimeout去不斷的請求服務器數據。
缺點:輪詢時間通常是死的,太長就不是很實時,太短增加服務器端的負擔。不斷的去請求沒有意義的更新的數據也是一種浪費服務器資源的做法。
長輪詢(long-polling)
客戶端發送一個請求到服務端,如果服務端沒有新的數據,就保持住這個連接直到有數據。一旦服務端有了數據(消息)給客戶端,它就使用這個連接發送數據給客戶端。接著連接關閉。
缺點:占較多的內存資源與請求數。
iframe流
iframe流就是在瀏覽器中動態載入一個iframe, 讓它的地址指向請求的服務器的指定地址(就是向服務器發送了一個http請求),然后在瀏覽器端創建一個處理數據的函數,在服務端通過iframe與瀏覽器的長連接定時輸出數據給客戶端,iframe頁面接收到這個數據就會將它解析成代碼并傳數據給父頁面從而達到即時通訊的目的。
缺點:兼容性與用戶體驗不好。服務器維護一個長連接會增加開銷。一些瀏覽器的的地址欄圖標會一直轉菊花。
Server-sent Events(sse)
sse與長輪詢機制類似,區別是每個連接不只發送一個消息。客戶端發送一個請求,服務端保持這個連接直到有新消息發送回客戶端,仍然保持著連接,這樣連接就可以消息的再次發送,由服務器單向發送給客戶端。
缺點:兼容性不好(IE,Edge不支持);服務器只能單向推送數據到客戶端。
WebSocket
html5 WebSocket規范定義了一種API,使Web頁面能夠使用WebSocket協議與遠程主機進行雙向通信。與輪詢和長輪詢相比,巨大減少了不必要的網絡流量和等待時間。
WebSocket屬于應用層協議。它基于TCP傳輸協議,并復用HTTP的握手通道。但不是基于HTTP協議的,只是在建立連接之前要借助一下HTTP,然后在第一次握手是升級協議為ws或者wss。

缺點:開發成本高,需要額外做重連保活。
在互動直播場景下,由于本身的實時性要求高,服務端與客戶端需要頻繁雙向通信,因此與它十分契合。
搭建自己的IM系統
上面簡單的概述了下即時通訊的實現技術,接下來我們就聊聊如何實現自己的IM系統。
從零開始搭建IM系統還是一件比較復雜與繁瑣的事情。自己搭建推薦基于 socket.io 來實現。socket.io對即時通訊的封裝已經很不錯了,是一個比較成熟的庫,對不同瀏覽器做了兼容,提供了各端的方案包括服務端,我們不用關心底層是用那種技術實現進行數據的通信,當然在現代瀏覽器種基本上是基于WebSocket來實現的。市面上也有不少IM云服務平臺,比如 云信 ,借助第三方的服務也可以快速集成。下面就介紹下前端怎么基于socket.io集成開發。
基礎的搭建
服務端集成socket.io(有JAVA版本的),服務端即成可以參考下 這里 ,客戶端使用socket.io-client集成。
參考socket.io官方api,訂閱生命周期與事件,通過訂閱的方式或來實現基礎功能。在回調函數執行解析包裝等邏輯,最終拋給上層業務使用。
import io from 'socket.io-client';
import EventEmitter from 'EventEmitter';
class Ws extends EventEmitter {
constructor (options) {
super();
//...
this.init();
}
init () {
const socket = this.link = io('wss://x.x.x.x');
socket.on('connect', this.onConnect.bind(this));
socket.on('message', this.onMessage.bind(this));
socket.on('disconnect', this.onDisconnect.bind.(this);
socket.on('someEvent', this.onSomeEvent.bind(this));
}
onMessage(msg) {
const data = this.parseData(msg);
// ...
this.$emit('message', data);
}
}
消息收發
與服務器或者其他客戶端進行消息通訊時通常會基于業務約定協議來封裝解析消息。由于都是異步行為,需要有唯一標識來處理消息回調。這里用自增seq來標記。
發送消息
class Ws extends EventEmitter {
seq = 0;
cmdTasksMap = {};
// ...
sendCmd(cmd, params) {
return new Promise((resolve, reject) => {
this.cmdTasksMap[this.seq] = {
resolve,
reject
};
const data = genPacket(cmd, params, this.seq++);
this.link.send({ data });
});
}
}
接受消息
class Ws extends EventEmitter {
// ...
onMessage(packet) {
const data = parsePacket(packet);
if (data.seq) {
const cmdTask = this.cmdTasksMap[data.seq];
if (cmdTask) {
if (data.body.code === 200) {
cmdTask.resolve(data.body);
} else {
cmdTask.reject(data.body);
}
delete this.cmdTasksMap[data.seq];
}
}
}
}
生產環境中優化
上文只介紹了基礎功能的簡單封裝,在生產環境中使用,還需要對考慮很多因素,尤其是在互動直播場景中,禮物展示,麥序(進行語音通話互動的順序),聊天,群聊等都強依賴長鏈接的穩定性,下面就介紹一些兜底與優化措施。
連接保持
為了穩定建立長鏈接與保持長鏈接。采用了以下幾個手段:
- 超時處理
- 心跳包
- 重連退避機制
超時處理
在實際使用中,并不一定每次發送消息都服務端都有響應,可能在客戶端已經出現異常了,我們與服務端的通訊方式都是一問一答。基于這一點,我們可以增加超時邏輯來判斷是否是發送成功。然后基于回調上層進行有友好提示,進入異常處理。接下來就進一步改造發送邏輯。
class Ws extends EventEmitter {
// ...
sendCmd(cmd, params) {
return new Promise((resolve, reject) => {
this.cmdTasksMap[this.seq] = {
resolve,
reject
};
// 加個定時器
this.timeMap[this.seq] = setTimeout(() => {
const err = new newTimeoutError(this.seq);
reject({ ...err });
}, CMDTIMEOUT);
const data = genPacket(cmd, params, this.seq++);
this.link.send({ data });
});
}
onMessage(packet) {
const data = parsePacket(packet);
if (data.seq) {
const cmdTask = this.cmdTasksMap[data.seq];
if (cmdTask) {
clearTimeout(this.timeMap[this.seq]);
delete this.timeMap[this.seq];
if (data.body.code === 200) {
cmdTask.resolve(data.body);
} else {
cmdTask.reject(data.body);
}
delete this.cmdTasksMap[data.seq];
}
}
}
}
心跳包
心跳包: 心跳包就是在客戶端和服務器間定時通知對方自己狀態的一個自己定義的命令字,按照一定的時間間隔發送,類似于心跳,所以叫做心跳包。
心跳包是檢查長鏈接存活的關鍵手段,在web端我們通過心跳包是否超時來判斷。TCP中已有 keepalive選項 ,為什么要在應用層加入心跳包機制?
- tcp keepalive檢查連接是否存活
- 應用keepalive檢測應用是否正常可響應
舉個栗子: 服務端死鎖,無法處理任何業務請求。但是操作系統仍然可以響應網絡層keepalive包。所以我們通常使用空內容的心跳包并設定合適的發送頻率與超時時間來作為連接的保持的判斷。
如果服務端只認心跳包作為連接存在判斷,那就在連接建立后定時發心跳就行。如果以收到包為判斷存活,那就在每次收到消息重置并起個定時器發送心跳包。
class Ws extends EventEmitter {
// ...
onMessage(packet) {
const data = parsePacket(packet);
if (data.seq) {
const cmdTask = this.cmdTasksMap[data.seq];
if (cmdTask) {
clearTimeout(this.timeMap[this.seq]);
if (data.body.code === 200) {
cmdTask.resolve(data.body);
} else {
cmdTask.reject(data.body);
}
delete this.cmdTasksMap[data.seq];
}
}
this.startHeartBeat();
}
startHeartBeat() {
if (this.heartBeatTimer) {
clearTimeout(this.heartBeatTimer);
this.heartBeatTimer = null;
}
this.heartBeatTimer = setTimeout(() => {
// 在sendCmd中指定heartbeat類型seq為0,讓業務包連續編號
this.sendCmd('heartbeat').then(() => {
// 發送成功了就不管
}).catch((e) => {
this.heartBeatError(e);
});
}, HEARTBEATINTERVAL);
}
}
重連退避機制
連不上了,重連,還連不上,重連,又連不上,重連。重連是一個保活的手段,但總不能一直重連吧,因此我們要用合理策去重連。
通常服務端會提供lbs(Location Based Services,LBS)接口,來提供最優節點,我們端上要做便是緩存這些地址并設定端上的重連退避機制。按級別次數通常會做以下處理。
- 重連(超時<X次)
- 換連接地址重連 (超時>=X次)
- 重新獲取連接地址(X<MAX)
- 上層處理(超過MAX)
在重連X次后選擇換地址,在一個地址失敗后,選擇重新去拿地址再去循環嘗試。具體的嘗試次數根據實際業務來定。當然在一次又一次失敗中做好異常上報,以便于分析解決問題。
接受消息優化
在高并發的場景下尤其是聊天室場景,我們要做一定的消息合并與緩沖,來避免過多的UI繪制與應用阻塞。
因此要約定好解析協議,服務端與客戶端都做消息合并,并設置消息緩沖。示例如下:
Fn.startMsgFlushTimer = function () {
this.msgFlushTimer = setTimeout(() => {
const msgs = this.msgBuffer.splice(0, BUFFERSIZE);
// 回調消息通知
this.onmsgs(msgs);
if (!this.msgBuffer.length) {
this.msgFlushTimer = null;
} else {
this.startMsgFlushTimer();
}
}, MSGBUFFERINTERVAL);
};
流量優化
持久化存儲
在單聊場景中每次都同步全量的會話,歷史消息等這是一個很大的代價。此外關閉web也是一種比較容易的操作(基本上就需要重新同步一次)。如果我們用增量的方式去同步就可以減少很多流量。實現增量同步自然想到了web存儲。
常用web存儲cookie,localStorage,sessionStorage不太能滿足我們持久化的場景,然而html5的indexedDB正常好滿足我們的需求。IndexedDB 內部采用對象倉庫(object store)存放數據。所有類型的數據都可以直接存入,包括JavaScript對象。indexedDB的api直接用可能會比較難受,可以使用 Dexie.js , db.js 這些二次封裝的庫來實現業務的數據層。
在滿足持久化存儲后, 我們便可以用時間戳,來進行增量同步,在收到消息通知時,存儲到web數據庫。上層操作獲取數據,優先從數據庫獲取數據,避免總是高頻率、高數據量的與服務器通訊。當然敏感性信息不要存在數據庫或者增加點破解難度,畢竟所有web本地存儲都是能看到的。此外注意下存儲大小還是有限制的, 每種瀏覽器可能不一樣 ,但是遠大于其他Web本地存儲了,只要該放云端的數據放云端(比如云消息),不會有太大問題。
在編碼實現上,由于處理消息通知都是異步操作,要維護一個隊列保證 入庫時序 。此外要做好 降級方案 。
減少連接數
在Web桌面端的互動直播場景,同一種頁面開啟了多個tab訪問應該是很常見的。業務上也會有多端互踢操作,但是對Web場景如果只能一個頁面能進行互動那肯定是不行的,一不小心就不知道切到哪個tab上去了。所以通常會設置一個多端在線的最大數,超過了就踢。因而一個瀏覽器建立7,8個長鏈接是一件很尋常的事情,對于服務端資源也是一種極大的浪費。
Web Worker可以為Web內容在后臺線程中運行腳本提供了一種簡單的方法,線程可以執行任務而不干擾用戶界面。并且可以將消息發送到創建它的JavaScript代碼, 通過將消息發布到該代碼指定的事件處理程序(反之亦然)。雖然Web Worker中不能使用DOM API,但是XHR,WebSocket這些通訊API并沒有限制(而且可以操作本地存儲)。因此我們可以通過SharedWorker API創建一個執行指定腳本來共享web worker來實現多個tab之前的通訊復用,來達到減少連接數的目的。在兼容性要求不那么高的場景可以嘗試一下。
小結
本文介紹了互動直播中的即時通訊技術的在前端中應用,并分享了自己在工作開發中的一些經驗,希望對您有所幫助,歡迎探討。
本文作者:吳杰