一、簡介
最後更新: 2023-01-27
建立排行榜需要什麼?
從本質上講,排行榜只是帶有一個複雜因素的分數表:讀取任何給定分數的排名需要以某種順序了解所有其他分數。此外,如果您的遊戲取得成功,您的排行榜將會變得越來越大,並且會被頻繁讀取和寫入。為了建立一個成功的排行榜,它需要能夠快速處理這種排名操作。
你將構建什麼
在此 Codelab 中,您將實現各種不同的排行榜,每種排行榜適用於不同的場景。
你將學到什麼
您將學習如何實現四種不同的排行榜:
- 使用簡單的記錄計數來確定排名的簡單實現
- 一個便宜的、定期更新的排行榜
- 帶有一些樹廢話的實時排行榜
- 用於對非常大的玩家群進行近似排名的隨機(概率)排行榜
你需要什麼
- 最新版本的 Chrome(107 或更高版本)
- Node.js 16 或更高版本(如果您使用 nvm,請運行
nvm --version
查看版本號) - 付費 Firebase Blaze 計劃(可選)
- Firebase CLI v11.16.0 或更高版本
要安裝 CLI,您可以運行npm install -g firebase-tools
或參閱CLI 文檔以獲取更多安裝選項。 - 了解 JavaScript、Cloud Firestore、Cloud Function 和Chrome DevTools
2. 設置
獲取代碼
我們已將此項目所需的所有內容放入 Git 存儲庫中。首先,您需要獲取代碼並在您最喜歡的開發環境中打開它。在本 Codelab 中,我們使用了 VS Code,但任何文本編輯器都可以。
或者,克隆到您選擇的目錄中:
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
我們的出發點是什麼?
我們的項目目前是一張白紙,有一些空函數:
-
index.html
包含一些粘合腳本,讓我們可以從開發控制台調用函數並查看它們的輸出。我們將使用它與後端交互並查看函數調用的結果。在現實場景中,您可以直接從遊戲中進行這些後端調用 - 我們在此 Codelab 中不使用遊戲,因為每次您想要將分數添加到排行榜時,玩遊戲都會花費太長時間。 -
functions/index.js
包含我們所有的雲函數。您將看到一些實用函數,例如addScores
和deleteScores
,以及我們將在此 Codelab 中實現的函數,這些函數調用另一個文件中的輔助函數。 -
functions/functions-helpers.js
包含我們將實現的空函數。對於每個排行榜,我們將實現讀取、創建和更新功能,您將看到我們選擇的實現如何影響實現的複雜性及其擴展性能。 -
functions/utils.js
包含更多實用函數。我們不會在此 Codelab 中觸及此文件。
創建並配置 Firebase 項目
- 在Firebase 控制台中,單擊添加項目。
- 要創建新項目,請輸入所需的項目名稱。
這還將根據項目名稱將項目 ID(顯示在項目名稱下方)設置為某些內容。您可以選擇單擊項目 ID 上的編輯圖標以進一步自定義它。 - 如果出現提示,請查看並接受Firebase 條款。
- 單擊繼續。
- 選擇為此項目啟用 Google Analytics選項,然後單擊繼續。
- 選擇要使用的現有 Google Analytics 帳戶,或選擇創建新帳戶來創建新帳戶。
- 單擊創建項目。
- 創建項目後,單擊“繼續” 。
- 從“構建”菜單中,單擊“功能” ,如果出現提示,請升級您的項目以使用 Blaze 計費計劃。
- 從“構建”菜單中,單擊Firestore 數據庫。
- 在出現的“創建數據庫”對話框中,選擇“以測試模式啟動” ,然後單擊“下一步” 。
- 從Cloud Firestore 位置下拉列表中選擇一個區域,然後單擊“啟用” 。
配置並運行您的排行榜
- 在終端中,導航到項目根目錄並運行
firebase use --add
。選擇您剛剛創建的 Firebase 項目。 - 在項目的根目錄中,運行
firebase emulators:start --only hosting
。 - 在瀏覽器中,導航到
localhost:5000
。 - 打開 Chrome DevTools 的 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"),
};
}
不幸的是,如果不在項目中添加計費帳戶,您將無法部署和測試它。如果您確實有計費帳戶,請縮短預定功能的時間間隔,並觀看您的功能神奇地為您的排行榜分數分配排名。
如果沒有,則刪除預定的函數並跳到下一個實現。
繼續並通過單擊分數集合旁邊的 3 個點來刪除 Firestore 數據庫中的分數,為下一部分做好準備。
5. 實現實時樹排行榜
這種方法的工作原理是將搜索數據存儲在數據庫集合本身中。我們的目標不是擁有統一的集合,而是將所有內容存儲在樹中,我們可以通過移動文檔來遍歷該樹。這使我們能夠對給定分數的排名執行二元(或 n 元)搜索。那會是什麼樣子?
首先,我們希望能夠將分數分佈到大致均勻的桶中,這需要對用戶記錄的分數值有一定的了解;例如,如果您正在為競技遊戲中的技能評級構建排行榜,則用戶的技能評級幾乎總是呈正態分佈。我們的隨機分數生成函數使用 JavaScript 的Math.random()
,這會產生近似均勻的分佈,因此我們將均勻地劃分我們的存儲桶。
在此示例中,為簡單起見,我們將使用 3 個存儲桶,但您可能會發現,如果在實際應用程序中使用此實現,更多存儲桶將產生更快的結果 - 較淺的樹平均意味著更少的集合獲取和更少的鎖爭用。
玩家的排名是由得分較高的玩家人數加上玩家本身的分數得出的。 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.
* @returns {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. 實現隨機(概率)排行榜
運行插入代碼時,您可能會注意到,如果並行運行太多次,您的函數將開始失敗,並顯示與事務鎖爭用相關的錯誤消息。我們不會在本 Codelab 中探索解決這個問題的方法,但如果您不需要精確的排名,您可以放棄之前方法的所有復雜性,以獲得更簡單、更快的方法。讓我們看一下如何返回玩家分數的估計排名而不是準確排名,以及這如何改變我們的數據庫邏輯。
對於這種方法,我們將排行榜分為 100 個部分,每個部分大約代表我們期望收到的分數的百分之一。即使不知道我們的分數分佈,這種方法也可以工作,在這種情況下,我們無法保證整個存儲桶中分數的大致均勻分佈,但如果我們確實知道我們的分數將如何分佈,我們將在近似值中獲得更高的精度。
我們的方法是這樣的:和以前一樣,每個桶存儲其內分數的數量和分數的範圍。插入新分數時,我們將找到該分數的存儲桶並增加其計數。當獲取排名時,我們只需將其前面的存儲桶相加,然後在我們的存儲桶內進行近似,而不是進一步搜索。這為我們提供了非常好的恆定時間查找和插入,並且需要更少的代碼。
一、插入:
// 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();
}
}
}
您會注意到此插入代碼有一些邏輯用於在頂部初始化數據庫狀態,並警告不要在生產中執行此類操作。初始化代碼根本沒有針對競爭條件的保護,因此如果您要這樣做,多個並發寫入會通過給您一堆重複的存儲桶來損壞您的數據庫。
繼續部署您的函數,然後運行插入以初始化計數為零的所有存儲桶。它會返回一個錯誤,您可以安全地忽略該錯誤。
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
函數生成均勻分佈的分數,並且我們在存儲桶內使用線性插值,因此我們將獲得非常準確的結果,隨著用戶數量的增加,排行榜的性能不會降低,並且在更新計數時我們不必擔心鎖爭用(同樣多)。
7. 附錄:作弊
等一下,您可能會想,如果我通過瀏覽器選項卡的 JS 控制台向我的 Codelab 寫入值,我的任何玩家難道不能在排行榜上撒謊並說他們獲得了高分嗎?公平地實現?
是的他們可以。如果您想防止作弊,最可靠的方法是通過安全規則禁用客戶端寫入數據庫,保護對雲功能的訪問,以便客戶端無法直接調用它們,然後在您的服務器上驗證遊戲內操作。將分數更新發送到排行榜。
值得注意的是,這種策略並不是對抗作弊的靈丹妙藥——只要有足夠大的激勵,作弊者就可以找到繞過服務器端驗證的方法,並且許多大型成功的視頻遊戲不斷地與作弊者玩貓捉老鼠的遊戲來識別作弊者。新的作弊行為並阻止它們擴散。這種現象的一個困難後果是,每個遊戲的服務器端驗證本質上都是定制的;儘管 Firebase 提供了 App Check 等反濫用工具,可以防止用戶通過簡單的腳本客戶端複製您的遊戲,但 Firebase 並不提供任何相當於整體反作弊的服務。
對於足夠流行的遊戲或足夠低的作弊障礙,任何缺乏服務器端驗證的行為都會導致排行榜上的最高值都是作弊者。
8. 恭喜
恭喜您,您已在 Firebase 上成功構建了四個不同的排行榜!根據您的遊戲對精確性和速度的需求,您將能夠以合理的成本選擇適合您的一款。
接下來,查看遊戲的學習路徑。