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

WindrunnerMax發表於2024-04-10

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

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

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

  • OT劃詞評論能力
  • CRDT劃詞評論能力

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

  • Collab Example
  • 初探富文字之OT協同演算法
  • 初探富文字之CRDT協同演算法
  • 初探富文字之OT協同例項
  • 初探富文字之CRDT協同例項

描述

實際上實現劃詞評論在互動上並不是非常困難的事,我們可以先簡單設想一下,無非是在文件中選中文字,然後在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

相關文章