🍉

【React Native】Cloud DB(Firestore)とWatermelonDBを組み合わせて「早い、安い」を実現する

2024/12/03に公開

Cloud Databaseを使ったスマホアプリの課題

スマートフォンアプリの場合、WEBアプリと異なり端末上に情報を保存しやすいため、SQLiteなどのLocal Databaseを利用することは多いです。
しかし、バックエンド側での処理やデータの共有機能などを実現しようとすると、Cloud Databaseが必要になることも多いでしょう。
その際、Local Databaseと比べてCloud Databaseでは以下のような課題があります。

  • クエリや帯域利用による従量課金の発生
    • サービスによって課金体系が異なりますが、多くのCloud Databaseは使用量に応じた従量課金が発生します。
  • Databaseとの通信に伴う遅延の発生
    • データ量や通信環境にも依りますが、Local Databaseに比べるとある程度の遅延が発生します。

従量課金については、クエリの発行タイミングや回数を工夫しないと、コストが指数関数的に増えてしまう可能性がありますし、
遅延についてもなるべく発生しないよう、気を使って実装を行う必要があります。

解消法:LocalにもDatabaseを持つ

解消・低減方法としてはいくつかアプローチがあると思いますが、そのうちの一つに「LocalにもDatabaseを持つ」というものがあります。
Cloud Databaseのデータと同様のデータをLocal Databaseとして持つことで、課題の解決を図ろうというものです。

「クエリや帯域利用による従量課金の発生」

課金の課題についてですが、こちらは完全に0にすることはできません。
ただ、従量課金の発生するタイミングは、Cloud DatabaseとLocal Databaseの同期をとるときだけで済みます。
それ以外のクエリについてはLocal Databaseに対して行えばいいので、費用について気にすることなくDatabaseを利用することができます。

「Databaseとの通信に伴う遅延の発生」

遅延の問題についても、基本的な操作はLocal Databaseに対して行うため、ネットワーク起因の遅延は発生しなくなります。
性格には、Cloudとの同期を取るところでは引き続き遅延が発生する可能性がありますが、それ以外の処理は遅延しなくなるということです。

Local Databaseを持つことによる新たな課題

ただ、良いことばかりではなく、Local Databaseを持つことによって新たな課題も生まれます。

  • Cloud Databaseとの同期処理・管理の複雑さ
    • Local DatabaseとCloud Databaseの同期を取ることが肝になるわけですが、レコードごとの追加・更新・削除の管理が複雑になります。
    • 特に競合の発生やCloudとLocalでデータ不整合が起きた場合に対処が難しくなります。
  • Localのリソース管理が必要に
    • 安価な端末などでは重い処理は動かない場合もあり、ストレージ・メモリなどのリソース管理が必要になります。

2つ目のリソース管理はCloud Databaseでもある程度必要となるため、大きな問題ではないと思います。
特に1つ目の同期処理に伴う煩雑さが最大の課題となるでしょう。
そこで、WatermelonDBを紹介します。

Reactive Database FrameworkのWatermelonDB

WatermelonDBは、React / React Native向けに開発された、「Reactive Database Framework」です。
内部ではSQLiteを使用しており、テーブルのスキーマ定義やMigration、ORMやCloudとの同期機能などを有しています。
なお、React / React Native向けに開発されていますが、純粋なJavaScriptで動作するため、その他のFWと組み合わせて使うことも可能です。

https://watermelondb.dev/docs

以下のような定義をすることで、SQLite用のORMとして使用できます。

model/schema.js
import { appSchema, tableSchema } from '@nozbe/watermelondb'

export const mySchema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'posts',
      columns: [
        { name: 'title', type: 'string' },
        { name: 'subtitle', type: 'string', isOptional: true },
        { name: 'body', type: 'string' },
        { name: 'is_pinned', type: 'boolean' },
      ]
    }),
    tableSchema({
      name: 'comments',
      columns: [
        { name: 'body', type: 'string' },
        { name: 'post_id', type: 'string', isIndexed: true },
      ]
    }),
  ]
})

associationsを使い、Table間のRelationも定義できます。
また、SQLiteだとそのままでは保存できないDate型やJSON型を保存するためのラッパーもあります。

model/Post.js
import { field, text } from '@nozbe/watermelondb/decorators'

class Post extends Model {
  static table = 'posts'
  static associations = {
    comments: { type: 'has_many', foreignKey: 'post_id' },
  }

  @text('title') title
  @text('body') body
  @field('is_pinned') isPinned
  @date('last_event_at') lastEventAt
}

この他、Databaseのスキーマ変更を反映するMigrationの仕組みや、クエリ自体をObservableにして対象行の変更を検知する仕組みなどがあります。

WatermelonDBのCloud Databaseとの同期機能

WatermelonDBにはCloud Databaseと同期するのをサポートする仕組みが用意されています。

https://watermelondb.dev/docs/Sync/Intro

基本的な仕組みはこうです。

  • 各レコードに作成日時、更新日時、削除日時、同期化ステータスを持つ
  • Cloud Databaseから前回同期化を行った以降に追加・更新・削除されたレコードをPull
  • ローカルデータのうちCloudと同期されていないレコード(同期化ステータスで判断)をCloud DatabaseにPush

WatermelonDBはFront側の仕組みになるため、Backend側の仕組みはWatermelonDBの指定するIFに合わせて独自に構築する必要があります。
(Cloud Functionsなどで同期用APIを用意し、その中でCloud Databaseの同期を行う)

Frontendの例

import { synchronize } from '@nozbe/watermelondb/sync'

async function mySync() {
  await synchronize({
    database,
    pullChanges: async ({ lastPulledAt, schemaVersion, migration }) => {
      const urlParams = `last_pulled_at=${lastPulledAt}&schema_version=${schemaVersion}&migration=${encodeURIComponent(
        JSON.stringify(migration),
      )}`
      const response = await fetch(`https://my.backend/sync?${urlParams}`)
      if (!response.ok) {
        throw new Error(await response.text())
      }

      const { changes, timestamp } = await response.json()
      return { changes, timestamp }
    },
    pushChanges: async ({ changes, lastPulledAt }) => {
      const response = await fetch(`https://my.backend/sync?last_pulled_at=${lastPulledAt}`, {
        method: 'POST',
        body: JSON.stringify(changes),
      })
      if (!response.ok) {
        throw new Error(await response.text())
      }
    },
    migrationsEnabledAtVersion: 1,
  })
}

BackendのIF要件はこちらのページに書かれています。

https://watermelondb.dev/docs/Sync/Backend

この要件に従ったBackendを用意することで、どんなCloud Databaseとでも同期を行うことが可能となります。
当然、RDBのみでなく、NoSQLデータベースでも(スキーマの使い方など制約は生まれますが)利用が可能です。

ただ、Backendを用意しなければならないため、管理するものが増えてしまい面倒、という難点があります。
しかし、FirestoreはBackendなしてFrontから直接DBを操作することが可能ですので、Firestore以外にBackendを用意せず、Frontのみで完結させることも可能です。

WatermelonDBを使ってFirestoreと同期する

FirestoreでBackendなしで同期を実現するには、先程のサンプルコードでもあったWatermelonDBが用意している「synchronize」を拡張することで可能です。
具体的には、BackendのIF要件に沿うように、synchronizeの中のpullChangesとpushChangesを実装してあげます。

実装例
useCloudSync.ts
import { synchronize } from "@nozbe/watermelondb/sync";
import SyncLogger from "@nozbe/watermelondb/sync/SyncLogger";
import { randomUUID } from "expo-crypto";
import { documentDirectory, readDirectoryAsync } from "expo-file-system";
import {
  collection,
  doc,
  getDocs,
  query,
  runTransaction,
  where,
} from "firebase/firestore";
import { getBlob, ref } from "firebase/storage";
import { useCallback, useContext, useEffect, useRef } from "react";
import { Alert, LayoutAnimation } from "react-native";
import { CommonAppContext } from "../common/Context";
import { mySchema } from "../models/Schema";
import { blobToBase64, getImageFilePath, setNotification } from "./commonUtils";
import { database } from "./dbUtil";
import { auth, db, storage } from "./fire";
import useCloudStorage from "./useCloudStorage";

const defaultExcluded = ["_status", "_changed", "notification_id", "image"];

export const DOCUMENT_WAS_MODIFIED_ERROR =
  "DOCUMENT WAS MODIFIED DURING PULL AND PUSH OPERATIONS";
export const DOCUMENT_WAS_DELETED_ERROR =
  "DOCUMENT WAS DELETED DURING PULL AND PUSH OPERATIONS";
const tableKeys = Object.keys(mySchema.tables);

const logger = new SyncLogger(10 /* limit of sync logs to keep in memory */);

export default function useSync() {
  const sessionId = useRef<string>();
  const { uploadFile, deleteFile } = useCloudStorage();
  const { onListUpdate, isSyncing, setIsSyncing } =
    useContext(CommonAppContext);

  useEffect(() => {
    database.localStorage.get("sessionId").then((id) => {
      if (typeof id === "string") {
        sessionId.current = id;
      } else {
        sessionId.current = randomUUID();
        database.localStorage.set("sessionId", sessionId.current);
      }
    });
  }, []);

  const mySync = useCallback(async () => {
    if (!auth.currentUser) {
      return;
    }
    LayoutAnimation.easeInEaseOut();
    setIsSyncing(true);
    await synchronize({
      database: database,
      log: logger.newLog(),
      pullChanges: async ({ lastPulledAt }) => {
        const syncTimestamp = new Date().getTime();
        const lastPulledAtTime = lastPulledAt || 0;
        let changes = {};
        try {
          await Promise.all(
            tableKeys.map(async (collectionName) => {
              const collectionRef = collection(
                db,
                "users",
                auth.currentUser!.uid,
                collectionName
              );
              const [createdSN, deletedSN, updatedSN] = await Promise.all([
                getDocs(
                  query(
                    collectionRef,
                    where("created_at", ">=", lastPulledAtTime),
                    where("created_at", "<=", syncTimestamp)
                  )
                ),
                getDocs(
                  query(
                    collectionRef,
                    where("deleted_at", ">=", lastPulledAtTime),
                    where("deleted_at", "<=", syncTimestamp)
                  )
                ),
                getDocs(
                  query(
                    collectionRef,
                    where("updated_at", ">=", lastPulledAtTime),
                    where("updated_at", "<=", syncTimestamp)
                  )
                ),
              ]);

              const fileList = await readDirectoryAsync(
                documentDirectory! + "images"
              );

              const created = await Promise.all(
                createdSN.docs
                  .filter(
                    (t) =>
                      t.data().sessionId !== sessionId.current &&
                      !deletedSN.docs.some((doc) => doc.id === t.id)
                  )
                  .map(async (createdDoc) => {
                    const data = createdDoc.data();
                    defaultExcluded.forEach((ex) => delete data[ex]);
                    if (data.notify_date_at) {
                      data.notification_id = await setNotification(
                        data.name,
                        new Date(data.deadline_at),
                        new Date(data.notify_date_at),
                        data.id
                      );
                    }

                    if (
                      data.imageFileName &&
                      !fileList.some((f) => f === data.imageFileName)
                    ) {
                      const filePath = `${auth.currentUser!.uid}/${
                        data.imageFileName
                      }`;
                      const imageBlob = await getBlob(ref(storage, filePath));
                      data.image = await blobToBase64(imageBlob);
                    }

                    return data;
                  })
              );

              const updated = await Promise.all(
                updatedSN.docs
                  .filter(
                    (t) =>
                      t.data().sessionId !== sessionId.current &&
                      !createdSN.docs.some((doc) => doc.id === t.id) &&
                      !deletedSN.docs.some((doc) => doc.id === t.id)
                  )
                  .map(async (updatedDoc) => {
                    const data = updatedDoc.data();
                    defaultExcluded.forEach((ex) => delete data[ex]);
                    if (data.notify_date_at) {
                      data.notification_id = await setNotification(
                        data.name,
                        new Date(data.deadline_at),
                        new Date(data.notify_date_at),
                        data.id
                      );
                    }

                    if (!fileList.some((f) => f === data.imageFileName)) {
                      const imageBlob = await getBlob(
                        ref(
                          storage,
                          `${auth.currentUser!.uid}/${data.imageFileName}`
                        )
                      );
                      data.image = await blobToBase64(imageBlob);
                    }

                    return data;
                  })
              );

              const deleted = deletedSN.docs
                .filter((t) => t.data().sessionId !== sessionId.current)
                .map((deletedDoc) => {
                  return deletedDoc.id;
                });

              changes = {
                ...changes,
                [collectionName]: { created, deleted, updated },
              };
            })
          );
        } catch (error) {
          console.error(error);
        }

        onListUpdate?.();

        return { changes, timestamp: syncTimestamp };
      },

      pushChanges: async ({ changes, lastPulledAt }) => {
        try {
          await Promise.all(
            Object.entries(changes).map(async ([collectionName, row]) => {
              const collectionRef = collection(
                db,
                "users",
                auth.currentUser!.uid,
                collectionName
              );

              await Promise.all(
                Object.entries(row).map(
                  async ([changeName, arrayOfChanged]) => {
                    await runTransaction(db, async (transaction) => {
                      const isDelete = changeName === "deleted";

                      await Promise.all(
                        arrayOfChanged.map(async (document) => {
                          const itemValue = isDelete
                            ? null
                            : (document.valueOf() as any);
                          const docRef = isDelete
                            ? doc(collectionRef, document.toString())
                            : doc(collectionRef, itemValue!.id);

                          const data = isDelete ? null : { ...itemValue };
                          const newData = {
                            ...data,
                            sessionId: sessionId.current,
                          };
                          if (!isDelete) {
                            defaultExcluded.forEach((ex) => delete newData[ex]);
                          }

                          switch (changeName) {
                            case "created": {
                              if (data.image) {
                                // ファイル名をそのまま使用する
                                await uploadFile(
                                  getImageFilePath(data.imageFileName),
                                  data.imageFileName
                                );
                              }

                              transaction.set(docRef, newData);

                              break;
                            }

                            case "updated": {
                              const docFromServer = await transaction.get(
                                docRef
                              );
                              if (!docFromServer.exists()) {
                                return;
                              }
                              const { deleted_at, updated_at, imageFileName } =
                                docFromServer.data() as any;

                              if (updated_at > lastPulledAt) {
                                throw new Error(DOCUMENT_WAS_MODIFIED_ERROR);
                              }

                              if (deleted_at > lastPulledAt) {
                                throw new Error(DOCUMENT_WAS_DELETED_ERROR);
                              }

                              if (data.image) {
                                if (data._changed.includes("imageFileName")) {
                                  await deleteFile(imageFileName);
                                  // ファイル名をそのまま使用する
                                  await uploadFile(
                                    getImageFilePath(data.imageFileName),
                                    data.imageFileName
                                  );
                                }
                              }

                              transaction.update(docRef, newData);

                              break;
                            }

                            case "deleted": {
                              const docFromServer = await transaction.get(
                                docRef
                              );
                              if (!docFromServer.exists()) {
                                return;
                              }
                              const { deleted_at, updated_at } =
                                docFromServer.data() as any;

                              if (updated_at > lastPulledAt) {
                                throw new Error(DOCUMENT_WAS_MODIFIED_ERROR);
                              }

                              if (deleted_at > lastPulledAt) {
                                throw new Error(DOCUMENT_WAS_DELETED_ERROR);
                              }

                              transaction.update(docRef, {
                                deleted_at: new Date().getTime(),
                                isDeleted: true,
                                sessionId: sessionId.current,
                              });

                              break;
                            }
                          }
                        })
                      );
                    });
                  }
                )
              );
            })
          );
        } catch (error) {
          Alert.alert(
            "エラー",
            "データ同期中にエラーが発生しました。:" + error
          );
          console.error(error);
        }
      },
    })
      .catch((error) =>
        Alert.alert("エラー", "データ同期中にエラーが発生しました。:" + error)
      )
      .finally(() => {
        LayoutAnimation.easeInEaseOut();
        setIsSyncing(false);
      });
  }, [onListUpdate, setIsSyncing]);

  return sessionId.current ? { mySync, isSyncing } : {};
}

コードのすべては解説しませんが、基本的な考え方はこうです。

  • 各レコードに作成日時(Createの場合のみ)・更新日時(Updateの場合のみ)・削除日時(Deleteの場合のみ)とSESSION IDを持たせてPushする
  • 作成日時・更新日時・削除日時が前回同期日時以降 且つ SESSION IDが異なるものを取得する(自分が更新したデータを除外するため)

これをFront側で行うことで、Backendなしでも同期化を行うことが可能となります。

まとめ

Cloud Databaseを活用する際にネックとなる課題の、解決手段の一つとしてWatermelonDBの紹介をしました。
特にFirestoreのようなNoSQLデータベースの場合、課金は時間ではなく従量課金という場合が多く、こういったLocal Databaseとの同期という使い方は相性が良いです。
実際、自分が開発しているスマートフォンアプリでは、Local Databaseを活用することでFirestore側のクエリ数を半分以下に減らすことができました。
効果がどの程度出るかはアプリの特性にもよりますが、Local Databaseの活用を検討してみてはいかがでしょうか。

ちなみに、WatermelonDBは同期処理の仕組みは提供してくれますが、同期のタイミング制御は利用者に任されています。
そのため、これらを利用したとしても、「他の端末で同時に更新したときはどうするか」「アプリに復帰直後の同期処理をどう実現するか」などはユーザーに委ねられていますので注意が必要です。

宣伝

紹介したWatermelonDBを使用したアプリはiOS / Androidでリリースしています。
あまり使わないけどたまに使うものを、どこにしまっておいたか記録しておくためのアプリです。
良ければ使ってみてください。

iOS(iPhone / iPad)向け

https://apps.apple.com/us/app/where-the-stock-item-mgmt/id1513922140

Android向け

https://play.google.com/store/apps/details?id=com.apps.hal.aledoco

お願い

WatermelonDBについては自分も探り探り使っているので、ちょっとわかっていないところもあります。
不明点や記事の誤りなどあれば、X(旧 Twitter) などでご連絡ください。

https://x.com/HAL1986____

Discussion