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

第3回データの読み出しをマスターする

前回の連載では、Firebaseに簡単なデータを保存し、それを実際にAndroidクライアントから読み出してみました。今回は、より詳細なデータの取得方法と、取得するデータのソート、取得するデータ数のリミットや範囲といった複雑なクエリについて解説します。

今回は例として、リアルタイムチャットアプリケーションを作成すると想定して以下の様なデータを用意し、さまざまな方法で読み出してみたいと思います。

  • /messages以下に、チャットのメッセージ情報を{"sender":"送信者", "body":"本文"}の一覧形式で格納
  • /counts以下に、どのユーザが何通メッセージを受信したかという情報を{"ユーザ名":数}という形式で格納

イベントとリスナ

前回「イベント駆動型プログラミング」の節でご説明したように、Firebaseではデータが欲しいタイミングでプログラマが能動的に取りに行く「Pull型」ではなく、データが追加されたり変更されたタイミングで通知される「Push型」でデータを取得します。

このような「データが追加された」「データが変更された」といった契機のことをイベントと呼びます。

プログラマは、コードブロックに「受け取ったデータをどのように扱いたいか」を記述しておき、その処理を実行して欲しいイベントに関連付けます。このコードブロックはリスナと呼ばれ、イベントにコードブロックを紐付けることを「リスナを登録する」と表現します。

今回作成するリアルタイムチャットアプリケーションでは、⁠新着メッセージが到着した」というのがイベントで、⁠新着メッセージを受け取ったらメッセージ一覧に追加し、画面に新着メッセージを表示する」という処理がリスナです。

イベントが発生するたびにリスナに書いてある処理は実行され、ユーザがわざわざ画面をリロードしなくても「新着メッセージが到着するたびにメッセージを表示する」といったリアルタイムなアプリケーションを開発することがFirebaseでは可能なのです。

Firebaseのイベント一覧

Firebaseでは処理開始の契機として利用できるイベントが全部で5つあります。

イベントタイミング
Value イベントデータの新規追加/変更のタイミングで呼び出される
Child Added イベントリストのようなデータ構造で、子要素が新規追加されたタイミングで呼び出される
Child Changed イベントリストのようなデータ構造で、子要素が変更されたタイミングで呼び出される
Child Removed イベントリストのようなデータ構造で、子要素が削除されたタイミングで呼び出される
Child Moved イベントリストのようなデータ構造で、子要素が移動されたタイミングで呼び出される

それぞれについて詳しく解説していきます。

サンプルデータ

今回作成するリアルタイムチャットアプリケーションのために、Webコンソールを使って/messagesに以下のようなデータを用意してください。

{
  "messages" : {
    "01" : {
      "body" : "Hi, there!",
      "sender" : "John"
    },
    "02" : {
      "body" : "What's up?",
      "sender" : "Steve"
    },
    "03" : {
      "body" : "hey hey hey!",
      "sender" : "Bill"
    }
  }
}

Webコンソールの使い方は前回の連載を参考にしてください。

Valueイベント

Valueイベントは、特定のURI以下にデータが新規に追加されたり、変更があった場合に呼び出されます。

今回のサンプルデータの例で言うと、https://<YOUR-FIREBASE-APP>.firebaseio.com/messages以下に新規メッセージが追加されたり、メッセージが変更された際に処理を実行したい場合、/messagesのValueイベントに対してリスナを登録しておけば良いということになります。

AndroidクライアントでValueイベントに対応するリスナは、ValueEventListenerインタフェースの実装クラスを使って作成します。

データの取得に成功すればonDataChange(DataSnapshot snapshot)が呼ばれ、何らかの理由で失敗すればonCancelled(FirebaseError error)が呼ばれます。

さっそくコードで見ていきましょう。

リスト1 Valueイベントによるデータ取得の実装例
Firebase ref = new Firebase("https://<YOUR-FIREBASE-APP>.firebaseio.com/messages");

ref.addValueEventListener(new ValueEventListener() {
    @Override
    public void onDataChange(DataSnapshot snapshot) {
        for (DataSnapshot dataSnapshot : snapshot.getChildren()) {
            String sender = (String) dataSnapshot.child("sender").getValue();
            String body = (String) dataSnapshot.child("body").getValue();
            Log.d("Firebase", String.format("sender:%s, body:%s", sender, body));
        }
    }

    @Override
    public void onCancelled(FirebaseError error) {
    }
});

まずはこの通りコードを書いて実行してみてください。ログに以下のように出力されれば成功です。

D/Firebase: sender:John, body:Hi, there!
D/Firebase: sender:Steve, body:What's up?
D/Firebase: sender:Bill, body:hey hey hey!

では、リスト1の各行の意味を順を追って見ていきましょう。

DataSnapshot

リスト1の5行目で、データ取得成功時にonDataChangeの引数として渡されるDataSnapshotは、文字通り「あるURIのある瞬間のデータのスナップショット」です。つまり、その瞬間Firebaseに存在するデータを意味します。

DataSnapshot自体はデータの入れ物であり、データそのものを取り出すにはgetValue()メソッドを使います。FirebaseはスキーマレスなJSONオブジェクトで任意の値を保持できるので、取り出す値はもちろんJSONオブジェクトで表現できる任意の型です。したがって利用者が適宜適切な型にキャストして利用しなければなりません。その時点で指定したURIに何のデータも存在しない場合はnullが返されます。

また、DataSnapshotが配列のようなデータ構造(コラム参照)をしている場合、getChildren()メソッドで各要素をコレクションとして取り出すことができます。

今回のサンプルデータでは、/messagesから取得したDataSnapshotに対してgetChildren()すると

{
  "body" : "Hi, there!",
  "sender" : "John"
},
{
  "body" : "What's up?",
  "sender" : "Steve"
},
{
  "body" : "hey hey hey!",
  "sender" : "Bill"
}

これらが順番に取得できます。

さらに、DataSnapshotがオブジェクトの場合は、child("キー名")メソッドでキーに対応する値が取得できます。リスト1の7行目でdataSnapshot.child("sender").getValue()senderに対応する値を取り出し、(String)で文字列型に変換して送信者名を取り出しています。8行目のbodyについても同様です。最後にログ出力して完成です。

型安全なデータの読み出し

リスト1の7、8行目でchild()メソッドを使って値を取り出した部分は、対応するエンティティクラスを用意することで型安全に取り出すことも可能です。

以下のコードを、

String sender = (String) dataSnapshot.child("sender").getValue();
String body = (String) dataSnapshot.child("body").getValue();

以下のように修正してみてください。

まず、チャット本文を表現するChatMessageというエンティティクラスを新規に定義します。

public class ChatMessage {
    public String body;
    public String sender;
}

次に、リスト1の7、8行目を以下のようにdataSnapshot.getValue(ChatMessage.class)に書き換えます。

ChatMessage chatMessage = dataSnapshot.getValue(ChatMessage.class);
String sender = chatMessage.sender;
String body = chatMessage.body;

この方がコードの見通しもよく、取り回しもしやすくなったことがわかると思います。

Valueイベントの呼び出しタイミングと注意点

Valueイベントは初回アクセス時に一度呼び出され、対応するURI以下のデータをすべて取得します。それに加え、URI以下のデータが変更される度に毎回呼ばれますが、その都度毎回URI以下の全データを取得し直します。

単純な値を取り出す場合や、データを1回だけ取得できれば良い場合はValueイベントでも問題ありませんが、チャットメッセージ一覧のようなデータ構造の場合、新着メッセージがひとつ届くだけで過去のメッセージも含めてすべてのデータ取り直すことになり、大変非効率です。そういった場合には、次にご紹介するChild Addedイベントを利用すると良いでしょう。

Child Addedイベント

Child Addedイベントは、配列のようなデータ構造に要素が追加されるごとに呼び出されます。

Valueイベントが指定したURI以下の全データを毎回取り直すのに対し、Child Addedイベントは追加された要素だけをDataSnapshotとして受け取るので、チャットメッセージ一覧のようなケースにうってつけです。

Child Addedイベントは、ChildEventListenerインタフェースのonChildAdded(DataSnapshot snapshot, String previousChildKey)をオーバーライドすることで受信することができます。

onChildAddedの第一引数のDataSnapshotは、追加された要素そのものになります。

Firebase ref = new Firebase("https://<YOUR-FIREBASE-APP>.firebaseio.com/messages");

ref.addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String previousKey) {
        ChatMessage chatMessage = dataSnapshot.getValue(ChatMessage.class);
        String sender = chatMessage.sender;
        String body = chatMessage.body;
        Log.d("Firebase", String.format("onChildAdded, sender:%s, body:%s", sender, body));
    }
    ...
});

このように、DataSnapshotの中身がチャットメッセージの各要素だけになるので分かりやすく、また、新規メッセージが追加されるごとに全データを取り直したりしないので効率的です。

onChildAddedの第二引数のpreviousKeyは、その要素が全体の何番目かを知るのに利用できるキー(サンプルデータの場合は、"01", "02", "03" などの通し番号)が渡されますが、これの利用方法は後の連載で予定している実践的なテクニックの中でご紹介できればと思います。

最後に、Child Addedイベントにリスナを登録した場合、初回アクセス時に要素数分だけonChildAddedが呼ばれ、以後新規に要素が追加されるたびに呼ばれます。

Child Changedイベント

Child Changedイベントは、配列のようなデータ構造で、要素に更新があるごとに呼び出されます。

Child Changedイベントは、ChildEventListenerインタフェースのonChildChanged(DataSnapshot snapshot, String previousChildKey)をオーバーライドすることで受信することができます。

onChildChangedの第一引数のDataSnapshotは、更新された要素そのものになります。

ソースコードは、Child Addedのサンプルコードの5行目がonChildChanged(DataSnapshot dataSnapshot, String previousKey)になる以外はまったく同じです。

実装後、WebコンソールからsenderJohnからJackに変えてみてください。

ログにJackの情報だけが出力されれば成功です。

D/Firebase: onChildChanged, sender:Jack, body:Hi, there!

Child Removedイベント

Child Removedイベントは、配列のようなデータ構造で、要素が削除されるごとに呼び出されます。

Child Removedイベントは、ChildEventListenerインタフェースのonChildRemoved(DataSnapshot snapshot)をオーバーライドすることで受信することができます。

onChildRemovedの引数のDataSnapshotは、削除された要素そのものになります。

こちらもソースコードは、Child Addedのサンプルコードの5行目がonChildRemoved(DataSnapshot dataSnapshot)になる以外はまったく同じです。

実装後、WebコンソールからsenderJackのチャットメッセージを削除してみてください。

以下のように削除されたJackのメッセージがログに出力されれば成功です。

D/Firebase: onChildRemoved, sender:Jack, body:Hi, there!

Child Movedイベント

Child Movedイベントは、配列のようなデータ構造で、要素が移動されるごとに呼び出されます。

Child Movedイベントは、ChildEventListenerインタフェースのonChildMoved(DataSnapshot snapshot, String previousKeyをオーバーライドすることで受信することができます。

onChildMovedの第一引数のDataSnapshotは、移動された要素そのものになります。

こちらもソースコードは、Child Addedのサンプルコードの5行目がonChildMoved(DataSnapshot dataSnapshot, String previousKey)になる以外はまったく同じです。

Childe Movedイベントは、ソート済みデータの一部に変更があり、順番の並び替えが発生した場合に呼び出されます。データのソートに関しては本連載の後半について解説します。

イベントに関する保証

Firebaseではイベントの通知順について以下の保証があります。

  1. 必ずデータベースのローカルコピーに最初に状態を反映する
  2. 一時的にローカルコピーとリモートのデータベースの状態が食い違ったとしても、最終的にきちんと同期される
  3. ローカルコピーの変更はリモートに書き込まれ、完了後に全クライアントにブロードキャストされる
  4. Valueイベントは必ず最後に呼び出され、その引数で渡されるDataSnapshotにはこれまでの変更がすべて含まれていることが保証される

Firebaseではデータベースのローカルコピーをクライアント側に持つため、オフライン状態でも問題なくデータの読み書きができることを連載第1回で述べました。

このため、まずはローカルにデータが書き込まれ、それから中央のデータベースに変更が反映されます。その後、同じデータベースを参照している各クライアントに変更が通知され、最終的にはすべての環境で同じ状態に収束します。

また、先ほどの/messagesの例のように、ひとつのURIに複数のリスナを登録することができますが、その場合でもValueイベントは必ず一番最後に呼び出され、一連の変更がすべて反映されたDataSnapshotが入っていることが保証されています。あまり同一URIに複数のリスナを登録するケースは多くはないかも知れませんが、頭の片隅に覚えておくと良いかもしれません。

リスナの登録解除

登録したリスナは、ref.removeEventListener(originalListener)で解除することができます。

もし複数のリスナを登録している場合は、不要になった際に登録した分だけ解除してください。リスナを適切に解除しないと、意図しないイベントコールバックが発生したり、メモリリークにつながる恐れがあります。

一点注意点として、上位のURIでリスナを登録解除したからといって、それ以下のすべてのリスナを解除したということにはなりませんので注意してください。

ワンショットのValueイベントリスナ

一度だけデータを取得するのに使って以後変更があっても利用しないようなケースでは、addListenerForSingleValueEvent()ValueEventListenerを登録するのが便利です。

ref.addListenerForSingleValueEvent(new ValueEventListener() {
    @Override
    public void onDataChange(DataSnapshot snapshot) {
        // do some stuff once
    }
    @Override
    public void onCancelled(FirebaseError firebaseError) {
    }
});

以上のようにすると、初回のみ指定したURI以下の全データを取得し、そのままリスナを登録解除してくれます。

データのクエリ

これまでの例では、データを追加した順に取得するのみでしたが、実際には

  • データを特定の条件にしたがって並び替える
  • データを特定の条件のものだけ取り出す

といった、いわゆる「クエリ」を利用することが複雑なアプリケーション開発には欠かせません。

ここではFirebaseでさまざまなクエリの発行方法をご紹介したいと思います。

Firebaseのクエリで利用できるメソッド一覧

Firebaseのクエリでは、大きく分けて「並び替え」用のメソッドと「条件付き取得」用のメソッドが用意されています。

並び替え

メソッド概要
orderByChild()子要素のキーで並び替え
orderByKey()要素のキーで並び替え
orderByValue()要素の値で並び替え
orderByPriority()要素の優先度で並び替え

条件付き取得

メソッド概要
limitToFirst()先頭からn件取得
limitToLast()後方からn件取得
startAt()条件にマッチする値以降を取得
endAt()条件にマッチする値以前を取得
equalTo()条件にマッチする値だけを取得

それぞれについて詳しく確認していきたいと思います。

サンプルデータ

さまざまなクエリを試すにあたり、サンプルデータを以下のように変更してください。

{
  "messages" : {
    "01" : {
      "body" : "Hi, there!",
      "sender" : "John",
      "timestamp" : 20160123
    },
    "02" : {
      "body" : "What's up?",
      "sender" : "Steve",
      "timestamp" : 20151004
    },
    "03" : {
      "body" : "hey hey hey!",
      "sender" : "Bill",
      "timestamp" : 20151217
    },
    "04" : {
      "body" : "ho ho ho:)",
      "sender" : "Mike",
      "timestamp" : 20160403
    },
    "05" : {
      "body" : "howdy",
      "sender" : "Clint",
      "timestamp" : 20140411
    }
  }
}

また、これに合わせてChatMessageクラスも以下のように変更してください。

public class ChatMessage {
    public String body;
    public String sender;
    public long timestamp;
}

さらに、各ユーザが受信したメールの数を管理するデータも追加します。

ルートノードに以下のようなデータを追加してください。

{
  "counts" : {
    "john" : 16,
    "Steve" : 2,
    "Bill" : 7,
    "Mike" : 25,
    "Clint" : 9
  }
}

並び替え

orderByChild()

子要素を特定のキー名でソートする場合は、orderByChild("キー名")を利用します。まずは以下のコードを参照してください。

Firebase ref = new Firebase("https://<YOUR-FIREBASE-APP>.firebaseio.com/messages");

Query query = ref.orderByChild("timestamp");
query.addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String previousKey) {
        ChatMessage chatMessage = dataSnapshot.getValue(ChatMessage.class);
        String sender = chatMessage.sender;
        String body = chatMessage.body;
        long timestamp = chatMessage.timestamp;
        Log.d("Firebase", String.format("onChildAdded, sender:%s, body:%s, timestamp:%d", sender, body, timestamp));
    }

これまでと同じように、/messagesの参照refを作りますが、そこから更にorderByChild("キー名")でソートしたいキー名を指定します。上記の例ではtimestampを指定しているのでタイムスタンプ順にソートされるはずです。

D/Firebase: onChildAdded, sender:Clint, body:howdy, timestamp:20140411
D/Firebase: onChildAdded, sender:Steve, body:What's up?, timestamp:20151004
D/Firebase: onChildAdded, sender:Bill, body:hey hey hey!, timestamp:20151217
D/Firebase: onChildAdded, sender:John, body:Hi, there!, timestamp:20160123
D/Firebase: onChildAdded, sender:Mike, body:ho ho ho:), timestamp:20160403

見事、タイムスタンプでソートされているようです。他にも手元でref.orderByChild("sender")に変えたりしていろいろ試してみてください。

orderByKey()

子要素を要素自身のキー名でソートする場合は、orderByKey()を利用します。

サンプルデータの場合は、/messages以下の "01", "02, "03", "04", "05" がそれぞれの要素のキーになります。

Firebase ref = new Firebase("https://<YOUR-FIREBASE-APP>.firebaseio.com/messages");

Query query = ref.orderByKey();
query.addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String previousKey) {
        ChatMessage chatMessage = dataSnapshot.getValue(ChatMessage.class);
        String sender = chatMessage.sender;
        String body = chatMessage.body;
        long timestamp = chatMessage.timestamp;
        Log.d("Firebase", String.format("onChildAdded, sender:%s, body:%s, timestamp:%d", sender, body, timestamp));
    }

結果は、最初に用意した通りの順番となるはずです。この挙動はオーダリングを指定しなかった場合のデフォルトの挙動です。

orderByValue()

子要素のキーではなく、要素の値でソートしたい場合は、orderByValue()を利用します。今度は、/countsに対してリスナを登録してみましょう。以下のようにします。

Firebase ref = new Firebase("https://<YOUR-FIREBASE-APP>.firebaseio.com/counts");
Query query = ref.orderByValue();
query.addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String previousKey) {
        String user = dataSnapshot.getKey();
        long count = (long) dataSnapshot.getValue();
        Log.d("Firebase", String.format("user: %s's count=%d", user, count));
    }

今回は/countsに対してChild Addedイベントリスナを登録したので、各子要素は{"Bill" : 7}といったものが取得できるはずです。

そこでString user = dataSnapshot.getKey()してユーザ名を取得し、long count = (long) dataSnapshot.getValue();でメッセージ数を取得しています。

D/Firebase: user: Steve's count=2
D/Firebase: user: Bill's count=7
D/Firebase: user: Clint's count=9
D/Firebase: user: john's count=16
D/Firebase: user: Mike's count=25

以上のように、countで昇順にログ出力できたら成功です。

orderByPriority()

もうひとつ、priority(優先度)で並び替えるためのオプションがあります。これは、各データにユーザが任意の優先度を付けて並び替えをカスタマイズできるように用意されたものです。

実はFirebaseにはデータを降順に並べる手段が標準では用意されていません。したがって降順にするためのワークアラウンドにこのオプションを利用したりします。この辺りは後の連載の実践テクニックでぜひご紹介したいと思います。

条件付き取得

並び替えと組み合わせて、取得数の上限や、どこからどこまで取得するといった範囲を指定することができます。

limitToFirst(), limitToLast()

データを最初から数えていくつまで、最後から数えていくつまで、といった指定をするには、それぞれlimitToFirst(),limitToLast()を利用します。

Firebase ref = new Firebase("https://<YOUR-FIREBASE-APP>.firebaseio.com/counts");
Query query = ref.orderByValue().limitToFirst(2);
// or
Query query = ref.orderByValue().limitToLast(2);
query.addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String previousKey) {
        String user = dataSnapshot.getKey();
        long count = (long) dataSnapshot.getValue();
        Log.d("Firebase", String.format("user: %s's count=%d", user, count));
    }

両方試して、それぞれ以下のように最初の2件、最後の2件が取得できることを確認してみてください。

D/Firebase: user: Steve's count=2
D/Firebase: user: Bill's count=7
// or
D/Firebase: user: john's count=16
D/Firebase: user: Mike's count=25

startAt(), endAt()

データの値がどこから始まって、どこまでを含む、といった範囲を指定するには、それぞれstartAt(), endAt()を利用します。 単独で使うことも、コンビネーションで使うこともできます。

Firebase ref = new Firebase("https://<YOUR-FIREBASE-APP>.firebaseio.com/counts");
Query query = ref.orderByValue().startAt(3).endAt(17);
query.addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String previousKey) {
        String user = dataSnapshot.getKey();
        long count = (long) dataSnapshot.getValue();
        Log.d("Firebase", String.format("user: %s's count=%d", user, count));
    }

上記の例ですと、3~17の範囲の要素が取得できるので、以下のようにログに出力されれば成功です。

D/Firebase: user: Bill's count=7
D/Firebase: user: Clint's count=9
D/Firebase: user: john's count=16

ここにさらにref.orderByValue().startAt(3).endAt(17).limitToFirst(1)のようにして範囲内の最初の1件に絞るといったことも容易です。

equalTo()

最後に、指定した値とぴったり同じ値でマッチングするにはequalTo()を利用します。今度は/messagesからsenderMikeのメッセージを取得してみましょう。

Firebase ref = new Firebase("https://<YOUR-FIREBASE-APP>.firebaseio.com/messages");
Query query = ref.orderByChild("sender").equalTo("Mike");
query.addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String previousKey) {
        ChatMessage chatMessage = dataSnapshot.getValue(ChatMessage.class);
        String sender = chatMessage.sender;
        String body = chatMessage.body;
        long timestamp = chatMessage.timestamp;
        Log.d("Firebase", String.format("onChildAdded, sender:%s, body:%s, timestamp:%d", sender, body, timestamp));
    }
以下のように"Mike"のメッセージだけが取得できていれば成功です。
D/Firebase: onChildAdded, sender:Mike, body:ho ho ho:), timestamp:20160403

まとめ

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

今回の連載では、同じようにデータを取得する場合でも、一気に全データを取得したり、特定のケースだけに特化した効率的なやり方があることを見ていきました。また、クエリを利用すればデータを並び替えたり取得条件を指定して柔軟にデータを取り出すことができることも確認しました。

次回の連載では、このチャットアプリケーションに新規にデータを追加する方法を解説し、ケースにあったさまざまなデータの保存方法をご紹介したいと思います。

どうぞお楽しみに。

おすすめ記事

記事・ニュース一覧