スピナーの先へ:PouchDBとCouchDBで構築する堅牢なオフラインファーストアプリ

Database tutorial - IT technology blog
Database tutorial - IT technology blog

なぜオフラインファーストが新たな標準なのか

私は10年間、分散システムにおけるデータ状態の不整合の修正に携わってきました。MySQL、PostgreSQL、MongoDBのどれを使っていても、悩みは常に同じでした。それは「Lie-Fi(偽のWi-Fi)」現象です。これは、デバイスのアンテナはフルに立っているのに、実際には通信が死んでいる状態を指します。アプリが常にAPIとの通信に依存している場合、ユーザーはローディングスピナーを見つめ続けることになります。これは顧客を失う非常に残念な原因です。

オフラインファーストのアプローチはこの状況を一変させます。すべてのアクションでリモートAPIを叩く代わりに、アプリはブラウザ内のデータベースと通信します。ローカルにあるため高速で、常に利用可能です。デバイスが4GやWi-Fi信号を掴んだときに、データはバックグラウンドで同期されます。PouchDBとCouchDBはこのワークフローにおけるゴールドスタンダード(決定版)です。

アーキテクチャの転換:REST対同期

このスタックの価値を理解するには、私たちが20年間使ってきた標準的なリクエスト・レスポンスのサイクルがどのように置き換わるかを見る必要があります。

従来のオンライン専用モデル

典型的なReactやVueの構成では、ユーザーが「保存」をクリックすると、フロントエンドがPOSTリクエストを送信します。サーバーがそれを処理し、PostgreSQLなどのデータベースを更新して、200 OKを返します。もし配送ドライバーがコンクリートの倉庫に入って電波を失えば、そのリクエストは失敗します。そのため、複雑なリトライロジックや「下書き」状態を自前で構築しなければなりません。

オフラインファーストモデル(PouchDB + CouchDB)

PouchDBを使用すると、アプリはローカルディスク上のIndexedDBとやり取りします。書き込みはほぼ瞬時で、通常10ミリ秒未満です。データはまずローカルに保存されるため、ローディングスピナーは必要ありません。同期はバックグラウンドプロセスになります。ユーザーが2時間オフラインであっても、エラーメッセージを一度も見ることなく作業を続けることができます。

メリットとデメリット:期待できること

どんなスタックも銀の弾丸ではありません。この組み合わせは強力ですが、注意点を知っておく必要があります。

メリット

  • ゼロレイテンシ: サーバーとの往復300ミリ秒を待つ必要がないため、UIは非常にキビキビと動作します。
  • ネイティブな同期: PouchDBはCouchDBのレプリケーションプロトコルをネイティブにサポートしています。実際のデータ転送を処理するためのコードを一行も書く必要はありません。
  • スマートなコンフリクト管理: CouchDBはGitに似たリビジョンベースのシステムを使用しています。2人が同じドキュメントを編集した場合、両方のバージョンを保存し、後で差異を解決できるようにします。

トレードオフ

  • ストレージ容量の制限: ブラウザは通常、IndexedDBの使用を空きディスク容量の約60%に制限しています。数万件のレコードには最適ですが、クライアントに50GBのビデオライブラリを保存しようとしないでください。
  • 結果整合性: 変更が発生した瞬間にサーバーに反映されるわけではありません。UIには、データが「ローカルに保存済み」なのか「クラウドに同期済み」なのかを反映させる必要があります。
  • セキュリティのオーバーヘッド: PouchDBがデータベースと直接通信するため、データの漏洩を防ぐにはCORSとドキュメントレベルのセキュリティ(バリデーション関数経由)を正しく設定する必要があります。

実践的なセットアップ

私のお気に入りのアーキテクチャは、ReactフロントエンドでPouchDBをプライマリデータストアとして使用する構成です。これは、Dockerコンテナで動作するCouchDBインスタンスと同期します。本番環境では、データがCouchDBに届く前にJWTベースの認証を処理するための小さなNode.jsサービスを追加するのが一般的です。

[ ローカルデバイス: PouchDB ] <-- (双方向同期) --> [ リモートサーバー: CouchDB ]

ステップ・バイ・ステップの実装

1. CouchDBのデプロイ

DockerはCouchDBを立ち上げる最も信頼できる方法です。この設定により、コンテナが再起動してもデータが保持されます。

docker run -d \
  --name couchdb-prod \
  -e COUCHDB_USER=admin \
  -e COUCHDB_PASSWORD=your_secure_password \
  -p 5984:5984 \
  -v $(pwd)/couchdata:/opt/couchdb/data \
  couchdb:latest

コンテナが起動したら、CORSを有効にする必要があります。これを行わないと、ブラウザはすべての同期試行をブロックします。これはFauxtonダッシュボード(http://localhost:5984/_utils)の設定(Configuration)タブで切り替えることができます。

2. PouchDBの初期化

まず、npmからパッケージを取得します。

npm install pouchdb

次に、ローカルとリモートのデータベースインスタンスをセットアップします。この例では、リモートURLに認証情報を含めていることに注意してください。

import PouchDB from 'pouchdb';

const localDB = new PouchDB('app_records');
const remoteDB = new PouchDB('http://admin:password@localhost:5984/app_records');

3. 同期の自動化

syncメソッドはアプリのエンジンです。live: trueを設定すると接続を維持し、retry: trueを設定するとトンネル内や圏外から復帰した際にアプリが自動的に再接続されるようになります。

localDB.sync(remoteDB, {
  live: true,
  retry: true
}).on('change', (info) => {
  console.log('サーバーから新しいデータが届きました!');
}).on('error', (err) => {
  console.error('同期に失敗しました:', err);
});

4. ドキュメント操作

PouchDBはNoSQLドキュメントストアです。各エントリには一意の_idが必要です。ここではISO形式の文字列やUUIDを使用するのが最適です。

async function saveNote(content) {
  const doc = {
    _id: `note_${new Date().getTime()}`,
    text: content,
    status: 'draft'
  };
  
  try {
    await localDB.put(doc);
  } catch (err) {
    console.error("保存に失敗しました", err);
  }
}

コンフリクトをマスターする

オフラインアプリにおいてコンフリクトは避けられません。2人のユーザーがオフライン中に同じプロフィールを更新した場合、CouchDBはどちらが勝つかを勝手に判断しません。代わりに、ドキュメントに_conflictsフラグを立てます。ドキュメントを取得する際に両方のバージョンを確認し、タイムスタンプを比較して、不要な方を削除することができます。これは、ユーザーデータを黙って上書きしてしまう「最後に書き込んだ者が勝つ(last-write-wins)」アプローチよりもはるかに安全です。

結論

オフラインファーストへの切り替えは、考え方の転換です。ネットワークの状態を心配するのをやめ、データの状態に集中し始めることになります。ネットワークロジックをPouchDBに任せることで、アプリケーションの堅牢性は大幅に向上します。MapReduceビューやリビジョンIDを学ぶには時間がかかりますが、その結果、プロフェッショナルで壊れにくいユーザー体験が得られます。モバイルユーザーや現場作業員向けのアプリを構築しているのであれば、このスタックは投資する価値が十分にあります。

Share: