初探富文字之OT協同例項

WindrunnerMax發表於2023-01-27

初探富文字之OT協同例項

在前邊初探富文字之OT協同演演算法一文中我們探討了為什麼需要協同、為什麼僅有原子化的操作並不能實現協同、為什麼要有操作變換、如何進行操作變換、什麼時候能夠應用操作、服務端如何進行協同排程等等,這些屬於完成協同所需要了解的基礎知識,實際上當前有很多成熟的協同實現,例如ot.jsShareDBot-jsonEasySync等等,本文就是以ShareDBOT協同框架來實現協同的例項。

描述

接入協同框架實際上並不是一件簡單的事情,尤其是對於OT實現的協同演演算法而言,OT的英文全稱是Operational Transformation,也就是說實現OT的基礎就是對內容的描述與操作是Operational原子化的。在富文字領域,最經典的Operationquilldelta模型,透過retaininsertdelete三個操作完成整篇檔案的描述與操作,還有slateJSON模型,透過insert_textsplit_noderemove_text等等操作來完成整篇檔案的描述與操作。有了這個協同實現的基礎之後,還需要對所有Op具體實現變換Transformation,這就是個比較麻煩的工作了,而且也是必不可少的實現。同樣是以quillslate兩款開源編輯器為例,在quill中已經實現了對於其資料結構delta的所有Transformation,可以直接呼叫官方的quill-delta包即可;對於slate而言,官方只提供了原子化的操作API,並沒有Transformation的具體實現,但是有社群維護的slate-ot包實現了其JSON資料的Transformation,也可以直接呼叫即可。

OT協同的實現在富文字領域有比較多的實現可供參考,特別是在開源的富文字引擎上,其實現方案還是比較成熟的,但是引申一下,在其他領域可能並沒有具體的實現,那麼就需要參考接入的檔案自己實現了。例如我們有一個自研的思維導圖功能需要實現協同,而儲存的資料結構都是自定義的,沒有直接可以呼叫的實現方案,那麼這就需要自己實現操作變換了,對於一個思維導圖而言我們實現原子化的操作還是比較容易的,所以我們主要關注於變換的實現。假如這個思維導圖功能我們是透過JSON的資料結構儲存的資料,那麼我們就可以參考json0或者slate-ot的實現,特別是透過閱讀單元測試可以比較容易地理解具體的功能,透過參考其實現來自行實現一份OT的變換,或者直接依照其實現維護一箇中間層的資料結構,依照於這個中間層進行資料轉換。再假如我們的思維導圖維護的是一個線性的類文字結構,那麼就可以參考rich-textquill-delta的實現,只不過這樣的話實現原子化的操作可能就麻煩一些了,當然同樣我們也可以維護一箇中間層的資料結構來完成OT。實際上有比較多的參考之後,接入OT協同就主要是理解並且實現的問題了,這樣就有一個大體的實現方向了,而不是毫無頭緒不知道應該從哪裡開始做協同。另外還是那個宗旨,合適的才是最好的,要考慮到實現的成本問題,沒有必要硬套資料結構的實現,就比如上邊說的實現思維導圖使用線性的文字來表示還是有點牽強的,當然並不是不可能的,比如Google DocsTable就是完全的線性結構,要知道其是可以實現表格中巢狀表格的,相當於每一個單元格都是一篇檔案,內部可以嵌入任何的富文字結構,而在實現上就是透過線性的結構完成的。

或許上邊的json0rich-text等概念可能一時間讓人難以理解,所以下面的CounterQuill兩個例項就是介紹瞭如何使用sharedb實現協同,以及json0rich-text究竟完成了什麼樣的工作,當然具體的API呼叫還是還是需要看sharedb的檔案,本文只涉及到最基本的協同操作,所有的程式碼都在https://github.com/WindrunnerMax/Collab中,注意這是個pnpmworkspace monorepo專案,要注意使用pnpm安裝依賴。

Counter

首先我們執行一個基礎的協同例項Counter,實現的主要功能是在多個客戶端可以+1的情況下我們可以維護同一份計數器總數,該例項的地址是https://github.com/WindrunnerMax/Collab/tree/master/packages/ot-counter,首先簡單看一下目錄結構(tree --dirsfirst -I node_modules):

ot-counter
├── public
│   ├── favicon.ico
│   └── index.html
├── server
│   ├── index.ts
│   └── types.d.ts
├── src
│   ├── client.ts
│   ├── counter.tsx
│   └── index.tsx
├── babel.config.js
├── package.json
├── rollup.config.js
├── rollup.server.js
└── tsconfig.json

先簡略說明下各個資料夾和檔案的作用,public儲存了靜態資原始檔,在客戶端打包時將會把內容移動到build資料夾,server資料夾中儲存了OT服務端的實現,在執行時同樣會編譯為js檔案放置於build資料夾下,src資料夾是客戶端的程式碼,主要是檢視與OT客戶端的實現,babel.config.jsbabel的配置資訊,rollup.config.js是打包客戶端的配置檔案,rollup.server.js是打包服務端的配置檔案,package.jsontsconfig.json大家都懂,就不贅述了。

首先我們需要了解一下json0,乍眼一看json0確實不容易知道這是個啥,實際上這是sharedb預設攜帶的型別,sharedb提供了很多處理操作的機制,例如我們前邊提到的服務端對於Op原子操作的排程,但沒有提供轉換操作的實際實現,因為業務的複雜性,必然會導致將要操作的資料結構的複雜性,於是轉換和處理操作實際上是委託到業務自行實現的,在sharedb中稱為OT Types

OT Types實際上相當於定義了一系列的介面,而要在sharedb中註冊型別必須實現這些介面,而這些實現就是我們需要實現的OT操作變換,例如需要實現的transform函式transform(op1, op2, side) -> op1',則必須滿足apply(apply(snapshot, op1), transform(op2, op1, 'left')) == apply(apply(snapshot, op2), transform(op1, op2, 'right')),由此來保證變換的最終一致性,再比如compose函式compose(op1, op2) -> op,就必須滿足apply(apply(snapshot, op1), op2) == apply(snapshot, compose(op1, op2)),具體的檔案與要求可以參考https://github.com/ottypes/docs

上邊的這個實現看起來就很麻煩,乍眼一看還有公式,看起來對於數學上還有些要求。實現操作變換雖然本質上就是索引的轉換,透過轉換索引位置以確保收斂,但是要自己寫還是需要些時間的,所幸在開源社群已經有很多的實現可以提供參考,在sharedb中也附帶一個了預設型別json0,透過json0這個JSON OT型別可用於編輯任意JSON檔案,實際上不光是JSON檔案,我們的計數器也就是使用json0來實現的,畢竟在這裡計數器也是隻需要透過藉助JSON的一個欄位就可以實現的。回到json0支援以下操作:

  • 在列表中插入/刪除/移動/替換專案。
  • 物件插入/刪除/替換。
  • 原子數值加法運算。
  • 嵌入任意子型別。
  • 嵌入式字串編輯,使用text0 OT型別作為子型別。

json0也是一種可逆型別,也就是說所有的操作都有一個逆操作,可以撤銷原來的操作。但其並不完美,其不能實現物件移動,設定為NULL,在列表中高效地插入多個專案。此外也可以看一下json1的實現,其實現了json0的超集,解決了json0的一些問題。其實看是否可以支援某些操作,直接看其檔案中是不是有定義的操作就可以了,比如本例子中需要實現的計數器,就需要{p:[path], na:x}這個Op,將x新增到[path]處的數字,具體的檔案可以參考https://github.com/ottypes/json0

接下來我們來看看服務端的實現,主要實現是例項化ShareDB並且透過透過collectionid獲取檔案例項,在檔案就緒之後觸發回撥啟動HTTP伺服器,在這裡如果不存在的檔案就需要初始化,注意在這裡初始化的資料就是客戶端訂閱時獲得的資料。例項中具體的API就不介紹了,可以參考https://share.github.io/sharedb/api/,在這裡主要是描述一下其功能。當然在這裡只是非常簡單的實現,真正的生產環境肯定是需要接入路由、資料庫等功能的。

const backend = new ShareDB(); // `ShareDB`服務端例項

function start(callback: () => void) {
  const connection = backend.connect(); // 連線到`ShareDB`
  const doc = connection.get("ot-example", "counter"); // 透過`collection`與`id`獲取`Doc`例項
  doc.fetch(err => {
    if (err) throw err;
    if (doc.type === null) { // 如果不存在
      doc.create({ num: 0 }, callback); // 建立初始檔案然後觸發回撥
      return;
    }
    callback(); // 觸發回撥
  });
}

function server() {
  const app = express(); // 例項化`express`
  app.use(express.static("build")); // 客戶端打包過後的靜態資源路徑
  const server = http.createServer(app); // 建立`HTTP`伺服器

  const wss = new WebSocket.Server({ server: server });
  wss.on("connection", ws => {
    const stream = new WebSocketJSONStream(ws);
    backend.listen(stream); // `ShareDB`後端需要`Stream`例項
  });

  server.listen(3000);
  console.log("Listening on http://localhost:3000");
}

start(server);

在客戶端方面主要是定義了一個定義了一個共用的連結,透過collectionid來獲取的獲取了檔案的例項,也就是上面我們在服務端建立的那個檔案,之後我們透過訂閱檔案的快照以及監聽Op事件,來運算元據,在這裡我們沒有直接運算元據,而是所有的操作都走的client,這種方式就不需要考慮原子化操作的問題了,如果類似於我們下邊的Quill的例項的話,就需要監聽檔案的變化來實現了,在完整的實現了原子化操作的情況下,這種方案更加合適。

export type ClientCallback = (num: number) => void;

class Connection {
  private connection: sharedb.Connection;

  constructor() {
    // 透過`WebSocket`連線到`ShareDB`
    const socket = new ReconnectingWebSocket("ws://localhost:3000");
    this.connection = new sharedb.Connection(socket as Socket);
  }

  bind(cb: ClientCallback) {
    const doc = this.connection.get("ot-example", "counter"); // 透過`collection`與`id`獲取`Doc`例項
    const onSubscribe = () => cb(doc.data.num); // 初始化資料的回撥
    doc.subscribe(onSubscribe); // 訂閱初始化資料
    const onOpExec = () => cb(doc.data.num); // 觸發`Op`的回撥
    doc.on("op", onOpExec); // 訂閱`Op`事件 // 客戶端或伺服器的`Op`都會觸發
    return {
      increase: () => {
        doc.submitOp([{ p: ["num"], na: 1 }]); // `json0`的`Op`操作 // 此處為`{ num: 0 }`增加了`1`
      },
      unbind: () => {
        doc.off("op", onOpExec); // 取消事件監聽
        doc.unsubscribe(onSubscribe); // 取消訂閱
        doc.destroy(); // 銷燬檔案
      },
    };
  }

  destroy() {
    this.connection.close(); // 關閉連結
  }
}

Quill

接下來我們執行一個富文字的例項Quill,實現的主要功能是在quill富文字編輯器中接入協同,並支援編輯遊標的同步,該例項的地址是https://github.com/WindrunnerMax/Collab/tree/master/packages/ot-quill,首先簡單看一下目錄結構(tree --dirsfirst -I node_modules):

ot-quill
├── public
│   └── favicon.ico
├── server
│   ├── index.ts
│   └── types.d.ts
├── src
│   ├── client.ts
│   ├── index.css
│   ├── index.ts
│   ├── quill.ts
│   └── types.d.ts
├── package.json
├── rollup.config.js
├── rollup.server.js
└── tsconfig.json

依舊簡略說明下各個資料夾和檔案的作用,public儲存了靜態資原始檔,在客戶端打包時將會把內容移動到build資料夾,server資料夾中儲存了OT服務端的實現,在執行時同樣會編譯為js檔案放置於build資料夾下,src資料夾是客戶端的程式碼,主要是檢視與OT客戶端的實現,rollup.config.js是打包客戶端的配置檔案,rollup.server.js是打包服務端的配置檔案,package.jsontsconfig.json大家都懂,就不贅述了。

quill的資料結構並不是JSON而是DeltaDelta是透過retaininsertdelete三個操作完成整篇檔案的描述與操作,那麼這樣我們就不能使用json0來對資料結構進行描述了,我們需要使用新的OT型別rich-textrich-text的具體的實現是在官方的quill-delta中實現的,具體可以參考https://www.npmjs.com/package/rich-texthttps://www.npmjs.com/package/quill-delta

ShareDB.types.register(richText.type); // 註冊`rich-text`型別
const backend = new ShareDB({ presence: true, doNotForwardSendPresenceErrorsToClient: true }); // `ShareDB`服務端例項

function start(callback: () => void) {
  const connection = backend.connect(); // 連線到`ShareDB`
  const doc = connection.get("ot-example", "quill"); // 透過`collection`與`id`獲取`Doc`例項
  doc.fetch(err => {
    if (err) throw err;
    if (doc.type === null) { // 如果不存在
      doc.create([{ insert: "OT Quill" }], "rich-text", callback); // 建立初始檔案然後觸發回撥
      return;
    }
    callback();
  });
}

function server() {
  const app = express(); // 例項化`express`
  app.use(express.static("build")); // 客戶端打包過後的靜態資源路徑
  app.use(express.static("node_modules/quill/dist")); // `quill`的靜態資源路徑
  const server = http.createServer(app); // 建立`HTTP`伺服器

  const wss = new WebSocket.Server({ server: server });
  wss.on("connection", function (ws) {
    const stream = new WebSocketJSONStream(ws);
    backend.listen(stream); // `ShareDB`後端需要`Stream`例項
  });

  server.listen(3000);
  console.log("Listening on http://localhost:3000");
}

start(server);

在客戶端主要分為了三部分,分別是例項化quill的例項,例項化ShareDB的客戶端例項,以及quillShareDB客戶端通訊的實現。在quill的實現中主要是將quill例項化,註冊遊標的外掛,隨機生成id的方法,以及透過id獲取隨機顏色的方法。在ShareDB的客戶端操作中主要是註冊了rich-text OT型別,並且例項化了客戶端與服務端的ws連結。在quillShareDB客戶端通訊的實現中,主要是完成了對於quilldoc的事件監聽,主要是OpCursor相關的實現。

Quill.register("modules/cursors", QuillCursors); // 註冊遊標外掛

export default new Quill("#editor", { // 例項化`quill`
  theme: "snow",
  modules: { cursors: true },
});

const COLOR_MAP: Record<string, string> = {}; // `id => color`

export const getRandomId = () => Math.floor(Math.random() * 10000).toString(); // 隨機生成使用者`id`

export const getCursorColor = (id: string) => { // 根據`id`獲取顏色
  COLOR_MAP[id] = COLOR_MAP[id] || tinyColor.random().toHexString();
  return COLOR_MAP[id];
};
const collection = "ot-example";
const id = "quill";

class Connection {
  public doc: sharedb.Doc<Delta>;
  private connection: sharedb.Connection;

  constructor() {
    sharedb.types.register(richText.type); // 註冊`rich-text`型別
    // 透過`WebSocket`連線到`ShareDB`
    const socket = new ReconnectingWebSocket("ws://localhost:3000");
    this.connection = new sharedb.Connection(socket as Socket);
    this.doc = this.connection.get(collection, id); // 透過`collection`與`id`獲取`Doc`例項
  }

  getDocPresence() {
    // 訂閱來自其他客戶端的線上狀態資訊
    return this.connection.getDocPresence(collection, id);
  }

  destroy() {
    this.doc.destroy(); // 銷燬檔案
    this.connection.close(); // 關閉連結
  }
const presenceId = getRandomId(); // 生成隨機`id`
const doc = client.doc; // 獲取`doc`例項

const userNode = document.getElementById("user") as HTMLInputElement;
userNode && (userNode.value = "User: " + presenceId); // 顯示當前使用者

doc.subscribe(err => { // 訂閱`doc`的初始化
  if (err) {
    console.log("DOC SUBSCRIBE ERROR", err);
    return;
  }
  const cursors = quill.getModule("cursors"); // 獲取遊標模組
  quill.setContents(doc.data); // 初始化`doc`資料

  quill.on("text-change", (delta, oldDelta, source) => { // 訂閱編輯器變化
    if (source !== "user") return; // 非當前使用者操作不提交
    doc.submitOp(delta); // 提交操作
  });

  doc.on("op", (op, source) => { // 訂閱`Op`變化
    if (source) return; // 當前使用者操作則返回
    quill.updateContents(op as unknown as Delta); // 服務端的`Op`更新本地內容
  });

  const presence = client.getDocPresence(); // 訂閱其他客戶端狀態
  presence.subscribe(error => { // 訂閱錯誤資訊
    if (error) console.log("PRESENCE SUBSCRIBE ERROR", err);
  });
  const localPresence = presence.create(presenceId); // 建立本地的狀態

  quill.on("selection-change", (range, oldRange, source) => { // 選區發生變化
    if (source !== "user") return; // 不是當前使用者則返回
    if (!range) return; // 沒有`Range`則返回
    localPresence.submit(range, error => { // 本地的狀態來提交選區`Range`
      if (error) console.log("LOCAL PRESENCE SUBSCRIBE ERROR", err);
    });
  });

  presence.on("receive", (id, range) => { // 訂閱收到狀態的回撥
    const color = getCursorColor(id); // 獲取顏色
    const name = "User: " + id; // 拼裝名字
    cursors.createCursor(id, name, color); // 建立遊標
    cursors.moveCursor(id, range); // 移動遊標
  });
});

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://github.com/ottypes/docs
https://share.github.io/sharedb/
https://github.com/share/sharedb
https://www.npmjs.com/package/ot-json0
https://www.npmjs.com/package/ot-json1
https://zhuanlan.zhihu.com/p/481370601
https://zhuanlan.zhihu.com/p/425265438
https://www.npmjs.com/package/rich-text
https://www.npmjs.com/package/quill-delta

相關文章