Open GarinZ opened 3 years ago
Operational Transformation(OT)是一个应用于协同编辑领域的并发控制和冲突解决系统,要解决的是“多个用户对同一个文本域的同一个版本进行编辑时如何处理”的问题。下文中简称冲突问题。
整体来看,OT解决并发编辑冲突问题的思路有以下几步:
接下来我使用ot.js的实现来详细解释一下冲突解决的思路
我们先来看看没有原子操作的世界。对有状态的UI Component来说其实并没有操作类型的概念,每次UI操作只会生成newValue在浏览器端通过e.target.value获取。如果进行粗略的oldValue和newValue的diff,在浏览器文本框中,用户可以进行以下UI操作:
e.target.value
"a" => "ba"
"a" => "ab"
"ab" => "acb"
"abbbbb" => "ac"
可以看到用户可在UI上操作种类太多、缺乏定义而且和UI组件绑定没有通用性。因此我们需要定义一些更简洁,更具有原子性的操作。在ot.js中定义的操作为:
ot.js
retain(Integer n)
insert(String str)
delete(String str)
定义了原子操作之后,就可以将UI操作表示为由原子操作组成的操作序列了:
// 头部插入:"a" => "ba" insert("b"); // 尾部插入:"a" => "ab" retain(1).insert("b") // 中间插入:`"ab" => "acb"` retain(1).insert("c") // 删除并插入:`"abbbbb" => "ac"`(选中bbbbb,输入c) retain(1).delete("bbbbb").insert("c")
调用上面的原子操作后,在ot.js中操作序列会维护在一个称为TextOperation的类中
TextOperation
// 删除并插入:`"abbbbb" => "ac"`(选中bbbbb,输入c) TextOperation to = new TextOperation().retain(1).delete("bbbbb").insert("c"); // to.ops = [Retain(1), Insert("bbb"), Delete("c")]
而TextOperation就是进行多端同步的最小单位,同时也实现了操作的可序列化
既然算法要解决冲突问题,先回顾一下什么是冲突问题:
冲突问题是:“多个用户对同一个文本域的同一个版本进行编辑时如何处理”的问题
先解释一下这里的关键词,正确理解冲突问题非常重要: 1、同一个文本域:对于不同的底层数据模型来说,“同一个文本域”的概念是不同的,对于<textarea></textarea>来说文本域的概念一定是这个文本框中的全部文本。但对于像Notion、Roam Research或者飞书这样的应用,它们的每一行都是一个Block,每个Block可以看做单独的textarea这时候“同一个文本域”这个概念也许就是文档中的每个Block,原子操作类型设计也会更加复杂(比如:Block的insert、move、delete,Block内嵌组件时,组件内如何协作),具体的定义还需要看应用的设计。但无论怎样,只有对同一个文本域进行的修改才是我们这里说的冲突。
<textarea></textarea>
textarea
2、版本:采用一个全局唯一且自增的数字标识,在协同编辑领域称为revision。生成新的TextOperation时,当前Client的revision加1。版本号一方面表示了文本所处的状态,另一方面也决定了操作是否可以应用于当前的文本上。(PS:之所以文本域的版本不采用字符串比较,避免ABA问题应该也是决定因素之一)。
已经理解了什么是冲突问题,那么从ot.js来看就是有两个作用在相同revision的文本域上的TextOperation,每个TextOperation中都带着一组原子操作类型序列。那么我们应该如何处理这两个TextOperation才能让冲突自动解决呢?
retain();insert();delete()
接下来用具体的例子解释通过增加操作解决冲突方法,两个操作都对0这个位置进行操作,通过冲突解决算法operationA和operationB被串行化,operationA中的insert操作先执行,operationB通过新增一个Retain()操作从而保留A操作的影响。生成了两个新操作,新操作中newOperationA和原来一样,newOperationB多了一个院子操作。
operationA
operationB
Retain()
// Text: "" // opeationA: "" => "a" operationA.ops = [Insert("a")]; // opeationB: "" => "b" opeationB.ops = [Insert("b")]; // 冲突解决 transform(operationA, operationB) // {newOperationA: [Insert("a")], newOperationB: [Retain(1), Insert("b")]}
假如我们只针对这一种情况编写transform方法的处理逻辑,代码可能会像下面这样
transform
function transform(operationA, operationB) { // 等待构建和返回的新操作 newOperationA = new TextOperation(); newOperationB = new TextOperation(); // operationA和operationB的原子操作序列 opsA = operationA.ops; opsB = operationB.ops; // 遍历opA和opB的指针 indexA = 0; indexB = 0; while (indexA < opsA.length && indexB < opsB.length) { if (opsA[indexA] instanceof Insert && opsB[indexB] instanceof Insert ) { op = opsA[indexA ++]; newA.ops.push(op); // op.value = "a" newB.ops.push(new Retain(op.value.length)); newB.ops.push(opsB[indexB ++]); } } return {newOperationA, newOperationB}; }
假如将操作应用到文本的方法我们定义为String apply(String str, TextOperation op),经过冲突解决后生成的新操作可以达到:
String apply(String str, TextOperation op)
apply(apply(str, operationA), newOperationB) === apply(apply(str, operationB), newOperationA)
通过分解原子操作和全部排列组合的transform方法代码可以参考:ot.js - text-operation.js
目前为止我们理解了什么是冲突、如何解决冲突。那么在具体的应用中,什么时候会发生冲突呢?这个问题其实和应用前后端数据流设计有关,在此我举出一个具体的例子进行讨论。 假如发生上面操作时,Client和Server的数据流设计方案如下所示:
在图中所有调用transform方法的地方都是冲突发生的地方。也就是说在实际的协同编辑应用中,冲突会在两种场景下发生:1)Server接收到Client上传的操作。2)Client接收到Server广播的操作。 在Client/Server协作时,还有几个事实我们需要意识到:
想要枚举并理解全部Case的话,查看Visualization of OT with a central server是最好的方式。
整体来看,OT是一种基于阻塞同步的多版本并发控制方案。
Mark
mark
Operational Transformation(OT)是一个应用于协同编辑领域的并发控制和冲突解决系统,要解决的是“多个用户对同一个文本域的同一个版本进行编辑时如何处理”的问题。下文中简称冲突问题。
整体来看,OT解决并发编辑冲突问题的思路有以下几步:
接下来我使用ot.js的实现来详细解释一下冲突解决的思路
定义原子操作类型
我们先来看看没有原子操作的世界。对有状态的UI Component来说其实并没有操作类型的概念,每次UI操作只会生成newValue在浏览器端通过
e.target.value
获取。如果进行粗略的oldValue和newValue的diff,在浏览器文本框中,用户可以进行以下UI操作:"a" => "ba"
"a" => "ab"
"ab" => "acb"
"abbbbb" => "ac"
(选中bbbbb,输入c)可以看到用户可在UI上操作种类太多、缺乏定义而且和UI组件绑定没有通用性。因此我们需要定义一些更简洁,更具有原子性的操作。在
ot.js
中定义的操作为:retain(Integer n)
: 保留n个字符,可以看作将字符串数组的指针移动到下标为n的位置insert(String str)
:在当前位置插入strdelete(String str)
:删除当前位置后面的str定义了原子操作之后,就可以将UI操作表示为由原子操作组成的操作序列了:
调用上面的原子操作后,在
ot.js
中操作序列会维护在一个称为TextOperation
的类中而
TextOperation
就是进行多端同步的最小单位,同时也实现了操作的可序列化理解冲突问题
既然算法要解决冲突问题,先回顾一下什么是冲突问题:
先解释一下这里的关键词,正确理解冲突问题非常重要: 1、同一个文本域:对于不同的底层数据模型来说,“同一个文本域”的概念是不同的,对于
<textarea></textarea>
来说文本域的概念一定是这个文本框中的全部文本。但对于像Notion、Roam Research或者飞书这样的应用,它们的每一行都是一个Block,每个Block可以看做单独的textarea
这时候“同一个文本域”这个概念也许就是文档中的每个Block,原子操作类型设计也会更加复杂(比如:Block的insert、move、delete,Block内嵌组件时,组件内如何协作),具体的定义还需要看应用的设计。但无论怎样,只有对同一个文本域进行的修改才是我们这里说的冲突。2、版本:采用一个全局唯一且自增的数字标识,在协同编辑领域称为revision。生成新的
TextOperation
时,当前Client的revision加1。版本号一方面表示了文本所处的状态,另一方面也决定了操作是否可以应用于当前的文本上。(PS:之所以文本域的版本不采用字符串比较,避免ABA问题应该也是决定因素之一)。设计冲突解决算法
已经理解了什么是冲突问题,那么从
ot.js
来看就是有两个作用在相同revision的文本域上的TextOperation
,每个TextOperation
中都带着一组原子操作类型序列。那么我们应该如何处理这两个TextOperation才能让冲突自动解决呢?retain();insert();delete()
排列组合,组成9种搭配。就相当于枚举了全部的冲突情况,针对每种情况设计解决方案后当冲突发生时算法就可以自动解决冲突。接下来用具体的例子解释通过增加操作解决冲突方法,两个操作都对0这个位置进行操作,通过冲突解决算法
operationA
和operationB
被串行化,operationA
中的insert操作先执行,operationB
通过新增一个Retain()
操作从而保留A操作的影响。生成了两个新操作,新操作中newOperationA和原来一样,newOperationB多了一个院子操作。假如我们只针对这一种情况编写
transform
方法的处理逻辑,代码可能会像下面这样假如将操作应用到文本的方法我们定义为
String apply(String str, TextOperation op)
,经过冲突解决后生成的新操作可以达到:通过分解原子操作和全部排列组合的
transform
方法代码可以参考:ot.js - text-operation.js多端冲突解决
目前为止我们理解了什么是冲突、如何解决冲突。那么在具体的应用中,什么时候会发生冲突呢?这个问题其实和应用前后端数据流设计有关,在此我举出一个具体的例子进行讨论。 假如发生上面操作时,Client和Server的数据流设计方案如下所示:
在图中所有调用
transform
方法的地方都是冲突发生的地方。也就是说在实际的协同编辑应用中,冲突会在两种场景下发生:1)Server接收到Client上传的操作。2)Client接收到Server广播的操作。 在Client/Server协作时,还有几个事实我们需要意识到:想要枚举并理解全部Case的话,查看Visualization of OT with a central server是最好的方式。
总结
整体来看,OT是一种基于阻塞同步的多版本并发控制方案。