Firebaseデータモデリング 読み込みを高速化する方法


こちらの記事は、2016年11月に公開された『 Firebase Data Modeling 』の和訳になります。

パフォーマンスを発揮するFirebaseアプリのモデルデータ

Firebaseは、体系化されていないJSONデータを体系化することについてあまり説明がありません。Firebaseは番号付きのリストキーの使用を推奨していません。制限はそれだけで、データモデルの残りの設計はあなた次第です。

Firebaseを効率的に設計する方法をいくつか記載します。

正規化/浅いデータ構造

ほとんどのJSONデータ構造は正規化されていないため、他の要素を参照しない傾向があります。この考え方はFirebaseにも適用しやすいですが、効率が良くありません。

Firebaseは、浅いデータ構造の場合に一番力を発揮します。そのためにはデータを正規化する必要があります。以下の2つのデータ構造の例を比べてみましょう。まず、遅くて効率的ではない深くネストされたデータです。

注:以下の例では、サンプルデータ構造の作成と読み取りを容易にするためにproduct1やtransaction2などのキーを使用しています。本番アプリケーションでは、someRef.push()によって生成されたプッシュキーを使用します。例えば、一般的な製品またはトランザクションIDは、むしろproduct1またはtransaction2よりも==+M0H4sFUOPe1vgQSXkWqdAのようなキーが使用されるでしょう。

深いデータ構造 <アンチパターン!!!>

{
    "users": {
        "user1": {
            "email": "[email protected]",
            "transactions": {
                "transaction1": {
                    "total": "500",
                    "products": {
                        "product1": "paper airplanes",
                        "product2": "tooth picks"
                    }
                },
                "transaction2": {
                    "total": "250",
                    "products": {
                        "product1": "rocks and dirt",
                        "product2": "spatulas"
                    }
                }
            }
        }
    }
}

このデータ構造では、全てのユーザー属性に全ての子ノードが入っていることに注目してください。このモデルは非効率です。

理由としては、全ユーザーをループ処理してメールアドレスのデータを取得することができないからです。

Firebaseを使用して個々のユーザーのメールアドレスを効率的に取得することはできますが、ユーザーグループのメールアドレスだけを取得することはできません。1000人のユーザーをループ処理しなければいけない場合は、それらのユーザーの全てのトランザクションとそのメールアドレスを要求しなければなりません。

次に浅いデータ構造を見てみましょう。

浅いデータ構造

{
    "users": {
        "user1": {
            "email": "[email protected]"
        }
    },
    "transactions": {
        "user1": {
            "transaction1": {
                "total": "500",
                "products": {
                    "product1": "paper airplanes",
                    "product2": "tooth picks"
                }
            },
            "transaction2": {
                "total": "250",
                "products": {
                    "product1": "rocks and dirt",
                    "product2": "spatulas"
                }
            }
        }
    }
}

/users/user1属性には、1つの子ノード、つまりユーザーのメールアドレスしかないことに注目してください。ユーザーのトランザクションはtransactions/user1からアクセスできますが、余分なデータを引き出すことなくユーザーのメールアドレスを効率的にループできます。

浅いデータ構造のデメリットは、トランザクションのデータを引き出すために2回目の参照を作成し投げる必要があることです…users/{userId}参照ではうまくいきません。

2つの参照を使ってデータを結合する

how-to-firebase-3-two-ref-join.js
var userRef = ref.child('users/' + userId);
var userTransactionsRef = ref.child('transactions/' + userId);
Promise.all([
 userRef.once('value'),
 userTransactionsRef.once('value')
]).then(function (snaps) {
 var user = snaps[0].val();
 var userTransactions = snaps[1].val();
});

how-to-firebase-3-two-ref-join.js

データの使用方法に基づいて、常に正規化(浅い構造)と非正規化(深い構造)のバランスを取る必要があります。

トランザクションと共にメールアドレスを定期的に取得している場合は、次のようにトランザクション内でユーザーのメールアドレスをコピーする必要があります。

…
“transaction1”: {
 “email”: “[email protected]”,
 “total”: “500”,
 “products”: {
 “product1”: “paper airplanes”,
 “product2”: “tooth picks”
 }
}
…

恐れずに読み取り速度を上げるためにデータをコピーしてください。データをコピーすると、書き込みが少し遅くなり管理が複雑になることがありますが、データをコピーするとアプリケーションの数百万回の読み取り処理を軽くできます。

ストリームデータ

データをストリームとしてモデル化すると、大規模のデータ処理にも対応できFirebaseの処理速度が低下するような大きなクエリを防ぐことができます。

チャットアプリを例にデータ構造を見てみましょう。

体系化されたチャットデータ

…
"transaction1": {
 "email": "[email protected]",
 "total": "500",
 "products": {
 "product1": "paper airplanes",
 "product2": "tooth picks"
 }
}
…

上のチャットデータは深くネストされています。このデータのクエリ処理は重くなります。Firebaseは一度に1つの子ノードにしかクエリできず、「孫」ノードにはできないからです。リストの最上位レベルで直接の子ノードである必要があります。この場合、 userChatsノードを照会することはできません。直接の子ノードはどれも値ではなく、全てネストされたノードだからです。

次はフラットな構造を見てみましょう。

ストリーム化されたチャットデータ

{
 "chats": {
   "chat1": {
     "user": "user1",
     "username": "Chris",
     "message": "First!"
   },
   "chat2": {
     "user": "user2",
     "username": "Melissa",
     "message": "Hey user one."
   },
   "chat3: {
     "user": "user2",
     "username": "Melissa",
     "message": "Where did you go?"
   },
   "chat4": {
     "user": "user1",
     "username": "Chris",
     "message": "I’m still here…"
   }
 }
}

この場合、最上位ノードに「chats」と名前を付け、各chatのuserIDとusernameをコピーしました。user のchatsノードに次のようにクエリを投げることができます。

how-to-firebase-3-equal-to-once.js
chatsRef.orderByValue('user').equalTo('user1').once('value', function (snap) {
 var user1Chats = snap.val();
 console.log('user1 chats!', user1Chats);
});

how-to-firebase-3-equal-to-once.js

また、 child_addedイベントを監視して、UIにchatsを追加することもできます。

how-to-firebase-3-child-added.js
chatsRef.on('child_added', function (snap) {
 console.log("let’s add this chat to our UI!", snap.val());
});

how-to-firebase-3-child-added.js

可能な限りデータをストリーム化してください。つまり、長くて浅いデータのリストにします。必要以上に入れ子にしないでください。また、恐れずにユーザー名、ユーザーID、オブジェクトのタイトルなどのデータをコピーしてください。データ構造をUIと一致させるようにした方が良いです。体系化されたchatデータの例では、各「chat」オブジェクトにはユーザー名をアタッチする必要がありました。ユーザー名をchatに結合すると深くネストされたデータのクエリ処理は重くなります。

valueイベントよりchild_addedイベントを利用する

Firebaseには、データを取得するための2つの主要なイベントタイプ、 valueとchild_addedがあります。valueイベントは、ソートされていないJSONオブジェクト内の全ての子ノードを返し、その後いずれかの子ノードに変更があるたびに全てのノードを返します。

child_addedイベントは既存の各子ノードに対して1回発生し、その後子ノードが追加されるたびに発生します。child_addedイベントは全ての子ノードに対して1回起動されるため、クエリのorderBy *パラメータを使用することもできます。

ほとんどのFirebase初心者は、valueイベントを使用しがちです。しかし、child_addedイベントは Firebaseを実行しているサーバーへの負荷が少なくなりスケーラビリティが向上するため、慣れているユーザーは可能な限りchild_addedイベントを使用します。

また、 child_addedイベントはソート順なので、クライアント上のデータを手動でソートする必要はありません。

賢くキューを使う

私たちはFirebaseをフロントエンドであるクライアントサイド技術と考えがちですが、実は非常にスケーラブルなサーバー処理のアーキテクチャです。

Firebaseリストに項目を追加することで発生する軽い動作のNode.jsタスクを作成するためにGoogle Cloud Functionsと統合しました。ユーザーはキューにジョブを追加でき、Cloud Functionsはそのキューを監視、ジョブを処理、キューからジョブを削除、次の処理のために別のジョブを別のキューに追加することもできます。

次の例は、ユーザー名の変更とショッピングカートのチェックアウトをユーザーから受け付ける単純なキューデータ構造です。

この例では、user1がユーザー名の変更を要求し、user2がショッピングカートのチェックアウトを要求します。

サーバーは既にuser3のユーザー名変更を承認し、さらに次の処理のためにその変更をserverQueues/updateUsernameノードに追加します。

サーバーはuser4のuserQueues/cartCheckoutジョブも承認し、支払い処理のためにuser4のクレジットカード情報をserverQueues/chargeCardノードに追加します。

キューの例

{
 "userQueues": {
   "changeUsername": {
     "user1": {
       "proposedUsername": "T-Rex"
     }
   },
   "cartCheckout": {
     "user2": {
       "total": 750,
       "products": {
         "somePushKey": "tongue depressors",
         "anotherPushKey": "deoderant"
       }
     }
   }
 },
 "serverQueues": {
   "updateUsername": {
     "somePushKey": {
       "user": "user3",
       "username": "Charlie"
     }
   },
   "chargeCard": {
     "somePushKey": {
       "user": "user4",
       "total": 250,
       "cartToken": "1234asdf"
     }
   }
 }
}

userQueues/changeUsername/$userノードとuserQueues/cartCheckout/$userノードが各ユーザーのIDを子キーとしてどのように使用するかに注目してください 。

通常、このようなリストでは新しいプッシュキーを使用しますが、クライアントがキューにジョブを追加できるように、これらのノードはユーザー書き込み可能である必要があります。

ユーザーIDを子キーとして使用することで、ユーザーの認証と一度に1つのジョブしかキューに入れられないというセキュリティ強化のためのルールを作成できます。

{
 "rules": {
   "userQueues": {
     "$queueName": {
       "$userId": {
         ".write": "auth.uid == $userId"
       }
     }
   }
 }
}

上記のセキュリティルールは、auth.uidがusersQueues/$queueName/$userIdのユーザーIDと一致する全てのユーザーに書き込み権限を付与します。セキュリティルールでは、読み取りと書き込みの両方の権限がデフォルトでfalseに設定されています。

セキュリティルールはノード名で一致させますが、\$で始まるワイルドカードノード名も許可されます。この場合、ワイルドカード$queueNameとワイルドカード$userIdが後ろに付いているuserQueuesにルールを追加します。ユーザーが認証され、ユーザーの認証UIDがノード名と一致する場合、userQueues/$queueName/$userIdノードへの書き込みアクセスが許可されます。

あなたの認証UIDがuser6の場合、usersQueues/changeUsername/user6またはusersQueues/cartCheckout/user6またはusersQueues/anyOtherQueueName/user6に書き込むことができます。

パスの一部であるuser7がuser6のUID:user6と一致しないため、user6は userQueues/changeUsername/user7に書き込むことはできません。

実際には、これらのUIDはこの例で使用したIDつまりuser1とuser2よりはるかに長くなっています 。これらのキーはFirebase認証によって機械的に割り振られ、 WQ3mVT7f8pRbBmry6eZju1Z4lPi1などの長いエンコードされた文字列のようになります。

このデータ構造内の全てのノードは、サーバーで/service-account.json apiキーを通じて管理者の権限である完全な読み取り/書き込み権限があり使用できます。

そのため、ユーザーは一度に1つのジョブをuserQueues/$queueName/$userIdノードにしか追加できませんが、サーバーだけはserverQueues/ データツリーにジョブを追加できます。

理解度テスト

データ構造に関する文書を読んで以下の質問に答えてください。

  • Firebaseでは、全てのオブジェクトではなく一部のオブジェクトをクエリできますか?
  • データをネストすることのデメリットは何ですか?
  • データをネストすることのメリットは何ですか?
  • データモデルで共有キーを使用するメリットは何ですか?
  • どんな時にデータの一部をコピーすべきですか?
  • 一枚の紙にJSONファイルを作成し、データ構造を使った基本的なToDoアプリを書いてみてください。自由な発想でより現実的に作成してください。
    • 考えているユーザーオブジェクトはありますか?もしあるなら、ユーザーはどんな属性が必要になりますか?
    • 各ToDoアイテムにはどのような属性を使用しますか。また、ToDoとユーザーをどのように関連付けますか?
    • いくつかのデータをオブジェクト間でコピーしていますか?
    • 結果として得られるデータ構造は適切に拡張されていますか?それともネストされたデータを読み込むのに苦労していますか?

翻訳協力

Author: Chris Esplin
Thank you for letting us share your knowledge!

記事選定: @takitakis
翻訳/技術監査: Yuichi / 湊
Markdown化: @aoharu