Edited at

Firebase - realtime databaseと認証・認可の簡単な実例

More than 1 year has passed since last update.


はじめに

Firebaseで簡素なWebアプリを作りました。その過程で学習したFirebaseの使い方を書き付けます。

Firebase SDKのバージョンは3.6.8です。


なぜFirebaseを使ったか


データの読み書きが容易

アプリのデータを永続化しようと思ったら、典型的には以下の様なことが必要になるかと思います。

1. データベースサーバのセットアップ

2. サーバと通信する手段の開発または整備(REST APIを開発するなど)

3. アプリ側でその手段を用いてデータの読み書きをするコードを書く

Firebaseを利用すれば、サービスの払い出すsnippetをコピペするだけで3からスタートできます。


認証が容易かつその手段も豊富

これは、認証情報という機密性の高いものを自分で取り扱いたくなかったというのが第一にあります。

Firebaseではアプリ独自のパスワードを使った認証も可能ですが、Google, Twitterといった認証プロバイダを使うことも出来ます。そしてそれがほんの数行で実現できます。


realtime database


はじめに


Firebaseの始め方

Googleアカウントを持っていれば、トップページの「無料で開始」から辿っていくだけでプロジェクトを作成できます。

作成後、どの種類のアプリ(iOS/Android/Web)からFirebaseを使うかを選べば、アプリのソースコードにコピペするだけでFirebase利用の準備が整うsnippetが発行されます。


Firebase realtime databaseの特徴

プロジェクトのデータベース全体が1つのJSONとして表現されます。ただし、配列はありません。

私のWebアプリで作ったデータベースは以下のようになっています。ただし、JSONではコメントが書けないのでYAMLで表記します。また、アプリ自体がニッチなもの(音ゲー支援ツール)なので、データの意味がわからないところも多いかと思います。

データを冗長に保持している点については本稿の中で説明していきます。

- musics

- difficulty
- $difficulty_value # 楽曲の難度。1 - 12 の数値風文字列
- $musicid # 楽曲の識別子(≒楽曲タイトル)
- lowerTrack # 楽曲タイトルを小文字にしたもの
- unofficialDifficulty # 有志による細分化された難度。0 - 1300の数値
- all
- $musicid # difficulty下のものが全て含まれる
- users
- $uid
- musics # 以下、ユーザごとの楽曲のプリファレンスを記録する
- $musicid
######### 以下、プリファレンス
- flip
- leftOption
- rightOption
- disabled


realtime databaseの使い方

firebase.database()によりDatabaseオブジェクトが取得できます。また、アクセスしたい階層をReferenceといい、ref()メソッドにより取得します。例えば、難度5のすべての楽曲にアクセスしたい場合はfirebase.database().ref('musics/difficulty/5')となります。

以下firebase.database()を単にdatabaseと表記します。


書き込み

Referenceに対してset(), transaction()等のメソッドにより書き込みます。これらはいずれもPromiseを返します。

書き込みにあたって親階層が存在しない場合は作成されます。

set()は指定階層の値を上書きします。

以下のコードはユーザfoo1の、music1という楽曲のdisabledプリファレンスの値をtrueにします。

database.ref('users/foo/musics/music1/disabled').set(true)

.catch(function(error){
// エラー処理
});

transaction()は現在の値を元に新しい値を設定できます。トランザクションと聞いて想像する、複数の関連するデータをatomicに変更する操作はupdate()というメソッドで実現できます。

以下のコードはflipプリファレンスの値をnull, 1, 2の順でローテートします。

database.ref('users/foo/musics/music1/flip').transaction(function(flip){

var newVal = (flip + 1) % 3;
return 0 == newVal ? null : newVal;
})
.catch(function(error){
// エラー処理
});

新しい値としてnullを渡すとその階層は削除されます。上記コードでtransaction()のコールバックがnullを返すと、flipキー自体が削除されます。その結果music1が子キーを持たなくなるとmusic1も削除されます。


読み込み

読み込みはReferenceに対してイベントハンドラをセットするということで実現されます。イベントハンドラはon()メソッドにより設定します。イベントの種類はいくつかありますが、valueイベントはハンドラがセットされたときと、その階層以下のデータが少しでも変化したときに発火します。

ハンドラにはDataSnapshotが渡されます。これはデータの値などを含むオブジェクトです。valueイベントの場合、ハンドラにはその階層以下をすべて含むDataSnapshotが渡されます。

以下のコードはmusic1のプリファレンスを取得・監視します。

database.ref('users/foo/musics/music1').on('value', function(snapshot){

var v = snapshot.val(); // 値を取得
if (v) { // music1自体が存在しない場合vはnullになっている
var flip = v.flip;
var leftOption = v.leftOption;
var rightOption = v.rightOption;
var disabled = v.disabled;
}
// ...
});

ここまでで、データ管理UIが提供できます。

1. on()により初期状態を取得して表示

2. DOMイベントハンドラから書き込みメソッドを呼び出して変更

3. on()により変更が反映される

イベントの監視をやめる場合はoff()メソッドを呼び出します。


クエリ

データベースから読み出すとなればSQLのwhere句やorder by句にあたるものを使いたいところです。

並べ替えはorderByXX()メソッドを使います。

以下のコードは難度5の楽曲をcase-insensitiveに並べ替えて使います。

database.ref('musics/difficulty/5').orderByChild('lowerTrack').once('value')

.then(function(snapshot){
// ...
})
.catch(function(error){
// ...
});

once()メソッドはデータ取得メソッドですが、on()と違い一度読むだけで、ハンドラを設定しません。これはPromiseを返します。

値のフィルタはorderByXX()で並べ替えた後にequalTo()などのフィルタリングメソッドを呼び出します。

以下のコードは{"disabled": true}な楽曲のリストを得ます。

database.ref('users/foo/musics').orderByChild('disabled').equalTo(true).on(...);

さて、クエリを使えば、難度5の楽曲は以下のようにして得られます2

database.ref('musics/all').orderByChild('difficulty').equalTo(5).on(...);

しかし重要な点として、orderByXX()メソッドは一度しか使えないという制限があります。つまり難度を5に絞ってからcase-insensitiveに並べ替えるとか、第1キーをlowerTrackに、第2キーをunofficialDifficultyにするとかいったことはできません。そのため難度の階層を設けて分けています(allの存在理由についてはまた後ほど)。


認証・認可


認証

認証用インタフェースはfirebase.auth()により得られます。以降単にauthと表記します。

認証するには、プロバイダを設定してからsignInWithPopup()などの認証用画面を表示するメソッドを呼び出します。

以下のコードはTwitterのアプリ連携画面を呼び出してサインインさせます3

var provider = new firebase.auth.TwitterAuthProvider();

auth.signInWithPopup(provider)
.catch(function(error) {
// エラー処理
});

例によってsignInWithPopup()Promiseを返します。

認証に成功したときの処理はthen()で書いてもいいのですが、それでは一度サインインしたあとページに再アクセスしたときに対応できません。

認証状態の変化を監視するにはonAuthStateChanged()を用います。

auth.onAuthStateChanged(function(user){

if (user) {
// サインインした・している
} else {
// サインアウトした・サインインしていない
}
);

認証されているか否かでボタンの出し分けをしたり、認証されているならユーザデータへのReferenceを用意しておいたりします。

このメソッドの外でもauth.currentUserで認証状態をチェックできます。このUserオブジェクトのuidプロパティがアプリケーションにおけるユーザのIDとなります4


認可

認可の設定はFirebaseコンソールで行います。合わせてバリデーションとインデックス設定も行うことが出来ます。

認可はデータベース同様のJSONオブジェクトの、階層に対して読み出し・書き込み可能な条件を設定することで実現されます。

認可の重要な点は以下2点です。


  • デフォルトでは拒否

  • ある階層で条件を指定すると、それより下の階層で上書きすることはできない

私のアプリケーションでの設定は以下のようになっています。

{

"rules": {
"musics": {
"difficulty": {
".read": true
},
".write": "auth.uid === 'my-service-worker'"
},
"users": {
"$uid": {
".read": "auth.uid === $uid",
".write": "auth.uid === $uid"
}
}
}
}

上から順に見ていきます。

まずmusics/difficulty".read": trueとしています。これは誰でもこの階層下は読み取り可能ということになります。ここは個人データなどではないのでログイン不要で読めるようにしています。

次にmusics".write": "auth.uid === 'my-service-worker'"としています。auth.uidは認証で登場したauth.currentUser.uidを表し、その値がmy-service-workerならば書き込み可能ということです。しかしuidは無作為な文字列であり、my-service-workerという意味のある文字列にすることはできません。

実はこれは特権アクセスによって可能になるもので、# su - my-service-workerとしたようなイメージです。特権アクセスについては本稿では扱いません。ひとまず一般ユーザには書き込み不可能ということです。

ここまででmusics/allに対して読み取りが設定されていないことがわかります。これはすなわち誰も読み取れないということになります。その存在理由については後ほど説明します。

下段へ行くと、users/$uidに対して.read.writeauth.uid === $uidが設定されています。このようにパスの一部を変数にキャプチャして条件で使うことが出来ます。ここでは、users直下にログインユーザのuidで階層を作ることが可能であること、またそれ以下への読み書き許可はユーザ本人にだけ出されるという設定をしています。

実は、この書き込み許可はそれだけだと危険です。これはusers/$uidに対してはいかなる書き込みも可能ということを意味します。例えばアプリケーションロジックとしてはusers/$uid/musics/$musicid/flipに値を書き込みたいのに、不具合でusers/$uid/musics/flipに書くようになっていても気づきようがありません。

更に、特にWebアプリではユーザがスクリプトを容易に書き換えられるので、users/$uid/foo/barという風に好きなところに好きなデータを書き込むことが出来てしまいます。

こういったことを防ぐにはバリデーションを利用します。

余談ながら、本アプリでは使っていないのですが、Firebase storageでも同様の認可設定があります。これら認可設定のデフォルトは「認証していれば完全に読み書き可能」です。databaseにせよstorageにせよ、使わないなら「完全に拒否」に変更しておくことを勧めます。


バリデーション

バリデーションは単に「値の検証」以上の意味を持ちます。というのも、Firebaseではリーフノードのプリミティブな値だけでなく、ツリーのオブジェクトを書き込めるので、その正しさを保証するということは、データベースの整合性を保証することになるからです。

これも実例を示します。実際には上記の認可設定と合わせて書くのですが、バリデーションだけ取り出して書きます。

{

"rules": {
"musics": {
"$other": {
".validate": "false"
},
"difficulty": {
"$difficulty_value": {
".validate": "$difficulty_value.matches(/^[1-9]$/) || $difficulty_value.matches(/^1[0-2]$/)",
"$musicid": {
".validate": "newData.parent().parent().parent().child('all').hasChild($musicid) && newData.hasChild('unofficialDifficulty') && newData.hasChild('lowerTrack')",
"$other": {
".validate": "false"
},
"unofficialDifficulty": {
".validate": "newData.val() === newData.parent().parent().parent().parent().child('all/' + $musicid + '/unofficialDifficulty').val()"
},
"lowerTrack": {
".validate": "newData.val() === newData.parent().parent().parent().parent().child('all/' + $musicid + '/lowerTrack').val()"
}
}
}
},
"all": {
"$musicid": {
"$other": {
".validate": "false"
},
"unofficialDifficulty": {
".validate": "newData.isNumber() && 0 < newData.val() && newData.val() < 1300"
},
"lowerTrack": {
".validate": "true"
}
}
}
},
"users": {
"$uid": {
"$other": {
".validate": "false"
},
"musics": {
"$musicid": {
".validate": "root.child('musics/all').hasChild($musicid)",
"$other": {
".validate": "false"
},
"flip": {
".validate": "newData.isNumber() && 0 <= newData.val() && newData.val() <= 2"
},
"leftOption": {
".validate": "newData.isNumber() && 0 <= newData.val() && newData.val() <= 5"
},
"rightOption": {
".validate": "newData.isNumber() && 0 <= newData.val() && newData.val() <= 5"
},
"disabled": {
".validate": "newData.isBoolean() && true === newData.val()"
}
}
}
}
}
}
}

まずmusics/$otherにてfalseなバリデーションとなっています。これはいかなる値も書き込めないということになります。では$otherとはどんなパスを想定しているかというと、これと同階層に書かれているdifficulty, all以外の全てです。パスのキャプチャは、一致するものが他にないときに行われるため、difficulty, allだけが$otherにキャプチャされずに済みます。こうしてmusics下はdifficultyallしかないことが保証されます。

次にmusics/difficulty/$difficulty_valueにて$difficulty_value.matches(/^[1-9]$/) || $difficulty_value.matches(/^1[0-2]$/)なるバリデーションを行っています。これは1-12の数値風文字列であることを確認しています。キャプチャしたパスセグメントは必ず文字列であり、認証ルールの中では文字列を数値に変換することは出来ないので、このようになっています。

musics/difficulty/$difficulty_value/$musicidのバリデーションは3条件のANDです。

1つ目は相当読みづらいですがnewData.parent().parent().parent().child('all')musics/difficulty/$difficulty_value/$musicid/../../../allすなわちmusics/allを表します。そして.hasChild($musicid)をあわせ、最終的にmusics/all/$musicidが存在するかどうかを確認しています。musics/allは楽曲のマスターとして働いていて、この条件はRDBにおける外部キー制約を表現しています。

2つ目、3つ目はわかりやすいかと思います。この階層の下にunofficialDifficultylowerTrackを持っていなければならないということです。

ここまででmusics/difficulty/$difficulty_valueとその子musics/difficulty/$difficulty_value/$musicidに対してバリデーションが設定されましたが、読み書き権限と違い、この2つは両方満たされなければなりません。ある書き込みが複数のバリデーションの対象になるならば、その全てを満たさなければ書き込みは失敗します。

musics/difficulty/$difficulty_value/$musicid/unofficialDifficultymusics/all/$musicid/unofficialDifficultyと同じ値であることを要求しています。lowerTrackも同様です。これは、ソートのために複製されたデータがオリジナルと一致するという制約です。

musics/all/$musicid/unofficialDifficultyは普通のバリデータであり、データが1-1299の数値であることを要求しています。

一方lowerTracktrueなのでバリデーションの役割を果たしていません。これは$otherにマッチしないために書いています。

下段はusers/$uid/musics/$musicidに対してroot.child('musics/all').hasChild($musicid)という制約から始まります。これは先述したmusics/difficulty/$difficulty_value/$musicidに対する1つ目の条件と実質的には同じで、$musicidの外部キー制約です。

ではなぜ異なった書き方をしているかと言えば、データの書き込みのタイミングが異なるからです。

root変数はデータを書き込むのツリーの状態を表します。そしてusers/$uid/musics/$musicidは、システムデータであるmusics以下が準備されたに書き込まれる想定です。したがってrootから始まる式によるバリデーションが成立します。

一方musics/difficulty/$difficulty_value/$musicidmusics/all/$musicid同時に初期化されるようになっています。したがって書き込まれたのデータ(もし書き込みが成功したらこうなるという状態)でバリデーションを行う必要があります。rootから始まるバリデーションを書いても、書き込み前にはまだmusics/all/$musicidは存在しないので、必ず失敗します。書き込み後のデータを得る手段はnewDataしかないため、newDataからツリーを辿ってバリデーションする必要があるわけです。

その下のリーフノードのバリデーションは大体見ためどおりです。注意点として、val()メソッドはDataSnapshotのそれとは違い、データがオブジェクトの場合、そのプロパティにアクセスすることはできません。つまりusers/$uid/musics/$musicidにおいて0 <= newData.val().flip && newData.val().flip <= 2などと書くことはできず、プリミティブ型の値に対するバリデーションは必ずリーフノードに書く必要があります。

ちなみにval()nullを返す、すなわちデータの削除時はバリデーションは働きません。言い換えると、書き込みの権限がある以上削除を止めることは出来ません。

isNumber()は小数も許してしまうのが悩みのタネです……)


インデックス設定

同じくルールで設定するものとしてインデックス設定に触れておきます。

{

"rules": {
"users": {
"$uid": {
"musics": {
".indexOn": "disabled"
}
}
}
}
}

これは先程のdatabase.ref('users/foo/musics').orderByChild('disabled').equalTo(true)というクエリを高速化するためのインデックスです。設定しなくても利用は可能です。


終わりに

私のアプリケーションを開発する中で学んだFirebaseの使い方を書きました。

Firebaseはそのリアルタイム性が取り沙汰されることが多いようですが、単にデータをストアするための手段としてみても、簡単に目的を達成できます。また認証も簡単かつ、認証情報をアプリケーションロジックから容易に扱えます。

私のアプリケーションでは重視していませんが、データの読み取りでlistenするイベントを適切に絞るなど工夫すれば、効率的に動作するアプリケーションが書けるでしょう。

アプリケーション外からの、システムデータの書き込み(先述のmy-service-worker関連の話)についても、別な記事でいずれ書きたいと思っています。


資料





  1. 実際のuidは無作為な文字列です 



  2. 難度をdifficultyとして持っているという仮定のもとで 



  3. アプリ連携させるにはTwitter側への設定も必要ですが、それは参考リンクをご覧ください 



  4. Twitterで認証したからといってスクリーンネームだったりはしません