互动直播中的前端技术 -- 即时通信

本文做者:吴杰

前言

在疫情期间,上班族开启了远程办公,体验了各类远程办公软件。老师作起了主播,学生们感觉到了被钉钉支配的恐惧,歌手们开启了在线演唱会,许多综艺节目也变成了在线直播。在这全民互动直播的时期,咱们来聊聊互动直播中的即时通信技术在前端中的使用。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以前的通信复用,来达到减小链接数的目的。在兼容性要求不那么高的场景能够尝试一下。

小结

本文介绍了互动直播中的即时通信技术的在前端中应用,并分享了本身在工做开发中的一些经验,但愿对您有所帮助,欢迎探讨。

参考资料

本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们
相关文章
相关标签/搜索