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

3,251 阅读9分钟

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

效果就像这样:

preview

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

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

需求

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

  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 的数据结构,所以需要在每个操作的数据大小不一、传输速度不同的情况下严格保序。

数据切分

解决数据体积限制的一个思路是将数据切分为多个 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 使用体验征文大赛 | 掘金技术征文,征文活动正在进行中