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.

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
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:

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

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

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

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.

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).
- Per attivare Cloud Shell dalla console Cloud, fai clic su Attiva Cloud Shell
(bastano pochi istanti per eseguire il provisioning e connettersi all'ambiente).


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:

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
nel menu a tre linee in alto a sinistra
o cerca Spanner premendo "/" e digitando "Spanner".

A questo punto, fai clic su
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.

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.

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:

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:

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.

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.

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.

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:

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.

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:
|
|
|
|
|
|
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:
|
|
|
|
|
|
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.