1. บทนำ
อัปเดตล่าสุด: 27-01-2023
ต้องทำอย่างไรจึงจะสร้างลีดเดอร์บอร์ดได้
โดยพื้นฐานแล้ว ลีดเดอร์บอร์ดก็คือตารางคะแนนที่มีปัจจัยที่ซับซ้อนเพียงอย่างเดียว นั่นคือการอ่านอันดับสำหรับคะแนนใดก็ตามต้องอาศัยความรู้เกี่ยวกับคะแนนอื่นๆ ทั้งหมดในลำดับใดลำดับหนึ่ง นอกจากนี้ หากเกมของคุณได้รับความนิยม ลีดเดอร์บอร์ดจะมีขนาดใหญ่ขึ้นและมีการอ่านและเขียนข้อมูลบ่อยครั้ง หากต้องการสร้างลีดเดอร์บอร์ดที่ประสบความสำเร็จ ลีดเดอร์บอร์ดจะต้องจัดการการจัดอันดับนี้ได้อย่างรวดเร็ว
สิ่งที่คุณจะสร้าง
ใน Codelab นี้ คุณจะได้ใช้ลีดเดอร์บอร์ดต่างๆ ซึ่งแต่ละลีดเดอร์บอร์ดเหมาะกับสถานการณ์ที่แตกต่างกัน
สิ่งที่คุณจะได้เรียนรู้
คุณจะได้เรียนรู้วิธีติดตั้งใช้งานลีดเดอร์บอร์ด 4 แบบต่อไปนี้
- การติดตั้งใช้งานแบบง่ายๆ โดยใช้การนับระเบียนอย่างง่ายเพื่อกำหนดอันดับ
- ลีดเดอร์บอร์ดราคาถูกที่อัปเดตเป็นระยะ
- ลีดเดอร์บอร์ดแบบเรียลไทม์ที่มีเรื่องไร้สาระเกี่ยวกับต้นไม้
- ลีดเดอร์บอร์ดแบบสุ่ม (อิงตามความน่าจะเป็น) สำหรับการจัดอันดับโดยประมาณของผู้เล่นจำนวนมาก
สิ่งที่ต้องมี
- Chrome เวอร์ชันล่าสุด (107 ขึ้นไป)
- Node.js 16 ขึ้นไป (เรียกใช้
nvm --version
เพื่อดูหมายเลขเวอร์ชันหากคุณใช้ nvm) - แพ็กเกจ Blaze ของ Firebase แบบชำระเงิน (ไม่บังคับ)
- Firebase CLI เวอร์ชัน 11.16.0 ขึ้นไป
หากต้องการติดตั้ง CLI ให้เรียกใช้npm install -g firebase-tools
หรือดูตัวเลือกการติดตั้งเพิ่มเติมได้ที่เอกสารประกอบของ CLI - ความรู้เกี่ยวกับ JavaScript, Cloud Firestore, Cloud Functions และ Chrome DevTools
2. การเริ่มตั้งค่า
รับรหัส
เราได้รวบรวมทุกสิ่งที่คุณต้องการสำหรับโปรเจ็กต์นี้ไว้ในที่เก็บ Git แล้ว หากต้องการเริ่มต้นใช้งาน คุณจะต้องคัดลอกโค้ดและเปิดในสภาพแวดล้อมการพัฒนาที่คุณชื่นชอบ สำหรับ Codelab นี้ เราใช้ 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 ใหม่
- ลงชื่อเข้าใช้คอนโซล Firebase โดยใช้บัญชี Google
- คลิกปุ่มเพื่อสร้างโปรเจ็กต์ใหม่ แล้วป้อนชื่อโปรเจ็กต์ (เช่น
Leaderboards Codelab
)
- คลิกต่อไป
- หากได้รับแจ้ง ให้อ่านและยอมรับข้อกำหนดของ Firebase แล้วคลิกต่อไป
- (ไม่บังคับ) เปิดใช้ความช่วยเหลือจาก AI ในคอนโซล Firebase (เรียกว่า "Gemini ใน Firebase")
- สำหรับ Codelab นี้ คุณไม่จำเป็นต้องใช้ Google Analytics ดังนั้นให้ปิดตัวเลือก Google Analytics
- คลิกสร้างโปรเจ็กต์ รอให้ระบบจัดสรรโปรเจ็กต์ แล้วคลิกดำเนินการต่อ
ตั้งค่าผลิตภัณฑ์ Firebase
- จากเมนูสร้าง ให้คลิกฟังก์ชัน แล้วอัปเกรดโปรเจ็กต์เพื่อใช้แพ็กเกจราคา Blaze หากได้รับแจ้ง
- คลิกฐานข้อมูล Firestore จากเมนูสร้าง
- ในกล่องโต้ตอบสร้างฐานข้อมูลที่ปรากฏขึ้น ให้เลือกเริ่มในโหมดทดสอบ แล้วคลิกถัดไป
- เลือกภูมิภาคจากเมนูแบบเลื่อนลงตำแหน่ง Cloud Firestore แล้วคลิกเปิดใช้
กำหนดค่าและเรียกใช้ลีดเดอร์บอร์ด
- ในเทอร์มินัล ให้ไปที่รูทของโปรเจ็กต์แล้วเรียกใช้
firebase use --add
เลือกโปรเจ็กต์ Firebase ที่คุณเพิ่งสร้าง - เรียกใช้
firebase emulators:start --only hosting
ในรูทของโปรเจ็กต์ - ในเบราว์เซอร์ ให้ไปที่
localhost:5000
- เปิดคอนโซล JavaScript ของเครื่องมือสำหรับนักพัฒนาเว็บใน Chrome แล้วนำเข้า
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 บรรทัด เมื่อใช้วิธีนี้แล้ว ให้ลองเพิ่มคะแนน 2-3 รายการลงในฐานข้อมูลและสังเกตโครงสร้างของแผนผังที่ได้ ในคอนโซล 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. ใช้ลีดเดอร์บอร์ดแบบสุ่ม (เชิงความน่าจะเป็น)
เมื่อเรียกใช้โค้ดการแทรก คุณอาจสังเกตเห็นว่าหากเรียกใช้โค้ดหลายครั้งเกินไปแบบขนาน ฟังก์ชันจะเริ่มล้มเหลวพร้อมข้อความแสดงข้อผิดพลาดที่เกี่ยวข้องกับการแย่งชิงการล็อกธุรกรรม เราจะไม่พูดถึงวิธีแก้ปัญหานี้ในโค้ดแล็บนี้ แต่หากไม่จำเป็นต้องจัดอันดับที่แน่นอน คุณก็สามารถทิ้งความซับซ้อนทั้งหมดของแนวทางก่อนหน้าเพื่อใช้แนวทางที่ง่ายและเร็วกว่าได้ มาดูกันว่าเราจะแสดงอันดับโดยประมาณสำหรับคะแนนของผู้เล่นแทนการจัดอันดับที่แน่นอนได้อย่างไร และการดำเนินการดังกล่าวจะเปลี่ยนแปลงตรรกะของฐานข้อมูลอย่างไร
สำหรับแนวทางนี้ เราจะแบ่งลีดเดอร์บอร์ดออกเป็น 100 กลุ่ม โดยแต่ละกลุ่มจะแสดงคะแนนประมาณ 1% ที่เราคาดว่าจะได้รับ วิธีนี้ใช้ได้แม้ว่าเราจะไม่ทราบการกระจายคะแนน ในกรณีนี้เราไม่มีทางรับประกันได้ว่าคะแนนจะกระจายอย่างเท่าเทียมกันโดยประมาณตลอดทั้งกลุ่ม แต่เราจะประมาณค่าได้อย่างแม่นยำมากขึ้นหากทราบว่าคะแนนจะกระจายอย่างไร
แนวทางของเรามีดังนี้ เช่นเดียวกับก่อนหน้านี้ แต่ละที่เก็บจะจัดเก็บจำนวนคะแนนภายในและช่วงของคะแนน เมื่อแทรกคะแนนใหม่ เราจะค้นหาที่เก็บคะแนนและเพิ่มจำนวนคะแนน เมื่อดึงข้อมูลอันดับ เราจะรวมกลุ่มที่อยู่ก่อนหน้า แล้วประมาณค่าภายในกลุ่มแทนที่จะค้นหาต่อไป ซึ่งช่วยให้เราค้นหาและแทรกข้อมูลได้ในเวลาคงที่ที่ยอดเยี่ยม และต้องใช้โค้ดน้อยลงมาก
ก่อนอื่น การแทรก
// 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();
}
}
}
คุณจะเห็นว่าโค้ดการแทรกนี้มีตรรกะบางอย่างสำหรับการเริ่มต้นสถานะฐานข้อมูลที่ด้านบนพร้อมคำเตือนไม่ให้ทำสิ่งต่างๆ เช่น นี้ในเวอร์ชันที่ใช้งานจริง โค้ดสำหรับการเริ่มต้นระบบไม่ได้รับการป้องกันจาก Race Condition เลย ดังนั้นหากคุณทำเช่นนี้ การเขียนพร้อมกันหลายรายการจะทำให้ฐานข้อมูลเสียหายโดยการสร้างที่เก็บข้อมูลที่ซ้ำกันจำนวนมาก
ดำเนินการติดตั้งใช้งานฟังก์ชัน แล้วเรียกใช้การแทรกเพื่อเริ่มต้นที่เก็บข้อมูลทั้งหมดด้วยจำนวน 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 ได้สำเร็จแล้ว คุณจะเลือกใช้บริการที่เหมาะกับความต้องการของเกมในด้านความแม่นยำและความเร็วได้ในราคาที่สมเหตุสมผล
ถัดไป ดูเส้นทางการเรียนรู้สำหรับเกม