作为研发人员,大家或多或少可能都在博客网站上编辑过博客。也可能大家会使用一些笔记应用,记录一些事情。无论是博客网站,还是笔记 APP,大家都会编辑内容,然后这些内容会同步到云端。这样,只要有网络,你就可以随时随地,在任何设备上查看这些内容。
如果我们要设计这个博客网站的编辑功能,我们会有哪些方案呢?
上述的几个设计,基本上可以比较好的解决一个普通博客网站,或者是笔记类产品的编辑需求。但是如果考虑一些更高级的场景,比如大家一起编写一篇周会文档的情况。在这个场景下,会有不止一个操作者,对着同一篇文档几乎同时进行编辑。这种情况下,上述的几个设计,都会有一些很严重的问题。
首先(1)和(2)本质上都是进行覆盖的操作。在这种设计下的问题是,我这边刚粘贴进一句话进来,转眼别人打了一个换行,就把我这一句话给覆盖掉了,然后我就需要重新输入这句话。这样的情况如果在周会上一直重复出现,肯定是无法被使用者所容忍的。
(3)这种根据下标的处理方式,看起来可行性会更高一些,但是仍然会有如下一些问题。
easysync is good.
这时用户 A 和用户 B 都想对这段话进行修改。The easysync is good.
如果使用下标来表示用户 A 的操作,那就是:在下标0处,增加4个字,内容是 The
和一个空格。easysync is very good.
那 B 的操作就是在下标12处,增加5个字,内容是 very
和一个空格。The easysync is good.
随后,服务端收到了 B 的提交:在下标12处,增加5个字。这时,服务端存储的内容就会变成 The easysyncvery is good.
很明显,服务端最终存储的这段话的内容完全不符合用户 B 对 good 这一形容词添加副词very 进行修饰的预期。easysync is good.
用户 A 和用户 B 都想对这段话进行修改。easysync is very good.
那 B 的操作就是:在下标12处,增加5个字,内容是 very
和一个空格。而且 B 的操作先达到了服务端,服务端将文档内容正确地进行了更新。easysync is very good.
同样提交了:在下标12处,增加5个字,内容是 very
和一个空格,这样的一个变更记录。那最终,文档的最新内容会变成 easysync is very very good.
很明显,这并不符合用户 A 的预期。The easysync is good.
但是由于还没有到定时提交的时间,用户 A 增加的 The
几个字还在本地,这时用户 A 的页面收到了服务端发送的消息,说用户 B 对文档内容做了变更:在下标12处,增加5个字,内容是 very
和一个空格。如果客户端直接应用这个变更的话,那用户A 的页面就会展示成The easysyncvery is good.
显然,再次产生不符合预期的内容。所以如果使用基于下标进行编辑的设计,会有如上的两个问题,这两个问题我们可以概括一下:
协同处理算法,就是处理上述场景中,编辑冲突问题的一类算法。
OT算法是协同处理算法中常见的一种,OT是operation transform的简写,中文可理解为操作转换。这类算法的主要思想是,将用户提交的编辑数据基于一些规则进行转换,使其从不符合语义的提交,变为符合语义的提交。比如上述的问题(1):服务端通过既定的规则,将用户B提交的数据进行一些转换,而后服务端应用转换后的编辑数据,得到相对更加符合语义的文档内容。
CRDT算法是另一种协同处理算法,全称是Conflict-free Replicated Data Types ,直译的话即冲突避免可复制数据类型。这种算法的主要是为了实现分布式系统下数据最终一致的问题,所以其可以广泛的用于其他系统中,而不仅仅是文档协同处理。关于 CRDT 的具体内容大家可自行了解。
而本文主要讲述的easysync协同算法是OT算法的一种。同样属于OT算法这一类的还有ot-json算法。这两种算法都是基于OT的思想处理编辑冲突,只是具体描述文档和编辑信息的数据结构有所不同。一般而言,easysync算法适合处理文本类内容,比如传统WPS文档。而ot-json更适合处理结构化数据,比如json数据。
这部分,我们会讲解 easysync 的算法思想,是如何解决如上的两个协同冲突问题的。首先,我们需要简单定义一些关键的数据结构。请注意easysync 算法并不给定这些数据结构的详细格式,只是给定了这些数据结构的一些限制,只要满足限制都是可以用作 easysync 的数据结构。
这部分只是简单阐述了其中的一些逻辑,详细的 Follow 算法的实现,我们将会在后续定义了具体的数据结构后在进行描述。
在协同处理上,服务端需要处理的最核心的两件事情如下:
对于3这一操作,会遇到最开始我们提出的问题:
我们在引入 Follow 算法时,曾经拿这一问题进行举例,我们这里再次阐述这个问题的解决方式:
当前服务端文档状态为 X,当服务端收到了基于 X 的 changeset A,并成功apply 变成了 XA 之后,又收到了基于 X 的 B,这时服务端应该首先计算 f(A,B),然后在 XA 上 apply 得到的 f(A,B),即:XAf(A,B)的计算方式,得到最新的文档状态。
在基于这个方案的基础之上,服务端的操作3又可以细化为这样几个步骤:
客户端的处理相较于服务端会更加复杂一些。服务端的问题在于处理不同客户端提交的 changeset。而客户端,不止需要处理收到的不同客户端已提交的 changeset 数据,还需要处理本地环境新的输入,并将其转换为 changeset 提交到服务端。
客户端需要进行的操作可以归类为如下几种:
而我们先前概括的那个问题:
其实就是2、3、5这些操作依次处理时,应该采取何种处理方式的一个问题。
我们用先前的定义重新描述这个问题,并从操作1开始,推导客户端的变化。
客户端本地状态的维护( AXY 模型):
客户端链接服务端并请求初始文档,我们定义初始文档状态为 A
这时用户可能会输入一些内容,并生成 changeset,我们将这些内容定义为 Y
间隔一段时间后,客户端需要将changeset提交到服务端,我们将其定义为 X,其中 X <- Y ,即 X 为用户刚刚输入的内容。然后将 Y 置空,重新接受用户的输入,即 Y <- In .
所以客户端可以用三种类型的 changeset 来维持自身的状态,即 AXY
新的本地编辑:
当用户进行本地编辑生成了 changeset E 的时候,客户端会计算 Y 与 E 的合并,并将 Y 更新为 YE,即:Y <- YE。
向服务器提交 changeset:
当客户端向服务器提交本地变更时,它会传送 Y 的一个副本,并将 Y 赋值给 X,重新以初值初始化 Y。
注意,客户端提交 changeset 的操作是一个阻塞的操作,也就是在没有收到服务端对 X 的 ACK 之前,Y 永远不会成为新的 X。
接受来自服务器的 ack:
当客户端接收到来自服务器的 ACK 信息时,会做如下处理。
接受并处理其他客户端提交的 changesets:
在 AXY 的状态下,客户端除了自身提交 changeset 和接受服务端的 ACK 之外,还有可能收到其他客户端提交的 changeset,我们称之为 B。由于 B 一定是服务端落盘的数据,所以我们需要更新 AXY 的内容,假设新的内容为A'X'Y'。另外,由于 AXY 是已经展示给用户的状态了,所以无法直接用 A'X'Y'替换展示,需要重新计算一个新的 changeset,我们称之为 D,将其直接应用到AXY 上,使其展示的内容与 A'X'Y'一致,即:AXYD = A'X'Y'。
对于 A'X'Y' 和 D 的计算,可以按照如下的方式进行推导:
当收到其他客户端提交的 changeset B 时,客户端按照如上的计算公式,分布计算出 A'X'Y'和 D,将 D 作用于当前展示的数据,得到新的展示。将A'X'Y'替换 AXY 作为新的 AXY 模型。
接收changeset发现本地版本过低:
在上述处理接收的其他客户端的 changeset B 的时,我们定义本地状态为 AXY。但是上述过程的推导,基于的一个前提是 B 和 X 都是基于 A 的变更。如果客户端收到了一个 B,发现B 是基于比 A 更新的版本的变更(中间的变更数据可能因为网络原因没有收到),这时客户端可以按照如下的步骤进行处理:
一般而言,easysync 中文档的数据结构主要就是由 apool,text,attribs 组成的。
apool的全称为attribute pool,属性池的意思,它有个numToAttrib的字段,是个map对象。 其中,这个 map 的 value 代表属性,用来描述一段文字,key 是属性的序号,用来找到某个属性。而属性一般是由一个两项的字符串数组组成。第一项代表属性的 key,第二项代表属性的具体的值。除此之外 apool 中还有nextNum字段,代表下一个属性的序号,增加属性时使用。
{ "numToAttrib": { "0": ["author", "001"], "1": ["color", "red"], }, "nextNum": 2 }
text 代表文档中的纯文本内容。在文档结构中,有一个单独的字段描述 text。在changesets 中,可以看到文本一般是跟在一个 $ 符号后面。
attribs是将apool和text关联起来的桥,它是个字符串,以行的形式描述了整篇文档的构成,是 easysync 的数据结构中理解成本比较高的一种定义。
这里我们举个例子:
{ "apool": { "nextNum": 3, "numToAttrib": { "0": [ "author", "001" ], "1": [ "color", "red" ], "2": [ "font", "Source Han Sans" ] } }, "text": "easysync 算法详解\n一、从场景出发\n", "attribs": "*0*1+8*0*2|2+d" }
changeset 的主要内容也是由apool 和类似 attribs 和 text 的结构组成,
这里我们举一个增加字符的 changeset 的例子:
"Z:go>2|m=dz=4*0+2$fd"
通过观察可以发现和文档的描述相比,changeset 中没有 text 字段和attribs字段。这两个字段相当于组合到了一起,而attribs的含义其实是和文档的是一样的,但是 changeset 又多了一些特殊的字符,其中:
上面的是一个增加字符的 changeset,相对的,还会有删除字符的 changeset
"Z:q<3|2=e=5-3$"
和增加字符的 changeset相比,有这样几处不同:
在上边,我们提到过,easysync 的论文中给出了 Follow 算法三个建议的实现逻辑:
当然实际上,具体的逻辑还不止这三种情况。观察 changeset 的数据结构我们可以发现,一般而言,changeset 包括了 '+', '=' ,'-' 这三种操作。算法处理时,我们可以认为是对两个 changeset 种的 ops 数组进行按位的比较与运算,其中每一位的操作符就是 '+', '=' ,'-' 。 ,所以实际的处理逻辑可以认为有9种组合(有些组合是相同的处理逻辑)。
我们可以将上述处理逻辑简单整理为一份伪代码。这份伪代码刚好涵盖了9种组合情况。需要注意的是,为了着重表现9种组合情况的处理逻辑,这份伪代码没有表现:A 的 ops 处理完,将剩余的B 直接追加到结果中的这块逻辑,也就是上述序号5的逻辑。同时这份伪代码也没有关注 changeset 的每一个 OP的属性在 Follow 过程中的处理。
var res for i, j := 0, 0; i < len(A) || j < len(B); { if A[i].Op == '+' { // 覆盖 ++ +- += 的组合 A[i].Op = '=' // A 为 '+',则将 A 的操作修改为 '=',追加到结果中,按位操作下一个 A res = append(res, A[i]) i++ } else if B[j].Op == '+' { // 覆盖 -+ =+ 的组合 res = append(res, B[j]) // B 为 '+',则直接将 B 追加到结果中,按位操作下一个 B j++ } else if A[i].Op == '-' { // 覆盖 -= -- 的组合 if A[i].CharNum < B[j].CharNum { // 如果 A 的字符数更少 B[j].CharNum -= A[i].CharNum // 则将 B 的字符数相应减小,相当于覆盖了 A 的操作,然后操作下一个 A i++ } else if A[i].CharNum > B[j].CharNum { // 反之,如果 B 的字符数更少 A[i].CharNum -= B[j].CharNum // 则将 A 的字符数相应减小,相当于覆盖了 B 的操作,然后操作下一个 B j++ } else { // 如果两者字符数相等,相当于刚好抵消,全部跳到下一个处理 i++ j++ } } else if B[j].Op == '-' { // 覆盖 =- 的组合 if A[i].CharNum < B[j].CharNum { // 如果 A 的字符数更少,那 B 将这些字符删除后,有可能会继续删除一些字符 newOp := {CharNum: A[i].CharNum, Op: '-'} // 先构造一个新的'-' op,相当于删除 A 的保留的数据 res = append(res, newOp) // 新的 Op 追加到结果中 B[j].CharNum -= A[i].CharNum // 而 B 则相应减去删除的 A 的字符数 i++ // 然后操作下一个 A } else if A[i].CharNum > B[j].CharNum { // 反之,如果 B 的字符数更少 A[i].CharNum -= B[j].CharNum // 则将 A 的字符数相应减小,相当于覆盖了 B 的操作,然后操作下一个 B j++ } else { // 如果两者字符数相等,相当于刚好抵消,全部跳到下一个处理 i++ j++ } } else { // 覆盖 == 的组合 if A[i].CharNum < B[j].CharNum { // 如果 A 的字符数更少,则 A 为共同部分,将 A 追加到结果中 B[j].CharNum -= A[i].CharNum // 同时将 B 的字符数相应减小,继续操作下一个 A res = append(res, A[i]) i++ } else if A[i].CharNum > B[j].CharNum { // 如果 B 的字符数更少,则 B 为共同部分,将 B 追加到结果中 A[i].CharNum -= B[j].CharNum // 同时将 A 的字符数相应减小,继续操作下一个 B res = append(res, B[j]) j++ } else { // 如果字符数相同,说明两者一致,追加任意一个,全部跳到下一个处理 res = append(res, B[j]) i++ j++ } } } return res
Apply算法也是 easysync 中比较重要的一个算法。相比于 Follow 算法而言,apply 算法可能会相对简单一些,因为 apply 算法是将一条 CS 作用于一篇文档上,而文档的操作符只有 '+'。而且 apply 的概念上,也是比 follow 更直观的。
apply 一般要包括两部分的操作,一部分是 text 的apply,一部分是 attribs 的 apply。
text 的 apply 操作比较简单,我们假设游标处于文档原 text 的0处,然后开始按位处理 changeset 的 op。遇到 '=' 操作,就将游标后移 '=' 操作影响的字符数长度,并将这部分字符追加到结果中,相当于保留。如果遇到 '-' 操作,就只将游标 '-' 操作符影响的字符数长度,但是不追加这部分字符到结果中,相当于删除。如果遇到 '+' 操作符,则游标不变,直接将 op 中的字符追加到结果中。
如果使用伪代码简单描述,则是如下这样:
var res textIndex := 0 // 初始化游标 for i := 0; i < len(CS); i++ { if CS[i].Op == '+' { // 如果是 '+' 操作符,游标不变,将 CS 中的字符追加到结果中 res = append(res, CS[i].CharBank) } else if CS[i].Op == '=' { // 如果是 '=' 操作符,将原 Text 中相应位置的字符追加到结果中,并移动游标 res = append(res, oldText[textIndex:textIndex+CS[i].CharNum]...) textIndex += CS[i].CharNum } else if CS[i].Op == '-' { // 如果是 '-' 操作符,则只移动游标,跳过原 text 这部分字符 textIndex += CS[i].CharNum } } return res
attribs 部分的 apply 操作,我们可以像处理 follow 算法那样,对于文档和 changeset 的 ops数组进行逐个按位的处理。
由于文档的 attribs 解析之后只有 '+' 操作符,而changeset 包括了 '+', '=' ,'-' 这三种操作,所以组合起来相当于一共有3种操作。我们从changeset 不同 Op 的操作简述这3种组合的处理逻辑。
将上述逻辑简单整理成一份伪代码。注意这份伪代码同样没有表现上述序号4的逻辑,也没有关注 changeset 的每一个 OP的属性在 Apply 过程中的处理。
var res for i, j := 0, 0; i < len(CS) || j < len(Doc); { if CS[i].Op == '+' { // CS 是插入,则直接将 CS 的 op 追加到结果中 res = append(res, CS[i]) i++ } else if CS[i].Op == '-' { // CS 是删除 if CS[i].CharNum < Doc[j].CharNum { // 如果删除的字符数更少 Doc[j].CharNum -= CS[i].CharNum // 则将文档该 OP 的字符数相应减小,然后操作CS 下一个 OP i++ } else if CS[i].CharNum > Doc[j].CharNum { // 反之,如果文档该 OP的字符数更少 CS[i].CharNum -= Doc[j].CharNum // 则将删除OP的字符数相应减小,相当于文档的这个 OP 被完全删除掉 j++ // 然后操作下一个文档的 OP } else { // 如果两者字符数相等,说明删除操作刚好删除了文档这个 OP,全部跳到下一个处理 i++ j++ } } else if CS[i].Op == '=' { // CS 是保留操作 if CS[i].CharNum < Doc[j].CharNum { // 如果 CS 的字符数更少,则需要将文档的 Op 进行拆分 newOp := new{CharNum: CS[i].CharNum, Op: Doc[j].Op} // 构造一个新 Op,新 Op 的其他内容都和文档这个 Op 相同,只有字符数是 CS Op 的字符数 Doc[j].CharNum -= CS[i].CharNum // 原先文档的 Op 字符数响应减小,然后开始操作 CS 下一个 OP res = append(res, newOp) // 新 Op 追加到结果中 i++ } else if CS[i].CharNum > Doc[j].CharNum { // 如果文档 Op 的字符数更少,则文档的这个 Op 全部保留,追加到结果中 CS[i].CharNum -= Doc[j].CharNum // 同时将CS OP 的字符数相应减小,继续操作文档下一个 OP res = append(res, Doc[j]) j++ } else { // 如果字符数相同,说明 Doc 的这个Op 刚好被全部保留下来,追加到结果中 res = append(res, Doc[j]) i++ j++ } } else { // CS 中出现其他操作符,则是异常情况了 error } } return res