リアルタイムの共同編集は、舞台裏を覗いてみるまで、いとも簡単に思えるものです。一人がタイピングし、別の人が3段落上で一行を削除し、三人目がStack Overflowからスニペットを貼り付ける。それにもかかわらず、ドキュメントは何とかして一つの整合性のある状態に落ち着きます。WebSocketや分散状態に関する事前の経験がない状態で、その流動性をゼロから構築するのは、無謀に聞こえるかもしれません。しかし、それこそが真に学ぶための正しい方法のようにも思えるのです。
このプロジェクトはゼロからスタートします。既存のボイラープレート(雛形)は使いません。難しい部分を30秒のモンタージュで飛ばしてしまうような、洗練されたYouTubeのチュートリアルも使いません。目標は、複数のユーザーが同じファイルを同時に編集でき、お互いの変更内容やカーソルの動きをリアルタイムで確認できる、共同編集可能なコードエディタを作成することです。その実現には、トランスポート層、一貫性モデル、そしてドキュメントを破損させることなく同時編集をマージするという難解な問題に取り組む必要があります。
「リアルタイム」の真の意味
ほとんどのWebアプリケーションは、リクエスト・レスポンス・サイクルで動作することに慣れています。フォームを送信し、サーバーが保存し、ページを更新する。リアルタイムの共同編集は、その契約を完全に打ち破ります。すべてのキーストロークは、通常ミリ秒単位ですべての接続クライアントに伝播し、意味を損なわない順序で到着しなければならないイベントなのです。
WebSocketsは、クライアントとサーバー間に持続的な全二重接続を維持するため、ここでのトランスポート手段として当然の選択肢となります。数秒おきに「何か新しいことはありますか?」と尋ねて帯域を浪費するHTTPポーリングとは異なり、WebSocketは接続を開いたままにします。ユーザーAがセミコロンを入力すると、その文字はメッセージとなり、ソケットを通って中央サーバーへ送られ、そこからユーザーBとCへと拡散(ファンアウト)されます。その部分に関しては、比較的単純です。
難しいのは、BとCが全く同じ瞬間にタイピングしたときに何が起こるか、という点です。両方の変更がほぼ同時にサーバーに届いた場合、どちらが優先されるのでしょうか? 単にメッセージが到着した順にブロードキャストするだけでは、文字の欠落やテキストの乱れが生じるリスクがあります。単純な「最後に書き込んだものが勝ち(last-write-wins)」という戦略は、ユーザーの意図を無視するため失敗します。私が1行目の冒頭に「hello」と入力し、あなたが同じく1行目の冒頭に「world」と入力した場合、結果はどちらかが消されてしまうような衝突であってはなりません。決定論的に選ばれた「helloworld」または「worldhello」であるべきです。これを実現するには、ドキュメントの構造を理解した同期戦略が必要になります。
なぜゼロから始めることが重要なのか
こうした複雑さを隠蔽してくれる優れたフレームワークは存在します。Yjs、Automerge、Socket.IOを使えば、こうした苦労を抽象化し、午後のひとときで動作するプロトタイプを作成できてしまいます。しかし、その根底にあるプリミティブ(基本要素)を理解せずにこれらを使うのは、計器の読み方を知らずにオートパイロットで飛行機を操縦するようなものです。乱気流に見舞われたとき(分散システムにおいては、必ず見舞われます)、問題がネットワーク層にあるのか、競合解決にあるのか、あるいはデータモデルにあるのかを判断できなければなりません。
ここでの決意は、ライブラリに頼る前に、まず概念を学ぶことです。つまり、以下のような場合に何が起こるのかを、論理的に考えるということです。
- クライアントが入力の途中で切断され、10秒後に再接続した場合
- 2人のユーザーが同じカーソル位置に同時にテキストを挿入した場合
- あるユーザーが、別のユーザーが現在編集中のブロックを削除した場合
- サーバーがクラッシュし、新しいノードがゼロからドキュメントの状態を再構築しなければならない場合
これらの問題に対する主要な解決策には、Operational Transformation (OT) と Conflict-free Replicated Data Types (CRDTs) の2つの系統があります。Google Docsは、初期のアーキテクチャにOTを採用したことで有名です。OTでは、操作を適用する前に、中央サーバーが各操作を互いに変換(transform)させる必要があります。対照的に、CRDTsは、調整なしにローカルで同時更新をマージできるように設計されており、ピア・ツー・ピア(P2P)やエッジベースの構成に適しています。これら(あるいはハイブリッドなアプローチ)のどちらを選択するかには、メモリ使用量、収束の保証、実装の複雑さにおけるトレードオフを理解する必要があります。トレードオフについて読むだけでは不十分です。計画としては、単純なバージョンと洗練されたバージョンの両方を実装し、どこで破綻するのかを実際に確かめるつもりです。
再構築、失敗、そして行き止まり
期待値は正直に設定しておくべきだ。何もかもがうまくいかない時期が必ずある。最初の試みでは、テキストの変更を表現するために単純な JSON patch を使うかもしれないが、JSON には「段落内のインデックス5」という概念がないことに気づくだけだろう。その結果、同じインデックスへの2つの同時挿入が、マージされる代わりに互いに上書きし合ってしまう。2回目の試みでは、独自の線形履歴ログを構築するかもしれないが、ドキュメントが大きくなると、そのログを再生することが Big O 的な悪夢であることに気づく。3回目の試みでは、ローカルで WebSockets を動作させることに成功しても、パケットロスや変動するレイテンシがルールを書き換えてしまう実際のネットワーク上では、すべてが崩壊するかもしれない。
その摩擦こそが目的だ。動作するリポジトリをコピーするだけでは、なぜキューがその特定の順序でフラッシュされるのか、あるいはなぜサーバーが version vector を保持しているのかといった調査をスキップしてしまうことになる。同じコンポーネントを3回作り直すのは時間がかかるが、フレームワークが担当する範囲と、自分自身のロジックで対処しなければならない範囲の境界を理解することを強いてくれる。
このプロセスのドキュメントは、成功体験のハイライト集にはならない。間違いについても記述する。例えば、プレゼンス(誰がオンラインで、カーソルがどこにあるかを知ること)の構築は、それがテキスト自体と同じ一貫性モデルに依存していることに気づくまで、単なる見た目の機能のように思える。ユーザーAがユーザーBのカーソルを10列目で見たとき、ユーザーBが4文字挿入したら、そのカーソルはどこに移動するのか? ドキュメントのトポロジーに関する共通認識がなければ、プレゼンスデータは現実から乖離してしまう。これを解決するには、カーソルの位置を単なる数値インデックスではなく、基盤となるデータ構造のアイデンティティに紐付ける必要がある。これらは、退屈だからといってチュートリアルでさらっと流されがちな詳細だが、決して重要ではないわけではない。
次のステップ
当面のロードマップは、意図的に最小限に留めている。最初のマイルストーンは以下の通りだ:
- 文字イベントをエコーする生の WebSocket サーバー(レイテンシと接続のライフサイクルを直接体感するため)
- クライアント側の単純な文字列バッファ(並行処理下でなぜ素朴な挿入順序が失敗するのかを理解するため)
- 順序付きシーケンスのための、たとえ非効率であってもゼロからの CRDT(交換法則を実際に確認するため)
- 実際のコードエディタ(CodeMirror や Monaco など)への段階的な統合(エディタの命令形 API と、操作履歴の関数的な性質との間のミスマッチに取り組むため)
各ステップには、書面による根拠を添える。なぜそのアプローチなのか、なぜ他ではないのか? どのような仮定が覆されたのか? どのような抽象化が漏れ出した(leaked)のか?
真の教訓
WebSockets や CRDT の経験なしにこのようなプロジェクトを始めるのは威圧的に感じるかもしれないが、専門知識とは、多くの場合「より適切なラベルを貼った、繰り返される混乱」に過ぎない。目的は、素早く終わらせることではない。すべてのレイヤーが「期待」とともにインポートされたものではなく、「意図」を持って構築されているため、その挙動が予測可能であるシステムを作ることだ。
テキストエディタ、デザインツール、あるいはゲーム状態の同期エンジンなど、共同作業ソフトウェアを構築した経験があるなら、不意を突かれた失敗モードをぜひ共有してほしい。もしあなたもこれらのシステムを学んでいる最中なら、ぜひ一緒に進んでほしい。コードはゆっくりと現れ、何度も書き直されることになるだろう。Day 0 は今、始まる。
