Tạo bảng xếp hạng với Firestore

1. Giới thiệu

Lần cập nhật gần đây nhất: ngày 27 tháng 1 năm 2023

Cần những gì để tạo bảng xếp hạng?

Về cơ bản, bảng xếp hạng chỉ là bảng điểm với một yếu tố phức tạp: để đọc thứ hạng cho một điểm số bất kỳ, bạn cần biết tất cả các điểm số khác theo một thứ tự nào đó. Ngoài ra, nếu trò chơi của bạn trở nên phổ biến, bảng xếp hạng sẽ có quy mô lớn và thường xuyên được đọc và ghi. Để tạo một bảng xếp hạng thành công, bảng xếp hạng đó cần có khả năng xử lý nhanh thao tác xếp hạng này.

Sản phẩm bạn sẽ tạo ra

Trong lớp học lập trình này, bạn sẽ triển khai nhiều bảng xếp hạng khác nhau, mỗi bảng phù hợp với một tình huống khác nhau.

Kiến thức bạn sẽ học được

Bạn sẽ tìm hiểu cách triển khai 4 bảng xếp hạng khác nhau:

  • Một cách triển khai đơn giản bằng cách sử dụng phương pháp đếm số bản ghi đơn giản để xác định thứ hạng
  • Bảng xếp hạng rẻ tiền và được cập nhật định kỳ
  • Bảng xếp hạng theo thời gian thực với một số nội dung vô nghĩa về cây
  • Bảng xếp hạng ngẫu nhiên (xác suất) để xếp hạng gần đúng cho số lượng người chơi rất lớn

Bạn cần có

  • Một phiên bản Chrome gần đây (107 trở lên)
  • Node.js 16 trở lên (chạy nvm --version để xem số phiên bản nếu bạn đang dùng nvm)
  • Gói giá linh hoạt có tính phí của Firebase (không bắt buộc)
  • Firebase CLI phiên bản 11.16.0 trở lên
    Để cài đặt CLI, bạn có thể chạy npm install -g firebase-tools hoặc tham khảo tài liệu về CLI để biết thêm các lựa chọn cài đặt.
  • Có kiến thức về JavaScript, Cloud Firestore, Cloud Functions và Công cụ dành cho nhà phát triển của Chrome

2. Thiết lập

Lấy mã

Chúng tôi đã đặt mọi thứ bạn cần cho dự án này vào một kho lưu trữ Git. Để bắt đầu, bạn cần lấy mã và mở mã đó trong môi trường phát triển mà bạn yêu thích. Trong lớp học lập trình này, chúng ta đã sử dụng VS Code, nhưng bạn có thể dùng bất kỳ trình chỉnh sửa văn bản nào.

và giải nén tệp zip đã tải xuống.

Hoặc sao chép vào thư mục bạn chọn:

git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git

Điểm xuất phát của chúng ta là gì?

Dự án của chúng ta hiện là một bảng trống với một số hàm trống:

  • index.html chứa một số tập lệnh kết dính cho phép chúng ta gọi các hàm từ bảng điều khiển dành cho nhà phát triển và xem đầu ra của các hàm đó. Chúng ta sẽ dùng công cụ này để tương tác với phần phụ trợ và xem kết quả của các lệnh gọi hàm. Trong trường hợp thực tế, bạn sẽ thực hiện trực tiếp các lệnh gọi phụ trợ này từ trò chơi của mình. Chúng ta không sử dụng trò chơi trong lớp học lập trình này vì sẽ mất quá nhiều thời gian để chơi trò chơi mỗi khi bạn muốn thêm điểm vào bảng xếp hạng.
  • functions/index.js chứa tất cả Cloud Functions của chúng tôi. Bạn sẽ thấy một số hàm tiện ích, chẳng hạn như addScoresdeleteScores, cũng như các hàm mà chúng ta sẽ triển khai trong lớp học lập trình này. Các hàm này sẽ gọi đến các hàm trợ giúp trong một tệp khác.
  • functions/functions-helpers.js chứa các hàm trống mà chúng ta sẽ triển khai. Đối với mỗi bảng xếp hạng, chúng ta sẽ triển khai các hàm đọc, tạo và cập nhật. Bạn sẽ thấy lựa chọn triển khai của chúng ta ảnh hưởng như thế nào đến cả độ phức tạp của việc triển khai và hiệu suất mở rộng quy mô của việc triển khai đó.
  • functions/utils.js chứa nhiều hàm tiện ích hơn. Chúng ta sẽ không chỉnh sửa tệp này trong lớp học lập trình này.

Tạo và thiết lập dự án Firebase

Tạo một dự án Firebase mới

  1. Đăng nhập vào bảng điều khiển của Firebase bằng Tài khoản Google của bạn.
  2. Nhấp vào nút này để tạo một dự án mới, rồi nhập tên dự án (ví dụ: Leaderboards Codelab).
  3. Nhấp vào Tiếp tục.
  4. Nếu được nhắc, hãy xem xét và chấp nhận các điều khoản của Firebase, rồi nhấp vào Tiếp tục.
  5. (Không bắt buộc) Bật tính năng hỗ trợ của AI trong bảng điều khiển của Firebase (còn gọi là "Gemini trong Firebase").
  6. Đối với lớp học lập trình này, bạn không cần Google Analytics, vì vậy hãy tắt lựa chọn Google Analytics.
  7. Nhấp vào Tạo dự án, đợi dự án được cấp phép rồi nhấp vào Tiếp tục.

Thiết lập các sản phẩm của Firebase

  1. Trong trình đơn Build (Xây dựng), hãy nhấp vào Functions (Hàm) và nếu được nhắc, hãy nâng cấp dự án của bạn để sử dụng gói giá Blaze.
  2. Trong trình đơn Build (Xây dựng), hãy nhấp vào Cơ sở dữ liệu Firestore.
  3. Trong hộp thoại Create database (Tạo cơ sở dữ liệu) xuất hiện, hãy chọn Start in test mode (Bắt đầu ở chế độ kiểm thử), rồi nhấp vào Next (Tiếp theo).
  4. Chọn một khu vực trong trình đơn thả xuống Vị trí Cloud Firestore, sau đó nhấp vào Bật.

Định cấu hình và chạy bảng xếp hạng

  1. Trong một thiết bị đầu cuối, hãy chuyển đến thư mục gốc của dự án và chạy firebase use --add. Chọn dự án Firebase mà bạn vừa tạo.
  2. Trong thư mục gốc của dự án, hãy chạy firebase emulators:start --only hosting.
  3. Trong trình duyệt, hãy truy cập vào localhost:5000.
  4. Mở bảng điều khiển JavaScript của Công cụ của Chrome cho nhà phát triển và nhập leaderboard.js:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Chạy leaderboard.codelab(); trong bảng điều khiển. Nếu thấy thư chào mừng, tức là bạn đã hoàn tất! Nếu không, hãy tắt trình mô phỏng và chạy lại các bước 2-4.

Hãy bắt đầu với cách triển khai bảng xếp hạng đầu tiên.

3. Triển khai bảng xếp hạng đơn giản

Khi kết thúc phần này, chúng ta sẽ có thể thêm điểm vào bảng xếp hạng và biết được thứ hạng của mình.

Trước khi bắt đầu, hãy cùng tìm hiểu cách triển khai bảng xếp hạng này: Tất cả người chơi đều được lưu trữ trong một bộ sưu tập duy nhất và việc tìm nạp thứ hạng của người chơi được thực hiện bằng cách truy xuất bộ sưu tập và đếm số lượng người chơi xếp trên họ. Nhờ đó, bạn có thể dễ dàng chèn và cập nhật điểm số. Để chèn điểm số mới, chúng ta chỉ cần thêm điểm số đó vào bộ sưu tập. Để cập nhật điểm số, chúng ta lọc người dùng hiện tại rồi cập nhật tài liệu kết quả. Hãy xem mã này trông như thế nào.

Trong functions/functions-helper.js, hãy triển khai hàm createScore. Đây là hàm đơn giản nhất có thể:

async function createScore(score, playerID, firestore) {
  return firestore.collection("scores").doc().create({
    user: playerID,
    score: score,
  });
}

Để cập nhật điểm số, chúng ta chỉ cần thêm một bước kiểm tra lỗi để đảm bảo điểm số được cập nhật đã tồn tại:

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,
  });
}

Và cuối cùng, hàm xếp hạng đơn giản nhưng ít có khả năng mở rộng của chúng ta:

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}`);
}

Hãy thử xem sao! Triển khai các hàm bằng cách chạy lệnh sau trong thiết bị đầu cuối:

firebase deploy --only functions

Sau đó, trong bảng điều khiển JS của Chrome, hãy thêm một số điểm khác để chúng ta có thể xem thứ hạng của mình so với những người chơi khác.

leaderboard.addScores(); // Results may take some time to appear.

Giờ đây, chúng ta có thể thêm điểm số của riêng mình vào hỗn hợp này:

leaderboard.addScore(999, 11); // You can make up a score (second argument) here.

Khi quá trình ghi hoàn tất, bạn sẽ thấy một phản hồi trong bảng điều khiển có nội dung "Đã tạo điểm số". Bạn có thấy lỗi không? Mở nhật ký Hàm thông qua bảng điều khiển của Firebase để xem lỗi xảy ra.

Và cuối cùng, chúng ta có thể tìm nạp và cập nhật điểm số của mình.

leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)

Tuy nhiên, cách triển khai này khiến chúng ta có các yêu cầu không mong muốn về thời gian và bộ nhớ tuyến tính để tìm nạp thứ hạng của một điểm số nhất định. Vì thời gian thực thi hàm và bộ nhớ đều bị giới hạn, nên không chỉ các lệnh tìm nạp của chúng ta sẽ ngày càng chậm hơn, mà sau khi đủ điểm số được thêm vào bảng xếp hạng, các hàm của chúng ta sẽ hết thời gian chờ hoặc gặp sự cố trước khi có thể trả về kết quả. Rõ ràng là chúng ta sẽ cần một thứ gì đó tốt hơn nếu muốn mở rộng quy mô vượt quá một vài người chơi.

Nếu là người hâm mộ Firestore, bạn có thể biết về các truy vấn tổng hợp COUNT. Các truy vấn này sẽ giúp bảng xếp hạng này hoạt động hiệu quả hơn nhiều. Và bạn đã trả lời đúng! Với các truy vấn COUNT, số lượng người dùng này sẽ tăng lên dưới một triệu hoặc hơn, mặc dù hiệu suất vẫn là tuyến tính.

Nhưng khoan đã, có thể bạn đang nghĩ rằng nếu chúng ta sẽ liệt kê tất cả các tài liệu trong bộ sưu tập, thì chúng ta có thể chỉ định cho mỗi tài liệu một thứ hạng và sau đó khi cần tìm nạp tài liệu, các lần tìm nạp của chúng ta sẽ có thời gian và bộ nhớ là O(1)! Điều này dẫn đến phương pháp tiếp theo của chúng ta, đó là bảng xếp hạng cập nhật định kỳ.

4. Triển khai bảng xếp hạng cập nhật định kỳ

Điểm mấu chốt của phương pháp này là lưu trữ thứ hạng trong chính tài liệu, vì vậy, việc tìm nạp thứ hạng sẽ giúp chúng ta có được thứ hạng mà không cần thêm công việc. Để đạt được điều này, chúng ta cần một loại hàm mới.

Trong index.js, hãy thêm nội dung sau:

// 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;
    });

Giờ đây, các thao tác đọc, cập nhật và ghi của chúng ta đều rất đơn giản và dễ dàng. Thao tác ghi và cập nhật đều không thay đổi, nhưng thao tác đọc sẽ trở thành (trong 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"),
  };
}

Rất tiếc, bạn sẽ không thể triển khai và kiểm thử tính năng này nếu không thêm tài khoản thanh toán vào dự án của mình. Nếu bạn có tài khoản thanh toán, hãy rút ngắn khoảng thời gian của hàm theo lịch và xem hàm của bạn tự động chỉ định thứ hạng cho điểm số trên bảng xếp hạng.

Nếu không, hãy xoá hàm đã lên lịch và chuyển sang bước triển khai tiếp theo.

Hãy xoá điểm số trong cơ sở dữ liệu Firestore bằng cách nhấp vào 3 dấu chấm bên cạnh tập hợp điểm số để chuẩn bị cho phần tiếp theo.

Trang tài liệu về điểm số của Firestore có tính năng Xoá bộ sưu tập được kích hoạt

5. Triển khai bảng xếp hạng cây theo thời gian thực

Phương pháp này hoạt động bằng cách lưu trữ dữ liệu tìm kiếm trong chính bộ sưu tập cơ sở dữ liệu. Thay vì có một bộ sưu tập đồng nhất, mục tiêu của chúng ta là lưu trữ mọi thứ trong một cây mà chúng ta có thể duyệt qua bằng cách di chuyển qua các tài liệu. Điều này cho phép chúng tôi thực hiện tìm kiếm nhị phân (hoặc n-phân) cho thứ hạng của một điểm số nhất định. Điều đó có thể diễn ra như thế nào?

Để bắt đầu, chúng ta cần phân phối điểm số vào các nhóm tương đối bằng nhau. Điều này đòi hỏi bạn phải có kiến thức về các giá trị điểm số mà người dùng đang ghi lại; ví dụ: nếu bạn đang tạo một bảng xếp hạng về chỉ số kỹ năng trong một trò chơi cạnh tranh, thì chỉ số kỹ năng của người dùng hầu như luôn được phân phối bình thường. Hàm tạo điểm số ngẫu nhiên của chúng tôi sử dụng Math.random() của JavaScript, dẫn đến một phân phối gần như đồng đều, vì vậy, chúng tôi sẽ chia các nhóm một cách đồng đều.

Trong ví dụ này, chúng ta sẽ sử dụng 3 nhóm để đơn giản hoá, nhưng có thể bạn sẽ thấy rằng nếu sử dụng cách triển khai này trong một ứng dụng thực tế, thì nhiều nhóm hơn sẽ mang lại kết quả nhanh hơn – cây càng nông thì trung bình càng ít lượt tìm nạp bộ sưu tập và ít tranh chấp khoá hơn.

Thứ hạng của người chơi được xác định bằng tổng số người chơi có điểm số cao hơn, cộng thêm một cho chính người chơi đó. Mỗi tập hợp trong scores sẽ lưu trữ 3 tài liệu, mỗi tài liệu có một dải, số lượng tài liệu trong mỗi dải và sau đó là 3 tập hợp con tương ứng. Để đọc một thứ hạng, chúng ta sẽ duyệt qua cây này để tìm điểm số và theo dõi tổng số điểm số lớn hơn. Khi tìm được điểm số, chúng ta cũng sẽ có tổng chính xác.

Việc viết phức tạp hơn đáng kể. Trước tiên, chúng ta cần thực hiện tất cả các thao tác ghi trong một giao dịch để ngăn chặn sự không nhất quán về dữ liệu khi nhiều thao tác ghi hoặc đọc xảy ra cùng lúc. Chúng ta cũng cần duy trì tất cả các điều kiện mà chúng ta đã mô tả ở trên khi duyệt qua cây để ghi các tài liệu mới. Và cuối cùng, vì chúng ta có tất cả độ phức tạp của cây trong phương pháp mới này, kết hợp với nhu cầu lưu trữ tất cả các tài liệu gốc, nên chi phí lưu trữ của chúng ta sẽ tăng nhẹ (nhưng vẫn theo đường thẳng).

Trong 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,
      });
    });
  });
}

Chắc chắn là điều này phức tạp hơn so với lần triển khai gần đây nhất của chúng ta, đó là một lệnh gọi phương thức duy nhất và chỉ có 6 dòng mã. Sau khi triển khai phương thức này, hãy thử thêm một vài điểm số vào cơ sở dữ liệu và quan sát cấu trúc của cây kết quả. Trong bảng điều khiển JS:

leaderboard.addScores();

Cấu trúc cơ sở dữ liệu kết quả sẽ có dạng như sau, với cấu trúc cây rõ ràng và các nhánh của cây đại diện cho từng điểm số.

scores
  - document
    range: 0-333.33
    count: 2
    scores:
      - document
        exact:
          score: 18
          user: 1
      - document
        exact:
          score: 22
          user: 2

Giờ đây, khi đã vượt qua phần khó khăn, chúng ta có thể đọc điểm bằng cách duyệt qua cây như mô tả trước đó.

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,
  };
}

Các bản cập nhật được coi là một bài tập bổ sung. Hãy thử thêm và tìm nạp điểm trong bảng điều khiển JS bằng các phương thức leaderboard.addScore(id, score)leaderboard.getRank(id), đồng thời xem bảng xếp hạng của bạn thay đổi như thế nào trong bảng điều khiển của Firebase.

Tuy nhiên, với cách triển khai này, độ phức tạp mà chúng ta đã thêm vào để đạt được hiệu suất theo hàm logarit sẽ phải trả giá.

  • Trước tiên, việc triển khai bảng xếp hạng này có thể gặp phải vấn đề về tranh chấp khoá, vì các giao dịch yêu cầu khoá hoạt động đọc và ghi vào tài liệu để đảm bảo tính nhất quán.
  • Thứ hai, Firestore áp dụng giới hạn độ sâu của bộ sưu tập con là 100, tức là bạn cần tránh tạo cây con sau 100 điểm số được liên kết. Tuy nhiên, việc triển khai này không thực hiện điều đó.
  • Và cuối cùng, bảng xếp hạng này chỉ có thể mở rộng theo hàm logarit trong trường hợp lý tưởng khi cây được cân bằng. Nếu cây không cân bằng, hiệu suất trong trường hợp xấu nhất của bảng xếp hạng này sẽ lại là tuyến tính.

Sau khi hoàn tất, hãy xoá các tập hợp scoresplayers thông qua bảng điều khiển Firebase. Sau đó, chúng ta sẽ chuyển sang bước triển khai bảng xếp hạng cuối cùng.

6. Triển khai bảng xếp hạng ngẫu nhiên (xác suất)

Khi chạy mã chèn, bạn có thể nhận thấy rằng nếu chạy quá nhiều lần song song, các hàm của bạn sẽ bắt đầu gặp lỗi kèm theo thông báo lỗi liên quan đến tranh chấp khoá giao dịch. Có những cách giải quyết vấn đề này mà chúng ta sẽ không khám phá trong lớp học lập trình này. Tuy nhiên, nếu không cần xếp hạng chính xác, bạn có thể bỏ qua tất cả sự phức tạp của phương pháp trước đó để có được một phương pháp đơn giản và nhanh hơn. Hãy xem cách chúng ta có thể trả về thứ hạng ước tính cho điểm số của người chơi thay vì thứ hạng chính xác và cách điều đó thay đổi logic cơ sở dữ liệu của chúng ta.

Đối với phương pháp này, chúng ta sẽ chia bảng xếp hạng thành 100 nhóm, mỗi nhóm đại diện cho khoảng 1% số điểm mà chúng ta dự kiến sẽ nhận được. Phương pháp này vẫn hiệu quả ngay cả khi không biết phân phối điểm số. Trong trường hợp đó, chúng ta không thể đảm bảo phân phối điểm số tương đối đồng đều trong nhóm. Tuy nhiên, chúng ta sẽ đạt được độ chính xác cao hơn trong các phép tính xấp xỉ nếu biết cách phân phối điểm số.

Phương pháp của chúng tôi như sau: giống như trước đây, mỗi nhóm lưu trữ số lượng điểm số trong phạm vi điểm số. Khi chèn một điểm số mới, chúng ta sẽ tìm vùng chứa cho điểm số đó và tăng số lượng của vùng chứa. Khi tìm nạp một thứ hạng, chúng ta chỉ cần cộng các nhóm phía trước thứ hạng đó rồi ước chừng trong nhóm của mình thay vì tìm kiếm thêm. Điều này giúp chúng ta có các hoạt động tra cứu và chèn thời gian hằng số rất hiệu quả, đồng thời yêu cầu ít mã hơn nhiều.

Trước tiên, hãy chèn:

// 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();
    }
  }
}

Bạn sẽ nhận thấy mã chèn này có một số logic để khởi tạo trạng thái cơ sở dữ liệu ở trên cùng kèm theo cảnh báo không được làm điều gì đó tương tự như thế này trong quá trình sản xuất. Mã để khởi tạo hoàn toàn không được bảo vệ trước các tình huống tương tranh. Vì vậy, nếu bạn làm việc này, nhiều thao tác ghi đồng thời sẽ làm hỏng cơ sở dữ liệu của bạn bằng cách cung cấp cho bạn một loạt các vùng chứa trùng lặp.

Tiến hành triển khai các hàm của bạn rồi chạy một thao tác chèn để khởi chạy tất cả các vùng chứa với số lượng bằng 0. Thao tác này sẽ trả về một lỗi mà bạn có thể yên tâm bỏ qua.

leaderboard.addScore(999, 0); // The params aren't important here.

Bây giờ cơ sở dữ liệu đã được khởi tạo đúng cách, chúng ta có thể chạy addScores và xem cấu trúc dữ liệu của mình trong bảng điều khiển Firebase. Cấu trúc kết quả phẳng hơn nhiều so với lần triển khai trước của chúng tôi, mặc dù chúng có vẻ tương tự nhau.

leaderboard.addScores();

Và bây giờ, hãy đọc điểm số:

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,
  };
}

Vì chúng ta đã tạo hàm addScores để tạo một phân phối điểm số đồng nhất và chúng ta đang sử dụng phép nội suy tuyến tính trong các nhóm, nên chúng ta sẽ nhận được kết quả rất chính xác, hiệu suất của bảng xếp hạng sẽ không giảm khi chúng ta tăng số lượng người dùng và chúng ta không phải lo lắng về tình trạng tranh chấp khoá (nhiều) khi cập nhật số lượng.

7. Phụ lục: Hành vi gian lận

Khoan đã, có thể bạn đang nghĩ rằng nếu tôi ghi các giá trị vào codelab thông qua bảng điều khiển JS của một thẻ trình duyệt, thì chẳng phải người chơi nào cũng có thể nói dối bảng xếp hạng và nói rằng họ đạt được điểm số cao mà không phải do họ đạt được một cách công bằng sao?

Có. Nếu muốn ngăn chặn hành vi gian lận, cách hiệu quả nhất là vô hiệu hoá quyền ghi của ứng dụng khách vào cơ sở dữ liệu thông qua các quy tắc bảo mật, bảo mật quyền truy cập vào Cloud Functions để ứng dụng khách không thể gọi trực tiếp các quy tắc này, sau đó xác thực các hành động trong trò chơi trên máy chủ trước khi gửi thông tin cập nhật điểm số lên bảng xếp hạng.

Điều quan trọng cần lưu ý là chiến lược này không phải là giải pháp toàn diện chống lại hành vi gian lận. Nếu có đủ động lực, kẻ gian lận có thể tìm cách lách qua quy trình xác thực phía máy chủ. Nhiều trò chơi điện tử lớn và thành công liên tục phải đối phó với những kẻ gian lận để xác định các thủ đoạn gian lận mới và ngăn chặn chúng lan rộng. Một hậu quả khó khăn của hiện tượng này là việc xác thực phía máy chủ cho mọi trò chơi vốn dĩ đều được tuỳ chỉnh; mặc dù Firebase cung cấp các công cụ chống hành vi sai trái như App Check để ngăn người dùng sao chép trò chơi của bạn thông qua một ứng dụng khách được viết kịch bản đơn giản, nhưng Firebase không cung cấp bất kỳ dịch vụ nào tương đương với một giải pháp chống gian lận toàn diện.

Nếu không có quy trình xác thực phía máy chủ, thì đối với một trò chơi đủ phổ biến hoặc có rào cản gian lận đủ thấp, bảng xếp hạng sẽ chỉ có những kẻ gian lận ở các vị trí hàng đầu.

8. Xin chúc mừng

Xin chúc mừng, bạn đã tạo thành công 4 bảng xếp hạng khác nhau trên Firebase! Tuỳ thuộc vào nhu cầu về độ chính xác và tốc độ của trò chơi, bạn có thể chọn một dịch vụ phù hợp với mình với chi phí hợp lý.

Tiếp theo, hãy xem lộ trình học tập dành cho trò chơi.