實現(xiàn)一個多人協(xié)作在線文檔有哪些技術(shù)難點?
這是一篇鴿了很久的回答,正巧 Cloud Studio 也實現(xiàn)了多人協(xié)作代碼編輯,技術(shù)原理上來說是差不多的,這里把之前我的一篇博客發(fā)上來吧。協(xié)同編輯基本實現(xiàn)思路有兩種,分別是 CRDT(Conflict-Free Replicated Data Types) 和 OT(Operational-Transformation)。
CRDT
CRDT即無沖突可復(fù)制數(shù)據(jù)類型,看上去很難理解(其實我也不怎么理解),這是一些分布式系統(tǒng)中適應(yīng)于不同場景且可以保持最終一致性的數(shù)據(jù)結(jié)構(gòu)的統(tǒng)稱。也就是說CRDT本身只是一個概念,應(yīng)用于協(xié)作編輯中需要自行實現(xiàn)數(shù)據(jù)結(jié)構(gòu),比如GitHub團隊開源的。
ATOM的實時協(xié)作功能就是基于這個庫來實現(xiàn)的,數(shù)據(jù)傳輸采用WebRTC,只有在最初的邀請/加入階段依賴GitHub的服務(wù)器外,所有的傳輸都是點對點的(peer-to-peer),同時以確保隱私,所有數(shù)據(jù)都是加密的。
OTO
perational-Transformation 或者叫操作轉(zhuǎn)換,是指對文檔編輯以及同時編輯沖突解決的一類技術(shù),不僅僅是一個算法。與CRDT不同的是,OT算法全程依賴于服務(wù)器來保持最終一致性。成本而言,CRDT優(yōu)于OT,但因CRDT的實現(xiàn)復(fù)雜性(沒學會),本文主要介紹基于OT算法的實時協(xié)同編輯。OT算法不僅可用于純文本操作,同時還支持一些更為復(fù)雜的場景:
- 協(xié)同圖形編輯
支持實時協(xié)作的多媒體編輯器,可以讓多個用戶在同一 Adobe Flash 中同時編輯同一文檔
- 協(xié)同HTML/XML以及富文本編輯
基于網(wǎng)絡(luò)的實時協(xié)作編輯器
- 協(xié)同電子表格、Word文檔等
- 計算機輔助設(shè)計(Maya)
用于多人協(xié)同編輯 Autodesk Maya 文檔OT算法維持一致性的基本思路是根據(jù)先前執(zhí)行的并發(fā)操作的影響將編輯操作轉(zhuǎn)換為新形式,以便轉(zhuǎn)換后的操作可以實現(xiàn)正確的效果,并確保復(fù)制的文檔相同。事實上,并不是在多人同時編輯相鄰字符時才必須要使用OT,OT的適用性與并發(fā)操作的字符/對象數(shù)量無關(guān),無論這些目標對象是否相互重疊,無論這些字符相鄰遠近,OT都會針對具有位置依賴關(guān)系的對象進行并發(fā)控制。
OT將文檔變更表示為三類操作(Operational)
- Insert 插入
- Retain 保留
- Delete 刪除
例如對于一個原始內(nèi)容為“abc”的文檔,假設(shè)用戶O1在文檔位置0處插入一個字符“x”,表示為`Insert[0,"x"]`,用戶O2在文檔位置2處刪除一個字符,表示為`Delete[2,1]`(或者Delete[2,'c']),這將產(chǎn)生一個并發(fā)操作。在OT的控制下,本地操作會如期執(zhí)行,遠端服務(wù)器收到兩個操作后會進行轉(zhuǎn)換`Transformation`,具體過程如下
- 用戶O1首先執(zhí)行插入操作,文檔內(nèi)容變?yōu)?ldquo;xabc”。然后O2的操作到達且被轉(zhuǎn)換為`O2' = T(O2,O1) = Delete[3,1]`,產(chǎn)生了一個新的操作,此時位置增加了1,因為O1插入了一個字符。然后在文檔“xabc”執(zhí)行O2',此時文檔內(nèi)容變?yōu)?ldquo;xab”,即“c”被正確的刪除。(如果不進行轉(zhuǎn)換,會錯誤的刪除“b”)。
- 用戶O2首先執(zhí)行刪除操作,文檔內(nèi)容變?yōu)?ldquo;ab”,然后O1的操作到達且被轉(zhuǎn)換為`O1' = T(O1, o2) = Insert[0,"x"]`,也產(chǎn)生了一個新的操作,由于先前執(zhí)行的O2與O1互不影響,轉(zhuǎn)換后的O1'與O1相同,文檔內(nèi)容變?yōu)?ldquo;xab”。
這里忽略了光標操作,實際上多用戶實時編輯時,應(yīng)用在編輯器上,并不會真正的去移動光標,只會在相應(yīng)的位置插入一個fake cursor。Monaco-Editor 與 ot.js我們使用ot.js來實現(xiàn)Monaco-Editor的協(xié)同編輯。ot.js包含客戶端與服務(wù)端的實現(xiàn),在客戶端,它將編輯操作轉(zhuǎn)換為一系列的operation。
- // 對于文檔“Operational Transformation”
- const operation = new ot.Operation()
- .retain(11) // 前11個字符保留
- .insert("color"); // 插入字符
- // 這將使文檔變更為 "Operationalcolor"
- // “abc”
- const deleteOperation = new ot.Operation()
- .retain(2) //
- .delete(1)
- .insert("x") // axc
同時operation也是可組合的,比如將兩個操作組合為一個操作
- const operation0 = new ot.Operation()
- .retain(13)
- .insert(" hello");
- const operation1 = new ot.Operation()
- .delete("misaka ")
- .retain(13);
- const str0 = "misaka mikoto";
- const str1 = operation0.apply(str0); // "misaka mikoto hello"
- const str2a = operation1.apply(str1); // "mikoto hello"
- // 組合
- const combinedOperation = operation0.compose(operation1);
- const str2b = combinedOperation.apply(str0); // "mikoto dolor"
應(yīng)用到Monaco中,我們需要監(jiān)聽編輯器的onChange事件以及光標相關(guān)操作事件(selectionChange,cursorChange,blur等)。在文本內(nèi)容修改的事件中,將每次修改產(chǎn)生的`changes`轉(zhuǎn)換為一個或多個操作,也叫`operation`。光標的操作很好處理,轉(zhuǎn)換成一個`Retain`操作即可。
- const editor = monaco.editor.create(container, {
- language: 'php',
- glyphMargin: true,
- lightbulb: {
- enabled: true,
- },
- theme: 'vs-dark',
- });
- editor.onDidChangeModelContent((e) => {
- const { changes } = e;
- let docLength = this.editor.getModel().getValueLength(); // 文檔長度
- let operation = new TextOperation().retain(docLength); // 初始化一個operation,并保留文檔原始內(nèi)容
- for (let i = changes.length - 1; i >= 0; i--) {
- const change = changes[i];
- const restLength = docLength - change.rangeOffset - change.text.length; // 文檔
- operation = new TextOperation()
- .retain(change.rangeOffset) // 保留光標位置前的所有字符
- .delete(change.rangeLength) // 刪除N個字符(如為0這個操作無效)
- .insert(change.text) // 插入字符
- .retain(restLength) // 保留剩余字符
- .compose(operation); // 與初始operation組合為一個操作
- });
這段代碼首先創(chuàng)建了一個編輯器實例,監(jiān)聽了`onDidChangeModelContent`事件,遍歷changes數(shù)組,change.rangeOffset代表產(chǎn)生操作時的光標位置,change.rangeLength代表刪除的字符長度(為0即沒有刪除操作),restLength是根據(jù)文檔最終長度 - 光標位置 - 插入字符長度得出,用于在文檔中間位置插入字符時保留剩余字符的操作。
但同時我們也要考慮到撤銷/重做,ot.js中對撤銷/重做的處理是每次編輯操作都需要產(chǎn)生對應(yīng)的`逆操作`,并存入撤銷/重做棧,在上面代碼的循環(huán)體中,我們還需要添加一個名為`inverse`的操作。
- let inverse = new TextOperation().retain(docLength);
- // 獲取刪除的字符,實現(xiàn)略
- const removed = getRemovedText(change, this.documentBeforeChanged);
- inverse = inverse.compose(
- new TextOperation()
- .retain(change.rangeOffset) // 與編輯相同
- .delete(change.text.length) // 插入變?yōu)閯h除
- .insert(removed) // 刪除變?yōu)椴迦?/span>
- .retain(restLength); // 同樣保留剩余字符
這樣就產(chǎn)生了一個編輯操作和一個用于撤銷的逆操作,編輯操作會發(fā)送到服務(wù)端進行轉(zhuǎn)換同時再發(fā)送到給其他客戶端,逆操作保存在本地用于實現(xiàn)撤銷。
撤銷/重做的思路很簡單,因為不論如何都會對編輯器產(chǎn)成一個change事件,并且實時編輯的狀態(tài)下,兩個用戶的撤銷/重做棧需要互相獨立,也就是說A的操作不能進入B的撤銷棧,因而在B執(zhí)行撤銷的時候只能對自己先前的操作產(chǎn)生影響,不能撤銷A的編輯,所以我們需要實現(xiàn)一個自定義的撤銷函數(shù)來覆蓋編輯器自帶的撤銷功能。
我們需要覆蓋默認的撤銷
- this.editor.addAction({
- id: 'cuctom_undo',
- label: 'undo',
- keybindings: [
- monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_Z
- ],
- run: () => {
- this._undoFn()
- }
- })
這里_undoFn的實現(xiàn)不再贅述,實際就是將先前change事件中產(chǎn)生的逆操作保存在一個自定義的undoManager中,每次執(zhí)行撤銷就undoStack.pop()拿出最近一次的操作并應(yīng)用在本地,同時發(fā)送給協(xié)作者,因為undoManager中并未保存協(xié)作者的逆操作,所以執(zhí)行撤銷不會影響協(xié)作者的操作。
ot.js還包含了服務(wù)端的實現(xiàn),只需要將ot.js的服務(wù)端代碼運行在nodejs中,同時搭建一個簡單的websocket服務(wù)器即可。
- const EditorSocketIOServer = require('ot.js/socketio-server.js');
- const server = new EditorSocketIOServer("", [], 1);
- io.on('connection', function(socket) {
- server.addClient(socket);
- });
服務(wù)端接收到每個協(xié)作者的operation并進行轉(zhuǎn)換后下發(fā)到其他協(xié)作者客戶端,轉(zhuǎn)換操作實際是調(diào)用一個`transform`函數(shù),可以戳這里transform查看,實際上這個函數(shù)也正是OT技術(shù)的核心,由于時間有限,所以不再詳細解讀這個函數(shù)的源碼(逃隨著 Cloud Studio 的架構(gòu)升級和改進,我們正在準備拋棄 OT 轉(zhuǎn)向 CRDT,所以等全部實現(xiàn)完成再來分享。