1. บทนำ
อัปเดตล่าสุด 27-01-2023
การสร้างลีดเดอร์บอร์ดต้องทำอย่างไร
หลักๆ แล้ว ลีดเดอร์บอร์ดเป็นเพียงตารางคะแนนที่มีปัจจัยที่ซับซ้อนอย่างหนึ่ง การอ่านอันดับของคะแนนใดก็ตามต้องอาศัยความรู้เกี่ยวกับคะแนนอื่นๆ ทั้งหมดตามลำดับ นอกจากนี้ หากเริ่มเกม ลีดเดอร์บอร์ดของคุณจะมีขนาดใหญ่ขึ้น โดยจะมีการอ่านและเขียนอย่างต่อเนื่อง ในการสร้างลีดเดอร์บอร์ดที่ประสบความสำเร็จ ลีดเดอร์บอร์ดนั้นต้องสามารถจัดการกับการดำเนินการจัดอันดับนี้ได้อย่างรวดเร็ว
สิ่งที่คุณจะสร้าง
ใน Codelab นี้ คุณจะได้ใช้ลีดเดอร์บอร์ดต่างๆ แตกต่างกันไป ซึ่งแต่ละรายการจะเหมาะสมกับสถานการณ์ที่แตกต่างกัน
สิ่งที่คุณจะได้เรียนรู้
คุณจะได้เรียนรู้วิธีการใช้ลีดเดอร์บอร์ด 4 แบบดังนี้
- การใช้งานแบบไร้เดียงสาที่ใช้การนับระเบียนอย่างง่ายเพื่อกำหนดอันดับ
- ลีดเดอร์บอร์ดราคาถูกและอัปเดตเป็นระยะๆ
- ลีดเดอร์บอร์ดแบบเรียลไทม์ที่ไม่มีเนื้อหาเกี่ยวกับต้นไม้
- ลีดเดอร์บอร์ด stochastic (ความน่าจะเป็น) สำหรับการจัดอันดับโดยประมาณของฐานผู้เล่นที่มีขนาดใหญ่มาก
สิ่งที่ต้องมี
- Chrome เวอร์ชันล่าสุด (107 ขึ้นไป)
- Node.js 16 ขึ้นไป (เรียกใช้
nvm --version
เพื่อดูหมายเลขเวอร์ชันหากคุณใช้ nvm) - แพ็กเกจ Firebase Blaze แบบชำระเงิน (ไม่บังคับ)
- Firebase CLI เวอร์ชัน 11.16.0 ขึ้นไป
หากต้องการติดตั้ง CLI คุณสามารถเรียกใช้npm install -g firebase-tools
หรือดูเอกสาร CLI เพื่อดูตัวเลือกการติดตั้งเพิ่มเติม - ความรู้เกี่ยวกับ JavaScript, Cloud Firestore, Cloud Functions และเครื่องมือสำหรับนักพัฒนาเว็บใน Chrome
2. การตั้งค่า
รับรหัส
เราได้ใส่ทุกอย่างที่คุณต้องการสำหรับโปรเจ็กต์นี้ไว้ในที่เก็บ Git แล้ว ในการเริ่มต้นใช้งาน คุณจะต้องดึงโค้ดและเปิดในสภาพแวดล้อมของการพัฒนาซอฟต์แวร์ที่คุณชื่นชอบ เราใช้ VS Code สำหรับ Codelab นี้ แต่ตัวแก้ไขข้อความจะใช้แทน
แล้วแตกไฟล์ ZIP ที่ดาวน์โหลดมา
หรือโคลนในไดเรกทอรีที่ต้องการ ดังนี้
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
เริ่มจากอะไร
ปัจจุบันโปรเจ็กต์ของเราเป็นแถบสเลทเปล่าและมีหน้าที่ว่างเปล่าดังนี้
index.html
มีสคริปต์กาวบางอย่างที่ช่วยให้เราเรียกใช้ฟังก์ชันจากคอนโซลสำหรับนักพัฒนาซอฟต์แวร์และดูเอาต์พุตได้ เราจะใช้ข้อมูลนี้เพื่อเชื่อมต่อกับแบ็กเอนด์และดูผลลัพธ์ของการเรียกใช้ฟังก์ชัน ในสถานการณ์จริง คุณจะเรียกใช้แบ็กเอนด์เหล่านี้จากเกมของคุณโดยตรง เราไม่ได้ใช้เกมใน Codelab นี้เนื่องจากจะต้องใช้เวลานานเกินไปในการเล่นเกมทุกครั้งที่คุณต้องการเพิ่มคะแนนลงในลีดเดอร์บอร์ดfunctions/index.js
มี Cloud Functions ทั้งหมด คุณจะเห็นฟังก์ชันยูทิลิตีบางอย่าง เช่น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 รายการ ตัวเลขนี้ก็จะอยู่ในระดับต่ำกว่า 1 ล้านคนโดยประมาณ แต่ประสิทธิภาพก็ยังคงเป็นเส้นตรง
แต่เดี๋ยวก่อน คุณอาจคิดกับตัวเองว่าถ้าเราจะแจกแจงเอกสารทั้งหมดในคอลเล็กชัน เราสามารถกำหนดอันดับให้กับเอกสารทุกฉบับได้ และเมื่อเราจำเป็นต้องดึงข้อมูล การดึงข้อมูลของเราจะเป็น 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 รายการเพื่อความเรียบง่าย แต่คุณน่าจะพบว่าหากคุณใช้การติดตั้งใช้งานนี้ในแอปจริง จะมีที่เก็บข้อมูลมากขึ้นจะให้ผลลัพธ์ที่เร็วขึ้น ต้นไม้ที่ตื้นขึ้นหมายความว่าโดยเฉลี่ยแล้วมีการดึงข้อมูลคอลเล็กชันน้อยกว่าและการช่วงชิงล็อกที่น้อยลง
อันดับของผู้เล่นจะมาจากผลรวมของจำนวนผู้เล่นที่มีคะแนนสูงกว่า บวกด้วย 1 อันดับสำหรับตัวผู้เล่นเอง แต่ละคอลเล็กชันภายใต้ scores
จะจัดเก็บเอกสาร 3 รายการ โดยแต่ละรายการมีช่วง จำนวนเอกสารในแต่ละช่วง และคอลเล็กชันย่อยที่เกี่ยวข้อง 3 รายการ ในการอ่านอันดับ เราจะข้ามต้นไม้นี้เพื่อค้นหาคะแนนและติดตามผลรวมของคะแนนที่มากกว่า เมื่อหาคะแนนได้แล้ว เราจะได้ผลรวมที่ถูกต้องด้วย
การเขียนมีความซับซ้อนมากขึ้นมาก ก่อนอื่น เราจะต้องเขียนการเขียนทั้งหมดภายในธุรกรรมเพื่อป้องกันความไม่สอดคล้องกันของข้อมูล เมื่อมีการเขียนหรือการอ่านหลายรายการเกิดขึ้นพร้อมกัน และเรายังจะต้องคงสภาพทั้งหมดที่เราอธิบายไว้ข้างต้นเมื่อเคลื่อนผ่านต้นไม้เพื่อเขียนเอกสารใหม่ของเรา และสุดท้าย เนื่องจากแนวทางใหม่นี้มีความซับซ้อนทั้งหมด ประกอบกับความจำเป็นที่จะต้องจัดเก็บเอกสารต้นฉบับทั้งหมดของเรา ค่าใช้จ่ายในการจัดเก็บจึงเพิ่มขึ้นเล็กน้อย (แต่ก็ยังคงเป็นเส้นตรง)
ใน 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,
});
});
});
}
เรื่องนี้มีความซับซ้อนกว่าการติดตั้งใช้งานครั้งล่าสุดของเรา ซึ่งเป็นการเรียกแบบเมธอดเดียวและมีโค้ดเพียง 6 บรรทัดเท่านั้น เมื่อคุณใช้วิธีนี้แล้ว ให้ลองเพิ่มคะแนนลงในฐานข้อมูลและสังเกตโครงสร้างของแผนผังรูปที่ได้ ในคอนโซล 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
อย่างไรก็ตาม จากการติดตั้งใช้งานนี้ ความซับซ้อนที่เพิ่มขึ้นเพื่อให้ได้ประสิทธิภาพลอการิทึมจะมีค่าใช้จ่าย
- ประการแรก การใช้ลีดเดอร์บอร์ดนี้อาจพบปัญหาการแย่งชิงล็อก เนื่องจากธุรกรรมจำเป็นต้องมีการล็อกการอ่านและเขียนในเอกสารเพื่อให้มั่นใจได้ว่าการทำงานจะสอดคล้องกัน
- ประการที่ 2 Firestore กำหนดขีดจำกัดความลึกของคอลเล็กชันย่อยไว้ที่ 100 ซึ่งหมายความว่าคุณจะต้องหลีกเลี่ยงการสร้างแผนผังย่อยหลังจากคะแนนเท่ากัน 100 คะแนน ซึ่งการใช้งานนี้จะไม่สามารถทำได้
- และสุดท้าย ลีดเดอร์บอร์ดนี้จะปรับขนาดแบบลอการิทึมเฉพาะในอุดมคติที่โครงสร้างต้นไม้สมดุล นั่นคือ ถ้าไม่สมดุล ประสิทธิภาพกรณีที่แย่ที่สุดของลีดเดอร์บอร์ดนี้จะเป็นแบบเชิงเส้นอีกครั้ง
เมื่อเสร็จแล้ว ให้ลบคอลเล็กชัน scores
และ players
ผ่านคอนโซล Firebase และเราจะไปต่อที่การใช้งานลีดเดอร์บอร์ดล่าสุด
6. ใช้ลีดเดอร์บอร์ด stochastic (ความน่าจะเป็น)
เมื่อเรียกใช้โค้ดการแทรก คุณอาจสังเกตเห็นว่าหากเรียกใช้พร้อมกันหลายครั้งเกินไป ฟังก์ชันจะเริ่มล้มเหลวโดยมีข้อความแสดงข้อผิดพลาดที่เกี่ยวข้องกับการช่วงชิงล็อกธุรกรรม ซึ่งมีวิธีทำไม่ได้ที่เราจะไม่ได้สำรวจใน 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();
}
}
}
คุณจะเห็นว่าโค้ดแทรกนี้มีตรรกะในการเริ่มต้นสถานะฐานข้อมูลของคุณที่ด้านบน พร้อมคำเตือนว่าไม่ดำเนินการลักษณะนี้ในเวอร์ชันที่ใช้งานจริง โค้ดสำหรับการเริ่มต้นไม่ได้รับการป้องกันจากสภาวะการแข่งขันเลย ดังนั้นหากคุณทำเช่นนี้ การเขียนพร้อมกันหลายรายการจะทำให้ฐานข้อมูลของคุณเสียหายเนื่องจากมีการสร้างที่เก็บข้อมูลที่ซ้ำกันจำนวนมาก
ติดตั้งใช้งานฟังก์ชันแล้วเรียกใช้การแทรกเพื่อเริ่มต้นที่เก็บข้อมูลทั้งหมดด้วยจำนวนเป็น 0 ระบบจะแสดงผลข้อผิดพลาด ซึ่งคุณสามารถเพิกเฉยได้อย่างปลอดภัย
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 ของแท็บเบราว์เซอร์ ผู้เล่นคนอื่นๆ จะต้องโกหกทั้งลีดเดอร์บอร์ดไม่ได้และบอกว่าได้คะแนนสูงแต่ไม่ถึงพอสมควรเหรอ
ได้ หากต้องการป้องกันการโกง วิธีที่มีประสิทธิภาพมากที่สุดคือปิดใช้การเขียนในฐานข้อมูลของไคลเอ็นต์ผ่านกฎความปลอดภัย การเข้าถึง Cloud Functions ที่ปลอดภัยเพื่อไม่ให้ไคลเอ็นต์เรียกใช้โดยตรง แล้วตรวจสอบการดำเนินการในเกมบนเซิร์ฟเวอร์ก่อนส่งการอัปเดตคะแนนไปยังลีดเดอร์บอร์ด
โปรดทราบว่ากลยุทธ์นี้ไม่ใช่การป้องกันการโกง แต่ด้วยสิ่งจูงใจที่เพียงพอแล้ว นักโกงสามารถหาวิธีหลบเลี่ยงการตรวจสอบฝั่งเซิร์ฟเวอร์ และวิดีโอเกมขนาดใหญ่ที่ประสบความสำเร็จจำนวนมากก็เล่นกับเหล่าคนโกงอย่างต่อเนื่องเพื่อระบุกลโกงใหม่ๆ และหยุดยั้งไม่ให้เพิ่มจำนวนขึ้น ผลลัพธ์ที่ยากของปรากฏการณ์นี้คือการตรวจสอบฝั่งเซิร์ฟเวอร์สำหรับทุกเกมเป็นการสั่งทำโดยธรรมชาติ แม้ว่า Firebase จะมีเครื่องมือป้องกันการละเมิด เช่น App Check ที่จะป้องกันไม่ให้ผู้ใช้คัดลอกเกมของคุณผ่านไคลเอ็นต์ที่ใช้สคริปต์แบบง่าย แต่ Firebase ก็ไม่ได้ให้บริการใดๆ ที่จะเป็นการป้องกันการโกงแบบองค์รวม
สิ่งใดก็ตามที่ขาดการตรวจสอบฝั่งเซิร์ฟเวอร์จะทำให้เกมที่มีความนิยมเพียงพอหรืออุปสรรคในการโกงต่ำพอ จะส่งผลให้เกิดลีดเดอร์บอร์ดที่ค่าอันดับต้นๆ เป็นพวกโกงทั้งหลาย
8. ขอแสดงความยินดี
ขอแสดงความยินดี คุณสร้างลีดเดอร์บอร์ดที่แตกต่างกัน 4 รายการใน Firebase สำเร็จแล้ว คุณสามารถเลือกเกมที่เหมาะกับคุณได้ในราคาที่ย่อมเยา ซึ่งขึ้นอยู่กับความต้องการในเรื่องความแน่นอนและความเร็วของเกม
ถัดไป ลองดูเส้นทางการเรียนรู้สำหรับเกม