互動直播中的前端技術 -- 即時通信

本文做者:吳傑

前言

在疫情期間,上班族開啓了遠程辦公,體驗了各類遠程辦公軟件。老師作起了主播,學生們感覺到了被釘釘支配的恐懼,歌手們開啓了在線演唱會,許多綜藝節目也變成了在線直播。在這全民互動直播的時期,咱們來聊聊互動直播中的即時通信技術在前端中的使用。html

即時通信技術

即時通信(Instant Messaging,簡稱IM)是一個實時通訊系統,容許兩人或多人使用網絡實時的傳遞文字消息、文件、語音與視頻交流。如何來實現呢,一般咱們會使用服務器推送技術來實現。常見的有如下幾種實現方式。前端

輪詢(polling)

這是一種咱們幾乎都用到過的的技術實現方案。客戶端和服務器之間會一直進行鏈接,每隔一段時間就詢問一次。前端一般採起setInterval或者setTimeout去不斷的請求服務器數據。html5

優勢:實現簡單,適合處理的異步查詢業務。

缺點:輪詢時間一般是死的,太長就不是很實時,過短增長服務器端的負擔。不斷的去請求沒有意義的更新的數據也是一種浪費服務器資源的作法。java

長輪詢(long-polling)

客戶端發送一個請求到服務端,若是服務端沒有新的數據,就保持住這個鏈接直到有數據。一旦服務端有了數據(消息)給客戶端,它就使用這個鏈接發送數據給客戶端。接着鏈接關閉。git

優勢:對比輪詢作了優化,有較好的時效性。

缺點:佔較多的內存資源與請求數。github

iframe流

iframe流就是在瀏覽器中動態載入一個iframe, 讓它的地址指向請求的服務器的指定地址(就是向服務器發送了一個http請求),而後在瀏覽器端建立一個處理數據的函數,在服務端經過iframe與瀏覽器的長鏈接定時輸出數據給客戶端,iframe頁面接收到這個數據就會將它解析成代碼並傳數據給父頁面從而達到即時通信的目的。web

優勢:對比輪詢作了優化,有較好的時效性。

缺點:兼容性與用戶體驗很差。服務器維護一個長鏈接會增長開銷。一些瀏覽器的的地址欄圖標會一直轉菊花。數據庫

Server-sent Events(sse)

sse與長輪詢機制相似,區別是每一個鏈接不僅發送一個消息。客戶端發送一個請求,服務端保持這個鏈接直到有新消息發送回客戶端,仍然保持着鏈接,這樣鏈接就能夠消息的再次發送,由服務器單向發送給客戶端。api

優勢:HTML5 標準;實現較爲簡單;一個鏈接能夠發送多個數據。

缺點:兼容性很差(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.jsdb.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以前的通信複用,來達到減小鏈接數的目的。在兼容性要求不那麼高的場景能夠嘗試一下。

小結

本文介紹了互動直播中的即時通信技術的在前端中應用,並分享了本身在工做開發中的一些經驗,但願對您有所幫助,歡迎探討。

參考資料

本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們
相關文章
相關標籤/搜索