自动合并:一种类似JSON的数据结构(CRDT),可以同时修改

2022-02-21 16:21:56

构建JavaScript应用程序的一种常见方法是将应用程序的状态保存在模型对象中,例如JSON文档。例如,假设您正在开发一个任务跟踪应用程序,其中每个任务都由一张卡片表示。在vanilla JavaScript中,您可以编写以下内容:

const doc={cards:[]}//用户添加一个卡片文档。卡。push({title:';网状样条线';,done:false})//用户将任务标记为已完成文档。卡片[0]。完成=正确

Automerge的使用方式类似,但最大的区别在于它支持自动同步和合并:

您可以在多个设备(可能属于同一用户或不同用户)上本地拥有应用程序状态的副本。每个用户都可以独立地更新其本地设备上的应用程序状态,甚至在脱机时,并将状态保存到本地磁盘。

当网络连接可用时,Automerge会计算出哪些更改需要从一个设备同步到另一个设备,并使它们处于相同的状态。

(与git类似,git允许您在联机时推送自己的更改,并从其他开发人员那里获取更改。)

如果状态在不同的设备上同时更改,Automerge会自动将更改干净地合并在一起,这样每个人都会处于相同的状态,并且不会丢失任何更改。

自动合并跟踪您对状态所做的更改,以便您可以查看旧版本、比较版本、创建分支,以及选择何时合并它们。

网络不可知论者。Automerge是一个纯数据结构库,它不关心您使用的网络类型。它适用于任何面向连接的网络协议,可以是客户端/服务器(如WebSocket)、对等(如WebRTC)或完全本地(如蓝牙)。特定网络技术的绑定由单独的库处理;有关示例,请参阅“发送和接收更改”一节。它也适用于单向消息:你可以通过电子邮件附件或邮件中的USB驱动器发送自动合并文件,收件人可以将其与他们的版本合并。

不变的状态。自动合并对象是应用程序状态在某个时间点的不可变快照。无论何时进行更改,或合并来自网络的更改,都会返回一个反映该更改的新状态对象。例如,这一事实使Automerge与React和Redux的功能反应式编程风格兼容。

自动合并。自动合并是一种无冲突的复制数据类型(CRDT),它允许自动合并不同设备上的并发更改,而无需任何中央服务器。它基于对JSON CRDT的学术研究,但Automerge中的算法细节与JSON CRDT论文不同,我们计划在未来发布更多细节。

相当便携。我们';我们还没有努力支持旧平台,但我们已经在Node中测试了融合。js、Chrome、Firefox、Safari、MS Edge和Electron。对于TypeScript用户,Automerge附带了类型定义,允许您以类型安全的方式使用Automerge。

Automerge旨在创建本地第一软件,即处理用户的软件#39;将其数据的本地副本(在自己的设备上)作为主副本,而不是将数据集中在云服务中。本地优先的方法允许脱机工作,同时允许多个用户实时协作,并跨多个设备同步他们的数据。通过减少对云服务的依赖(如果有人停止支付服务器的费用,云服务可能会消失),local first软件可以拥有更长的使用寿命、更强的隐私和更好的性能,并让用户对其数据拥有更多的控制权。关于local first software的文章更详细地介绍了Automerge背后的理念,以及这种方法的优缺点。

然而,如果你想在一个集中服务器上使用Automerge,那也可以!您仍然可以获得一些有用的好处,例如允许多个客户端同时更新数据,客户端和服务器之间轻松同步,能够检查应用程序的更改历史记录';支持分支和合并工作流。

如果你';重新使用npm,npm安装自动合并。如果你';重新使用纱线,纱线添加自动合并。然后可以使用require(';自动合并';)导入它如下面的示例所示(如果使用ES2015或TypeScript,则从';自动合并';导入*作为自动合并)。

纱线构建-创建捆绑的JS文件dist/automerge。用于web浏览器的js。它包括依赖项,并设置为可以通过脚本标记加载。

//这是在节点中加载自动合并的方式。在浏览器中,只需包含//script标记即可设置自动合并对象。const Automerge=require(';Automerge';)//让';假设doc1是设备1上的应用程序状态。//再往下走';我将模拟第二台设备。//我们初始化文档,使其最初包含一个空的卡片列表。让doc1=Automerge。from({cards:[]})//doc1对象被视为不可变的——您绝不能直接更改它。要更改它,需要调用自动合并。带有回调//的change(),您可以在其中改变状态。您还可以包括一个人类可读的//更改描述,比如提交消息,它存储在//更改历史记录中(见下文)。doc1=自动合并。change(doc1,';Add card';,doc=>;{doc.cards.push({title:';在Clojure中重写所有内容';,done:false})//现在doc1的状态是://{cards:[{title:';在Clojure中重写所有内容';,done:false}]//Automerge()还定义了一种插入方法,用于在//列表中的特定位置。如果愿意,也可以使用splice()。doc1=自动合并。更改(doc1,';添加另一张卡片';,doc=>;{doc.cards.insertAt(0,{title:';重写Haskell中的所有内容';,done:false})/{cards://{title:';重写Haskell中的所有内容&#完成:false},/{title:&';重写Clojure中的所有内容&#完成:false};s模拟另一个设备,其应用程序状态为doc2。我们//单独初始化它,并将doc1合并到其中。合并后,doc2//拥有doc1中所有卡片的副本。让doc2=自动合并。init()doc2=Automerge。merge(doc2,doc1)//现在在设备1上进行更改:doc1=Automerge。更改(doc1,';将卡片标记为已完成';,doc=>;{doc.cards[0].done=true})/{cards://{title:';在Haskell中重写所有内容';,完成:true},/{title:';在Clojure中重写所有内容';,完成:false}//,并且在设备1未知的情况下,也在设备2上进行更改:doc2=Automerge。改变(doc2,';删除卡片';,doc=>;{Delete doc.cards[1]})/{cards:[{title:';重写Haskell中的所有内容';,done:false}///现在是关键时刻。让';s将设备2/的更改合并回设备1。你也可以用另一种方式合并,你';我会得到同样的结果。合并的结果记住';重写//Haskell'中的所有内容;设定为真,这';用Clojure'改写一切;已//删除:让finalDoc=Automerge。合并(doc1,doc2)/{cards:[{title:';在Haskell中重写所有内容';,done:true}]}//作为最后一个技巧,我们可以检查更改历史。自动合并//自动跟踪每次更改,以及";提交消息";//你传给了change()。当您查询该历史记录时,它既包括//您在本地所做的更改,也包括来自其他设备的更改。您//还可以在//过去的任何时刻查看应用程序状态的快照。例如,我们可以计算每个点有多少张牌:自动合并。getHistory(finalDoc)。映射(state=>;[state.change.message,state.snapshot.cards.length])/[&[39;Initialization&[39,0],/[&[39;Add card';,1],/[39;Add card';,2],/[&[39;Mark card as done';,2],/[39;Delete card&[39;]

自动合并。from(initialState)创建一个新的自动合并文档,并用对象initialState的内容填充它。

自动合并文档必须视为不可变。它永远不会直接更改,只有通过自动合并。更改功能,如下所述。

目前,由于性能成本的原因,Automerge没有强制执行这种不变性。如果要使文档对象严格不可变,可以传递一个选项:Automerge。初始化({freeze:true})或自动合并。加载(字符串,{freeze:true})。

自动合并。更改(doc、message、changeFn)允许您修改自动合并文档文档,并返回文档的更新副本。

传递给自动合并的changeFn函数。change()是通过doc的可变版本调用的,如下所示。

可选的message参数允许您将任意字符串附加到更改,该更改不会被自动合并解释,而是保存为更改历史的一部分。您可以省略messageargument,只需调用Automerge。更改(文档,回拨)。

newDoc=自动合并。change(currentDoc,doc=>;{//注意:永远不要直接修改'currentDoc',只更改'doc'!doc.property=';value';//为属性doc[';property';]指定字符串值='价值';//相当于上一行删除文档[';属性';]//删除属性//支持所有JSON基元数据类型。stringValue=';价值';医生。numberValue=1个文档。布尔值=真文档。空值=空文档。nestedObject={}//创建一个嵌套对象文档。嵌套对象。财产=';价值';//还可以指定已经具有某些属性的对象。otherObject={key:';value';,number:42}//完全支持数组。list=[]//创建一个空的list对象doc。列表push(2,3)//push()将元素添加到结束文档中。列表unshift(0,1)//unshift()在文档的开头添加元素。列表[3]=数学。PI//通过索引覆盖列表元素//现在是文档。列表是[0,1,2,3.141592653589793]//在列表上循环就像你';d expect:for(让i=0;i<;doc.list.length;i++)doc。list[i]*=2//now doc。清单是[0,2,4,6.283185307179586]文件。列表拼接(2,2和#39;自动合并和#39;)//现在医生。列表是[0,';你好';';自动合并';,4]doc。列表[4]={key:';value';}//对象也可以嵌套在列表中//自动合并中的数组提供了方便的函数“insertAt”和“deleteAt”doc。列表插页(1,';你好';';世界';)//在给定的索引文档中插入元素。列表deleteAt(5)//删除给定索引处的元素//现在是doc。列表是[0,';你好';';世界';,2,4])

自动合并返回的newDoc。change()是一个常规JavaScript对象,包含您在回调中所做的所有编辑。文件中您未提及的任何部分';改变不会被改变。它唯一的特点是:

每个对象都有一个唯一的ID,可以通过将对象传递给自动合并来获得该ID。getObjectId()函数。自动合并使用此ID跟踪哪个对象是哪个对象。

对象还包含有关冲突的信息,当多个用户同时更改同一属性时会使用这些信息(见下文)。您可以使用自动合并获取冲突。getConflicts()函数。

如果您以前在JavaScript中使用过不可变状态,您可能会习惯使用以下习惯用法:

状态=自动合并。更改(state,';Add card';,doc=>;{const newItem={id:123,title:';重写Rust中的所有内容';,done:false}doc.cards={id:[…doc.cards.ids,newItem.id],entications:{…doc cards.enties,[newItem.id]:newItem})

虽然这种模式在自动合并之外运行良好,但请不要';不要在自动合并中这样做!请使用可变习惯用法来更新状态,如下所示:

状态=自动合并。更改(state,';Add card';,doc=>;{const newItem={id:123,title:';重写Rust中的所有内容';,done:false}doc.cards.ids.push(newItem.id)doc。卡。实体[newItem.id]=newItem})

即使您使用的是变异API,Automerge也会确保上面的代码不会实际改变状态,而是返回反映更改的状态的新副本。第一个例子的问题是来自Automerge';在s看来,您正在替换整个文档。卡片对象(以及其中的所有内容)带有一个全新的对象。因此,如果两个用户同时更新文档,Automerge将无法合并这些更改(相反,您只会在doc.cards属性上遇到冲突)。

第二个示例通过在细粒度级别进行更改来避免这个问题:使用ID向ID数组中添加oneitem。推送(newItem.id),并将一个项目添加到实体映射中,实体[newItem.id]=newItem。这段代码工作得更好,因为它准确地告诉Automerge您正在对状态进行哪些更改,并且此信息允许Automerge更好地处理不同用户的并发更新。

作为自动合并的一般原则,应该尽可能以最精细的粒度级别进行状态更新。唐';如果';您只修改该对象的一个属性;只需分配一个属性即可。

自动合并。save(doc)将自动合并文档doc的状态序列化为字节数组(Uint8Array),您可以将其写入磁盘(例如,如果重新使用node.js,则将其作为文件系统上的文件;如果在浏览器中运行,则将其写入IndexedDB)。序列化的数据包含文档的完整更改历史(有点像Git存储库)。

const actorId=';1234-abcd-56789-qrstuv和#39;const doc1=自动合并。init(actorId)const doc2=自动合并。from({foo:1},actorId)const doc3=Automerge。加载(str,actorId)

actorId是唯一标识当前节点的字符串;如果省略actorId,将生成arandom UUID。如果传入自己的actorId,则必须确保不能有两个具有相同参与者ID的不同进程。即使在同一台计算机上运行两个不同的进程,它们也必须具有不同的参与者ID。

除非你知道自己在做什么,否则你应该坚持默认设置,让actorId beauto生成。

自动合并库本身对网络层来说是不可知的——也就是说,你可以使用你喜欢的任何通信机制从一个节点到另一个节点进行更改。目前有几个选项,还有更多正在开发中:

使用自动合并。getChanges()和自动合并。applyChanges()手动捕获onenode上的更改并将其应用于另一个。这些更改被编码为字节数组(Uint8Array)。您还可以将这些更改的日志存储在磁盘上,以便将其持久化。

使用自动合并。generateSyncMessage()生成消息,通过任何传输协议(如WebSocket)发送消息,并调用Automerge。收件人上的receiveSyncMessage()来处理消息。同步协议以同步方式记录。医学博士。

还有一些外部库为自动合并提供网络同步;正在更新Automerge 1.0数据格式和同步协议。

//在一个节点上,让newDoc=Automerge。更改(currentDoc,doc=>;{//对文档进行任意更改})让更改=自动合并。getChanges(currentDoc,newDoc)//将更改作为字节数组网络广播。广播(更改)//在另一个节点上,接收字节数组let changes=network。receive()让[newDoc,patch]=自动合并。applyChanges(currentDoc,changes)/'patch'是对应用的更改的描述(一种差异)

注意自动合并。getChanges(oldDoc,newDoc)将两个文档作为参数:旧状态和新状态。然后返回自oldDoc以来在newDoc中所做的所有更改的列表。如果你想要一个文档中所有更改的列表,你可以调用Automerge。getAllChanges(文档)。

对应的自动合并。applyChanges(oldDoc,changes)将更改列表应用于给定文档,并返回应用了这些更改的新文档。Automerge保证,无论何时,只要任何两个文档应用了相同的更改集(即使更改以不同的顺序应用),那么这两个文档都是相等的。这种特性被称为收敛,它是自动合并的本质。

自动合并。merge(doc1,doc2)是一个相关的函数,用于测试。它查找doc2中出现但doc1中未出现的任何更改,并将其应用于doc1,返回doc1的更新版本。有关使用自动合并的示例,请参见上面的用法部分。合并()。

自动合并允许不同的节点独立地对文档的各自副本进行任意更改。在大多数情况下,这些变化可以毫无困难地结合起来。例如,如果用户修改了两个不同的对象,或者同一个对象中的两个不同属性,那么将这些更改合并在一起是向前的。

如果用户同时插入或删除列表中的项目(或文本文档中的字符),AutoMerge将保留所有插入和删除操作。如果两个用户同时在同一位置插入,Automerge将任意将其中一个插入放在第一位,另一个放在第二位,同时确保所有节点上的最终顺序相同。

自动合并无法自动处理的唯一情况是,当用户同时更新同一对象中的同一属性(或者,类似地,同一列表中的sameindex)时,因为没有明确定义的解决方案。在这种情况下,Automerge会任意选择其中一个并发writenValue作为";获胜者";:

//使用已知的参与者ID初始化文档,让doc1=Automerge。更改(Automerge.init(';actor-1';),doc=>;{doc.x=1})让doc2=Automerge。更改(Automerge.init(';actor-2';),doc=>;{doc.x=2})doc1=Automerge。合并(doc1,doc2)doc2=自动合并。merge(doc2,doc1)//现在,doc1可能是{x:1}或{x:2}——选择是随机的。//然而,doc2将是相同的,无论选择哪个值作为赢家。明确肯定deepEqual(doc1、doc2)

虽然只有一个并发写入的值显示在对象中,但其他值不会丢失。它们只不过是一个冲突对象。假设医生。选择x=2作为";获胜";价值:

doc1/{x:2}doc2/{x:2}自动合并。getConflicts(doc1和#39;x和#39;)//{'1@01234567': 1, '1@89abcdef': 2} 自动合并。getConflicts(doc2和#39;x和#39;)//{'1@01234567': 1, '1@89abcdef': 2}

在这里,我们';我在属性x上记录了冲突。getConflicts返回的对象包含冲突值,包括";获胜者";以及";失败者";。您可以使用Conflicts对象中的信息在用户界面中显示冲突。冲突对象中的键是更新属性x的操作的内部ID。

下次分配给冲突属性时,冲突将自动被视为已解决,并且冲突将从自动合并返回的对象中消失。getConflicts()。

自动合并文档会在内部保存所有更改的完整历史记录。这实现了一个很好的功能:查看过去时间点的文档状态,也称为";提姆

......