4

1 背景

疫情的原因推进了在线面试的应用场景,除了音视频通话技术,对于IT行业的面试,必不可少的要写写代码,所以其中一项重要功能是协同编辑,下面就让来分析一下协同编辑功能的实现方案和OT算法的应用。

2 协同编辑场景

协同编辑的场景是在同一房间内,所有人都可同时操作编辑器内容,关键是让所有人看到的内容是一致的,这里面涉及的技术就包括了web编辑器,消息的广播和保证内容一致的算法,我们今天讨论的是保证内容一致的算法。

让我们看下实现保证内容一致的一些思路:

  • 思路1: 全量覆盖
    这种方法就是简单粗暴,每次都把编辑器的内容全量广播给其他人,这样在多人同时操作的时候就会有相互覆盖的现象,导致无法同时编辑,所以是不可取的。
  • 思路2: 加操作锁
    既然同时编辑会有相互覆盖的问题,那就加个操作锁,每次改变之前之前先抢一下锁,然后再改变,这种思路不是实现同时编辑,而是强制不同时编辑,会导致体验很差,明明输入了但是因为没有抢到锁而不生效。
  • 思路3: 增量操作
    既然全量传递会相互覆盖,那就增量的传递,只传递这次改变了什么,比如我在第2个字符插入了'xx'等等,这样虽然不会全量覆盖了 但是同时操作会导致内容不一致,举例如下图:

image.png

两个用户,开始的内容都是AAAZ,同时操作,经过传递和转换之后两端的内容就不一致了。
OT协同算法的核心就是解决该问题。

3 OT算法

OT算法的关键技术点总结为四点:定义原子操作、版本确认机制、操作转换、客户端状态转移,下面就分别讲解。

3.1 定义原子操作

OT算法中对于内容的操作抽象为三种:

  • Retain 保持操作,用大于0的数字表示 记录要保持的字符数量
  • Insert 插入操作,用插入的字符串表示
  • Delete 删除操作,用小于0的数字表示 -n就是删除n个字符

每次对于整个文本的处理都可以用一个有上述3中原子操作的数组表示。下面我举一些直观的例子。

  • "" -> "A" ,操作数组为 ["A"] 只有一个Insert
  • "A" -> "AB" ,操作数组为 [1,"B"] 保持一个字符,然后插入B
  • "ABC" -> "AB" ,操作数组为 [1,-1,1] 保持一个字符,删除一个字符,然后再保持一个字符

编辑器和服务端之间传输的就是这种操作数组,当收到之后会将其转换为操作对象,定义如下:

function TextOperation () {
    this.ops = [];
    this.baseLength = 0;
    this.targetLength = 0;
  }

其中除了 this.ops记录操作数组之外,还增加了两个属性this.baseLengththis.targetLength

  • this.baseLength表示该操作前文本的长度,会用于操作转换时的校验。
  • this.targetLength表示该操作应用在文本后,文本的长度。

举个复杂的例子:

  • "123456789" -> "123A89" ,操作数组为 [3,"A",-4,2],转成的操作对象为

    {
    ops: [3,"A",-4,2],
    baseLength: 9,
    targetLength: 6
    }

3.2 版本确认机制

在OT协同算法中,服务端作为一个记录文档内容和所有历史版本操作的中心,当有新的客户端建立连接时首先进行一次同步,之后客户端每次发送一个操作时 还要同时发送本次操作时基于什么版本进行的更新,这样服务端会从该版本开始进行操作转换。客户端每次的操作不能直接修改当前的版本,而是要收到服务端的确认之后才能进行版本的更新,因为客户端不记录所有历史的操作和版本的对应关系,通过未确认的状态来判断是否需要操作转换。举例如下图:
image.png

3.3 操作转换

操作转换是OT算法的核心,它的核心公式是:

[A`,B`]  = transform(A,B)
    
apply(apply(S, A), B') = apply(apply(S, B), A')

AB两个操作,经过转换之后生成A`和B`, 使得对于相同的内容S 经过A和B`的应用结果 等于 经过B和A`的应用结果,具体transform代码参见开源项目:ot.js

这里有一个菱形表示法用于图形化的描述OT的操作转换过程:
image.png
我们可以这样理解,每一个点是一个状态,每一条边是一个操作,左边的永远是客户端的操作,
右边的永远是服务端的操作,两者在相同的状态下,客户端经过A和B`之后和服务端经过B和A`之后达到相同的状态。 这是最基本的一个版本冲突的菱形表示法,实际应用中会有更多的版本冲突,使用菱形表示法会把整个过程展示的很直观。

3.4 客户端状态转移

客户端每次操作不会立刻增加版本,而是要等待服务端的确认后增加版本,同时客户端的操作在没有被服务端确认之前,是不会继续发送新的操作,而是将要发送的操作进行缓存,等待确认后再进行发送,这里客户端就维护了三种状态,除了客户端的发送之外,还可能从服务端接受操作,不通的状态客户端会有不同的处理。

  • Synchronized, 已经同步状态,客户端没有待确认的操作。
  • AwaitingConfirm, 等待确认状态,客户端有等待服务端确认的操作。
  • AwaitingWithBuffer,等待确认状态,并且还有待发送的操作。

三种状态的转移如下图所示:
image.png

4 OT场景分析

以上讲解了OT算法的关键技术,下面就针对需要进行OT操作的场景进行分析。

4.1 客户端修改一次,服务端修改一次冲突

此时客户端的状态是AwaitingConfirm,而收到了服务端下发的操作,菱形表示法如下:
image.png
解释:客户端进行了A操作,服务端先进行了B操作,客户端进行OT之后 应用B`,等待的状态变为
A`, 而服务端收到A之后,同样与B进行OT,然后应用A`。

4.2 客户端修改一次,服务端修改多次冲突

此时客户端的状态是AwaitingConfirm,相比于4.1的情况,在收到修改确认之前,连续多次收到服务端的下发,菱形表示法如下:
image.png
客户端先后 应用B1`和B2`,可以接受更多的修改。

对于服务端来说,当收到客户端的操作A时,是连续进行转换,菱形表示法如下:
image.png
让操作A连续的和操作B1和B2进行转换,最终应用A``。

4.3 客户端修改多次,服务端修改一次冲突

此时客户端的状态是AwaitingWithBuffer,客户端变化的菱形表示法如下:
image.png
客户端进行了A操作后,还执行了A2操作,所以第一次OT之后 状态C不是最终状态,而是一种临时状态。B`和A2就要继续OT计算,达到状态D。同时注意这里从等待发送A2 变成了等待放A2`。
服务端则是先收到A操作后 应用A`,而后收到A``。

5 总结

OT算法和一整套完整的设计,确实解决了协同操作的问题,本文通过分析纯文本的协同,讲清楚最核心的关键技术点。对于更复杂的场景则需要设计的原子操作和操作转换算法也会更加的复杂。比如腾讯的在线文档,除了文本之外,还需要同步样式、图片元素等等。留一个问题:既然OT算法可以解决协同操作问题,为什么在使用腾讯在线excel的过程中发现多个人同时操作一个表格单元的时候,会出现相互覆盖的问题?

如果觉得有收获请关注微信公众号 前端良文 分享前端开发中的干货知识点。


传播正能量
238 声望30 粉丝