基于 Yjs 的协同方案介绍

协同编辑一直是一个技术上具有挑战性的领域,其中数据一致性问题尤为复杂,但随着各种技术迭代,目前已经有了比较成熟的解决方案,下面会介绍介绍 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 可视化 网站中试一下:

  1. Alice 在末尾插入了 ab,并且 操作a 已经到达了服务器,而 操作b 由于网络延迟还没送达,而后 Bob 在末尾插入了 12,并且,操作1操作 2 都在 操作a 之后到达服务器:

  2. 随后操作 操作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 的优势

  1. 去中心化 减少对服务器的依赖,任意客户端可自行独立地解决冲突。

  2. 性能 通过近些年的优化,CRDT 的性能已经超越传统 OT 的性能,并且由于 CRDT 的去中心化性质,可以容忍较高的延迟,并且可在离线模式下解决冲突。

  3. 灵活性和可用性 CRDT 支持更广泛的数据类型,像 Yjs 支持 Text、Array、Map 和 Xml 等数据结构,使其适用于更多业务场景。

Yjs 介绍

Yjs 基本概念

github.com/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 可以自动处理冲突,因此可以更容易的开发不同协同工具,比如富文本编辑器,思维导图,文档等。

由于时间关系,文章并不涉及其中的一些实现原理,我也还在学习中,文章内容如有疏漏或错误,望不吝赐教!

相关推荐
hackeroink1 小时前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss
迷雾漫步者3 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-3 小时前
验证码机制
前端·后端
燃先生._.4 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖5 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235245 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240256 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar6 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人7 小时前
前端知识补充—CSS
前端·css