データの読み取りと書き込み

(省略可)Firebase Emulator Suite でプロトタイピングおよびテストを行う

アプリが Realtime Database との間でどのようにデータを読み取り / 書き込みするのかを説明する前に、Realtime Database の機能のプロトタイピングとテストに使用できるツールである Firebase Emulator Suite について紹介します。異なるデータモデルの試行や、セキュリティ ルールの最適化、あるいはバックエンドとのやり取りで費用対効果の高い方法の検出を行う場合は、ライブサービスをデプロイせずにローカルで作業できるようにすると、大きなメリットが得られます。

Realtime Database エミュレータは Emulator Suite の一部であり、これを使用すると、アプリはエミュレートしたデータベースのコンテンツや構成とやり取りできるほか、エミュレートしたプロジェクトのリソース(関数、他のデータベース、セキュリティ ルール)とも任意でやり取りできます。

Realtime Database エミュレータを使用するには、いくつかの手順を実施するだけです。

  1. アプリのテスト構成にコード行を追加して、エミュレータに接続します。
  2. ローカル プロジェクトのディレクトリのルートから、firebase emulators:start を実行します。
  3. 通常どおり Realtime Database プラットフォーム SDK を使用して、または Realtime Database REST API を使用して、アプリのプロトタイプ コードから呼び出しを行います。

Realtime Database と Cloud Functions については、詳しいチュートリアルをご覧ください。Emulator Suite の概要もご覧ください。

DatabaseReference を取得する

データベースでデータの読み書きを行うには、DatabaseReference のインスタンスが必要です。

DatabaseReference ref = FirebaseDatabase.instance.ref();

データを書き込む

このドキュメントでは、Firebase データの読み取りと書き込みの基本について説明します。

Firebase データは DatabaseReference に書き込まれ、参照によって生成されるイベントを待機またはリッスンすることで取得されます。イベントは、データの初期状態で 1 回生成され、その後、データが変更されるたびに生成されます。

基本的な書き込みオペレーション

基本的な書き込みオペレーションは、set() を使用してデータを特定の参照に保存できます。そのパスにある既存のデータが置換されます。StringbooleanintdoubleMapList 型への参照を設定できます。

たとえば、次のように set() でユーザーを追加できます。

DatabaseReference ref = FirebaseDatabase.instance.ref("users/123");

await ref.set({
  "name": "John",
  "age": 18,
  "address": {
    "line1": "100 Mountain View"
  }
});

set() をこの方法で使用すると、特定の場所にあるデータ(子ノードも含む)が上書きされます。ただし、オブジェクト全体を書き換えずに子を更新することもできます。ユーザーに自分のプロフィールの更新を許可する場合、次のように username を更新できます。

DatabaseReference ref = FirebaseDatabase.instance.ref("users/123");

// Only update the name, leave the age and address!
await ref.update({
  "age": 19,
});

update() メソッドは、ノードへのサブパスを受け入れ、データベースの複数のノードを一度に更新できます。

DatabaseReference ref = FirebaseDatabase.instance.ref("users");

await ref.update({
  "123/age": 19,
  "123/address/line1": "1 Mountain View",
});

データを読み取る

値イベントをリッスンしてデータを読み取る

パスにあるデータを読み取り、変更をリッスンするには、DatabaseReferenceonValue プロパティを使用して DatabaseEvent をリッスンします。

DatabaseEvent を使用すると、特定のパスにあるデータをイベントの発生時の状態で読み取ることができます。このイベントはリスナーがアタッチされたときに 1 回トリガーされます。さらに、データ(子も含む)が変更されると、そのたびに再トリガーされます。イベントの snapshot プロパティには、その場所にあるすべてのデータ(子のデータも含む)が含まれています。データが存在しない場合、スナップショットの exists プロパティは false になり、その value プロパティは null になります。

次の例は、データベースから投稿の詳細を取得するソーシャル ブログ アプリケーションを示しています。

DatabaseReference starCountRef =
        FirebaseDatabase.instance.ref('posts/$postId/starCount');
starCountRef.onValue.listen((DatabaseEvent event) {
    final data = event.snapshot.value;
    updateStarCount(data);
});

リスナーは DataSnapshot を受信します。その value プロパティには、イベントのときにデータベース内の指定された場所にあったデータが含まれています。

データの 1 回読み取り

get() を使用して 1 回読み取る

SDK は、アプリがオンラインかオフラインかに関係なく、データベース サーバーとのやり取りを管理するように設計されています。

通常は、上述の値イベント手法を使用してデータを読み取り、データの更新に関する通知をバックエンドから受け取ります。これらの手法を利用することで、データの使用量と請求額を削減でき、オンラインとオフラインのどちらでも最高のユーザー エクスペリエンスを実現できます。

データが 1 回だけ必要な場合は、get() を使用してデータベースからデータのスナップショットを取得します。なんらかの理由で get() がサーバー値を返せない場合は、クライアントがローカル ストレージ キャッシュを調べ、それでも値が見つからなければエラーを返します。

次の例は、データベースからユーザーの公開ユーザー名を 1 回だけ取得する方法を示しています。

final ref = FirebaseDatabase.instance.ref();
final snapshot = await ref.child('users/$userId').get();
if (snapshot.exists) {
    print(snapshot.value);
} else {
    print('No data available.');
}

get() を必要以上に使用すると、帯域幅の使用が増加し、パフォーマンスの低下を招くおそれがあります。ただし、上記のリアルタイム リスナーを使用することで、これを回避できます。

once() を使用してデータを 1 回読み取る

更新された値をサーバーで確認するのではなく、値をローカル キャッシュから直ちに返したい場合があります。そのような場合は、once() を使用してローカル ディスク キャッシュから直ちにデータを取得できます。

これは 1 回読み込む必要があるだけで頻繁な変更やアクティブなリッスンを行うことは想定していないデータに対して有用です。たとえば、前述の例のブログアプリでは、このメソッドを使用して、ユーザーが新しい投稿を作成し始めたときにユーザーのプロフィールを読み込んでいます。

final event = await ref.once(DatabaseEventType.value);
final username = event.snapshot.value?.username ?? 'Anonymous';

データを更新または削除する

特定のフィールドを更新する

他の子ノードを上書きすることなく、ノードの特定の複数の子に同時に書き込むには、update() メソッドを使用します。

update() の呼び出し時に、キーのパスを指定して下位レベルの子の値を更新できます。スケーラビリティを向上させるためにデータが複数の場所に保存されている場合、データのファンアウトを使用してそのデータのすべてのインスタンスを更新できます。たとえば、ソーシャル ブログ アプリで、投稿を作成して、それと同時にその投稿から最近のアクティビティ フィードと投稿ユーザーのアクティビティ フィードを更新するとします。これを実現するには、ブログアプリで次のようなコードを使用します。

void writeNewPost(String uid, String username, String picture, String title,
        String body) async {
    // A post entry.
    final postData = {
        'author': username,
        'uid': uid,
        'body': body,
        'title': title,
        'starCount': 0,
        'authorPic': picture,
    };

    // Get a key for a new Post.
    final newPostKey =
        FirebaseDatabase.instance.ref().child('posts').push().key;

    // Write the new post's data simultaneously in the posts list and the
    // user's post list.
    final Map<String, Map> updates = {};
    updates['/posts/$newPostKey'] = postData;
    updates['/user-posts/$uid/$newPostKey'] = postData;

    return FirebaseDatabase.instance.ref().update(updates);
}

この例では、push() を使用して、/posts/$postid にある全ユーザーの投稿が格納されているノード内に投稿を作成すると同時に、key でキーを取得しています。その後、このキーを使用して、/user-posts/$userid/$postid にあるユーザーの投稿に別のエントリを作成できます。

これらのパスを使用すると、上記の例で両方の場所に新しい投稿を作成したように、update() を 1 回呼び出すだけで JSON ツリー内の複数の場所に対して更新を同時に実行できます。この方法による同時更新はアトミック(不可分)です。つまり、すべての更新が成功するか、すべての更新が失敗するかのどちらかです。

完了コールバックの追加

データがいつ commit されたのかを把握する場合は、完了コールバックを登録します。set()update() はどちらも Future を返します。ここには、書き込みがデータベースに commit された場合に呼び出される成功コールバックと、呼び出しが失敗した場合に呼び出されるエラー コールバックをアタッチできます。

FirebaseDatabase.instance
    .ref('users/$userId/email')
    .set(emailAddress)
    .then((_) {
        // Data saved successfully!
    })
    .catchError((error) {
        // The write failed...
    });

データの削除

データを削除する最も簡単な方法は、そのデータの場所への参照の remove() を呼び出すことです。

また、他の書き込みオペレーション(set()update() など)の値として null を指定する方法でも削除できます。この方法と update() を併用すると、API を 1 回呼び出すだけで複数の子を削除できます。

トランザクションとしてのデータの保存

増分カウンタなど、同時変更によって破損する可能性があるデータを操作する場合は、トランザクション ハンドラを runTransaction() に渡します。トランザクション ハンドラは、データの現在の状態を引数として取り、書き込む新しい状態を返します。新しい値が正常に書き込まれる前に別のクライアントがその場所に書き込んだ場合、現在の新しい値を使用して更新用の関数が再度呼び出され、書き込みが再試行されます。

たとえば、このソーシャル ブログアプリの例では、次のようにして、投稿にスターを付ける操作と投稿のスターを取り消す操作をユーザーに許可し、投稿で得られたスターの数を追跡できます。

void toggleStar(String uid) async {
  DatabaseReference postRef =
      FirebaseDatabase.instance.ref("posts/foo-bar-123");

  TransactionResult result = await postRef.runTransaction((Object? post) {
    // Ensure a post at the ref exists.
    if (post == null) {
      return Transaction.abort();
    }

    Map<String, dynamic> _post = Map<String, dynamic>.from(post as Map);
    if (_post["stars"] is Map && _post["stars"][uid] != null) {
      _post["starCount"] = (_post["starCount"] ?? 1) - 1;
      _post["stars"][uid] = null;
    } else {
      _post["starCount"] = (_post["starCount"] ?? 0) + 1;
      if (!_post.containsKey("stars")) {
        _post["stars"] = {};
      }
      _post["stars"][uid] = true;
    }

    // Return the new data.
    return Transaction.success(_post);
  });
}

デフォルトでは、トランザクション更新関数が実行されるたびにイベントが発生するため、関数を複数回実行すると、中間状態が表示される場合があります。applyLocallyfalse に設定すると、これらの中間状態を抑制し、トランザクションが完了するまで待ってからイベントを発生させることができます。

await ref.runTransaction((Object? post) {
  // ...
}, applyLocally: false);

トランザクションの結果は TransactionResult になります。ここには、トランザクションが commit されたかどうかや新しいスナップショットなどの情報が含まれます。

DatabaseReference ref = FirebaseDatabase.instance.ref("posts/123");

TransactionResult result = await ref.runTransaction((Object? post) {
  // ...
});

print('Committed? ${result.committed}'); // true / false
print('Snapshot? ${result.snapshot}'); // DataSnapshot

トランザクションのキャンセル

トランザクションを安全にキャンセルする場合は、Transaction.abort() を呼び出して AbortTransactionException をスローします。

TransactionResult result = await ref.runTransaction((Object? user) {
  if (user !== null) {
    return Transaction.abort();
  }

  // ...
});

print(result.committed); // false

サーバーサイドのアトミックなインクリメント

上のユースケースでは 2 つの値をデータベースに書き込みます。投稿にスターを付ける / スターを外すユーザーの ID と、インクリメントされたスターの数です。ユーザーが投稿にスターを付けていることがわかっている場合は、トランザクションではなくアトミックなインクリメント オペレーションを使用できます。

void addStar(uid, key) async {
  Map<String, Object?> updates = {};
  updates["posts/$key/stars/$uid"] = true;
  updates["posts/$key/starCount"] = ServerValue.increment(1);
  updates["user-posts/$key/stars/$uid"] = true;
  updates["user-posts/$key/starCount"] = ServerValue.increment(1);
  return FirebaseDatabase.instance.ref().update(updates);
}

このコードはトランザクション オペレーションを使用しないため、競合する更新があっても、自動的に再実行されることはありません。ただし、インクリメント オペレーションはデータベース サーバー上で直接発生するため、競合は発生しません。

ユーザーが以前にスターを付けた投稿に再度スターを付けるなど、アプリケーション固有の競合を検出して拒否するには、そのユースケースのためのカスタムのセキュリティ ルールを作成する必要があります。

オフラインでのデータ操作

クライアントでネットワーク接続が切断された場合でも、アプリは引き続き適切に機能します。

Firebase データベースに接続しているクライアントはそれぞれ、アクティブ データの内部バージョンを独自に保持しています。データが書き込まれると、まず、このローカル バージョンに書き込まれます。次に Firebase クライアントは、「ベスト エフォート」ベースでそのデータをリモート データベース サーバーや他のクライアントと同期します。

その結果、データベースへの書き込みが発生すると、実際にサーバーへデータが書き込まれるよりも早く、ローカル イベントが直ちにトリガーされます。つまり、ネットワークのレイテンシや接続に関係なく、アプリは応答性の高い状態を維持します。

接続が再確立されると、アプリは適切な一連のイベントを受け取り、クライアントが現在のサーバー状態と同期されます。この処理のためにカスタムコードを記述する必要はありません。

オフラインの動作については、オンライン機能とオフラインの機能の詳細で説明します。

次のステップ