Cloud Spanner – pierwsze kroki w tworzeniu gier

1. Wprowadzenie

Cloud Spanner to w pełni zarządzana, skalowalna w poziomie, rozproszona globalnie, relacyjna usługa baz danych, która zapewnia transakcje ACID i semantykę SQL bez utraty wydajności i wysokiej dostępności.

Te funkcje sprawiają, że usługa Spanner znakomicie sprawdza się w architekturze gier, które chcą zapewnić globalną grupę graczy lub którym zależy na spójności danych.

W tym module utworzysz 2 usługi Go, które będą współdziałać z regionalną bazą danych Spannera, aby umożliwić użytkownikom zarejestrowanie się i rozpoczęcie gry.

413fdd57bb0b68bc.png

Następnie wygenerujesz dane, używając platformy wczytywania języka Python Locust.io, aby symulować rejestrowanie się graczy i granie w grę. Następnie wysyła do Spannera zapytania o liczbę graczy oraz statystyki dotyczące wygrane i rozegrane mecze.

Na koniec usuniesz zasoby utworzone w tym module.

Co utworzysz

W ramach tego modułu:

  • Tworzenie instancji usługi Spanner
  • Wdróż usługę profilu napisaną w języku Go, aby obsługiwać rejestrację graczy
  • Wdróż usługę dopasowywania graczy napisaną w Go, aby przypisywać graczy do gier, określać zwycięzców i aktualizować statystyki gry.

Czego się nauczysz

  • Jak skonfigurować instancję Cloud Spanner
  • Jak utworzyć bazę danych i schemat gier
  • Jak wdrażać aplikacje w języku Go do współpracy z Cloud Spanner
  • Jak generować dane za pomocą narzędzia Locust
  • Jak tworzyć zapytania dotyczące danych w Cloud Spanner, aby uzyskać odpowiedzi na pytania dotyczące gier i graczy.

Czego potrzebujesz

  • Projekt Google Cloud połączony z kontem rozliczeniowym.
  • Przeglądarka, np. Chrome lub Firefox.

2. Konfiguracja i wymagania

Utwórz projekt

Jeśli nie masz jeszcze konta Google (w Gmailu lub Google Apps), musisz je utworzyć. Zaloguj się w konsoli Google Cloud Platform ( console.cloud.google.com) i utwórz nowy projekt.

Jeśli masz już projekt, kliknij menu wyboru projektu w lewym górnym rogu konsoli:

6c9406d9b014760.png

i kliknij „NOWY PROJEKT”. w wyświetlonym oknie, aby utworzyć nowy projekt:

949d83c8a4ee17d9.png

Jeśli nie masz jeszcze projektu, zobaczysz takie okno dialogowe umożliwiające utworzenie pierwszego:

870a3cbd6541ee86.png

W kolejnym oknie tworzenia projektu możesz wpisać szczegóły nowego projektu:

6a92c57d3250a4b3.png

Zapamiętaj identyfikator projektu, który jest niepowtarzalną nazwą we wszystkich projektach Google Cloud (powyższa nazwa jest już zajęta i nie będzie Ci odpowiadać). W dalszej części tego ćwiczenia z programowania będzie on określany jako PROJECT_ID.

Następnie musisz włączyć płatności w Developers Console, aby korzystać z zasobów Google Cloud i włączyć interfejs Cloud Spanner API.

15d0ef27a8fbab27.png

Wykonanie tych ćwiczeń w programie nie powinno kosztować więcej niż kilka dolarów, ale może być droższe, jeśli zdecydujesz się na więcej zasobów lub pozostawisz je włączone (patrz sekcja „Czyszczenie” na końcu tego dokumentu). Cennik Google Cloud Spanner znajdziesz tutaj.

Nowi użytkownicy Google Cloud Platform mogą skorzystać z bezpłatnej wersji próbnej o wartości 300 USD, dzięki czemu te ćwiczenia z programowania będą całkowicie bezpłatne.

Konfiguracja Google Cloud Shell

Usługi Google Cloud i Spanner można obsługiwać zdalnie z poziomu laptopa, ale w ramach tych ćwiczeń w programowaniu użyjemy Google Cloud Shell – środowiska wiersza poleceń działającego w chmurze.

Ta maszyna wirtualna oparta na Debianie zawiera wszystkie potrzebne narzędzia dla programistów. Zawiera stały katalog domowy o pojemności 5 GB i działa w Google Cloud, co znacznie zwiększa wydajność sieci i uwierzytelnianie. Oznacza to, że do tego ćwiczenia z programowania wystarczy przeglądarka (tak, działa ona na Chromebooku).

  1. Aby aktywować Cloud Shell z poziomu konsoli Cloud, kliknij Aktywuj Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (udostępnienie środowiska i połączenie z nim powinno zająć tylko kilka chwil).

JjEuRXGg0AYYIY6QZ8d-66gx_Mtc-_jDE9ijmbXLJSAXFvJt-qUpNtsBsYjNpv2W6BQSrDc1D-ARINNQ-1EkwUhz-iUK-FUCZhJ-NtjvIEx9pIkE-246DomWuCfiGHK78DgoeWkHRw

Zrzut ekranu 2017-06-14 o 10.13.43 PM.png

Po nawiązaniu połączenia z Cloud Shell powinno pojawić się potwierdzenie, że użytkownik jest już uwierzytelniony, a projekt ma już ustawiony identyfikator PROJECT_ID.

gcloud auth list

Dane wyjściowe polecenia

Credentialed accounts:
 - <myaccount>@<mydomain>.com (active)
gcloud config list project

Dane wyjściowe polecenia

[core]
project = <PROJECT_ID>

Jeśli z jakiegoś powodu projekt nie jest skonfigurowany, uruchom po prostu to polecenie:

gcloud config set project <PROJECT_ID>

Szukasz swojego projektu PROJECT_ID? Sprawdź identyfikator użyty w krokach konfiguracji lub wyszukaj go w panelu Cloud Console:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell ustawia też domyślnie niektóre zmienne środowiskowe, które mogą być przydatne podczas uruchamiania kolejnych poleceń.

echo $GOOGLE_CLOUD_PROJECT

Dane wyjściowe polecenia

<PROJECT_ID>

Pobieranie kodu

W Cloud Shell możesz pobrać kod tego modułu. Te informacje są oparte na wersji v0.1.0, więc zajrzyj do tego tagu:

git clone https://github.com/cloudspannerecosystem/spanner-gaming-sample.git
cd spanner-gaming-sample/

# Check out v0.1.0 release
git checkout tags/v0.1.0 -b v0.1.0-branch

Dane wyjściowe polecenia

Cloning into 'spanner-gaming-sample'...
*snip*
Switched to a new branch 'v0.1.0-branch'

Skonfiguruj generator obciążenia Locust

Locust to platforma do testowania obciążenia Pythona, która przydaje się do testowania punktów końcowych interfejsu API REST. W ramach tego ćwiczenia w Codelabs mamy 2 różne testy obciążenia w „generatorach”. który wyróżnimy:

  • authentication_server.py: zawiera zadania tworzenia odtwarzaczy oraz pobierającego losowy odtwarzacz, który naśladuje wyszukiwania jednopunktowe.
  • match_server.py: zawiera zadania do tworzenia i zamykania gier. Podczas tworzenia gier zostanie do nich przypisanych 100 losowych graczy, którzy w danej chwili nie grają w grę. Zamknięcie gry spowoduje zaktualizowanie statystyk game_played i gry_won oraz umożliwi przypisanie tych graczy do przyszłej gry.

Aby uruchomić Locust w Cloud Shell, musisz mieć Pythona w wersji 3.7 lub nowszej. Cloud Shell zawiera Pythona 3.9, więc nie musisz nic robić poza weryfikacją wersji:

python -V

Dane wyjściowe polecenia

Python 3.9.12

Teraz możesz zainstalować wymagania dotyczące Locust.

pip3 install -r requirements.txt

Dane wyjściowe polecenia

Collecting locust==2.11.1
*snip*
Successfully installed ConfigArgParse-1.5.3 Flask-BasicAuth-0.2.0 Flask-Cors-3.0.10 brotli-1.0.9 gevent-21.12.0 geventhttpclient-2.0.2 greenlet-1.1.3 locust-2.11.1 msgpack-1.0.4 psutil-5.9.2 pyzmq-22.3.0 roundrobin-0.0.4 zope.event-4.5.0 zope.interface-5.4.0

Teraz zaktualizuj PATH, aby znaleźć nowo zainstalowany plik binarny locust:

PATH=~/.local/bin":$PATH"
which locust

Dane wyjściowe polecenia

/home/<user>/.local/bin/locust

Podsumowanie

W tym kroku skonfigurujesz projekt (jeśli jeszcze go nie masz), aktywowałeś Cloud Shell i pobrasz kod do tego modułu.

Na koniec skonfigurujesz Locust do generowania obciążenia w dalszej części modułu.

Następny krok

Następnie skonfigurujesz instancję i bazę danych Cloud Spanner.

3. Tworzenie instancji i bazy danych Spanner

Tworzenie instancji usługi Spanner

W tym kroku skonfigurowaliśmy instancję Spannera na potrzeby ćwiczeń w Codelabs. Wyszukaj pozycję Spannera 1a6580bd3d3e6783.png w menu Hamburger po lewej stronie 3129589f7bc9e5ce.png lub wyszukaj usługę Spanner, naciskając „/” i wpisz „Spanner”

36e52f8df8e13b99.png

Następnie kliknij 95269e75bc8c3e4d.png i wypełnij formularz, wpisując nazwę instancji cloudspanner-gaming dla swojej instancji, wybierając konfigurację (wybierz instancję regionalną, na przykład us-central1) i ustaw liczbę węzłów. Do tego ćwiczenia w Codelabs wystarczy Ci 500 processing units.

Kliknij „Utwórz”. a w ciągu kilku sekund będziesz mieć do dyspozycji instancję Cloud Spanner.

4457c324c94f93e6.png

Tworzenie bazy danych i schematu

Gdy instancja zacznie działać, możesz utworzyć bazę danych. Spanner umożliwia korzystanie z wielu baz danych w jednej instancji.

Baza danych to miejsce, w którym definiujesz schemat. Możesz też kontrolować, kto ma dostęp do bazy danych, skonfigurować szyfrowanie niestandardowe, skonfigurować optymalizatora i ustawić okres przechowywania.

W przypadku instancji obejmujących wiele regionów możesz też skonfigurować domyślną replikę wiodącej. Dowiedz się więcej o bazach danych w usłudze Spanner.

W ramach tego ćwiczenia w programie utworzysz bazę danych z opcjami domyślnymi i podasz schemat podczas tworzenia.

W tym module zostaną utworzone 2 tabele: gracze i gry.

77651ac12e47fe2a.png

Gracze mogą uczestniczyć w wielu grach jednocześnie, ale tylko w jednym. Gracze korzystają też z statystyk jako typu danych JSON, aby śledzić interesujące statystyki, np. games_played i games_won. Później mogą zostać dodane inne statystyki, więc ta kolumna jest nieschematyczna dla graczy.

Gry śledzą graczy, którzy uczestniczyli w programie, za pomocą typu danych ARRAY w usłudze Spanner. Atrybuty zwycięzcy i ukończonych gier są wypełniane dopiero po zamknięciu gry.

Istnieją jeden klucz obcy, który potwierdza, że current_game gracza jest prawidłową grą.

Teraz utwórz bazę danych, klikając „Utwórz bazę danych” w omówieniu instancji:

a820db6c4a4d6f2d.png

Podaj szczegóły. Ważne opcje to nazwa bazy danych i dialekt. W tym przykładzie nazwaliśmy bazę danych sample-game i wybraliśmy standardowy dialekt SQL Google.

Skopiuj ten DDL i wklej go w polu schematu:

CREATE TABLE games (
  gameUUID STRING(36) NOT NULL,
  players ARRAY<STRING(36)> NOT NULL,
  winner STRING(36),
  created TIMESTAMP,
  finished TIMESTAMP,
) PRIMARY KEY(gameUUID);

CREATE TABLE players (
  playerUUID STRING(36) NOT NULL,
  player_name STRING(64) NOT NULL,
  email STRING(MAX) NOT NULL,
  password_hash BYTES(60) NOT NULL,
  created TIMESTAMP,
  updated TIMESTAMP,
  stats JSON,
  account_balance NUMERIC NOT NULL DEFAULT (0.00),
  is_logged_in BOOL,
  last_login TIMESTAMP,
  valid_email BOOL,
  current_game STRING(36),
  FOREIGN KEY (current_game) REFERENCES games (gameUUID),
) PRIMARY KEY(playerUUID);

CREATE UNIQUE INDEX PlayerAuthentication ON players(email) STORING (password_hash);

CREATE INDEX PlayerGame ON players(current_game);

CREATE UNIQUE INDEX PlayerName ON players(player_name);

Następnie kliknij przycisk Utwórz i zaczekaj kilka sekund na utworzenie bazy danych.

Strona tworzenia bazy danych powinna wyglądać tak:

d39d358dc7d32939.png

Teraz musisz ustawić w Cloud Shell kilka zmiennych środowiskowych, których użyjesz w dalszej części modułu kodu. Zanotuj identyfikator instancji i ustaw w Cloud Shell wartości INSTANCE_ID oraz DATABASE_ID.

f6f98848d3aea9c.png

export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game

Podsumowanie

W tym kroku utworzyliśmy instancję Spannera i bazę danych sample-game. Masz też zdefiniowany schemat, z którego korzysta ta przykładowa gra.

Następny krok

Następnie wdrożysz usługę profilu, aby umożliwić graczom rejestrowanie się w grze.

4. Wdrażanie usługi profilu

Omówienie usługi

Usługa profili to interfejs API typu REST napisany w języku Go, który korzysta z platformy gin.

4fce45ee6c858b3e.png

Ten interfejs API umożliwia graczom rejestrowanie się w grach. Jest ono tworzone przez proste polecenie POST, które akceptuje nazwę gracza, adres e-mail i hasło. Hasło jest zaszyfrowane przy użyciu bcrypt, a hasz jest przechowywany w bazie danych.

Adres e-mail jest traktowany jako unikalny identyfikator, a player_name służy do wyświetlania informacji w grze.

Ten interfejs API nie obsługuje obecnie logowania, ale wdrożenie tego interfejsu możesz wykonać w ramach dodatkowego ćwiczenia.

Plik ./src/golang/profile-service/main.go usługi profilu ujawnia 2 główne punkty końcowe w następujący sposób:

func main() {
   configuration, _ := config.NewConfig()

   router := gin.Default()
   router.SetTrustedProxies(nil)

   router.Use(setSpannerConnection(configuration))

   router.POST("/players", createPlayer)
   router.GET("/players", getPlayerUUIDs)
   router.GET("/players/:id", getPlayerByID)

   router.Run(configuration.Server.URL())
}

A kod tych punktów końcowych będzie kierował do modelu odtwarzacza.

func getPlayerByID(c *gin.Context) {
   var playerUUID = c.Param("id")

   ctx, client := getSpannerConnection(c)

   player, err := models.GetPlayerByUUID(ctx, client, playerUUID)
   if err != nil {
       c.IndentedJSON(http.StatusNotFound, gin.H{"message": "player not found"})
       return
   }

   c.IndentedJSON(http.StatusOK, player)
}

func createPlayer(c *gin.Context) {
   var player models.Player

   if err := c.BindJSON(&player); err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   ctx, client := getSpannerConnection(c)
   err := player.AddPlayer(ctx, client)
   if err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   c.IndentedJSON(http.StatusCreated, player.PlayerUUID)
}

Jedną z pierwszych czynności wykonywanych przez usługę jest ustawienie połączenia Spannera. Jest to wdrażane na poziomie usługi w celu utworzenia puli sesji dla usługi.

func setSpannerConnection() gin.HandlerFunc {
   ctx := context.Background()
   client, err := spanner.NewClient(ctx, configuration.Spanner.URL())

   if err != nil {
       log.Fatal(err)
   }

   return func(c *gin.Context) {
       c.Set("spanner_client", *client)
       c.Set("spanner_context", ctx)
       c.Next()
   }
}

Wartości Player i PlayerStats mają strukturę zdefiniowany w ten sposób:

type Player struct {
   PlayerUUID      string `json:"playerUUID" validate:"omitempty,uuid4"`
   Player_name     string `json:"player_name" validate:"required_with=Password Email"`
   Email           string `json:"email" validate:"required_with=Player_name Password,email"`
   // not stored in DB
   Password        string `json:"password" validate:"required_with=Player_name Email"` 
   // stored in DB
   Password_hash   []byte `json:"password_hash"`                                       
   created         time.Time
   updated         time.Time
   Stats           spanner.NullJSON `json:"stats"`
   Account_balance big.Rat          `json:"account_balance"`
   last_login      time.Time
   is_logged_in    bool
   valid_email     bool
   Current_game    string `json:"current_game" validate:"omitempty,uuid4"`
}

type PlayerStats struct {
   Games_played spanner.NullInt64 `json:"games_played"`
   Games_won    spanner.NullInt64 `json:"games_won"`
}

Funkcja dodawania odtwarzacza korzysta z wstawiania DML w transakcji ReadWrite, ponieważ dodawanie odtwarzaczy wymaga pojedynczej instrukcji, a nie wstawiania wsadowego. Funkcja wygląda tak:

func (p *Player) AddPlayer(ctx context.Context, client spanner.Client) error {
   // Validate based on struct validation rules
   err := p.Validate()
   if err != nil {
       return err
   }

   // take supplied password+salt, hash. Store in user_password
   passHash, err := hashPassword(p.Password)

   if err != nil {
       return errors.New("Unable to hash password")
   }

   p.Password_hash = passHash

   // Generate UUIDv4
   p.PlayerUUID = generateUUID()

   // Initialize player stats
   emptyStats := spanner.NullJSON{Value: PlayerStats{
       Games_played: spanner.NullInt64{Int64: 0, Valid: true},
       Games_won:    spanner.NullInt64{Int64: 0, Valid: true},
   }, Valid: true}

   // insert into spanner
   _, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       stmt := spanner.Statement{
           SQL: `INSERT players (playerUUID, player_name, email, password_hash, created, stats) VALUES
                   (@playerUUID, @playerName, @email, @passwordHash, CURRENT_TIMESTAMP(), @pStats)
           `,
           Params: map[string]interface{}{
               "playerUUID":   p.PlayerUUID,
               "playerName":   p.Player_name,
               "email":        p.Email,
               "passwordHash": p.Password_hash,
               "pStats":       emptyStats,
           },
       }

       _, err := txn.Update(ctx, stmt)
       return err
   })
   if err != nil {
       return err
   }
   // return empty error on success
   return nil
}

Aby pobrać gracza na podstawie jego identyfikatora UUID, wykonywany jest prosty odczyt. Pobierają one wartości playerUUID, player_name, e-mail i statystyki.

func GetPlayerByUUID(ctx context.Context, client spanner.Client, uuid string) (Player, error) {
   row, err := client.Single().ReadRow(ctx, "players",
       spanner.Key{uuid}, []string{"playerUUID", "player_name", "email", "stats"})
   if err != nil {
       return Player{}, err
   }

   player := Player{}
   err = row.ToStruct(&player)

   if err != nil {
       return Player{}, err
   }
   return player, nil
}

Domyślnie usługa jest konfigurowana przy użyciu zmiennych środowiskowych. Zapoznaj się z odpowiednią sekcją pliku ./src/golang/profile-service/config/config.go.

func NewConfig() (Config, error) {
   *snip*
   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8080)

   // Bind environment variable override
   viper.BindEnv("server.host", "SERVICE_HOST")
   viper.BindEnv("server.port", "SERVICE_PORT")
   viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
   viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
   viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")

   *snip*

   return c, nil
}

Jak widać, działanie domyślne polega na uruchomieniu usługi na serwerze localhost:8080.

Gdy masz te informacje, możemy uruchomić usługę.

Uruchom usługę profilu

Uruchom usługę za pomocą polecenia go. Spowoduje to pobranie zależności i ustanowienie usługi działającej na porcie 8080:

cd ~/spanner-gaming-sample/src/golang/profile-service
go run . &

Dane wyjściowe polecenia:

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /players                  --> main.createPlayer (4 handlers)
[GIN-debug] GET    /players                  --> main.getPlayerUUIDs (4 handlers)
[GIN-debug] GET    /players/:id              --> main.getPlayerByID (4 handlers)
[GIN-debug] GET    /players/:id/stats        --> main.getPlayerStats (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8080

Przetestuj usługę, wykonując polecenie curl:

curl http://localhost:8080/players \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"email": "test@gmail.com","password": "s3cur3P@ss","player_name": "Test Player"}'

Dane wyjściowe polecenia:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <date> 18:55:08 GMT
Content-Length: 38

"506a1ab6-ee5b-4882-9bb1-ef9159a72989"

Podsumowanie

Na tym etapie wdrożyłeś(-aś) usługę profilu, która pozwala graczom rejestrować się w grze, i przetestowałeś(-aś) ją, uruchamiając wywołanie POST API w celu utworzenia nowego gracza.

Następne kroki

W następnym kroku wdrożysz usługę dopasowywania reklam.

5. Wdrażanie usługi dopasowywania

Omówienie usługi

Usługa dopasowywania to interfejs API typu REST napisany w języku Go, który korzysta z platformy gin.

9aecd571df0dcd7c.png

W tym interfejsie API gry są tworzone i zamknięte. Po utworzeniu gry przypisanych do niej 10 graczy, którzy jeszcze w niej nie grali.

Gdy mecz jest zamknięty, losowo wybierany jest zwycięzca, a każdy zawodnik dostosowane statystyki dla games_played i games_won. Dodatkowo każdy gracz jest aktualizowany, aby wskazać, że już nie gra i że może grać w kolejne gry.

Konfiguracja i kod ./src/golang/matchmaking-service/main.go usługi dopasowywania mają podobną konfigurację i kod jak w przypadku usługi profile, więc się tu nie powtarzają. Ta usługa ujawnia 2 główne punkty końcowe w następujący sposób:

func main() {
   router := gin.Default()
   router.SetTrustedProxies(nil)

   router.Use(setSpannerConnection())

   router.POST("/games/create", createGame)
   router.PUT("/games/close", closeGame)

   router.Run(configuration.Server.URL())
}

Ta usługa obejmuje strukturę Game oraz odchudzone struktury Player i PlayerStats:

type Game struct {
   GameUUID string           `json:"gameUUID"`
   Players  []string         `json:"players"`
   Winner   string           `json:"winner"`
   Created  time.Time        `json:"created"`
   Finished spanner.NullTime `json:"finished"`
}

type Player struct {
   PlayerUUID   string           `json:"playerUUID"`
   Stats        spanner.NullJSON `json:"stats"`
   Current_game string           `json:"current_game"`
}

type PlayerStats struct {
   Games_played int `json:"games_played"`
   Games_won    int `json:"games_won"`
}

Aby utworzyć grę, system dobierania graczy wybiera losowo wybranych 100 graczy, którzy w danym momencie nie grają w grę.

Mutacje spalin są wybierane do utworzenia gry i przypisania do niej graczy, ponieważ w przypadku dużych zmian są one skuteczniejsze niż DML.

// Create a new game and assign players
// Players that are not currently playing a game are eligble to be selected for the new game
// Current implementation allows for less than numPlayers to be placed in a game
func (g *Game) CreateGame(ctx context.Context, client spanner.Client) error {
   // Initialize game values
   g.GameUUID = generateUUID()

   numPlayers := 10

   // Create and assign
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       var m []*spanner.Mutation

       // get players
       query := fmt.Sprintf("SELECT playerUUID FROM (SELECT playerUUID FROM players WHERE current_game IS NULL LIMIT 10000) TABLESAMPLE RESERVOIR (%d ROWS)", numPlayers)
       stmt := spanner.Statement{SQL: query}
       iter := txn.Query(ctx, stmt)

       playerRows, err := readRows(iter)
       if err != nil {
           return err
       }

       var playerUUIDs []string

       for _, row := range playerRows {
           var pUUID string
           if err := row.Columns(&pUUID); err != nil {
               return err
           }

           playerUUIDs = append(playerUUIDs, pUUID)
       }

       // Create the game
       gCols := []string{"gameUUID", "players", "created"}
       m = append(m, spanner.Insert("games", gCols, []interface{}{g.GameUUID, playerUUIDs, time.Now()}))

       // Update players to lock into this game
       for _, p := range playerUUIDs {
           pCols := []string{"playerUUID", "current_game"}
           m = append(m, spanner.Update("players", pCols, []interface{}{p, g.GameUUID}))
       }

       txn.BufferWrite(m)

       return nil
   })

   if err != nil {
       return err
   }

   return nil
}

Losowy wybór graczy jest dokonywany w języku SQL przy użyciu funkcji TABLESPACE RESERVOIR GoogleSQL.

Zamknięcie gry jest nieco bardziej skomplikowane. Polega na wybraniu losowego zwycięzcy spośród graczy, oznaczenie czasu zakończenia gry i aktualizowaniem games_played i games_won.

Z powodu złożoności i liczby zmian mutacje są ponownie wybierane w celu zamknięcia gry.

func determineWinner(playerUUIDs []string) string {
   if len(playerUUIDs) == 0 {
       return ""
   }

   var winnerUUID string

   rand.Seed(time.Now().UnixNano())
   offset := rand.Intn(len(playerUUIDs))
   winnerUUID = playerUUIDs[offset]
   return winnerUUID
}

// Given a list of players and a winner's UUID, update players of a game
// Updating players involves closing out the game (current_game = NULL) and
// updating their game stats. Specifically, we are incrementing games_played.
// If the player is the determined winner, then their games_won stat is incremented.
func (g Game) updateGamePlayers(ctx context.Context, players []Player, txn *spanner.ReadWriteTransaction) error {
   for _, p := range players {
       // Modify stats
       var pStats PlayerStats
       json.Unmarshal([]byte(p.Stats.String()), &pStats)

       pStats.Games_played = pStats.Games_played + 1

       if p.PlayerUUID == g.Winner {
           pStats.Games_won = pStats.Games_won + 1
       }
       updatedStats, _ := json.Marshal(pStats)
       p.Stats.UnmarshalJSON(updatedStats)

       // Update player
       // If player's current game isn't the same as this game, that's an error
       if p.Current_game != g.GameUUID {
           errorMsg := fmt.Sprintf("Player '%s' doesn't belong to game '%s'.", p.PlayerUUID, g.GameUUID)
           return errors.New(errorMsg)
       }

       cols := []string{"playerUUID", "current_game", "stats"}
       newGame := spanner.NullString{
           StringVal: "",
           Valid:     false,
       }

       txn.BufferWrite([]*spanner.Mutation{
           spanner.Update("players", cols, []interface{}{p.PlayerUUID, newGame, p.Stats}),
       })
   }

   return nil
}

// Closing game. When provided a Game, choose a random winner and close out the game.
// A game is closed by setting the winner and finished time.
// Additionally all players' game stats are updated, and the current_game is set to null to allow
// them to be chosen for a new game.
func (g *Game) CloseGame(ctx context.Context, client spanner.Client) error {
   // Close game
   _, err := client.ReadWriteTransaction(ctx,
       func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
           // Get game players
           playerUUIDs, players, err := g.getGamePlayers(ctx, txn)

           if err != nil {
               return err
           }

           // Might be an issue if there are no players!
           if len(playerUUIDs) == 0 {
               errorMsg := fmt.Sprintf("No players found for game '%s'", g.GameUUID)
               return errors.New(errorMsg)
           }

           // Get random winner
           g.Winner = determineWinner(playerUUIDs)

           // Validate game finished time is null
           row, err := txn.ReadRow(ctx, "games", spanner.Key{g.GameUUID}, []string{"finished"})
           if err != nil {
               return err
           }

           if err := row.Column(0, &g.Finished); err != nil {
               return err
           }

           // If time is not null, then the game is already marked as finished. 
           // That's an error.
           if !g.Finished.IsNull() {
               errorMsg := fmt.Sprintf("Game '%s' is already finished.", g.GameUUID)
               return errors.New(errorMsg)
           }

           cols := []string{"gameUUID", "finished", "winner"}
           txn.BufferWrite([]*spanner.Mutation{
               spanner.Update("games", cols, []interface{}{g.GameUUID, time.Now(), g.Winner}),
           })

           // Update each player to increment stats.games_played 
           // (and stats.games_won if winner), and set current_game 
           // to null so they can be chosen for a new game
           playerErr := g.updateGamePlayers(ctx, players, txn)
           if playerErr != nil {
               return playerErr
           }

           return nil
       })

   if err != nil {
       return err
   }

   return nil
}

Konfiguracja jest ponownie obsługiwana za pomocą zmiennych środowiskowych zgodnie z opisem w artykule ./src/golang/matchmaking-service/config/config.go dotyczącym usługi.

   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8081)

   // Bind environment variable override
   viper.BindEnv("server.host", "SERVICE_HOST")
   viper.BindEnv("server.port", "SERVICE_PORT")
   viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
   viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
   viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")

Aby uniknąć konfliktów z usługą profile, ta usługa jest domyślnie uruchomiona na serwerze localhost:8081.

Mając te informacje, czas rozpocząć dobieranie w firmie.

Skorzystaj z usług dobierania

Uruchom usługę za pomocą polecenia go. Spowoduje to ustanowienie usługi na porcie 8082. Ta usługa ma wiele tych samych zależności co profil-usługa, więc nowe zależności nie zostaną pobrane.

cd ~/spanner-gaming-sample/src/golang/matchmaking-service
go run . &

Dane wyjściowe polecenia:

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /games/create             --> main.createGame (4 handlers)
[GIN-debug] PUT    /games/close              --> main.closeGame (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8081

Utwórz grę

przetestować usługę, aby utworzyć grę; Najpierw otwórz nowy terminal w Cloud Shell:

90eceac76a6bb90b.png

Następnie uruchom następujące polecenie curl:

curl http://localhost:8081/games/create \
    --include \
    --header "Content-Type: application/json" \
    --request "POST"

Dane wyjściowe polecenia:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <date> 19:38:45 GMT
Content-Length: 38

"f45b0f7f-405b-4e67-a3b8-a624e990285d"

Zamknij grę

curl http://localhost:8081/games/close \
    --include \
    --header "Content-Type: application/json" \
    --data '{"gameUUID": "f45b0f7f-405b-4e67-a3b8-a624e990285d"}' \
    --request "PUT"

Dane wyjściowe polecenia:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: <date> 19:43:58 GMT
Content-Length: 38

"506a1ab6-ee5b-4882-9bb1-ef9159a72989"

Podsumowanie

W tym kroku wdrożyłeś(-aś) usługę dopasowywania gier do tworzenia gier i przypisywania do niej graczy. Usługa ta obejmuje też zamknięcie gry, w którym wybierany jest losowy zwycięzca i aktualizuje dane wszystkich graczy games_played i games_won.

Następne kroki

Skoro Twoje usługi są już uruchomione, czas zachęcić graczy do rejestracji i zagrania w gry.

6. Rozpocznij odtwarzanie

Po uruchomieniu profili i usług dopasowywania słów kluczowych możesz wygenerować obciążenie za pomocą generatorów losów losu.

Locust ma interfejs internetowy do uruchamiania generatorów, ale w tym module użyjesz wiersza poleceń (opcja –headless).

Zarejestruj graczy

Najpierw musisz wygenerować graczy.

Kod Pythona do tworzenia odtwarzaczy w pliku ./generators/authentication_server.py wygląda tak:

class PlayerLoad(HttpUser):
   def on_start(self):
       global pUUIDs
       pUUIDs = []

   def generatePlayerName(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

   def generatePassword(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

   def generateEmail(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32) + ['@'] +
           random.choices(['gmail', 'yahoo', 'microsoft']) + ['.com'])

   @task
   def createPlayer(self):
       headers = {"Content-Type": "application/json"}
       data = {"player_name": self.generatePlayerName(), "email": self.generateEmail(), "password": self.generatePassword()}

       with self.client.post("/players", data=json.dumps(data), headers=headers, catch_response=True) as response:
           try:
               pUUIDs.append(response.json())
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'gameUUID'")

Nazwy graczy, adresy e-mail i hasła są generowane losowo.

Zarejestrowani odtwarzacze są pobierani przez drugie zadanie w celu wygenerowania obciążenia odczytu.

   @task(5)
   def getPlayer(self):
       # No player UUIDs are in memory, reschedule task to run again later.
       if len(pUUIDs) == 0:
           raise RescheduleTask()

       # Get first player in our list, removing it to avoid contention from concurrent requests
       pUUID = pUUIDs[0]
       del pUUIDs[0]

       headers = {"Content-Type": "application/json"}

       self.client.get(f"/players/{pUUID}", headers=headers, name="/players/[playerUUID]")

Podane niżej polecenie wywołuje plik ./generators/authentication_server.py, który generuje nowe odtwarzacze na 30 sekund (t=30s) z równoczesnością 2 wątków jednocześnie (u=2):

cd ~/spanner-gaming-sample
locust -H http://127.0.0.1:8080 -f ./generators/authentication_server.py --headless -u=2 -r=2 -t=30s

Zawodnicy dołączają do gier

Skoro masz już zapisanych graczy, mogą zacząć grać.

Kod Pythona do tworzenia i zamykania gier w pliku ./generators/match_server.py wygląda tak:

from locust import HttpUser, task
from locust.exception import RescheduleTask

import json

class GameMatch(HttpUser):
   def on_start(self):
       global openGames
       openGames = []

   @task(2)
   def createGame(self):
       headers = {"Content-Type": "application/json"}

       # Create the game, then store the response in memory of list of open games.
       with self.client.post("/games/create", headers=headers, catch_response=True) as response:
           try:
               openGames.append({"gameUUID": response.json()})
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'gameUUID'")


   @task
   def closeGame(self):
       # No open games are in memory, reschedule task to run again later.
       if len(openGames) == 0:
           raise RescheduleTask()

       headers = {"Content-Type": "application/json"}

       # Close the first open game in our list, removing it to avoid 
       # contention from concurrent requests
       game = openGames[0]
       del openGames[0]

       data = {"gameUUID": game["gameUUID"]}
       self.client.put("/games/close", data=json.dumps(data), headers=headers)

Po uruchomieniu ten generator będzie otwierać i zamykać gry ze współczynnikiem 2:1 (otwarcie:zamknięcie). To polecenie uruchomi generator na 10 sekund (-t=10s):

locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s

Podsumowanie

Najpierw symulowane są gry, w które gracze rejestrują się w grach, oraz przeprowadzanie przez graczy symulacji dla dobierania graczy. W tych symulacji wykorzystano platformę Locust Python do wysyłania żądań do naszych usług. API REST.

Możesz zmienić czas poświęcony na tworzenie graczy i granie w gry, a także liczbę równoczesnych użytkowników (-u).

Następne kroki

Po zakończeniu symulacji warto sprawdzić różne statystyki, wysyłając zapytanie do Spannera.

7. Pobieranie statystyk gier

Skoro już symulujemy możliwość rejestracji i grania w gry, warto sprawdzić swoje statystyki.

W tym celu użyj konsoli Cloud, aby wysyłać żądania zapytań do usługi Spanner.

b5e3154c6f7cb0cf.png

Sprawdzanie rozgrywek otwartych z zamkniętymi

Gra zamknięta to gra, w której sygnatura czasowa zakończona jest wypełniona, a otwarta gra ma wartość zakończoną jako NULL. Ta wartość jest ustawiana po zamknięciu gry.

Możesz więc sprawdzić, ile gier jest otwartych, a ile zamkniętych:

SELECT Type, NumGames FROM
(SELECT "Open Games" as Type, count(*) as NumGames FROM games WHERE finished IS NULL
UNION ALL
SELECT "Closed Games" as Type, count(*) as NumGames FROM games WHERE finished IS NOT NULL
)

Wynik:

Type

NumGames

Open Games

0

Closed Games

175

Sprawdzanie liczby graczy w porównaniu z liczbą graczy, którzy nie grali

Gracz gra, jeśli ma ustawioną kolumnę current_game. W przeciwnym razie nie gra obecnie w grę.

Aby porównać liczbę graczy, którzy w danej chwili grają, a ilu jeszcze nie grają, użyj tego zapytania:

SELECT Type, NumPlayers FROM
(SELECT "Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NOT NULL
UNION ALL
SELECT "Not Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NULL
)

Wynik:

Type

NumPlayers

Playing

0

Not Playing

310

Wskazywanie najskuteczniejszych zmian

Po zamknięciu gry jeden z graczy jest losowo wybierany do zwycięzcy. Wartość tego gracza games_won zwiększa się podczas zamykania gry.

SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;

Wynik:

playerUUID

statystyki

07e247c5-f88e-4bca-a7bc-12d2485f2f2b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

09b72595-40af-4406-a000-2fb56c58fe92

{&quot;games_played&quot;:56,&quot;games_won&quot;:1}

1002385b-02a0-462b-a8e7-05c9b27223aa

{&quot;games_played&quot;:66,&quot;games_won&quot;:1}

13ec3770-7ae3-495f-9b53-6322d8e8d6c3

{&quot;games_played&quot;:44,&quot;games_won&quot;:1}

15513852-3f2a-494f-b437-fe7125d15f1b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

17faec64-4f77-475c-8df8-6ab026cf6698

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

1abfcb27-037d-446d-bb7a-b5cd17b5733d

{&quot;games_played&quot;:63,&quot;games_won&quot;:1}

2109a33e-88bd-4e74-a35c-a7914d9e3bde

{&quot;games_played&quot;:56,&quot;games_won&quot;:2}

222e37d9-06b0-4674-865d-a0e5fb80121e

{&quot;games_played&quot;:60,&quot;games_won&quot;:1}

22ced15c-0da6-4fd9-8cb2-1ffd233b3c56

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

Podsumowanie

W tym kroku przeanalizowaliśmy różne statystyki dotyczące graczy i gier za pomocą konsoli Cloud, aby wysłać zapytanie do usługi Spanner.

Następne kroki

Czas na posprzątanie!

8. Czyszczenie (opcjonalne)

Aby wyczyścić dane, otwórz sekcję Cloud Spanner w konsoli Cloud i usuń instancję „cloudspanner-gaming”, którą utworzyliśmy w kroku z programowania o nazwie „Skonfiguruj instancję Cloud Spanner”.

9. Gratulacje!

Gratulacje! Udało Ci się wdrożyć przykładową grę w usłudze Spanner

Co dalej?

W tym module zapoznaliśmy się z różnymi tematami dotyczącymi pracy z usługą Spanner z wykorzystaniem sterownika golang. Powinien ułatwić Ci zrozumienie kluczowych pojęć, takich jak:

  • Projekt schematu
  • DML a mutacje
  • Praca z Golang

Zapoznaj się z ćwiczeniem w usłudze Cloud Spanner Game Trading Post (w języku angielskim), aby zobaczyć kolejny przykład korzystania z usługi Spanner jako backendu w grze.