สร้างลีดเดอร์บอร์ดด้วย Firestore

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 แต่คุณจะใช้โปรแกรมแก้ไขข้อความใดก็ได้

และแตกไฟล์ ZIP ที่ดาวน์โหลด

หรือโคลนลงในไดเรกทอรีที่คุณต้องการ

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 ใหม่

  1. ลงชื่อเข้าใช้คอนโซล Firebase โดยใช้บัญชี Google
  2. คลิกปุ่มเพื่อสร้างโปรเจ็กต์ใหม่ แล้วป้อนชื่อโปรเจ็กต์ (เช่น Leaderboards Codelab)
  3. คลิกต่อไป
  4. หากได้รับแจ้ง ให้อ่านและยอมรับข้อกำหนดของ Firebase แล้วคลิกต่อไป
  5. (ไม่บังคับ) เปิดใช้ความช่วยเหลือจาก AI ในคอนโซล Firebase (เรียกว่า "Gemini ใน Firebase")
  6. สำหรับ Codelab นี้ คุณไม่จำเป็นต้องใช้ Google Analytics ดังนั้นให้ปิดตัวเลือก Google Analytics
  7. คลิกสร้างโปรเจ็กต์ รอให้ระบบจัดสรรโปรเจ็กต์ แล้วคลิกดำเนินการต่อ

ตั้งค่าผลิตภัณฑ์ Firebase

  1. จากเมนูสร้าง ให้คลิกฟังก์ชัน แล้วอัปเกรดโปรเจ็กต์เพื่อใช้แพ็กเกจราคา Blaze หากได้รับแจ้ง
  2. คลิกฐานข้อมูล Firestore จากเมนูสร้าง
  3. ในกล่องโต้ตอบสร้างฐานข้อมูลที่ปรากฏขึ้น ให้เลือกเริ่มในโหมดทดสอบ แล้วคลิกถัดไป
  4. เลือกภูมิภาคจากเมนูแบบเลื่อนลงตำแหน่ง Cloud Firestore แล้วคลิกเปิดใช้

กำหนดค่าและเรียกใช้ลีดเดอร์บอร์ด

  1. ในเทอร์มินัล ให้ไปที่รูทของโปรเจ็กต์แล้วเรียกใช้ firebase use --add เลือกโปรเจ็กต์ Firebase ที่คุณเพิ่งสร้าง
  2. เรียกใช้ firebase emulators:start --only hosting ในรูทของโปรเจ็กต์
  3. ในเบราว์เซอร์ ให้ไปที่ localhost:5000
  4. เปิดคอนโซล JavaScript ของเครื่องมือสำหรับนักพัฒนาเว็บใน Chrome แล้วนำเข้า leaderboard.js ดังนี้
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. เรียกใช้ 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 จุดข้างคอลเล็กชันคะแนนเพื่อเตรียมพร้อมสำหรับส่วนถัดไป

Firestore ให้คะแนนหน้าเอกสารโดยเปิดใช้งาน\nDelete Collection

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 ได้สำเร็จแล้ว คุณจะเลือกใช้บริการที่เหมาะกับความต้องการของเกมในด้านความแม่นยำและความเร็วได้ในราคาที่สมเหตุสมผล

ถัดไป ดูเส้นทางการเรียนรู้สำหรับเกม