Cloud Spanner – pierwsze kroki w tworzeniu gier

1. Wprowadzenie

Cloud Spanner to usługa w pełni zarządzanej, skalowalnej w poziomie, globalnie rozproszonej relacyjnej bazy danych, która zapewnia transakcje ACID i semantykę SQL bez utraty wydajności i wysokiej dostępności.

Dzięki tym funkcjom Spanner doskonale sprawdza się w architekturze gier, które chcą obsługiwać graczy z całego świata lub dbają o spójność danych.

W tym module utworzysz 2 usługi Go, które będą korzystać z regionalnej bazy danych Spanner, aby umożliwić graczom rejestrację i rozpoczęcie gry.

413fdd57bb0b68bc.png

Następnie wygenerujesz dane za pomocą platformy do testowania obciążenia w języku Python Locust.io, aby symulować rejestrację graczy i granie w grę. Następnie wyślesz zapytanie do Spannera, aby określić, ilu graczy gra, oraz uzyskać statystyki dotyczące wygranych i rozegranych gier.

Na koniec usuniesz zasoby utworzone w tym module.

Co utworzysz

W ramach tego modułu:

  • Tworzenie instancji usługi Spanner
  • Wdrożenie usługi Profile napisanej w Go do obsługi rejestracji graczy
  • Wdróż usługę Matchmaking napisaną w Go, aby przypisywać graczy do gier, określać zwycięzców i aktualizować statystyki graczy.

Czego się nauczysz

  • Jak skonfigurować instancję Cloud Spanner
  • Jak utworzyć bazę danych i schemat gry
  • Jak wdrażać aplikacje w Go do pracy z Cloud Spanner
  • Jak generować dane za pomocą narzędzia Locust
  • Jak wysyłać zapytania do 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 (Gmail 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 w wyświetlonym oknie kliknij przycisk „NOWY PROJEKT”, aby utworzyć nowy projekt:

949d83c8a4ee17d9.png

Jeśli nie masz jeszcze projektu, powinien wyświetlić się taki dialog, w którym możesz utworzyć pierwszy projekt:

870a3cbd6541ee86.png

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

6a92c57d3250a4b3.png

Zapamiętaj identyfikator projektu, który jest unikalną nazwą we wszystkich projektach Google Cloud (podana powyżej nazwa jest już zajęta i nie będzie działać w Twoim przypadku). W dalszej części tych ćwiczeń z programowania będzie on nazywany PROJECT_ID.

Następnie, jeśli nie zostało to jeszcze zrobione, musisz włączyć płatności w Konsoli deweloperów, aby korzystać z zasobów Google Cloud i włączyć interfejs Cloud Spanner API.

15d0ef27a8fbab27.png

Wykonanie tego samouczka nie powinno kosztować więcej niż kilka dolarów, ale może okazać się droższe, jeśli zdecydujesz się wykorzystać więcej zasobów lub pozostawisz je uruchomione (patrz sekcja „Czyszczenie” na końcu tego dokumentu). Ceny Google Cloud Spanner są opisane tutaj.

Nowi użytkownicy Google Cloud Platform mogą skorzystać z bezpłatnego okresu próbnego, w którym mają do dyspozycji środki w wysokości 300 USD, co powinno sprawić, że ten samouczek będzie całkowicie bezpłatny.

Konfiguracja Google Cloud Shell

Z Google Cloud i Spanner można korzystać zdalnie na laptopie, ale w tym ćwiczeniu programistycznym będziemy używać Google Cloud Shell, czyli środowiska wiersza poleceń działającego w chmurze.

Ta maszyna wirtualna oparta na Debianie zawiera wszystkie potrzebne narzędzia dla programistów. Zawiera również stały katalog domowy o pojemności 5 GB i działa w Google Cloud, co znacznie zwiększa wydajność sieci i usprawnia proces uwierzytelniania. Oznacza to, że do ukończenia tego ćwiczenia potrzebujesz tylko przeglądarki (działa ona na Chromebooku).

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

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

Screen Shot 2017-06-14 at 10.13.43 PM.png

Po połączeniu z Cloud Shell zobaczysz, że uwierzytelnianie zostało już przeprowadzone, a projekt jest już ustawiony na Twój identyfikator PROJECT_ID.

gcloud auth list

Wynik polecenia

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

Wynik polecenia

[core]
project = <PROJECT_ID>

Jeśli z jakiegoś powodu projekt nie jest ustawiony, po prostu wydaj to polecenie:

gcloud config set project <PROJECT_ID>

Szukasz identyfikatora PROJECT_ID? Sprawdź, jakiego identyfikatora użyto w krokach konfiguracji, lub wyszukaj go w panelu konsoli Cloud:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

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

echo $GOOGLE_CLOUD_PROJECT

Wynik polecenia

<PROJECT_ID>

Pobieranie kodu

W Cloud Shell możesz pobrać kod na potrzeby tego modułu. Jest to oparte na wersji v0.1.0, więc sprawdź ten tag:

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

Wynik polecenia

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

Konfigurowanie generatora obciążenia Locust

Locust to platforma do testowania obciążenia w Pythonie, która jest przydatna do testowania punktów końcowych interfejsu API REST. W tym laboratorium znajdziesz 2 różne testy obciążeniowe w katalogu „generators”, które wyróżnimy:

  • authentication_server.py: zawiera zadania tworzenia graczy i pobierania losowego gracza do imitowania wyszukiwań pojedynczych punktów.
  • match_server.py: zawiera zadania związane z tworzeniem i zamykaniem gier. Tworzenie gier spowoduje przypisanie 100 losowych graczy, którzy aktualnie nie grają. Zamknięcie gier spowoduje zaktualizowanie statystyk games_played i games_won oraz umożliwi przypisanie tych graczy do przyszłej gry.

Aby uruchomić Locust w Cloud Shell, musisz mieć Pythona 3.7 lub nowszego. Cloud Shell zawiera Pythona 3.9, więc wystarczy tylko sprawdzić wersję:

python -V

Wynik polecenia

Python 3.9.12

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

pip3 install -r requirements.txt

Wynik 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 zmienną PATH, aby można było znaleźć nowo zainstalowany plik binarny locust:

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

Wynik polecenia

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

Podsumowanie

W tym kroku skonfigurowano projekt (jeśli nie był jeszcze utworzony), aktywowano Cloud Shell i pobrano kod do tego modułu.

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

Następny

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

3. Tworzenie instancji i bazy danych Spanner

Tworzenie instancji usługi Spanner

W tym kroku skonfigurujemy instancję Spannera na potrzeby ćwiczeń z programowania. Wyszukaj wpis Spanner 1a6580bd3d3e6783.pngw menu hamburger po lewej stronie u góry 3129589f7bc9e5ce.png lub wyszukaj Spanner, naciskając „/” i wpisując „Spanner”.

36e52f8df8e13b99.png

Następnie kliknij 95269e75bc8c3e4d.png i wypełnij formularz, wpisując nazwę instancji cloudspanner-gaming, wybierając konfigurację (wybierz instancję regionalną, np. us-central1) i ustawiając liczbę węzłów. W tym ćwiczeniu potrzebujemy tylko 500 processing units.

Na koniec kliknij „Utwórz”. W ciągu kilku sekund będziesz mieć do dyspozycji instancję Cloud Spanner.

4457c324c94f93e6.png

Tworzenie bazy danych i schematu

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

W bazie danych definiujesz schemat. Możesz też kontrolować, kto ma dostęp do bazy danych, konfigurować niestandardowe szyfrowanie i optymalizator oraz ustawiać okres przechowywania.

W przypadku instancji obejmujących wiele regionów możesz też skonfigurować domyślny region repliki wiodącej. Więcej informacji o bazach danych w usłudze Spanner

W tym module utworzysz bazę danych z opcjami domyślnymi i podasz schemat w momencie tworzenia.

W tym module utworzysz 2 tabele: playersgames.

77651ac12e47fe2a.png

Gracze mogą brać udział w wielu grach, ale tylko w jednej naraz. Gracze mają też statystykiformacie danych JSON, aby śledzić ciekawe statystyki, takie jak rozegrane meczewygrane mecze. Ponieważ w przyszłości mogą zostać dodane inne statystyki, jest to w przypadku graczy kolumna bez schematu.

Gry śledzą graczy, którzy wzięli w nich udział, za pomocą typu danych ARRAY w usłudze Spanner. Zwycięzca meczu i atrybuty ukończenia nie są wypełniane, dopóki mecz nie zostanie zakończony.

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

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

a820db6c4a4d6f2d.png

Następnie 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 standardową wersję SQL Google.

W przypadku schematu skopiuj i wklej ten DDL w polu:

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 tworzenia i poczekaj kilka sekund na utworzenie bazy danych.

Strona tworzenia bazy danych powinna wyglądać tak:

d39d358dc7d32939.png

Teraz musisz ustawić w Cloud Shell zmienne środowiskowe, które będą używane później w tym module. Zanotuj identyfikator instancji i ustaw INSTANCE_ID oraz DATABASE_ID w Cloud Shell.

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. Zdefiniowano też schemat, którego używa ta przykładowa gra.

Następny

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

4. Wdróż usługę profilu

Omówienie usługi

Usługa profilu to interfejs REST API napisany w Go, który korzysta z platformy Gin.

4fce45ee6c858b3e.png

W tym interfejsie API gracze mogą rejestrować się w grach. Jest on tworzony za pomocą prostego polecenia POST, które akceptuje nazwę gracza, adres e-mail i hasło. Hasło jest szyfrowane za pomocą algorytmu bcrypt, a hash jest przechowywany w bazie danych.

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

Ten interfejs API nie obsługuje obecnie logowania, ale możesz go zaimplementować jako dodatkowe ćwiczenie.

Plik ./src/golang/profile-service/main.go usługi profilu udostępnia 2 podstawowe punkty końcowe:

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

Kod tych punktów końcowych będzie kierowany 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 skonfigurowanie połączenia z Spannerem. Jest to realizowane 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()
   }
}

PlayerPlayerStats to struktury zdefiniowane 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 gracza wykorzystuje instrukcję DML w transakcji ReadWrite, ponieważ dodawanie graczy to pojedyncza instrukcja, a nie wstawianie wsadowe. 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, wystarczy wykonać prostą operację odczytu. Pobierane są playerUUID, player_name, emailstatystyki gracza.

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 za pomocą zmiennych środowiskowych. Zobacz 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
}

Widzisz, że domyślnie usługa jest uruchamiana na localhost:8080.

Mając te informacje, możesz uruchomić usługę.

Uruchom usługę profilu

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

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

Wynik 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ę, wydają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"}'

Wynik 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

W tym kroku wdrożyliśmy usługę profilu, która umożliwia graczom rejestrację w grze. Przetestowaliśmy ją, wysyłając wywołanie interfejsu API POST w celu utworzenia nowego gracza.

Następne kroki

W następnym kroku wdrożysz usługę dobierania graczy.

5. Wdróż usługę dobierania w pary

Omówienie usługi

Usługa dobierania graczy to interfejs REST API napisany w Go, który korzysta z platformy Gin.

9aecd571df0dcd7c.png

W tym interfejsie API gry są tworzone i zamykane. Gdy gra zostanie utworzona, przypisujemy do niej 10 graczy, którzy aktualnie nie grają w żadną grę.

Gdy gra zostanie zamknięta, zwycięzca jest wybierany losowo, a statystyki każdego gracza dotyczące rozegranych gierwygranych gier są korygowane. Każdy gracz jest też aktualizowany, aby wskazać, że nie gra już w bieżącej grze, więc jest dostępny do gry w przyszłości.

Plik ./src/golang/matchmaking-service/main.go usługi kojarzenia graczy ma podobną konfigurację i kod jak usługa profile, więc nie jest tu powtarzany. Ta usługa udostępnia 2 główne punkty końcowe:

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 udostępnia strukturę Game, a także uproszczone struktury PlayerPlayerStats:

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ę, usługa dobierania graczy losowo wybiera 100 osób, które aktualnie nie grają.

Mutacje Spannera są wybierane do tworzenia gry i przypisywania graczy, ponieważ w przypadku dużych zmian są wydajniejsze 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 odbywa się za pomocą SQL z wykorzystaniem funkcji TABLESPACE RESERVOIR w GoogleSQL.

Zamknięcie gry jest nieco bardziej skomplikowane. Polega ona na wybraniu losowego zwycięzcy spośród graczy, oznaczeniu czasu zakończenia gry i zaktualizowaniu statystyk każdego gracza w zakresie rozegranych gierwygranych gier.

Ze względu na złożoność i liczbę zmian do zakończenia gry ponownie wybrano mutacje.

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, jak opisano w pliku ./src/golang/matchmaking-service/config/config.go dla 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ą profilu, ta usługa jest domyślnie uruchamiana na localhost:8081.

Na podstawie tych informacji możesz teraz uruchomić usługę dobierania graczy.

Uruchom usługę dobierania w pary

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

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

Wynik 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

Tworzenie gry

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

90eceac76a6bb90b.png

Następnie wydaj to polecenie curl:

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

Wynik 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"

Wynik 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żysz usługę matchmakingu, która będzie tworzyć gry i przypisywać do nich graczy. Ta usługa obsługuje też zakończenie gry, w ramach którego losowany jest zwycięzca i aktualizowane są statystyki wszystkich graczy w zakresie rozegranych gierwygranych gier.

Następne kroki

Usługi działają, więc czas zachęcić graczy do rejestracji i grania w gry.

6. Rozpocznij odtwarzanie

Teraz, gdy usługi profilu i dopasowywania działają, możesz wygenerować obciążenie za pomocą dostarczonych generatorów locust.

Locust udostępnia interfejs internetowy do uruchamiania generatorów, ale w tym laboratorium użyjesz wiersza poleceń (opcja –headless).

Rejestrowanie graczy

Najpierw wygeneruj graczy.

Kod Pythona do tworzenia graczy 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.

Gracze, którzy zarejestrują się w usłudze, zostaną pobrani 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]")

To polecenie wywołuje plik ./generators/authentication_server.py, który będzie generować nowych graczy przez 30 sekund (t=30s) z jednoczesnym użyciem 2 wątków (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

Gracze dołączają do gier

Gracze są już zarejestrowani, więc chcą 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 generatora gry będą otwierane i zamykane w stosunku 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

W tym kroku symulujesz rejestrację graczy w celu grania w gry, a następnie przeprowadzasz symulacje grania w gry przez graczy za pomocą usługi dobierania graczy. W tych symulacjach wykorzystaliśmy platformę Locust w języku Python do wysyłania żądań do interfejsu REST API naszych usług.

Możesz dowolnie modyfikować czas spędzony na tworzeniu graczy i graniu w gry, a także liczbę jednoczesnych użytkowników (-u).

Następne kroki

Po symulacji warto sprawdzić różne statystyki, wysyłając zapytania do usługi Spanner.

7. Pobieranie statystyk gry

Teraz, gdy symulowani gracze mogą się rejestrować i grać w gry, sprawdź swoje statystyki.

Aby to zrobić, użyj Cloud Console do wysyłania zapytań do Spannera.

b5e3154c6f7cb0cf.png

Sprawdzanie otwartych i zamkniętych gier

Zamknięta gra to taka, w której pole finished jest wypełnione sygnaturą czasową, a otwarta gra to taka, w której pole finished ma wartość NULL. Ta wartość jest ustawiana po zamknięciu gry.

To zapytanie pozwoli Ci 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, którzy grają, i tych, którzy nie grają

Gracz gra w grę, jeśli ustawiona jest kolumna current_game. W przeciwnym razie nie grają w żadną grę.

Aby porównać, ilu graczy gra obecnie, a ilu nie, 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

Określanie zwycięzców

Po zamknięciu gry jeden z graczy jest losowo wybierany jako zwycięzca. Podczas zamykania gry zwiększa się statystyka games_won tego gracza.

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

{"games_played":49,"games_won":1}

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

{"games_played":56,"games_won":1}

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

{"games_played":66,"games_won":1}

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

{"games_played":44,"games_won":1}

15513852-3f2a-494f-b437-fe7125d15f1b

{"games_played":49,"games_won":1}

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

{"games_played":50,"games_won":1}

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

{"games_played":63,"games_won":1}

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

{"games_played":56,"games_won":2}

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

{"games_played":60,"games_won":1}

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

{"games_played":50,"games_won":1}

Podsumowanie

W tym kroku sprawdziliśmy różne statystyki graczy i gier, używając konsoli Cloud do wysyłania zapytań do Spannera.

Następne kroki

Teraz czas na porządki.

8. Czyszczenie (opcjonalnie)

Aby posprzątać, otwórz sekcję Cloud Spanner w konsoli Cloud i usuń instancję „cloudspanner-gaming” utworzoną w kroku „Konfigurowanie instancji Cloud Spanner” w tym laboratorium.

9. Gratulacje!

Gratulacje, udało Ci się wdrożyć przykładową grę w Spannerze.

Co dalej?

W tym module zapoznaliśmy się z różnymi tematami związanymi z pracą z usługą Spanner przy użyciu sterownika Go. Powinno to pomóc Ci lepiej zrozumieć kluczowe pojęcia, takie jak:

  • Projektowanie schematu
  • DML a mutacje
  • Praca z Golangiem

Zapoznaj się z samouczkiem Cloud Spanner Game Trading Post, aby zobaczyć kolejny przykład korzystania z Spannera jako backendu gry.