Edited at

Firebase Cloud Firestoreの使い方


概要

Firebase Realtime Databaseの次世代版。Realtime Databaseはデーターベース制約でいろいろとパフォーマンス等考慮したデータ構造にしないといけなかったが、Cloud Firestoreは自由度が高まったため、データベース制約をあまり意識せずにデータ格納できるようになった。


用語と概要


データ

キーとバリューのセット


data

key1 : value1



ドキュメント

データおよびサブコレクションの集合。

ドキュメントでは、以下のような形で、複数のデータを保持することができる。


document

key1 : value1

key2 : value2
key3 : value3

JSONのObjectのような形でネストも可能。

key1 :

key11 : value11
key12 : value12
key2 : value2

データだけでなく、後述するサブコレクションも保持することができる。


document_with_data_and_subcollection

key1 : value1

subcollection1 : {
document1
document2
document3
}


コレクション

データベースのルート要素で、ドキュメントの集合。配下のドキュメントは一意な名前を持つ。

コレクションの生成と破棄は意識しなくてよい。ドキュメントを作成すればコレクションは自動生成され、ドキュメントが存在しなくなればコレクションは自動的に破棄される。


collection

users: {

userdocument1
userdocument2
userdocument3
}


サブコレクション

ドキュメント配下に存在するコレクション。コレクションと同じく、ドキュメントを配下にとることが出来る。

ドキュメントとサブコレクションは交互に存在することができ、最大100階層まで作成可能。

コレクションもサブコレクションも、配下にドキュメントを保持することが出来るという点では同じで、Databaseのルートにあればコレクションで、ドキュメントの配下にあればサブコレクションだと思えばよいが、サブコレクションの親のドキュメントを削除しても、サブコレクション自体は破棄されない。削除するには手動で消さないといけないという点だけは注意が必要。


参照方法

usersコレクションへの参照取得。

var usersCollectionRef = db.collection('users');

usersというコレクション内のalovelaceドキュメントへの参照取得。

var alovelaceDocumentRef = db.collection('users').doc('alovelace');

以下でも同等。

var alovelaceDocumentRef = db.doc('users/alovelace');

roomsというコレクションの配下のroomAというドキュメントの配下のmessagesというサブコレクションの配下のmessage1というドキュメントへの参照取得。

var messageRef = db.collection('rooms').doc('roomA')

.collection('messages').doc('message1');


データ構造の選択

あるデータを扱う際に、ドキュメント内のネスト構造とするか、サブコレクションとして階層を分けるか、コレクションそのものを分けるかの判断要素となる知識。


ドキュメント内のネスト構造

一つのドキュメントに固定で含まれているデータの場合に利用する。

例えば、nameの配下にfirstname, middlename, lastnameの3つが必ず含まれるという場合は、このデータ構造が良い。


document

age: 13

name: # ここがネスト構造
firstname: 'Yamada'
middlename: 'Jon'
lastname: 'Tarou'


サブコレクション

配下のデータ数が予測できないような場合に利用すると良い。例えば、チャットルーム内の発言数のようなデータは、発言が増えるたびに増加していくので、サブコレクションが適している。


document

roomname: 'chatroom1'

chat: { # ここがサブコレクション
chat1: chatdata1
chat2: chatdata2
chat3: chatdata3
}

サブコレクション内部のドキュメントには、クエリが実行できるので、chat1, chat2, chat3に横断的にクエリを実行してuser1の発言だけを抽出などすることができる。また、データが増減する際に追加や削除が容易。連結リストのようなイメージ。

ただし、現時点ではサブコレクションをまたぐクエリは実行できないという制約がある。


コレクション

強力なクエリ実行が可能だが、階層データが紐づいているので、階層構造変更などの影響を受けやすい。


データ型


説明

配列
JSONにおけるArrayのようなもの。値を複数格納できる。

ブール値
true or false

バイト
最大 1,048,487 バイト

日時
日付と時間を格納。精度はマイクロ秒。

浮動小数点
64 ビット倍精度

座標
緯度と経度のセットを格納

整数
64 ビットのsigned int

マップ
JSONにおけるオブジェクトのようなもの。キー・バリュー値を格納できる。

Null
Null

参照
projects/[PROJECT_ID]/databases/[DATABASE_ID]/documents/[DOCUMENT_PATH] のような形式

文字列
最大 1,048,487 バイト


ドキュメントの追加


ドキュメント名を指定して追加

set()メソッドを使う。


値の追加(すでに存在する場合は上書き)

citiesというコレクションに対して、LAというドキュメントを追加。

db.collection("cities").doc("LA").set({

name: "Los Angeles",
state: "CA",
country: "USA"
})
.then(function() {
console.log("Document successfully written!");
})
.catch(function(error) {
console.error("Error writing document: ", error);
});


値の追加(すでに存在する場合は統合)

citiesというコレクションに対して、BJというドキュメントを追加。

setメソッドの第二引数に{merge:true}を指定することで、すでにドキュメントが存在する場合は統合動作になる。

var cityRef = db.collection('cities').doc('BJ');

var setWithMerge = cityRef.set({
capital: true
}, { merge: true });


一意なIDをドキュメント名に設定して追加

add()メソッドを使うと、勝手に一意なIDがセットされる。

// Add a new document with a generated id.

db.collection("cities").add({
name: "Tokyo",
country: "Japan"
})
.then(function(docRef) {
console.log("Document written with ID: ", docRef.id);
})
.catch(function(error) {
console.error("Error adding document: ", error);
});

もしくは、以下でも良い。

// Add a new document with a generated id.

var newCityRef = db.collection("cities").doc();

// later...
newCityRef.set(data);

いきなり追加するか、参照を取得したのちに追加するかの差であり、挙動としては同一。


値の更新

ドキュメントの特定のフィールドを更新するには、update()メソッドを使用する。

var washingtonRef = db.collection("cities").doc("DC");

// Set the "capital" field of the city 'DC'
return washingtonRef.update({
capital: true
})
.then(function() {
console.log("Document successfully updated!");
})
.catch(function(error) {
// The document probably doesn't exist.
console.error("Error updating document: ", error);
});

あくまでドキュメントの内部更新であって、ドキュメントの名前を自体を変えたいときには、ドキュメントの削除+追加をする必要がある。

ドキュメント内のネストされたデータの更新をする場合は、"."を使う。


// Create an initial document to update.
var frankDocRef = db.collection("users").doc("frank");
frankDocRef.set({
name: "Frank",
favorites: { food: "Pizza", color: "Blue", subject: "recess" },
age: 12
});

// To update age and favorite color:
db.collection("users").doc("frank").update({
"age": 13,
"favorites.color": "Red"
})
.then(function() {
console.log("Document successfully updated!");
});

ドキュメントの更新だけでなく、ドキュメントへの追記という用途でも使える。

公式サイトでは、以下のようなタイムスタンプ更新の例も示されている。

この機能は多用するので、タイムスタンプ取得関数firebase.firestore.FieldValue.serverTimestamp()と共に覚えておくと良い。

var docRef = db.collection('objects').doc('some-id');

// Update the timestamp field with the value from the server
var updateTimestamp = docRef.update({
timestamp: firebase.firestore.FieldValue.serverTimestamp()
});


ドキュメントおよびコレクションの削除


ドキュメントの削除

ドキュメントの削除には、delete()メソッドを使用する。

db.collection("cities").doc("DC").delete().then(function() {

console.log("Document successfully deleted!");
}).catch(function(error) {
console.error("Error removing document: ", error);
});

ドキュメント内の特定のデータを削除する場合には、update()メソッドを使用する。

その際に、削除対象の要素に対してfirebase.firestore.FieldValue.delete()を指定する。

var cityRef = db.collection('cities').doc('BJ');

// Remove the 'capital' field from the document
var removeCapital = cityRef.update({
capital: firebase.firestore.FieldValue.delete()
});


コレクションの削除

コレクションを一括削除するメソッドは存在しない。コレクション配下のドキュメントを全削除すればコレクションも消える。

ドキュメント数が非常に多い場合は、以下のような形で、メモリ不足を避けるために指定個数ずつ順々に削除する。

(後述するbatchを使っているため、アトミック動作)

/**

* Delete a collection, in batches of batchSize. Note that this does
* not recursively delete subcollections of documents in the collection
*/

function deleteCollection(db, collectionRef, batchSize) {
var query = collectionRef.orderBy('__name__').limit(batchSize);

return new Promise(function(resolve, reject) {
deleteQueryBatch(db, query, batchSize, resolve, reject);
});
}

function deleteQueryBatch(db, query, batchSize, resolve, reject) {
query.get()
.then((snapshot) => {
// When there are no documents left, we are done
if (snapshot.size == 0) {
return 0;
}

// Delete documents in a batch
var batch = db.batch();
snapshot.docs.forEach(function(doc) {
batch.delete(doc.ref);
});

return batch.commit().then(function() {
return snapshot.size;
});
}).then(function(numDeleted) {
if (numDeleted <= batchSize) {
resolve();
return;
}

// Recurse on the next process tick, to avoid
// exploding the stack.
process.nextTick(function() {
deleteQueryBatch(db, query, batchSize, resolve, reject);
});
})
.catch(reject);
}

なお、ドキュメントを削除しても配下のサブコレクションは削除されないので、サブコレクションも消したいならば、

先にサブコレクションの配下のドキュメントを全削除しないといけない。


アトミック操作


トランザクション

トランザクションを使うことで、データの読みとりと書きこみの一連のオペレーションをアトミックに実行することができる。

システム制約にて、読み込みは書き込みの前に実施する必要があり、書き込み後の読み込みが行われた場合はトランザクションは失敗する。

また、トランザクション実行時に他のクライアントが書き込みを行った場合は、そのトランザクションは失敗し、再試行が行われる。

クライアントがオフラインの場合もトランザクションは失敗となる。

// Create a reference to the SF doc.

var sfDocRef = db.collection("cities").doc("SF");

// Uncomment to initialize the doc.
// sfDocRef.set({ population: 0 });

return db.runTransaction(function(transaction) {
// This code may get re-run multiple times if there are conflicts.
return transaction.get(sfDocRef).then(function(sfDoc) {
var newPopulation = sfDoc.data().population + 1;
transaction.update(sfDocRef, { population: newPopulation });
});
}).then(function() {
console.log("Transaction successfully committed!");
}).catch(function(error) {
console.log("Transaction failed: ", error);
});

トランザクション内部で、アプリケーションの状態を変更してはいけない。つまり、変数代入やUIの変更をしてはいけない。

トランザクションは失敗時に自動的に何度も再実行されるので、トランザクション内の処理は何度も実行される可能性があり、一度きりと期待していた動作が何度も繰り返される可能性があるため。

代わりに、以下のような形でトランザクション内部で得られた値を外の関数に渡せばよい。

トランザクション内ではトランザクション外の要素を操作しないと覚えておく。

// Create a reference to the SF doc.

var sfDocRef = db.collection("cities").doc("SF");

db.runTransaction(function(transaction) {
return transaction.get(sfDocRef).then(function(sfDoc) {
var newPopulation = sfDoc.data().population + 1;
if (newPopulation <= 1000000) {
transaction.update(sfDocRef, { population: newPopulation });
return newPopulation;
} else {
return Promise.reject("Sorry! Population is too big.");
}
});
}).then(function(newPopulation) {
console.log("Population increased to ", newPopulation);
}).catch(function(err) {
// This will be an "population is too big" error.
console.error(err);
});


バッチ処理

データ読み取りが不要ならば、最大500件のオペレーションを一括実行できるバッチ機能を使うことができる。

バッチ処理では、set(), update(), delete()の処理が任意の箇所に実行可能で、個々に実施するよりも素早く実行できる。

バッチ処理は、他プロセスによる同時実行の影響を受けないので、失敗も少なくなる。また、端末がオフラインでも実行可能。

使い方は以下の通り。batchオブジェクトに対して呼び出したいメソッドを実行し、第一引数にその対象となるドキュメントの参照と、変更したい値を指定する。

// Get a new write batch

var batch = db.batch();

// Set the value of 'NYC'
var nycRef = db.collection("cities").doc("NYC");
batch.set(nycRef, {name: "New York City"});

// Update the population of 'SF'
var sfRef = db.collection("cities").doc("SF");
batch.update(sfRef, {"population": 1000000});

// Delete the city 'LA'
var laRef = db.collection("cities").doc("LA");
batch.delete(laRef);

// Commit the batch
batch.commit().then(function () {
// ...
});


データの検索と取得

以下のデータに対して検索を行う。

var citiesRef = db.collection("cities");

citiesRef.doc("SF").set({
name: "San Francisco", state: "CA", country: "USA",
capital: false, population: 860000 });
citiesRef.doc("LA").set({
name: "Los Angeles", state: "CA", country: "USA",
capital: false, population: 3900000 });
citiesRef.doc("DC").set({
name: "Washington, D.C.", state: null, country: "USA",
capital: true, population: 680000 });
citiesRef.doc("TOK").set({
name: "Tokyo", state: null, country: "Japan",
capital: true, population: 9000000 });
citiesRef.doc("BJ").set({
name: "Beijing", state: null, country: "China",
capital: true, population: 21500000 });

少し整形すると以下のような感じ。

cities + SF - {

| name: "San Francisco",
| state: "CA",
| country: "USA",
| capital: false,
| population: 860000
| }
+ LA - {
| name: "Los Angeles",
| state: "CA",
| country: "USA",
| capital: false,
| population: 3900000
| }
+ DC - {
| name: "Washington, D.C.",
| state: null,
| country: "USA",
| capital: true,
| population: 680000
| }
+ TOK- {
| name: "Tokyo",
| state: null,
| country: "Japan",
| capital: true,
| population: 9000000
| }
+ BJ - {
| name: "Beijing",
| state: null
| country: "China",
| capital: true,
| population: 21500000
| }


単一のドキュメント取得

単一ドキュメント取得は以下のようにして行う。

var docRef = db.collection("cities").doc("SF");

docRef.get().then(function(doc) {
if (doc.exists) {
console.log("Document data:", doc.data());
} else {
console.log("No such document!");
}
}).catch(function(error) {
console.log("Error getting document:", error);
});

docRef によって参照された場所にドキュメントがない場合、結果の document は null になります。

という注釈があるので、cities - SFにデータが存在しない状態でdoc.existsを実行するとnull参照になって、catch節に飛ぶように思うが、コード見るとelseに飛んでいきそう。後ほど確認する。


複数のドキュメントの取得

コレクション内の全データを取得するには、コレクション配下でget()を呼ぶ。

db.collection("cities").get().then(function(querySnapshot) {

querySnapshot.forEach(function(doc) {
console.log(doc.id, " => ", doc.data());
});
});

コレクション内で条件に合致するデータを取得するには、以下のようにwhere()を指定して条件設定し、get()で取得する。

db.collection("cities").where("capital", "==", true)

.get()
.then(function(querySnapshot) {
querySnapshot.forEach(function(doc) {
console.log(doc.id, " => ", doc.data());
});
})
.catch(function(error) {
console.log("Error getting documents: ", error);
});


クエリタイプ


比較演算子

比較演算子は、以下が使用可能。

<<===>>=


複合クエリ

where()メソッドをつなげることで、AND条件検索が実行可能。

citiesRef.where("state", "==", "CO").where("name", "==", "Denver")

citiesRef.where("state", "==", "CA").where("population", "<", 1000000)

ただし、等価比較の==と範囲比較の<, <=, >, >=を組み合わせる場合はカスタムインデックスを作成しておく必要がある。詳細は後ほど調査するが、カスタムインデックスを定義しておかなければエラーになり、その際のログに設定用のURLが含まれるようなので、一度エラー出しておいてURLから設定でも良さそうなことが書いてある。

また、範囲比較演算は複数フィールドに対して実行することはできない。つまり、以下は許されるが、

citiesRef.where("state", ">=", "CA").where("state", "<=", "IN")

以下は実行できない。

citiesRef.where("state", ">=", "CA").where("population", ">", 100000)


並べ替えと制限

データの並び順を指定するには、orderBy()を使う。

また、取得数の制限を行う場合は、limit()を使う。

以下のようにして、コレクション内のドキュメントを"name"要素でソーティングして、取得数を3つまでに制限することができる。

citiesRef.orderBy("name").limit(3)

降順に並び替えるには、orderByの第二引数に"desk"を指定する。

citiesRef.orderBy("name", "desc").limit(3)

2つの要素を指定して並び替える場合には、以下のようにorderByを連結する。

citiesRef.orderBy("state").orderBy("population", "desc")

クエリと組み合わせることで、特定の条件を満たすドキュメントに対して並び替えおよび制限を実施することができる。

citiesRef.where("population", ">", 100000).orderBy("population").limit(2)

ただし、クエリにて不等式を使用している場合は、orderByによる並び替えは不等式演算を行った対象の要素に対して実施する必要がある。

citiesRef.where("population", ">", 100000).orderBy("population")


ページ


クエリカーソル

クエリ結果が膨大なときに、25個ずつ値が欲しいなどの場合はページ機能を使う。

この機能を実現するために、startAt(), startAfter(), endAt(), endBefore()メソッドが存在しており、

startAt()とendAt()は境界値を含む条件、startAfter()とendBefore()は境界値を含まない条件指定が可能。

以下の例では、populationが1000000以上のドキュメント集合を昇順に取得する。

citiesRef.orderBy("population").startAt(1000000)

以下の例では、populationが1000000以下のドキュメント集合を昇順に取得する。

citiesRef.orderBy("population").endAt(1000000)


ドキュメントスナップショットを用いたクエリ

サンフランシスコの人口より多い都市の集合がほしい場合には、


  1. サンフランシスコの人口を取得

  2. 都市集合からサンフランシスコの人口以上のものを取得

という手順を用いなくとも、サンフランシスコのドキュメントスナップショットを用いて以下の手順にて取得が可能。

var citiesRef = db.collection("cities");

return citiesRef.doc("SF").get().then(function(doc) {
// Get all cities with a population bigger than San Francisco
var biggerThanSf = citiesRef
.orderBy("population")
.startAt(doc);

// ...
});

ポイントは、startAt()にてサンフランシスコのドキュメントスナップショットを指定している点。orderBy("population")を指定してやれば、startAt()の条件もpopulationに自動的に制限される。


ページ設定

上記の方法を組み合わせることで、ページ設定が実現できる。

var first = db.collection("cities")

.orderBy("population")
.limit(25);

return first.get().then(function (documentSnapshots) {
// Get the last visible document
var lastVisible = documentSnapshots.docs[documentSnapshots.docs.length-1];
console.log("last", lastVisible);

// Construct a new query starting at this document,
// get the next 25 cities.
var next = db.collection("cities")
.orderBy("population")
.startAfter(lastVisible)
.limit(25);
});

この方法では"cities"というコレクションのうち、populationが小さいものから25個の要素を取得している。

その後、25個目の要素を変数に格納しておき、その要素より後の25個の要素を改めて取得している。

仮に、24, 25, 26個目の要素の"population"が1000だった場合、"population"をkeyとした次のページの検索にて24, 25個目の要素要素を取り除くのは手間がかかるが、ドキュメントスナップショットを使うことで、手軽に26個目の要素から取得が可能になる。


変更発生時の通知

onSnapshot()メソッドでリスナをセットすることで、ドキュメントの変更時にコールバックを受けることが出来る。変更とは、具体的には、追加・削除・変更であり、削除時にはnullが返ってくるものと思われる。


単一ドキュメント変更のリッスン

以下のようにすると、ドキュメントが変更された際にコールバックを受けることができる。

db.collection("cities").doc("SF")

.onSnapshot(function(doc) {
console.log("Current data: ", doc && doc.data());
});

この状態でこのユーザーがデータの書き込みを行うと、書き込みを行ったユーザーにだけ直ちにコールバックが返ってくる。

metadata.hasPendingWritesプロパティを参照することで、ローカル書き込み契機の(つまり、自身の書き込みによる)コールバックなのか、サーバー書き込み契機の(つまり他人の書き込みによる)コールバックなのかを確認することができる。

db.collection("cities").doc("SF")

.onSnapshot(function(doc) {
var source = doc.metadata.hasPendingWrites ? "Local" : "Server";
console.log(source, " data: ", doc && doc.data());
});

この状態で自身が書き込みを行った場合、直感的には"Local"と"Server"の二度のコールバックが来そうなものだが、"Local"のコールバックしか受けられない。

自身が書き込んだ場合でも"Server"コールバックを確認したいならば、includeMetadataChangestrueに指定する必要がある。

db.collection("cities").doc("SF")

.onSnapshot({
// Listen for document metadata changes
includeMetadataChanges: true
}, function(doc) {
// ...
});

この状態でデータ書き込みを実施した場合は、コールバックは二度発生する。

単に書き込みの成功だけを確認したい場合には、onSnapshot()を実行する必要はない。set()やupdate()実行後のPromiseに対してthen()節が実行された場合は書き込み成功している。

チャット的なシステムを考えるとき、doc.metadata.hasPendingWrites == "Server"な場合だけリッスンするのは危険だろう。これだと、ネットワーク状況によっては通知が非常に遅れることがあるだろうし、オフラインサポートも不可能になる。書き込みしたのにUI上は文字が表示されていないというのは、書き込み失敗したのかと思うだろう。

書き込み自体は即座にUIに反映したほうが良いだろうから、


  • onSnapshot()にて、doc.metadata.hasPendingWrites == "Local"な変更が生じた際

  • onSnapshot()にて、doc.metadata.hasPendingWrites == "Server"な変更が生じた際

の双方でUIを変更するが、双方とも既存のデータと同一な場合はUI変更前にreturnするとかだろうか。


複数ドキュメント変更のリッスン

クエリと組み合わせることで、複数ドキュメントの変更をリッスンすることができる。以下のような形で、state == CAが設定されているドキュメントに変更が生じた際にコールバックを受けることが出来る。

db.collection("cities").where("state", "==", "CA")

.onSnapshot(function(querySnapshot) {
var cities = [];
querySnapshot.forEach(function(doc) {
cities.push(doc.data().name);
});
console.log("Current cities in CA: ", cities.join(", "));
});

クエリを使わず、単純にコレクション配下のドキュメントの変更を監視したいだけの場合は、where()メソッドを省略すれば良い。


複数ドキュメントの変更タイプ取得

クエリ実行による複数ドキュメントのリッスンでは、変更タイプの取得も可能。

db.collection("cities").where("state", "==", "CA")

.onSnapshot(function(snapshot) {
snapshot.docChanges.forEach(function(change) {
if (change.type === "added") {
console.log("New city: ", change.doc.data());
}
if (change.type === "modified") {
console.log("Modified city: ", change.doc.data());
}
if (change.type === "removed") {
console.log("Removed city: ", change.doc.data());
}
});
});


リスナーのデタッチ

変更通知を受け取る必要がなくなった場合は、以下のようにしてリスナーのデタッチを行う。

var unsubscribe = db.collection("cities")

.onSnapshot(function () {});
// ...
// Stop listening to changes
unsubscribe();


リッスンエラーへの対処

リード権限がない、無効なクエリが指定されたなどの理由で、リッスンが行えない場合は、エラーとなる。その場合は、エラーコールバックが発生する。

エラー時にはデタッチをする必要はない。

db.collection("cities")

.onSnapshot(function(snapshot) {
//...
}, function(error) {
//...
});


作ったもの

Firebase Cloud Firestoreを使って、陸上競技場の利用日確認サイト作りました。

https://itsrun.info/