Cloud Firestore 網路程式碼研究室

1. 總覽

目標

在本程式碼研究室中,您將建構由 Cloud Firestore 支援的餐廳推薦網頁應用程式。

img5.png

課程內容

  • 從網頁應用程式讀取及寫入 Cloud Firestore 資料
  • 即時監聽 Cloud Firestore 資料的變更
  • 使用 Firebase 驗證和安全規則保護 Cloud Firestore 資料
  • 編寫複雜的 Cloud Firestore 查詢

軟硬體需求

開始本程式碼研究室之前,請確認您已安裝下列項目:

2. 建立及設定 Firebase 專案

建立 Firebase 專案

  1. 使用 Google 帳戶登入 Firebase 控制台
  2. 按一下按鈕建立新專案,然後輸入專案名稱 (例如 FriendlyEats)。
  3. 按一下「繼續」
  4. 如果系統提示,請詳閱並接受 Firebase 條款,然後按一下「繼續」
  5. (選用) 在 Firebase 控制台中啟用 AI 輔助功能 (稱為「Gemini in Firebase」)。
  6. 本程式碼研究室不需要 Google Analytics,因此請關閉 Google Analytics 選項。
  7. 按一下「建立專案」,等待專案佈建完成,然後按一下「繼續」

設定 Firebase 產品

我們要建構的應用程式會使用幾項 Firebase 網路服務:

  • Firebase 驗證,輕鬆識別使用者
  • Cloud Firestore:在雲端儲存結構化資料,並在資料更新時立即收到通知
  • Firebase Hosting,用於託管及提供靜態資產

在本程式碼研究室中,我們已設定 Firebase 託管。不過,我們會逐步說明如何使用 Firebase 控制台設定及啟用 Firebase 驗證和 Cloud Firestore 服務。

啟用匿名驗證

雖然驗證並非本程式碼研究室的重點,但應用程式中必須有某種形式的驗證。我們將使用匿名登入,也就是使用者會自動登入,不會收到提示。

您必須啟用匿名登入

  1. 在 Firebase 控制台中,找出左側導覽列的「Build」部分。
  2. 依序點選「Authentication」和「Sign-in method」分頁標籤 (或按這裡直接前往該分頁)。
  3. 啟用「Anonymous」登入供應商,然後按一下「Save」

img7.png

這樣一來,使用者存取網頁應用程式時,應用程式就能自動登入。歡迎參閱匿名驗證說明文件,瞭解詳情。

啟用 Cloud Firestore

應用程式會使用 Cloud Firestore 儲存及接收餐廳資訊和評分。

您需要啟用 Cloud Firestore。在 Firebase 控制台的「Build」專區中,按一下「Firestore Database」。在 Cloud Firestore 窗格中,按一下「建立資料庫」

Cloud Firestore 中的資料存取權由安全性規則控管。我們稍後會在本程式碼研究室中詳細說明規則,但首先需要為資料設定一些基本規則,才能開始使用。在 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;
      }
    }
  }
}

我們會在後續章節中討論這些規則及其運作方式。

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 目錄。這個目錄包含本程式碼研究室的起始程式碼,其中包含尚未運作的餐廳推薦應用程式。我們會在整個程式碼研究室中讓應用程式運作,因此您很快就需要編輯該目錄中的程式碼。

4. 安裝 Firebase 指令列介面

Firebase 指令列介面 (CLI) 可讓您在本地提供網頁應用程式,並將網頁應用程式部署至 Firebase 託管。

  1. 執行下列 npm 指令來安裝 CLI:
npm -g install firebase-tools
  1. 執行下列指令,確認 CLI 是否已正確安裝:
firebase --version

確認 Firebase CLI 版本為 7.4.0 以上。

  1. 執行下列指令,授權 Firebase CLI:
firebase login

我們已設定網頁應用程式範本,從應用程式的本機目錄和檔案中,提取 Firebase 代管服務的應用程式設定。但為此,我們需要將應用程式與 Firebase 專案建立關聯。

  1. 請確認指令列正在存取應用程式的本機目錄。
  2. 執行下列指令,將應用程式與 Firebase 專案建立關聯:
firebase use --add
  1. 系統顯示提示訊息時,請選取「專案 ID」,然後為 Firebase 專案提供別名。

如果您有多個環境 (正式版、預先發布版等),別名就非常實用。不過,在本程式碼研究室中,我們只會使用 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 開啟應用程式。

您應該會看到 FriendlyEats 的副本,且該副本已連結至 Firebase 專案。

應用程式已自動連結至 Firebase 專案,並以匿名使用者身分登入。

img2.png

6. 將資料寫入 Cloud Firestore

在本節中,我們將一些資料寫入 Cloud Firestore,以便填入應用程式的 UI。您可以透過 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. 按一下「新增模擬資料」

應用程式會自動產生一組隨機的餐廳物件,然後呼叫 addRestaurant 函式。不過,您還不會在實際的網頁應用程式中看到資料,因為我們仍需實作擷取資料 (程式碼研究室的下一節)。

不過,如果您前往 Firebase 控制台的「Cloud Firestore」分頁,現在應該會在 restaurants 集合中看到新文件!

img6.png

恭喜!您剛才已從網頁應用程式將資料寫入 Cloud Firestore!

下一節將說明如何從 Cloud Firestore 擷取資料,並在應用程式中顯示。

7. 顯示 Cloud Firestore 中的資料

在本節中,您將瞭解如何從 Cloud Firestore 擷取資料,並在應用程式中顯示。主要步驟有兩個:建立查詢和新增快照監聽器。這個事件監聽器會收到所有符合查詢條件的現有資料通知,並即時接收更新。

首先,請建構查詢,提供未經過濾的預設餐廳清單。

  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.type 等於 removed。因此在本例中,我們會呼叫函式,從 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

目前您無法新增評分,因為我們還需要在程式碼研究室中實作新增評分的功能。

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 控制台會自動開啟索引建立使用者介面,並填入正確的參數。在下一節中,我們將編寫並部署這個應用程式所需的索引。

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 的數值。同時,我們也會將新的 rating 新增至 ratings 子集合。

12. 保護資料安全

在本程式碼研究室的開頭,我們已設定應用程式的安全規則,限制應用程式的存取權。

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. 結論

在本程式碼研究室中,您已瞭解如何使用 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. 將已部署的網址新增至「網域清單」,並確認「使用核取方塊驗證」選項未選取
  5. 按一下「建立金鑰」,然後將產生的金鑰存放在安全的地方。您稍後會用到這項資訊。

啟用 App Check

  1. 在 Firebase 控制台中,找出左側面板的「Build」部分。
  2. 點選「應用程式檢查」,然後點選「開始使用」按鈕 (或直接重新導向至 控制台)。
  3. 按一下「註冊」,在系統提示時輸入 reCAPTCHA Enterprise 金鑰,然後按一下「儲存」
  4. 在「API 檢視畫面」中,選取「Storage」,然後按一下「Enforce」。對 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「應用程式檢視畫面」

按一下溢位選單,然後選取「管理偵錯權杖」

然後按一下「新增偵錯權杖」,並按照提示貼上控制台中的偵錯權杖。

恭喜!應用程式現在應可使用 App Check。