Cloud Firestore Web Codelab

1. 概要

目標

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

img5.png

ラボの内容

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

必要なもの

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

  • npm(通常は Node.js に付属しています)- Node 16 以降をおすすめします
  • 任意の IDE またはテキスト エディタ(WebStormVS CodeSublime など)

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

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

  1. Firebase コンソールで [プロジェクトを追加] をクリックし、Firebase プロジェクトに FriendlyEats という名前を付けます。

Firebase プロジェクトのプロジェクト ID を覚えておいてください。

  1. [プロジェクトの作成] をクリックします。

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

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

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

匿名認証を有効にする

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

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

  1. Firebase コンソールの左側のナビゲーション バーで、[Build] セクションを見つけます。
  2. [認証] をクリックし、[ログイン方法] タブをクリックします(または、ここをクリックして [ログイン方法] タブに直接移動します)。
  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. [鍵を作成] をクリックし、生成された鍵を安全な場所に保存します。このステップの後半で必要になります。

App Check を有効にする

  1. Firebase コンソールの左側のパネルで、[Build] セクションを見つけます。
  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 関数を更新して reCaptcha キーを追加し、App Check を初期化します。

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 の [アプリビュー] に移動します。

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

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

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