Erstellen Sie Bestenlisten mit Firestore

1. Einleitung

Letzte Aktualisierung: 27.01.2023

Was ist nötig, um eine Bestenliste zu erstellen?

Im Kern handelt es sich bei Bestenlisten lediglich um Ergebnistabellen mit einem erschwerenden Faktor: Um eine Rangfolge für einen bestimmten Punktestand zu ermitteln, muss man alle anderen Punktestände in einer bestimmten Reihenfolge kennen. Wenn Ihr Spiel erfolgreich ist, werden Ihre Bestenlisten außerdem größer und häufig gelesen und beschrieben. Um eine erfolgreiche Bestenliste zu erstellen, muss dieser Ranking-Vorgang schnell durchgeführt werden können.

Was Sie bauen werden

In diesem Codelab implementieren Sie verschiedene Bestenlisten, die jeweils für ein anderes Szenario geeignet sind.

Was Sie lernen werden

Sie erfahren, wie Sie vier verschiedene Bestenlisten implementieren:

  • Eine naive Implementierung, die einfache Datensatzzählung zur Bestimmung des Rangs verwendet
  • Eine günstige, regelmäßig aktualisierte Bestenliste
  • Eine Echtzeit-Bestenliste mit etwas Baum-Unsinn
  • Eine stochastische (probabilistische) Bestenliste für die ungefähre Rangfolge sehr großer Spielerbasen

Was du brauchen wirst

  • Eine aktuelle Version von Chrome (107 oder höher)
  • Node.js 16 oder höher (führen Sie nvm --version aus, um Ihre Versionsnummer anzuzeigen, wenn Sie nvm verwenden)
  • Ein kostenpflichtiger Firebase Blaze-Plan (optional)
  • Die Firebase CLI v11.16.0 oder höher
    Um die CLI zu installieren, können Sie npm install -g firebase-tools ausführen oder in der CLI-Dokumentation nach weiteren Installationsoptionen suchen.
  • Kenntnisse in JavaScript, Cloud Firestore, Cloud Functions und Chrome DevTools

2. Erste Schritte

Holen Sie sich den Code

Wir haben alles, was Sie für dieses Projekt benötigen, in einem Git-Repo zusammengefasst. Um zu beginnen, müssen Sie sich den Code holen und ihn in Ihrer bevorzugten Entwicklungsumgebung öffnen. Für dieses Codelab haben wir VS-Code verwendet, aber jeder Texteditor reicht aus.

und entpacken Sie die heruntergeladene ZIP-Datei.

Oder klonen Sie in das Verzeichnis Ihrer Wahl:

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

Was ist unser Ausgangspunkt?

Unser Projekt ist derzeit ein unbeschriebenes Blatt mit einigen leeren Funktionen:

  • index.html enthält einige Glue-Skripte, mit denen wir Funktionen von der Entwicklungskonsole aus aufrufen und deren Ausgaben sehen können. Wir verwenden dies, um eine Schnittstelle zu unserem Backend herzustellen und die Ergebnisse unserer Funktionsaufrufe anzuzeigen. In einem realen Szenario würden Sie diese Back-End-Aufrufe direkt von Ihrem Spiel aus tätigen – wir verwenden in diesem Codelab kein Spiel, da es zu lange dauern würde, jedes Mal ein Spiel zu spielen, wenn Sie der Bestenliste einen Punktestand hinzufügen möchten .
  • functions/index.js enthält alle unsere Cloud-Funktionen. Sie sehen einige Hilfsfunktionen wie addScores und deleteScores sowie die Funktionen, die wir in diesem Codelab implementieren und die Hilfsfunktionen in einer anderen Datei aufrufen.
  • functions/functions-helpers.js enthält die leeren Funktionen, die wir implementieren werden. Für jede Bestenliste implementieren wir Lese-, Erstellungs- und Aktualisierungsfunktionen, und Sie werden sehen, wie sich unsere Wahl der Implementierung sowohl auf die Komplexität unserer Implementierung als auch auf deren Skalierungsleistung auswirkt.
  • functions/utils.js enthält weitere Hilfsfunktionen. Wir werden diese Datei in diesem Codelab nicht berühren.

Erstellen und konfigurieren Sie ein Firebase-Projekt

  1. Klicken Sie in der Firebase-Konsole auf Projekt hinzufügen .
  2. Um ein neues Projekt zu erstellen, geben Sie den gewünschten Projektnamen ein.
    Dadurch wird auch die Projekt-ID (die unter dem Projektnamen angezeigt wird) auf etwas basierend auf dem Projektnamen festgelegt. Sie können optional auf das Bearbeitungssymbol der Projekt-ID klicken, um sie weiter anzupassen.
  3. Wenn Sie dazu aufgefordert werden, lesen Sie die Firebase-Bedingungen durch und akzeptieren Sie sie.
  4. Klicken Sie auf Weiter .
  5. Wählen Sie die Option „Google Analytics für dieses Projekt aktivieren“ und klicken Sie dann auf „Weiter“ .
  6. Wählen Sie ein vorhandenes Google Analytics-Konto aus, das Sie verwenden möchten, oder wählen Sie „Neues Konto erstellen“ , um ein neues Konto zu erstellen.
  7. Klicken Sie auf Projekt erstellen .
  8. Wenn das Projekt erstellt wurde, klicken Sie auf Weiter .
  9. Klicken Sie im Menü „Erstellen“ auf „Funktionen“ und aktualisieren Sie Ihr Projekt, wenn Sie dazu aufgefordert werden, um den Blaze-Abrechnungsplan zu verwenden.
  10. Klicken Sie im Menü „Erstellen“ auf „Firestore-Datenbank“ .
  11. Wählen Sie im angezeigten Dialogfeld „Datenbank erstellen“ die Option „Im Testmodus starten“ aus und klicken Sie dann auf „Weiter“ .
  12. Wählen Sie im Dropdown- Menü „Cloud Firestore-Standort“ eine Region aus und klicken Sie dann auf „Aktivieren“ .

Konfigurieren Sie Ihre Bestenliste und führen Sie sie aus

  1. Navigieren Sie in einem Terminal zum Projektstammverzeichnis und führen Sie firebase use --add aus. Wählen Sie das Firebase-Projekt aus, das Sie gerade erstellt haben.
  2. Führen Sie im Stammverzeichnis des Projekts firebase emulators:start --only hosting aus.
  3. Navigieren Sie in Ihrem Browser zu localhost:5000 .
  4. Öffnen Sie die JavaScript-Konsole von Chrome DevTools und importieren Sie leaderboard.js :
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Führen Sie leaderboard.codelab(); in der Konsole. Wenn Sie eine Willkommensnachricht sehen, sind Sie startklar! Wenn nicht, fahren Sie den Emulator herunter und führen Sie die Schritte 2–4 erneut aus.

Kommen wir zur ersten Bestenlisten-Implementierung.

3. Implementieren Sie eine einfache Bestenliste

Am Ende dieses Abschnitts können wir der Bestenliste eine Punktzahl hinzufügen und uns unseren Rang anzeigen lassen.

Bevor wir loslegen, erklären wir, wie diese Bestenlisten-Implementierung funktioniert: Alle Spieler werden in einer einzigen Sammlung gespeichert, und der Rang eines Spielers wird ermittelt, indem die Sammlung abgerufen und gezählt wird, wie viele Spieler vor ihm liegen. Dies erleichtert das Einfügen und Aktualisieren einer Partitur. Um eine neue Partitur einzufügen, hängen wir sie einfach an die Sammlung an. Um sie zu aktualisieren, filtern wir nach unserem aktuellen Benutzer und aktualisieren dann das resultierende Dokument. Mal sehen, wie das im Code aussieht.

Implementieren Sie in functions/functions-helper.js die Funktion createScore , die so einfach wie möglich ist:

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

Zum Aktualisieren von Partituren müssen wir lediglich eine Fehlerprüfung hinzufügen, um sicherzustellen, dass die zu aktualisierende Partitur bereits vorhanden ist:

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

Und schließlich unsere einfache, aber weniger skalierbare Rangfunktion:

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

Stellen wir es auf die Probe! Stellen Sie Ihre Funktionen bereit, indem Sie Folgendes im Terminal ausführen:

firebase deploy --only functions

Fügen Sie dann in der JS-Konsole von Chrome einige weitere Punkte hinzu, damit wir unsere Rangliste unter anderen Spielern sehen können.

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

Jetzt können wir unsere eigene Partitur zum Mix hinzufügen:

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

Wenn der Schreibvorgang abgeschlossen ist, sollte in der Konsole die Antwort „Score erstellt“ angezeigt werden. Sehen Sie stattdessen einen Fehler? Öffnen Sie die Funktionsprotokolle über die Firebase-Konsole, um zu sehen, was schief gelaufen ist.

Und schließlich können wir unsere Punktzahl abrufen und aktualisieren.

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

Diese Implementierung führt jedoch zu unerwünschten linearen Zeit- und Speicheranforderungen für das Abrufen des Rangs einer bestimmten Punktzahl. Da sowohl die Ausführungszeit der Funktion als auch der Speicher begrenzt sind, bedeutet dies nicht nur, dass unsere Abrufe immer langsamer werden, sondern es kommt auch zu einer Zeitüberschreitung oder einem Absturz unserer Funktionen, nachdem der Bestenliste genügend Punkte hinzugefügt wurden, bevor sie ein Ergebnis zurückgeben können. Natürlich brauchen wir etwas Besseres, wenn wir über eine Handvoll Spieler hinauswachsen wollen.

Wenn Sie ein Firestore-Fan sind, kennen Sie möglicherweise COUNT-Aggregationsabfragen , die diese Bestenliste wesentlich leistungsfähiger machen würden. Und du hättest Recht! Bei COUNT-Abfragen lässt sich dies gut auf unter etwa eine Million Benutzer skalieren, obwohl die Leistung immer noch linear ist.

Aber Moment, denken Sie vielleicht: Wenn wir sowieso alle Dokumente in der Sammlung auflisten wollen, können wir jedem Dokument einen Rang zuweisen, und wenn wir es dann abrufen müssen, werden unsere Abrufe O(1) sein. Zeit und Erinnerung! Dies führt uns zu unserem nächsten Ansatz, der regelmäßig aktualisierten Bestenliste.

4. Implementieren Sie eine regelmäßig aktualisierte Bestenliste

Der Schlüssel zu diesem Ansatz besteht darin, den Rang im Dokument selbst zu speichern, sodass wir durch das Abrufen den Rang ohne zusätzlichen Aufwand erhalten. Um dies zu erreichen, benötigen wir eine neue Art von Funktion.

Fügen Sie in index.js Folgendes hinzu:

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

Jetzt sind unsere Lese-, Aktualisierungs- und Schreibvorgänge alle schön und einfach. Sowohl „Write“ als auch „Update“ bleiben unverändert, aber „Read“ wird (in functions-helpers.js “) zu Folgendem:

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

Leider können Sie dies nicht bereitstellen und testen, ohne Ihrem Projekt ein Rechnungskonto hinzuzufügen. Wenn Sie über ein Rechnungskonto verfügen, verkürzen Sie das Intervall für die geplante Veranstaltung und beobachten Sie, wie Ihre Veranstaltung auf magische Weise Ihren Bestenlistenergebnissen Ränge zuordnet.

Wenn nicht, löschen Sie die geplante Funktion und fahren Sie mit der nächsten Implementierung fort.

Löschen Sie die Ergebnisse in Ihrer Firestore-Datenbank, indem Sie auf die drei Punkte neben der Bewertungssammlung klicken, um sich auf den nächsten Abschnitt vorzubereiten.

Firestore scores document page with\nDelete Collection activated

5. Implementieren Sie eine Baum-Bestenliste in Echtzeit

Bei diesem Ansatz werden Suchdaten in der Datenbanksammlung selbst gespeichert. Anstatt eine einheitliche Sammlung zu haben, ist es unser Ziel, alles in einem Baum zu speichern, den wir durch das Durchsuchen von Dokumenten durchqueren können. Dadurch können wir eine binäre (oder n-äre) Suche nach dem Rang einer bestimmten Punktzahl durchführen. Wie könnte das aussehen?

Zunächst möchten wir in der Lage sein, unsere Ergebnisse in ungefähr gleichmäßige Bereiche zu verteilen, was einige Kenntnisse über die Werte der Ergebnisse erfordert, die unsere Benutzer protokollieren. Wenn Sie beispielsweise eine Bestenliste für die Fähigkeitsbewertung in einem Wettbewerbsspiel erstellen, werden die Fähigkeitsbewertungen Ihrer Benutzer am Ende fast immer normalverteilt sein. Unsere Funktion zur Generierung von Zufallswerten verwendet Math.random() von JavaScript, was zu einer annähernd gleichmäßigen Verteilung führt, sodass wir unsere Buckets gleichmäßig aufteilen.

In diesem Beispiel verwenden wir der Einfachheit halber drei Buckets. Wenn Sie diese Implementierung jedoch in einer echten App verwenden, werden Sie wahrscheinlich feststellen, dass mehr Buckets schnellere Ergebnisse liefern – ein flacherer Baum bedeutet im Durchschnitt weniger Sammlungsabrufe und weniger Sperrkonflikte.

Der Rang eines Spielers ergibt sich aus der Summe der Anzahl der Spieler mit höheren Punktzahlen plus eins für den Spieler selbst. Jede Sammlung unter scores speichert drei Dokumente mit jeweils einem Bereich, der Anzahl der Dokumente unter jedem Bereich und dann drei entsprechende Untersammlungen. Um einen Rang abzulesen, durchsuchen wir diesen Baum auf der Suche nach einer Punktzahl und verfolgen die Summe der höheren Punktzahlen. Wenn wir unsere Punktzahl finden, haben wir auch die richtige Summe.

Das Schreiben ist deutlich komplizierter. Zunächst müssen wir alle unsere Schreibvorgänge innerhalb einer Transaktion durchführen, um Dateninkonsistenzen zu verhindern, wenn mehrere Schreib- oder Lesevorgänge gleichzeitig erfolgen. Außerdem müssen wir alle oben beschriebenen Bedingungen einhalten, wenn wir den Baum durchlaufen, um unsere neuen Dokumente zu schreiben. Und schließlich steigen unsere Speicherkosten leicht an (aber sie sind immer noch linear), da wir über die gesamte Baumkomplexität dieses neuen Ansatzes verfügen und gleichzeitig alle unsere Originaldokumente speichern müssen.

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

Dies ist sicherlich komplizierter als unsere letzte Implementierung, die aus einem einzelnen Methodenaufruf und nur sechs Codezeilen bestand. Nachdem Sie diese Methode implementiert haben, versuchen Sie, der Datenbank einige Punkte hinzuzufügen und die Struktur des resultierenden Baums zu beobachten. In Ihrer JS-Konsole:

leaderboard.addScores();

Die resultierende Datenbankstruktur sollte in etwa so aussehen, wobei die Baumstruktur deutlich sichtbar ist und die Blätter des Baums einzelne Bewertungen darstellen.

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

Nachdem wir nun den schwierigen Teil hinter uns haben, können wir die Ergebnisse lesen, indem wir den Baum wie zuvor beschrieben durchlaufen.

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

Aktualisierungen bleiben als zusätzliche Übung übrig. Versuchen Sie, mit den Methoden leaderboard.addScore(id, score) und leaderboard.getRank(id) Punkte in Ihrer JS-Konsole hinzuzufügen und abzurufen, und sehen Sie, wie sich Ihre Bestenliste in der Firebase-Konsole ändert.

Bei dieser Implementierung hat die Komplexität, die wir zur Erzielung einer logarithmischen Leistung hinzugefügt haben, jedoch ihren Preis.

  • Erstens kann es bei dieser Leaderboard-Implementierung zu Problemen mit Sperrenkonflikten kommen, da Transaktionen das Sperren von Lese- und Schreibvorgängen in Dokumenten erfordern, um sicherzustellen, dass sie konsistent bleiben.
  • Zweitens legt Firestore eine Untersammlungstiefenbeschränkung von 100 fest, was bedeutet, dass Sie die Erstellung von Unterbäumen nach 100 Gleichstandswerten vermeiden müssen, was bei dieser Implementierung nicht der Fall ist.
  • Und schließlich skaliert diese Bestenliste nur im Idealfall, wenn der Baum ausgeglichen ist, logarithmisch. Wenn er unausgeglichen ist, ist die Leistung dieser Bestenliste im schlechtesten Fall wiederum linear.

Wenn Sie fertig sind, löschen Sie die scores und players über die Firebase-Konsole und wir fahren mit unserer letzten Bestenlisten-Implementierung fort.

6. Implementieren Sie eine stochastische (probabilistische) Bestenliste

Wenn Sie den Einfügecode ausführen, stellen Sie möglicherweise fest, dass Ihre Funktionen fehlschlagen und eine Fehlermeldung im Zusammenhang mit einem Transaktionssperrenkonflikt angezeigt wird, wenn Sie ihn zu oft parallel ausführen. Es gibt Möglichkeiten, dies zu umgehen, die wir in diesem Codelab nicht untersuchen werden. Wenn Sie jedoch keine genaue Rangfolge benötigen, können Sie die gesamte Komplexität des vorherigen Ansatzes aufgeben und stattdessen etwas Einfacheres und Schnelleres verwenden. Werfen wir einen Blick darauf, wie wir anstelle einer genauen Rangliste einen geschätzten Rang für die Punkte unserer Spieler zurückgeben könnten und wie sich dadurch unsere Datenbanklogik ändert.

Für diesen Ansatz unterteilen wir unsere Bestenliste in 100 Bereiche, von denen jeder ungefähr ein Prozent der erwarteten Ergebnisse darstellt. Dieser Ansatz funktioniert auch ohne Kenntnis unserer Punkteverteilung. In diesem Fall haben wir keine Möglichkeit, eine annähernd gleichmäßige Verteilung der Punkte im gesamten Bucket zu garantieren. Wir erreichen jedoch eine höhere Präzision bei unseren Näherungen, wenn wir wissen, wie unsere Punkte verteilt werden .

Unser Ansatz ist wie folgt: Wie zuvor speichert jeder Bucket die Anzahl der darin enthaltenen Scores und den Bereich der Scores. Beim Einfügen einer neuen Partitur suchen wir den Bucket für die Partitur und erhöhen dessen Anzahl. Wenn wir einen Rang abrufen, summieren wir einfach die davor liegenden Buckets und nähern uns dann innerhalb unseres Buckets an, anstatt weiter zu suchen. Dies ermöglicht uns sehr schöne Such- und Einfügungsvorgänge in konstanter Zeit und erfordert viel weniger Code.

Erstes Einfügen:

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

Sie werden feststellen, dass dieser Einfügungscode oben eine Logik zum Initialisieren Ihres Datenbankstatus mit einer Warnung enthält, so etwas in der Produktion nicht zu tun. Der Code für die Initialisierung ist überhaupt nicht gegen Race Conditions geschützt. Wenn Sie dies also tun würden, würden mehrere gleichzeitige Schreibvorgänge Ihre Datenbank beschädigen, indem Sie eine Reihe doppelter Buckets erhalten.

Fahren Sie fort, stellen Sie Ihre Funktionen bereit und führen Sie dann eine Einfügung durch, um alle Buckets mit der Anzahl Null zu initialisieren. Es wird ein Fehler zurückgegeben, den Sie getrost ignorieren können.

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

Nachdem die Datenbank nun korrekt initialisiert ist, können wir addScores ausführen und die Struktur unserer Daten in der Firebase-Konsole sehen. Die resultierende Struktur ist viel flacher als unsere letzte Implementierung, obwohl sie oberflächlich betrachtet ähnlich sind.

leaderboard.addScores();

Und nun zum Notenlesen:

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

Da wir dafür gesorgt haben, dass die addScores Funktion eine gleichmäßige Verteilung der Punktzahlen generiert und wir eine lineare Interpolation innerhalb der Buckets verwenden, erhalten wir sehr genaue Ergebnisse. Die Leistung unserer Bestenliste wird sich nicht verschlechtern, wenn wir die Anzahl der Benutzer erhöhen. und wir müssen uns bei der Aktualisierung von Zählungen nicht (so viele) Gedanken über Sperrenkonflikte machen.

7. Nachtrag: Betrug

Moment, Sie denken vielleicht: Wenn ich Werte über die JS-Konsole eines Browser-Tabs in mein Codelab schreibe, kann dann keiner meiner Spieler einfach die Bestenliste anlügen und sagen, dass er einen Highscore erzielt hat, den er nicht erreicht hat? fair erreichen?

Ja, sie können. Wenn Sie Betrug verhindern möchten, besteht die wirksamste Möglichkeit darin, Client-Schreibvorgänge in Ihre Datenbank über Sicherheitsregeln zu deaktivieren, den Zugriff auf Ihre Cloud-Funktionen zu sichern , damit Clients sie nicht direkt aufrufen können, und dann die Aktionen im Spiel zuvor auf Ihrem Server zu validieren Senden von Ergebnisaktualisierungen an die Bestenliste.

Es ist wichtig zu beachten, dass diese Strategie kein Allheilmittel gegen Betrug ist – mit einem ausreichend großen Anreiz können Betrüger Möglichkeiten finden, serverseitige Validierungen zu umgehen, und viele große, erfolgreiche Videospiele spielen ständig Katz und Maus mit ihren Betrügern, um sie zu identifizieren neue Cheats und verhindern Sie deren Verbreitung. Eine schwierige Folge dieses Phänomens ist, dass die serverseitige Validierung für jedes Spiel von Natur aus maßgeschneidert ist; Obwohl Firebase Anti-Missbrauchstools wie App Check bereitstellt, die einen Benutzer daran hindern, Ihr Spiel über einen einfachen Skript-Client zu kopieren, bietet Firebase keinen Service, der einem ganzheitlichen Anti-Cheat gleichkommt.

Alles andere als eine serverseitige Validierung führt bei einem ausreichend beliebten Spiel oder einer ausreichend niedrigen Hürde für Betrug zu einer Bestenliste, in der die höchsten Werte ausschließlich Betrüger sind.

8. Herzlichen Glückwunsch

Herzlichen Glückwunsch, Sie haben erfolgreich vier verschiedene Bestenlisten auf Firebase erstellt! Abhängig von den Anforderungen Ihres Spiels an Genauigkeit und Geschwindigkeit können Sie zu einem angemessenen Preis eines auswählen, das für Sie geeignet ist.

Schauen Sie sich als Nächstes die Lernpfade für Spiele an.