Zenn
🗝️

【Firestore】NoSQLでもJOINしたい(シングルトン設計)

2025/03/23に公開

FirestoreというNoSQLライクなDBを使っているのですが、NoSQLの正解は「非正規化」であり、JOINが使えません。

将来的にSQLに移行したいと思ったら、Firestore独自の参照フィールドを使ったり、非正規化したりといった、「正解」を避けたいですよね。これはNoSQLの設計原則から外れているためアンチパターンとされることもありますが、プロトタイピングやSQL移行を見越した場合には、思い切りSQL脳で設計して、フロントでJOIN的なことしても、マスターデータ的なものくらいなら、設計次第で大丈夫でしょ!と思ったので、パフォーマンス的に良い設計を考えて、備忘録として書き殴ってみます。

シングルトンな設計

アンチパターン的な正規化されたデータをフロントでJOINしても重くならない設計、それはつまり無駄なリクエストを減らし、状態を一箇所で管理する。それはきっと「シングルトン」な設計なんだと思います。
デザインパターンは最近学び始めたばかりですが、実際使う時が来て嬉しいです。

1度だけFetch

コンテンツを管理する管理画面のアプリを作っているとします。
たとえば「記事」に「カテゴリ」、作成した「ユーザー」が紐づいている時。そんな時は非正規化するのではなく、category_ids だったりや created_byuser_id を記事に持たせてSQLっぽくします。

その「カテゴリ」や「ユーザー」の一覧を、シングルトンとして、アプリ内で1度だけ取得します。
そして、記事一覧を取得し整形するときにその取得済みの「カテゴリ」や「ユーザー」からピックしてJOIN的な結合をさせます。

それだけです。

きっと誰もが考えることかもしれませんが...シングルトンに取得する設計というのが重要なので、そのベストな設計を考えていきたいと思います。

Nuxt3の場合

Firestoreの処理はcomposablesに書いていきます。

まずは、シングルトンな取得するためのラッパー関数です。

// composables/useLazyAsyncOnce.ts

export function useLazyAsyncOnce<T>(key: string, fn: () => Promise<T>) {
  const result = useState<T | null>(`${key}:result`, () => null);
  const promise = useState<Promise<T> | null>(`${key}:promise`, () => null);
  const isLoaded = useState(`${key}:loaded`, () => false);

  async function execute(): Promise<T> {
    if (isLoaded.value && result.value !== null) return result.value;
    if (promise.value) return promise.value;

    promise.value = fn().then((res) => {
      result.value = res;
      isLoaded.value = true;
      promise.value = null;
      return res;
    });

    return promise.value;
  }

  return {
    execute,
    result,
    isLoaded,
  };
}

result には取得したデータのリストが入り、promise では非同期の競合を防ぐため複数回走らないようプロミスの状態を共有、isLoadedでロード完了したか否かのフラグを立てます。

カテゴリをシングルトンに取得したいときはこうします。

// composables/useCategory.ts
const {
  execute: ensureCategoryLoaded,
  result: categoryList,
  isLoaded,
} = useLazyAsyncOnce('category', () => getCategoryList());

getCategoryList()ではFirestoreからcategory一覧を取ってくる処理を書いています。

そして記事一覧を取って来たい時には、処理の前にこのおまじないをかけます。

await ensureCategoryLoaded();

カテゴリの取得が完了するまで待ってくれるので、安心して記事を整形することができますね。

1回だけFetchするケースを書きましたが、Firestoreのリアルタイムに購読する onSnapshot を使いたいこともありますよね。

1度だけ購読

Nuxt3の場合

// composables/useFirestoreSubscriptionOnce.ts
import { onSnapshot, type Query } from 'firebase/firestore';

export function useFirestoreSubscriptionOnce<T>(key: string, query: Query<T>, mapFn: (doc: any) => T) {
  const dataList = useState<T[]>(`${key}:data`, () => []);
  const isLoaded = useState(`${key}:loaded`, () => false);
  const promise = useState<Promise<void> | null>(`${key}:promise`, () => null);
  const unsubscribe = useState<() => void>(`${key}:unsubscribe`, () => () => {});

  function ensureLoaded(): Promise<void> {
    if (isLoaded.value) return Promise.resolve();
    if (promise.value) return promise.value;

    promise.value = new Promise((resolve) => {
      unsubscribe.value = onSnapshot(query, (snapshot) => {
        dataList.value = snapshot.docs.map(mapFn);
        isLoaded.value = true;
        resolve();
      });
    });

    return promise.value;
  }

  // 任意で購読解除できるようにしておく
  function stop() {
    unsubscribe.value?.();
  }

  return {
    dataList: readonly(dataList),
    isLoaded: readonly(isLoaded),
    ensureLoaded,
    stop,
  };
}

これを、useCategoryStore で使います。

// composables/useCategoryStore.ts
import { collection, query } from 'firebase/firestore';
import { db } from '@/firebase';

export function useCategoryStore() {
  return useFirestoreSubscriptionOnce(
    'categories',
    query(collection(db, 'categories')),
    (doc) => ({ id: doc.id, ...doc.data() } as Category)
  );
}

これを app.vueなどの最上階で呼びます。

useCategories();

記事一覧の整形時などでのおまじないで使いたい時はこうですね。

const { dataList: categoryList, ensureLoaded } = useCategories();

await ensureLoaded(); // 最初のデータが来るまで待つ!

これできっと、重くならずに描画できるはず!
何かあればコメントでご教授いただけると幸いです。

Discussion

ログインするとコメントできます