基于 WebRTC 实如今线编程面试工具 | 掘金技术征文

WebRTC 是一种点对点的实时通信技术,本文将基于这一技术实现一个实时的在线编程面试工具,让远程面试时双方不只能够音视频通话,面试官还能实时看到面试者的编程状况。javascript

效果就像这样: java

preview

Agora SDK 是声网提供的一套实时通讯解决方案,其中也包含了对 WebRTC 的封装,咱们将基于它开发 WebRTC 相关的功能,以提供更接近生产级别的实时通讯体验。git

此工具完整源代码存放于此仓库中,能够配合文章阅读其源码。github

需求

这个在线编程面试工具须要解决两部分的需求:web

  1. 面试官和面试者能够实时音视频通话进行交流。
  2. 面试者可以使用一个在线代码编辑器进行答题,面试官可以实时的看到面试者编写的代码。编辑器最好能有高亮、代码补全的功能方便面试者发挥。

设计思路

经过了解 Agora SDK 提供的功能,发现有两种 SDK 能够用于实现咱们的需求:面试

  • Video SDK,提供可靠的实时音视频通话服务,能够用于面试官和面试者沟通交流。
  • Signaling SDK,提供稳定的消息通道,能够用于将面试者的编写过程对应的数据传递给面试官。

音视频部分 Video SDK 已经提供了渲染相关的实现,能够将视频输出到指定的 DOM 节点中,基本开箱即用。而代码编辑器及其数据传输则须要必定的开发。编程

通过一些对比和挑选,最终选择使用 VScode 的编辑器部分 monaco-editor 做为内置的代码编辑器,再使用以前开源的 Web 录制和回放库 rrweb 记录 monaco-editor 中的操做,将数据经过 Signaling SDK 传输至面试官一侧,一样经过 rrweb 进行实时的回放,达到代码同步的效果。浏览器

注意,本工具只是一个概念验证性质的项目,仅供讨论。优化程度还不足以应用在生产中,设计自己也有很大的改进空间,例如依赖完整的 VScode 提供代码执行、debug 等功能,实现一个更接近于 live share 的方案。 网络

封装 SDK

Agora SDK 的 API 自己比较清晰易懂,文档也足够完善,可是 API 大可能是异步,而且以回调的形式提供。数据结构

以视频功能为例,完成初始化、加入频道、建立流、发布、订阅等一系列准备动做以后可能已经嵌套了四五层回调。因此我先对用到的 API 进行了简单的封装,使其提供 Promise 风格的接口,能够在使用时经过 async/await 保持更清晰的代码结构以及更好的控制能力。

以初始化为例,SDK 的 API 使用方式是:

client.init(appId, function () {
  console.log("AgoraRTC client initialized");
}, function (err) {
  console.log("AgoraRTC client init failed", err);
});
复制代码

咱们能够用这种方式将其转化为 Promise:

const init = appId =>
  new Promise((resolve, reject) => {
    client.init(appId, () => resolve(), err => reject(err));
  });
复制代码

将全部 API 这样封装后,咱们的基本流程代码也就更加简单清晰:

async function main() {
  try {
    await rtc.init(APP_ID);
    const uid = await rtc.join(null, CHANNEL_ID, ACCOUNT);
    const stream = rtc.createStream();
    await rtc.initStream(stream);
    await rtc.subscribe(...);
    await rtc.publish(...);
  } catch (error) {
    console.error(error);
  }
}
复制代码

以上对 SDK 的异步封装能够参考此文件

音视频通话

音视频通话的功能主要参照 quick start guide 实现,步骤能够概括为:

  1. 基于 APP ID 初始化一个客户端。
  2. 加入一个 channel,每一个 channel 有本身的惟一 id,channel 里的用户能够订阅到同 channel 里其它用户发布的视频音频流。在咱们的工具中,使用 url query 存放一个 channel id,例如 ?id=abc123,面试双方打开的 query 一致就能保证加入到同一个 channel 中。
  3. 建立并初始化本地的音视频流(视频内容为使用者本人),并将视频初始化到 DOM 中。在工具中咱们会同时看到本身和对方的视频,此步骤中渲染的为本身的视频。
  4. 发布本身的音视频流。
  5. 订阅对方发布的音视频流,接收到对方音视频流后渲染到 DOM 中。

在实际实现的过程当中,因为咱们对 SDK 进行了 Promise 的封装,因此第 4 和 第 5 步针对面试双方作了顺序上的调整:

  1. 面试官订阅对方的流。
  2. 面试者发布本身的流,同时订阅对方的流。
  3. 面试官订阅成功以后,才发布本身的流,此时对方已处于订阅状态,必定可以成功接收到这一发布信息。

这主要是为了不发布时对方还未订阅,致使最终没能创建链接的问题。

实时编程

在设计思路中咱们已经提到将使用 monaco-editor 做为在线编辑器,并用 rrweb 记录编辑器中的操做。两个工具的 API 都很是易用,在面试者的页面中,经过十几行初始化代码就完成了集成:

import * as monaco from "monaco-editor/esm/vs/editor/editor.main.js";
import { record } from "rrweb";

self.MonacoEnvironment = {
  getWorkerUrl: function(moduleId, label) {
    // get worker urls
  }
};
monaco.editor.create(document.body, {
  value: ["function x() {", '\tconsole.log("Hello world!");', "}"].join("\n"),
  language: "javascript"
});

record({
  emit(event) {
    parent.postMessage({ event }, parent.origin);
  },
  inlineStylesheet: false
});
复制代码

在实现时,咱们将编辑器以 iframe 的形式嵌套在面试者页面中,rrweb 录制到操做记录时会经过 parent.postMessage 的方式将数据传递给主页面,交由 Signaling SDK 传输。

但在实际使用 Signaling SDK 时,咱们遇到了两个比较典型的问题:

  1. 传输数据有体积限制,每条消息可见字符大小不能超过 8 KB。
  2. 因为 rrweb 的录制是 log-structured 的数据结构,因此须要在每一个操做的数据大小不1、传输速度不一样的状况下严格保序。

数据切分

解决数据体积限制的一个思路是将数据切分为多个 chunk,并在每一个 chunk 中标识这是一个不完整的数据记录,须要拼接后再使用。

对应的实现以下:(此处使用了一个较为粗糙的方式进行标识,实际上还能够记录更多 meta 信息提升识别的准确性)

// 将操做数据转化为字符串
const eventStr = JSON.stringify(e.data.event);

export const CHUNK_START = "_0_";
export const CHUNK_SIZE = 8 * 1024 - CHUNK_START.length;
export const CHUNK_REG = new RegExp(`.{1,${CHUNK_SIZE}}`, "g");

const chunks = [];
if (eventStr.length > CHUNK_SIZE) {
  for (const chunk of eventStr.match(CHUNK_REG)) {
    chunks.push(CHUNK_START + chunk);
  }
}
复制代码

在面试官页面接收到 Signaling SDK 传入的数据时,就能够根据数据的头部是否有 CHUNK_START 的特殊标识来判断当前是一个完整数据仍是一个须要拼接的数据:

let largeMessage = "";
on("messageInstantReceive", (messageAccount, uid, message) => {
  const events = [];
  if (message.startsWith(CHUNK_START)) {
    largeMessage += message.slice(CHUNK_START.length, message.length);
  } else {
    if (largeMessage) {
      // reset chunks
      events.push(JSON.parse(largeMessage));
      largeMessage = "";
    }
    events.push(JSON.parse(message));
  }
});
复制代码

保证时序

上文已经提到,因为 rrweb 的实现下传输的数据可能为较大的全量快照,也可能为较小的单次 Oplog,因此在网络传输速度的影响下,若是不加以控制,有可能会出现较晚发生的操做先传输完成的状况,致使回放异常。因此咱们须要自行实现传输数据保序。

Signaling SDK 提供的发送数据 API messageInstantSend 提供了第三个参数 callback,当发送成功时调用。但实际测试时 callback 触发并不保证接收端已经下载完成,因此咱们仍需自行实现包含下载在内的保序。如个人理解或测试有误,请指正。

一种较为简单的实现是在面试者这一侧增长一个消息队列,当 rrweb 录制到新的操做时先将数据放入队列中。

同时,在面试官一侧准备好接受数据时,先向对方发出一个 START 信号,面试者一侧收到信号后从消息队列中取出第一条数据发送。此后,面试官一侧每收到一条数据就回复一个 ACK 信号,面试者一侧收到此信号后才继续从队列中取出消息发送,就能够保证面试官一侧接收到的数据都是严格保序的。

对应的示例以下:

on("messageInstantReceive", async (messageAccount, uid, message) => {
  if (message === START) {
    // 发送第一条数据
    await signal.messageInstantSend(interviewerAccount, eventQueue.dequeue());
  }
  if (message === ACK && eventQueue.length > 0) {
    await signal.messageInstantSend(interviewerAccount, eventQueue.dequeue());

  }
});
复制代码

更完整的实际实现能够参考此文件

优化

上述基于接收端 ACK 的保序方式也有比较明显的缺点:因为 Signaling SDK 自己基于 TCP 实现,这样的已读确认机制产生的额外通讯会形成较大的时延,致使面试官一侧观看回放时的实时性受到影响。

一些可行的优化思路包括:

  1. 再也不严格的限制数据接收的时序,而是在数据的 meta 区域中记录编号索引,接收端再发现两次接收的数据之间有“空隙”时选择不当即回放,而是等待数据传输完成补全后从新排序再回放。这样就无需传递 ACK 信号,减小一轮网络往返的延迟。
  2. 发送端队列里有超过一条记录时,尝试将多条记录在不超过体积限制的状况下拼接成一条,经过一次数据传输批量发送到对端。这样可以减小一些数据传输创建链接时的开销,尤为是小数据块数量较多时优化效果会比较明显。

相信在增长以上优化以后,咱们的在线编程面试工具会更具实用性。

总结

随着 Web API 的不断进化以及愈来愈多成熟工具、服务的出现,开发者能够基于它们快速地开发出各类实用的工具、产品。以本文中的项目为例,因为使用了 Agora SDK、monaco-editor 和 rrweb 三个工具/服务,用很是少的代码量就完成了功能的可行性验证。

当 VScode remote/browser 相关的功能更为成熟时,编辑器部分的功能还会被进一步强化,可能就能够造成一个实际可用的产品。因此咱们有理由相信当浏览器提供的 API 更增强大、性能更好时,会诞生更多以浏览器为客户端的服务,而 WebRTC 提供的实时通讯会是颇有价值一环。

Agora SDK 使用体验征文大赛 | 掘金技术征文,征文活动正在进行中

相关文章
相关标签/搜索