はじめに
最近、Notionとか Google Docs みたいなリアルタイムコラボレーション機能を自分のアプリに入れたいと思ったことありませんか。個人的には結構あるんですよね。
ただ、これを自前で実装しようとすると、競合解決とかオフライン対応とか、考えることが山ほどあって正直なところ大変です。そこで注目したいのが Automerge というライブラリ。
Automergeは、CRDT(Conflict-free Replicated Data Type)という技術をベースにした「ローカルファースト同期エンジン」です。GitHubスター数は5.8k超え、Herokuの共同設立者やケンブリッジ大学のMartin Kleppmann教授らが開発に関わっているという、なかなか信頼できるプロジェクトなんですよ。
Automergeはコアがブラウザ上のWebAssemblyで動くので、実はこの記事の中でもAutomerge.init()やAutomerge.merge()を実際に動かして確認できます。先に挙動を見たい方はこちらからどうぞ。
特徴・メリット
Automergeの良いところを整理してみます。
1. オフライン完全対応
これ、意外と重要なんですよね。ネットワークが切れても普通に編集できて、再接続したら自動的に同期される。電車の中とか飛行機の中でも作業できるのは地味にQOL上がります。
2. 自動マージで競合解決
複数人が同時に編集しても、CRDTの仕組みで一貫性のあるマージを自動実行してくれます。データ損失がないのが安心ポイント。「あ、上書きされた...」みたいな事故がなくなります。
3. バージョン履歴の完全保持
Gitみたいに全ての変更履歴を持っているので、過去の状態に戻したり、ブランチを切って実験したりもできる。ドキュメント管理としては最強クラスですね。
4. マルチ言語対応
JavaScript、Rust がメインですが、Swift、Python、C、Java へのバインディングもあります。コアがRust実装でWebAssemblyに変換されているので、パフォーマンスも良好。
5. 柔軟なネットワーク構成
P2P、クライアント・サーバー、ファイルディスク、なんならメール添付まで対応。どんな環境でも使えるのは嬉しいところです。
インストール方法
npmでサクッと入れられます。
npm install @automerge/automerge
Yarnの場合はこちら。
yarn add @automerge/automerge
バンドルサイズは約219KB(Minified + Gzipped)。まあ許容範囲かなという感じですね。
Automergeのサンプルを動かす
Automergeのコアは実際にはRust製で、それをWebAssembly(WASM)にコンパイルしたものがJS版のパッケージに同梱されています。ブラウザで直接WASMを読み込んで動かせるので、Automerge.init()でドキュメントを作り、Automerge.change()で更新する一連の流れを、この記事の中でそのまま試せます。
要点だけ抜き出すと、こういう流れになります。
import * as Automerge from '@automerge/automerge/slim'
// ブラウザ単体で使う場合はWASM本体を明示的に初期化する
await Automerge.initializeWasm(fetch('.../automerge.wasm'))
let doc = Automerge.init()
// change()はdocを直接書き換えるのではなく、
// 新しいイミュータブルなドキュメントを返す
doc = Automerge.change(doc, 'タスクを追加', (d) => {
d.tasks = []
d.tasks.push({ title: '牛乳を買う', done: false })
})
console.log(doc.tasks)
実際に動かせるものが下です。入力欄にタスク名を入れて「追加」を押すと、そのたびにAutomerge.change()が呼ばれてドキュメントが更新され、一覧に反映されます。WASMの読み込みに数秒かかることがあるので、「準備完了」の表示が出るまで少し待ってから操作してください。
Automerge.change()の第2引数(今回だと`「${title}」を追加`の部分)はコミットメッセージのようなもので、あとで変更履歴として参照できます(後述の変更履歴サンプルで確認できます)。第3引数のコールバック内でオブジェクトを普通のJSのように書き換えるだけで、裏側でCRDTとしての差分が記録される点がAutomergeの読みやすさにつながっています。
基本的な使い方
実際のコードを見ていきましょう。
ドキュメントの作成と編集
import * as Automerge from '@automerge/automerge'
// 型定義
interface TaskList {
tasks: Array<{ title: string; done: boolean }>
}
// 新しいドキュメントを作成
let doc = Automerge.init<TaskList>()
// ドキュメントを変更(イミュータブルに新しいドキュメントを返す)
doc = Automerge.change(doc, 'タスクを追加', doc => {
doc.tasks = []
doc.tasks.push({ title: '牛乳を買う', done: false })
doc.tasks.push({ title: 'コードレビュー', done: false })
})
console.log(doc.tasks)
// [{ title: '牛乳を買う', done: false }, { title: 'コードレビュー', done: false }]
複数ユーザーの変更をマージ
// ドキュメントをクローン(別ユーザーをシミュレート)
let doc1 = Automerge.clone(doc)
let doc2 = Automerge.clone(doc)
// ユーザー1がタスクを完了にする
doc1 = Automerge.change(doc1, 'タスク完了', doc => {
doc.tasks[0].done = true
})
// ユーザー2が新しいタスクを追加
doc2 = Automerge.change(doc2, '新タスク追加', doc => {
doc.tasks.push({ title: 'ミーティング準備', done: false })
})
// 両方の変更をマージ
const mergedDoc = Automerge.merge(doc1, doc2)
console.log(mergedDoc.tasks)
// 両方の変更が反映される!
// [
// { title: '牛乳を買う', done: true },
// { title: 'コードレビュー', done: false },
// { title: 'ミーティング準備', done: false }
// ]
変更の同期(ネットワーク越し)
// 変更を取得(バイナリ形式)
const changes = Automerge.getChanges(doc, mergedDoc)
// 別のクライアントで変更を適用
let remoteDoc = Automerge.init<TaskList>()
remoteDoc = Automerge.applyChanges(remoteDoc, changes)[0]
正直、このAPIのシンプルさは良いですね。イミュータブルな設計なのでReactとの相性も抜群です。
実践的なユースケース
1. 共同編集ドキュメント
NotionやGoogle Docsみたいなリアルタイム共同編集。ProsemirrorやCodemirror用のプラグインも公式で提供されているので、テキストエディタとの統合も比較的楽にできます。
2. オフラインファーストなモバイルアプリ
電波が不安定な環境でも動作するメモアプリやTODOアプリ。オフラインで編集 → オンラインで自動同期、という体験がスムーズに作れます。
3. P2Pアプリケーション
サーバーレスで動作する分散アプリ。WebRTCと組み合わせれば、中央サーバーなしでデータ同期ができる。これ、個人開発でサーバー代を節約したい時に良いかもしれません。
4. ゲームのセーブデータ同期
複数デバイス間でのセーブデータ同期。競合が起きても自動解決されるので、「どっちが新しいセーブ?」問題が発生しない。
5. Automergeの変更履歴を追跡する
Automergeはドキュメントへの変更をすべて内部に保持しているので、Automerge.getAllChanges()で変更のバイナリ一覧を取り出し、Automerge.decodeChange()でメッセージや操作内容を読めます。監査ログや「誰がいつ何を変更したか」を見せたいアプリで使えるパターンです。
// 要点: getAllChanges()で全変更を取得し、decodeChange()で中身を見る
const changes = Automerge.getAllChanges(doc)
const history = changes
.map((c) => Automerge.decodeChange(c))
.map((c) => ({ message: c.message, ops: c.ops.length }))
console.log(history)
// [{ message: 'ドキュメント作成', ops: 1 }, { message: '編集 #1', ops: 1 }, ...]
ボタンを押すたびにAutomerge.change()で1件ずつ変更を加え、そのたびにgetAllChanges()とdecodeChange()で履歴を再構築して画面に表示しています。
何度もボタンを押して、履歴が1行ずつ積み上がっていく様子を確認してみてください。Automerge.change()のコミットメッセージをもっと具体的な文言に変えれば、そのまま監査ログの表示文言としても使えます。
6. 複数ドキュメント間のマージをシミュレーションする
Automergeの本質は、オフラインで別々に編集された2つのドキュメントをAutomerge.merge()で1つに統合できることです。ここではAutomerge.clone()で同じドキュメントを「ユーザーA」「ユーザーB」に見立てて複製し、それぞれ別の変更を加えたあとにマージして、両方の変更が失われずに反映される様子を確認します。
// 要点: clone()で複製し、別々にchange()したあとmerge()で1つに統合する
let docA = Automerge.clone(base)
let docB = Automerge.clone(base)
docA = Automerge.change(docA, 'Aがタスク完了', (d) => {
d.tasks[0].done = true
})
docB = Automerge.change(docB, 'Bがタスク追加', (d) => {
d.tasks.push({ title: 'レビュー依頼', done: false })
})
const merged = Automerge.merge(Automerge.clone(docA), docB)
// merged.tasks には両方の変更が反映される
「ユーザーA」「ユーザーB」それぞれのボタンで、オフラインで別々に編集したことをシミュレーションできます。両方押してから「マージして確認」を押すと、Automerge.merge()が2つの変更を1つのドキュメントに統合する様子が結果欄に表示されます。
Aだけ押してマージするとdone: trueだけが反映され、Bだけ押してマージすると新しいタスクだけが追加されます。両方押してからマージすると、Automerge.merge()が2人分の変更を両方とも取りこぼさずに1つのドキュメントへ統合しているのが分かるはずです。
7. ドキュメントの保存と復元(オフライン永続化)
「オフラインで編集して、あとで別デバイスに復元する」というAutomergeの得意分野を、Automerge.save()とAutomerge.load()で再現できます。save()はドキュメント全体をバイナリ(Uint8Array)にシリアライズし、load()はそのバイナリから別インスタンスとしてドキュメントを復元します。実際のアプリではこのバイナリをlocalStorageやファイル、サーバーに保存しておき、オフライン明けに読み込む、という使い方をします。
// 要点: save()でバイナリ化し、load()で別インスタンスとして復元する
const bytes = Automerge.save(doc) // Uint8Array
// ここでlocalStorageやファイルに保存し、後で読み込む想定
const restored = Automerge.load(bytes)
console.log(restored.tasks) // doc.tasksと同じ内容が復元される
「保存(save)」を押すとドキュメントがバイナリに変換され、そのバイト数が表示されます。続けて「別インスタンスで復元(load)」を押すと、そのバイナリだけから中身が完全に復元されることを確認できます。
doc.noteの初期値('オフラインでも書けるメモ'の部分)を書き換えてから保存・復元してみると、バイナリの中身も復元結果もそれに応じて変わります。ネットワーク越しの同期を実装する際も、まずはこのsave()/load()の組み合わせで「ドキュメントを丸ごと運ぶ」パターンを押さえておくと理解しやすいです。
まとめ
Automergeは、リアルタイムコラボレーションやオフライン対応を実現したいときの有力な選択肢です。
個人的に気に入っているポイントをまとめると:
- 学習コストが低い:JSONライクなAPIで直感的に使える
- オフライン対応が標準装備:追加実装なしでオフラインファースト
- 競合解決を考えなくていい:CRDTが自動でやってくれる
- TypeScript対応:型安全に開発できる
30代になって思うのは、「車輪の再発明をしない」ことの大切さなんですよね。競合解決のアルゴリズムを自前で書くより、こういった検証済みのライブラリを使う方がコスパ的に正解だと思います。
ローカルファーストなアプリを作りたい人は、一度試してみる価値ありですよ。
