协同编辑一直是一个技术上具有挑战性的领域,其中数据一致性问题尤为复杂,但随着各种技术迭代,目前已经有了比较成熟的解决方案,下面会介绍介绍 CRDT 以及其开源方案 Yjs 的协同方案,并通过demo 来更好的了解如何快速的构建一个多人协同编辑器

数据一致性问题

协同编辑最大的问题就是在实时同步的过程中,确保多个用户同时编辑同一文档时,所有用户的编辑行为能够互相同步,最终产出文档的状态符合所有用户预期的条件。

通常产品的解决方案有以下三种:

CRDT (Conflict-free Replicated Data Type)

前面提到,OT 依赖中心化服务器完成协作,在网络传输和分布式系统中,数据到达服务器的时间不用,最终得到的结果也可能不同。可以在 OT 可视化 网站中试一下:

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

image.png

随后操作 操作b 到达服务器,结果则变成了 Lorem·ipsuma12b 而不是预期的 Lorem·ipsumab12

image.png

而 CRDT 的提出则是为了解决数据这种最终一致性的问题,根据CAP 定理,对于一个分布式计算系统来说,不可能同时完美地满足以下三点:

因此系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在 C 和 A 之间做出选择,所以「完美的一致性」与「完美的可用性」是冲突的。

CRDT 不提供「完美的一致性」,而是保证了「最终一致性」。意思是进程 A 可能无法立即反映进程 B 上发生的状态改动,但是当 A B 同步消息之后它们二者就可以恢复一致性,并且不需要解决潜在冲突(CRDT 在数学上就不让冲突发生)。而「强最终一致性」是不与「可用性」和「分区容错性」冲突的,所以 CRDT 同时提供了这三者,提供了很好的 CAP 上的权衡。

image.png

CRDT 的基本原理

CRDT 有两种类型:Op-based CRDT(操作一致性) 和 State-based CRDT(状态一致性):

基于状态的 CRDT 更容易设计和实现,每个 CRDT 的整个状态最终都必须传输给其他每个副本,每个副本之间通过同步全量状态达到最终一致状态,这可能开销很大;

而基于操作的 CRDT 只传输更新操作,各副本之间通过同步操作来达到最终一致状态,通常很小。

CRDT 的优势

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

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

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

Yjs 介绍Yjs 基本概念

/yjs/yjs

在 Yjs 的介绍中说明中提到,Yjs 是,它公开了其内部的 data 结构作为 共享类型。并且其与网络无关,支持许多现有的富文本编辑器、离线编辑、版本快照、撤消/重做和意识感知。

Yjs 架构图

image.png

Quill + Yjs 协同编辑

下面我们根据官网的例子来实现一个简单的 demo,会发现现有的方案已经能帮我们快速完成一个简单能用的方案,这里用 Quill 来作为演示。

初始化 web 端

首先创建一个 vue 项目,并且安装相关依赖

pnpm create vite co_editor --template vue
pnpm add quill quill-cursors yjs y-quill y-websocket

依赖说明:

首先封装一下 quill 初始化的函数

// 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;
  }
}

接下来封装一下协同操作需要的工具,这样后续针对协同的操作都可以在这里处理

// 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 中使用

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>

搭建协同网络服务

take server
pnpm init
pnpm add ws y-websocket
touch app.js

接着添加服务器的代码

// 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 中:

// 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 方法中:

// 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);
      });
    });
  }
}

到这里已经实现了光标的状态同步了,但仔细观察会发现多个光标的颜色的一样的,因为颜色使我们默认设置的,要随机颜色也很简单,代码如下:

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) {
    // ...
  }
}

image.png

总结

这篇文章简单的介绍了 CRDT 协同算法的一些概念,然后介绍了其开源的视线方案 Yjs,Yjs 通过合理的数据结构,来规避了复杂的数据冲突处理,是一套不同于 OT 的数据一致性解决方案。

后半部分通过 Yjs 实现了一个 demo,以及部分中间层的代码开发,来了解通过 Yjs 开发一个协同文档需要做什么,由于 Yjs 可以自动处理冲突,因此可以更容易的开发不同协同工具,比如富文本编辑器,思维导图,文档等。

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

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。