前言
對前端來說開發一個在線文檔需要啥技術呢?想一下,開發一個在線文檔我們可能要解決的問題:
- 最基礎的文本編輯功能(哦?好像textarea就可以完成,那如果是富文本呢?)我們需要一個文檔模型來描述文檔;
- 富文本編輯器,提供富文本的編輯和渲染能力;
- 協同功能,不同的用戶對同一份文檔的編輯需要保持大家看到的都是一樣的;
- 協同網絡模型,保證服務器和客戶端之間的文檔模型一致;
名詞解釋
OT:一種解決協同問題的算法;
OP:operation的簡稱,在OT中指的是一次操作;
etherpad: 一個實現文檔協同功能的開源庫;
easysync: etherpad中實現文檔協同的核心算法,是OT算法的一種,主要用來處理文本協同;
ot-json:ot算法的一種,顧名思義,是主要用來處理結構化數據;
Changeset: 一種描述文檔更改的數據格式,用來表示整個文檔的一次修改;
ClientVars : 表示一篇文檔的初始化數據,一般由連續的changeset組合而成;
符號解釋
|
:移動光標;
·
:疊加;
正文
OT算法
什么是OT算法呢?我們先從頭說起,如果要實現一個多人共同編輯文檔的功能,我們最簡單暴力的做法是啥?
編輯鎖
顧名思義,假如A在編輯文檔,服務端直接將這個文檔加鎖,B如果在這個時候也加入了編輯,由於鎖的存在,B的編輯直接被丟棄。可以看出,這種編輯鎖的實現方式非常粗暴,體驗極其糟糕,當然了,在很多公司(比如我們的某死對頭公司)的一些wiki系統就是用這種實現方式,由於這種實現方式比較簡單,而且體驗很糟糕(內容丟失 & 無法實時),我們這里就不做討論了。
Linux中的diff-patch
Linux中有兩個命令:diff和patch;如果我們能在JS中實現這套算法,那么多人協同編輯可以這樣做:
- 用戶打開文檔后和服務端建立長鏈接,保存文檔副本;
- 用戶編輯的時候如果有停頓(比如3s),則將現有的文檔和副本進行diff對比,將結果傳給服務端,更新副本;
- 服務端更新文檔,將diff結果通過長鏈接通知到其它用戶,其它用戶使用patch方法更新本地的文檔;
我們來測試下:
# 本地文檔
$ echo '復仇者聯盟
鋼鐵俠
美國隊長' > test-local.txt
# 生成用戶A編輯后的文檔
$ echo '復仇者聯盟
鋼鐵俠
綠巨人' > test-userA.txt
# diff兩個文檔
$ diff test-local.txt test-userA.txt > diff-test.patch
# 查看diff-test.patch內容
$ cat diff-test.patch
3c3
< 美國隊長
---
> 綠巨人
從diff-test.patch內容可以看出,已經找出了兩個文檔不同的地方,然后我們再模擬下用戶B的行為:
# 生成用戶B編輯的文檔
$ echo '復仇者聯盟
黑寡婦
美國隊長' > test-userB.txt
# patch方法更新文檔
$ patch test-userB.txt < diff-test.patch
# 查看test-userB.txt內容
$ cat test-userB.txt
復仇者聯盟
黑寡婦
綠巨人
可以看到,用戶B文檔的第三行已經更新為了用戶A修改后的“綠巨人”。
- 但這種實現方式有個問題,因為他是基於行來進行對比的,就會導致很容易出現沖突,比如:
# 生成文件1
$ echo '復仇者聯盟' > local.txt
# 生成文件2
$ echo '復仇者聯盟鋼鐵俠' > userA.txt
# diff對比
$ diff local.txt userA.txt > diff.patch
查看diff.patch內容:
1c1
< 復仇者聯盟
---
> 復仇者聯盟鋼鐵俠
這就意味着如果兩個人同時修改同一行,那必然就會產生沖突,我們測試下:
# 生成文件3
$ echo '復仇者聯盟美國隊長' > userB.txt
# patch
$ patch userB.txt < diff.patch
以上我們發現,假如原始文檔是“復仇者聯盟”,用戶A修改為“復仇者聯盟鋼鐵俠”,將diff結果傳給服務端,服務端傳給用戶B,而用戶B只是將文檔改為了“復仇者聯盟美國隊長”,直覺上我們可以看出,這兩處是不沖突的,完全可以合並成“復仇者聯盟鋼鐵俠美國隊長”,但實際上的patch結果卻是這樣的:
$ cat userB.txt.rej
***************
*** 1
- 復仇者聯盟
--- 1 -----
+ 復仇者聯盟鋼鐵俠
因此這種基於行的算法還是比較粗糙,體驗上比編輯鎖雖然好了一點,但實際弊端還是比較大,既然基於行的實現無法滿足需求,那有木有可能去基於字符進行diff呢?
diff-patch算法
diff-match-patch[1]是另一種diff-patch算法的實現,它是基於字符去進行diff的,這里不介紹該算法的細節了,它的算法在這:diff-match-patch JS實現源碼[2]。我們直接測試下它的效果
// 示例1
const localText = '復仇者聯盟';
const userAText = '復仇者聯盟鋼鐵俠';
const userBText = '復仇者聯盟美國隊長';
// 結果為:復仇者聯盟鋼鐵俠美國隊長
// 示例2
const localText = '復仇者聯盟';
const userAText = '復仇者聯盟美國隊長';
const userBText = '復仇者聯盟鋼鐵俠';
// 結果為:復仇者聯盟鋼鐵俠美國隊長
// 示例3
const localText = '復仇者聯盟';
const userAText = '復仇者聯盟 美國隊長';
const userBText = '復仇者聯盟 鋼鐵俠';
// 結果為:復仇者聯盟 美國隊長 鋼鐵俠
如上示例已經解決了Linux的diff-patch基於行diff的弊端,但仍然存在問題,如上的示例1和示例2如果沒有符號分割,那么結果是一樣的。
const localText = '復仇者 Iron Man';
const userAText = 'Iron Man 鋼鐵俠';
const userBText = '復仇者 Caption';
// 結果為:Caption
原始文檔為“復仇者 Iron Man”,用戶A修改為了“Iron Man 鋼鐵俠”,用戶B修改為了“復仇者 Caption”,直覺上其實可以合並為“Caption 鋼鐵俠”,但實際上卻修改為了“Caption ”(注意Caption后面有個空格,鋼鐵俠沒了),也就是說diff-match-patch存在丟字符的情況,這個富文本格式的文檔中會是致命的問題,比如丟失了某個 > 可能整個文檔都會亂掉,那么有木有既解決了行匹配沖突問題又解決了丟字符問題的解決方案呢?答案就是本文的重點——OT算法
operation transformation
示例
ot.js[3]是針對純文本的一種JS實現,我們看下它的實現效果,針對同樣的示例:
const str = '復仇者 Iron Man';
const operation0 = new ot.TextOperation().delete('復仇者 ').retain(8).insert(' 鋼鐵俠');
const operation1 = new ot.TextOperation().retain(4).delete('Iron Man').insert('Captain');
const op = ot.TextOperation.transform(operation0, operation1);
// 結果:Captain 鋼鐵俠
可以看到這正是符合我們預期的結果。
原理
看了很多講OT的文檔,基本每一篇都很長,雲山霧罩,但其實它的核心原理很簡單。在OT中,我們將文檔的操作分為三個類型,通過組合這三個原子操作完成對整個文檔的編輯工作:
- insert(插入字符);
- delete(刪除字符)
- retain(保持n個字符,也就是移動光標);
注: 實際上diff-match-patch算法也將操作分為三類:insert,delete,equal(不變的字符),insert、delete和OT中含義類似,equal是指對比diff過程中那些沒有改變的字符,diff-match-patch會給這些不同類型的字符打標,后面patch的時候再根據不同類型的字符做對應的邏輯處理。
insert
|
復仇者聯盟|
如上|代表的是光標的位置,從上到下模擬用戶操作的行為,以上操作使用ot.js來描述:
const str = '';
const operation = new ot.TextOperation().insert('復仇者聯盟');
const result = operation.apply(str);
console.log(result); // 復仇者聯盟
op創建時會有一個虛擬光標位於字符的開頭,在一個op結束時,光標一定要在字符串的末尾,其中insert會自動移動光標位置,因此我們這里不需要手動去移動光標;
retain
|復仇者聯盟
復仇者聯盟|
復仇者聯盟鋼鐵俠|
如上過程用ot.js來描述:
const str = '復仇者聯盟';
const operation = new ot.TextOperation().retain(5).insert('鋼鐵俠');
const result = operation.apply(str);
console.log(result);// 復仇者聯盟鋼鐵俠
delete
|復仇者聯盟鋼鐵俠
復仇者聯盟|鋼鐵俠
復仇者聯盟|
如上過程用ot.js描述:
const str = '復仇者聯盟鋼鐵俠';
const operation = new ot.TextOperation().retain(5).delete('鋼鐵俠');
const result = operation.apply(str);
console.log(result);// 復仇者聯盟
刪除字符時可以輸入字符,也可以輸入字符數,實際上源碼中是直接取的'鋼鐵俠'.length
因此對於delete中字符串而言,只要長度正確就可以達到目的,上面代碼改成delete('123')
不會有任何影響。
transform
前面的代碼我們看到過ot.js的這個方法,正是這個方法實現了diff-match-patch的丟失字符的問題,而transform正是OT中的核心方法。我們先不羅列他的源碼,先看幾個例子:
示例1
原始文檔內容(空白文檔):|
用戶A編輯后的文檔內容:鋼鐵俠
用戶B編輯后的文檔內容:雷神
對應代碼實現:
const str = ' ';
const operation0 = new ot.TextOperation().insert('鋼鐵俠');
const operation1 = new ot.TextOperation().insert('雷神');
const op = ot.TextOperation.transform(operation0, operation1);
console.log('transform后op操作:', op[0].toString(), ' | ', op[1].toString());
// transform后op操作:insert '鋼鐵俠', retain 2 | retain 3, insert '雷神'
console.log('transform后操作后的字符串:', op[0].apply(operation1.apply(str)), ' | ', op[1].apply(operation0.apply(str)));
// transform后操作后的字符串: 鋼鐵俠雷神 | 鋼鐵俠雷神
最終結果是“鋼鐵俠雷神”;
transform的操作過程:
循環次數 | op1 | op2 | operation1prime | operation2prime |
---|---|---|---|---|
1 | 3 | 2 | insert('鋼鐵俠') | retain(3) |
2 | undefined | 2 | retain(2) | insert('雷神') |
示例2
原始文檔:復仇者聯盟
用戶A:復仇者鋼鐵俠聯盟
用戶B:復仇者聯盟美國隊長
對應代碼實現:
const str = '復仇者聯盟';
const operation0 = new ot.TextOperation().retain(3).insert('鋼鐵俠').retain(2);
const operation1 = new ot.TextOperation().retain(5).insert('美國隊長');
const op = ot.TextOperation.transform(operation0, operation1);
console.log('transform后op操作:', op[0].toString(), ' | ', op[1].toString());
// transform后op操作:retain 3, insert '鋼鐵俠', retain 6 | retain 8, insert '美國隊長'
console.log('transform后操作后的字符串:', op[0].apply(operation1.apply(str)), ' | ', op[1].apply(operation0.apply(str)));
// transform后操作后的字符串: 復仇者鋼鐵俠聯盟美國隊長 | 復仇者鋼鐵俠聯盟美國隊長
最終結果是“復仇者鋼鐵俠聯盟美國隊長”;
transform的操作過程:
循環次數 | op1 | op2 | operation1prime | operation2prime |
---|---|---|---|---|
1 | 3 | 5 | retain(3) | retain(3) |
2 | '鋼鐵俠' | 2 | insert('鋼鐵俠') | retain(3) |
3 | 2 | 2 | retain(2) | retain(2) |
4 | undefined | '美國隊長' | retain(4) | insert('美國隊長') |
示例3
原始文檔:復仇者聯盟鋼鐵俠美國隊長
用戶A:復仇者聯盟鋼鐵俠
用戶B:復仇者聯盟美國隊長
對應代碼實現:
const str = '復仇者聯盟鋼鐵俠美國隊長';
const operation0 = new ot.TextOperation().retain(5).delete('鋼鐵俠').retain(4);
const operation1 = new ot.TextOperation().retain(8).delete('美國隊長');
const op = ot.TextOperation.transform(operation0, operation1);
console.log('transform后op操作:', op[0].toString(), ' | ', op[1].toString());
// transform后op操作:retain 5, delete 3 | retain 5, delete 4
console.log('transform后操作后的字符串:', op[0].apply(operation1.apply(str)), ' | ', op[1].apply(operation0.apply(str)));
// transform后操作后的字符串: 復仇者聯盟 | 復仇者聯盟
最終結果是“復仇者聯盟”;
操作過程:
循環次數 | op1 | op2 | operation1prime | operation2prime |
---|---|---|---|---|
1 | 5 | 8 | retain(5) | retain(5) |
2 | -3 | 3 | delete(3) | - |
3 | 4 | -4 | - | delete(4) |
最終結果是“復仇者聯盟”;
示例4
原始文檔:復仇者聯盟鋼鐵俠美國隊長'
用戶A:復仇者聯盟
用戶B:復仇者聯盟美國隊長
對應代碼實現:
const str = '復仇者聯盟鋼鐵俠美國隊長';
const operation0 = new ot.TextOperation().retain(5).delete('鋼鐵俠美國隊長');
const operation1 = new ot.TextOperation().retain(5).delete('鋼鐵俠').retain(4);
const op = ot.TextOperation.transform(operation0, operation1);
console.log('transform后op操作:', op[0].toString(), ' | ', op[1].toString());
//transform后op操作:retain 5, delete 4 | retain 5
console.log('transform后操作后的字符串:', op[0].apply(operation1.apply(str)), ' | ', op[1].apply(operation0.apply(str)));
// transform后操作后的字符串: 復仇者聯盟 | 復仇者聯盟
最終結果是“復仇者聯盟”;
操作過程:
循環次數 | op1 | op2 | operation1prime | operation2prime |
---|---|---|---|---|
1 | 5 | 5 | retain(5) | retain(5) |
2 | -7 | -3 | - | - |
3 | -4 | 4 | delete(4) | - |
ot.js中transform的源碼如下:
TextOperation.transform = function (operation1, operation2) {
// ...
var operation1prime = new TextOperation();
var operation2prime = new TextOperation();
var ops1 = operation1.ops, ops2 = operation2.ops;
var i1 = 0, i2 = 0;
var op1 = ops1[i1++], op2 = ops2[i2++];
while (true) {
//...
// 對應示例1第一次循環的操作邏輯
if (isInsert(op1)) {
operation1prime.insert(op1);
operation2prime.retain(op1.length);
op1 = ops1[i1++];
continue;
}
// 對應示例1第二次循環的操作邏輯
if (isInsert(op2)) {
operation1prime.retain(op2.length);
operation2prime.insert(op2);
op2 = ops2[i2++];
continue;
}
// ...
var minl;
// 對應示例2循環
if (isRetain(op1) && isRetain(op2)) {
if (op1 > op2) {
minl = op2;
op1 = op1 - op2;
op2 = ops2[i2++];
// 對應示例2第三次循環的操作邏輯
} else if (op1 === op2) {
minl = op2;
op1 = ops1[i1++];
op2 = ops2[i2++];
// 對應示例2的第一次循環操作邏輯
} else {
minl = op1;
op2 = op2 - op1;
op1 = ops1[i1++];
}
operation1prime.retain(minl);
operation2prime.retain(minl);
// 對應示例4的第二次循環
} else if (isDelete(op1) && isDelete(op2)) {
if (-op1 > -op2) {
op1 = op1 - op2;
op2 = ops2[i2++];
} else if (op1 === op2) {
op1 = ops1[i1++];
op2 = ops2[i2++];
} else {
op2 = op2 - op1;
op1 = ops1[i1++];
}
// 示例3的第二次循環
} else if (isDelete(op1) && isRetain(op2)) {
if (-op1 > op2) {
minl = op2;
op1 = op1 + op2;
op2 = ops2[i2++];
} else if (-op1 === op2) {
minl = op2;
op1 = ops1[i1++];
op2 = ops2[i2++];
} else {
minl = -op1;
op2 = op2 + op1;
op1 = ops1[i1++];
}
operation1prime['delete'](minl "'delete'");
// 示例3的第三次循環
} else if (isRetain(op1) && isDelete(op2)) {
if (op1 > -op2) {
minl = -op2;
op1 = op1 + op2;
op2 = ops2[i2++];
} else if (op1 === -op2) {
minl = op1;
op1 = ops1[i1++];
op2 = ops2[i2++];
} else {
minl = op1;
op2 = op2 + op1;
op1 = ops1[i1++];
}
operation2prime['delete'](minl "'delete'");
} else {
throw new Error( The two operations aren't compatible );
}
}
return [operation1prime, operation2prime];
};
如上4個示例覆蓋了transform
所有分支的操作。核心原理其實很簡單,就是通過循環去將兩個操作重新進行排列組合,按照操作的類型作出不同的邏輯處理,這是OT中非常核心的方法,除此之外還有compose
,apply
等方法,這里就不一一羅列了。
上面過程經常用一個菱形圖來表示transform過程:
transform(a, b) = (a', b');
compose
顧名思義,這個方法是用來合並兩次操作OP的,比如:
const str = '復仇者聯盟';
const operation0 = new ot.TextOperation().retain(5).insert('鋼鐵俠');
const operation1 = new ot.TextOperation().retain(8).insert('黑寡婦');
const op = operation0.compose(operation1);
console.log('compose后op操作:', op.toString());
console.log('結果:', op.apply(str)); // 復仇者聯盟鋼鐵俠黑寡婦
compose的實現和transform類似,羅列兩個OP所有的組合可能性,分別作出對應的邏輯處理。相關源碼可以去github[4]這里查看。當然ot.js的API遠不止這兩個,比如客戶端的undo/redo方法用來實現文檔的撤銷/重做,這里就不一一過了
時序控制
基於以上示例和代碼相信OT的核心原理大家應該比較清晰了,但OT算法基於順序來進行轉換的,假如用戶A操作了兩次文檔,但因為網絡原因,第二次比第一次先到達了服務器,而服務器基於收到的順序來分發給其它用戶那么必然會出現問題。流程圖如下:
因此我們需要對每次的操作進行一個版本控制,在每次提交的時候加上一個版本標識,類似git的commitID,每次提交版本都自增1,來標識每次的OP操作;客戶端和服務端各自維護一份版本;
客戶端:發送出的請求返回確認后,本地版本+1;
服務端:完成一次OP時,版本+1;
因此客戶端的版本一定是小於等於服務端的。
相關轉換過程如圖,這里就不細說了,其實就是上面菱形的延伸。感興趣可以去http://operational-transformation.github.io/index.html這里模擬體驗整個過程。github上有很多js版本的OT實現庫,比如https://github.com/marcelklehr/changesets也是OT算法的實現,感興趣同學也可以去了解下。
狀態控制
OT將操作的狀態三為三種:
- Synchronized: 沒有提交OP,等待新的OP
- AwaitingConfirm: 有新的OP提交了,等待后台確認,在此期間沒有新的編輯行為產生;
此階段收到后台新的OP,會進行一次transform
transform(OP1, OP) = (OP1', OP');其中OP'會被應用到本地
- OP1是本地提交后但未被確認的OP;
- AwaitingWithBuffer: 有新的OP提交了,等待后台確認,在此期間有新的編輯行為產品了新OP;
此階段產生的新的OP,會和上次本地編輯的OP做一次compose,合並為一個新的OP
此階段收到后台新的OP,會進行兩次transform和一次compose:
OP3 = OP1.compose(OP2);
transform(OP1, OP) = (OP1', OP');
transform(OP3 , OP') = (OP3', OP'');
最終OP''會被應用到本地,然后更新OP1 = OP1' 和OP3 = OP3'
OP:服務端推送的新的OP;
- OP1:本地提交后但未被確認的OP;
- OP2:此階段產生的新的編輯操作;
- OP3:是OP1和OP2 compose后生成的OP;
OT算法很適合用用來處理文本的協同,最早提出時間可以追溯到1989年,也有各種語言的具體實現,相對比較成熟,目前在Google Docs,騰訊文檔,包括我司的飛書文檔都是用的OT算法,但OT目前是沒法做到點對點通信的,隨着Web通信協議的發展(比如WebRTC),點對點的通信已經成為C/S架構的可替代方案,CRDT算法也是一種協同算法,大概在2006年提出,目前在Atom、Figma等產品中都有落地使用,CRDT在支持C/S架構模型的同時也可以支持點對點的傳輸,但目前各個文檔其實還是主要使用OT,下面這個視頻有講說CRDT隨着文本內容的增加復雜度會遠大於OT,具體原因還沒了解,感興趣的同學可以一起研究下。
https://youtu.be/PWzrbg9qqK8
CRDT相關論文:https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types
CRDT介紹:https://crdt.tech/resources
CRDT開源實現:https://github.com/yjs/yjs
Easysync
以上介紹了協同處理算法中的OT算法,我們的例子也都是用的純文本,但實際上的在線文檔不可能如此簡單的,比如有各種各樣的block,富文本的支持,評論,選中等等功能;如果單純去使用ot.js來去做的話,無異於挖坑自埋。而easysync也是OT算法的一種實現,它被使用在etherpad中。
關於Etherpad
easysync這套算法作者是申請了專利的,專利地址:https://www.freepatentsonline.com/y2012/0110445.html,憑借這套算法作者創立了etherpad公司,后面被google收購,然后將etherpad開源了。起初etherpad是一套跑在Java虛擬機上,可以用JS來寫邏輯的服務,但更多的功能還是以jar包的形式提供,這樣搞也主要是為了easysync只需要實現一份JS的版本就可以同時跑在前端和后台,后面隨着功能的迭代完善官方也發覺了這套東西很難維護,后面推出了nodejs版本的etherpad-lite[5],不在需要維護jar包。簡單說etherpad就是個google開源(Apache 協議)的富文本編輯器(demo地址[6]),而協同算法用的是OT算法之一的easysync算法。
描述文檔(clientVars)
在easysync中使用一種數據結構來描述整個文檔。
對於上面截圖中的文檔的描述:
{
attribs : *0+5|1+1*1*2*3*4*5+1*6+3*2|1+1 ,
text : 復仇者聯盟\n*鋼鐵俠\n
}
上面這個對象描述的是整個文檔的內容和格式,text存儲的是整個文檔的內容包括換行符等符號,attribs存儲的是對文檔內容格式的描述,上圖中的屬性中*+都不是我們印象中的乘法加法,這里面的數字只代表序號。翻譯下具體符號的含義:
- *n:第n個屬性;
- |n: 影響n行;
- +n: 取出n個字符數;
注:easysync里面的數字大都是36進制的,主要目的是為了縮短傳輸字符的長度;
具體的屬性(加粗,斜體等)描述在另一個屬性apool中。上面文檔中對應的屬性描述如下:
{
apool :{
numToAttrib :{
0 :[
lineguid ,
HwV9Nr
],
1 :[
align ,
left
],
2 :[
author ,
52000000000000025
],
3 :[
fragmentguid ,
1981193224752831644
],
4 :[
insertorder ,
first
],
5 :[
lmkr ,
1
],
6 :[
lineguid ,
sQgJ38
],
7 :[
store ,
{\ contentConfig\ :{\ 0 \ :[1,2,3],\ 1 \ :[1,2,3],\ 2 \ :[1,2,3,4,5],\ 3 \ :[1,4,5],\ 4 \ :[]}}
]
},
nextNum :8
}
}
如上的numToAttrib屬性里面存儲的序號就是上面中數字。結合apool里面的屬性值我們就可以把 *0+5|1+1*1*2*3*4*5+1*6+3*2|1+1
****給翻譯出來了:
- 第0個屬性,應用於前5個字符(復仇者聯盟),影響一行;
- 取出1個字符應用第1,2,3,4,5屬性(這里的屬性2是author,即當前編輯這部分文本的用戶);
- 取出1個字符應用屬性6;
- 取出3個字符(鋼鐵俠)應用屬性2,影響一行;
- 取出1個字符(換行);
可以看出這其實就是一份描述文檔的數據結構,理論上我們只要實現了對應平台的渲染器,那就不僅可以把它渲染成html,同樣也可以應用在native。但這種格式是按文行和列來描述,遇到表格這種一行里面分格子的需求就很難做了。
描述文檔的變更(changeset)
上面easysync定義了一組數據結構來描述整個文檔內容,但在協同的場景下如何處理變更也會是一個很棘手的問題。在easysync定義了一個叫changeset的概念去描述文檔的變更。文檔在一開始創建或是導入的時候會生成一個初始化的內容結構,之后所有的更改都會用changeset來表示。對文檔做一下變更,則會產生一次changeset:
Z:c>3|1=7=4*0+3$ 黑寡婦
如果用通信協議來理解changeset的話,可以分為包頭和包體,包頭主要用來描述字符長度,而上面的Z似乎是個Magicnumber,每一個changeset都會以Z開頭。而包體則用來描述具體的操作(比如新增字符,刪除字符等)。$ 后面的被叫做charbank,所有這次變更新增的字符都是從charbank里面取出來的。在changeset中符號代表的含義如下:
- Z: Magicnumber,目測沒啥含義;
- :n: 之前文檔的長度(在easysnc中一般都用36進制來表示數字);
- >n: 新增字節;
- <n: 刪除字節;
- |n:影響了第n行;
- *n: 應用屬性n;
- +n:取出n個字符數;
- -n: 從當前位置開始,刪除3個字符;
- =n: 字節不變;
上面的changeset翻譯如下:
之前的文本長度是c(十進制的12),影響了第1行,保留了7個字符,保留4個字符,插入3個字符應用屬性0。
而這里的+、-、=
在某種意義上對應的就是ot.js中的insert,delete和retain
三個原子操作;
我們再看一個刪除字符(刪除了黑寡婦)的例子:
Z:f<3|1=7=4-3$
之前的文本長度是f(十進制的15),影響了第1行,保留7個字符,保留4個字符,從這個位置開始,刪除3個字符;
在刪除的changeset中charbank是空的,因為是刪除,沒有新增字符;官方文檔參考:https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-notes.pdf
協同
在實際操作中changeset會非常的多,很頻繁,比如現在的我在瘋狂碼字一樣,那么對於changeset的合並(compose)就很重要,它可以極大地縮短傳輸字符的長度,而在協同的場景下,用戶A和用戶B提交的changeset就需要去合並,我們上面提到過的ot.js中的transform方法,在easysync中它叫做follow。回顧下前面的ot.js我們會發現
- ot.js中的一次OP => easysync中的changeset;
- ot.js中tranform方法 => easysync中的follow方法;
compose
用戶B本地操作:插入字符”美國隊長“,對應changeset是:
Z:c>4|1=7=4*0+4$ 美國隊長
用戶B緊接着操作:插入字符”雷神“,對應changeset為:
Z:g>2|1=7=8*0+2$ 雷神
兩次操作假設相隔很短,那么完全可以合並為一次changeset:
const cs1 = 'Z:c>4|1=7=4*0+4$ 美國隊長';
const cs2 = 'Z:g>2|1=7=8*0+2$ 雷神';
console.log(Changeset.compose(cs1, cs2, false, null));
// Z:c>6|1=7=4*0+6$ 美國隊長雷神
注意,compose是有合並順序的,參數1一定是參數2的前置操作。下文中我們將 compose方法省略。 狀態A和狀態B合並為狀態C(狀態A是狀態B的前置操作),記為 C = AB;
merge
compose合並的是有前后操作關系的狀態,但在文檔協同中更多的是並發沖突問題,merge是easysync中解決並發沖突的算法,比如用戶A和用戶B同時編輯了一份文檔:
用戶A插入 黑寡婦 :
Z:c>3|1=7=4*0+3$ 黑寡婦
用戶B插入 美國隊長 :
Z:c>4|1=7=4*0+4$ 美國隊長
merge就是將操作A和B進行合並的,合並后的狀態我們記為C,即 C = m(A, B);
對 m
的約束條件: A
和 B
的順序可以是任意的,即 m(A, B) = m(B, A)
;
follow
上面的例子生成的狀態C = m(B, A),其實是應用於服務端的狀態,假設服務端狀態 X => X'。那么可記為X' = Xm(B, A)。X'是通過X 和 m(B, A)合並得到的,但對客戶端來說無法直接去這樣操作,因為對用戶A來說,狀態C並不是A的前置,無法直接去合並,我們需要一個算法去做轉換,這個實現就是follow方法,還是上面的例子:
const A = 'Z:b>3|1=4*0+3$ 黑寡婦';
const B = 'Z:b>4|1=4*0+4$ 美國隊長';
const A1 = follow(B, A, false, null);
const B1 = follow(A, B, true, null);
console.log(A1, B1);
// Z:f>3|1=4=4*0+3$ 黑寡婦 Z:e>4|1=4*0+4$ 美國隊長
const A2 = compose(A, B1);
const B2 = compose(B, A1);
console.log(A2, B2);
// Z:b>7|1=4*0+7$ 美國隊長黑寡婦 Z:b>7|1=4*0+7$ 美國隊長黑寡婦
可以看到用戶A和B,最終的changeset分別是A2和B2,A2和B2是完全相等的。這里我們將follow方法記為f, 當服務端收到用戶A和用戶B的並發操作的時候處理過程,假設服務端目前的狀態是X,收到了用戶A的操作A,然后apply到字符串變成了XA,此時又收到了用戶B的操作B,很明顯,此時直接應用XAB是不行的,因為A和B都是基於X來變更的,A並不是B的前置,此時就需要一個B',來實現XAB',且有XAB' = XBA'。 也就是上面例子中的follow結果,B' = f(A, B),A' = f(B, A)。由此可得到:XAf(A, B) = XBf(B, A)。即,Af(A, B) = B f(B, A) ;這個公式就是follow算法的核心。
C/S模型
前面也提到過OT算法必然需要一個Server去中轉,不支持點對點。我們來看下客戶端和服務端分別是怎么工作的:
客戶端
前面說過在OT中客戶端可以把用戶的操作分為三種狀態,在easysync中也有三種狀態:
A: 服務端最新內容,未進行修改;
X:changeset已經提交了,還沒被確認;
Y: 客戶端產生的還沒提交到服務端的變更集;
還有一種特殊的changeset,就是在X期間,又產生了新的changeset,我們用E來代替。
E: ****changeset提交期間產生的新的編輯;
etherpad官方文檔里面寫的巨復雜,我簡單梳理下這里客戶端的狀態變化和操作(注意:下文中的=和≠都是傳統數學意義上的符號,直接合並即使用compose/merge合並):
- 拉取最新文檔,未進行編輯
此時X = Y = A = In(初始狀態)
- 開始產生編輯行為
用戶產生新的編輯E,此時Y有兩種狀態,一種是初始狀態Y = In,一種是之前產生了編輯,Y ≠ In。但無論Y是否等於In,操作E都是合並到Y中,此時Y = Y · E;這樣可以不間斷的更新Y,不至於丟失用戶數據;
- 提交變更集到服務器(未收到ack);
此時,變更集Y會變成已提交狀態,同時Y的狀態重置,X = Y,Y = In;
- 收到服務器ack確認;
此時X變成了已確認的狀態,A狀態轉變為最新的X,X狀態重置,也就是A = X;X = In;
- 收到用戶B的變更集B;
- 前置條件: 對B的約束條件是,A 必須是 B 的前置,即運算 A · B 是可執行的(為什么要有這個約束條件,大家可以思考下) 此時的變化是比較復雜的,前面AXY的變遷規則已無法適應這種場景,需要作出調整。用戶 A 需要吸收 B 生成,A'、X'、Y'來回歸到前面的變遷行為。由於此時 AXY 是已經展現給用戶的狀態了,我們用 V 來表示最終的文檔變更狀態。
- A 吸收 B 變為 A':
A' = AB
; - X 吸收 B 變為 X': B 和 X 有相同的前置 A,使用follow方法合並即可,
X' = f(B, X)
,B' = f(X, B)
;可推斷出ABX' = AXB ' - Y 吸收 B 變為 Y':
-
由ab兩點可知,A'B' = ABX' = AXB ' (follow算法) 前面說了B的前置是A,但Y的前置是X,因此需要轉化一下B' = f(X, B),使得B'和Y都有相同的前置X,然后生成Y' = f(B', Y);即
Y' = f(f(X, B), Y)
; - 生成最終狀態V:
-
V = A'X'Y'
= AB · f(B, X) · f(f(X, B), Y)
= A · Bf(B, X) · f(f(X, B), Y)
= A · Xf(X, B) · f(f(X, B), Y)
= AX · f(X, B) · f(f(X, B), Y)
= AX · Y · f(Y, f(X,B))
= AXY · f(Y, f(X, B)
此時再將新的A',X',Y'賦值給AXY
服務端
這里服務端的處理邏輯相對前端來說要簡單很多,主要做兩件事:
- 響應客戶端請求,建立鏈接,並返回文檔最新狀態;
- 處理客戶端提交上來的變更集,返回確認信息;
其中處理變更集的邏輯比較值得一說,當服務端收到一個變更集C的時候,會做以下五件事:
- 從客戶端版本記錄中,獲取
C
的前置版本S
c
(客戶端的初始版本);
- 注意服務器記錄的最后一個變更集(Changeset)
S
h
與Sc
之間可能還存在多次變更集(Changeset)記錄(此時可能有其它用戶已經推送了新的版本,但還未下發到當前客戶端)。此時需要計算出C
相對S
h
的后置C'
。這樣才能「疊加」到當前文檔上;
- 發送
C'
到其它客戶端;
- 發送確認(ACK)給當前客戶端;
- 將
C'
添加到記錄列表中,同時更新版本;
后記
比較粗淺的了解了以上和在線文檔相關的一些技術,其中的一些細節實現和難題都充滿了挑戰,這個方向確實是道阻且長,很多看似簡單的功能背后都充滿着工程師的心血(比如協同中的文字選中)。
參考資料
[1]
diff-match-patch: https://github.com/google/diff-match-patch/tree/master/javascript
[2]
diff-match-patch JS實現源碼: https://github.com/google/diff-match-patch/blob/master/javascript/diff_match_patch_uncompressed.js
[3]
ot.js: https://github.com/Operational-Transformation/ot.js/blob/master/lib/text-operation.js
[4]
github: https://github.com/Operational-Transformation/ot.js/blob/master/lib/text-operation.js#L238
[5]
etherpad-lite: https://github.com/ether/etherpad-lite
[6]
demo地址: https://rich.etherpad.com/p/re3434hkj
[7]
《OT協同》: https://bytedance.feishu.cn/wiki/wikcnn505JVvliIX3Z0JKJEDQqh#zdaw84
[8]
Etherpad 協同概述: https://bytedance.feishu.cn/wiki/wikcnQ0bGESsmnJr6HegIno15Gg
轉自https://mp.weixin.qq.com/s/dUx1auXLmU4NzT_46mW1vA