のんびり精進

調べた情報などをおすそ分けできれば。

Cloud Firestoreを用いてリアルタイムチャットに挑戦

モバイルアプリのクライアント側では何度も利用経験があり、タイトルの「初挑戦」は設計面のことです。

関連記事:
kabochapo.hateblo.jp

昨年末に上記教材(公式の動画やドキュメント)で学び、年明けに初めて設計から取り組みました。

チャットアプリ画像

Vue.js の動画に出てきたリアルチャットアプリ(Firebase Realtime Database 利用)を拡張しつつ Firestore に置き換えたものです。
元はチャットルームの機能もユーザ制限もなく、送信後の絵文字の自動付加もありませんでした。
そのあたりが自分で拡張した部分です。

工夫したこと、苦労したこと

リアルタイム更新

過去メッセージの編集や削除の機能は無し。
リアルタイムな取得は新メッセージだけなので、直近 N 件を get() してから、それより後をクエリカーソル指定して onSnapshot() で listen することにしました。

Read の抑制

メッセージごとの document を大量取得すると費用が多くなるのでパジネーションを入れました。

パジネーション画像

もし過去メッセージもリアルタイム更新するとしたらパジネーションとの組み合わせが大変そうです。
ページごとに listen し、ページ内のメッセージがなくなったら listener を破棄する必要があるかと思います。

クエリカーソル

startAfter() が機能しなくて悩みました。
エラーが出ず、startAfter() なしでクエリしたときと同じ結果が返ってきていました。

原因は QueryDocumentSnapshot ではなく DocumentReference を渡してしまっていたという凡ミスです。
間違っているなら警告してほしいです…。

Firestore のメソッドで返ってきたものが何を表しているのか理解せずに使っていたのも良くなかったです。
  ↓
後で調べて記事にしたので、理解があやふやな方は見てみてください。
qiita.com

チャットルームを利用できるユーザの制限

ルームの document の下に subcollection を作り、その中の document に members という配列フィールドを持たせて、ルームの参加メンバーの ユーザ ID を入れるようにしました。
これは次の理由によります。

  • クライアントで取得するメッセージデータに参加者データを含めないため
    • 参加者データはアクセスを制限するためのものであり、ルームの document には不要です
  • 参加者データへのアクセスをセキュリティルールで制限しやすくするため
    • (結局やりにくかったです…)
rooms
  ├ xxxxxx
  │  ├ messages
  │  │  ├ xxxxxx
  │  │  └ xxxxxx
  │  └ private
  │      └ allowed - members [xxxxxxxx, xxxxxxxx]  ← これ
  └ xxxxxx
      ├ messages
      │  ├ xxxxxx
      │  └ xxxxxx
      └ private
          └ allowed - members [xxxxxxxx, xxxxxxxx]  ←

下記のように subcollection の document を参加ユーザ単位にする(document の ID をユーザ ID にする)ことも考えましたが、document の読み取りが増えるのでやめました。
また、document 内に持たせるデータが特にないので配列のほうがいいなと思いました。

rooms
  ├ xxxxxx
  │  ├ messages
  │  │  ├ xxxxxx
  │  │  └ xxxxxx
  │  └ private
  │      ├ xxxxxxxx  ← これ
  │      └ xxxxxxxx  ←
  └ xxxxxx
      ├ messages
      │  ├ xxxxxx
      │  └ xxxxxx
      └ private
          ├ xxxxxxxx  ←
          └ xxxxxxxx  ←

配列にしたことでセキュリティルールに苦労した(後述)ので、楽なのは後者のほうかもしれません。

Collection group

ユーザ ID は複数の subcollection からまとめて取得するので、colletion group を使いました。

動画等の説明によると、collection group のインデックス設定ができていないままクエリを実行するとブラウザのコンソールに設定用の URL が出てくるとのことでしたが、なぜか出なかったので Firebase コンソール上で自分で設定しました。

クライアント側

subcollection 内のユーザ ID の有無によって親 document を取得できると思っていたのですが、subcollection のクエリで取得できるのは subcollection の document だけだとわかりました。

stackoverflow.com

それを取得してから subcollectionDoc.ref.parent.parent.get() と遡って取得しないといけません。
子と親の両方の document を取得することになるので、費用に関わる Read のカウントが増えます。

セキュリティルール

collection group 用のセキュリティルールが必要なのを忘れていて、しばらく取得できずに悩みました。
設定画面にある playground で get のシミュレーションをしても正常でした。
問題があったのは collection group の場合だけだったのです。
playground では collection group のクエリのシミュレーションはできないようで、不便に思いました。

直した最終的なルールは下記です。

match /{path=**}/private/{document} {
  allow read: if request.auth.uid in resource.data.members;
}

これの前のルールは次のようにしていました。

match /{path=**}/private/{document} {
  allow read: if request.auth.uid
    in get(/databases/$(database)/documents/$(path)/private/$(document)).data.members;
}

わざわざ get() する(document を一回読み取ったことになる)のは無駄があります。
ルールの get()exists() はヒットしなくても課金対象になるようです。

stackoverflow.com

無駄ではあっても間違ってはいないと思っていましたが、実際には無駄+間違いでした。
先ほどのクエリで「Uncaught (in promise) FirebaseError: Missing or insufficient permissions.」というエラーが出ました。

どうしても理由がわからないので Stack Overflow で質問をして原因が判明しました。
記事が長くならないよう省略しますので、SO の回答で確認してください。

stackoverflow.com

もう一つ悩んだのが、private 内には allowed だけが必ず存在するのに

match /{path=**}/private/allowed {

のようにワイルドカードを使わずに直に指定するとデータを取得できなくなったことです。
これも Stack Overflow で質問したところ、公式動画シリーズの Doug さんから回答をもらえました。

stackoverflow.com

試していませんが、collection group のルールで特定の document ID を使えないなら、その ID は自動附番で良い気がします。

セキュリティルールの質

セキュリティルールは正しく設定すれば守ってくれて心強いもののはずです。
しかし、ちょっとしたミスでアプリが機能しなくなるのを経験して怖くなりました。
安全のためのルールが効いていなくてザルになることもありそうです。

上記のようなことを防ぐためには、ルールのテストが必須に思えました。
まだそう思えただけであり、実際のテストは書いていません。
下記公式ブログ記事にテストやレビューのことが書かれているので参考にしたいと思います。

firebase.googleblog.com

subcollection に分けるもの

上のブログ記事に書かれていますが、少なくとも個人情報は分ける必要がありそうです。
あとは、分けないとクライアントで取得する document データに含まれてしまって無駄に多くのデータを取得することになってしまうケースかなと思いました。

先ほどのアクセス可能なユーザの ID は、漏れてはならない個人情報でもないかもしれません。
分けたことで取得に手間がかかり、セキュリティルールでも混乱したので、分けずにルームの document に含めたほうが良かった気がしています。

Cloud Functions

Cloud Functions も使えるようになっておかなければ、と思って使ってみました。
冒頭の GIF アニメに見えるように、新たなメッセージが送信されたときに末尾に「🥺ぴえん」を自動的に付ける Function を作ってみました。
シンプルな機能なので何も難しいところはなく、あっさりとできました。

なお、Cloud Functions の料金は Container storage のみは無料枠がないようです。
使い始める前に従量課金のプランである Blaze プランへのアップグレードと請求先の設定を求められましたので、少しでも使うと支払いが生じるのだろうと思います。

さいごに

いろいろとハマりどころが多くて大変でした。

Flutter の開発者の多くが Firestore を使っていて、簡単だという話ばかり聞こえてきていました。
短期間にアプリを量産してリリースしている人も目にします。

実際に学んで使ってみるとそれほど簡単ではなく、DB 構造などの工夫が必要で、注意点も多かったです。
それなのになぜ皆簡単に感じながら使えているのか…。
あまりドキュメントも読まずに使い始めて何となく使っている人が多いのかも、と思いました。

もしセキュリティルールを「安全でないルールがあります」の警告メールが来ない程度にいじっただけのアプリを公開すると、悪意のある人が他人のデータを閲覧できたりしてしまうのでしょうか…。
そんなアプリが世の中に溢れないことを願います。