สร้างกระดานผู้นำด้วย Firestore

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

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

หรือโคลนลงในไดเร็กทอรีที่คุณเลือก:

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

  1. ใน คอนโซล Firebase คลิก เพิ่มโครงการ
  2. หากต้องการสร้างโปรเจ็กต์ใหม่ ให้ป้อนชื่อโปรเจ็กต์ที่ต้องการ
    นอกจากนี้ยังจะตั้งค่ารหัสโปรเจ็กต์ (แสดงใต้ชื่อโปรเจ็กต์) ให้เป็นค่าบางอย่างตามชื่อโปรเจ็กต์ คุณสามารถเลือกคลิกไอคอน แก้ไข บนรหัสโปรเจ็กต์เพื่อปรับแต่งเพิ่มเติมได้
  3. หากได้รับแจ้ง ให้อ่านและยอมรับ ข้อกำหนดของ Firebase
  4. คลิก ดำเนินการต่อ
  5. เลือกตัวเลือก เปิดใช้งาน Google Analytics สำหรับโปรเจ็กต์นี้ จากนั้นคลิก ดำเนินการต่อ
  6. เลือกบัญชี Google Analytics ที่มีอยู่เพื่อใช้หรือเลือก สร้างบัญชีใหม่ เพื่อสร้างบัญชีใหม่
  7. คลิก สร้างโครงการ
  8. เมื่อสร้างโครงการแล้ว คลิก ดำเนินการต่อ
  9. จากเมนู สร้าง คลิก ฟังก์ชัน และหากได้รับแจ้ง ให้อัปเกรดโปรเจ็กต์ของคุณเพื่อใช้แผนการเรียกเก็บเงิน Blaze
  10. จากเมนู Build คลิก ฐานข้อมูล Firestore
  11. ในกล่องโต้ตอบ สร้างฐานข้อมูล ที่ปรากฏขึ้น ให้เลือก เริ่มในโหมดทดสอบ จากนั้นคลิก ถัดไป
  12. เลือกภูมิภาคจากดรอปดาวน์ ตำแหน่ง Cloud Firestore จากนั้นคลิก เปิดใช้งาน

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

  1. ในเทอร์มินัล ให้นำทางไปยังรูทโปรเจ็กต์และรัน firebase use --add เลือกโปรเจ็กต์ Firebase ที่คุณเพิ่งสร้างขึ้น
  2. ในรูทของโปรเจ็กต์ ให้รัน firebase emulators:start --only hosting
  3. ในเบราว์เซอร์ของคุณ ให้ไปที่ localhost:5000
  4. เปิดคอนโซล JavaScript ของ Chrome DevTools และนำเข้า 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 รายการ สิ่งนี้จะปรับขนาดผู้ใช้ได้ต่ำกว่าหนึ่งล้านคนอย่างมาก แม้ว่าประสิทธิภาพจะยังคงเป็นเส้นตรงก็ตาม

แต่เดี๋ยวก่อน คุณอาจจะคิดกับตัวเองว่า ถ้าเราจะแจกแจงเอกสารทั้งหมดในคอลเลกชัน เราสามารถกำหนดอันดับให้กับเอกสารทุกฉบับได้ และเมื่อเราต้องการดึงข้อมูลนั้น การดึงข้อมูลของเราจะเป็น 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 scores document page with\nDelete Collection activated

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

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