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

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

  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 รายการ ตัวเลขนี้ก็จะอยู่ในระดับต่ำกว่า 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 และเปิดใช้งาน\nลบคอลเล็กชัน

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

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