これまでの連載で見てきたように、Firebaseのデータベースは任意のJSONオブジェクトをツリー状に保持できる柔軟なNoSQLです。一般的なリレーショナルデータベースのような厳格なスキーマ定義等は存在せず、自由な発想でデータを格納することができます。
しかしながら、必要なデータを何でも1つのツリーの中に含めてしまうと、思いもよらない無駄な大量のデータ転送やパフォーマンスの低下を招くことがあります。今回は、Firebaseで効率的かつ高速にデータを扱うためのベストプラクティスをご紹介します。
今回は例として以下のようなチャットアプリケーションを想定し、データをどのように持つとより効率的なのかを確認していきたいと思います。
- チャットルームが複数存在する
- チャットのメッセージはルームごとに管理する
- 複数ユーザがおり、ユーザは任意のルームに参加して発言することができる
効率的なデータ構造
データのネストを避ける
FirebaseはJSONのツリーを最大で32階層まで持つことができます。これは一般的なアプリケーションを作るのに十分な深さです。
今回のアプリケーションでは、たとえば以下のようなデータ構造が考えられるでしょう。
まず/chats
という階層を作り、その下に複数のチャットルームが/chats/room01
、/chats/room02
のように存在しています。
チャットルームの下には/chats/$room_id/title
といった形でルームのタイトルが保存され、同様に/chats/$room_id/messages
の下には/chats/$room_id/messages/$message_id
の形式でルームのメッセージ一覧が保持されており、sender
、message
でそれぞれ送信者とメッセージ本文が保存されています。
一見すると何の問題もなさそうなデータ構造ですが、この状態でチャットルーム名一覧を取得しようとした場合を考えてみてください。
「第3回 データの読み出しをマスターする」で解説したように、その場合は/chats
にValueEventListener
をセットしてDataSnapshot#getChildren()
メソッドを使ってループを回しながら取得する方法などが考えられますが、この際にチャットルーム名が必要なだけなのに、チャットの本文を含めた全データがダウンロードされます。
Firebaseはあるパスに存在するデータを取得する際に、その子ノードも含めた全データを取得するからです。したがって、このアプローチは効率的ではありません。
できるだけ階層を浅くする
このため、今回は以下のようにユーザ一覧
、ルーム一覧
、本文一覧
でツリーを分けることにしてみましょう。
これでずいぶん良くなりました。
ユーザ一覧が/users
へ、ルーム一覧が/rooms
へ、本文一覧が/messages
へ分かれたので、それぞれ必要なデータを必要なときに最小限取得するだけで済むようになりました。
ところがこれでもまだ問題があります。もしユーザshiroyama
が自分の参加しているルーム一覧を取得したくなったとしたら、今のままでは/rooms
以下の全ルームをひとつひとつ走査しながら自分がそのルームのメンバーかどうかを確認する必要があります。もしルームが何千と存在すれば、これはとても現実的ではありません。
セキュリティルールはフィルタとしては利用できない
上記の例が良くない理由がもうひとつあります。
前回、Firebaseの特定のパスに対するアクセス制御について解説しました。今回のチャットアプリケーションの例だと、もし「プライベートルームを作り、メンバー以外には参加者を公開しない」というようなよくある仕様が追加された場合に、セキュリティルールの設定次第では、あるルームのメンバーが閲覧できないケースが出てくるでしょう。
Firebaseでは、閲覧しようとしたパスにひとつでもread権限のないデータが含まれた場合、全データの読み出しに失敗します。
今回の場合、もし仮に以下のようなセキュリティルールがあった場合、/rooms/$room_id/members
以下へのアクセスは、member01
だけ成功してmember02
は失敗するのではなく、全部が失敗します。
これは公式ドキュメントにも説明されている仕様通りの挙動なので注意してください。
したがって、ルーム一覧からメンバーを走査するという戦略は採れないということになります。
双方向のリレーションを作成する
これらの状況を踏まえて、以下のようにデータ構造を変更してみましょう。
/users/$user_id/rooms
以下に、自分が所属しているルーム一覧を保持するようになりました。同様に、/rooms/$room_id/members
にもユーザ一覧を保持するようになりました。
こうすることで、ルーム一覧を全行走査しなくてもあるユーザの所属するルームが即座に分かるようになりました。かつ、自分の所属しているルームはread権限があることが保証されるので、Firebaseのセキュリティルール上も適切であることが分かります。
値はいずれもtrue
にしていますが、これは重要ではありません。キーが存在することが重要です。こうすることで、ユーザの側からもルームの側からもお互いを参照することができ、Firebaseで双方向のリレーションが実現できたことがおわかりいただけたと思います。
これは、リレーショナルデータベースにおける外部キーのような役割を果たしていると考えれば理解しやすいと思います。
残念ながら、このようなデータ構造にすることで/users
の下にも/rooms
の下にもいわば重複するデータが存在することになります(非正規化)。したがって、あるユーザがルームから退出する際には、両方のレコードを修正する必要が出てしまいます。
しかしながら、Firebaseの柔軟なデータベースにおいて、このようにあえて非正規化を利用することは、効率性と利便性のバランスをうまく取ったベストプラクティスとして紹介されているので、ぜひ利用してみてください。
データを結合する
さて、ここまで、Firebaseではデータアクセスの効率化のためにできるだけツリーの階層を浅くすることが肝要であることを学びました。また、それぞのツリーで双方向にリレーションを持つためにユニークなキーを利用することを見てきました。
リレーショナルデータベースでは、このように分割したデータ構造を結合する際にJOIN構文などを利用しますが、Firebaseではどのようにすれば良いのでしょう?
結合から言うと、Firebaseにはリレーショナルデータベースで言うところのJOIN構文は用意されていません。そういった場合には単純にリスナをネストします。
まずは以下の例を見てください。
1~2行目で/messages
と/users
のリファレンスをそれぞれ取得し、3行目で/messages/room01
に対してValueEventListener
をセットして6~7行目でメッセージ一覧を取得しています。ここまでは第3回の内容を参考にしていただければ問題ないと思います。
次に、8行目、つまり/messages/room01
のループの中でuserRef.child(message.sender)
に対して更にValueEventListener
をセットして、ユーザ情報を取得しています。最終的に11~14行目でユーザ情報の取得を待ち合わせた上で、すべてをログ出力しています。
リレーショナルデータベースに慣れていると、これはいわゆるn+1問題
でパフォーマンスが心配になるかもしれませんが、Firebaseには強力なキャッシュ機能等があり、このような使われ方は問題ないという想定のようです(参考ブログ)。
結局のところ、FirebaseはNoSQLなので、これまでの常識をいったん忘れ、郷に入っては郷に従う精神が大切なのかもしれません。
なお、注意点として、本例でメッセージ一覧を取得する処理とユーザ情報を取得する処理は、それぞれ別スレッドで実行されます。
結果的に、11~14行目でメッセージ情報にユーザ情報をマージした時点では、メッセージの到着順の情報は失われています。これは別途順番を覚えておくなり、順番を並び替えたりする必要がありますが、この辺りは今後の連載の実践テクニックでご紹介できればと思います。
データにIndexを作成する
連載第3回の「データのクエリ」で確認したように、Firebaseは柔軟で強力なクエリ機能を備えていますが、アプリケーションで利用するクエリがあらかじめわかっている場合は、.indexOn
ルールを使ってデータにIndexを作成することで、クエリのパフォーマンスを向上させることができます。
たとえば、今回のチャットアプリケーションで、「ルーム一覧を表示する際に、ルーム内チャットの最終発言時間が古い順/新しい順に並べ変える機能を付けたい」という仕様が追加されたとします。
/rooms/$room_id
以下にtimestamp
情報を追加しましょう。
このtimestamp
のIndexを作成するには、ダッシュボードの「Security & Rules(前回の連載参照)」を以下のように編集します。
こうすることでtimestamp
にIndexが作成され、読み出しが高速になります。
読み出しのコードは以下のようになります。
/rooms
へのリファレンスを作成し、orderByChild("timestamp")
で並び替えているだけです。こちらも、第3回を参考にしていただければ問題ないと思います。ルーム一覧がtimestamp
の昇順(つまり古い順)に並べば成功です。
なお、Indexを作成するフィールドを増やしたい場合は、".indexOn": ["timestamp", "title"]
のようにカンマ区切りで追加するだけです。
デフォルトで作成されるIndex
Indexは、ノードのキーとプライオリティ(優先度)には自動的に作成され、ユーザが明示的にルールを設定する必要はありません。
今回の例では/rooms/$room_id
の部分はキーなので、特に何もしなくても高速に読み出すことができます。
プライオリティ(優先度)
第3回で少し触れましたが、Firebaseではデータ保存時にプライオリティ(優先度)を設定することができます。
Firebaseではデータ作成時に、任意のプライオリティ(優先度)を設定できます。標準の機能では「降順のソート」ができないという制限があるので、ワークアラウンドとしてプライオリティを設定することがよくあります。
今回の例では、ルーム一覧を降順(つまり更新の新しい順)にも並べたいというものでした。なので、プライオリティにtimestamp
を符号反転させたものを設定してみましょう。こうすることでtimestamp
を降順に並べることが可能です。
詳細は割愛しますが、room01
の情報を保存するロジックで、同じパスにsetPriority()
してtimestamp
の符号をマイナスにしたものをセットしているだけです。
読み出すコードは以下のとおりです。
ValueEventListener
の中身は昇順読み出しとまったく同様です。ルーム一覧が降順に並べば成功です。
まとめ
いかがだったでしょうか。
今回はFirebaseのデータベース構造を効率的に設計し、かつIndexを設定することで高速に読み出しが可能なことを確認しました。また、リスナをネストして分割したデータを結合する例や、プライオリティを利用して降順に並び替える例なども見てまいりました。
今回までで、Firebaseのリアルタイムデータベースに関する解説はすべて完了しました。アイディア次第で素晴らしいアプリが作れることと思います!
さて、つい先日行われたGoogle I/O 2016でFirebaseに大々的な更新が発表され、大幅に機能が追加されました。次回の連載では、Firebaseに新たに追加された機能を紹介しつつ、これまでの連載で解説した機能の変更点などを見て行きたいと思います。どうぞお楽しみに。