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