[この記事は Doug Stevenson、デベロッパー アドボケートによる The Firebase Blog の記事 "What happens to database listeners when security rules reject an update?" を元に翻訳・加筆したものです。詳しくは元記事をご覧ください。]

Doug Stevenson

Doug Stevenson
Developer Advocate

アプリで Firebase Realtime Database を利用している場合、データベースの変更をすぐにアプリに通知できる機能を多用していることでしょう。リスナーが呼び出されて新しいデータを受け取ると、ユーザーは満足することができ、万事が順調に進みます。

ただし、リスナーが 予想どおり に動作しない場合もあります。それは、セキュリティ ルールや検証ルールが登録されている場合です。

Firebase Realtime Database の動作にはわずかながら重要な違いがあり、その違いによってリスナーの起動方法が影響を受けます。予想外のことを予想できるように、そのような状況をいくつか紹介しましょう。

セキュリティ ルールと値イベント リスナー

データへのアクセスを保護するセキュリティ ルールや検証ルールはお使いでしょうか。使っていない方は、一度しっかりと確認してみてください。ルールを 使っている 方は、最初は奇妙に思える動作に遭遇するかもしれません。しかし、Firebase Realtime Database クライアント ライブラリの動作の仕組みを理解すれば、実は予測可能であることがわかるでしょう。

私は主に Android を使っているので、ここに掲載したコードサンプルはすべて Java で記述していますが、iOS、ウェブ、サーバーサイド JavaScript など、すべてのサポート対象プラットフォームに同じ原理があてはまります。

次のデータベース ルールが設定されているとしましょう。
{
    "rules": {
        ".read": true,
        ".write": false
    }
}


簡潔に言うと、読み取りはすべて可能で、書き込みは一切できないというルールです。実際にはこれよりも詳細なセキュリティ ルールを使っているでしょうが、重要な点は、場所や状況によって一部の書き込みが許可されないことです。皆さんが新しいプロジェクトでこのコードサンプルを使って実験できるように、今回はシンプルなルールを使用します。

データベースに、次のようなデータだけが存在するとします。
ROOT
- data
  - value: 99

/data ノードに対して ValueEventListener を呼び出すと、キー「value」と数値 99 のマップを含むスナップショットが返されます。そのため、次のコードを実行すると、詳細を表示する 1 行のログが出力されます。
private class MyValueEventListener implements ValueEventListener {
    @Override
    public void onDataChange(DataSnapshot dataSnapshot) {
        Log.i("********** change", dataSnapshot.getKey() + ": " + dataSnapshot.getValue());
    }

    @Override
    public void onCancelled(DatabaseError databaseError) {
        // If we're not expecting an error, report it to your Firebase console
        FirebaseCrash.report(databaseError.toException());
    }
}


とても簡単です。しかし、次にこの値を 99 から 100 に変更するとします。
HashMap map = new HashMap<>();
map.put("value", 100);
dataRef.setValue(map);


この操作はセキュリティ ルールによって禁じられているため、失敗するはずです。実際に動作させると失敗します。しかしもう 1 つ、おそらく皆さんにとって予想外のことも起こります。setValue() を呼び出した時点でまだ MyValueEventListener が登録されていれば、 リスナーが呼び出され、新しい値 100 を受け取ります。しかしそれだけではなく、 リスナーは再び呼び出され、元の値 99 を受け取ります。アプリのログには、次のように出力されるはずです。
I/********** change: DataSnapshot { key = data, value = {value=99} }
I/********** change: DataSnapshot { key = data, value = {value=100} }
W/RepoOperation: setValue at /data failed: DatabaseError: Permission denied
I/********** change: DataSnapshot { key = data, value = {value=99} }


つまり、リスナーは元の値 99 を受け取り、アップデートされた値 100 を受け取り、エラーが発生した後に、元の 99 を受け取ります。

そのため、こう思う方もいるかもしれません。「セキュリティ ルールで 100 への変更は禁止されているはずなのに、いったいどうなっているんだ!」。このように考えるのは当然です。しかしここで、実際に起きていることを理解して、正しく予想できるようになりましょう。

クライアント SDK は、プロジェクトのセキュリティ ルールを一切認識していません。セキュリティ ルールは Firebase のサーバー側にあり、そこで強制されるものです。一方、setValue() の呼び出しを処理するとき、SDK はサーバー上で更新が反映されるものと仮定して処理を進めます。ルールを設定済みのデータベースを使うコードでは、これは一般的な動作です。

つまり通常、意図的にルールに違反することはありません。SDK はこの仮定に基づいて 先回りで処理を進め、データベース内の指定された場所への書き込みが実際に成功したかのように処理します。その結果、同じアプリのプロセス内で、変更された場所に対して現在追加されているすべてのリスナーが呼び出されます。

しかし、腑に落ちないことがあります。書き込みが失敗する可能性があるのに、なぜクライアント SDK はこのように先回りで動作するようになっているのでしょうか。その理由は、即座にコールバックを呼び出せば、ネットワーク接続が不安定な場合でもアプリの反応が軽快になり、完全にオフラインになっても継続してアプリを利用できるようになるからです。たとえば、ユーザーがプロフィールを変更する場合、サーバーに変更が送信されてサーバーから応答が返されるまで待つのではなく、即座に変更を見えるようにした方がいいと思いませんか。少なくとも、セキュリティ ルールを遵守したコードが記述されていれば、問題は起こらないはずです。

今回のように、コードがセキュリティ ルールに違反した場合は、サーバーがその場所のアップデートが失敗したことをアプリに通知します。この時点でやるべきことは、元のデータを使ってその場所を監視しているすべてのリスナーを呼び出すことです。それによって、アプリの UI はサーバーが認識している値との整合性を取り戻すことになります。

以上のようなセキュリティ ルールの動作を理解した上で、別のシナリオを見てみましょう。

セキュリティ ルールと子要素イベント リスナー

子要素イベント リスナーは、前述の値イベント リスナーとは異なります。先ほどの ValueEventListener には、特定の場所の一部が変更されるたびに、その場所の内容全体が渡されます。一方の ChildEventListener では、ある場所の下にある子要素が追加、変更、移動、削除された際、コールバックには個々の子ノードが渡されます。

ここで、先ほどと同じく、すべて読み込み可能で書き込みは一切できないというセキュリティ ルールをあてはめてみましょう。
{
    "rules": {
        ".read": true,
        ".write": false
    }
}


データベースに /messages というノードがあり、ユーザーがそこに新しい内容のメッセージを登録し、他のユーザーと共有できるとします。
private class MyChildEventListener implements ChildEventListener {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String s) {
        Log.i("**********", "childAdded " + dataSnapshot.toString());
    }

    @Override
    public void onChildChanged(DataSnapshot dataSnapshot, String s) {
        Log.i("**********", "childChanged " + dataSnapshot.toString());
    }

    @Override
    public void onChildRemoved(DataSnapshot dataSnapshot) {
        Log.i("**********", "childRemoved " + dataSnapshot.toString());
    }

    @Override
    public void onChildMoved(DataSnapshot dataSnapshot, String s) {
        Log.i("**********", "childMoved " + dataSnapshot.toString());
    }

    @Override
    public void onCancelled(DatabaseError databaseError) {
        FirebaseCrash.report(databaseError.toException());
    }
}

DatabaseReference messagesRef =
    FirebaseDatabase.getInstance().getReference("messages");
messagesRef.addChildEventListener(new MyChildEventListener());

HashMap map = new HashMap<>();
map.put("key", "value");
DatabaseReference newMesssageRef = newMessageRef.push();
newMessageRef.setValue(map);


このコードは、/messages に ChildEventListener を追加した上で、生成されたプッシュ ID によって決まる場所に新しい子オブジェクトを追加しようとしています。もちろん、セキュリティ ルールが働いているため、この操作は失敗しますが、ログを見て、コードを実行したときに実際に何が起きるのかを確認してみましょう。
I/**********: childAdded DataSnapshot { key = -KTfacNOAJt2fCUVtwtj, value = {key=value} }
W/RepoOperation: setValue at /messages/-KTfacNOAJt2fCUVtwtj failed: DatabaseError: Permission denied
I/**********: childRemoved DataSnapshot { key = -KTfacNOAJt2fCUVtwtj, value = {key=value} }

クライアント ライブラリによって即座に onChildAdded メソッドが呼び出され、/messages の下に追加された新しい子オブジェクトが渡されています。その後、ログにエラーが出力され、onChildRemoved コールバックが呼ばれて同じオブジェクトが渡されています。

先ほどの例を読み理解できた方にとっては、この動作はさほど驚くことではないかもしれません。ここでも、Firebase クライアント SDK は、書き込みが成功すると仮定して setValue() の呼び出しに 先回りで 対応します。その後、セキュリティ ルールの影響で書き込みが失敗すると、失敗した追加の「取り消し」を実行しようとします。それによって、アプリの UI が正しい子要素の値を持つ最新状態になることが保証されます。この動作は、onChildRemoved が正しく実装されていることが前提となっています。

以上の説明で、セキュリティ ルール違反が起こった際の Firebase クライアント ライブラリの動作をしっかりと理解できたはずです。しかし、違反が起こったことをどうやって検知すればよいのか、疑問に思うかもしれません。アプリによっては、単に書き込みの結果を取り消すだけでは十分でないかもしれません。実際、これはプログラムのエラーが原因となっている可能性もあるため、取り消しが実際に発生しているかどうか、いつ発生したかを知りたいと思うかもしれません。次は、この点について考えてみたいと思います。

書き込みエラーの検知

先ほどの例では、リスナーのコールバックを見ただけで setValue() の呼び出しがサーバーで失敗したかどうかを判断するのは困難です。失敗を検知したい場合、失敗のイベントに応答するコードを追加で書く必要があります。これには、2 つの方法があります。1 つ目の方法は、オーバーロードされた setValue に渡すことができる CompletionListener を使い、そこでエラーを受け取ることです。別の方法として、Play Services Task API を使うこともできます。具体的には、setValue が返す Task オブジェクトを使います。ここでのおすすめは、アクティビティのリーク対策が組み込まれている Task を使う方法です(addOnCompleteListener の最初の引数には Activity のインスタンスを渡します)。

Task task = messageRef.setValue(map);
task.addOnCompleteListener(MainActivity.this, new OnCompleteListener() {
    @Override
    public void onComplete(@NonNull Task task) {
        Log.i("**********", "setValue complete");
        if (!task.isSuccessful()) {
            Log.i("**********", "BUT IT FAILED", task.getException());
            FirebaseCrash.log("Error writing to " + ref.toString());
            FirebaseCrash.report(task.getException());
        }
    }
});


成功してもエラーになっても、値の書き込みが完了すると、Task に登録された OnCompleteListener が呼ばれます。失敗した場合は、Task を調べて成功かどうかを確認した上で、必要に応じて対処できます。上記のコードでは、Firebase Crash Reporting にエラーを報告しています。これで、コードやセキュリティ ルールに誤りがあるか、あるとすればどこにあるかが判断しやすくなります。普通の状況でもセキュリティ ルールに違反する書き込みが発生する可能性がある場合を除き、このような書き込みの失敗は常に報告するとよいでしょう。

Task API の詳細については、こちらで連載したブログをご覧ください。

まとめ

アクティブなリスナーが監視している場所を同じプロセスからアップデートする場合、そのプロセスにおけるデータフローは次のようになります。
  1. すべての関連するリスナーが即座に呼び出され、新しい値が渡されます。
  2. アップデートが Firebase サーバーサイドに送信されます。
  3. 検証のため、セキュリティ ルールが確認されます。
  4. セキュリティ ルールに違反している場合、クライアント SDK に通知します。
  5. 再度関連するリスナーが呼び出され、アプリの変更がロールバックされて元の状態に戻ります。


この仕組みを理解すれば、これまでの予想を白紙に戻し、予想外だったリスナーの動作を予想できるようになるでしょう。皆さんの予想は変わりましたか?下のコメント欄からぜひ感想をお寄せください。他に Firebase Realtime Database のプログラミングに関する質問がありましたら、Stack Overflow で firebase-database タグを付けてお問い合わせください。一般的な質問は、Quorafirebase-talk Google Group にお寄せいただくこともできます。

よろしければ、Twitter で CodingDoug をフォローしてください。また、Firebase チュートリアルが掲載されている YouTube チャンネル などもぜひご確認ください。


Posted by Yoshifumi Yamaguchi - Developer Relations Team