概要
- 本記事は、 CRDT や OT なしで実現するシンプルな 協調テキスト編集手法 を紹介
- 各文字に グローバル一意ID を付与し、「insert after」操作で編集を管理する提案
- サーバ・クライアント間の 楽観的ローカル更新 や リコンシリエーション 手法も説明
- 既存のCRDT/OTライブラリの 複雑さ・拡張性の課題 を回避可能
- 本手法は リスト編集全般 にも応用が可能
CRDT・OT不要のシンプルな協調テキスト編集アプローチ
背景と課題
- 協調テキスト編集は 複数ユーザー による同時編集時の 操作競合 が最大の課題
- 典型的なアプローチ(例:「index 17に挿入」)は 並行編集 で破綻しやすいことを確認
- サーバは「 どの操作をどう解釈し、どのように適用するか」を明確にする必要があることを強調
- この問題は Google Docs のようなリアルタイム編集だけでなく、一般的なリスト編集にも波及することを認識
既存手法の問題点
- CRDT :各文字に不変なIDを割り当て、全体順序を数学的に定義するが、アルゴリズムが難解で実装が困難
- OT :操作を並行編集に合わせて「変換」するが、変換則の組み合わせが膨大でバグも多い
- これらの手法は ブラックボックス的なライブラリ 利用が前提となり、アプリ固有の拡張やカスタマイズが困難
- 例:部分的なロード、サブドキュメント権限、Google Docs風の「提案」機能、柔軟なストレージ形式などが難しい
提案するシンプルな手法
-
各文字に グローバル一意ID(例:UUID) を付与し、
Array<{ id: ID; char: string }>型で管理することを提案 -
クライアントは「 既存IDの直後に挿入」という形で編集操作をサーバに送信することを推奨
-
例:「f1bdb70aの後ろに'the'を挿入する」
-
サーバはこの「insert after」操作を そのまま解釈 し、該当IDの直後に新文字を挿入することで、競合を直感的に解決
-
削除操作時は、IDを保持しつつ
isDeleted: trueとすることで、削除後も参照可能性を維持することを提案- 型定義例:
Array<{ id: ID; char?: string; isDeleted: boolean }> - テキスト生成例:
list.filter(elt => !elt.isDeleted).map(elt => elt.char).join('')
- 型定義例:
-
クライアントが文字を入力する際の流れ
- 挿入位置直前のIDを特定すること
- 新しいUUIDを生成すること
- 「insert ${char} after ${before} with id ${id}」形式でサーバに送信すること
- サーバ側は該当IDの直後に
{ id, char, isDeleted: false }を挿入すること
-
削除時の流れ
- 削除対象文字のIDを特定すること
- 「delete the entry with id ${id}」形式でサーバに送信すること
- サーバは該当エントリの
isDeletedをtrueにすること
実用上の懸念と最適化
- 文字ごとにUUIDを持つのは ストレージ効率が悪い ため、後述の最適化が必要であることを認識
クライアント側の楽観的ローカル更新とサーバリコンシリエーション
-
Google Docs風の即時反映 には「楽観的ローカル更新」が不可欠であることを確認
-
ローカルで未送信の操作群がある状態で、サーバから新たなリモート操作が届く場合の処理手順
- 未送信のローカル操作を 一旦巻き戻す こと
- 新しいリモート操作を適用すること
- 未送信ローカル操作を再適用すること(リコンシリエーション)
-
この手法は CRDT不要 であり、提案手法と完全に両立することを強調
-
より簡易な戦略として「クライアントは常にサーバ応答待ちで新規操作を禁止する」手もあるが、実用性は低いことを指摘
応用範囲
- 本手法は テキスト編集 だけでなく、 リスト編集一般 (レシピの材料リスト、スライド一覧、スプレッドシート行列など)にも適用可能であることを明示
このアプローチにより、従来のCRDT/OTに頼らず 柔軟で拡張性の高い協調編集アプリ を自作できることが期待される提案。