Cloud FirestoreとCloud Storageを使ったDocument更新
今回は、Cloud Firestore と Cloud Storage と Cloud 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をコントロールすることが可能です。しかしユーザーへの読み書きとシステムの公開非公開のコントロールは別物です。
ここに示すReadableUser
のprofileImage
はURL
となっています。
ReadableUser
は一般的な要件に合わせる方がいいと考えています。一般的な要件とはStorageFile
などの独自の仕様を取っ払った、標準的なJSONを目指すべきと考えています。この理由については、自明だと思うのでここでは解説しません。
一方でWritableUser
のprofileImage
はStorageFile
となっています。
これはシステムサイドの都合で、こうしておいた方が連携しやすい意図が含まれています。またこの仕様は書き込み時のみに適応が必要で読み込みには一歳関係ありません。
目的を分離させる意味でも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を置いておきますのでよかったら覗いて見てください。
Writeの要件
次にWriteに関して考えています。
ここで注目すべきは、変更履歴を残すところです。
ユーザー情報の変更に履歴を残す必要がるかどうかは置いといて、注文情報などの例では必要になることも多いので今回はあえて要件に盛り込みました。
クライアントサイドの要件
- ユーザーのプロフィール画像を変更できる
- ユーザーのニックネームを変更できる
サーバーサイドの要件
- 変更履歴を保持する
- ストレージにはCloud Storageを利用する
ではReadableUser
を更新するまでの流れを簡単に説明します。
- Domain.WritableUserを更新する
-
/domain/v1/writable_users/:uid
のonUpdate
トリガーで/domain/v1/writable_users/:uid/changes/:timestamp
と/domain/v1/readable_users/:uidを更新する
とてもシンプルです。
changesについてモデルに言及していませんが、change.before
, change.after
なりを入れておくといいと思います。
Cloud FirestoreのデフォルトソートはDocumentIDで行われます。changesのDocumentIDをtimestampにしているのはそのためです。コンソールからも見やすくなります。
さて、ここで改めてStorageFile
について説明しようと思います。
StorageFile
を定義している理由は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
身内からの質問ですがw
このtimestampのDocumentIDは Timestamp.now().toMillis() のString型であってますか?
いい質問だ。
を使うより
を使う方がより正確。