WebRTC 是一種點對點的實時通信技術,本文將基於這一技術實現一個實時的在線編程面試工具,讓遠程面試時雙方不只能夠音視頻通話,面試官還能實時看到面試者的編程狀況。javascript
效果就像這樣: java
Agora SDK 是聲網提供的一套實時通訊解決方案,其中也包含了對 WebRTC 的封裝,咱們將基於它開發 WebRTC 相關的功能,以提供更接近生產級別的實時通訊體驗。git
此工具完整源代碼存放於此倉庫中,能夠配合文章閱讀其源碼。github
這個在線編程面試工具須要解決兩部分的需求:web
經過了解 Agora SDK 提供的功能,發現有兩種 SDK 能夠用於實現咱們的需求:面試
音視頻部分 Video SDK 已經提供了渲染相關的實現,能夠將視頻輸出到指定的 DOM 節點中,基本開箱即用。而代碼編輯器及其數據傳輸則須要必定的開發。編程
通過一些對比和挑選,最終選擇使用 VScode 的編輯器部分 monaco-editor 做爲內置的代碼編輯器,再使用以前開源的 Web 錄製和回放庫 rrweb 記錄 monaco-editor 中的操做,將數據經過 Signaling SDK 傳輸至面試官一側,一樣經過 rrweb 進行實時的回放,達到代碼同步的效果。瀏覽器
注意,本工具只是一個概念驗證性質的項目,僅供討論。優化程度還不足以應用在生產中,設計自己也有很大的改進空間,例如依賴完整的 VScode 提供代碼執行、debug 等功能,實現一個更接近於 live share 的方案。 網絡
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 實現,步驟能夠概括爲:
?id=abc123
,面試雙方打開的 query 一致就能保證加入到同一個 channel 中。在實際實現的過程當中,因爲咱們對 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 時,咱們遇到了兩個比較典型的問題:
解決數據體積限制的一個思路是將數據切分爲多個 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 實現,這樣的已讀確認機制產生的額外通訊會形成較大的時延,致使面試官一側觀看回放時的實時性受到影響。
一些可行的優化思路包括:
相信在增長以上優化以後,咱們的在線編程面試工具會更具實用性。
隨着 Web API 的不斷進化以及愈來愈多成熟工具、服務的出現,開發者能夠基於它們快速地開發出各類實用的工具、產品。以本文中的項目爲例,因爲使用了 Agora SDK、monaco-editor 和 rrweb 三個工具/服務,用很是少的代碼量就完成了功能的可行性驗證。
當 VScode remote/browser 相關的功能更爲成熟時,編輯器部分的功能還會被進一步強化,可能就能夠造成一個實際可用的產品。因此咱們有理由相信當瀏覽器提供的 API 更增強大、性能更好時,會誕生更多以瀏覽器爲客戶端的服務,而 WebRTC 提供的實時通訊會是頗有價值一環。