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

久しぶりの投稿です。[Stamp Inc](//stamp.inc)の[nori](//twitter.com/1amageek)です。

今回は、__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のモデルは次のようにしました。

```swift

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はどこで管理すべきでしょうか？
おそらく今回定義したモデルの中には含めないことが賢明でしょう。定義するならば以下のようにするのがいいと思います。

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

## Read/Write分割の利点

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

```swift
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.com)を見ていただくとわかりやすいかもしれません。

例えば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の仕様](https://nextjs.org/docs/api-reference/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/:uid`の`onUpdate`トリガーで`/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`を定義している理由は２つあります。

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

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

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

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

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

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

```swift
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](//stamp.inc)はFirebaseを使ったサービス開発専門の技術支援事業を行なっています。お困りごとがあればお気軽にお声がけください。

TwitterからDMをいただいてもOKです！[@1amageek](//twitter.com/1amageek)