初探富文字之OT協同例項
在前邊初探富文字之OT協同演算法一文中我們探討了為什麼需要協同、為什麼僅有原子化的操作並不能實現協同、為什麼要有操作變換、如何進行操作變換、什麼時候能夠應用操作、服務端如何進行協同排程等等,這些屬於完成協同所需要了解的基礎知識,實際上當前有很多成熟的協同實現,例如ot.js
、ShareDB
、ot-json
、EasySync
等等,本文就是以ShareDB
為OT
協同框架來實現協同的例項。
描述
接入協同框架實際上並不是一件簡單的事情,尤其是對於OT
實現的協同演算法而言,OT
的英文全稱是Operational Transformation
,也就是說實現OT
的基礎就是對內容的描述與操作是Operational
原子化的。在富文字領域,最經典的Operation
有quill
的delta
模型,透過retain
、insert
、delete
三個操作完成整篇文件的描述與操作,還有slate
的JSON
模型,透過insert_text
、split_node
、remove_text
等等操作來完成整篇文件的描述與操作。有了這個協同實現的基礎之後,還需要對所有Op
具體實現變換Transformation
,這就是個比較麻煩的工作了,而且也是必不可少的實現。同樣是以quill
與slate
兩款開源編輯器為例,在quill
中已經實現了對於其資料結構delta
的所有Transformation
,可以直接呼叫官方的quill-delta
包即可;對於slate
而言,官方只提供了原子化的操作API
,並沒有Transformation
的具體實現,但是有社群維護的slate-ot
包實現了其JSON
資料的Transformation
,也可以直接呼叫即可。
OT
協同的實現在富文字領域有比較多的實現可供參考,特別是在開源的富文字引擎上,其實現方案還是比較成熟的,但是引申一下,在其他領域可能並沒有具體的實現,那麼就需要參考接入的文件自己實現了。例如我們有一個自研的思維導圖功能需要實現協同,而儲存的資料結構都是自定義的,沒有直接可以呼叫的實現方案,那麼這就需要自己實現操作變換了,對於一個思維導圖而言我們實現原子化的操作還是比較容易的,所以我們主要關注於變換的實現。假如這個思維導圖功能我們是透過JSON
的資料結構儲存的資料,那麼我們就可以參考json0
或者slate-ot
的實現,特別是透過閱讀單元測試可以比較容易地理解具體的功能,透過參考其實現來自行實現一份OT
的變換,或者直接依照其實現維護一箇中間層的資料結構,依照於這個中間層進行資料轉換。再假如我們的思維導圖維護的是一個線性的類文字結構,那麼就可以參考rich-text
與quill-delta
的實現,只不過這樣的話實現原子化的操作可能就麻煩一些了,當然同樣我們也可以維護一箇中間層的資料結構來完成OT
。實際上有比較多的參考之後,接入OT
協同就主要是理解並且實現的問題了,這樣就有一個大體的實現方向了,而不是毫無頭緒不知道應該從哪裡開始做協同。另外還是那個宗旨,合適的才是最好的,要考慮到實現的成本問題,沒有必要硬套資料結構的實現,就比如上邊說的實現思維導圖使用線性的文字來表示還是有點牽強的,當然並不是不可能的,比如Google Docs
的Table
就是完全的線性結構,要知道其是可以實現表格中巢狀表格的,相當於每一個單元格都是一篇文件,內部可以嵌入任何的富文字結構,而在實現上就是透過線性的結構完成的。
或許上邊的json0
和rich-text
等概念可能一時間讓人難以理解,所以下面的Counter
與Quill
兩個例項就是介紹瞭如何使用sharedb
實現協同,以及json0
和rich-text
究竟完成了什麼樣的工作,當然具體的API
呼叫還是還是需要看sharedb
的文件,本文只涉及到最基本的協同操作,所有的程式碼都在https://github.com/WindrunnerMax/Collab
中,注意這是個pnpm
的workspace 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.js
是babel
的配置資訊,rollup.config.js
是打包客戶端的配置檔案,rollup.server.js
是打包服務端的配置檔案,package.json
與tsconfig.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
並且透過透過collection
與id
獲取文件例項,在文件就緒之後觸發回撥啟動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);
在客戶端方面主要是定義了一個定義了一個共用的連結,透過collection
與id
來獲取的獲取了文件的例項,也就是上面我們在服務端建立的那個文件,之後我們透過訂閱文件的快照以及監聽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.json
與tsconfig.json
大家都懂,就不贅述了。
quill
的資料結構並不是JSON
而是Delta
,Delta
是透過retain
、insert
、delete
三個操作完成整篇文件的描述與操作,那麼這樣我們就不能使用json0
來對資料結構進行描述了,我們需要使用新的OT
型別rich-text
,rich-text
的具體的實現是在官方的quill-delta
中實現的,具體可以參考https://www.npmjs.com/package/rich-text
與https://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
的客戶端例項,以及quill
與ShareDB
客戶端通訊的實現。在quill
的實現中主要是將quill
例項化,註冊游標的外掛,隨機生成id
的方法,以及透過id
獲取隨機顏色的方法。在ShareDB
的客戶端操作中主要是註冊了rich-text OT
型別,並且例項化了客戶端與服務端的ws
連結。在quill
與ShareDB
客戶端通訊的實現中,主要是完成了對於quill
與doc
的事件監聽,主要是Op
與Cursor
相關的實現。
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