NoSQLクラウドDB「Firestore」の基本ガイド: 詳細徹底解説
はじめに:Firestoreとは何か?
現代のアプリケーション開発において、データの永続化は不可欠です。そして、その要件を満たすデータベースの種類は多岐にわたります。リレーショナルデータベース(RDB)が長らく主流でしたが、スケーラビリティ、柔軟性、リアルタイム性といった新たなニーズに応える形で、NoSQLデータベースが注目を集めています。
Google Cloudの提供する「Firestore」は、Firebaseの一部としても提供されている、スケーラブルで高性能なNoSQLドキュメントデータベースです。特にモバイル、ウェブ、サーバー開発者にとって非常に扱いやすく設計されており、リアルタイム同期やオフライン対応といった強力な機能を備えています。
このガイドでは、Firestoreを初めて利用する方から、より深く理解したい方までを対象に、その基本概念から応用的な機能、さらには最適な利用方法までを詳細に解説します。約5000語をかけて、Firestoreの魅力を余すところなくお伝えします。
第1章:Firestoreの基本概念
Firestoreは、従来のRDBとは異なる「ドキュメント指向」のNoSQLデータベースです。この章では、Firestoreの最も基本的な構成要素である「コレクション」と「ドキュメント」、そしてデータの表現方法について掘り下げます。
1.1. ドキュメント指向とは?
RDBがデータをテーブル(スキーマ定義された行と列の集合)に格納するのに対し、ドキュメント指向データベースはデータを「ドキュメント」と呼ばれる単位で格納します。ドキュメントは、キーと値のペア(フィールド)の集合であり、構造は柔軟です。RDBのように事前に厳密なスキーマ定義を行う必要はありません。
FirestoreのドキュメントはJSONライクな構造を持ちますが、実際にはより多くのデータ型をサポートしています。この柔軟性が、変化の激しいアプリケーション開発において大きなメリットとなります。
1.2. コレクション(Collections)
コレクションは、ドキュメントを格納するためのコンテナです。RDBのテーブルに似ていますが、以下の点が異なります。
- ドキュメントの格納: コレクションはドキュメントのみを格納し、他のコレクションを直接格納することはできません(ただし、ドキュメント内にサブコレクションを持つことは可能です)。
- スキーマの柔軟性: コレクション内のドキュメントは、必ずしも同じフィールドを持つ必要はありません。各ドキュメントは独立した構造を持つことができます。
- 階層構造: コレクションは、データベースのルート直下、または特定のドキュメントの「サブコレクション」として存在できます。これにより、自然な階層構造を構築できます。
例:ユーザー情報を格納する users
コレクション、投稿を格納する posts
コレクションなど。
コレクション名は、 /
や .
などの特殊文字を含まない、有効なUTF-8文字列である必要があります。
1.3. ドキュメント(Documents)
ドキュメントは、Firestoreにおけるデータの最小単位です。各ドキュメントは、一意のIDを持ち、キーと値のペアの集合である「フィールド」で構成されます。
例:ユーザー情報を表すドキュメント
json
{
"name": "山田 太郎",
"email": "[email protected]",
"age": 30,
"isActive": true,
"address": {
"street": "渋谷区道玄坂 1-1-1",
"city": "東京都"
},
"tags": ["engineer", "firestore", "nosql"],
"createdAt": "2023-10-27T10:00:00Z"
}
- ID: ドキュメントIDは、自動生成することも、任意に指定することも可能です。自動生成されるIDは一意性が保証されており、順序性はありません。任意に指定する場合、そのコレクション内で一意である必要があります。
- フィールド: 各フィールドは、キー(フィールド名)と値のペアです。値には様々なデータ型を使用できます。
- サイズ制限: 1つのドキュメントには最大1MiB(1,048,576バイト)のデータしか格納できません。これは、過度に大きなドキュメント設計を防ぎ、パフォーマンスを維持するための重要な制約です。
1.4. データ型(Data Types)
Firestoreは、NoSQLながらリッチなデータ型をサポートしています。主要なデータ型は以下の通りです。
- String: テキスト文字列。
- Number: 整数または浮動小数点数。
- Boolean:
true
またはfalse
。 - Map: キーと値のペアの集合。ネストされたオブジェクトを表すのに使用します。ドキュメント内に構造化されたデータを格納できます。例:
address
フィールド。 - Array: 値のリスト。異なるデータ型の値を混在させることができます。例:
tags
フィールド。 - Null: Null値。
- Timestamp: 日時を表すタイムスタンプ。Firestore SDKは通常、ネイティブの日時オブジェクト(例:JavaScriptの
Date
オブジェクト)にマッピングします。 - GeoPoint: 緯度と経度。位置情報を格納するのに便利です。
- Reference: データベース内の他のドキュメントへの参照。RDBにおける外部キーのような関連付けに使用できますが、参照自体がデータを格納するわけではありません。
これらのデータ型を組み合わせることで、複雑なデータ構造をドキュメント内に表現できます。
1.5. サブコレクション(Subcollections)
ドキュメントはコレクションを格納できます。これをサブコレクションと呼びます。サブコレクションを使用することで、データを階層的に整理できます。
例:
* posts
(コレクション)
* postId1
(ドキュメント)
* title
: “Firestoreについて”
* content
: “Firestoreは素晴らしい…”
* comments
(サブコレクション)
* commentId1
(ドキュメント)
* author
: “ユーザーA”
* text
: “参考になりました!”
* commentId2
(ドキュメント)
* author
: “ユーザーB”
* text
: “別の視点も知りたいです”
* postId2
(ドキュメント)
* …
* comments
(サブコレクション)
* …
この構造により、特定の投稿 (postId1
) に関連するコメント (comments
サブコレクション) だけを効率的に取得できます。
サブコレクションは、親ドキュメントが削除されても自動的に削除されません。サブコレクション内のドキュメントを削除するには、明示的にコードで処理する必要があります。
1.6. 階層構造とパス
Firestore内のすべてのデータは、コレクションとドキュメントのパスによって一意に識別されます。パスは /
で区切られたコレクション名とドキュメント名のシーケンスです。
例:
* /users
(コレクション)
* /users/userId1
(ドキュメント)
* /posts/postId1/comments
(サブコレクション)
* /posts/postId1/comments/commentId1
(ドキュメント)
このパスは、データを読み書きする際に使用します。
第2章:Firestoreのセットアップ
Firestoreを利用するには、Firebaseプロジェクトが必要です。この章では、Firestoreをプロジェクトに追加し、アプリケーションからアクセスするための基本的な手順を解説します。
2.1. Firebaseプロジェクトの作成/選択
まだFirebaseプロジェクトがない場合は、Firebaseコンソール(console.firebase.google.com)にアクセスし、新しいプロジェクトを作成します。既存のプロジェクトがある場合は、それを使用できます。
2.2. Firestoreの有効化
Firebaseプロジェクトを選択または作成したら、左側のナビゲーションメニューから「Build」 > 「Firestore Database」を選択します。
- 「データベースを作成」ボタンをクリックします。
- セキュリティルールの設定を選択します。「本番環境用ロックモードで開始」または「テストモードで開始」を選びます。最初は「テストモード」でも構いませんが、公開するアプリケーションでは必ず「本番環境用」を選択し、後でセキュリティルールを適切に設定し直してください。
- Firestoreのロケーションを選択します。ユーザーの地理的な位置や他のGoogle Cloudサービスとの連携を考慮して、最適なリージョンを選択します。一度設定すると変更できないため、慎重に選びましょう。
- データベースがプロビジョニングされるまで待ちます。
これで、Firestoreデータベースがプロジェクト内で有効化されました。
2.3. アプリケーションへのSDKの追加
アプリケーションの種類(Web、Android、iOS、サーバーサイドなど)に応じて、対応するFirestore SDKをプロジェクトに追加します。
Webアプリケーションの場合(例:npm)
bash
npm install firebase
または
bash
yarn add firebase
初期化コード(Webの場合)
アプリケーションの初期化スクリプトにFirebaseアプリの初期化コードを追加します。Firebaseコンソールのプロジェクト設定から、ウェブアプリを追加する際に提供される設定オブジェクトを使用します。
“`javascript
// Import the functions you need from the SDKs you need
import { initializeApp } from “firebase/app”;
import { getFirestore } from “firebase/firestore”;
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app’s Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: “YOUR_API_KEY”,
authDomain: “YOUR_AUTH_DOMAIN”,
projectId: “YOUR_PROJECT_ID”,
storageBucket: “YOUR_STORAGE_BUCKET”,
messagingSenderId: “YOUR_MESSAGING_SENDER_ID”,
appId: “YOUR_APP_ID”,
measurementId: “YOUR_MEASUREMENT_ID”
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
// Get a reference to the Firestore service
const db = getFirestore(app);
// Now you can use ‘db’ to interact with Firestore
“`
他のプラットフォーム(Android, iOS, Node.jsなど)でも同様に、それぞれの方法でSDKを追加し、初期化を行います。初期化後、db
オブジェクト(またはそれに相当するもの)を通じてFirestoreデータベースへの操作を行います。
第3章:基本的なデータ操作(CRUD)
データベースの基本はCRUD操作です。Create(作成)、Read(読み取り)、Update(更新)、Delete(削除)の方法を、具体的なコード例とともに見ていきましょう。
Firestore SDKの操作は非同期です。ここではJavaScriptのPromiseまたはasync/await構文を使用して説明します。
3.1. データの作成(Create)
新しいドキュメントをコレクションに追加する方法は主に2つあります。
-
自動生成IDでドキュメントを追加する (
add()
)
Firestoreにドキュメントの内容だけを渡し、一意のIDを自動で生成させたい場合に便利です。
“`javascript
import { collection, addDoc } from “firebase/firestore”;async function addPost(title, content) {
try {
const docRef = await addDoc(collection(db, “posts”), {
title: title,
content: content,
createdAt: new Date() // Timestampとして保存される
});
console.log(“Document written with ID: “, docRef.id);
return docRef.id;
} catch (e) {
console.error(“Error adding document: “, e);
throw e;
}
}addPost(“Firestoreの基本”, “Firestoreは使いやすいデータベースです。”);
``
addDocは、追加されたドキュメントへの参照(
DocumentReference)を返します。その
id` プロパティで自動生成されたIDを取得できます。 -
任意のIDを指定してドキュメントを設定する (
set()
)
ユーザーIDなど、特定のIDをドキュメントIDとして使用したい場合に使います。指定したIDのドキュメントが既に存在する場合は、そのドキュメント全体が上書きされます。
“`javascript
import { doc, setDoc } from “firebase/firestore”;async function setUser(userId, name, email) {
try {
await setDoc(doc(db, “users”, userId), {
name: name,
email: email,
updatedAt: new Date()
});
console.log(“User document set with ID: “, userId);
} catch (e) {
console.error(“Error setting document: “, e);
throw e;
}
}setUser(“user_abc123”, “佐藤 花子”, “[email protected]”);
``
setDoc` は、成功すると何も返しません。
既存ドキュメントへのフィールド追加/更新 (set()
with { merge: true }
)
setDoc
に { merge: true }
オプションを付けると、ドキュメント全体を上書きするのではなく、指定したフィールドだけを追加または更新し、それ以外のフィールドはそのまま保持します。指定したドキュメントが存在しない場合は、新しく作成されます。
“`javascript
import { doc, setDoc } from “firebase/firestore”;
async function updateUserData(userId, newData) {
try {
await setDoc(doc(db, “users”, userId), newData, { merge: true });
console.log(“User document merged with ID: “, userId);
} catch (e) {
console.error(“Error merging document: “, e);
throw e;
}
}
updateUserData(“user_abc123”, { age: 25, city: “大阪市” }); // user_abc123ドキュメントにageとcityフィールドが追加/更新される
“`
3.2. データの読み取り(Read)
データは単一のドキュメントまたはコレクション全体(またはクエリ結果)として読み取ることができます。
-
単一ドキュメントの取得 (
get()
)
特定のコレクションの、特定のIDを持つドキュメントを取得します。
“`javascript
import { doc, getDoc } from “firebase/firestore”;async function getPost(postId) {
const docRef = doc(db, “posts”, postId);
const docSnap = await getDoc(docRef);if (docSnap.exists()) {
console.log(“Document data:”, docSnap.data());
return docSnap.data();
} else {
// doc.data() will be undefined in this case
console.log(“No such document!”);
return null;
}
}getPost(“auto_generated_post_id”);
``
getDocは
DocumentSnapshotを返します。
exists()メソッドでドキュメントが存在するか確認し、
data()` メソッドでドキュメントのフィールドを取得します。 -
コレクション内の全ドキュメントの取得 (
get()
)
コレクション内のすべてのドキュメントを取得します。ドキュメント数が多い場合は後述のクエリとページネーションを組み合わせるのが推奨されます。
“`javascript
import { collection, getDocs } from “firebase/firestore”;async function getAllPosts() {
const querySnapshot = await getDocs(collection(db, “posts”));
querySnapshot.forEach((doc) => {
// doc.data() is never undefined for query doc snapshots
console.log(${doc.id} => ${JSON.stringify(doc.data())}
);
});
return querySnapshot.docs.map(doc => ({ id: doc.id, …doc.data() }));
}getAllPosts();
``
getDocsは
QuerySnapshotを返します。
forEachメソッドでスナップショット内の各ドキュメントをループ処理できます。
docs` プロパティでドキュメントスナップショットの配列として取得することも可能です。
3.3. データの更新(Update)
既存のドキュメントの特定のフィールドを更新します。ドキュメントが存在しない場合は失敗します。
“`javascript
import { doc, updateDoc } from “firebase/firestore”;
async function updatePostTitle(postId, newTitle) {
const postRef = doc(db, “posts”, postId);
try {
await updateDoc(postRef, {
title: newTitle
});
console.log(“Post title updated successfully.”);
} catch (e) {
console.error(“Error updating document: “, e);
throw e;
}
}
updatePostTitle(“auto_generated_post_id”, “Firestoreの最新情報”);
“`
updateDoc
は複数のフィールドを同時に更新できます。
“`javascript
import { doc, updateDoc } from “firebase/firestore”;
async function updatePostFields(postId, updates) {
const postRef = doc(db, “posts”, postId);
try {
await updateDoc(postRef, updates); // updates は { field1: value1, field2: value2 } のようなオブジェクト
console.log(“Post fields updated successfully.”);
} catch (e) {
console.error(“Error updating document: “, e);
throw e;
}
}
updatePostFields(“auto_generated_post_id”, {
title: “更新されたタイトル”,
content: “更新されたコンテンツ”,
updatedAt: new Date()
});
“`
ネストされたフィールドを更新する場合は、ドット記法を使用します。
“`javascript
import { doc, updateDoc } from “firebase/firestore”;
async function updateAddressCity(userId, newCity) {
const userRef = doc(db, “users”, userId);
try {
await updateDoc(userRef, {
“address.city”: newCity // ネストされたフィールドはドットで指定
});
console.log(“User address city updated successfully.”);
} catch (e) {
console.error(“Error updating document: “, e);
throw e;
}
}
updateAddressCity(“user_abc123”, “京都市”);
“`
配列フィールドの操作
配列フィールドに要素を追加したり削除したりする場合、特別なフィールドバリューを使用します。
“`javascript
import { doc, updateDoc, arrayUnion, arrayRemove } from “firebase/firestore”;
async function updateUserTags(userId) {
const userRef = doc(db, “users”, userId);
try {
// ‘tags’ 配列に “database” と “cloud” を追加(既に存在すれば追加しない)
await updateDoc(userRef, {
tags: arrayUnion(“database”, “cloud”)
});
console.log(“Tags added.”);
// 'tags' 配列から "firestore" を削除
await updateDoc(userRef, {
tags: arrayRemove("firestore")
});
console.log("Tag 'firestore' removed.");
} catch (e) {
console.error(“Error updating tags: “, e);
throw e;
}
}
updateUserTags(“user_abc123”);
``
arrayUnionは重複を避けて要素を追加し、
arrayRemove` は指定した要素をすべて削除します。
3.4. データの削除(Delete)
ドキュメント全体またはドキュメント内の特定のフィールドを削除できます。
-
ドキュメントの削除 (
deleteDoc()
)
指定したドキュメントを完全に削除します。そのドキュメントのサブコレクションは自動的には削除されないことに注意が必要です。
“`javascript
import { doc, deleteDoc } from “firebase/firestore”;async function deletePost(postId) {
try {
await deleteDoc(doc(db, “posts”, postId));
console.log(“Post document deleted successfully.”);
} catch (e) {
console.error(“Error deleting document: “, e);
throw e;
}
}deletePost(“auto_generated_post_id”);
“` -
ドキュメント内のフィールドの削除 (
updateDoc()
withFieldValue.delete()
)
ドキュメント全体ではなく、特定のフィールドだけを削除したい場合に使います。
“`javascript
import { doc, updateDoc, deleteField } from “firebase/firestore”;async function deleteUserAge(userId) {
const userRef = doc(db, “users”, userId);
try {
await updateDoc(userRef, {
age: deleteField()
});
console.log(“User age field deleted successfully.”);
} catch (e) {
console.error(“Error deleting field: “, e);
throw e;
}
}deleteUserAge(“user_abc123”);
``
deleteField()` は、そのフィールドをドキュメントから削除するための特別な値です。
コレクションの削除
Firestoreには、コレクション全体を単一の操作で削除する機能はありません。これは、誤って大量のデータを削除することを防ぐための設計です。コレクションを削除するには、そのコレクション内のすべてのドキュメント(およびそのサブコレクション内のドキュメント)を再帰的に削除する必要があります。これは通常、サーバーサイドのコード(Cloud Functionsなど)を使用して実装されます。Firestore SDKには、ドキュメントのバッチ削除を支援する機能がありますが、コレクションの再帰的な削除自体は手動でコーディングが必要です。Firebase CLIには、コレクションを削除するためのコマンドが提供されています(ただし、本番環境での使用は慎重に)。
第4章:データのクエリ(Queries)
単にドキュメントを取得するだけでなく、特定の条件に一致するドキュメントを検索したり、結果を並べ替えたり、取得数を制限したりすることが、実用的なアプリケーションでは必須です。Firestoreのクエリ機能を見ていきましょう。
クエリは、collection()
関数でコレクション参照を取得した後、それに様々なメソッドをチェインしていく形で構築します。
4.1. フィルタリング(Filtering: where()
)
where()
メソッドを使用して、フィールドの値に基づきドキュメントをフィルタリングします。
等価比較 (==
)
“`javascript
import { collection, query, where, getDocs } from “firebase/firestore”;
async function getActiveUsers() {
const q = query(collection(db, “users”), where(“isActive”, “==”, true));
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
}
getActiveUsers();
“`
不等価比較 (!=
, <
, <=
, >
, >=
)
数値、文字列、タイムスタンプなどのフィールドで不等価比較が可能です。
“`javascript
import { collection, query, where, getDocs } from “firebase/firestore”;
async function getPostsBeforeDate(date) {
const q = query(collection(db, “posts”), where(“createdAt”, “<“, date));
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
}
getPostsBeforeDate(new Date()); // 現在時刻より前に作成された投稿を取得
“`
配列メンバーでのフィルタリング (array-contains
, array-contains-any
)
array-contains
は、配列フィールドが特定の値を 少なくとも1つ 含んでいるドキュメントを検索します。
“`javascript
import { collection, query, where, getDocs } from “firebase/firestore”;
async function getUsersByTag(tag) {
const q = query(collection(db, “users”), where(“tags”, “array-contains”, tag));
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
}
getUsersByTag(“engineer”); // tags配列に”engineer”を含むユーザーを取得
“`
array-contains-any
は、配列フィールドが指定された値のリスト内の いずれか を含んでいるドキュメントを検索します。最大10個の値のリストを指定できます。
“`javascript
import { collection, query, where, getDocs } from “firebase/firestore”;
async function getUsersByAnyTag(tags) { // tags = [“engineer”, “designer”] など
const q = query(collection(db, “users”), where(“tags”, “array-contains-any”, tags));
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
}
getUsersByAnyTag([“engineer”, “firestore”]); // tags配列に”engineer”または”firestore”を含むユーザーを取得
“`
複数値一致でのフィルタリング (in
, not-in
)
in
は、フィールドの値が指定された値のリスト内の いずれか に一致するドキュメントを検索します。最大10個の値のリストを指定できます。
“`javascript
import { collection, query, where, getDocs } from “firebase/firestore”;
async function getUsersByRoles(roles) { // roles = [“admin”, “editor”] など
const q = query(collection(db, “users”), where(“role”, “in”, roles));
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
}
getUsersByRoles([“admin”, “editor”]); // roleフィールドが”admin”または”editor”のユーザーを取得
“`
not-in
は、フィールドの値が指定された値のリスト内の いずれにも一致しない ドキュメントを検索します。最大10個の値のリストを指定できます。null
と NaN
は not-in
では使用できません。
“`javascript
import { collection, query, where, getDocs } from “firebase/firestore”;
async function getUsersNotInCities(cities) { // cities = [“東京都”, “大阪市”] など
const q = query(collection(db, “users”), where(“city”, “not-in”, cities));
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
}
getUsersNotInCities([“東京都”, “大阪市”]); // cityフィールドが”東京都”でも”大阪市”でもないユーザーを取得
“`
4.2. 並べ替え(Ordering: orderBy()
)
orderBy()
メソッドを使用して、クエリ結果を1つまたは複数のフィールドで並べ替えることができます。昇順 (asc
) または降順 (desc
) を指定できます(デフォルトは昇順)。
“`javascript
import { collection, query, orderBy, getDocs } from “firebase/firestore”;
async function getPostsOrderedByDate() {
const q = query(collection(db, “posts”), orderBy(“createdAt”, “desc”)); // 作成日の新しい順
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
}
getPostsOrderedByDate();
“`
複数のフィールドで並べ替えることも可能です。並べ替えの順番は重要です。
“`javascript
import { collection, query, orderBy, getDocs } from “firebase/firestore”;
async function getUsersByCityAndAge() {
// まず都市で昇順、次に年齢で昇順
const q = query(collection(db, “users”), orderBy(“address.city”), orderBy(“age”, “asc”));
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
}
getUsersByCityAndAge();
“`
4.3. 取得件数の制限(Limiting: limit()
)
limit()
メソッドを使用して、クエリ結果として取得するドキュメントの最大数を指定できます。
“`javascript
import { collection, query, orderBy, limit, getDocs } from “firebase/firestore”;
async function getLatest5Posts() {
const q = query(collection(db, “posts”), orderBy(“createdAt”, “desc”), limit(5)); // 最新5件
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
}
getLatest5Posts();
“`
4.4. クエリの組み合わせとインデックス
複数の where()
、orderBy()
、limit()
を組み合わせて、より複雑なクエリを作成できます。ただし、特定のクエリの組み合わせには「インデックス」が必要になります。
Firestoreは、いくつかの単純なクエリに対しては自動的にインデックスを使用しますが、以下のような複雑なクエリには複合インデックスの手動での作成が必要になることが多いです。
- 複数のフィールドでのフィルタリング (
where()
を複数使用)。 - フィルタリングされたフィールドとは異なるフィールドでの並べ替え (
where()
とorderBy()
を異なるフィールドで使用)。 - 複数のフィールドでの並べ替え (
orderBy()
を複数使用)。
複合インデックスが必要なクエリを実行しようとすると、エラーが発生します。エラーメッセージには、Firebaseコンソールで必要なインデックスを作成するためのリンクが表示されます。リンクをクリックして、簡単にインデックスを作成できます。
インデックスはクエリのパフォーマンスを向上させますが、データの書き込み(作成、更新、削除)にはオーバーヘッドが発生し、ストレージ使用量も増加します。そのため、必要なインデックスだけを作成するのがベストプラクティスです。
4.5. ページネーション(Pagination)
大量のデータを一度に取得するのではなく、ページごとに取得することで、アプリケーションの応答性を高め、コストを抑えることができます。Firestoreはカーソルベースのページネーションをサポートしています。
ページネーションは、startAt()
, startAfter()
, endBefore()
, endAt()
メソッドを使用し、開始点または終了点を指定します。これらのメソッドには、フィールドの値またはドキュメントスナップショットを渡すことができます。ドキュメントスナップショットを使用するのが一般的で、前のページの最後のドキュメントをカーソルとして使用します。
“`javascript
import { collection, query, orderBy, limit, startAfter, getDocs } from “firebase/firestore”;
let lastVisibleDoc = null; // 最後のドキュメントを保持する変数
async function getNextPageOfPosts(pageSize = 10) {
let q;
if (lastVisibleDoc) {
// 最後のドキュメントの次から取得
q = query(collection(db, “posts”),
orderBy(“createdAt”, “desc”), // ページネーションに使用するorderByと一致させる
startAfter(lastVisibleDoc),
limit(pageSize));
} else {
// 最初のページ
q = query(collection(db, “posts”),
orderBy(“createdAt”, “desc”),
limit(pageSize));
}
const querySnapshot = await getDocs(q);
if (querySnapshot.docs.length > 0) {
// 取得したドキュメントの最後のものを保存し、次のページネーションの開始点とする
lastVisibleDoc = querySnapshot.docs[querySnapshot.docs.length – 1];
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.data()}
);
});
return querySnapshot.docs.map(doc => ({ id: doc.id, …doc.data() }));
} else {
console.log(“No more documents.”);
lastVisibleDoc = null; // 全て取得したらリセット
return [];
}
}
// 最初のページを取得
getNextPageOfPosts();
// 次のページを取得(ユーザー操作などに応じて呼び出す)
// getNextPageOfPosts();
``
startAfter(lastVisibleDoc)は、
lastVisibleDoc*の次* から取得を開始します。
startAt(lastVisibleDoc)は、
lastVisibleDoc*を含めて* 取得を開始します。同様に、
endBeforeと
endAt` があります。
ページネーションには、クエリが orderBy()
を使用している必要があり、startAt/After
および endBefore/At
に渡すフィールドの値またはドキュメントスナップショットは、クエリの orderBy()
句と一致する必要があります。
第5章:リアルタイムアップデート(Realtime Updates)
Firestoreの最も強力な機能の一つが、リアルタイムでデータの変更を監視し、アプリケーションに反映する機能です。これは、チャットアプリ、共同編集ツール、ライブダッシュボードなどの開発に非常に便利です。
リアルタイムアップデートは、onSnapshot()
メソッドを使用して実現します。これは、クエリの結果や特定のドキュメントへの変更を監視し、変更が発生するたびにコールバック関数を実行します。
5.1. 単一ドキュメントの監視
“`javascript
import { doc, onSnapshot } from “firebase/firestore”;
const docRef = doc(db, “posts”, “your_post_id”);
const unsubscribe = onSnapshot(docRef, (docSnap) => {
if (docSnap.exists()) {
console.log(“Current data: “, docSnap.data());
} else {
console.log(“Document was removed or does not exist.”);
}
}, (error) => {
console.error(“Error listening to document:”, error);
});
// 監視を停止したい場合は、unsubscribe() を呼び出す
// unsubscribe();
``
onSnapshot` は初期データも一度返します。その後、監視しているドキュメントが変更、削除、または存在しないことが判明するたびに、コールバックが再度実行されます。
5.2. クエリ結果(コレクションまたはフィルタリングされたリスト)の監視
クエリ結果全体への変更を監視することもできます。新しいドキュメントの追加、既存ドキュメントの変更、ドキュメントの削除などが検知されます。
“`javascript
import { collection, query, orderBy, onSnapshot } from “firebase/firestore”;
const q = query(collection(db, “posts”), orderBy(“createdAt”, “desc”));
const unsubscribe = onSnapshot(q, (querySnapshot) => {
querySnapshot.forEach((doc) => {
console.log(${doc.id} => ${JSON.stringify(doc.data())}
);
});
console.log(“— End of current posts —“);
}, (error) => {
console.error(“Error listening to query:”, error);
});
// 監視を停止
// unsubscribe();
“`
このコールバックは、クエリ結果全体が取得されるたびに(初期取得時と変更発生時)実行されます。
5.3. 変更点の詳細取得 (docChanges()
)
onSnapshot
のコールバックで渡される QuerySnapshot
は、変更があったドキュメントのスナップショットのリスト (docs
プロパティ) を持ちますが、どのような変更(追加、更新、削除)が発生したかを詳細に知りたい場合は、docChanges()
メソッドを使用します。
“`javascript
import { collection, query, orderBy, onSnapshot } from “firebase/firestore”;
const q = query(collection(db, “posts”), orderBy(“createdAt”, “desc”));
const unsubscribe = onSnapshot(q, (querySnapshot) => {
querySnapshot.docChanges().forEach((change) => {
if (change.type === “added”) {
console.log(“New post: “, change.doc.data());
}
if (change.type === “modified”) {
console.log(“Modified post: “, change.doc.data());
}
if (change.type === “removed”) {
console.log(“Removed post: “, change.doc.data());
}
});
}, (error) => {
console.error(“Error listening to query:”, error);
});
// 監視を停止
// unsubscribe();
``
docChanges()は、前回のスナップショットからの変更点に関する情報を格納したオブジェクトの配列を返します。各変更オブジェクトには、
type(
‘added’,
‘modified’,
‘removed’)、
doc(変更されたドキュメントのスナップショット)、
oldIndex(変更前の位置)、
newIndex`(変更後の位置)が含まれます。これは、リスト表示などを効率的に更新する際に非常に便利です。
5.4. リスナーのデタッチ
onSnapshot()
メソッドは、監視を停止するための関数を返します。アプリケーションが不要になったリスナーを適切にデタッチすることは、リソースの解放と課金の最適化のために非常に重要です。例えば、コンポーネントがアンマウントされる際にリスナーをデタッチします。
第6章:データモデリング戦略
FirestoreのようなNoSQLデータベースでは、RDBとは異なるデータモデリングのアプローチが必要です。JOIN操作が存在しないため、クエリの効率を最大化するためにデータを非正規化したり、クエリパターンに合わせてデータを構造化したりすることが一般的です。
6.1. 非正規化(Denormalization)
RDBではデータの重複を避けるために正規化を行いますが、Firestoreでは読み取りパフォーマンス向上のために意図的にデータを重複させることがよくあります。
例:ブログ投稿と著者情報
RDBなら posts
テーブルと users
テーブルをJOINして投稿リストと著者の名前を表示しますが、Firestoreでは posts
ドキュメントに著者のIDだけでなく、頻繁に表示される名前やプロフィール画像URLなども一緒に埋め込む(非正規化する)ことが多いです。
json
// posts/postId1 ドキュメント
{
"title": "Firestoreについて",
"content": "...",
"authorId": "userId123",
"authorName": "山田 太郎", // 非正規化されたデータ
"authorAvatarUrl": "...", // 非正規化されたデータ
"createdAt": "..."
}
こうすることで、投稿リストを表示する際に users
コレクションを別途クエリする必要がなくなり、読み取り回数とレイテンシが削減されます。ただし、著者の名前が変更された場合は、その著者が関与するすべての投稿ドキュメントを更新する必要があるというデメリットもあります(これは、Cloud Functionsなどを使用して自動化できます)。
6.2. 埋め込み(Embedding) vs 参照(Referencing)
関連するデータをドキュメント内に埋め込むか、他のドキュメントへの参照として持つかを選択します。
-
埋め込み:
- 関連データが比較的小さく、一緒に頻繁に取得される場合。
- データの変更頻度が低い場合。
- 例:ユーザーの住所、商品の詳細情報、投稿に付随するタグのリスト。
- メリット:単一の読み取り操作で関連データを取得できるため高速。
- デメリット:親ドキュメントのサイズが大きくなる可能性、関連データの更新時に複数の場所を更新する必要がある。
-
参照:
- 関連データが大きい場合、またはそのサイズが変動する場合。
- 関連データが頻繁に変更される場合。
- 関連データ自体が独立したエンティティとしてクエリされる場合。
- 例:投稿に対するコメント(サブコレクションがより一般的だが、コメント数が非常に多い場合は参照+別途クエリも検討)、ユーザーのお気に入りリスト(投稿IDの配列)、他のユーザーへの言及。
- メリット:親ドキュメントのサイズを小さく保てる、関連データの更新が容易(そのドキュメントだけを更新すればよい)。
- デメリット:関連データを取得するために別途読み取り操作が必要になる(N+1問題に注意)。
6.3. サブコレクションの活用
サブコレクションは、ドキュメントと関連付けられたデータのコレクションを格納するのに適しています。
- ユースケース:
- ブログ投稿へのコメント
- タスクリストの各タスクに紐づくサブタスク
- チャットルーム内のメッセージ
- メリット:
- 親ドキュメント(例:投稿ドキュメント)から独立してスケーリングする。コメント数が増えても投稿ドキュメント自体のサイズは変わらない。
- 特定の親ドキュメントに関連するデータだけを効率的にクエリできる。
- デメリット:
- 親ドキュメントを削除してもサブコレクションは自動削除されない。
- サブコレクション内の全ドキュメントを跨いだクエリはできない(例:全投稿のコメントから特定のユーザーのコメントを探す、といった操作は難しいか非効率)。
6.4. 配列とMapの使い分け
- Array:
- シンプルな値のリスト。
- 要素の順序が重要でない場合が多い。
array-contains
やarray-contains-any
でクエリ可能。- 例:タグのリスト、ユーザーが「いいね」した投稿IDのリスト。
- Map:
- 構造化された、キーと値のペアの集まり。
- 各フィールドに名前を付けてデータを格納したい場合。
- 例:ユーザーの住所(
{ street: ..., city: ... }
)、商品の属性({ color: "Red", size: "L" }
)。
大きな配列やMapはドキュメントサイズ制限に影響するため注意が必要です。また、配列内の特定の要素の値に基づいて複雑なクエリを行いたい場合は、配列ではなく別のコレクションとしてデータを管理することを検討します。
6.5. アンチパターン
- 巨大な配列: 配列の要素数が極端に多い場合、ドキュメントサイズ制限に達したり、特定の要素の検索や更新が非効率になったりします。別のコレクションに移動するか、サブコレクションを使用することを検討します。
- 深いネスト: ドキュメント内にMapを多重にネストしすぎると、構造が複雑になり、特定のフィールドにアクセスするためのパスが長くなります。
- 頻繁に更新されるカウンター: ドキュメント内のカウンターフィールドを多数のクライアントが同時に更新しようとすると、書き込み競合が発生しやすくなります。分散カウンターのようなアプローチを検討します。
- リレーションシップの再現: RDBのJOINや外部キーの関係をそのままFirestoreで再現しようとすると、非効率なクエリや複雑なコードになります。NoSQLの思想に合わせてデータを非正規化し、クエリパターンを最適化します。
データモデリングはアプリケーションのパフォーマンスとスケーラビリティに大きな影響を与えます。開発の初期段階で、どのようなクエリが必要になるかを十分に検討し、それに合わせたデータ構造を設計することが重要です。後から大規模なデータ構造の変更を行うのはコストがかかります。
第7章:セキュリティルール
Firestoreのセキュリティルールは、データベース内のデータへのアクセスを制御するための非常に重要な仕組みです。クライアントサイドSDKからのすべての読み取りおよび書き込みリクエストは、サーバー側でセキュリティルールによって評価されます。適切に設定しないと、予期せぬデータの読み取り、書き込み、改ざん、削除が発生する可能性があります。
セキュリティルールは、FirebaseコンソールのFirestoreセクション、またはFirebase CLIを使ってデプロイできます。ルールは独自の構文で記述されます。
7.1. 基本構造
firestore
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// ルールはここに記述
match /{document=**} { // 全てのドキュメントにマッチする例
allow read, write: if false; // デフォルトでは全て拒否
}
}
}
rules_version
: ルールのバージョンを指定します(現在 ‘2’ が推奨)。service cloud.firestore
: Firestoreサービスに対するルールであることを示します。match /databases/{database}/documents
: プロジェクトのデフォルトデータベースにマッチします。複数のデータベースを使用している場合は、適切なパスを指定します。match /{document=**}
: これはワイルドカードマッチングで、/databases/{database}/documents
以下のすべてのドキュメントパスにマッチします。{document=**}
は再帰的なワイルドカードで、パスのどこにあってもマッチします。allow read, write: if <condition>;
: 指定されたパスに対する操作(read
またはwrite
)を許可するかどうかを、<condition>
がtrue
の場合に判断します。
操作の種類:
* read
: get
およびクエリ(list
)操作を許可します。
* get
: 単一ドキュメントの読み取り。
* list
: コレクション内のドキュメントのクエリ(コレクション全体またはフィルタリングされたサブセット)。
* write
: create
、update
、delete
操作を許可します。
* create
: 新しいドキュメントの作成。
* update
: 既存ドキュメントの更新。
* delete
: ドキュメントの削除。
これらの操作はまとめて read
や write
で指定することも、個別に create
, update
, delete
, get
, list
で指定することも可能です。例えば、作成だけを許可し、更新や削除は禁止するといった細かい制御が可能です。
7.2. パスマッチングとワイルドカード
match
ステートメントは、データベース内のドキュメントパスをパターンマッチングします。
- 単一ワイルドカード
{id}
: パスの単一セグメント(コレクション名またはドキュメントID)にマッチし、その値を変数id
に格納します。
firestore
match /users/{userId} {
// /users/alice または /users/bob にマッチ
}
match /posts/{postId}/comments/{commentId} {
// /posts/post1/comments/commentA などにマッチ
} - 再帰的ワイルドカード
{document=**}
: パスの任意の数のセグメントにマッチします。これは、特定のコレクション配下のすべてのドキュメントとそのサブコレクションのドキュメントにルールを適用したい場合などに便利です。
より具体的なパスにマッチするルールは、より一般的なパスにマッチするルールよりも優先されます。
7.3. 認証状態の利用 (request.auth
)
多くのアプリケーションでは、ユーザーのログイン状態やユーザーIDに基づいてアクセス制御を行います。セキュリティルールでは、request.auth
オブジェクトを通じて認証されたユーザーの情報を取得できます。
request.auth.uid
: 認証されたユーザーのユニークなID(Firebase AuthenticationのUID)。未認証の場合はnull
。request.auth.token
: 認証されたユーザーの認証トークンに含まれるペイロード(カスタムクレームなど)。
例:ログインしているユーザーのみがデータを読み書きできるルール
firestore
match /someCollection/{docId} {
allow read, write: if request.auth != null;
}
例:ユーザーは自分のドキュメント (/users/{userId}
) のみを読み書きできるルール
firestore
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
7.4. リクエストデータの検証 (request.resource.data
)
書き込み(create
, update
)操作の場合、request.resource.data
を使用して、書き込まれるデータの値を検証できます。
request.resource.data
: 書き込み操作で送信される新しいドキュメントのデータ。resource.data
: 既にデータベースに存在する、対象ドキュメントの現在のデータ(update
,delete
操作の場合に利用可能)。
例:新しい投稿を作成する際に、title
フィールドが存在し、文字列であり、空でないことを確認するルール
firestore
match /posts/{postId} {
allow create: if request.auth != null &&
request.resource.data.title is string &&
request.resource.data.title.size() > 0;
}
例:ユーザーが自分のプロフィールを更新する際に、role
フィールドを変更できないようにするルール
firestore
match /users/{userId} {
allow update: if request.auth != null &&
request.auth.uid == userId &&
request.resource.data.role == resource.data.role; // 新しい role が現在の role と同じであること
}
7.5. コレクション内のすべてのドキュメントに対するリスト操作の制御 (list
)
read
は get
と list
を含みますが、コレクション全体やクエリ結果のリスト操作 (list
) は、個別のドキュメントの取得 (get
) とは異なるセキュリティ上の考慮事項があります。たとえば、ユーザーは自分のプロフィールは読み取れても、他の全ユーザーのリストを取得することは許可しない、といったケースです。
list
操作には、コレクション内のすべてのドキュメントをスキャンする可能性があるため、パフォーマンスやコストの観点からも注意が必要です。list
ルールは、クエリの結果セット全体に対して評価されるのではなく、クエリ自体がルールによって許可されるかどうかを判断します。つまり、ルールに合致しない可能性のあるドキュメントを含むクエリは許可されません。
例:ログインユーザーは自分のドキュメントは get
できるが、コレクション全体の list
はできない。
firestore
match /users/{userId} {
allow get: if request.auth != null && request.auth.uid == userId;
// allow list: ... // list を許可しない場合は記述しないか、if false をつける
}
7.6. テストとデプロイ
Firebaseコンソールには、セキュリティルールをシミュレーションしてテストするためのツールがあります。様々なユーザー認証状態やデータ内容で読み書き操作を試すことで、ルールの意図しない挙動を防ぐことができます。
ルールは、コンソールで編集して公開するか、Firebase CLIを使用してローカルファイルからデプロイします。本番環境にデプロイする前に、必ず十分なテストを行ってください。
第8章:オフラインサポート
Firestore SDKは、オフライン環境でも機能するように設計されています。これは、モバイルアプリケーションなどでネットワーク接続が不安定な場合や、完全にオフラインになっている場合に非常に便利です。
8.1. オフラインでの読み取り
Firestore SDKは、デバイスのローカルにデータのキャッシュを保持します。アプリケーションは、ネットワーク接続があるかないかに関わらず、このローカルキャッシュからデータを読み取ることができます。
get()
による読み取り:デフォルトでは、キャッシュがあればまずキャッシュからデータを返し、次にサーバーからの最新データを取得しようとします。Source
オプション(cache
,server
,default
)を指定して、キャッシュのみ、またはサーバーのみから読み取るように制御することも可能です。onSnapshot()
によるリスニング:最初にローカルキャッシュからデータを返し、その後ネットワークを通じてサーバーと同期し、変更があれば通知します。
8.2. オフラインでの書き込み
Firestore SDKで書き込み操作(add
, set
, update
, delete
)を行うと、その変更はまずローカルキャッシュにコミットされ、即座にアプリケーションのUIに反映されます。その後、SDKはバックグラウンドでその変更をFirestoreサーバーに同期しようとします。
ネットワーク接続が回復すると、キューに積まれたオフラインでの書き込み操作が自動的にサーバーに送信され、データベースが同期されます。この際、他のクライアントによる変更と競合が発生する可能性がありますが、Firestoreはそれを検知し、トランザクションなど適切な手段が講じられていれば解決します。
8.3. 持続性(Persistence)の有効化
デフォルトでは、ローカルキャッシュはインメモリです。つまり、アプリケーションが終了するとキャッシュは失われます。デバイス間でキャッシュを保持したい場合は、永続性を有効にする必要があります。
“`javascript
import { initializeApp } from “firebase/app”;
import { getFirestore, enableIndexedDbPersistence } from “firebase/firestore”;
const firebaseConfig = { / … / };
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
// 持続性を有効化(ブラウザ、Android、iOSで利用可能)
try {
await enableIndexedDbPersistence(db); // Webの場合
console.log(“Offline persistence enabled”);
} catch (err) {
if (err.code == ‘failed-precondition’) {
// 複数のタブやウィンドウでPersistenceが有効になっている
console.log(“Persistence could not be enabled, likely because it’s already in use in another tab or window.”);
} else if (err.code == ‘unimplemented’) {
// 現在のブラウザではPersistenceがサポートされていない
console.log(“Persistence is not available in this environment.”);
} else {
console.error(“Error enabling persistence”, err);
}
}
// これ以降、Firestore操作はオフラインに対応
“`
enableIndexedDbPersistence
(Web) またはそれに相当するメソッドをアプリケーション起動時に一度だけ呼び出します。これにより、データがIndexedDB(Web)またはデバイスのファイルシステム(モバイル)に保存され、アプリケーションを再起動してもキャッシュが維持されます。
永続性を有効にすることで、オフライン対応が強化されますが、ストレージの使用量が増加することに注意が必要です。
第9章:トランザクションとバッチ書き込み
複数の読み取りおよび書き込み操作をアトミックに実行したい場合や、複数のドキュメントに対する書き込みを効率的に行いたい場合に、トランザクションとバッチ書き込みが役立ちます。
9.1. トランザクション(Transactions)
トランザクションは、複数の読み取りおよび書き込み操作を1つのアトミックな単位として実行します。トランザクション内のすべての操作が成功するか、すべて失敗するかのいずれかになります。これにより、複数のクライアントが同時に同じデータを変更しようとした場合に発生する競合(レースコンディション)を防ぎ、データの一貫性を保つことができます。
Firestoreトランザクションはオプティミスティック並行制御を採用しています。トランザクションを開始し、データを読み取り、変更を計算し、書き込もうとします。書き込み時に、読み取ったデータがトランザクション開始時から変更されていないかを確認します。変更されている場合、トランザクションは自動的にリトライされます。そのため、トランザクション関数は、複数回実行されても安全(べき等)である必要があります。
“`javascript
import { doc, getDoc, runTransaction } from “firebase/firestore”;
async function transferFunds(fromAccountId, toAccountId, amount) {
const fromAccountRef = doc(db, “accounts”, fromAccountId);
const toAccountRef = doc(db, “accounts”, toAccountId);
try {
await runTransaction(db, async (transaction) => {
// 1. トランザクション内でドキュメントを読み取る
const fromAccountSnap = await transaction.get(fromAccountRef);
const toAccountSnap = await transaction.get(toAccountRef);
if (!fromAccountSnap.exists()) {
throw "Source account does not exist!";
}
if (!toAccountSnap.exists()) {
throw "Destination account does not exist!";
}
const fromBalance = fromAccountSnap.data().balance;
const toBalance = toAccountSnap.data().balance;
if (fromBalance < amount) {
throw "Insufficient funds in source account!";
}
// 2. 新しい値を計算
const newFromBalance = fromBalance - amount;
const newToBalance = toBalance + amount;
// 3. トランザクション内でドキュメントを書き込む(set, update, delete)
transaction.update(fromAccountRef, { balance: newFromBalance });
transaction.update(toAccountRef, { balance: newToBalance });
console.log("Transaction successful.");
});
} catch (e) {
console.error(“Transaction failed: “, e);
}
}
// 例: account1 から account2 に 100 単位送金
transferFunds(“account1”, “account2”, 100);
“`
トランザクション内の読み取りは、トランザクションの開始時点ではなく、get()
が呼び出された時点での最新データを取得します。すべての読み取り操作は、すべての書き込み操作の前に実行する必要があります。
9.2. バッチ書き込み(Batched Writes)
バッチ書き込みは、複数のドキュメントに対する作成 (set
)、更新 (update
)、削除 (delete
) 操作を、単一のネットワークラウンドトリップで実行するためのものです。これにより、個々の書き込みを繰り返し行うよりも効率が向上します。
バッチ書き込みはアトミックではありません。バッチ内のいずれかの操作が失敗しても、他の操作は成功する可能性があります。ただし、複数の操作が同時に実行されることによるパフォーマンス上の利点があります。
“`javascript
import { doc, writeBatch } from “firebase/firestore”;
async function updateMultiplePosts(postUpdates) { // postUpdates は { postId: { title: …, content: … }, … } のようなオブジェクト
const batch = writeBatch(db);
for (const postId in postUpdates) {
const postRef = doc(db, “posts”, postId);
batch.update(postRef, postUpdates[postId]);
}
try {
await batch.commit();
console.log(“Batch write successful.”);
} catch (e) {
console.error(“Batch write failed: “, e);
throw e;
}
}
const updates = {
“postId1”: { title: “更新1”, updatedAt: new Date() },
“postId2”: { content: “更新2コンテンツ”, updatedAt: new Date() }
};
updateMultiplePosts(updates);
“`
バッチには最大500個の操作を含めることができます。トランザクションと同様に、バッチ内の操作はサーバー側で実行されます。
第10章:料金体系
Firestoreの料金は、主に以下の要素に基づいて計算されます。
- ドキュメントの読み取り、書き込み、削除: 最も主要な課金要素です。
- ドキュメントを1つ読み取るごとに1回の読み取りとしてカウントされます。
- クエリでは、クエリに一致し、クライアントに返されるドキュメントごとに1回の読み取りとしてカウントされます。クエリ自体(インデックスのスキャンなど)に追加のコストが発生する場合もあります(特に複合インデックス)。
onSnapshot
によるリスニングでは、初期スナップショットの読み取り回数に加え、変更が発生したドキュメントごとに読み取り回数がカウントされます。- ドキュメントを1つ作成、更新、または削除するごとに1回の書き込み/削除としてカウントされます。
- トランザクションやバッチ書き込み内の各操作も個別にカウントされます。
- 保存データ容量: データベースに保存されているデータの総量に基づきます。
- ネットワーク egress: データベースからネットワーク経由でデータが送信される量に基づきます。特にGoogle Cloud Platform外への送信に課金されます。
- インデックスの使用量: インデックス自体が占めるストレージ容量に課金されます。
Firestoreには generous な無料枠が用意されています。例えば、1日あたりの読み取り回数、書き込み回数、削除回数、および保存データ容量には無料枠があります。開発や小規模なアプリケーションであれば、多くの場合無料枠内に収まります。しかし、アプリケーションがスケールし、無料枠を超えると課金が発生します。
料金を最適化するためには、以下の点を考慮する必要があります。
- 不要な読み取りを避ける: 必要なデータだけを取得するようにクエリを最適化します。リアルタイムリスナーは、不要になったらデタッチすることを忘れないでください。
- データ構造を最適化: 読み取りに必要なデータを効率的に取得できるよう、非正規化などを適切に行います。
- 大きなドキュメントを避ける: ドキュメントサイズが大きいと、読み取り/書き込みごとのデータ転送量が増加します。
- インデックスを管理: 必要最低限のインデックスのみを作成し、不要なインデックスは削除します。
- コレクションの削除: 大量のドキュメントを含むコレクションを再帰的に削除する場合、削除操作にも課金が発生します。
詳細な料金情報は、Firebaseの公式料金ページで確認してください。
第11章:Firestoreと他のデータベースとの比較(概要)
11.1. Firestore vs Firebase Realtime Database (RTDB)
FirestoreとRTDBはどちらもFirebaseのNoSQLデータベースですが、設計思想と最適なユースケースが異なります。
特徴 | Firestore | Realtime Database |
---|---|---|
データ構造 | ドキュメント指向 (コレクション > ドキュメント > マップ/配列/基本型) | JSONツリー指向 (キー > 値のネスト) |
クエリ | 高機能 (複合クエリ、不等価、配列クエリなど) | 基本的 (単純なフィルタリング、ソート) |
スケーリング | 大規模なデータセットや複雑なクエリにも対応 | 単一巨大JSONツリーの限界がある |
整合性 | 強力 (読み取りは常に最新データまたはキャッシュから) | イベント順序に依存、レイテンシで不整合も |
リージョン | マルチリージョン、リージョン設定可能 | グローバル単一インスタンス |
課金 | 読み書き回数、保存容量、ネットワーク egress | データ転送量、保存容量、接続時間 |
開発 | 新しく推奨される | 先行サービス、一部ユースケースで有利 |
Firestoreが適しているケース:
* 複雑なクエリが必要な場合。
* 大規模なデータセットを扱う場合。
* 強いデータ整合性が求められる場合。
* 地域ごとにデータを配置したい場合。
RTDBが適しているケース:
* 非常にリアルタイム性が高く、かつデータ構造がシンプルで巨大なJSONツリーにならない場合(例: ゲームの状態、チャットのメッセージ履歴)。
* データ転送量が主な課金要素となるため、読み書き回数が多くても1回あたりのデータ量が少ない場合。
11.2. Firestore vs 従来のリレーショナルデータベース (RDBMS)
特徴 | Firestore (NoSQL ドキュメント) | RDBMS (SQL リレーショナル) |
---|---|---|
スキーマ | スキーマレス (柔軟) | 固定スキーマ (厳格) |
JOIN | なし (非正規化や複数クエリで対応) | あり |
スケーリング | 水平スケーリングが容易 | 垂直スケーリングが一般的、水平は複雑 |
クエリ | ドキュメント内やコレクション内のクエリに強い | 複数のテーブルを跨いだ複雑なクエリに強い (JOIN) |
整合性 | 結果整合性 (トランザクションは強い) | 強い整合性 (ACIDトランザクション) |
開発速度 | 初期スキーマ設計不要で開発開始が速い | 厳格なスキーマ設計が必要だが、一貫性維持が容易 |
Firestoreが適しているケース:
* スキーマが頻繁に変更される可能性のあるアプリケーション。
* 高いスケーラビリティが求められ、RDBの水平スケーリングが困難な場合。
* リアルタイム機能が重要で、クエリパターンが主にドキュメント内または単一コレクション内完結型の場合。
* モバイル/ウェブアプリケーションでオフライン機能やリアルタイム同期を容易に実装したい場合。
RDBMSが適しているケース:
* 厳格なデータ整合性と複雑なトランザクション処理が必須の場合。
* 複数のエンティティ間を複雑に結合したクエリが頻繁に必要な場合。
* データ構造が安定しており、スキーマ変更が少ない場合。
* 長年の運用実績と広範なツール・知見を利用したい場合。
多くの現代的なアプリケーションでは、異なる種類のデータベースを組み合わせて使用する「ポリグロット・パーシステンス」戦略が採用されています。例えば、ユーザー情報や設定にはFirestore、分析データにはBigQuery、大規模なファイルにはCloud Storage、といった使い分けです。
第12章:ベストプラクティスと考慮事項
Firestoreを効率的かつコスト効果的に利用するためのいくつかのベストプラクティスです。
- クエリパターンを先に考える: データ構造を設計する前に、アプリケーションがどのようなクエリを実行するかを洗い出します。Firestoreはクエリパターンに合わせてデータを設計するのが重要です。
- 読み取りコストを意識する: Firestoreの課金モデルは読み取り回数が大きな要素です。不要な読み取りを減らすためのデータモデリングやクエリ最適化を徹底します。
- インデックスを計画的に管理する: 必要なクエリのために適切な複合インデックスを作成しますが、不必要なインデックスは書き込みコストとストレージコストを増やすため避けます。
- セキュリティルールは厳格に: クライアントサイドからの直接アクセスを許可する場合、セキュリティルールによるデータ保護は必須です。「テストモード」で開始した場合でも、本番移行前に必ず見直します。
- ドキュメントサイズ制限を考慮する: 1MiBのドキュメントサイズ制限は、大きなデータを単一ドキュメントに詰め込みすぎないための重要な制約です。大きなバイナリデータ(画像など)はCloud Storageに保存し、Firestoreにはその参照(URLなど)のみを保存するのが一般的です。
- コレクションの削除に注意: コレクション全体を簡単に削除する操作はありません。再帰的な削除はサーバーサイドコードで実装が必要です。開発環境などで一時的なコレクションを扱う場合は注意しましょう。
- オフライン対応を考慮する場合、Persistenceを有効化: アプリケーションがオフラインでも動作する必要がある場合は、明示的にPersistenceを有効にします。
- リアルタイムリスナーの管理: 不要になったリスナーは必ずデタッチし、リソースの無駄遣いと課金を防ぎます。
- 監視とモニタリング: FirebaseコンソールやGoogle Cloudコンソールで、読み書き回数、レイテンシ、エラー率などを定期的にモニタリングし、問題の早期発見やコスト最適化に役立てます。
まとめ
Firestoreは、現代のアプリケーション開発において非常に強力で柔軟なNoSQLドキュメントデータベースです。リアルタイム同期、オフライン対応、自動スケーリングといった特徴は、特にモバイルおよびウェブアプリケーションにとって大きなメリットとなります。
このガイドでは、Firestoreの基本的な概念(コレクション、ドキュメント、データ型、サブコレクション)から始まり、セットアップ、基本的なCRUD操作、高度なクエリ、リアルタイムアップデート、データモデリング戦略、セキュリティルール、オフラインサポート、トランザクション、バッチ書き込み、料金体系、そして他のデータベースとの比較まで、幅広く詳細に解説しました。
Firestoreの学習は、そのドキュメント指向という特性とNoSQLのデータモデリングのアプローチを理解することから始まります。RDBの考え方から離れ、アプリケーションの読み取りパターンに最適化されたデータ構造を設計することが成功の鍵となります。
このガイドが、あなたがFirestoreを理解し、活用していくための一助となれば幸いです。実際の開発を通じて、Firestoreのさらなる可能性をぜひ探求してみてください。