Tworzenie tabel wyników w Firestore

1. Wprowadzenie

Last Updated: 2023-01-27

Co jest potrzebne do utworzenia tablicy wyników?

Tablice wyników to w zasadzie tabele wyników z jednym utrudnieniem: aby odczytać pozycję dla danego wyniku, trzeba znać wszystkie inne wyniki w określonej kolejności. Jeśli Twoja gra zyska popularność, tabele wyników będą się rozrastać, a odczytywanie i zapisywanie danych w nich będzie częste. Aby tablica wyników była skuteczna, musi szybko obsługiwać operację rankingową.

Co utworzysz

W tym laboratorium kodowania zaimplementujesz różne tablice wyników, z których każda będzie odpowiednia w innym scenariuszu.

Czego się nauczysz

Dowiesz się, jak wdrożyć 4 różne tablice wyników:

  • Prosta implementacja wykorzystująca proste zliczanie rekordów do określania pozycji
  • Tania, okresowo aktualizowana tablica wyników
  • Tablica wyników w czasie rzeczywistym z elementami związanymi z drzewami
  • Stochastyczna (probabilistyczna) tablica wyników do przybliżonego rankingu bardzo dużej liczby graczy

Czego potrzebujesz

  • najnowsza wersja Chrome (107 lub nowsza),
  • Node.js w wersji 16 lub nowszej (jeśli używasz nvm, uruchom polecenie nvm --version, aby sprawdzić numer wersji)
  • płatny abonament Firebase Blaze (opcjonalnie);
  • Wiersz poleceń Firebase w wersji 11.16.0 lub nowszej
    Aby zainstalować wiersz poleceń, możesz uruchomić polecenie npm install -g firebase-tools lub zapoznać się z dokumentacją wiersza poleceń, aby poznać więcej opcji instalacji.
  • Znajomość JavaScriptu, Cloud Firestore, Cloud Functions i Narzędzi deweloperskich w Chrome

2. Przygotowanie

Pobierz kod

Wszystko, czego potrzebujesz do tego projektu, umieściliśmy w repozytorium Git. Aby rozpocząć, pobierz kod i otwórz go w ulubionym środowisku programistycznym. W tym ćwiczeniu w Codelabs używamy VS Code, ale możesz użyć dowolnego edytora tekstu.

i rozpakuj pobrany plik ZIP.

Możesz też sklonować repozytorium do wybranego katalogu:

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

Od czego zaczynamy?

Nasz projekt jest obecnie pusty i zawiera kilka pustych funkcji:

  • index.html zawiera skrypty pomocnicze, które umożliwiają wywoływanie funkcji z konsoli deweloperskiej i wyświetlanie ich wyników. Będziemy go używać do komunikacji z naszym backendem i wyświetlania wyników wywołań funkcji. W rzeczywistości wywołania backendu byłyby wykonywane bezpośrednio z gry. W tym samouczku nie używamy gry, ponieważ za każdym razem, gdy chcesz dodać wynik do tablicy wyników, musisz grać, co zajmuje zbyt dużo czasu.
  • functions/index.js zawiera wszystkie nasze funkcje w Cloud Functions. Zobaczysz kilka funkcji narzędziowych, takich jak addScores i deleteScores, a także funkcje, które zaimplementujemy w tym laboratorium, które wywołują funkcje pomocnicze w innym pliku.
  • functions/functions-helpers.js zawiera puste funkcje, które będziemy implementować. W przypadku każdej tablicy wyników zaimplementujemy funkcje odczytu, tworzenia i aktualizowania. Zobaczysz, jak nasz wybór implementacji wpływa na złożoność implementacji i skalowalność.
  • functions/utils.js zawiera więcej funkcji narzędziowych. W tym laboratorium nie będziemy modyfikować tego pliku.

Tworzenie i konfigurowanie projektu Firebase

Tworzenie nowego projektu Firebase

  1. Zaloguj się w konsoli Firebase, korzystając ze swojego konta Google.
  2. Kliknij przycisk, aby utworzyć nowy projekt, a potem wpisz jego nazwę (np. Leaderboards Codelab).
  3. Kliknij Dalej.
  4. Po wyświetleniu monitu przeczytaj i zaakceptuj warunki usługi Firebase, a potem kliknij Dalej.
  5. (Opcjonalnie) Włącz w konsoli Firebase pomoc AI (nazywaną „Gemini w Firebase”).
  6. W tym samouczku nie potrzebujesz Google Analytics, więc wyłącz opcję Google Analytics.
  7. Kliknij Utwórz projekt, poczekaj, aż projekt zostanie udostępniony, a następnie kliknij Dalej.

Konfigurowanie usług Firebase

  1. W menu Build (Kompilacja) kliknij Functions (Funkcje) i w razie potrzeby przenieś projekt na abonament Blaze.
  2. W menu Kompilacja kliknij Baza danych Firestore.
  3. W wyświetlonym oknie Utwórz bazę danych kliknij Rozpocznij w trybie testowym, a następnie kliknij Dalej.
  4. Wybierz region z menu Lokalizacja Cloud Firestore, a następnie kliknij Włącz.

Konfigurowanie i uruchamianie tablicy wyników

  1. W terminalu przejdź do katalogu głównego projektu i uruchom polecenie firebase use --add. Wybierz utworzony projekt Firebase.
  2. W katalogu głównym projektu uruchom firebase emulators:start --only hosting.
  3. W przeglądarce otwórz localhost:5000.
  4. Otwórz konsolę JavaScript w Narzędziach deweloperskich w Chrome i zaimportuj leaderboard.js:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Uruchom leaderboard.codelab(); w konsoli. Jeśli zobaczysz komunikat powitalny, wszystko jest w porządku. Jeśli nie, zamknij emulator i ponownie wykonaj kroki 2–4.

Zacznijmy od pierwszego wdrożenia tablicy wyników.

3. Wdrażanie prostej tabeli wyników

Po zakończeniu tej sekcji będziemy mogli dodawać wyniki do tablicy wyników i sprawdzać swoje miejsce.

Zanim zaczniemy, wyjaśnijmy, jak działa ta implementacja tablicy wyników: wszyscy gracze są przechowywani w jednej kolekcji, a pozycja gracza jest pobierana przez wyszukanie kolekcji i policzenie, ilu graczy jest przed nim. Ułatwia to wstawianie i aktualizowanie wyniku. Aby wstawić nowy wynik, po prostu dodajemy go do kolekcji, a aby go zaktualizować, filtrujemy go pod kątem bieżącego użytkownika, a następnie aktualizujemy wynikowy dokument. Zobaczmy, jak to wygląda w kodzie.

W pliku functions/functions-helper.js zaimplementuj funkcję createScore, która jest bardzo prosta:

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

Aby zaktualizować wyniki, musimy tylko dodać sprawdzanie błędów, aby upewnić się, że aktualizowany wynik już istnieje:

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

Na koniec prosta, ale mniej skalowalna funkcja rank:

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

Sprawdźmy to! Wdróż funkcje, uruchamiając w terminalu to polecenie:

firebase deploy --only functions

Następnie w konsoli JS w Chrome dodaj inne wyniki, abyśmy mogli zobaczyć naszą pozycję wśród innych graczy.

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

Teraz możemy dodać do tego własny wynik:

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

Po zakończeniu zapisu w konsoli powinna pojawić się odpowiedź „Score created” (Utworzono wynik). Widzisz komunikat o błędzie? Otwórz logi funkcji w konsoli Firebase, aby sprawdzić, co poszło nie tak.

Na koniec możemy pobrać i zaktualizować nasz wynik.

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

Jednak ta implementacja powoduje niepożądane wymagania dotyczące czasu i pamięci w przypadku pobierania rangi danego wyniku. Czas wykonywania funkcji i pamięć są ograniczone, więc nie tylko nasze pobieranie danych będzie coraz wolniejsze, ale po dodaniu do tablicy wyników wystarczającej liczby wyników funkcje przekroczą limit czasu lub ulegną awarii, zanim zwrócą wynik. Jeśli chcemy wyjść poza garstkę graczy, potrzebujemy czegoś lepszego.

Jeśli dobrze znasz Firestore, być może wiesz o zapytaniach agregujących COUNT, które znacznie zwiększyłyby wydajność tej tablicy wyników. I będziesz mieć rację. W przypadku zapytań COUNT dobrze się to sprawdza w przypadku około miliona użytkowników, ale wydajność jest nadal liniowa.

Możesz jednak pomyśleć, że jeśli i tak musimy wyliczyć wszystkie dokumenty w kolekcji, możemy przypisać każdemu dokumentowi rangę, a potem, gdy będziemy musieli go pobrać, pobieranie będzie wymagało czasu i pamięci O(1). To prowadzi nas do kolejnego podejścia, czyli okresowo aktualizowanej tablicy wyników.

4. Wdrażanie okresowo aktualizowanej tabeli wyników

Kluczem do tego podejścia jest przechowywanie rangi w samym dokumencie, dzięki czemu jej pobranie nie wymaga dodatkowej pracy. Aby to osiągnąć, potrzebujemy nowego rodzaju funkcji.

W sekcji index.js dodaj te informacje:

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

Teraz operacje odczytu, aktualizacji i zapisu są proste i wygodne. Operacje zapisu i aktualizacji pozostają bez zmian, ale operacja odczytu zmienia się na (w 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"),
  };
}

Niestety nie możesz wdrożyć i przetestować tego rozwiązania bez dodania konta rozliczeniowego do projektu. Jeśli masz konto rozliczeniowe, skróć interwał w zaplanowanej funkcji i obserwuj, jak funkcja magicznie przypisuje pozycje do wyników w tabeli wyników.

Jeśli nie, usuń zaplanowaną funkcję i przejdź do następnej implementacji.

Aby przygotować się do następnej sekcji, usuń wyniki z bazy danych Firestore, klikając 3 kropki obok kolekcji wyników.

Firestore ocenia stronę dokumentu z włączoną opcją Usuń kolekcję

5. Wdrażanie tabeli wyników drzew w czasie rzeczywistym

To podejście polega na przechowywaniu danych wyszukiwania w samej kolekcji bazy danych. Zamiast jednolitej kolekcji chcemy przechowywać wszystko w drzewie, po którym możemy się poruszać, przechodząc między dokumentami. Dzięki temu możemy przeprowadzić wyszukiwanie binarne (lub n-arne) dla danego wyniku. Jak to może wyglądać?

Na początek chcemy rozdzielić wyniki na mniej więcej równe przedziały, co będzie wymagało pewnej wiedzy o wartościach wyników rejestrowanych przez użytkowników. Jeśli na przykład tworzysz tabelę wyników oceny umiejętności w grze rywalizacyjnej, oceny umiejętności użytkowników prawie zawsze będą miały rozkład normalny. Nasza funkcja generowania losowych wyników używa funkcji Math.random() JavaScriptu, która zapewnia w przybliżeniu równomierny rozkład, więc podzielimy nasze koszyki równomiernie.

W tym przykładzie dla uproszczenia użyjemy 3 grup, ale w przypadku wdrożenia w prawdziwej aplikacji więcej grup da szybsze wyniki – płytsze drzewo oznacza średnio mniej pobrań kolekcji i mniejszą rywalizację o blokady.

Pozycja gracza jest określana przez sumę liczby graczy z wyższymi wynikami plus jeden (sam gracz). Każda kolekcja w scores będzie przechowywać 3 dokumenty, z których każdy będzie zawierać zakres, liczbę dokumentów w każdym zakresie, a następnie 3 odpowiednie podkolekcje. Aby odczytać pozycję, przechodzimy przez to drzewo, szukając wyniku i śledząc sumę większych wyników. Gdy znajdziemy nasz wynik, będziemy też mieć prawidłową sumę.

Pisanie jest znacznie bardziej skomplikowane. Najpierw musimy wykonać wszystkie zapisy w ramach transakcji, aby zapobiec niespójności danych, gdy wiele zapisów lub odczytów występuje w tym samym czasie. Podczas przechodzenia przez drzewo w celu zapisania nowych dokumentów musimy też zachować wszystkie opisane powyżej warunki. Wreszcie, ponieważ mamy całą złożoność drzewa tego nowego podejścia w połączeniu z koniecznością przechowywania wszystkich oryginalnych dokumentów, koszt przechowywania nieco wzrośnie (ale nadal będzie liniowy).

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

Jest to z pewnością bardziej skomplikowane niż ostatnie wdrożenie, które wymagało tylko jednego wywołania metody i 6 wierszy kodu. Po wdrożeniu tej metody spróbuj dodać do bazy danych kilka wyników i obserwuj strukturę powstałego drzewa. W konsoli JS:

leaderboard.addScores();

Wynikowa struktura bazy danych powinna wyglądać mniej więcej tak, aby struktura drzewa była dobrze widoczna, a liście drzewa reprezentowały poszczególne wyniki.

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

Najtrudniejsza część jest już za nami, więc możemy odczytywać wyniki, przechodząc przez drzewo w sposób opisany wcześniej.

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

Aktualizacje są traktowane jako dodatkowe ćwiczenie. Spróbuj dodać i pobrać wyniki w konsoli JS za pomocą metod leaderboard.addScore(id, score)leaderboard.getRank(id), a potem sprawdź, jak zmienia się tabela wyników w konsoli Firebase.

Jednak w tym przypadku złożoność, którą dodaliśmy, aby osiągnąć wydajność logarytmiczną, wiąże się z kosztem.

  • Po pierwsze, ta implementacja tablicy wyników może napotkać problemy z konfliktami blokad, ponieważ transakcje wymagają blokowania odczytów i zapisów w dokumentach, aby zapewnić ich spójność.
  • Po drugie, Firestore ma limit głębokości podkolekcji wynoszący 100, co oznacza, że po 100 remisach musisz unikać tworzenia poddrzew, czego ta implementacja nie robi.
  • Ta lista wyników jest skalowana logarytmicznie tylko w idealnym przypadku, gdy drzewo jest zrównoważone. Jeśli nie jest, najgorsza wydajność tej listy wyników jest ponownie liniowa.

Gdy to zrobisz, usuń kolekcje scoresplayers w konsoli Firebase. Następnie przejdziemy do ostatniej implementacji tablicy wyników.

6. Wdrażanie stochastycznej (probabilistycznej) tabeli wyników

Podczas uruchamiania kodu wstawiania możesz zauważyć, że jeśli uruchomisz go zbyt wiele razy równolegle, funkcje zaczną się nie powodzić i będzie się wyświetlać komunikat o błędzie związany z konfliktem blokady transakcji. Istnieją sposoby na obejście tego problemu, ale nie będziemy ich omawiać w tym laboratorium. Jeśli nie potrzebujesz dokładnego rankingu, możesz zrezygnować ze wszystkich złożonych elementów poprzedniego podejścia i zastosować prostsze i szybsze rozwiązanie. Przyjrzyjmy się, jak możemy zwracać szacunkową pozycję dla wyników graczy zamiast dokładnej pozycji i jak to zmienia logikę naszej bazy danych.

W tym podejściu podzielimy tabelę wyników na 100 grup, z których każda będzie reprezentować około 1% oczekiwanych wyników. To podejście działa nawet bez znajomości rozkładu wyników. W takim przypadku nie możemy zagwarantować mniej więcej równomiernego rozkładu wyników w przedziale, ale jeśli będziemy wiedzieć, jak rozkładają się nasze wyniki, uzyskamy większą precyzję przybliżeń.

Nasze podejście jest następujące: tak jak wcześniej, każdy przedział przechowuje liczbę wyników w danym przedziale oraz zakres wyników. Podczas wstawiania nowego wyniku znajdziemy odpowiedni przedział i zwiększymy jego licznik. Podczas pobierania pozycji po prostu zsumujemy przedziały przed nią, a potem dokonamy przybliżenia w naszym przedziale, zamiast dalej wyszukiwać. Dzięki temu mamy bardzo szybkie wyszukiwanie i wstawianie w stałym czasie, a kod jest znacznie krótszy.

Najpierw wstawianie:

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

Zauważysz, że ten kod wstawiania zawiera u góry logikę inicjowania stanu bazy danych z ostrzeżeniem, aby nie robić czegoś takiego w środowisku produkcyjnym. Kod inicjujący nie jest w ogóle chroniony przed warunkami wyścigu, więc jeśli to zrobisz, wiele równoczesnych zapisów uszkodzi bazę danych, tworząc wiele zduplikowanych zasobników.

Wdróż funkcje, a potem uruchom wstawianie, aby zainicjować wszystkie zasobniki wartością zero. Zwróci błąd, który możesz bezpiecznie zignorować.

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

Teraz, gdy baza danych została prawidłowo zainicjowana, możemy uruchomić addScores i zobaczyć strukturę naszych danych w konsoli Firebase. Wynikowa struktura jest znacznie bardziej płaska niż w przypadku ostatniego wdrożenia, chociaż na pierwszy rzut oka są podobne.

leaderboard.addScores();

Aby odczytać wyniki:

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

Ponieważ funkcja addScores generuje jednolity rozkład wyników, a w przedziałach używamy interpolacji liniowej, uzyskamy bardzo dokładne wyniki, wydajność naszej tablicy wyników nie pogorszy się wraz ze wzrostem liczby użytkowników i nie musimy się (aż tak bardzo) martwić o konflikty blokad podczas aktualizowania liczników.

7. Aneks: oszukiwanie

Zaraz, możesz pomyśleć, że jeśli zapisuję wartości w codelabie za pomocą konsoli JS w karcie przeglądarki, to czy któryś z moich graczy nie może po prostu skłamać w tabeli wyników i powiedzieć, że uzyskał wysoki wynik, którego nie osiągnął uczciwie?

Tak. Jeśli chcesz zapobiec oszustwom, najlepszym sposobem jest wyłączenie zapisywania danych w bazie danych przez klientów za pomocą reguł zabezpieczeń, zabezpieczenie dostępu do Cloud Functions, aby klienci nie mogli ich wywoływać bezpośrednio, a następnie weryfikowanie działań w grze na serwerze przed wysłaniem aktualizacji wyników do tablicy wyników.

Warto pamiętać, że ta strategia nie jest panaceum na oszustwa. Przy wystarczająco dużej zachęcie oszuści mogą znaleźć sposoby na obejście weryfikacji po stronie serwera. Wiele dużych, popularnych gier wideo nieustannie bawi się w kotka i myszkę z oszustami, aby wykrywać nowe oszustwa i zapobiegać ich rozprzestrzenianiu się. Trudną konsekwencją tego zjawiska jest to, że weryfikacja po stronie serwera w przypadku każdej gry jest z natury dostosowana do jej potrzeb. Firebase udostępnia narzędzia do ochrony przed nadużyciami, takie jak Sprawdzanie aplikacji, które uniemożliwiają użytkownikowi skopiowanie gry za pomocą prostego skryptu klienta, ale nie oferuje żadnej usługi, która stanowiłaby kompleksowe rozwiązanie do walki z oszustwami.

W przypadku popularnej gry lub gdy oszukiwanie jest łatwe, brak weryfikacji po stronie serwera spowoduje, że na tablicy wyników na najwyższych pozycjach znajdą się oszuści.

8. Gratulacje

Udało Ci się utworzyć w Firebase 4 różne tablice wyników. W zależności od potrzeb gry w zakresie dokładności i szybkości możesz wybrać odpowiednią opcję w rozsądnej cenie.

Następnie zapoznaj się ze ścieżkami szkoleniowymi dotyczącymi gier.