1. บทนำ
อัปเดตล่าสุด: 27-01-2023
การสร้างลีดเดอร์บอร์ดต้องใช้อะไรบ้าง?
โดยพื้นฐานแล้ว กระดานผู้นำเป็นเพียงตารางคะแนนที่มีปัจจัยซับซ้อนประการหนึ่ง การอ่านอันดับของคะแนนที่กำหนดจะต้องมีความรู้เกี่ยวกับคะแนนอื่นๆ ทั้งหมดตามลำดับ นอกจากนี้ หากเกมของคุณได้รับความนิยม บอร์ดผู้นำของคุณจะขยายใหญ่ขึ้นและมีการอ่านและเขียนบ่อยครั้ง หากต้องการสร้างกระดานผู้นำที่ประสบความสำเร็จ จะต้องสามารถจัดการการดำเนินการจัดอันดับนี้ได้อย่างรวดเร็ว
สิ่งที่คุณจะสร้าง
ใน Codelab นี้ คุณจะใช้กระดานผู้นำต่างๆ ซึ่งแต่ละกระดานเหมาะสำหรับสถานการณ์ที่แตกต่างกัน
สิ่งที่คุณจะได้เรียนรู้
คุณจะได้เรียนรู้วิธีใช้งานลีดเดอร์บอร์ดที่แตกต่างกันสี่แบบ:
- การใช้งานที่ไร้เดียงสาโดยใช้การนับบันทึกอย่างง่ายเพื่อกำหนดอันดับ
- บอร์ดผู้นำราคาถูกและมีการอัปเดตเป็นระยะ
- กระดานผู้นำแบบเรียลไทม์พร้อมเรื่องไร้สาระบางอย่าง
- กระดานผู้นำสุ่ม (น่าจะเป็น) สำหรับการจัดอันดับโดยประมาณของฐานผู้เล่นที่มีขนาดใหญ่มาก
สิ่งที่คุณต้องการ
- Chrome เวอร์ชันล่าสุด (107 ขึ้นไป)
- Node.js 16 หรือสูงกว่า (เรียกใช้
nvm --version
เพื่อดูหมายเลขเวอร์ชันของคุณหากคุณใช้ nvm) - แผน Firebase Blaze แบบชำระเงิน (ไม่บังคับ)
- Firebase CLI v11.16.0 หรือสูงกว่า
หากต้องการติดตั้ง CLI คุณสามารถเรียกใช้npm install -g firebase-tools
หรือดู เอกสารประกอบของ CLI สำหรับตัวเลือกการติดตั้งเพิ่มเติม - ความรู้เกี่ยวกับ JavaScript, Cloud Firestore, ฟังก์ชั่นคลาวด์ และ Chrome DevTools
2. ตั้งค่า
รับรหัส
เราได้รวมทุกสิ่งที่คุณต้องการสำหรับโปรเจ็กต์นี้ไว้ใน repo Git ในการเริ่มต้น คุณจะต้องคว้าโค้ดและเปิดมันในสภาพแวดล้อมการพัฒนาที่คุณชื่นชอบ สำหรับ Codelab นี้ เราใช้ VS Code แต่โปรแกรมแก้ไขข้อความใดๆ ก็ใช้ได้
หรือโคลนลงในไดเร็กทอรีที่คุณเลือก:
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
จุดเริ่มต้นของเราคืออะไร?
โครงการของเราในปัจจุบันเป็นเพียงกระดานชนวนว่างเปล่าและมีฟังก์ชันว่างบางส่วน:
-
index.html
มีสคริปต์กาวที่ช่วยให้เราสามารถเรียกใช้ฟังก์ชันจากคอนโซล dev และดูผลลัพธ์ได้ เราจะใช้สิ่งนี้เพื่อเชื่อมต่อกับแบ็กเอนด์ของเราและดูผลลัพธ์ของการเรียกใช้ฟังก์ชันของเรา ในสถานการณ์จริง คุณจะทำการเรียกแบ็กเอนด์จากเกมของคุณโดยตรง—เราไม่ได้ใช้เกมใน Codelab นี้เนื่องจากจะใช้เวลานานเกินไปในการเล่นเกมทุกครั้งที่คุณต้องการเพิ่มคะแนนให้กับกระดานผู้นำ . -
functions/index.js
มีฟังก์ชั่นคลาวด์ทั้งหมดของเรา คุณจะเห็นฟังก์ชันยูทิลิตี้บางอย่าง เช่นaddScores
และdeleteScores
รวมถึงฟังก์ชันที่เราจะใช้ใน Codelab นี้ ซึ่งจะเรียกฟังก์ชันตัวช่วยในไฟล์อื่น -
functions/functions-helpers.js
มีฟังก์ชันว่างที่เราจะใช้ สำหรับบอร์ดผู้นำแต่ละบอร์ด เราจะใช้ฟังก์ชันการอ่าน สร้าง และอัปเดต และคุณจะเห็นว่าการเลือกใช้งานของเราส่งผลต่อความซับซ้อนของการนำไปใช้และประสิทธิภาพการปรับขนาดอย่างไร -
functions/utils.js
มีฟังก์ชั่นยูทิลิตี้เพิ่มเติม เราจะไม่แตะต้องไฟล์นี้ใน Codelab นี้
สร้างและกำหนดค่าโปรเจ็กต์ Firebase
- ใน คอนโซล Firebase คลิก เพิ่มโครงการ
- หากต้องการสร้างโปรเจ็กต์ใหม่ ให้ป้อนชื่อโปรเจ็กต์ที่ต้องการ
นอกจากนี้ยังจะตั้งค่ารหัสโปรเจ็กต์ (แสดงใต้ชื่อโปรเจ็กต์) ให้เป็นค่าบางอย่างตามชื่อโปรเจ็กต์ คุณสามารถเลือกคลิกไอคอน แก้ไข บนรหัสโปรเจ็กต์เพื่อปรับแต่งเพิ่มเติมได้ - หากได้รับแจ้ง ให้อ่านและยอมรับ ข้อกำหนดของ Firebase
- คลิก ดำเนินการต่อ
- เลือกตัวเลือก เปิดใช้งาน Google Analytics สำหรับโปรเจ็กต์นี้ จากนั้นคลิก ดำเนินการต่อ
- เลือกบัญชี Google Analytics ที่มีอยู่เพื่อใช้หรือเลือก สร้างบัญชีใหม่ เพื่อสร้างบัญชีใหม่
- คลิก สร้างโครงการ
- เมื่อสร้างโครงการแล้ว คลิก ดำเนินการต่อ
- จากเมนู สร้าง คลิก ฟังก์ชัน และหากได้รับแจ้ง ให้อัปเกรดโปรเจ็กต์ของคุณเพื่อใช้แผนการเรียกเก็บเงิน Blaze
- จากเมนู Build คลิก ฐานข้อมูล Firestore
- ในกล่องโต้ตอบ สร้างฐานข้อมูล ที่ปรากฏขึ้น ให้เลือก เริ่มในโหมดทดสอบ จากนั้นคลิก ถัดไป
- เลือกภูมิภาคจากดรอปดาวน์ ตำแหน่ง Cloud Firestore จากนั้นคลิก เปิดใช้งาน
กำหนดค่าและเรียกใช้ลีดเดอร์บอร์ดของคุณ
- ในเทอร์มินัล ให้นำทางไปยังรูทโปรเจ็กต์และรัน
firebase use --add
เลือกโปรเจ็กต์ Firebase ที่คุณเพิ่งสร้างขึ้น - ในรูทของโปรเจ็กต์ ให้รัน
firebase emulators:start --only hosting
- ในเบราว์เซอร์ของคุณ ให้ไปที่
localhost:5000
- เปิดคอนโซล JavaScript ของ Chrome DevTools และนำเข้า
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
จากนั้นในคอนโซล JS ของ Chrome ให้เพิ่มคะแนนอื่นๆ เพื่อให้เราดูอันดับของเราในหมู่ผู้เล่นคนอื่นๆ ได้
leaderboard.addScores(); // Results may take some time to appear.
ตอนนี้เราสามารถเพิ่มคะแนนของเราเองลงในมิกซ์ได้:
leaderboard.addScore(999, 11); // You can make up a score (second argument) here.
เมื่อการเขียนเสร็จสิ้น คุณจะเห็นการตอบกลับในคอนโซลว่า "สร้างคะแนนแล้ว" เห็นข้อผิดพลาดแทน? เปิดบันทึกฟังก์ชันผ่านคอนโซล Firebase เพื่อดูว่ามีอะไรผิดพลาด
และสุดท้าย เราก็สามารถดึงข้อมูลและอัปเดตคะแนนของเราได้
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 ของคุณโดยคลิกที่จุด 3 จุดถัดจากการรวบรวมคะแนนเพื่อเตรียมพร้อมสำหรับส่วนถัดไป
5. ใช้กระดานผู้นำแบบต้นไม้แบบเรียลไทม์
วิธีการนี้ทำงานโดยการจัดเก็บข้อมูลการค้นหาไว้ในคอลเลกชันฐานข้อมูล แทนที่จะมีคอลเลกชันที่เหมือนกัน เป้าหมายของเราคือการจัดเก็บทุกสิ่งไว้ในแผนผังที่เราสามารถสำรวจได้โดยการย้ายผ่านเอกสาร สิ่งนี้ช่วยให้เราทำการค้นหาแบบไบนารี (หรือ n-ary) สำหรับอันดับคะแนนที่กำหนด นั่นอาจมีลักษณะเป็นอย่างไร?
ในการเริ่มต้น เราจะต้องการกระจายคะแนนของเราลงในกลุ่มข้อมูลแบบเท่าๆ กัน ซึ่งจะต้องอาศัยความรู้บางประการเกี่ยวกับค่าของคะแนนที่ผู้ใช้ของเรากำลังบันทึกไว้ ตัวอย่างเช่น หากคุณกำลังสร้างกระดานผู้นำสำหรับการให้คะแนนทักษะในเกมการแข่งขัน การให้คะแนนทักษะของผู้ใช้ของคุณมักจะจบลงด้วยการกระจายตามปกติ ฟังก์ชันสร้างคะแนนแบบสุ่มของเราใช้ Math.random()
ของ JavaScript ซึ่งส่งผลให้มีการกระจายเท่าๆ กันโดยประมาณ ดังนั้นเราจะแบ่งที่เก็บข้อมูลของเราเท่าๆ กัน
ในตัวอย่างนี้ เราจะใช้ 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.
* @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,
};
}
การอัปเดตจะเหลือไว้เป็นแบบฝึกหัดเพิ่มเติม ลองเพิ่มและดึงคะแนนในคอนโซล JS ของคุณด้วยเมธอด leaderboard.addScore(id, score)
และ leaderboard.getRank(id)
และดูว่าลีดเดอร์บอร์ดของคุณเปลี่ยนแปลงไปอย่างไรในคอนโซล Firebase
อย่างไรก็ตาม ด้วยการปรับใช้นี้ ความซับซ้อนที่เราได้เพิ่มเพื่อให้ได้ประสิทธิภาพลอการิทึมต้องแลกมาด้วยต้นทุน
- ประการแรก การใช้งานกระดานผู้นำนี้อาจประสบปัญหาการช่วงชิงการล็อก เนื่องจากธุรกรรมจำเป็นต้องมีการล็อกการอ่านและเขียนลงในเอกสารเพื่อให้แน่ใจว่าจะสอดคล้องกัน
- ประการที่สอง Firestore กำหนด ขีดจำกัดความลึกของคอลเลกชันย่อยที่ 100 ซึ่งหมายความว่าคุณจะต้องหลีกเลี่ยงการสร้างแผนผังย่อยหลังจากคะแนนที่เสมอกัน 100 คะแนน ซึ่งการใช้งานนี้ไม่มี
- และสุดท้าย บอร์ดผู้นำนี้จะปรับขนาดแบบลอการิทึมเฉพาะในกรณีที่แผนภูมิมีความสมดุลเท่านั้น หากแผนภูมิไม่สมดุล ประสิทธิภาพกรณีที่แย่ที่สุดของบอร์ดผู้นำนี้จะเป็นเส้นตรงอีกครั้ง
เมื่อเสร็จแล้ว ให้ลบ scores
และคอลเลกชัน players
ผ่านคอนโซล Firebase แล้วเราจะไปยังการใช้งานลีดเดอร์บอร์ดครั้งสุดท้าย
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. ภาคผนวก: การโกง
เดี๋ยวก่อน คุณอาจกำลังคิดว่าหากฉันกำลังเขียนค่าลงใน Codelab ของฉันผ่านคอนโซล JS ของแท็บเบราว์เซอร์ ผู้เล่นของฉันคนใดจะโกหกกระดานผู้นำแล้วบอกว่าพวกเขาได้คะแนนสูงโดยที่พวกเขาไม่ได้ทำ บรรลุธรรม?
ใช่ พวกเขาทำได้ หากคุณต้องการป้องกันการโกง วิธีที่มีประสิทธิภาพที่สุดในการดำเนินการดังกล่าวคือการปิดใช้งานการเขียนไคลเอนต์ไปยังฐานข้อมูลของคุณผ่าน กฎความ ปลอดภัย เข้าถึงฟังก์ชันคลาวด์ของคุณอย่างปลอดภัย เพื่อให้ไคลเอนต์ไม่สามารถโทรหาพวกเขาได้โดยตรง จากนั้นตรวจสอบการกระทำในเกมบนเซิร์ฟเวอร์ของคุณก่อน ส่งการอัปเดตคะแนนไปยังลีดเดอร์บอร์ด
สิ่งสำคัญที่ควรทราบคือกลยุทธ์นี้ไม่ใช่ยาครอบจักรวาลสำหรับการโกง - ด้วยแรงจูงใจที่มากพอ ผู้ขี้โกงสามารถค้นหาวิธีหลีกเลี่ยงการตรวจสอบฝั่งเซิร์ฟเวอร์ได้ และวิดีโอเกมขนาดใหญ่ที่ประสบความสำเร็จจำนวนมากก็เล่นแมวจับหนูกับผู้ขี้โกงอยู่ตลอดเวลาเพื่อระบุตัวตน กลโกงใหม่และหยุดการแพร่กระจาย ผลที่ตามมาที่ยากลำบากของปรากฏการณ์นี้คือการตรวจสอบฝั่งเซิร์ฟเวอร์สำหรับทุกเกมนั้นเป็นไปตามธรรมชาติ แม้ว่า Firebase จะมีเครื่องมือป้องกันการละเมิด เช่น App Check ที่จะป้องกันไม่ให้ผู้ใช้คัดลอกเกมของคุณผ่านไคลเอนต์ที่ใช้สคริปต์ธรรมดา แต่ Firebase ไม่ได้ให้บริการใดๆ ที่เทียบเท่ากับการต่อต้านการโกงแบบองค์รวม
อะไรก็ตามที่ขาดการตรวจสอบฝั่งเซิร์ฟเวอร์ สำหรับเกมที่ได้รับความนิยมหรือมีอุปสรรคในการโกงต่ำจะส่งผลให้กระดานผู้นำซึ่งค่าสูงสุดคือผู้โกงทั้งหมด
8. ขอแสดงความยินดี
ยินดีด้วย คุณสร้างลีดเดอร์บอร์ดที่แตกต่างกัน 4 รายการบน Firebase สำเร็จแล้ว ขึ้นอยู่กับความต้องการของเกมของคุณในด้านความแม่นยำและความเร็ว คุณจะสามารถเลือกเกมที่เหมาะกับคุณได้ในราคาที่สมเหตุสมผล
ถัดไป โปรดดู เส้นทางการเรียนรู้ สำหรับเกม