WebRTC 是一种点对点的实时通讯技术,本文将基于这一技术实现一个实时的在线编程面试工具,让远程面试时双方不仅可以音视频通话,面试官还能实时看到面试者的编程情况。
效果就像这样:
Agora SDK 是声网提供的一套实时通信解决方案,其中也包含了对 WebRTC 的封装,我们将基于它开发 WebRTC 相关的功能,以提供更接近生产级别的实时通信体验。
此工具完整源代码存放于此仓库中,可以配合文章阅读其源码。
需求
这个在线编程面试工具需要解决两部分的需求:
- 面试官和面试者可以实时音视频通话进行交流。
- 面试者能够使用一个在线代码编辑器进行答题,面试官能够实时的看到面试者编写的代码。编辑器最好能有高亮、代码补全的功能方便面试者发挥。
设计思路
通过了解 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 实现,步骤可以归纳为:
- 基于 APP ID 初始化一个客户端。
- 加入一个 channel,每个 channel 有自己的唯一 id,channel 里的用户可以订阅到同 channel 里其它用户发布的视频音频流。在我们的工具中,使用 url query 存放一个 channel id,例如
?id=abc123
,面试双方打开的 query 一致就能保证加入到同一个 channel 中。 - 创建并初始化本地的音视频流(视频内容为使用者本人),并将视频初始化到 DOM 中。在工具中我们会同时看到自己和对方的视频,此步骤中渲染的为自己的视频。
- 发布自己的音视频流。
- 订阅对方发布的音视频流,接收到对方音视频流后渲染到 DOM 中。
在实际实现的过程中,由于我们对 SDK 进行了 Promise 的封装,所以第 4 和 第 5 步针对面试双方做了顺序上的调整:
- 面试官订阅对方的流。
- 面试者发布自己的流,同时订阅对方的流。
- 面试官订阅成功之后,才发布自己的流,此时对方已处于订阅状态,一定能够成功接收到这一发布信息。
这主要是为了避免发布时对方还未订阅,导致最终没能建立连接的问题。
实时编程
在设计思路中我们已经提到将使用 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 时,我们遇到了两个比较典型的问题:
- 传输数据有体积限制,每条消息可见字符大小不能超过 8 KB。
- 由于 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 实现,这样的已读确认机制产生的额外通信会造成较大的时延,导致面试官一侧观看回放时的实时性受到影响。
一些可行的优化思路包括:
- 不再严格的限制数据接收的时序,而是在数据的 meta 区域中记录编号索引,接收端再发现两次接收的数据之间有“空隙”时选择不立即回放,而是等待数据传输完成补全后重新排序再回放。这样就无需传递 ACK 信号,减少一轮网络往返的延迟。
- 发送端队列里有超过一条记录时,尝试将多条记录在不超过体积限制的情况下拼接成一条,通过一次数据传输批量发送到对端。这样能够减少一些数据传输建立连接时的开销,尤其是小数据块数量较多时优化效果会比较明显。
相信在增加以上优化之后,我们的在线编程面试工具会更具实用性。
总结
随着 Web API 的不断进化以及越来越多成熟工具、服务的出现,开发者可以基于它们快速地开发出各种实用的工具、产品。以本文中的项目为例,由于使用了 Agora SDK、monaco-editor 和 rrweb 三个工具/服务,用非常少的代码量就完成了功能的可行性验证。
当 VScode remote/browser 相关的功能更为成熟时,编辑器部分的功能还会被进一步强化,可能就可以形成一个实际可用的产品。所以我们有理由相信当浏览器提供的 API 更加强大、性能更好时,会诞生更多以浏览器为客户端的服务,而 WebRTC 提供的实时通信会是很有价值一环。
Agora SDK 使用体验征文大赛 | 掘金技术征文,征文活动正在进行中