基於OT與CRDT協同算法的文檔劃詞評論能力實現

基於OT與CRDT協同算法的文檔劃詞評論能力實現

當我們實現在線文檔平臺時,劃詞評論的功能是非常必要的,特別是在重文檔管理流程的在線文檔產品中,文檔反饋是非常重要的一環,這樣可以幫助文檔維護者提高文檔質量。而即使是單純的將劃詞評論作爲討論區,也是非常有用的,尤其是在文檔並不那麼完善的情況下,對接產品系統的時候可以得到文檔之外的輸入。那麼本文將通過引入協同算法來解決衝突,從而實現在線文檔的劃詞評論能力。

我們即將要聊的OTCRDT的實現分別會有相關示例:

如果想了解關於協同的相關內容,也可以參考之前的文章:

描述

實際上實現劃詞評論在交互上並不是非常困難的事,我們可以先簡單設想一下,無非是在文檔中選中文本,然後在onMouseUp事件喚醒評論的按鈕,當用戶點擊按鈕時輸入評論的內容,然後將評論的位置和數據傳輸到持久化存儲即可。在這裏不禁讓我想起來了一個著名的問題,把大象放進冰箱需要幾步?答案是三步:把冰箱門打開,把大象放進去,把冰箱門關上。而把長頸鹿放進冰箱需要四步:把冰箱門打開,把大象拿出來,把長頸鹿放進去,把冰箱門關上。

我們的劃詞評論也很像將大象放進冰箱,那麼這個問題難點究竟是什麼,很明顯我們不容易找到評論的位置,如果此時不是富文本編輯器的話,我們可以考慮一種方案,即將DOM的具體層級存儲起來,也就是保存一個路徑數組,在渲染以及Resize的時候將其重新查找並計算即可。當然如果情況允許的話,對於每個文本節點都放置一個id,然後持久化的時候存儲idoffset即可,只不過通常不太容易具備這種條件,入侵性太強且可能需要改造數據->渲染的存儲結構,也就是說這個id是需要冪等地渲染,即多次渲染不會改變id,這樣對於數據的存儲也是額外增加了負擔,當然如果對於位置計算比較複雜的話,這種空間換時間的實現也是可取的。

那麼對於靜態的內容,我們可能有很多辦法來解決劃詞位置的持久化問題,而我們的在線文檔是動態的內容,我們需要考慮到文檔的變更,而文檔內容的變更就有可能影響到劃詞位置的改變。例如原本劃詞的位置是[2, 6],而此時在0位置上加入了文本或者圖片等內容,此時如果還保持着[2, 6]的位置,那麼劃詞的位置就不正確了,所以我們需要引入協同算法來解決這個問題,相當於follow文檔的變更,重新計算劃詞的位置。請注意,在這裏我們討論的是非協同場景下的劃詞評論能力,如果此時文檔系統已經引入了在線協同編輯的能力,那麼基本就不需要考慮位置的計算問題,此時我們可以直接將後端同樣作爲一個協同編輯的客戶端,直接使用協同算法來解決位置變換的問題。

OT

那麼首先我們來聊一聊編輯時的評論位置同步,通常劃詞評論會分爲兩部分,一部分是在文檔中劃詞的位置展示,另一部分是右側的評論面板。那麼在這裏我們主要討論的是文檔中劃詞的位置展示,也就是如何在編輯的時候保持劃詞評論位置的正確follow,此部分的相關代碼都在https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/comment-ot.html中。

我們可以設想一個問題,實際上在文檔中的劃詞部分對於編輯器來說僅僅就是一個樣式而已,與加粗等樣式沒有什麼本質上的區別,也就是說我們可以通過在attributes上增加類似於{ comment: id }的形式將其表達出來。那麼這種方式是能夠正常跟隨編輯區域移動的,本質上是編輯器引擎幫我們實現了這部分能力,對於這種方式我們可以在編輯器中輕鬆地實現,只需要對選區中的內容做format即可。

const onFormatComment = (id: string) => {
  editor.format("comment", id);
}

這種方案是最方便的實現方式,但是這裏需要注意的是,此時我們是非協同場景下的劃詞評論,因爲不存在協同編輯的實現,我們通常都是需要使用編輯鎖來防止內容覆蓋的問題。那麼這種劃詞評論方式的問題是我們需要在內容中寫入數據,相當於所有有權限的人都可以在整個流程中寫入數據,所以我們通常不能將全量的數據存儲到後端,而是應該在後端同樣對數據做一次format,並且保持好多端的數據同步,否則在多人同時評論的場景下例如審覈的流程中,存在內容覆蓋的風險。

實際上我們可以發現,上述的方式是通過保證文檔實例只存在一份的方式來實現的,也就是說無論是處於草稿狀態下的編輯鎖,還是審覈狀態下的評論能力,都是操作了同一份文檔。那麼如果場景再複雜一些,此時我們的文檔平臺存在線上態和草稿態,線上狀態和草稿狀態都分別可以評論,當然這裏通常是分開管理的,草稿態是內部對文檔的修改標註和評審意見等,線上態的評論主要是用戶的反饋和討論等,那麼在編輯態的方案我們上邊已經比較清晰地實現了,那麼在線上狀態的評論就沒有這麼簡單了。試想一個場景,此時我們對文檔發佈了一個版本A,而在後臺又將文檔編輯了一部分此時內容爲B版本,用戶此時在A版本上評論了內容,然而此時我們的文檔已經是B版本了,如何將用戶評論的內容同步到B版本,以便於我們發佈C版本的時候能夠正確保持用戶的評論位置,這就是我們即將要討論的問題。

在討論具體的問題之前,我們不妨先考慮一下這個問題的本質,實質上就是需要我們根據文檔的改變來transform評論的位置,那麼我們不如直接將這部分實現先抽象一下,將這個複雜的問題原子化實現一下,那麼首先我們先定義一個選區列表用來存儲評論的位置。

const COMMENT_LIST = [
  { index: 12, length: 2 },
  { index: 17, length: 4 },
];

因爲先前我們是使用format來實現的,也就是將評論的實質性地寫入到了delta當中,而在這裏爲了演示實際效果,此處的評論是使用虛擬圖層的方式實現的,關於虛擬圖層的實現我們在先前的 文檔diff算法實現與對比視圖 中已經抽象出來了通用能力,在這裏就不具體展開了。使用虛擬圖層實現就是相當於我們的數據表達是完全脫離於delta,也就是意味着我們可以將其獨立存儲起來,這樣就可以做到完全的狀態分離。

那麼接下來就是在視圖初始化時將虛擬圖層渲染上去,並且爲我們先前定義的評論按鈕加入事件冒泡和默認行爲的阻止,特別是我們不希望在點擊評論按鈕的時候失去編輯器的焦點,所以需要阻止其默認行爲。

const applyComment = document.querySelector(".apply-comment");
applyComment.onmousedown = e => {
  e.stopPropagation();
  e.preventDefault();
};

接下來我們需要關注於點擊評論按鈕需要實現的功能,實際上也比較簡單,主要是將選區的位置存儲起來,然後將其渲染到虛擬圖層上,最後將選區的位置移動到評論的位置上,也就是將選區摺疊起來。

applyComment.onclick = e => {
  const selection = editor.getSelection();
  if (selection) {
    const sel = { ...selection };
    console.log("添加評論:", sel);
    COMMENT_LIST.push(sel);
    editor.renderLayer(COMMENT_LIST);
    editor.setSelection(sel.index + sel.length);
  }
};

最後的重點來了,當我們編輯的時候會觸發內容變更的事件,在這裏是原子化的op/delta,那麼我們就可以藉助於這個op來對評論的位置進行transform,也就是說此時評論的位置會根據op的變化來重新計算,最後將評論的虛擬圖層全部渲染出來。由此可以看到當我們編輯的時候,評論是會正常跟隨我們的編輯進行位置變換的。而實際上我們不同版本的文檔評論的位置同步也是類似的,只不過是單個op還是多個op的問題,而本身op又是可以進行composeops的,所以本質上就是同一個問題,那麼就可以通過類似的方案解決問題。

editor.on("text-change", delta => {
  for(const item of COMMENT_LIST){
    const { index, length } = item;
    const start = delta.transformPosition(index);
    const end = delta.transformPosition(index + length, true);
    item.index = start;
    item.length = end - start;
  }
  editor.renderLayer(COMMENT_LIST);
});

在這裏我們需要再看一下transformPosition這個方法,這個方法是根據delta變換索引,對於表示光標以及選區相關的操作時很有用,而第二個參數是比較有迷惑性的,我們可以藉助transform方法來表示這個參數的意義,如果是true,那麼就表示this的行爲被認爲是first也就是首先執行的delta,因爲我們的delta都是從0開始索引位置,而對於同樣的位置進行操作則會產生衝突,所以需要此標識來決定究竟誰在前,或者說誰是先執行的delta

const a = new Delta().insert('a');
const b = new Delta().insert('b').retain(5).insert('c');

// Ob' = OT(Oa, Ob)
a.transform(b, true);  // new Delta().retain(1).insert('b').retain(5).insert('c');
a.transform(b, false); // new Delta().insert('b').retain(6).insert('c');

回到我們線上文檔也就是消費側評論的場景,我們不如舉一個具體的例子來描述要解決的問題,此時線上文檔的狀態是A內容是yyy,在草稿態我們又重新編輯了文檔,此時文檔的狀態是B內容是xxxyyy,也就是在yyy前邊加了3x字符,那麼此時有個用戶在線上A版本劃詞評論了內容yyy,此時的標記索引是[0, 3],過後我們將B版本發佈到了線上,如果此時評論還保持着[0, 3]的位置,那麼就會出現位置不正確的問題,此時評論標記內容將會是xxx,並不符合用戶最初劃詞的內容,所以我們需要將評論的位置根據A -> B的變化來重新計算,也就是將[0, 3]變換爲[3, 6]

實際上這裏有個點需要注意的是,我們並不會將消費側的評論同步到草稿狀態上,如果此時用戶正在評論且作者正在寫文檔的話,這個狀態同步將會是比較麻煩的問題,相當於實現了簡化的協同編輯,複雜性上升且不容易把控,在這種情況下甚至可以直接考慮接入成熟的協同系統。那麼根據我們之前實現的原子化的transform評論位置的方法,我們只需要將版本A到版本B的變更ops找出來,並且將評論的位置根據delta進行transform即可,那麼如何找出AB的變更呢,這裏就有兩個辦法:

  • 一種方案是記錄版本之間的ops,實際上我們的線上狀態文檔和草稿狀態的文檔並不是完全不相關的兩個文檔,草稿狀態實際上就是由前一個線上文檔版本得到的,那麼我們就完全可以將文檔變更時的ops完整記錄下來,需要的時候再取得相關的ops進行transform即可,這種方式實際上是實現OT協同的常見操作,並且通過記錄ops的方式可以更方便地實現 細粒度的操作記錄回滾、字數變更統計、追溯字粒度的作者 等等。
  • 另一種方案是在發佈時對版本內容做diff,如果我們的在線文檔系統最開始就沒有設計ops的記錄以及做協同能力的儲備的話,突然想加入相關的能力成本是會比較高的,而我們如果單單爲了評論就引入完整的協同能力顯然並不是那麼必要,所以此時我們直接對兩個版本做diff就可以以更加低成本的方式實現評論的位置同步,關於diff的性能消耗可以參考之前的 文檔diff算法實現與對比視圖 中的相關內容。

那麼先前我們實現的方案可以看作是記錄了ops的方案,接下來我們以上述的例子演示一下基於diff的實現算法,首先將線上狀態onlinedraft的表達按照之前的例子表示出來,然後標記出評論的位置,在這裏需要注意的是評論的位置是我們數據庫持久化存儲的內容,實際做transform時需要將其轉換爲delta表達,之後將線上內容和評論compose,這就是實際上要展示給用戶帶評論劃線的內容,然後對線上和草稿狀態做diffdiff的順序是online -> draft,下面就是將評論的內容進行transform,這裏需要注意的是我們是需要在diff的基礎上做comment變換,因爲我們的draft相當於已經應用了diff,所以根據Ob' = OT(Oa, Ob),我們實際想要得到的是Ob',之後新的comment表達應用到draft上即可得到最終的評論內容。可以看到我們的評論是正確follow了原來的位置,此外因爲最終還是要把新的評論位置存儲到數據庫中,所以我們需要將delta轉換爲indexlength的形式存儲,也可以在做transform時直接使用transformPosition來構造新的位置,然後根據新的位置構造delta表達來做應用與存儲。

const Delta = Quill.import("delta");
// 線上狀態
const online = new Delta().insert("yyy");
// 草稿狀態
const draft = new Delta().insert("xxxyyy");
// 評論位置
const comment = { index: 0, length: 3 };
// 評論位置的`delta`表示
const commentDelta = new Delta().retain(comment.index).retain(comment.length, { id: "xxx" });
// 線上版本展示的實際內容
const onlineContent = online.compose(commentDelta);
// [{ "insert": "yyy", "attributes": { "id": "xxx" } }]
// `diff`結果
const diff = online.diff(draft); // [{ "insert": "xxx" }]
// 更新的評論`delta`表示
const nextCommentDelta = diff.transform(commentDelta); 
// [{ "retain": 3 }, { "retain": 3, "attributes": { "id": "xxx" } }]
// 更新之後的線上版本實際內容
const nextOnlineContent = draft.compose(nextCommentDelta);
// [{ "insert": "xxx" }, { "insert": "yyy", "attributes": { "id": "xxx" } }]

此外使用diff來實現評論的同步時,還有一個需要關注的點是採用diff的方案可能會存在意圖不一致的問題,,統一進行diff計算而不是完整記錄ops可能會存在數據精度上的損失,例如此時我們有N個連續的xxx塊,編輯時刪除了某個xxx塊,此塊上又恰好攜帶了消費側的評論,如果按照我們的實際意圖來計算,下次發佈新版本時這個評論應該會消失或者被收起來,然而事實上可能並不如此,因爲diff的時候是根據內容來計算的,究竟刪除的是哪個xxx塊只是算法上的解而非是意圖上的解,所以在這種情況下如果我們需要保證完整的意圖的話就需要引入額外的標記信息,或者採用第一種方案來記錄ops,甚至完整引入協同算法,這樣才能保證我們的意圖是完整的。

在這裏聊了這麼多關於評論位置的記錄與變換操作,別忘了我們還有右側的評論面板部分,這部分實際上沒有涉及到很複雜的操作,通常只需要跟文檔編輯器通信來獲取評論距離文檔頂部的實際top來做位置計算即可,可以直接使用CSStransform: translateY(Npx);,當然這裏邊細節還是很多的,例如 何時更新評論位置、避免多個評論卡片重疊、選擇評論時可能需要移動評論卡片 等等,交互上需要的實現比較多。當然實現展示評論的交互還有很多種,例如Hover或者點擊文檔內評論時展示具體的評論內容,這些都是可以根據實際需求來實現的。

當然這裏還有個可以關注的點,就是如何獲取評論距離文檔頂部的位置,通常編輯器內部會提供相關的API,例如在Quill中可以通過editor.getBounds(index, 0)來獲取具體選區的rect。那麼爲什麼需要關注這裏呢,因爲這裏的實現是比較有趣的,因爲我們的選區並不一定是個完整的DOM,可能存在只選擇了一個文本表達的某N個字,我們不能直接取這個DOM節點的位置,因爲可能這是個長段落髮生了很多次折行,高度實際上是發生偏移的,那麼在這種情況下我們就需要構造Range並且使用Range.getClientRects方法來得到選區信息了,當然通常我們是可以直接取選區的首個位置即直接使用Range.getBoundingClientRect就可以了,在獲取這部分位置之後我們還需要根據編輯器的位置信息作額外計算,在這裏就不贅述了。

const node = $0; // <strong>123123</strong>
const text = node.firstChild; // "123123"
const range = new Range();
range.setStart(text, 0);
range.setEnd(text, 1);
const rangeRect = range.getBoundingClientRect();
const editorRect = editor.container.getBoundingClientRect();
const selectionRect = editor.getBounds(editor.getSelection().index, 0);
rangeRect.top - editorRect.top === selectionRect.top; // true
rangeRect.left - editorRect.left === selectionRect.left; // true

CRDT

在上述的實現中我們使用了OT的方式來解決評論位置的同步問題,而本質上我們就是通過協同來解決的同步問題,那麼同樣的我們也可以使用CRDT的協同方案來解決這個問題,那麼在這裏我們使用yjs來實現與上述OT的功能類似的評論位置同步,此部分的相關代碼都在https://codesandbox.io/p/devbox/comment-crdt-psm548中。

首先我們需要定義yjs的數據結構即Y.Doc,然後爲了方便我們直接採用indexeddb作爲存儲而不是使用websocket來與後端yjs通信,由此我們可以直接在本地進行測試,在yjs中內置了getText的富文本數據結構表達,實際上在使用上是等同於quill-delta的數據結構,並且使用yjs提供的y-quill將數據結構與編輯器綁定。

const ydoc = new Y.Doc();
new IndexeddbPersistence("y-indexeddb", ydoc);
// ...
const ytext = ydoc.getText("quill");
new QuillBinding(ytext, editor);

緊接着我們同樣初始化一個評論列表,這就是我們持久化存儲的內容,與之前不同的是此時我們存儲的是CRDT的相對位置,也就是說我們存儲的是yjsRelativePosition,這個位置是相對於文檔的位置而不是絕對的index,這是由協同算法的特性決定的,在這裏就不具體展開了,有興趣的話可以看一下之前的 OT協同算法、CRDT協同算法 文章的相關內容。然後我們需要初始化虛擬圖層的實現,在這裏我們同樣藉助虛擬圖層來實現評論的位置展示,接下來我們需要在具體渲染之前,將相對位置轉換爲絕對位置。這裏需要注意的是,我們創建相對位置時時使用的yText,而通過相對位置創建絕對位置時是使用的yDoc

const COMMENT_LIST: [string, string][] = [];
const layerDOM = initLayerDOM();
const renderAllCommentWithRelativePosition = () => {
  const ranges: Range[] = [];
  for (const item of COMMENT_LIST) {
    const start = JSON.parse(item[0]);
    const end = JSON.parse(item[1]);
    const stratPosition = Y.createAbsolutePositionFromRelativePosition(
      start,
      ydoc,
    );
    const endPosition = Y.createAbsolutePositionFromRelativePosition(end, ydoc);
    if (stratPosition && endPosition) {
      ranges.push({
        index: stratPosition.index,
        length: endPosition.index - stratPosition.index,
      });
    }
  }
  renderLayer(layerDOM, ranges);
};

同樣的,我們依然需要爲我們先前定義的評論按鈕加入事件冒泡和默認行爲的阻止,特別是我們不希望在點擊評論按鈕的時候失去編輯器的焦點,所以需要阻止其默認行爲。

const applyComment = document.querySelector(".apply-comment") as HTMLDivElement;
applyComment.onmousedown = (e) => {
  e.stopPropagation();
  e.preventDefault();
};

接下來我們需要關注於點擊評論按鈕需要實現的功能,此時我們需要將選區的內容轉換爲相對位置,通過createRelativePositionFromTypeIndex方法可以根據我們的數據類型與索引值取得clientclock用以標識全序的相對位置,取得相對位置之後我們將其存儲到COMMENT_LIST中,然後將其渲染到虛擬圖層上,最後同樣將選區的位置移動到評論的位置上。

applyComment.onclick = () => {
  const selection = editor.getSelection();
  if (selection) {
    const sel = { ...selection };
    console.log("添加評論:", sel);
    const start = Y.createRelativePositionFromTypeIndex(ytext, sel.index);
    const end = Y.createRelativePositionFromTypeIndex(
      ytext,
      sel.index + sel.length,
    );
    COMMENT_LIST.push([JSON.stringify(start), JSON.stringify(end)]);
    renderAllCommentWithRelativePosition();
    editor.setSelection(sel.index + sel.length);
  }
};

那麼最後我們在文本內容發生變動的時候重新渲染即可,因爲是標識了相對位置,在這裏我們不需要對選區作transform,我們只需要重新渲染虛擬圖層即可。通過添加評論並且編輯內容之後,發現我們的評論位置也是能夠正常follow初始選區的,那麼由此也可以說明CRDT能夠根據相對位置實現評論位置的同步,我們不需要爲其作transform或者diff的操作,只需要保持數據結構是完整存儲與更新即可,而之後的評論面板部分內容是基本一致的實現,通過Range對象的操作來獲取評論的位置,然後根據編輯器的位置信息作高度計算即可。

editor.on("text-change", () => {
  renderAllCommentWithRelativePosition();
});

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://quilljs.com/docs/delta
https://docs.yjs.dev/api/relative-positions
https://www.npmjs.com/package/quill-delta/v/4.2.2
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章