Closed14

100日チャレンジ day9 (同時編集可能なテキストエディタ)

riddle_tecriddle_tec

昨日
https://zenn.dev/gin_nazo/scraps/1cde7e2a535b6d


https://blog.framinal.life/entry/2025/04/14/154104

100日チャレンジに感化されたので、アレンジして自分でもやってみます。

やりたいこと

  • 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
  • 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
  • 生成aiをつかったバイブコーティングになれる
  • 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)

できたもの

https://github.com/lirlia/100day_challenge_backend/tree/main/day9_collaborative_editor

riddle_tecriddle_tec

承知いたしました。複数人同時編集における主要な競合解決・同期方式について、それぞれ説明します。

  1. Last Write Wins (LWW)

    • 説明: 最もシンプルな方式です。複数のユーザーが同じデータを編集した場合、サーバー(または他のクライアント)が最後に受信した編集内容が、それ以前の編集内容を完全に上書きします。タイムスタンプやシーケンス番号で「最後」を判断します。
    • 長所: 実装が非常に簡単です。複雑なロジックは不要です。
    • 短所: ユーザーの編集内容が意図せず失われる可能性が高いです。例えば、2人のユーザーがほぼ同時に同じ段落を編集した場合、先に保存(送信)したユーザーの編集は、後から保存(送信)したユーザーの編集によって完全に消えてしまいます。細かいテキスト編集には不向きです。
    • 用途: プロフィール更新など、データの特定フィールド全体を置き換えるような単純な更新には使われることがあります。
  2. Diff/Patch (差分検出/パッチ適用)

    • 説明: 各ユーザーは、自分が最後に知っていたドキュメントの状態と現在の状態との「差分 (Diff)」を計算してサーバーに送ります。サーバー(または他のクライアント)は、受信した差分情報(パッチ)を自身のドキュメント状態に「適用 (Patch)」しようと試みます。
    • 長所: LWWよりは粒度が高いです。重ならない箇所の同時編集は比較的うまく扱えます。変更点のみを送るので、ネットワーク転送量を削減できます。diff-match-patchのようなライブラリが存在します。
    • 短所: 編集箇所が競合する場合(同じ行を編集するなど)や、パッチを適用するベースとなるドキュメントのバージョンがずれている場合に、パッチ適用が失敗したり、予期せぬ結果になったりする可能性があります。依然として競合解決のロジックが必要です。
  3. Operational Transformation (OT)

    • 説明: Google Docs や Etherpad などで伝統的に使われてきた方式です。編集を「操作 (Operation)」(例: "5番目の位置に 'abc' を挿入"、"10番目から3文字削除")として表現します。サーバー(またはクライアント)は、新しい操作を受け取った際に、すでに適用済みの他の(並行して行われた)操作の影響を考慮して、受け取った操作を「変換 (Transform)」します。これにより、操作がどのような順序で届いたとしても、最終的に全員のドキュメントが同じ状態に収束することを保証します。通常、操作の順序付けと変換を行う中央サーバーが必要です。
    • 長所: (正しく実装されれば)数学的に一貫性が保証されます。複雑な同時編集もうまく扱えます。ユーザー体験が良い傾向があります。
    • 短所: アルゴリズムの理解と正確な実装が非常に複雑です。特に、あらゆる操作のペアに対する変換関数を正しく設計・実装する必要があります。デバッグが困難になることがあります。中央サーバーへの依存度が高いです。
  4. Conflict-free Replicated Data Types (CRDTs)

    • 説明: より新しいアプローチです。データ構造自体が、競合が発生しないように(あるいは自動的に解決されるように)設計されています。各レプリカ(ユーザーの持つドキュメント)は、他のユーザーから受け取った操作を(多くの場合、特別な変換なしに)任意の順序で適用でき、最終的には同じ状態に収束します。操作はしばしば可換(順序を変えても結果が同じ)になるように設計されます。ピアツーピアやオフライン編集のシナリオに適しています。
    • 長所: 数学的に最終的な一貫性が保証されます。OTのような複雑な変換関数が不要な場合が多く、概念的にシンプルになることがあります。オフライン編集やサーバーレス環境との親和性が高いです。
    • 短所: データ構造によっては(特にテキストのような線形シーケンス)、直感的でない挙動を示すことがあります(ただし、テキスト編集用の優れたCRDTアルゴリズムも存在します)。メタデータやストレージのオーバーヘッドがOTより大きくなる場合があります。パフォーマンスはアルゴリズムによります。依然として注意深い実装が必要です。

今回のチャレンジでは:

100日チャレンジの1日という制約を考えると、OTやCRDTsの完全な実装は非常に困難です。

  • LWW: 実装は最も簡単ですが、テキストエディタとしては実用性が低いです。
  • Diff/Patch: ライブラリを使えば実装の複雑さは軽減されますが、競合時の処理は依然として課題です。シンプルな競合解決(例:後勝ち)と組み合わせることも考えられます。

今回のチャレンジでは、まず WebSocket で単純にテキスト全体を送り合い、LWW で同期する ところから始めるのが現実的かもしれません。もし余裕があれば、diff-match-patch を使った Diff/Patch (ただし競合解決はLWW) に挑戦する、というステップが良いかもしれません。

どの方式で進めたいか、ご希望はありますか?

riddle_tecriddle_tec

day9 は共同編集可能なオンラインテキストエディタを作りたいです。

なお@https://github.com/Operational-Transformation/ot.js を使用したOTバージョンとします
また自動保存で保存される google document のような感じです。

1端末で検証したいので別タブであれば別のユーザとみなしてください。
また各ユーザがどこにカーソルを合わせているかを表示するようにしてください(色を変えた上で)

上記の内容で仕様を考えて


承知しました。day9 の共同編集可能なオンラインテキストエディタの仕様を以下のように考えました。

アプリケーション名: day9_collaborative_editor

コア機能:

  1. リアルタイム共同編集:
    • 複数のユーザーが同時に1つのテキストドキュメントを編集できます。
    • 編集内容は Operational Transformation (ot.js) を用いて衝突解決され、リアルタイムに他のユーザーに反映されます。
  2. ユーザーカーソル表示:
    • 各ユーザーの現在のカーソル位置が、テキストエリア内に色付きで表示されます。
    • ユーザーごとに異なる色が割り当てられます。
  3. 自動保存(簡易版):
    • サーバー側でドキュメントの最新状態を保持します。(今回はシンプルにサーバーのメモリ上に保持します)
  4. ユーザー識別:
    • ブラウザの別タブまたは別ウィンドウは、それぞれ別のユーザーとして扱われます。
    • 各ユーザーには接続時に一意のIDと色が割り当てられます。

技術要素:

  • フロントエンド: Next.js (App Router), React, TypeScript, Tailwind CSS, ot.js, WebSocket Client
  • バックエンド: Node.js (WebSocketサーバー), ws ライブラリ, ot.js
  • 通信: WebSocket

アーキテクチャ概要:

  1. クライアント (ブラウザタブ):
    • ページ読み込み時に WebSocket でサーバーに接続します。
    • 接続時にサーバーから現在のドキュメント内容とリビジョン番号を受け取ります。
    • テキストエリアでの編集やカーソル移動を検知します。
    • 編集内容は ot.js でオペレーションに変換し、リビジョン番号と共に WebSocket でサーバーに送信します。
    • カーソル移動情報も WebSocket でサーバーに送信します。
    • サーバーから他のユーザーの編集オペレーションやカーソル情報を受け取り、ローカルのテキストエリアとカーソル表示に反映させます (ot.js でオペレーションを適用)。
  2. サーバー (Node.js WebSocketサーバー):
    • クライアントからの WebSocket 接続を待ち受けます。
    • 現在のドキュメント内容、リビジョン番号、接続中のユーザー情報(ID、色、カーソル位置など)をメモリ上で管理します。
    • クライアントから編集オペレーションを受け取ると、ot.js を用いてサーバー上のドキュメントに適用し、リビジョン番号を更新します。
    • 適用したオペレーションを、送信元クライアントのリビジョンに合わせて変換し、他の全クライアントにブロードキャストします。
    • クライアントからカーソル移動情報を受け取ると、他の全クライアントにブロードキャストします。
    • クライアント切断時にはユーザー情報を削除します。

UI:

  • 中央にテキスト編集エリア (<textarea> または CodeMirror/Monaco Editorのようなライブラリを使用) を配置します。
  • テキストエリア内に、他のユーザーのカーソル位置を示す色付きのインジケータを表示します。

注意点:

  • ot.js (https://github.com/Operational-Transformation/ot.js) は現在アクティブにメンテナンスされていないようです。基本的な動作は可能と思われますが、複雑なケースで問題が発生する可能性はあります。
  • リアルタイム通信のため、Next.js の開発サーバーとは別に、Node.js で WebSocket サーバーを立てる構成とします(例: server.js を作成し、npm run start:ws のようなスクリプトで起動)。

実装手順案:

  1. プロジェクト初期化:
    • template から day9_collaborative_editor ディレクトリをコピー。
    • package.jsonnameday9_collaborative_editor に変更。
    • README.md に上記の仕様概要を記述。
  2. WebSocket サーバー実装 (server.js):
    • ws ライブラリをインストール。
    • 基本的な WebSocket サーバーを構築 (接続/切断処理、メッセージ受信)。
    • ドキュメント状態 (内容、リビジョン) とクライアント情報を管理するロジックを追加。
    • ot.js を用いてオペレーションを処理・ブロードキャストする機能を実装。
    • カーソル情報をブロードキャストする機能を実装。
    • package.json に WebSocket サーバー起動用のスクリプトを追加。
  3. フロントエンド実装 (app/page.tsx):
    • テキストエリアコンポーネントを設置。
    • WebSocket サーバーへの接続処理 (useEffect) を実装。
    • テキストエリアの変更を検知し、ot.js オペレーションを生成してサーバーに送信する処理を実装。
    • カーソル位置の変更を検知し、サーバーに送信する処理を実装。
    • サーバーからのメッセージ (オペレーション、カーソル情報) を受信し、テキストエリアの内容とカーソル表示を更新する処理を実装。
    • ot.js のクライアント側ロジックを組み込む。
    • 他のユーザーのカーソルを色付きで表示する UI を実装。

この仕様と実装手順で進めてよろしいでしょうか? 特に WebSocket サーバーを別プロセスで立てる点について、ご認識いただければと思います。

riddle_tecriddle_tec

承知いたしました。以下の手順で進めます。

  1. プロジェクト初期化
  2. 依存関係のインストール
  3. WebSocket サーバー実装 (server.js)
  4. package.json にスクリプト追加
  5. Prisma スキーマ定義 (不要モデルの削除)
  6. フロントエンド基本レイアウト (app/layout.tsx, app/page.tsx)
  7. WebSocket クライアント接続 (app/page.tsx)
  8. ot.js クライアント側実装
  9. カーソル表示 UI (app/page.tsx)
riddle_tecriddle_tec

やっとリアルタイム書き込みできるようになった

riddle_tecriddle_tec

はい、ここまでの作業の流れをまとめます。

  1. デザイン調整:

    • 最初に Etherpad 風のデザインに変更。
    • 次に、シンプルな左右分割(入力/プレビュー)デザインに変更。
    • 最終的に HackMD 風のデザイン(黒いヘッダー、左右分割、薄いグレー背景)に調整しました。この過程で背景色の適用に問題がありましたが、コード上は正しく適用されていることを確認しました(ブラウザキャッシュ等の影響が考えられました)。
  2. 同期機能の不具合発覚: デザイン調整後、テキスト入力が他のタブに同期されない問題が発覚しました。

  3. 原因調査と初期対応 (OTライブラリ):

    • WebSocket 接続は行われているものの、変更を差分(オペレーション)として送受信し、適用する OT (Operational Transformation) ロジックが未実装であることを確認しました。
    • OT ライブラリとして ot-jsot-text-unicode の導入を試みましたが、npm パッケージが見つからない、あるいは型定義の問題で Linter エラーが解消しない、という問題に直面しました。
  4. 方針転換 (ShareDB導入):

    • より安定した実装を目指し、OT を含む高機能なリアルタイム同期フレームワークである sharedb を利用する方針に転換しました。
    • 必要なライブラリ (sharedb, rich-text, reconnecting-websocket, quill-delta) をインストールしました。
  5. サーバー側リファクタリング (server.js):

    • server.jssharedb を使うように全面的に書き換えました。
    • sharedb バックエンドを初期化し、WebSocket 接続を sharedb に渡すようにしました。
    • 途中で MemoryStreambackend.listen の使い方に関するエラーが発生しましたが、WebSocket 接続をラップする WebSocketJSONStream クラスを作成することで解決しました。
    • 初期ドキュメントを rich-text 型で作成するようにしました。
  6. クライアント側リファクタリング (app/page.tsx):

    • app/page.tsxsharedb/client を使うように書き換えました。
    • ReconnectingWebSocket で接続し、sharedb ドキュメントを購読しました。
    • サーバーからのオペレーション受信 (doc.on('op')) と、ローカル変更の送信 (handleTextChange 内の doc.submitOp) ロジックを実装しました。
  7. 同期しない問題のデバッグ:

    • sharedb 導入後も同期しない問題が続きました。
    • 詳細なデバッグログを追加し、調査を進めました。
    • 接続問題: 一時的に "Disconnected" になる問題がありましたが、サーバーの再起動やクライアントのログ確認で、接続自体は最終的に確立できていることを確認しました。
    • 差分計算の問題: クライアント側の handleTextChangerichText.type.diff() を使って差分を計算していましたが、プレーンな文字列同士の比較では常に差分なし (ops: Array(0)) となり、オペレーションがサーバーに送信されないことが根本原因だと判明しました。
    • 解決策: rich-text が内部で利用する quill-delta を直接利用し、new Delta().insert(text) でテキストから Delta オブジェクトを作成し、previousDelta.diff(newDelta) で差分 Delta を計算するように修正しました。
  8. 同期成功: quill-delta を使って差分を計算・送信するように修正した結果、無事にリアルタイム同期が機能するようになりました。

  9. コミット: デザイン変更、ShareDB 導入、同期修正の各段階で作業内容をコミットしました。

長くなりましたが、主に sharedb の導入と、それに伴うクライアント・サーバー双方の実装変更、そして最終的に quill-delta を使った差分計算方法の修正によって同期の問題を解決した、という流れになります。

riddle_tecriddle_tec

振り返り

  • なぜか普段使ってるのとは違うバージョンが使われた
    • そのせいでデザイン適用に時間がかかった
  • また OT 周りのライブラリがうまく動かない、代わりのライブラリ使うのも大変だった
  • バグの出る範囲がフロント、バックエンド、WSと色々あって大変だった
このスクラップは5ヶ月前にクローズされました