使用 yjs 给图形编辑器加上多人协同编辑功能

今天我们来简单了解如何 给图形编辑器加上多人协同编辑功能

多人协同,需要一种协同编辑算法,有 OT 和 CRDT 两种主流算法。

OT 涉及算法比较复杂难懂,且泛用性和扩展性较差,在之前 CRDT 还没有成熟前,所以早期很多产品选择 OT 来实现多人协同,因为并没有太多选择。

后来 CRDT 发展成熟,很多新的应用选择了用 CRDT 或类 CRDT 来实现多人协同。

CRDT 泛用性和扩展性良好,但是需要相当多的对象来保存历史状态,导致内存的耗费相比 OT 较高,但经过优化后问题并不算大。

当然最重要是 yjs 开源协同库的出现,让多人协同的接入变得简单了许多。不需要深入研究和实现复杂的 CRDT 算法,就能快速地让应用拥有简单的多人协同能力。

基于 CRDT 的 yjs 协同库

yjs 是成熟的开源协同库,基于 CRDT,提供完整的一套的协同方案,支持各种数据结构,支持各种数据传输方案(ws、webrtc等),也有后端实现。

我们的图形编辑器使用了 yjs 来实现协同编辑。

yjs 针对 CRDT 的高耗费内存做了很多优化,比如对多个 op 压缩为一个,并使用二进制形式来压缩存储和传输。

yjs 帮我们把协同的非业务逻辑部分帮我们封装好了,我们只需要把数据转为 yjs 对应的各种数据结构对象,比如 Y.map Y.Array。

如果要支持服务端保存,我们需要魔改 y-websoket 。

图形编辑器的图形树,为了配合协同编辑,需要拍平成一个 id 到属性映射表。具体为图形 id 对应 attrs。如下:

const nodes = {
  '4:9': {
    type: 'rect',
    width: 10,
    height: 40,
    matrix: [1, 0, 0, 1, 300, 400],
    parentIndex: {
      id: '4:5',
      position: 'a4'
    }
  }
}

其中 parentIndex 用来表达当前图形是谁的子节点,以及它在它和它的兄弟节点的位置。

yjs 和编辑器状态互相同步

我们用到 yjs 的 Y.Map 对象,操作这个对象,yjs 会帮我们把新产生的状态。

Y.Map 的用法大概是:

const yDoc = new Y.Doc();
const yMap = yDoc.getMap('nodes');

// 修改某个图形的属性
yMap.set('4:9', { type: 'rect', /* ... */ })
// 删除某个图形
yMap.delelte('4:23');

对 yMap 的频繁乱序更新图形属性,在图形编辑器很常见,这种行为会破坏 yMap 的优化,对此可以使用 yjs 作者提供基于 Y.Array 封装的 YKeyValue 类型,具体可以看:https://github.com/yjs/y-utility

另外有个粒度问题,比如我只想改 width,但前面的做法会更新整个 attrs 然后提交。所以读者可以考虑粒度细一点,拍得更平一些,id 换成 { '4:9_width': 6967 }

编辑器同步到 yjs

图形编辑器要收集用户操作产生的操作(operation),包括图形属性的变更(update)、新增(add)和删除(delete),同步到 yjs 中。

通常我们会给图形对象提供一个 updateAttrs 方法(类似 React),或者给属性加代理,静默收集变更(类似 Vue),然后把所有的变更收集起来,然后通过节流的方式提交给 yjs。

不使用节流的话,提交频率会过高,对服务端有较大压力。

const editorObserve = (ops: IChanges) => {
  yDoc.transact(() => {
    // 新增
    for (const [id, attrs] of ops.added) {
      yMap.set(id, attrs);
    }
    for (const id of ops.deleted) {
      yMap.delete(id);
    }
    for (const [id, attrs] of ops.update) {
      const oldAttrs = yMap.get(id);
      const keys = Object.keys(attrs);
      // 对比一下是否发生变化,防止互相同步死循环
      if (!isEqual(pick(oldAttrs, keys), attrs)) {
        yMap.set(id, { ...yMap.get(id), ...attrs });
      }
    }
  }, 'user');
};

// 监听编辑器状态的变化
editor.doc.on('sceneChange', editorObserve);

yjs 同步到编辑器

其它用户的操作也要同步给编辑器,使其更新为最新状态。

const yMapObserve = (event: YMapEvent<any>) => {
  const changes: IChanges = {
    added: new Map(),
    deleted: new Set(),
    update: new Map(),
  };

  for (const [id, { action }] of event.changes.keys) {
    if (action === 'delete') {
      changes.deleted.add(id);
      return;
    }
    const attrs = yMap.get(id) as GraphicsAttrs;
    if (action === 'add' && attrs.type !== GraphicsType.Document) {
      changes.added.set(id, attrs);
    } else if (action === 'update') {
      changes.update.set(id, attrs);
    }
  }

  // 编辑器应用修改
  this.editor.applyChanges(changes);
};

光标位置同步

如果编辑器要支持多人协同,对于 其它用户操作的感知(Awaness) 是非常重要的。

我们需要同步其它用户的光标位置,并显示用户名,如下图。

具体实现看我之前写的文章:

《多人协同场景下,图形编辑器如何实现多人光标功能》

服务端

服务端也要实现一些功能。

持久化

首先是数据的持久化。yjs 提供了 y-websocket 这么一个库,可以拿到二进制数据,我们需要根据图纸 id 将图纸数据数据库对应的位置。

y-websocket 比较原始,通常我们需要对它做一层封装,提供易用的事件钩子,这里我尝试了 hocuspocus 库,一个基于 yjs 封装的协同编辑工具集。

这里我们用了 hocuspocus 的官方插件 @hocuspocus/extension-database,来做持久化。fetch 方法需要返回图纸数据,store 方法则是拿到内存中最新的图纸二进制数据,我们需要把这个数据持久化到数据库(比如 postgres)中。

import { Hocuspocus, Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';


const server = Server.configure({
  name: 'suika-document-server',
  extensions: [
    new Database({
      // 1. 客户端初始化,获取图纸数据
      fetch: async (data) => {
        const docId = data.documentName;
        // 从数据库中取出图纸数据
        const res = await this.filesService.getContent(docId);
        return new Uint8Array(res.content);
      },
      
      // 2. 服务端通过节流的方式,及时保存数据到数据库中
      store: async (data) => {
        const docId = data.documentName;
        await this.filesService.updateContent(docId, data.state);
      },
    }),
  ],
});

需要注意的是,yjs 的数据是二进制的,且为增量的形式。

当没有用户连接的时候,可以考虑把增量形式的数据转换为全量形式,降低数据大小。

鉴权

图纸也需要做一下鉴权。

下面的代码是检验用户 token,并判断图纸 id 是否属于这个用户,满足才允许通过用户查看和编辑图纸。

const server = Server.configure({
  name: 'suika-document-server',
  extensions: [
    // ...
  ],
  onAuthenticate: async (data) => {
    // 校验用户 token,得到用户 id
    const payload = await this.jwtService.verifyAsync(data.token, {
      secret: jwtConstants.secret,
    });
    const docId = data.documentName;
    // 判断图纸是否属于当前用户
    await this.filesService.checkExit(docId, payload.id);
    return {
      userid: payload.id
    };
  },
});

上面这个只是最简单的场景。

实际生产中会更复杂,比如图纸是否公开、游客能否查看、图纸对其他人只读、指定的人才能编辑或只能查看等等。

初始化图纸数据

我们需要在用户点击创建文件按钮的时候调用后端接口,服务端要给图纸一个初始数据。

这个事情需要服务端来做,不能让客户端来做。如果多个客户端同时初始化且网络不好的时候,可能会有多个用户创建了相同的初始数据导致冲突。

初始数据根据需求,通常包含图形树个节点,当前 page 节点,此外可能有一些样式数据,比如标准文字样式。

const generateInitialDoc = () => {
  const suikaDoc = {
    objectName: 'Document',
    width: 0,
    height: 0,
    type: 'Document',
    id: '0:0',
    transform: [1, 0, 0, 1, 0, 0],
  };

  const yDoc = new Y.Doc();
  const yMap = yDoc.getMap('nodes');
  yMap.set(suikaDoc.id, suikaDoc);

  // 转为 yjs 的二进制格式,然后保存到数据库中
  initialDocBuffer = Buffer.from(Y.encodeStateAsUpdate(yDoc));
  return initialDocBuffer;
};

此外我们也可以提供制定模板创建图纸。我们会把一些图纸标记为模版,当用户基于该模版创建时,将模板的数据复制一份作为初始数据即可。

其它

yjs 支持离线编辑。离线状态下,对 yjs 提交的状态会持久化到本地,当重新连接服务器时,会把没有提交的数据提交到服务端。

历史记录的处理在协同中也是需要注意,想要简单点,可以直接用 yjs 的 UndoManager。

也可以自己实现。这种情况下,我们不能用快照的方式记录每个历史记录,而是要用 patch 打补丁的方案。

执行操作,然后撤销,我们要计算这个操作的逆操作,应用到最后的状态,然后把这个操作提交到服务端同步给其它客户端。

我们有一个重要的准则:撤销后复制了一些东西,然后重做到当前位置,文档不应该被改变。实现上大概需要在其它用户的修改同步过来时,修改一下最后一个历史记录的增删改数据。

然后还有顺序一致性问题,节点产生环的问题,可以通过一些特定的 CRDT 算法来处理。

结尾

我是前端西瓜哥,关注我,学习更多图形编辑器知识。

原文链接:,转发请注明来源!