読み込み中 %

アイコン

Firestoreの設計ミスをCloudFuntionsとGoスクリプトで解消する

taikishiino
2020/10/12

はじめに

先日リリースしたFlutter×Firebase製アプリ 「カラオケメモアプリ-うためも」が機能アップデートをしました🎉

ユーザーの方からリクエストが多かった、「フォルダ機能」になります。

今回は、フォルダ機能の開発にあたって大変だったことと対応手順をまとめていきます。



⬇️「カラオケメモアプリ-うためも」はこちらからインストールできます

utamemo_icon

App Store: https://apps.apple.com/us/app/id1527778412

Google Play: https://play.google.com/store/apps/details?id=com.taikishiino.uta_memo



フォルダ機能アップデートにあたって

全ユーザーのコレクション配下に新しくfoldersサブコレクションを書き込むスクリプトを流す必要がありました。 スクリプト処理としては、usersコレクション(/users)のドキュメント一覧を取得した後、各ドキュメントにfoldersサブコレクション(/users/userId/folders/folderId)に書き込むといった流れになります。

Firestore設計ミス

usersコレクションの全てのドキュメントが、「サブコレクションはあるがフィールドが空のドキュメント」になっていました。 firestore

これの何が悪いのかというと、ドキュメント一覧を取得する以下のようなクエリで取得できないという点です。(空扱いになるらしい)

db.collection("users").get()

そのため、当初の予定だった「usersコレクション(/users)のドキュメント一覧を取得した後、各ドキュメントにfoldersサブコレクション(/users/userId/folders/folderId)に書き込む」ができないのです。

解決方法

usersコレクション配下の全てのドキュメントにフィールドを書き込んだのち、新しいサブコレクションを書き込むスクリプトを流すといった少々強引な方法で行いました。

全てのドキュメントにフィールドを書き込む

以下のCloud Funtionsをデプロイした後に、Goスクリプトを流すことで全てのドキュメントにフィールドを書き込むことに成功しました。

・Cloud Funtions

サブコレクションへの書き込みをトリガーに、親のドキュメントRefにupdatedAtフィールドに書き込むTypeScriptのファンクションを用意しました。

index.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

const serviceAccount = require("../xxxxx.json");
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: "https://xxxxxxx.firebaseio.com"
});

export const SyncSongsToUsers = functions.firestore
  .document('users/{userId}/songs/{songId}')
  .onWrite(async (change: any, context: any) => {
    const uid = context.params.userId;
    const newVal = change.after.data();
    const updatedAt = newVal.updatedAt;

    const queryUser = admin.firestore().collection(`users`).doc(uid);
    try {
        const doc = await queryUser.get();
      if (doc.exists) {
        await queryUser.update({
          updatedAt: updatedAt.toDate()
        });
      } else {
        await queryUser.set({
          createdAt: updatedAt.toDate(),
          updatedAt: updatedAt.toDate(),
          deletedAt: null
        });
      }
    } catch(e) {
      console.log("ERROR Set or Update =====>", e);
    }

    return null
  });

・Goスクリプト

上記で設定したサブコレクションusers/{userId}/songs/{songId}パスに対してのコレクショングループを使い直接UPDATE処理をするスクリプトを流します。

songs/main.go
func main() {
	ctx := context.Background()
	firestoreClient, err := connectFirestore(ctx)
	if err != nil {
		fmt.Println("firebase Connection error: ", err)
	}
	defer firestoreClient.Close()

	it := firestoreClient.CollectionGroup("songs").Documents(ctx)
	for {
		doc, err := it.Next()
		if err == iterator.Done {
			break
		}
		if err != nil {
            fmt.Println("err\n", err)
		}
		song := &Song{}
		err = doc.DataTo(song)
		if err != nil {
			return
		}
		doc.Ref.Set(ctx, map[string]interface{}{
			"folderId": xxxx,
		}, firestore.MergeAll)
	}
	fmt.Println("----- main end -----")
}

func connectFirestore(ctx context.Context) (*firestore.Client, error) {
	sa := option.WithCredentialsFile("../xxxxx.json")
	app, err := firebase.NewApp(ctx, nil, sa)
	if err != nil {
		fmt.Printf("error initializing app: %v", err)
		return nil, err
	}
	return app.Firestore(ctx)
}

foldersサブコレクションを書き込むスクリプトを流す

以下を流して、usersのコレクション配下に新しくfoldersサブコレクションを書き込むことができました。

folders/main.go
func main() {
	ctx := context.Background()
	firestoreClient, err := connectFirestore(ctx)
	if err != nil {
		fmt.Println("firebase Connection error: ", err)
	}
	defer firestoreClient.Close()

	iter := firestoreClient.Collection("users").Documents(ctx)
	for {
		doc, err := iter.Next()
		if err == iterator.Done {
			break
		}
		if err != nil {
			fmt.Println("err\n", err)
		}
		it := doc.Ref.Collection("folders").Documents(ctx)
		flg := true
		for {
			folderDoc, err := it.Next()
			if err == iterator.Done {
				break
			}
			if doc.Data() != nil {
				flg = false
			}
		}
		if flg == true {
			uid := doc.Ref.ID
			now := time.Now()
			_, _, err = firestoreClient.
				Collection("users").
				Doc(uid).
				Collection("folders").
				Add(ctx, map[string]interface{}{
                    "name":          "未分類",
                    ・
                    ・
					"createdAt":     now,
					"updatedAt":     now,
					"deletedAt":     nil,
				})
			if err != nil {
				fmt.Println("err\n", err)
			}
		}
	}
	fmt.Println("----- main end -----")
}

func connectFirestore(ctx context.Context) (*firestore.Client, error) {
	sa := option.WithCredentialsFile("../xxxxx.json")
	app, err := firebase.NewApp(ctx, nil, sa)
	if err != nil {
		fmt.Printf("error initializing app: %v", err)
		return nil, err
	}
	return app.Firestore(ctx)
}

最後に

業務では選択しないであろう強引な方法でしたが、勉強にもなったのでよかったかなと思っています。。

のちに気付いたのですが、「サブコレクションの親ドキュメントRefを取得する方法」もあるらしく、わざわざCloud Funtions使わなくてもよかったなと少し後悔しています。。

Firestoreの設計は、クエリドリブンなデータベース設計をするのが良いと感じました。 その中でも、今回のようなアンチパターン対策として、「サブコレクションがあるドキュメントにも何かしらのフィールドは作る」ようにしましょう。