今天我们来简单了解如何 给图形编辑器加上多人协同编辑功能。
多人协同,需要一种协同编辑算法,有 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 算法来处理。
结尾
我是前端西瓜哥,关注我,学习更多图形编辑器知识。