Yjs
一个具有强大的共享数据抽象的 CRDT 框架。
Yjs 是一个 CRDT 实现,它以共享类型的形式公开其内部数据结构。共享类型是像 Map 或 Array 这样常见的数据类型,具有超强的功能:更改会自动分发到其他对等方,并在没有合并冲突的情况下进行合并。
Yjs 是网络不可知的(p2p!),支持许多现有的富文本编辑器,离线编辑,版本快照,撤销/重做和共享游标。它的用户数量不受限制,并且非常适合于大型文档。
- 演示:https://github.com/yjs/yjs-demos
- 讨论:https://discuss.yjs.dev
- 对标 Yjs 与 Automerge: https://github.com/dmonad/crdt-benchmarks
- 播客 "Yjs深度挖掘实时协作编辑解决方案":
- 播客 "基于YJS框架的Google文档风格编辑":
如果你正在寻找专业的(付费)支持来构建协作式或分布式应用,请发邮件给我们:yjs@tag1consulting.com。否则,你可以在我们的讨论区找到帮助。
谁在使用 Yjs
- Relm 一个团队合作和社区的协作游戏世界。
- nput 一个协作记事的应用。
oom.sh 一款集成了协作绘图、编辑和编码工具的会议应用程序。 - http://coronavirustechhandbook.com/
一个由数千名不同的人编辑的合作维基,致力于快速和复杂地应对冠状病毒的爆发和后续影响。
- Nimbus Note 一个由 Nimbus Web 设计的笔记应用。
- JoeDocs 一个开放的协作式维基。
- Pluxbox RadioManager 一个基于 Web 的应用程序,用于协作组织电台广播。
- Cattaz 一个可以在 wiki 页面中运行自定义应用程序的 wiki。
概述
此存储库包含一个共享类型的集合,可以观察这些类型的更改并同时进行操作。网络功能和双向绑定在单独的模块中实现。
绑定(Bindings)
名称 | 光标 | 绑定 | 演示 |
---|---|---|---|
ProseMirror | y-prosemirror | demo | |
Quill | y-quill | demo | |
CodeMirror | y-codemirror | demo | |
Monaco | y-monaco | demo |
提供商(Providers)
设置客户之间的沟通,管理意识信息,以及存储共享数据以供离线使用是相当麻烦的。提供商为您管理这一切,是您的协作应用的完美起点。
- y-webrtc
- 使用 WebRTC 点对点传播文件更新。点对点通过信令服务器交换信令数据。可以使用公开的信令服务器。通过信令服务器进行的通信可以通过提供共享秘密进行加密,保持连接信息和共享文档的私密性。
- y-websocket
- 包含一个简单的 websocket 后端和一个连接到该后端的 websocket 客户端的模块。后台可以扩展到 leveldb 数据库中持久化更新。
- y-indexeddb
- 有效地将文档更新坚持到浏览器的 indexeddb 数据库中。文档立即可用,只需要通过网络提供商同步差异。
- y-dat
- [WIP]使用 multifeed 将文档更新有效地写入 dat 网络。每个客户机都有一个仅附加的 CRDT 本地更新日志(hypercore)。multified 管理和同步超线程,y-dat 监听更改并将其应用于 Yjs 文档。
入门
用你喜欢的软件包管理器安装 Yjs 和一个提供商。
npm i yjs y-websocket
启动 y-websocket 服务器
PORT=1234 node ./node_modules/y-websocket/bin/server.js
示例:观察类型(Observe types)
const yarray = doc.getArray('my-array') yarray.observe(event => { console.log('yarray was modified') }) // 每次本地或远程客户端修改 yarray 时,都会调用观察者 yarray.insert(0, ['val']) // => "yarray was modified"
示例:嵌套类型(Nest types)
记住,共享类型只是普通的数据类型。唯一的限制是,一个共享类型在共享文档中只能存在一次。
const ymap = doc.getMap('map') const foodArray = new Y.Array() foodArray.insert(0, ['apple', 'banana']) ymap.set('food', foodArray) ymap.get('food') === foodArray // => true ymap.set('fruit', foodArray) // => Error! foodArray is already defined
现在你明白了如何在共享文档上定义类型了。接下来你可以跳转到 演示仓库 或继续阅读 API 文档。
示例:使用和组合提供者
任何一个 Yjs 供应商都可以相互组合。所以你可以通过不同的网络技术来同步数据。
在大多数情况下,你希望将网络提供者(如y-websocket或y-webrtc)与持久化提供者(浏览器中的y-indexeddb)结合使用。持久性允许你更快地加载文档,并持久化离线时创建的数据。
在本演示中,我们将两个不同的网络提供者与一个持久性提供者结合起来。
import * as Y from 'yjs' import { WebrtcProvider } from 'y-webrtc' import { WebsocketProvider } from 'y-websocket' import { IndexeddbPersistence } from 'y-indexeddb' const ydoc = new Y.Doc() // 这允许您立即获取(缓存)文档数据 const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc) indexeddbProvider.whenSynced.then(() => { console.log('loaded data from indexed db') }) // 将客户端与 y-webrtc provider 程序同步。 const webrtcProvider = new WebrtcProvider('count-demo', ydoc) // Sync clients with the y-websocket provider const websocketProvider = new WebsocketProvider( 'wss://demos.yjs.dev', 'count-demo', ydoc ) // 产生和的数的数组 const yarray = ydoc.getArray('count') // 观察总数的变化 yarray.observe(event => { // print updates when the data changes console.log('new sum: ' + yarray.toArray().reduce((a,b) => a + b)) }) // 总和加1 yarray.push([1]) // => "new sum: 1"
API
import * as Y from 'yjs'
(恕删略。请参考自述文件)
Yjs CRDT 算法
用于协作编辑的无冲突复制数据类型(CRDT)是操作转换(OT)的另一种方法。这两种方法的一个非常简单的区别是,OT 试图转换索引位置以确保收敛(所有客户端最终得到的是相同的内容),而 CRDT 使用数学模型,通常不涉及索引转换,如链接列表。OT 是目前文本上共享编辑的事实标准。支持共享编辑的 OT 方法如果没有一个中心真理源(中心服务器),就需要太多的记账工作,在实践中是不可行的。CRDTs 更适合分布式系统,它提供了额外的保证,即文档可以与远程客户端同步,并且不需要中央真实源。
Yjs 实现了 本论文 所述算法的修改版本。本文解释了在 CRDT 模型上的简单优化,并对 Yjs 中的性能特性做了更多的介绍。更多关于具体实现的信息可以在 INTERNALS.md 和 本篇 Yjs 代码库的演练中获得。
适合共享文本编辑的 CRDT 的苦恼是,它们的体积只会越来越大。有的 CRDT 虽然不会变大,但却不具备共享文本编辑所需的特性(如意图保存)。Yjs 在原有算法的基础上做了很多改进,减少了文档只增长大小的折衷。我们不能在保证结构的唯一顺序的同时,对删除的结构(墓碑)进行垃圾回收。但是我们可以:1.将前面的结构合并到一个结构中,以减少元信息量;2.如果结构被删除,我们可以从结构中删除内容;3.如果我们不再关心结构的顺序,我们可以垃圾回收墓碑(例如,如果父结构被删除)。
举个例子:
如果用户按顺序插入元素,结构将被合并成一个结构。例如:array.insert(0, ['a']), array.insert(0, ['b']); 首先表示为两个结构([{id: {client, clock: 0}, content: 'a'}, {id: {client, clock: 1}, content: 'b'}),然后合并成一个结构。[{id: {client, clock: 0}, content: 'ab'}]。
当一个包含内容的结构(如 ItemString)被删除时,该结构将被一个不再包含内容的 ItemDeleted 代替。
当一个类型被删除时,所有的子元素都会转化为 GC 结构。一个 GC 结构只表示一个结构的存在以及它被删除。如果 id 相邻,GC 结构总是可以与其他 GC 结构合并。
特别是在处理结构化内容时(例如在 ProseMirror 上的共享编辑),当对随机文档编辑进行基准测试时,这些改进会产生非常好的结果。在实践中,它们显示出了更好的结果,因为用户通常会按顺序编辑文本,导致结构很容易被合并。基准测试表明,即使在最坏的情况下,用户从右到左编辑文本,Yjs 也能实现良好的性能,即使是巨大的文档。
状态向量
Yjs 有能力在同步两个客户端时只交换差异。我们使用 lamport 时间戳来识别 structs,并跟踪客户端创建它们的顺序。每个结构都有一个 struct.id = { client: number, clock: number},它唯一地标识一个结构。我们将每个客户端的下一个预期时钟定义为状态向量。这个数据结构类似于版本向量数据结构。但是我们只用状态向量来描述本地文档的状态,所以我们可以计算远程客户端丢失的结构。我们不使用它来跟踪因果关系。
许可和作者
Yjs 和所有相关项目都是 MIT 授权。
Yjs 是基于我在 RWTH
i5
的学生时代的研究。现在我在业余时间从事 Yjs 的研究。
通过在 GitHub Sponsors 上捐款来资助这个项目,或者雇佣我作为你的合作应用的承包商。
(The first version translated by vz on 2020.10.03)