GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

ウマ娘フレンド募集掲示板の Firestore 設計と検索 #GameWith #TechWith

はじめに

こんにちは。GameWith のエンジニアの tiwu です!

自分が所属しているチームではいくつか攻略ツールを実装しており、去年はあつ森交換掲示板をリリースしたりしました!

その際のブログはこちらになります!

tech.gamewith.co.jp

今回はウマ娘フレンド募集掲示板を実装した際に得た Firestore の知見や工夫について書いていこうと思います!

ウマ娘フレンド募集掲示板

ウマ娘フレンド募集掲示板は、自分のプロフィールを投稿したり、いろいろな条件で他のプレイヤーを検索したりすることができます。

gamewith.jp

このツールもあつ森交換掲示板と同じく GameWith Design System を利用し Vue + TypeScript で実装を行っています。

tech.gamewith.co.jp

Firestore の設計

登録する内容は自分のフレンドコードに加えて、代表ウマ娘 + 親(継承元)1 + 親(継承元)2 の情報となっています。

代表ウマ娘, 親(継承元)1, 親(継承元)2 に関しては登録するデータの構造はほぼ同じになっているため、当初はサブコレクションを利用した設計を考えていました。

サブコレクションは、親になっているドキュメントを取得した際に一緒に取得できるわけではなく、親のドキュメントとは別に取得する必要があります。

そのため1つの募集を表示するために、募集ドキュメント + 代表ウマ娘ドキュメント + 親(継承元)1ドキュメント + 親(継承元)2ドキュメント = 4回ドキュメントを取得することになります。

Firestore は取得するドキュメントの数で課金されるため、1つの募集を表示するために4回ドキュメントの課金が発生します。

firebase.google.com

今回コスト削減を考えサブコレクションでデータを持つのではなく、一覧で利用するデータは全て募集ドキュメントで持つようにし、1つの募集を表示するのに1回のドキュメント取得で済むようにしています。

検索について

サブコレクションの検索

一覧のコストを考えサブコレクションでデータを持たず、募集ドキュメントでデータを持っていますが、検索面の理由もあり募集ドキュメントでデータを持つようにしています。

例えば代表ウマ娘が「スピード」因子を持つ募集を検索する場合、サブコレクションでデータを持っていると

  1. ウマ娘サブコレクションから代表ウマ娘が「スピード」因子を持つ、代表ウマ娘ドキュメントを取得
  2. parent を利用して、親ドキュメントの documentId を取得
  3. 親ドキュメントを documentId を利用して取得する

とったフローで検索をすることになります。

const parentDocIds = [];

const subCollectionQuerySnapshot = await firestore.collectionGroup('SUB_COLLECTION_NAME').where('因子', '==', 'スピード').get();
subCollectionQuerySnapshot.forEach((doc) => {
  // 1つ上の親はドキュメントではなくコレクション自体を指すので、2つ上を参照する
  if (doc.ref.parent.parent) {
    parentDocIds.push(doc.ref.parent.parent.id);
  }
});

// in が 10件しか受け付けないので分割して検索
for (let i = 0; i <= parentDocIds.length; i += 10) {
  const splitParentDocIds = parentDocIds.slice(i, i + 10);
  const querySnapshot = await firestore.collection('COLLECTION_NAME').where(firebase.firestore.FieldPath.documentId(), 'in', splitParentDocIds).get();
}

https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference#parentfirebase.google.com

https://firebase.google.com/docs/reference/js/firebase.firestore.FieldPath#documentidfirebase.google.com

表示と同じくドキュメント取得数が多くなるため、募集ドキュメントに検索に利用するデータをもたせています。

OR 検索の AND 検索

Firestore では (A or B or C) and (D or E or F) といった where-in の AND 検索はできません。

cloud.google.com

// できない
const querySnapshot = await firestore.collection('COLLECTION_NAME').where('サポートカード', 'in', ['1','2','3']).where('ウマ娘', 'in', ['A','B','C']).get();

// できる
const querySnapshot1 = await firestore.collection('COLLECTION_NAME').where('サポートカード', 'in', ['1','2','3']).get();
const querySnapshot2 = await firestore.collection('COLLECTION_NAME').where('ウマ娘', 'in', ['A','B','C']).get();

ウマ娘フレンド募集掲示板では、A or B or C で100件取得、D or E or F で100件取得し、重複を抽出をするようなロジックを組んで検索を実現しています。

ページング

ウマ娘フレンド募集掲示板では SNS の一覧のように、続きを読み込むというページング機能を実装しています(指定したページを表示する機能はありません)

firebase.google.com

シンプルな AND 検索と、OR 検索の AND 検索で実装方法を変えているのでそれぞれ紹介します。

AND 検索

AND 検索の場合は公式の例のようにシンプルな実装になっています。

startAt, startAfter を利用することでクエリの開始点を指定することができるため、前回の末尾のスナップショットを保持して利用することで実現しています。

class Api {
  lastSnapShot: firebase.firestore.QueryDocumentSnapshot | null;

  get() {
    let query = firestore.collection('COLLECTION_NAME');

    if (this.lastSnapShot) {
      query = query.startAfter(this.lastSnapShot);
    }

    const querySnapshot = await query.get();

    this.lastSnapShot = querySnapshot.docs[querySnapshot.docs.length - 1];
  }
}

OR 検索の AND 検索

前述したように、 (A or B or C) and (D or E or F) といった where-in の AND 検索はできないため、A or B or C で100件取得、D or E or F で100件取得し、重複を抽出をするようなロジックを組んで検索を実現しています。

AND 検索のページングのように1回のクエリで取得できないため、各クエリ毎にスナップショットを保持する設計で実現しています。

また、取得後の結果で重複を判定するため、過去に取得したデータも保持してします(下記サンプルでは細かいところは省略しています)

class Api {
  lastSnapShotMap: Map<'サポートカード' | 'ウマ娘', firebase.firestore.QueryDocumentSnapshot | null>;
  documents: any[];

  search(key: 'サポートカード' | 'ウマ娘', value: string[]) {
    let query = firestore.collection('COLLECTION_NAME').where(key, 'in', value);

    const lastSnapShot = this.lastSnapShotMap.get(key);
    if (lastSnapShot) {
      query = query.startAfter(lastSnapShot);
    }

    const querySnapshot = await query.get();

    this.lastSnapShotMap.set(key, querySnapshot.docs[querySnapshot.docs.length - 1]);

    querySnapshot.forEach((doc) => {
      this.documents.push(doc.data());
    });
  }

  get() {
    search('サポートカード', ['1','2','3']);
    search('ウマ娘', ['A','B','C']);
  }
}

終わりに

今回の知見をまとめると

  • ドキュメント毎の課金なので、一覧を実装する際に可能な限り一覧のドキュメントでデータを持つ
  • (A or B or C) and (D or E or F) といった where-in の AND 検索はできない
  • シンプルなページングであれば簡単に実現ができる

上記3点がポイントとなります。

(A or B or C) and (D or E or F) といった where-in の AND 検索ができないのは、多くの人が詰まるポイントかなと今回感じました。

これからもチャレンジしていくので、よろしくおねがいします!

Twitter

Twitter にてテックブログの投稿をツイートしていますので、よろしければフォローをお願いします!

twitter.com

Wanted!

一緒に働く仲間(特にサーバサイドエンジニア)を絶賛募集中です! 以下 Wantedly のページからぜひカジュアル面談へお申し込みください!

www.wantedly.com