Gyron.js 编辑器 3.0 协同编辑 视频通话

2,014 阅读6分钟

继上一篇内容过去也大概半个多月了,这段时间内一直在我的gyron.cc平台上进行验证,才知道其实里面有很多细节问题需要解决,接下来我将我的编辑器 3.0 的实现过程和实现思路写下来,也是对自己技术的一个总结和分享。

名词解释

  • 协同编辑器:协同编辑器是一种多人同时编辑同一文档的工具,可以实时的帮助开发人员解决问题,降低沟通成本
  • Gyron.js: 这是一个零依赖的响应式框架,秉持着直观、高效的理念,帮助开发人员减少心智负担为目标的响应式框架

实时通信

所谓协同就是实时通信,以便多个用户可以同时编辑同一文档。实时通信可以使用 WebSocket 技术来实现,WebSocket 可以在客户端和服务器之间建立一个持久化的连接,并且支持服务端进行推送。但是我们这里需要语言、视频通话,但是如果使用 WebSocket 会对服务器造成一定的压力,如果多人同时在线还可能导致服务器崩溃。所以这里实现实时通信我们选择了 RTC 架构方案,WebRTC 是一种实时通信技术,它允许浏览器和移动应用程序之间进行音频、视频和数据传输,而无需任何插件或其他软件。

文档同步

在选择完通信技术后我们再实现我们的文档同步功能。在我们的编辑器中,选择了 yjs 提供的 y-webrtc lib,这刚好符合我们文档代码编辑需求,也不需要做过多的改动就可以实现文档同步。

y-webrtc 中提供了 WebrtcProvider 方法,它将完全负责 monaco-editor model 的同步和更新,具体用法如下:

import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";

export const ydoc = new Y.Doc();
// clients connected to the same room-name share document updates
export const provider = new WebrtcProvider("your-room-name", ydoc, {
  password: "optional-room-password",
});

其中provider.awareness就是可以传递给y-monaco的同步状态管理器。

import * as Y from "yjs";
import { provider, ydoc } from "./provider";
import { MonacoBinding } from "y-monaco";
import * as monaco from "monaco-editor";

const type = ydoc.getText("monaco");

const editor = monaco.editor.create(document.getElementById("monaco-editor"), {
  value: "", // MonacoBinding overwrites this value with the content of type
  language: "javascript",
});

// Bind Yjs to the editor model
const monacoBinding = new MonacoBinding(
  type,
  editor.getModel(),
  new Set([editor]),
  provider.awareness
);

至此,就完成了单文档的模块同步。这里再展开讲解一下,在实际场景中我们需要实现多个文件的状态同步,我们需要给这些文件创建一个命名空间,然后在文件切换的时候重新进入新的your-room-name

冲突解决

大家在编辑同一份文件时可能导致文件冲突,这个时候我们需要解决这个冲突,就好比 git 在 merge 的时候可能也会遇到无法自动合并的行,需要人为的选择哪部分代码是正确的,但是在协同编辑中不可能提示让用户选择哪一个才是正确的,这也会导致编辑器变得更复杂和不可控。这个时候我们需要见证一下 yjs 的强大能力,自动解决冲突,保持文件内容一致。看一下 yjs 的使用方法。

以下内容来源于 github.com/doodlewind

const doc1 = new Y.Doc();
const doc2 = new Y.Doc();
const yText1 = doc1.getText();
const yText2 = doc2.getText();

// 在某份 YDoc 更新时,应用二进制的 update 数据到另一份 YDoc 上
doc1.on("update", (update) => Y.applyUpdate(doc2, update));
doc2.on("update", (update) => Y.applyUpdate(doc1, update));

// 制造两次存在潜在冲突的更新
yText1.insert(0, "Edwards");
yText2.insert(0, "Wilson");

// CRDT 算法可保证两份客户端中的状态始终一致
yText1.toJSON(); // WilsonEdwards
yText2.toJSON(); // WilsonEdwards

透过这些 Yjs 表层 API 的例子,我们应该已经可以认识到 CRDT 的威力所在了。下面真正有趣的问题来了:Yjs 内部是如何实现这一能力的呢?

建模数据结构

提到「底层原理」,很多同学可能会立刻会开始想象某种精妙的冲突解决算法。但在介绍这一算法前,我们最好先熟悉一下 Yjs 在工程上建模 CRDT 时所用的基础数据结构:双向链表

在 Yjs 中,不论是 YText、YArray 还是 YMap,这些 YModel 实例中的数据都存储在一条双向链表里。粗略地说,这条链表中的每一项(或者说每个 item)都唯一地记录了某次用户操作所修改的数据,某种程度上和区块链有些异曲同工。可以认为上面例子中对 YModel 的操作,最后都会转为对这条链表的 append、insert、split、merge 等结构变换。链表中每个 item 会被序列化编码后分发,而基于 CRDT 算法的保证,只要每个客户端最终都能接收到全部的 item,那么不论客户端以何种顺序接收到这些 item,它们都能重建出完全一致的文档状态。

显然, 在多人实时协作这种无法保证 item 接收时序的场景下,每个 item 都需要携带某种标识,供系统唯一确定其在逻辑时间轴上的位置。Yjs 会为每个 item 分配一个唯一 ID,其结构为 ID(clientID, clock),如下所示:

// Yjs 中的 ID 源码,这样的朴素实现有利于引擎的 hidden class 优化
class ID {
  constructor(client: number, clock: number) {
    // 客户端的唯一 ID
    this.client = client;
    // 逻辑时间戳
    this.clock = clock;
  }
}

这里的 client 和 clock 都是整数,前者用于唯一标识某个 YDoc 对应的客户端,而后者则属于一种名为 Lamport timestamp 的逻辑时间戳,可以认为这就是个从零开始递增的计数器。它的更新规则非常简单:

发生本地事件时,localClock += 1。 在接收到远程事件时,localClock = max(remoteClock, localClock) + 1。

这种机制看似简单,但实际上使我们获得了数学上性质良好的全序结构。这意味着只要再配合比较 client 的大小,即可令任意两个 item 之间均可对比获得逻辑上的先后关系,这对保证 CRDT 算法的正确性相当重要。但相关数学理论并非本文重点,在此不再展开。

好了,这不是本篇内容的核心部分,所以不展开讲解其中的原理,具体可以前往 zhuanlan.zhihu.com/p/452980520

视频通话

在线编辑器 3.0 主要提供两种方式的协同,一种是视频通话,一种是文件协同。视频通话其中最主要就是获取到当前摄像头和麦克风的数据,然后通过 webrtc 传递给对方,前提是两者之间通过 ice 建立了连接。

通过peerjs,我们实现视频通话特别简单:

import { Peer } from "peerjs";
const peer = new Peer("pick-an-id");

navigator.mediaDevices.getUserMedia(
  { video: true, audio: true },
  (stream) => {
    const call = peer.call("another-peers-id", stream);
    call.on("stream", (remoteStream) => {
      // Show stream in some <video> element.
    });
  },
  (err) => {
    console.error("Failed to get local stream", err);
  }
);
peer.on("call", (call) => {
  navigator.mediaDevices.getUserMedia(
    { video: true, audio: true },
    (stream) => {
      call.answer(stream); // Answer the call with an A/V stream.
      call.on("stream", (remoteStream) => {
        // Show stream in some <video> element.
      });
    },
    (err) => {
      console.error("Failed to get local stream", err);
    }
  );
});

背景模糊/替换

视频背景模糊和替换都需要我们获取到视频帧的数据,然后通过 tensorflow 架构下的deeplab模型,将每一帧的数据处理后传递给 canvas,通过 canvas 的叠加模式,就可以达到背景模糊或者替换的功能。

至此,在线编辑器已经迭代到 3.0,大部分功能都已经实现,现在你可以前往gyron.cc/explorer进行体验。体验前需要通过 github 授权,请别担心,只是授权后将协同数据保存下来,以便于下次接着编辑。