6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Miroみたいなリアルタイム共同編集ボードを作るんだ!!!!

Last updated at Posted at 2025-12-08

はじめに

なぜこの記事を書こうと思ったか? Miroってどうやってできてるの!? って思ったからです。あとバックエンド久しぶりに書きたかった。

最初はGoで書こうかと思っていましたが、ポインタの理解に脳のリソースが割かれそうだったので、過去に実装実績のあるHonoで書くことにしました。

この記事でわかること

  • CRDTの基本: 複数人が同時編集しても競合しない仕組み
  • LWWの実装方法: タイムスタンプで「どっちが勝つか」を決めるシンプルなルール
  • 楽観的更新: サーバー応答を待たずにUIを即反映させるテクニック
  • BE/FEの実装ポイント: Hono + Flutter での具体的な実装の勘所

対象読者

経験 レベル
Flutter APIの繋ぎ込み経験あり
Hono 触ったことがある
WebSocket 概念は知ってる

何を作るのか

ボード上の図形をリアルタイムで移動できるアプリを作ります。

一番の課題は複数人が同時に動かしたときに、どの更新を正とするかです。これについては1日目の記事で解説していますが、今回はLWW(Last-Writer-Wins)を使用して実装します。
以下では、コア機能だけ解説していきますので、詳細はリポジトリをcloneして、コードリーディングしてみてください!

今回のスコープ
今回は永続化しませんが、DBの更新タイミングで読み込みが走らないようなシームレスな永続化が今後の課題です。

完成形

図形の作成

create.gif

図形の削除

delete.gif

技術スタック

レイヤー 技術
Backend Hono + Bun(TypeScript)
Frontend Flutter Web(Dart)
同期方式 CRDT(LWW-Element-Set)
通信 WebSocket

リポジトリ

ソースコードは以下で公開しています。この記事と合わせて参照してください。


全体の流れ

まずは全体像を把握しましょう。クライアントとサーバーがどうやりとりするかを示します。

ポイントは:

  • クライアント: 操作をWebSocketで送信、他クライアントの操作を受信
  • Hub: WebSocket接続を管理、操作をブロードキャスト
  • MemoryRepository: CRDT状態を保持(将来的にはDB差し替え可能)
  • CRDT: 競合解決ロジック(今回のキモ)
なぜ /api/state は HTTP GET なのか
  • WebSocketは操作をリアルタイムにやり取りするためのチャネル
  • 全体のスナップショットを一度だけ取りたいときはリアルタイム性不要なので、シンプルなHTTP GETで十分
  • クライアントが接続直後に「今の全状態」を取得する用途に合っている

CRDTとは

CRDT(Conflict-free Replicated Data Type) は「競合しないデータ型」です。複数のクライアントが同時に編集しても、最終的に全員が同じ状態に収束します。Miroみたいなリアルタイム共同編集には必須の技術。

なぜ必要なのか

普通のやり方だと、2人が同時に同じ図形を動かしたらどっちが正しいか分からなくなります。「最後に保存した人が勝ち」だと、片方の編集が消えてしまう。

CRDTを使えば、両方の編集が保存されつつ、全員の画面が同じ状態になります。

LWW(Last-Writer-Wins)の実装

今回使うのはLWW-Element-Set。シンプルで分かりやすいCRDTです。

ルール: タイムスタンプが新しい方が勝つ。同時刻ならclientIdの辞書順で決める。

競合解決の心臓部

// これがCRDTの心臓部
function shouldOverwrite(
  newTimestamp: number,      // 新しい操作の時間
  newClientId: string,       // 新しい操作の送り主
  existingTimestamp: number, // 既存データの時間
  existingClientId: string   // 既存データの送り主
): boolean {
  // 時間が新しいほうが勝ち
  if (newTimestamp > existingTimestamp) {
    return true;
  }
  // 同じ時間なら、clientId(文字列)の辞書順で大きいほうが勝ち
  if (newTimestamp === existingTimestamp && newClientId > existingClientId) {
    return true;
  }
  // それ以外は負け
  return false;
}

たったこれだけ。 この関数を全ての操作(追加・更新・削除・マージ)で使います。

ポイントは「時間が新しいものを優先。同時刻なら送り主IDの文字列順で大きいほうを優先」というルールを一貫して使うだけ。

操作の流れ

各操作の説明

操作 説明
upsert 同じIDのデータがあるとき、より新しい方(または同時刻ならclientIdが辞書順で大きい方)が上書き。新しい方ならshapeとtimestampを保存してdeleted=false
delete 既存データがある場合だけ比較し、より新しい方なら「削除フラグで上書き」。そうでなければ現状維持
マージ 相手のエントリを1つずつ見て、自分にないものはそのまま採用。自分にある場合は、新しい方が上書き

操作の適用

図形の追加/更新と削除の実装です。

function applyOperation(state: CRDTState, op: Operation): void {
  switch (op.type) {
    case "upsert": {
      const existing = state.shapes[op.shape.id];
      // 新しい操作が勝つなら上書き
      if (!existing || shouldOverwrite(op.timestamp, op.clientId, existing.timestamp, existing.shape.clientId)) {
        state.shapes[op.shape.id] = {
          shape: op.shape,
          timestamp: op.timestamp,
          deleted: false,
        };
      }
      break;
    }
    case "delete": {
      const existing = state.shapes[op.shapeId];
      // 既存があって、新しい操作が勝つなら削除フラグを立てる
      if (existing && shouldOverwrite(op.timestamp, op.clientId, existing.timestamp, existing.shape.clientId)) {
        state.shapes[op.shapeId] = {
          ...existing,
          timestamp: op.timestamp,
          deleted: true,  // 物理削除じゃなく論理削除
        };
      }
      break;
    }
  }
}

ポイント: 論理削除
deleted: trueにするだけで、データ自体は残します。削除した後に「古い追加操作」が届いても、タイムスタンプで比較できるからです。

状態のマージ

2つのクライアントの状態を合体させます。

function mergeState(state: CRDTState, other: CRDTState): void {
  for (const [id, entry] of Object.entries(other.shapes)) {
    const existing = state.shapes[id];
    // 相手の方が新しいなら採用
    if (!existing || shouldOverwrite(entry.timestamp, entry.shape.clientId, existing.timestamp, existing.shape.clientId)) {
      state.shapes[id] = entry;
    }
  }
}

再接続時にサーバーから全状態をもらって、ローカルとマージします。これでオフライン中の編集も失われません。

補足: 1日目の記事で解説したPN-Counterとの違い
  • LWW-Element-Set: 「どの値を最後に採用するか」を上書きルールで決める集合系のCRDT
  • PN-Counter: 「整数の加算・減算を衝突なく足し合わせる」カウンター系のCRDT

使い分け: 図形の最終状態みたいに「最後に決まった値」を持ちたいならLWW系。いいね数や在庫数のように「総和だけ分かればよい」ならPN-Counterが向いています。

楽観的更新(Optimistic Update)

Miroを触ったことがある人なら分かると思いますが、図形を動かしたときに「待ち」がないですよね。これが楽観的更新です。

悲観的更新 vs 楽観的更新

悲観的更新(普通のやり方)

サーバーの応答を待ってから画面を更新します。ネットワーク遅延がそのままUXに響く。100msの遅延でも「もっさり」感じます。

楽観的更新

「サーバーはたぶん成功するでしょ」と楽観的に考えて、先に画面を更新してしまいます。サーバーへの送信は裏で勝手にやる。

なぜCRDTと相性がいいのか

楽観的更新の問題は「サーバーで失敗したらどうする?」「他の人と競合したらどうする?」という点です。

普通のシステムだと、失敗時のロールバック処理が地獄。でもCRDTなら:

問題 CRDTでの解決
競合したら? LWWで自動的に解決される
ロールバックは? 不要。最終的に全員の状態が収束する
失敗したら? 再接続時にマージされる

つまり「楽観的に更新しても、最終的には正しい状態になる」という保証がCRDTにあります。だから安心して楽観的更新できる。

実装のポイント

// ユーザーが図形を動かしたとき
void onShapeMoved(Shape shape, Offset newPosition) {
  final updatedShape = shape.copyWith(
    x: newPosition.dx,
    y: newPosition.dy,
  );

  // 1. ローカル状態を即座に更新(画面に即反映)
  _localCrdtState.applyOperation(operation);

  // 2. サーバーに送信(非同期、待たない)
  _webSocket.send(operation);
}

この順番が重要!
ローカル更新が先、送信は後。送信の成功/失敗は気にしない。

体感速度の違い

方式 ネットワーク遅延100ms 体感
悲観的更新 100ms待ってから反映 もっさり
楽観的更新 0ms(即反映) サクサク

たかが100msと思うかもしれませんが、ドラッグ中に毎フレーム100ms遅延があると使い物になりません。楽観的更新なら60fpsでヌルヌル動きます。

実装ポイントまとめ

Backend(Hono + Bun)

ポイント 説明
WebSocket接続 HonoのupgradeWebSocketで確立
初期状態送信 クライアント接続時に現在のCRDT状態をsnapshot送信
ブロードキャスト 受信したOperationはCRDTStateに適用後、他クライアントに配信

Frontend(Flutter)

ポイント 説明
状態適用 受信Operationは即座にローカルCRDT状態に適用
操作フロー Operation生成 → ローカル適用 → WebSocket送信の順
UI更新 CRDT状態をwatchしてリアクティブに更新
楽観的更新 UXを優先、サーバー応答を待たない

おわりに

CRDTを使えば、複雑に見えるリアルタイム同期も意外とシンプルに実装できます。

楽観的更新との組み合わせで、ユーザー体験を損なわずにリアルタイム同期を実現できました。

コードは生成AIありきなので、手動でもっと洗練されたものにしていきたいです。

ぜひリポジトリをcloneして動かしてみてください!

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?