この記事はAtrae Advent Calendar 2024の6日目の記事です。前回は@motonosuke_devによる「改善余地のあるコードから考える学びの重要性」でした。
今日はリアルタイム共同編集が実現できるライブラリのLoroを試してみようと思います。似たようなライブラリには、Yjsがありますが、筆者の個人的な感想としてはYjsよりドキュメントがわかりやすく、かつタイムトラベル機能が容易に実装できるのが魅力的です。

Loroとは
LoroはCRDT(Conflict-free Replicated Data Types)というデータ構造を活用したライブラリで、リアルタイムの共同編集やバージョン管理機能をアプリケーションに組み込むことができます。

簡単に言うと、複数人で同時にデータを操作しても競合を起こさないでいい感じにマージしてくれるライブラリです。 同時編集中に接続が切れオフラインで操作をしても、オンライン復帰時に他の人の操作も含めていい感じにマージしてくれます。
CRDTについてはCRDT (Conflict-free Replicated Data Type)を15分で説明してみるが参考になるのでぜひご覧ください。

使い方
使い方は意外と簡単です。LoroDocと呼ばれるものを初期化して、好みのデータを追加したり削除したりしていきます。 サポートしてるデータ形式はList,Text,Mapなどです。詳しくは公式ドキュメントを参照してください。
const doc = new LoroDoc();
const text: LoroText = doc.getText("text");
text.insert(0, "Hello world!");
console.log(doc.toJSON()); // { "text": "Hello world!" }
共同で編集する場合は複数のLoroDocを同期させる必要がありますが、exportメソッドとimportメソッドが用意されているのでそれを使います。変更を共有したいLoroDocの内容をexportして、変更を取り込みたいLoroDocでimportするといった具合です。
exportメソッドは、用途に応じてエンコードモードを使い分けます。
以下は同時に同じmapを操作したときの例です。Loroのmapは最後に編集したものが反映されるLWW(Last Writer Wins)というポリシーを使用しています。また同時編集でコンフリクトが起きた際には、Lamport timestampの順序決定アルゴリズムを使用して競合を解消しているらしいです。
import { LoroDoc } from "loro-crdt";
const docA = new LoroDoc();
const docB = new LoroDoc();
docA.setPeerId(0) // 同時に編集が起きた場合は、peerId の大きい方が優先される.
docB.setPeerId(1) // peerIdを反対にすると結果も反対になる.
docA.getMap("map").set("key1", "valueA");
docA.getMap("map").set("key2", "valueA");
docB.getMap("map").set("key1", "valueB");
const bytesA = docA.export({mode: "update"})
const bytesB = docB.export({mode: "update"})
console.log(docA.toJSON()) // { map: { key1: 'valueA', key2: 'valueA' } }
console.log(docB.toJSON()) // { map: { key1: 'valueB' } }
docB.import(bytesA)
docA.import(bytesB)
console.log(docA.toJSON()) // { map: { key1: 'valueB', key2: 'valueA' } }
console.log(docB.toJSON()) // { map: { key1: 'valueB', key2: 'valueA' } }
実際にアプリケーションの組み込んでみる
今回はホワイトボードライブラリの tldraw を使って、共同編集可能なホワイトボードを作ってみます。今回はサーバーでは状態を持たず、ひたすら差分を垂れ流すだけにして、各クライアントがLoroDocを管理する形にしてみます。
サンプルを用意しましたのでぜひ参考にしてみてください。
受け取ったメッセージを他のクライアントに送信するWebsocketサーバーを作る
まずはメッセージを垂れ流すだけのWebsocketサーバーを作ります。
import { Hono } from "hono";
import { createNodeWebSocket } from "@hono/node-ws";
import { serve } from "@hono/node-server";
import { WebSocket } from "ws";
const app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({
app,
});
const port = 1234;
const server = serve({ fetch: app.fetch, port });
injectWebSocket(server);
console.log(`Server is running on port ${port}`);
// 接続してるクライアントを管理する配列
const conns: WebSocket[] = [];
app.get(
"/",
upgradeWebSocket(() => {
return {
onOpen: async (evt, ws) => {
if (!ws.raw) return;
if (!(ws.raw instanceof WebSocket)) return;
// すでに接続済みの場合はスキップ
if (!conns.includes(ws.raw)) conns.push(ws.raw);
},
onMessage: (message, ws) => {
if (!ws.raw) return;
if (!(ws.raw instanceof WebSocket)) return;
const update = message.data as ArrayBuffer;
ws.raw.binaryType = "arraybuffer";
for (const conn of conns) {
// 送信元以外に垂れ流し
if (conn === ws.raw) continue;
conn.send(update);
}
}
};
}),
);
実態はほぼ33行目と38行目で、来たデータをそのまま他のクライアントに送り返してるだけです。
tldrawを使ったクライアントアプリを作る
続いてクライアントです。
クライアントの処理の流れは大体以下のような感じです。
- tldrawの状態の変化を検知し、LoroDocを更新
- LoroDocの更新を検知してWebsocketに変更差分を送信
- (Websocketが他のクライアントに差分を垂れ流す)
- Websocketから送られてきた差分をLoroDocに適用
- LoroDocの更新を検知してtldrawに反映
1. tldrawの状態の変化を検知し、LoroDocを更新
tldrawのボード上にオブジェクトが追加されたり、更新・削除された場合、tldrawのstoreのイベントハンドラで変更を検知できます。イベントハンドラ内で、取得した変更を元にローカルのLoroDocを更新します。
const editor = useEditor(); // tldrawが提供するhook
const doc = new LoroDoc();
// 中略
useEffect(() => {
// ホワイトボードの変更を検知
const listen = editor.store.listen((e) => {
const { changes, source } = e;
if (source !== "user") return; // ローカルでの変更のみを処理
const { added, removed, updated } = changes;
for (const shape of added) {
// LoroDocの更新
doc.getMap("map").set(shape.id, shape)
}
});
return listen;
}, []);
2. LoroDocの更新を検知してWebsocketに変更差分を送信
ステップ1の13行目でのLoroDocの更新によって、Mapのイベントハンドラが発火します。Mapのイベントハンドラ内では、ローカルの変更をWebsocketサーバーに送信する処理を追加します。
versionRefはステップ4で説明します。
const doc = new LoroDoc();
const versionRef = useRef(); // 前回との差分のみを送信するために保持するref
// 中略
const handleMapUpdate = useCallback(
(e: LoroEventBatch) => {
if (e.by === "local") {
// ローカルでの更新
// websocketに差分を送信
const updated = doc.export({
mode: "update",
from: versionRef.current,
});
websocket.send(update)
}
},
[doc, editor.store, wsProvider],
);
useEffect(() => {
doc.getMap("map").subscribe(handleMapUpdate);
}, [doc, handleMapUpdate]);
3. (Websocketが他のクライアントに差分を垂れ流す)
4. Websocketから送られてきた差分をLoroDocに適用
ステップ2で変更差分が送信元以外のクライアントに送信されます。差分を受け取ったクライアントは、LoroDocに適用します。
この時LoroDocを更新したタイミングのバージョンをrefで保持しておきます。そうすることで、状態更新時に前のバージョンとの差分のみを取り出して送信できます。
const doc = new LoroDoc();
const ws = new Websocket();
const versionRef = useRef();
// 中略
const handleWsMessage = useCallback(
async (ev: MessageEvent) => {
const data = ev.data;
const arrayMessage = new Uint8Array(data);
doc.import(arrayMessage); // LoroDocに適用
versionRef.current = doc.version(); // バージョンをrefに保持
},
[doc],
);
useEffect(() => {
ws.addEventListener("message", handleWsMessage);
return () => {
ws.removeEventListener("message", handleWsMessage);
};
}, [handleWsMessage, wsProvider]);
5. LoroDocの更新を検知してtldrawに反映
Loroではsubscribeメソッドが用意されていて、Docの変更をトリガーとしたイベントハンドラをセットできます。 イベントハンドラ内では、ローカルでの更新(map.set("hoge")等の直接の更新)とリモートの更新(importメソッドを使用した更新)を区別できるので、以下のようにそれぞれ処理を分けることができます。
自分の操作(→Websocketに送信)と他のクライアントから送信されたもの(→tldrawに反映)を区別するために、先程のMapのイベントハンドラに条件分岐を追加します。
const doc = new LoroDoc();
const handleMapUpdate = useCallback(
(e: LoroEventBatch) => {
if (e.by === "local") {
// doc.getMap("map").set()による変更
}
if (e.by === "import") {
// doc.importによる変更
const updateShapes: TLRecord[] = [];
const events = e.events;
for (const event of events) {
if (event.diff.type === "map") {
const map = event.diff.updated;
for (const key in map) {
const shape = map[key] as unknown as TLShape;
updateShapes.push(shape);
}
}
}
// tldrawにLoroDocの変更を反映
editor.store.mergeRemoteChanges(() => {
editor.store.put([...updateShapes]);
});
}
},
[doc, editor.store],
);
うまくいくとこんな感じでリアルタイムに同期されたホワイトボードができるはずです。
また、ドキュメントには書いてないのですが、Awarenessの実装もあるのでちょっと工夫すればユーザーカーソルの同期のように永続化しなくても良い情報の同期もよしなにできるようになります。
AwarenessについてはYjsのドキュメントを読んだほうが分かりやすいかもしれません。概念はおそらくほぼ同じです。
Websocketサーバーに一手間加えて永続化できるようにしてあげればリロードしても他のクライアントと同期できるようにもなります。
まとめ
今回はLoroを使ってリアルタイムに共同編集できるホワイトボードを作ってみました。Yjsと比較してもドキュメントがわかりやすく操作も直感的な気がします。
履歴機能も割と簡単に作れちゃうのでぜひ皆さんも試してみてください。
CloudflareのDurable Objectsと組み合わせて使えたらかなり面白そうということで、後日Honoのアドベントカレンダーでチャレンジしようと思います。
それではまた次回お会いしましょう。
宣伝
我が家の猫のLGTM画像サイトです。ぜひご自由に使ってください。

次回の7日目は @muttsu_623による「Androidアプリエンジニアからフルスタックエンジニアになった話 a.k.a. バックエンドの学び方【後編】」です。