らこです。
FirebaseにはRulesというデータのアクセシビリティを管理する仕組みがあるのですが、一見複雑そうに見えるため取っ付きづらいと思ってる人が多いように思われます。
ですが本当は簡単でわかりやすいので、今回はRulesの基本的な部分から解説します。
Rulesを理解すれば、Firebaseを使う上で望ましいデータベース構造が見えてくるので、Firebaseを使ってみようと思う方はまずRulesから理解しましょう。
Firebaseって何?という人はこちらのブログ記事にわかりやすく紹介されています。
Rulesの基礎
FirebaseはMongoのような他のKVSとは違い、すべてのデータを1つのJSONの中に配置します。シンプルなチャットを例にすれば、次のようなデータ構造になります。
{
"messages": {
"user001": {
"user": "laco",
"text": "hello!",
"timestamp": 1435285206
},
"user002": {
"user": "labo",
"text": "hi",
"timestamp": 1435288005
}
}
}
ここでアクセシビリティについて考えます。このチャットには認証がなく、ルーム制でもありません。チャットに入った人は誰でもチャットのログを見て、投稿ができます。
このときのRuleは次のようになります。
{
"messages":{
".read": true,
".write": true
}
}
右辺がtrueの場合は省略可能ですが、今回は明示的に書くことで理解しやすくしています。
このRulesにより、messagesの中身は全員が読み書きできる状態になりました。注目すべき点は、JSONのツリー構造はデータとRulesで全く同じということです。
ここでチャットの作者は、誰でも読むことはできるけども、書き込みはログインした人だけができるようにしたいと考えました。
するとRulesは次のようになります。
{
"messages":{
".read": true,
".write": "auth !== null"
}
}
Rulesの.readや.writeの右辺には式を使うことができ、Firebaseの認証情報が入る変数authの他にも、Rules内で使える特別な変数がいくつかあります。それらは後ほど説明します。
複雑なRules
作者はさらに招待制のルームを建ててそれぞれの中でチャットができるようにしました。データ構造は例えばこんな感じです。
{
"users": { //ユーザー一覧
"user001": { //ユーザーID
"name": "laco", //ユーザー名
"rooms": { //ユーザーが参加権限を持つルームID一覧
"room001": true
}
},
"user002": {
"name": "labo",
"rooms": {
"room002": true
}
}
},
"rooms": { //ルーム一覧
"room001": "部屋A", //ルームIDとルーム名
"room002": "部屋B"
},
"messages": { //メッセージ一覧
"room001": { //ルーム毎のメッセージ一覧
"message0001": { //メッセージ
"user": "user001", //投稿したユーザーのID
"text": "hello!", //投稿内容
"timestamp": 1435285206 //タイムスタンプ
}
},
"room002": {
"message0001": {
"user": "user002",
"text": "hi",
"timestamp": 1435288364
}
}
}
}
一気に大きなデータになりましたが、構造は単純です。このデータに対してアクセシビリティを考えていきます。
ルーム
ログイン後、ページにアクセスするとまずはルームの一覧があるとします。
なので、roomsの読み取り権限はログインしたユーザー全員です。
新しいルームを立てる権限も全員にあるとします。
{
"rooms": {
".read": "auth !== null",
".write": "auth !== null"
}
}
auth変数
authにはデータにアクセスした際のFirebaseの認証情報が入っています。authには次の2つのプロパティがあります。
-
auth.provider: 認証情報のプロバイダが取得できます。"twitter"や"google"など。 -
auth.uid: ユーザーを一意に示すIDが取得できます。IDはプロバイダごとに違い、例えばTwitterで認証した場合のIDは"Twitter:<TwitterのユーザID>"となります。
今回使った"auth !== null"が表すのは、「ログインしているユーザーのみ」という条件です。次のユーザに関するアクセシビリティではauth.uidを使った制御を書いていきます。
ユーザー
ユーザーが持つ情報は、自分自身の名前と、そのユーザーが入ることの出来るルームの一覧です。
自分のユーザー名は他のユーザーから見られるようにしないといけませんが、
参加できるルーム一覧は自分自身だけが見られるべきです。
そのような階層ごとのアクセシビリティも直感的に記述できます。
{
"rooms": {
".read": "auth !== null",
".write": "auth !== null"
},
"users": {
".read": "auth !== null", //全ユーザーの取得
".write": "auth !== null", //ユーザーの追加
"$user_id": { //ユーザー毎のRule
//何も書かなければ上位の".read"が引き継がれる
".write": "auth.uid === $user_id", //ユーザー情報の変更は自分自身のみ
"rooms": {
".read": "auth.uid === $user_id" //roomsの読み取りは自分自身のみ
}
}
}
}
$location変数
突然登場した"$user_id"ですが、これは $location変数 と呼ばれる特殊な変数です。頭に$を付けた好きな名前の変数を宣言すると、「その階層にあるノードのキー」がそこに入ります。
具体的に言うと、今回の例では"$user_id"にはusersの下にある"user001"や"user002"が入ります。$location変数を使うとその階層にある子ノードをfor-eachして、
それぞれのキー文字列を条件式に組み込むことができるというイメージですね。
今回は自分自身のuidと一致するユーザーだけ読み書きが可能であるという条件を
"auth.uid === $user_id"
で表現しています。
メッセージ
メッセージはルームごとに管理されており、ルームのチャットを閲覧、投稿できるのはそのルームに属しているユーザだけです。
{
"rooms": {
".read": "auth !== null",
".write": "auth !== null"
},
"users": {
".read": "auth !== null",
".write": "auth !== null",
"$user_id": {
".write": "auth.uid === $user_id",
"rooms": {
".read": "auth.uid === $user_id"
}
}
},
"messages": {
//ログインしているユーザだけがアクセスできる
".read": "auth !== null",
".weite": "auth !== null",
"$room_id": { //ルーム毎のアクセシビリティ
//参加権限を持つルームだけ読み書きができる
".read": "root.child('users/'+ auth.uid +'/rooms/'+$room_id).exists()",
".write": "root.child('users/'+ auth.uid +'/rooms/'+$room_id).exists()"
}
}
}
root変数
またもや突然登場したrootという変数ですが、これはそのFirebaseデータの最上位ノードの参照を示す root変数 というものです。root変数はRuleDataSnapshotという型で、APIでDataSnapshotを操作するように、child()関数などを使って任意のノードまで移動することができます。
今回は「自分自身のroomsに同じIDのルームがあるかどうか」によってアクセシビリティを制御しています。
root変数と似た変数に data変数 というものがあります。これは今注目しているノード自身の参照で、そこからdata.parent()やdata.child()を使ってノードを移動することができます。
RuleDataSnapshotのchild(path)関数は存在しないパスのノードにも移動することが可能で、そのノードが存在するかどうかをexists()関数で評価することができます。ほかにもhasChild(childPath)によって指定したパスの子が存在するかどうかであったり、isString()やisNumber()によってそのノードの値が文字列、数値であるかどうかなども評価することができます。詳しいAPIのリファレンスはこちらにかかれています。
アプリケーション側の実装
Rulesを設定し終えたので、これを念頭にアプリケーション側でFirebaseを利用します。
ルーム一覧の取得
ログインしたユーザーに自分が入れるルームの一覧を表示する必要があります。
現在存在する値を一度にすべて取得するにはref.once()と"value"クエリを使います。"child_added"クエリでも存在するデータをすべて取得することは出来るのですが、これは1件ずつ順番に送られてくるので、一度に全て欲しい場合は"value"のほうがよいでしょう。ただしその後の新規追加を反映する必要がある場合は、once()のコールバック中でon("child_added")して取得しなくてはなりません。
事前に"value"で全データを取得していたとしても新しく"child_added"を取得するとすでに"value"で取得しているデータも送られてきます。データの重複が発生するので、アプリケーション側でノードのキーによってマップに保存するのがよいと思います。
また、ユーザーが持っているのはルームのIDだけですので、ルーム一覧に表示するルーム名はroomsの中を見ないといけません。予めroomsもすべて取得してアプリケーション側でキャッシュしておいても良いですが、自分自身が入れるルームの情報だけを取得したい場合にもonce()と"value"を使います。
ref.child(["users", auth.uid, "rooms"].join("/")).once("value", function(rooms){
rooms.forEach((room)=> {
ref.child(["rooms", room.key()].join("/")).once("value", function(roomData) {
rooms[roomData.key()] = roomData.val();
});
});
ref.child(["users", auth.uid, "rooms"].join("/")).on("child_added", function(room){
ref.child(["rooms", room.key()].join("/")).once("value", function(roomData) {
rooms[roomData.key()] = roomData.val();
});
});
});
ルーム内のチャット取得
ルームに入ったらメッセージを取得します。チャットのログであればすべて"child_added"で取得しても違和感はないでしょう。ルームIDをキーに取得します。
ref.child(["messages", roomId].join("/")).on("child_added", function(message){
messages[message.key()] = message.val();
});
チャットメッセージの送信も同じ階層にpush()します。
var message = {
user: auth.uid,
text: inputText,
timestamp: Date.now()
};
ref.child(["messages", roomId].join("/")).push(message);
Rulesのまとめ
- 何も書かなければ
true - データと同じ階層で記述する
-
"auth !== null"と"auth.uid === $user_id" - $location変数で兄弟ノードをfor-each
- 他のノードを見るにはroot変数
この内容はあくまでも私見なので、間違っている点や気になる点がありましたらぜひコメントで教えてください。