1. 簡介
上次更新時間:2023 年 1 月 27 日
建立排行榜需要哪些條件?
排行榜本質上就是分數表,但有一個複雜因素:如要讀取任何給定分數的排名,必須知道所有其他分數的某種順序。此外,如果遊戲大受歡迎,排行榜就會越來越大,而且會經常讀取及寫入資料。如要建立成功的排行榜,必須能快速處理這項排名作業。
建構項目
在本程式碼研究室中,您將實作各種不同的排行榜,每種排行榜都適合不同的情境。
課程內容
您將瞭解如何實作四種不同的排行榜:
- 使用簡單的記錄計數來判斷排名的簡單實作方式
- 便宜且定期更新的排行榜
- 即時排行榜,但有些樹木相關的無稽之談
- 隨機 (機率) 排行榜,可大致排列大量玩家的排名
事前準備
- 新版 Chrome (107 以上版本)
- Node.js 16 以上版本 (如果使用 nvm,請執行
nvm --version
查看版本號碼) - 付費的 Firebase Blaze 方案 (選用)
- Firebase CLI 11.16.0 以上版本
如要安裝 CLI,可以執行npm install -g firebase-tools
,或參閱 CLI 說明文件瞭解更多安裝選項。 - 熟悉 JavaScript、Cloud Firestore、Cloud Functions 和 Chrome 開發人員工具
2. 開始設定
取得程式碼
我們已將本專案所需的資料都放到 Git 存放區中。如要開始進行本專案,請先取得程式碼並在慣用的開發環境中開啟。在本程式碼研究室中,我們使用 VS Code,但任何文字編輯器都可以。
或者,複製到所選目錄:
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
從哪裡開始?
我們的專案目前是空白狀態,只有一些空函式:
index.html
包含一些膠合指令碼,可讓我們從開發人員控制台叫用函式,並查看輸出內容。我們會使用這個介面與後端互動,並查看函式叫用結果。在實際情況中,您會直接從遊戲發出這些後端呼叫。我們在本程式碼研究室中未使用遊戲,因為每次想在排行榜中新增分數時,都必須花費大量時間玩遊戲。functions/index.js
包含所有 Cloud Functions。您會看到一些公用程式函式,例如addScores
和deleteScores
,以及我們將在本程式碼研究室中實作的函式,這些函式會呼叫另一個檔案中的輔助函式。functions/functions-helpers.js
包含我們將實作的空白函式。我們會為每個排行榜實作讀取、建立及更新函式,您會看到實作選擇如何影響實作的複雜度,以及其擴充效能。functions/utils.js
包含更多公用程式函式。在本程式碼研究室中,我們不會修改這個檔案。
建立及設定 Firebase 專案
建立新的 Firebase 專案
- 使用 Google 帳戶登入 Firebase 控制台。
- 按一下按鈕建立新專案,然後輸入專案名稱 (例如
Leaderboards Codelab
)。
- 按一下「繼續」。
- 如果系統提示,請詳閱並接受 Firebase 條款,然後按一下「繼續」。
- (選用) 在 Firebase 控制台中啟用 AI 輔助功能 (稱為「Gemini in Firebase」)。
- 本程式碼研究室不需要 Google Analytics,因此請關閉 Google Analytics 選項。
- 按一下「建立專案」,等待專案佈建完成,然後按一下「繼續」。
設定 Firebase 產品
- 在「Build」選單中,按一下「Functions」,並視需要將專案升級至 Blaze 方案。
- 在「建構」選單中,按一下「Firestore 資料庫」。
- 在隨即顯示的「建立資料庫」對話方塊中,選取「以測試模式啟動」,然後按一下「下一步」。
- 從「Cloud Firestore location」(Cloud Firestore 位置) 下拉式選單中選擇區域,然後按一下「Enable」(啟用)。
設定及執行排行榜
- 在終端機中,前往專案根目錄並執行
firebase use --add
。選擇您剛建立的 Firebase 專案。 - 在專案的根目錄中,執行
firebase emulators:start --only hosting
。 - 在瀏覽器中前往
localhost:5000
。 - 開啟 Chrome 開發人員工具的 JavaScript 控制台,然後匯入
leaderboard.js
:const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
- 在控制台中執行
leaderboard.codelab();
。如果看到歡迎訊息,表示一切就緒!如果沒有,請關閉模擬器,然後重新執行步驟 2 到 4。
我們來實作第一個排行榜。
3. 實作簡易排行榜
本節結束時,我們就能將分數新增至排行榜,並顯示我們的排名。
開始之前,請先瞭解排行榜的實作方式:所有玩家都會儲存在單一集合中,如要擷取玩家的排名,只要擷取集合並計算有多少玩家領先即可。因此插入和更新分數非常簡單。如要插入新分數,只要將分數附加至集合即可;如要更新分數,請篩選目前使用者,然後更新產生的文件。我們來看看程式碼的樣子。
在 functions/functions-helper.js
中實作 createScore
函式,這項作業非常簡單:
async function createScore(score, playerID, firestore) {
return firestore.collection("scores").doc().create({
user: playerID,
score: score,
});
}
如要更新分數,我們只需要新增錯誤檢查,確保要更新的分數已存在:
async function updateScore(playerID, newScore, firestore) {
const playerSnapshot = await firestore.collection("scores")
.where("user", "==", playerID).get();
if (playerSnapshot.size !== 1) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
const doc = firestore.doc(player.id);
return doc.update({
score: newScore,
});
}
最後,我們簡單但較不具擴充性的排名函式:
async function readRank(playerID, firestore) {
const scores = await firestore.collection("scores")
.orderBy("score", "desc").get();
const player = `${playerID}`;
let rank = 1;
for (const doc of scores.docs) {
const user = `${doc.get("user")}`;
if (user === player) {
return {
user: player,
rank: rank,
score: doc.get("score"),
};
}
rank++;
}
// No user found
throw Error(`User not found in leaderboard: ${playerID}`);
}
現在就來測試自己吧!在終端機中執行下列指令,部署函式:
firebase deploy --only functions
接著,在 Chrome 的 JS 控制台中新增一些其他分數,這樣我們就能查看自己和其他玩家的排名。
leaderboard.addScores(); // Results may take some time to appear.
現在,我們可以加入自己的分數:
leaderboard.addScore(999, 11); // You can make up a score (second argument) here.
寫入完成後,控制台應會顯示「已建立樂譜」的回覆。看到錯誤訊息嗎?透過 Firebase 控制台開啟 Functions 記錄,查看發生錯誤的原因。
最後,我們可以擷取及更新分數。
leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)
不過,這種實作方式會導致擷取特定分數的排名時,出現不理想的線性時間和記憶體需求。由於函式執行時間和記憶體都有限制,這不僅代表擷取作業會越來越慢,而且在排行榜中加入足夠的分數後,函式會在傳回結果前逾時或當機。顯然,如果我們要擴大玩家人數,就需要更好的解決方案。
如果您是 Firestore 專家,可能知道 COUNT 匯總查詢,這類查詢可大幅提升排行榜效能。答對了!使用 COUNT 查詢時,這項功能可順利擴充至約一百萬名使用者,但效能仍為線性。
但請稍等,您可能會想,如果我們無論如何都要列舉集合中的所有文件,那麼可以為每個文件指派排名,然後在需要擷取文件時,擷取作業的時間和記憶體複雜度就會是 O(1)!這帶出了下一個方法:定期更新排行榜。
4. 實作定期更新的排行榜
這種做法的關鍵在於將排名儲存在文件中,這樣一來,擷取排名時就不必額外作業。為此,我們需要一種新的函式。
在 index.js
中新增下列項目:
// Also add this to the top of your file
const admin = require("firebase-admin");
exports.scheduledFunctionCrontab = functions.pubsub.schedule("0 2 * * *")
// Schedule this when most of your users are offline to avoid
// database spikiness.
.timeZone("America/Los_Angeles")
.onRun((context) => {
const scores = admin.firestore().collection("scores");
scores.orderBy("score", "desc").get().then((snapshot) => {
let rank = 1;
const writes = [];
for (const docSnapshot of snapshot.docs) {
const docReference = scores.doc(docSnapshot.id);
writes.push(docReference.set({rank: rank}, admin.firestore.SetOptions.merge()));
rank++;
}
Promise.all(writes).then((result) => {
console.log(`Writes completed with results: ${result}`);
});
});
return null;
});
現在讀取、更新和寫入作業都變得簡單明瞭。寫入和更新作業都不會變更,但讀取作業會變成 (在 functions-helpers.js
中):
async function readRank(playerID, firestore) {
const scores = firestore.collection("scores");
const playerSnapshot = await scores
.where("user", "==", playerID).get();
if (playerSnapshot.size === 0) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
if (player.get("rank") === undefined) {
// This score was added before our scheduled function could run,
// but this shouldn't be treated as an error
return {
user: playerID,
rank: null,
score: player.get("score"),
};
}
return {
user: playerID,
rank: player.get("rank"),
score: player.get("score"),
};
}
很抱歉,您必須在專案中新增帳單帳戶,才能部署及測試這項功能。如果您有帳單帳戶,請縮短排定函式執行的間隔,並觀察函式如何自動為排行榜分數指派排名。
如果沒有,請刪除排定的函式,然後跳至下一個實作步驟。
點選分數集合旁的三點圖示,刪除 Firestore 資料庫中的分數,為下一節做準備。
5. 實作即時樹木排行榜
這個方法會將搜尋資料儲存在資料庫集合中。我們的目標不是建立統一的集合,而是將所有內容儲存在樹狀結構中,方便我們透過移動文件來遍歷樹狀結構。這樣我們就能對特定分數的排名執行二元 (或 n 元) 搜尋。這會是什麼情況呢?
首先,我們要將分數分配到大致平均的區間,這需要瞭解使用者記錄的分數值;舉例來說,如果您要為競技遊戲的技能評等建立排行榜,使用者的技能評等幾乎都會呈現常態分布。我們的隨機分數產生函式使用 JavaScript 的 Math.random()
,這會產生大致平均的分佈,因此我們會平均劃分值區。
為求簡單,本範例會使用 3 個 bucket,但您可能會發現,在實際應用程式中使用這項實作時,bucket 越多,結果就越快出爐,因為樹狀結構越淺,平均來說,擷取的集合就越少,鎖定爭用也越少。
玩家的排名等於分數較高的玩家人數加上 1 (玩家本人)。scores
下的每個集合都會儲存三份文件,每份文件都有一個範圍、每個範圍下的文件數量,以及三個對應的子集合。如要讀取排名,我們會遍歷這棵樹,搜尋分數並追蹤較高分數的總和。找到分數後,我們也會得到正確的總和。
寫入作業會複雜許多。首先,我們需要在交易中進行所有寫入作業,以防止同時發生多個寫入或讀取作業時,出現資料不一致的情況。在遍歷樹狀結構以撰寫新文件時,我們也需要維持上述所有條件。最後,由於我們採用新方法後,樹狀結構的複雜度會提高,且需要儲存所有原始文件,因此儲存空間成本會稍微增加 (但仍為線性)。
在 functions-helpers.js
中:
async function createScore(playerID, score, firestore) {
/**
* This function assumes a minimum score of 0 and that value
* is between min and max.
* Returns the expected size of a bucket for a given score
* so that bucket sizes stay constant, to avoid expensive
* re-bucketing.
* @param {number} value The new score.
* @param {number} min The min of the previous range.
* @param {number} max The max of the previous range. Must be greater than
* min.
* @return {Object<string, number>} Returns an object containing the new min
* and max.
*/
function bucket(value, min, max) {
const bucketSize = (max - min) / 3;
const bucketMin = Math.floor(value / bucketSize) * bucketSize;
const bucketMax = bucketMin + bucketSize;
return {min: bucketMin, max: bucketMax};
}
/**
* A function used to store pending writes until all reads within a
* transaction have completed.
*
* @callback PendingWrite
* @param {admin.firestore.Transaction} transaction The transaction
* to be used for writes.
* @return {void}
*/
/**
* Recursively searches for the node to write the score to,
* then writes the score and updates any counters along the way.
* @param {number} id The user associated with the score.
* @param {number} value The new score.
* @param {admin.firestore.CollectionReference} coll The collection this
* value should be written to.
* @param {Object<string, number>} range An object with properties min and
* max defining the range this score should be in. Ranges cannot overlap
* without causing problems. Use the bucket function above to determine a
* root range from constant values to ensure consistency.
* @param {admin.firestore.Transaction} transaction The transaction used to
* ensure consistency during tree updates.
* @param {Array<PendingWrite>} pendingWrites A series of writes that should
* occur once all reads within a transaction have completed.
* @return {void} Write error/success is handled via the transaction object.
*/
async function writeScoreToCollection(
id, value, coll, range, transaction, pendingWrites) {
const snapshot = await transaction.get(coll);
if (snapshot.empty) {
// This is the first score to be inserted into this node.
for (const write of pendingWrites) {
write(transaction);
}
const docRef = coll.doc();
transaction.create(docRef, {exact: {score: value, user: id}});
return;
}
const min = range.min;
const max = range.max;
for (const node of snapshot.docs) {
const data = node.data();
if (data.exact !== undefined) {
// This node held an exact score.
const newRange = bucket(value, min, max);
const tempRange = bucket(data.exact.score, min, max);
if (newRange.min === tempRange.min &&
newRange.max === tempRange.max) {
// The scores belong in the same range, so we need to "demote" both
// to a lower level of the tree and convert this node to a range.
const rangeData = {
range: newRange,
count: 2,
};
for (const write of pendingWrites) {
write(transaction);
}
const docReference = node.ref;
transaction.set(docReference, rangeData);
transaction.create(docReference.collection("scores").doc(), data);
transaction.create(
docReference.collection("scores").doc(),
{exact: {score: value, user: id}},
);
return;
} else {
// The scores are in different ranges. Continue and try to find a
// range that fits this score.
continue;
}
}
if (data.range.min <= value && data.range.max > value) {
// The score belongs to this range that may have subvalues.
// Increment the range's count in pendingWrites, since
// subsequent recursion may incur more reads.
const docReference = node.ref;
const newCount = node.get("count") + 1;
pendingWrites.push((t) => {
t.update(docReference, {count: newCount});
});
const newRange = bucket(value, min, max);
return writeScoreToCollection(
id,
value,
docReference.collection("scores"),
newRange,
transaction,
pendingWrites,
);
}
}
// No appropriate range was found, create an `exact` value.
transaction.create(coll.doc(), {exact: {score: value, user: id}});
}
const scores = firestore.collection("scores");
const players = firestore.collection("players");
return firestore.runTransaction((transaction) => {
return writeScoreToCollection(
playerID, score, scores, {min: 0, max: 1000}, transaction, [],
).then(() => {
transaction.create(players.doc(), {
user: playerID,
score: score,
});
});
});
}
這當然比我們上次的實作方式複雜,上次只要呼叫單一方法,而且只有六行程式碼。實作這個方法後,請嘗試在資料庫中新增幾筆分數,並觀察產生的樹狀結構。在 JS 控制台中:
leaderboard.addScores();
產生的資料庫結構應如下所示,樹狀結構清晰可見,樹狀結構的葉節代表個別分數。
scores
- document
range: 0-333.33
count: 2
scores:
- document
exact:
score: 18
user: 1
- document
exact:
score: 22
user: 2
現在我們已經完成最困難的部分,可以按照先前的說明,遍歷樹狀結構來讀取分數。
async function readRank(playerID, firestore) {
const players = await firestore.collection("players")
.where("user", "==", playerID).get();
if (players.empty) {
throw Error(`Player not found in leaderboard: ${playerID}`);
}
if (players.size > 1) {
console.info(`Multiple scores with player ${playerID}, fetching first`);
}
const player = players.docs[0].data();
const score = player.score;
const scores = firestore.collection("scores");
/**
* Recursively finds a player score in a collection.
* @param {string} id The player's ID, since some players may be tied.
* @param {number} value The player's score.
* @param {admin.firestore.CollectionReference} coll The collection to
* search.
* @param {number} currentCount The current count of players ahead of the
* player.
* @return {Promise<number>} The rank of the player (the number of players
* ahead of them plus one).
*/
async function findPlayerScoreInCollection(id, value, coll, currentCount) {
const snapshot = await coll.get();
for (const doc of snapshot.docs) {
if (doc.get("exact") !== undefined) {
// This is an exact score. If it matches the score we're looking
// for, return. Otherwise, check if it should be counted.
const exact = doc.data().exact;
if (exact.score === value) {
if (exact.user === id) {
// Score found.
return currentCount + 1;
} else {
// The player is tied with another. In this case, don't increment
// the count.
continue;
}
} else if (exact.score > value) {
// Increment count
currentCount++;
continue;
} else {
// Do nothing
continue;
}
} else {
// This is a range. If it matches the score we're looking for,
// search the range recursively, otherwise, check if it should be
// counted.
const range = doc.data().range;
const count = doc.get("count");
if (range.min > value) {
// The range is greater than the score, so add it to the rank
// count.
currentCount += count;
continue;
} else if (range.max <= value) {
// do nothing
continue;
} else {
const subcollection = doc.ref.collection("scores");
return findPlayerScoreInCollection(
id,
value,
subcollection,
currentCount,
);
}
}
}
// There was no range containing the score.
throw Error(`Range not found for score: ${value}`);
}
const rank = await findPlayerScoreInCollection(playerID, score, scores, 0);
return {
user: playerID,
rank: rank,
score: score,
};
}
更新作業則留待額外練習。請嘗試使用 leaderboard.addScore(id, score)
和 leaderboard.getRank(id)
方法,在 JS 控制台中新增及擷取分數,並查看 Firebase 控制台中的排行榜變化。
不過,我們為達成對數效能而增加的複雜度,會帶來成本。
- 首先,由於交易需要鎖定文件的讀取和寫入作業,確保文件保持一致,因此這個排行榜實作可能會遇到鎖定爭用問題。
- 其次,Firestore 子集合深度限制為 100,也就是說,您必須避免在 100 個綁定分數後建立子樹狀結構,但這個實作方式不會這麼做。
- 最後,只有在樹狀結構平衡的理想情況下,這個排行榜才會以對數方式擴展;如果樹狀結構不平衡,這個排行榜的最差效能仍為線性。
完成後,請透過 Firebase 控制台刪除 scores
和 players
集合,接著進行最後一個排行榜實作步驟。
6. 實作隨機 (機率) 排行榜
執行插入程式碼時,您可能會發現,如果平行執行次數過多,函式就會開始失敗,並顯示與交易鎖定爭用相關的錯誤訊息。本程式碼研究室不會探討解決這個問題的方法,但如果您不需要精確排名,可以捨棄先前做法的所有複雜性,改用更簡單快速的方法。接下來,我們將瞭解如何傳回玩家分數的預估排名,而非確切排名,以及這項做法如何改變資料庫邏輯。
採用這種做法時,我們會將排行榜分成 100 個區間,每個區間代表我們預期會收到的分數約 1%。即使不知道分數分布情況,這個方法也適用。在這種情況下,我們無法保證分數在儲存區中大致平均分布,但如果知道分數分布情況,就能更精確地估算。
我們的做法如下:與先前一樣,每個值區都會儲存分數範圍內的分數數量和分數範圍。插入新分數時,我們會找出該分數的 bucket 並遞增其計數。擷取排名時,我們會將該排名之前的儲存區加總,然後在儲存區內進行估算,而不是進一步搜尋。這可讓我們進行非常好的常數時間查閱和插入作業,而且需要的程式碼少得多。
首先,插入:
// Add this line to the top of your file.
const admin = require("firebase-admin");
// Implement this method (again).
async function createScore(playerID, score, firestore) {
const scores = await firestore.collection("scores").get();
if (scores.empty) {
// Create the buckets since they don't exist yet.
// In a real app, don't do this in your write function. Do it once
// manually and then keep the buckets in your database forever.
for (let i = 0; i < 10; i++) {
const min = i * 100;
const max = (i + 1) * 100;
const data = {
range: {
min: min,
max: max,
},
count: 0,
};
await firestore.collection("scores").doc().create(data);
}
throw Error("Database not initialized");
}
const buckets = await firestore.collection("scores")
.where("range.min", "<=", score).get();
for (const bucket of buckets.docs) {
const range = bucket.get("range");
if (score < range.max) {
const writeBatch = firestore.batch();
const playerDoc = firestore.collection("players").doc();
writeBatch.create(playerDoc, {
user: playerID,
score: score,
});
writeBatch.update(
bucket.ref,
{count: admin.firestore.FieldValue.increment(1)},
);
const scoreDoc = bucket.ref.collection("scores").doc();
writeBatch.create(scoreDoc, {
user: playerID,
score: score,
});
return writeBatch.commit();
}
}
}
您會發現這段插入程式碼在頂端有一些邏輯,用於初始化資料庫狀態,並警告您不要在正式環境中執行類似操作。初始化程式碼完全不會防範競爭狀況,因此如果您這麼做,多個並行寫入作業會提供一堆重複的 bucket,導致資料庫損毀。
請繼續部署函式,然後執行插入作業,將所有 bucket 初始化為零。系統會傳回錯誤,您可以放心忽略。
leaderboard.addScore(999, 0); // The params aren't important here.
資料庫已正確初始化,現在可以執行 addScores
,並在 Firebase 控制台中查看資料結構。雖然表面上相似,但產生的結構比上次實作的結構平坦許多。
leaderboard.addScores();
如要查看閱讀分數,請按照下列步驟操作:
async function readRank(playerID, firestore) {
const players = await firestore.collection("players")
.where("user", "==", playerID).get();
if (players.empty) {
throw Error(`Player not found in leaderboard: ${playerID}`);
}
if (players.size > 1) {
console.info(`Multiple scores with player ${playerID}, fetching first`);
}
const player = players.docs[0].data();
const score = player.score;
const scores = await firestore.collection("scores").get();
let currentCount = 1; // Player is rank 1 if there's 0 better players.
let interp = -1;
for (const bucket of scores.docs) {
const range = bucket.get("range");
const count = bucket.get("count");
if (score < range.min) {
currentCount += count;
} else if (score >= range.max) {
// do nothing
} else {
// interpolate where the user is in this bucket based on their score.
const relativePosition = (score - range.min) / (range.max - range.min);
interp = Math.round(count - (count * relativePosition));
}
}
if (interp === -1) {
// Didn't find a correct bucket
throw Error(`Score out of bounds: ${score}`);
}
return {
user: playerID,
rank: currentCount + interp,
score: score,
};
}
由於我們已讓 addScores
函式產生分數的均勻分布,且在 bucket 中使用線性插補,因此可獲得非常準確的結果,隨著使用者人數增加,排行榜的效能不會降低,更新計數時也不必擔心鎖定爭用 (在某種程度上)。
7. 附錄:作弊
等等,您可能會想,如果我透過瀏覽器分頁的 JS 控制台將值寫入程式碼研究室,那麼任何玩家是否都能向排行榜謊報,聲稱自己獲得高分,但其實是作弊?
可以。如要防止作弊,最有效的方法是透過安全性規則停用用戶端寫入資料庫的作業,安全存取 Cloud Functions,讓用戶端無法直接呼叫,然後在伺服器上驗證遊戲內動作,再將分數更新傳送至排行榜。
請注意,這項策略並非防止作弊的萬靈丹。只要誘因夠大,作弊者就能找到方法規避伺服器端驗證。許多大型熱門電玩遊戲都會不斷與作弊者鬥智,找出新的作弊手法並加以防堵。這項現象的嚴重後果是,每個遊戲的伺服器端驗證本質上都是客製化。雖然 Firebase 提供 App Check 等防濫用工具,可防止使用者透過簡單的指令碼用戶端複製遊戲,但 Firebase 並未提供任何可全面防作弊的服務。
如果遊戲夠熱門,或作弊門檻夠低,只要沒有伺服器端驗證,排行榜上的最高分就會全是作弊者。
8. 恭喜
恭喜,您已在 Firebase 上成功建構四種不同的排行榜!您可以根據遊戲對準確度和速度的需求,選擇合適的服務,並以合理的價格使用。
接著,請查看遊戲的學習路徑。