⛏️

Cloud FirestoreとCloud Storageを使ったDocument更新

2021/08/23に公開2

久しぶりの投稿です。Stamp Incnoriです。

今回は、Cloud FirestoreCloud StorageCloud Functions を連携させたDocumentの更新について解説しようと思います。

Cloud Firestore だけの更新であれば簡単ですが、Cloud Storage と連携させると割と考慮することが増えるので、参考になると幸いです。

今回の要件

今回は例としてユーザーの情報を変更するためのロジックを考えていきましょう。また要件は次のようにします。

クライアントサイドの要件

  • ユーザーのプロフィール画像を表示できる
  • ユーザーのニックネームを表示できる
  • ユーザーのプロフィール画像を変更できる
  • ユーザーのニックネームを変更できる

サーバーサイドの要件

  • サイズに応じた画像を返す
  • 変更履歴を保持する
  • ストレージにはCloud Storageを利用する

まずは、Cloud Firestoreの構成について説明していきます。

Cloud Firestoreの構成とモデル

Cloud Firestoreの構成は次のようにしました。

/domain/v1/readable_users/:uid // 全てのユーザーが読み込み可能
/domain/v1/writable_users/:uid // uidを持つユーザーのみが読み書き可能
/domain/v1/writable_users/:uid/changes/:timestamp // uidを持つユーザーのみが書き可能

Cloud Firestoreのモデルは次のようにしました。


enum StorageFile {
    case local(name: String, data: Data)
    case storage(path: String, url: URL?)
    case server(url: URL)
}

struct Domain {
    struct ReadableUser: Identifiable, Codable {
        @DocumentID var id: String?
	var name: String?
        var profileImage: URL?
    }
}

struct Domain {
    struct WritableUser: Identifiable, Codable {
        @DocumentID var id: String?
	var name: String?
        var profileImage: StorageFile?
    }
}

ここで注目すべきは、Domainによる分割とRead/Writeの分割です。
これは僕が他の記事や公開してるYouTubeでも何回か述べていますが、以下の利点があります。

Domain分割の利点

開発を行なっていく中でUserと呼ばれるモデルに機能が追加されていくことは常にあります。また、同じように機能が削除されていくことも常にあります。長期的な開発を考慮するのであれば、追加しやすく削除しやすい運用が求められるはずです。

例えば、Push通知を送りたい要件が追加された時、FCM Tokenはどこで管理すべきでしょうか?
おそらく今回定義したモデルの中には含めないことが賢明でしょう。定義するならば以下のようにするのがいいと思います。

struct PushNotification {
    struct User: Identifiable, Codable {
        @DocumentID var id: String?
	var fcmTokens: [String: String]
        var isAllowedPushNotifications: Bool
    }
}

Read/Write分割の利点

今回のモデルではprofileImageのところに少し差異があることに注目してください。この差異は非常に重要です。

struct Domain {
    struct ReadableUser: Identifiable, Codable {
        @DocumentID var id: String?
	var name: String?
        var profileImage: URL?
    }
}

struct Domain {
    struct WritableUser: Identifiable, Codable {
        @DocumentID var id: String?
	var name: String?
        var profileImage: StorageFile?
    }
}

ここではStorageFileと呼ばれるstructを独自で定義しました。StorageFileはCloud Storageと連携させるために役割を果たします。StorageFileについては後に記しますので、一旦このまま進めます。

Read/Writeを分割する最大の利点はセキュリティの担保にあります。
Cloud Firestoreセキュリティルールによって、DocumentのRead/Writeをコントロールすることが可能です。しかしユーザーへの読み書きとシステムの公開非公開のコントロールは別物です。

ここに示すReadableUserprofileImageURLとなっています。
ReadableUserは一般的な要件に合わせる方がいいと考えています。一般的な要件とはStorageFileなどの独自の仕様を取っ払った、標準的なJSONを目指すべきと考えています。この理由については、自明だと思うのでここでは解説しません。

一方でWritableUserprofileImageStorageFileとなっています。
これはシステムサイドの都合で、こうしておいた方が連携しやすい意図が含まれています。またこの仕様は書き込み時のみに適応が必要で読み込みには一歳関係ありません。

目的を分離させる意味でもRead/Writeを分離させることにメリットがあると考えています。

要件について考える

Readの要件

まずは画面の表示(Read)について考えて行きましょう。
今回の要件では次のことを考えなければいけません。

クライアントサイドの要件

  • ユーザーのプロフィール画像を表示できる
  • ユーザーのニックネームを表示できる

サーバーサイドの要件

  • サイズに応じた画像を返す

クライアントサイドの要件についてはCloud Firestoreから読み込み可能である次のパスからデータをロードすれば何も問題なさそうです。

/domain/v1/readable_users/:uid

ただしサーバーサイドの要件は少し大変です。
みなさんが普段利用されているサービスのほとんどには、この仕様が搭載されいます。代表的な例で言うならばInstagramを見ていただくとわかりやすいかもしれません。

例えばInstagramの画像のURLは次のような構成になっています。s640x640これがサイズに関する情報です。

https://scontent-nrt1-1.cdninstagram.com/v/xxx.xxxxxx/xx.xx/xxx/s640x640/xxxx_n.jpg

next.jsなどを使うとデフォルトこの機能を搭載しているので参考にするのもいいでしょう。
next.jsのnext/imageの仕様

また、Cloud Functionsを使っても同じ仕様を作ることができるので今回はそれを紹介します。
Sample Codeを置いておきますのでよかったら覗いて見てください。

https://gist.github.com/1amageek/a5142e9c229bafab78b75b9415efa2f6

Writeの要件

次にWriteに関して考えています。
ここで注目すべきは、変更履歴を残すところです。
ユーザー情報の変更に履歴を残す必要がるかどうかは置いといて、注文情報などの例では必要になることも多いので今回はあえて要件に盛り込みました。

クライアントサイドの要件

  • ユーザーのプロフィール画像を変更できる
  • ユーザーのニックネームを変更できる

サーバーサイドの要件

  • 変更履歴を保持する
  • ストレージにはCloud Storageを利用する

ではReadableUserを更新するまでの流れを簡単に説明します。

  1. Domain.WritableUserを更新する
  2. /domain/v1/writable_users/:uidonUpdateトリガーで/domain/v1/writable_users/:uid/changes/:timestampと/domain/v1/readable_users/:uidを更新する

とてもシンプルです。
changesについてモデルに言及していませんが、change.before, change.afterなりを入れておくといいと思います。
https://firebase.google.com/docs/reference/functions/cloud_functions_.change?hl=ja

Cloud FirestoreのデフォルトソートはDocumentIDで行われます。changesのDocumentIDをtimestampにしているのはそのためです。コンソールからも見やすくなります。

さて、ここで改めてStorageFileについて説明しようと思います。
StorageFileを定義している理由は2つあります。

  1. クライアントサイドでの画像の取り回し
  2. サーバーサイドでの画像の削除

クライアントサイドでの画像の取り回し

まず、クライアントサイドでの画像の取り回しについて説明します。
読み込み時はURLを扱えばどこにでも表示することが可能だったprofileImageですが、データをサーバーにアップロードされるまではDataとして扱う必要があります。

今回の例では、クライアントで扱い場合のlocal、Cloud Functionsで扱う場合のstorage、単純にURLとして扱う場合のserverを定義しました。

enum StorageFile {
    case local(name: String, data: Data) // ローカルでのみ扱う
    case storage(path: String, url: URL?) // WritableUserに保存される状態
    case server(url: URL) // ローカルでのみ扱う
}

例えば次のように使います。

struct StorageImage: View {

    var file: StorageFile?

    var body: some View {
        if let file = item {
            Group {
                switch file {
                    case .local(_, let data):
                        Image(uiImage: UIImage(data: data)!)
                    case .server(let url):
                        KFImage(url)
                    case .storage(_, let url):
                        KFImage(url)
                }
            }
	    .resizable()
        } else {
            Rectangle()
                .fill(Color(.secondarySystemBackground))
        }
    }
}

クライアントサイドでは、プロフィール画像の更新のためにプレビュー機能を搭載することがあるかもしれません。そういった時はURL, Dataのどちらでも画像を表現できる方が良さそうです。

サーバーサイドでの画像の削除

次にサーバーサイドでの画像の削除について考えます。
画像はCloud Storageに保存されることを想定しているので、これもまたURLでの取り扱いでは保存したpathとURLが全く関連性のない場合など難しい場合があります。

つまりユーザー情報を更新するにはURLしか持たないReadableUserだけではCloud Storageを扱うことが難しく、WritableUserを使うことが必要になります。

Cloud Functionsでは、StorageFileのpathを使ってfirebase-adminからデータの削除を行います。

これで今回の解説は終了です。

Stamp IncはFirebaseを使ったサービス開発専門の技術支援事業を行なっています。お困りごとがあればお気軽にお声がけください。

TwitterからDMをいただいてもOKです!@1amageek

Discussion

shohei@株式会社Nevershohei@株式会社Never

身内からの質問ですがw

/domain/v1/writable_users/:uid/changes/:timestamp

このtimestampのDocumentIDは Timestamp.now().toMillis() のString型であってますか?

1amageek1amageek

いい質問だ。

Timestamp.now()

を使うより

snapshot.after.updateTime.toMillis()

を使う方がより正確。