🤔

InstagramのFirestore設計

2021/12/21に公開

前提

InstagramのようなSNSをFirebaseでフル実装するために色々試行錯誤したので、その過程やメリット、デメリットを考察しながら記載します。
Qiitaではアドベントカレンダーとして投稿しています。
SNSアプリのFirestore設計

必要知識

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

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

概要

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

今回の記事の内容

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

前提条件

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

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

Firebaseの使用する機能

  • Authentication(上述の通り、特に触れません)
  • Firestore
  • Storage
  • Cloud Function

Firestoreのデータスキーム

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

- 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

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

今回の論点

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

一つ一つみていきます。

投稿数やフォロー、フォロワー数をどのように保持するか

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

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

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

1.投稿やフォロー、フォロワーのIDを配列で持つ

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

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

2. 投稿やフォロー、フォロワーをusersのサブコレクションで持つ

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

- users
    - authのuuid
        - 省略

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

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

        - followers(サブコレクション)
            - userId
                - 省略
メリット
  • セキュリティルールがシンプルに書ける
    • ex. フォローされているユーザーのみ各サブコレクションの値を参照できる(コレクションに対してセキュリティルールが設定可能)
  • 各カウントの増減に対して、userのドキュメントのサイズが変わらない
    • サブクレクションのためドキュメントサイズには影響なし
デメリット
  • countを取得するためにサブクレクションを全て取得する必要がある(Firestoreの読み取り数も増えるためかなりネック)
    • コレクションのドキュメント数のみを取得するクエリがないため、一度全てドキュメントを取得する必要がある
  • 各種値を取得するために、もう一度クエリを発行する必要がある
    • サブコレクションは別のコレクションなので、users/authのuuidのクエリでは参照できない
    • users/authのuuid/myPostsに対して再度アクセスが必要

3. 2かつ、各countの値を持つ

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

- 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を更新する仕組みを作ります。

Cloud Functionsでトリガーする仕組み

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

Group3.png

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

結論

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

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

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

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

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

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

- users
    - authのuuid
        - 省略

- posts
    - randomId
        - postUserId # ユーザー情報をを正規化して持つ
        - 省略
メリット
  • 1回のクエリで必要な情報を取得できる
    • (collection(posts).whereField("postUserId", isEqualTo: "自分のId"))
デメリット
  • postsのデータが増えると取得に時間がかかるようになる
  • 新規機能追加時にセキュリティルールが設定しづらい
    • 鍵アカなどのときにposts全体にセキュリティを設定するのがめんどくさい

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

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

- users
    - authのuuid
        - 省略

        - myPosts
            - postId
                - userId
                - createdAt

- posts
    - randomId
        - 省略
メリット
  • postsのデータが増えても取得に影響がない(myPostsから取得するデータを決定するため)
  • myPostsに対してセキュリティを設定するので管理しやすい
デメリット
  • 投稿を取得するのに2回クエリを発行する必要がある(取得に少し時間がかかる)
    • myPostsから1回、postsから1回

結論

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

タイムライン機能

Firestoreとの対応

Instagramのタイムライン画面には画像のように以下の情報があります。
image

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

今回の論点

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

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

いわゆるSNSのタイムラインの機能を実現するための設計を考えます。
考慮しなければいけない点は以下になります。

  • タイムラインのデータが更新される(いいねがあるので頻繁に更新される)

    • 正規化しておく必要がある
  • タイムラインへの投稿の追加や削除(追加、削除タイミングが多い)

    • フォローしているユーザーが投稿したとき(追加)
    • フォローしているユーザーが投稿を削除したとき(削除)
    • 新しくユーザーをフォローしたとき(追加)
    • フォローを解除したとき(削除)

タイムラインのデータが更新される

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

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

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

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

- users
    - authのuuid
        - 省略

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

タイムラインへの投稿の追加や削除

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

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

Cloud Functionsによるタイムライン機能の実装

追加と削除でそれぞれ4つのトリガーを設定する必要がありますが、
今回は一旦追加時のみ図解しています。(削除時はonDeleteで反対の処理をする感じです。)

新規投稿時のタイムラインへの追加

Group4.png

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

フォロー時のタイムラインへの追加

Group5.png

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

結論

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

さいごに

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

株式会社HAKATOMO tech blog

Discussion