协同编辑一直是一个技术上具有挑战性的领域,其中数据一致性问题尤为复杂,但随着各种技术迭代,目前已经有了比较成熟的解决方案,下面会介绍介绍 CRDT 以及其开源方案 Yjs 的协同方案,并通过demo 来更好的了解如何快速的构建一个多人协同编辑器
数据一致性问题
协同编辑最大的问题就是在实时同步的过程中,确保多个用户同时编辑同一文档时,所有用户的编辑行为能够互相同步,最终产出文档的状态符合所有用户预期的条件。
通常产品的解决方案有以下三种:
- 读写锁:类似于文件加锁,即同一时间只允许存在一个用户进行编辑,不同用户在编辑前,需要抢占编辑锁,但这也会导致其他用户突然无法正常保存、编辑,这也是在项目开发中最常使用的方案。
- diff-patch: 类似 git 一样,在保存时发现有冲突,首先是合并能自动合并的部分,然后不能合并的冲突部分,丢给用户手动选择应该怎么处理。怎么判断哪些是可以合并,哪些是不可以合并的呢,就要用到文本 diff 算法了,这里边又有基于行 diff、词 diff 和字符 diff 等等。比如场景的 Myers 算法
- 自动合并冲突:如何自动合并冲突,保证数据的一致性,则是协同编辑所面临的最大问题,OT 算法与 CRDT 算法应该算是目前比较好的协同算法了,两个方案的对比如下:
- OT (Operation Transformation):Google Docs 中所采用的方案,也是目前主流的协同文档编辑器主要使用的方案。OT 指的是一类技术,而不是具体的算法,其依赖中心化服务器对基于同一版本进行的不同操作,进行的一致性处理。
- CRDT (Conflict-free Replicated Data Type):一种解决分布式系统中数据同步问题的数据结构,CRDT 的核心思想是确保所有副本之间的数据一致性,而无需进行复杂的操作转换。CRDT 有两种主要类型:状态同步(State-based CRDT)和操作同步(Operation-based CRDT)。
CRDT (Conflict-free Replicated Data Type)
前面提到,OT 依赖中心化服务器完成协作,在网络传输和分布式系统中,数据到达服务器的时间不用,最终得到的结果也可能不同。可以在 OT 可视化 网站中试一下:
-
Alice 在末尾插入了
ab
,并且操作a
已经到达了服务器,而操作b
由于网络延迟还没送达,而后 Bob 在末尾插入了12
,并且,操作1
和操作 2
都在操作a
之后到达服务器: -
随后操作
操作b
到达服务器,结果则变成了Lorem·ipsuma12b
而不是预期的Lorem·ipsumab12
而 CRDT 的提出则是为了解决数据这种最终一致性的问题,根据 CAP 定理,对于一个分布式计算系统来说,不可能同时完美地满足以下三点:
- 一致性(Consistency): 每一次读都会收到最近的写的结果或报错;表现起来像是在访问同一份数据
- 可用性(Availability): 每次请求都能获取到非错的响应------但是不保证获取的数据为最新数据
- 分区容错性(Partition tolerance): 以实际效果而言,不同分区之间的通信不一定能保证成功,所以这一条通常来说都是成立的
因此系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在 C 和 A 之间做出选择,所以「完美的一致性」与「完美的可用性」是冲突的。
CRDT 不提供「完美的一致性」,而是保证了「最终一致性」。意思是进程 A 可能无法立即反映进程 B 上发生的状态改动,但是当 A B 同步消息之后它们二者就可以恢复一致性,并且不需要解决潜在冲突(CRDT 在数学上就不让冲突发生)。而「强最终一致性」是不与「可用性」和「分区容错性」冲突的,所以 CRDT 同时提供了这三者,提供了很好的 CAP 上的权衡。
CRDT 的基本原理
CRDT 有两种类型:Op-based CRDT(操作一致性) 和 State-based CRDT(状态一致性):
-
在状态同步 CRDT 中,每个副本都维护一个局部状态。当有新操作发生时,副本之间通过传递状态来达到一致性。状态之间的合并操作是幂等的、可交换的和可关联的,这保证了最终一致性。
-
操作同步 CRDT 中,副本之间通过传递操作来达到一致性。在这种情况下,操作需要满足可交换性和可关联性。为了满足这些条件,CRDT 通常使用一种特殊的数据结构,如增加 - 删除集合(Add-Wins Set)、观察删除集合(Observed-Remove Set)等。
基于状态的 CRDT 更容易设计和实现,每个 CRDT 的整个状态最终都必须传输给其他每个副本,每个副本之间通过同步全量状态达到最终一致状态,这可能开销很大;
而基于操作的 CRDT 只传输更新操作,各副本之间通过同步操作来达到最终一致状态,通常很小。
CRDT 的优势
-
去中心化 减少对服务器的依赖,任意客户端可自行独立地解决冲突。
-
性能 通过近些年的优化,CRDT 的性能已经超越传统 OT 的性能,并且由于 CRDT 的去中心化性质,可以容忍较高的延迟,并且可在离线模式下解决冲突。
-
灵活性和可用性 CRDT 支持更广泛的数据类型,像 Yjs 支持 Text、Array、Map 和 Xml 等数据结构,使其适用于更多业务场景。
Yjs 介绍
Yjs 基本概念
在 Yjs 的介绍中说明中提到,Yjs 是 CRDT 的一个实现,它公开了其内部的 data 结构作为 共享类型。并且其与网络无关,支持许多现有的富文本编辑器、离线编辑、版本快照、撤消/重做和意识感知。
Yjs 架构图
接入层
:Yjs 支持大部分主流编辑器的接入,只需要实现中间绑定层将 Yjs 的数据模型与编辑器的数据模型进行绑定,用于编辑器数据模型与 Yjs 数据模型之间进行转换,即可快速实现一个协同工具Yjs
: 包含最核心的数据结构及逻辑。如数据类型的定义,数据读写编码 encoding 模块,事件监听,状态管理 StructStore,Undo/Redo 管理器等Providers
:支持将 Yjs 数据在网络之间转发,或同步到数据库- y-websocket: 提供协同编辑时的消息通讯,如:更新文档和 awareness 信息
- y-protocols: 定义消息通讯协议,包括文档更新、管理用户信息、用户状态,同步光标等
- y-redis - 持久化数据到 Redis
- y-indexeddb - 持久化数据到 IndexedDB
Quill + Yjs 协同编辑
下面我们根据官网的例子来实现一个简单的 demo,会发现现有的方案已经能帮我们快速完成一个简单能用的方案,这里用 Quill 来作为演示。
初始化 web 端
首先创建一个 vue 项目,并且安装相关依赖
bash
pnpm create vite co_editor --template vue
pnpm add quill quill-cursors yjs y-quill y-websocket
依赖说明:
- quill-cursors :光标显示插件
- y-quill: yjs 提供的将 Yjs 与 Quill 富文本编辑器结合起来的工具
- y-websocket: 用于基于 ws 的搭建协同服务器
首先封装一下 quill 初始化的函数
typescript
// src/common/utils/QuillEditor.js
import Quill from 'quill';
import QuillCursors from 'quill-cursors';
import 'quill/dist/quill.snow.css';
Quill.register('modules/cursors', QuillCursors);
export class QuillEditor {
constructor(editorContainer) {
this.quillInstance = new Quill(editorContainer, {
theme: 'snow',
modules: {
cursors: true
}
});
}
getInstance() {
return this.quillInstance;
}
}
接下来封装一下协同操作需要的工具,这样后续针对协同的操作都可以在这里处理
typescript
// src/collaboration/collaborationService.js
import { QuillBinding } from 'y-quill';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
export class CollaborationService {
constructor(wsUrl) {
this.wsUrl = wsUrl;
}
setupCollaboration(docId, quill) {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(this.wsUrl, docId, ydoc);
const ytext = ydoc.getText('quill');
new QuillBinding(ytext, quill, provider.awareness);
provider.on('status', event => {
console.log(event.status);
});
return { ydoc, provider };
}
}
在 App.vue 中使用
html
<template>
<div id="app">
<div ref="editorContainer" class="quill-editor"></div>
</div>
</template>
<script>
import { onMounted, ref } from 'vue';
import { CollaborationService } from './collaboration/collaborationService';
import { QuillEditor } from './common/utils/QuillEditor';
const wsUrl = 'ws://127.0.0.1:1234';
const docId = 'my-shared-document';
export default {
name: 'App',
setup() {
const editorContainer = ref(null);
onMounted(() => {
const quillEditor = new QuillEditor(editorContainer.value);
const collaborationService = new CollaborationService(wsUrl);
collaborationService.setupCollaboration(docId, quillEditor.getInstance());
});
return {
editorContainer
};
}
};
</script>
<style scoped>
#app {
max-width: 800px;
margin: 50px auto;
}
.quill-editor {
height: 400px;
}
</style>
搭建协同网络服务
bash
take server
pnpm init
pnpm add ws y-websocket
touch app.js
接着添加服务器的代码
typescript
// server/app.js
const WebSocket = require('ws');
const { setupWSConnection } = require('y-websocket/bin/utils');
const port = 1234;
const wss = new WebSocket.WebSocketServer({ port });
wss.on('connection', (ws, req) => {
const docName = req.url.slice(1);
console.log("等待初始化 WS 服务...", docName);
setupWSConnection(ws, req, { docName })
});
然后再 server/package.json
添加命令 "start": "node app.js"
, 并且运行,然后就能得到一个支持离线编辑,光标状态同步的协同文档了。
到这里就完成一个协同文档,得益于 Yjs 提供了针对 Quill 等富文本编辑器的一些配套,所以写一些接入层的代码即可实现一个协同文档,但 Yjs 本身是框架无关的,Yjs 本身是一个数据结构,对于文档内容修改,通过中间层将文档数据转换成 CRDT 数据;通过 CRDT 进行数据数据更新这种增量的同步,通过中间层将 CRDT 的数据转换成文档数据,另一个协作方就能看到对方内容的更新。 对于中间内容的更新以及冲突处理,都是 Yjs 承担的,所以 Yjs 可以支持任意需要协同的场景,比如像 Figma、在线思维导图, 下面我们直接基于 Yjs 来实现中间层的代码,以便更好的理解 Yjs 的能力
实现文档编辑同步
上面将文档内容的修改转成 Yjs 的 CRDT 数据是使用了 y-quill
这个库,我们首先实现这个能力,我们把 QuillBinding
的代码去掉,然后通过 Quill
提供的 API 来处理文档内容的变更,代码入下,在 bindYjsToQuill
中:
quill.on('text-change')
: 监听文档的变更,然后转成 CRDT 数据,借助y-websocket
同步到其他客户端 ,这里需要判断文档的变更是来自于用户,而不是协同的更新ytext.observe
:当y-websocket
推送其他客户端的变更后,通过这个事件来将变更同步到当前文档中,同步的时候把变更者改为变更的用户,这样可以在上一步用于区分是不是当前用户的变更,避免重复的更新
typescript
// src/collaboration/collaborationService.js
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
export class CollaborationService {
constructor(wsUrl) {
this.wsUrl = wsUrl;
}
setupCollaboration(docId, quill) {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(this.wsUrl, docId, ydoc);
const ytext = ydoc.getText('quill');
// new QuillBinding(ytext, quill, provider.awareness);
// 初始化 Quill 内容
quill.setContents(quill.clipboard.convert(ytext.toString()));
this.bindYjsToQuill(ydoc, ytext, quill);
provider.on('status', event => {
console.log(event.status);
});
return { ydoc, provider };
}
bindYjsToQuill(ydoc, ytext, quill) {
quill.on('text-change', (delta, oldDelta, source, origin) => {
if (source === Quill.sources.USER) {
ydoc.transact(() => {
ytext.applyDelta(delta.ops);
}, ytext);
}
});
ytext.observe((event, transact) => {
if (transact.origin!== ytext) {
quill.updateContents(event.delta, 'yjs');
}
});
}
}
来看看,现在已经支持了文本的同步
实现光标状态同步
光标(意识)同步,上面也是在 y-quill
中实现的,在下面的 setupCursors
方法中:
quill.on('selection-change')
: 鼠标点击、选中都会触发这个事件,因此通过这个事件,将当前选中的位置信息、选中的长度通过y-websocket
的 awareness 模块转发给其他客户端awareness.on('change')
: 通过监听y-websocket
的 awareness 模块接收到的其他客户端的光标状态数据,写入到 Quill 中。
typescript
// src/collaboration/collaborationService.js
import Quill from 'quill';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
const updateCursor = (quillCursors, aw, clientId, doc) => {
try {
// 这里要区分是远端同步,还是当前编辑器的光标
if (aw && aw.cursor && clientId !== doc.clientID) {
const user = aw.user || {};
const color = user.color || '#fc4';
const name = user.name || `User: ${clientId}`;
quillCursors.createCursor(clientId.toString(), name, color);
if (aw.cursor) {
quillCursors.moveCursor(clientId.toString(), aw.cursor);
}
} else {
quillCursors.removeCursor(clientId.toString())
}
} catch (error) {
console.log(error);
}
}
export class CollaborationService {
constructor(wsUrl) {
this.wsUrl = wsUrl;
}
setupCollaboration(docId, quill) {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(this.wsUrl, docId, ydoc);
const ytext = ydoc.getText('quill');
// new QuillBinding(ytext, quill, provider.awareness);
// 初始化 Quill 内容
quill.setContents(quill.clipboard.convert(ytext.toString()));
this.bindYjsToQuill(ydoc, ytext, quill);
// 共享光标位置和状态信息
this.setupCursors(provider, ytext, quill);
provider.on('status', event => {
console.log(event.status);
});
return { ydoc, provider };
}
bindYjsToQuill(ydoc, ytext, quill) {
// ...
}
setupCursors(provider, ytext, quill) {
const awareness = provider.awareness;
const cursorsModule = quill.getModule('cursors');
// 更新本地光标状态
quill.on('selection-change', (range, oldRange, source) => {
if (source === Quill.sources.USER) {
if (range) {
awareness.setLocalStateField('cursor', {
index: range.index,
length: range.length
});
} else {
awareness.getLocalState() !== null &&
awareness.setLocalStateField('cursor', null);
}
}
});
// 监听远程光标状态变化
awareness.on('change', changes => {
changes.added.forEach(clientId => {
const state = awareness.getStates().get(clientId);
updateCursor(cursorsModule, state, clientId, ytext.doc);
});
changes.updated.forEach(clientId => {
const state = awareness.getStates().get(clientId);
updateCursor(cursorsModule, state, clientId, ytext.doc);
});
changes.removed.forEach(clientId => {
cursorsModule.removeCursor(clientId);
});
});
}
}
到这里已经实现了光标的状态同步了,但仔细观察会发现多个光标的颜色的一样的,因为颜色使我们默认设置的,要随机颜色也很简单,代码如下:
typescript
import Quill from 'quill';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
const updateCursor = (quillCursors, aw, clientId, doc) => {
// ...
}
export class CollaborationService {
constructor(wsUrl) {
this.wsUrl = wsUrl;
}
setupCollaboration(docId, quill) {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(this.wsUrl, docId, ydoc);
const ytext = ydoc.getText('quill');
// new QuillBinding(ytext, quill, provider.awareness);
// 初始化 Quill 内容
quill.setContents(quill.clipboard.convert(ytext.toString()));
this.bindYjsToQuill(ydoc, ytext, quill);
// 自定义光标颜色
this.updateAwareness(provider.awareness, ydoc.clientID);
// 共享光标位置和状态信息
this.setupCursors(provider, ytext, quill);
provider.on('status', event => {
console.log(event.status);
});
return { ydoc, provider };
}
bindYjsToQuill(ydoc, ytext, quill) {
// ...
}
updateAwareness (awareness, userId) {
const user = {
name: `User ${userId}`,
color: `#${Math.floor(Math.random() * 0xffffff).toString(16)}`,
};
awareness.setLocalStateField('user', user);
}
setupCursors(provider, ytext, quill) {
// ...
}
}
总结
这篇文章简单的介绍了 CRDT 协同算法的一些概念,然后介绍了其开源的视线方案 Yjs,Yjs 通过合理的数据结构,来规避了复杂的数据冲突处理,是一套不同于 OT 的数据一致性解决方案。
后半部分通过 Yjs 实现了一个 demo,以及部分中间层的代码开发,来了解通过 Yjs 开发一个协同文档需要做什么,由于 Yjs 可以自动处理冲突,因此可以更容易的开发不同协同工具,比如富文本编辑器,思维导图,文档等。
由于时间关系,文章并不涉及其中的一些实现原理,我也还在学习中,文章内容如有疏漏或错误,望不吝赐教!