スマホアプリ開発を加速する,Firebaseを使ってみよう

第6回 Firebaseデータベースの効率的なデータ構造と高速化のポイント

この記事を読むのに必要な時間:およそ 6 分

これまでの連載で見てきたように,Firebaseのデータベースは任意のJSONオブジェクトをツリー状に保持できる柔軟なNoSQLです。一般的なリレーショナルデータベースのような厳格なスキーマ定義等は存在せず,自由な発想でデータを格納することができます。

しかしながら,必要なデータを何でも1つのツリーの中に含めてしまうと,思いもよらない無駄な大量のデータ転送やパフォーマンスの低下を招くことがあります。今回は,Firebaseで効率的かつ高速にデータを扱うためのベストプラクティスをご紹介します。

今回は例として以下のようなチャットアプリケーションを想定し,データをどのように持つとより効率的なのかを確認していきたいと思います。

  • チャットルームが複数存在する
  • チャットのメッセージはルームごとに管理する
  • 複数ユーザがおり,ユーザは任意のルームに参加して発言することができる

効率的なデータ構造

データのネストを避ける

FirebaseはJSONのツリーを最大で32階層まで持つことができます。これは一般的なアプリケーションを作るのに十分な深さです。

今回のアプリケーションでは,たとえば以下のようなデータ構造が考えられるでしょう。

{
  "chats": {
    "room01": {
      "title": "チャットルーム01",
      "messages": {
        "message01": {
          "sender": "Fumihiko Shiroyama",
          "message": "こんにちは。誰かいますか?"
        },
        "message02": { ... },
        "message03": { ... }
      }
    },
    "room02": { ... }
  }
}

まず/chatsという階層を作り,その下に複数のチャットルームが/chats/room01/chats/room02のように存在しています。

チャットルームの下には/chats/$room_id/titleといった形でルームのタイトルが保存され,同様に/chats/$room_id/messagesの下には/chats/$room_id/messages/$message_idの形式でルームのメッセージ一覧が保持されており,sendermessageでそれぞれ送信者とメッセージ本文が保存されています。

一見すると何の問題もなさそうなデータ構造ですが,この状態でチャットルーム名一覧を取得しようとした場合を考えてみてください。

「第3回 データの読み出しをマスターする」で解説したように,その場合は/chatsValueEventListenerをセットしてDataSnapshot#getChildren()メソッドを使ってループを回しながら取得する方法などが考えられますが,この際にチャットルーム名が必要なだけなのに,チャットの本文を含めた全データがダウンロードされます

Firebaseはあるパスに存在するデータを取得する際に,その子ノードも含めた全データを取得するからです。したがって,このアプローチは効率的ではありません。

できるだけ階層を浅くする

このため,今回は以下のようにユーザ一覧ルーム一覧本文一覧でツリーを分けることにしてみましょう。

{
  "users": {
    "shiroyama": { "name": "Fumihiko Shiroyama" },
    "tanaka": { ... },
    "sato": { ... }
  },
  "rooms": {
    "room01": {
      "title": "チャットルーム01",
      "members": {
        "member01": "shiroyama",
        "member02": "tanaka"
      }
    },
    "room02": { ... }
  },
  "messages": {
    "room01": {
      "message01": {
        "sender": "shiroyama",
        "message": "こんにちは。誰かいますか?"
      },
      "message02": {
        "sender": "tanaka",
        "message": "はい,いますよ。"
      },
      "message03": { ... }
    },
    "room02": { ... }
  }
}

これでずいぶん良くなりました。

ユーザ一覧が/usersへ,ルーム一覧が/roomsへ,本文一覧が/messagesへ分かれたので,それぞれ必要なデータを必要なときに最小限取得するだけで済むようになりました。

ところがこれでもまだ問題があります。もしユーザshiroyamaが自分の参加しているルーム一覧を取得したくなったとしたら,今のままでは/rooms以下の全ルームをひとつひとつ走査しながら自分がそのルームのメンバーかどうかを確認する必要があります。もしルームが何千と存在すれば,これはとても現実的ではありません。

セキュリティルールはフィルタとしては利用できない

上記の例が良くない理由がもうひとつあります。

前回Firebaseの特定のパスに対するアクセス制御について解説しました。今回のチャットアプリケーションの例だと,もし「プライベートルームを作り,メンバー以外には参加者を公開しない」というようなよくある仕様が追加された場合に,セキュリティルールの設定次第では,あるルームのメンバーが閲覧できないケースが出てくるでしょう。

Firebaseでは,閲覧しようとしたパスにひとつでもread権限のないデータが含まれた場合,全データの読み出しに失敗します。

今回の場合,もし仮に以下のようなセキュリティルールがあった場合,/rooms/$room_id/members以下へのアクセスは,member01だけ成功してmember02は失敗するのではなく,全部が失敗します。

{
  "rules": {
    "rooms": {
      "$room_id": {
        "members": {
          "member01": {
            ".read": true
          },
          "member02": {
            ".read": false
          }
        }
      }
    }
  }
}

これは公式ドキュメントにも説明されている仕様通りの挙動なので注意してください。

したがって,ルーム一覧からメンバーを走査するという戦略は採れないということになります。

双方向のリレーションを作成する

これらの状況を踏まえて,以下のようにデータ構造を変更してみましょう。

{
  "users": {
    "shiroyama": {
      "name": "Fumihiko Shiroyama",
      "rooms": {
        "room01": true,
        "room02": true
      }
    },
    "tanaka": { ... },
    "sato": { ... }
  },
  "rooms": {
    "room01": {
      "title": "チャットルーム01",
      "members": {
        "shiroyama": true,
        "tanaka": true
      }
    },
    "room02": { ... }
  },
  "messages": {
    "room01": {
      "message01": {
        "sender": "shiroyama",
        "message": "こんにちは。誰かいますか?"
      },
      "message02": {
        "sender": "tanaka",
        "message": "はい,いますよ。"
      },
      "message03": { ... }
    },
    "room02": { ... }
  }
}

/users/$user_id/rooms以下に,自分が所属しているルーム一覧を保持するようになりました。同様に,/rooms/$room_id/membersにもユーザ一覧を保持するようになりました。

こうすることで,ルーム一覧を全行走査しなくてもあるユーザの所属するルームが即座に分かるようになりました。かつ,自分の所属しているルームはread権限があることが保証されるので,Firebaseのセキュリティルール上も適切であることが分かります。

値はいずれもtrueにしていますが,これは重要ではありません。キーが存在することが重要です。こうすることで,ユーザの側からもルームの側からもお互いを参照することができ,Firebaseで双方向のリレーションが実現できたことがおわかりいただけたと思います。

これは,リレーショナルデータベースにおける外部キーのような役割を果たしていると考えれば理解しやすいと思います。

残念ながら,このようなデータ構造にすることで/usersの下にも/roomsの下にもいわば重複するデータが存在することになります(非正規化)⁠したがって,あるユーザがルームから退出する際には,両方のレコードを修正する必要が出てしまいます。

しかしながら,Firebaseの柔軟なデータベースにおいて,このようにあえて非正規化を利用することは,効率性と利便性のバランスをうまく取ったベストプラクティスとして紹介されているので,ぜひ利用してみてください。

データを結合する

さて,ここまで,Firebaseではデータアクセスの効率化のためにできるだけツリーの階層を浅くすることが肝要であることを学びました。また,それぞのツリーで双方向にリレーションを持つためにユニークなキーを利用することを見てきました。

リレーショナルデータベースでは,このように分割したデータ構造を結合する際にJOIN構文などを利用しますが,Firebaseではどのようにすれば良いのでしょう?

結合から言うと,Firebaseにはリレーショナルデータベースで言うところのJOIN構文は用意されていません。そういった場合には単純にリスナをネストします。

まずは以下の例を見てください。

final Firebase messageRef = ref.child("messages");
final Firebase userRef = ref.child("users");
messageRef.child("room01").addListenerForSingleValueEvent(new ValueEventListener() {
    @Override
    public void onDataChange(DataSnapshot dataSnapshot) {
        for (DataSnapshot snapshot : dataSnapshot.getChildren()) {
            final Message message = snapshot.getValue(Message.class);
            userRef.child(message.sender).addListenerForSingleValueEvent(new ValueEventListener() {
                @Override
                public void onDataChange(DataSnapshot dataSnapshot) {
                    Log.d(TAG, "message: " + message.message);
                    Log.d(TAG, "sender: " + message.sender);
                    User user = dataSnapshot.getValue(User.class);
                    Log.d(TAG, "sender's full name: " + user.name);
                }

                @Override
                public void onCancelled(FirebaseError firebaseError) {
                    Log.e(TAG, firebaseError.getMessage(), firebaseError.toException());
                }
            });
        }
    }

    @Override
    public void onCancelled(FirebaseError firebaseError) {
        Log.e(TAG, firebaseError.getMessage(), firebaseError.toException());
    }
});

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行目でメッセージ情報にユーザ情報をマージした時点では,メッセージの到着順の情報は失われています。これは別途順番を覚えておくなり,順番を並び替えたりする必要がありますが,この辺りは今後の連載の実践テクニックでご紹介できればと思います。

著者プロフィール

白山文彦(しろやまふみひこ)

サーバサイド,インフラ,Androidなど何でもやるプログラマ。

コメント

コメントの記入