Introduzione a Cloud Spanner allo sviluppo di giochi

1. Introduzione

Cloud Spanner è un servizio di database relazionale completamente gestito, scalabile orizzontalmente e distribuito a livello globale che fornisce transazioni ACID e semantica SQL senza rinunciare a prestazioni e alta disponibilità.

Queste funzionalità rendono Spanner ideale per l'architettura di giochi che vogliono attivare una base di giocatori globale o sono preoccupati per la coerenza dei dati.

In questo lab creerai due servizi Go che interagiscono con un database Spanner a livello di regione per consentire ai giocatori di registrarsi e iniziare a giocare.

413fdd57bb0b68bc.png

Successivamente, genererai dati sfruttando il framework di caricamento Python Locust.io per simulare i giocatori che si registrano e giocano. Dopodiché, interrogherai Spanner per determinare il numero di giocatori che stanno giocando e alcune statistiche sulle partite vinte rispetto a quelle giocate.

Infine, ripulirai le risorse create in questo lab.

Cosa creerai

Nell'ambito di questo lab, imparerai a:

  • Creazione di un'istanza di Spanner
  • Esegui il deployment di un servizio di profilo scritto in Go per gestire la registrazione dei giocatori
  • Implementa un servizio di matchmaking scritto in Go per assegnare i giocatori alle partite, determinare i vincitori e aggiornare le statistiche di gioco dei giocatori.

Obiettivi didattici

  • Come configurare un'istanza Cloud Spanner
  • Come creare un database e uno schema di gioco
  • Come eseguire il deployment di app Go per funzionare con Cloud Spanner
  • Come generare dati utilizzando Locust
  • Come eseguire query sui dati in Cloud Spanner per rispondere a domande su giochi e giocatori.

Che cosa ti serve

  • Un progetto Google Cloud collegato a un account di fatturazione.
  • Un browser web, ad esempio Chrome o Firefox.

2. Configurazione e requisiti

Crea un progetto

Se non hai ancora un Account Google (Gmail o Google Apps), devi crearne uno. Accedi alla console di Google Cloud ( console.cloud.google.com) e crea un nuovo progetto.

Se hai già un progetto, fai clic sul menu a discesa per la selezione dei progetti in alto a sinistra nella console:

6c9406d9b014760.png

e fai clic sul pulsante "NUOVO PROGETTO" nella finestra di dialogo risultante per creare un nuovo progetto:

949d83c8a4ee17d9.png

Se non hai ancora un progetto, dovresti visualizzare una finestra di dialogo come questa per creare il primo:

870a3cbd6541ee86.png

La finestra di dialogo successiva per la creazione del progetto ti consente di inserire i dettagli del nuovo progetto:

6a92c57d3250a4b3.png

Ricorda l'ID progetto, che è un nome univoco tra tutti i progetti Google Cloud (il nome riportato sopra è già stato utilizzato e non funzionerà per te, ci dispiace). In questo codelab verrà indicato come PROJECT_ID.

Successivamente, se non l'hai ancora fatto, devi abilitare la fatturazione in Developers Console per utilizzare le risorse Google Cloud e abilitare l'API Cloud Spanner.

15d0ef27a8fbab27.png

L'esecuzione di questo codelab non dovrebbe costarti più di qualche dollaro, ma potrebbe essere più cara se decidi di utilizzare più risorse o se le lasci in esecuzione (vedi la sezione "Pulizia" alla fine di questo documento). I prezzi di Google Cloud Spanner sono documentati qui.

I nuovi utenti di Google Cloud Platform possono beneficiare di una prova senza costi di 300$, che dovrebbe rendere questo codelab completamente senza costi.

Configurazione di Google Cloud Shell

Anche se Google Cloud e Spanner possono essere gestiti da remoto dal tuo laptop, in questo codelab utilizzeremo Google Cloud Shell, un ambiente a riga di comando in esecuzione nel cloud.

Questa macchina virtuale basata su Debian viene caricata con tutti gli strumenti di sviluppo di cui avrai bisogno. Offre una home directory permanente da 5 GB e viene eseguita in Google Cloud, migliorando notevolmente le prestazioni e l'autenticazione della rete. Ciò significa che per questo codelab ti servirà solo un browser (sì, funziona su Chromebook).

  1. Per attivare Cloud Shell dalla console Cloud, fai clic su Attiva Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (bastano pochi istanti per eseguire il provisioning e connettersi all'ambiente).

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

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

Una volta eseguita la connessione a Cloud Shell, dovresti vedere che il tuo account è già autenticato e il progetto è già impostato sul tuo PROJECT_ID.

gcloud auth list

Output comando

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

Output comando

[core]
project = <PROJECT_ID>

Se per qualche motivo il progetto non è impostato, esegui questo comando:

gcloud config set project <PROJECT_ID>

Cerchi il tuo PROJECT_ID? Controlla l'ID che hai utilizzato nei passaggi di configurazione o cercalo nella dashboard della console Cloud:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell imposta anche alcune variabili di ambiente per impostazione predefinita, che potrebbero essere utili quando esegui i comandi futuri.

echo $GOOGLE_CLOUD_PROJECT

Output comando

<PROJECT_ID>

Scarica il codice

In Cloud Shell, puoi scaricare il codice per questo lab. Si basa sulla release v0.1.0, quindi controlla il 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

Output comando

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

Configura il generatore di carico Locust

Locust è un framework di test di carico Python utile per testare gli endpoint API REST. In questo codelab, evidenzieremo due diversi test di carico nella directory "generators":

  • authentication_server.py: contiene attività per creare giocatori e per ottenere un giocatore casuale da imitare nelle ricerche di un singolo punto.
  • match_server.py: contiene attività per creare e chiudere le partite. La creazione di partite assegnerà 100 giocatori casuali che non stanno giocando. La chiusura delle partite aggiornerà le statistiche relative a partite giocate e partite vinte e consentirà di assegnare i giocatori a una partita futura.

Per eseguire Locust in Cloud Shell, devi disporre di Python 3.7 o versioni successive. Cloud Shell include Python 3.9, quindi non devi fare altro che convalidare la versione:

python -V

Output comando

Python 3.9.12

Ora puoi installare i requisiti per Locust.

pip3 install -r requirements.txt

Output comando

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

Ora aggiorna PATH in modo che sia possibile trovare il file binario locust appena installato:

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

Output comando

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

Riepilogo

In questo passaggio hai configurato il progetto, se non ne avevi già uno, hai attivato Cloud Shell e hai scaricato il codice per questo lab.

Infine, configurerai Locust per la generazione del carico più avanti nel lab.

Prossimo

Il passaggio successivo consiste nel configurare l'istanza e il database Cloud Spanner.

3. Crea un'istanza e un database Spanner

Crea l'istanza Spanner

In questo passaggio configuriamo l'istanza Spanner per il codelab. Cerca la voce Spanner 1a6580bd3d3e6783.pngnel menu a tre linee in alto a sinistra 3129589f7bc9e5ce.png o cerca Spanner premendo "/" e digitando "Spanner".

36e52f8df8e13b99.png

A questo punto, fai clic su 95269e75bc8c3e4d.png e compila il modulo inserendo il nome dell'istanza cloudspanner-gaming, scegliendo una configurazione (seleziona un'istanza regionale come us-central1) e impostando il numero di nodi. Per questo codelab avremo bisogno solo di 500 processing units.

Infine, fai clic su "Crea" e in pochi secondi avrai a disposizione un'istanza Cloud Spanner.

4457c324c94f93e6.png

Crea il database e lo schema

Una volta che l'istanza è in esecuzione, puoi creare il database. Spanner consente più database su una singola istanza.

Il database è il luogo in cui definisci lo schema. Puoi anche controllare chi ha accesso al database, configurare la crittografia personalizzata, configurare l'ottimizzatore e impostare il periodo di conservazione.

Nelle istanze multiregionali, puoi anche configurare il leader predefinito. Scopri di più sui database su Spanner.

Per questo lab, creerai il database con le opzioni predefinite e fornirai lo schema al momento della creazione.

Questo lab creerà due tabelle: players e games.

77651ac12e47fe2a.png

I giocatori possono partecipare a molte partite nel tempo, ma solo a una partita alla volta. I giocatori hanno anche statistiche come tipo di dati JSON per tenere traccia di statistiche interessanti come games_played e games_won. Poiché in un secondo momento potrebbero essere aggiunte altre statistiche, questa è effettivamente una colonna senza schema per i giocatori.

Games tiene traccia dei giocatori che hanno partecipato utilizzando il tipo di dati ARRAY di Spanner. Gli attributi relativi al vincitore e alla fine di una partita non vengono compilati finché la partita non viene chiusa.

Esiste una chiave esterna per garantire che l'attributo current_game del giocatore sia un gioco valido.

Ora crea il database facendo clic su "Crea database" nella panoramica dell'istanza:

a820db6c4a4d6f2d.png

e poi inserisci i dettagli. Le opzioni importanti sono il nome del database e il dialetto. In questo esempio, abbiamo chiamato il database sample-game e abbiamo scelto il dialetto SQL standard di Google.

Per lo schema, copia e incolla questo DDL nella casella:

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

Quindi, fai clic sul pulsante di creazione e attendi qualche secondo per la creazione del database.

La pagina di creazione del database dovrebbe avere il seguente aspetto:

d39d358dc7d32939.png

Ora devi impostare alcune variabili di ambiente in Cloud Shell da utilizzare in un secondo momento nel codelab. Prendi nota dell'ID istanza e imposta INSTANCE_ID e DATABASE_ID in Cloud Shell.

f6f98848d3aea9c.png

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

Riepilogo

In questo passaggio hai creato un'istanza Spanner e il database sample-game. Hai anche definito lo schema utilizzato da questo gioco di esempio.

Prossimo

Successivamente, eseguirai il deployment del servizio di profili per consentire ai giocatori di registrarsi per giocare.

4. Esegui il deployment del servizio di profilo

Panoramica del servizio

Il servizio di profili è un'API REST scritta in Go che sfrutta il framework Gin.

4fce45ee6c858b3e.png

In questa API, i giocatori possono registrarsi per giocare. Viene creato da un semplice comando POST che accetta il nome, l'email e la password di un giocatore. La password viene criptata con bcrypt e l'hash viene memorizzato nel database.

L'email viene trattata come un identificatore univoco, mentre player_name viene utilizzato a scopo di visualizzazione per il gioco.

Al momento questa API non gestisce l'accesso, ma l'implementazione può essere lasciata a te come esercizio aggiuntivo.

Il file ./src/golang/profile-service/main.go per il servizio profili espone due endpoint principali come segue:

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

Il codice di questi endpoint verrà indirizzato al modello player.

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

Una delle prime operazioni eseguite dal servizio è impostare la connessione Spanner. Questa operazione viene implementata a livello di servizio per creare il pool di sessioni per il servizio.

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

Player e PlayerStats sono struct definiti come segue:

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"`
}

La funzione per aggiungere il giocatore utilizza un inserimento DML all'interno di una transazione ReadWrite, perché l'aggiunta di giocatori è una singola istruzione anziché inserimenti batch. La funzione ha il seguente aspetto:

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
}

Per recuperare un giocatore in base al suo UUID, viene emessa una semplice lettura. Vengono recuperati playerUUID, player_name, email e stats del giocatore.

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
}

Per impostazione predefinita, il servizio viene configurato utilizzando le variabili di ambiente. Consulta la sezione pertinente del file ./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
}

Puoi notare che il comportamento predefinito è l'esecuzione del servizio su localhost:8080.

Con queste informazioni è il momento di eseguire il servizio.

Esegui il servizio di profilazione

Esegui il servizio utilizzando il comando go. Verranno scaricate le dipendenze e verrà stabilito il servizio in esecuzione sulla porta 8080:

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

Output comando:

[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

Testa il servizio eseguendo un comando 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"}'

Output comando:

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"

Riepilogo

In questo passaggio hai eseguito il deployment del servizio di profili che consente ai giocatori di registrarsi per giocare al tuo gioco e hai testato il servizio eseguendo una chiamata API POST per creare un nuovo giocatore.

Passaggi successivi

Nel passaggio successivo eseguirai il deployment del servizio di matchmaking.

5. Esegui il deployment del servizio di abbinamento

Panoramica del servizio

Il servizio di matchmaking è un'API REST scritta in Go che sfrutta il framework Gin.

9aecd571df0dcd7c.png

In questa API, le partite vengono create e chiuse. Quando viene creata una partita, vengono assegnati 10 giocatori che non stanno giocando.

Quando una partita viene chiusa, viene selezionato un vincitore in modo casuale e le statistiche di ogni giocatore relative a partite_giocate e partite_vinte vengono aggiornate. Inoltre, ogni giocatore viene aggiornato per indicare che non sta più giocando ed è quindi disponibile per giocare a partite future.

Il file ./src/golang/matchmaking-service/main.go per il servizio di matchmaking segue una configurazione e un codice simili a quelli del servizio profile, pertanto non vengono ripetuti qui. Questo servizio espone due endpoint principali come segue:

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

Questo servizio fornisce una struttura Game, nonché strutture Player e PlayerStats ridotte:

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"`
}

Per creare una partita, il servizio di matchmaking seleziona in modo casuale 100 giocatori che non stanno giocando.

Le mutazioni Spanner vengono scelte per creare la partita e assegnare i giocatori, poiché sono più efficienti del linguaggio di manipolazione dei dati per le modifiche di grandi dimensioni.

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

La selezione casuale dei giocatori viene eseguita con SQL utilizzando la funzionalità TABLESPACE RESERVOIR di GoogleSQL.

Chiudere una partita è leggermente più complicato. Consiste nella scelta di un vincitore casuale tra i giocatori della partita, nella registrazione dell'ora in cui la partita è terminata e nell'aggiornamento delle statistiche di ogni giocatore per games_played e games_won.

A causa di questa complessità e della quantità di modifiche, le mutazioni vengono nuovamente scelte per chiudere la partita.

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
}

La configurazione viene gestita di nuovo tramite le variabili di ambiente, come descritto in ./src/golang/matchmaking-service/config/config.go per il servizio.

   // 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")

Per evitare conflitti con il servizio di profili, questo servizio viene eseguito su localhost:8081 per impostazione predefinita.

Con queste informazioni, è ora di eseguire il servizio di matchmaking.

Esegui il servizio di abbinamento coppie

Esegui il servizio utilizzando il comando go. In questo modo verrà stabilito il servizio in esecuzione sulla porta 8082. Questo servizio ha molte delle stesse dipendenze del servizio di profili, quindi non verranno scaricate nuove dipendenze.

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

Output comando:

[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

Creare una partita

Testa il servizio per creare una partita. Innanzitutto, apri un nuovo terminale in Cloud Shell:

90eceac76a6bb90b.png

Quindi, esegui il seguente comando curl:

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

Output comando:

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"

Chiudi il gioco

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

Output comando:

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"

Riepilogo

In questo passaggio, hai eseguito il deployment di matchmaking-service per gestire la creazione di partite e l'assegnazione dei giocatori alla partita. Questo servizio gestisce anche la chiusura di una partita, che sceglie un vincitore casuale e aggiorna le statistiche di tutti i giocatori per games_played e games_won.

Passaggi successivi

Ora che i tuoi servizi sono in esecuzione, è il momento di invitare i giocatori a registrarsi e giocare.

6. Inizia a giocare

Ora che i servizi di profilo e matchmaking sono in esecuzione, puoi generare il carico utilizzando i generatori di locust forniti.

Locust offre un'interfaccia web per l'esecuzione dei generatori, ma in questo lab utilizzerai la riga di comando (opzione –headless).

Registrare giocatori

Per prima cosa, devi generare i giocatori.

Il codice Python per creare giocatori nel file ./generators/authentication_server.py è il seguente:

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'")

I nomi, gli indirizzi email e le password dei giocatori vengono generati in modo casuale.

I giocatori che si sono registrati correttamente verranno recuperati da un secondo compito per generare il carico di lettura.

   @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]")

Il comando seguente chiama il file ./generators/authentication_server.py che genererà nuovi giocatori per 30 secondi (t=30s) con una concorrenza di due thread alla volta (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

I giocatori partecipano alle partite

Ora che hai registrato i giocatori, vogliono iniziare a giocare.

Il codice Python per creare e chiudere le partite nel file ./generators/match_server.py è il seguente:

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)

Quando viene eseguito, questo generatore apre e chiude i giochi con un rapporto di 2:1 (apertura:chiusura). Questo comando eseguirà il generatore per 10 secondi (-t=10s):

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

Riepilogo

In questo passaggio, hai simulato la registrazione di giocatori per giocare e poi hai eseguito simulazioni per far giocare i giocatori utilizzando il servizio di matchmaking. Queste simulazioni hanno sfruttato il framework Python Locust per inviare richieste all'API REST dei nostri servizi.

Puoi modificare il tempo dedicato alla creazione di giocatori e alla riproduzione di giochi, nonché il numero di utenti simultanei (-u).

Passaggi successivi

Dopo la simulazione, ti consigliamo di controllare varie statistiche eseguendo query su Spanner.

7. Recuperare le statistiche di gioco

Ora che abbiamo simulato la registrazione e la possibilità di giocare, devi controllare le tue statistiche.

Per farlo, utilizza la console Cloud per inviare richieste di query a Spanner.

b5e3154c6f7cb0cf.png

Controllare le partite aperte e chiuse

Una partita chiusa è una partita in cui è stato inserito il timestamp finished, mentre una partita aperta avrà il valore NULL per finished. Questo valore viene impostato alla chiusura del gioco.

Questa query ti consentirà di controllare quanti giochi sono aperti e quanti sono chiusi:

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
)

Risultato:

Type

NumGames

Open Games

0

Closed Games

175

Controllo del numero di giocatori che giocano rispetto a quelli che non giocano

Un giocatore sta giocando a un gioco se la colonna current_game è impostata. In caso contrario, al momento non sta giocando.

Per confrontare il numero di giocatori che stanno giocando e quelli che non stanno giocando, utilizza questa query:

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
)

Risultato:

Type

NumPlayers

Playing

0

Not Playing

310

Determinare i vincitori principali

Quando una partita viene chiusa, uno dei giocatori viene selezionato in modo casuale come vincitore. La statistica games_won del giocatore viene incrementata durante la chiusura della partita.

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

Risultato:

playerUUID

stats

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}

Riepilogo

In questo passaggio, hai esaminato varie statistiche di giocatori e partite utilizzando la console Cloud per eseguire query su Spanner.

Passaggi successivi

Poi, è il momento di pulire.

8. Pulizia (facoltativo)

Per eseguire la pulizia, vai alla sezione Cloud Spanner di Cloud Console ed elimina l'istanza "cloudspanner-gaming" che abbiamo creato nel passaggio del codelab denominato "Configura un'istanza Cloud Spanner".

9. Complimenti!

Congratulazioni, hai eseguito correttamente il deployment di un gioco di esempio su Spanner.

Passaggi successivi

In questo lab sono stati introdotti vari argomenti relativi all'utilizzo di Spanner con il driver Golang. Dovrebbe fornirti una base migliore per comprendere concetti fondamentali come:

  • Progettazione di uno schema
  • DML e mutazioni
  • Utilizzo di Golang

Assicurati di dare un'occhiata al codelab Cloud Spanner Game Trading Post per un altro esempio di utilizzo di Spanner come backend per il tuo gioco.