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

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

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

データにIndexを作成する

連載第3回の「データのクエリ」で確認したように、Firebaseは柔軟で強力なクエリ機能を備えていますが、アプリケーションで利用するクエリがあらかじめわかっている場合は、.indexOnルールを使ってデータにIndexを作成することで、クエリのパフォーマンスを向上させることができます。

たとえば、今回のチャットアプリケーションで、⁠ルーム一覧を表示する際に、ルーム内チャットの最終発言時間が古い順/新しい順に並べ変える機能を付けたい」という仕様が追加されたとします。

/rooms/$room_id以下にtimestamp情報を追加しましょう。

{
  "rooms": {
    "room01": {
      "title": "チャットルーム01",
      "members": {
        "shiroyama": true,
        "tanaka": true
      },
      "timestamp": 1464511789
    },
    "room02": { ... }
  }
}

このtimestampのIndexを作成するには、ダッシュボードの「Security & Rules(前回の連載参照)を以下のように編集します。

{
  "rules": {
    "rooms": {
      "$room_id": {
        ".indexOn": ["timestamp"]
      }
    }
  }
}

こうすることでtimestampにIndexが作成され、読み出しが高速になります。

読み出しのコードは以下のようになります。

Firebase roomRef = ref.child("rooms");
roomRef.orderByChild("timestamp").addListenerForSingleValueEvent(new ValueEventListener() {
    @Override
    public void onDataChange(DataSnapshot dataSnapshot) {
        for (DataSnapshot snapshot : dataSnapshot.getChildren()) {
            Room room = snapshot.getValue(Room.class);
            Log.d(TAG, "title: " + room.title);
            Log.d(TAG, "timestamp: " + room.timestamp);
        }
    }

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

/roomsへのリファレンスを作成し、orderByChild("timestamp")で並び替えているだけです。こちらも、第3回を参考にしていただければ問題ないと思います。ルーム一覧がtimestampの昇順(つまり古い順)に並べば成功です。

なお、Indexを作成するフィールドを増やしたい場合は、".indexOn": ["timestamp", "title"]のようにカンマ区切りで追加するだけです。

デフォルトで作成されるIndex

Indexは、ノードのキーとプライオリティ(優先度)には自動的に作成され、ユーザが明示的にルールを設定する必要はありません。

今回の例では/rooms/$room_idの部分はキーなので、特に何もしなくても高速に読み出すことができます。

プライオリティ(優先度)

第3回で少し触れましたが、Firebaseではデータ保存時にプライオリティ(優先度)を設定することができます。

Firebaseではデータ作成時に、任意のプライオリティ(優先度)を設定できます。標準の機能では「降順のソート」ができないという制限があるので、ワークアラウンドとしてプライオリティを設定することがよくあります。

今回の例では、ルーム一覧を降順(つまり更新の新しい順)にも並べたいというものでした。なので、プライオリティにtimestamp符号反転させたものを設定してみましょう。こうすることでtimestampを降順に並べることが可能です。

Firebase roomRef = ref.child("rooms");
roomRef.child("room01").setValue(room01);
roomRef.child("room01").setPriority(-room01.timestamp);

詳細は割愛しますが、room01の情報を保存するロジックで、同じパスにsetPriority()してtimestampの符号をマイナスにしたものをセットしているだけです。

読み出すコードは以下のとおりです。

Firebase roomRef = ref.child("rooms");
roomRef.orderByPriority().addListenerForSingleValueEvent(new ValueEventListener() { // 略 }

ValueEventListenerの中身は昇順読み出しとまったく同様です。ルーム一覧が降順に並べば成功です。

まとめ

いかがだったでしょうか。

今回はFirebaseのデータベース構造を効率的に設計し、かつIndexを設定することで高速に読み出しが可能なことを確認しました。また、リスナをネストして分割したデータを結合する例や、プライオリティを利用して降順に並び替える例なども見てまいりました。

今回までで、Firebaseのリアルタイムデータベースに関する解説はすべて完了しました。アイディア次第で素晴らしいアプリが作れることと思います!

さて、つい先日行われたGoogle I/O 2016でFirebaseに大々的な更新が発表され、大幅に機能が追加されました。次回の連載では、Firebaseに新たに追加された機能を紹介しつつ、これまでの連載で解説した機能の変更点などを見て行きたいと思います。どうぞお楽しみに。

おすすめ記事

記事・ニュース一覧