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

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

相关推荐
GISer_Jing2 小时前
前端面试通关:Cesium+Three+React优化+TypeScript实战+ECharts性能方案
前端·react.js·面试
落霞的思绪3 小时前
CSS复习
前端·css
咖啡の猫5 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲7 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5818 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路8 小时前
GeoTools 读取影像元数据
前端
ssshooter9 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
你的人类朋友9 小时前
【Node.js】什么是Node.js
javascript·后端·node.js
Jerry9 小时前
Jetpack Compose 中的状态
前端
dae bal10 小时前
关于RSA和AES加密
前端·vue.js