Cloud Firestore Web Codelab

1. 概要

目標

この Codelab では、Cloud Firestore を利用して、おすすめレストラン ウェブアプリを構築します。

img5.png

学習内容

  • ウェブアプリから Cloud Firestore へのデータの読み取りと書き込みを行う
  • Cloud Firestore データの変更をリアルタイムでリッスンする
  • Firebase Authentication とセキュリティ ルールを使用して Cloud Firestore データを保護する
  • 複雑な Cloud Firestore クエリを作成する

必要なもの

この Codelab を開始する前に、次のものがインストールされていることを確認してください。

2. Firebase プロジェクトを作成して設定する

Firebase プロジェクトを作成する

  1. Google アカウントを使用して Firebase コンソールにログインします。
  2. ボタンをクリックして新しいプロジェクトを作成し、プロジェクト名(例: FriendlyEats)を入力します。
  3. [続行] をクリックします。
  4. Firebase の利用規約が表示されたら、内容を読み、同意して [続行] をクリックします。
  5. (省略可)Firebase コンソールで AI アシスタンス(「Gemini in Firebase」)を有効にします。
  6. この Codelab では Google アナリティクスは必要ないため、Google アナリティクスのオプションをオフに切り替えます
  7. [プロジェクトを作成] をクリックし、プロジェクトのプロビジョニングが完了するまで待ってから、[続行] をクリックします。

Firebase プロダクトを設定する

これから構築するアプリでは、ウェブで利用できる以下の Firebase サービスを使用します。

  • Firebase Authentication: ユーザーを簡単に識別できます。
  • Cloud Firestore: 構造化データをクラウドに保存し、データが更新されたらすぐに通知を受け取れます。
  • Firebase Hosting: 静的アセットをホストして配信できます。

このコードラボでは、Firebase Hosting はすでに構成されています。ただし、Firebase Auth と Cloud Firestore については、Firebase コンソールを使用してサービスを構成し、有効にする手順を説明します。

匿名認証を有効にする

認証はこの Codelab の主要なトピックではありませんが、アプリではなんらかの形の認証を行うことが重要です。ここでは、匿名ログイン(プロンプトが表示されず、ユーザーが自動的にログインする)を使用します。

匿名ログインを有効にする必要があります。

  1. Firebase コンソールの左側のナビゲーションで、[構築] セクションを見つけます。
  2. [Authentication] をクリックし、[Sign-in method] タブをクリックします(または、ここをクリックして直接移動します)。
  3. [匿名] ログイン プロバイダを有効にして、[保存] をクリックします。

img7.png

これにより、ユーザーはウェブアプリにアクセスしたときに、アプリに自動的にログインできるようになります。詳しくは、匿名認証のドキュメントをご覧ください。

Cloud Firestore の有効化

このアプリは、Cloud Firestore を使用してレストランの情報と評価を受信し、保存します。

Cloud Firestore を有効にする必要があります。Firebase コンソールの [構築] セクションで、[Firestore データベース] をクリックします。[Cloud Firestore] ペインで [データベースを作成] をクリックします。

Cloud Firestore 内のデータへのアクセスは、セキュリティ ルールによって制御されます。ルールについてはこの Codelab の後半で詳しく説明しますが、まず、データに基本的なルールを設定して始めましょう。Firebase コンソールの [ルール] タブで次のルールを追加し、[公開] をクリックします。

rules_version = '2';
service cloud.firestore {

  // Determine if the value of the field "key" is the same
  // before and after the request.
  function unchanged(key) {
    return (key in resource.data)
      && (key in request.resource.data)
      && (resource.data[key] == request.resource.data[key]);
  }

  match /databases/{database}/documents {
    // Restaurants:
    //   - Authenticated user can read
    //   - Authenticated user can create/update (for demo purposes only)
    //   - Updates are allowed if no fields are added and name is unchanged
    //   - Deletes are not allowed (default)
    match /restaurants/{restaurantId} {
      allow read: if request.auth != null;
      allow create: if request.auth != null;
      allow update: if request.auth != null
                    && (request.resource.data.keys() == resource.data.keys())
                    && unchanged("name");

      // Ratings:
      //   - Authenticated user can read
      //   - Authenticated user can create if userId matches
      //   - Deletes and updates are not allowed (default)
      match /ratings/{ratingId} {
        allow read: if request.auth != null;
        allow create: if request.auth != null
                      && request.resource.data.userId == request.auth.uid;
      }
    }
  }
}

これらのルールとその仕組みについては、この Codelab の後半で説明します。

3. サンプルコードを取得する

コマンドラインから、GitHub リポジトリのクローンを作成します。

git clone https://github.com/firebase/friendlyeats-web

サンプルコードのクローンは、📁friendlyeats-web ディレクトリに作成されているはずです。これ以降は、次のディレクトリからすべてのコマンドを実行してください。

cd friendlyeats-web/vanilla-js

スターター アプリをインポートする

IDE(WebStorm、Atom、Sublime、Visual Studio Code など)を使用して、📁friendlyeats-web ディレクトリを開くかインポートします。このディレクトリには Codelab のスターター コードが格納されています。コードは、まだ機能しないレストランおすすめアプリで構成されています。この Codelab 全体を通して、ディレクトリ内のコードを機能させるための編集を進めていきます。

4. Firebase コマンドライン インターフェースをインストールする

Firebase コマンドライン インターフェース(CLI)を使用すると、ウェブアプリをローカルで提供し Firebase Hosting にデプロイできます。

  1. 次の npm コマンドを実行して、CLI をインストールします。
npm -g install firebase-tools
  1. 次のコマンドを実行して、CLI が正しくインストールされたことを確認します。
firebase --version

Firebase CLI のバージョンが v7.4.0 以降であることを確認します。

  1. 次のコマンドを実行して、Firebase CLI を承認します。
firebase login

ウェブアプリ テンプレートは、アプリのローカル ディレクトリとファイルから Firebase Hosting のアプリの構成を取得するように設定されています。ただし、そのためには、アプリを Firebase プロジェクトに関連付ける必要があります。

  1. コマンドラインがアプリのローカル ディレクトリにアクセスしていることを確認します。
  2. 次のコマンドを実行して、アプリを Firebase プロジェクトに関連付けます。
firebase use --add
  1. プロンプトが表示されたら、プロジェクト ID を選択して、Firebase プロジェクトにエイリアスを指定します。

エイリアスは、複数の環境(本番環境、ステージング環境など)を使用する場合に役立ちます。ただし、この Codelab では、default というエイリアスのみを使用します。

  1. コマンドラインで指示される手順に沿って操作します。

5. ローカル サーバーを実行する

アプリで実際に作業を開始する準備が整いました。アプリをローカルで実行してみましょう。

  1. 次の Firebase CLI コマンドを実行します。
firebase emulators:start --only hosting
  1. コマンドラインに次のレスポンスが表示されます。
hosting: Local server: http://localhost:5000

Firebase Hosting エミュレータを使用して、アプリをローカルで提供します。これで、ウェブアプリは http://localhost:5000 から利用できます。

  1. http://localhost:5000 でアプリを開きます。

Firebase プロジェクトに接続された FriendlyEat のコピーが表示されるはずです。

アプリは自動的に Firebase プロジェクトに接続され、ユーザーは匿名ユーザーとして自動的にログインします。

img2.png

6. Cloud Firestore にデータを書き込む

このセクションでは、アプリの UI にデータを入力できるように、Cloud Firestore にデータを書き込みます。これは Firebase コンソールから手動で実行できますが、ここでは、アプリ自体で実行して、基本的な Cloud Firestore の書き込みを例示します。

データモデル

Firestore データは、コレクション、ドキュメント、フィールド、およびサブコレクションに分割されます。各レストランは、restaurants という名前の最上位のコレクションにドキュメントとして保存されます。

img3.png

後で、各レストランの ratings という名前のサブコレクションに各レビューを格納します。

img4.png

レストランを Firestore に追加する

このアプリのメインのモデル オブジェクトは、レストランです。レストランのドキュメントを restaurants コレクションに追加するコードを作成してみましょう。

  1. ダウンロードしたファイルから scripts/FriendlyEats.Data.js を開きます。
  2. 関数 FriendlyEats.prototype.addRestaurant を見つけます。
  3. 関数全体を次のコードに置き換えます。

FriendlyEats.Data.js

FriendlyEats.prototype.addRestaurant = function(data) {
  var collection = firebase.firestore().collection('restaurants');
  return collection.add(data);
};

上記のコードは、restaurants コレクションに新しいドキュメントを追加します。ドキュメント データは、プレーンな JavaScript オブジェクトから取得されます。そのためには、まず Cloud Firestore コレクション restaurants への参照を取得し、次にデータを add します。

レストランを追加しましょう。

  1. ブラウザで FriendlyEats アプリに戻り、更新します。
  2. [Add Mock Data] をクリックします。

アプリはレストラン オブジェクトのランダムなセットを自動的に生成し、addRestaurant 関数を呼び出します。ただし、データの「取得」を実装する必要があるため(Codelab の次のセクションで行います)、実際のウェブアプリにデータは表示されません

ただし、Firebase コンソールで [Cloud Firestore] タブに移動すると、restaurants コレクションに新しいドキュメントが表示されるはずです。

img6.png

おつかれさまです。これで、ウェブアプリから Cloud Firestore にデータが書き込まれました。

次のセクションでは、Cloud Firestore からデータを取得してアプリに表示する方法を学びます。

7. Cloud Firestore からのデータを表示する

このセクションでは、Cloud Firestore からデータを取得してアプリに表示する方法を学びます。主要なステップは、クエリの作成とスナップショット リスナーの追加の 2 つです。このリスナーは、クエリに一致するすべての既存データについて通知され、リアルタイムで更新を受信します。

まず、フィルタリングされていないデフォルトのレストラン リストを提供するクエリを作成してみましょう。

  1. ファイル scripts/FriendlyEats.Data.js に戻ります。
  2. 関数 FriendlyEats.prototype.getAllRestaurants を見つけます。
  3. 関数全体を次のコードに置き換えます。

FriendlyEats.Data.js

FriendlyEats.prototype.getAllRestaurants = function(renderer) {
  var query = firebase.firestore()
      .collection('restaurants')
      .orderBy('avgRating', 'desc')
      .limit(50);

  this.getDocumentsInQuery(query, renderer);
};

上記のコードでは、restaurants という名前の最上位のコレクションから最大 50 件のレストランを取得し、平均評価(現在はすべてゼロ)の順に並べ替えるクエリを作成します。このクエリを宣言した後、データの読み込みとレンダリングを担当する getDocumentsInQuery() メソッドにこのクエリを渡します。

これを行うには、スナップショット リスナーを追加します。

  1. ファイル scripts/FriendlyEats.Data.js に戻ります。
  2. 関数 FriendlyEats.prototype.getDocumentsInQuery を見つけます。
  3. 関数全体を次のコードに置き換えます。

FriendlyEats.Data.js

FriendlyEats.prototype.getDocumentsInQuery = function(query, renderer) {
  query.onSnapshot(function(snapshot) {
    if (!snapshot.size) return renderer.empty(); // Display "There are no restaurants".

    snapshot.docChanges().forEach(function(change) {
      if (change.type === 'removed') {
        renderer.remove(change.doc);
      } else {
        renderer.display(change.doc);
      }
    });
  });
};

上記のコードでは、query.onSnapshot は、クエリの結果に変更があるたびにコールバックをトリガーします。

  • 初回は、クエリの結果セット全体(Cloud Firestore の restaurants コレクション全体)でコールバックがトリガーされます。次に、すべての個々のドキュメントを renderer.display 関数に渡します。
  • ドキュメントが削除されると、change.typeremoved と等しくなります。この場合、UI からレストランを削除する関数を呼び出します。

両方のメソッドを実装したので、アプリを更新し、先ほど Firebase コンソールで見たレストランがアプリに表示されることを確認します。このセクションを正常に完了すると、アプリは Cloud Firestore でデータの読み書きを行います。

レストランのリストが変更されると、このリスナーは自動的に更新され続けます。Firebase コンソールに移動して、手動でレストランを削除したり、名前を変更したりしてみてください。変更内容がすぐにサイトに反映されるはずです。

img5.png

8. Get() データ

ここまでは、onSnapshot を使用してリアルタイムで更新を取得する方法を学びました。しかし、いつでもそうするのが望ましいわけではありません。一度だけデータをフェッチすればよい場合もあります。

ユーザーがアプリで特定のレストランをクリックしたときにトリガーされるメソッドを実装します。

  1. ファイル scripts/FriendlyEats.Data.js に戻ります。
  2. 関数 FriendlyEats.prototype.getRestaurant を見つけます。
  3. 関数全体を次のコードに置き換えます。

FriendlyEats.Data.js

FriendlyEats.prototype.getRestaurant = function(id) {
  return firebase.firestore().collection('restaurants').doc(id).get();
};

このメソッドを実装すると、各レストランのページを表示できるようになります。リスト内のレストランをクリックすると、そのレストランの詳細ページが表示されます。

img1.png

現時点では、評価を追加することはできません。評価の追加は、この Codelab の後半で実装する必要があります。

9. データの並べ替えとフィルタ

現在、アプリにはレストランのリストが表示されますが、ユーザーが自分のニーズに基づいてフィルタすることはできません。このセクションでは、Cloud Firestore の高度なクエリを使用して、フィルタリングを有効にします。

Dim Sum のレストランをすべてフェッチするシンプルなクエリの例を次に示します。

var filteredQuery = query.where('category', '==', 'Dim Sum')

where() メソッドは、その名が示すように、設定した制限を満たすフィールドを持つコレクションのメンバーのみをクエリしてダウンロードします。この例では、categoryDim Sum のレストランのみをダウンロードします。

アプリでは、複数のフィルタを連結して、「サンフランシスコのピザ」や「ロサンゼルスのシーフードを人気順に表示」などの特定のクエリを作成できます。

ユーザーが選択した複数の条件に基づいてレストランをフィルタするクエリを構築するメソッドを作成します。

  1. ファイル scripts/FriendlyEats.Data.js に戻ります。
  2. 関数 FriendlyEats.prototype.getFilteredRestaurants を見つけます。
  3. 関数全体を次のコードに置き換えます。

FriendlyEats.Data.js

FriendlyEats.prototype.getFilteredRestaurants = function(filters, renderer) {
  var query = firebase.firestore().collection('restaurants');

  if (filters.category !== 'Any') {
    query = query.where('category', '==', filters.category);
  }

  if (filters.city !== 'Any') {
    query = query.where('city', '==', filters.city);
  }

  if (filters.price !== 'Any') {
    query = query.where('price', '==', filters.price.length);
  }

  if (filters.sort === 'Rating') {
    query = query.orderBy('avgRating', 'desc');
  } else if (filters.sort === 'Reviews') {
    query = query.orderBy('numRatings', 'desc');
  }

  this.getDocumentsInQuery(query, renderer);
};

上記のコードは、複数の where フィルタと単一の orderBy 句を追加して、ユーザー入力に基づく複合クエリを作成します。これで、クエリはユーザーの要件を満たすレストランのみを返すようになりました。

ブラウザで FriendlyEats アプリを更新し、料金、都市、カテゴリでフィルタできることを確認します。テスト中、ブラウザの JavaScript コンソールに次のようなエラーが表示されます。

The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=...

このようなエラーが発生するのは、Cloud Firestore はほとんどの複合クエリについてインデックスを必要とするためです。クエリでインデックスを必須にすると、Cloud Firestore は大規模なデータを高速で処理できます。

エラー メッセージからリンクを開くと、Firebase コンソールで、正しいパラメータが入力された状態のインデックス作成 UI が自動的に開きます。次のセクションでは、このアプリに必要なインデックスを作成してデプロイします。

10. インデックスをデプロイする

アプリ内のすべてのパスを調査して個々のインデックス作成リンクをたどりたくない場合は、Firebase CLI を使用して、一度に多数のインデックスを簡単にデプロイできます。

  1. アプリのダウンロードされたローカル ディレクトリに firestore.indexes.json ファイルがあります。

このファイルには、フィルタのすべての可能な組み合わせに対して必要なすべてのインデックスが記述されています。

firestore.indexes.json

{
 "indexes": [
   {
     "collectionGroup": "restaurants",
     "queryScope": "COLLECTION",
     "fields": [
       { "fieldPath": "city", "order": "ASCENDING" },
       { "fieldPath": "avgRating", "order": "DESCENDING" }
     ]
   },

   ...

 ]
}
  1. 次のコマンドを使用して、これらのインデックスをデプロイします。
firebase deploy --only firestore:indexes

数分後、インデックスが有効になり、エラー メッセージが表示されなくなります。

11. トランザクションでデータを書き込む

このセクションでは、ユーザーがレストランに対するレビューを投稿する機能を追加します。これまでのところ、すべての書き込みはアトミックで、比較的単純でした。いずれかの書き込みがエラーになった場合は、単にユーザーに再試行を求める方法が考えられます。ユーザーがそうしなかった場合、アプリは自動的に書き込みを再試行します。

アプリにはレストランのレビューを書き込みたいユーザーが多数いるはずなので、複数の読み取りと書き込みを調整する必要があります。最初に、レビュー自体を送信する必要があります。次に、レストランの評価の countaverage rating を更新する必要があります。一方が失敗して他方が成功した場合、データベースのある部分のデータが別の部分のデータと一致しなくなるという矛盾した状態に陥ります。

幸いなことに、Cloud Firestore は単一のアトミック オペレーションで複数の読み取りと書き込みを実行できるトランザクション機能を備えているため、データの整合性を保証できます。

  1. ファイル scripts/FriendlyEats.Data.js に戻ります。
  2. 関数 FriendlyEats.prototype.addRating を見つけます。
  3. 関数全体を次のコードに置き換えます。

FriendlyEats.Data.js

FriendlyEats.prototype.addRating = function(restaurantID, rating) {
  var collection = firebase.firestore().collection('restaurants');
  var document = collection.doc(restaurantID);
  var newRatingDocument = document.collection('ratings').doc();

  return firebase.firestore().runTransaction(function(transaction) {
    return transaction.get(document).then(function(doc) {
      var data = doc.data();

      var newAverage =
          (data.numRatings * data.avgRating + rating.rating) /
          (data.numRatings + 1);

      transaction.update(document, {
        numRatings: data.numRatings + 1,
        avgRating: newAverage
      });
      return transaction.set(newRatingDocument, rating);
    });
  });
};

上記のブロックでは、トランザクションをトリガーして、レストラン ドキュメント内の avgRatingnumRatings の数値を更新します。同時に、新しい ratingratings サブコレクションに追加します。

12. データをセキュリティで保護する

この Codelab の冒頭では、アプリのセキュリティ ルールを設定して、アプリへのアクセスを制限しました。

firestore.rules

rules_version = '2';
service cloud.firestore {

  // Determine if the value of the field "key" is the same
  // before and after the request.
  function unchanged(key) {
    return (key in resource.data)
      && (key in request.resource.data)
      && (resource.data[key] == request.resource.data[key]);
  }

  match /databases/{database}/documents {
    // Restaurants:
    //   - Authenticated user can read
    //   - Authenticated user can create/update (for demo purposes only)
    //   - Updates are allowed if no fields are added and name is unchanged
    //   - Deletes are not allowed (default)
    match /restaurants/{restaurantId} {
      allow read: if request.auth != null;
      allow create: if request.auth != null;
      allow update: if request.auth != null
                    && (request.resource.data.keys() == resource.data.keys())
                    && unchanged("name");

      // Ratings:
      //   - Authenticated user can read
      //   - Authenticated user can create if userId matches
      //   - Deletes and updates are not allowed (default)
      match /ratings/{ratingId} {
        allow read: if request.auth != null;
        allow create: if request.auth != null
                      && request.resource.data.userId == request.auth.uid;
      }
    }
  }
}

これらのルールによりアクセスが制限され、クライアントは安全に変更を加えることができます。例:

  • レストラン ドキュメントの更新では、評価のみが変更されます。名前およびその他の不変データは変更されません。
  • 評価は、ユーザー ID がログイン ユーザーと一致する場合にのみ作成できます。これにより、なりすましが防止されます。

Firebase コンソールの代わりに Firebase CLI を使用して、Firebase プロジェクトにルールをデプロイできます。作業ディレクトリの firestore.rules ファイルには、上記のルールがすでに含まれています。このルールを(Firebase コンソールを使用せずに)ローカル ファイルシステムからデプロイするには、次のコマンドを実行します。

firebase deploy --only firestore:rules

13. まとめ

この Codelab では、Cloud Firestore での基本的な読み書きと高度な読み書きを実行する方法と、セキュリティ ルールを使用してデータアクセスを保護する方法を学びました。完全な解答コードは、quickstarts-js リポジトリにあります。

Cloud Firestore の詳細については、以下のリソースをご覧ください。

14. [省略可] App Check で適用する

Firebase App Check は、アプリへの不要なトラフィックを検証して防止することで保護を提供します。このステップでは、reCAPTCHA Enterprise を使用して App Check を追加し、サービスへのアクセスを保護します。

まず、App Check と reCaptcha を有効にする必要があります。

reCaptcha Enterprise を有効にする

  1. Cloud コンソールで、[セキュリティ] の [reCaptcha Enterprise] を見つけて選択します。
  2. プロンプトが表示されたらサービスを有効にして、[キーを作成] をクリックします。
  3. 表示名を入力し、プラットフォーム タイプとして [ウェブサイト] を選択します。
  4. デプロイした URL を [ドメインリスト] に追加し、[チェックボックスによる本人確認を使用する] オプションがオフになっていることを確認します。
  5. [Create Key] をクリックし、生成されたキーを安全な場所に保存します。この値は、この手順の後半で必要になります。

App Check を有効にする

  1. Firebase コンソールの左側のパネルで、[構築] セクションを見つけます。
  2. [App Check] をクリックし、[開始] ボタンをクリックします(または、 コンソールに直接リダイレクトします)。
  3. [登録] をクリックし、メッセージが表示されたら reCaptcha Enterprise キーを入力して、[保存] をクリックします。
  4. [API] ビューで [ストレージ] を選択し、[適用] をクリックします。Cloud Firestore についても同様に操作します。

これで App Check が適用されるようになりました。アプリを更新して、レストランの作成または表示を試します。次のエラー メッセージが表示されます。

Uncaught Error in snapshot listener: FirebaseError: [code=permission-denied]: Missing or insufficient permissions.

つまり、App Check はデフォルトで未検証のリクエストをブロックします。それでは、アプリに検証を追加しましょう。

FriendlyEats.View.js ファイルに移動し、initAppCheck 関数を更新して、App Check を初期化するための reCaptcha キーを追加します。

FriendlyEats.prototype.initAppCheck = function() {
    var appCheck = firebase.appCheck();
    appCheck.activate(
    new firebase.appCheck.ReCaptchaEnterpriseProvider(
      /* reCAPTCHA Enterprise site key */
    ),
    true // Set to true to allow auto-refresh.
  );
};

appCheck インスタンスはキーを含む ReCaptchaEnterpriseProvider で初期化され、isTokenAutoRefreshEnabled によりアプリでトークンを自動更新できます。

ローカル テストを有効にするには、FriendlyEats.js ファイルでアプリが初期化されるセクションを見つけ、FriendlyEats.prototype.initAppCheck 関数に次の行を追加します。

if(isLocalhost) {
  self.FIREBASE_APPCHECK_DEBUG_TOKEN = true;
}

これにより、ローカル ウェブアプリのコンソールに次のようなデバッグ トークンが記録されます。

App Check debug token: 8DBDF614-649D-4D22-B0A3-6D489412838B. You will need to add it to your app's App Check settings in the Firebase console for it to work.

次に、Firebase コンソールの App Check の [アプリビュー] に移動します。

オーバーフロー メニューをクリックし、[デバッグ トークンを管理] を選択します。

次に、[デバッグ トークンを追加] をクリックし、プロンプトが表示されたらコンソールからデバッグ トークンを貼り付けます。

これで完了です。これで、アプリで App Check が動作するようになります。