SNSアプリのFirestore設計

# はじめに
本記事は with Advent Calendar 2021 19日目の記事です。

こんにちは。withでiOSアプリ開発をしている @PictoMki  です。
withアドベントカレンダーの19日目を担当させていただきます。

# 必要知識
本記事はFirebaseを使用してアプリ開発をしたことがあるのを前提に記載しているため、
使用したことない場合は少し読みづらくなるかもしれません。

NoSQLデータベースについてや、一度Firebaseを使用してみることをお勧めします。

# 概要
最近Firebaseを使用してアプリのインフラ構築を行う機会が多かったので、
アウトプットも兼ねてSNSアプリのFirebaseでの設計を記載します。

## 今回の記事の内容
- Instagramを例に以下の機能を実現するためのFirestoreの設計を考察する
    - ログイン機能
        - Authenticationにてログインを提供し、uuidを発行
        - usersコレクションのドキュメントIDをuuidにする
        - 今回はここの部分は省略
    - プロフィール機能
        - 　名前、画像、投稿数、フォロー、フォロワーが見える
    - タイムライン機能
        - フォローしているユーザーの投稿が表示される

## 前提条件
・ユーザーのプロフィールは編集可能
・投稿は後から編集可能

上記の前提条件を元にして、各コレクションは**元データ(users,posts)のIDを正規化して持つ**ようにします。
Firestoreにおける正規化の考え方については[こちらの記事](https://zenn.dev/t0_inoue/articles/bb3b177f77412e)を参考にしてください。

## Firebaseの使用する機能
- Authentication(上述の通り、特に触れません)
- Firestore
- Storage
- Cloud Function


## Firestoreのデータスキーム
一旦結論として、データスキーム全体を記載しておきます。
詳細は各機能の概要部分で説明します。

```yaml
- users
    - authのuuid
        - name: String
        - accountName: String
        - imageUrl: String
        - followCount: Int
        - followerCount: Int
        - myPostCount: Int
        - createdAt: Timestamp
        - updatedAt: Timestamp

        # 以下サブコレクション

        - myPosts # 自分の投稿
            - postId
                - userId: String
                - createdAt: Timestamp

        - timelines # タイムタインに表示される投稿
            - postId
                - userId: String
                - createdAt: Timestamp

        - follows # フォローしてるユーザー
            - userId
                - createdAt: Timestamp

        - followers # フォローされているユーザー
            - userId
                - createdAt: Timestamp


- posts
    - randomId
        - description: String
        - imageUrls: [String]
        - likeCount: Int
        - createdAt: Timestamp
        - updatedAt: Timestamp

        # 以下サブコレクション

        - likeUsers
            - userId
                - createdAt: Timestamp
```

# 各機能におけるFirebaseの活用例
各機能ごとにFirebaseでの実現例を記載していきます。

## プロフィール機能
### Firestoreとの対応

Instagramのプロフィール画面には画像のように以下の情報があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514870/f9689f86-805b-25a4-4e5a-f406c4100e0e.png)


| 種別 | データ | Firestoreのdataとの対応 |
|:---------------|:---------------|:--------------|
| アカウント名       | tomoooki12    | accountName    |
| プロフィール画像    | 画像の通り      | imageUrl       |
| 投稿数           | 6             | postCount      |
| フォロワー数       | 274           | followCount    |
| フォロー数        | 315           | followerCount  |
| 名前            | 井上           | name           |
| 自分の投稿一覧    | マスクしてる部分　 | myPosts(サブコレクション) |

### 今回の論点
上記の設計を考えるにあたり論点は以下になります。

:::note warn
・投稿数やフォロー、フォロワー数をどのように保持するか
・自分の投稿をどう表示するか
:::


一つ一つみていきます。

### 投稿数やフォロー、フォロワー数をどのように保持するか
考えられるケースとしては以下の3パターンになるかと思います。

1. 投稿やフォロー、フォロワーのIDを配列で持つ
2. 投稿やフォロー、フォロワーをusersのサブコレクションで持つ
3. 2かつ、各countの値を持つ

それぞれについてメリット、デメリットを考えていきます。

#### 1.投稿やフォロー、フォロワーのIDを配列で持つ
スキームは以下のようになります。

```yaml
- users
    - authのuuid
        - 省略
        - postIds: [String] # 投稿のIDの配列
        - followUserIds: [String] # フォローしているユーザーのIDの配列
        - followerUserIds: [String] # フォローされているユーザーのIDの配列
```

##### メリット
- 1回のクエリで情報を取得できる
    - users/authのuuidの一件の取得で取れる
- 数については配列.countで取得できるので持つ必要がない

##### デメリット
- 各値が増えるごとにドキュメントのサイズが大きくなる(アプリがスケールしていく時に危険)
    - ex. 投稿が1万件の場合は配列に1万個データが入る
- セキュリティルールの設定がしにくい
    - ex. プライベート機能(フォローされている人しか見れない)みたいな機能をつける場合
        - フィールドに対してセキュリティルールを書く必要があるので、一つのコレクションに対するルールの記載が多くなり管理がしにくい
        - コレクション全体に対してセキュリティルールを書く方がシンプル(個人的見解)

#### 2. 投稿やフォロー、フォロワーをusersのサブコレクションで持つ
スキームは以下のようになります。

```yaml
- users
    - authのuuid
        - 省略

        - myPosts(サブコレクション)
            - postId
                - 省略

        - follows(サブコレクション)
            - userId
                - 省略

        - followers(サブコレクション)
            - userId
                - 省略
```

##### メリット
- セキュリティルールがシンプルに書ける
    - ex. フォローされているユーザーのみ各サブコレクションの値を参照できる(コレクションに対してセキュリティルールが設定可能)
- 各カウントの増減に対して、userのドキュメントのサイズが変わらない
    - サブクレクションのためドキュメントサイズには影響なし

##### デメリット
- countを取得するためにサブクレクションを全て取得する必要がある(Firestoreの読み取り数も増えるためかなりネック)
    - コレクションのドキュメント数のみを取得するクエリがないため、一度全てドキュメントを取得する必要がある
- 各種値を取得するために、もう一度クエリを発行する必要がある
    - サブコレクションは別のコレクションなので、users/authのuuidのクエリでは参照できない
    - users/authのuuid/myPostsに対して再度アクセスが必要

#### 3. 2かつ、各countの値を持つ
スキームは以下のようになります。

```yaml
- users
    - authのuuid
        - 省略
        - myPostCount: Int
        - followCount: Int
        - followerCount: Int

        - myPosts(サブコレクション)
            - postId
                - 省略

        - follows(サブコレクション)
            - userId
                - 省略

        - followers(サブコレクション)
            - userId
                - 省略
```

##### メリット
- 1回のクエリでプロフィール画面に必要な情報を取れる(1同様)
- 各カウントの増減に対して、userのドキュメントのサイズが変わらない(2同様)
- セキュリティルールがシンプルに書ける(2同様)

##### デメリット
- 各種値を取得するために、もう一度クエリを発行する必要がある(2同様)
- サブコレクションにデータが追加されるたびにcountを更新する必要がある
    - 後述しますがCloud Functionsにてデータの作成、削除をトリガーにしてcountを更新する仕組みを作ります。

<details><summary>Cloud Functionsでトリガーする仕組み</summary><div>
Cloud Functionsには図のようにFirestoreへのデータの作成、更新、削除をトリガーして
何か処理を行うことができます。
今回は上記の機能を使用して、`投稿したときにusersにある投稿数を更新する処理`を行っています。

![Group３.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514870/d10b5601-e631-4db1-a5c6-78297d949286.png)

フォロー、フォロワー数についても同様の仕組みを利用して実現しています。
</div></details>


#### 結論
３パターン目は1と２でそれぞれネックになっている、
**各値が増えるごとにドキュメントのサイズが大きくなる**
**countを取得するためにサブクレクションを全て取得する必要がある**
こちらの二つを解消できるため、今回は3パターン目を採用するのがいいと感じました。

### 自分の投稿をどう表示するか

考えられるケースとしては以下の2パターンになるかと思います。

1. postsから自分のIDを検索して取得
2. usersコレクションにサブクレクションで自分の投稿の参照を持つ

#### 1. postsから自分のIDを検索して取得

スキームは以下になります。

```yaml
- users
    - authのuuid
        - 省略

- posts
    - randomId
        - postUserId # ユーザー情報をを正規化して持つ
        - 省略
```

##### メリット
- 1回のクエリで必要な情報を取得できる
    - `(collection(posts).whereField("postUserId", isEqualTo: "自分のId"))`

##### デメリット
- postsのデータが増えると取得に時間がかかるようになる
- 新規機能追加時にセキュリティルールが設定しづらい
    - 鍵アカなどのときにposts全体にセキュリティを設定するのがめんどくさい

#### 2. usersコレクションにサブクレクションで自分の投稿の参照を持つ

スキームは以下になります。

```yaml
- users
    - authのuuid
        - 省略

        - myPosts
            - postId
                - userId
                - createdAt

- posts
    - randomId
        - 省略
```

##### メリット
- postsのデータが増えても取得に影響がない(myPostsから取得するデータを決定するため)
- myPostsに対してセキュリティを設定するので管理しやすい

##### デメリット
- 投稿を取得するのに2回クエリを発行する必要がある(取得に少し時間がかかる)
    - myPostsから1回、postsから1回

#### 結論
2パターン目を採用する方が望ましいと思いました。
postsのような投稿データはユーザー数が増えるにつれて、データが増大していくので
`postsのデータが増えると取得に時間がかかるようになる`
こちらのデメリットが影響が大きくなる危険性があるで、そちらをさけられる２パターン目の方が
リスクが少ないという観点で採用しています。

## タイムライン機能
### Firestoreとの対応

Instagramのタイムライン画面には画像のように以下の情報があります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514870/086a6f4d-03cc-9693-dd2c-1fd43a8e8785.png" width="800px">


| 種別 | データ | Firestoreのdataとの対応 |
|:---------------|:---------------|:--------------|
| アカウント名       | mendokoro_shimizu    | users.accountName    |
| プロフィール画像    | 画像の通り      | users.imageUrl       |
| 投稿画像           | 画像の通り             | posts.imageUrls      |
| いいね数       |75       | posts.likeCount    |
| 投稿説明        | おはようございます！          | posts.description  |

### 今回の論点
上記の設計を考えるにあたり論点は以下になります。

:::note warn
フォローしているユーザーの投稿をどう表示するか
:::

### フォローしているユーザーの投稿をどう表示するか
いわゆるSNSのタイムラインの機能を実現するための設計を考えます。
考慮しなければいけない点は以下になります。

- タイムラインのデータが更新される(いいねがあるので頻繁に更新される)
    - 正規化しておく必要がある

- タイムラインへの投稿の追加や削除(追加、削除タイミングが多い)
    - フォローしているユーザーが投稿したとき(追加)
    - フォローしているユーザーが投稿を削除したとき(削除)
    - 新しくユーザーをフォローしたとき(追加)
    - フォローを解除したとき(削除)

### タイムラインのデータが更新される
元データの更新頻度が多い場合は正規化してデータを持っていく方が良いです。(※前提条件参照)
正規化したデータの持ち方は2パターンあると思います。

1. usersに配列で持つ
2. usersのサブコレクションで持つ

こちらの議論は`投稿数やフォロー、フォロワー数をどのように保持するか`の1パターン目の話同様で
タイムラインのデータは無限に増え続けていく(定期的に削除するのもありだが)ため、配列で保持すると
件数の増加にともなってuserデータが肥大化していくため、サブコレクションで持つのが賢明だと思います。

スキームは以下になります。

```yaml
- users
    - authのuuid
        - 省略

        - timelines(サブコレクション)
            - postId
                - userId: String
                - createdAt: Timestamp

```

### タイムラインへの投稿の追加や削除
タイムラインへのデータ追加は、データの更新量がかなり多い処理になります。
※フォロワーが100人いたら、100回の更新を行う(各userのtimelinesに追加)
そのためアプリ側で処理を行うと、速度がかなり遅くなると思います。

上記を回避するためにCloud Functionsのトリガーを利用してサーバー側で処理を行います。

### Cloud Functionsによるタイムライン機能の実装
追加と削除でそれぞれ4つのトリガーを設定する必要がありますが、
今回は一旦追加時のみ図解しています。(削除時はonDeleteで反対の処理をする感じです。)

#### 新規投稿時のタイムラインへの追加
![Group４.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514870/521a325c-a17b-0f2a-70a6-503a33d1f093.png)

上記の画像のように投稿を検知して、タイムラインへの追加を実現します。
ユーザーについては`users/自分のID/followers`にデータがあるため、そちらのユーザーのtimelinesにデータを追加します。

#### フォロー時のタイムラインへの追加
![Group5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514870/33bdabdb-783c-bf9a-2eef-198bce5f0e0c.png)

フォロー時も同様にフォローを検知して、タイムラインへの追加を実現します。
投稿については`users/フォローしたユーザーのID/myPosts`から取得して追加しています。

### 結論
FirestoreはRDBのようにいい感じにテーブル結合して、取得するみたいなことができないため
タイムラインのように取得時に複雑なクエリを発行する必要がありそうなものは、
Cloud Functionsを活用して、書き込み側に負荷を持たせるのが良いと思います。


# さいごに
今回はInstagramを例にSNSのFirebaseでの実現方法を考察しました。
各SNSによって必要条件が異なるため、上記の方法が最適解ではないと思いつつ、今のところ
他にあまり良い方法が思い付いていないので、もし良い方法があればご教授頂ければと思います！
長くなりましたが、拝読ありがとうございました。