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 disponibilità elevata.

Queste funzionalità rendono Spanner un ottimo adattamento nell'architettura di giochi che vogliono abilitare una base di giocatori globale o sono preoccupati della 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

Ora creerai i dati sfruttando il framework di caricamento Python Locust.io per simulare i giocatori che si registrano e utilizzano il gioco. Poi eseguirai una query su Spanner per determinare quanti giocatori stanno giocando e alcune statistiche sui giocatori partite vinte rispetto a partite giocate.

Infine, eseguirai la pulizia delle 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 Profile scritto in Vai per gestire la registrazione del player
  • Distribuisci un servizio di matchmaking scritto in Go per assegnare i giocatori ai giochi, determinare i vincitori e aggiornare i giocatori statistiche di gioco.

Cosa imparerai a fare

  • Configurare un'istanza Cloud Spanner
  • Come creare un database e uno schema di un gioco
  • Come eseguire il deployment delle app Go per farle funzionare con Cloud Spanner
  • Come generare dati con 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

Creare un progetto

Se non disponi già di un account Google (Gmail o Google Apps), devi crearne uno. Accedi alla console della piattaforma 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 del progetto in alto a sinistra nella console:

6c9406d9b014760.png

e fai clic su "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 crearne uno:

870a3cbd6541ee86.png

La finestra di dialogo di creazione del progetto successiva 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 precedente è già in uso e non funzionerà per te). Verrà indicato più avanti in questo codelab come PROJECT_ID.

Successivamente, se non l'hai ancora fatto, dovrai 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 costare più di qualche euro, ma potrebbe essere più costoso se decidi di utilizzare più risorse o se le lasci in esecuzione (consulta la sezione relativa alla pulizia alla fine di questo documento). I prezzi di Google Cloud Spanner sono documentati qui.

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

Configurazione di Google Cloud Shell

Mentre 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 necessari. Offre una home directory permanente da 5 GB e viene eseguita in Google Cloud, migliorando notevolmente le prestazioni di rete e l'autenticazione. Ciò significa che per questo codelab sarà sufficiente 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 (il provisioning e la connessione all'ambiente dovrebbero richiedere solo pochi minuti).

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

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

Una volta stabilita la connessione a Cloud Shell, dovresti vedere che hai già eseguito l'autenticazione e che 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, invia semplicemente il seguente comando:

gcloud config set project <PROJECT_ID>

Stai cercando il tuo PROJECT_ID? Controlla l'ID 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, cosa che può essere utile quando eseguirai 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 questo 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 di locusta

Locust è un framework di test del carico Python utile per testare gli endpoint dell'API REST. In questo codelab, abbiamo due diversi test di carico nei "generatori" che evidenzieremo:

  • authentication_server.py: contiene attività per creare player e fare in modo che un giocatore casuale imita le ricerche con un singolo punto.
  • match_server.py: contiene attività per creare giochi e chiudere i giochi. Quando crei giochi, verranno assegnati 100 giocatori casuali che al momento non stanno giocando. La chiusura delle partite comporterà l'aggiornamento delle statistiche di game_played e di game_won e consentirà di assegnare questi giocatori a una partita futura.

Per eseguire Locust in Cloud Shell, è necessario Python 3.7 o versioni successive. Cloud Shell viene fornito con 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 il PATH in modo che possa 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 tuo progetto, se non ne avevi già uno, attivato Cloud Shell e scaricato il codice per il lab.

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

Successivo

Successivamente, configurerai l'istanza e il database Cloud Spanner.

3. Creare un'istanza e un database Spanner

crea l'istanza Spanner

In questo passaggio configuriamo la nostra 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 digita "Spanner"

36e52f8df8e13b99.png

Fai clic su 95269e75bc8c3e4d.png e compila il modulo inserendo il nome dell'istanza cloudspanner-gaming per l'istanza, scegliendo una configurazione (seleziona un'istanza a livello di regione come us-central1) e imposta il numero di nodi. Per questo codelab avremo bisogno solo di 500 processing units.

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

4457c324c94f93e6.png

Crea il database e lo schema

Quando l'istanza è in esecuzione, puoi creare il database. Spanner consente di utilizzare più database su una singola istanza.

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

Nelle istanze multiregionali, puoi anche configurare la variante leader predefinita. Scopri di più sui database su Spanner.

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

In questo lab verranno create due tabelle: giocatori e giochi.

77651ac12e47fe2a.png

I giocatori possono partecipare a diverse partite nel corso del tempo, ma solo a una partita alla volta. I giocatori hanno anche delle statistiche sotto forma di 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 è in realtà una colonna senza schema per i giocatori.

I giochi tengono traccia dei giocatori che hanno partecipato utilizzando il tipo di dati ARRAY di Spanner. Gli attributi relativi al vincitore e al termine di un gioco vengono inseriti solo dopo la chiusura della partita.

È presente una sola chiave esterna per assicurare che il valore current_game del giocatore sia una partita valida.

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

a820db6c4a4d6f2d.png

Poi inserisci i dettagli. Le opzioni importanti sono il nome e il dialetto del database. In questo esempio, abbiamo chiamato il database sample-game e 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 Crea e attendi alcuni secondi per la creazione del database.

La pagina per la creazione del database dovrebbe avere il seguente aspetto:

d39d358dc7d32939.png

A questo punto devi impostare alcune variabili di ambiente in Cloud Shell da utilizzare in un secondo momento nel codelab. Prendi nota di instance-id 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 in questo gioco di esempio.

Successivo

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

4. esegui il deployment del servizio di profili

Panoramica del servizio

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

4fce45ee6c858b3e.png

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

L'indirizzo email viene considerato un identificatore univoco, mentre il valore player_name viene utilizzato ai fini di visualizzazione del gioco.

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

Il file ./src/golang/profile-service/main.go per il servizio del profilo 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())
}

E il codice per questi endpoint verrà reindirizzato al modello del 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 del servizio è impostare la connessione Spanner. Viene implementato 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 player sfrutta un inserimento DML all'interno di una transazione ReadWrite, perché l'aggiunta di player avviene mediante una singola istruzione anziché l'inserimento di dati in batch. La funzione ha questo 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 all'UUID, viene inviata una semplice lettura. Vengono recuperati playerUUID, player_name, email e stats.

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 è 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 è eseguire il servizio su localhost:8080.

Con queste informazioni è il momento di eseguire il servizio.

Esegui il servizio di profili

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 inviando 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 profilo che consente ai giocatori di registrarsi per giocare al tuo gioco e hai provato il servizio effettuando una chiamata POST all'API per creare un nuovo giocatore.

Passaggi successivi

Nel passaggio successivo, eseguirai il deployment del servizio di creazione di corrispondenze.

5. Esegui il deployment del servizio di ricerca degli abbinamenti

Panoramica del servizio

Il servizio di creazione di corrispondenze è un'API REST scritta in Go che sfrutta il framework gin.

9aecd571df0dcd7c.png

In questa API, i giochi vengono creati e chiusi. Quando viene creato un gioco, vi vengono assegnati 10 giocatori che al momento non stanno giocando.

Quando una partita viene chiusa, viene selezionato un vincitore a caso e ogni giocatore le statistiche per games_played e games_won vengono modificate. Inoltre, ogni giocatore viene aggiornato per indicare che non gioca più e che quindi è disponibile per i giochi futuri.

Il file ./src/golang/matchmaking-service/main.go per il servizio di ricerca del partner segue una configurazione e un codice simili a quelli del servizio profile, quindi non viene ripetuto qui. Questo servizio espone due endpoint primari 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 uno struct Game, oltre a struct Player e PlayerStats ridotti:

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 un gioco, il servizio di selezione giocatori afferra una selezione casuale di 100 giocatori che in quel momento non stanno giocando.

Le mutazioni di Spanner vengono scelte per creare il gioco e assegnare i giocatori, poiché le mutazioni hanno un rendimento migliore rispetto a DML per cambiamenti di grande entità.

// 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 player viene eseguita con SQL utilizzando la funzionalità TABLESPACE RESERVOIR di GoogleSQL.

Chiudere un gioco è un po' più complicato. Prevede la scelta di un vincitore casuale tra i giocatori, l'indicazione del tempo in cui il gioco è terminato e l'aggiornamento di ogni partecipante statistiche relative a games_played e games_won.

A causa di questa complessità e della quantità di cambiamenti, vengono nuovamente scelte le mutazioni per chiudere il gioco.

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 nuovamente gestita 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 profile-service, questo servizio viene eseguito su localhost:8081 per impostazione predefinita.

Con queste informazioni, è il momento di eseguire il servizio di ricerca del partner.

Esegui il servizio di ricerca degli abbinamenti

Esegui il servizio utilizzando il comando go. Questa operazione consente di stabilire l'esecuzione del servizio sulla porta 8082. Questo servizio ha molte delle stesse dipendenze del servizio profile, 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

Crea un gioco

Testa il servizio per creare un gioco. 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 distribuito il servizio di selezione dei giocatori per gestire la creazione delle partite e l'assegnazione dei giocatori al gioco. Questo servizio gestisce anche la chiusura di un gioco, che sceglie un vincitore casuale e aggiorna tutti i giocatori statistiche relative a games_played e games_won.

Passaggi successivi

Ora che i tuoi servizi sono in esecuzione, è il momento di far registrare i giocatori e iniziare a giocare.

6. Inizia a giocare

Ora che il profilo e i servizi di ricerca del partner sono in esecuzione, puoi generare il carico utilizzando i generatori di locuste forniti.

Locust offre un'interfaccia web per eseguire i generatori, ma in questo lab utilizzerai la riga di comando (opzione -headless).

Registra giocatori

Innanzitutto, devi generare i giocatori.

Il codice Python per creare player nel file ./generators/authentication_server.py è simile al 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'")

Nomi dei giocatori, email e password vengono generati in modo casuale.

I player registrati verranno recuperati da una seconda attività 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 player per 30 secondi (t=30s) con una contemporaneità 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

Giocatori si uniscono ai giochi

Ora che hanno effettuato la registrazione dei giocatori, vogliono iniziare a giocare.

Il codice Python per creare e chiudere i giochi nel file ./generators/match_server.py è simile al 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 questo generatore è in esecuzione, aprirà e chiuderà i giochi con un rapporto 2:1 (apri:chiudi). 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 l'accesso dei giocatori per giocare e poi hai eseguito simulazioni per consentire ai giocatori di giocare usando il servizio di selezione giocatori. Queste simulazioni hanno sfruttato il framework Python Locust per inviare richieste ai nostri servizi API REST.

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

Passaggi successivi

Dopo la simulazione, ti conviene controllare varie statistiche eseguendo una query su Spanner.

7. Recupera le statistiche di gioco

Ora che i giocatori simulati possono registrarsi e giocare, devi controllare le tue statistiche.

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

b5e3154c6f7cb0cf.png

Controllo delle partite aperte e chiuse

Un gioco chiuso è un gioco per cui è stato compilato il timestamp finito, mentre un gioco aperto sarà finito essendo NULL. Questo valore viene impostato alla chiusura del gioco.

Questa query ti aiuterà a controllare quante partite sono aperte e quante sono chiuse:

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 quanti non stanno giocando

Un giocatore sta giocando se è impostata la colonna current_game. Altrimenti, al momento non sta giocando.

Quindi, per confrontare quanti giocatori stanno giocando in quel momento e quanti non stanno giocando, usa 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 scelto a caso come vincitore. La statistica games_won di quel giocatore viene incrementata alla 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

{&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}

Riepilogo

In questo passaggio hai esaminato varie statistiche relative a giocatori e giochi utilizzando la console Cloud per eseguire query su Spanner.

Passaggi successivi

Ora è il momento di pulire.

8. Eseguire la pulizia (facoltativo)

Per eseguire la pulizia, vai alla sezione Cloud Spanner della console Cloud ed elimina l'istanza "cloudspanner-gaming" creata nel passaggio del codelab denominato "Configura un'istanza Cloud Spanner".

9. Complimenti!

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

Passaggi successivi

In questo lab ti sono stati presentati vari argomenti relativi all'utilizzo di Spanner con il driver golang. Dovrebbe fornire una base migliore per comprendere concetti fondamentali quali:

  • Progettazione di uno schema
  • DML e mutazioni
  • Lavorare con Golang

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