👨‍⚖️

[Flutter]Firestoreで作るチャットアプリの設計&コストカットを考える。1対1ルーム編

2021/11/15に公開約7,600字

初めに

(2022/05/13)に記事の内容を大きく変更しました。

こんにちはえんでばーです.

Firestoreでチャットアプリを作る際のデータベース設計を考えたいと思います。
ただ作れたではなく。少しでも通信コストを下げて且つ高速なもの提案していこうと思います。

実践に基づいて3パターンの設計も考えてきました。
3つのパターンのメリットデメリットもしっかりお伝えできればと思います
firestoreについて詳しくない人にも、できるだけ分かりやすく丁寧に説明していきます。

他の人の記事//参考にしないで

まず自分がfirestoreについて詳しく知らなかった時に出会った記事、

また他の人が書いていてグッドを押されていた記事についての問題点やデメリットについても指摘していきます。
気分を害されたら申し訳ないです。

https://medium.com/flutter-community/building-a-chat-app-with-flutter-and-firebase-from-scratch-9eaa7f41782e

https://medium.com/flutter-community/a-chat-application-flutter-firebase-1d2e87ace78f

この二つを参考に作ってみました。

問題が起こった

上記の記事の二つには

String getConversationID(String userID, String peerID) {
    return userID.hashCode <= peerID.hashCode 
      ? userID + '_' + peerID 
      : peerID + '_' + userID;
 } 

uidをhashコード化し、チャットルームを作成しています。

これの何が問題なのかというと、

cloud Functuonsで同じコードが書けない

jsの公式ライブラリにhashcodeを生成するものが無く、誰かが作ったhashコード生成ライブラリを使った際、2人の間で2つのroomができるという事件が発生した。

これではroomが二つできてしまい、訳のわからない状態になってしまう。

クライアント側だけで処理するアプリならいいのだが、
ちょっと大きくなっていろんな操作をしたいって場合にこれが致命傷である。

チャットルームのdocIdに二人のuidを繋げるといった事はやらない方が安全にアプリを作る事ができる。

チャットルームの設計のベストプラクティス

まず作りたいものを考える。例えば上の画像の様なチャットアプリを作りたい場合
一つのルームに必要なデータは

結論

  • ルームID string
  • 最後にメッセージを送信した人のuid string
  • 最後のメッセージ string
  • 送った時間 Timestamp
  • 未読カウント int
  • 自分が参加しているルームリストを表示する為のデータ List-string-
  • 相手と自分のルームが存在するかのクエリ用のデータ Map-string,boolean-

が必要になってきます。マークダウン慣れて無いので<、>が-になってます、、。
申し訳ないです、、。


  • ルームID
  • 最後にメッセージを送信した人のuid
  • 最後のメッセージ
  • 送った時間
  • 未読カウント

この5つに関しては、目に見えて必要になってくるデータという事で理解できると思います。


自分の参加しているルームを一覧させるクエリを行う必要があるので、

これでList stringという形で入れておくことで、自分のルームを検索する事ができます。

//これで最新順に取得可能
await FirebaseFirestore.instance
        .collection('rooms')
        .where('joinedUsers', arrayContains: myUid)
        .orderBy('updateAt', descending: true)
        .get();

自分と相手のグループが存在するのかしないのかを検索したい場合

ルームを作成する場合すでにルームがあるか無いかを検索したい場合があると思います。
しかし上記の設計のjoinedUsersのみだと実現できません。
firestoreのORクエリは貧弱すぎる為別の方法で実現する必要があります。

ORクエリの解決方法

これはfirestoreのORクエリ制限の回避策として取られる方法なのですが、
map<String,bool>といった形でデータを入れておく事で、where文を二回使ってクエリする事ができます。
これであるか無いかの検索が可能になりました。

try{
await FirebaseFirestore.instance
        .collection('rooms')
        .where(
          'usersQuery.$youUid',
          isEqualTo: true,
        ).where(
          'usersQuery.$myUid',
          isEqualTo: true,
        ).get().then(
          (QuerySnapshot querySnapshot) => querySnapshot.docs.first,
        );
} catch(e){
   print('ルームは無いよ')
}

こうやればいけるんじゃねと思う方もいると思います。

//これは不可能
await FirebaseFirestore.instance
        .collection('rooms')
        .where('joinedUsers', arrayContains: [myUid,youUid])
        .get();

Firestore search array contains for multiple values


//これは意味が違う
await FirebaseFirestore.instance
        .collection('rooms')
        .where('joinedUsers', arrayContainsAny: [myUid,youUid])
        .get();

Cloud Firestore で単純なクエリと複合クエリを実行する


チャットルームデータ設計の結論

こんな感じになっていればOK!!
あとは自分の好きな様にカスタマイズして頂ければ問題ない!


チャットルームの読み取りコスト最小限にする方法

ここからはfirestoreでチャットアプリを作る際にやっておいた方がいいサーバーとの通信で発生するコストカットの方法を説明していきます。

チャットアプリ以外にも使えるシーンが沢山あると思うので知って損なし!
firestoreを深く知る事で更なるコストカットを測っていけます。

基本知識


limit

//これで最新の10件
await FirebaseFirestore.instance
        .collection('rooms')
        .where('joinedUsers', arrayContains: myUid)
        .orderBy('updateAt', descending: true)
	.limit(10)
        .get();

これで読み取り量は10件 東京リージョンなら$0.0000038


startAfter

await FirebaseFirestore.instance
        .collection('rooms')
	.where('joinedUsers', arrayContains: myUid)
        .orderBy('updateAt', descending: false)
        .startAfter([最後にフェッチした時間(Timestamp)])
        .get()

最後にデータをフェッチした時間を入れた場合、最新のデータが一件以上あればその件数の読み取りコスト、何もなければ1読み取りになる


Source.cache

await FirebaseFirestore.instance
        .collection('rooms')
        .where('joinedUsers', arrayContains: myUid)
        .orderBy('updateAt', descending: true)
        .limit(1)
        .get(const GetOptions(source: Source.cache))

get()関数の中のGetOptionsパラメータにcacheを選択する事でキャッシュ(端末内)データを取得する事ができる

つまりキャッシュを先に見にいって欲しい時はget(const GetOptions(source: Source.cache))をする必要がある。get()はドキュメントが変更されたか確認に行くため、変わってなくてもどっちにしろ1読み取りかかってしまう。


上の3つを知るだけでコストカットの基本なので確実に使っていきましょう!

設計パターン1(初学者が絶対やってしまうやつ)

  1. 最新の10件とってくる stream
  2. ページングしてさらに10件とってくる stream

みたいな流れになると思います。かつては自分もそうやって作っていました。


取得パターン2

  1. キャッシュにあるルームリストを取得
  2. キャッシュの中の最新のデータよりupdateAtが新しいデータを取得
  3. キャッシュから返す

0.アプリ開いてる時はsnaphotで監視しつつアプリないのデータに混ぜていく

実際コードを書くとこうなる

  1. キャッシュにある最も新しいルームを一つを取得
 await FirebaseFirestore.instance
        .collection('rooms')
        .where('joinedUsers', arrayContains: myUid)
        .orderBy('updateAt', descending: true)
        .limit(1)
        .get(const GetOptions(source: Source.cache))
	.then(
          (QuerySnapshot querySnapshot) =>
              RoomModel.fromFirestore(querySnapshot.docs.first),
        );
  1. キャッシュの中の最新のデータよりupdateAtが新しいデータを取得
 await FirebaseFirestore.instance
        .collection('rooms')
        .where('joinedUsers', arrayContains: myUid)
        .orderBy('updateAt', descending: false)
        .startAfter([lastFetchTime])
        .get(const GetOptions(source: Source.server))
  1. キャッシュの中の最新のデータを20件取得

   return await FirebaseFirestore.instance
       .collection('rooms')
       .where('joinedUsers', arrayContains: myUid)
       .orderBy('updateAt', descending: true)
   	.limit(20)
       .get(const GetOptions(source: Source.cache)
       .then(
         (QuerySnapshot querySnapshot) => querySnapshot.docs
             .map((DocumentSnapshot documentSnapshot) =>
                 RoomModel.fromFirestore(documentSnapshot))
             .toList(),
       );
 

この流れをする事により、アプリ閉じている間に5件メッセージが来たならアプリ開けば読み取り量は5件のみとなる。

設計 設計1 設計2
読み取り回数 アプリを開いたら10件 アプリを開き更新データがなければ1件、あればその件数分

になる1対1チャットの様な更新があまりないであろう物を作る場合確実に設計2を選んだ方がコストは減らせる!

しかし設計2には大きなデメリットがある

つまりデータ溜まってくと自動で消えてくよって事です!
もしこれを回避する方法は

キャッシュの限界容量を無制限にする方法

db.settings = const Settings(
  persistenceEnabled: true,
  cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);

キャッシュ無制限

//キャッシュクリアするコード
 await FirebaseFirestore.instance.clearPersistence();

取得パターン3

申し訳ないですが、次回更新しようと思います、、。
長くなりますので、、。

終わり。

最後に

何か問題や指摘があった際はDMにでもこっそり教えてもらえると嬉しいです。

https://twitter.com/dev__our

Discussion

ログインするとコメントできます